@@ -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 (?: e x t e n d s | k e y o f | t y p e o f | i n f e r | i n | r e a d o n l y | n e w ) | = > ) [ \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 ] * (?: e x p o r t | i m p o r t | d e c l a r e | i n t e r f a c e | t y p e | c l a s s | e n u m | c o n s t | l e t | v a r | f u n c t i o n | a b s t r a c t | a s y n c | @ ) / ;
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 */
24982683export 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 ( '{' ) && / \b p o s i t i o n s \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