From edfa017317940a34628946cb6305df52f9dccc86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 02:28:16 +0000 Subject: [PATCH 1/2] fix(dispatch-gates): stop inheriting the CLI package as a population from the build prerequisite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/cli-build-prerequisite.mjs` spells `packages/cli` as a join base and as the vocabulary of its consumers' rerun commands. Followed as an import (#11190), that one literal reached check:i18n and check:i18n-coverage as a whole-package subtree claim: all 322 tracked files of the package, 210 of them unable to change a byte of the dist/ those gates spawn. Declare what a caller really inherits with the #11556 marker — the stub the gates spawn, the manifest they parse, and the src/ the spawned command is compiled from. Measured: 322 -> 214 covered files per gate, 216 fabricated pairs withdrawn, all 112 files the gates really read still named. The self-test's reconstruction re-scanned a followed module's raw literals, so it could not model the declaration its own case name claims; fixed at the same seam discoverFamilies reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69 --- scripts/cli-build-prerequisite.mjs | 52 +++++++++++++++- scripts/pm/dispatch-gates.mjs | 98 ++++++++++++++++++++++++++---- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/scripts/cli-build-prerequisite.mjs b/scripts/cli-build-prerequisite.mjs index 603df73115..6f6a94cbb1 100644 --- a/scripts/cli-build-prerequisite.mjs +++ b/scripts/cli-build-prerequisite.mjs @@ -57,9 +57,57 @@ export const atRepoRoot = (rel) => join(REPO_ROOT, rel); export const CLI = 'packages/cli/bin/run.js'; /** The package whose `oclif` block declares where the built commands land. */ export const CLI_PKG = 'packages/cli'; +/** + * That manifest as ONE whole path, rather than joined from `CLI_PKG` at the read. + * + * The value is identical either way; what changes is what a CALLER inherits. + * scripts/pm/dispatch-gates.mjs follows a gate's first-party imports and reads + * this module's module-body literals as population for every gate that imports + * it (#11190) — and a join base is indistinguishable, from out there, from a + * whole-package subtree claim. The manifest is the one file this module opens, + * so this module spells it, which is what lets the `inherited-population` + * marker below name it: that declaration may only NARROW to paths its module + * really spells, never invent one. + */ +const CLI_PKG_JSON = 'packages/cli/package.json'; /** The one command that satisfies the prerequisite, for every gate that reports it. */ export const CLI_BUILD_FIX = 'pnpm exec turbo run build --filter=@objectstack/cli'; +/** + * The CLI's COMPILED surface — the tree `tsc -p packages/cli/tsconfig.build.json` + * turns into the `dist/commands` oclif resolves a spawned command out of. + * + * A bare coupling constant: nothing here reads it, and it is declared for the + * reason check-i18n-bundles.mjs declares its two registry modules (#9144). It + * is the only tree under `packages/cli` whose edit can change what a consumer's + * spawn ANSWERS, so a declaration naming the stub and the manifest alone would + * leave every consumer blind to exactly the edit that moves its verdict — the + * shape #11190 exists to prevent, arriving by the other door. Deliberately the + * subtree and not a file list: the consumers spawn whole commands (`os i18n + * extract`, `os lint`), not one module, and a file list here would be the + * hand-written path map this derivation refuses. + */ +const CLI_SRC = 'packages/cli/src'; + +// What a consumer of this module READS under `packages/cli`, and therefore all +// of it a consumer inherits (#12500). `CLI_PKG` above is a join base and the +// vocabulary of every message here, never a tree anything opens — but inherited +// whole it named all 322 tracked files of the package for `check:i18n` and +// `check:i18n-coverage`, 210 of them (the 100-file test suite, the package +// docs, the vitest config, the sibling app-nav gate script) unable to change a +// byte of the `dist/` those two gates spawn. The gates' own refusal text is +// exemplary, so a card that skipped the build read NOT MEASURED rather than +// green: the price was never a false verdict, it was a full CLI closure build +// bought per card to measure two gates the diff provably could not move. +// +// Build CONFIGURATION is deliberately NOT declared. A tsconfig-only edit that +// changes the shipped command surface loses one lead — one card, one CI round, +// the direction this derivation errs in everywhere — against the ~10 minutes +// each false lead charged. `CLI_SRC` is a prefix, so the ~101 `*.test.ts` files +// interleaved under `src/` stay named; `hintCovers` matches subtrees, and a +// declaration cannot express "the compiled subset of this tree". +// dispatch-gates: inherited-population packages/cli/bin/run.js packages/cli/package.json packages/cli/src -- a consumer spawns the stub, parses the manifest for oclif.commands.target, and resolves its command out of the dist/ compiled from src/; `packages/cli` itself is this module's join base and message vocabulary, not a tree any consumer opens (#12500) + /** * The npm scope every workspace package publishes under (`create-objectstack` is * the one exception, and no gate's input imports it). It is used for exactly one @@ -245,9 +293,9 @@ export function resolveCliCommandFile(commandId) { // The message stays repo-relative (it is the path a reader would `cat` from // the root); only the READ is anchored. node's own ENOENT text carries the // absolute path it tried, which is evidence rather than vocabulary. - pkgJson = JSON.parse(readFileSync(atRepoRoot(join(CLI_PKG, 'package.json')), 'utf8')); + pkgJson = JSON.parse(readFileSync(atRepoRoot(CLI_PKG_JSON), 'utf8')); } catch (e) { - return { unknown: `could not read ${CLI_PKG}/package.json (${e.message})` }; + return { unknown: `could not read ${CLI_PKG_JSON} (${e.message})` }; } return oclifCommandFileFor(pkgJson, commandId); } diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 11ca09e02e..e2a9b0d4be 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -8286,17 +8286,30 @@ function selfTest() { ); // Cost of the mechanism on this tree, pinned so it cannot grow unnoticed: the // marker is an opt-out, and an opt-out that spreads is how a real population - // goes quiet. Exactly one module in the scripts tree declares one today. - t( - 'exactly one module in the scripts tree carries the declaration — this one', - (() => { - const declaring = trackedFiles() - .filter((f) => f.startsWith('scripts/') && /\.(mjs|mts|js|sh)$/.test(f)) - // Read from the MODULE BODY, so the fixture markers above — which live - // inside this very self-test — are not counted as live declarations. - .filter((f) => INHERITED_POPULATION_MARKER.test(maskSelfTests(readFileSync(join(ROOT, f), 'utf8')))); - return declaring.length === 1 && declaring[0] === 'scripts/pm/dispatch-gates.mjs'; - })(), + // goes quiet. TWO modules in the scripts tree declare one today, and this + // case NAMES them rather than counting them — a bare count reddens for a + // third module without saying which ones were already priced, and the price + // is the whole admission criterion: + // + // scripts/pm/dispatch-gates.mjs 2632 pairs — join bases and tier + // globs, nothing this tool opens + // scripts/cli-build-prerequisite.mjs 216 pairs — 108 files x 2 gates, + // each charging the card that touched + // one a full CLI closure build to + // measure gates it could not move + // (#12500) + // + // A third entry is not forbidden; it is required to arrive with its own + // measured price, which is what re-pointing this case costs an author. + const declaringModules = trackedFiles() + .filter((f) => f.startsWith('scripts/') && /\.(mjs|mts|js|sh)$/.test(f)) + // Read from the MODULE BODY, so the fixture markers above — which live + // inside this very self-test — are not counted as live declarations. + .filter((f) => INHERITED_POPULATION_MARKER.test(maskSelfTests(readFileSync(join(ROOT, f), 'utf8')))) + .sort(); + t( + `exactly the two priced modules in the scripts tree carry the declaration (${declaringModules.join(' · ') || 'none'})`, + declaringModules.join(' · ') === 'scripts/cli-build-prerequisite.mjs · scripts/pm/dispatch-gates.mjs', ); // The residue count that carries it refuses a missing or impossible value in // the same shape as every other count in that line: a subset that could go @@ -8383,7 +8396,18 @@ function selfTest() { // and silent about the rule. const liveGateFiles = new Set([...liveDiscovery.byCheck.values()].flatMap((e) => e.files ?? [])); const liveSource = (rel) => readFileSync(join(ROOT, rel), 'utf8'); - const liveModuleHints = (rel) => extractWatchHints(liveSource(rel), rel, { tree: liveTree }); + // A followed module's hints AS A FOLLOWER RECEIVES THEM. `discoverFamilies` + // reads `declaredInheritedPopulation` at this seam (`hintsOfModule`), so a + // reconstruction that re-scanned the raw literals instead would redden for + // every family importing a module that narrows — while the case it feeds + // asserts, in its own name, that a shared enumerator CAN carry a population + // declaration for its callers. It was raw until #12500 put the second live + // declaration in the tree, and the two i18n families are what found it. + const liveModuleHints = (rel) => { + const source = liveSource(rel); + const spelled = extractWatchHints(source, rel, { tree: liveTree }); + return declaredInheritedPopulation(source, spelled)?.population ?? spelled; + }; const liveTargets = (rel) => firstPartyImportTargets(rel, liveSource(rel)); // The recogniser, on fixture source: one line per refusal, so a widening or @@ -8519,6 +8543,56 @@ function selfTest() { ); } + // ── A followed module's JOIN BASE is not a population (#12500) ───────────── + // + // `cli-build-prerequisite.mjs` spells `packages/cli` because it joins paths + // from it and writes it into every rerun command its two consumers print. + // Inherited whole it reads as a subtree claim, and it handed check:i18n and + // check:i18n-coverage all 322 tracked files of that package — 210 of them + // (the 100-file test suite, the package docs, the vitest config, the sibling + // app-nav gate script) unable to change a byte of the `dist/` those gates + // spawn. The gates' refusal text is exemplary, so the cost was never a false + // green: it was a full CLI closure build per card, bought to measure two + // gates the diff provably could not move. Measured at the narrowing: + // 322 -> 214 covered files per gate (108 x 2 = 216 fabricated pairs + // withdrawn), and all 112 files the gates really read still named. + // + // BOTH directions, against real files. A narrowing that also dropped the CLI + // source would be the under-naming mirror this card's pair exists to keep + // apart — the source compiled into the spawned command must still derive. + const CLI_PREREQ = 'scripts/cli-build-prerequisite.mjs'; + const cliPrereqSource = liveSource(CLI_PREREQ); + const cliPrereqSpelled = extractWatchHints(cliPrereqSource, CLI_PREREQ, { tree: liveTree }); + const cliPrereqPopulation = declaredInheritedPopulation(cliPrereqSource, cliPrereqSpelled)?.population ?? []; + t( + `the CLI build-prerequisite module declares what its callers inherit (${cliPrereqPopulation.join(' ') || 'nothing'})`, + cliPrereqPopulation.length === 3, + ); + t( + 'and it still SPELLS the whole-package join base — the declaration narrows a live literal, not a deleted one', + cliPrereqSpelled.includes('packages/cli') && !cliPrereqPopulation.includes('packages/cli'), + ); + // Specimens, not classes: the extractor module whose edit really does move + // the committed bundles, and a test file that compiles into nothing either + // gate runs. Both live, so neither direction can pass over an empty set. + const CLI_SRC_SPECIMEN = 'packages/cli/src/utils/i18n-extract.ts'; + const CLI_TEST_SPECIMEN = 'packages/cli/test/authoring-rule-command-parity.test.ts'; + t( + 'both CLI specimens are real tracked files, so the two directions below are live', + existsSync(join(ROOT, CLI_SRC_SPECIMEN)) && existsSync(join(ROOT, CLI_TEST_SPECIMEN)), + ); + for (const check of ['check:i18n', 'check:i18n-coverage']) { + const cliEntry = liveDiscovery.byCheck.get(check); + t( + `${check} still derives the CLI source compiled into the command it spawns`, + Boolean(cliEntry) && coveringKey(cliEntry, CLI_SRC_SPECIMEN)?.key === 'packages/cli/src', + ); + t( + `${check} no longer derives a CLI test file, which compiles into nothing it runs`, + Boolean(cliEntry) && coveringKey(cliEntry, CLI_TEST_SPECIMEN) === null, + ); + } + // The live guard: every REAL paths-filtered workflow either discovers a // family or declares why not. This is what actually fails CI the day a new // paths-filtered workflow adds an undiscoverable verification step and From 399cf697137b9819c1b141a3af98508dc281d5a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 02:31:01 +0000 Subject: [PATCH 2/2] test(dispatch-gates): close the empty-set hole in the join-base pin Ablating the marker left this one case green: with nothing inherited, nothing inherits the join base either. Require a non-empty declaration, so the case asserts the narrowing rather than an absence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69 --- scripts/pm/dispatch-gates.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index e2a9b0d4be..c5b484da91 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -8570,7 +8570,12 @@ function selfTest() { ); t( 'and it still SPELLS the whole-package join base — the declaration narrows a live literal, not a deleted one', - cliPrereqSpelled.includes('packages/cli') && !cliPrereqPopulation.includes('packages/cli'), + // The `length > 0` is not decoration: without it a DELETED marker satisfies + // this case by the empty set (nothing is inherited, so nothing inherits the + // join base) — the shape a pin that only asserts an absence always has. + cliPrereqPopulation.length > 0 + && cliPrereqSpelled.includes('packages/cli') + && !cliPrereqPopulation.includes('packages/cli'), ); // Specimens, not classes: the extractor module whose edit really does move // the committed bundles, and a test file that compiles into nothing either