diff --git a/Agent.md b/Agent.md index 5f787ed6..fe108290 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1307) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1310) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/scripts/check-doc-count.py b/scripts/check-doc-count.py index 96d427a3..7c22f705 100644 --- a/scripts/check-doc-count.py +++ b/scripts/check-doc-count.py @@ -63,6 +63,16 @@ COLLECTED = re.compile(r"(\d+) tests? collected") +# The one spelling of "run this tool" that every hint in this repo prints: this +# module's two hints, the pytest guard's failure message, and Agent.md's doc +# line. Measured 2026-09-10 (cyc20260910-191242) in the main clone: this form +# exits 0, while the bare `python3` form the drift hint used to print exits 2 +# without measuring anything - the host's `python3` has no pytest, so a hint +# spelled that way sends the reader straight into a second failure. A hint is +# only worth printing if it runs; keep the spelling here and let +# tests/test_check_doc_count.py prove the other sites agree with it. +INVOCATION = "uv run --no-sync python3 scripts/check-doc-count.py" + class DocCountError(Exception): """The doc or the tree is not in a shape this tool can act on.""" @@ -81,7 +91,7 @@ def measured_count() -> int: f"pytest --collect-only failed (rc={proc.returncode}):\n" + (proc.stdout[-2000:] + proc.stderr[-2000:]).strip() + "\n\nhint: run this with the project interpreter, e.g." - " `uv run --no-sync python3 scripts/check-doc-count.py`" + f" `{INVOCATION}`" ) match = COLLECTED.search(proc.stdout) if not match: @@ -165,7 +175,7 @@ def main(argv: list[str] | None = None) -> int: f"but {measured} are collected" ) if not (args.write or args.dry_run): - print("\nFix with: python3 scripts/check-doc-count.py --write") + print(f"\nFix with: {INVOCATION} --write") return 1 if args.dry_run: diff --git a/tests/test_check_doc_count.py b/tests/test_check_doc_count.py index ef010a1d..e1296a68 100644 --- a/tests/test_check_doc_count.py +++ b/tests/test_check_doc_count.py @@ -35,6 +35,19 @@ GUARD = REPO_ROOT / "tests" / "test_doc_counts.py" +def _load_guard(): + """Load the guard module so its function can be driven in both states. + + By path, the same way `test_doc_counts.py` loads itself: pytest imports + these files as `tests.test_doc_counts`, and a plain top-level name does not + resolve. + """ + spec = importlib.util.spec_from_file_location("_doc_count_guard", GUARD) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _load_module(): spec = importlib.util.spec_from_file_location("check_doc_count", SCRIPT) mod = importlib.util.module_from_spec(spec) @@ -81,6 +94,96 @@ def _guard_pattern() -> str: raise AssertionError("could not find the guard's count pattern") +def _guard_assert_messages() -> list[str]: + """Every assertion message in the count guard, as static text. + + Read from the AST, not by regex, so a reworded message is still read as the + message it is. Adjacent string constants inside an f-string are joined; the + `{doc}` / `{documented}` placeholders are `FormattedValue` nodes and drop + out, which is fine - the hint this test cares about is static text. + """ + tree = ast.parse(GUARD.read_text(encoding="utf-8")) + func = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "test_python_count_matches_docs" + ) + messages = [] + for node in ast.walk(func): + if isinstance(node, ast.Assert) and node.msg is not None: + messages.append( + "".join( + part.value + for part in ast.walk(node.msg) + if isinstance(part, ast.Constant) and isinstance(part.value, str) + ) + ) + return messages + + +def test_guard_failure_names_a_runnable_repair_command(mod, capsys) -> None: + """The guard must fail with a command the host can actually run. + + Reporting drift without naming the repair path leaves the host to find the + tool - and the tool exists precisely for this failure. Both halves are + needed, so both are asserted: the path in the message must exist, and + `--write` must be a flag the tool really accepts. A hint spelled + consistently is not a repair path; a repair path that only exists in a + comment is not one either. + + Necessary, not sufficient: this reads the hint out of the guard's source, and + a string that lives in an assert the drift path never reaches satisfies it. + `test_guard_drift_message_names_the_repair_command` is the half that drives + the guard and checks the message a host actually sees. + """ + messages = _guard_assert_messages() + hinted = [m for m in messages if "scripts/check-doc-count.py" in m] + assert hinted, ( + "the guard's failure message no longer names the repair tool, so a host " + f"who hits it in CI has no next step; messages found: {messages}" + ) + assert any("--write" in m for m in hinted), ( + f"the hint does not offer the repair flag, only a path: {hinted[0]!r}" + ) + assert SCRIPT.exists(), f"the guard points at {SCRIPT}, which does not exist" + + with pytest.raises(SystemExit) as excinfo: + mod.main(["--help"]) + assert excinfo.value.code == 0 + assert "--write" in capsys.readouterr().out + + +def test_guard_drift_message_names_the_repair_command(mod, monkeypatch) -> None: + """The hint must be in the message a host actually gets, in the drift state. + + The static checks next door read the hint out of the guard's source, which + makes them necessary but not sufficient. Measured (cyc20260910-192726): moving + that same hint string onto the assert that fires when the *anchor* is missing + left both of them green, while a drifting host saw a message with no hint at + all - the exact regression this branch exists to prevent. So drive the guard + the way drift drives it: stub the collected count (no subprocess, ~0s) and + read the raised message. + """ + guard = _load_guard() + canonical = "uv run --no-sync python3 scripts/check-doc-count.py --write" + documented = mod.documented_count((REPO_ROOT / "Agent.md").read_text(encoding="utf-8")) + + # Positive state: a consistent tree leaves the guard silent. Without this + # half, a guard that compared nothing would still pass the negative one. + monkeypatch.setattr(guard, "_collected_pytest_count", lambda: documented) + assert guard.test_python_count_matches_docs() is None + + # Negative state: one test's worth of drift - the real incident shape. + monkeypatch.setattr(guard, "_collected_pytest_count", lambda: documented + 1) + with pytest.raises(AssertionError) as excinfo: + guard.test_python_count_matches_docs() + message = str(excinfo.value) + assert f"Fix with: {canonical}" in message, ( + "the message a host sees on drift no longer names the repair command; " + f"it says: {message!r}" + ) + + def test_tool_pattern_agrees_with_the_guard(mod) -> None: """The guard and this tool must key on the same phrase in the real Agent.md. @@ -235,3 +338,62 @@ def test_real_tree_is_consistent() -> None: ) assert proc.returncode == 0, proc.stdout + proc.stderr assert "OK: Agent.md documents" in proc.stdout + + +# --- the repair hint itself must be a command that runs ----------------------- + + +def test_every_repair_hint_prints_one_runnable_command(mod, monkeypatch, tmp_path, capsys) -> None: + """Every site that tells someone how to repair the count must agree. + + Measured (cycle cyc20260910-191242, main clone): the canonical form exits 0, + while the bare `python3` form the drift hint used to print exits 2 having + measured nothing - the host's `python3` cannot import pytest. The guard's + message, this tool's error hint and Agent.md already used the canonical form, + so the drift hint was the one site sending the reader into a second failure. + A hint that fails is worse than no hint: it looks like a next step. + + All four sites are checked here (tool source, tool drift output, tool error + output, guard message, plus Agent.md), because the defect was precisely a + disagreement between them. + """ + canonical = "uv run --no-sync python3 scripts/check-doc-count.py" + assert mod.INVOCATION == canonical + + source = SCRIPT.read_text(encoding="utf-8") + hits = list(re.finditer(r"python3 scripts/check-doc-count\.py", source)) + assert hits, "the tool no longer mentions its own invocation at all" + for hit in hits: + prefix = source[max(0, hit.start() - len("uv run --no-sync ")) : hit.start()] + assert prefix == "uv run --no-sync ", ( + "a hint in scripts/check-doc-count.py spells the invocation without the " + f"project runner: ...{source[max(0, hit.start() - 40) : hit.end() + 20]!r}" + ) + + doc = (REPO_ROOT / "Agent.md").read_text(encoding="utf-8") + assert canonical in doc, "Agent.md no longer documents the canonical invocation" + assert any( + f"Fix with: {canonical} --write" in message + for message in _guard_assert_messages() + ), "the pytest guard's failure message no longer prints the canonical fix command" + + # Drift state, for real: the printed line must be the runnable one. + mod.DOC = _doc(tmp_path, 1307) + monkeypatch.setattr(mod, "measured_count", lambda: 1308) + assert mod.main([]) == 1 + assert f"Fix with: {canonical} --write" in capsys.readouterr().out + + # Error state: the interpreter advice must be the same spelling. A fresh + # module, because `measured_count` on `mod` is stubbed above to reach the + # drift path, and this half needs the real function to run. + fresh = _load_module() + + class _Proc: + returncode = 4 + stdout = "" + stderr = "" + + monkeypatch.setattr(fresh.subprocess, "run", lambda *a, **k: _Proc()) + with pytest.raises(fresh.DocCountError) as excinfo: + fresh.measured_count() + assert f"`{canonical}`" in str(excinfo.value) diff --git a/tests/test_doc_counts.py b/tests/test_doc_counts.py index 1ffca982..3651d15a 100644 --- a/tests/test_doc_counts.py +++ b/tests/test_doc_counts.py @@ -77,7 +77,8 @@ def test_python_count_matches_docs() -> None: documented = int(m.group(1)) assert documented == collected, ( f"{doc} documents {documented} Python tests but {collected} are collected " - f"(--collect-only). Sync the doc (and this guard) when adding/removing tests." + f"(--collect-only). Sync the doc (and this guard) when adding/removing tests.\n" + "Fix with: uv run --no-sync python3 scripts/check-doc-count.py --write" )