diff --git a/packages/spec/scripts/build-api-surface.ts b/packages/spec/scripts/build-api-surface.ts index 6f945962748..ac338bf9fa1 100644 --- a/packages/spec/scripts/build-api-surface.ts +++ b/packages/spec/scripts/build-api-surface.ts @@ -61,7 +61,7 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { inspectDistFreshness } from './lib/dist-freshness'; +import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness'; import { API_SURFACE_DIR_NAME, aggregateApiSurfaceShards, @@ -84,8 +84,11 @@ const freshness = inspectDistFreshness( `pnpm --filter @objectstack/spec ${CHECK ? 'check' : 'gen'}:api-surface`, ); if (!freshness.fresh) { - console.error(freshness.message); - process.exit(1); + // PREREQUISITE NOT MET, not a finding (#19227): nothing below this line ran, + // so the code says so rather than borrowing the one a real breaking-change + // report uses. + console.error(prerequisiteNotMetText(`${CHECK ? 'check' : 'gen'}:api-surface`, freshness)); + process.exit(EXIT_PREREQUISITE_NOT_MET); } /** Public entry points → their built CJS `.d.ts`, read from the exports map. */ diff --git a/packages/spec/scripts/check-browser-reachable-entries.ts b/packages/spec/scripts/check-browser-reachable-entries.ts index c5e26994a93..ee9735a4d29 100644 --- a/packages/spec/scripts/check-browser-reachable-entries.ts +++ b/packages/spec/scripts/check-browser-reachable-entries.ts @@ -154,7 +154,11 @@ import { fileURLToPath } from 'node:url'; import { buildStamp } from '../../../scripts/check-regen-pending.mjs'; import { scanSource } from '../../../scripts/js-comment-mask.mjs'; -import { inspectBundleFreshness } from './lib/dist-freshness'; +import { + EXIT_PREREQUISITE_NOT_MET, + inspectBundleFreshness, + prerequisiteNotMetText, +} from './lib/dist-freshness'; const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const LEDGER_PATH = join(PKG_DIR, 'browser-reachable-entries.json'); @@ -518,8 +522,11 @@ function reconcile(exportsMap: ExportsMap, ledger: Ledger, problems: string[]): function audit(): never { const freshness = inspectBundleFreshness(PKG_DIR, 'check', RERUN); if (!freshness.fresh) { - console.error(`❌ check:browser-reachable-entries — NOT MEASURED.${freshness.message}`); - process.exit(1); + // This refusal already SAID "NOT MEASURED" in prose and then exited with a + // finding's code, so the sentence and the number disagreed and only the + // number is machine-read (#19227). Both now say the same thing. + console.error(prerequisiteNotMetText('check:browser-reachable-entries', freshness)); + process.exit(EXIT_PREREQUISITE_NOT_MET); } const pkg = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8')) as { diff --git a/packages/spec/scripts/check-dual-source-exports.ts b/packages/spec/scripts/check-dual-source-exports.ts index a23a1bb1263..b1c07457df6 100644 --- a/packages/spec/scripts/check-dual-source-exports.ts +++ b/packages/spec/scripts/check-dual-source-exports.ts @@ -65,7 +65,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { inspectDistFreshness } from './lib/dist-freshness'; +import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness'; const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); const BASELINE_PATH = resolve(PKG_DIR, 'dual-source-exports.baseline.json'); @@ -396,8 +396,16 @@ const freshness = inspectDistFreshness( : 'pnpm --filter @objectstack/spec check:dual-source-exports', ); if (!freshness.fresh) { - console.error(freshness.message); - process.exit(1); + // PREREQUISITE NOT MET, not a finding (#19227) — in BOTH modes. `--update` is + // the writing half, and a refusal there is still "nothing was measured": the + // baseline on disk is untouched, so no ratchet verdict exists to report. + console.error( + prerequisiteNotMetText( + UPDATE ? 'check-dual-source-exports.ts --update' : 'check:dual-source-exports', + freshness, + ), + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); } const entries = collectEntries(); diff --git a/packages/spec/scripts/check-entry-nameability.ts b/packages/spec/scripts/check-entry-nameability.ts index 27993a012bc..bf1895613f0 100644 --- a/packages/spec/scripts/check-entry-nameability.ts +++ b/packages/spec/scripts/check-entry-nameability.ts @@ -163,7 +163,7 @@ import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -import { inspectDistFreshness } from './lib/dist-freshness'; +import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG_DIR = path.resolve(HERE, '..'); @@ -583,8 +583,18 @@ function main(): number { const freshness = inspectDistFreshness(PKG_DIR, 'check', RERUN); if (!freshness.fresh) { - console.error(freshness.message); - return 1; + // PREREQUISITE NOT MET, not a finding (#19227). ⚠️ This gate returns its + // code up to `process.exit(main())` rather than exiting inline, which is + // why a `grep -c 'process.exit(1)'` over this file reads 0 while the + // refusal was there the whole time — the instrument, not the tree. + // + // The three refusals BELOW keep `return 1` deliberately: a dead canary, a + // probe that fails to compile for an unrelated reason and an enumeration + // that found zero callable exports are all findings about this gate or its + // subject, and each says so in its own text ("Both are findings; neither is + // a pass"). Only the unmet build prerequisite is "nothing was measured". + console.error(prerequisiteNotMetText('check:entry-nameability', freshness)); + return EXIT_PREREQUISITE_NOT_MET; } const manifest = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8')) as { diff --git a/packages/spec/scripts/check-exported-any.ts b/packages/spec/scripts/check-exported-any.ts index 99c2fbe7253..8e3498ca72b 100644 --- a/packages/spec/scripts/check-exported-any.ts +++ b/packages/spec/scripts/check-exported-any.ts @@ -84,7 +84,7 @@ import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { inspectDistFreshness } from './lib/dist-freshness'; +import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness'; const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); const SELF_TEST = process.argv.includes('--self-test'); @@ -447,8 +447,11 @@ const freshness = inspectDistFreshness( 'pnpm --filter @objectstack/spec check:exported-any', ); if (!freshness.fresh) { - console.error(freshness.message); - process.exit(1); + // PREREQUISITE NOT MET, not a finding (#19227). The `process.exit(1)` above — + // the self-test's — stays exactly what it was: that one IS a finding about + // the detector. This path measured nothing at all. + console.error(prerequisiteNotMetText('check:exported-any', freshness)); + process.exit(EXIT_PREREQUISITE_NOT_MET); } const entries = collectEntries(); diff --git a/packages/spec/scripts/check-skill-examples.ts b/packages/spec/scripts/check-skill-examples.ts index eff077ea39a..bf5153085f1 100644 --- a/packages/spec/scripts/check-skill-examples.ts +++ b/packages/spec/scripts/check-skill-examples.ts @@ -297,7 +297,12 @@ import os from 'os'; import path from 'path'; import ts from 'typescript'; -import { inspectDistFreshness } from './lib/dist-freshness'; +import { + EXIT_PREREQUISITE_NOT_MET, + inspectDistFreshness, + prerequisiteNotMetText, + type DistFreshness, +} from './lib/dist-freshness'; // ── Paths ──────────────────────────────────────────────────────────────────── @@ -1471,6 +1476,30 @@ function refuse(message: string): never { process.exit(1); } +/** + * PREREQUISITE NOT MET — "this gate never got to look", as distinct from BOTH + * of the two above: `fail()` looked and found something, `refuse()` produced no + * result because the gate's own invariant broke. This one produced no result + * because the WORKSPACE is not built (#19227). + * + * A third token for the same reason the file already carries two — the states + * need spellings a reader can tell apart at a glance — but the half that + * matters here is the EXIT CODE, which is what a machine reads: + * `scripts/pm/dispatch-gates.mjs --ran` derives its NOT-MEASURED class from the + * recorded code and counts every other non-kill code as a family that RAN. So + * an unmet build prerequisite spelled `exit 1` is reconciled as coverage the + * round does not have. ⛔ `fail()` and `refuse()` keep exit 1 on purpose: both + * are real verdicts, and this change must not move either. + * + * ⚠️ It exits rather than throwing, like its two siblings, so the `exit` + * listener installed by `withBuildDirCleanup` is what removes the build dirs — + * `finally` does not run through `process.exit()`. + */ +function prerequisiteNotMet(text: string): never { + console.error(`${text}\n`); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + // ── Self-test ──────────────────────────────────────────────────────────────── /** @@ -3241,18 +3270,16 @@ function main() { // dist-independent and worth reporting even when a build is stale. So the // guard sits at the boundary rather than at the top: no verdict below it is // computed for a stale surface, and no honest finding above it is suppressed. - let staleMessage: string | null = null; + let stale: Extract | null = null; for (const pkgDir of surface.selfPackages) { const freshness = inspectDistFreshness(pkgDir, 'check', 'pnpm --filter @objectstack/spec check:skill-examples'); if (!freshness.fresh) { - staleMessage = freshness.message; + stale = freshness; break; } } - if (staleMessage) { - console.error(`\n[${surface.name}]`); - console.error(staleMessage); - process.exit(1); + if (stale) { + prerequisiteNotMet(prerequisiteNotMetText(`check:skill-examples [${surface.name}]`, stale)); } const { paths, missing } = surfacePaths(surface.selfPackages); @@ -3263,11 +3290,18 @@ function main() { // mtime rule alone would read as fresh. const unbuiltSelfPackages = surface.selfPackages.filter((dir) => !fs.existsSync(paths[pkgName(dir)]?.[0] ?? '')); if (unbuiltSelfPackages.length > 0) { - fail( - `[${surface.name}] not built — no declarations to check examples against:\n\n` + - missing.map((m) => ` - ${m} (missing)`).join('\n') + - `\n\n Build first (CI does this in the "Build workspace packages" step):\n\n` + - unbuiltSelfPackages.map((d) => ` pnpm --filter ${pkgName(d)} build`).join('\n'), + // Same event as the staleness guard above, reached the other way, so it + // answers with the same code (#19227) — ⛔ not `fail()`, whose exit 1 + // said "these examples are wrong" about examples nothing ever compiled. + prerequisiteNotMet( + prerequisiteNotMetText(`check:skill-examples [${surface.name}]`, { + headline: 'a self-package on this surface has no declarations to check examples against', + detail: + `\n` + + missing.map((m) => ` - ${m} (missing)`).join('\n') + + `\n\n Build first (CI does this in the "Build workspace packages" step):\n\n` + + unbuiltSelfPackages.map((d) => ` pnpm --filter ${pkgName(d)} build`).join('\n'), + }), ); } diff --git a/packages/spec/scripts/dist-freshness-adoption.test.ts b/packages/spec/scripts/dist-freshness-adoption.test.ts index 8edc01f8189..4c6651ee7f8 100644 --- a/packages/spec/scripts/dist-freshness-adoption.test.ts +++ b/packages/spec/scripts/dist-freshness-adoption.test.ts @@ -43,6 +43,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { EXIT_PREREQUISITE_NOT_MET } from './lib/dist-freshness'; + const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG = path.resolve(HERE, '..'); const REPO_ROOT = path.resolve(PKG, '../..'); @@ -181,9 +183,20 @@ function runGate(spec: string, script: string, args: string[] = []): SpawnSyncRe }); } -/** Both halves of the refusal: it fired, and it named the right gate. */ +/** + * All three halves of the refusal: it fired, it named the right gate, and it + * answered with the code that says NOTHING WAS MEASURED. + * + * The third half moved in #19227 and this helper is where its pin lives. It was + * `1` — a real finding's code — which is what let `dispatch-gates --ran` + * reconcile a gate that refused before its first `.d.ts` read as a family that + * ran. ⛔ Do not relax it back to "non-zero": that assertion passes for exactly + * the defect this card removed. + */ function expectRefusal(run: SpawnSyncReturns, rerun: string): void { - expect(run.status).toBe(1); + expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET); + expect(run.stderr).toContain('PREREQUISITE NOT MET'); + expect(run.stderr).toContain('Nothing was measured'); expect(run.stderr).toContain('OLDER than packages/spec/src'); expect(run.stderr).toContain('pnpm --filter @objectstack/spec build'); expect(run.stderr).toContain(rerun); @@ -245,7 +258,9 @@ describe('check:dual-source-exports refuses a stale dist (#7181)', () => { seedDualBaseline(tree.spec); const run = runGate(tree.spec, DUAL, ['--update']); - expect(run.status).toBe(1); + // The WRITING half refuses with the same code (#19227): it wrote nothing, + // so it has no ratchet verdict to report and did not measure one. + expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET); expect(run.stderr).toContain('WRITE a baseline'); expect(fs.readFileSync(path.join(tree.spec, DUAL_BASELINE), 'utf8')).toBe(SENTINEL); }); diff --git a/packages/spec/scripts/dist-freshness.test.ts b/packages/spec/scripts/dist-freshness.test.ts index 1879d0174ca..13bbdd4a161 100644 --- a/packages/spec/scripts/dist-freshness.test.ts +++ b/packages/spec/scripts/dist-freshness.test.ts @@ -31,7 +31,13 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { inspectDistFreshness, packageDirLabel } from './lib/dist-freshness'; +import { + EXIT_FINDINGS, + EXIT_PREREQUISITE_NOT_MET, + inspectDistFreshness, + packageDirLabel, + prerequisiteNotMetText, +} from './lib/dist-freshness'; import { declarationStamp } from '../../../scripts/check-regen-pending.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -470,7 +476,7 @@ describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => { seed(tree.spec, { distMtime: OLD, srcMtime: NEW }); const run = runGenerator(tree.spec, []); - expect(run.status).toBe(1); + expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET); expect(run.stderr).toContain('OLDER than packages/spec/src'); expect(run.stderr).toContain('pnpm --filter @objectstack/spec build'); expect(baseline(tree.spec)).toBe(SENTINEL); @@ -484,7 +490,7 @@ describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => { seed(tree.spec, { distMtime: OLD, srcMtime: NEW }); const run = runGenerator(tree.spec, ['--check']); - expect(run.status).toBe(1); + expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET); expect(run.stderr).toContain('FALSE GREEN'); expect(run.stdout).not.toContain('unchanged'); }); @@ -495,9 +501,92 @@ describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => { for (const args of [[], ['--check']]) { const run = runGenerator(tree.spec, args); - expect(run.status).toBe(1); + expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET); expect(run.stderr).toContain('no .d.ts declarations'); } expect(baseline(tree.spec)).toBe(SENTINEL); }); }); + +// ── The exit code the refusal answers with (#19227) ────────────────────────── +// +// Until this card the five dist-reading gates refused with `process.exit(1)` — +// a real finding's code — while `scripts/import-prerequisite.mjs:250` declared +// `EXIT_PREREQUISITE_NOT_MET = 3` for these exact two words. The consumer that +// makes the difference concrete is `scripts/pm/dispatch-gates.mjs --ran`: it +// derives its NOT-MEASURED class from the recorded code and counts every other +// non-kill code as a family that RAN. +// +// These pins are the spec-side half of keeping one vocabulary. The root-side +// half is that module's own `--self-test`, which asserts the same 3 there; the +// value is hand-carried between the two (the reason is in the constant's +// docblock), so BOTH pins have to exist or the two declarations can fork in +// silence. +describe('the prerequisite exit code, and the refusal text that explains it (#19227)', () => { + it('is 3, the number this repo already means by PREREQUISITE NOT MET', () => { + expect(EXIT_PREREQUISITE_NOT_MET).toBe(3); + }); + + it("is distinct from a finding's — the whole point, and the half a machine reads", () => { + expect(EXIT_FINDINGS).toBe(1); + expect(EXIT_PREREQUISITE_NOT_MET).not.toBe(EXIT_FINDINGS); + expect(EXIT_PREREQUISITE_NOT_MET).not.toBe(0); + }); + + it('agrees with the root frame that declares it, read from that module', async () => { + // The drift check, against the authority rather than against a copy of it. + // `packages/spec` already declares `scripts/**` in CROSS_PACKAGE_TEST_INPUTS, + // so this read is inside a declared radius. + // + // ⚠️ The cast goes through `unknown` because `scripts/import-prerequisite.d.mts` + // is PARTIAL BY DESIGN — its own header says the exit-code constants are + // deliberately omitted from the mirror — so tsc sees a namespace without + // them. That is exactly why the two `typeof` assertions are here rather + // than implied: without them the cast would make a vanished export read as + // `undefined` on both sides of a comparison nobody would notice. + const frame = (await import('../../../scripts/import-prerequisite.mjs')) as unknown as { + EXIT_PREREQUISITE_NOT_MET?: number; + EXIT_FINDINGS?: number; + }; + expect(typeof frame.EXIT_PREREQUISITE_NOT_MET).toBe('number'); + expect(typeof frame.EXIT_FINDINGS).toBe('number'); + expect(EXIT_PREREQUISITE_NOT_MET).toBe(frame.EXIT_PREREQUISITE_NOT_MET); + expect(EXIT_FINDINGS).toBe(frame.EXIT_FINDINGS); + }); + + it('carries the fleet phrase, so it greps beside its siblings', () => { + // `check-dts-closure`, `check-dual-build-cjs-loads`, `check-i18n-bundles`, + // `check-i18n-coverage` and `check-closing-target-claim` all print + // `: PREREQUISITE NOT MET — …`. A reader who learned the phrase from + // one of those must find this one with the same grep. + write('dist/contracts/index.d.ts', 'export {};', OLD); + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + + const verdict = inspectDistFreshness(sandbox, 'check', 'pnpm run check:example'); + expect(verdict.fresh).toBe(false); + if (verdict.fresh) throw new Error('unreachable: the sandbox dist is stale'); + + const text = prerequisiteNotMetText('check:example', verdict); + expect(text).toContain('check:example: PREREQUISITE NOT MET'); + // The verdict's own words are passed through, not paraphrased. + expect(text).toContain(verdict.message); + // And the sentence a number alone cannot carry. + expect(text).toContain('Nothing was measured'); + expect(text).toContain(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`); + expect(text).toContain(`distinct from a finding's ${EXIT_FINDINGS}`); + }); + + it('wraps a caller-composed refusal the same way — the partial-dist arm', () => { + // `check:skill-examples` refuses a self-package whose `.d.ts` was never + // emitted, which the mtime rule reads as fresh, so that refusal is not an + // `inspectDistFreshness` verdict at all. Same trailer, one source. + const text = prerequisiteNotMetText('check:skill-examples [spec]', { + headline: 'a self-package on this surface has no declarations', + detail: '\n - packages/client-react/dist/index.d.ts (missing)', + }); + expect(text).toContain('check:skill-examples [spec]: PREREQUISITE NOT MET'); + expect(text).toContain('a self-package on this surface has no declarations'); + expect(text).toContain('packages/client-react/dist/index.d.ts (missing)'); + expect(text).toContain('Nothing was measured'); + }); +}); diff --git a/packages/spec/scripts/lib/dist-freshness.ts b/packages/spec/scripts/lib/dist-freshness.ts index 167a8d21387..64053fc6e93 100644 --- a/packages/spec/scripts/lib/dist-freshness.ts +++ b/packages/spec/scripts/lib/dist-freshness.ts @@ -363,3 +363,105 @@ export function inspectBundleFreshness( ` the .d.ts-reading gates next door that it blinds.)`, }; } + +// ── The exit code a dist-precondition refusal answers with (#19227) ────────── + +/** + * PREREQUISITE NOT MET — the code every refusal composed here exits with. + * + * ## Why this constant exists at all + * + * The five gates that adopted the precondition above all spelled their refusal + * `process.exit(1)` — the number a REAL FINDING uses. At the only place a + * caller looks, "this tree is wrong" and "I could not read the tree" were the + * same reading, and the cost is not cosmetic: `scripts/pm/dispatch-gates.mjs + * --ran` derives its NOT-MEASURED class from the recorded exit code and counts + * anything else as a family that RAN, so a gate that refused before its first + * `.d.ts` read was reconciled as coverage the round did not have. + * + * ## Why 3, and why the value is hand-carried rather than imported + * + * 3 is this repo's declared vocabulary for these two words: + * `scripts/import-prerequisite.mjs:250` declares + * `export const EXIT_PREREQUISITE_NOT_MET = 3` and argues the case at length in + * its own docblock — that file is the authority, and it is NOT re-decided here. + * + * It is not IMPORTED from there for two measured reasons, and neither is a + * preference: + * + * - that module is a dependency LOADER. Its frame (`requireDependency`, + * `requireDefaultExport`) answers "can this gate import the package it + * names?"; a stale `dist` is a different prerequisite reached a different + * way, so adopting the frame would drag in machinery that has nothing to + * say here while adopting none of the part that fits. + * - `scripts/import-prerequisite.d.mts` is PARTIAL BY DESIGN and declares + * only the two loaders — its own header says the exit-code constants are + * deliberately omitted — so a TypeScript consumer importing this name gets + * TS2305 until that declaration mirror is extended, which is a root-tree + * edit outside this change's surface. + * + * What keeps the two in step is a pin on each side: that module's `--self-test` + * asserts `EXIT_PREREQUISITE_NOT_MET === 3`, and `dist-freshness.test.ts` pins + * this one to the same value and to being distinct from `EXIT_FINDINGS`. ⛔ Do + * not "simplify" either pin away — a second declaration whose value nothing + * asserts is how the vocabulary forks. + */ +export const EXIT_PREREQUISITE_NOT_MET = 3; + +/** + * A gate's real verdict, named here only so the refusal text can say which code + * it is distinct FROM. Mirrors `scripts/import-prerequisite.mjs`'s constant of + * the same name; ⛔ nothing in this file ever exits with it. + */ +export const EXIT_FINDINGS = 1; + +/** + * The refusal a dist-reading gate prints, in the shape the rest of the fleet + * already prints it. + * + * `: PREREQUISITE NOT MET — …` is not decoration: `check-dts-closure`, + * `check-dual-build-cjs-loads`, `check-i18n-bundles`, `check-i18n-coverage` and + * `check-closing-target-claim` all emit that exact phrase, so a reader (or a + * `grep` over a CI log) finds this refusal beside its siblings instead of + * having to know that `packages/spec` words the same event differently. The + * verdict's own `message` — the cause, the damage and the two-line build remedy + * — is passed through BYTE FOR BYTE; this only wraps it. + * + * The closing paragraph is the half the number alone cannot carry: a reader who + * sees a non-zero code and no sentence has no way to learn that nothing was + * measured, which is the whole defect #19227 names. + * + * The second argument arm exists because one adopter's build prerequisite is + * not an `inspectDistFreshness` verdict at all: `check:skill-examples` ALSO + * refuses a self-package whose `.d.ts` was never emitted, which the mtime rule + * reads as fresh. Same event, same remedy, same code — so it gets the same + * wrapper rather than a second trailer nobody keeps in step. + * + * @param gate this gate's own name, e.g. `check:exported-any` — the caller's + * identity, for the same reason `inspectDistFreshness` takes `rerun`: a + * default would hand a new caller the previous gate's name (#7181). + * @param refusal the refusing verdict from `inspectDistFreshness` / + * `inspectBundleFreshness`, or a caller-composed `{ headline, detail }`. + */ +export function prerequisiteNotMetText( + gate: string, + refusal: Extract | { headline: string; detail: string }, +): string { + const headline = + 'headline' in refusal + ? refusal.headline + : refusal.state === 'missing' + ? 'this gate reads built output, and there is none to read' + : 'this gate reads built output, and what is on disk predates the sources'; + const detail = 'headline' in refusal ? refusal.detail : refusal.message; + return ( + `\n${gate}: PREREQUISITE NOT MET — ${headline}\n` + + `${detail}\n\n` + + ` Nothing was measured: this gate refused before reading a single declaration, so this\n` + + ` result says NOTHING about what it gates. It is NOT a finding, and it is not evidence\n` + + ` that anything in the tree is wrong.\n` + + ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:\n` + + ` \` > /tmp/gate.log 2>&1; echo "EXIT=$?"\`. Piped, \`$?\` is the LAST command's\n` + + ` status, and \`head\`/\`tail\` essentially never fail — that is the false green.)` + ); +}