Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions scripts/assert-console-spec-injection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ import path from 'node:path';

import {
ProbeError,
describeCandidates,
pickProbe,
chooseProbes,
readBundle,
readSpecBlob,
writeStamp,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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('');
Expand All @@ -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);
65 changes: 64 additions & 1 deletion scripts/check-console-injection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) ───
Expand Down
58 changes: 58 additions & 0 deletions scripts/console-spec-probes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
Loading