Skip to content

fix(pm): dispatch-gates and check-self-test-wired discover package-local gate invocations by their real path (#15342) - #15415

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-15342-package-local-gate-anchoring
Sep 4, 2026
Merged

fix(pm): dispatch-gates and check-self-test-wired discover package-local gate invocations by their real path (#15342)#15415
baozhoutao merged 2 commits into
mainfrom
claude/issue-15342-package-local-gate-anchoring

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #15342

lint.yml runs one gate by a package-local path. Two tools read that corpus and both anchored on the literal scripts/; they failed in opposite directions, and both silently.

Measured first — every workflow step invoking a gate by a path NOT under scripts/

Extracted with check-self-test-wired.mjs's own collectInvocations (the extraction check-self-test-workflow-commands.mjs already imports), never a fourth parser. Over 30 workflow files and 186 named paths, the population is exactly one step:

  • lint.yml — step Relationship carriers are spelled as the string the spec declares:
    • node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test
    • node packages/lint/scripts/check-reference-carrier-shape.mjs

The only other non-root path any workflow invokes with node is packages/cli/bin/run.js, which carries no scripts/ segment and is not a gate.

(a) dispatch-gates.mjs never discovered it, and --residue could not report it either

Both DIRECT_CHECK_INVOCATION and SELF_TEST_INVOCATION required node to be followed immediately by scripts/, so neither matched the step at all. A family that is never discovered is absent from the universe the matched / silent / undetermined buckets partition — the #11397 state, one lane over. Measured on 65846bc46, for a card editing that gate's own file:

  • --commands names it 0 times (26 commands derived, none of them this gate);
  • all 27 --residue mentions are the dev's own changed path echoed back as an INPUT (matched via packages/lint/… ⇢ gate source 'packages/**'), never as a family;
  • the step surfaces only in the always-runs STEP tail, as one of the 33 unconditional CI steps the derivation names no family for.

(b) check-self-test-wired.mjs keyed it to a root path with no file behind it

Its INVOCATION_RE opened on the bare literal scripts/ with nothing to its left, so it matched the package-local path as a substring and filed the gate under scripts/check-reference-carrier-shape.mjs — a key with no file behind it. Both directions were silent: the real file is outside the root walk so it was in no population and never audited, and the phantom key could never be reconciled against a carrier either, so neither auditPopulation nor auditLedger had anything to say. Measured before: 186 named paths, exactly 1 with no file on disk.

The fix — minimal and additive, at both anchors

scripts/pm/dispatch-gates.mjs. Both patterns gain the same directory prefix, spelled identically. A prefix segment must start with an alphanumeric or _, so .. is refused: a path this ROOT cannot resolve is how a phantom identity key gets minted, and resolveCheckToFiles' docblock prices that outcome as strictly worse than the bug being fixed. A scripts/ segment is still required, so the prefix widens the DIRECTORY a gate may sit under and never the species of file.

scripts/check-self-test-wired.mjs. INVOCATION_RE consumes the prefix into the key and gains a left boundary, so a path is keyed WHOLE; a leading ./ is stripped (cut-rc.yml and release.yml import from "./scripts/…", and keying those elsewhere would drop two live invocations). The population gains a second source — a script CI NAMES that lives outside the root walk — admitted on the root walk's own terms: the file must exist and its CODE must carry the literal. Fixing the key alone would have left that half exactly as silent as before. After: 186 named paths, zero without a file on disk; carriers 182 → 183, members 168 → 169, wired 164 → 165.

Its two skip predicates are membership in the root walk's own output, not startsWith('scripts/'). That re-spelling is a bare top-level word wearing a separator: it reaches the dispatch derivation's hint set as the plain literal scripts and joined the shrink-only escapable-literal species FRESH on both of this gate's families (caught by dispatch-gates --self-test, and confirmed by diffing the extracted hint sets base vs branch). The set form says what the predicate means and declares nothing.

The A/B

For a card touching packages/lint/scripts/check-reference-carrier-shape.mjs, --commands --repo objectstack-ai/objectstack:

base    26 commands, `check-reference-carrier-shape` named 0 times (stdout and stderr)
branch  30 commands, first two lines:
          node packages/lint/scripts/check-reference-carrier-shape.mjs
          node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test
        (+ node scripts/pm/bare-root-worklist.mjs --self-test and pnpm check:pm-dispatch-gates,
         which the card now incurs because its file became a GATE SCRIPT)

The gate declares a populationROOT_DIR_WATCH_HINTS = ['packages/**', 'examples/**', 'scripts/**', 'apps/**'] — so it does not land in the residue. It reaches cards by that declared population as well as by identity: a card touching packages/spec/src/data/field.zod.ts now derives both of its invocations too.

Priced in both directions over 7472 tracked files:

check families discovered        252 -> 254       (+2, ZERO lost)
gate files                       201 -> 202       (+1, ZERO lost)
watch-hint (gate, file) pairs  240947 -> 254181   (+13234, ZERO lost)
existing families re-attributed  0 — every pre-existing family's pair count is
                                 byte-identical before and after
always-runs steps naming NO family  33 -> 32 (18 -> 17 distinct commands)

The +13234 is exactly 2 x 6617: four subtree hints, two invocations, two keys — the #14880 split working as designed. Becoming a GATE FILE is the one direction that could SUBTRACT (discoverFamilies excludes gate files from import-following), so it is measured rather than argued: no existing family's pair count moved either way.

SELF_TEST_INVOCATION gains zero recall today — the one specimen carries a check- basename, so the direct matcher takes it and the self-test matcher skips it by design. It is widened anyway because the two matchers' coordination contract is that they mint byte-identical keys for one script; a package-local gate not named check-* would otherwise be discovered by neither. Same standard as the extension right-boundary in resolveCheckToFiles: zero recall today, zero cost today, class closed before the first specimen arrives.

Self-tests

check-self-test-wired: two new batteries, left boundary (6) and live corpus (3) — 50 → 59 cases, battery floor 7 → 9. No existing case weakened.

dispatch-gates: eight fixture cases plus two live pins. Two existing always-runs-tail cases pinned the defect as a property (a package-local gate invoked by path IS in the tail, and its live twin). Neither is weakened: each is inverted and given a positive control, so the step's absence from the tail cannot go green through a tail that stopped walking. The tail's own class assertion — a step the derivation names nothing for IS listed — is untouched and still held by the other-interpreter member, on the fixture and on the live tree.

Ablation, restore proven

The mutation put both patterns back to the pre-#15342 spelling and was confirmed on disk before any reading (widened-prefix occurrences 2 → 0; base-spelled 3, being the two patterns plus one prose mention in the new battery comment). Restore is git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap, verified by blob hash against git rev-parse HEAD:PATH and an empty git diff HEAD, not by an exit code.

Each case's own predicate, same inputs and same operators as the shipped assertions, against the base anchoring — 7 red, 6 controls green:

FAIL  1 package-local gate discovered at all
FAIL  2 bare production invocation is the second family
FAIL  3 both keyed by the REAL path
PASS  4 control: no phantom root key minted
PASS  5 control: climbing spelling refused
FAIL  6 self-test matcher carries the same prefix grammar
PASS  7 control: non-scripts path admitted by neither
PASS  8 control: root-spelled key byte-identical
FAIL  9 live corpus carries the package-local lane
PASS 10 every live direct invocation resolves to a file that EXISTS
FAIL 11 REPAIRED fixture: package-local step has left the tail, and the derivation names it
FAIL 12 REPAIRED live: instance has left the live tail, and the derivation names a family for it
PASS 13 the tail CLASS is unweakened: another-interpreter member still listed

The same probe against the shipped tree is 13/13, matching the shipped self-test's own verdict. Case 10 passing on both is correct and stated: on the base tree nothing package-local is discovered, so there is no phantom to catch — it is a class pin, not an instrument for this anchor.

Verification

Exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?), verdict lines quoted from the tools' own output. Run at 2c22f5716.

  • node scripts/pm/dispatch-gates.mjs --self-test (foreground, shared verify lock) — ✓ dispatch-gates self-test: 1402 cases pass., os-verify-lock: VERDICT command-exit 0 · held the lock 385s. pnpm check:pm-dispatch-gates is the wrapper that spawns exactly this command.
  • node scripts/check-self-test-wired.mjs✓ … every one of the 169 script(s) CI runs that ship a --self-test has that self-test run by CI (scope: 183 carriers, 1 of them package-local gate(s) CI names by path).
  • node scripts/check-self-test-wired.mjs --self-test9 declared batteries, 59 cases registered, every battery at or above its pinned floor.
  • The derived family for this change, --changed --commands --repo objectstack-ai/objectstack — 25 commands, every one run, all exit 0.
  • Always-runs tail: check-self-test-workflow-commands.mjs and --self-test, check:declared-population-live (203 of 254 famil(ies) declare a path population, and every one of them reaches this tree's 7472 tracked file(s)), check:ratchet-remedy-authority, check:nul-bytes, check:watch-hint-literal — all exit 0.
  • Whole-repo pnpm lint (eslint . --no-inline-config) — exit 0.

Out-of-scope finding filed while measuring: #15414 — check-self-test-workflow-commands builds its own root-only population, so the same package-local gate is in no sweep there either. Not touched here.

No changeset: this publishes nothing from any released package (scripts/ tooling only), so the skip-changeset label carries it.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

`check-self-test-wired` anchored `INVOCATION_RE` on the bare literal
`scripts/`, so lint.yml's `node packages/lint/scripts/check-reference-
carrier-shape.mjs --self-test` matched as a SUBSTRING and was filed under
`scripts/check-reference-carrier-shape.mjs` — a key with no file behind it.
Both directions were silent: the real file sits outside the root walk, so it
was in no population and never audited, and the phantom key could never be
reconciled against a carrier either.

Two anchors move, and only these:

  - `INVOCATION_RE` consumes the directory prefix into the key and gains a
    left boundary, so a path is keyed WHOLE. A leading `./` is stripped
    (cut-rc.yml and release.yml import from `"./scripts/…"`, and keying those
    elsewhere would drop two live invocations); `..` is not a prefix segment,
    because a path this ROOT cannot resolve is how the phantom was minted.
  - the population gains a second source: a script CI NAMES that lives outside
    the root walk, admitted on the root walk's own terms (must exist, must
    carry the literal in code). Not a second walk — see the comment in main().

Measured on the live corpus: 186 named paths, 0 without a file on disk
(before: exactly 1). Carriers 182 -> 183, members 168 -> 169, wired 164 -> 165.

Self-test grows two batteries — `left boundary` (6) and `live corpus` (3);
50 cases -> 59, floor 7 -> 9. No existing case is weakened.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…eal path (#15342)

`lint.yml` runs one gate by a package-local path — `node packages/lint/
scripts/check-reference-carrier-shape.mjs --self-test`, plus the bare
production invocation on the next line. Both tools that read that corpus
anchored on the literal `scripts/`, and failed in opposite ways.

`dispatch-gates` required `node ` to be followed IMMEDIATELY by `scripts/`,
so neither `DIRECT_CHECK_INVOCATION` nor `SELF_TEST_INVOCATION` matched the
step at all: the gate was in NO family, and `--residue` could not report it
either — it is absent from the universe the matched / silent / undetermined
buckets partition. Measured on `65846bc46` for a card editing that gate's own
file: `--commands` named it zero times, all 27 `--residue` mentions were the
dev's own changed path echoed back, and the step surfaced only in the
always-runs STEP tail, as one of the 33 CI steps the derivation names no
family for.

Both patterns gain the same directory prefix, spelled identically. A segment
must start with an alphanumeric or `_`, so `..` is refused — a path this ROOT
cannot resolve is how a phantom identity key gets minted. A `scripts/` segment
is still required, so no new species is admitted (`node packages/cli/bin/
run.js` stays out). Over 7472 tracked files:

  check families discovered        252 -> 254       (+2, ZERO lost)
  gate files                       201 -> 202       (+1, ZERO lost)
  watch-hint (gate, file) pairs  240947 -> 254181   (+13234, ZERO lost)
  existing families re-attributed  0 — every pre-existing family's pair
                                   count is byte-identical before and after
  always-runs steps naming NO family  33 -> 32

The gate declares four subtree hints, so it lands in the derivation for cards
touching `packages/**`, `examples/**`, `scripts/**` and `apps/**`, and by
identity for a card editing its own file — not in the residue.

Two existing tail cases pinned the defect as a property. Neither is weakened:
each is inverted and gains a positive control, so the step's absence from the
tail cannot go green through a tail that stopped walking.

check-self-test-wired keyed the same path by SUBSTRING, filing it under
`scripts/check-reference-carrier-shape.mjs` — a key with no file behind it,
audited by nobody. `INVOCATION_RE` consumes the prefix and gains a left
boundary; the population gains a second source, admitted on the root walk's
own terms. Its two new skip predicates are membership in the walk's OWN
OUTPUT rather than `startsWith('scripts/')`: that re-spelling reaches the
dispatch derivation as the bare literal `scripts` and joined the shrink-only
escapable-literal species FRESH on both of this gate's families.

Self-tests grow four batteries: `left boundary` (6) and `live corpus` (3) in
check-self-test-wired (50 -> 59 cases, floor 7 -> 9), and eight fixture cases
plus two live pins in dispatch-gates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants