Skip to content

fix(pm): derive a gate family for a card that edits the gate's own check script (#8509) - #8532

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8509-own-script-derivation
Aug 13, 2026
Merged

fix(pm): derive a gate family for a card that edits the gate's own check script (#8509)#8532
hotlong merged 1 commit into
mainfrom
claude/issue-8509-own-script-derivation

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8509

derive() resolves every discovered check:* family to the script files that implement it and stores them on entry.files, then compared only entry.hints — the path literals scanned out of those files' contents. So the most direct relationship the tool has was the one it never used: this gate IS this file. A card editing scripts/check-empty-changeset.mjs derived nothing, because the gate whose entire job is running that script's self-test names it in package.json, not in the script's own source.

What changed

The card's own direction, and nothing beyond it: each of entry.files is now a watch key for its own family, alongside the scanned ones. Two pure exported functions carry the judgment so the self-test can drive it offline:

  • coveringHint(entry, inputPath) — the family's own script files first, then the scanned hints, compared with the same hintCovers. One answer per path, so the two key sets can never double-print; identity goes first only because it is the more specific claim, so it is the better provenance when both fire.
  • classifyEntry(entry, paths)matched / undetermined / silent, replacing the inline loop in derive.

Nothing is listed.entry.files is already resolved at runtime from package.json, so the identity key keeps the same derived-never-listed contract as the rest of the script: a gate script added tomorrow is matched by the next run with nothing to update.

The matched via column shows the file path as its own provenance, exactly as the card described:

$ node scripts/pm/dispatch-gates.mjs scripts/check-empty-changeset.mjs
before: No check family names the given paths in its own source.
after: - pnpm check:changeset-gate-self-tests [lint.yml]
matched via scripts/check-empty-changeset.mjs => 'scripts/check-empty-changeset.mjs'
- node scripts/check-empty-changeset.mjs [cut-rc.yml, pr-automation.yml]
matched via scripts/check-empty-changeset.mjs => 'scripts/check-empty-changeset.mjs'

The trap this deliberately avoids

The one-line spelling — push entry.files into entry.hints — also silently empties the "repo-wide / undetermined" bucket. A family whose source names no path whatsoever still resolves to a script file, so hints.length === 0 stops being true for it. Measured on this tree: 35 families have no discoverable path literals and 16 of them resolve to a script file, so that spelling moves 16 gates out of the output's honest half and into silence — a gate the derivation cannot mention at all, the one output shape this script's contract forbids.

The two questions are different. Matching asks is this family relevant to these paths?, which identity answers. The bucket asks does this family's source name any path at all?, which identity answers for nobody's card but the one editing that very script. So identity decides matching, and the bucket keeps reading entry.hints. That invariant is pinned, and reverse-verified below.

Measured, not reasoned

Re-measured on the current origin/main (a6231c7), not on the card's numbers — the farm grew to 70 discoverable gate scripts since the card measured 66. "Does editing this gate script derive at least one gate family?":

treescripts that derive a gate when edited
main @ a6231c7 (red baseline, reproduced)8 of 70
this branch70 of 70

The 8 survivors are the accidental ones the card described — scripts that happen to quote their own filename in their module body (check-nul-bytes, check-adr-anchors, check-doc-anchors, check-published-files, check-skill-compatibility-version, check-type-check-coverage, docs-audit/affected-docs, git-merge-regen). The count is computed with the tool's real exported judgment, not a copy of it.

Probed end to end, before -> after:

card pathbeforeafter
scripts/check-empty-changeset.mjs0 families2 (check:changeset-gate-self-tests + the direct invocation)
scripts/pm/check-dispatch-gates.mjs01 (check:pm-dispatch-gates, by identity)
scripts/pm/dispatch-gates.mjs11 — unchanged, still via the module-body constant
scripts/check-nul-bytes.mjs11 — byte-identical output (both keys are the same string here)
packages/spec/src/data/filter.zod.ts77 — unchanged, the non-gate-script control
any unrelated card, undetermined bucket3535 — preserved

Two consequences worth stating rather than burying:

  • Provenance got more honest for scripts-shaped inputs. Identity-first replaced several matches that had been landing on data files or prose artifacts: check:query-options-erasure was attributed to scripts/query-options-erasure-baseline.json, check:type-source-resolution to scripts/tool.ts, and check:skill-compatibility to a literal with a trailing dot (...-version.mjs., a sentence end read as a path). All three now name the script that implements the gate.
  • A card dispatched with the bare directory scripts now matches ~every gate implemented there (16 -> 70). This is the existing bidirectional hintCovers rule (input dir covers hint below it, itself a pinned case), not a new one, and it is true: editing all of scripts/ really does move every gate whose implementation lives there. Named here because the number is large enough to surprise a reader.

Self-test: 87 -> 101 cases

Both directions, as the card asked. Offline fixtures for the boundary (a gate script derives its own family with the file path as provenance; an unrelated path gains nothing; another gate's script does not match through this one's identity; a family resolving to no file matches nothing by identity; precedence in both directions). Live pins for the population: the thin-gate-file specimen in both of its directions — the gate file now derives its own family by identity, while the tool it runs still derives it through the module-body constant, so the two keys answer different inputs and do not fight — and the card's own specimen resolved through the real root package.json, so the whole chain (resolve -> identity -> cover) is asserted rather than the fixture alone.

Reverse verification, direction predicted first

Ablation A — remove the identity key (coveringHint falls back to scanned hints only). Predicted: exactly 6 red — the identity pin, the precedence pin, the live gate-file pin, the end-to-end pin, the matched-not-undetermined pin, the hit-shape pin; the eight "gains nothing / still works" cases stay green because they assert null or a scanned hint either way. Observed: exactly those 6, and the live repro reverted to the card's red baseline (No check family names the given paths in its own source.).

Ablation B — the one-line spelling (let the merged keys answer the bucket question). Predicted: 1 red (the bucket guard) and the live undetermined count collapsing. Observed: 1 red, and 35 -> 19 families — the 16 losses measured above, reproduced.

Both ablations were run from the committed state and restored with git checkout claude/issue-8509-own-script-derivation -- scripts/pm/dispatch-gates.mjs, confirmed byte-identical (git diff --stat HEAD empty) before the gates below.

Gates

  • pnpm check:pm-dispatch-gates — green, 101/101.
  • pnpm check:nul-bytes — green (7682 files scanned, 0 control bytes), plus a wider control-character self-scan of the changed file (grep -naP over the full C0 set plus DEL) — clean.
  • npx eslint scripts/pm/dispatch-gates.mjs --no-inline-config — clean, exit 0.

Re-derived against the actual diff (scripts/pm/dispatch-gates.mjs): the tool names exactly pnpm check:pm-dispatch-gates — no family the dispatch prompt missed, and the derivation for this card is unchanged by this change (the tool is not itself a gate script; its gate reaches it through the module-body constant).

No changeset: a scripts/-only change releases nothing, so the skip-changeset route applies — same as the predecessor on this file.

Scope

One file, derive() plus the self-test, as claimed. The residue #8352 names — gates whose population is a runtime-computed git diff and which name no literal at all — is untouched in either direction; it is a different key, not this one.


Generated by Claude Code

…eck script (#8509)
`derive()` resolves every discovered `check:*` family to the script FILES that
implement it and stores them on `entry.files`, then compared only `entry.hints`
— the path literals scanned out of those files' CONTENTS. So the most direct
relationship the tool has was the one it never used: *this gate IS this file*.
A card editing `scripts/check-empty-changeset.mjs` derived nothing, because the
gate whose whole job is running that script's self-test names it in
package.json, not in the script's own source.
Measured on this tree: of the 70 gate scripts the workflows resolve to, 8
derived any family when edited — and those eight only because they happen to
quote their own filename in their module body, which was never a feature. After
this change, 70 of 70. The blind spot was self-shaped: exactly the class of card
that edits gate tooling, which is the work most likely to break a gate.
Nothing is listed. `entry.files` is already resolved at runtime from
package.json, so the identity key keeps the same derived-never-listed contract
as the rest of the script.
Two pure exported functions carry the judgment, so the self-test can drive it
offline: `coveringHint(entry, path)` (identity keys first, then scanned hints —
one answer per path, so the two can never double-print) and
`classifyEntry(entry, paths)`. The bucket predicate deliberately still reads
`entry.hints`: pushing `entry.files` into `entry.hints` would have been the
one-line spelling, and it silently empties the "repo-wide / undetermined"
bucket for the 16 of 35 hint-less families that resolve to a script file.
Self-test 87 -> 101 cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WuTtyckQa1VcXwgd52JpN
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 5:32pm

Request Review

@hotlonghotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 13, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 13, 2026 17:49
@hotlong
hotlong added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 663dd99Aug 13, 2026
24 checks passed
@hotlong
hotlong deleted the claude/issue-8509-own-script-derivation branch August 13, 2026 18:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates never derives a gate for a card that edits the gate's OWN check script — the resolved script paths are already in hand and unused

2 participants

@hotlong@claude