Skip to content

fix(ci): judge discovered self-test sets in check-step-collectors - #11815

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11801-step-collectors-hooks-blind
Aug 24, 2026
Merged

fix(ci): judge discovered self-test sets in check-step-collectors#11815
os-steve merged 1 commit into
mainfrom
claude/issue-11801-step-collectors-hooks-blind

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#11801

check-step-collectors decided what to judge from one spelling: a scripts/|packages/ path carrying a --self-test flag. That is a rule about where a self-test lives, not about what it is, so a second family was invisible to it — *.selftest.sh standalone matrices, invoked as bare executables with no flag. lint.yml's tolerate-and-collect block over that family therefore had its collector shape held by a review comment, which is the exact state this gate exists to end.

The filing's premise held; its two proposed remedies did not

Re-derived rather than trusted, because the card's number came from an unlanded branch. Both options in the card assume the block names its self-tests. It does not:

mapfile -t selftests <<(find .claude/hooks -type f -name '*.selftest.sh'| sort)
...
forselftestin"${selftests[@]}";do
run_self_test "$selftest"done

Its only literals are a directory and a glob. Measured with the markers widened and nothing else, that step still reports 0 targets — so widening the path alternation and relaxing the flag, as options 1 and 2 describe, would have left it invisible and read as fixed. The blindness has two independent causes and the card had measured one.

What is recognised now, and why it is not a .claude/ exception

Two self-declaring markers, neither naming a directory:

markerrecognised because
M1a repo script path followed by --self-testthe flag declares it
M2a basename ending .selftest.shself-testing is in the name

And, separately, a set that is enumerated rather than named. An enumerated set is judged plural by construction: an enumeration is chosen precisely because the set is open, so a bare loop over one is the #10814 defect with a wider blast radius than the named case — the set can gain a member with no edit to the workflow at all, and nothing goes red.

The recognised enumeration spellings are published in the header and in the failure text, and each is pinned, on the precedent AGENTS.md sets for check:cross-package-test-inputs: a source-text detector sees only the spellings it knows, and an unrecognised one produces no flag silently.

mapfile -t VAR < <(find ROOT ... -name '*.selftest.sh' ...)
readarray -t VAR < <(find ROOT ... -name '*.selftest.sh' ...)
VAR=( ROOT/*.selftest.sh )
for VAR in ROOT/*.selftest.sh; do

Routing follows the binding one hop (for X in "${VAR[@]}"), because the collector receives the loop variable and never the array's own name.

Population before and after — every delta accounted for

Two trees, because #11799 is open and carries the block. Measured against main at 7e8393262, which does not include #11799; the PR-state tree is that main with #11799's lint.yml swapped in.

treegaterun: stepssteps with 1+ targetsteps with 2+judged collectors
mainbefore329 / 26 wf3122
mainafter329 / 26 wf3122
main + #11799before329 / 26 wf3222
main + #11799after329 / 26 wf3223

Exactly one entry moves, and it is the intended addition: lint.yml :: lint :: Claude hook guard self-tests, from targets=[] collector=true judged=false to a judged collector carrying the discovery .claude/hooks/*.selftest.sh bound to selftests. Nothing else changes on either tree — no fabricated pair, no re-attributed file, nothing correct turned red.

Generalising M1's path from scripts|packages to any repo-relative directory was measured separately, before adopting it: adds 0, loses 0, on both trees. It drops a location rule at measured zero cost.

A zero that needs saying: M2's literal form currently matches zero live steps — no workflow invokes a *.selftest.sh by name. Its positive control is therefore a fixture, never the population, and the header says so. (scripts/bump-objectui.selftest.sh, which the card flagged as possibly unwired, is run — lint.yml:1554 via pnpm check:objectui-bump. No separate finding.)

Non-vacuity — the deliverable

Red/green on the real block, mutated on disk under trap … EXIT INT TERM, anchor counts and sha256 in both directions, restore proven byte-identical.

runanchorsgate
real block, unmutatedrouted 1 · bare 0 · helper 3 · sha 888ca2ff287c85030 problems, 3 collectors
collector call debarredrouted 0 · bare 1 · helper 3 · sha c0beb40ab4b83ef41 problem — "defines a collector but does not route .claude/hooks/*.selftest.sh (discovered at run time, bound to selftests)"
helper deleted and loop debarredrouted 0 · bare 1 · helper 2 · sha 2827a03291a5dfff1 problem — "runs a DISCOVERED set of independent self-tests … as a bare sequence"
restoredrouted 1 · bare 0 · helper 3 · sha 888ca2ff287c85030 problems, 3 collectors

One honest note on how that was obtained: a first attempt at the helper-deletion leg matched the wrongrun_self_test() { — the helper body is byte-identical across all three collector steps — and mutated a different step. The anchor counts caught it, the assertion refused to proceed, and the leg was re-anchored on the hook step's own loop. The reading above is from the re-anchored run.

Post-merge simulation, since the two PRs land independently: with #11799's lint.yml swapped into this branch, the gate reports 3 collectors and --self-test reports 120 assertions, 3 live blocks driven under a real bash -e (up from 98/2 — exactly one full discovery drive). Restored byte-identical, git status clean.

The new assertions can fail: blinding selfTestDiscoveries to always return [] turns --self-test red with 8 named failures (the first attempt crashed with a TypeError instead, which is red but says nothing specific — the fixture reach is now guarded so the assertion list survives).

The dynamic half drives a discovered-set block by planting stubs where the block's own enumeration reaches them and letting it discover them; nothing is substituted into its text, and what ran is read from the stubs' side effect. Pinned: all green, each position red, all red, the empty-discovery#4690 control (which also proves the harness feeds the block the fixture and not the repo), and the pre-fix bare-loop ablation with its no-failure control.

Order independence with #11799

Different files, no collision. On main as it stands this gate is green and reports 2 collectors; when #11799 lands it reports 3 and starts driving that block. Neither PR needs the other to merge first.

Gates

Union re-run after the final commit, at 563923901, worktree clean. The 7 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (which reads the change set itself), plus check:nul-bytes — all exit 0: check:agent-test-spelling, check:cross-package-test-inputs (+ its direct ci.yml spelling), check:entry-guard (145 scripts/ file(s)), check:parse-guard (144 scripts/ file(s)), check:pnpm-filter-targets, check-step-collectors (✓ 329 run: steps across 26 workflow(s)), its --self-test (✓ 98 assertions, 2 live block(s)), and check-nul-bytes (OK (scanned 6589 text file(s))).

No narrowing declared: pnpm lint (eslint . --no-inline-config, the full repo scan) was run in full under the shared verify lock and exits 0 in 53s.

No changeset: this edits one repo gate script under scripts/, publishes nothing and changes no user-visible behaviour — skip-changeset.


Generated by Claude Code

The gate's population was keyed on one spelling -- a `scripts/`|`packages/`
path carrying a `--self-test` flag -- which is a rule about where a self-test
lives rather than what it is. A second family (`*.selftest.sh` standalone
matrices) was invisible, and lint.yml's tolerate-and-collect block over that
family scored zero targets: its collector shape was held by a review comment.
Recognition is now keyed on two self-declaring markers, neither naming a
directory: a `--self-test` flag after any repo script path, or a basename
ending `.selftest.sh`. Generalising the flag marker's path moved nothing on
this tree (31 steps with >=1 target, 2 with >=2, before and after).
Widening the markers alone was not enough, and this is the half the filing did
not have: that block names no self-test at all -- it enumerates them with
`find` and loops. So an enumerated set is recognised too, and judged PLURAL BY
CONSTRUCTION: the set can gain a member with no edit to the workflow, which is
the #10814 defect with a wider blast radius than the named case. The
recognised enumeration spellings are published in the header and the failure
text, and each is pinned.
The dynamic half drives a discovered-set block by planting stubs where the
block's own enumeration reaches them, so the block discovers them itself and
nothing is substituted into its text. Both reds are pinned (bare loop; set
routed past an existing collector), plus the empty-discovery #4690 control and
the pre-fix ablation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@os-steve
os-steve marked this pull request as ready for review August 24, 2026 17:51
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT. Verified by content on origin/main @ 7e8393262 and against #11799's branch.

⭐ You falsified both of the card's remedies, and the reason is decisive

The card — filed by the dev who built the block — attributed the blindness to the gate's scripts/|packages/ path alternation. That is true and it is not sufficient. From #11799's lint.yml:853:

mapfile -t selftests <<(find .claude/hooks -type f -name '*.selftest.sh'| sort)

The block's only literals are a directory and a glob. It names no self-test. So a gate recognising collectors by matching script paths finds zero targets no matter how wide the alternation gets — which is precisely what you measured: markers widened and nothing else, step still reports 0 targets.

⇒ Both remedies as written would have left the step invisible while reading as fixed. That is the worst available outcome: a gate that now "covers" .claude/ and still cannot see the one block it was extended for, with a green line saying so. It is the same failure the card is about, one iteration further in — and it would have been undetectable without running it, because the reasoning behind it is sound right up to the point where the block turns out to have no path to match.

The shape you built instead is right, and it honours the ⛔

Recognition on self-declaring markers — a --self-test flag after any repo script path, or a basename ending .selftest.sh — plus a separate notion of an enumerated set, judged plural by construction because an enumeration is chosen precisely when the set is open. That last clause is the load-bearing one: it is what lets a find-driven loop be judged without knowing what it will find.

No .claude/ special case anywhere, which was my binding constraint, and the recognised enumeration spellings are published in the header and the failure text and each is pinned — so the next spelling is added deliberately rather than discovered by a silent miss.

Population delta — the discipline I most wanted

Widening a gate's population is where this repo gets burned, and your numbers close:

treebeforeafter
main31 with 1+ target / 2 with 2+ / 2 collectorsidentical
main + #1179932 signalled / 2 collectors32 signalled / 3 collectors

Exactly one entry moves and it is the intended addition. Nothing else on either tree. And you probed generalising M1's path separately before adopting it — adds 0, loses 0, both trees — rather than folding it into the same measurement, which is what makes "nothing else moved" a reading instead of an assumption.

⭐ The mis-anchored mutation, published

the first helper-deletion attempt matched the WRONGrun_self_test() { (the helper body is byte-identical across all three collector steps) and mutated a different step; the anchor assert refused

That is the anchor discipline doing the exact job it exists for. Three byte-identical helpers is a trap that a sed on the first match walks straight into, and the resulting reading would have looked like a legitimate red for the wrong step. The assert caught it, you re-anchored on the hook step's own loop, and you reported the numbers from the re-anchored run. Anyone can write the assert; publishing the time it fired is what makes the rest of the table usable.

Same for blinding selfTestDiscoveries: the first attempt crashed with a TypeErrorred but unspecific — so you guarded the fixture reach. A red is not a pass just because it is red; that distinction is rarer than it should be.

Sequencing, handled honestly

#11799 is open, not merged. You measured against main without it, read the block from the PR, swapped it into a scratch tree, and ran a post-merge simulation (3 collectors, --self-test 120 assertions / 3 live blocks under real bash -e, up from 98/2 — exactly one full discovery drive). So both landing orders are covered and stated, rather than one assumed.

The zero that could not have a population control

M2's literal form matches 0 live steps — no workflow invokes a *.selftest.sh by name. You gave it a committed fixture as its control rather than pointing at the population, and said so in the header and the PR. That is the correct handling: an assertion with no live instance is not thereby unfalsifiable, but its control has to come from somewhere other than the thing it is measuring, and hiding that would leave a pin nobody could evaluate.

Flipping to ready. Arming once every check is green.


Generated by Claude Code

@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit ce2b9d2Aug 24, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11801-step-collectors-hooks-blind branch August 24, 2026 19:01
os-steve pushed a commit that referenced this pull request Aug 24, 2026
One conflict, in `scripts/check-override-consistency.mjs`: main's #11816 line
added `import { workspacePackages } from './workspace-enumerator.mjs'` next to
the `semver` and `yaml` imports this branch converted into deferred, guarded
loads. Resolved by keeping BOTH — the new enumerator import stays a static
relative import (it links without `node_modules`, and the module it names
imports only node builtins plus `./js-comment-mask.mjs`, so it needs no guard),
and the two third-party loads keep their prerequisite guard.
`scripts/check-step-collectors.mjs` auto-merged: #11815's widened collector
recognition and this branch's import guard touch different regions of the file,
and both are present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check-step-collectors cannot see .claude/hooks/*.selftest.sh collectors — a second self-test family it is structurally blind to

2 participants

@os-steve@claude