fix(pm): decide an inherited population per CALLER, off what the import binds - #18157
Conversation
…rt binds `inherited-population` is keyed on the MODULE, while whether a contribution is fabricated is a property of the CALLER: a gate that binds one exported string constant out of `scripts/pm/dispatch-gates.mjs` inherited that module's whole declared watch surface and was told to run for every card touching the workflow directory it never opens. `firstPartyImportBindings` carries the binding beside each resolved target (the resolution and its order are unchanged to the byte), and `importBindsNoPopulation` reads it: an importer binding only primitive value constants reaches none of the module's reads and inherits nothing, while a table, a function, a class, a namespace, a default, a re-export and every clause this reading cannot parse inherit exactly what they did. A string constant whose own text is one of the module's paths stays population- bearing — binding a one-path population is reading it. Measured over the live tree: 1 of 52 followed import edges answers inert, and exactly 1 of 307 families' derived population moves. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 2433/2444, then In-seat record (non-governed surface: ① Derived judgments
② Semver levelNone. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #17991
inherited-populationis keyed on the MODULE, while whether a contribution is fabricated is a property of the CALLER.scripts/pm/check-clause2-carriers.mjsimports one exported string constant (CONTRACT_REVIEW_TIER) fromscripts/pm/dispatch-gates.mjsand thereby inherited that module's whole declared watch surface, so every card touching.github/workflows/**was told to runpnpm check:pm-clause2-carriers— a gate that never opens that directory. No per-module declaration can express the refusal, because the same module's globs ARE a real population forcheck:pm-widening-tells, which binds and reads them.What changed
One file:
scripts/pm/dispatch-gates.mjs.firstPartyImportBindingsis the resolverfirstPartyImportTargetsalready was, carrying the one thing that function dropped: WHAT the importer binds from each module it reaches. The resolution, its refusals and its sort order are unchanged to the byte —firstPartyImportTargetsis now akeys()of it, and the whole-tree derivation was proved identical across that refactor before any rule was added.exportedValueConstantreads the exporter half: is NAME declared in that module as aconstinitialised to one primitive literal? It is read in ONE direction only (a positive answer removes leads), so every shape it cannot read answers negative — an array, an object, a function, a class, a computed or multi-line initialiser, a re-exported binding and an undeclared name are all "not a value".importBindsNoPopulationis the per-caller rule: an importer binding only value constants reaches none of the module's reads and inherits nothing; a table, a function, a class, a namespace, a default, a re-export and every clause this reading cannot parse inherit exactly what they did. One exception, and it is a live specimen rather than a hypothetical: a string constant whose own text is one of the module's paths stays population-bearing —scripts/adr-anchors.mjsexports its anchor directory that way, and binding a one-path population is reading it.discoverFamilieskeepsentry.importswhole (the edge is real) and records the contributing subset asentry.populationImports, unioned across the family's files.The two docblocks the card quotes are extended in place rather than a new marker being added:
declaredInheritedPopulationnow states what it is keyed on and what reaches the caller instead, and the follow's docblock states that reaching a module and inheriting from it are two questions. #11556's semantics for table importers are untouched, and.github/workflows/**is untouched.Acceptance
All readings on this branch at
1b75a853a0, base295eae57d.The card's ablation, reproduced on the committed base (mutate, measure, restore, restore proved by blob hash
e59aaff8e0698af16cc20bd2128b81b6bc211c5awithgit status --porcelainempty):And reversed on the head — the same ablation, same restore proof, now with no effect at all:
The derivation a dispatch prompt pastes.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack .github/workflows/lint.yml, base against head, is exactly one line shorter:and for
scripts/pm/check-clause2-carriers.mjsthe two command lists are byte-identical — the gate's own identity key is untouched, and a card editing the carrier checker still runs it.The control population (
check:pm-widening-tells) still inherits. It bindsSUSPECT_TIER_GLOBS(a table) andhintCovers(a function) from the same module, keeps.github/workflowswithhintOriginscripts/pm/dispatch-gates.mjs, and keeps all 50 of its inherited hints (49 of them fromscripts/regen-artifacts.mjs). The derivation's own line, from the self-test:The family-population diff for the whole tree. Every family's
hints,importsandhintOrigindumped before and after:Measured the same way over every live import edge the derivation follows: 1 of 52 edges binds only value constants, and it is the card's. The other four importers of this module are unmoved —
check:pm-widening-tellsbinds a table, andcheck:declared-population-live,check:watch-hint-literaland the two-hop cases bind functions. Note the card's suggested enumeration (git grep "from './dispatch-gates.mjs'") is incomplete: it misses the./pm/dispatch-gates.mjsspelling two of those importers use, so the enumeration here is taken from the derivation rather than from the grep.Gates. Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths, three-dot against merge base295eae57d, change set = 1 path), every one run in the foreground with$?captured before any pipe, then reconciled:All 31 exited 0, plus two run beyond the union because this change moves them:
pnpm check:pm-clause2-carriersandpnpm check:pm-widening-tells, both exit 0.pnpm check:declared-population-live(its self-test and then the live sweep) exits 0 — the #18138 lesson was measured rather than assumed: the wording added here adds no path-shaped literal any family declares, and the whole-tree dump above is the check.The self-test.
pnpm check:pm-dispatch-gates: 1723 cases pass, 0 failures, 0 NOT MEASURED subjects. Elevent(call sites added and zero removed (git diffcounts), so the count strictly increased; 1712 before is arithmetic off that diff, 1723 after is the measured run. The new cases pin both directions on fixtures (the clause reader, the exporter recogniser, the rule) and then the live shape:The self-test's own reconstruction of a family's hints now models the binding rule too, so a future widening of the recogniser reds there rather than silently agreeing with itself.
Restart-touch
Hold #14290 (
pm:on-hold, "dispatch-gates: STAGE-THEN-RUN reaches a program by an edge neither follow traverses") carries aRestart-touch:onscripts/pm/dispatch-gates.mjs, so this landing fires its re-verification. This change does not touch STAGE-THEN-RUN edges: it changes only what an IMPORT edge contributes, and leavesspawnedProgramTargets,readProgramTargetsInSourceandpackageManifestTargets— the follows #14290 is about — untouched in both traversal and population.Changeset
None.
scripts/pm/**publishes nothing — theskip-changesetlabel is applied.Acceptance notes
scripts/pm/dispatch-gates.mjsdoes not derivecheck:pm-clause2-carrierstoday either, measured on the base — the import edge's only contribution to that family was the fabricated workflow lead. An IDENTITY key over import edges is deliberately refused ([finding] dispatch-gates never names a family for an edit to a first-party module its gate script IMPORTS — 228 (family, module) pairs unreached, measured #13126, priced in the derivation's own self-test), so this is a recorded disposition rather than a gap this PR opens. Successor: none.entry.selfTestis still read off the WORKFLOW argv while a--self-testin apackage.jsonscript body is invisible to it, so the cheaper guard beside the fix stays silent for everypnpm check:*family whose self-test-only-ness is spelled in the manifest. The card offers it as a separable shape and the machinery for it already exists next door (selfTestOnlyInvocationresolves a row one hop through the manifest that defines it). It is left out here because it would move other families' derivation, which is the one thing this PR's blast radius is pinned against. Successor: the card, which stays open on this half if the seat wants it.Generated by Claude Code