Skip to content

feat(pm): dispatch-gates derivation follows first-party imports - #11512

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11190-dispatch-gates-follow-imports
Aug 24, 2026
Merged

feat(pm): dispatch-gates derivation follows first-party imports#11512
os-steve merged 1 commit into
mainfrom
claude/issue-11190-dispatch-gates-follow-imports

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11190

Step 1 only, as dispatched. The derivation now follows first-party imports. The thirteen private pnpm-workspace.yaml parsers are not consolidated here — that is step 2, filed as #11510. This PR is what makes step 2 safe: doing 2 without 1 is a regression with no red gate.

The mechanism

resolveCheckToFiles reads a family's script paths out of the npm script's command string, and discoverFamilies scanned exactly those files. A module a gate imports was never opened, so a population declaration moved out of a gate and into a shared module stopped contributing hints to every gate that imports it — with every gate still green and nothing in the output saying so.

firstPartyImportTargets(scriptPath, source) resolves a gate script's relative static import specifiers one level down, inside the repo's own scripts/ tree, never into node_modules, and never into a module that is itself a discovered gate file. discoverFamilies appends the hints of those modules after every own hint, and records which module contributed each one in entry.hintOrigin, which coveringKey prints in the via column:

- pnpm check:i18n [lint.yml] matched via packages/cli/src/commands/serve.ts
⇢ gate source via scripts/cli-build-prerequisite.mjs 'packages/cli'

The measurement — the deliverable, not the code

Corpus: 140 discovered families × 6460 tracked files, swept before and after on this branch's tree, the way #9626 and #10540 did.

beforeafterdelta
watch-hint (gate, file) pairs5184852741+893
matched pairs, any key (identity / CI trigger / hint)6474265633+891
families gaining coverage6+6
families losing coverage00
existing matches re-attributed (key or via changed)00
undetermined bucket45450
hints admitted32+32

For scale, hintCovers' docblock prices the bare-top-level-word admission it refuses at +139084 pairs on the same corpus. Following imports is 0.6% of that trade.

Per-family matched counts, every family that moved:

familybeforeafterinherited from
check:i18n3288scripts/cli-build-prerequisite.mjs (packages/cli, packages/cli/bin/run.js)
check:i18n-coverage1286same
check:merge-driver12411505scripts/regen-artifacts.mjs — 20 hints, the artifacts the driver regenerates
check:adr-anchors130183scripts/adr-anchors.mjs (scripts/adr-anchors)
check:slot-lookup50675070scripts/eslint-fatal-guard.mjs, scripts/eslint-stack-headroom.mjs
check:query-options-erasure25same

Zero families gained or lost from the discovered set, and the undetermined bucket is unmoved — every family that gained already had hints of its own, so none left the honest bucket.

Every narrowing is a measurement, not a preference

refusalmeasured cost of admitting it
dynamic import()+0 pairs. Both live dynamic edges point at modules the gate-module rule excludes anyway. Real hazard, zero gain: module bodies carry illustrative specifiers that resolve to nothing only because of the existence check.
depth 2 / depth 3+0 pairs each. Every module reached from a followed module (invoked-as.mjs, js-comment-mask.mjs, ts-parse.mjs) declares no path literal at all. One level is the mandate and the measured fixpoint.
first-party outside scripts/+2517 pairs. Live specimen: check:slot-lookup and check:query-options-erasure both import ../eslint.config.mjs, whose globs describe what the lint reads, not what either ratchet reads. Cost of the refusal today: zero — the only two family scripts outside scripts/ (packages/lint/scripts/*.mjs) carry no relative first-party import.
bare specifiersresolve through node_modules (workspace links included); an installed dependency is not a repo source input.
modules that are themselves gate files+4014 pairs (the sweep reads +4907 instead of +893). 3065 of those are check:examples-live-imports inheriting the repo-wide CROSS_PACKAGE_TEST_INPUTS declaration table from a gate it imports one string helper (globToRegExp) from. That is a fabricated lead in the column a dispatch prompt pastes — refused on provenance, the way #9964 refused an admission worth 17 pairs because 8 of them were fabricated.

The gate-module refusal costs recall in one direction and the PR states it rather than hiding it: where an importer really does read the imported gate's population (check-ci-filter-parity.mjs imports CROSS_PACKAGE_TEST_INPUTS itself, +595 pairs), that lead is now missing. A missing lead costs one card one CI round; a fabricated one is pasted into every prompt whose surface brushes it. The card is not blind either way — the imported gate's own family is discovered too and already matches those paths.

The assertion PR #11189 pinned

The card is right that this change makes it false by design. It is inverted, not deleted: the same section now asserts that imports are followed, exactly one level, and never into a gate module — so a later author still measures the constraint instead of trusting a paragraph.

⚠️ Worth recording, because it changes how the replacement is written: the old pin did not go red on this change. It selected its specimen as "the first single-file family that imports a sibling". Measured on this tree, 80 families answer that description, only 3 of them would have failed the assertion, and the one iteration order actually picks — scripts/check-adr-links.mjs, importing invoked-as.mjs, which declares no path at all — is not among the 3. A pin whose specimen is chosen by iteration order can be true of the tree and silent about the rule. Every replacement case therefore names its specimen with a count.

Reverse verification

Three ablations, each proving the mutation landed on disk with anchored single-line greps (never an editor's exit code), each restored from the commit by an EXIT INT TERM trap:

ablationself-test result
the follow returns nothing5 cases red — the fixture recogniser, the live inherit count, the depth-bound non-vacuity, the gate-module-edge non-vacuity, and the inherited-lead case
the follow made transitive (one extra hop)1 case red — "one level only", naming the offending families
the gate-module exclusion removed2 cases red — the hint-set reconstruction and the exclusion case

Reported honestly: under ablation 1 the reconstruction case stays green, because it recomputes the expected set from the same (ablated) function — it pins what discoverFamilies does with the follow, not the follow itself, which is why the fixture cases sit beside it. And under ablation 2 the reconstruction also stays green, because today's depth-2 modules declare nothing — which is exactly why the depth bound is pinned on entry.imports rather than on the hint set.

Verification

All at 538517ab, exit codes captured before any pipe, each gate quoted by its own verdict line:

  • pnpm check:pm-dispatch-gates✓ dispatch-gates self-test: 564 cases pass. (557 before; +9 cases, −2 replaced)
  • pnpm check:cross-package-test-inputsOK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
  • pnpm check:entry-guard✓ check:entry-guard: 139 scripts/ file(s) — every entry guard goes through invoked-as.mjs; …
  • pnpm check:parse-guard✓ check:parse-guard: 138 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
  • pnpm check:pnpm-filter-targets✓ check:pnpm-filter-targets: 120/148 --filter occurrence(s) across 25 file(s) resolve against 78 workspace package(s); …
  • node scripts/check-ci-filter-parity.mjsOK: all 89 declared cross-package glob(s) (77 unique) are covered by core or crosspkg, …
  • pnpm check:nul-bytescheck-nul-bytes: OK (scanned 6455 text file(s) …; no raw ASCII control bytes).

Gate set derived, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths — off the merge base) named exactly these seven; check:nul-bytes was added on top for the any-edit rule.

Declared narrowing — lint. The repo-wide eslint . --no-inline-config run is CI's; here it is narrowed to the one changed file, and the narrowing is measured rather than asserted: (1) the population comes from eslint's own configuration, which this repo runs as a single eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — measured in that file's own header with a positive control), so a one-file diff cannot move an untouched file's verdict; (2) the count is read from --format json: 1 file linted, 0 errors, 0 warnings, exit 0; (3) eslint.config.mjs imports nothing from scripts/, so this diff cannot change the config or the linted set.

No changeset: this PR touches one internal PM tool (scripts/pm/dispatch-gates.mjs) and publishes nothing. Labelled skip-changeset.

Adjacent, checked and NOT touched

The card's check:cross-package-test-inputs double discovery (pnpm check:x and node scripts/check-x.mjs keyed separately) is unchanged by this PR, measured: neither family gains a single hint, because that script's only first-party import is invoked-as.mjs, which declares nothing. It stays on #11199.

One finding the sweep produced, filed as #11511 and not fixed here: check:ci-filter-parity imports the CROSS_PACKAGE_TEST_INPUTS table it genuinely reads out of a gate script, so the gate-module refusal costs it a real 595-pair lead. The remedy is the same module shape #11510 needs.

Re-derived on today's tree, since both earlier figures were dated: 15 families across 13 distinct scripts name pnpm-workspace.yaml (#10542 measured twelve at 119 families; the card measured fifteen at 137; the farm is 140 today).


Generated by Claude Code

`resolveCheckToFiles` reads a family's script paths out of the npm script's
command string and `discoverFamilies` scanned exactly those files, so a module
a gate imports was never opened: a population declaration moved out of a gate
and into a shared module stopped contributing hints to every gate that imports
it, with every gate still green and nothing in the output saying so.
`firstPartyImportTargets` resolves the relative static import specifiers of a
gate script one level down, inside the repo's own scripts/ tree, never into
node_modules and never into a module that is itself a discovered gate file.
Hints from a followed module are appended after every own hint, so no existing
match changes key or provenance, and `entry.hintOrigin` carries which module
contributed each inherited hint into `coveringKey`'s via column.
Measured over 140 discovered families x 6460 tracked files:
watch-hint (gate, file) pairs 51848 -> 52741 (+893, ZERO lost)
families gaining coverage 6
existing matches re-attributed 0
Every refusal carries its own measurement in the docblock: following gate
modules too costs +4907 instead of +893 (3065 of the difference is one family
inheriting a repo-wide declaration table from a gate it imports one string
helper from), reaching outside scripts/ costs +2517 more, dynamic import()
adds +0, and depth 2 and depth 3 add +0.
The self-test's pin of the old constraint is inverted rather than deleted, and
every case now names its specimen with a count: measured, the old pin would
NOT have gone red on this change, because it picked its specimen by iteration
order and landed on a family whose imported module declares no path at all.
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 02:27
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit c96cbfcAug 24, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11190-dispatch-gates-follow-imports branch August 24, 2026 02:36
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

2 participants

@os-steve@claude