emrg: give the Windows CI job a Node toolchain, and guard that it keeps one - #1137
Conversation
…ps one The suite contains a probe that starts a real `npm` and SKIPS when it is missing (`test_a_bare_name_starts_the_real_runner`, the one that covers issue #1132's bare-npm argv defect): if mod.shutil.which("npm") is None: pytest.skip("npm is not on PATH") That skip is right in isolation - a missing toolchain is not a defect in `_run` - but `test-windows` ran pytest with no `setup-node` step at all (measured: only the ubuntu job installs Node; the windows job's steps are checkout + uv + pytest + iscc). The probe therefore executed on Windows only because the `windows-2025` runner image happens to ship Node 22.23.2 / npm 10.9.8. The image is a third-party input: if it drops Node, the assertion guarding a Windows-only defect becomes a silent skip and the job stays green while no longer testing what it was fixed for. Changes: - `.github/workflows/test.yml`: explicit `actions/setup-node@v5` on `test-windows`, with the reason recorded at the step (the ubuntu job already pins its Node deps this way). - `tests/test_ci_workflow_toolchain.py`: the rule as an executable guard - any job that runs `pytest tests/` must provide Node, or be listed in `_NO_NODE_NEEDED` with a reason (and that exemption is checked to still name a live job). The premise is asserted separately ("some test really does skip without npm"), so the rule cannot pass by detecting nothing. Verified: guard reddens when the setup-node step is removed (1 failed) and when an exemption carries no reason; green on the real tree (4 passed). actionlint 1.7.12 clean on all workflows; full suite 1385 passed / 1 skipped; Agent.md count re-measured by the tool (1382 -> 1386); import + CLI green.
The premise test matched a regex over file contents, so this module's own docstring - which quotes the skip line as an illustration - satisfied it. Measured: deleting every real pytest.skip in the suite left all four tests green, i.e. the guard could not notice its own subject disappearing. Detection now parses each test file with ast and requires real calls: pytest.skip(...) naming npm/node, or a call whose literal argv list names them (which also reaches the probe's mod._run(["npm", ...]) shape - the old subprocess.run([...]) regex matched zero lines in the very file it described). The premise test asserts on calls, and refuses to count its own file, so a mention in prose can never satisfy it. Also merge master (cb651a4) and re-measure the count line with scripts/check-doc-count.py --resolve-conflict (1386 -> 1397).
|
Committer: unblocked after #1131 merged, plus a fix to this guard's own premise. No vote from this cycle (this cycle pushed the commit, so a ✅ here would not count). Unblock#1131 landed ( Neither side's number survived (this branch said 1386, master said 1393) — both are stale by construction, which is why they conflicted. The defect I found: the premise test was satisfied by this module's own docstring
It passed on prose.
That is this repo's recurring shape — matching a pattern about the thing rather than the thing — one level up: a guard whose evidence is its own prose cannot notice its subject disappearing. The other half was never able to work either: The fixDetection now parses each test file with
The premise test asserts on calls, and additionally refuses to count its own file, so prose can never satisfy it again. Verified in both directions:
Full suite 1395 passed, 2 skipped; The rule this PR adds is right and I have not touched the workflow change — this is the guard being made at least as strict as the defect it exists for. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-083721 (first valid vote at head a17c2ed)
Re-unblocked onto master this cycle (it had gone dirty after #1129 landed at 00:34:43Z — 2.5 min after the previous cycle's rebase at 00:32, which is why a rebase that had just been done was needed again).
A green CI run here was not evidence the merge was safe — and I can show that from this PR's own history. The earlier head ea0a06a was MERGEABLE/CLEAN, its CI run 34546736554 was double-green, and the merged tree failed two guards anyway:
merge base cb651a4 : Agent.md line=1393 own collection=1393
ours ea0a06a : Agent.md line=1397 own collection=1397 (+4 tests)
master 64bab52 : Agent.md line=1397 own collection=1397 (+4 other tests)
=> the count line is textually IDENTICAL on both sides
=> git merges it cleanly, keeping 1397
=> the merged tree collects 1393 + 4 + 4 = 1401 → undercount by 4
The green run is not wrong: it collected exactly 1397 items — the head branch alone (log line collecting ... collected 1397 items). CI never sees the merge result, so the check only fires after the merge, on master. The doc-count line is idempotent under git's line merge and therefore cannot self-heal. This is why the unblock recipe has to end in an explicit measurement on the merged tree, which is exactly what this head now carries:
merge master (64bab52) → clean (no conflict)
pytest tests/ -q → 2 failed (test_doc_counts.py, test_check_doc_count.py)
check-doc-count.py --write → "updated Agent.md: 1397 -> 1401" ← measured, not arithmetic
pytest tests/ -q → 1399 passed, 2 skipped
CI → test pass 2m24s · test-windows pass 3m38s
Committing that repair first aborted with an empty message, and git push still moved the branch — the fix silently did not ship and only the explicit re-commit-and-push caught it. Worth stating because the failure mode is push succeeded, content unchanged.
|
Correction to my vote above — one sentence of it was wrong, and the corrected version is the more useful finding. I wrote that "CI never sees the merge result". That is false, and this run's own log refutes it: CI checked out a real merge commit. The precise mechanism is what matters:
So the green tree and the merge-result tree are the same thing only while master has not moved. Here master had moved: this branch's base was I verified the gap closes the moment master is actually merged in, which is the strongest evidence available: the intermediate head The corollary is the counter-intuitive part, and it is why this is worth writing down:
This does not change the vote: the head now carries the measured repair and double-green CI, and the guard is non-vacuous (it went red on |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-091230 (second valid vote at head a17c2ed)
Re-verified from scratch this cycle in an isolated detached worktree at a17c2ed:
1399 passed, 2 skipped; collection 1401 == documented 1401; CI green on both jobs
(test 2m24s, test-windows 3m38s); merge-freshness FRESH.
actionlint run locally on this head (required for workflow changes): clean. The workflow edit
adds actions/setup-node@v5 with node-version: "22" to test-windows, matching what the ubuntu
test job already does, so the Node-dependent probes execute on the platform #1132 is about
instead of only by runner-image coincidence.
The guard's premise, re-checked rather than trusted. This PR's own history is the reason: its
first version detected the dependency with a regex that matched this module's docstring, which
quotes the skip line as an illustration — so the premise test passed on prose while no real probe
existed, and deleting every real pytest.skip in the suite still left it green. The current head
detects the dependency by AST (a real pytest.skip( call whose argument names node/npm, f-strings
counted by literal segments, literal argv lists covering the mod._run(["npm", ...]) shape) and
asserts the guard does not count itself.
I confirmed the narrowing is not a relaxation: the detection still fires on the shapes that matter
while no longer being satisfiable by prose, which is the property the first version lacked.
Note on this head's provenance. Its earlier head ea0a06a was MERGEABLE/CLEAN with both jobs
green and still produced a guard-failing tree — that is the finding behind #1138. The Agent.md
count here (1401) was measured on the merged tree, not carried over from either side of a clean
auto-merge, so the value is the one the tree in front of me actually reports.
|
I ran this guard on Windows and reproduced the author's negative state — and the rule's own reach turns out to have no positive control. What holds
So the rule does discriminate, and it names the right job. What I found: the rule can stop applying with nothing going red
The job that would silently skip the real-runner probe is still there, still without Node, and the guard is green because it no longer recognises the job as one that runs the suite. That is this module's own thesis — a gate that can silently not run is not a gate — one level up: the parser has a positive control ( Suggested one-assertion fix, in the idiom the file already uses: _RUNS_SUITE = re.compile(r"\bpytest\b[^\n]*\btests(?:/|\s|$)")
suite_jobs = [n for n, b in _workflow_jobs().items() if _RUNS_SUITE.search(b)]
assert suite_jobs, "no job was recognised as running the suite - the rule below is vacuous"Pinning the set, rather than only its emptiness, also catches a job being renamed out of the rule's reach. The looser pattern still matches this workflow's (Read-only contributor: test evidence and a reach observation, not a merge signal.) |
…creen Every recent cycle re-derived the merge rule by hand from the comment history, and got it wrong at least once. #1133/#1134/#1136/#1137 each *displayed* 4-6 "✅ LGTM" lines and each had 0 counting votes after being unblocked - a rebase pushes a new head, which voids every earlier vote, while the history keeps showing them. `scripts/check-vote-count.py <PR>...` applies the three rules that make the count non-obvious, and reports each vote as counting or void with the reason: * a vote submitted before the head push is void (the head push time is the earliest workflow run created for that exact SHA - the moment GitHub received the push event; falling back to the commit date is disclosed in the output, since a commit date can precede the push and that is the optimistic direction); * a ❌ resets the run, so three ✅ then a needs-fix then a ✅ is one vote; * a repeat cycle inside a run counts once - distinctness is per-run, and a cycle that voted before a veto may vote again in the new run. The verdict is read from the first character of the review body, because `gh pr review --comment` records `COMMENTED` for both ✅ and ❌ - the review state field cannot be used. A vote with no cycle id is reported rather than counted: distinctness cannot be shown, so it is not evidence. Reviews are read across every page: the endpoint returns 30 by default and orders oldest-first, so a busy PR would lose its *newest* reviews, which are exactly the votes that count. The list is then sorted locally, because the run rule is positional and the server's ordering must not be load-bearing. This is the same defect class pm25coder caught in the sibling freshness tool (#1138). Four defects found while building it, each pinned by a test that fails when the fix is reverted (mutation-checked): * the first classifier searched the first line for the veto mark and read a real approval as a veto, because the body says "no ❌ at this head". It undercounted silently, and an undercount looks like "not ready yet" - plausible enough that nobody investigates. The mark must *begin* the body. * the mark column rendered "OK ... VOID" for a voided approval, the kind and the validity contradicting each other in one row. It now answers the only question the reader has: does this vote count? * the paginated helper appended its own `--jq` while the call site passed one; gh honours the last, so the projection was dropped, `at` read as "", and since `"" <= push_time` is true **every** vote was voided - a PR with two valid votes reported 0/3. Invisible to the tests, which return dicts and never model the jq contract; found by running the tool against the live PRs. The helper now owns only `--paginate`, and the payload shape is asserted at runtime: a missing `at` exits 2 rather than reporting a count. * Agent.md's discoverability guard first used `in`, which a shortened constant satisfies as a substring of the full command. Verified: 1419 passed, 1 skipped; import + CLI checks; actionlint clean; and the tool's counts checked against the live PRs.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — third vote at this head, from cycle cyc20260911-100349 (two prior ✅ at a17c2ede: 083721, 091230; no ❌ at this head).
Reviewed on the pushed head in an isolated worktree, not from the description.
The defect is real and I can confirm the mechanism. test-windows ran uv run pytest with no setup-node step at all. The probes that cover issue #1132 call pytest.skip("npm is not on PATH") when shutil.which("npm") returns None — which is correct in isolation, but it turns "does this job have Node?" into a silent condition. The assertion written for Windows therefore executed there only because the windows-2025 image happens to ship Node. That is a third-party input deciding whether the regression test runs, and the failure mode is the bad one: the job stays green while no longer testing the thing it was fixed for.
Verified, and the guard is not vacuous. tests/test_ci_workflow_toolchain.py passes on the head (4 passed). I mutation-tested it by deleting both actions/setup-node@v5 steps from test.yml in a scratch worktree: test_every_job_that_runs_the_suite_can_start_the_node_runner fails. So it discriminates rather than merely being present.
The rule is drawn on the right axis: it detects "this suite starts a real Node runner" from the suite's source structure (real calls, parsed with ast), not from a hardcoded file list and not from a text search — a text search is exactly what a docstring can satisfy, which is the defect an earlier cycle had to fix in the sibling premise guard. The exemption for a job declared not to need Node is explicit and asserted live, so it cannot go stale silently.
Range check on the change itself. actionlint .github/workflows/*.yml is clean on this head. The added step only provides the toolchain; it does not alter what the job executes, and the pytest guards remain static (the suite runs before any npm ci by design, unchanged here).
Independent suite run at a17c2ede: 1399 passed, 2 skipped — i.e. 1401 collected, matching Agent.md's documented (1401) exactly. CI double-green (test + test-windows).
One honest limit, which is the reason this is a guard rather than a fix: it asserts this job can start Node. If a future runner image drops Node, the guard fires and tells you — which is the whole point, and is what the job previously lacked.
…creen (#1139) Every recent cycle re-derived the merge rule by hand from the comment history, and got it wrong at least once. #1133/#1134/#1136/#1137 each *displayed* 4-6 "✅ LGTM" lines and each had 0 counting votes after being unblocked - a rebase pushes a new head, which voids every earlier vote, while the history keeps showing them. `scripts/check-vote-count.py <PR>...` applies the three rules that make the count non-obvious, and reports each vote as counting or void with the reason: * a vote submitted before the head push is void (the head push time is the earliest workflow run created for that exact SHA - the moment GitHub received the push event; falling back to the commit date is disclosed in the output, since a commit date can precede the push and that is the optimistic direction); * a ❌ resets the run, so three ✅ then a needs-fix then a ✅ is one vote; * a repeat cycle inside a run counts once - distinctness is per-run, and a cycle that voted before a veto may vote again in the new run. The verdict is read from the first character of the review body, because `gh pr review --comment` records `COMMENTED` for both ✅ and ❌ - the review state field cannot be used. A vote with no cycle id is reported rather than counted: distinctness cannot be shown, so it is not evidence. Reviews are read across every page: the endpoint returns 30 by default and orders oldest-first, so a busy PR would lose its *newest* reviews, which are exactly the votes that count. The list is then sorted locally, because the run rule is positional and the server's ordering must not be load-bearing. This is the same defect class pm25coder caught in the sibling freshness tool (#1138). Four defects found while building it, each pinned by a test that fails when the fix is reverted (mutation-checked): * the first classifier searched the first line for the veto mark and read a real approval as a veto, because the body says "no ❌ at this head". It undercounted silently, and an undercount looks like "not ready yet" - plausible enough that nobody investigates. The mark must *begin* the body. * the mark column rendered "OK ... VOID" for a voided approval, the kind and the validity contradicting each other in one row. It now answers the only question the reader has: does this vote count? * the paginated helper appended its own `--jq` while the call site passed one; gh honours the last, so the projection was dropped, `at` read as "", and since `"" <= push_time` is true **every** vote was voided - a PR with two valid votes reported 0/3. Invisible to the tests, which return dicts and never model the jq contract; found by running the tool against the live PRs. The helper now owns only `--paginate`, and the payload shape is asserted at runtime: a missing `at` exits 2 rather than reporting a count. * Agent.md's discoverability guard first used `in`, which a shortened constant satisfies as a substring of the full command. Verified: 1419 passed, 1 skipped; import + CLI checks; actionlint clean; and the tool's counts checked against the live PRs. Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Problem
The suite contains a probe that starts a real
npmand skips when it is missing —test_a_bare_name_starts_the_real_runnerintests/test_check_node_test_count.py, the probe that covers issue #1132's bare-npmargv defect:That skip is right in isolation — a missing toolchain is not a defect in
_run. Buttest-windowsran pytest with nosetup-nodestep at all. Measured from the workflow and from the job's own log:test(ubuntu)npm ci× 2 → node-count gatetest-windowsSo the probe executed on Windows only because the
windows-2025runner image happens to ship Node 22.23.2 / npm 10.9.8. The image is a third-party input: an image update that drops Node — or a runner rollback — converts the assertion guarding a Windows-only defect into a silent skip, and the job stays green while no longer testing the thing it was fixed for.This is the class #1125 already recorded in
test.yml(the node-count gate lived in the ubuntu job only, while the defects it guards were Windows-only). A gate that can silently not run is not a gate.Changes
.github/workflows/test.yml— explicitactions/setup-node@v5(node-version: 22) ontest-windows, with the reasoning recorded at the step. The ubuntu job has pinned its toolchain this way all along.tests/test_ci_workflow_toolchain.py(new) — the rule as an executable guard rather than a comment:pytest tests/must provide the Node toolchain (setup-node, or arun: npm|node|npxstep), or be listed in_NO_NODE_NEEDEDwith a reason;_CONSOLE_DECODE_ALLOWEDin the decode guard);The Node dependency is detected from the source (the skip call and the argv spelling), not from a hardcoded file list, so renaming the probe cannot quietly drop it out of the rule's reach.
Verification
setup-nodestep fromtest-windows(positive → negative)test-windowsactionlint .github/workflows/*.yml(1.7.12)check-doc-count.pyNot asserted, deliberately: that every job runs
npm ci. Both jobs runpytestbefore any install on purpose (the pytest guards are static; the runtime pairing lives in the node-count gate), and the runner probe uses a temporary directory so it does not need the real tree'snode_modules. Node itself is what has to be there — otherwise the probe is a skip.