Skip to content

Commit 1be26b0

Browse files
claude[bot]claude
andauthored
fix(devx): readDeclaredTypeSurface returns the declared member surface for interface/class/enum and the whole RHS of a multi-line alias (#15489) (#15628)
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45a72b0 commit 1be26b0

1 file changed

Lines changed: 250 additions & 10 deletions

File tree

scripts/check-adr-0087-registration.mjs

Lines changed: 250 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
398398
'The #8299 category: `runtime-interface-only`': 12,
399399
'#12881: a metadata surface that names the symbol only in PROSE': 30,
400400
'The #13080 category: `type-surface-only`': 26,
401-
'TSO-6048: THE REGRESSION PIN -- the founding case must never admit': 7,
401+
'TSO-6048: THE REGRESSION PIN -- the founding case must never admit': 8,
402402
'TSO-N: the predicate set is pinned BY NAME, never by count': 3,
403-
'TSO-U: unit pins on predicate 4\'s readers': 19,
403+
'TSO-U: unit pins on predicate 4\'s readers': 30,
404404
'G6: a changeset that was ALREADY breaking at base is inherited': 1,
405405
'R15: a changeset RENAMED AND turned breaking in the same commit': 5,
406406
'G10: a PURE rename of an ALREADY-breaking stock changeset': 3,
@@ -2022,7 +2022,12 @@ export function exportedTypeDeclaration(text, symbol) {
20222022
'm',
20232023
);
20242024
const m = re.exec(text);
2025-
return m ? { kind: m[1], rest: m[2] } : null;
2025+
if (!m) return null;
2026+
// `restStart` is where `rest` BEGINS in `text`. A caller that walks the
2027+
// declaration's body needs an offset, and `text.indexOf(rest)` is not one: `rest`
2028+
// is the tail of a single LINE, so an identical tail anywhere earlier in the file
2029+
// wins that search and the walk starts inside someone else's declaration.
2030+
return { kind: m[1], rest: m[2], restStart: m.index + m[0].length - m[2].length };
20262031
}
20272032

20282033
/** Any declaration of the name at all, exported or not -- the homonym test. */
@@ -2485,6 +2490,180 @@ export function memberReturnAnnotation(text, symbol) {
24852490
return null;
24862491
}
24872492

2493+
/** A declared surface as a message prints it: one line, comment spans collapsed. */
2494+
const normaliseSurface = (text) => text.replace(/\s+/g, ' ').trim();
2495+
2496+
/**
2497+
* The index of the bracket matching the one at `open`, or -1.
2498+
*
2499+
* ⚠️ `structural` MUST be the comment- AND literal-masked projection. A `}` inside
2500+
* a string literal is not a closer, and reading one as such is exactly how the
2501+
* lazy `([\s\S]*?);` this file used to carry truncated `type Sep = ';' | ',';`
2502+
* down to a single quote character (#15489).
2503+
*/
2504+
function matchBracket(structural, open) {
2505+
const CLOSES = { '{': '}', '[': ']', '(': ')', '<': '>' };
2506+
const opener = structural[open];
2507+
const closer = CLOSES[opener];
2508+
if (!closer) return -1;
2509+
let depth = 0;
2510+
for (let i = open; i < structural.length; i++) {
2511+
if (structural[i] === opener) depth++;
2512+
else if (structural[i] === closer && --depth === 0) return i;
2513+
}
2514+
return -1;
2515+
}
2516+
2517+
/**
2518+
* Where the BODY of an `interface` / `class` / `enum` opens at/after `from`, or -1.
2519+
*
2520+
* The first `{` is not the answer on its own: `class C implements I<{ a: 1 }>` opens
2521+
* one inside the type arguments. Only a `{` reached at angle/paren/bracket depth
2522+
* zero is the body, and a `;` at that depth ends a bodiless declaration first.
2523+
*/
2524+
function declarationBodyStart(structural, from) {
2525+
let angle = 0;
2526+
let paren = 0;
2527+
let square = 0;
2528+
for (let i = from; i < structural.length; i++) {
2529+
const c = structural[i];
2530+
if (c === '<') angle++;
2531+
else if (c === '>') { if (angle > 0) angle--; }
2532+
else if (c === '(') paren++;
2533+
else if (c === ')') { if (paren > 0) paren--; }
2534+
else if (c === '[') square++;
2535+
else if (c === ']') { if (square > 0) square--; }
2536+
else if (angle === 0 && paren === 0 && square === 0) {
2537+
if (c === '{') return i;
2538+
if (c === ';') return -1;
2539+
}
2540+
}
2541+
return -1;
2542+
}
2543+
2544+
/**
2545+
* The declared MEMBER surface of a brace-bodied declaration at/after `from`, or null.
2546+
*
2547+
* Structure is walked on `structural`; the answer is SLICED out of `masked`, so a
2548+
* string literal's own text survives into the surface while its brackets never
2549+
* steer the walk. The braces are kept: the result is a type text a reader can read.
2550+
*/
2551+
function declaredMemberSurface(masked, structural, from) {
2552+
const open = declarationBodyStart(structural, from);
2553+
if (open === -1) return null;
2554+
const close = matchBracket(structural, open);
2555+
if (close === -1) return null;
2556+
return normaliseSurface(masked.slice(open, close + 1));
2557+
}
2558+
2559+
/**
2560+
* The index of the `=` that opens a `type` alias's RHS at/after `from`, or -1.
2561+
*
2562+
* The type-parameter list is skipped by BRACKET MATCHING rather than by a regex:
2563+
* `type Box<T = string> = ...` carries an `=` inside its own parameter list, and
2564+
* the `(?:<[^=]*>)?` spelling this replaced could not see past it.
2565+
*/
2566+
function aliasEqualsIndex(structural, from) {
2567+
let i = from;
2568+
while (i < structural.length && /\s/.test(structural[i])) i++;
2569+
if (structural[i] === '<') {
2570+
const close = matchBracket(structural, i);
2571+
if (close === -1) return -1;
2572+
i = close + 1;
2573+
while (i < structural.length && /\s/.test(structural[i])) i++;
2574+
}
2575+
return structural[i] === '=' ? i : -1;
2576+
}
2577+
2578+
// A RHS line that ends on one of these is mid-expression, so the newline after it
2579+
// is never the end of the declaration.
2580+
const RHS_CONTINUES_RE = /(?:[|&,=:?<([{]|\b(?:extends|keyof|typeof|infer|in|readonly|new)|=>)[ \t]*$/;
2581+
// ...and what a following line has to look like for that newline to END the alias.
2582+
const NEXT_STATEMENT_RE =
2583+
/^[ \t]*(?:export|import|declare|interface|type|class|enum|const|let|var|function|abstract|async|@)/;
2584+
2585+
/**
2586+
* The END of the alias RHS that starts just after `eq`.
2587+
*
2588+
* The first `;` at nesting depth ZERO -- never merely the first `;`, which sits
2589+
* inside the very first member of any multi-line object alias and truncated every
2590+
* one of them (#15489). When the alias carries no `;` at all (ASI), the end of the
2591+
* DECLARATION is used rather than the end of the file: a depth-0 newline whose next
2592+
* non-blank line opens a new top-level statement.
2593+
*
2594+
* ⚠️ Angle brackets are deliberately NOT counted. `<` is a type-argument opener but
2595+
* `>` is also the second half of `=>`, so counting them makes every function type in
2596+
* a RHS close a depth nothing opened. Nothing is lost: a `;` cannot reach depth 0
2597+
* inside type arguments without passing through a brace, bracket or paren first.
2598+
*/
2599+
function aliasRhsEnd(structural, eq) {
2600+
let depth = 0;
2601+
for (let i = eq + 1; i < structural.length; i++) {
2602+
const c = structural[i];
2603+
if (c === '{' || c === '[' || c === '(') depth++;
2604+
else if (c === '}' || c === ']' || c === ')') {
2605+
if (depth === 0) return i; // ran out of the declaration into an enclosing block
2606+
depth--;
2607+
} else if (depth === 0 && c === ';') return i;
2608+
else if (depth === 0 && c === '\n') {
2609+
const soFar = structural.slice(eq + 1, i);
2610+
if (soFar.trim() === '' || RHS_CONTINUES_RE.test(soFar)) continue;
2611+
const next = structural.slice(i + 1).split('\n').find((line) => line.trim() !== '');
2612+
if (next !== undefined && NEXT_STATEMENT_RE.test(next)) return i;
2613+
}
2614+
}
2615+
return structural.length;
2616+
}
2617+
2618+
/**
2619+
* How many top-level members a brace-delimited member surface declares.
2620+
*
2621+
* SEPARATOR-counted (`;` / `,` at depth 0), which is what a normalised surface has
2622+
* left to count: it is a figure for a truncation notice, not a contract. `null` when
2623+
* the surface is not a brace body at all.
2624+
*/
2625+
export function countSurfaceMembers(surface) {
2626+
const s = normaliseSurface(String(surface ?? ''));
2627+
if (!s.startsWith('{') || !s.endsWith('}')) return null;
2628+
const structural = maskCommentsAndLiterals(s);
2629+
let depth = 0;
2630+
let members = 0;
2631+
let pending = '';
2632+
for (let i = 1; i < s.length - 1; i++) {
2633+
const c = structural[i];
2634+
if (c === '{' || c === '[' || c === '(' || c === '<') depth++;
2635+
else if (c === '}' || c === ']' || c === ')' || c === '>') { if (depth > 0) depth--; }
2636+
else if (depth === 0 && (c === ';' || c === ',')) {
2637+
if (pending.trim() !== '') members++;
2638+
pending = '';
2639+
continue;
2640+
}
2641+
pending += s[i];
2642+
}
2643+
if (pending.trim() !== '') members++;
2644+
return members;
2645+
}
2646+
2647+
/** How much of a declared surface a refusal message prints before it truncates. */
2648+
export const SURFACE_PRINT_LIMIT = 160;
2649+
2650+
/**
2651+
* A declared surface, cut down to something a refusal can print on a few lines.
2652+
*
2653+
* A whole `interface` body is now the reading (it is the thing that narrowed), and
2654+
* the real specimen is over 1,500 characters — long enough to bury the sentence
2655+
* around it. Truncation is explicit rather than silent: an ellipsis, the member
2656+
* count, and the full length, so an author can tell "long" from "all of it".
2657+
*/
2658+
export function printableSurface(surface) {
2659+
if (surface === null || surface === undefined) return null;
2660+
const s = String(surface);
2661+
if (s.length <= SURFACE_PRINT_LIMIT) return s;
2662+
const members = countSurfaceMembers(s);
2663+
return `${s.slice(0, SURFACE_PRINT_LIMIT).trimEnd()} …` +
2664+
` (${members === null ? '' : `${members} members, `}${s.length} chars, truncated at ${SURFACE_PRINT_LIMIT})`;
2665+
}
2666+
24882667
/**
24892668
* What this gate can read about `symbol`'s declared type at one rev, from SOURCE.
24902669
*
@@ -2493,19 +2672,33 @@ export function memberReturnAnnotation(text, symbol) {
24932672
* is not readable here at all -- which predicate 4 treats as a REFUSAL on the base
24942673
* side, never as "it must have been erased".
24952674
*
2675+
* `type` is the DECLARED SURFACE, never the symbol's own name (#15489): the name is
2676+
* identical on both sides of every diff, so predicate 4's before/after reading of an
2677+
* `interface` used to print `ExportFieldMeta` -> `ExportFieldMeta` across a commit
2678+
* that removed eight of its members. What is compared, and what an author is shown,
2679+
* has to be the thing that can move.
2680+
*
24962681
* @returns {{ shape: string, type: string|null, erased: boolean }|null}
24972682
*/
24982683
export function readDeclaredTypeSurface(text, symbol) {
24992684
const masked = maskComments(text);
25002685
const exported = exportedTypeDeclaration(masked, symbol);
25012686
if (exported) {
2687+
const structural = maskCommentsAndLiterals(text);
25022688
if (exported.kind !== 'type') {
25032689
// An `interface` / `class` / `enum` is a named STRUCTURAL declaration. It is
2504-
// never `any`, and #6048's symbol is exactly this shape at base.
2505-
return { shape: `an exported \`${exported.kind} ${symbol}\``, type: symbol, erased: false };
2690+
// never `any` -- #6048's symbol is exactly this shape at base -- so `erased`
2691+
// is false by construction here and no reading of the body can move it. The
2692+
// body is what predicate 4 REPORTS, and what a member removal changes.
2693+
return {
2694+
shape: `an exported \`${exported.kind} ${symbol}\``,
2695+
type: declaredMemberSurface(masked, structural, exported.restStart),
2696+
erased: false,
2697+
};
25062698
}
2507-
const rhs = /^\s*(?:<[^=]*>)?\s*=\s*([\s\S]*?);/.exec(`${exported.rest}\n${masked.slice(masked.indexOf(exported.rest) + exported.rest.length)}`);
2508-
const rhsText = rhs ? rhs[1].replace(/\s+/g, ' ').trim() : null;
2699+
const eq = aliasEqualsIndex(structural, exported.restStart);
2700+
const rhs = eq === -1 ? '' : normaliseSurface(masked.slice(eq + 1, aliasRhsEnd(structural, eq)));
2701+
const rhsText = rhs === '' ? null : rhs;
25092702
return {
25102703
shape: `the exported \`type ${symbol}\` alias`,
25112704
type: rhsText,
@@ -2679,7 +2872,7 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages,
26792872
if (at.erased) {
26802873
problems.push(
26812874
`\`type-surface-only ${ref}\` [predicate 4: narrowed-from-erased] is false at HEAD:\n` +
2682-
` ${at.shape} is still ${at.type === null ? 'UNANNOTATED' : `\`${at.type}\``}.\n` +
2875+
` ${at.shape} is still ${at.type === null ? 'UNANNOTATED' : `\`${printableSurface(at.type)}\``}.\n` +
26832876
' This category is for a surface that MOVED OFF an erased type. One that is still erased\n' +
26842877
' narrowed nothing, so nothing about it can be breaking in the way this category\n' +
26852878
' describes.',
@@ -2702,7 +2895,9 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages,
27022895
if (!before.erased) {
27032896
problems.push(
27042897
`\`type-surface-only ${ref}\` [predicate 4: narrowed-from-erased] is FALSE: at the merge base\n` +
2705-
` ${before.shape} was already CONCRETE (\`${before.type}\`), not \`any\` / \`unknown\` /\n` +
2898+
` ${before.shape} was already CONCRETE (${before.type === null
2899+
? 'its declared surface is not readable here'
2900+
: `\`${printableSurface(before.type)}\``}), not \`any\` / \`unknown\` /\n` +
27062901
' unannotated.\n' +
27072902
' ⛔ This is the founding case of this whole gate. PR #6048 removed the `roles` member of\n' +
27082903
' a concretely typed exported interface and the ledger got nothing; the ONLY thing\n' +
@@ -2715,7 +2910,11 @@ export function verifyTypeSurfaceOnly(refs, { base, head, cwd, bumps, packages,
27152910
continue;
27162911
}
27172912

2718-
verified.push({ ref, from: before.type === null ? 'unannotated' : before.type, to: at.type ?? at.shape });
2913+
verified.push({
2914+
ref,
2915+
from: before.type === null ? 'unannotated' : printableSurface(before.type),
2916+
to: at.type === null ? at.shape : printableSurface(at.type),
2917+
});
27192918
}
27202919

27212920
return { problems, verified, checked };
@@ -4170,6 +4369,19 @@ function selfTest() {
41704369
'and #6048 removed a member from it -- exactly the class the ledger DOES serve. Reading it as ' +
41714370
`erased would hand the founding case a green exit (got: ${JSON.stringify(realRead)}).`,
41724371
);
4372+
// ...and it must read the MEMBERS. `#6048` removed a member; a reading that
4373+
// answers with the symbol's own NAME is identical on both sides of that diff,
4374+
// so predicate 4's before/after comparison and the message it prints would both
4375+
// be describing something that cannot move (#15489).
4376+
assert(
4377+
realRead !== null && realRead.type !== null &&
4378+
realRead.type.startsWith('{') && /\bpositions\b/.test(realRead.type) &&
4379+
realRead.type !== 'ActorUser',
4380+
'TSO-6048d: predicate 4 must read the REAL `ActorUser` as its declared MEMBER SURFACE -- the ' +
4381+
'brace-balanced body, naming `positions` -- never the string `ActorUser`. The symbol name is ' +
4382+
'the one part of an interface a member removal cannot change, so reading it is a comparison ' +
4383+
`that always says "unchanged" (got type: ${JSON.stringify(realRead && realRead.type)}).`,
4384+
);
41734385
}
41744386
// (b) the same fact, through the shipping scan(). The changeset is #6048's own
41754387
// reconstructed body -- `迁移:FROM → TO` with a worked block -- claiming the one
@@ -4253,6 +4465,34 @@ function selfTest() {
42534465
assert(readDeclaredTypeSurface('export type Row = { a: string };\n', 'Row')?.erased === false, 'TSO-U18: an exported alias of a real type is concrete');
42544466
assert(readDeclaredTypeSurface('export interface Other { a: 1 }\n', 'Missing') === null, 'TSO-U19: a symbol that is not there reads as null -- never as "it must have been erased"');
42554467

4468+
// U20-U30 (#15489): the same readings asserted on `.type`. Until this row the
4469+
// battery asserted `.erased` and nothing else, and `.erased` is the ONE field
4470+
// both misreads left alone -- an `interface` is concrete whatever its body says,
4471+
// and a truncated alias RHS is concrete for the same reason the whole one is. So
4472+
// the gate reported `ExportFieldMeta` -> `ExportFieldMeta` across a commit that
4473+
// removed eight of its members, printed that to an author in a live refusal, and
4474+
// every case here stayed green.
4475+
assert(readDeclaredTypeSurface(ACTOR_BASE, 'ActorUser')?.type === '{ id: string; roles: string[]; positions: string[]; }', 'TSO-U20: an exported interface reads as its declared MEMBER SURFACE -- the brace-balanced body -- never as the symbol name, which is identical on both sides of every diff');
4476+
assert(readDeclaredTypeSurface('export class C {\n a = 1;\n b(): number { return 2; }\n}\n', 'C')?.type === '{ a = 1; b(): number { return 2; } }', 'TSO-U21: a `class` body is read the same way, nested braces included');
4477+
assert(readDeclaredTypeSurface('export enum E {\n A = 1,\n B = 2,\n}\n', 'E')?.type === '{ A = 1, B = 2, }', 'TSO-U22: an `enum` body is read the same way');
4478+
assert(readDeclaredTypeSurface('export type Row = {\n a: string;\n b: number;\n};\n', 'Row')?.type === '{ a: string; b: number; }', 'TSO-U23: a MULTI-LINE object alias survives the `;` that ends its FIRST member -- the lazy `([\\s\\S]*?);` this replaced stopped there and reported `{ a: string`');
4479+
assert(readDeclaredTypeSurface("export type Sep = ';' | ',';\n", 'Sep')?.type === "';' | ','", 'TSO-U24: a `;` INSIDE a string literal does not terminate the RHS -- the walk is string-aware, and the literal\'s own text still reaches the surface');
4480+
assert(readDeclaredTypeSurface('export type Box<T = string> = { value: T; tag: string };\n', 'Box')?.type === '{ value: T; tag: string }', 'TSO-U25: a GENERIC alias keeps its whole RHS, and the `=` of a type-parameter DEFAULT is not mistaken for the alias\'s own -- the parameter list is skipped by bracket matching, not by `(?:<[^=]*>)?`');
4481+
assert(readDeclaredTypeSurface('export type Row = { a: string }\n\nexport const x = 1;\n', 'Row')?.type === '{ a: string }', 'TSO-U26: an alias with NO `;` ends at the END OF THE DECLARATION, not at the next `;` anywhere in the file -- over-reading into the following statement is the same defect pointing the other way');
4482+
assert(readDeclaredTypeSurface('export interface Loose {\n data: any;\n}\n', 'Loose')?.erased === false, 'TSO-U27: reading the BODY does not move `.erased` -- an interface whose members are `any` is still a CONCRETE declaration, the #6048 reading, and this category is about surfaces that moved OFF an erased type');
4483+
assert(countSurfaceMembers('{ a: string; b: Record<string, number>; c: { d: 1; e: 2 } }') === 3, 'TSO-U28: members are counted at the TOP level only -- the separators inside a member\'s own type are not members');
4484+
assert(countSurfaceMembers('AnalyticsResult') === null, 'TSO-U29: a surface that is not a brace body has no member count, and says so rather than guessing one');
4485+
{
4486+
const wide = `{ ${Array.from({ length: 40 }, (_, i) => `k${i}: string`).join('; ')} }`;
4487+
const shown = printableSurface(wide);
4488+
assert(
4489+
shown.length < wide.length && shown.includes('…') && shown.includes('40 members') && shown.includes(`${wide.length} chars`) &&
4490+
printableSurface('{ a: string }') === '{ a: string }',
4491+
'TSO-U30: a long surface is truncated EXPLICITLY -- an ellipsis, the member count and the full ' +
4492+
`length -- so an author can tell "long" from "all of it"; a short one is printed byte-identical. Got: ${JSON.stringify(shown)}`,
4493+
);
4494+
}
4495+
42564496
// ---- G6: a changeset that was ALREADY breaking at base is inherited -------
42574497
battery('G6: a changeset that was ALREADY breaking at base is inherited');
42584498
{

0 commit comments

Comments
 (0)