From 3497032ac9dc33fc10cca8943e6e4c262a9cbf6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:05:14 +0000 Subject: [PATCH] fix(ci): verify the RESTORED console dist bundles this tree's spec, not only the built one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console dist cache key is hashFiles('.objectui-sha', 'scripts/build-console.sh') — spelled identically in ci.yml (restore + save) and release.yml. packages/spec is not in it, and scripts/assert-console-spec-injection.mjs runs INSIDE build-console.sh, whose step is skipped on a cache hit. So the run that did not build the dist is exactly the run that never asks whether the dist is right. Adding packages/spec to the key was rejected on cost (~20 min cold rebuild per spec change, ~18 merges/day). This keeps the key and the deliberate split restore/save untouched and removes only the silence: - scripts/console-spec-probes.mjs — probe derivation, extracted so the build-time assertion and the restore-time gate cannot drift apart. - assert-console-spec-injection.mjs — same behaviour and exit codes; now stamps the probes it chose into dist/.objectstack-injection.json, written only after every assertion is green. - scripts/check-console-injection.mjs — replays the stamped probes against whatever dist is on disk, cache hit or miss, and re-checks that the stamped probe still discriminates instead of trusting it forever. Fails rather than rebuilds: Actions cache keys are immutable, so a gate that responded by rebuilding could not evict the entry and would rebuild on every run until the pin moved. Every failure names its remedy, including the exact `gh cache delete` line for the key that produced it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .github/workflows/ci.yml | 33 ++ package.json | 1 + scripts/assert-console-spec-injection.mjs | 157 +++---- scripts/check-console-injection.mjs | 497 ++++++++++++++++++++++ scripts/console-spec-probes.mjs | 176 ++++++++ 5 files changed, 764 insertions(+), 100 deletions(-) create mode 100644 scripts/check-console-injection.mjs create mode 100644 scripts/console-spec-probes.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028dc69efc..5b18340951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: - '.objectui-sha' - 'scripts/build-console.sh' - 'scripts/check-console-sha.mjs' + - 'scripts/check-console-injection.mjs' + - 'scripts/console-spec-probes.mjs' + - 'scripts/assert-console-spec-injection.mjs' - '.github/workflows/ci.yml' test: @@ -1472,6 +1475,36 @@ jobs: - name: Verify the Console dist stamp matches the pin run: pnpm check:console-sha + # The spec-injection half of the same question, and the reason it is a + # SEPARATE step from build-console.sh (#9667). + # + # The dist cache key is hashFiles('.objectui-sha', 'scripts/build-console.sh') + # — packages/spec is deliberately NOT in it, because adding it would bust the + # key on every spec change and force a ~20 min cold console rebuild on a repo + # doing ~18 merges a day. The cost model stays; what does not stay is the + # silence. scripts/assert-console-spec-injection.mjs runs INSIDE + # build-console.sh, so the `if: cache-hit != 'true'` above skips it exactly + # when the dist was NOT built here — which is the run that most needs asking. + # + # That script cannot simply be re-run on this path: it derives its stale + # detector from the PUBLISHED @objectstack/spec in objectui's own lockfile, + # and on a cache hit there is no objectui clone to read it from. So the build + # stamps the probes it chose into dist/.objectstack-injection.json and this + # step replays them against whatever is actually on disk — one node process, + # no network, cheap enough to run on every PR (which is the whole point; a + # gate too expensive to always run is a gate that gets skipped). + # + # --require-stamp because the vacuity that check:console-sha tolerates is + # wrong here for the same reason the presence assert above exists: an + # unstamped dist is one no build ever proved, including one saved by a run + # that died before the assertion. release.yml uses the COMBINED cache action, + # whose post-step saves even on failure, under this same repo-scoped key — so + # a poisoned entry is reachable from here and must not pass quietly. + - name: Verify the restored Console dist bundles this tree's spec + env: + CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} + run: pnpm check:console-injection --require-stamp + # Reached only with every assertion above green (see the split-restore note). # This deviates from the workflow's restore-only-on-PRs policy on purpose: # that policy targets the ~5 turbo entries every push writes, whereas this diff --git a/package.json b/package.json index 7b4ce6120a..c261a96e45 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs", "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", "check:console-sha": "node scripts/check-console-sha.mjs", + "check:console-injection": "node scripts/check-console-injection.mjs --self-test && node scripts/check-console-injection.mjs", "check:dev-prereqs": "node scripts/check-dev-prereqs.mjs --self-test && node scripts/check-dev-prereqs.mjs", "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", "check:objectui-pin-fresh": "node scripts/check-objectui-pin-fresh.mjs --self-test && node scripts/check-objectui-pin-fresh.mjs", diff --git a/scripts/assert-console-spec-injection.mjs b/scripts/assert-console-spec-injection.mjs index d2e88cf6be..51faeeff76 100644 --- a/scripts/assert-console-spec-injection.mjs +++ b/scripts/assert-console-spec-injection.mjs @@ -35,20 +35,22 @@ // positive evidence that the published spec is still in the bundle. The fresh // witness alone cannot distinguish "injection worked" from "some other copy". // -// ## Substring safety +// ## Probe derivation lives in ./console-spec-probes.mjs // -// A probe is only usable if a literal search can tell the two specs apart, so -// each candidate is checked against the ENTIRE other spec's built output, not -// against a string set. Descriptions are routinely REWORDED by appending a -// clause, which makes the old text a prefix of the new one — three of the first -// candidates measured here were exactly that, and a set-difference check called -// them unique when a substring search would have matched both. +// Not here, because objectstack#9667 added a SECOND consumer: this script can +// only run where BOTH specs exist, which is inside a build. On a console-dist +// cache HIT there is no objectui build tree and therefore no vendored spec, so +// nothing could re-ask the question about the restored artifact. Two scripts, +// one derivation — see that module's header. // -// ## When the two specs agree +// ## This script also writes the provenance stamp // -// If neither side has text the other lacks, there is nothing to detect and the -// check reports "no skew" and exits 0. That is a real state — the build right -// after a spec publish — not a failure. +// On success it records the probes it chose into `/.objectstack-injection.json`, +// so `pnpm check:console-injection` can replay them against a restored dist that +// this script can no longer be run against. Written LAST, only once every +// assertion below is green — unlike the `.objectui-sha` stamp, which +// build-console.sh writes before its canary assert (the asymmetry ci.yml's +// split restore/save comment calls out). // // Usage: // node scripts/assert-console-spec-injection.mjs \ @@ -59,13 +61,16 @@ // Exit: 0 = injection proven (or no skew to prove) · 1 = injection failed // 2 = inconclusive / cannot run -import fs from 'node:fs'; import path from 'node:path'; -/** Export conditions a browser/ESM bundler picks, in preference order. - * `types` is deliberately absent — it sits first in each condition object and - * would resolve every subpath at a `.d.mts` file. */ -const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default']; +import { + ProbeError, + describeCandidates, + pickProbe, + readBundle, + readSpecBlob, + writeStamp, +} from './console-spec-probes.mjs'; function fail(message) { console.error(`✗ assert-console-spec-injection: ${message}`); @@ -86,101 +91,52 @@ function parseArgs(argv) { return out; } -function pickImportTarget(value) { - if (typeof value === 'string') return value; - if (value === null || typeof value !== 'object') return null; - if (Array.isArray(value)) { - for (const candidate of value) { - const hit = pickImportTarget(candidate); - if (hit) return hit; - } - return null; - } - for (const condition of IMPORT_CONDITIONS) { - if (!Object.hasOwn(value, condition)) continue; - const hit = pickImportTarget(value[condition]); - if (hit) return hit; - } - return null; -} - -/** Every JS file a package's exports map resolves to, concatenated once. */ -function readSpecBlob(packageDir, label) { - const manifestPath = path.join(packageDir, 'package.json'); - if (!fs.existsSync(manifestPath)) fail(`${label} spec has no package.json at \`${manifestPath}\``); - let exportsMap; - try { - exportsMap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).exports; - } catch (error) { - fail(`${label} \`${manifestPath}\` is not readable JSON (${error.message})`); - } - if (!exportsMap || typeof exportsMap !== 'object') fail(`${label} spec declares no exports map`); - - const chunks = []; - for (const value of Object.values(exportsMap)) { - const target = pickImportTarget(value); - if (!target || !/\.(js|mjs|cjs)$/.test(target)) continue; - const absolute = path.resolve(packageDir, target); - if (!fs.existsSync(absolute)) continue; - chunks.push(fs.readFileSync(absolute, 'utf8')); - } - if (chunks.length === 0) fail(`${label} spec at \`${packageDir}\` has no built JavaScript to compare`); - return chunks.join('\n'); -} - -/** - * Candidate probe strings: Zod `.describe()` arguments. - * - * They are prose written by spec authors, which makes them stable across a - * bundler (plain string literals, preserved by minification) and specific enough - * that a match is not a coincidence — the property objectstack#8134's own - * measurement relied on, and the reason a bare key name like `object` is - * unusable here (`optionsFrom.object` false-positives). - */ -function describeCandidates(blob) { - const found = new Set(); - const pattern = /\.describe\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g; - for (const match of blob.matchAll(pattern)) { - const text = match[2]; - // Long enough to be unique, short enough to survive intact, and free of - // escapes and line breaks so a literal search means what it says. - if (text.length < 32 || text.length > 160) continue; - if (/[\\\r\n]/.test(text)) continue; - found.add(text); - } - return [...found].sort(); -} - -/** First candidate present in `mine` and absent from `theirs`, as raw text. */ -function pickProbe(candidates, theirs) { - for (const candidate of candidates) { - if (!theirs.includes(candidate)) return candidate; - } - return null; -} - const args = parseArgs(process.argv); const assetsDir = path.resolve(args.assets); -if (!fs.existsSync(assetsDir)) fail(`assets dir \`${assetsDir}\` does not exist`); -const assetChunks = []; -for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) { - if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) { - assetChunks.push(fs.readFileSync(path.join(assetsDir, entry.name), 'utf8')); - } +// The dist root is the assets dir's parent: build-console.sh passes +// `/assets`, so this is where the .objectui-sha stamp already lives. +const distDir = path.dirname(assetsDir); + +let bundle; +let injectedBlob; +let vendoredBlob; +try { + bundle = readBundle(assetsDir); + injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected'); + vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored'); +} catch (error) { + if (!(error instanceof ProbeError)) throw error; + fail(error.message); } -if (assetChunks.length === 0) fail(`no JavaScript assets under \`${assetsDir}\``); -const bundle = assetChunks.join('\n'); - -const injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected'); -const vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored'); const freshWitness = pickProbe(describeCandidates(injectedBlob), vendoredBlob); const staleDetector = pickProbe(describeCandidates(vendoredBlob), injectedBlob); +/** Record what this build proved, for check:console-injection to replay. */ +function stamp(skew) { + try { + writeStamp(distDir, [ + { + name: '@objectstack/spec', + injectedFrom: path.resolve(args.injected), + skew, + freshWitness, + staleDetector, + }, + ]); + } catch (error) { + // A dist we cannot stamp is still a dist this script just proved good. + // Failing here would turn a green build red over provenance bookkeeping; + // check:console-injection reports the missing stamp on its own terms. + console.warn(`⚠ could not write the injection stamp: ${error.message}`); + } +} + if (!freshWitness && !staleDetector) { 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); } @@ -223,4 +179,5 @@ 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}"`); +stamp(true); process.exit(0); diff --git a/scripts/check-console-injection.mjs b/scripts/check-console-injection.mjs new file mode 100644 index 0000000000..b5dba91a07 --- /dev/null +++ b/scripts/check-console-injection.mjs @@ -0,0 +1,497 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check:console-injection — re-ask the spec-injection question about a console + * dist that was RESTORED FROM CACHE rather than built. + * + * ## The hole this closes (objectstack#9667) + * + * The vendored Console SPA is cached under + * + * ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} + * + * spelled identically in ci.yml (twice: restore + save) and release.yml. The key + * does NOT include packages/spec, so a dist built while spec was at state X is + * restored and reused after spec moves on — and because + * scripts/assert-console-spec-injection.mjs runs INSIDE build-console.sh, a + * cache hit skips the entire build step and therefore skips the assertion too. + * The injection fixed resolution; the cache could still serve a console whose + * bundled spec is not the one this build proved. + * + * Adding packages/spec to the cache key was considered and REJECTED: it busts + * the key on every spec change and forces a full cold console rebuild (~20 min, + * measured) on a repo doing ~18 merges a day. The cache's economics — including + * ci.yml's deliberate split restore/save, which exists so a failed build never + * poisons the entry — are kept exactly as they are. Only the silent half is + * removed: this gate runs on every console-job run, cache hit or miss. + * + * ## Why it replays a STAMP instead of re-deriving probes + * + * assert-console-spec-injection.mjs derives its two probes from the two specs on + * disk — this tree's packages/spec, and the published @objectstack/spec that + * objectui's own lockfile installs. On a cache hit the second one DOES NOT + * EXIST: no objectui clone, no node_modules, nothing to compare against. So that + * script cannot simply be re-run here, and a version of it that fetched the + * published spec would put a network round-trip on every PR. + * + * Instead the build stamps the probes it chose into + * `/.objectstack-injection.json`, and this gate replays them against the + * restored bundle. Cost: one node process reading files already on disk. + * + * ## The stamped probes are checked for EXPIRY, not trusted forever + * + * A frozen probe is exactly the failure objectstack#8134 was filed about: #7804's + * `describe()` text ended up carried by the published 17.0.0 too, so grepping for + * it matched before AND after and proved nothing. A stale detector is only + * evidence while it still tells the two specs apart, so this gate re-checks that + * the stamped detector is STILL ABSENT from this tree's spec. Once the published + * spec catches up, the stamp is expired and says so instead of passing. + * + * ## Failure response: FAIL, deliberately, rather than rebuild + * + * GitHub Actions cache keys are IMMUTABLE. A gate that reacted to a bad restore + * by rebuilding could not evict the offending entry, so the next run would + * restore it and rebuild again — a ~20 min rebuild on every run until the pin + * moves, which is strictly worse than the cost that got the cache-key option + * rejected. Failing once, with the eviction command spelled out, is the cheaper + * and more honest response. Every failure below names its remedy. + * + * Exit codes: + * 0 verified; or no dist to verify; or an unstamped dist without --require-stamp + * 1 the restored dist is not one this repo can vouch for (see the message) + * 2 cannot run (unreadable assets, malformed stamp) + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + ProbeError, + STAMP_BASENAME, + describeCandidates, + pickProbe, + readBundle, + readSpecBlob, + readStamp, +} from './console-spec-probes.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * How an operator clears a cached dist this gate refused. + * + * ⛔ The `gh cache delete` line is MAINTAINER-ONLY — it needs repo write scope, + * and it deletes an entry shared by ci.yml and release.yml. It is named anyway + * because ruling out a remedy an operator cannot discover is how a red becomes + * noise; a contributor who cannot run it should hand this message to a + * maintainer rather than guess. + */ +function remedy(cacheKey) { + const key = cacheKey || '${{ runner.os }}-console-dist-${{ hashFiles(\'.objectui-sha\', \'scripts/build-console.sh\') }}'; + return [ + ' How to clear this:', + '', + ' • Locally — rebuild the console at the pinned SHA:', + '', + ' pnpm objectui:build', + '', + ' • In CI — the restored artifact is a CACHE ENTRY, not anything in this PR.', + " Nothing in the diff can fix it; the entry has to go. ⛔ MAINTAINER-ONLY:", + '', + ` gh cache delete "${key}"`, + '', + ' then re-run the Console Pin Gate job. The next run misses, rebuilds,', + ' and re-stamps.', + ]; +} + +/** + * The one evaluation path. `main()` and `--self-test` both go through here, so + * the self-test exercises the real logic rather than a parallel imitation. + * + * Returns `{ code, out, err }` instead of printing, so the self-test can assert + * on verdicts without capturing stdout. + */ +export function evaluate({ distDir, specDir, requireStamp = false, cacheKey = '' }) { + const out = []; + const err = []; + const rel = (p) => path.relative(ROOT, p) || p; + + const index = path.join(distDir, 'index.html'); + if (!fs.existsSync(index)) { + // Published installs and package-only CI legitimately have no dist. Same + // policy as check:console-sha — but --require-stamp callers have already + // asserted a dist is present and a missing one means the restore broke. + if (requireStamp) { + err.push(`✗ No console dist at ${rel(distDir)} — nothing to verify, but a dist was required.`); + return { code: 1, out, err }; + } + out.push(`ℹ No console dist at ${rel(distDir)} — skipping injection check. Build it with: pnpm objectui:build`); + return { code: 0, out, err }; + } + + let stamp; + let bundle; + try { + stamp = readStamp(distDir); + bundle = readBundle(path.join(distDir, 'assets')); + } catch (error) { + if (!(error instanceof ProbeError)) throw error; + err.push(`✗ check:console-injection cannot run: ${error.message}`); + return { code: 2, out, err }; + } + + if (!stamp) { + // A dist built by a build-console.sh that predates the stamp, assembled by + // hand, or — the case that matters — written by a run that never reached the + // assertion. Unprovable either way. + const lines = [ + `⚠ Console dist at ${rel(distDir)} carries no ${STAMP_BASENAME} stamp,`, + ' so whether it bundles THIS tree\'s @objectstack/spec cannot be verified.', + ]; + if (!requireStamp) { + lines.push(' Rebuild once to enable the guard: pnpm objectui:build'); + out.push(...lines); + return { code: 0, out, err }; + } + err.push( + `✗ Console dist at ${rel(distDir)} carries no ${STAMP_BASENAME} stamp.`, + ' This job consumes a dist that may have been RESTORED FROM CACHE, and an', + ' unstamped dist is one no build ever proved the spec injection for —', + ' including a dist saved by a run that failed before the assertion.', + '', + ...remedy(cacheKey), + ); + return { code: 1, out, err }; + } + + // The tree's own spec, for the expiry re-check. Absent when spec is not built + // — a real state for a bare checkout, and not a reason to fail: the bundle + // assertions below stand on their own. + let treeBlob = null; + let treeBlobNote = ''; + try { + treeBlob = readSpecBlob(specDir, 'this tree\'s'); + } catch (error) { + if (!(error instanceof ProbeError)) throw error; + treeBlobNote = error.message; + } + + let asserted = 0; + for (const entry of stamp.packages) { + const name = entry?.name || ''; + + if (!entry?.skew) { + out.push(`ℹ ${name}: the build recorded no observable skew between the injected and`); + out.push(' published spec, so there is no probe that could tell them apart.'); + continue; + } + + const { freshWitness, staleDetector } = entry; + + if (staleDetector && bundle.includes(staleDetector)) { + err.push( + `✗ The console dist carries the PUBLISHED ${name}, not this tree's.`, + '', + ' This dist was RESTORED FROM CACHE or built without the injection. Any', + ' authorable key the framework declared after the last spec publish is', + ' unreachable in the Studio designer — the defect objectstack#8134 exists', + ' to end, reaching main one layer out through the cache.', + '', + ' Text in the bundle that only the PUBLISHED spec has:', + ` "${staleDetector}"`, + '', + ...remedy(cacheKey), + ); + return { code: 1, out, err }; + } + + if (freshWitness && !bundle.includes(freshWitness)) { + err.push( + `✗ The console dist does not carry the ${name} content its own stamp records.`, + '', + ' The stamp was written beside this dist by the build that produced it, so', + ' the witness below MUST be in these assets. It is not — the artifact and', + ' its stamp disagree, which means a partial cache restore or a dist that', + ' was modified after it was proved.', + '', + ' Expected in the bundle (from the stamp):', + ` "${freshWitness}"`, + '', + ...remedy(cacheKey), + ); + return { code: 1, out, err }; + } + + // Expiry. A stale detector is evidence only while it still separates the two + // specs; once this tree's spec also contains it, a bundle that "passes" is + // proving nothing at all. + if (staleDetector && treeBlob && treeBlob.includes(staleDetector)) { + err.push( + `✗ The stamped staleness probe for ${name} has EXPIRED.`, + '', + ' The probe is text that used to exist ONLY in the published spec. This', + " tree's spec now contains it too, so a bundle carrying it is no longer", + ' distinguishable from one carrying the injected spec. The check would', + ' pass from here on while proving nothing — the silent pass this gate', + ' exists to prevent, so it reports the expiry instead.', + '', + ' Expired probe:', + ` "${staleDetector}"`, + '', + ' The dist itself is not known to be bad. It is UNVERIFIABLE, and a fresh', + ' build re-derives a probe that discriminates again.', + '', + ...remedy(cacheKey), + ); + return { code: 1, out, err }; + } + + asserted += 1; + out.push(`✓ ${name}: the dist carries this tree's copy, and not the published one.`); + if (freshWitness) out.push(` present (injected only): "${freshWitness}"`); + if (staleDetector) out.push(` absent (published only): "${staleDetector}"`); + } + + if (treeBlobNote && asserted > 0) { + out.push(`ℹ Probe expiry not re-checked: ${treeBlobNote}`); + out.push(' (build the spec — `pnpm --filter @objectstack/spec build` — to enable it)'); + } + + if (asserted === 0) { + out.push('ℹ Nothing assertable in this stamp; the dist was not contradicted.'); + } + + return { code: 0, out, err }; +} + +// ── self-test ──────────────────────────────────────────────────────────────── + +function tmpdir(name) { + const dir = fs.mkdtempSync(path.join(fs.realpathSync(process.env.RUNNER_TEMP || '/tmp'), `${name}-`)); + return dir; +} + +/** A minimal package whose exports map resolves to one built ESM file. */ +function makeSpecPkg(dir, descriptions) { + fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name: '@objectstack/spec', + exports: { '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } } }, + }), + ); + const body = descriptions.map((d) => `z.string().describe(${JSON.stringify(d)})`).join(';\n'); + fs.writeFileSync(path.join(dir, 'dist', 'index.mjs'), `${body}\n`); + return dir; +} + +/** A minimal console dist: index.html, one JS asset, optionally a stamp. */ +function makeDist(dir, assetText, stamp) { + fs.mkdirSync(path.join(dir, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.html'), ''); + fs.writeFileSync(path.join(dir, 'assets', 'app-abc123.js'), assetText); + if (stamp !== undefined) { + fs.writeFileSync(path.join(dir, STAMP_BASENAME), typeof stamp === 'string' ? stamp : JSON.stringify(stamp)); + } + return dir; +} + +const FRESH = 'Authorable key this tree declares and the registry reads'; +const STALE = 'Published description that only the vendored spec carries'; + +function stampFor({ skew = true, freshWitness = FRESH, staleDetector = STALE } = {}) { + return { + stampVersion: 1, + generatedBy: 'scripts/assert-console-spec-injection.mjs', + packages: [{ name: '@objectstack/spec', injectedFrom: '/x/packages/spec', skew, freshWitness, staleDetector }], + }; +} + +function selfTest() { + const failures = []; + let checked = 0; + const root = tmpdir('check-console-injection'); + + const expect = (label, actual, wanted) => { + checked += 1; + if (actual !== wanted) failures.push(`${label}: expected ${wanted}, got ${actual}`); + }; + + const specDir = makeSpecPkg(path.join(root, 'spec-ahead'), [FRESH, 'A shared description both specs carry always']); + + // 1. Clean pass: fresh witness in the bundle, stale detector nowhere. + { + const dist = makeDist(path.join(root, 'ok'), `console(${JSON.stringify(FRESH)})`, stampFor()); + expect('clean pass', evaluate({ distDir: dist, specDir }).code, 0); + } + + // 2. THE DEFECT: the restored bundle carries the published spec. + // + // The fixture carries BOTH probes on purpose. A bundle holding only the + // published text also trips the missing-fresh-witness branch below, so a + // fixture like that proves nothing about THIS branch — asserting the exit code + // alone passed with the stale-detector check deleted, which is how this case + // was found to be vacuous. Both strings present is also the real shape: the + // console bundle holds a second, transitive copy of this tree's spec via the + // injected client, which is exactly why the check is two-sided. + { + const dist = makeDist( + path.join(root, 'stale'), + `console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`, + stampFor(), + ); + const r = evaluate({ distDir: dist, specDir, cacheKey: 'Linux-console-dist-deadbeef' }); + expect('published spec in bundle fails', r.code, 1); + const text = r.err.join('\n'); + checked += 1; + // Branch-unique wording, not the shared remedy block: every failure path + // prints remedy(), so keying on it cannot tell the branches apart. + if (!text.includes('carries the PUBLISHED')) { + failures.push('published-spec failure must say the dist carries the published spec'); + } + checked += 1; + if (!text.includes('gh cache delete "Linux-console-dist-deadbeef"')) { + failures.push('published-spec failure must name the exact cache key to delete'); + } + } + + // 3. Partial restore: the stamp's own witness is missing from the assets. + { + const dist = makeDist(path.join(root, 'partial'), 'console("unrelated bundle text")', stampFor()); + const r = evaluate({ distDir: dist, specDir }); + expect('missing fresh witness fails', r.code, 1); + checked += 1; + if (!r.err.join('\n').includes('does not carry the @objectstack/spec content its own stamp records')) { + failures.push('missing-witness failure must name the stamp/artifact disagreement'); + } + } + + // 4. Probe expiry: this tree's spec now carries the stale detector too. + { + const caught = makeSpecPkg(path.join(root, 'spec-caught-up'), [FRESH, STALE]); + const dist = makeDist(path.join(root, 'expired'), `console(${JSON.stringify(FRESH)})`, stampFor()); + const r = evaluate({ distDir: dist, specDir: caught }); + expect('expired probe fails', r.code, 1); + checked += 1; + if (!r.err.join('\n').includes('EXPIRED')) failures.push('expiry failure must say the probe expired'); + } + + // 5/6. Unstamped dist: advisory by default, fatal under --require-stamp. + { + const dist = makeDist(path.join(root, 'unstamped'), `console(${JSON.stringify(FRESH)})`, undefined); + expect('unstamped is advisory', evaluate({ distDir: dist, specDir }).code, 0); + expect('unstamped is fatal when required', evaluate({ distDir: dist, specDir, requireStamp: true }).code, 1); + } + + // 7. No dist at all: nothing to verify, unless one was required. + { + const dist = path.join(root, 'absent'); + expect('no dist passes', evaluate({ distDir: dist, specDir }).code, 0); + expect('no dist fails when required', evaluate({ distDir: dist, specDir, requireStamp: true }).code, 1); + } + + // 8. A build that found no skew records it, and this gate says so honestly. + { + const dist = makeDist( + path.join(root, 'noskew'), + 'console("anything")', + stampFor({ skew: false, freshWitness: null, staleDetector: null }), + ); + const r = evaluate({ distDir: dist, specDir }); + expect('no-skew stamp passes', r.code, 0); + checked += 1; + if (!r.out.join('\n').includes('no observable skew')) failures.push('no-skew stamp must say so'); + } + + // 9/10. A stamp that cannot be trusted is inconclusive, never a quiet pass. + { + const bad = makeDist(path.join(root, 'malformed'), 'console("x")', '{not json'); + expect('malformed stamp is inconclusive', evaluate({ distDir: bad, specDir }).code, 2); + const old = makeDist(path.join(root, 'oldversion'), 'console("x")', { stampVersion: 0, packages: [] }); + expect('wrong stampVersion is inconclusive', evaluate({ distDir: old, specDir }).code, 2); + } + + // 11. Substring safety, the property the whole probe scheme rests on: a + // reworded description makes the OLD text a PREFIX of the new one, and a + // set-difference check would call it unique. pickProbe must not. + { + checked += 1; + const oldText = 'The label shown above the field in forms'; + const newText = `${oldText} and in the record detail header`; + const reworded = pickProbe(describeCandidates(`x.describe(${JSON.stringify(oldText)})`), `y.describe(${JSON.stringify(newText)})`); + if (reworded !== null) { + failures.push(`pickProbe returned a prefix of a reworded string as unique: ${JSON.stringify(reworded)}`); + } + checked += 1; + const genuine = pickProbe(describeCandidates(`x.describe(${JSON.stringify(FRESH)})`), `y.describe(${JSON.stringify(newText)})`); + if (genuine !== FRESH) failures.push('pickProbe failed to find a genuinely unique candidate'); + } + + // 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. + { + const injected = makeSpecPkg(path.join(root, 'rt-injected'), [FRESH, 'Shared text in both specs for the round trip']); + const vendored = makeSpecPkg(path.join(root, 'rt-vendored'), [STALE, 'Shared text in both specs for the round trip']); + const dist = makeDist(path.join(root, 'rt-dist'), `console(${JSON.stringify(FRESH)})`, undefined); + 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')], + { encoding: 'utf8' }, + ); + expect('assert script passes on a good fixture', run.status, 0); + checked += 1; + if (!fs.existsSync(path.join(dist, STAMP_BASENAME))) { + failures.push('assert script did not write the injection stamp'); + } else { + expect('round trip: this gate accepts the stamp the assert script wrote', evaluate({ distDir: dist, specDir: injected }).code, 0); + } + } + + fs.rmSync(root, { recursive: true, force: true }); + + if (failures.length > 0) { + console.error(`✗ check-console-injection --self-test -- ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` ${f}`); + process.exit(1); + } + console.log(`✓ check-console-injection --self-test: ${checked} assertions over real fixture trees (real evaluate() path)`); +} + +// ── entry point ────────────────────────────────────────────────────────────── + +// Guarded so `evaluate` is genuinely importable. Without this, `import`ing this +// module RUNS the CLI — it would evaluate the default dist path and call +// process.exit() before the importer's first line, which is what the assert +// script's top-level-argv shape does and the reason probe derivation was moved +// into a side-effect-free module. Measured here: the first attempt to drive +// evaluate() from a script exited 0 with "no console dist" and never reached the +// caller's code. +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (!invokedDirectly) { + // imported as a module — expose evaluate() and do nothing else +} else if (process.argv.includes('--self-test')) { + selfTest(); +} else { + const argOf = (flag, fallback) => { + const i = process.argv.indexOf(flag); + return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback; + }; + const result = evaluate({ + distDir: path.resolve(argOf('--dist', path.join(ROOT, 'packages', 'console', 'dist'))), + specDir: path.resolve(argOf('--spec', path.join(ROOT, 'packages', 'spec'))), + requireStamp: process.argv.includes('--require-stamp'), + cacheKey: process.env.CONSOLE_DIST_CACHE_KEY || '', + }); + for (const line of result.out) console.log(line); + if (result.err.length > 0) console.error(`\n${result.err.join('\n')}\n`); + process.exit(result.code); +} diff --git a/scripts/console-spec-probes.mjs b/scripts/console-spec-probes.mjs new file mode 100644 index 0000000000..a731cb51e8 --- /dev/null +++ b/scripts/console-spec-probes.mjs @@ -0,0 +1,176 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared probe derivation for the vendored-Console spec-injection guards. + * + * TWO scripts ask "does this console bundle carry THIS tree's @objectstack/spec + * 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. + * 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. + * + * They must derive probes IDENTICALLY: the second script re-asserts strings the + * first one chose, so two implementations that drift would silently stop + * agreeing about what a probe even is. Hence one module, imported by both, + * rather than a copy in each — the copy is what rots. + * + * Nothing here exits the process; callers own exit codes. A module that called + * process.exit() could not be unit-tested, and check-console-injection's + * --self-test drives these functions directly. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** Basename of the provenance stamp written into the console dist. */ +export const STAMP_BASENAME = '.objectstack-injection.json'; + +/** Shape version of that stamp. Bump only on an incompatible field change. */ +export const STAMP_VERSION = 1; + +/** Export conditions a browser/ESM bundler picks, in preference order. + * `types` is deliberately absent — it sits first in each condition object and + * would resolve every subpath at a `.d.mts` file. */ +const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default']; + +/** Raised for every "cannot run" condition, so callers map it to their own + * inconclusive exit code instead of inheriting one from this module. */ +export class ProbeError extends Error {} + +const bad = (message) => { + throw new ProbeError(message); +}; + +export function pickImportTarget(value) { + if (typeof value === 'string') return value; + if (value === null || typeof value !== 'object') return null; + if (Array.isArray(value)) { + for (const candidate of value) { + const hit = pickImportTarget(candidate); + if (hit) return hit; + } + return null; + } + for (const condition of IMPORT_CONDITIONS) { + if (!Object.hasOwn(value, condition)) continue; + const hit = pickImportTarget(value[condition]); + if (hit) return hit; + } + return null; +} + +/** Every JS file a package's exports map resolves to, concatenated once. */ +export function readSpecBlob(packageDir, label) { + const manifestPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(manifestPath)) bad(`${label} spec has no package.json at \`${manifestPath}\``); + let exportsMap; + try { + exportsMap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).exports; + } catch (error) { + bad(`${label} \`${manifestPath}\` is not readable JSON (${error.message})`); + } + if (!exportsMap || typeof exportsMap !== 'object') bad(`${label} spec declares no exports map`); + + const chunks = []; + for (const value of Object.values(exportsMap)) { + const target = pickImportTarget(value); + if (!target || !/\.(js|mjs|cjs)$/.test(target)) continue; + const absolute = path.resolve(packageDir, target); + if (!fs.existsSync(absolute)) continue; + chunks.push(fs.readFileSync(absolute, 'utf8')); + } + if (chunks.length === 0) bad(`${label} spec at \`${packageDir}\` has no built JavaScript to compare`); + return chunks.join('\n'); +} + +/** + * Candidate probe strings: Zod `.describe()` arguments. + * + * They are prose written by spec authors, which makes them stable across a + * bundler (plain string literals, preserved by minification) and specific enough + * that a match is not a coincidence — the property objectstack#8134's own + * measurement relied on, and the reason a bare key name like `object` is + * unusable here (`optionsFrom.object` false-positives). + */ +export function describeCandidates(blob) { + const found = new Set(); + const pattern = /\.describe\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g; + for (const match of blob.matchAll(pattern)) { + const text = match[2]; + // Long enough to be unique, short enough to survive intact, and free of + // escapes and line breaks so a literal search means what it says. + if (text.length < 32 || text.length > 160) continue; + if (/[\\\r\n]/.test(text)) continue; + found.add(text); + } + return [...found].sort(); +} + +/** + * First candidate present in `mine` and absent from `theirs`, as raw text. + * + * SUBSTRING, not set difference. Descriptions are routinely reworded by + * appending a clause, which makes the old text a PREFIX of the new one — three + * of the first candidates measured for objectstack#8134 were exactly that, and a + * set-difference check called them unique when a substring search matched both. + */ +export function pickProbe(candidates, theirs) { + for (const candidate of candidates) { + if (!theirs.includes(candidate)) return candidate; + } + return null; +} + +/** Concatenated JavaScript of a built console `assets/` directory. */ +export function readBundle(assetsDir) { + if (!fs.existsSync(assetsDir)) bad(`assets dir \`${assetsDir}\` does not exist`); + const chunks = []; + for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) { + if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) { + chunks.push(fs.readFileSync(path.join(assetsDir, entry.name), 'utf8')); + } + } + if (chunks.length === 0) bad(`no JavaScript assets under \`${assetsDir}\``); + return chunks.join('\n'); +} + +/** + * Write the provenance stamp beside a console dist. + * + * `entries` is an ARRAY, not a `spec` field: objectstack#9659 records that 4 of 6 + * `@objectstack/*` packages in the console build tree still resolve from + * objectui's lockfile, and any of them gaining an injection later belongs in the + * same staleness question. Appending an entry must not need a shape change. + */ +export function writeStamp(distDir, entries) { + const stamp = { + stampVersion: STAMP_VERSION, + generatedBy: 'scripts/assert-console-spec-injection.mjs', + packages: entries, + }; + fs.writeFileSync(path.join(distDir, STAMP_BASENAME), `${JSON.stringify(stamp, null, 2)}\n`); +} + +/** Read and shape-check a stamp. Returns null when the dist carries none. */ +export function readStamp(distDir) { + const file = path.join(distDir, STAMP_BASENAME); + if (!fs.existsSync(file)) return null; + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + bad(`\`${file}\` is not readable JSON (${error.message})`); + } + if (!parsed || typeof parsed !== 'object') bad(`\`${file}\` is not a JSON object`); + if (parsed.stampVersion !== STAMP_VERSION) { + bad( + `\`${file}\` is stampVersion ${JSON.stringify(parsed.stampVersion)}, expected ${STAMP_VERSION} — ` + + 'rebuild the console so the stamp matches this checkout', + ); + } + if (!Array.isArray(parsed.packages)) bad(`\`${file}\` has no \`packages\` array`); + return parsed; +}