wip: local BUG-139/BUG-140 fixes before syncing origin/main

- pre_work_check.py: prefer repo .venv Python >=3.11, strict JYOTISH_PRE_WORK_PYTHON override, fail closed
- tests/test_pre_work_check.py: regression coverage for venv preference / system 3.9 / strict override
- globals.css: transient :active pressed feedback for entrypoint cards, hover only under (hover: hover)
- consultation-entrypoint.test.ts: sticky-hover regression test
- BUG_HISTORY.md: BUG-139 / BUG-140 records
- pre_work_error_ledger.md: ERR-078 resolved entry
This commit is contained in:
Jesse
2026-08-10 17:57:06 +08:00
parent ae41b3aef5
commit 8fb6b65b01
6 changed files with 252 additions and 27 deletions
+32
View File
@@ -2384,3 +2384,35 @@
- 相关记录:BUG-134、BUG-137、ERR-097、ERR-100、ERR-101
- 复发自:无
- 修复版本:`6c1dcbe857006ec6ae7463b57b2b7d5947da4851`
## BUG-139 | 系统 Python 启动预检时重复使用不兼容解释器
- 状态:resolved
- 首次发现:2026-08-07
- 最近更新:2026-08-07
- 影响面:`scripts/pre_work_check.py`、开工预检、碎片扫描与聚焦治理测试
- 用户现象:每次执行文档规定的 `python3 scripts/pre_work_check.py ...`,碎片扫描都会因 Python 3.9 不支持项目使用的 PEP 604 类型注解而退出,聚焦测试同时报 `/usr/bin/python3: No module named pytest`
- 触发条件:macOS 的裸 `python3` 指向 `/usr/bin/python3` 3.9,而仓库 `.venv` 已安装 Python 3.11 与 pytest。
- 根因:预检脚本把启动自身的 `sys.executable` 固定为全部子命令的解释器,没有验证项目声明的 Python >=3.11,也没有优先使用仓库虚拟环境;`ERR-078` 原防线只要求操作者手动改用 `.venv`,因此文档中的标准命令仍会稳定复发。
- 修复:预检入口按确定顺序探测项目 `.venv`、当前解释器和可用的 Python 3.11+;只有同时满足 Python >=3.11 与 pytest 可用才执行全部 Python 子检查。报告显式记录选择结果和各候选探测信息;找不到兼容环境时失败闭合并给出安装指引。新增严格的 `JYOTISH_PRE_WORK_PYTHON` 覆盖入口,配置错误时不静默退回其他解释器。
- 验证:`tests/test_pre_work_check.py` 覆盖虚拟环境优先、系统 3.9 跳过、严格 override 和 runtime failure 状态;使用裸 `/usr/bin/python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 完整通过,报告选择仓库 `.venv/bin/python` 3.11 且 focused governance tests 通过。
- 防复发:预检不得再直接用未经探测的 `sys.executable` 启动项目脚本或 pytest;标准文档命令必须纳入真实系统 Python 启动回归,且 JSON 报告必须保留 `python_runtime_ok` 和实际解释器信息。
- 相关记录:ERR-011、ERR-014、ERR-078
- 复发自:无
- 修复版本:待提交
## BUG-140 | 首页入口卡片点击后持续显示灰色交互态
- 状态:resolved
- 首次发现:2026-08-07
- 最近更新:2026-08-07
- 影响面:首页 `starter-theme-card``daily-starlanguage-card` 与共享 `product-entrypoint-card` 交互反馈
- 用户现象:用户点击首页主题或产品入口卡片后,卡片背景持续处于灰色/选中样式,没有在松开指针后恢复。
- 触发条件:在触摸设备点击卡片,或在桌面端点击后让指针继续停留在卡片上。
- 根因:卡片把铺满表面的背景反馈绑定到无设备能力边界的 `:hover`;移动浏览器可能在点击后保留模拟 hover,桌面指针停留也会让一次点击看起来像永久选中。`hover`、短暂按压和真实 disabled 三种状态因此在视觉上混在一起。
- 修复:将铺灰背景收敛到 `:active`,只在按压期间显示并于释放后恢复;桌面 `@media (hover: hover)` 只保留轻量边框、光泽和箭头位移,不再改变卡片底色;键盘继续使用现有 `focus-visible` 轮廓,真实 disabled 透明度规则保持不变。
- 验证:`frontend/tests/consultation-entrypoint.test.ts` 新增 sticky-hover 回归;与 `frontend/tests/starter-questions.test.ts` 精确运行 40/40 通过;目标 ESLint 与 `git diff --check` 通过。全量前端尝试中 1457 项通过,16 项因本机缺少 Docker 或 `python` 可执行命令而失败,与本次 CSS 修改无关。
- 防复发:首页可点击卡片的表面底色只能由短暂 `:active` 或真实业务 disabled 状态改变;hover 视觉必须限制在支持 hover 的设备,且回归测试禁止重新为这些卡片的 hover surface 添加背景色。
- 相关记录:无
- 复发自:无
- 修复版本:待提交
+1 -1
View File
@@ -108,7 +108,7 @@ For large architecture or release work, also read:
| ERR-075 | `real_case_calibration_catalog()` unconditionally opened an untracked holdout manifest, so clean installs could silently lose the MCP runtime evidence log. | resolved 2026-07-16 | Missing optional holdout assets return structured `blocked` metadata instead of raising. |
| ERR-076 | Fragment/inventory tests required machine-local drafts, workspace residue, or a fixed minimum file count. | resolved 2026-07-16 | Assert count invariants and stable tracked classifications; validate optional local categories only when present. |
| ERR-077 | VedAstro request previews serialized the live `x-api-key` header, allowing logs and failed test output to expose credentials. | resolved; credential rotation required 2026-07-16 | Preview/metadata profiles never contain authentication headers. Inject `VEDASTRO_API_KEY` only immediately before network execution; rotate any key exposed before this fix. |
| ERR-078 | On this macOS host, bare `python3` resolves to Python 3.9 without pytest, while the project uses PEP 604 annotations; rerunning with `.venv/bin/python` reaches the tests but `test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets` has a stale `high_value_unpromoted_count >= 1` assertion even when the scanner correctly reports zero. | observed 2026-07-15 | Run the gate with the project venv on this host and do not claim the full pre-work gate passed until the fragment-governance assertion is reconciled with the valid zero-unpromoted state. |
| ERR-078 | On this macOS host, bare `python3` resolves to Python 3.9 without pytest, while the project requires Python >=3.11 and uses PEP 604 annotations. The pre-work launcher previously reused that incompatible interpreter for every child check. | resolved 2026-08-07 | `pre_work_check.py` must probe and prefer the repository `.venv` (Python >=3.11 with pytest), expose the selected runtime in its JSON report, and fail closed with setup guidance if no compatible runtime exists. Keep regression coverage for system-Python launch and strict `JYOTISH_PRE_WORK_PYTHON` override behavior. |
| ERR-079 | VedAstro hosted replay and fixed-commit self-host validation cannot run on this host when `VEDASTRO_API_ENDPOINT` / `VEDASTRO_API_KEY` are absent and neither a .NET runtime nor a reachable Docker daemon is available. | observed 2026-07-17 | Keep hosted closure `blocked`; run `vedastro_contract_probe.py --repeats 3` only after injecting credentials through the environment, and build the pinned source in a provisioned Windows/.NET or reachable container environment. Never persist credentials in artifacts or shell history. |
## ERR-080 | Issue link was mistaken for benchmark provenance | mitigated 2026-07-18
+27 -19
View File
@@ -550,9 +550,11 @@ button:disabled { cursor: default; opacity: .45; }
.product-entrypoint-card { position: relative; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
.product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; }
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(-1px); }
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { transform: translateY(0); }
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); }
.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
@media (hover: hover) {
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); transform: translateY(-1px); }
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { opacity: .65; }
.product-entrypoint-hitarea:disabled { cursor: not-allowed; }
.daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
@@ -1119,19 +1121,25 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
pointer-events: none;
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) {
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) {
border-color: transparent;
background: color-mix(in srgb, var(--color-action-soft) 58%, var(--color-canvas));
transform: none;
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover)::after {
opacity: .65;
transform: translateX(48%);
}
@media (hover: hover) {
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) {
border-color: transparent;
transform: none;
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) .product-entrypoint-action {
transform: translateX(3px);
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover)::after {
opacity: .65;
transform: translateX(48%);
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) .product-entrypoint-action {
transform: translateX(3px);
}
}
.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) {
@@ -1270,17 +1278,22 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
border-right: 0;
}
.starter-theme-card:not(:disabled):hover,
.starter-theme-card:focus-visible {
.starter-theme-card:not(:disabled):active {
background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas));
}
.starter-theme-card:not(:disabled):hover .starter-arrow,
.starter-theme-card:focus-visible .starter-arrow {
.starter-theme-card:not(:disabled):active .starter-arrow {
color: var(--color-action);
transform: translate(2px, -2px);
}
@media (hover: hover) {
.starter-theme-card:not(:disabled):hover .starter-arrow {
color: var(--color-action);
transform: translate(2px, -2px);
}
}
.starter-content {
min-width: 0;
display: grid;
@@ -1438,11 +1451,6 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
border-bottom: 0;
}
.starter-theme-card:not(:disabled):hover,
.starter-theme-card:focus-visible {
background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas));
}
.starter-content span {
max-width: 440px;
}
@@ -289,6 +289,22 @@ test("homepage entrypoints use two whole-card native actions", () => {
assert.doesNotMatch(source, /className="daily-starlanguage-heading">[\s\S]{0,180}<button/);
});
test("starter cards use transient pressed feedback instead of sticky hover shading", () => {
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const productHoverRules = [...styles.matchAll(
/\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:not\(:disabled\):hover\)\s*\{([^}]*)\}/g,
)];
const starterHoverSurfaceRules = [...styles.matchAll(
/\.starter-theme-card:not\(:disabled\):hover\s*\{([^}]*)\}/g,
)];
assert.ok(productHoverRules.length > 0);
for (const rule of productHoverRules) assert.doesNotMatch(rule[1], /background\s*:/);
assert.equal(starterHoverSurfaceRules.length, 0);
assert.match(styles, /\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:not\(:disabled\):active\)\s*\{[^}]*background\s*:/);
assert.match(styles, /\.starter-theme-card:not\(:disabled\):active\s*\{[^}]*background\s*:/);
});
test("starter homepage stays editorial and hides technical chart parameters", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+122 -7
View File
@@ -11,15 +11,17 @@ from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
PYTHON = sys.executable
MINIMUM_PYTHON_VERSION = (3, 11)
PYTHON_OVERRIDE_ENV = "JYOTISH_PRE_WORK_PYTHON"
DEFAULT_COMMAND_TIMEOUT_SECONDS = 45
DEFAULT_FRAGMENT_TIMEOUT_SECONDS = 90
FOCUSED_TEST_TARGETS = [
@@ -39,6 +41,97 @@ PRE_WORK_DOCS = [
]
def python_candidates(
*,
root: Path = ROOT,
environ: dict[str, str] | None = None,
current_executable: str | None = None,
) -> list[str]:
"""Return Python candidates in deterministic preference order.
An explicit override is strict: a broken override must fail closed rather
than silently run the checks under a different environment.
"""
env = os.environ if environ is None else environ
override = env.get(PYTHON_OVERRIDE_ENV, "").strip()
if override:
return [override]
candidates = [
str(root / ".venv" / "bin" / "python"),
str(root / ".venv" / "Scripts" / "python.exe"),
current_executable or sys.executable,
]
candidates.extend(path for name in ("python3.13", "python3.12", "python3.11") if (path := shutil.which(name)))
return list(dict.fromkeys(candidate for candidate in candidates if candidate))
def probe_python(candidate: str) -> dict[str, Any]:
probe = (
"import importlib.util,json,sys;"
"print(json.dumps({'version':list(sys.version_info[:3]),"
"'pytest_available':importlib.util.find_spec('pytest') is not None}))"
)
try:
completed = subprocess.run(
[candidate, "-c", probe],
cwd=ROOT,
text=True,
capture_output=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return {"candidate": candidate, "ok": False, "error": str(exc)}
if completed.returncode != 0:
return {
"candidate": candidate,
"ok": False,
"error": (completed.stderr or completed.stdout).strip() or f"probe exited {completed.returncode}",
}
try:
metadata = json.loads(completed.stdout)
version = tuple(int(part) for part in metadata["version"])
pytest_available = metadata["pytest_available"] is True
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
return {"candidate": candidate, "ok": False, "error": f"invalid probe output: {exc}"}
requirements = []
if version < MINIMUM_PYTHON_VERSION:
requirements.append(f"Python {version[0]}.{version[1]} is below required 3.11")
if not pytest_available:
requirements.append("pytest is not installed")
return {
"candidate": candidate,
"ok": not requirements,
"version": ".".join(str(part) for part in version),
"pytest_available": pytest_available,
"error": "; ".join(requirements),
}
def select_python(
candidates: list[str] | None = None,
probe: Callable[[str], dict[str, Any]] = probe_python,
) -> dict[str, Any]:
attempts = []
for candidate in candidates or python_candidates():
result = probe(candidate)
attempts.append(result)
if result.get("ok"):
return {"ok": True, "executable": candidate, "attempts": attempts, **result}
return {
"ok": False,
"executable": "",
"attempts": attempts,
"error": (
"No usable project Python found. Create .venv with Python >=3.11 and install requirements-dev.txt, "
f"or set {PYTHON_OVERRIDE_ENV} to a compatible interpreter."
),
}
def run(args: list[str], timeout: int, env: dict[str, str] | None = None) -> dict[str, Any]:
try:
completed = subprocess.run(
@@ -73,8 +166,9 @@ def classify_status(
pytest_ok: bool,
remote_status: str,
external_engine_ok: bool = True,
python_ok: bool = True,
) -> str:
if not docs_ok or not fragment_ok or not pytest_ok or not external_engine_ok:
if not docs_ok or not fragment_ok or not pytest_ok or not external_engine_ok or not python_ok:
return "fail"
if remote_status == "verified":
return "pass"
@@ -85,9 +179,23 @@ def build_report(remote_timeout: int, command_timeout: int, fragment_timeout: in
docs = {path: (ROOT / path).exists() for path in PRE_WORK_DOCS}
git_status = run(["git", "status", "--short", "--branch"], command_timeout)
git_remote = run(["git", "remote", "-v"], command_timeout)
fragment = run([PYTHON, "scripts/preflight_fragment_scan.py"], fragment_timeout)
external_engine = run([PYTHON, EXTERNAL_ENGINE_DIAGNOSTIC_TARGET, "--json"], command_timeout)
remote = run([PYTHON, "scripts/remote_repo_visibility_check.py", "--timeout", str(remote_timeout)], command_timeout)
python_runtime = select_python()
python = str(python_runtime.get("executable") or "")
if python_runtime["ok"]:
fragment = run([python, "scripts/preflight_fragment_scan.py"], fragment_timeout)
external_engine = run([python, EXTERNAL_ENGINE_DIAGNOSTIC_TARGET, "--json"], command_timeout)
remote = run([python, "scripts/remote_repo_visibility_check.py", "--timeout", str(remote_timeout)], command_timeout)
else:
unavailable = {
"ok": False,
"returncode": None,
"stdout": "",
"stderr": "",
"error": python_runtime["error"],
}
fragment = dict(unavailable)
external_engine = dict(unavailable)
remote = dict(unavailable)
remote_report: dict[str, Any] = {}
if remote["ok"]:
try:
@@ -101,7 +209,10 @@ def build_report(remote_timeout: int, command_timeout: int, fragment_timeout: in
cache = Path(tempfile.gettempdir()) / "jyotish_preflight_fragment_scan_report.json"
cache.write_text(fragment["stdout"], encoding="utf-8")
env["PREFLIGHT_FRAGMENT_SCAN_REPORT"] = str(cache)
pytest_result = run([PYTHON, "-m", "pytest", "-q", *FOCUSED_TEST_TARGETS], command_timeout, env=env)
if python_runtime["ok"]:
pytest_result = run([python, "-m", "pytest", "-q", *FOCUSED_TEST_TARGETS], command_timeout, env=env)
else:
pytest_result = {"ok": False, "stdout": "", "stderr": "", "error": python_runtime["error"]}
remote_status = str(remote_report.get("status") or "blocked")
status = classify_status(
docs_ok=all(docs.values()),
@@ -109,11 +220,13 @@ def build_report(remote_timeout: int, command_timeout: int, fragment_timeout: in
pytest_ok=pytest_result["ok"],
remote_status=remote_status,
external_engine_ok=external_engine["ok"],
python_ok=python_runtime["ok"],
)
return {
"scope": "pre_work_check",
"status": status,
"must_not_claim_synced": remote_report.get("must_not_claim_synced", True),
"python": python_runtime,
"docs": docs,
"git": {
"status_ok": git_status["ok"],
@@ -122,6 +235,7 @@ def build_report(remote_timeout: int, command_timeout: int, fragment_timeout: in
"remote": git_remote["stdout"],
},
"checks": {
"python_runtime_ok": python_runtime["ok"],
"fragment_scan_ok": fragment["ok"],
"external_engine_adapters_ok": external_engine["ok"],
"remote_visibility_status": remote_status,
@@ -129,6 +243,7 @@ def build_report(remote_timeout: int, command_timeout: int, fragment_timeout: in
"focused_tests_ok": pytest_result["ok"],
},
"errors": {
"python_runtime": python_runtime.get("error", ""),
"fragment_scan": fragment["error"],
"external_engine_adapters": external_engine["error"],
"remote_visibility": remote["error"],
+54
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
from pathlib import Path
from scripts.pre_work_check import (
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_FRAGMENT_TIMEOUT_SECONDS,
EXTERNAL_ENGINE_DIAGNOSTIC_TARGET,
FOCUSED_TEST_TARGETS,
PRE_WORK_DOCS,
PYTHON_OVERRIDE_ENV,
classify_status,
python_candidates,
select_python,
)
@@ -14,6 +19,55 @@ def test_classify_status_keeps_remote_blocked_distinct_from_failure() -> None:
assert classify_status(True, True, True, "verified") == "pass"
assert classify_status(True, True, True, "blocked") == "pass_with_remote_blocked"
assert classify_status(True, True, False, "verified") == "fail"
assert classify_status(True, True, True, "verified", python_ok=False) == "fail"
def test_pre_work_check_prefers_project_venv_over_launching_python(tmp_path: Path) -> None:
candidates = python_candidates(root=tmp_path, environ={}, current_executable="/usr/bin/python3")
assert candidates[0] == str(tmp_path / ".venv" / "bin" / "python")
assert candidates[1] == str(tmp_path / ".venv" / "Scripts" / "python.exe")
assert candidates[2] == "/usr/bin/python3"
def test_pre_work_check_python_override_is_strict() -> None:
candidates = python_candidates(
environ={PYTHON_OVERRIDE_ENV: "/opt/project-python"},
current_executable="/usr/bin/python3",
)
assert candidates == ["/opt/project-python"]
def test_pre_work_check_skips_incompatible_launcher_and_selects_venv() -> None:
results = {
"/usr/bin/python3": {
"candidate": "/usr/bin/python3",
"ok": False,
"version": "3.9.6",
"pytest_available": False,
"error": "Python 3.9 is below required 3.11; pytest is not installed",
},
".venv/bin/python": {
"candidate": ".venv/bin/python",
"ok": True,
"version": "3.11.15",
"pytest_available": True,
"error": "",
},
}
selected = select_python(
["/usr/bin/python3", ".venv/bin/python"],
probe=lambda candidate: results[candidate],
)
assert selected["ok"] is True
assert selected["executable"] == ".venv/bin/python"
assert [attempt["candidate"] for attempt in selected["attempts"]] == [
"/usr/bin/python3",
".venv/bin/python",
]
def test_pre_work_check_runs_governance_test_set() -> None: