diff --git a/scripts/assert-console-spec-injection.mjs b/scripts/assert-console-spec-injection.mjs index ced83f7b1b7..4fd59042a9e 100644 --- a/scripts/assert-console-spec-injection.mjs +++ b/scripts/assert-console-spec-injection.mjs @@ -35,6 +35,19 @@ // positive evidence that the published spec is still in the bundle. The fresh // witness alone cannot distinguish "injection worked" from "some other copy". // +// ## objectui's own text is not evidence of either spec (objectstack#21709) +// +// The bundle also carries objectui's OWN code, and objectui's component +// registry writes `inputs` descriptions that mirror spec `.describe()` text. +// When the spec rewords one, the old text is published-only and objectui's +// literal still carries it, so a pure substring search reads objectui's own +// registry as "the published spec": object-gantt `markers` turned every +// merge-queue build red that way. So the third input, --objectui, is the tree +// the console was built from, and a probe its tracked source carries is never +// counted as present — nor stamped. Absent text stays evidence; see +// chooseProbes and readHostSourceBlob for the rule and why it cannot hide a +// real leak. +// // ## Probe derivation lives in ./console-spec-probes.mjs // // Not here, because objectstack#9667 added a SECOND consumer: this script can @@ -56,6 +69,7 @@ // node scripts/assert-console-spec-injection.mjs \ // --injected \ // --vendored \ +// --objectui \ // --assets // // Exit: 0 = injection proven (or no skew to prove) · 1 = injection failed @@ -67,6 +81,7 @@ import { ProbeError, chooseProbes, readBundle, + readHostSourceBlob, readSpecBlob, writeStamp, } from './console-spec-probes.mjs'; @@ -84,7 +99,7 @@ function parseArgs(argv) { if (argv[i + 1] === undefined) fail(`\`${key}\` has no value`); out[key.slice(2)] = argv[i + 1]; } - for (const required of ['injected', 'vendored', 'assets']) { + for (const required of ['injected', 'vendored', 'objectui', 'assets']) { if (!out[required]) fail(`--${required} is required`); } return out; @@ -100,10 +115,12 @@ const distDir = path.dirname(assetsDir); let bundle; let injectedBlob; let vendoredBlob; +let hostBlob; try { bundle = readBundle(assetsDir); injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected'); vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored'); + hostBlob = readHostSourceBlob(path.resolve(args.objectui), 'objectui'); } catch (error) { if (!(error instanceof ProbeError)) throw error; fail(error.message); @@ -112,12 +129,31 @@ try { // 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, -}); +// alphabetical pick read text from entries the console never imports. And with +// objectui's own source in view (objectstack#21709): text that source carries is +// never counted as present, on either leg, and never stamped. +const { + freshWitness, + freshPresent, + freshCounts, + staleDetector, + stalePresent, + staleCounts, + freshHostCarried, + staleHostCarried, +} = chooseProbes({ injectedBlob, vendoredBlob, bundle, hostBlob }); + +/** One leg's descriptions that this bundle carries only as objectui's own text. */ +function hostCarriedNote(indent, kind, counts, carried) { + if (counts.hostCarried === 0) return []; + return [ + `${indent}${counts.hostCarried} ${kind} description(s) ARE in the bundle, and objectui's own`, + `${indent}source at the pin carries each of them too, so they are not evidence of either spec:`, + `${indent} "${carried[0]}"`, + ]; +} +const freshNote = (indent) => hostCarriedNote(indent, 'injected-only', freshCounts, freshHostCarried); +const staleNote = (indent) => hostCarriedNote(indent, 'published-only', staleCounts, staleHostCarried); /** Record what this build proved, for check:console-injection to replay. */ function stamp(skew) { @@ -139,13 +175,29 @@ function stamp(skew) { } } -if (!freshWitness && !staleDetector) { +if (freshCounts.pool === 0 && staleCounts.pool === 0) { console.log('✓ Injected and vendored @objectstack/spec declare the same descriptions'); console.log(' — no observable skew, so nothing for this check to assert.'); stamp(false); process.exit(0); } +// The stale leg with no probe left: every published-only description is in the +// bundle AND carried by objectui's own source, so not one of them can say which +// side put it there. That is a leg that cannot judge this bundle — not "no skew" +// (the pool is not empty) and not "absent" (the text is right there) — so it is +// inconclusive, never a pass: a bundle carrying the published spec would look +// exactly like this. +if (staleCounts.pool > 0 && staleDetector === null) { + console.error("✗ The staleness leg cannot judge this bundle: every published-only description"); + console.error(" it could look for is in the bundle, and objectui's own source at the pin carries"); + console.error(' each one too, so none can tell the published spec from objectui\'s own text.'); + console.error(' The injection is UNVERIFIED by this check.'); + console.error(` published-only descriptions: ${staleCounts.pool}, all of them carried by objectui's source`); + console.error(` first of them: "${staleHostCarried[0]}"`); + process.exit(2); +} + // 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) { @@ -153,6 +205,7 @@ if (freshPresent !== true && stalePresent !== true) { 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}`); + for (const line of [...freshNote(' '), ...staleNote(' ')]) console.error(line); process.exit(2); } @@ -165,6 +218,11 @@ if (stalePresent === true) { 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 (staleCounts.hostCarried > 0) { + console.error(''); + console.error(` Not counted: ${staleCounts.hostCarried} more published-only description(s) the bundle`); + console.error(" carries that objectui's own source at the pin carries too."); + } if (freshPresent === true) { console.error(''); console.error(' Note: text unique to this tree\'s spec is ALSO in the bundle —'); @@ -179,15 +237,22 @@ if (freshPresent !== true) { console.error(" this tree's spec was found either — the build is in an unexpected"); console.error(' state and the injection is UNVERIFIED.'); console.error(` expected: "${freshWitness}"`); + for (const line of freshNote(' ')) console.error(line); process.exit(2); } console.log("✓ Console bundle carries THIS tree's @objectstack/spec, and only it."); console.log(` present (injected only): "${freshWitness}"`); console.log(` — ${freshCounts.inBundle} of ${freshCounts.pool} injected-only descriptions are in the bundle`); +for (const line of freshNote(' ')) console.log(line); if (staleDetector) { console.log(` absent (vendored only): "${staleDetector}"`); - console.log(` — and all ${staleCounts.pool} published-only descriptions are absent`); + if (staleCounts.hostCarried === 0) { + console.log(` — and all ${staleCounts.pool} published-only descriptions are absent`); + } else { + console.log(` — and ${staleCounts.pool - staleCounts.hostCarried} of ${staleCounts.pool} published-only descriptions are absent;`); + for (const line of staleNote(' ')) console.log(line); + } } stamp(true); process.exit(0); diff --git a/scripts/build-console.sh b/scripts/build-console.sh index 9fa1ff98860..dec6466d11d 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -376,9 +376,12 @@ fi # BOTH directions: the console bundle also holds a second, transitive copy of # this tree's spec (pulled in through the injected client above), which makes a # one-sided "is the new text present" probe pass even with no injection at all. +# --objectui is the checkout just built: objectui's own registry mirrors spec +# descriptions, so text its tracked source carries is not evidence of either spec. node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \ --injected "$SPEC_PKG" \ --vendored "${BUILD_ROOT}/node_modules/@objectstack/spec" \ + --objectui "$BUILD_ROOT" \ --assets "${TARGET}/assets" # ── Ship the TRACKED SDUI manifest (ADR-0080) ──────────────────────── diff --git a/scripts/check-console-injection.mjs b/scripts/check-console-injection.mjs index 6e91a163863..24c07fa40e3 100644 --- a/scripts/check-console-injection.mjs +++ b/scripts/check-console-injection.mjs @@ -42,6 +42,13 @@ * `/.objectstack-injection.json`, and this gate replays them against the * restored bundle. Cost: one node process reading files already on disk. * + * The objectui tree is just as absent here, and the replay does not need it: + * the build never chooses — so never stamps — a probe objectui's own source + * carries (objectstack#21709, see chooseProbes). A stamped detector is text no + * part of a good build writes, so finding it in the restored bundle still means + * the published spec, and the replay reaches the build's answer by substring + * alone. Battery 14 of the self-test holds the two to one answer. + * * ## The stamped probes are checked for EXPIRY, not trusted forever * * A frozen probe is exactly the failure objectstack#8134 was filed about: #7804's @@ -169,6 +176,7 @@ import { fileURLToPath } from 'node:url'; import { ProbeError, STAMP_BASENAME, + chooseProbes, describeCandidates, pickProbe, readBundle, @@ -206,11 +214,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '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, + "14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE: a probe the console's own source carries": 23, }); // 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 = 11; +const SELF_TEST_BATTERY_FLOOR = 12; // 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 @@ -557,6 +566,24 @@ function makeDist(dir, assetText, stamp) { return dir; } +/** + * A minimal objectui build tree: a git checkout whose TRACKED files are the + * given ones. The assert script reads objectui's own source with `git ls-files` + * (never a directory walk, which would also read node_modules and built dists), + * so the fixture has to be tracked to be seen; an index entry is enough. + */ +function makeHostTree(dir, files) { + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + for (const argv of [['init', '-q'], ['add', '-A']]) { + const run = spawnSync('git', ['-C', dir, ...argv], { encoding: 'utf8' }); + if (run.status !== 0) throw new Error(`fixture: git ${argv.join(' ')} failed in ${dir}: ${run.stderr || run.error}`); + } + return dir; +} + const FRESH = 'Authorable key this tree declares and the registry reads'; const STALE = 'Published description that only the vendored spec carries'; @@ -851,6 +878,12 @@ function selfTest() { // 12. ROUND TRIP against the real assert script: whatever it stamps, this gate // must accept. This is the drift the shared module exists to prevent, and // the only assertion here that proves the two halves still agree. + // objectui's build tree, which the assert script reads for objectui's own + // source (battery 14 says why). This one carries no spec text at all. + const plainHost = makeHostTree(path.join(root, 'host-plain'), { + 'packages/plugin/src/index.tsx': 'export const inputs = [{ name: "title", description: "Unrelated host text" }];\n', + }); + battery('12. ROUND TRIP against the real assert script: whatever it stamps, this gate'); { const injected = makeSpecPkg(path.join(root, 'rt-injected'), [FRESH, 'Shared text in both specs for the round trip']); @@ -859,7 +892,7 @@ function selfTest() { const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); const run = spawnSync( process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--assets', path.join(dist, 'assets')], + [assert, '--injected', injected, '--vendored', vendored, '--objectui', plainHost, '--assets', path.join(dist, 'assets')], { encoding: 'utf8' }, ); expect('assert script passes on a good fixture', run.status, 0); @@ -885,7 +918,7 @@ function selfTest() { const runAssert = (injected, vendored, dist) => spawnSync( process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--assets', path.join(dist, 'assets')], + [assert, '--injected', injected, '--vendored', vendored, '--objectui', plainHost, '--assets', path.join(dist, 'assets')], { encoding: 'utf8' }, ); const SHARED = 'Shared text in both specs for the blind-spot fixtures'; @@ -933,6 +966,140 @@ function selfTest() { } } + // 14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE (objectstack#21709). The bundle + // carries objectui's code too, and objectui's registry `inputs` mirror spec + // descriptions — as a prefix it extends (object-gantt `markers`, which + // turned every merge-queue build red) or verbatim. Once the spec rewords + // one, the old text is published-only and objectui's literal still holds + // it, so a substring search reads objectui's own registry as "the published + // spec". Every fixture here runs the real assert script and replays what it + // stamped through evaluate(), so the two halves are held to one answer. + // + // The mirrored texts sort FIRST on purpose: an unfiltered pick would choose + // them, as the detector of a failing build or as the stamped detector of a + // passing one — and a stamped detector objectui's own code puts in every + // build would fail every cache-hit replay of a good dist. + battery("14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE: a probe the console's own source carries"); + { + const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); + const runAssert = (injected, vendored, host, dist) => + spawnSync( + process.execPath, + [assert, '--injected', injected, '--vendored', vendored, '--objectui', host, '--assets', path.join(dist, 'assets')], + { encoding: 'utf8' }, + ); + const stampOf = (dist) => (fs.existsSync(path.join(dist, STAMP_BASENAME)) ? readStamp(dist) : null); + const SHARED = 'Shared text in both specs for the collision fixtures'; + // Published-only once the spec reworded them; objectui still writes both. + const MIRRORED = 'A marker line drawn like the Today marker, as the spec used to say'; + const MIRRORED_EXACT = "An exact registry description objectui's registry copied from the spec"; + const OBJECTUI_MARKERS = `${MIRRORED}. The renderer's own clause continues the sentence.`; + // objectui's source, as written — the apostrophes escaped inside single + // quotes, which is not how the bundler emits them — plus a TEST file quoting + // the published-only STALE text, the way objectui's parity tests do. + const quoteSingle = (text) => `'${text.replace(/'/g, "\\'")}'`; + const host = makeHostTree(path.join(root, 'cl-host'), { + 'packages/plugin-gantt/src/index.tsx': + `export const inputs = [\n` + + ` { name: 'markers', description: ${quoteSingle(OBJECTUI_MARKERS)} },\n` + + ` { name: 'criticalPath', description: ${quoteSingle(MIRRORED_EXACT)} },\n` + + `];\n`, + 'packages/plugin-gantt/src/__tests__/inputs-parity.test.ts': `expect(describe).toBe(${JSON.stringify(STALE)});\n`, + }); + // What a bundler emits for that registration. + const objectuiChunk = `register({inputs:[{name:"markers",description:${JSON.stringify(OBJECTUI_MARKERS)}},{name:"criticalPath",description:${JSON.stringify(MIRRORED_EXACT)}}]})`; + const injected = makeSpecPkg(path.join(root, 'cl-injected'), [FRESH, SHARED]); + const vendored = makeSpecPkg(path.join(root, 'cl-vendored'), [MIRRORED, MIRRORED_EXACT, STALE, SHARED]); + + // PIN 1 — this tree's spec plus objectui's literal beginning with (and one + // equal to) a published-only description PASSES, and stamps a detector + // objectui does not write. + const good = makeDist(path.join(root, 'cl-good'), `console(${JSON.stringify(FRESH)});${objectuiChunk}`, undefined); + const goodRun = runAssert(injected, vendored, host, good); + expect('pin 1: injected spec + objectui literals beginning with / equal to published-only text passes', goodRun.status, 0); + expect('pin 1: the pass says the host-carried text is not evidence', goodRun.stdout.includes('not evidence of either spec'), true); + const goodStamp = stampOf(good); + expect('pin 1: the passing build writes its stamp', goodStamp !== null, true); + expect('pin 1: the stamp records the FILTERED detector, never objectui\'s text', goodStamp?.packages?.[0]?.staleDetector, STALE); + expect('pin 1: the stamp records the witness the bundle carries', goodStamp?.packages?.[0]?.freshWitness, FRESH); + expect('pin 3: the replay agrees with the build on bundle 1', evaluate({ distDir: good, specDir: injected, requireStamp: true }).code, 0); + + // PIN 2 — a bundle carrying the PUBLISHED spec itself still FAILS, through a + // published-only description objectui does not write. STALE is quoted by + // objectui's TEST file only, so this case also pins that tests are not read + // as objectui's source: were they, STALE would be excused too and the + // verdict would be "cannot judge" (exit 2), not this exit 1. + const publishedJs = fs.readFileSync(path.join(vendored, 'dist', 'index.mjs'), 'utf8'); + const leaked = makeDist( + path.join(root, 'cl-leaked'), + `${publishedJs};console(${JSON.stringify(FRESH)});${objectuiChunk}`, + undefined, + ); + const leakedRun = runAssert(injected, vendored, host, leaked); + expect('pin 2: the published spec itself in the bundle still fails', leakedRun.status, 1); + expect('pin 2: the failure names a detector objectui does not write', leakedRun.stderr.includes(`"${STALE}"`), true); + expect('pin 2: objectui\'s mirrored text is never named as the detector', leakedRun.stderr.includes(`"${MIRRORED}"`), false); + expect('pin 2: a failing build writes no stamp', stampOf(leaked), null); + // The replay half: the stamp a good build wrote, beside these assets — a cache + // entry whose dist no longer matches its proof. (No stamp to copy is pin 1's + // failure, already registered above; the wording assertion below fails too.) + if (goodStamp) fs.copyFileSync(path.join(good, STAMP_BASENAME), path.join(leaked, STAMP_BASENAME)); + const leakedReplay = evaluate({ distDir: leaked, specDir: injected, requireStamp: true }); + expect('pin 3: the replay agrees with the build on bundle 2', leakedReplay.code, 1); + expect('pin 3: and says the dist carries the published spec', leakedReplay.err.join('\n').includes('carries the PUBLISHED'), true); + + // The leg with no probe left: the ONLY published-only description is in the + // bundle and objectui writes it. Not "no skew" and not "absent", so it is + // inconclusive, never a pass — the published spec would look exactly so. + const onlyMirrored = makeSpecPkg(path.join(root, 'cl-vendored-mirrored'), [MIRRORED, SHARED]); + const blind = makeDist(path.join(root, 'cl-blind'), `console(${JSON.stringify(FRESH)});${objectuiChunk}`, undefined); + const blindRun = runAssert(injected, onlyMirrored, host, blind); + expect('a stale leg whose every candidate is host-carried and bundled is inconclusive', blindRun.status, 2); + expect('and says the leg cannot judge the bundle', blindRun.stderr.includes('cannot judge this bundle'), true); + expect('and writes no stamp', stampOf(blind), null); + + // ABSENCE stays evidence: the same host-carried text, NOT in the bundle + // (objectui's chunk tree-shaken away), is absent from every source, so it is + // still a detector — this rule excuses presence, never absence. + const shaken = makeDist(path.join(root, 'cl-shaken'), `console(${JSON.stringify(FRESH)})`, undefined); + const shakenRun = runAssert(injected, onlyMirrored, host, shaken); + expect('host-carried text absent from the bundle still verifies as absent', shakenRun.status, 0); + expect('and is a legitimate stamped detector', stampOf(shaken)?.packages?.[0]?.staleDetector, MIRRORED); + expect('and the replay agrees', evaluate({ distDir: shaken, specDir: injected, requireStamp: true }).code, 0); + + // THE FRESH SIDE, same rule: an injected-only description objectui already + // writes is no witness. The bundle below holds objectui's literal and no spec + // at all — a pass before this rule, "neither spec appears" now. + const FRESH_MIRRORED = 'A key this tree declares in the words objectui already wrote'; + const freshHost = makeHostTree(path.join(root, 'cl-host-fresh'), { + 'packages/plugin/src/index.tsx': `export const d = ${quoteSingle(FRESH_MIRRORED)};\n`, + }); + const injectedMirror = makeSpecPkg(path.join(root, 'cl-injected-mirror'), [FRESH_MIRRORED, FRESH, SHARED]); + const vendoredPlain = makeSpecPkg(path.join(root, 'cl-vendored-plain'), [STALE, SHARED]); + const hollow = makeDist(path.join(root, 'cl-hollow'), `register(${JSON.stringify(FRESH_MIRRORED)})`, undefined); + const hollowRun = runAssert(injectedMirror, vendoredPlain, freshHost, hollow); + expect('a witness objectui writes itself does not prove the injection', hollowRun.status, 2); + expect('and the verdict is that neither spec appears', hollowRun.stderr.includes('Neither spec appears'), true); + + // CANNOT RUN: the objectui tree must be a checkout whose tracked files git + // can list — a directory walk would read node_modules (the published spec + // itself) as objectui's own source. + const notGit = fs.mkdtempSync(path.join(root, 'cl-not-git-')); + fs.writeFileSync(path.join(notGit, 'index.tsx'), `export const d = ${quoteSingle(MIRRORED)};\n`); + const notGitRun = runAssert(injected, vendored, notGit, makeDist(path.join(root, 'cl-not-git-dist'), `console(${JSON.stringify(FRESH)})`, undefined)); + expect('an objectui tree git cannot list is inconclusive', notGitRun.status, 2); + expect('and says it is not a git checkout', notGitRun.stderr.includes('not a git checkout'), true); + + // The shared module refuses to choose probes blind to objectui's source. + let blindChoice = null; + try { + chooseProbes({ injectedBlob: '', vendoredBlob: '', bundle: '' }); + } catch (error) { + blindChoice = error; + } + expect('chooseProbes without hostBlob throws a TypeError', blindChoice instanceof TypeError, true); + } + 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 2114de76453..903c3e80cce 100644 --- a/scripts/console-spec-probes.mjs +++ b/scripts/console-spec-probes.mjs @@ -7,7 +7,8 @@ * or the published one", at two different moments: * * scripts/assert-console-spec-injection.mjs — right after a build, with BOTH - * specs on disk. Derives the probes and STAMPS them into the dist. + * specs and objectui's build tree on disk. Derives the probes and STAMPS + * them into the dist. * scripts/check-console-injection.mjs — on every console-job run, * including a cache HIT, where the objectui build tree does not exist and * the published spec is therefore unavailable. Replays the stamped probes. @@ -22,6 +23,7 @@ * --self-test drives these functions directly. */ +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -129,6 +131,62 @@ export function uniqueCandidates(candidates, theirs) { return candidates.filter((candidate) => !theirs.includes(candidate)); } +/** Tracked files whose string literals a console bundle can carry. */ +const HOST_SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/; + +/** Tracked test files: source no console bundle carries. */ +const HOST_TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/; + +/** + * objectui's OWN source at the pin, as one blob: every tracked code file of the + * checkout the console was built from, test files excluded. + * + * A console bundle carries text from two places, the specs and objectui's own + * code, and objectui's component registry writes `inputs` descriptions that + * mirror spec `.describe()` text — verbatim, or as a prefix it then extends. + * Measured at objectui ab1879721595 over its non-test code: 18 literals equal a + * spec description and 18 more begin with one. When the spec rewords such a + * description, the old text becomes published-only while objectui's literal + * still carries it, and a substring search finds "the published spec" in a + * bundle built from this tree's spec (objectstack#21709: object-gantt `markers`). + * Probe text found here is therefore not evidence of either spec — see + * chooseProbes for how it is judged. + * + * TRACKED files, from `git ls-files`, because the build tree also holds + * objectui's node_modules (the published spec itself) and its built dists: + * reading those as "objectui's own source" would excuse exactly the text the + * stale leg exists to find. Tests are excluded because no bundle carries them, + * and objectui's parity tests quote spec descriptions on purpose — two of the 38 + * published-only descriptions measured at that pin sit in one — so counting + * them would only take evidence away from a real leak. + * + * The one normalisation is a quote's backslash: objectui writes + * `'…chart\'s range…'` where the bundler emits `"…chart's range…"`, and probe + * text never contains a backslash (describeCandidates drops those). Any other + * spelling difference leaves the text unmatched here, which keeps it EVIDENCE — + * the loud direction, never a silent pass. + */ +export function readHostSourceBlob(hostDir, label) { + if (!fs.existsSync(hostDir)) bad(`${label} source tree \`${hostDir}\` does not exist`); + const listed = spawnSync('git', ['-C', hostDir, 'ls-files', '-z'], { + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }); + if (listed.error || listed.status !== 0) { + const reason = listed.error ? listed.error.message : (listed.stderr || '').trim() || `git exited ${listed.status}`; + bad(`${label} source tree \`${hostDir}\` is not a git checkout whose tracked files can be listed (${reason})`); + } + const chunks = []; + for (const file of listed.stdout.split('\0')) { + if (!file || !HOST_SOURCE_FILE.test(file) || HOST_TEST_FILE.test(file)) continue; + const absolute = path.join(hostDir, file); + if (!fs.existsSync(absolute)) continue; + chunks.push(fs.readFileSync(absolute, 'utf8')); + } + if (chunks.length === 0) bad(`${label} source tree \`${hostDir}\` tracks no source files`); + return chunks.join('\n').replace(/\\(['"`])/g, '$1'); +} + /** * The two probes for ONE built console bundle, chosen with the bundle in view. * @@ -166,19 +224,56 @@ export function uniqueCandidates(candidates, theirs) { * * `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. + * + * ## Text objectui's own source carries is not evidence (objectstack#21709) + * + * A candidate is in the bundle either because a spec put it there or because + * objectui's own code did — readHostSourceBlob says why the second is routine. + * `hostBlob` is that code, and a candidate it carries is judged by what its + * presence can and cannot show: + * + * - PRESENT in the bundle and carried by `hostBlob`: proves nothing about + * either spec. It is counted (`counts.hostCarried`) and never decides a + * verdict, and it is never returned as a probe — the stamp replays the + * probes returned here, and a stamped detector that objectui's own text puts + * in every build would fail every cache-hit replay of a good dist. + * - ABSENT from the bundle: still evidence, whoever else carries it. The text + * is not there, from any source. + * + * So `inBundle` counts EVIDENCE only, and a bundle carrying the published spec + * still fails: every published-only description objectui does not write is + * still a detector. One state is new, and the caller must refuse it: a side + * whose unique candidates are ALL present and host-carried has no probe left + * (`freshWitness` / `staleDetector` null) while its pool is non-empty — a + * leg that cannot judge this bundle, which is not the same thing as no skew. */ -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)); +export function chooseProbes({ injectedBlob, vendoredBlob, bundle, hostBlob }) { + if (typeof hostBlob !== 'string') { + throw new TypeError('chooseProbes needs hostBlob: objectui\'s own source, from readHostSourceBlob'); + } + const judge = (pool) => { + const inBundle = pool.filter((candidate) => bundle.includes(candidate)); + const hostCarried = new Set(inBundle.filter((candidate) => hostBlob.includes(candidate))); + const evidence = inBundle.filter((candidate) => !hostCarried.has(candidate)); + const usable = pool.filter((candidate) => !hostCarried.has(candidate)); + return { + probe: evidence[0] ?? usable[0] ?? null, + present: pool.length === 0 ? null : evidence.length > 0, + counts: { pool: pool.length, inBundle: evidence.length, hostCarried: hostCarried.size }, + hostCarried: [...hostCarried], + }; + }; + const fresh = judge(uniqueCandidates(describeCandidates(injectedBlob), vendoredBlob)); + const stale = judge(uniqueCandidates(describeCandidates(vendoredBlob), injectedBlob)); 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 }, + freshWitness: fresh.probe, + freshPresent: fresh.present, + freshCounts: fresh.counts, + freshHostCarried: fresh.hostCarried, + staleDetector: stale.probe, + stalePresent: stale.present, + staleCounts: stale.counts, + staleHostCarried: stale.hostCarried, }; }