Skip to content

Commit 9c8f113

Browse files
fix(scripts): the console spec-injection probes are chosen with the bundle in view (#20646) (#20743)
Fixes #20646 Clause-②: no `Console Pin Gate` is red on `main` since `fbec216e2d` (PR #20695, the `@objectstack/spec/migrations` entry split). Its build step exits 2: "Neither spec appears in the built console — no @objectstack/spec content matched." The injection works. The check was reading text the console never bundles. This PR makes the build-time probe derivation choose with the bundle in view. The fresh leg stops reading entries the console never imports, and the stale leg becomes strictly stronger. ## Root cause, measured `scripts/console-spec-probes.mjs` builds each spec's blob from every JS file the package's `exports` map resolves to. The assertion then took the alphabetically first unique `.describe()` text on each side. A console bundles only the entries it imports, so either probe could come from an entry the bundle can never carry. The readings below compare the published `@objectstack/spec` 17.4.0 that objectui locks at pin `dd3f7e1be356` with the framework spec. | leg | framework | fresh witness (carried by) | in bundle | stale detector (carried by) | in bundle | old check | | --- | --- | --- | --- | --- | --- | --- | | dark | `f927864ea0` (main before the split) | "A public export added or removed by one release." (`.`) | yes | "Accepted developer agreement version" (`./cloud`) | no | exit 0 | | lit | `fbec216e2d` (`origin/main`) | the same text, now (`./migrations`) | no | the same (`./cloud`) | no | exit 2 | - The witness moved with the split from the root to `./migrations`, which the console never imports. Meanwhile 102 of the 142 injected-only descriptions ARE in that bundle; the first sorts from `./ui`. - The stale leg was already blind. Its detector sits in the published `./cloud` entry, which objectui's shipped source never imports: 116 `/ui`, 62 `/data`, 17 `/kernel` and so on, and no `/cloud`. A console built WITHOUT the injection carries 56 of the 160 published-only descriptions, and not that one. - The spec side is not the cause. The console never imported the change manifest, and the injected spec is in the bundle. `packages/spec` is untouched here. ## The fix (`scripts/console-spec-probes.mjs`, `chooseProbes`) - **Fresh witness:** the first injected-only description the bundle DOES carry. With none carried, "neither spec appears" is still exit 2. - **Stale leg:** judged over EVERY published-only description. It fails when ANY of them is in the bundle, and reports the first one found with the count. A bundle the old single pick flagged is still flagged, so this is strictly stronger. - **Stamp:** keeps its shape and version. With nothing published in the bundle, the stamped detector is the same one `pickProbe` chose, so `check:console-injection`'s cache-hit replay is unchanged. - **Assertion script:** `scripts/assert-console-spec-injection.mjs` calls `chooseProbes` and prints the counts. The exit ladder is unchanged: 0 verified, 1 published spec bundled, 2 unverified. - **Self-test:** `scripts/check-console-injection.mjs` gains battery 13 (roster floor 10 to 11). It runs the real assertion script on fixtures whose first unique candidate is not in the bundle: - a working injection verifies, and its stamp replays green; - a published-only description the old pick skipped fails the build and writes no stamp; - neither side present stays exit 2. I checked that the battery can fail: reverting `chooseProbes` to first-pick semantics made it red with 5 failures, and restoring the fix turned it green again. ## Proof All legs ran against real `vite build`s of the console, with the old and the new assertion on the same bundle. | console build | objectui pin | new check | old check | | --- | --- | --- | --- | | injected spec from `fbec216e2d` | `dd3f7e1be356` | exit 0 (witness from `./ui`; 102/142 present; 0/160 published) | exit 2 | | NOT injected (published spec) | `dd3f7e1be356` | exit 1 (56/160 published-only descriptions present) | exit 2 | | a bundle with no spec text | — | exit 2 ("Neither spec appears") | — | | injected spec from `f927864ea0` (dark) | `dd3f7e1be356` | exit 0 (111/142 present) | exit 0 | | injected spec from `fbec216e2d` | `db11afd49670` (PR #20706's pin) | exit 0 (102/142; 0/160) | exit 2 | | NOT injected (published spec) | `db11afd49670` | exit 1 (56/160) | exit 2 | On this branch's head, the Console Pin Gate's steps run locally all pass: `bash scripts/build-console.sh` with the injected spec exits 0; the dist presence assert passes; `pnpm check:console-sha` exits 0; and `pnpm check:console-injection --require-stamp` exits 0 and replays the new stamp. ## Verification record - `dispatch-gates --commands` for the 3 changed paths derives 31 families. `--ran` reconciles them as 30 run, all exit 0, and 1 NOT MEASURED: `check:pm-dispatch-gates`, whose self-test alone outruns the 590-second foreground cap here. It does not read these files' behaviour. - `node scripts/check-console-injection.mjs --self-test` passes: 44 assertions, battery floor met. - Lint, narrowed and proven: `eslint --no-inline-config` on the 3 changed files reports 0 errors and 0 warnings. The config enables no type-aware linting, so untouched files' verdicts cannot move. - No changeset: the diff touches only root `scripts/`, which ship in no published package (`@objectstack/spec-monorepo` is private). ## Acceptance notes - **The cache-hit replay's stale leg is still a single stamped detector.** At this pin that detector is `./cloud` text no console bundles, so on a dist-cache HIT the replay cannot catch a published spec. The build-time leg now can. Fixing the replay needs a multi-detector stamp (a `stampVersion` bump that invalidates every cached dist under the unchanged cache key), which is outside this claim. Carrier: the `domain:spec` seat. Noted, not filed. - **CI on this head:** `Console Pin Gate` (job 109696257740) concluded success. The dist cache missed, so step 11 built the console and ran the changed assertion (105 of 145 injected-only descriptions present, all 160 published-only absent), and steps 12 to 14 passed on that fresh dist. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97005ae commit 9c8f113

3 files changed

Lines changed: 141 additions & 10 deletions

File tree

‎scripts/assert-console-spec-injection.mjs‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ import path from 'node:path';
6565

6666
import {
6767
ProbeError,
68-
describeCandidates,
69-
pickProbe,
68+
chooseProbes,
7069
readBundle,
7170
readSpecBlob,
7271
writeStamp,
@@ -110,8 +109,15 @@ try {
110109
fail(error.message);
111110
}
112111

113-
const freshWitness = pickProbe(describeCandidates(injectedBlob), vendoredBlob);
114-
const staleDetector = pickProbe(describeCandidates(vendoredBlob), injectedBlob);
112+
// Chosen with the bundle in view (objectstack#20646): the witness is injected-only
113+
// text this bundle carries, and the stale leg is judged over EVERY published-only
114+
// description, not the one that sorts first — see chooseProbes for why the old
115+
// alphabetical pick read text from entries the console never imports.
116+
const { freshWitness, freshPresent, freshCounts, staleDetector, stalePresent, staleCounts } = chooseProbes({
117+
injectedBlob,
118+
vendoredBlob,
119+
bundle,
120+
});
115121

116122
/** Record what this build proved, for check:console-injection to replay. */
117123
function stamp(skew) {
@@ -140,14 +146,13 @@ if (!freshWitness && !staleDetector) {
140146
process.exit(0);
141147
}
142148

143-
const freshPresent = freshWitness ? bundle.includes(freshWitness) : null;
144-
const stalePresent = staleDetector ? bundle.includes(staleDetector) : null;
145-
146149
// Neither probe anywhere in the bundle means the spec is not in this build at
147150
// all — the check cannot speak to an injection it cannot see.
148151
if (freshPresent !== true && stalePresent !== true) {
149152
console.error('✗ Neither spec appears in the built console — no @objectstack/spec');
150153
console.error(' content matched. The injection is UNVERIFIED by this check.');
154+
console.error(` injected-only descriptions in the bundle: ${freshCounts.inBundle} of ${freshCounts.pool}`);
155+
console.error(` published-only descriptions in the bundle: ${staleCounts.inBundle} of ${staleCounts.pool}`);
151156
process.exit(2);
152157
}
153158

@@ -157,7 +162,8 @@ if (stalePresent === true) {
157162
console.error(' key this framework declared after the last spec publish is unreachable');
158163
console.error(' in the Studio designer — the defect objectstack#8134 exists to end.');
159164
console.error('');
160-
console.error(' Text found in the bundle that ONLY the vendored spec has:');
165+
console.error(` Text found in the bundle that ONLY the vendored spec has (${staleCounts.inBundle} of`);
166+
console.error(` ${staleCounts.pool} published-only descriptions), the first of them:`);
161167
console.error(` "${staleDetector}"`);
162168
if (freshPresent === true) {
163169
console.error('');
@@ -178,6 +184,10 @@ if (freshPresent !== true) {
178184

179185
console.log("✓ Console bundle carries THIS tree's @objectstack/spec, and only it.");
180186
console.log(` present (injected only): "${freshWitness}"`);
181-
if (staleDetector) console.log(` absent (vendored only): "${staleDetector}"`);
187+
console.log(` — ${freshCounts.inBundle} of ${freshCounts.pool} injected-only descriptions are in the bundle`);
188+
if (staleDetector) {
189+
console.log(` absent (vendored only): "${staleDetector}"`);
190+
console.log(` — and all ${staleCounts.pool} published-only descriptions are absent`);
191+
}
182192
stamp(true);
183193
process.exit(0);

‎scripts/check-console-injection.mjs‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
196196
'7d. The producer cannot emit that stamp in the first place. writeStamp is': 2,
197197
'8. A build that found no skew records it, and this gate says so honestly.': 3,
198198
'12. ROUND TRIP against the real assert script: whatever it stamps, this gate': 2,
199+
'13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose': 6,
199200
});
200201

201202
// DELETING an entry silences that battery's floor exactly as effectively as
202203
// zeroing it, so the roster's own size is pinned too.
203-
const SELF_TEST_BATTERY_FLOOR = 10;
204+
const SELF_TEST_BATTERY_FLOOR = 11;
204205

205206
// The key an assertion is filed under when no battery is open. It is not a
206207
// declared battery, so it reds by the same set difference rather than silently
@@ -850,6 +851,68 @@ function selfTest() {
850851
}
851852
}
852853

854+
// 13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose
855+
// its probes with the bundle in view. A spec package publishes entries a
856+
// console never imports, and the alphabetically first unique description
857+
// can sit in one of them — on the fresh side that read a WORKING injection
858+
// as "neither spec appears", on the stale side it let a console built from
859+
// the PUBLISHED spec pass. Each fixture below puts the first unique
860+
// candidate where the bundle does not carry it, and runs the real assert
861+
// script against it.
862+
battery('13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose');
863+
{
864+
const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs');
865+
const runAssert = (injected, vendored, dist) =>
866+
spawnSync(
867+
process.execPath,
868+
[assert, '--injected', injected, '--vendored', vendored, '--assets', path.join(dist, 'assets')],
869+
{ encoding: 'utf8' },
870+
);
871+
const SHARED = 'Shared text in both specs for the blind-spot fixtures';
872+
// Both sort before FRESH and STALE, so the old first-candidate pick chose them.
873+
const UNBUNDLED_FRESH = 'A description only an injected entry the console never imports carries';
874+
const UNBUNDLED_STALE = 'A description only a published entry the console never imports carries';
875+
876+
// Fresh side: the first injected-only candidate is not in the bundle, a later
877+
// one is. The injection worked, so the check must pass on the one it carries.
878+
const freshInjected = makeSpecPkg(path.join(root, 'bs-fresh-injected'), [UNBUNDLED_FRESH, FRESH, SHARED]);
879+
const freshVendored = makeSpecPkg(path.join(root, 'bs-fresh-vendored'), [STALE, SHARED]);
880+
const freshDist = makeDist(path.join(root, 'bs-fresh-dist'), `console(${JSON.stringify(FRESH)})`, undefined);
881+
const fresh = runAssert(freshInjected, freshVendored, freshDist);
882+
expect('a witness the bundle carries verifies, whatever sorts first', fresh.status, 0);
883+
const freshStamp = fs.existsSync(path.join(freshDist, STAMP_BASENAME)) ? readStamp(freshDist) : null;
884+
expect('the stamp records the witness the bundle carries', freshStamp?.packages?.[0]?.freshWitness, FRESH);
885+
expect('and this gate replays that stamp green', evaluate({ distDir: freshDist, specDir: freshInjected }).code, 0);
886+
887+
// Stale side: the first published-only candidate is not in the bundle, a
888+
// later one IS. The console carries the published spec, so the check must
889+
// fail — the old single pick read the absent one and passed.
890+
const staleInjected = makeSpecPkg(path.join(root, 'bs-stale-injected'), [FRESH, SHARED]);
891+
const staleVendored = makeSpecPkg(path.join(root, 'bs-stale-vendored'), [UNBUNDLED_STALE, STALE, SHARED]);
892+
const staleDist = makeDist(
893+
path.join(root, 'bs-stale-dist'),
894+
`console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`,
895+
undefined,
896+
);
897+
const stale = runAssert(staleInjected, staleVendored, staleDist);
898+
expect('any published-only description in the bundle fails the build', stale.status, 1);
899+
checked += 1;
900+
if (!stale.stderr.includes('still carries the PUBLISHED') || !stale.stderr.includes(STALE)) {
901+
failures.push('the stale-side failure must say the published spec is bundled and name the text it found');
902+
}
903+
expect('a failing build writes no stamp', fs.existsSync(path.join(staleDist, STAMP_BASENAME)), false);
904+
905+
// Neither: the bundle carries no unique text from either spec. Still exit 2 —
906+
// choosing from the bundle must never turn "unverified" into a pass.
907+
const neitherDist = makeDist(path.join(root, 'bs-neither-dist'), `console(${JSON.stringify(SHARED)})`, undefined);
908+
const neither = runAssert(freshInjected, freshVendored, neitherDist);
909+
expect('neither spec in the bundle stays inconclusive', neither.status, 2);
910+
checked += 1;
911+
if (!neither.stderr.includes('Neither spec appears')) {
912+
failures.push('the neither-found verdict must still say that neither spec appears');
913+
}
914+
}
915+
853916
fs.rmSync(root, { recursive: true, force: true });
854917

855918
// ── The floor: every declared battery RAN, and ran its cases (#13489) ───

‎scripts/console-spec-probes.mjs‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,64 @@ export function pickProbe(candidates, theirs) {
124124
return null;
125125
}
126126

127+
/** Every candidate `theirs` does not contain, by the same substring rule as pickProbe. */
128+
export function uniqueCandidates(candidates, theirs) {
129+
return candidates.filter((candidate) => !theirs.includes(candidate));
130+
}
131+
132+
/**
133+
* The two probes for ONE built console bundle, chosen with the bundle in view.
134+
*
135+
* ## The blind spot this closes (objectstack#20646)
136+
*
137+
* Both blobs are every JS file the package's exports map resolves to, but a
138+
* console bundles only the entries it imports. Taking the alphabetically first
139+
* unique candidate therefore picked text the bundle could never carry, on both
140+
* legs, and nothing noticed until the witness landed in one:
141+
*
142+
* - FRESH: the new `@objectstack/spec/migrations` entry took the change-manifest
143+
* descriptions off the root. "A public export added or removed by one
144+
* release." stayed the first injected-only candidate, now carried only by an
145+
* entry the console never imports, so a working injection read as "neither
146+
* spec appears" (exit 2) — while 102 of the 142 injected-only descriptions
147+
* were in that very bundle (measured on fbec216e2d against objectui
148+
* dd3f7e1be356 and its published @objectstack/spec 17.4.0).
149+
* - STALE: the first published-only candidate was text from the published
150+
* `./cloud` entry, which the console never imports either, so the detector
151+
* was absent by construction: a console built from the PUBLISHED spec passed
152+
* this leg too.
153+
*
154+
* ## What is chosen instead
155+
*
156+
* - The fresh witness is the first injected-only candidate the bundle DOES
157+
* carry. When it carries none, the first candidate is still returned with
158+
* `freshPresent: false` — "neither spec appears" stays exit 2 at the caller.
159+
* - The stale leg is judged over EVERY published-only candidate, not one: it is
160+
* present when ANY of them is in the bundle, and the detector returned is the
161+
* first one found. That is strictly stronger than the single pick — a bundle
162+
* the old leg flagged is still flagged — and it no longer depends on which
163+
* entry happens to sort first. With none present, the first candidate is
164+
* returned, exactly the one pickProbe chose, so the stamp this feeds keeps
165+
* its shape and its replay (check-console-injection) keeps its meaning.
166+
*
167+
* `freshPresent` / `stalePresent` are `null` when that side has no unique
168+
* candidate at all — no skew on that side — matching the caller's old tri-state.
169+
*/
170+
export function chooseProbes({ injectedBlob, vendoredBlob, bundle }) {
171+
const freshPool = uniqueCandidates(describeCandidates(injectedBlob), vendoredBlob);
172+
const stalePool = uniqueCandidates(describeCandidates(vendoredBlob), injectedBlob);
173+
const freshInBundle = freshPool.filter((candidate) => bundle.includes(candidate));
174+
const staleInBundle = stalePool.filter((candidate) => bundle.includes(candidate));
175+
return {
176+
freshWitness: freshInBundle[0] ?? freshPool[0] ?? null,
177+
freshPresent: freshPool.length === 0 ? null : freshInBundle.length > 0,
178+
freshCounts: { pool: freshPool.length, inBundle: freshInBundle.length },
179+
staleDetector: staleInBundle[0] ?? stalePool[0] ?? null,
180+
stalePresent: stalePool.length === 0 ? null : staleInBundle.length > 0,
181+
staleCounts: { pool: stalePool.length, inBundle: staleInBundle.length },
182+
};
183+
}
184+
127185
/** Concatenated JavaScript of a built console `assets/` directory. */
128186
export function readBundle(assetsDir) {
129187
if (!fs.existsSync(assetsDir)) bad(`assets dir \`${assetsDir}\` does not exist`);

0 commit comments

Comments
 (0)