diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index f9058dc7569..aa514794fe8 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -462,6 +462,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '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, 'WS (#26220): ui workspace packages, `.tsx` modules, aliases and shims': 11, + 'IX (#26220): predicate 4 reads an `any` index signature; the entry may be `src/index.tsx`': 52, '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, @@ -494,7 +495,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 = 56; +const SELF_TEST_BATTERY_FLOOR = 57; // 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 @@ -3661,6 +3662,134 @@ export function resolveMemberPath(text, segments) { return { ok: true, from, to }; } +/** + * The declaration of an exported `interface` -- or a `type` alias whose RHS is ONE + * object type literal -- split into what predicate 4's index-signature limb + * compares: its heritage (type parameters, `extends` clause) and its top-level + * members, each normalised. `null` for any other shape, which the limb refuses. + * + * @returns {{ kind: string, heritage: string, members: string[] }|null} + */ +export function structuralDeclarationParts(text, symbol) { + const masked = maskComments(text); + const exported = exportedTypeDeclaration(masked, symbol); + if (!exported || (exported.kind !== 'interface' && exported.kind !== 'type')) return null; + const structural = maskCommentsAndLiterals(text); + let open; + let heritageEnd; + if (exported.kind === 'interface') { + open = declarationBodyStart(structural, exported.restStart); + heritageEnd = open; + } else { + const eq = aliasEqualsIndex(structural, exported.restStart); + if (eq === -1) return null; + open = eq + 1; + while (open < structural.length && /\s/.test(structural[open])) open++; + if (structural[open] !== '{') return null; + heritageEnd = eq; + // ONE object type literal and nothing else: `{ ... } & X` is not this shape. + const close = matchBracket(structural, open); + if (close === -1 || structural.slice(close + 1, aliasRhsEnd(structural, eq)).trim() !== '') return null; + } + if (open === -1) return null; + const close = matchBracket(structural, open); + if (close === -1) return null; + return { + kind: exported.kind, + heritage: normaliseSurface(masked.slice(exported.restStart, heritageEnd)), + members: splitTopLevelMembers(masked.slice(open + 1, close), structural.slice(open + 1, close)), + }; +} + +/** + * The top-level members of a brace body's INSIDE, normalised, separators dropped. + * Depth is walked on the literal-masked projection, and the `>` of an `=>` is not + * a closer -- counting it would end a nested literal early and hand a nested index + * signature to the caller as a top-level one. + */ +function splitTopLevelMembers(inside, structural) { + const members = []; + let depth = 0; + let from = 0; + for (let i = 0; i < structural.length; i++) { + const c = structural[i]; + if (c === '{' || c === '[' || c === '(' || c === '<') depth++; + else if (c === '}' || c === ']' || c === ')' || (c === '>' && structural[i - 1] !== '=')) { if (depth > 0) depth--; } + else if (depth === 0 && (c === ';' || c === ',')) { + const m = normaliseSurface(inside.slice(from, i)); + if (m !== '') members.push(m); + from = i + 1; + } + } + const last = normaliseSurface(inside.slice(from)); + if (last !== '') members.push(last); + return members; +} + +// `[key: K]: V`, optionally `readonly`: the key part and the value type. A mapped +// type (`[P in K]`) and a computed member name (`[Symbol.iterator]()`) do not match. +const INDEX_SIGNATURE_RE = /^((?:readonly\s+)?\[\s*[A-Za-z_$][\w$]*\s*:\s*[^\]]+\])\s*:\s*([\s\S]+)$/; + +/** A normalised member as an index signature `{ key, value }`, or null. */ +export function readIndexSignature(member) { + const m = INDEX_SIGNATURE_RE.exec(member); + return m ? { key: normaliseSurface(m[1]), value: normaliseSurface(m[2]) } : null; +} + +/** + * Predicate 4's index-signature limb (ADR-0087 D8, amended 2026-10-27, #26220). + * + * For a bare reference to an exported `interface`, or a `type` alias whose RHS is + * one object type literal, the limb holds when ALL of: + * 1. at the merge base at least one index signature's value type is `any`; + * 2. at HEAD at least one of THOSE carries another value type (`unknown` + * counts as narrowed here, and only here); + * 3. everything else is identical: the kind, the heritage, and every member, + * position by position. Only the value types of base-`any` index signatures + * are set aside -- a non-`any` index signature that changes is a re-typed + * member like any other, so this reads no wider than the ADR's text. + * #6048 (a member removed beside a concrete surface) fails 3 and keeps its + * founding-case refusal. ⛔ Never widen a condition here without the ADR first. + * + * @returns {{ ok: true, from: string, to: string } | { ok: false, reason: string }} + */ +export function indexSignatureLimb(baseText, headText, symbol) { + const before = structuralDeclarationParts(baseText, symbol); + const after = structuralDeclarationParts(headText, symbol); + if (!before || !after) { + return { ok: false, reason: 'it reads only an exported `interface`, or a `type` alias whose right-hand side is one object type literal, at both revs' }; + } + if (before.kind !== after.kind || before.heritage !== after.heritage) { + return { ok: false, reason: `the declaration's heritage moved (\`${before.kind}${before.heritage ? ` ${before.heritage}` : ''}\` -> \`${after.kind}${after.heritage ? ` ${after.heritage}` : ''}\`)` }; + } + if (before.members.length !== after.members.length) { + return { ok: false, reason: `the member count moved (${before.members.length} -> ${after.members.length}): a member was added or removed` }; + } + const narrowed = []; + let anyAtBase = 0; + for (let i = 0; i < before.members.length; i++) { + const b = before.members[i]; + const a = after.members[i]; + const bi = readIndexSignature(b); + if (bi && bi.value === 'any') { + anyAtBase++; + const ai = readIndexSignature(a); + if (ai && ai.key === bi.key) { + if (ai.value !== 'any') narrowed.push({ from: b, to: a }); + continue; + } + } + if (a !== b) return { ok: false, reason: `member ${i + 1} moved (\`${b}\` -> \`${a}\`)` }; + } + if (anyAtBase === 0) return { ok: false, reason: 'no index signature is typed `any` at the merge base' }; + if (narrowed.length === 0) return { ok: false, reason: 'every index signature typed `any` at the merge base is still `any` at HEAD' }; + return { + ok: true, + from: narrowed.map((n) => n.from).join('; '), + to: narrowed.map((n) => n.to).join('; '), + }; +} + /** * What predicate 4 can read about ONE parsed reference, or why it is refused. * @@ -4170,6 +4299,13 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages, continue; } if (!before.erased) { + // The 2026-10-27 amendment (#26220): an `any` INDEX SIGNATURE is the one member + // that can be erased inside a concrete declaration. A bare reference only. + const limb = parsedRef.dotted ? { ok: false, reason: 'it does not apply to a dotted member path' } : indexSignatureLimb(baseText, headText, parsedRef.symbol); + if (limb.ok) { + verified.push({ ref, from: limb.from, to: limb.to }); + continue; + } problems.push( `\`type-surface-only ${ref}\` [predicate 4: narrowed-from-erased] is FALSE: at the merge base\n` + ` ${before.shape} was already CONCRETE (${before.type === null @@ -4182,6 +4318,7 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages, ' exempt from it. Predicate 4 is what pays for that exemption: a surface that was already\n' + ' concrete did not move off `any`, and a member removed from it is exactly the class the\n' + ' ledger DOES serve.\n' + + ` The index-signature limb (ADR-0087 D8, amended 2026-10-27) does not hold either: ${limb.reason}.\n` + ' fix: register the migration.', ); continue; @@ -4245,7 +4382,9 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages, // 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 +// `.d.cts` twin tsup writes beside it) is generated from `src/index.ts` -- or, +// since the 2026-10-27 amendment (#26220), from `src/index.tsx`, the entry +// vite-plugin-dts builds the `ui/` packages from; exactly one of the two. 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 @@ -4528,6 +4667,8 @@ export async function loadExportReader() { /** 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$/; +/** The source files that entry may be generated from: exactly one must exist (D9, amended 2026-10-27, #26220). */ +const SOURCE_ENTRY_NAMES = Object.freeze(['src/index.ts', 'src/index.tsx']); /** 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?$/; @@ -4536,9 +4677,12 @@ 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. + * `resolveTypesEntry`, lands on `dist/index.d.ts` (or its tsup twin), generated + * from the package's ONE source entry, `src/index.ts` or `src/index.tsx` (the + * 2026-10-27 amendment, #26220: vite-plugin-dts builds the `ui/` packages from + * the `.tsx`). Anything else -- no types entry, another file name, neither + * source, BOTH sources -- is refused by name rather than guessed. The build tool + * is never asked: the source file's name is the fact read here. */ function sourceEntryAt(rev, cwd, pkgName, pkgs, kit) { const info = pkgs.get(pkgName); @@ -4553,17 +4697,25 @@ function sourceEntryAt(rev, cwd, pkgName, pkgs, kit) { 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) { + const present = SOURCE_ENTRY_NAMES.map((name) => `${dir}${name}`).filter((p) => showOrNull(rev, p, cwd) !== null); + if (declared !== null && TSUP_DTS_ENTRY_RE.test(declared) && present.length > 1) { + return { + refusal: `the published entry of \`${pkgName}\` is AMBIGUOUS at ${rev.slice(0, 12)}: its \`exports\` map names \`${declared}\`,\n` + + ` and the package carries BOTH ${present.join(' and ')}. The mapping reads ONE source entry\n` + + ' (ADR-0087 D9, amended 2026-10-27), and which one the build compiles is the build tool\'s\n' + + ' answer, which this gate never asks. Delete the stale one. Refused, never guessed', + }; + } + if (declared === null || !TSUP_DTS_ENTRY_RE.test(declared) || present.length === 0) { 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' + + ` names ${declared === null ? 'no types entry' : `\`${declared}\``}, and the only mapping this gate reads is\n` + + ` \`dist/index.d.ts\` (or its \`.d.mts\` / \`.d.cts\` twin) -> \`src/index.ts\` or \`src/index.tsx\`` + + (declared !== null && TSUP_DTS_ENTRY_RE.test(declared) ? `, and neither ${dir}src/index.ts nor ${dir}src/index.tsx exists there` : '') + '.\n' + ' An entry this gate cannot map is refused, never guessed', }; } - return { entry, dir }; + return { entry: present[0], dir }; } /** @@ -7710,6 +7862,131 @@ function selfTest() { `WS-U5: a glob richer than \`