Skip to content

Commit 9646cdb

Browse files
yinlianghuiclaude
andauthored
fix(pm): let a followed module declare what a gate inherits by importing it (#12346)
* fix(pm): let a followed module declare what a gate inherits by importing 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. * test(pm): read the live declaration through ?. so deleting the marker names a case instead of aborting the run --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6c1e21 commit 9646cdb

1 file changed

Lines changed: 293 additions & 12 deletions

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 293 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,25 @@ export { isExtractConfigPath, isMetadataFormModulePath };
182182

183183
const ROOT = new URL('../..', import.meta.url).pathname;
184184

185+
// ── What a gate that IMPORTS this module inherits (#11556) ─────────────────
186+
//
187+
// This module is importable and is NOT a discovered gate file — `check:pm-dispatch-gates`
188+
// resolves to `check-dispatch-gates.mjs`, which reaches the tool by `spawnSync`, so the
189+
// follow's "never open a module that is itself a gate file" rule does not cover it. A gate
190+
// that imports it therefore inherits its module-body literals as watch hints. Measured on
191+
// c48d46d70a, over 6840 tracked files: nine literals, covering 2660 of them. Exactly ONE is
192+
// a population this file opens (the workflow directory `discoverFamilies` readdirs, 28
193+
// files); the other eight are the package-manifest join bases `discoverFamilies` builds
194+
// paths FROM and the tier globs `MANDATORY_TIER_GLOBS`/`SUSPECT_TIER_GLOBS` declare — 2632
195+
// pairs of population no caller reads.
196+
//
197+
// Until now the only thing standing between that and a dispatch prompt was prose in ONE
198+
// caller's header (`check-dispatch-gates.mjs`, #8162): a convention held by the caller that
199+
// remembered, not a property of this module. The line below makes it the module's own
200+
// declaration, read fresh on every run and held to a subset of what this file really spells
201+
// — see `declaredInheritedPopulation`.
202+
// dispatch-gates: inherited-population .github/workflows -- the workflow directory this tool readdirs; every other module-body literal here is a package-manifest join base or a tier glob, not a path this file opens (#11556)
203+
185204
// ---------------------------------------------------------------------------
186205
// Extraction — pure functions over file contents, self-testable offline.
187206
// ---------------------------------------------------------------------------
@@ -662,6 +681,84 @@ export function declaredNoPathPopulation(scriptSource) {
662681
return m ? m[1].trim() : null;
663682
}
664683

684+
/**
685+
* A FOLLOWED MODULE's own declaration of which of its module-body literals are
686+
* a population a gate INHERITS by importing it — a whole-line comment anywhere
687+
* in the module's source:
688+
*
689+
* // dispatch-gates: inherited-population <path> [<path> ...] -- <reason>
690+
* # dispatch-gates: inherited-population <path> [<path> ...] -- <reason>
691+
*
692+
* ## What it is for (#11556)
693+
*
694+
* `firstPartyImportTargets` opens a gate's first-party imports and appends the
695+
* imported module's hints to the gate's own, because a population MOVED out of
696+
* a gate and into a shared module must not stop being declared. That is right
697+
* for a module whose literals ARE a population. It is wrong for a module whose
698+
* literals are join bases it builds paths from, or a declaration table it
699+
* exports for some other purpose: the importer never opens those trees, so
700+
* every pair they contribute is a fabricated lead in the column a dispatch
701+
* prompt pastes.
702+
*
703+
* The follow already refuses one case of this — a module that is itself a
704+
* discovered gate file (firstPartyImportTargets' docblock carries the +4014
705+
* measurement that decided it). That refusal keys on a property the derivation
706+
* can SEE. This marker is for the case it cannot see: an ordinary module,
707+
* followable by construction, whose author knows which of its literals a caller
708+
* would be reading and which it would not.
709+
*
710+
* ## Why a marker IN the module, never a roster in this script
711+
*
712+
* Same reason as the two markers above: a roster here is a second copy of a
713+
* fact that belongs on the thing it describes, and it rots silently — the
714+
* module grows a real population, or is renamed, and the roster keeps vouching
715+
* for it. It is also the exact failure this card was filed against: the guard
716+
* that existed was prose in one CALLER, so it protected that caller and no
717+
* other. A declaration the module carries protects every caller, including the
718+
* one written next year.
719+
*
720+
* ## Narrowing only — a declaration can never INVENT a population
721+
*
722+
* Every declared path must be one the module's own source really spells: the
723+
* declaration is checked against `extractWatchHints` of that same source and
724+
* REFUSES (throws) on a path that is not there. So the marker can only ever
725+
* remove leads a caller would otherwise inherit, never add one — an opt-out
726+
* that could also opt IN would be a hand-written path map, which is the drift
727+
* this file's whole contract refuses. A marker carrying no path at all does not
728+
* parse as a declaration — it reads as no marker, so the module keeps
729+
* contributing everything it spells. That is the safe direction: a blanket
730+
* "inherit nothing" reads identically to a placeholder nobody will revisit, and
731+
* a module with no literals needs no marker to contribute none.
732+
*
733+
* The reason is REQUIRED, and separated from the path list by a SPACE-delimited
734+
* `--`: a bare `--` would split a path that legitimately contains one.
735+
*
736+
* Returns `{ population, reason }`, or null when the module declares nothing.
737+
*/
738+
const INHERITED_POPULATION_MARKER =
739+
/^[ \t]*(?:\/\/|#)[ \t]*dispatch-gates:[ \t]*inherited-population[ \t]+(\S.*?)[ \t]+--[ \t]+(\S.*)$/m;
740+
741+
export function declaredInheritedPopulation(moduleSource, hints = null) {
742+
const source = String(moduleSource);
743+
const m = INHERITED_POPULATION_MARKER.exec(source);
744+
if (!m) return null;
745+
// The path list is non-empty by construction: the marker pattern requires a
746+
// non-space before the ` -- `, so a marker carrying only a reason does not
747+
// parse as a declaration at all — it reads as no marker, which is the safe
748+
// direction (inherit everything) rather than a silent blanket opt-out.
749+
const population = m[1].trim().split(/[ \t]+/).filter(Boolean);
750+
const reason = m[2].trim();
751+
const spelled = new Set(hints ?? extractWatchHints(source));
752+
const invented = population.filter((h) => !spelled.has(h));
753+
if (invented.length > 0) {
754+
throw new Error(
755+
`dispatch-gates: inherited-population declares ${invented.length} path(s) this module does not spell: ` +
756+
`${invented.join(', ')} — the declaration may only NARROW what a caller inherits, never invent it`,
757+
);
758+
}
759+
return { population, reason };
760+
}
761+
665762
/**
666763
* The workflows (by filename) that violate the #9187 coverage invariant:
667764
*
@@ -3345,17 +3442,26 @@ export function residueLines(
33453442
* ## One measured side effect of putting a path in a MODULE BODY
33463443
*
33473444
* Comment masking cannot reach a module-body string, so these globs — and the
3348-
* suspect glob below — are watch hints of this file's own source: re-measured
3349-
* after the 2026-08-20 narrowing, `extractWatchHints` yields 8 hints here
3350-
* against 4 on the base, the new ones being the four globs themselves. They are
3351-
* inert today because no check family resolves to THIS file — the gate that
3352-
* covers it is `check:pm-dispatch-gates`, which resolves to
3353-
* `check-dispatch-gates.mjs` and matches this file through that file's one
3354-
* constant. If the tool is ever wired as its own gate (a shape
3355-
* `check-dispatch-gates.mjs`'s header measures and refuses), this hint would
3356-
* start printing that gate as MATCHED for every card editing the PM skill —
3357-
* a fabricated lead. The refusal already recorded there is what keeps it inert;
3358-
* this note is so the next reader knows the cost is known, not unnoticed.
3445+
* suspect glob below — are watch hints of this file's own source. Re-measured
3446+
* on c48d46d70a over 6840 tracked files: `extractWatchHints` yields 9 hints
3447+
* here, and the four globs of these two tables cover 1026 files between them
3448+
* (1023 of that is the suspect glob's contract surface).
3449+
*
3450+
* They stay inert against a gate that RESOLVES to this file, because no check
3451+
* family does — `check:pm-dispatch-gates` resolves to `check-dispatch-gates.mjs`
3452+
* and matches this file through that file's one constant. If the tool is ever
3453+
* wired as its own gate (a shape `check-dispatch-gates.mjs`'s header measures
3454+
* and refuses), this hint would start printing that gate as MATCHED for every
3455+
* card editing the PM skill — a fabricated lead the refusal recorded there is
3456+
* what prevents.
3457+
*
3458+
* They are inert against a gate that IMPORTS this module for a second reason
3459+
* now, and that one is structural rather than remembered: the module's own
3460+
* `inherited-population` declaration (top of the module body, #11556) names the
3461+
* single population a follower inherits, and these globs are not in it. That
3462+
* closes the class rather than these four literals — a tier glob added tomorrow
3463+
* inherits nothing without someone widening the declaration, and the declaration
3464+
* cannot be widened to a path this file does not spell.
33593465
*
33603466
* The authority for the policy is the maintainer ruling quoted in the PM
33613467
* dispatch skill (2026-08-10 three-tier ruling, clause ① of its 强制条款, as
@@ -3595,7 +3701,15 @@ export function discoverFamilies() {
35953701
const moduleHints = new Map();
35963702
const hintsOfModule = (rel) => {
35973703
if (!moduleHints.has(rel)) {
3598-
moduleHints.set(rel, extractWatchHints(readFileSync(join(ROOT, rel), 'utf8')));
3704+
// ONE read, two answers — the module's literals and its own declaration of
3705+
// which of them a caller INHERITS — so the pair cannot describe different
3706+
// revisions of a file, the same discipline the trigger paths take above.
3707+
// A module that declares nothing contributes everything it spells, which
3708+
// is the behaviour every followed module had before the marker existed.
3709+
const source = readFileSync(join(ROOT, rel), 'utf8');
3710+
const spelled = extractWatchHints(source);
3711+
const declared = declaredInheritedPopulation(source, spelled);
3712+
moduleHints.set(rel, declared ? declared.population : spelled);
35993713
}
36003714
return moduleHints.get(rel);
36013715
};
@@ -4447,6 +4561,40 @@ function selfTest() {
44474561
(bareRootEntry?.hints ?? []).length === 0 && (bareRootEntry?.hintOrigin?.size ?? 0) === 0,
44484562
);
44494563

4564+
// The SECOND guard, on the same live specimen (#11556). The narrowing above
4565+
// is invocation-shaped: it holds for a `--self-test` family and nothing else,
4566+
// so a `check-` gate importing the same modules was untouched by it. What
4567+
// covers that caller is the module's OWN inherited-population declaration,
4568+
// and this measures it through the follow's rule rather than through the raw
4569+
// extractor the case above uses.
4570+
const inheritableFromImports = importedByBareRoot.flatMap((m) => {
4571+
const src = readFileSync(join(ROOT, m), 'utf8');
4572+
const spelled = extractWatchHints(src);
4573+
return declaredInheritedPopulation(src, spelled)?.population ?? spelled;
4574+
});
4575+
t(
4576+
`a gate that IMPORTS the same modules inherits ${inheritableFromImports.length} of those ${wouldHaveInherited.length} literal(s)`,
4577+
inheritableFromImports.length > 0 && inheritableFromImports.length < wouldHaveInherited.length,
4578+
);
4579+
const inhSweep = trackedFiles();
4580+
const inhCovered = (hs) => inhSweep.filter((f) => hs.some((h) => hintCovers(h, f))).length;
4581+
t(
4582+
`and the price of that import drops from ${inhCovered(wouldHaveInherited)} tracked files to ${inhCovered(inheritableFromImports)}`,
4583+
inhCovered(inheritableFromImports) < inhCovered(wouldHaveInherited),
4584+
);
4585+
// The direction that could SUBTRACT, asserted rather than argued: a
4586+
// declaration is a narrowing, and a narrowing that took the real population
4587+
// with it would read exactly like this one — fewer pairs, every gate green.
4588+
// The tool DOES readdir the workflow tree, so every file in it must stay
4589+
// reachable through what a follower inherits.
4590+
t(
4591+
'and it is not a coverage cut — every workflow file the tool really readdirs is still reachable through the declaration',
4592+
inhSweep.filter((f) => f.startsWith('.github/workflows/')).length > 0
4593+
&& inhSweep
4594+
.filter((f) => f.startsWith('.github/workflows/'))
4595+
.every((f) => inheritableFromImports.some((h) => hintCovers(h, f))),
4596+
);
4597+
44504598
// The direction that could SUBTRACT, and the reason it is asserted rather
44514599
// than argued: admitting these nine makes six previously-followable modules
44524600
// GATE FILES, and `discoverFamilies` refuses to follow a gate file. Any
@@ -6165,6 +6313,139 @@ function selfTest() {
61656313
'the marker must be its OWN line — a mention inside prose is a discussion of the convention, not a declaration under it',
61666314
declaredNoPathPopulation('// see the dispatch-gates: no-path-population -- marker for how to opt out\n') === null,
61676315
);
6316+
6317+
// ── The followed-module inherited-population declaration (#11556) ─────────
6318+
//
6319+
// The two markers above are a GATE's declarations about itself. This one is a
6320+
// followed MODULE's declaration about what a gate inherits by importing it —
6321+
// the half that had no mechanism at all, only prose in the one caller that
6322+
// remembered to spawn instead of import.
6323+
const inhFixture = [
6324+
"const WF = '.github/workflows';",
6325+
"const BASE = 'packages/plugins';",
6326+
'// dispatch-gates: inherited-population .github/workflows -- the only tree this module opens',
6327+
].join('\n');
6328+
t(
6329+
'a followed module declares the population a caller inherits, and the reason reads back',
6330+
(() => {
6331+
const d = declaredInheritedPopulation(inhFixture);
6332+
return d.population.length === 1
6333+
&& d.population[0] === '.github/workflows'
6334+
&& d.reason === 'the only tree this module opens';
6335+
})(),
6336+
);
6337+
t(
6338+
'and the literal it did NOT declare stops being inheritable, while still being a literal it spells',
6339+
extractWatchHints(inhFixture).includes('packages/plugins')
6340+
&& !declaredInheritedPopulation(inhFixture).population.includes('packages/plugins'),
6341+
);
6342+
t(
6343+
'the shell comment spelling is read too (a followed module can be a shell helper)',
6344+
declaredInheritedPopulation("X='.github/workflows'\n# dispatch-gates: inherited-population .github/workflows -- shell reason\n")
6345+
?.reason === 'shell reason',
6346+
);
6347+
t(
6348+
'several paths may be declared, space separated',
6349+
(() => {
6350+
const src = ["const A = '.github/workflows';", "const B = 'packages/spec/src/**';",
6351+
'// dispatch-gates: inherited-population .github/workflows packages/spec/src/** -- two real reads'].join('\n');
6352+
return declaredInheritedPopulation(src).population.length === 2;
6353+
})(),
6354+
);
6355+
t('no marker present reads as no declaration — the module contributes everything it spells', declaredInheritedPopulation("const A = '.github/workflows';\n") === null);
6356+
t(
6357+
'a marker carrying only a reason does not parse as a declaration (it reads as no marker, so the module keeps contributing — never a silent blanket opt-out)',
6358+
declaredInheritedPopulation("const A = '.github/workflows';\n// dispatch-gates: inherited-population -- everything here is a join base\n") === null,
6359+
);
6360+
t(
6361+
'the marker must be its OWN line here too — a mention inside prose is a discussion of the convention, not a declaration under it',
6362+
declaredInheritedPopulation("const A = '.github/workflows';\n// see dispatch-gates: inherited-population .github/workflows -- for how a module opts out\n") === null,
6363+
);
6364+
// NARROWING ONLY. This is the load-bearing invariant: an opt-out that could
6365+
// also opt IN would be the hand-written path map this file's contract exists
6366+
// to refuse, and it would be invisible — a declared path nothing spells reads
6367+
// exactly like a real one in the MATCHED column.
6368+
t(
6369+
'a declared path the module does not spell is REFUSED, not silently inherited',
6370+
(() => {
6371+
try {
6372+
declaredInheritedPopulation("const A = '.github/workflows';\n// dispatch-gates: inherited-population packages/spec/src/** -- invented\n");
6373+
return false;
6374+
} catch (e) {
6375+
return /may only NARROW/.test(String(e.message));
6376+
}
6377+
})(),
6378+
);
6379+
t(
6380+
'and the refusal names every invented path, not just the first',
6381+
(() => {
6382+
try {
6383+
declaredInheritedPopulation("const A = '.github/workflows';\n// dispatch-gates: inherited-population packages/a packages/b -- invented\n");
6384+
return false;
6385+
} catch (e) {
6386+
return /packages\/a, packages\/b/.test(String(e.message));
6387+
}
6388+
})(),
6389+
);
6390+
// The `--` separator is SPACE-delimited on purpose: a bare `--` would split a
6391+
// path that legitimately carries one.
6392+
t(
6393+
'a declared path containing a double dash survives the reason separator',
6394+
(() => {
6395+
const src = ["const A = 'packages/a--b/src';", '// dispatch-gates: inherited-population packages/a--b/src -- a real subtree'].join('\n');
6396+
const d = declaredInheritedPopulation(src);
6397+
return d.population.length === 1 && d.population[0] === 'packages/a--b/src' && d.reason === 'a real subtree';
6398+
})(),
6399+
);
6400+
// ── LIVE: this file's own declaration ─────────────────────────────────────
6401+
//
6402+
// Pinned against the real source, because the whole value of the marker is
6403+
// that it holds for THIS module — the one measured specimen. Delete the
6404+
// marker line and these cases redden instead of 2632 fabricated pairs coming
6405+
// back silently for the next gate that imports the tool.
6406+
const ownToolSource = readFileSync(join(ROOT, 'scripts/pm/dispatch-gates.mjs'), 'utf8');
6407+
const ownDeclared = declaredInheritedPopulation(ownToolSource);
6408+
// Read through `?.` on purpose: deleting the marker line must render as a
6409+
// NAMED failing case, not as a TypeError that aborts the run and takes every
6410+
// case after this one with it — a self-test that crashes reports one defect
6411+
// where the tree may hold several.
6412+
const ownPopulation = ownDeclared?.population ?? [];
6413+
t('this module declares what a follower inherits', (ownDeclared?.reason ?? '').length > 0);
6414+
t(
6415+
'it declares exactly the workflow tree it readdirs',
6416+
ownPopulation.length === 1 && ownPopulation[0] === '.github/workflows',
6417+
);
6418+
t('so a follower still reaches the workflow files this tool really opens', covers(ownPopulation, '.github/workflows/lint.yml'));
6419+
// The four fabricating classes the card measured, each pinned as SPELLED but
6420+
// NOT INHERITED — the two halves have to be asserted together, because the
6421+
// literal disappearing from the file would also pass "not inherited" while
6422+
// silently deleting the tier declaration this table is.
6423+
for (const fabricated of ['packages/plugins', 'packages/drivers', 'packages/services', 'packages/spec/src/**']) {
6424+
t(
6425+
`the module still spells ${fabricated} (join base / tier glob) but no follower inherits it`,
6426+
ownHints.includes(fabricated) && !ownPopulation.includes(fabricated),
6427+
);
6428+
}
6429+
t(
6430+
'and the tier-table file globs are not inheritable either',
6431+
ownPopulation.length > 0
6432+
&& !ownPopulation.includes('.claude/agents/os-dev.md')
6433+
&& !ownPopulation.includes('skills/objectstack-pm-dispatch/SKILL.md'),
6434+
);
6435+
// Cost of the mechanism on this tree, pinned so it cannot grow unnoticed: the
6436+
// marker is an opt-out, and an opt-out that spreads is how a real population
6437+
// goes quiet. Exactly one module in the scripts tree declares one today.
6438+
t(
6439+
'exactly one module in the scripts tree carries the declaration — this one',
6440+
(() => {
6441+
const declaring = trackedFiles()
6442+
.filter((f) => f.startsWith('scripts/') && /\.(mjs|mts|js|sh)$/.test(f))
6443+
// Read from the MODULE BODY, so the fixture markers above — which live
6444+
// inside this very self-test — are not counted as live declarations.
6445+
.filter((f) => INHERITED_POPULATION_MARKER.test(maskSelfTests(readFileSync(join(ROOT, f), 'utf8'))));
6446+
return declaring.length === 1 && declaring[0] === 'scripts/pm/dispatch-gates.mjs';
6447+
})(),
6448+
);
61686449
// The residue count that carries it refuses a missing or impossible value in
61696450
// the same shape as every other count in that line: a subset that could go
61706451
// absent quietly renders as `undefined` in the one line a reader needs.

0 commit comments

Comments
 (0)