Skip to content

fix(pm): let a followed module declare what a gate inherits by importing it - #12346

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-11556-followable-non-gate-module
Aug 25, 2026
Merged

fix(pm): let a followed module declare what a gate inherits by importing it#12346
yinlianghui merged 3 commits into
mainfrom
claude/issue-11556-followable-non-gate-module

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11556

scripts/pm/dispatch-gates.mjs is importable and is not a discovered gate file — check:pm-dispatch-gates resolves to check-dispatch-gates.mjs, which reaches the tool by spawnSync — so discoverFamilies' "never open a module that is itself a gate file" rule does not cover it. A gate that imports it inherits its module-body literals as watch hints, and until now the only thing standing between that and a dispatch prompt was prose in one caller's header (#8162): a convention held by the caller that remembered, not a property of the module.

Remedy chosen, and why

The card offered three shapes and asserted none. This is shape 3, done by declaration rather than by heuristic — the module carries a marker naming the one population a follower inherits:

// dispatch-gates: inherited-population PATH [PATH ...] -- REASON

read fresh on every run by the follow (hintsOfModule), in the same idiom as the two markers already in this file (declaredNoCheckFamiliesReason, declaredNoPathPopulation) — "a marker the thing carries, never a roster in this script".

Narrowing only. Every declared path is checked against extractWatchHints of the same source and refused (throws) if the module does not spell it. A declaration can therefore only ever remove leads a caller would otherwise inherit, never invent one — an opt-out that could also opt in would be the hand-written path map this file's contract exists to refuse.

Why not shape 1 (respell the literals as unslashed halves, the DEFAULT_BASE_REF idiom): it protects exactly the literals someone remembered to respell — the same failure mode one level down — and it would have to mangle MANDATORY_TIER_GLOBS/SUSPECT_TIER_GLOBS, a machine-readable copy of a maintainer ruling whose readability is the point. Shape 2 (extract a plain module) is a file-surface change and was not built here; #11511/#11510 still want it and this composes with, rather than preempts, that move.

Measured — every term re-derived together, on one named tree

The card's absolute numbers were stale (2553 of 6465). Restated on c48d46d70a / 2d5d0fc300, 6840→6841 tracked files:

beforeafter
literals a gate inherits by importing the tool91
tracked files those cover266028
this file's own watch-hint set99 (added [], removed [])
discovered families / (gate, file) pairs173 / 70013173 / 70013
inherited hints across the live tree350350
modules in scripts/ carrying the marker01 (this one)

The 2632 pairs removed are packages/plugins, packages/drivers, packages/services (the package-manifest join bases discoverFamilies builds paths from) and packages/spec/src/** plus the three tier-table file globs — none a population any caller opens. The 28 that survive are the workflow directory the tool really readdirs.

Driven through hintCovers, not collapseHint#12300's globInNonFinalSegment branch short-circuits before collapseHint, so probing that function would have reproduced old readings under a false reachability claim. Measured for the record: none of the nine hints has a glob in a non-final segment.

Live inheritors swept (positive control: the same search shape returns many hits for js-comment-mask.mjs): scripts/pm/bare-root-worklist.mjs is still the only static importer, and it is a --self-test family, so it inherits nothing today. Nothing loses a lead in this change — the fabrication was latent, and the gate now prints its counterfactual price instead of leaving it in prose.

This file judges the derivation it changes — both-ways derivation

BASE tool (origin/maincf99875ea8) and HEAD tool run over the identical ten input paths in the same tree, output compared byte-for-byte:

plain BYTE-IDENTICAL 25130 bytes / 113 lines
--residue BYTE-IDENTICAL 49355 bytes / 304 lines
--tier BYTE-IDENTICAL 1764 bytes / 7 lines

The union that judges this PR does not move.

Gates — derived at the final commit 2d5d0fc300

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no STALE TREE), 11 matched families, every exit code captured before any pipe:

check:agent-test-spelling EXIT=0
check:bash32-floor EXIT=0
check:cli-command-ids EXIT=0 ✓ 276 command-id literal(s) … all resolve
check:cross-package-test-inputs EXIT=0 OK: 17 package(s) read outside themselves, all declared
check:entry-guard EXIT=0 ✓ 168 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:parse-guard EXIT=0
check:pm-dispatch-gates EXIT=0 ✓ dispatch-gates self-test: 664 cases pass. (642 on base)
check:pnpm-filter-targets EXIT=0
scripts/check-ci-filter-parity EXIT=0 OK: all 102 declared cross-package glob(s) … covered
scripts/check-cross-package-test-inputs EXIT=0
scripts/check-self-test-wired EXIT=0
check:nul-bytes EXIT=0 scanned 6834 text file(s); no raw ASCII control bytes
node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0 46 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) — none stale, none missing (byte-identical to base)
eslint . --no-inline-config EXIT=0 (repo-wide, 83s under the shared verify lock — not a narrowed run)

check-ci-filter-parity first reported PREREQUISITE NOT MET (no yaml in a fresh worktree). That is not measured; the row above is the real run after pnpm install.

Ablation — both new invariants proven able to fail

Both mutations confirmed on disk (marker-line count, not an editor's exit code), restored under trap … EXIT INT TERM, restore proven byte-identical with git hash-object (97e68e68… before and after). No build/dist leg: the tool is source-resolved, run directly by node.

  • A — delete the live marker line (live-marker lines: 1 → 0): gate EXIT=1, 7 of 664 cases fail, and the case names print the restored fabrication verbatim:
    ✗ a gate that IMPORTS the same modules inherits 9 of those 9 literal(s) ·
    ✗ and the price of that import drops from 2660 tracked files to 2660.
  • B — widen the declaration with a path the module does not spell (packages/objectql/src): gate EXIT=1 with
    Error: dispatch-gates: inherited-population declares 1 path(s) this module does not spell: packages/objectql/src — the declaration may only NARROW what a caller inherits, never invent it.

A first attempt at B was refused by its own anchor assertion (the string occurs twice — live marker and docblock example) and therefore never landed on disk; that run measured nothing and is not counted. The second commit here reads the live declaration through ?. so that deleting the marker renders as named failing cases instead of a TypeError that aborts the run and hides every case after it.

Changeset

None. scripts/** is repo-internal PM tooling: it is in no package's published files, and nothing in this diff changes a package's runtime behaviour, exports, or docs — there is no user-visible change for a release note to describe. skip-changeset applied. ⚠️ Applied by the fallback route (read → union → whole-set write, union size/m + skip-changeset): the additive POST .../labels endpoint is not reachable from this seat — raw api.github.com returns "GitHub access is not enabled for this session" and no gh CLI exists here. Read-back is reported below.

Scope

One file: scripts/pm/dispatch-gates.mjs (+ its --self-test), the declared surface. #12064, #12074, #12046, #12173 and #12299 are held serial behind this claim and are not addressed here; #11511 and #11510 remain open.


Generated by Claude Code

…ing it
`scripts/pm/dispatch-gates.mjs` is importable and is not a discovered gate
file — `check:pm-dispatch-gates` resolves to `check-dispatch-gates.mjs`, which
reaches the tool by `spawnSync` — so `discoverFamilies`' "never open a module
that is itself a gate file" rule does not cover it. A gate that imports it
inherits its module-body literals as watch hints: measured on c48d46d over
6840 tracked files, nine literals covering 2660 of them, of which exactly one
(the workflow directory the tool readdirs, 28 files) is a population the module
opens. The other 2632 pairs are package-manifest join bases and tier globs.
The only thing standing between that and a dispatch prompt was prose in one
caller's header (#8162) — a convention held by the caller that remembered, not
a property of the module. This adds a marker the MODULE carries:
// dispatch-gates: inherited-population <path> [<path> ...] -- <reason>
read fresh on every run by the follow, and held to a SUBSET of what the module
really spells: a declared path the module does not spell is refused, so the
marker can only ever narrow what a caller inherits, never invent it.
Measured, on this tree:
a gate importing the tool inherits 9 literals / 2660 files -> 1 / 28
this file's own watch-hint set 9 before, 9 after (added [], removed [])
derivation output over 10 paths byte-identical, plain/--residue/--tier
families / (gate, file) pairs 173 / 70007 before and after
modules declaring the marker 1 (this one), pinned live
Nothing inherits from the module today, so no existing card loses a lead; the
gate now prints the counterfactual price rather than leaving it in prose.
@yinlianghuiyinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026 — with Claude
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review of #11556.

The property the whole design rests on, verified in the diff

A declaration that could add a path would be the hand-written path map this file's contract exists to refuse. So I checked the refusal is real code, not a stated intention:

+ `dispatch-gates: inherited-population declares ${invented.length} path(s) this module does not spell: `
+ `${invented.join(', ')} — the declaration may only NARROW what a caller inherits, never invent it`
+ 'a declared path the module does not spell is REFUSED, not silently inherited',
+ return /may only NARROW/.test(String(e.message));

Checked against extractWatchHints of the same source, with a self-test case asserting the throw. An opt-out that cannot opt in — that is the whole difference between this and a roster.

The sole-inheritor sweep, re-run with a control

static importers of ./dispatch-gates.mjs under scripts/ : 2
scripts/pm/bare-root-worklist.mjs
scripts/pm/dispatch-gates.mjs ← the file itself (its own marker docblock)
CONTROL — importers of js-comment-mask.mjs : 35

One genuine external importer, and the control returns 35 where importers exist, so the low count is a fact about this module rather than a broken search. Matches your reading — and since that one caller is a --self-test family, nothing loses a lead in this change. The fabrication was latent, and it is now priced by the gate instead of described in a header.

⭐ You drove the right function, and closed the gap I left open

Zone 2c warned that probing collapseHint reproduces old readings under a false reachability claim, and that my own "probably does not move them" was not a measurement. You drove hintCoversand measured the thing I hand-waved:

Measured for the record: none of the nine hints has a glob in a non-final segment.

That is the difference between inheriting my guess and closing it.

The two instrument details that make the rest trustworthy

Both-ways derivation, three modes, byte-identical (plain, --residue, --tier). This file judges the union that judges this PR, so without that leg every number below it would be self-referential.

The ?. change is the subtle one and I want it on the record. The first version made deleting the marker throw a TypeError that aborted the run; the second renders it as named failing cases. Those are not the same red: a mutation that crashes the harness proves nothing about the cases it never reached, and it hides every case after it. Ablation A's value is precisely that it names what comes back —

✗ a gate that IMPORTS the same modules inherits 9 of those 9 literal(s)
✗ and the price of that import drops from 2660 tracked files to 2660

— the restored fabrication printed verbatim, which is a much stronger artifact than an exit code.

Ablation B's first attempt was refused by its own anchor assertion (the string occurs twice — live marker and docblock example) and never landed on disk, so it measured nothing and you did not count it. That is the third self-caught instrument failure across three devs this hour, and all three were caught by the same habit: checking why a reading came back, not just that it did.

On the shape chosen

Shape 3 by declaration, with shape 1 declined for a reason I accept: respelling literals protects exactly the ones someone remembered to respell — the same failure mode one level down — and it would have to mangle MANDATORY_TIER_GLOBS/SUSPECT_TIER_GLOBS, whose readability as a machine-readable copy of a maintainer ruling is the point. Leaving shape 2 to #11511/#11510 rather than preempting it is right; ⛔ that is a file-surface change and was correctly not built here.

9 literals → 1, 2660 tracked files → 28, with the survivors being the workflow directory the tool really readdirs. Every term re-derived together on one named tree, and the card's stale 2553/6465 restated rather than quoted.

Landing conditions

  1. Lint & Repo Gates reports completed + success, read by name.
  2. Nothing else. [finding] bare-root-worklist prints REACHABLE beside a REFUSE-* reason when a gate takes the escape, and --self-test stays green — the contradiction lands silently #12064 · [finding] "A new gate that assembles a root must record a bare-root verdict" is not derivable — two independent new gates hit the identical CI red within one hour #12074 · [finding] A turbo.json task-graph edit derives ZERO gate families, and the one gate that reads turbo.json structurally only inspects #test tasks #12046 · [finding] A PR's local gate family cannot contain a gate that landed on main after its branch was cut — and one CI job's first failure hides every gate behind it #12173 · extractWatchHints strips a module-relative literal's own depth, so unreachableReason prints false "never was a repo path" rows for files the gates really read — residue-side repair, shape pending the #12371 ruling #12299 stay serial behind this claim and are correctly untouched.

Generated by Claude Code

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

@yinlianghui@claude