From 147dacd8df57e7b0d2d81fbea2e8de87161cdc05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:22:51 +0000 Subject: [PATCH] fix(devx): answer an unbuilt closure with exit 3 PREREQUISITE NOT MET, once check:published-readme-exports discovered the unbuilt-closure precondition once per README import, so an unbuilt worktree answered exit 1 -- the code reserved for "a README is wrong" -- under 198 `Build first` lines naming READMEs the reader never touched. The precondition is now detected up front, before the type surface is built and before one document is judged, and answered once with the sibling banner and exit 3. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/check-published-readme-exports.mjs | 398 +++++++++++++++++++-- 1 file changed, 369 insertions(+), 29 deletions(-) diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index 703195b479..af8744f37a 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -8,6 +8,32 @@ // node scripts/check-published-readme-exports.mjs --self-test // node scripts/check-published-readme-exports.mjs --unread-report // +// ## Exit codes -- and why an UNBUILT CLOSURE has one of its own +// +// 0 every published document was read against a BUILT type surface, and +// every symbol it documents exists there. +// 1 a FINDING: a README disagrees with a type entry that EXISTS, or the +// baseline went stale. A claim about the tree. +// 3 PREREQUISITE NOT MET. The built type entries this gate reads are not +// there, so nothing was measured and the run says NOTHING about any +// README. NOT a pass, and NOT a finding. +// +// The split is the sibling convention, not a local invention: +// `check-type-check-coverage.mjs` publishes the identical table and +// `check-dual-build-cjs-loads.mjs` answers the IDENTICAL condition -- a gate +// that reads built output, run against a tree with no `dist/` -- with 3. This +// gate used to answer it with 1, one line per import statement: on an unbuilt +// worktree that is 198 `Build first` lines under the exit code reserved for "a +// README is wrong", naming only READMEs the reader never touched. Measured +// twice in one day on one seat, by two agents who each had to read the lines to +// decide the red was not theirs. The cost past the reading is the one that +// matters: an agent that learns to discount this gate's 1 has un-gated it. +// +// ⛔ The boundary, deliberately. Exit 3 is for what the CHECKOUT lacks, never +// for what the repo publishes. A subpath whose `exports` declares no types at +// all, and a type entry that EXISTS but cannot be read as a module, stay +// findings at 1 -- `pnpm build` repairs neither, so neither is a prerequisite. +// // ## The bug it exists to prevent (#9532, from #9517) // // `packages/plugins/plugin-audit/README.md` documented a `PluginAudit` class @@ -98,9 +124,12 @@ // That makes this gate build-dependent, and build-dependent gates have a // characteristic failure: on a fresh checkout `dist/` is absent, the scan // reads nothing, and a green result means "not measured" while looking exactly -// like "measured and clean" (#4690). ⇒ A missing type entry is a HARD ERROR -// naming the build command, never a skip. The gate runs in the workflow job -// that has already built the workspace, next to the other dist-reading checks. +// like "measured and clean" (#4690). ⇒ An absent type entry is never a skip. +// It is answered UP FRONT -- before the type surface is built and before one +// document is judged -- and with the code for "nothing was measured" rather +// than the one for "a README is wrong": see the exit-code table above. The gate +// runs in the workflow job that has already built the workspace, next to the +// other dist-reading checks, where that state does not arise. // // ## The population axis, and the FOUR refusals on it (#9911, #10417) // @@ -286,7 +315,8 @@ // `--self-test` exercises the branch in BOTH directions over a fixture, so // the coverage is real at a tree population of 0. -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, posix, resolve } from 'node:path'; import process from 'node:process'; import { requireDefaultExport } from './import-prerequisite.mjs'; @@ -306,6 +336,15 @@ const ROOT = resolve(import.meta.dirname, '..'); const SELF = 'scripts/check-published-readme-exports.mjs'; const BASELINE_REL = 'scripts/published-readme-exports.baseline.json'; +// The three answers this gate can give, named rather than typed as a literal at +// each `return`. The whole value of the exit-code split is that a reader -- a CI +// step, a wrapper, an agent reconciling a gate family -- classifies a run from +// the number alone, and four hand-typed numbers are one careless edit away from +// two states sharing one meaning. +const EXIT_OK = 0; +const EXIT_FINDINGS = 1; +const EXIT_PREREQUISITE_NOT_MET = 3; + /** * ⛔ THIS GATE DECLARES NO WORKSPACE POPULATION, DELIBERATELY (#10542). * @@ -940,7 +979,7 @@ export function resolveTypesEntry(manifest, subpath) { * inherited them and gone from 2 matched files to 5397 — precisely the * fabricated population the docblock above measured and refused. */ -const workspaceDirs = () => workspacePackageDirs(ROOT); +const workspaceDirs = (root = ROOT) => workspacePackageDirs(root); /** Package-relative POSIX paths of every non-build file in a package. */ function walk(absDir, prefix = '', out = []) { @@ -954,9 +993,9 @@ function walk(absDir, prefix = '', out = []) { } /** The published markdown of a package, minus the excluded-by-design set. */ -function publishedMarkdown(dir, files) { +function publishedMarkdown(dir, files, root = ROOT) { const matchers = files.map(filesMatcher); - return walk(join(ROOT, dir)) + return walk(join(root, dir)) .filter((rel) => rel.toLowerCase().endsWith('.md')) .filter((rel) => !MARKDOWN_EXCLUDED.has(posix.basename(rel))) .filter((rel) => matchers.some((m) => m(rel))) @@ -1337,8 +1376,8 @@ export function analyzeDocument(doc, resolveTarget, measured = null) { // Baseline // --------------------------------------------------------------------------- -function loadBaseline() { - const abs = join(ROOT, BASELINE_REL); +function loadBaseline(root = ROOT) { + const abs = join(root, BASELINE_REL); if (!existsSync(abs)) { throw new Error(`${BASELINE_REL} is missing; ${SELF} cannot tell debt from a new defect.`); } @@ -1789,14 +1828,22 @@ export function reachedTargets(docs, byName) { * would disagree the first time a package's `files` array changed — silently, * each still green. So it is computed HERE and imported, never re-derived. * + * The `root` option exists for ONE caller: this gate's own self-test, which + * drives the whole pipeline over a scratch workspace so both directions of the + * exit-code split are pinned against a REAL run rather than a fake resolver + * (#15600). It defaults to the repo, so no production path is parameterised by + * anything -- the verdict still cannot depend on where the gate was invoked + * from, which is what the `ROOT` docblock above promises. + * * @param {string} [caller] gate name to attribute an empty-population error to + * @param {{root?: string}} [options] scratch root, for this gate's self-test * @returns {{ members: {dir: string, manifest: any}[], byName: Map, * docs: {pkg: string, file: string, text: string}[] }} */ -export function publishedDocs(caller = SELF) { - const members = workspaceDirs().map((dir) => ({ +export function publishedDocs(caller = SELF, { root = ROOT } = {}) { + const members = workspaceDirs(root).map((dir) => ({ dir, - manifest: JSON.parse(readFileSync(join(ROOT, dir, 'package.json'), 'utf8')), + manifest: JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')), })); const byName = new Map(); for (const m of members) if (m.manifest.name) byName.set(m.manifest.name, m); @@ -1806,11 +1853,11 @@ export function publishedDocs(caller = SELF) { if (!manifest.name || manifest.private === true) continue; const files = Array.isArray(manifest.files) ? manifest.files : []; if (files.length === 0) continue; // the packaging guard owns that failure - for (const file of publishedMarkdown(dir, files)) { + for (const file of publishedMarkdown(dir, files, root)) { docs.push({ pkg: manifest.name, file: posix.join(dir, file), - text: readFileSync(join(ROOT, dir, file), 'utf8'), + text: readFileSync(join(root, dir, file), 'utf8'), }); } } @@ -1855,14 +1902,101 @@ export function surfaceTarget(surface, abs) { } /** - * @param {{unreadReport?: boolean}} [options] `--unread-report` prints the + * The type entries this run would judge that are NOT BUILT, as one row per + * package, sorted. + * + * PURE and exported for the reason every decision in this file is lifted out of + * `run`: the self-test can drive it in both directions without a tree, and the + * production path has exactly one copy of the predicate. + * + * ⛔ The predicate is narrow on purpose, and the two states it deliberately + * EXCLUDES are the boundary of the exit-3 class. A subpath whose `exports` + * declares no types at all, and an entry that exists but cannot be read as a + * module, are statements about what the repo PUBLISHES -- `pnpm build` repairs + * neither -- so both stay findings at 1. Only "declared, resolved to a path, + * and that path is not on disk" is a statement about the CHECKOUT. That is why + * the target carries an explicit `unbuilt` flag rather than this function + * re-deriving the state from the prose of `missing`: two spellings of one + * condition drift, and the direction they drift in here decides an exit code. + * + * @param {Map} targets + * @param {{root?: string}} [options] + * @returns {string[]} ` -> ` rows, one per package + */ +export function unbuiltTypeEntries(targets, { root = ROOT } = {}) { + const rows = new Map(); + for (const t of targets.values()) { + if (!t.unbuilt) continue; + rows.set(t.name, `${t.name} -> ${posix.relative(root, t.abs)}`); + } + return [...rows.keys()].sort().map((name) => rows.get(name)); +} + +/** + * The refusal an unbuilt closure prints, in the words of the state it names. + * + * ⛔ ANY unbuilt entry refuses the whole run, not the packages that happen to be + * built. A half-measured tree cannot say "every published README agrees with its + * package's built surface" -- it can only say so about the half it read, which + * is precisely the reading (#4690) this gate exists to refuse. Naming the + * unbuilt packages keeps the remedy per-package for a reader who wants it. + * + * @param {string[]} unbuilt rows from `unbuiltTypeEntries` + * @returns {string|null} `null` when every entry the run would judge is built + */ +export function unbuiltClosureRefusal(unbuilt) { + if (unbuilt.length === 0) return null; + const shown = unbuilt.slice(0, 8); + return ( + `${unbuilt.length} package(s) whose built type entry this run would read are not built,\n` + + ` so not one of the READMEs importing from them could be judged:\n\n` + + shown.map((row) => ` · ${row}`).join('\n') + + (unbuilt.length > shown.length ? `\n · … ${unbuilt.length - shown.length} more` : '') + + `\n\n Run \`pnpm build\` (or \`pnpm --filter build\` for each) and re-run this gate.` + ); +} + +/** + * The banner a refusal prints, as a VALUE -- so the self-test can assert on the + * advisory without spawning a process or stubbing `process.exit`. + * + * The wording is COPIED from `check-type-check-coverage.mjs`, adapted only in + * the gate name, the claim it disclaims and the remedy command. That is the + * whole point of the class: a reader who has learned one of these banners has + * learned all of them, and a second wording of "nothing was measured" is a third + * thing to learn. The pipe advisory is not decoration -- `EXIT=$?` written after + * `cmd | tail -40` reads TAIL's status, and `head`/`tail` essentially never + * fail, so a refusal and a green run are the same `0` there: the one reading + * this whole exit-code split exists to make impossible. + * + * @param {string} message the refusal, in the words of the site that raised it + * @returns {string} + */ +function prerequisiteNotMetText(message) { + return ( + `\ncheck-published-readme-exports: PREREQUISITE NOT MET\n\n` + + `${message}\n\n` + + ` ⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says\n` + + ` NOTHING about whether any published README agrees with its package's built type\n` + + ` surface. In particular it is NOT evidence that a README is wrong, and ⛔ no README\n` + + ` may be edited on it.\n` + + ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:\n` + + ` \`node ${SELF} > /tmp/published-readme-exports.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover this gate's own code.)` + ); +} + +/** + * @param {{unreadReport?: boolean, root?: string}} [options] `--unread-report` prints the * `NOT read:` pair decomposed per document. ⛔ Opt-in, and visibility * only: it changes no finding, no verdict and no exit code, so the CI * invocation in package.json does not pass it and nothing about a run's - * result depends on whether it was passed. + * result depends on whether it was passed. `root` is the self-test's + * scratch workspace and defaults to the repo — see `publishedDocs`. */ -function run({ unreadReport: wantsUnreadReport = false } = {}) { - const { byName, docs } = publishedDocs(); +function run({ unreadReport: wantsUnreadReport = false, root = ROOT } = {}) { + const { byName, docs } = publishedDocs(SELF, { root }); // Pass 1: which workspace type entries do the READMEs actually reach? const { importStatements, ownScope, unresolvable, targets: reached } = reachedTargets( @@ -1889,28 +2023,46 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { const unscoped = scopeRefusal({ documents: docs.length, importStatements, ownScope }); if (unscoped) throw new Error(unscoped); - const targets = new Map(); // "" -> { name, subpath, abs, declared, missing } + const targets = new Map(); // "" -> { name, subpath, abs, declared, unbuilt, missing } for (const [key, { name, subpath }] of reached) { const { dir, manifest } = byName.get(name); const { entry, declared } = resolveTypesEntry(manifest, subpath); - const abs = entry ? join(ROOT, dir, entry.replace(/^\.\//, '')) : null; + const abs = entry ? join(root, dir, entry.replace(/^\.\//, '')) : null; + // The CHECKOUT state, flagged where it is decided rather than re-derived + // downstream from the prose below. `unbuiltTypeEntries` reads this flag and + // nothing else; its docblock owns why the other `missing` arm is excluded. + const unbuilt = Boolean(declared && abs && !existsSync(abs)); targets.set(key, { name, subpath, declared, abs, + unbuilt, missing: declared && !abs ? `imports from '${name}${subpath.slice(1)}', which declares no ` + `TypeScript types for that entry — this gate cannot verify it.` - : declared && !existsSync(abs) + : unbuilt ? `imports from '${name}${subpath.slice(1)}', whose type entry ` + - `${posix.relative(ROOT, abs)} does not exist. Build first: ` + + `${posix.relative(root, abs)} does not exist. Build first: ` + `\`pnpm --filter ${name} build\` (or \`pnpm build\`).` : null, }); } + // ⛔ UP FRONT: before the type surface is built and before one document is + // judged. The state is a property of the CHECKOUT, and it is known here in + // full — every entry this run would read has just been resolved. Discovered + // instead inside the judging loop, the same fact arrives once per import + // statement (198 lines on an unbuilt worktree of this repo) under the exit + // code that means "a README is wrong"; an agent reading a 3 after 198 lines + // is barely better off than one reading a 1. + const unbuiltClosure = unbuiltTypeEntries(targets, { root }); + if (unbuiltClosure.length > 0) { + console.error(prerequisiteNotMetText(unbuiltClosureRefusal(unbuiltClosure))); + return EXIT_PREREQUISITE_NOT_MET; + } + const surface = typeSurface( [...targets.values()].filter((t) => t.abs && !t.missing).map((t) => t.abs), ); @@ -1932,7 +2084,7 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { return ( surfaceTarget(surface, t.abs) ?? { declared: true, - entryMissing: `type entry ${posix.relative(ROOT, t.abs)} could not be read as a module.`, + entryMissing: `type entry ${posix.relative(root, t.abs)} could not be read as a module.`, } ); }; @@ -1942,7 +2094,7 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { for (const doc of docs) findings.push(...analyzeDocument(doc, resolveTarget, measured)); // Reconcile against the shrink-only baseline, BOTH directions. - const baseline = loadBaseline(); + const baseline = loadBaseline(root); const baselineById = new Map(baseline.map((e) => [e.id, e])); const observed = new Set(findings.map((f) => f.id)); // A missing type entry is a statement about the CHECKOUT, not about the @@ -1969,6 +2121,12 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { unresolvable, }); + // ⚠️ What still reaches here, now that the unbuilt CLOSURE is refused up front + // with its own code: the two states that are NOT the checkout's fault — a + // subpath declaring no types, and an entry that exists but cannot be read as a + // module. Both are findings about what the repo publishes, so both keep the + // finding's code and this branch's wording is left exactly as it was. ⛔ The + // absent-entry case can no longer arrive here at all; it returns above. if (unbuilt.length > 0) { console.error( `✗ check:published-readme-exports — ${unbuilt.length} package(s) are not built, so this\n` + @@ -1977,7 +2135,7 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { ); for (const f of unbuilt) console.error(` ${f.id.split('|')[1]} line ${f.line}: ${f.text}`); console.error(''); - return 1; + return EXIT_FINDINGS; } // The fourth state on the population axis (#10417), decided only now because @@ -2017,7 +2175,7 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { successSummary({ measured, baselineCount: baseline.length, memberChecks }), ); if (report) console.log(`\n${report}`); - return 0; + return EXIT_OK; } if (fresh.length > 0) { @@ -2053,7 +2211,7 @@ function run({ unreadReport: wantsUnreadReport = false } = {}) { ); } if (report) console.log(report); - return 1; + return EXIT_FINDINGS; } // --------------------------------------------------------------------------- @@ -2114,11 +2272,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ `--unread-report`: `NOT read:` DECOMPOSED, AND THE CHECKSUM (#10815)': 4, 'THE POPULATION AXIS, and the third refusal on it (#9911)': 7, 'THE FOURTH STATE ON THAT AXIS (#10417)': 4, + 'THE UNBUILT-CLOSURE PREDICATE, and the two states it excludes (#15600)': 5, + 'THE EXIT-CODE SPLIT, END TO END on a scratch workspace (#15600)': 8, }); // 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 = 23; +const SELF_TEST_BATTERY_FLOOR = 25; // 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 @@ -3512,6 +3672,180 @@ function selfTest() { { measured: { symbols: 0, checked: 1 }, refusal: null }, ); + // -- THE UNBUILT-CLOSURE PREDICATE, and the two states it excludes --------- + // + // The predicate decides an EXIT CODE, so its boundary is pinned in both + // directions on the shapes `run` really builds: a target that resolved to a + // path nothing wrote is the checkout's fault (3); a target that declares no + // types, and one whose entry exists, are the repo's (1, or nothing at all). + battery('THE UNBUILT-CLOSURE PREDICATE, and the two states it excludes (#15600)'); + const unbuiltTarget = (name, over = {}) => ({ + name, + subpath: '.', + declared: true, + abs: `/repo/packages/${name.split('/')[1]}/dist/index.d.ts`, + unbuilt: false, + missing: null, + ...over, + }); + eq( + 'unbuiltTypeEntries — an entry that resolved to a path nothing wrote is the prerequisite', + unbuiltTypeEntries( + new Map([['@objectstack/b', unbuiltTarget('@objectstack/b', { unbuilt: true })]]), + { root: '/repo' }, + ), + ['@objectstack/b -> packages/b/dist/index.d.ts'], + ); + eq( + 'unbuiltTypeEntries — GREEN CONTROL: a built tree yields no prerequisite at all', + unbuiltTypeEntries(new Map([['@objectstack/b', unbuiltTarget('@objectstack/b')]]), { root: '/repo' }), + [], + ); + eq( + 'unbuiltTypeEntries — a subpath declaring NO types is a finding, never a prerequisite', + unbuiltTypeEntries( + new Map([ + [ + '@objectstack/b', + unbuiltTarget('@objectstack/b', { + abs: null, + missing: 'imports from ..., which declares no TypeScript types for that entry', + }), + ], + ]), + { root: '/repo' }, + ), + [], + ); + eq( + 'unbuiltTypeEntries — one row per PACKAGE however many READMEs reached it, sorted', + unbuiltTypeEntries( + new Map([ + ['@objectstack/z.', unbuiltTarget('@objectstack/z', { unbuilt: true })], + ['@objectstack/z./sub', unbuiltTarget('@objectstack/z', { unbuilt: true })], + ['@objectstack/a.', unbuiltTarget('@objectstack/a', { unbuilt: true })], + ]), + { root: '/repo' }, + ), + ['@objectstack/a -> packages/a/dist/index.d.ts', '@objectstack/z -> packages/z/dist/index.d.ts'], + ); + eq( + 'unbuiltClosureRefusal — no unbuilt entry, no refusal', + unbuiltClosureRefusal([]), + null, + ); + + // -- THE EXIT-CODE SPLIT, END TO END on a scratch workspace ---------------- + // + // ⭐ Driven through the PRODUCTION `run` over a real workspace on disk, so + // what is pinned is the number a real run returns — not a classifier's + // opinion about one, and not a branch a fake resolver reached. ONE fixture + // carries both directions and differs only in what is in `dist/`: with no + // built entry the run must refuse ONCE with the banner (3); with a built + // entry that disagrees with the README it must report the finding (1); with + // a built entry that agrees it must pass (0). + // + // ⛔ Both directions, deliberately. A single case pinning only the new + // refusal would leave the exit-1 path free to decay into a second refusal — + // the failure this card is about, in the mirror. + battery('THE EXIT-CODE SPLIT, END TO END on a scratch workspace (#15600)'); + const fixtureRoot = mkdtempSync(join(tmpdir(), 'readme-exports-exit-split-')); + try { + const pkgDir = join(fixtureRoot, 'packages', 'fixture'); + mkdirSync(join(pkgDir, 'dist'), { recursive: true }); + mkdirSync(join(fixtureRoot, 'scripts'), { recursive: true }); + writeFileSync(join(fixtureRoot, WORKSPACE_FILE), 'packages:\n - packages/*\n'); + writeFileSync(join(fixtureRoot, BASELINE_REL), `${JSON.stringify({ entries: [] }, null, 2)}\n`); + writeFileSync( + join(pkgDir, 'package.json'), + `${JSON.stringify( + { + name: '@objectstack/exit-split-fixture', + version: '0.0.0', + files: ['dist', 'README.md'], + exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' } }, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + join(pkgDir, 'README.md'), + [ + '# fixture', + '', + '```typescript', + "import { Documented } from '@objectstack/exit-split-fixture';", + '```', + '', + ].join('\n'), + ); + const entry = join(pkgDir, 'dist', 'index.d.ts'); + // The gate writes its verdict to the console; a self-test that asserts on + // an exit code alone would pass just as happily on a 3 printed as 198 + // lines, which is the whole defect. + const drive = () => { + const printed = []; + const { log, error } = console; + console.log = (...args) => printed.push(args.join(' ')); + console.error = (...args) => printed.push(args.join(' ')); + try { + return { exit: run({ root: fixtureRoot }), text: printed.join('\n') }; + } finally { + console.log = log; + console.error = error; + } + }; + + const unbuiltRun = drive(); + eq( + 'END TO END — an unbuilt closure exits PREREQUISITE NOT MET, not a finding', + unbuiltRun.exit, + EXIT_PREREQUISITE_NOT_MET, + ); + eq( + 'END TO END — it prints the sibling banner, verbatim in the sentence that classifies it', + [ + /check-published-readme-exports: PREREQUISITE NOT MET/.test(unbuiltRun.text), + /NOT a pass and NOT a finding: nothing was measured/.test(unbuiltRun.text), + ], + [true, true], + ); + eq( + 'END TO END — it answers ONCE: no per-import `Build first` line survives', + /Build first/.test(unbuiltRun.text), + false, + ); + eq( + 'END TO END — the refusal names the package whose entry is not built', + /@objectstack\/exit-split-fixture -> packages\/fixture\/dist\/index\.d\.ts/.test(unbuiltRun.text), + true, + ); + + writeFileSync(entry, 'export declare const Undocumented: string;\n'); + const disagreeing = drive(); + eq( + 'END TO END — a BUILT entry the README disagrees with is still a FINDING', + disagreeing.exit, + EXIT_FINDINGS, + ); + eq( + 'END TO END — the finding names the symbol the README invented', + /documents `import \{ Documented \}/.test(disagreeing.text), + true, + ); + eq( + 'END TO END — a finding is never dressed as a prerequisite', + /PREREQUISITE NOT MET/.test(disagreeing.text), + false, + ); + + writeFileSync(entry, 'export declare const Documented: string;\n'); + eq('END TO END — GREEN CONTROL: a README that agrees passes', drive().exit, EXIT_OK); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + // The authority convention (#8435): the baseline path must never be offered // as a co-equal author remedy. const remedy = freshRemedy(); @@ -3632,7 +3966,13 @@ function selfTest() { ' refusal too, measured off a fixture by `reachedTargets` — a tree of purely foreign\n' + ' imports reads statements and no scoped specifier — and the header prints the scoped\n' + ' population as resolved/total, so a recogniser that stops matching shows up as a\n' + - ' denominator that fell rather than as a defect count that never moved.', + ' denominator that fell rather than as a defect count that never moved.\n' + + ' THE EXIT-CODE SPLIT (#15600) is pinned END TO END through the production `run` over a\n' + + ' scratch workspace, in all three directions one fixture can carry: no built entry\n' + + ' refuses ONCE with the sibling banner and exit 3 (and no `Build first` line survives),\n' + + ' a built entry the README disagrees with is still the finding at exit 1 and is never\n' + + ' dressed as a prerequisite, and a built entry it agrees with passes — so neither\n' + + ' direction of the split can decay while the other keeps this line printing.', ); return SELF_TEST_VERDICT;