From a6007593c2448381958d6f08aae9a54a8a4ad845 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 12:44:03 +0800 Subject: [PATCH 01/13] fix(e2e-compare): actually compare setClassMetadata between compilers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractClassMetadataCalls` matched `i\d+\.ɵɵsetClassMetadata` but both compilers emit `i0.ɵsetClassMetadata` (single ɵ), so classMetadataDiffs was always empty — the comparator has been dead code. Fixes: - the regex (also covering `ɵsetClassMetadataAsync`) - `normalizeMetadataString` now parses each argument and compares normalized ASTs, so quote style, indentation and trailing commas no longer produce noise (this turned out to be the bulk of the 79 diffs enabling extraction surfaced) - `KnownDifference` gains `metadataDiffs` (Class.field allowlist) and `testFixture` fails on undocumented metadata diffs, per the Codex review comment on #560 Two pre-existing custom-decorator entries now declare their extra setClassMetadata (Oxc compiles the class where ngtsc leaves it uncompiled), and `edge-cases/aliased-injectable` declares its AliasedService.setClassMetadata diff instead of passing implicitly. Closes #561. --- .../e2e/compare/fixtures/known-differences.ts | 5 +++++ .../e2e/compare/fixtures/runner.ts | 5 ++++- .../e2e/compare/src/compare.ts | 21 ++++++++++++++----- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index 9629509ce..3de24beb5 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -13,6 +13,8 @@ interface KnownDifference { fields: string[] /** Every import difference, exactly as `compareImports` reports it. */ importDiffs?: ImportDiff[] + /** Every setClassMetadata difference, as `Class.field`, e.g. `MyComponent.setClassMetadata`. */ + metadataDiffs?: string[] /** Why each difference exists. */ reasons: string[] } @@ -111,14 +113,17 @@ export const KNOWN_DIFFERENCES: Record = { }, 'edge-cases/aliased-injectable': { fields: ['AliasedService.ɵfac'], + metadataDiffs: ['AliasedService.setClassMetadata'], reasons: [ALIASED_INJECTABLE], }, 'edge-cases/multiple-custom-decorators': { fields: ['MultiDecoratorComponent.ɵcmp', 'MultiDecoratorComponent.ɵfac'], + metadataDiffs: ['MultiDecoratorComponent.setClassMetadata'], reasons: [CUSTOM_DECORATOR], }, 'edge-cases/single-custom-decorator': { fields: ['MyComponent.ɵcmp', 'MyComponent.ɵfac'], + metadataDiffs: ['MyComponent.setClassMetadata'], reasons: [CUSTOM_DECORATOR], }, 'full-file/component-with-pipes': { diff --git a/napi/angular-compiler/e2e/compare/fixtures/runner.ts b/napi/angular-compiler/e2e/compare/fixtures/runner.ts index 6c0c3de86..c491fb4f4 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/runner.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/runner.ts @@ -156,7 +156,10 @@ async function testFixture(fixture: Fixture, verbose?: boolean): Promise `${diff.className}.${diff.field}`) + .filter((field) => !(known.metadataDiffs ?? []).includes(field)) + const allUndocumented = [...undocumented, ...undocumentedImports, ...undocumentedMetadata] if (allUndocumented.length > 0) { return { ...result, diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 72eb3fe2f..cfdf8e086 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2323,9 +2323,10 @@ function findMatchingBracket(code: string, startIdx: number, open: string, close function extractClassMetadataCalls(code: string): ClassMetadataInfo[] { const results: ClassMetadataInfo[] = [] - // Pattern matches: i0.ɵɵsetClassMetadata(ClassName, + // Pattern matches: i0.ɵsetClassMetadata(ClassName, + // (the runtime name uses a single ɵ; `ɵsetClassMetadataAsync` also exists) // Need to handle nested brackets and multiline content - const startPattern = /i\d+\.ɵɵsetClassMetadata\(\s*(\w+)\s*,\s*/g + const startPattern = /i\d+\.ɵsetClassMetadata(?:Async)?\(\s*(\w+)\s*,\s*/g let match while ((match = startPattern.exec(code)) !== null) { @@ -2382,12 +2383,22 @@ function extractClassMetadataCalls(code: string): ClassMetadataInfo[] { } /** - * Normalize a metadata string for comparison. - * Removes whitespace variations while preserving semantic content. + * Normalize a metadata argument for comparison. + * + * The argument is an expression fragment (`[...]`, `{...}`, `null`, ...), so + * parse it and compare normalized ASTs: quote style, indentation, trailing + * commas and key ordering noise don't matter semantically for these + * structures. Falls back to whitespace-collapsed text when the fragment + * can't be parsed. */ function normalizeMetadataString(s: string | null): string { if (!s) return 'null' - return s.replace(/\s+/g, ' ').trim() + const parsed = parseSync('metadata.js', `(${s}\n)`, { sourceType: 'module' }) + const stmt = parsed.program.body[0] + if (parsed.errors.length > 0 || !stmt || stmt.type !== 'ExpressionStatement') { + return s.replace(/\s+/g, ' ').trim() + } + return normalizeAst(stmt.expression) } /** From 33b709fbc796315ee7f89f4bff2255127fc24dbe Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 12:58:45 +0800 Subject: [PATCH 02/13] fix(e2e-compare): parse full setClassMetadata call arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex findings on the revived comparator: - ctorParams/propDecorators were sniffed by first character, so an arrow `() => [{ type: Service }]` desynced the parse and both args read as null. The extractor now finds the call's matching paren and splits arguments on top-level commas — any argument shape works. - the callee regex required `i\d+`, missing calls under a reused namespace import (`ng.ɵsetClassMetadata`); it accepts any identifier now. Enabling ctor-param comparison surfaces a real Oxc emit difference: `type:` names the annotation even for type-only symbols where ngtsc emits `undefined` (issue #563) — recorded in three fixtures' metadataDiffs until that is fixed. --- .../e2e/compare/fixtures/known-differences.ts | 11 +- .../e2e/compare/src/compare.ts | 114 ++++++++++-------- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index 3de24beb5..d2ef7f75c 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -35,6 +35,8 @@ const INJECTABLE_FACTORY_WRAPPER = 'an @Injectable useFactory is wrapped in a function expression where Angular emits an arrow function' const SET_CLASS_METADATA_IMPORT = 'Oxc keeps the @Inject import because its setClassMetadata references it; ngtsc emits the same reference but TypeScript elision still drops the import (upstream emit bug)' +const CTOR_PARAM_TYPE = + 'ctorParams emits the parameter`s type name where ngtsc emits type: undefined for type-only annotations (Oxc emit bug, issue #563)' const ALIASED_INJECTABLE = 'an aliased @Injectable compiles to a broken ɵprov under ngtsc — its needsFactory check compares the written decorator name, so the factory (and setClassMetadata) are never emitted; Oxc emits a working ɵfac (issue #507, kept deliberately)' @@ -61,7 +63,8 @@ export const KNOWN_DIFFERENCES: Record = { actual: ['Inject', 'Injectable', 'InjectionToken'], }, ], - reasons: [SET_CLASS_METADATA_IMPORT], + metadataDiffs: ['TestableService.setClassMetadata.ctorParams'], + reasons: [SET_CLASS_METADATA_IMPORT, CTOR_PARAM_TYPE], }, 'class-metadata/class-metadata-with-inject': { fields: [], @@ -73,7 +76,8 @@ export const KNOWN_DIFFERENCES: Record = { actual: ['Component', 'Inject', 'InjectionToken'], }, ], - reasons: [SET_CLASS_METADATA_IMPORT], + metadataDiffs: ['InjectMetaComponent.setClassMetadata.ctorParams'], + reasons: [SET_CLASS_METADATA_IMPORT, CTOR_PARAM_TYPE], }, 'full-file/component-with-services': { fields: [], @@ -85,7 +89,8 @@ export const KNOWN_DIFFERENCES: Record = { actual: ['Component', 'Inject', 'InjectionToken', 'inject'], }, ], - reasons: [SET_CLASS_METADATA_IMPORT], + metadataDiffs: ['ComponentWithServices.setClassMetadata.ctorParams'], + reasons: [SET_CLASS_METADATA_IMPORT, CTOR_PARAM_TYPE], }, 'component-meta/change-detection-default': { fields: ['ChangeDetectionDefaultComponent.ɵcmp'], diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index cfdf8e086..1c4408b31 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2320,63 +2320,77 @@ function findMatchingBracket(code: string, startIdx: number, open: string, close * @param code - The compiled JavaScript code * @returns Array of extracted class metadata info */ +/** + * Split an argument list into its top-level arguments, tracking bracket and + * paren depth and skipping over string/template literals. Any argument shape + * counts — arrays, objects, `() => [...]` callbacks, `forwardRef(...)`. + */ +function splitTopLevelArguments(argsText: string): string[] { + const args: string[] = [] + let depth = 0 + let inString: string | null = null + let argStart = 0 + + for (let i = 0; i < argsText.length; i++) { + const char = argsText[i] + if (inString) { + if (char === inString && argsText[i - 1] !== '\\') inString = null + continue + } + if (char === '"' || char === "'" || char === '`') { + inString = char + continue + } + if (char === '(' || char === '[' || char === '{') depth++ + else if (char === ')' || char === ']' || char === '}') depth-- + else if (char === ',' && depth === 0) { + args.push(argsText.slice(argStart, i).trim()) + argStart = i + 1 + } + } + const tail = argsText.slice(argStart).trim() + if (tail) args.push(tail) + return args +} + +/** + * `null` / `void 0` / `undefined` all mean "no value" in these slots. + */ +function metadataArgOrNull(arg: string | undefined): string | null { + if (arg === undefined) return null + const trimmed = arg.trim() + if (trimmed === 'null' || trimmed === 'undefined' || /^void\b/.test(trimmed)) { + return null + } + return trimmed +} + function extractClassMetadataCalls(code: string): ClassMetadataInfo[] { const results: ClassMetadataInfo[] = [] - // Pattern matches: i0.ɵsetClassMetadata(ClassName, - // (the runtime name uses a single ɵ; `ɵsetClassMetadataAsync` also exists) - // Need to handle nested brackets and multiline content - const startPattern = /i\d+\.ɵsetClassMetadata(?:Async)?\(\s*(\w+)\s*,\s*/g + // `i0.ɵsetClassMetadata(ClassName, decorators, ctorParams, propDecorators)` + // — or `ɵsetClassMetadataAsync`, or under a reused namespace import + // (`ng.ɵsetClassMetadata`), so the callee is any identifier. + const startPattern = /[A-Za-z_$][\w$]*\.ɵsetClassMetadata(?:Async)?\(\s*(\w+)\s*/g let match while ((match = startPattern.exec(code)) !== null) { const className = match[1] - const startIdx = match.index + match[0].length - - // Extract the decorators array (first parameter after className) - const decoratorsEnd = findMatchingBracket(code, startIdx, '[', ']') - if (decoratorsEnd === -1) continue - const decorators = code.slice(startIdx, decoratorsEnd + 1) - - // Skip comma and whitespace to get to ctorParams - let idx = decoratorsEnd + 1 - while (idx < code.length && (code[idx] === ',' || code[idx] === ' ' || code[idx] === '\n')) - idx++ - - let ctorParams: string | null = null - if (code[idx] === '[') { - const ctorEnd = findMatchingBracket(code, idx, '[', ']') - if (ctorEnd !== -1) { - ctorParams = code.slice(idx, ctorEnd + 1) - idx = ctorEnd + 1 - } - } else if (code.slice(idx, idx + 4) === 'null') { - ctorParams = null - idx += 4 - } else if (code.slice(idx, idx + 4) === 'void') { - // Handle "void 0" which is equivalent to undefined/null - ctorParams = null - idx += 6 // Skip "void 0" - } - - // Skip comma and whitespace to get to propDecorators - while (idx < code.length && (code[idx] === ',' || code[idx] === ' ' || code[idx] === '\n')) - idx++ - - let propDecorators: string | null = null - if (code[idx] === '{') { - const propEnd = findMatchingBracket(code, idx, '{', '}') - if (propEnd !== -1) { - propDecorators = code.slice(idx, propEnd + 1) - } - } else if (code.slice(idx, idx + 4) === 'null') { - propDecorators = null - } else if (code.slice(idx, idx + 4) === 'void') { - // Handle "void 0" which is equivalent to undefined/null - propDecorators = null - } - - results.push({ className, decorators, ctorParams, propDecorators }) + const parenIdx = match.index + match[0].indexOf('(') + const callEnd = findMatchingBracket(code, parenIdx, '(', ')') + if (callEnd === -1) continue + + // args[0] is the class name; the rest are the metadata arguments of any + // shape — `ctorParams` is an arrow `() => [...]` when present. + const args = splitTopLevelArguments(code.slice(parenIdx + 1, callEnd)).slice(1) + if (args.length === 0) continue + const [decorators, ctorParams, propDecorators] = args + results.push({ + className, + decorators, + ctorParams: metadataArgOrNull(ctorParams), + propDecorators: metadataArgOrNull(propDecorators), + }) } return results From eedc45169786d04482c5adf5ba09353e5652a394 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 13:11:19 +0800 Subject: [PATCH 03/13] fix(e2e-compare): harden setClassMetadata extraction edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more Codex findings on the comparator: - `ɵsetClassMetadataAsync` (deferred imports) was extracted as a sync entry and overwrote the inner call in the class-keyed map. Async calls now carry `isAsync` + `resolver` and are keyed separately, so a sync-vs-async flip surfaces as missing/extra and the resolver thunk is compared via normalized AST. - `\w+` couldn't capture `$` in class names (`Foo$`) — now the full identifier grammar. - the callee gate now requires the namespace to be an `@angular/core` binding (`import * as X`/`const X = require(...)`) in the emitted file, so a user-side `debug.ɵsetClassMetadata` can't shadow real compiler metadata. --- .../e2e/compare/src/compare.ts | 88 +++++++++++++++---- 1 file changed, 72 insertions(+), 16 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 1c4408b31..a279839cd 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2269,6 +2269,10 @@ interface ClassMetadataInfo { ctorParams: string | null /** Property decorators (fourth argument, null if none) */ propDecorators: string | null + /** `ɵsetClassMetadataAsync` call: resolver thunk (second argument). */ + resolver?: string | null + /** Whether this entry came from `ɵsetClassMetadataAsync` (deferred imports). */ + isAsync?: boolean } /** @@ -2365,25 +2369,56 @@ function metadataArgOrNull(arg: string | undefined): string | null { return trimmed } +/** + * Namespace bindings (`import * as X` / `const X = require(...)`) for + * '@angular/core' in the emitted code — the callee of a metadata call must be + * one of these so a user-defined `debug.ɵsetClassMetadata` isn't picked up. + */ +function angularCoreNamespaces(code: string): Set { + const namespaces = new Set() + const nsPattern = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]@angular\/core['"]/g + const reqPattern = /const\s+([A-Za-z_$][\w$]*)\s*=\s*require\(['"]@angular\/core['"]\)/g + for (const m of code.matchAll(nsPattern)) namespaces.add(m[1]) + for (const m of code.matchAll(reqPattern)) namespaces.add(m[1]) + return namespaces +} + function extractClassMetadataCalls(code: string): ClassMetadataInfo[] { const results: ClassMetadataInfo[] = [] + const coreNamespaces = angularCoreNamespaces(code) - // `i0.ɵsetClassMetadata(ClassName, decorators, ctorParams, propDecorators)` - // — or `ɵsetClassMetadataAsync`, or under a reused namespace import - // (`ng.ɵsetClassMetadata`), so the callee is any identifier. - const startPattern = /[A-Za-z_$][\w$]*\.ɵsetClassMetadata(?:Async)?\(\s*(\w+)\s*/g + // `ns.ɵsetClassMetadata(ClassName, decorators, ctorParams, propDecorators)` + // and the async form `ns.ɵsetClassMetadataAsync(ClassName, resolver, wrapper)` + // (wrapper contains a nested sync call, extracted on its own pass). Class + // names accept `$` — `Foo$` is a valid identifier. + const startPattern = /([A-Za-z_$][\w$]*)\.ɵsetClassMetadata(Async)?\(\s*([A-Za-z_$][\w$]*)\s*/g let match while ((match = startPattern.exec(code)) !== null) { - const className = match[1] + const [, callee, asyncMarker, className] = match + if (!coreNamespaces.has(callee)) continue const parenIdx = match.index + match[0].indexOf('(') const callEnd = findMatchingBracket(code, parenIdx, '(', ')') if (callEnd === -1) continue - // args[0] is the class name; the rest are the metadata arguments of any - // shape — `ctorParams` is an arrow `() => [...]` when present. const args = splitTopLevelArguments(code.slice(parenIdx + 1, callEnd)).slice(1) if (args.length === 0) continue + + if (asyncMarker) { + // Async call: arg2 is the resolver thunk, arg3 the wrapper (which the + // pattern above also extracts as a nested sync call). The resolver is + // the distinguishing payload. + results.push({ + className, + decorators: 'null', + ctorParams: null, + propDecorators: null, + resolver: metadataArgOrNull(args[0]), + isAsync: true, + }) + continue + } + const [decorators, ctorParams, propDecorators] = args results.push({ className, @@ -2427,19 +2462,40 @@ function compareClassMetadata( tsMetadata: ClassMetadataInfo[], ): ClassMetadataDiff[] { const diffs: ClassMetadataDiff[] = [] - const tsMap = new Map(tsMetadata.map((m) => [m.className, m])) - const oxcMap = new Map(oxcMetadata.map((m) => [m.className, m])) + // Sync and async calls for the same class are separate entries — a class + // reported under both names on one side and only sync on the other is a + // real difference (deferredImports metadata wrapper). + const keyOf = (m: ClassMetadataInfo) => (m.isAsync ? `${m.className}#async` : m.className) + const tsMap = new Map(tsMetadata.map((m) => [keyOf(m), m])) + const oxcMap = new Map(oxcMetadata.map((m) => [keyOf(m), m])) // Check for missing/different metadata (in TS but not matching in Oxc) - for (const [className, tsInfo] of tsMap) { - const oxcInfo = oxcMap.get(className) + for (const [key, tsInfo] of tsMap) { + const className = tsInfo.className + const callName = tsInfo.isAsync ? 'setClassMetadataAsync' : 'setClassMetadata' + const oxcInfo = oxcMap.get(key) if (!oxcInfo) { diffs.push({ type: 'missing', className, - field: 'setClassMetadata', + field: callName, expected: `decorators: ${tsInfo.decorators.slice(0, 100)}...`, }) + } else if (tsInfo.isAsync) { + // Async entries: compare the deferred-imports resolver thunk. The + // wrapper's nested sync call is extracted as its own entry. + if ( + normalizeMetadataString(tsInfo.resolver ?? null) !== + normalizeMetadataString(oxcInfo.resolver ?? null) + ) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadataAsync.resolver', + expected: String(tsInfo.resolver).slice(0, 100), + actual: String(oxcInfo.resolver).slice(0, 100), + }) + } } else { // Compare decorators (normalized) const normalizedTsDecorators = normalizeMetadataString(tsInfo.decorators) @@ -2484,12 +2540,12 @@ function compareClassMetadata( } // Check for extra metadata (in Oxc but not in TS) - for (const className of oxcMap.keys()) { - if (!tsMap.has(className)) { + for (const [key, oxcInfo] of oxcMap) { + if (!tsMap.has(key)) { diffs.push({ type: 'extra', - className, - field: 'setClassMetadata', + className: oxcInfo.className, + field: oxcInfo.isAsync ? 'setClassMetadataAsync' : 'setClassMetadata', actual: 'found in Oxc output but not in TS', }) } From c0c98434d6433d19dc047acccb616a333d008452 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 13:22:05 +0800 Subject: [PATCH 04/13] fix(e2e-compare): extract setClassMetadata calls from the AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Codex findings, both rooted in the text scanner: - `findMatchingBracket` stopped at `)` inside regex literals or comments, truncating the argument list and hiding ctorParams/propDecorators diffs. Extraction now walks the parsed AST (`walkAst`) — `CallExpression`s whose callee is a member of an `@angular/core` namespace binding (`import * as X` or `const X = require('@angular/core')` read from `ImportDeclaration`s). Spans are character offsets, so argument text still slices cleanly. - The async wrapper `(B, A) => {...}` versus `(A, B) => {...}` was invisible: only the resolver was compared. Wrappers are now compared after alpha-renaming their parameters (`p0`, `p1`, ...), so param order changes surface while name-only differences don't. The scanner helpers (`findMatchingBracket`, `splitTopLevelArguments`) are gone with no remaining callers. --- .../e2e/compare/src/compare.ts | 220 +++++++++--------- 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index a279839cd..4a4a8d983 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2271,48 +2271,12 @@ interface ClassMetadataInfo { propDecorators: string | null /** `ɵsetClassMetadataAsync` call: resolver thunk (second argument). */ resolver?: string | null + /** `ɵsetClassMetadataAsync` call: wrapper with params alpha-renamed, normalized AST. */ + wrapperNormalized?: string | null /** Whether this entry came from `ɵsetClassMetadataAsync` (deferred imports). */ isAsync?: boolean } -/** - * Find matching closing bracket, handling nested structures and strings. - * - * @param code - The source code - * @param startIdx - Index where to start looking (should be at or before the opening bracket) - * @param open - Opening bracket character ('[', '{', or '(') - * @param close - Closing bracket character (']', '}', or ')') - * @returns Index of the matching closing bracket, or -1 if not found - */ -function findMatchingBracket(code: string, startIdx: number, open: string, close: string): number { - let depth = 0 - let inString: string | null = null - - for (let i = startIdx; i < code.length; i++) { - const char = code[i] - - if (inString) { - if (char === inString && code[i - 1] !== '\\') { - inString = null - } - continue - } - - if (char === '"' || char === "'" || char === '`') { - inString = char - continue - } - - if (char === open) depth++ - else if (char === close) { - depth-- - if (depth === 0) return i - } - } - - return -1 -} - /** * Extract setClassMetadata calls from compiled code. * @@ -2324,38 +2288,6 @@ function findMatchingBracket(code: string, startIdx: number, open: string, close * @param code - The compiled JavaScript code * @returns Array of extracted class metadata info */ -/** - * Split an argument list into its top-level arguments, tracking bracket and - * paren depth and skipping over string/template literals. Any argument shape - * counts — arrays, objects, `() => [...]` callbacks, `forwardRef(...)`. - */ -function splitTopLevelArguments(argsText: string): string[] { - const args: string[] = [] - let depth = 0 - let inString: string | null = null - let argStart = 0 - - for (let i = 0; i < argsText.length; i++) { - const char = argsText[i] - if (inString) { - if (char === inString && argsText[i - 1] !== '\\') inString = null - continue - } - if (char === '"' || char === "'" || char === '`') { - inString = char - continue - } - if (char === '(' || char === '[' || char === '{') depth++ - else if (char === ')' || char === ']' || char === '}') depth-- - else if (char === ',' && depth === 0) { - args.push(argsText.slice(argStart, i).trim()) - argStart = i + 1 - } - } - const tail = argsText.slice(argStart).trim() - if (tail) args.push(tail) - return args -} /** * `null` / `void 0` / `undefined` all mean "no value" in these slots. @@ -2374,59 +2306,116 @@ function metadataArgOrNull(arg: string | undefined): string | null { * '@angular/core' in the emitted code — the callee of a metadata call must be * one of these so a user-defined `debug.ɵsetClassMetadata` isn't picked up. */ -function angularCoreNamespaces(code: string): Set { +function angularCoreNamespaces(program: unknown): Set { const namespaces = new Set() - const nsPattern = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]@angular\/core['"]/g - const reqPattern = /const\s+([A-Za-z_$][\w$]*)\s*=\s*require\(['"]@angular\/core['"]\)/g - for (const m of code.matchAll(nsPattern)) namespaces.add(m[1]) - for (const m of code.matchAll(reqPattern)) namespaces.add(m[1]) + walkAst(program, (node) => { + if (node.type === 'ImportDeclaration') { + const source = node.source as NormAstNode | undefined + if (source?.value !== '@angular/core') return + for (const spec of (node.specifiers ?? []) as NormAstNode[]) { + if (spec.type === 'ImportNamespaceSpecifier') { + namespaces.add((spec.local as NormAstNode).name as string) + } + } + } else if (node.type === 'VariableDeclaration') { + // `const i0 = require('@angular/core')` + for (const decl of (node.declarations ?? []) as NormAstNode[]) { + const init = decl.init as NormAstNode | undefined + const callee = init?.callee as NormAstNode | undefined + if ( + init?.type === 'CallExpression' && + callee?.type === 'Identifier' && + callee.name === 'require' && + (init.arguments as NormAstNode[])?.[0]?.value === '@angular/core' + ) { + const id = decl.id as NormAstNode + if (id?.type === 'Identifier') namespaces.add(id.name as string) + } + } + } + }) return namespaces } -function extractClassMetadataCalls(code: string): ClassMetadataInfo[] { - const results: ClassMetadataInfo[] = [] - const coreNamespaces = angularCoreNamespaces(code) - - // `ns.ɵsetClassMetadata(ClassName, decorators, ctorParams, propDecorators)` - // and the async form `ns.ɵsetClassMetadataAsync(ClassName, resolver, wrapper)` - // (wrapper contains a nested sync call, extracted on its own pass). Class - // names accept `$` — `Foo$` is a valid identifier. - const startPattern = /([A-Za-z_$][\w$]*)\.ɵsetClassMetadata(Async)?\(\s*([A-Za-z_$][\w$]*)\s*/g - let match - - while ((match = startPattern.exec(code)) !== null) { - const [, callee, asyncMarker, className] = match - if (!coreNamespaces.has(callee)) continue - const parenIdx = match.index + match[0].indexOf('(') - const callEnd = findMatchingBracket(code, parenIdx, '(', ')') - if (callEnd === -1) continue - - const args = splitTopLevelArguments(code.slice(parenIdx + 1, callEnd)).slice(1) - if (args.length === 0) continue +/** + * Alpha-rename an arrow/function's parameters to `p0`, `p1`, ... so equivalent + * wrappers compare equal while a swapped parameter usage order still differs. + */ +function normalizeWrapperAst(wrapper: NormAstNode): string { + const cloned = JSON.parse(JSON.stringify(wrapper)) as NormAstNode + const paramNames: string[] = [] + for (const param of (cloned.params ?? []) as NormAstNode[]) { + if (param.type === 'Identifier') paramNames.push(param.name as string) + } + walkAst(cloned, (node) => { + if (node.type === 'Identifier') { + const index = paramNames.indexOf(node.name as string) + if (index >= 0) node.name = `p${index}` + } + }) + return normalizeAst(cloned) +} - if (asyncMarker) { - // Async call: arg2 is the resolver thunk, arg3 the wrapper (which the - // pattern above also extracts as a nested sync call). The resolver is - // the distinguishing payload. +/** + * Extract ɵsetClassMetadata/ɵsetClassMetadataAsync calls from the parsed AST. + * Working on the tree avoids every text-scanning edge case: regex literals, + * comments, nested calls and `$`-or-unicode identifiers are all handled by + * the parser. + */ +function extractClassMetadataCalls(program: unknown, code: string): ClassMetadataInfo[] { + const results: ClassMetadataInfo[] = [] + const coreNamespaces = angularCoreNamespaces(program) + + const slice = (node: NormAstNode | undefined): string | undefined => + node && typeof node.start === 'number' && typeof node.end === 'number' + ? code.slice(node.start, node.end) + : undefined + + walkAst(program, (node) => { + if (node.type !== 'CallExpression') return + const callee = node.callee as NormAstNode | undefined + if (!callee || callee.type !== 'MemberExpression') return + const object = callee.object as NormAstNode | undefined + const property = callee.property as NormAstNode | undefined + if (object?.type !== 'Identifier' || property?.type !== 'Identifier') return + if (!coreNamespaces.has(object.name as string)) return + + const isAsync = property.name === 'ɵsetClassMetadataAsync' + if (!isAsync && property.name !== 'ɵsetClassMetadata') return + + const args = node.arguments as NormAstNode[] | undefined + const classArg = args?.[0] + if (!args || !classArg) return + const className = + classArg.type === 'Identifier' ? (classArg.name as string) : (slice(classArg) ?? '') + + if (isAsync) { + // `ɵsetClassMetadataAsync(Class, resolver, wrapper)` — the wrapper's + // nested sync call is extracted on its own pass. + const wrapper = args[2] results.push({ className, decorators: 'null', ctorParams: null, propDecorators: null, - resolver: metadataArgOrNull(args[0]), + resolver: metadataArgOrNull(slice(args[1])), isAsync: true, + wrapperNormalized: + wrapper && + (wrapper.type === 'ArrowFunctionExpression' || wrapper.type === 'FunctionExpression') + ? normalizeWrapperAst(wrapper) + : (slice(wrapper) ?? null), }) - continue + return } - const [decorators, ctorParams, propDecorators] = args results.push({ className, - decorators, - ctorParams: metadataArgOrNull(ctorParams), - propDecorators: metadataArgOrNull(propDecorators), + decorators: slice(args[1]) ?? 'null', + ctorParams: metadataArgOrNull(slice(args[2])), + propDecorators: metadataArgOrNull(slice(args[3])), }) - } + }) return results } @@ -2482,8 +2471,10 @@ function compareClassMetadata( expected: `decorators: ${tsInfo.decorators.slice(0, 100)}...`, }) } else if (tsInfo.isAsync) { - // Async entries: compare the deferred-imports resolver thunk. The - // wrapper's nested sync call is extracted as its own entry. + // Async entries: compare the deferred-imports resolver thunk AND the + // wrapper (alpha-renamed) — a swapped `(B, A)` wrapper is behaviorally + // wrong even though its nested sync call looks identical. The nested + // sync call itself is extracted as its own entry. if ( normalizeMetadataString(tsInfo.resolver ?? null) !== normalizeMetadataString(oxcInfo.resolver ?? null) @@ -2496,6 +2487,15 @@ function compareClassMetadata( actual: String(oxcInfo.resolver).slice(0, 100), }) } + if ((tsInfo.wrapperNormalized ?? null) !== (oxcInfo.wrapperNormalized ?? null)) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadataAsync.wrapper', + expected: '(normalized wrapper)', + actual: '(normalized wrapper)', + }) + } } else { // Compare decorators (normalized) const normalizedTsDecorators = normalizeMetadataString(tsInfo.decorators) @@ -3253,8 +3253,8 @@ export async function compareFullFileSemantically( const staticFieldDiffs = compareStaticFields(oxcFields, tsFields, constMapping) // Extract and compare class metadata (setClassMetadata calls) - const oxcClassMetadata = extractClassMetadataCalls(normalizedOxcCode) - const tsClassMetadata = extractClassMetadataCalls(normalizedTsCode) + const oxcClassMetadata = extractClassMetadataCalls(oxcResult.program, normalizedOxcCode) + const tsClassMetadata = extractClassMetadataCalls(tsResult.program, normalizedTsCode) const classMetadataDiffs = compareClassMetadata(oxcClassMetadata, tsClassMetadata) // Extract and compare functions (template functions, etc.) From 0ba683c6441f8200cbf2f1d328adc9fee0f874c1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 13:37:19 +0800 Subject: [PATCH 05/13] fix(e2e-compare): preserve absent-arg and multi-call metadata differences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex findings on the setClassMetadata comparator: - `null` and `undefined`/`void 0`/a missing argument are not interchangeable: the runtime guards each field with `!== null`, so an absent argument assigns the field instead of skipping it. The extractor now records the literal absent spelling (`'null'` vs `'undefined'`) instead of collapsing them. - A source file can contain its own `ng.ɵsetClassMetadata(...)` call via the Angular namespace binding; keying the compare maps by class kept only the last call, letting an identical user call mask a divergent generated one. Keys now map to sorted call lists compared pairwise as a multiset. --- .../e2e/compare/src/compare.ts | 216 +++++++++++------- 1 file changed, 130 insertions(+), 86 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 4a4a8d983..6b281017f 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2290,14 +2290,18 @@ interface ClassMetadataInfo { */ /** - * `null` / `void 0` / `undefined` all mean "no value" in these slots. - */ -function metadataArgOrNull(arg: string | undefined): string | null { - if (arg === undefined) return null + * `null` and `undefined` are NOT interchangeable in these slots: the runtime + * `setClassMetadata` guards each field with `!== null`, so `undefined` (or a + * missing argument, which is `undefined` at the call site) assigns the field + * while `null` skips it. Keep the two spellings distinct so the comparator + * catches a regression between them. Returns the literal argument text, or + * `'null'` / `'undefined'` for the absent-value forms. + */ +function metadataArgOrAbsent(arg: string | undefined): string { + if (arg === undefined) return 'undefined' const trimmed = arg.trim() - if (trimmed === 'null' || trimmed === 'undefined' || /^void\b/.test(trimmed)) { - return null - } + if (trimmed === 'null') return 'null' + if (trimmed === 'undefined' || /^void\b/.test(trimmed)) return 'undefined' return trimmed } @@ -2398,7 +2402,7 @@ function extractClassMetadataCalls(program: unknown, code: string): ClassMetadat decorators: 'null', ctorParams: null, propDecorators: null, - resolver: metadataArgOrNull(slice(args[1])), + resolver: metadataArgOrAbsent(slice(args[1])), isAsync: true, wrapperNormalized: wrapper && @@ -2411,9 +2415,9 @@ function extractClassMetadataCalls(program: unknown, code: string): ClassMetadat results.push({ className, - decorators: slice(args[1]) ?? 'null', - ctorParams: metadataArgOrNull(slice(args[2])), - propDecorators: metadataArgOrNull(slice(args[3])), + decorators: metadataArgOrAbsent(slice(args[1])), + ctorParams: metadataArgOrAbsent(slice(args[2])), + propDecorators: metadataArgOrAbsent(slice(args[3])), }) }) @@ -2453,99 +2457,139 @@ function compareClassMetadata( const diffs: ClassMetadataDiff[] = [] // Sync and async calls for the same class are separate entries — a class // reported under both names on one side and only sync on the other is a - // real difference (deferredImports metadata wrapper). + // real difference (deferredImports metadata wrapper). Each key maps to a + // LIST of calls: a source file can legitimately contain its own + // `ng.ɵsetClassMetadata(...)` call through the same Angular namespace + // binding, and keeping only the last call would let an identical user call + // mask a divergent generated one. const keyOf = (m: ClassMetadataInfo) => (m.isAsync ? `${m.className}#async` : m.className) - const tsMap = new Map(tsMetadata.map((m) => [keyOf(m), m])) - const oxcMap = new Map(oxcMetadata.map((m) => [keyOf(m), m])) + const signatureOf = (m: ClassMetadataInfo) => + m.isAsync + ? `${normalizeMetadataString(m.resolver ?? 'null')}|${m.wrapperNormalized ?? ''}` + : `${normalizeMetadataString(m.decorators)}|${normalizeMetadataString(m.ctorParams)}|${normalizeMetadataString(m.propDecorators)}` + const groupByKey = (list: ClassMetadataInfo[]) => { + const map = new Map() + for (const m of list) { + const key = keyOf(m) + const group = map.get(key) + if (group) { + group.push(m) + } else { + map.set(key, [m]) + } + } + // Sort each group by normalized signature so pairing is stable regardless + // of call ordering in the two outputs. + for (const group of map.values()) + group.sort((a, b) => signatureOf(a).localeCompare(signatureOf(b))) + return map + } + const tsMap = groupByKey(tsMetadata) + const oxcMap = groupByKey(oxcMetadata) // Check for missing/different metadata (in TS but not matching in Oxc) - for (const [key, tsInfo] of tsMap) { - const className = tsInfo.className - const callName = tsInfo.isAsync ? 'setClassMetadataAsync' : 'setClassMetadata' - const oxcInfo = oxcMap.get(key) - if (!oxcInfo) { + for (const [key, tsGroup] of tsMap) { + const className = tsGroup[0].className + const callName = tsGroup[0].isAsync ? 'setClassMetadataAsync' : 'setClassMetadata' + const oxcGroup = oxcMap.get(key) + if (!oxcGroup) { diffs.push({ type: 'missing', className, field: callName, - expected: `decorators: ${tsInfo.decorators.slice(0, 100)}...`, + expected: `decorators: ${tsGroup[0].decorators.slice(0, 100)}...`, }) - } else if (tsInfo.isAsync) { - // Async entries: compare the deferred-imports resolver thunk AND the - // wrapper (alpha-renamed) — a swapped `(B, A)` wrapper is behaviorally - // wrong even though its nested sync call looks identical. The nested - // sync call itself is extracted as its own entry. - if ( - normalizeMetadataString(tsInfo.resolver ?? null) !== - normalizeMetadataString(oxcInfo.resolver ?? null) - ) { - diffs.push({ - type: 'different', - className, - field: 'setClassMetadataAsync.resolver', - expected: String(tsInfo.resolver).slice(0, 100), - actual: String(oxcInfo.resolver).slice(0, 100), - }) - } - if ((tsInfo.wrapperNormalized ?? null) !== (oxcInfo.wrapperNormalized ?? null)) { - diffs.push({ - type: 'different', - className, - field: 'setClassMetadataAsync.wrapper', - expected: '(normalized wrapper)', - actual: '(normalized wrapper)', - }) - } - } else { - // Compare decorators (normalized) - const normalizedTsDecorators = normalizeMetadataString(tsInfo.decorators) - const normalizedOxcDecorators = normalizeMetadataString(oxcInfo.decorators) - if (normalizedTsDecorators !== normalizedOxcDecorators) { - diffs.push({ - type: 'different', - className, - field: 'setClassMetadata.decorators', - expected: tsInfo.decorators.slice(0, 100), - actual: oxcInfo.decorators.slice(0, 100), - }) - } + continue + } + if (oxcGroup.length !== tsGroup.length) { + diffs.push({ + type: 'different', + className, + field: callName, + expected: `${tsGroup.length} call(s)`, + actual: `${oxcGroup.length} call(s)`, + }) + } + for (let i = 0; i < Math.min(tsGroup.length, oxcGroup.length); i++) { + const tsInfo = tsGroup[i] + const oxcInfo = oxcGroup[i] + if (tsInfo.isAsync) { + // Async entries: compare the deferred-imports resolver thunk AND the + // wrapper (alpha-renamed) — a swapped `(B, A)` wrapper is behaviorally + // wrong even though its nested sync call looks identical. The nested + // sync call itself is extracted as its own entry. + if ( + normalizeMetadataString(tsInfo.resolver ?? null) !== + normalizeMetadataString(oxcInfo.resolver ?? null) + ) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadataAsync.resolver', + expected: String(tsInfo.resolver).slice(0, 100), + actual: String(oxcInfo.resolver).slice(0, 100), + }) + } + if ((tsInfo.wrapperNormalized ?? null) !== (oxcInfo.wrapperNormalized ?? null)) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadataAsync.wrapper', + expected: '(normalized wrapper)', + actual: '(normalized wrapper)', + }) + } + } else { + // Compare decorators (normalized) + const normalizedTsDecorators = normalizeMetadataString(tsInfo.decorators) + const normalizedOxcDecorators = normalizeMetadataString(oxcInfo.decorators) + if (normalizedTsDecorators !== normalizedOxcDecorators) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadata.decorators', + expected: tsInfo.decorators.slice(0, 100), + actual: oxcInfo.decorators.slice(0, 100), + }) + } - // Compare ctorParams - if ( - normalizeMetadataString(tsInfo.ctorParams) !== normalizeMetadataString(oxcInfo.ctorParams) - ) { - diffs.push({ - type: 'different', - className, - field: 'setClassMetadata.ctorParams', - expected: String(tsInfo.ctorParams).slice(0, 100), - actual: String(oxcInfo.ctorParams).slice(0, 100), - }) - } + // Compare ctorParams + if ( + normalizeMetadataString(tsInfo.ctorParams) !== normalizeMetadataString(oxcInfo.ctorParams) + ) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadata.ctorParams', + expected: String(tsInfo.ctorParams).slice(0, 100), + actual: String(oxcInfo.ctorParams).slice(0, 100), + }) + } - // Compare propDecorators - if ( - normalizeMetadataString(tsInfo.propDecorators) !== - normalizeMetadataString(oxcInfo.propDecorators) - ) { - diffs.push({ - type: 'different', - className, - field: 'setClassMetadata.propDecorators', - expected: String(tsInfo.propDecorators).slice(0, 100), - actual: String(oxcInfo.propDecorators).slice(0, 100), - }) + // Compare propDecorators + if ( + normalizeMetadataString(tsInfo.propDecorators) !== + normalizeMetadataString(oxcInfo.propDecorators) + ) { + diffs.push({ + type: 'different', + className, + field: 'setClassMetadata.propDecorators', + expected: String(tsInfo.propDecorators).slice(0, 100), + actual: String(oxcInfo.propDecorators).slice(0, 100), + }) + } } } } // Check for extra metadata (in Oxc but not in TS) - for (const [key, oxcInfo] of oxcMap) { + for (const [key, oxcGroup] of oxcMap) { if (!tsMap.has(key)) { diffs.push({ type: 'extra', - className: oxcInfo.className, - field: oxcInfo.isAsync ? 'setClassMetadataAsync' : 'setClassMetadata', + className: oxcGroup[0].className, + field: oxcGroup[0].isAsync ? 'setClassMetadataAsync' : 'setClassMetadata', actual: 'found in Oxc output but not in TS', }) } From ef0225a16b15db08deaa8eacf103920a991b8d1a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 13:50:45 +0800 Subject: [PATCH 06/13] fix(e2e-compare): keep metadata call order, rename only bound references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Codex findings on the setClassMetadata comparator: - Repeated setClassMetadata calls overwrite class fields, so call order is semantically significant. Grouped calls are now compared in execution order instead of being sorted by normalized signature. - The async wrapper alpha-rename renamed every identifier matching a parameter name, including non-reference positions — member properties (`registry.X`), non-computed object keys (`{X: ...}`, the key of a shorthand `{X}`), labels, and `import.meta`. Those positions keep their names now, so wrappers differing only in observable key/property names no longer normalize identically. Also exports `extractClassMetadataCalls` and `compareClassMetadata` for direct probing. --- .../e2e/compare/src/compare.ts | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 6b281017f..76ac33c06 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2344,6 +2344,12 @@ function angularCoreNamespaces(program: unknown): Set { /** * Alpha-rename an arrow/function's parameters to `p0`, `p1`, ... so equivalent * wrappers compare equal while a swapped parameter usage order still differs. + * + * Only identifier *references* are renamed. Identifier nodes in positions that + * are not variable references — non-computed member properties (`registry.X`), + * non-computed object keys (`{X: ...}`, including the key of a shorthand + * `{X}`), class member keys, labels, and `import.meta` — keep their names so + * observable names stay in the normalized form. */ function normalizeWrapperAst(wrapper: NormAstNode): string { const cloned = JSON.parse(JSON.stringify(wrapper)) as NormAstNode @@ -2351,8 +2357,36 @@ function normalizeWrapperAst(wrapper: NormAstNode): string { for (const param of (cloned.params ?? []) as NormAstNode[]) { if (param.type === 'Identifier') paramNames.push(param.name as string) } + const nonReference = new Set() + const protect = (node: unknown) => { + const ident = node as NormAstNode | undefined + if (ident?.type === 'Identifier') nonReference.add(ident) + } + walkAst(cloned, (node) => { + if ( + (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') && + node.computed !== true + ) { + protect(node.property) + } else if ( + (node.type === 'Property' || + node.type === 'PropertyDefinition' || + node.type === 'MethodDefinition' || + node.type === 'AccessorProperty') && + node.computed !== true + ) { + protect(node.key) + } else if (node.type === 'LabeledStatement') { + protect(node.label) + } else if (node.type === 'BreakStatement' || node.type === 'ContinueStatement') { + protect(node.label) + } else if (node.type === 'MetaProperty') { + protect(node.meta) + protect(node.property) + } + }) walkAst(cloned, (node) => { - if (node.type === 'Identifier') { + if (node.type === 'Identifier' && !nonReference.has(node)) { const index = paramNames.indexOf(node.name as string) if (index >= 0) node.name = `p${index}` } @@ -2366,7 +2400,7 @@ function normalizeWrapperAst(wrapper: NormAstNode): string { * comments, nested calls and `$`-or-unicode identifiers are all handled by * the parser. */ -function extractClassMetadataCalls(program: unknown, code: string): ClassMetadataInfo[] { +export function extractClassMetadataCalls(program: unknown, code: string): ClassMetadataInfo[] { const results: ClassMetadataInfo[] = [] const coreNamespaces = angularCoreNamespaces(program) @@ -2450,7 +2484,7 @@ function normalizeMetadataString(s: string | null): string { * @param tsMetadata - Class metadata extracted from TS output * @returns Array of differences found */ -function compareClassMetadata( +export function compareClassMetadata( oxcMetadata: ClassMetadataInfo[], tsMetadata: ClassMetadataInfo[], ): ClassMetadataDiff[] { @@ -2463,10 +2497,6 @@ function compareClassMetadata( // binding, and keeping only the last call would let an identical user call // mask a divergent generated one. const keyOf = (m: ClassMetadataInfo) => (m.isAsync ? `${m.className}#async` : m.className) - const signatureOf = (m: ClassMetadataInfo) => - m.isAsync - ? `${normalizeMetadataString(m.resolver ?? 'null')}|${m.wrapperNormalized ?? ''}` - : `${normalizeMetadataString(m.decorators)}|${normalizeMetadataString(m.ctorParams)}|${normalizeMetadataString(m.propDecorators)}` const groupByKey = (list: ClassMetadataInfo[]) => { const map = new Map() for (const m of list) { @@ -2478,10 +2508,9 @@ function compareClassMetadata( map.set(key, [m]) } } - // Sort each group by normalized signature so pairing is stable regardless - // of call ordering in the two outputs. - for (const group of map.values()) - group.sort((a, b) => signatureOf(a).localeCompare(signatureOf(b))) + // Calls stay in execution order: repeated setClassMetadata calls overwrite + // fields, so the same calls in a different order produce different runtime + // metadata and must not compare equal. return map } const tsMap = groupByKey(tsMetadata) From 6b647fd3431397f6a8408fd1f5a7413b15d7b182 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 14:10:21 +0800 Subject: [PATCH 07/13] fix(e2e-compare): scope-aware wrapper rename, keep void side effects Two more Codex findings on the setClassMetadata comparator: - The wrapper alpha-rename matched by name only, so a nested binding shadowing a wrapper parameter (inner function param, let/const/class/ function declaration, hoisted var, catch param, class name) had its declaration and references renamed as though bound to the parameter. `renameBoundIdentifiers` now tracks lexical scopes and only renames references actually bound to the wrapper's parameters. - `metadataArgOrAbsent` collapsed every `void expr` to `undefined`. Only the inert spellings (`void 0`, `void(0)`) map to `undefined` now; an operand with side effects (`void recordMetadata()`) keeps its text so it diffs against `undefined`. --- .../e2e/compare/src/compare.ts | 197 +++++++++++++++++- 1 file changed, 190 insertions(+), 7 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 76ac33c06..f36432b7e 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2301,7 +2301,9 @@ function metadataArgOrAbsent(arg: string | undefined): string { if (arg === undefined) return 'undefined' const trimmed = arg.trim() if (trimmed === 'null') return 'null' - if (trimmed === 'undefined' || /^void\b/.test(trimmed)) return 'undefined' + // `undefined`, `void 0` and `void(0)` are the inert spellings a compiler + // emits; any other `void expr` runs its operand and must stay distinct. + if (trimmed === 'undefined' || trimmed === 'void 0' || trimmed === 'void(0)') return 'undefined' return trimmed } @@ -2385,15 +2387,196 @@ function normalizeWrapperAst(wrapper: NormAstNode): string { protect(node.property) } }) - walkAst(cloned, (node) => { - if (node.type === 'Identifier' && !nonReference.has(node)) { - const index = paramNames.indexOf(node.name as string) - if (index >= 0) node.name = `p${index}` - } - }) + // Rename the wrapper's own parameter declarations, then rename only the + // references actually bound to them — a nested scope re-declaring the same + // name shadows the parameter and must keep its spelling. + for (const [index, param] of ((cloned.params ?? []) as NormAstNode[]).entries()) { + if (param.type === 'Identifier') param.name = `p${index}` + } + const active = new Map(paramNames.map((name, index) => [name, `p${index}`])) + renameBoundIdentifiers(cloned, active, nonReference) return normalizeAst(cloned) } +/** + * Collect names bound by a binding pattern — parameter lists, destructuring + * targets, variable declarator ids and catch params. Expression parts + * (default values, computed keys) are not bindings and are skipped. + */ +function boundNames(pattern: NormAstNode | null | undefined, out: Set): void { + if (!pattern || typeof pattern !== 'object') return + switch (pattern.type) { + case 'Identifier': + out.add(pattern.name as string) + break + case 'ObjectPattern': + for (const prop of (pattern.properties ?? []) as NormAstNode[]) { + if (prop.type === 'Property') boundNames(prop.value as NormAstNode, out) + else if (prop.type === 'RestElement') boundNames(prop.argument as NormAstNode, out) + } + break + case 'ArrayPattern': + for (const el of (pattern.elements ?? []) as (NormAstNode | null)[]) boundNames(el, out) + break + case 'AssignmentPattern': + boundNames(pattern.left as NormAstNode, out) + break + case 'RestElement': + boundNames(pattern.argument as NormAstNode, out) + break + case 'TSParameterProperty': + boundNames(pattern.parameter as NormAstNode, out) + break + } +} + +/** + * Names bound directly inside a block/program/statement list by `let`, + * `const`, `class` and `function` declarations. Only direct members are + * scanned — nested blocks manage their own scope. + */ +function blockDeclNames(stmts: NormAstNode[] | undefined, out: Set): void { + for (const stmt of stmts ?? []) { + if (stmt.type === 'VariableDeclaration' && stmt.kind !== 'var') { + for (const decl of (stmt.declarations ?? []) as NormAstNode[]) { + boundNames(decl.id as NormAstNode, out) + } + } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'ClassDeclaration') { + const id = stmt.id as NormAstNode | undefined + if (id?.type === 'Identifier') out.add(id.name as string) + } + } +} + +/** + * `var`-bound names anywhere inside a function body — they hoist to the + * function scope. Nested functions are not descended into. + */ +function varDeclNames(node: unknown, out: Set): void { + if (node === null || typeof node !== 'object') return + if (Array.isArray(node)) { + for (const item of node) varDeclNames(item, out) + return + } + const obj = node as NormAstNode + if ( + obj.type === 'FunctionDeclaration' || + obj.type === 'FunctionExpression' || + obj.type === 'ArrowFunctionExpression' + ) { + return + } + if (obj.type === 'VariableDeclaration' && obj.kind === 'var') { + for (const decl of (obj.declarations ?? []) as NormAstNode[]) { + boundNames(decl.id as NormAstNode, out) + } + return + } + for (const value of Object.values(obj)) { + if (value !== null && typeof value === 'object') varDeclNames(value, out) + } +} + +/** + * Rename identifiers bound to names in `active` (param name → `p`), + * respecting lexical scope: parameters, `let`/`const`/`class`/`function` + * declarations, hoisted `var`s, catch params and class names that re-declare + * an active name shadow it for their subtree, and identifiers in + * `nonReference` positions are never renamed. + */ +function renameBoundIdentifiers( + node: unknown, + active: ReadonlyMap, + nonReference: ReadonlySet, +): void { + if (node === null || typeof node !== 'object') return + if (Array.isArray(node)) { + for (const item of node) renameBoundIdentifiers(item, active, nonReference) + return + } + const obj = node as NormAstNode + if (obj.type === 'Identifier') { + if (!nonReference.has(obj)) { + const mapped = active.get(obj.name as string) + if (mapped) obj.name = mapped + } + return + } + + // Narrow the active renames for this node's subtree when it introduces + // shadowing bindings. + let inner: Map = new Map(active) + const type = obj.type + if ( + type === 'FunctionDeclaration' || + type === 'FunctionExpression' || + type === 'ArrowFunctionExpression' + ) { + const shadowed = new Set() + for (const param of (obj.params ?? []) as NormAstNode[]) boundNames(param, shadowed) + const id = obj.id as NormAstNode | undefined + if (id?.type === 'Identifier') shadowed.add(id.name as string) + varDeclNames(obj.body, shadowed) + const body = obj.body as NormAstNode | undefined + if (body?.type === 'BlockStatement') { + blockDeclNames(body.body as NormAstNode[], shadowed) + } + if (shadowed.size > 0) { + inner = new Map(active) + for (const name of shadowed) inner.delete(name) + } + } else if ( + type === 'Program' || + type === 'BlockStatement' || + type === 'StaticBlock' || + type === 'SwitchStatement' + ) { + const shadowed = new Set() + if (type === 'SwitchStatement') { + // Case consequents share the switch's block scope. + for (const c of (obj.cases ?? []) as NormAstNode[]) { + blockDeclNames(c.consequent as NormAstNode[], shadowed) + } + } else { + blockDeclNames(obj.body as NormAstNode[], shadowed) + } + if (shadowed.size > 0) { + inner = new Map(active) + for (const name of shadowed) inner.delete(name) + } + } else if (type === 'CatchClause') { + const param = obj.param as NormAstNode | undefined + if (param) { + const shadowed = new Set() + boundNames(param, shadowed) + inner = new Map(active) + for (const name of shadowed) inner.delete(name) + } + } else if (type === 'ClassExpression' || type === 'ClassDeclaration') { + const id = obj.id as NormAstNode | undefined + if (id?.type === 'Identifier' && active.has(id.name as string)) { + inner = new Map(active) + inner.delete(id.name as string) + } + } else if (type === 'ForStatement' || type === 'ForInStatement' || type === 'ForOfStatement') { + const decl = (type === 'ForStatement' ? obj.init : obj.left) as NormAstNode | undefined + if (decl?.type === 'VariableDeclaration' && decl.kind !== 'var') { + const shadowed = new Set() + for (const d of (decl.declarations ?? []) as NormAstNode[]) { + boundNames(d.id as NormAstNode, shadowed) + } + inner = new Map(active) + for (const name of shadowed) inner.delete(name) + } + } + + for (const value of Object.values(obj)) { + if (value !== null && typeof value === 'object') { + renameBoundIdentifiers(value, inner, nonReference) + } + } +} + /** * Extract ɵsetClassMetadata/ɵsetClassMetadataAsync calls from the parsed AST. * Working on the tree avoids every text-scanning edge case: regex literals, From fa999bcb55fb588e98ea920692a6441a2542e40b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 14:21:15 +0800 Subject: [PATCH 08/13] fix(e2e-compare): switch discriminant scope, verify metadata allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Codex findings: - `renameBoundIdentifiers` removed case-level `let`/`const` bindings for the whole SwitchStatement, including the discriminant — but the discriminant is evaluated in the enclosing scope before the case-block environment exists. The discriminant is now traversed with the original mapping and only `cases` see the shadowed names. - `undocumentedMetadata` checked observed ⊆ declared only, so a fixed metadata bug left its declared `metadataDiffs` entry silently able to re-allow a regression. The check is now bidirectional: declared entries with no observed diff mark the fixture `mismatch` with `staleMetadataFields`, reported alongside stale known differences. --- .../e2e/compare/fixtures/runner.ts | 29 +++++++++++++++++++ .../e2e/compare/fixtures/types.ts | 3 ++ .../e2e/compare/src/compare.ts | 28 +++++++++--------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/fixtures/runner.ts b/napi/angular-compiler/e2e/compare/fixtures/runner.ts index c491fb4f4..1b6856e71 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/runner.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/runner.ts @@ -159,6 +159,12 @@ async function testFixture(fixture: Fixture, verbose?: boolean): Promise `${diff.className}.${diff.field}`) .filter((field) => !(known.metadataDiffs ?? []).includes(field)) + const observedMetadata = new Set( + (result.classMetadataDiffs ?? []).map((diff) => `${diff.className}.${diff.field}`), + ) + const unobservedMetadata = (known.metadataDiffs ?? []).filter( + (field) => !observedMetadata.has(field), + ) const allUndocumented = [...undocumented, ...undocumentedImports, ...undocumentedMetadata] if (allUndocumented.length > 0) { return { @@ -167,6 +173,17 @@ async function testFixture(fixture: Fixture, verbose?: boolean): Promise 0) { + return { + ...result, + status: 'mismatch', + knownDifferences, + staleMetadataFields: unobservedMetadata, + } + } return { ...result, status: 'known-difference', knownDifferences } } if (result.status === 'match') { @@ -997,6 +1014,18 @@ export function printFixtureSummary(report: FixtureReport): void { console.log(` ${result.fixture.category}/${result.fixture.name}`) } } + const staleMetadataFixtures = report.fixtures.filter((r) => r.staleMetadataFields) + if (staleMetadataFixtures.length > 0) { + console.log( + '\nStale Metadata Allowlist Entries (declared in known-differences.ts but no longer observed):', + ) + console.log('-'.repeat(50)) + for (const result of staleMetadataFixtures) { + console.log( + ` ${result.fixture.category}/${result.fixture.name}: ${result.staleMetadataFields!.join(', ')}`, + ) + } + } // Count feature discrepancies const fixturesWithAsymmetricFeatures = report.fixtures.filter( diff --git a/napi/angular-compiler/e2e/compare/fixtures/types.ts b/napi/angular-compiler/e2e/compare/fixtures/types.ts index 6097b933b..cff2e908b 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/types.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/types.ts @@ -288,6 +288,9 @@ export interface FixtureResult { /** Fields that differ in a fixture listed in known-differences.ts but are not documented there */ undocumentedFields?: string[] + /** `metadataDiffs` entries declared in known-differences.ts that no longer occur */ + staleMetadataFields?: string[] + /** Oxc compiler output */ oxcOutput?: FixtureCompilerOutput diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index f36432b7e..ca1388365 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2525,21 +2525,23 @@ function renameBoundIdentifiers( inner = new Map(active) for (const name of shadowed) inner.delete(name) } - } else if ( - type === 'Program' || - type === 'BlockStatement' || - type === 'StaticBlock' || - type === 'SwitchStatement' - ) { + } else if (type === 'SwitchStatement') { + // The discriminant is evaluated in the enclosing scope, before the + // case-block bindings exist — only the cases see the shadowed names. const shadowed = new Set() - if (type === 'SwitchStatement') { - // Case consequents share the switch's block scope. - for (const c of (obj.cases ?? []) as NormAstNode[]) { - blockDeclNames(c.consequent as NormAstNode[], shadowed) - } - } else { - blockDeclNames(obj.body as NormAstNode[], shadowed) + for (const c of (obj.cases ?? []) as NormAstNode[]) { + blockDeclNames(c.consequent as NormAstNode[], shadowed) } + renameBoundIdentifiers(obj.discriminant, active, nonReference) + if (shadowed.size > 0) { + inner = new Map(active) + for (const name of shadowed) inner.delete(name) + } + renameBoundIdentifiers(obj.cases, inner, nonReference) + return + } else if (type === 'Program' || type === 'BlockStatement' || type === 'StaticBlock') { + const shadowed = new Set() + blockDeclNames(obj.body as NormAstNode[], shadowed) if (shadowed.size > 0) { inner = new Map(active) for (const name of shadowed) inner.delete(name) From 45dbfdd300bc41db4d0a5df3f726a18bcae04309 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 15:18:41 +0800 Subject: [PATCH 09/13] fix(e2e-compare): use collision-proof canonical wrapper parameter names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming declarations to p0/p1 before traversing made a canonical name collide with an original parameter named p1, corrupting the shadow set and comparing equivalent wrappers as different. References are now renamed first while scope tracking still sees the original names, the parameter declarations are renamed afterwards, and the canonical spellings are `#p0`, `#p1`, ... — never legal identifiers — so a free `p0` in the body cannot collide either. --- napi/angular-compiler/e2e/compare/src/compare.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index ca1388365..07acb9b2d 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2387,14 +2387,18 @@ function normalizeWrapperAst(wrapper: NormAstNode): string { protect(node.property) } }) - // Rename the wrapper's own parameter declarations, then rename only the - // references actually bound to them — a nested scope re-declaring the same - // name shadows the parameter and must keep its spelling. + // Rename references bound to the wrapper's parameters before the parameter + // declarations: scope tracking must see the ORIGINAL names, and renaming + // declarations first would let a canonical name (`#p1`) collide with an + // original parameter and corrupt the shadow set. The canonical spellings + // `#p0`, `#p1`, ... can never be real identifiers, so a free `p1` in the + // body cannot collide with a renamed parameter either. + const canonical = (index: number) => `#p${index}` + const active = new Map(paramNames.map((name, index) => [name, canonical(index)])) + if (cloned.body) renameBoundIdentifiers(cloned.body, active, nonReference) for (const [index, param] of ((cloned.params ?? []) as NormAstNode[]).entries()) { - if (param.type === 'Identifier') param.name = `p${index}` + if (param.type === 'Identifier') param.name = canonical(index) } - const active = new Map(paramNames.map((name, index) => [name, `p${index}`])) - renameBoundIdentifiers(cloned, active, nonReference) return normalizeAst(cloned) } From 25a3ff86cc48826a1c2f65e57f09669b9b798661 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 15:49:03 +0800 Subject: [PATCH 10/13] fix(e2e-compare): exclude nested sync calls covered by async wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AST walk extracted `ɵsetClassMetadata` calls nested inside a `ɵsetClassMetadataAsync` wrapper argument as independent entries, so equivalent wrappers differing only in parameter spelling (`(A)` vs `(B)`) produced a spurious `setClassMetadata.decorators` diff even though `wrapperNormalized` already covers the nested call under the wrapper bindings. Nested calls inside the wrapper argument are now marked during the async pass and skipped. --- .../angular-compiler/e2e/compare/src/compare.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 07acb9b2d..1c977107c 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2592,6 +2592,10 @@ function renameBoundIdentifiers( export function extractClassMetadataCalls(program: unknown, code: string): ClassMetadataInfo[] { const results: ClassMetadataInfo[] = [] const coreNamespaces = angularCoreNamespaces(program) + // `ɵsetClassMetadata` calls nested inside an async wrapper are already + // covered by `wrapperNormalized` — extracting them again would compare the + // raw parameter spellings and produce spurious diffs (`A` vs `B`). + const callsInsideAsyncWrappers = new Set() const slice = (node: NormAstNode | undefined): string | undefined => node && typeof node.start === 'number' && typeof node.end === 'number' @@ -2613,13 +2617,24 @@ export function extractClassMetadataCalls(program: unknown, code: string): Class const args = node.arguments as NormAstNode[] | undefined const classArg = args?.[0] if (!args || !classArg) return + + if (callsInsideAsyncWrappers.has(node)) return + const className = classArg.type === 'Identifier' ? (classArg.name as string) : (slice(classArg) ?? '') if (isAsync) { // `ɵsetClassMetadataAsync(Class, resolver, wrapper)` — the wrapper's - // nested sync call is extracted on its own pass. + // nested calls are normalized with the parameter bindings, so mark every + // nested metadata call in the wrapper argument as covered. const wrapper = args[2] + if (wrapper) { + walkAst(wrapper, (nested) => { + if (nested !== wrapper && nested.type === 'CallExpression') { + callsInsideAsyncWrappers.add(nested) + } + }) + } results.push({ className, decorators: 'null', From 442f1b9535b57e926203567bf12d3e197e1d66d0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 16:23:16 +0800 Subject: [PATCH 11/13] fix(e2e-compare): compare metadata call order across classes Per-class grouping made each bucket compare equal even when the two outputs emitted the same calls in a different sequence, and call order is observable when decorator arguments run evaluated expressions. When both outputs hold the same multiset of calls (keyed by class/kind) in a different order, a `setClassMetadata.order` diff is now reported at the first divergent position; differing multisets keep producing only the existing missing/extra diffs. --- .../e2e/compare/src/compare.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 1c977107c..33fb22e16 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2828,6 +2828,32 @@ export function compareClassMetadata( } } + // Call order is observable across classes too: decorator arguments may run + // evaluated expressions, so emitting the same calls in a different sequence + // can change runtime behavior. When both outputs contain the same multiset + // of calls (keyed by class/kind) but in a different order, report it at the + // first divergent position. Same-multiset-only keeps the check meaningful: + // differing lengths are already covered by missing/extra diffs above. + const tsKeys = tsMetadata.map(keyOf) + const oxcKeys = oxcMetadata.map(keyOf) + const tsSorted = [...tsKeys].sort() + const oxcSorted = [...oxcKeys].sort() + if (tsSorted.length === oxcSorted.length && tsSorted.every((k, i) => k === oxcSorted[i])) { + const divergent = tsKeys.findIndex((k, i) => k !== oxcKeys[i]) + if (divergent >= 0) { + const callName = tsKeys[divergent].endsWith('#async') + ? 'setClassMetadataAsync' + : 'setClassMetadata' + diffs.push({ + type: 'different', + className: tsKeys[divergent].replace(/#async$/, ''), + field: `${callName}.order`, + expected: `call #${divergent + 1}: ${tsKeys[divergent].replace(/#async$/, '')}`, + actual: `call #${divergent + 1}: ${oxcKeys[divergent].replace(/#async$/, '')}`, + }) + } + } + return diffs } From 4c84ac9e6f429b4f3ac5eec4ffe0ca375f92949d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 17:10:18 +0800 Subject: [PATCH 12/13] fix(e2e-compare): keep body bindings out of parameter initializer scope Parameter default initializers evaluate in the parameter scope, but the function branch collected body bindings (hoisted vars and top-level block decls) into the shadowed set before traversing the whole node, so a `let A` in the body hid the outer mapping for `(x = A)` and made a free `A` compare equal to a bound one. Params are now traversed with only the function params/id removed, and body bindings apply to the body traversal only. --- .../e2e/compare/src/compare.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 33fb22e16..095e1cd5a 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2516,19 +2516,32 @@ function renameBoundIdentifiers( type === 'FunctionExpression' || type === 'ArrowFunctionExpression' ) { - const shadowed = new Set() - for (const param of (obj.params ?? []) as NormAstNode[]) boundNames(param, shadowed) + // Parameter initializers evaluate in the parameter scope — only the + // function's own params/id shadow there. Body bindings (vars and the + // body's top-level let/const/class/function decls) apply to the body + // only; otherwise a `let A` in the body would hide the outer mapping + // for a default `(x = A)`. + const paramInner = new Map(active) + for (const param of (obj.params ?? []) as NormAstNode[]) { + const names = new Set() + boundNames(param, names) + for (const name of names) paramInner.delete(name) + } const id = obj.id as NormAstNode | undefined - if (id?.type === 'Identifier') shadowed.add(id.name as string) - varDeclNames(obj.body, shadowed) + if (id?.type === 'Identifier') paramInner.delete(id.name as string) + + renameBoundIdentifiers(obj.params, paramInner, nonReference) + + const bodyShadowed = new Set() + varDeclNames(obj.body, bodyShadowed) const body = obj.body as NormAstNode | undefined if (body?.type === 'BlockStatement') { - blockDeclNames(body.body as NormAstNode[], shadowed) - } - if (shadowed.size > 0) { - inner = new Map(active) - for (const name of shadowed) inner.delete(name) + blockDeclNames(body.body as NormAstNode[], bodyShadowed) } + const bodyInner = new Map(paramInner) + for (const name of bodyShadowed) bodyInner.delete(name) + renameBoundIdentifiers(obj.body, bodyInner, nonReference) + return } else if (type === 'SwitchStatement') { // The discriminant is evaluated in the enclosing scope, before the // case-block bindings exist — only the cases see the shadowed names. From 53e2ebd2e45de766a80fc74faac91abf445de911 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 17:30:45 +0800 Subject: [PATCH 13/13] fix(e2e-compare): scope static-block vars to the static block `varDeclNames` recursed into class static blocks, hoisting `var` names there into the enclosing function's shadow set, while the StaticBlock branch never collected vars for its own scope. Static blocks are now treated as their own var scope: `varDeclNames` skips them (and class nodes), and the StaticBlock branch collects its hoisted vars alongside its lexical declarations. --- .../e2e/compare/src/compare.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/src/compare.ts b/napi/angular-compiler/e2e/compare/src/compare.ts index 095e1cd5a..e44dd152f 100644 --- a/napi/angular-compiler/e2e/compare/src/compare.ts +++ b/napi/angular-compiler/e2e/compare/src/compare.ts @@ -2454,7 +2454,9 @@ function blockDeclNames(stmts: NormAstNode[] | undefined, out: Set): voi /** * `var`-bound names anywhere inside a function body — they hoist to the - * function scope. Nested functions are not descended into. + * function scope. Nested functions, classes, and class static blocks are not + * descended into: a `var` in a static block scopes to the block itself, and + * class members can't contain `var` statements. */ function varDeclNames(node: unknown, out: Set): void { if (node === null || typeof node !== 'object') return @@ -2466,7 +2468,10 @@ function varDeclNames(node: unknown, out: Set): void { if ( obj.type === 'FunctionDeclaration' || obj.type === 'FunctionExpression' || - obj.type === 'ArrowFunctionExpression' + obj.type === 'ArrowFunctionExpression' || + obj.type === 'StaticBlock' || + obj.type === 'ClassDeclaration' || + obj.type === 'ClassExpression' ) { return } @@ -2556,7 +2561,17 @@ function renameBoundIdentifiers( } renameBoundIdentifiers(obj.cases, inner, nonReference) return - } else if (type === 'Program' || type === 'BlockStatement' || type === 'StaticBlock') { + } else if (type === 'StaticBlock') { + // A static block is its own `var` scope — collect both its hoisted vars + // and its top-level lexical declarations. + const shadowed = new Set() + varDeclNames(obj.body, shadowed) + blockDeclNames(obj.body as NormAstNode[], shadowed) + if (shadowed.size > 0) { + inner = new Map(active) + for (const name of shadowed) inner.delete(name) + } + } else if (type === 'Program' || type === 'BlockStatement') { const shadowed = new Set() blockDeclNames(obj.body as NormAstNode[], shadowed) if (shadowed.size > 0) {