ci: enforce comment-encoding rules with a guard - #1926
Conversation
The "Encoding: names, tests, and structure -- not prose" rules in agents/skills/review/SKILL.md were the only rule class on the 2026-08 GFQL stack with no automated enforcement, which is exactly why they were the only class that kept reaching the owner: rejected on #1894, #1895 and #1897 for the same reason each time. Everything else that mattered already had a gate -- bin/ci_type_hygiene_guard.py (AST, per-file baseline), bin/ci_cypher_surface_guard.py (line-count ratchet), the per-file coverage floors, test_polars_lane_completeness.py. Comment discipline was left to human review. This converts it into a gate. bin/ci_comment_density_guard.py is a stdlib-only tokenize + ast pass over graphistry/, modelled on the type-hygiene guard: same per-file count ratchet, same --report/--list/--update-baseline/--strict surface, same exit codes, and a `# guard-ok: <check> -- <reason>` escape hatch. It runs from bin/lint.sh, so it shares the python-lint-types matrix (py3.8-3.14) with no new workflow and no new job; that lane fires on any *.py change. Checks: comment-block 2+ adjacent full-line `#` comments (3+ for a Sphinx `#:` run, which was being used as a prose loophole) perf-claim complexity notation or performance vocabulary issue-rationale a standalone comment or docstring citing an issue number as the explanation comment-block is a form rule and reads `#` comments only; the two content rules also read docstrings, since a claim does not become admissible by moving into one. Tests are exempt from comment-block and issue-rationale -- a test may explain its oracle -- but not from perf-claim: measurement belongs in pyg-bench wherever it is written. Master's grandfathered debt: 948 comment-block, 216 perf-claim, 169 issue-rationale. Acceptance was measured, not asserted. Replayed against the three rejected branches at the commits the owner reviewed, the guard flags all 16 sites he flagged by hand, and each of the 9 touched files exceeds its master cap, so the ratchet would have failed all three PRs. Two false-positive classes were measured and tuned out rather than left as noise: `regress`/`A/B` also name correctness concepts, so they count only next to performance vocabulary; and a comment naming pyg-bench is a pointer to where measurement lives, not a claim. Together those were 128 of 344 raw perf-claim hits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Self-audit against the rules this guard enforces: the file carried six
single-line rationale comments explaining why each pattern is tuned the way it
is. Five were already stated verbatim by a test name, so they were pure
duplication:
pyg-bench claim applies to tests too ->
test_tests_are_exempt_from_comment_block_but_not_from_perf_claim
tool directives never start/continue a run ->
test_tool_directives_neither_start_nor_continue_a_run
Sphinx wraps at two, prose at three ->
test_sphinx_run_is_allowed_at_two_lines_and_flagged_at_three
regress/A-B need perf context ->
test_correctness_regression_without_performance_context_is_not_a_perf_claim
test_regression_next_to_performance_vocabulary_is_a_perf_claim
pyg-bench is a pointer, not a claim ->
test_pointing_at_pyg_bench_is_not_a_perf_claim
The sixth -- complexity notation matching case-sensitively so `foo(` and
`into(` cannot hit -- had NO test. It was a real coverage gap that a comment
was standing in for, which is exactly the failure mode the guard exists to
stop. Now pinned by test_a_lowercase_call_is_not_asymptotic_notation
(foo(x), into(rows), o(n), do(work)) and its positive twin
test_uppercase_complexity_notation_is_asymptotic_notation.
Both pins verified by MUTATION: adding re.IGNORECASE to the O( pattern turns
o(n) red, then reverted. The first mutation attempt was a sed that silently
did not match, so the pass was vacuous -- re-done and confirmed failing.
Note the blind spot this exposes: these six comments passed the guard because
`comment-block` only fires on runs of 2+ adjacent comment lines. A one-line
rationale comment is still rationale, and the guard does not see it. Raising
that would reclassify a large share of the 948 grandfathered comment-block
findings, so it is flagged for the owner rather than changed here.
27 guard tests pass; guard clean on the repo; ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
| '^bin/ci_type_hygiene_guard\.py$' \ | ||
| '^bin/ci_type_hygiene_baseline\.json$' \ | ||
| '^bin/ci_comment_density_guard\.py$' \ | ||
| '^bin/ci_comment_density_baseline\.json$' |
There was a problem hiding this comment.
Better to figure out a refactor for an encapsulated folder include?
There was a problem hiding this comment.
Done in 38162c5b3 — five lines become two.
Two of the four entries I added were dead. The group's first pattern is '\.py$', unanchored, so bin/ci_type_hygiene_guard.py and bin/ci_comment_density_guard.py were already matched. Listing them changed nothing. Only the two .json baselines ever needed covering.
So the encapsulation is one pattern:
'^bin/ci_.*baseline\.json$'
A future guard now needs no ci.yml edit at all — its script is caught by \.py$ and its baseline by the glob. Verified both directions: matches all four guard inputs plus ci_cypher_surface_guard_baseline.json, and does not match docker/, README.md, bin/something.sh or requirements/*.lock.
Pinned rather than described, by test_every_guard_baseline_triggers_the_python_ci_lane — it parses the live workflow and asserts every bin/ci_*baseline*.json on disk is matched by the python group. Mutation-verified: narrowing the pattern back to a single baseline turns it red. That is the part no comment could carry, because the failure mode is a guard added later whose baseline silently stops triggering the lane.
On the literal folder include (bin/ci_guards/): considered, and deferred rather than dismissed. 10 of the 13 open stack PRs modify a guard baseline, so renaming those files right now would conflict with every one of them — and unwinding exactly that kind of cascade is what most of today went to. Filed for post-release with the other structural moves (alongside the .agents/ migration). The pattern above already removes the per-guard edit that prompted your comment, so the deferral costs nothing ongoing.
Review (r3790045223): the ci.yml `python` path-filter grew one regex per guard file, so every new guard needed a workflow edit. Five lines become two. Two of the four added entries were DEAD. The group's first pattern is `\.py$`, unanchored, so `bin/ci_type_hygiene_guard.py` and `bin/ci_comment_density_guard.py` were already matched -- listing them changed nothing. Only the two `.json` baselines ever needed covering, and they are now covered by one `^bin/ci_.*baseline\.json$`, so a future guard needs no ci.yml edit at all. Verified positively and negatively: it matches all four guard inputs plus the cypher-surface baseline, and does not match docker/, README.md, bin/something.sh or requirements/*.lock. Pinned rather than described, by `test_every_guard_baseline_triggers_the_python_ci_lane`, which parses the live workflow and asserts every `bin/ci_*baseline*.json` on disk is matched by the python group. Mutation-verified: narrowing the pattern back to a single baseline turns it red. That is the part a comment could not carry -- the failure mode is a NEW guard added later whose baseline silently stops triggering the lane. An actual folder move (`bin/ci_guards/`) was considered and DEFERRED, not dismissed: 10 of the 13 open stack PRs modify a guard baseline, so renaming those files now would conflict with every one of them. Filed for post-release alongside the other structural moves; the pattern above already removes the per-guard edit that prompted the comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
#1895 went CONFLICTING when #1894 landed on master. One conflict, in polars/chain.py: master added `edge_src, edge_dst = _bound_edge_endpoints(self)` while this branch trimmed the perf claims out of the comment below it. Kept master's code AND this branch's trimmed comment. The merge also brought in #1926's comment-density guard, which then FAILED: hop.py carried 8 comment-block findings against a cap of 6. Master's own six are the same six, displaced by ~14 lines; the two extra were added by this branch, at the endpoint-backfill epilogue and at the seed-label writer. Both narrated the same contract -- the hop-label column used to ride the endpoint concat as a side effect, that concat is now conditional, and `.loc` recreates the column implicitly on pandas but RAISES on cudf. That is a rule, so it is now a NAME: `_nodes_with_hop_label_column`, called at both sites, with the nested `_ensure_node_hop_col` reduced to one call. I wrote two pins for it and then DELETED them: they passed under mutation, so they were decorative. Mutating the helper to a no-op is caught by four pins that already exist -- `test_output_hop_window_slices_only_closed_edges[...cudf]` and `test_output_window_on_a_cycle_keeps_only_the_windowed_endpoints` -- including a cudf lane, which is the engine that actually crashes. The contract was already covered; the comments were the redundant part, not the tests. One of the deleted pins had also encoded a WRONG oracle: it assumed every engine serves `label_node_hops` with `min_hops`, but polars declines that combination with a typed NotImplementedError naming both features. Legitimate decline, wrong expectation on my side. 262 passed in test_endpoint_closure_matrix.py; ruff clean; all three guards pass (comment-density now 28 files below baseline, type-hygiene 1 below; neither cap raised). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
…1924/#1926/#1894) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Standalone off master. Root cause of the class you rejected four PRs running: comment discipline was the only rule in this repo with no automated gate. Type hygiene, cypher surface, coverage floors and the polars lane-completeness lock all have one; comments were enforced by human review alone, so they were the only class that reliably reached you.
bin/ci_comment_density_guard.py— stdlibtokenize+ast, modelled onci_type_hygiene_guard.py: same per-file ratchet, same--report/--list/--update-baseline/--strictsurface, same exit codes, ~3s, identical counts on py3.10 and py3.12. Escape hatch# guard-ok: <check> -- <reason>.Checks:
comment-block(2+ adjacent full-line#; tool directives and the file header neither start nor continue a run; a#:Sphinx run is allowed at 2 and flagged at 3+ — the loophole you called out),perf-claim(O(...)plus faster/slower/vectoriz/measurably/cheap/expensive/hot-path),issue-rationale(a standalone comment or docstring citing#NNN; a trailing# #1891on a code line is fine).Acceptance test: 16/16 of your flagged comments
Replayed against the exact commits you reviewed (
9e16c74,b645af6,d044e3e; the outdated anchors resolved viaoriginal_commit_id). Every one is caught — thequery_types.pynote, all four "eliminate/shorten" sites, the threelowering.py"should this be a test?" sites, bothagg_types.pysites (the second via the#:3+ rule), the polars chain andhop_eagersites,hop.py, andexecution.py.More decisive than flagging: the ratchet would have failed all three PRs — all 9 touched files exceed their master cap (e.g.
gfql_unified.py32/1/15 → 36/1/18,hop.py6/4/1 → 10/6/6,query_types.pyabsent from baseline → 1).Two deviations from my brief, both forced by that acceptance test
comment-blockis not restricted to function bodies —query_types.py:9-12andagg_types.py:53-55are module-scope insideif TYPE_CHECKING:, and you flagged both.perf-claim/issue-rationalealso read docstrings — a claim doesn't become admissible by moving into a docstring, andflatten.py's 22-line narration is a docstring.False positives: measured, removed, and pinned as tests
Raw
perf-claimwas 344. Two classes were noise:regress*(101 hits) was almost all correctness — "REGRESSION GUARD — DO NOT REMOVE", plus junk fromregressive— andA/B(6) matcheda/b/c id columns. Both now require performance vocabulary in the same comment. A comment namingpyg-benchis exempt from thebenchmarkterm: pointing at where the measurement lives is the correct encoding. Net 344 → 216, no loss on the acceptance set. These decisions are locked in tests, not prose — 22 tests intest_ci_comment_density_guard.py; neither existing guard has one.Master's grandfathered debt: comment-block 948/138 files, perf-claim 216/88, issue-rationale 169/50. The guard also cleans its own five 2-line comments, so it obeys itself.
Honest limits — this is ~two-thirds of the class
It closes comment volume, which is the dominant violation and purely structural. It cannot decide whether a one-line comment earns its place (that is "could a test have been written instead?" — undecidable), and the predictable evasion is compressing narration into one dense 120-char line. It catches a docstring citing an issue or claiming perf, not one narrating mechanism — a length rule was considered and rejected as ~90% noise against 254 legitimate long docstrings. And "should this be a symbol / externed DRY?" is not mechanizable at all.
It also stays a ratchet:
lowering.pyalready carries a cap of 50, and within a cap comments can be freely rewritten.--strictwould tighten that over time but fails today.So it stops the specific thing that reached you four times and shrinks the residue to a review conversation — it does not make the review skill unnecessary.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi