diff --git a/scripts/assert-console-spec-injection.mjs b/scripts/assert-console-spec-injection.mjs index 51faeeff761..ced83f7b1b7 100644 --- a/scripts/assert-console-spec-injection.mjs +++ b/scripts/assert-console-spec-injection.mjs @@ -65,8 +65,7 @@ import path from 'node:path'; import { ProbeError, - describeCandidates, - pickProbe, + chooseProbes, readBundle, readSpecBlob, writeStamp, @@ -110,8 +109,15 @@ try { fail(error.message); } -const freshWitness = pickProbe(describeCandidates(injectedBlob), vendoredBlob); -const staleDetector = pickProbe(describeCandidates(vendoredBlob), injectedBlob); +// Chosen with the bundle in view (objectstack#20646): the witness is injected-only +// text this bundle carries, and the stale leg is judged over EVERY published-only +// description, not the one that sorts first — see chooseProbes for why the old +// alphabetical pick read text from entries the console never imports. +const { freshWitness, freshPresent, freshCounts, staleDetector, stalePresent, staleCounts } = chooseProbes({ + injectedBlob, + vendoredBlob, + bundle, +}); /** Record what this build proved, for check:console-injection to replay. */ function stamp(skew) { @@ -140,14 +146,13 @@ if (!freshWitness && !staleDetector) { process.exit(0); } -const freshPresent = freshWitness ? bundle.includes(freshWitness) : null; -const stalePresent = staleDetector ? bundle.includes(staleDetector) : null; - // Neither probe anywhere in the bundle means the spec is not in this build at // all — the check cannot speak to an injection it cannot see. if (freshPresent !== true && stalePresent !== true) { console.error('✗ Neither spec appears in the built console — no @objectstack/spec'); console.error(' content matched. The injection is UNVERIFIED by this check.'); + console.error(` injected-only descriptions in the bundle: ${freshCounts.inBundle} of ${freshCounts.pool}`); + console.error(` published-only descriptions in the bundle: ${staleCounts.inBundle} of ${staleCounts.pool}`); process.exit(2); } @@ -157,7 +162,8 @@ if (stalePresent === true) { console.error(' key this framework declared after the last spec publish is unreachable'); console.error(' in the Studio designer — the defect objectstack#8134 exists to end.'); console.error(''); - console.error(' Text found in the bundle that ONLY the vendored spec has:'); + console.error(` Text found in the bundle that ONLY the vendored spec has (${staleCounts.inBundle} of`); + console.error(` ${staleCounts.pool} published-only descriptions), the first of them:`); console.error(` "${staleDetector}"`); if (freshPresent === true) { console.error(''); @@ -178,6 +184,10 @@ if (freshPresent !== true) { console.log("✓ Console bundle carries THIS tree's @objectstack/spec, and only it."); console.log(` present (injected only): "${freshWitness}"`); -if (staleDetector) console.log(` absent (vendored only): "${staleDetector}"`); +console.log(` — ${freshCounts.inBundle} of ${freshCounts.pool} injected-only descriptions are in the bundle`); +if (staleDetector) { + console.log(` absent (vendored only): "${staleDetector}"`); + console.log(` — and all ${staleCounts.pool} published-only descriptions are absent`); +} stamp(true); process.exit(0); diff --git a/scripts/check-console-injection.mjs b/scripts/check-console-injection.mjs index 6b36a221a23..bd02c3b87c0 100644 --- a/scripts/check-console-injection.mjs +++ b/scripts/check-console-injection.mjs @@ -196,11 +196,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '7d. The producer cannot emit that stamp in the first place. writeStamp is': 2, '8. A build that found no skew records it, and this gate says so honestly.': 3, '12. ROUND TRIP against the real assert script: whatever it stamps, this gate': 2, + '13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose': 6, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 10; +const SELF_TEST_BATTERY_FLOOR = 11; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -850,6 +851,68 @@ function selfTest() { } } + // 13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose + // its probes with the bundle in view. A spec package publishes entries a + // console never imports, and the alphabetically first unique description + // can sit in one of them — on the fresh side that read a WORKING injection + // as "neither spec appears", on the stale side it let a console built from + // the PUBLISHED spec pass. Each fixture below puts the first unique + // candidate where the bundle does not carry it, and runs the real assert + // script against it. + battery('13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose'); + { + const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); + const runAssert = (injected, vendored, dist) => + spawnSync( + process.execPath, + [assert, '--injected', injected, '--vendored', vendored, '--assets', path.join(dist, 'assets')], + { encoding: 'utf8' }, + ); + const SHARED = 'Shared text in both specs for the blind-spot fixtures'; + // Both sort before FRESH and STALE, so the old first-candidate pick chose them. + const UNBUNDLED_FRESH = 'A description only an injected entry the console never imports carries'; + const UNBUNDLED_STALE = 'A description only a published entry the console never imports carries'; + + // Fresh side: the first injected-only candidate is not in the bundle, a later + // one is. The injection worked, so the check must pass on the one it carries. + const freshInjected = makeSpecPkg(path.join(root, 'bs-fresh-injected'), [UNBUNDLED_FRESH, FRESH, SHARED]); + const freshVendored = makeSpecPkg(path.join(root, 'bs-fresh-vendored'), [STALE, SHARED]); + const freshDist = makeDist(path.join(root, 'bs-fresh-dist'), `console(${JSON.stringify(FRESH)})`, undefined); + const fresh = runAssert(freshInjected, freshVendored, freshDist); + expect('a witness the bundle carries verifies, whatever sorts first', fresh.status, 0); + const freshStamp = fs.existsSync(path.join(freshDist, STAMP_BASENAME)) ? readStamp(freshDist) : null; + expect('the stamp records the witness the bundle carries', freshStamp?.packages?.[0]?.freshWitness, FRESH); + expect('and this gate replays that stamp green', evaluate({ distDir: freshDist, specDir: freshInjected }).code, 0); + + // Stale side: the first published-only candidate is not in the bundle, a + // later one IS. The console carries the published spec, so the check must + // fail — the old single pick read the absent one and passed. + const staleInjected = makeSpecPkg(path.join(root, 'bs-stale-injected'), [FRESH, SHARED]); + const staleVendored = makeSpecPkg(path.join(root, 'bs-stale-vendored'), [UNBUNDLED_STALE, STALE, SHARED]); + const staleDist = makeDist( + path.join(root, 'bs-stale-dist'), + `console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`, + undefined, + ); + const stale = runAssert(staleInjected, staleVendored, staleDist); + expect('any published-only description in the bundle fails the build', stale.status, 1); + checked += 1; + if (!stale.stderr.includes('still carries the PUBLISHED') || !stale.stderr.includes(STALE)) { + failures.push('the stale-side failure must say the published spec is bundled and name the text it found'); + } + expect('a failing build writes no stamp', fs.existsSync(path.join(staleDist, STAMP_BASENAME)), false); + + // Neither: the bundle carries no unique text from either spec. Still exit 2 — + // choosing from the bundle must never turn "unverified" into a pass. + const neitherDist = makeDist(path.join(root, 'bs-neither-dist'), `console(${JSON.stringify(SHARED)})`, undefined); + const neither = runAssert(freshInjected, freshVendored, neitherDist); + expect('neither spec in the bundle stays inconclusive', neither.status, 2); + checked += 1; + if (!neither.stderr.includes('Neither spec appears')) { + failures.push('the neither-found verdict must still say that neither spec appears'); + } + } + fs.rmSync(root, { recursive: true, force: true }); // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── diff --git a/scripts/console-spec-probes.mjs b/scripts/console-spec-probes.mjs index 3105322a10e..2114de76453 100644 --- a/scripts/console-spec-probes.mjs +++ b/scripts/console-spec-probes.mjs @@ -124,6 +124,64 @@ export function pickProbe(candidates, theirs) { return null; } +/** Every candidate `theirs` does not contain, by the same substring rule as pickProbe. */ +export function uniqueCandidates(candidates, theirs) { + return candidates.filter((candidate) => !theirs.includes(candidate)); +} + +/** + * The two probes for ONE built console bundle, chosen with the bundle in view. + * + * ## The blind spot this closes (objectstack#20646) + * + * Both blobs are every JS file the package's exports map resolves to, but a + * console bundles only the entries it imports. Taking the alphabetically first + * unique candidate therefore picked text the bundle could never carry, on both + * legs, and nothing noticed until the witness landed in one: + * + * - FRESH: the new `@objectstack/spec/migrations` entry took the change-manifest + * descriptions off the root. "A public export added or removed by one + * release." stayed the first injected-only candidate, now carried only by an + * entry the console never imports, so a working injection read as "neither + * spec appears" (exit 2) — while 102 of the 142 injected-only descriptions + * were in that very bundle (measured on fbec216e2d against objectui + * dd3f7e1be356 and its published @objectstack/spec 17.4.0). + * - STALE: the first published-only candidate was text from the published + * `./cloud` entry, which the console never imports either, so the detector + * was absent by construction: a console built from the PUBLISHED spec passed + * this leg too. + * + * ## What is chosen instead + * + * - The fresh witness is the first injected-only candidate the bundle DOES + * carry. When it carries none, the first candidate is still returned with + * `freshPresent: false` — "neither spec appears" stays exit 2 at the caller. + * - The stale leg is judged over EVERY published-only candidate, not one: it is + * present when ANY of them is in the bundle, and the detector returned is the + * first one found. That is strictly stronger than the single pick — a bundle + * the old leg flagged is still flagged — and it no longer depends on which + * entry happens to sort first. With none present, the first candidate is + * returned, exactly the one pickProbe chose, so the stamp this feeds keeps + * its shape and its replay (check-console-injection) keeps its meaning. + * + * `freshPresent` / `stalePresent` are `null` when that side has no unique + * candidate at all — no skew on that side — matching the caller's old tri-state. + */ +export function chooseProbes({ injectedBlob, vendoredBlob, bundle }) { + const freshPool = uniqueCandidates(describeCandidates(injectedBlob), vendoredBlob); + const stalePool = uniqueCandidates(describeCandidates(vendoredBlob), injectedBlob); + const freshInBundle = freshPool.filter((candidate) => bundle.includes(candidate)); + const staleInBundle = stalePool.filter((candidate) => bundle.includes(candidate)); + return { + freshWitness: freshInBundle[0] ?? freshPool[0] ?? null, + freshPresent: freshPool.length === 0 ? null : freshInBundle.length > 0, + freshCounts: { pool: freshPool.length, inBundle: freshInBundle.length }, + staleDetector: staleInBundle[0] ?? stalePool[0] ?? null, + stalePresent: stalePool.length === 0 ? null : staleInBundle.length > 0, + staleCounts: { pool: stalePool.length, inBundle: staleInBundle.length }, + }; +} + /** Concatenated JavaScript of a built console `assets/` directory. */ export function readBundle(assetsDir) { if (!fs.existsSync(assetsDir)) bad(`assets dir \`${assetsDir}\` does not exist`);