Skip to content

Commit bbf4a52

Browse files
claude[bot]claude
andauthored
fix(devx): declare check:merge-driver's manifest population, and re-measure the bounded-subtree walk specimen (#15538)
Two cards on the dispatch derivation, folded because both land in `scripts/pm/dispatch-gates.mjs`. Card relations are declared once in the PR body; this branch squashes, so no commit here carries a closing keyword. ## The manifest population (#15501) `git-merge-regen --self-test` refuses a generator with no recorded merge disposition, and the population that refusal sweeps is the MANIFESTS: the root `package.json` plus every workspace member's, read for their `gen:` / `check:` rows. What the family declared to `scripts/pm/dispatch-gates.mjs` was the artifact paths `scripts/regen-artifacts.mjs` carries, imported one level down — the generators ALREADY routed. So the one class of card the refusal exists to catch (a card that ADDS a generator, touching a manifest and a new `scripts/*.mjs`) was the one class the derivation could not name, and the gate fired a cycle late, in CI, on every card of that shape. Measured on 615fac3, before: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -- package.json → 0 lines naming check:merge-driver and after: `pnpm check:merge-driver` is listed, for the root manifest and for a member manifest alike. The repair is the population declaration, in the idiom the derivation already supports and beside the read it describes — `ROOT_FILE_WATCH_HINTS` for the root manifest in the subtree spelling `hintCovers` accepts, `DECLARED_WATCH_HINTS` for the member manifests as full-path glob literals, both following `check-workspace-manifest-cycles.mjs`, which declares the identical population. No hand list of generator names (that ledger is `regen-artifacts.mjs`'s, and this gate already reconciles it two ways), and no self-test case edited to pass. A twelfth self-test callee, `reconcileManifestPopulation`, holds both declarations against the enumerator's LIVE answer in both directions — every member manifest covered, no declared pattern covering zero, the root literal's collapsed form equal to the path the gate opens for the root owner — with its roster entry and floor (11 → 12), since nothing else in the repo can redden when a declaration read by another tool goes stale. Two dispatch-gates self-test cases per direction, pinned against the LIVE tree through the same discovery pass `derive` runs: a change set touching the root or a member manifest derives the gate, an unrelated brand-new `scripts/*.mjs` does not, and an in-place ablation shows it is the manifest declaration doing it. ## The bounded-subtree walk specimen (#15510) Two sentences cited `check-self-test-workflow-commands.mjs` as the LIVE specimen for `repoRootWalkSpelling`'s negative direction. The outcome they report is re-measured and unchanged — the limbs select five of the six candidates, and that gate is selected by none — but the REASON was written as the shape of that gate's walk, and a gate can stop holding a walk without the census moving. The census sentence now states the population (a bounded subtree, `scripts/`), which is what the remedy turns on; its limb attribution is corrected too, measured: three by limb A, one by B, one by C, not "limbs A/B". The fixture-side comment now names two measured specimens at two different walk roots — `check-self-test-wired.mjs` (`walkScripts`, seeded at `scripts/`) and `check-spec-parsed-alias.mjs` (`walkZodFiles`, seeded at `packages/spec/src`) — and two LIVE cases read them off their real source, so the claim cannot rot back into prose pointing at a file with no walk in it. Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4ef7dc commit bbf4a52

2 files changed

Lines changed: 324 additions & 16 deletions

File tree

scripts/git-merge-regen.mjs

Lines changed: 171 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,62 @@ import {
7676
import { blankAnchorLineNumbers } from './doc-line-anchors.mjs';
7777
import { workspacePackages } from './workspace-enumerator.mjs';
7878

79+
/**
80+
* The ROOT manifest, in the SUBTREE spelling `scripts/pm/dispatch-gates.mjs`
81+
* can match (#15501).
82+
*
83+
* `reconcileGenerators` and `reconcileScripts` read `gen:` / `check:` rows out
84+
* of the MANIFESTS -- the root one, plus every workspace member's -- and that
85+
* population was declared nowhere. What this family DID declare is the artifact
86+
* paths `scripts/regen-artifacts.mjs` carries, imported one level down as its
87+
* hints: the artifacts ALREADY routed. A card that ADDS a generator touches a
88+
* manifest and a new `scripts/*.mjs`, and neither is in that population until
89+
* the card lands the very row it is being asked to add. So the one class of
90+
* change the "no recorded merge disposition" refusal exists to catch was the
91+
* one class the dispatch derivation could not name, and the gate fired a cycle
92+
* late, in CI, on every card of that shape. Measured on `615fac3a0`, before
93+
* this declaration existed:
94+
*
95+
* node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -- package.json
96+
* -> 0 lines naming check:merge-driver
97+
*
98+
* ⛔ The fix is the POPULATION, never a hand list of generator names: that
99+
* ledger is `scripts/regen-artifacts.mjs`'s and this gate already owns the
100+
* two-way reconciliation over it.
101+
*
102+
* A bare `package.json` carries no path separator and `hintCovers` refuses it
103+
* as too generic; the trailing `/` + `**` suffix collapses back to the literal
104+
* filename and matches it exactly -- `check-workspace-manifest-cycles.mjs`'s
105+
* idiom for `pnpm-workspace.yaml` and `check-turbo-task-graph.mjs`'s for
106+
* `turbo.json`. `reconcileManifestPopulation` pins the exact string, because
107+
* every other signal this gate emits stays green when it is reworded back.
108+
*/
109+
const ROOT_FILE_WATCH_HINTS = ['package.json/**'];
110+
111+
/**
112+
* The MEMBER manifests this gate opens -- one per workspace package, which is
113+
* exactly the list `workspacePackages(REPO_ROOT)` hands `reconcileGenerators`
114+
* -- spelled as full-path glob LITERALS, never built from a `SCAN_ROOT`-shaped
115+
* constant (the bare-top-level-dir species `scripts/pm/bare-root-worklist.mjs`
116+
* records as unjudged). The three roots and their spelling follow
117+
* `check-workspace-manifest-cycles.mjs`, which declares the identical
118+
* population for the identical reason, and they are held against the
119+
* enumerator's LIVE answer in both directions by `reconcileManifestPopulation`
120+
* below -- so a workspace root this repo grows cannot leave the declaration
121+
* behind in silence.
122+
*
123+
* ⚠️ Each of these carries its glob in a NON-FINAL segment, so `hintCovers`
124+
* judges it as a PATTERN: it reaches member manifests and nothing else, never
125+
* the thousands of files under those roots. That is the distinction between
126+
* this declaration and the whole-subtree widening
127+
* `check-pnpm-filter-targets.mjs` refuses for its own population.
128+
*/
129+
const DECLARED_WATCH_HINTS = [
130+
'packages/**/package.json',
131+
'apps/**/package.json',
132+
'examples/**/package.json',
133+
];
134+
79135
/**
80136
* The comparators a row's `mixed` field may name (#14064).
81137
*
@@ -288,7 +344,7 @@ function drive(argv) {
288344
// ── Why the CALLEE NAME is the battery ──
289345
//
290346
// This file has no `selfTest()` entry function and no named section banners:
291-
// the `--self-test` dispatch at the bottom invokes ELEVEN named callees, each
347+
// the `--self-test` dispatch at the bottom invokes TWELVE named callees, each
292348
// printing its own line and returning a boolean. So the roster's unit is the
293349
// CALLEE, and its label is the one the SOURCE ALREADY CARRIES — the function's
294350
// own name. Nothing is invented and nothing is judged per comment, and a set
@@ -303,10 +359,10 @@ function drive(argv) {
303359
// (PR #15271, `check-sdui-manifest`) makes a table row a battery. It does so
304360
// for a file whose SELF-TEST *is* the table: one literal table, one driving
305361
// loop over it, and a sink that writes only when a row fails. Here the table is
306-
// a local of ONE callee among eleven, its rows are evaluated eagerly into
362+
// a local of ONE callee among twelve, its rows are evaluated eagerly into
307363
// booleans before anything loops, and the callee already reduces them to a
308364
// single printed verdict of its own. Flooring those rows would floor one
309-
// callee's internals while the other ten stayed at callee granularity — a
365+
// callee's internals while the other eleven stayed at callee granularity — a
310366
// roster whose unit changes per entry. The rule: the battery is the unit the
311367
// DISPATCH names.
312368
//
@@ -321,6 +377,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
321377
reconcileAttributeSemantics: 1,
322378
reconcileScripts: 1,
323379
reconcileGenerators: 1,
380+
reconcileManifestPopulation: 1,
324381
reconcileUntrackedDispositions: 1,
325382
reconcileOwnership: 1,
326383
hookIsExecutable: 1,
@@ -336,7 +393,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
336393
// key in the literal above, so the roster falls below this number; the
337394
// roster ↔ dispatch cross-check in the floor block is the other half, and it
338395
// names WHICH callee was listed twice.
339-
const SELF_TEST_BATTERY_FLOOR = 11;
396+
const SELF_TEST_BATTERY_FLOOR = 12;
340397

341398
// The key a registration is filed under when a callee registers no name at all.
342399
// It is not a declared battery, so it reds by the same set difference rather
@@ -346,7 +403,7 @@ const UNATTRIBUTED_BATTERY = '(no callee named)';
346403
// The battery ledger, read by `batteryFloorFailures()` from the dispatch block
347404
// at the very bottom of this file. It is MODULE-level rather than local to a
348405
// self-test body because this file HAS no self-test body: the registrations
349-
// happen inside eleven separate callees and the floor is read at the dispatch's
406+
// happen inside twelve separate callees and the floor is read at the dispatch's
350407
// verdict site, so the ledger has to outlive every one of those frames.
351408
//
352409
// ⚠️ Named for the roster's role, deliberately NOT with a self-test spelling:
@@ -358,7 +415,7 @@ const batterySeen = new Map();
358415
/**
359416
* Record that a self-test callee RAN.
360417
*
361-
* Called as the FIRST statement of each of the eleven callees the `--self-test`
418+
* Called as the FIRST statement of each of the twelve callees the `--self-test`
362419
* dispatch invokes — above any early return, so a callee that bails out early
363420
* still reports that it ran, and the floor is never met by a frame that
364421
* returned before doing anything.
@@ -371,7 +428,7 @@ function registerCase(name) {
371428
/**
372429
* The floor: every declared callee RAN (#13489).
373430
*
374-
* Evaluated at the dispatch's verdict site — after all eleven callees have had
431+
* Evaluated at the dispatch's verdict site — after all twelve callees have had
375432
* their chance and immediately before the success line — and reached only from
376433
* the `--self-test` branch, so a production merge-driver run never reads the
377434
* ledger at all.
@@ -677,6 +734,111 @@ function reconcileGenerators() {
677734
return ok;
678735
}
679736

737+
/**
738+
* The two watch-hint declarations above, held against the manifest population
739+
* `reconcileGenerators` really reads (#15501).
740+
*
741+
* Nothing else in this repo can redden when they go wrong. They are read by
742+
* ANOTHER tool entirely -- `extractWatchHints` in
743+
* `scripts/pm/dispatch-gates.mjs` scans this file's SOURCE TEXT -- so a wrong,
744+
* stale or re-computed declaration runs green here forever and pays itself out
745+
* as a dev dispatched on a new-generator card with this gate missing from the
746+
* brief, which is the whole failure this declaration was added to end. Same
747+
* reason `check-pnpm-filter-targets.mjs` pins its own, and the same discipline
748+
* `check-workspace-manifest-cycles.mjs` applies to the identical population.
749+
*
750+
* The population is derived through the SAME two functions the gate uses --
751+
* `workspacePackages` and `manifestFor` -- never re-spelled here, so a moved
752+
* read cannot leave this reconciliation agreeing with a list nobody opens.
753+
*/
754+
function reconcileManifestPopulation() {
755+
registerCase('reconcileManifestPopulation');
756+
757+
// `**` crosses separators; every other glob character stays within one
758+
// segment. Local rather than shared for the reason `check-watch-hint-literal`
759+
// gives for not importing the files it judges: a gate that pulled in the
760+
// derivation to borrow one predicate would import 20k lines of another tool's
761+
// module body -- and, here, hand this family every path literal in it.
762+
const patternMatches = (pattern, path) => {
763+
const segs = pattern.split('/');
764+
let rx = '';
765+
for (let i = 0; i < segs.length; i++) {
766+
if (segs[i] === '**') {
767+
rx += '(?:[^/]+/)*';
768+
continue;
769+
}
770+
rx += segs[i].replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
771+
if (i < segs.length - 1) rx += '/';
772+
}
773+
return new RegExp(`^${rx}$`).test(path);
774+
};
775+
776+
const problems = [];
777+
const ok = (label, cond) => {
778+
if (!cond) problems.push(label);
779+
};
780+
781+
// ── The ROOT manifest, pinned as BYTES ──
782+
// A reword back to the bare filename is invisible in every other signal:
783+
// production green, CI green, and the only thing lost is that a card adding a
784+
// `gen:` row to the root manifest can name this gate at all.
785+
ok(
786+
'the root manifest is declared in the SUBTREE spelling (hintCovers refuses the bare filename as too generic)',
787+
ROOT_FILE_WATCH_HINTS.join(',') === 'package.json/**',
788+
);
789+
// …and the literal is tied to the real read rather than typed twice: the
790+
// declaration's collapsed form must be the path `reconcileGenerators` opens
791+
// for the root owner.
792+
ok(
793+
'the collapsed root literal is the path this gate actually opens for the root owner',
794+
ROOT_FILE_WATCH_HINTS[0].replace(/\/\*+$/, '') === manifestFor('.'),
795+
);
796+
ok(
797+
'every declared entry carries a path separator',
798+
[...ROOT_FILE_WATCH_HINTS, ...DECLARED_WATCH_HINTS].every((h) => h.includes('/')),
799+
);
800+
801+
// ── The MEMBER manifests, against the enumerator's LIVE answer ──
802+
// Both directions, mirroring `check-workspace-manifest-cycles.mjs`: a pattern
803+
// covering nothing is a fabricated lead on every card, and a manifest no
804+
// pattern covers is the undeclared read this gate would otherwise ship with.
805+
let memberManifests;
806+
try {
807+
memberManifests = workspacePackages(REPO_ROOT).map((p) => manifestFor(p.dir));
808+
} catch (err) {
809+
return fail(`the workspace could not be enumerated, so the declaration could not be reconciled: ${err?.message ?? err}`);
810+
}
811+
ok(`the enumerator finds member manifests to declare (${memberManifests.length})`, memberManifests.length > 0);
812+
const undeclared = memberManifests.filter((m) => !DECLARED_WATCH_HINTS.some((h) => patternMatches(h, m)));
813+
ok(
814+
`every member manifest this gate opens is covered by a declared pattern (uncovered: ${undeclared.join(', ') || 'none'})`,
815+
undeclared.length === 0,
816+
);
817+
const empty = DECLARED_WATCH_HINTS.filter((h) => !memberManifests.some((m) => patternMatches(h, m)));
818+
ok(`and no declared pattern covers zero of them (empty: ${empty.join(', ') || 'none'})`, empty.length === 0);
819+
// The other direction on the ROOT: it is not a workspace member, so a member
820+
// pattern that reached it would mean the two declarations had collapsed into
821+
// one over-wide glob.
822+
ok(
823+
'no member pattern reaches the root manifest — the root is declared separately because it is not a workspace member',
824+
!DECLARED_WATCH_HINTS.some((h) => patternMatches(h, manifestFor('.'))),
825+
);
826+
ok(
827+
'the pattern matcher crosses separators for `**` and does not run past the filename',
828+
patternMatches('packages/**/package.json', 'packages/spec/package.json') &&
829+
patternMatches('packages/**/package.json', 'packages/plugins/plugin-auth/package.json') &&
830+
!patternMatches('packages/**/package.json', 'packages/spec/src/index.ts'),
831+
);
832+
833+
if (problems.length) {
834+
return fail(`watch-hint declaration(s) out of step with the manifest population:\n ${problems.join('\n ')}\n`
835+
+ ' ROOT_FILE_WATCH_HINTS / DECLARED_WATCH_HINTS at the top of this file declare the manifests\n'
836+
+ ' reconcileGenerators reads. Repair the DECLARATION, never this reconciliation.');
837+
}
838+
console.log(`✓ the declared manifest population covers the root manifest and all ${memberManifests.length} member manifest(s)`);
839+
return true;
840+
}
841+
680842
/**
681843
* The `untracked: true` dispositions, held against git rather than against their own
682844
* prose (#13731).
@@ -1140,7 +1302,7 @@ function endToEndMixed() {
11401302

11411303
if (process.argv.includes('--self-test')) {
11421304
console.log('git-merge-regen --self-test\n');
1143-
// The eleven callees as a literal LIST rather than eleven bare calls, so the
1305+
// The twelve callees as a literal LIST rather than twelve bare calls, so the
11441306
// names this block invokes are data the floor below can cross-check the
11451307
// roster against, in both directions. The names are read off the function
11461308
// declarations themselves (`fn.name`), so a renamed callee moves this list
@@ -1150,6 +1312,7 @@ if (process.argv.includes('--self-test')) {
11501312
reconcileAttributeSemantics,
11511313
reconcileScripts,
11521314
reconcileGenerators,
1315+
reconcileManifestPopulation,
11531316
reconcileUntrackedDispositions,
11541317
reconcileOwnership,
11551318
hookIsExecutable,

0 commit comments

Comments
 (0)