Files
improvise/scripts/check-ci-graph.py
T
Edward Langley 3542d6f717 fix(tooling): a leaf is not an identity, and a convenience flag was a bypass
Both blockers from the adversarial review of #83, both in the mode this PR
rewrote, both producing a pass while the gate did not do what its prose said.

1. cargo_test_names reduced every listed path to its last segment. Six leaf
names collide in this workspace and two are cited in evidence, so a heading was
satisfiable by a test in another module answering another question -- renaming
ui::project::tests::a_load_that_fails_is_not_a_rebase, the one its entry
describes, left the checker at exit 0 with the ui::host namesake answering for
it. Paths are kept whole; a bare leaf resolves only when unique, and an
ambiguous one is REFUSED with both candidates named. _RUST_NAME accepts `::`, so
a qualified heading is a claim rather than prose, and a citation is matched on
the leaf so qualifying a heading cannot orphan its own test. The two colliding
entries are qualified. Neither half suffices alone: rename one of a colliding
pair and the leaf becomes unique again, which is why qualification is the fix
and the refusal is what forces it.

2. --rust-tests called cargo_test_names with one argument, so the --ignored
subtraction never ran, and it printed a byte-identical success line. It now
requires --rust-tests-ignored (one alone exits 2) and the line names its source.

Also, from the same review: a missing context/test-evidence/ exited 0 while 170
citations pointed at nothing (early return, now gone); the doc-site "runs last"
check accepted a trailing `echo` that merely NAMED the generator, so it asks for
an invocation; bundle_freedom gains an arm for a transitive `import "../app.js"`,
which reaches the bundle while naming nothing forbidden; and two prose overclaims
are corrected -- the prose-heading count is a convenience rather than a guard
(git diff is the mechanism), and the ratio rule says "above the run MEDIAN",
which is defensible where "above the others" is false against second place.

scripts/check-ci-graph.py makes claim 6's rule a gate: pages-deploy must `needs`
every other job in ci.yml. The rule was stated in the last commit and a
zz-new-check: job added during review was still ungated with nothing noticing.
README now states the rule and lists no jobs, the second copy being what went
stale twice.

Every fault re-injected and observed; figures in context/test-evidence/tooling.md
rather than only here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:42:07 -07:00

125 lines
4.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""pages-deploy must `needs` every other job in the workflow.
The rule that does not go stale is "every check job"; a subset is what let
README name three of seven while the rest read as ungated (improvise-x7cq).
Stating it was not enough -- a new job was added during review of PR #83 and
nothing noticed, so the rule is a gate. See context/code/tooling.md.
`--self-test` checks the checker: each case is a graph this must refuse.
"""
import re
import sys
WORKFLOW = ".github/workflows/ci.yml"
DEPLOY = "pages-deploy"
_JOB = re.compile(r"^ ([A-Za-z0-9_-]+):$")
_NEEDS = re.compile(r"^ needs:\s*\[(.*)\]\s*$")
def read_graph(text):
"""(all job names in order, the names `pages-deploy` needs).
Both come off indentation, which is what a workflow's own shape is. A job
key is two spaces; `needs:` is four INSIDE the job it belongs to, so the
current job is tracked rather than the first `needs:` taken.
"""
jobs, needs, current = [], None, None
in_jobs = False
for raw in text.split("\n"):
if raw.startswith("jobs:"):
in_jobs = True
continue
if not in_jobs:
continue
m = _JOB.match(raw)
if m:
current = m.group(1)
jobs.append(current)
continue
m = _NEEDS.match(raw)
if m and current == DEPLOY:
needs = [n.strip() for n in m.group(1).split(",") if n.strip()]
return jobs, needs
def verdict(jobs, needs):
"""The complaints, as strings. Empty means the graph is sound."""
if DEPLOY not in jobs:
return [f"{DEPLOY} is not a job in this workflow"]
if needs is None:
return [f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]
expected = {j for j in jobs if j != DEPLOY}
missing = sorted(expected - set(needs))
unknown = sorted(set(needs) - expected)
out = [f"{DEPLOY} does not need `{j}`, so a failing {j} cannot stop a deploy"
for j in missing]
out += [f"{DEPLOY} needs `{j}`, which is not a job in this workflow" for j in unknown]
return out
# (name, workflow text, the complaints it must draw).
SELF_TEST = [
("a job nobody gates is named",
"jobs:\n test:\n x: 1\n zz-new-check:\n x: 1\n"
" pages-deploy:\n needs: [test]\n",
[f"{DEPLOY} does not need `zz-new-check`, so a failing zz-new-check cannot stop a deploy"]),
("the complete graph is accepted",
"jobs:\n test:\n x: 1\n wasm:\n x: 1\n"
" pages-deploy:\n needs: [test, wasm]\n", []),
("a needs naming a job that does not exist is caught",
"jobs:\n test:\n x: 1\n pages-deploy:\n needs: [test, ghost]\n",
[f"{DEPLOY} needs `ghost`, which is not a job in this workflow"]),
("a deploy with no needs at all is caught",
"jobs:\n test:\n x: 1\n pages-deploy:\n x: 1\n",
[f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]),
# `needs:` belongs to the job it is indented under. Taking the first one
# seen would read ANOTHER job's list as the deploy's and pass.
("another job's needs is not mistaken for the deploy's",
"jobs:\n test:\n x: 1\n browser:\n needs: [test]\n"
" pages-deploy:\n x: 1\n",
[f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]),
]
def self_test():
failures = []
for name, text, want in SELF_TEST:
jobs, needs = read_graph(text)
got = verdict(jobs, needs)
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
if failures:
print("ci-graph self-test FAILED:", file=sys.stderr)
print("\n".join(failures), file=sys.stderr)
return 1
print(f"ci-graph self-test: {len(SELF_TEST)} graphs judged")
return 0
def main():
if "--self-test" in sys.argv[1:]:
return self_test()
jobs, needs = read_graph(open(WORKFLOW, encoding="utf8").read())
if len(jobs) < 2:
print(f"error: {WORKFLOW}: found {len(jobs)} jobs; the parse is wrong",
file=sys.stderr)
return 2
complaints = verdict(jobs, needs)
if complaints:
for c in complaints:
print(f"ci-graph: {c}", file=sys.stderr)
return 1
print(f"ci-graph: {DEPLOY} needs all {len(jobs) - 1} other jobs")
return 0
if __name__ == "__main__":
sys.exit(main())