From d59d6b14026e6c3486f8571c509060f1b072f282 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:31:35 +0000 Subject: [PATCH 1/3] refactor(adr-0087 gate): runtime-interface-only's step-4 reference scan becomes metadataSurfaceReference The same reading, extracted unchanged so a second category can ask it of a retired export at the merge base without a second spelling of the question. Self-test: 463 assertions, unchanged. Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude --- scripts/check-adr-0087-registration.mjs | 94 +++++++++++++++---------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index af8e43e5353..04e663da6a3 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -2882,50 +2882,28 @@ export function verifyRuntimeInterfaceOnly(refs, { rev, cwd, packages }) { } // (4) no metadata surface references it - const word = new RegExp(`\\b${symbol}\\b`); - let refused = false; - for (const [hitPath, rawText] of surfaces()) { - if (hitPath === path || !word.test(rawText)) continue; - // PROSE IS NOT A REFERENCE. The raw hit above is the cheap filter; the - // question this step actually asks is about CODE, so every decision below - // reads the file with its comment spans blanked. `maskComments` (never - // `stripComments`) because this branch REPORTS a line back to the author, and - // blanking is the projection that keeps line numbers and offsets aligned with - // the file on disk. A docblock that NAMES a runtime type is documentation -- - // it puts nothing in a schema and gives `objectstack migrate meta` nothing to - // reach. Strings are deliberately left standing: see the header. - const hitText = maskComments(rawText); - if (!word.test(hitText)) continue; - // A file that declares the name itself is a HOMONYM, not a reference -- the - // measured `PackagePublishResult` pair in the header is exactly this case. - if (declaresLocally(hitText, symbol)) continue; - const specifiers = importSpecifiersFor(hitText, symbol); - const pkgs = packages(); - if (specifiers.length > 0) { - if (!specifiers.some((s) => specifierNamesModule(s, hitPath, path, pkgs))) continue; // imported from elsewhere - problems.push( - `\`runtime-interface-only ${ref}\` is false: ${hitPath} (${metadataSurfaceKind(hitPath)})\n` + - ` IMPORTS \`${symbol}\` from the module that declares it.\n` + - ' A runtime interface pulled into a metadata surface HAS a metadata surface, wherever it\n' + - ' was declared. That is what step 4 of the predicate exists to catch.', - ); - refused = true; - break; - } - const line = (hitText.split(/\r?\n/).find((l) => word.test(l)) ?? '').trim(); + const hit = metadataSurfaceReference(symbol, path, surfaces(), packages); + if (hit?.kind === 'import') { problems.push( - `\`runtime-interface-only ${ref}\` cannot be verified: ${hitPath} (${metadataSurfaceKind(hitPath)})\n` + + `\`runtime-interface-only ${ref}\` is false: ${hit.hitPath} (${metadataSurfaceKind(hit.hitPath)})\n` + + ` IMPORTS \`${symbol}\` from the module that declares it.\n` + + ' A runtime interface pulled into a metadata surface HAS a metadata surface, wherever it\n' + + ' was declared. That is what step 4 of the predicate exists to catch.', + ); + continue; + } + if (hit?.kind === 'mention') { + problems.push( + `\`runtime-interface-only ${ref}\` cannot be verified: ${hit.hitPath} (${metadataSurfaceKind(hit.hitPath)})\n` + ` mentions \`${symbol}\` IN CODE while neither declaring nor importing it:\n` + - ` ${line.slice(0, 140)}\n` + + ` ${hit.line.slice(0, 140)}\n` + ' Unresolvable, so refused rather than assumed unrelated (#4690). If it truly is a\n' + ' different symbol, the honest disposition is one that can be checked.\n' + ' Comment spans were already excluded before this scan, so the line above is not a\n' + ' docblock naming the type in prose -- that alone never refuses this category.', ); - refused = true; - break; + continue; } - if (refused) continue; verified.push({ ref, kind: decl.kind }); } @@ -2933,6 +2911,50 @@ export function verifyRuntimeInterfaceOnly(refs, { rev, cwd, packages }) { return { problems, verified }; } +/** + * Step 4 of `runtime-interface-only`, as a reading: the first metadata surface that + * REFERENCES `symbol` in code, or null. + * + * Shared, not copied: `runtime-export-retired` (#22918) asks the same question of + * a retired export at the merge base, and two spellings of "is this a reference?" + * would drift the way #12881 measured one of them drifting from the other. + * + * @param {string} symbol the bare name scanned for + * @param {string} declPath the repo-relative file that DECLARES it -- the module an + * import must name to count, and the one file that is never its own reference + * @param {Map} surfaceTexts path -> text of every metadata surface + * @param {() => Map} packages workspace + * manifests, read only when an import has to be attributed to a package + * @returns {{ kind: 'import', hitPath: string } | { kind: 'mention', hitPath: string, line: string } | null} + */ +export function metadataSurfaceReference(symbol, declPath, surfaceTexts, packages) { + const word = new RegExp(`\\b${symbol}\\b`); + for (const [hitPath, rawText] of surfaceTexts) { + if (hitPath === declPath || !word.test(rawText)) continue; + // PROSE IS NOT A REFERENCE. The raw hit above is the cheap filter; the + // question this step actually asks is about CODE, so every decision below + // reads the file with its comment spans blanked. `maskComments` (never + // `stripComments`) because this branch REPORTS a line back to the author, and + // blanking is the projection that keeps line numbers and offsets aligned with + // the file on disk. A docblock that NAMES a runtime type is documentation -- + // it puts nothing in a schema and gives `objectstack migrate meta` nothing to + // reach. Strings are deliberately left standing: see the header. + const hitText = maskComments(rawText); + if (!word.test(hitText)) continue; + // A file that declares the name itself is a HOMONYM, not a reference -- the + // measured `PackagePublishResult` pair in the header is exactly this case. + if (declaresLocally(hitText, symbol)) continue; + const specifiers = importSpecifiersFor(hitText, symbol); + if (specifiers.length > 0) { + const pkgs = packages(); + if (!specifiers.some((s) => specifierNamesModule(s, hitPath, declPath, pkgs))) continue; // imported from elsewhere + return { kind: 'import', hitPath }; + } + return { kind: 'mention', hitPath, line: (hitText.split(/\r?\n/).find((l) => word.test(l)) ?? '').trim() }; + } + return null; +} + // --------------------------------------------------------------------------- // The sixth category: `type-surface-only` (#13080) // From 85fd3fcd04523422a5106b8fc6674a03871052f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:35:43 +0000 Subject: [PATCH 2/3] feat(adr-0087 gate): the runtime-export-retired category -- operand grammar, the compiler export reader, the three predicates (WIP: ADR half and self-tests follow) Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude --- scripts/check-adr-0087-registration.mjs | 836 +++++++++++++++++++++++- 1 file changed, 825 insertions(+), 11 deletions(-) diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 04e663da6a3..c106445bda1 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -369,7 +369,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, posix, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; @@ -530,6 +530,7 @@ export const CATEGORIES = [ 'no-migration-prescription', 'runtime-interface-only', 'type-surface-only', + 'runtime-export-retired', ]; /** The ADR whose disposition vocabulary this gate enforces (#8299). */ @@ -1794,17 +1795,29 @@ function labelPositioned(line, col, prev, after = '') { * @param {string} body */ function carriesConcreteRewrite(body) { + return firstConcreteRewrite(body) !== null; +} + +/** + * `carriesConcreteRewrite`, answering WHICH line -- the same walk, unchanged, so a + * caller that has to name the rewrite it refuses on (`readFromBlock`, #22918) reads + * exactly what branch 1's corroboration reads. + * + * @param {string} body + * @returns {{ shape: string, line: string } | null} + */ +function firstConcreteRewrite(body) { let inTable = false; - let sawFromBlock = false; + let fromLine = null; for (const line of body.split(/\r?\n/)) { - if (REWRITE_RE.test(line)) return true; - if (VERTICAL_FROM_RE.test(line)) sawFromBlock = true; - else if (sawFromBlock && VERTICAL_TO_RE.test(line)) return true; + if (REWRITE_RE.test(line)) return { shape: 'an arrow rewrite', line: line.trim() }; + if (VERTICAL_FROM_RE.test(line)) fromLine ??= line.trim(); + else if (fromLine !== null && VERTICAL_TO_RE.test(line)) return { shape: 'a vertical FROM / TO pair', line: fromLine }; if (TABLE_DELIM_RE.test(line)) { inTable = true; continue; } if (!TABLE_ROW_RE.test(line)) { inTable = false; continue; } - if (inTable && isRewriteRow(line)) return true; + if (inTable && isRewriteRow(line)) return { shape: 'a rewrite table row', line: line.trim() }; } - return false; + return null; } /** @@ -3956,9 +3969,750 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages, } // --------------------------------------------------------------------------- -// The scan +// The seventh category: `runtime-export-retired` (#22918) +// +// ## The class, and the dead end it closes +// +// A changeset that RETIRES PUBLISHED RUNTIME EXPORTS -- functions, constants, +// classes and types a published package's entry exported, and members of an +// exported type -- declares `**BREAKING**` truthfully and ships the FROM -> TO line +// a consumer needs for each retired name. The worked example is +// `.changeset/15205-email-templates-resolve-registry.md` (PR #22875, merged as +// `7de0265fc7`): nine exports and two `EmailServicePluginOptions` members of +// `@objectstack/plugin-email`, six arrowless `- FROM ... TO ...` lines. Every +// disposition was closed to it on the facts but one -- `no-migration-prescription`, +// which it held ONLY because the detector reads an arrow and those lines carry +// none. That is the detector-miss shape D7 retires, and its own marker asked for a +// ruling (#22915, ruling A; #22918, the ruling of 2026-10-12). +// +// The ledger is the wrong channel for the reason D7 and D8 give: the affected party +// is a TypeScript consumer, the compiler at their own call site delivers the break +// (`TS2305 ... has no exported member`), and `objectstack migrate meta` has no +// metadata to rewrite. +// +// ## The operands (the ruling's Q1: B, narrowed) +// +// # a retired export of the package's published entry +// #. a retired member of an exported type -- the dotted +// spelling `type-surface-only` already uses +// +// The PACKAGE is named, never a file: "reachable from the published entry" is a +// fact about the package, and a declaration can move between files without the +// export moving. ⛔ A member is addressed by that canonical spelling ONLY. Nothing +// here parses a constructor argument or an object literal: a FROM line written +// `new EmailServicePlugin({ seedTemplates: false })` names `EmailServicePlugin`, an +// operand outside any set that names the member, and is refused as out of range. +// The worked example's lines 29-30 are exactly that, which is why it stays on +// `no-migration-prescription` (merged, never re-graded) and why the member limb +// exists at all. +// +// ## The three predicates, BY NAME, re-verified on every run +// +// 1. retired-from-entry -- an export is reachable from the package's published +// entry at the merge base and absent at HEAD; a member's type is reachable at +// the base, the member is present on it there (`getPropertiesOfType`) and +// absent at HEAD. Read by the TypeScript COMPILER over the package's source +// entry at each rev (`getExportsOfModule`, so `export *` resolves). The entry +// is located through the package's `exports` map -- `resolveTypesEntry`, +// imported from the readme-exports gate, never re-implemented -- and tsup's +// convention: its declaration entry `dist/index.d.ts` (or the `.d.mts` / +// `.d.cts` twin tsup writes beside it) is generated from `src/index.ts`. A +// published package whose entry cannot be mapped that way is REFUSED, loudly +// and by name, never guessed. +// 2. no-metadata-surface -- at the merge base no operand is declared in a +// `*.zod.ts`, under `packages/spec/src/contracts/` or in an object definition, +// none is a Zod projection, and no metadata surface references it in code: the +// `runtime-interface-only` primitives, read at the BASE because the operand is +// gone at HEAD. +// 3. from-operands-named -- the FROM -> TO block names EXACTLY the named +// operands, each spelled as in the marker (`Type.member` for a member), and the +// body carries no other prescription the detector can read. +// +// ## The deviation the maintainer accepted +// +// A merge base has no build output (`dist/` is gitignored), so predicate 1 compiles +// the SOURCE entry that generates the published `.d.ts`, not the `.d.ts`. For the +// question "which names does the package export" the two agree. +// +// ## Predicate 3 is a NARROWING of `no-migration-prescription`, never an escape +// +// This category does not skip the prescription refusal the way `type-surface-only` +// does. It reads the FROM side of every rewrite line the body writes -- a +// `FROM ... TO ...` line, an arrow `X -> Y` (or `X -> delete`), the FROM column of a +// `| FROM | TO |` / `| Wrote | Write instead |` table -- requires each FROM operand +// to be a named operand and each named operand to have a FROM side, neutralises +// exactly those lines, and then runs the SAME `findMigrationPrescription` over +// what is left. Anything the detector still reads is refused: a rewrite table with +// no FROM column, a vertical FROM / TO pair, a FROM -> TO label above anything this +// reader did not read. So nothing the detector refuses today becomes claimable +// unless every operand it rests on was positively verified retired by predicates +// 1-2 -- and an arrowless block, invisible to the detector, is READ here rather +// than waved through. +// +// ## What is deliberately NOT checked +// +// **That the claim is complete.** Like `registered` and `runtime-interface-only`, +// this judges the operands the author named, not every export the diff removed: +// inferring that set is the cross-package retirement detector the 2026-08-07 ruling +// routes around. +// +// **A subpath entry (`@scope/pkg/sub`).** Only the root entry is read; a retirement +// from a subpath names no operand this category can verify, so it is refused at the +// mapping step rather than assumed. +// +// **A static member of a class.** A member operand reads the declared (instance) +// type of a class, interface or type alias, and the value type of anything else. // --------------------------------------------------------------------------- +/** + * The three predicates, BY NAME -- the same discipline as `TYPE_SURFACE_PREDICATES`: + * the self-test pins the set and that the shipping function evaluates exactly it. + */ +export const RUNTIME_EXPORT_PREDICATES = [ + 'retired-from-entry', + 'no-metadata-surface', + 'from-operands-named', +]; + +/** An npm package name, scoped or not. */ +const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +/** `Export` or `Type.member` -- one level deep, never more. */ +const EXPORT_SPELLING_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/; + +/** + * `#` / `#.` -> its parts, or a refusal. + * + * @param {string} ref + * @returns {{ pkg: string, spelling: string, exportName: string, member: string|null } | { refusal: string }} + */ +export function parseExportOperand(ref) { + const at = ref.lastIndexOf('#'); + const pkg = at > 0 ? ref.slice(0, at) : ''; + const spelling = at > 0 ? ref.slice(at + 1) : ''; + if (at <= 0 || !PACKAGE_NAME_RE.test(pkg)) { + return { + refusal: /[\\/][^#]*\.[cm]?tsx?(?:#|$)/.test(ref) + ? `names a FILE (${pkg || ref}). This category names the PACKAGE whose published entry exported the operand,\n` + + ' because "reachable from the published entry" is a fact about the package, not about a file' + : 'is not a `#` or `#.` reference', + }; + } + if (!EXPORT_SPELLING_RE.test(spelling)) { + return { + refusal: /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){2,}$/.test(spelling) + ? `spells a member ${spelling.split('.').length - 1} levels deep. A member is named \`.\`: the\n` + + ' exported type it sits on, then the member, and nothing in between' + : `names \`${spelling}\`, which is not an export name or a canonical \`.\` spelling`, + }; + } + const [exportName, member = null] = spelling.split('.'); + return { pkg, spelling, exportName, member }; +} + +/** + * The canonical spelling a FROM operand names, or null. + * + * An export is spelled by its name; a member as `Type.member`. A CALL of it reads as + * it -- `createSysEmailTemplateLoader(engine, options?)` names + * `createSysEmailTemplateLoader`, and `new X(...)` names `X` -- because that is how a + * consumer writes the thing they must change. The argument list is never read: it is + * accepted as a trailing `( ... )` and nothing more (the ruling's ⛔ on parsing + * constructor arguments and object literals). Anything else -- an import statement, + * a literal, a deeper path, a type argument -- is not canonical. + * + * @param {string} text the operand, backticks already stripped + * @returns {string|null} + */ +export function canonicalFromOperand(text) { + const m = /^(?:new\s+)?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*(?:\([\s\S]*\))?\s*;?$/.exec(text.trim()); + if (!m || m[1] === 'new') return null; + return m[1]; +} + +const stripTicks = (s) => s.replace(/^`|`$/g, ''); +/** An inline code span on one line. */ +const CODE_SPAN_G = /`([^`\n]+)`/g; +/** The convention's two words, as WORDS -- `FROMAGE` and `TOP` are not tokens. */ +const CONVENTION_TOKEN_G = /(?→—-]*$/; +/** Text left of a FROM token that OPENS a segment: list/quote/emphasis markup or a sentence boundary. */ +const OPENS_SEGMENT_RE = /^(?:\s*(?:[-*+>]|\d+[.)])\s+)*\s*(?:\*\*|__|\*|_)?\s*$|[.:;。:](?:\*\*|__|\*|_)?\s*$/; +const ARROW_G = new RegExp(ARROW, 'g'); +const LEFT_OPERAND_RE = new RegExp(`(${OPERAND})\\s*$`); +const RIGHT_OF_ARROW_RE = new RegExp(`^\\s*(?:${OPERAND}|${REMOVAL_INSTRUCTION})`); +const OPERAND_G = new RegExp(OPERAND, 'g'); +/** The FROM column of a mapping table: `FROM`, or one of `OLD_COLUMN_RE`'s words. */ +const FROM_COLUMN_RE = /^\**\s*FROM\b/; +const TO_COLUMN_RE = /^\**\s*TO\b/; + +/** The index of a mapping table's FROM column, read off its header row, or -1. */ +function fromColumnOf(headerRow) { + const cells = tableCells(headerRow).map((c) => c.trim()); + const at = cells.findIndex((c) => FROM_COLUMN_RE.test(c) || OLD_COLUMN_RE.test(c)); + if (at < 0) return -1; + return cells.slice(at + 1).some((c) => TO_COLUMN_RE.test(c) || NEW_COLUMN_RE.test(c)) ? at : -1; +} + +/** A line with the convention's words lowercased and its arrows gone: nothing left for the detector. */ +const neutraliseLine = (line) => line.replace(ARROW_G, '~').replace(CONVENTION_TOKEN_G, (t) => t.toLowerCase()); + +/** + * Every FROM side the body writes, every rewrite shape it writes that this reader + * does NOT read, and what the detector still finds once the read lines are gone. + * + * The FROM sides come from three shapes, read wherever they stand: + * + * - a `FROM ... TO ...` segment on one line: every code span between the two + * words is an operand (the worked example's spelling). The bare placeholder -- + * `FROM -> TO`, nothing but markup between the words -- carries no operand and + * is left to the detector, which reads it as a label or a mention exactly as it + * always has. A segment that OPENS its line or sentence but spans no code at all + * is a prescription naming nothing, and is reported; + * - an arrow rewrite or removal -- precisely `REWRITE_RE` / `RETIREMENT_RE`'s + * shape -- whose FROM side is the operand left of the arrow; + * - a mapping table whose header names a FROM column (`FROM`, or the + * `OLD_COLUMN_RE` words) followed by a TO one: each data row's FROM cell. + * + * A VERTICAL FROM / TO pair -- the two words opening consecutive blocks -- is not + * read: its FROM side can span a fenced block, and reading code lines as operands + * is a parser this category does not need. It is reported, never skipped. + * + * @param {string} body + * @returns {{ sides: {text: string, operand: string, spelling: string|null}[], + * unread: {shape: string, text: string}[], + * residual: {branch: string, line: string}|null }} + */ +export function readFromBlock(body) { + const lines = body.split(/\r?\n/); + const copy = [...lines]; + const sides = []; + const unread = []; + const verticalFrom = []; + let inTable = false; + let fromCol = -1; + let prev = ''; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let read = false; + if (TABLE_DELIM_RE.test(line)) { + inTable = true; + fromCol = TABLE_ROW_RE.test(prev) ? fromColumnOf(prev) : -1; + if (fromCol >= 0) copy[i - 1] = neutraliseLine(lines[i - 1]); + prev = line; + continue; + } + const isRow = TABLE_ROW_RE.test(line); + if (!isRow) { inTable = false; fromCol = -1; } + if (isRow && inTable && fromCol >= 0) { + const cells = tableCells(line); + const operands = [...(cells[fromCol] ?? '').matchAll(OPERAND_G)].map((m) => m[0]); + if (operands.length === 0) unread.push({ shape: 'a FROM cell naming no operand', text: line.trim() }); + for (const o of operands) sides.push({ text: line.trim(), operand: o, spelling: canonicalFromOperand(stripTicks(o)) }); + copy[i] = `|${cells.map(() => ' - ').join('|')}|`; + prev = line; + continue; + } + if (!isRow) { + const spans = [...line.matchAll(CODE_SPAN_G)].map((m) => ({ from: m.index, to: m.index + m[0].length, text: m[1] })); + const tokens = [...line.matchAll(CONVENTION_TOKEN_G)] + .filter((m) => !spans.some((s) => m.index >= s.from && m.index < s.to)) + .map((m) => ({ tok: m[1], at: m.index, end: m.index + m[1].length })); + let segment = false; + for (let k = 0; k < tokens.length; k++) { + const next = tokens[k + 1]; + if (tokens[k].tok !== 'FROM' || next?.tok !== 'TO') continue; + segment = true; + const inGap = spans.filter((s) => s.from >= tokens[k].end && s.to <= next.at); + if (inGap.length === 0) { + if (PLACEHOLDER_GAP_RE.test(line.slice(tokens[k].end, next.at))) continue; + if (OPENS_SEGMENT_RE.test(line.slice(0, tokens[k].at))) { + unread.push({ shape: 'a `FROM ... TO ...` line naming no operand', text: line.trim() }); + } + continue; + } + read = true; + for (const s of inGap) sides.push({ text: line.trim(), operand: `\`${s.text}\``, spelling: canonicalFromOperand(s.text) }); + } + if (!segment && VERTICAL_FROM_RE.test(line)) verticalFrom.push(i); + } + for (const m of line.matchAll(ARROW_G)) { + const left = LEFT_OPERAND_RE.exec(line.slice(0, m.index)); + if (!left || !RIGHT_OF_ARROW_RE.test(line.slice(m.index + m[0].length))) continue; + read = true; + sides.push({ text: line.trim(), operand: left[1], spelling: canonicalFromOperand(stripTicks(left[1])) }); + } + if (read) copy[i] = neutraliseLine(line); + prev = line; + } + for (const i of verticalFrom) { + if (lines.slice(i + 1).some((l) => VERTICAL_TO_RE.test(l))) { + unread.push({ shape: 'a vertical FROM / TO pair', text: lines[i].trim() }); + } + } + + // What the detector still reads once every line above is neutralised. A + // placeholder LABEL may stand above the block it introduces, so a label hit is + // let through -- but only when the block exists and NOTHING concrete is left + // anywhere in the body: branch 1 judges the whole body, and so does this. + let residual = null; + for (let guard = 0; guard <= copy.length && residual === null; guard++) { + const hit = findMigrationPrescription(copy.join('\n')); + if (!hit) break; + const at = copy.findIndex((l) => l.trim() === hit.line); + if (hit.branch !== 'from-to-label' || at < 0) { residual = hit; break; } + const concrete = firstConcreteRewrite(copy.join('\n')); + if (sides.length === 0) residual = hit; + else if (concrete) residual = { branch: `${hit.branch} over ${concrete.shape}`, line: concrete.line }; + else copy[at] = neutraliseLine(copy[at]); + } + return { sides, unread, residual }; +} + +/** + * The compiler kit predicate 1 reads exports with -- `typescript`, the checked + * doors of `scripts/ts-parse.mjs` and `resolveTypesEntry` -- or null until loaded. + * + * LAZY, deliberately: `scan()` is synchronous and runs on every pull request, and + * almost none of them names this category, so the import is paid only by a run + * that needs it (`loadExportReader`, awaited by the entry point when a scan reports + * `needsExportReader`, and by `--self-test`). A run that reaches the category + * without it is a RED naming the loader, never a pass. + */ +let exportReaderKit = null; + +/** + * Load the kit once per process. `typescript` goes through `import-prerequisite.mjs`, + * so an uninstalled tree says so instead of dying on a link error. + */ +export async function loadExportReader() { + if (exportReaderKit) return exportReaderKit; + const { requireDefaultExport } = await import('./import-prerequisite.mjs'); + const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url, { + measures: 'whether each export a `runtime-export-retired` marker names really left the package\'s published entry', + }); + const { createProgramChecked, parseSourceFile } = await import('./ts-parse.mjs'); + const { resolveTypesEntry } = await import('./check-published-readme-exports.mjs'); + exportReaderKit = { ts, createProgramChecked, parseSourceFile, resolveTypesEntry, libFiles: new Map() }; + return exportReaderKit; +} + +/** tsup's declaration entry, and the `.d.mts` / `.d.cts` twins it writes beside it. */ +const TSUP_DTS_ENTRY_RE = /^dist\/index\.d\.[cm]?ts$/; +/** Where a rev's blobs live inside the compiler's file system. Never a real path. */ +const VIRTUAL_ROOT = '/__adr0087__/'; +const TS_SOURCE_RE = /\.(?:d\.)?[cm]?tsx?$/; + +/** + * The repo-relative SOURCE entry of a workspace package at a rev, or a refusal. + * + * Only the mapping the ruling names: the package's `exports` map, read by + * `resolveTypesEntry`, lands on tsup's `dist/index.d.ts` (or its twin), which tsup + * generates from `src/index.ts`. Anything else -- no types entry, another file + * name, no `src/index.ts` -- is refused by name rather than guessed. + */ +function sourceEntryAt(rev, cwd, pkgName, pkgs, kit) { + const info = pkgs.get(pkgName); + if (!info) return { refusal: `no workspace manifest at ${rev.slice(0, 12)} is named \`${pkgName}\`` }; + if (info.private) { + return { + refusal: `\`${pkgName}\` is PRIVATE (\`private: true\` in ${info.file}): it has no published entry to retire an\n` + + ' export from. The disposition that says so is `not-required (unpublished)`', + }; + } + let manifest; + try { manifest = JSON.parse(showOrNull(rev, info.file, cwd) ?? ''); } catch { return { refusal: `${info.file} does not parse at ${rev.slice(0, 12)}` }; } + const declared = kit.resolveTypesEntry(manifest, '.').entry?.replace(/^\.\//, '') ?? null; + const dir = info.file.slice(0, -'package.json'.length); + const entry = `${dir}src/index.ts`; + if (declared === null || !TSUP_DTS_ENTRY_RE.test(declared) || showOrNull(rev, entry, cwd) === null) { + return { + refusal: `the published entry of \`${pkgName}\` cannot be mapped to its source at ${rev.slice(0, 12)}: its \`exports\` map\n` + + ` names ${declared === null ? 'no types entry' : `\`${declared}\``}, and the only mapping this gate reads is tsup's\n` + + ` \`dist/index.d.ts\` (or its \`.d.mts\` / \`.d.cts\` twin) -> \`src/index.ts\`` + + (declared !== null && TSUP_DTS_ENTRY_RE.test(declared) ? `, whose source ${entry} does not exist there` : '') + '.\n' + + ' An entry this gate cannot map is refused, never guessed', + }; + } + return { entry, dir }; +} + +/** + * What a package's published entry exports at a rev, read by the compiler. + * + * A Program is built over the package's source entry with a CompilerHost that + * serves the rev's BLOBS (never the working tree), so the base and HEAD readings + * are each a pure function of their rev. Relative imports resolve inside the + * rev; a re-export (`export ... from`) of another workspace package's ROOT entry + * resolves through the same mapping, so a cross-package `export *` is followed; any + * other bare import is left unresolved, which costs nothing for the question asked + * -- an unresolved `export *` is recorded instead, because a name could hide + * behind it. + * + * @returns {{ refusal: string } | { pkg: string, entry: string, names: Map, + * unresolvedStars: {file: string, specifier: string}[], checker: object, entryFile: object, + * fromVirtual: (f: string) => string|null }} + */ +function readExportSurface(rev, cwd, pkgName, pkgs, kit) { + const mapped = sourceEntryAt(rev, cwd, pkgName, pkgs, kit); + if (mapped.refusal) return mapped; + const { ts } = kit; + const texts = new Map(); + const loaded = new Set(); + const loadDir = (dir) => { + if (loaded.has(dir)) return; + loaded.add(dir); + let listed = ''; + try { listed = git(['ls-tree', '-r', '--name-only', rev, '--', dir], cwd); } catch { return; } + const paths = listed.split('\n').map((s) => s.trim()).filter((p) => TS_SOURCE_RE.test(p) && !p.includes('node_modules/')); + for (const [p, t] of showManyOrNull(rev, paths, cwd)) texts.set(p, t); + }; + loadDir(mapped.dir); + const toVirtual = (p) => VIRTUAL_ROOT + p; + const fromVirtual = (f) => (f.startsWith(VIRTUAL_ROOT) ? f.slice(VIRTUAL_ROOT.length) : null); + const options = { + noEmit: true, skipLibCheck: true, types: [], lib: ['lib.es2022.d.ts'], + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, + }; + const real = ts.createCompilerHost(options, true); + const extensionOf = (p) => (p.endsWith('.d.ts') ? ts.Extension.Dts : p.endsWith('.tsx') ? ts.Extension.Tsx : ts.Extension.Ts); + const found = (p) => ({ resolvedModule: { resolvedFileName: toVirtual(p), extension: extensionOf(p), isExternalLibraryImport: false } }); + const resolveIn = (target) => { + const stem = target.replace(/\.(?:[cm]?js|jsx)$/, ''); + const candidates = [`${stem}.ts`, `${stem}.tsx`, `${stem}.d.ts`, `${stem}/index.ts`, `${stem}/index.tsx`]; + for (const c of candidates) { + if (texts.has(c)) return c; + if (![...loaded].some((d) => c.startsWith(d))) { + const t = showOrNull(rev, c, cwd); + if (t !== null) { texts.set(c, t); return c; } + } + } + return null; + }; + const host = { + ...real, + getCurrentDirectory: () => '/', + writeFile: () => {}, + fileExists: (f) => (fromVirtual(f) === null ? real.fileExists(f) : texts.has(fromVirtual(f))), + readFile: (f) => (fromVirtual(f) === null ? real.readFile(f) : texts.get(fromVirtual(f))), + getSourceFile: (f, version, onError, create) => { + const rel = fromVirtual(f); + if (rel === null) { + // A lib file: one parse per process, shared by every Program after it. + if (!kit.libFiles.has(f)) kit.libFiles.set(f, real.getSourceFile(f, version, onError, create)); + return kit.libFiles.get(f); + } + return texts.has(rel) ? kit.parseSourceFile(f, texts.get(rel)) : undefined; + }, + resolveModuleNameLiterals: (literals, containingFile) => literals.map((lit) => { + const from = fromVirtual(containingFile); + const spec = lit.text; + if (from === null) return { resolvedModule: undefined }; + if (spec.startsWith('.')) { + const hit = resolveIn(posix.normalize(posix.join(posix.dirname(from), spec))); + return hit ? found(hit) : { resolvedModule: undefined }; + } + // Another workspace package, ONLY where this file RE-EXPORTS it: that is the + // one place its names become this entry's names. + if (!lit.parent || !ts.isExportDeclaration(lit.parent)) return { resolvedModule: undefined }; + const other = sourceEntryAt(rev, cwd, spec, pkgs, kit); + if (other.refusal) return { resolvedModule: undefined }; + loadDir(other.dir); + return texts.has(other.entry) ? found(other.entry) : { resolvedModule: undefined }; + }), + }; + const program = kit.createProgramChecked([toVirtual(mapped.entry)], options, host); + const checker = program.getTypeChecker(); + const entryFile = program.getSourceFile(toVirtual(mapped.entry)); + const moduleSymbol = entryFile ? checker.getSymbolAtLocation(entryFile) : undefined; + const names = new Map((moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : []).map((s) => [s.name, s])); + const unresolvedStars = []; + const walked = new Set(); + const walk = (sf) => { + if (!sf || walked.has(sf.fileName)) return; + walked.add(sf.fileName); + for (const st of sf.statements) { + if (!ts.isExportDeclaration(st) || !st.moduleSpecifier || st.exportClause) continue; + const target = checker.getSymbolAtLocation(st.moduleSpecifier)?.declarations?.find((d) => ts.isSourceFile(d)); + if (target) walk(target); + else unresolvedStars.push({ file: fromVirtual(sf.fileName) ?? sf.fileName, specifier: st.moduleSpecifier.text }); + } + }; + walk(entryFile); + return { pkg: pkgName, entry: mapped.entry, names, unresolvedStars, checker, entryFile, fromVirtual }; +} + +/** The symbol an export resolves to, through every re-export alias. */ +function resolvedExport(surface, name, ts) { + const s = surface.names.get(name); + if (!s) return null; + return s.flags & ts.SymbolFlags.Alias ? surface.checker.getAliasedSymbol(s) : s; +} + +/** The repo-relative files declaring a symbol -- empty when it does not resolve. */ +const declarationFiles = (surface, symbol) => + [...new Set((symbol?.declarations ?? []).map((d) => surface.fromVirtual(d.getSourceFile().fileName)).filter(Boolean))]; + +/** + * The members of an exported type at one rev, or why they cannot be read. + * + * `unresolved` lists what the type's MEMBER SET depends on and the reader could not + * resolve -- an `extends` from another package, an intersection with an imported + * type. A member's own annotation is skipped: it decides the member's type, never + * whether the member exists. At HEAD a non-empty list means absence cannot be + * proven, because the member may have moved into what the reader cannot see. + */ +function membersOf(surface, typeName, ts) { + const symbol = resolvedExport(surface, typeName, ts); + if (!symbol) return { state: 'not-exported' }; + if (!symbol.declarations?.length) return { state: 'unreadable', why: 'it is re-exported from a module this reader does not follow' }; + const { checker } = surface; + const type = symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias) + ? checker.getDeclaredTypeOfSymbol(symbol) + : checker.getTypeOfSymbolAtLocation(symbol, surface.entryFile); + if (type.flags & ts.TypeFlags.Any) return { state: 'unreadable', why: 'it resolves to `any`' }; + const unresolved = []; + const visit = (node) => { + if (ts.isTypeElement(node) || ts.isClassElement(node) || ts.isTypeParameterDeclaration(node) || ts.isFunctionLike(node)) return; + if (ts.isHeritageClause(node) && node.token === ts.SyntaxKind.ImplementsKeyword) return; + const name = ts.isTypeReferenceNode(node) ? node.typeName + : ts.isExpressionWithTypeArguments(node) ? node.expression + : ts.isTypeQueryNode(node) ? node.exprName : null; + if (name) { + const s = checker.getSymbolAtLocation(name); + const target = s && (s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s); + if (!target?.declarations?.length) unresolved.push(name.getText()); + } + ts.forEachChild(node, visit); + }; + for (const d of symbol.declarations) ts.forEachChild(d, visit); + const props = new Map(checker.getPropertiesOfType(type).map((p) => [p.name, p])); + return { state: 'ok', symbol, props, unresolved: [...new Set(unresolved)] }; +} + +/** What predicate 3 reads, said once for every message that refuses on it. */ +const FROM_BLOCK_READS = + ' This category reads the FROM side of a `FROM ... TO ...` line, of an arrow `X -> Y` (or\n' + + ' `X -> delete it`), and of the FROM column of a `| FROM | TO |` / `| Wrote | Write instead |`\n' + + ' table. Every FROM operand must be a named operand, spelled as the marker spells it, and\n' + + ' every named operand must have a FROM side; what the detector still reads after that is\n' + + ' the `no-migration-prescription` refusal, inherited (ADR-0087, the seventh category).'; + +/** + * Verify one `runtime-export-retired` claim: the three predicates, all three, by + * name, every failure reported (the `verifyTypeSurfaceOnly` discipline). + * + * @param {string[]} refs the `#` / `#.` operands + * @param {{ base: string, head: string, cwd: string, body: string, + * packagesAt: (rev: string) => Map, + * kit?: object }} ctx `kit` defaults to the loaded `exportReaderKit` + * @returns {{ problems: string[], verified: {ref: string, kind: string, declared: string}[], checked: string[] }} + */ +export function verifyRuntimeExportRetired(refs, { base, head, cwd, body, packagesAt, kit = exportReaderKit }) { + const problems = []; + const verified = []; + const checked = []; + const P = (name) => { checked.push(name); return name; }; + const HOW = + ' fix: name each retired operand by the package that published it, e.g.\n' + + ' '; + if (!kit) throw new Error('verifyRuntimeExportRetired: the export reader is not loaded -- await loadExportReader() first'); + const { ts } = kit; + + if (refs.length === 0) { + problems.push( + '`not-required (runtime-export-retired)` names no operand.\n' + + ' The whole content of this exemption is WHICH exports retired, and every predicate is a\n' + + ' statement about a named one. An unnamed claim asserts nothing checkable (#4690).\n' + HOW, + ); + return { problems, verified, checked }; + } + const operands = []; + for (const ref of refs) { + const parsed = parseExportOperand(ref); + if (parsed.refusal) problems.push(`\`runtime-export-retired\` operand "${ref}" ${parsed.refusal}.\n${HOW}`); + else operands.push({ ref, ...parsed }); + } + if (operands.length === 0) return { problems, verified, checked }; + + // ---- predicate 1: retired from the published entry -------------------------- + P('retired-from-entry'); + const read = new Map(); + const surfaceAt = (rev, pkg) => { + const key = `${rev} ${pkg}`; + if (!read.has(key)) read.set(key, readExportSurface(rev, cwd, pkg, packagesAt(rev), kit)); + return read.get(key); + }; + const tag = (op) => `\`runtime-export-retired ${op.ref}\` [predicate 1: retired-from-entry]`; + const starNote = (surface) => surface.unresolvedStars.length === 0 ? '' : + `\n Its entry re-exports ${surface.unresolvedStars.map((s) => `\`export * from '${s.specifier}'\` (${s.file})`).join(', ')},\n` + + ' which this reader cannot follow -- a name could sit behind it.'; + const retired = []; + for (const op of operands) { + const at = surfaceAt(base, op.pkg); + if (at.refusal) { problems.push(`${tag(op)} cannot be read at the merge base:\n ${at.refusal}.`); continue; } + const now = surfaceAt(head, op.pkg); + if (now.refusal) { problems.push(`${tag(op)} cannot be read at HEAD:\n ${now.refusal}.`); continue; } + if (op.member === null) { + if (!at.names.has(op.exportName)) { + problems.push( + `${tag(op)} is false: \`${op.exportName}\` is not exported by ${at.entry} at the merge base, so\n` + + ` nothing named that was published to retire.${starNote(at)}`, + ); + continue; + } + if (now.unresolvedStars.length > 0) { + problems.push(`${tag(op)} cannot be proven: absence at HEAD is a claim about every name the entry exports.${starNote(now)}`); + continue; + } + if (now.names.has(op.exportName)) { + problems.push( + `${tag(op)} is false: \`${op.exportName}\` is STILL exported by ${now.entry} at HEAD.\n` + + ' A name that is still published retired nothing; the compiler has no break to deliver.', + ); + continue; + } + retired.push({ op, at, symbol: resolvedExport(at, op.exportName, ts), member: null }); + continue; + } + const before = membersOf(at, op.exportName, ts); + if (before.state !== 'ok') { + problems.push(before.state === 'not-exported' + ? `${tag(op)} is false: the type \`${op.exportName}\` is not exported by ${at.entry} at the merge base.${starNote(at)}` + : `${tag(op)} cannot read the members of \`${op.exportName}\` at the merge base: ${before.why}.`); + continue; + } + if (!before.props.has(op.member)) { + problems.push( + `${tag(op)} is false: \`${op.exportName}\` has no member \`${op.member}\` at the merge base.` + + (before.unresolved.length ? `\n Its shape there depends on ${before.unresolved.map((n) => `\`${n}\``).join(', ')}, which this reader does not resolve.` : ''), + ); + continue; + } + const after = membersOf(now, op.exportName, ts); + if (after.state === 'not-exported') { + problems.push( + `${tag(op)}: the type \`${op.exportName}\` itself is no longer exported at HEAD. A retired TYPE is\n` + + ` named as an export (\`${op.pkg}#${op.exportName}\`), not through one of its members.`, + ); + continue; + } + if (after.state !== 'ok') { problems.push(`${tag(op)} cannot read the members of \`${op.exportName}\` at HEAD: ${after.why}.`); continue; } + if (after.unresolved.length > 0) { + problems.push( + `${tag(op)} cannot be proven: at HEAD the members of \`${op.exportName}\` depend on\n` + + ` ${after.unresolved.map((n) => `\`${n}\``).join(', ')}, which this reader does not resolve -- \`${op.member}\` could have moved there.`, + ); + continue; + } + if (after.props.has(op.member)) { + problems.push(`${tag(op)} is false: \`${op.exportName}\` still has a member \`${op.member}\` at HEAD.`); + continue; + } + retired.push({ op, at, symbol: before.symbol, member: before.props.get(op.member) }); + } + + // ---- predicate 2: no metadata surface, read at the merge base ---------------- + P('no-metadata-surface'); + let metadataTexts = null; + const surfacePaths = retired.length > 0 ? metadataSurfaceFilesAt(base, cwd) : []; + if (retired.length > 0 && surfacePaths.length === 0) { + problems.push( + '`not-required (runtime-export-retired)` [predicate 2: no-metadata-surface] cannot be verified: NO\n' + + ` metadata-surface file (\`*.zod.ts\`, \`${SPEC_CONTRACTS_DIR}**\`, object definitions) exists at the merge\n` + + ' base, so the scan that would refuse this claim has nothing to read (#4690).', + ); + } + for (const { op, at, symbol, member } of surfacePaths.length > 0 ? retired : []) { + const t = `\`runtime-export-retired ${op.ref}\` [predicate 2: no-metadata-surface]`; + const files = declarationFiles(at, symbol); + if (files.length === 0) { + problems.push(`${t} cannot be verified: the declaration of \`${op.exportName}\` cannot be located at the merge base.`); + continue; + } + const site = [...files, ...declarationFiles(at, member)].find((f) => metadataSurfaceKind(f) !== null); + if (site) { + problems.push( + `${t} is false: \`${op.spelling}\` is declared in ${site} (${metadataSurfaceKind(site)}).\n` + + ' A metadata surface is what `objectstack migrate meta` reaches, so the ledger IS a channel here.\n' + + ' fix: register the migration.', + ); + continue; + } + const projection = (symbol.declarations ?? []).find((d) => ts.isTypeAliasDeclaration(d) && ZOD_PROJECTION_RE.test(d.type.getText())); + if (projection) { + problems.push( + `${t} is false: \`${op.exportName}\` is a projection of a Zod schema (${projection.type.getText().slice(0, 90)}).\n` + + ' Under Prime Directive #1 that is the house spelling of "this type IS metadata".', + ); + continue; + } + metadataTexts ??= showManyOrNull(base, surfacePaths, cwd); + const hit = metadataSurfaceReference(op.exportName, files[0], metadataTexts, () => packagesAt(base)); + if (hit) { + problems.push( + `${t} is false: ${hit.hitPath} (${metadataSurfaceKind(hit.hitPath)}) ` + + (hit.kind === 'import' + ? `IMPORTS \`${op.exportName}\` from the module that declares it.` + : `mentions \`${op.exportName}\` IN CODE while neither declaring nor importing it:\n ${hit.line.slice(0, 140)}`) + + '\n An export a metadata surface reaches HAS a metadata surface (comment spans are already excluded).', + ); + continue; + } + verified.push({ ref: op.ref, kind: op.member === null ? 'export' : 'member', declared: files[0] }); + } + + // ---- predicate 3: the FROM -> TO block names exactly the named operands ------ + P('from-operands-named'); + const t3 = '`not-required (runtime-export-retired)` [predicate 3: from-operands-named]'; + const named = new Set(operands.map((o) => o.spelling)); + const block = readFromBlock(body); + for (const u of block.unread) { + problems.push(`${t3}: the body carries ${u.shape}, which this category does not read:\n ${u.text.slice(0, 160)}\n${FROM_BLOCK_READS}`); + } + const seen = new Set(); + const reported = new Set(); + for (const side of block.sides) { + if (side.spelling === null) { + if (reported.has(side.operand)) continue; + reported.add(side.operand); + problems.push( + `${t3}: the FROM operand ${side.operand} is not a canonical operand:\n ${side.text.slice(0, 160)}\n` + + ' An export is spelled by its name -- a call `name(...)` or `new Name(...)` reads as that name --\n' + + ' and a member as `.`. Nothing inside an argument list or an object literal is\n' + + ' read (the ruling\'s ⛔ on parsing constructor arguments).', + ); + continue; + } + seen.add(side.spelling); + if (named.has(side.spelling) || reported.has(side.spelling)) continue; + reported.add(side.spelling); + problems.push( + `${t3}: the FROM operand ${side.operand} names \`${side.spelling}\`, which the marker does not name -- out of range:\n` + + ` ${side.text.slice(0, 160)}\n` + + ' A prescription for anything but the named operands keeps the `no-migration-prescription` refusal.\n' + + ' A retired MEMBER is named `.` in the marker and written the same way on the FROM side.', + ); + } + for (const op of operands) { + if (seen.has(op.spelling)) continue; + problems.push( + `${t3}: the named operand \`${op.ref}\` has no FROM side in the body. The block must name EXACTLY the\n` + + ` named operands: write the one-line fix a consumer needs, a line such as\n` + + ` - FROM \`${op.spelling}\` TO \n${FROM_BLOCK_READS}`, + ); + } + if (block.residual) { + problems.push( + `${t3}: once the named FROM sides are set aside, the body still carries a migration prescription\n` + + ` (${block.residual.branch}):\n ${block.residual.line.slice(0, 160)}\n${FROM_BLOCK_READS}`, + ); + } + + return { problems, verified, checked }; +} + const FIXIT = (ids) => [ ' Add ONE of these lines to the changeset body:', @@ -3971,6 +4725,7 @@ const FIXIT = (ids) => ' ', ' ', ' ', + ' ', '', ' Each category is described in ADR-0087, addendum of 2026-08-13.', ].join('\n'); @@ -3999,6 +4754,18 @@ export function scan({ cwd, base, head = 'HEAD' }) { // such claim. let pkgsCache = null; const packages = () => (pkgsCache ??= workspacePackagesAt(head, cwd)); + // `runtime-export-retired` reads manifests at BOTH revs (#22918); keyed by rev, + // and the HEAD entry is the same map `packages()` reads. + const pkgsByRev = new Map(); + const packagesAt = (rev) => { + if (rev === head) return packages(); + if (!pkgsByRev.has(rev)) pkgsByRev.set(rev, workspacePackagesAt(rev, cwd)); + return pkgsByRev.get(rev); + }; + // Set when a marker names `runtime-export-retired` in a process that has not + // loaded the compiler kit; the entry point loads it and scans again (see + // `exportReaderKit`). Never a pass: the changeset is reported as a problem too. + let needsExportReader = false; // Same lazy posture, and for the same reason: only a `type-surface-only` claim // needs the diff's file list. A sentinel rather than `??=`, because // `changedPathsBetween` returns null on a failed read and `??=` would re-run it @@ -4215,7 +4982,15 @@ export function scan({ cwd, base, head = 'HEAD' }) { // (the base-side type reading), never by this line. Predicates 1-3 were measured // NOT to separate #6048 from #12104. Read that function's header before widening // this condition to a second category. - const prescription = d.category === 'type-surface-only' ? null : findMigrationPrescription(parsed.body); + // + // `runtime-export-retired` (#22918) is NOT exempt, and is not dispatched here + // only because it inherits the refusal more finely: predicate 3 of + // `verifyRuntimeExportRetired` runs this same detector over the body with ONLY + // the FROM sides it verified set aside, so what is refused here is refused + // there, minus exactly the lines naming operands predicates 1-2 proved retired. + const prescription = d.category === 'type-surface-only' || d.category === 'runtime-export-retired' + ? null + : findMigrationPrescription(parsed.body); if (prescription) { push( `\`not-required (${d.category})\` contradicts the changeset's own body, which carries\n` + @@ -4237,7 +5012,12 @@ export function scan({ cwd, base, head = 'HEAD' }) { ' \n' + ' ⛔ Do NOT reach for it by dropping the `**BREAKING**` token instead. That was the only\n' + ' move this gate used to leave open and four changesets took it; it is what #13080 was\n' + - ' filed to stop.', + ' filed to stop.\n' + + ' And if the prescription rewrites only EXPORTS (or members of an exported type) that a\n' + + ' published package RETIRED -- each one reachable from its entry at the merge base, gone at\n' + + ' HEAD, no metadata surface -- the compiler delivers it too, and the operands are checked\n' + + ' (#22918):\n' + + ' ', ); continue; } @@ -4255,6 +5035,30 @@ export function scan({ cwd, base, head = 'HEAD' }) { continue; } + if (d.category === 'runtime-export-retired') { + if (!exportReaderKit) { + needsExportReader = true; + push( + '`not-required (runtime-export-retired)` cannot be judged in this process: its export reader (the\n' + + ' TypeScript compiler) is loaded on demand and was not. The entry point loads it and scans\n' + + ' again; a caller of `scan()` must `await loadExportReader()` first. Never a pass (#4690).', + ); + continue; + } + const { problems: bad, verified } = verifyRuntimeExportRetired(d.ids, { + base: from, head, cwd, body: parsed.body, packagesAt, + }); + if (bad.length > 0) { + for (const message of bad) push(message); + continue; + } + judged.push({ + file, verdict: 'not-required', category: d.category, ids: d.ids, why: d.why, signals: decl.signals, + detail: verified.map((v) => `${v.ref} (${v.kind}, ${v.declared})`).join(', '), + }); + continue; + } + if (d.category === 'type-surface-only') { const { problems: bad, verified } = verifyTypeSurfaceOnly(d.ids, { base: from, head, cwd, bumps: parsed.bumps, packages, changedPaths, @@ -4273,7 +5077,7 @@ export function scan({ cwd, base, head = 'HEAD' }) { judged.push({ file, verdict: 'not-required', category: d.category, why: d.why, signals: decl.signals }); } - return { problems, judged, skipped, base: from, ledgerAdded }; + return { problems, judged, skipped, base: from, ledgerAdded, needsExportReader }; } // --------------------------------------------------------------------------- @@ -7605,6 +8409,10 @@ if (isEntrypoint(import.meta.url)) { }; if (argv.includes('--self-test')) { + // The self-test drives `runtime-export-retired` through the shipping `scan()`, + // which reads exports with the compiler -- loaded here, once, as the gate's own + // entry point loads it (#22918). + await loadExportReader(); if (selfTest() !== SELF_TEST_VERDICT) { console.error( '\n✗ check-adr-0087-registration self-test: selfTest() returned without reaching its verdict,\n' @@ -7647,6 +8455,12 @@ if (isEntrypoint(import.meta.url)) { let result; try { result = scan({ cwd: REPO_ROOT, base, head }); + // Only a PR whose changeset names `runtime-export-retired` pays for the + // compiler (#22918): load it, then judge the same diff again with it. + if (result.needsExportReader) { + await loadExportReader(); + result = scan({ cwd: REPO_ROOT, base, head }); + } } catch (e) { console.error(`⛔ check-adr-0087-registration: ${e.message}`); process.exit(1); From c7644d49aec14de7c06ac5a25a03b740058f50f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:42:35 +0000 Subject: [PATCH 3/3] feat(adr-0087): the seventh disposition category, runtime-export-retired -- the ADR addendum (D9) and the self-test batteries Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude --- ...0087-metadata-protocol-upgrade-contract.md | 144 ++++++- scripts/check-adr-0087-registration.mjs | 359 +++++++++++++++++- 2 files changed, 494 insertions(+), 9 deletions(-) diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index 12c6ab7e3b2..9c2cc730ddf 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -730,6 +730,8 @@ gate's log and its `--list` output. + + ``` **The vocabulary is closed, and every exemption is re-verified on every run** — an @@ -739,8 +741,9 @@ avoid. `registered` must name ids that resolve *and* are new in the diff; `already-registered` requires the named ids to pre-date the merge base; `no-migration-prescription` is refused by a body that carries a migration prescription; `runtime-interface-only` is the subject of the rest of this -addendum; and `type-surface-only` — the sixth, added 2026-08-30 — is the subject of -the addendum after it. The checks live in +addendum; `type-surface-only` — the sixth, added 2026-08-30 — is the subject of +the addendum after it; and `runtime-export-retired` — the seventh, added 2026-10-12 — +is the subject of the addendum after that. The checks live in [`scripts/check-adr-0087-registration.mjs`](../../scripts/check-adr-0087-registration.mjs), which is also where each one's measured history is written down. @@ -970,6 +973,143 @@ lands, per the ruling, and one of them (`declare-search-clone-response-contracts fails predicates 2 and 3 outright — it bumps `@objectstack/spec` and adds `packages/spec/src/api/protocol.zod.ts`, so it is not a member of this class. +## Addendum (2026-10-12) — the seventh category: a published runtime EXPORT retirement (#22918) + +The vocabulary above gained a seventh answer, with two operand kinds that may be +mixed in one marker: + +```text + + +``` + +### The dead end it closes + +The worked example is `.changeset/15205-email-templates-resolve-registry.md` (PR +#22875, merged as `7de0265fc7`). It retires nine exports of +`@objectstack/plugin-email` and two members of its exported +`EmailServicePluginOptions`, and gives each retired name the one-line FROM → TO fix a +removal owes, written as six arrowless `- FROM … TO …` list lines. Every disposition +was closed to it on the facts but one: + +| disposition | outcome | +|---|---| +| `registered` / `already-registered` | closed on the facts — no ledger id covers these paths and the diff adds none, and `objectstack migrate meta` has nothing to rewrite in host code. | +| `not-required (unpublished)` | refused — every bumped package publishes. | +| `not-required (runtime-interface-only …)` | refused — it reads an exported type declaration at HEAD, and a retired export has none. | +| `not-required (type-surface-only …)` | refused — nothing narrowed off `any`. | +| `not-required (no-migration-prescription)` | admitted — but only because the detector reads an arrow or a framed table, and these lines carry neither. | + +So the exemption was held by a detector **miss**, the shape D7 retires, and the +changeset's own marker asked for a ruling. The class recurs with ADR-0131's v18 +retirements. Measured on the stock at `018b077fb`: 131 declared-breaking changesets, +83 of them on `no-migration-prescription`; arrowless `FROM … TO` lines naming code +appear in 6 changesets, 4 declared-breaking, and the detector reads none of them — +15205 is the one that holds the catch-all, the other three are `registered`. + +### D9 — a retired published runtime export is compiler-carried, and the gate proves the retirement with the compiler + +The channel argument is D7's and D8's: the affected party is a TypeScript consumer, +the compiler reports the break at their own import (`TS2305 … has no exported +member`) or at the object literal that still passes a removed option, and the ledger +has no metadata to rewrite. + +**Operands.** `#` names a retired export of the package's published +entry; `#.` names a retired member of an exported type — the +dotted spelling `type-surface-only` already uses. The **package** is named, never a +file: "reachable from the published entry" is a fact about the package, and a +declaration can move between files without the export moving. ⛔ A member is addressed +by that **canonical spelling only**. The gate parses no constructor argument and no +object literal: a FROM side written `new EmailServicePlugin({ seedTemplates: false })` +names `EmailServicePlugin`, never `EmailServicePluginOptions.seedTemplates`. + +An author may claim the category when, for every operand, all three of these hold — +re-verified on every run and checked by name (`retired-from-entry`, +`no-metadata-surface`, `from-operands-named`): + +1. **retired-from-entry** — an export is reachable from the package's published entry + at the merge base and absent at HEAD; for a member, its type is reachable at the + base, the member is present on it there (`getPropertiesOfType`), and it is absent + at HEAD while the type is still exported. The reading is the **TypeScript + compiler's**: a Program over the package's source entry at each rev, and + `getExportsOfModule` on the entry's symbol, so `export *` resolves. The entry is + located through the package's `exports` map (read with `resolveTypesEntry`, the + readme-exports gate's reader, imported rather than re-written) and tsup's + convention: the declaration entry `dist/index.d.ts` — or the `.d.mts` / `.d.cts` + twin tsup writes beside it, which is where `@objectstack/spec`'s `import` + condition lands — is generated from `src/index.ts`. A private package has no + published entry (`unpublished` is its disposition). +2. **no-metadata-surface** — at the merge base, no operand is declared in a + `*.zod.ts`, under `packages/spec/src/contracts/` or in an object definition, none + is a Zod projection, and no metadata surface references it in code: the D7 + primitives, read at the **base** because the operand is gone at HEAD. +3. **from-operands-named** — the FROM → TO block names **exactly** the named + operands, each spelled as in the marker (`Type.member` for a member): every FROM + operand is a named one, and every named one has a FROM side. + +⛔ **A published package whose entry cannot be mapped is refused, loudly and by name — +never guessed.** Measured at `018b077fb`, 4 of 70 published packages: +`@objectstack/client-react` (no `src/index.ts`), and `@objectstack/console`, +`@objectstack/skills` and `create-objectstack` (no types entry). Absence is proven, +not assumed, the same way: an `export *` the reader cannot follow refuses an absence +claim at HEAD (a name could sit behind it), and so does a type whose member set at +HEAD depends on something the reader cannot resolve. A re-export of another workspace +package's root entry is followed through the same mapping. + +**The deviation accepted with the ruling.** A merge base has no build output (`dist/` +is gitignored), so predicate 1 compiles the **source** entry that generates the +public `.d.ts`, not the `.d.ts` itself. For the question "which names does the +package export" the two agree. + +**It inherits the `no-migration-prescription` refusal — more finely, never less.** +Unlike `type-surface-only` it is not exempt. Predicate 3 reads the FROM side of every +rewrite the body writes — a `FROM … TO …` line, an arrow `X → Y` (or `X → delete`), +the FROM column of a `| FROM | TO |` or `| Wrote | Write instead |` table — then sets +exactly those lines aside and runs the same prescription detector over what is left. +Anything it still finds is refused: a rewrite table with no FROM column, a vertical +FROM / TO pair, a FROM → TO label over anything not read. Nothing the detector refuses +today becomes claimable unless every operand it rests on was proven retired by +predicates 1 and 2 — and the arrowless block the detector cannot see is read here +instead of waved through. + +### The worked example under this category + +Its lines 25–28 name the nine retired exports, and the gate accepts that half under +this marker (the self-test carries their text). Its lines 29–30 write the two retired +options as `new EmailServicePlugin({ … })`, which names the still-exported +`EmailServicePlugin`: under this marker they are refused as out of range. Those two +lines are the reason the member limb exists — without it a mixed retirement keeps +half its claim on a detector miss — and the reason the member is spelled canonically +(`EmailServicePluginOptions.seedTemplates`). The changeset itself is merged and is +**not re-graded**: it stays on `no-migration-prescription`. New retirements use this +category. + +### Not taken + +- **Exports only** — a mixed retirement would keep its member half on the catch-all, + held by a detector miss: the shape D7 retires. +- **Members resolved from constructor arguments** (`new X({ option })` read back to + the options type) — the fragile half, and unneeded once the spelling is canonical. +- **The existing detector alone** — it reads no arrowless line, so the class would be + admitted by the same miss. +- **A one-hop text reader of the entry that refuses `export *`** — a second export + resolver beside the compiler, and its blind spot refuses honest claims. +- **Per-package export snapshots** — a new family of generated artifacts with its own + gate; a new gate defaults to no. + +### What this does not decide + +**The completeness of the claim.** Like `registered`, `runtime-interface-only` and +`type-surface-only`, the gate judges the operands named, not every export the diff +removed. + +**A subpath entry or a static member.** Only the root entry is read, and a member is +read on a class's instance type; anything else is refused rather than assumed. + +**The detector.** `no-migration-prescription`'s refusal and the detector's arrow rule +are unchanged; the arrowless line stays outside the detector's reach, stated as such +in the gate's residual-blind-spot paragraph, and is read only by this category. + ## Addendum (2026-09-13) — the artifact-ingestion door opens a versioned window (#12772) The addenda above reach authored source and metadata at rest in `sys_metadata`. diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index c106445bda1..322b6f8728c 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -358,12 +358,17 @@ // pair had no home at all -- #8277's argument was correct, and there was no // named category anywhere for it to cite. // -// Zero third-party IMPORTS. One input needs the install, though: the parser-rot -// witness is generated by `packages/spec/scripts/build-spec-changes.ts` under the -// installed `tsx` (#22485), so the real run needs `pnpm install` first -- every CI -// site that runs it already installs before it -- and reports the missing runner -// as a red input, never as a pass. The `--self-test` drives that generator path -// through a fixture generator under this checkout's `tsx` too. +// ONE third-party import, and it is LAZY: `typescript`, which `runtime-export-retired` +// reads exports with (#22918). The line that used to open this paragraph -- "Zero +// third-party IMPORTS" -- is retracted by the ruling that added the category. It is +// loaded through `scripts/import-prerequisite.mjs` only when a changeset in the diff +// names that category (`loadExportReader`), so every other run pays nothing for it. +// Inputs need the install, too: the parser-rot witness is generated by +// `packages/spec/scripts/build-spec-changes.ts` under the installed `tsx` (#22485), +// so the real run needs `pnpm install` first -- every CI site that runs it already +// installs before it -- and reports the missing runner as a red input, never as a +// pass. The `--self-test` drives that generator path through a fixture generator +// under this checkout's `tsx` too, and loads the compiler for the seventh category. import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -437,6 +442,10 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'TSO-U: unit pins on predicate 4\'s readers': 30, 'TSO-D (#15627): a DOTTED member path resolves through the object-literal nesting': 27, 'TSO-C (#17279): a DOTTED member path resolves through a CLASS body': 17, + 'The #22918 category: `runtime-export-retired`': 49, + 'REX-N: the predicate set is pinned BY NAME, never by count': 3, + 'REX-U: unit pins on the operand grammar and the FROM-side reader': 24, + 'REX-L: the compiler is loaded only when a marker names the category': 6, 'G6: a changeset that was ALREADY breaking at base is inherited': 1, 'R15: a changeset RENAMED AND turned breaking in the same commit': 5, 'G10: a PURE rename of an ALREADY-breaking stock changeset': 3, @@ -468,7 +477,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // 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 = 51; +const SELF_TEST_BATTERY_FLOOR = 55; // 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 @@ -938,6 +947,16 @@ export function breakingDeclaration(parsed) { // different criterion (2+ replacements in code spans on a line with an // imperative verb), whose false-positive surface is intuitively much larger: // any changeset enumerating API usage would hit it. Falsify before implementing. +// * AN ARROWLESS `FROM ... TO ...` LINE -- `- FROM TO `, each side a code span -- +// the convention's two words as a list line with no arrow between them, so branch 1's +// placeholder pattern never matches it and no branch reads it. Measured at +// `018b077fb` (417 changesets): 6 carry such a line naming code, 4 of them +// declared-breaking, and the detector reads none of the 6; one of the four +// (`15205-email-templates-resolve-registry.md`) held `no-migration-prescription` +// on exactly this miss. Deliberately left out of reach here -- the ruling on +// #22918 kept the detector's arrow rule unchanged -- and READ instead by +// `runtime-export-retired`'s own `readFromBlock`, which is the category such a +// retirement now claims. // // ## #18745 -- the RETIREMENT arm: a rewrite whose TO side is an INSTRUCTION // @@ -7032,6 +7051,332 @@ function selfTest() { run(mk(CLS_FIXTURE(`type-surface-only ${CLS('NoSuchClass.findOne')}`))), [/cannot be resolved at HEAD/, /no `NoSuchClass` object literal or class/]); + // ---- The #22918 category: `runtime-export-retired` ------------------------- + // + // The fixture is the worked example in miniature: `@objectstack/plugin-email` + // with the nine exports and the two `EmailServicePluginOptions` members it + // retired, six of the nine reached through an `export *` (so predicate 1 is the + // COMPILER's reading, not a text grep of the entry). The changeset text is the + // worked example's own, VERBATIM -- lines 23-28 and 29-30 of + // `.changeset/15205-email-templates-resolve-registry.md` at `7de0265fc` -- + // carried here rather than read from the tree, because the release that consumes + // that changeset must not take this pin with it. + battery('The #22918 category: `runtime-export-retired`'); + const REX_PKG = '@objectstack/plugin-email'; + const REX_DIR = 'packages/plugins/plugin-email'; + const rexPath = (rel) => `${REX_DIR}/${rel}`; + const REX_PKGS = { + '@objectstack/spec': { dir: 'packages/spec', private: false }, + '@objectstack/platform-objects': { dir: 'packages/platform-objects', private: false }, + [REX_PKG]: { dir: REX_DIR, private: false }, + }; + const REX_MANIFEST = (over = {}) => JSON.stringify({ + name: REX_PKG, version: '1.0.0', main: 'dist/index.js', types: 'dist/index.d.ts', + exports: { '.': { types: './dist/index.d.ts', import: './dist/index.mjs', require: './dist/index.js' } }, + ...over, + }); + const REX_PLUGIN_BASE = + 'export interface EmailServicePluginOptions {\n transport?: string;\n seedTemplates?: boolean;\n templates?: unknown[];\n}\n' + + 'export class EmailServicePlugin {\n constructor(readonly options: EmailServicePluginOptions = {}) {}\n}\n'; + const REX_PLUGIN_HEAD = REX_PLUGIN_BASE.replace(' seedTemplates?: boolean;\n templates?: unknown[];\n', ''); + const REX_INDEX_BASE = + "export { EmailServicePlugin } from './email-plugin.js';\n" + + "export type { EmailServicePluginOptions } from './email-plugin.js';\n" + + "export { LogTransport } from './log-transport.js';\n" + + "export { createSysEmailTemplateLoader, type TemplateLoaderEngine, type CreateTemplateLoaderOptions } from './template-loader.js';\n" + + "export * from './declared-templates.js';\n"; + const REX_INDEX_HEAD = + "export { EmailServicePlugin } from './email-plugin.js';\n" + + "export type { EmailServicePluginOptions } from './email-plugin.js';\n" + + "export { LogTransport } from './log-transport.js';\n" + + "export { createRegistryEmailTemplateLoader, type EmailTemplateRegistrySources } from './template-loader.js';\n"; + const REX_BASE_FILES = { + [rexPath('package.json')]: REX_MANIFEST(), + [rexPath('src/index.ts')]: REX_INDEX_BASE, + [rexPath('src/email-plugin.ts')]: REX_PLUGIN_BASE, + [rexPath('src/log-transport.ts')]: 'export class LogTransport {}\n', + [rexPath('src/template-loader.ts')]: + 'export interface TemplateLoaderEngine { find(object: string): Promise }\n' + + 'export interface CreateTemplateLoaderOptions { object?: string }\n' + + 'export function createSysEmailTemplateLoader(engine: TemplateLoaderEngine, options?: CreateTemplateLoaderOptions) {\n' + + ' return { engine, options };\n}\n', + [rexPath('src/declared-templates.ts')]: + "export const EMAIL_TEMPLATE_OBJECT = 'sys_email_template';\n" + + 'export interface BootstrapDeclaredEmailTemplatesResult { written: number }\n' + + 'export function bootstrapDeclaredEmailTemplates(): BootstrapDeclaredEmailTemplatesResult { return { written: 0 }; }\n' + + 'export function upsertDeclaredEmailTemplate(): void {}\n' + + 'export function deactivateDeclaredEmailTemplate(): void {}\n' + + 'export function mapTemplateToRow(tpl: unknown) { return tpl; }\n', + // A metadata surface, so predicate 2 has something to read (#4690) -- and its + // docblock NAMES two retired exports in prose, as the real object does at the + // worked example's base. Prose is not a reference (#12881). + 'packages/platform-objects/src/audit/sys-email-template.object.ts': + '/** Rows `bootstrapDeclaredEmailTemplates` wrote and `createSysEmailTemplateLoader` read. */\n' + + "export const SysEmailTemplate = { name: 'sys_email_template' };\n", + }; + const REX_HEAD_FILES = { + [rexPath('src/index.ts')]: REX_INDEX_HEAD, + [rexPath('src/email-plugin.ts')]: REX_PLUGIN_HEAD, + [rexPath('src/template-loader.ts')]: + 'export interface EmailTemplateRegistrySources { protocol?: unknown; registry?: unknown }\n' + + 'export function createRegistryEmailTemplateLoader(sources: EmailTemplateRegistrySources) {\n return { sources };\n}\n', + [rexPath('src/declared-templates.ts')]: null, + }; + const REX_EXPORT_HALF = [ + '**What moves for consumers.**', + '', + '- FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`.', + '- FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call.', + '- FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`.', + "- FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing.", + ].join('\n'); + const REX_OPTION_LINES = [ + '- FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option.', + "- FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option.", + ].join('\n'); + const REX_CANONICAL_MEMBER_LINES = [ + '- FROM `EmailServicePluginOptions.seedTemplates` TO nothing: there is no seed to switch off. Delete the option.', + "- FROM `EmailServicePluginOptions.templates` TO the stack's `emailTemplates: [...]`. Delete the option.", + ].join('\n'); + const REX_NINE = [ + 'createSysEmailTemplateLoader', 'TemplateLoaderEngine', 'CreateTemplateLoaderOptions', + 'bootstrapDeclaredEmailTemplates', 'upsertDeclaredEmailTemplate', 'deactivateDeclaredEmailTemplate', + 'mapTemplateToRow', 'EMAIL_TEMPLATE_OBJECT', 'BootstrapDeclaredEmailTemplatesResult', + ]; + const REX_MEMBERS = ['EmailServicePluginOptions.seedTemplates', 'EmailServicePluginOptions.templates']; + const REX_WHY = + 'the retired names are runtime exports of a published package; the compiler reports each at its ' + + 'consumer\'s import, and no stored shape moves'; + const REX_CS = (operands, body, category = 'runtime-export-retired') => CS({ + bumps: [[REX_PKG, 'minor']], + body: + 'feat(plugin-email)!: templates resolve from the registry\n\n**BREAKING**: retired exports.\n\n' + + `${body}\n\n\n`, + }); + const REX_FIXTURE = (operands, body, over = {}) => ({ + pkgs: over.pkgs ?? REX_PKGS, + baseFiles: { ...REX_BASE_FILES, ...(over.baseFiles ?? {}) }, + files: { ...REX_HEAD_FILES, '.changeset/x.md': REX_CS(operands, body, over.category), ...(over.files ?? {}) }, + }); + const messages = (res) => res.problems.map((p) => p.message).join('\n'); + + // REX-G1: THE WORKED EXAMPLE'S EXPORT HALF, under the new marker -- the ruled + // acceptance case. Six of the nine are reached only through `export *`, so the + // detail line naming `declared-templates.ts` is the compiler's reading of it. + const rexG1 = run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF))); + green('REX-G1 the worked example\'s EXPORT HALF (its lines 25-28) is admitted under `runtime-export-retired`', rexG1); + assert( + rexG1.judged[0]?.category === 'runtime-export-retired' && + /mapTemplateToRow \(export, packages\/plugins\/plugin-email\/src\/declared-templates\.ts\)/.test(rexG1.judged[0]?.detail ?? ''), + 'REX-G1b: the verdict must name each operand\'s DECLARATION as the compiler resolved it -- `mapTemplateToRow` ' + + `is reached only through \`export *\`. Got: ${JSON.stringify(rexG1.judged[0])}`, + ); + assert(rexG1.needsExportReader === false, 'REX-G1c: a process that HAS the export reader judges the claim in one scan'); + + // REX-R1: a still-present export. Everything else true; predicate 1 alone refuses. + const rexR1 = run(mk(REX_FIXTURE([...REX_NINE, 'LogTransport'], `${REX_EXPORT_HALF}\n- FROM \`LogTransport\` TO nothing.`))); + red('REX-R1 a STILL-PRESENT export is refused by predicate 1', rexR1, + [/predicate 1: retired-from-entry\] is false: `LogTransport` is STILL exported/]); + assert(rexR1.problems.length === 1, `REX-R1b: only the still-present operand is refused (got ${rexR1.problems.length}):\n${messages(rexR1)}`); + + // REX-R2: a `*.zod.ts` symbol. Retired from the entry like the rest, so predicate + // 1 holds -- and predicate 2 refuses it on its declaration site. + red('REX-R2 a `*.zod.ts` symbol is refused by predicate 2', run(mk(REX_FIXTURE( + [...REX_NINE, 'EmailTemplateRowSchema'], + `${REX_EXPORT_HALF}\n- FROM \`EmailTemplateRowSchema\` TO nothing.`, + { + baseFiles: { + [rexPath('src/index.ts')]: `${REX_INDEX_BASE}export { EmailTemplateRowSchema } from './template.zod.js';\n`, + [rexPath('src/template.zod.ts')]: 'export const EmailTemplateRowSchema = { shape: {} };\n', + }, + }, + ))), [/predicate 2: no-metadata-surface\] is false: `EmailTemplateRowSchema` is declared in .*template\.zod\.ts \(a Zod schema\)/]); + + // REX-R3: a FROM operand outside the named set, both ways it arrives: a name the + // marker left out, and the worked example's own lines 29-30, which name the + // still-exported `EmailServicePlugin` -- the ruling's example, verbatim. + red('REX-R3a a FROM operand the marker does not name is refused as out of range', run(mk(REX_FIXTURE( + REX_NINE.filter((n) => n !== 'EMAIL_TEMPLATE_OBJECT'), REX_EXPORT_HALF, + ))), [/FROM operand `EMAIL_TEMPLATE_OBJECT` names `EMAIL_TEMPLATE_OBJECT`, which the marker does not name -- out of range/]); + red('REX-R3b the worked example in FULL: its lines 29-30 name `EmailServicePlugin`, out of range', run(mk(REX_FIXTURE( + REX_NINE, `${REX_EXPORT_HALF}\n${REX_OPTION_LINES}`, + ))), [/`new EmailServicePlugin\(\{ seedTemplates: false \}\)` names `EmailServicePlugin`, which the marker does not name -- out of range/]); + + // REX-R4: a non-canonical member spelling. The marker names the two members + // canonically; the body writes them as constructor arguments, which are never + // parsed -- so the FROM side is out of range AND the members have no FROM side. + red('REX-R4a members written as constructor arguments are not canonical member operands', run(mk(REX_FIXTURE( + [...REX_NINE, ...REX_MEMBERS], `${REX_EXPORT_HALF}\n${REX_OPTION_LINES}`, + ))), [/names `EmailServicePlugin`, which the marker does not name/, /`@objectstack\/plugin-email#EmailServicePluginOptions\.seedTemplates` has no FROM side/]); + red('REX-R4b a member spelled more than one level deep in the marker is refused by its grammar', run(mk(REX_FIXTURE( + [...REX_NINE, 'EmailServicePluginOptions.seedTemplates.enabled'], REX_EXPORT_HALF, + ))), [/operand "@objectstack\/plugin-email#EmailServicePluginOptions\.seedTemplates\.enabled" spells a member 2 levels deep/]); + + // REX-G2: the member limb, spelled canonically -- the reason it exists. + green('REX-G2 the retired members, spelled `.`, are admitted beside the nine exports', run(mk(REX_FIXTURE( + [...REX_NINE, ...REX_MEMBERS], `${REX_EXPORT_HALF}\n${REX_CANONICAL_MEMBER_LINES}`, + )))); + + // REX-R5: an unmappable package is refused by name, never guessed. + red('REX-R5a an entry that is not tsup\'s `dist/index.d.ts` is refused, not guessed', run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF, { + baseFiles: { [rexPath('package.json')]: REX_MANIFEST({ types: 'dist/main.d.ts', exports: { '.': { types: './dist/main.d.ts' } } }) }, + }))), [/cannot be mapped to its source/, /names `dist\/main\.d\.ts`/, /never guessed/]); + red('REX-R5b a package with no types entry at all is refused, not guessed', run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF, { + baseFiles: { [rexPath('package.json')]: JSON.stringify({ name: REX_PKG, version: '1.0.0', exports: { '.': './dist/index.js' } }) }, + }))), [/cannot be mapped to its source/, /names no types entry/]); + + // REX-R6: the marker names nothing. + red('REX-R6 a marker naming no operand is refused', run(mk(REX_FIXTURE([], REX_EXPORT_HALF))), + [/`not-required \(runtime-export-retired\)` names no operand/]); + + // REX-R7: "exactly" is both directions -- a named operand with no FROM side. + red('REX-R7 a named operand with NO FROM side is refused: the block names exactly the named set', run(mk(REX_FIXTURE( + REX_NINE, REX_EXPORT_HALF.split('\n').slice(0, -1).join('\n'), + ))), [/`@objectstack\/plugin-email#EMAIL_TEMPLATE_OBJECT` has no FROM side/, /BootstrapDeclaredEmailTemplatesResult` has no FROM side/]); + + // REX-R8: a prescription this category does not read keeps the inherited refusal. + red('REX-R8a a framed rewrite table with no FROM column is still the detector\'s, and refused', run(mk(REX_FIXTURE( + REX_NINE, `${REX_EXPORT_HALF}\n\n## Migration\n\n| before | after |\n| --- | --- |\n| \`sys_email_template.locale\` | \`email_template.locale\` |\n`, + ))), [/still carries a migration prescription/, /framed-table/]); + red('REX-R8b a vertical FROM / TO pair is reported, never skipped', run(mk(REX_FIXTURE( + REX_NINE, `${REX_EXPORT_HALF}\n\n- **FROM:** \`mapTemplateToRow(tpl)\`\n- **TO:** nothing\n`, + ))), [/carries a vertical FROM \/ TO pair, which this category does not read/]); + + // REX-G3: the convention's own label above the block is a label, not a refusal. + green('REX-G3 a `## FROM → TO` heading above the block it introduces is admitted', run(mk(REX_FIXTURE( + REX_NINE, `## FROM → TO\n\n${REX_EXPORT_HALF}`, + )))); + + // REX-R9..R13: predicates 1-2 refuse each of the other ways a claim can be false. + red('REX-R9 a PRIVATE package has no published entry', run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF, { + pkgs: { ...REX_PKGS, [REX_PKG]: { dir: REX_DIR, private: true } }, + baseFiles: { [rexPath('package.json')]: REX_MANIFEST({ private: true }) }, + }))), [/is PRIVATE/, /not-required \(unpublished\)/]); + red('REX-R10 a metadata surface that IMPORTS the export refuses it (predicate 2)', run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF, { + baseFiles: { + 'packages/spec/src/email/email-row.zod.ts': + `import { mapTemplateToRow } from '${REX_PKG}';\nexport const EmailRowSchema = mapTemplateToRow({});\n`, + }, + }))), [/predicate 2: no-metadata-surface\] is false: packages\/spec\/src\/email\/email-row\.zod\.ts \(a Zod schema\) IMPORTS `mapTemplateToRow`/]); + red('REX-R11 a member still present at HEAD is refused (predicate 1)', run(mk(REX_FIXTURE( + [...REX_NINE, REX_MEMBERS[0]], `${REX_EXPORT_HALF}\n${REX_CANONICAL_MEMBER_LINES.split('\n')[0]}`, + { files: { [rexPath('src/email-plugin.ts')]: REX_PLUGIN_BASE } }, + ))), [/`EmailServicePluginOptions` still has a member `seedTemplates` at HEAD/]); + red('REX-R12 a member of a type that is itself gone at HEAD is named as the export, not through the member', run(mk(REX_FIXTURE( + [...REX_NINE, REX_MEMBERS[0]], `${REX_EXPORT_HALF}\n${REX_CANONICAL_MEMBER_LINES.split('\n')[0]}`, + { files: { [rexPath('src/index.ts')]: REX_INDEX_HEAD.replace("export type { EmailServicePluginOptions } from './email-plugin.js';\n", '') } }, + ))), [/the type `EmailServicePluginOptions` itself is no longer exported at HEAD/]); + red('REX-R13 an `export *` the reader cannot follow refuses an absence claim at HEAD', run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF, { + files: { [rexPath('src/index.ts')]: `${REX_INDEX_HEAD}export * from '@objectstack/core';\n` }, + }))), [/cannot be proven: absence at HEAD/, /export \* from '@objectstack\/core'/]); + + // REX-C: the catch-all is UNCHANGED. The merged worked example is not re-graded, + // and the detector's arrow rule was kept: the same body under + // `no-migration-prescription` reads exactly as it did before this category. + green('REX-C the worked example\'s own disposition is unchanged: the catch-all still reads no arrowless line', + run(mk(REX_FIXTURE([], `${REX_EXPORT_HALF}\n${REX_OPTION_LINES}`, { category: 'no-migration-prescription' })))); + + // ---- REX-N: the predicate set is pinned BY NAME, never by count ------------ + battery('REX-N: the predicate set is pinned BY NAME, never by count'); + assert( + JSON.stringify(RUNTIME_EXPORT_PREDICATES) === + JSON.stringify(['retired-from-entry', 'no-metadata-surface', 'from-operands-named']), + `REX-N1: the three predicates are pinned BY NAME. Got: ${JSON.stringify(RUNTIME_EXPORT_PREDICATES)}. ` + + 'Changing one changes what the seventh category rests on (ADR-0087, addendum 2026-10-12) -- change this pin deliberately.', + ); + { + const f = mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF)); + const seen = verifyRuntimeExportRetired(REX_NINE.map((n) => `${REX_PKG}#${n}`), { + base: f.base, head: 'HEAD', cwd: f.dir, body: REX_EXPORT_HALF, packagesAt: (rev) => workspacePackagesAt(rev, f.dir), + }); + assert(seen.problems.length === 0, `REX-N2a: the all-true fixture must verify clean, got:\n${seen.problems.join('\n')}`); + assert( + JSON.stringify(seen.checked) === JSON.stringify(RUNTIME_EXPORT_PREDICATES), + 'REX-N2b: the predicates verifyRuntimeExportRetired ACTUALLY evaluates must be exactly the exported ' + + `RUNTIME_EXPORT_PREDICATES, in order. Got: ${JSON.stringify(seen.checked)}.`, + ); + } + + // ---- REX-U: unit pins on the operand grammar and the FROM-side reader ------ + battery('REX-U: unit pins on the operand grammar and the FROM-side reader'); + { + const op = parseExportOperand(`${REX_PKG}#mapTemplateToRow`); + assert(op.pkg === REX_PKG && op.exportName === 'mapTemplateToRow' && op.member === null, `REX-U1: an export operand parses (${JSON.stringify(op)})`); + const mem = parseExportOperand(`${REX_PKG}#EmailServicePluginOptions.seedTemplates`); + assert(mem.exportName === 'EmailServicePluginOptions' && mem.member === 'seedTemplates', `REX-U2: a member operand parses (${JSON.stringify(mem)})`); + assert(/names a FILE/.test(parseExportOperand(`${rexPath('src/index.ts')}#mapTemplateToRow`).refusal ?? ''), + 'REX-U3: a `#` operand -- the D7 spelling -- is refused with the reason: this category names the PACKAGE'); + assert(/2 levels deep/.test(parseExportOperand(`${REX_PKG}#A.b.c`).refusal ?? ''), 'REX-U4: a member path deeper than `.` is refused'); + assert(parseExportOperand('mapTemplateToRow').refusal !== undefined, 'REX-U5: a bare name, with no package, is refused'); + } + const canon = (t, want, label) => assert(canonicalFromOperand(t) === want, `${label}: canonicalFromOperand(${JSON.stringify(t)}) = ${JSON.stringify(canonicalFromOperand(t))}, want ${JSON.stringify(want)}`); + canon('createSysEmailTemplateLoader(engine, options?)', 'createSysEmailTemplateLoader', 'REX-U6 a CALL names the function it calls'); + canon('new EmailServicePlugin({ seedTemplates: false })', 'EmailServicePlugin', 'REX-U7 THE RULING\'S LINE: `new X({ option })` names `X`, never the option -- the argument list is not read'); + canon('EmailServicePluginOptions.seedTemplates', 'EmailServicePluginOptions.seedTemplates', 'REX-U8 the canonical member spelling'); + canon('bootstrapDeclaredEmailTemplates(...)', 'bootstrapDeclaredEmailTemplates', 'REX-U9 an elided argument list'); + canon("import { organizationIdForMetaWrite } from '@objectstack/metadata-core'", null, 'REX-U10 an import statement is not canonical'); + canon("'sys_email_template'", null, 'REX-U11 a literal is not an operand'); + canon('a.b.c', null, 'REX-U12 a path deeper than `.` is not canonical'); + canon('mapTemplateToRow(tpl).load', null, 'REX-U13 a member of a CALL\'s result is not canonical'); + canon('new', null, 'REX-U14 the keyword alone names nothing'); + { + const line25 = REX_EXPORT_HALF.split('\n')[2]; + const r25 = readFromBlock(line25); + assert(JSON.stringify(r25.sides.map((s) => s.spelling)) === JSON.stringify(['createSysEmailTemplateLoader', 'TemplateLoaderEngine', 'CreateTemplateLoaderOptions']), + `REX-U15: one line, two FROM ... TO segments, three operands -- the TO sides are never read (got ${JSON.stringify(r25.sides.map((s) => s.spelling))})`); + const mention = readFromBlock('The body does carry a FROM → TO block for the retired runtime exports and plugin options.'); + assert(mention.sides.length === 0 && mention.unread.length === 0 && mention.residual === null, + `REX-U16: the bare placeholder MENTIONED in prose (the worked example's own marker sentence) is neither a side nor a refusal (${JSON.stringify(mention)})`); + assert(/naming no operand/.test(readFromBlock('- FROM the old loader TO the new one').unread[0]?.shape ?? ''), + 'REX-U17: a FROM ... TO line that OPENS its line and names no operand is reported -- a prescription naming nothing'); + assert(readFromBlock('each FROM names an export and each TO its replacement').unread.length === 0, + 'REX-U18: the two words inside a running sentence are a mention, not a segment'); + const removal = readFromBlock('- `mapTemplateToRow` → delete the call'); + assert(removal.sides[0]?.spelling === 'mapTemplateToRow' && removal.residual === null, + `REX-U19: an arrow REMOVAL (\`X -> delete\`) is read, and leaves nothing for the detector (${JSON.stringify(removal)})`); + const table = readFromBlock('| FROM | TO |\n| --- | --- |\n| `mapTemplateToRow(tpl)` | nothing |\n'); + assert(table.sides[0]?.spelling === 'mapTemplateToRow' && table.residual === null && table.unread.length === 0, + `REX-U20: a \`| FROM | TO |\` table is read from its FROM column, and its header is no residue (${JSON.stringify(table)})`); + const wrote = readFromBlock('| Wrote | Write instead |\n| --- | --- |\n| `a.b` | `a.c` |\n'); + assert(wrote.sides[0]?.spelling === 'a.b', `REX-U21: a header-framed table is read from its OLD column (${JSON.stringify(wrote.sides)})`); + const framed = readFromBlock('## Migration\n\n| before | after |\n| --- | --- |\n| `a.b` | `a.c` |\n'); + assert(framed.sides.length === 0 && framed.residual?.branch === 'framed-table', + `REX-U22: a framed table with no FROM column is not read, and the detector's refusal stands (${JSON.stringify(framed)})`); + const label = readFromBlock('## FROM → TO\n\n- FROM `a` TO `b`\n\n| Member | Behaviour |\n| --- | --- |\n| `x.y` | `z.w` |\n'); + assert(/over a rewrite table row/.test(label.residual?.branch ?? ''), + `REX-U23: a FROM → TO label judges the WHOLE body, as branch 1 does -- an unread rewrite row anywhere refuses (${JSON.stringify(label.residual)})`); + assert(readFromBlock('run `SELECT x FROM t TO y` here').sides.length === 0, + 'REX-U24: the two words inside a code span are not convention tokens'); + } + + // ---- REX-L: the compiler is paid for only by a run that needs it ---------- + battery('REX-L: the compiler is loaded only when a marker names the category'); + { + // (1) A bare import of the gate loads no `typescript`; the loader does. In a + // CHILD, because this process loaded it before the self-test began. + const probe = spawnSync(process.execPath, ['--input-type=module', '-e', [ + "import { createRequire } from 'node:module';", + `const g = await import(${JSON.stringify(import.meta.url)});`, + 'const req = createRequire(import.meta.url);', + "const loaded = () => Object.keys(req.cache).some((k) => /[\\\\/]typescript[\\\\/]lib[\\\\/]typescript\\.js$/.test(k));", + 'const before = loaded();', + 'await g.loadExportReader();', + 'console.log(JSON.stringify({ before, after: loaded() }));', + ].join('\n')], { cwd: REPO_ROOT, encoding: 'utf8' }); + const seen = (() => { try { return JSON.parse(probe.stdout.trim().split('\n').pop()); } catch { return null; } })(); + assert(seen?.before === false, `REX-L1: importing the gate must not load \`typescript\` (got ${probe.stdout}${probe.stderr})`); + assert(seen?.after === true, `REX-L2: loadExportReader() must load it -- the positive control for L1 (got ${probe.stdout}${probe.stderr})`); + // (2) The handshake: a scan without the kit reports itself, never passes. + const kit = exportReaderKit; + exportReaderKit = null; + let unloaded; + try { unloaded = run(mk(REX_FIXTURE(REX_NINE, REX_EXPORT_HALF))); } finally { exportReaderKit = kit; } + red('REX-L3 a scan without the export reader is RED and asks for it', unloaded, [/export reader \(the\s+TypeScript compiler\) is loaded on demand and was not/]); + assert(unloaded.needsExportReader === true, 'REX-L4: ...and sets `needsExportReader`, which is what makes the entry point load and re-scan'); + assert(rexR1.needsExportReader === false && run(mk({ files: {} })).needsExportReader === false, + 'REX-L5: a scan with the reader loaded, or with no such marker, never asks for it'); + } + // ---- G6: a changeset that was ALREADY breaking at base is inherited ------- battery('G6: a changeset that was ALREADY breaking at base is inherited'); {