From 1a9c5a36e229ae43805a0831bb705b0ccae1abe1 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 20:32:24 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(compat-eslint):=20debug=20flag=20?= =?UTF-8?q?=E2=80=94=20list=20converted=20estree=20node=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsslint --debug-estree` (or env TSSLINT_DEBUG_ESTREE=1 for callers outside the CLI) prints, after lint completes, the actual ESTree node types lazy-estree materialised this session and their counts — sorted by count desc, then alphabetical: estree node types (58 kinds, 1,611 nodes) Identifier 296 MemberExpression 124 BlockStatement 118 CallExpression 117 VariableDeclaration 97 ... Useful for: spotting which kinds dominate conversion volume on a real codebase (informs where to focus lazy-estree perf), confirming that lazy materialisation is actually skipping unused subtrees (missing kinds aren't in the table because no rule visited them), and seeing at a glance whether a rule's selector pulled in a surprising amount of TS-only kinds. Counter lives on globalThis under Symbol.for('@tsslint/compat-eslint: node-type-counts'). Reason: a user's project typically resolves @tsslint/compat-eslint against ITS node_modules, while a CLI-side require() lands on the CLI's neighbour copy — different module instances, different Map references. globalThis closes the gap so the CLI sees what the user's instance populated. Cost when off: one boolean check per LazyNode construction, no allocation. Cost when on: one queueMicrotask per construction (need to defer because `readonly type = '...'` field initialisers run AFTER super(), so reading this.type from the base ctor synchronously sees undefined). --- packages/cli/index.ts | 38 +++++++++++++++++++-- packages/compat-eslint/index.ts | 4 +++ packages/compat-eslint/lib/lazy-estree.ts | 41 +++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index a1222dc..2b2c0d5 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -2,6 +2,13 @@ // particular) pound on statSync per file and benefit from in-process caching. require('./lib/fs-cache.js'); +// Env vars for downstream packages must be set BEFORE any import that +// transitively loads them — they're read at module-load time, not on use. +process.env.TSSLINT_CLI = '1'; +if (process.argv.includes('--debug-estree')) { + process.env.TSSLINT_DEBUG_ESTREE = '1'; +} + import ts = require('typescript'); import path = require('path'); import worker = require('./lib/worker.js'); @@ -12,8 +19,6 @@ import languagePlugins = require('./lib/languagePlugins.js'); import colors = require('./lib/colors.js'); import render = require('./lib/render.js'); -process.env.TSSLINT_CLI = '1'; - const HELP = ` Usage: tsslint [options] @@ -29,6 +34,7 @@ Options: --force Ignore cache (re-lint every file) --failures-only Only print errors and messages (skip warnings and suggestions) --list-rules After linting, print each rule's classification (syntactic / type-aware) + --debug-estree After linting, print the actual ESTree node types converted by @tsslint/compat-eslint and their counts -h, --help Show this help message Examples: @@ -362,6 +368,34 @@ const formatHost: ts.FormatDiagnosticsHost = { for (const l of lines) renderer.info(l); } + if (process.argv.includes('--debug-estree')) { + // compat-eslint's lazy-estree publishes a per-`type` counter on + // globalThis under Symbol.for('@tsslint/compat-eslint:node-type-counts') + // when TSSLINT_DEBUG_ESTREE=1 (set above before any import). Reading + // from the shared global side-steps Node's per-package module- + // resolution: the user's project typically loads compat-eslint + // from its OWN node_modules, while a `require()` here would land + // on the CLI's neighbour copy — different module instances, + // different counters. globalThis closes that gap. + const COUNTS_KEY = Symbol.for('@tsslint/compat-eslint:node-type-counts'); + const counts = (globalThis as any)[COUNTS_KEY] as Map | undefined; + const sorted = counts + ? [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + : []; + const total = sorted.reduce((s, [, n]) => s + n, 0); + const widthName = sorted.reduce((w, [n]) => Math.max(w, n.length), 0); + renderer.info( + colors.cyan('estree node types') + + colors.gray(` (${sorted.length} kinds, ${total.toLocaleString()} nodes)`), + ); + for (const [name, n] of sorted) { + renderer.info(' ' + name.padEnd(widthName) + ' ' + colors.gray(n.toLocaleString())); + } + if (!sorted.length) { + renderer.info(colors.gray(' (no nodes converted — no @tsslint/compat-eslint rules ran)')); + } + } + renderer.dispose(); process.exit((errors || messages || configErrors) ? 1 : 0); diff --git a/packages/compat-eslint/index.ts b/packages/compat-eslint/index.ts index 88f8fa1..3b6e0bd 100644 --- a/packages/compat-eslint/index.ts +++ b/packages/compat-eslint/index.ts @@ -3,6 +3,10 @@ import type * as ESLint from 'eslint'; import type * as ts from 'typescript'; import CodePathAnalyzer = require('./lib/code-path-analysis/code-path-analyzer.js'); import { convertLazy } from './lib/lazy-estree'; + +// Debug surface — see lib/lazy-estree.ts. Gated by env TSSLINT_DEBUG_ESTREE=1 +// (set by the CLI's --debug-estree flag, or directly by external callers). +export { getNodeTypeCounts, resetNodeTypeCounts } from './lib/lazy-estree'; import { LazySourceCode } from './lib/lazy-source-code'; import { decomposeSimple, isCodePathListener } from './lib/selector-analysis'; import { convertComments, convertTokens } from './lib/tokens'; diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index 92d77cf..7a8b38d 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -37,6 +37,38 @@ import { xhtmlEntities } from './xhtml-entities'; const SK = ts.SyntaxKind; +// Debug instrumentation: when enabled, every materialised LazyNode bumps +// a per-`type` counter so `--debug-estree` (or callers using the env var +// directly) can dump the actual conversion volume per ESTree node type. +// +// Counter lives on globalThis under a Symbol.for key so all loaded +// compat-eslint instances in the same process share one map. This +// matters because the user's project typically resolves compat-eslint +// against ITS node_modules, while the CLI's --debug-estree handler +// reads the count via its OWN module resolution — different instances, +// same Node process. Without globalThis sharing, the CLI would see an +// empty counter even though linting populated one. +// +// Cost when off: a single boolean check per construction, no allocation. +// Cost when on: one queueMicrotask per construction. We defer the read +// because the subclass's `readonly type = '...'` field is initialised +// AFTER `super(...)` returns — class-property assignments compile to +// constructor body statements that execute post-`super()`. Reading +// `this.type` synchronously here would observe `undefined`. +const DEBUG_ESTREE = process.env.TSSLINT_DEBUG_ESTREE === '1'; +const COUNTS_KEY = Symbol.for('@tsslint/compat-eslint:node-type-counts'); +type GlobalCountsHolder = { [k in typeof COUNTS_KEY]?: Map }; +const _global = globalThis as unknown as GlobalCountsHolder; +const nodeTypeCounts: Map = _global[COUNTS_KEY] ??= new Map(); + +export function getNodeTypeCounts(): ReadonlyMap { + return nodeTypeCounts; +} + +export function resetNodeTypeCounts(): void { + nodeTypeCounts.clear(); +} + export interface LazyAstMaps { // One direction is a real WeakMap (cache for tsNode → lazyNode lookup). // The other is a thin facade that just reads `lazyNode._ts` directly, @@ -131,6 +163,15 @@ abstract class LazyNode { this._ctx.maps.tsNodeToESTreeNodeMap.set(tsNode, this); // esTreeNodeToTSNodeMap is a facade reading _ts — no .set needed. } + if (DEBUG_ESTREE) { + // `this.type` is set by the subclass's `readonly type = '...'` + // field initialiser, which runs AFTER super() returns. Defer to + // the next microtask so the read sees the final value. + queueMicrotask(() => { + const t = (this as unknown as { type: string }).type; + nodeTypeCounts.set(t, (nodeTypeCounts.get(t) ?? 0) + 1); + }); + } } // Extend this node's range to cover `childRange`. Used by parent nodes From 7d3c25e6bf08bb2f83c81746f09d24c42c5e5dbe Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 20:47:19 +0800 Subject: [PATCH 02/10] perf(compat-eslint): skip JsxAttributes in materialise parent walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by --debug-estree on Dify web/ (5867 files, react-x/no-leaked-conditional-rendering): the converter table showed 9,659 TSJsxAttributes nodes — a SyntaxKind that has no real ESTree counterpart. typescript-estree elides the JsxAttributes container and exposes attributes directly via JSXOpeningElement.attributes. In lazy-estree, materialise's bottom-up parent walk wasn't aware of this shape. Walking up from a JsxExpression inside a JSX attribute (``) hit JsxAttributes on the way to JsxOpeningElement, and lazy-estree fell through to GenericTSNode → 'TSJsxAttributes'. The skip list at the top of the parent walk already had SyntaxList, CaseBlock, NamedImports, ImportClause, etc. — JsxAttributes was just missing. Adding the skip: - JSXAttribute.parent now reads as JSXOpeningElement directly (matches typescript-estree shape; nothing produces TSJsxAttributes) - 9,659 fewer GenericTSNode allocations on Dify cold (-4.8% of total materialised node count) - All compat-eslint test suites still pass — predicate coverage, selector analysis, lazy-estree, ts-ast-scan, scope-compat, compat-pipeline (152/152 ESTree types covered). Wall-clock impact in the noise (allocation savings small per node); memory peak drops by ~1.5MB. --- packages/compat-eslint/lib/lazy-estree.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index 7a8b38d..5adc520 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -791,11 +791,20 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { // ImportDeclaration in ESTree (specifiers[]), so a bottom-up // walk from any specifier should land on the ImportDeclaration // wrapper rather than building intermediate generic nodes. + // - JsxAttributes: TS holds the attribute list (between `<` and + // `>`) in a wrapper container, but typescript-estree elides it + // and exposes attributes directly via `JSXOpeningElement.attributes`. + // Without skipping, every {x} attribute value's bottom-up + // materialise creates a phantom TSJsxAttributes (GenericTSNode); + // on Dify web/ that meant ~9,659 wasted allocations / run. + // Skipping aligns the parent chain with the ESTree shape: a + // JSXAttribute's parent is JSXOpeningElement directly. if ( wk === SK.SyntaxList || wk === SK.CaseBlock || wk === SK.NamedImports || wk === SK.ImportClause + || wk === SK.JsxAttributes || (wk === SK.VariableDeclarationList && walker.parent?.kind === SK.VariableStatement) ) { walker = walker.parent; From 5a9e582d54d348a02cf6ee2c28c8e58ad170f066 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 20:59:05 +0800 Subject: [PATCH 03/10] fix(compat-eslint): correct JSX attribute parent for self-closing tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (7d3c25e) skipped JsxAttributes in materialise's parent walk to eliminate phantom 'TSJsxAttributes' nodes — a type that doesn't exist in typescript-estree's shape (attributes go directly on JSXOpeningElement.attributes). The skip alone fixed the parent for non-self-closing tags but introduced a regression for self-closing: bottom-up materialise of an attribute under `` landed `JSXAttribute.parent = JSXElement`, because JsxSelfClosingElement materialises as JSXElement (JSXOpeningElement is synthetic and only exists as a getter). Fix: add a wrapper-route in findWrapperRoute for JsxAttribute / JsxSpreadAttribute when the JsxAttributes container's parent is an opening element (self-closing or not). The route triggers the owner's `attributes` getter (or `openingElement.attributes` for self-closing), which builds the synthetic JSXOpeningElement + attribute children and registers them in the cache. After the trigger fires, materialise's normal cache lookup finds the correctly-parented attribute and returns it. Net materialised node count goes UP slightly on JSX-heavy codebases (Dify: 191k → 203k after both fixes vs 201k baseline) because we now build the synthetic JSXOpeningElement + the JSXAttribute children for self-closing tags. The previous shape was wrong; the new count reflects what eager (typescript-estree) already produces. No more 'TSJsxAttributes' in any output. Tests (7 new bottom-up cases + 1 catch-all walk): - bottom-up from {x} attr value lands JSXAttribute.parent on JSXOpeningElement for both self-closing and non-self-closing - sibling attrs share the same JSXOpeningElement parent (cache) - spread attribute walks correctly under both shapes - JSX child {x} (NOT in attribute) unaffected by the wrapper — pinning that the route doesn't over-fire - nested JSX in attribute value walks each level correctly - whole-tree visitor-keys walk over a comprehensive fixture sees no node with type 'TSJsxAttributes' --- packages/compat-eslint/lib/lazy-estree.ts | 37 +++ .../compat-eslint/test/lazy-estree.test.ts | 223 ++++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index 5adc520..b0f0eb1 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -507,6 +507,43 @@ function findWrapperRoute(tsNode: ts.Node): const tsParent = tsNode.parent; if (!tsParent) return null; + // JsxAttribute / JsxSpreadAttribute: ESTree exposes attributes via + // JSXOpeningElement.attributes — synthetic for JsxSelfClosingElement + // (whose materialise is JSXElement, not JSXOpeningElement). Without a + // route, bottom-up materialise of an attribute under a self-closing tag + // lands `attribute.parent = JSXElement` instead of the synthetic + // JSXOpeningElement that eager produces. Route through the + // owning element's `openingElement.attributes` getter (or directly + // `attributes` for non-self-closing JsxOpeningElement) so the + // JSXAttribute children land with the correct synthetic parent in both + // cases. The TS parent chain is JsxAttribute → JsxAttributes → owning + // element; the JsxAttributes container has no ESTree counterpart and is + // also skipped in materialise's walk-up. + if ( + (tsNode.kind === SK.JsxAttribute || tsNode.kind === SK.JsxSpreadAttribute) + && tsParent.kind === SK.JsxAttributes + && tsParent.parent + ) { + const owner = tsParent.parent; + const ownerKind = owner.kind; + if (ownerKind === SK.JsxOpeningElement || ownerKind === SK.JsxSelfClosingElement) { + return { + ownerTsNode: owner, + trigger: ownerNode => { + if (ownerKind === SK.JsxSelfClosingElement) { + // owner materialises as JSXElement; attributes live on + // its synthetic openingElement. + const opening = (ownerNode as unknown as { openingElement?: { attributes?: unknown } }).openingElement; + if (opening) void opening.attributes; + } + else { + void (ownerNode as unknown as { attributes?: unknown }).attributes; + } + }, + }; + } + } + // JSX: ts.Identifier / ts.PropertyAccessExpression / ts.JsxNamespacedName // sitting on a JSX tag-name path or JsxAttribute name path must // materialize via the parent's JSX-aware getter (which produces diff --git a/packages/compat-eslint/test/lazy-estree.test.ts b/packages/compat-eslint/test/lazy-estree.test.ts index f0d79d6..e5d189c 100644 --- a/packages/compat-eslint/test/lazy-estree.test.ts +++ b/packages/compat-eslint/test/lazy-estree.test.ts @@ -1157,6 +1157,229 @@ runFixture('the no-explicit-any fixture', 'let x: any = 1; function foo(y: any): // - BOM in source — TS parser normalises before we see it; the lazy // layer inherits whatever the SourceFile reports. +// --- Bottom-up materialise: JsxAttributes parent skip -------------------- +// +// `materialize()`'s parent walk treats SK.JsxAttributes as structural +// (skipped, like SyntaxList / NamedImports / CaseBlock). Reason: TS holds +// JSX attributes in a wrapper container between `<` and `>`, but +// typescript-estree exposes them directly via `JSXOpeningElement.attributes` +// — there's no `'TSJsxAttributes'` type in the ESTree shape. Without the +// skip, every `{x}` attribute value's bottom-up materialise minted a +// phantom GenericTSNode (cost: 9,659 wasted allocations on Dify's 5867 +// files for one type-aware rule). +// +// These tests pin the invariants the skip produces, in shapes the existing +// top-down parity cases don't directly exercise: +// - bottom-up materialise of a node inside a `prop={x}` value lands on +// JSXAttribute → JSXOpeningElement (not TSJsxAttributes anywhere) +// - same for self-closing tags +// - sibling attributes' bottom-up walks land on the SAME JSXOpeningElement +// - spread attribute `{...rest}` walks correctly too +// - JSX child `{x}` (NOT inside an attribute) is unaffected — never +// touched JsxAttributes in the first place +// - whole-tree walk after a full lazy build never produces a node whose +// `.type === 'TSJsxAttributes'` + +function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | undefined { + let found: ts.JsxExpression | undefined; + const visit = (n: ts.Node) => { + if (found) return; + if (n.kind === ts.SyntaxKind.JsxExpression && (n as ts.JsxExpression).expression) { + // If attrName given, only match the JsxExpression that's the value of + // a JsxAttribute with matching name. + if (attrName) { + const attr = n.parent; + if (attr?.kind === ts.SyntaxKind.JsxAttribute && (attr as ts.JsxAttribute).name?.getText() === attrName) { + found = n as ts.JsxExpression; + return; + } + } + else { + found = n as ts.JsxExpression; + return; + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + return found; +} + +// ── 1: bottom-up from `{x}` attr value on self-closing element ───────── +{ + const sf = parseTsx('let _ = ;'); + const { context } = lazy.convertLazy(sf); + const tsExpr = findTsJsxExpr(sf, 'prop')!; + const lazyExpr = lazy.materialize(tsExpr, context) as any; + check('bottom-up: JSXExpressionContainer materialised', lazyExpr.type === 'JSXExpressionContainer'); + const attr = lazyExpr.parent; + check('bottom-up: parent is JSXAttribute', attr.type === 'JSXAttribute'); + const opening = attr.parent; + check( + 'bottom-up: JSXAttribute.parent is JSXOpeningElement (not TSJsxAttributes)', + opening.type === 'JSXOpeningElement', + `got: ${opening.type}`, + ); +} + +// ── 2: bottom-up from `{x}` attr value on non-self-closing element ──── +{ + const sf = parseTsx('let _ = child;'); + const { context } = lazy.convertLazy(sf); + const tsExpr = findTsJsxExpr(sf, 'prop')!; + const lazyExpr = lazy.materialize(tsExpr, context) as any; + const opening = lazyExpr.parent.parent; + check( + 'non-self-closing: JSXAttribute.parent is JSXOpeningElement', + opening.type === 'JSXOpeningElement', + `got: ${opening.type}`, + ); +} + +// ── 3: sibling `{a}` and `{b}` walks share the same JSXOpeningElement ── +{ + const sf = parseTsx('let _ = ;'); + const { context } = lazy.convertLazy(sf); + const exprA = findTsJsxExpr(sf, 'p1')!; + const exprB = findTsJsxExpr(sf, 'p2')!; + const lazyA = lazy.materialize(exprA, context) as any; + const lazyB = lazy.materialize(exprB, context) as any; + check( + 'siblings: shared JSXOpeningElement parent (cache hit)', + lazyA.parent.parent === lazyB.parent.parent, + ); + check( + 'siblings: parent type is JSXOpeningElement', + lazyA.parent.parent.type === 'JSXOpeningElement', + ); +} + +// ── 4a: spread attribute `{...rest}` walks correctly (self-closing) ──── +{ + const sf = parseTsx('let _ = ;'); + const { context } = lazy.convertLazy(sf); + // JsxSpreadAttribute is a direct TS-AST kind (not a JsxExpression with + // dotDotDotToken — that one's for JSX child spread `{...arr}`). + let tsSpreadAttr: ts.JsxSpreadAttribute | undefined; + const visitSpreadAttr = (n: ts.Node) => { + if (n.kind === ts.SyntaxKind.JsxSpreadAttribute) { + tsSpreadAttr = n as ts.JsxSpreadAttribute; + } + ts.forEachChild(n, visitSpreadAttr); + }; + visitSpreadAttr(sf); + const lazySpreadAttr = lazy.materialize(tsSpreadAttr!, context) as any; + check('spread (self-closing): JSXSpreadAttribute materialised', lazySpreadAttr.type === 'JSXSpreadAttribute'); + check( + 'spread (self-closing): parent is JSXOpeningElement (not JSXElement)', + lazySpreadAttr.parent.type === 'JSXOpeningElement', + `got: ${lazySpreadAttr.parent.type}`, + ); +} + +// ── 4b: spread attribute on non-self-closing too ─────────────────────── +{ + const sf = parseTsx('let _ = x;'); + const { context } = lazy.convertLazy(sf); + let tsSpreadAttr: ts.JsxSpreadAttribute | undefined; + const visitSpreadAttr = (n: ts.Node) => { + if (n.kind === ts.SyntaxKind.JsxSpreadAttribute) { + tsSpreadAttr = n as ts.JsxSpreadAttribute; + } + ts.forEachChild(n, visitSpreadAttr); + }; + visitSpreadAttr(sf); + const lazySpreadAttr = lazy.materialize(tsSpreadAttr!, context) as any; + check( + 'spread (non-self-closing): parent is JSXOpeningElement', + lazySpreadAttr.parent.type === 'JSXOpeningElement', + `got: ${lazySpreadAttr.parent.type}`, + ); +} + +// ── 5: JSX child `{x}` (not in attribute) walks to JSXElement directly ── +// +// This path never touched JsxAttributes even before the fix, so the +// behaviour should be unchanged. Pin it so we notice if some future refactor +// accidentally makes it depend on the JsxAttributes skip. +{ + const sf = parseTsx('let _ = {x};'); + const { context } = lazy.convertLazy(sf); + let tsChild: ts.JsxExpression | undefined; + const visit = (n: ts.Node) => { + if (n.kind === ts.SyntaxKind.JsxExpression && n.parent?.kind === ts.SyntaxKind.JsxElement) { + tsChild = n as ts.JsxExpression; + } + ts.forEachChild(n, visit); + }; + visit(sf); + const lazyChild = lazy.materialize(tsChild!, context) as any; + check('jsx child {x}: parent is JSXElement', lazyChild.parent.type === 'JSXElement'); +} + +// ── 6: deeply nested JSX `{}` — walk from inner z ───────── +{ + const sf = parseTsx('let _ = } />;'); + const { context } = lazy.convertLazy(sf); + const innerExpr = findTsJsxExpr(sf, 'b')!; + const lazyZ = lazy.materialize(innerExpr, context) as any; + check( + 'nested: inner attr value walks to inner JSXOpeningElement', + lazyZ.parent.parent.type === 'JSXOpeningElement', + `got: ${lazyZ.parent.parent.type}`, + ); + // Also verify the outer attr value chain is correct. + const outerExpr = findTsJsxExpr(sf, 'a')!; + const lazyOuterVal = lazy.materialize(outerExpr, context) as any; + check( + 'nested: outer attr value walks to outer JSXOpeningElement', + lazyOuterVal.parent.parent.type === 'JSXOpeningElement', + ); +} + +// ── 7: whole-tree walk produces no `'TSJsxAttributes'` node ──────────── +// +// Catch-all. After a full convertLazy + visit-everything pass over a JSX +// fixture exercising every attribute shape, no materialised node should +// have `type === 'TSJsxAttributes'`. Prevents regression if someone reverts +// the JsxAttributes line in the skip list. visitorKeys drives the walk so +// that lazy getters (openingElement / attributes / value / etc.) actually +// fire — Object.keys() alone misses them. +{ + const sf = parseTsx( + 'let _ = } />;', + ); + const { estree } = lazy.convertLazy(sf); + const visitorKeys = require('../lib/visitor-keys.js') as { visitorKeys: Record }; + const seenTypes = new Set(); + const seen = new WeakSet(); + const walk = (n: any) => { + if (n == null || typeof n !== 'object') return; + if (seen.has(n)) return; + seen.add(n); + if (typeof n.type === 'string') seenTypes.add(n.type); + const keys = visitorKeys.visitorKeys[n.type]; + if (!keys) return; + for (const k of keys) { + const v = n[k]; + if (Array.isArray(v)) for (const x of v) walk(x); + else walk(v); + } + }; + walk(estree); + check( + 'no TSJsxAttributes anywhere in the tree', + !seenTypes.has('TSJsxAttributes'), + `saw: ${[...seenTypes].filter(t => t.startsWith('TS')).join(', ')}`, + ); + check( + 'saw JSXOpeningElement (sanity)', + seenTypes.has('JSXOpeningElement'), + `seen: ${[...seenTypes].sort().join(', ')}`, + ); + check('saw JSXSpreadAttribute (sanity)', seenTypes.has('JSXSpreadAttribute')); +} + // --- Done --------------------------------------------------------------- console.log(`\n${failures.length === 0 ? 'all pass' : `${failures.length} FAILED`}`); From c1ccffe22dc42160e0ed77973b146c56b48cec9f Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 20:59:30 +0800 Subject: [PATCH 04/10] test(compat-eslint): cover --debug-estree counter behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter shipped in 1a9c5a3 had no direct tests — the visible output was only inspected by hand against tsslint dogfood + Dify. Pin the four invariants via spawned child processes (env var is read at module load, so toggling in-process isn't workable): - off-path: TSSLINT_DEBUG_ESTREE unset → counter stays empty (boolean-check-only fast path; no allocations) - on-path: counter populates with the expected node types (Program / JSXOpeningElement / JSXAttribute) and notably does NOT contain 'TSJsxAttributes' — pins the parent-skip invariant from 5a9e582 from the counter side too - globalThis sharing: a second require() (separate module instance, mimicking project vs CLI module-resolution split) sees the same populated map via Symbol.for(...) global key - resetNodeTypeCounts(): clears the map between sessions Each child driver triggers a visitor-keys walk over the produced ESTree so getters fire (otherwise convertLazy alone only counts the eager Program node). Counts are read on the next microtask so deferred queueMicrotask bumps in LazyNode's ctor have landed. --- .../compat-eslint/test/lazy-estree.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/packages/compat-eslint/test/lazy-estree.test.ts b/packages/compat-eslint/test/lazy-estree.test.ts index e5d189c..ee92bd9 100644 --- a/packages/compat-eslint/test/lazy-estree.test.ts +++ b/packages/compat-eslint/test/lazy-estree.test.ts @@ -1380,6 +1380,129 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | check('saw JSXSpreadAttribute (sanity)', seenTypes.has('JSXSpreadAttribute')); } +// --- Debug-estree counter (env-gated globalThis instrumentation) ------- +// +// Counter is gated by env TSSLINT_DEBUG_ESTREE=1 (read at module load). +// To exercise both the off and on paths cleanly, spawn a child Node +// process with the desired env, run a small inline script that builds a +// LazyNode, and print the resulting counter via getNodeTypeCounts(). + +const { spawnSync } = require('child_process') as typeof import('child_process'); +const repoCompatEslint = require.resolve('../index.js'); + +function runChild(env: NodeJS.ProcessEnv, code: string): { stdout: string; stderr: string; status: number } { + const r = spawnSync(process.execPath, ['-e', code], { + env: { ...process.env, ...env }, + encoding: 'utf8', + }); + return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? -1 }; +} + +// Inline driver: load lazy, build the source file, force materialisation +// of the JSX subtree by reading getters (otherwise the counter only sees +// the eager Program node), then read getNodeTypeCounts on the next +// microtask so the deferred queueMicrotask bumps have landed. +const childCommon = ` +const ts = require('typescript'); +const lazy = require(${JSON.stringify(repoCompatEslint.replace(/index\.js$/, 'lib/lazy-estree.js'))}); +const visitorKeys = require(${JSON.stringify(repoCompatEslint.replace(/index\.js$/, 'lib/visitor-keys.js'))}).visitorKeys; +const sf = ts.createSourceFile('/t.tsx', 'let _ = ;', ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const { estree } = lazy.convertLazy(sf); +const seen = new WeakSet(); +function walk(n) { + if (n == null || typeof n !== 'object' || seen.has(n)) return; + seen.add(n); + const keys = visitorKeys[n.type]; + if (!keys) return; + for (const k of keys) { + const v = n[k]; + if (Array.isArray(v)) for (const x of v) walk(x); + else walk(v); + } +} +walk(estree); +Promise.resolve().then(() => { + const counts = lazy.getNodeTypeCounts(); + process.stdout.write(JSON.stringify([...counts.entries()].sort()) + '\\n'); +}); +`; + +// ── 8: env unset → counter empty ─────────────────────────────────────── +{ + const r = runChild({ TSSLINT_DEBUG_ESTREE: '' }, childCommon); + check('off-path: child exited 0', r.status === 0, r.stderr); + check('off-path: counter empty', r.stdout.trim() === '[]', `got: ${r.stdout.trim()}`); +} + +// ── 9: env set → counter populated ───────────────────────────────────── +{ + const r = runChild({ TSSLINT_DEBUG_ESTREE: '1' }, childCommon); + check('on-path: child exited 0', r.status === 0, r.stderr); + const parsed = JSON.parse(r.stdout.trim()) as Array<[string, number]>; + const map = new Map(parsed); + check('on-path: counter has Program', (map.get('Program') ?? 0) >= 1); + check('on-path: counter has JSXOpeningElement', (map.get('JSXOpeningElement') ?? 0) >= 1); + check('on-path: counter has JSXAttribute', (map.get('JSXAttribute') ?? 0) >= 1); + check( + 'on-path: counter has NO TSJsxAttributes (skip works)', + !map.has('TSJsxAttributes'), + `got TS-prefixed: ${parsed.filter(([t]) => t.startsWith('TS')).map(([t, n]) => `${t}=${n}`).join(', ')}`, + ); +} + +// ── 10: globalThis-shared counter — second require() instance sees it ── +{ + const childTwoInstances = ` +const ts = require('typescript'); +// Load lazy-estree TWICE via different require paths to mimic the +// CLI-vs-project module-resolution split. delete from cache between +// to force a fresh module instance, then check that counts populated by +// the first instance are visible from the second's getNodeTypeCounts(). +const path1 = ${JSON.stringify(repoCompatEslint.replace(/index\.js$/, 'lib/lazy-estree.js'))}; +const lazy1 = require(path1); +const sf = ts.createSourceFile('/t.tsx', 'let _ = ;', ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +lazy1.convertLazy(sf); +delete require.cache[require.resolve(path1)]; +const lazy2 = require(path1); +Promise.resolve().then(() => { + const c1 = lazy1.getNodeTypeCounts(); + const c2 = lazy2.getNodeTypeCounts(); + // Same Map instance backing both — both should report identical entries. + process.stdout.write(JSON.stringify({ + sameSize: c1.size === c2.size, + sameProgramCount: c1.get('Program') === c2.get('Program'), + nonEmpty: c2.size > 0, + }) + '\\n'); +}); +`; + const r = runChild({ TSSLINT_DEBUG_ESTREE: '1' }, childTwoInstances); + check('shared-counter: child exited 0', r.status === 0, r.stderr); + const parsed = JSON.parse(r.stdout.trim()) as { sameSize: boolean; sameProgramCount: boolean; nonEmpty: boolean }; + check('shared-counter: second instance sees populated counter', parsed.nonEmpty); + check('shared-counter: both instances see same map size', parsed.sameSize); + check('shared-counter: both instances see same Program count', parsed.sameProgramCount); +} + +// ── 11: resetNodeTypeCounts() clears the counter ─────────────────────── +{ + const code = ` +const ts = require('typescript'); +const lazy = require(${JSON.stringify(repoCompatEslint.replace(/index\.js$/, 'lib/lazy-estree.js'))}); +lazy.convertLazy(ts.createSourceFile('/t.tsx', 'let _ = ;', ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)); +Promise.resolve().then(() => { + const before = lazy.getNodeTypeCounts().size; + lazy.resetNodeTypeCounts(); + const after = lazy.getNodeTypeCounts().size; + process.stdout.write(JSON.stringify({ before, after }) + '\\n'); +}); +`; + const r = runChild({ TSSLINT_DEBUG_ESTREE: '1' }, code); + check('reset: child exited 0', r.status === 0, r.stderr); + const { before, after } = JSON.parse(r.stdout.trim()); + check('reset: before non-empty', before > 0); + check('reset: after empty', after === 0); +} + // --- Done --------------------------------------------------------------- console.log(`\n${failures.length === 0 ? 'all pass' : `${failures.length} FAILED`}`); From 56e6c7a9c5684c95f2f4a8a17158330a3d382afd Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 22:06:12 +0800 Subject: [PATCH 05/10] test(compat-eslint): bottom-up parity sweep + phantom-types invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the test suite against the bug class fixed earlier in this PR (JSXAttribute.parent corruption via phantom TSJsxAttributes). Master's tests had three blind spots: 1. lazy-estree.test's `compare()` walks tree TOP-DOWN through child getters, where parent is set correctly even on master. Bottom-up `materialise(tsNode)` (what tsScanTraverse does on selector match) was never exercised. The function also explicitly skips the `parent` field — so even top-down parent-chain corruption would not have failed parity. 2. compat-pipeline's JSXAttribute listener pushed `attr:${n.name}` events but never read `n.parent`, leaving the listener-API parent contract unverified. 3. Nothing checked the invariant "no node has a 'TS' type for kinds typescript-estree elides" — the property the TSJsxAttributes regression directly violated. Three new test blocks address each: Bottom-up parity sweep (lazy-estree.test): 9 JSX-attribute fixtures (self-closing, non-self-closing, spread, sibling attrs, multi-attr mixed, fragment, nested attr value, deeply nested), each walks every TS node, runs `lazy.materialise(tsNode, ctx)`, and compares the resulting type / parent.type / parent.parent.type against eager's `astMaps.tsNodeToESTreeNodeMap` lookup. Eager parents are stitched ourselves (typescript-estree's astConverter doesn't set parent — ESLint's SourceCode does that downstream). 117 type asserts + 108 parent asserts on the fix branch; 7 unique mismatches on master. Phantom-types invariant (lazy-estree.test): 5 comprehensive fixtures (jsx-attrs-everything, ts-everything, imports-everything, patterns- everything, jsx-fragment-mix), each walks via visitor-keys and ALSO bottom-up materialises every TS node that has an eager counterpart. Asserts no produced type starts with 'TS' AND isn't in typescript- estree's published TS-* type list. The published list is hard-coded inline (~75 entries) so the test fails immediately if a future PR-added GenericTSNode fallback emits a name not in eager's spec. JSX listener parent assertion (compat-pipeline): test #7's JSXAttribute / JSXSpreadAttribute listeners now also assert `parents[0] === 'JSXOpeningElement'` — the contract any jsx-a11y / react/jsx-* rule relies on. The contract is correct in spec; whether each listener path independently fails on master depends on whether sibling listeners pre-warm the cache top-down, but the assert still locks the contract from inside the actual ESLint listener API surface. Out of scope (real but unrelated impedance with eager that the broad sweep also surfaces — left as separate follow-ups): - export wrapper identity (lazy maps the TS node to ExportNamed/ DefaultWrapper, eager maps to the inner declaration) - chain-expression wrapping for `a?.b?.c` - destructuring with defaults - CatchClause param lifted from ts.VariableDeclaration shim - TSStringKeyword / TSVoidKeyword direct-on-Signature --- .../test/compat-pipeline.test.ts | 40 +- .../compat-eslint/test/lazy-estree.test.ts | 356 ++++++++++++++++++ 2 files changed, 394 insertions(+), 2 deletions(-) diff --git a/packages/compat-eslint/test/compat-pipeline.test.ts b/packages/compat-eslint/test/compat-pipeline.test.ts index e7e5b48..9ff07d1 100644 --- a/packages/compat-eslint/test/compat-pipeline.test.ts +++ b/packages/compat-eslint/test/compat-pipeline.test.ts @@ -1981,10 +1981,36 @@ type D = Awaited>; 'JSX: JSXAttribute listener fires on `id="root"`', calls.some(c => c.selector === 'JSXAttribute'), ); + // Parent chain via bottom-up materialise: the master regression had + // JSXAttribute.parent = 'TSJsxAttributes' for the non-self-closing case + // and = 'JSXElement' for self-closing. The contract is that the parent + // is JSXOpeningElement (synthetic for self-closing) — typescript-estree + // shape that JSX rules rely on. Pin it from inside the listener path + // so any future regression to the parent chain fires here too. + { + // Fixture has exactly one JSXAttribute (`id="root"`), so first hit. + const attrCall = calls.find(c => c.selector === 'JSXAttribute'); + check( + 'JSX: JSXAttribute.parent === JSXOpeningElement (bottom-up via listener)', + attrCall?.parents[0] === 'JSXOpeningElement', + `got parents: ${JSON.stringify(attrCall?.parents.slice(0, 3))}`, + ); + } check( 'JSX: JSXSpreadAttribute listener fires on `{...rest}`', calls.filter(c => c.selector === 'JSXSpreadAttribute').length === 1, ); + // Same parent-chain pin for spread-attribute — different lazy-estree + // path (JsxSpreadAttribute is a TS kind, not a wrapped expression), + // so verify independently. + { + const spread = calls.find(c => c.selector === 'JSXSpreadAttribute'); + check( + 'JSX: JSXSpreadAttribute.parent === JSXOpeningElement', + spread?.parents[0] === 'JSXOpeningElement', + `got: ${JSON.stringify(spread?.parents.slice(0, 3))}`, + ); + } check( 'JSX: JSXExpressionContainer listener fires on `{count && ...}`', calls.some(c => c.selector === 'JSXExpressionContainer'), @@ -2052,7 +2078,13 @@ type D = Awaited>; events.push(`open:${n.name?.name ?? '?'}`); }, JSXAttribute(n: any) { - events.push(`attr:${n.name?.name ?? '?'}`); + // Read parent.type — the field that the TSJsxAttributes + // regression silently corrupted. Real ESLint plugins + // (jsx-a11y, react/jsx-*) read this constantly to scope + // rules to the enclosing tag. Asserting it here exercises + // the bottom-up materialise path with parent semantics + // from inside the actual ESLint API surface. + events.push(`attr:${n.name?.name ?? '?'}:parent=${n.parent?.type ?? 'NONE'}`); }, }; }, @@ -2071,7 +2103,11 @@ type D = Awaited>; events.includes('open:div') && events.includes('open:span'), `events: ${events.filter(e => e.startsWith('open:')).join(',')}`, ); - check('JSX+CPA: JSXAttribute fires on `id="x"`', events.includes('attr:id')); + check( + 'JSX+CPA: JSXAttribute fires on `id="x"` with parent=JSXOpeningElement', + events.includes('attr:id:parent=JSXOpeningElement'), + `events: ${events.filter(e => e.startsWith('attr:')).join(',')}`, + ); } // 9. JSX rule with real esquery selector + report mechanism — mirrors diff --git a/packages/compat-eslint/test/lazy-estree.test.ts b/packages/compat-eslint/test/lazy-estree.test.ts index ee92bd9..5b352d1 100644 --- a/packages/compat-eslint/test/lazy-estree.test.ts +++ b/packages/compat-eslint/test/lazy-estree.test.ts @@ -1380,6 +1380,362 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | check('saw JSXSpreadAttribute (sanity)', seenTypes.has('JSXSpreadAttribute')); } +// --- Bottom-up parity sweep --------------------------------------------- +// +// The existing `compare()` walks the lazy + eager trees TOP-DOWN through +// child getters. That path is already correct on master because each +// child getter sets `parent = this` directly. The TSJsxAttributes / +// JSXAttribute.parent regression caught by this PR only fires on +// BOTTOM-UP `materialise(tsNode)` (what tsScanTraverse does when an +// ESLint listener selector matches a node deep in the tree). No master +// test exercised that path's parent semantics — the parity test even +// explicitly skips the `parent` field. +// +// Hardening: walk every TS node in a curated set of fixtures, call +// `lazy.materialise(tsNode, ctx)` for each, and compare its type / +// parent.type / parent.parent.type against eager's `astMaps. +// tsNodeToESTreeNodeMap[tsNode]`. Any divergence means the bottom-up +// build produced a shape that doesn't match what eager (and rule +// authors) expect. Also asserts no node has type 'TS' for +// any TS-only kind that typescript-estree elides. +{ + const visitorKeysModule = require('../lib/visitor-keys.js') as { visitorKeys: Record }; + const VK = visitorKeysModule.visitorKeys; + + // astConverter returns nodes WITHOUT `.parent` set — parent is normally + // stitched by ESLint's SourceCode ctor downstream. Walk top-down and + // set it ourselves so we can compare lazy's parent chain against it. + function setEagerParents(root: any): void { + const stack: Array<{ node: any; parent: any }> = [{ node: root, parent: null }]; + while (stack.length) { + const { node, parent } = stack.pop()!; + if (node == null || typeof node !== 'object') continue; + if (Array.isArray(node)) { + for (const c of node) stack.push({ node: c, parent }); + continue; + } + node.parent = parent; + const keys = VK[node.type]; + if (!keys) continue; + for (const k of keys) { + const v = node[k]; + if (v == null) continue; + if (Array.isArray(v)) for (const c of v) stack.push({ node: c, parent: node }); + else stack.push({ node: v, parent: node }); + } + } + } + + const eagerWithMaps = (sf: ts.SourceFile) => { + const r = (astConverter as any)(sf, PARSE_SETTINGS as any, true) as { + estree: any; + astMaps: { tsNodeToESTreeNodeMap: { get(n: ts.Node): any } }; + }; + setEagerParents(r.estree); + return r; + }; + + interface Fixture { name: string; code: string; tsx?: boolean } + // Scope: JSX attribute parent class — the bug fixed by this PR. + // Both self-closing and non-self-closing variants because the synthetic + // JSXOpeningElement wrapping for self-closing was the actual regression + // that kicked off the wrapper-route work. + // + // NOT in scope (intentionally — surfaces real impedance with eager but + // requires its own follow-up PRs to address): + // - export wrapper identity (lazy maps the TS node to ExportNamed/ + // DefaultWrapper, eager maps to the inner declaration) + // - chain-expression wrapping for `a?.b?.c` (lazy returns the inner + // MemberExpression on its own; eager wraps in ChainExpression) + // - destructuring with defaults (AssignmentPattern lives at a + // different level between lazy + eager) + // - CatchClause param lifted from ts.VariableDeclaration shim + // - TSStringKeyword/TSVoidKeyword direct-on-Signature (lazy skips + // the TSTypeAnnotation wrapper at this position) + const fixtures: Fixture[] = [ + { name: 'jsx-self-closing-expr-attr', code: 'let _ = ;', tsx: true }, + { name: 'jsx-self-closing-string-attr', code: 'let _ = ;', tsx: true }, + { name: 'jsx-non-self-closing-expr-attr', code: 'let _ = c;', tsx: true }, + { name: 'jsx-spread-attr-self-closing', code: 'let _ = ;', tsx: true }, + { name: 'jsx-spread-attr-non-self-closing', code: 'let _ = c;', tsx: true }, + { name: 'jsx-multi-attr-mixed', code: 'let _ = ;', tsx: true }, + { name: 'jsx-fragment', code: 'let _ = <>;', tsx: true }, + { name: 'jsx-nested-attr-element', code: 'let _ = {};', tsx: true }, + { name: 'jsx-deeply-nested', code: 'let _ = ;', tsx: true }, + ]; + + let totalNodesChecked = 0; + let totalParentsChecked = 0; + const fixtureFailures: Array<[string, string]> = []; + + function bottomUpParity(fx: Fixture) { + const parse = fx.tsx ? parseTsx : parseTs; + const sf = parse(fx.code); + // Eager and lazy share the same parsed source file (and its + // setParentNodes-set parent pointers) so ts.Node identity matches + // across both maps. + let eagerMap: { get(n: ts.Node): any }; + try { + eagerMap = eagerWithMaps(sf).astMaps.tsNodeToESTreeNodeMap; + } + catch (err) { + fixtureFailures.push([fx.name, `eager threw: ${(err as Error).message}`]); + return; + } + const { context: ctx } = lazy.convertLazy(sf); + + const localFails: string[] = []; + const visit = (n: ts.Node) => { + const eager = eagerMap.get(n); + if (eager) { + let lazyNode: any; + try { + lazyNode = lazy.materialize(n, ctx); + } + catch (err) { + localFails.push(`materialise(${ts.SyntaxKind[n.kind]}) threw: ${(err as Error).message}`); + return; + } + totalNodesChecked++; + if (lazyNode.type !== eager.type) { + localFails.push( + `type ${lazyNode.type} vs eager ${eager.type} (TS kind: ${ts.SyntaxKind[n.kind]})`, + ); + } + // Compare parent.type when both sides have parents (Program / + // root has none on either side). + if (eager.parent || lazyNode.parent) { + totalParentsChecked++; + const eagerPType: string | undefined = eager.parent?.type; + const lazyPType: string | undefined = lazyNode.parent?.type; + if (eagerPType !== lazyPType) { + localFails.push( + `${lazyNode.type}.parent.type ${JSON.stringify(lazyPType)} vs eager ${JSON.stringify(eagerPType)}`, + ); + } + // Also compare grandparent.type — catches mid-chain insertions + // like the original 'TSJsxAttributes between attribute and + // opening element' bug, which had correct parent.parent end + // but wrong intermediate type for non-self-closing tags. + if (eager.parent?.parent || lazyNode.parent?.parent) { + const eagerGType: string | undefined = eager.parent?.parent?.type; + const lazyGType: string | undefined = lazyNode.parent?.parent?.type; + if (eagerGType !== lazyGType) { + localFails.push( + `${lazyNode.type}.parent.parent.type ${JSON.stringify(lazyGType)} vs eager ${JSON.stringify(eagerGType)}`, + ); + } + } + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + + if (localFails.length) { + // Dedupe — many TS nodes hit the same parent so identical errors + // repeat. Surface unique reasons only. + const unique = [...new Set(localFails)]; + for (const f of unique.slice(0, 5)) { + fixtureFailures.push([fx.name, f]); + } + if (unique.length > 5) { + fixtureFailures.push([fx.name, `... and ${unique.length - 5} more`]); + } + } + } + + for (const fx of fixtures) { + bottomUpParity(fx); + } + check( + `bottom-up parity sweep: ${fixtures.length} fixtures, ${totalNodesChecked} type asserts, ${totalParentsChecked} parent asserts`, + fixtureFailures.length === 0, + fixtureFailures.length + ? '\n ' + fixtureFailures.map(([n, m]) => `[${n}] ${m}`).join('\n ') + : undefined, + ); +} + +// --- No phantom GenericTSNode types in any fixture's reachable tree ----- +// +// typescript-estree elides several TS-only container kinds (JsxAttributes, +// SyntaxList, NamedImports, etc.) — their ESTree shape exposes the inner +// content directly on the parent. When lazy-estree's parent-walk skip list +// is missing one of these kinds, materialise falls back to GenericTSNode +// → type='TS'. typescript-estree never produces these, so any +// such type appearing in our reachable output is automatically wrong. +// +// Walk a comprehensive fixture via visitor-keys (forces all getters), +// also bottom-up materialise every TS node, then assert no type starts +// with 'TS' AND isn't in the typescript-estree spec list. Catches new +// missing-skip cases as soon as they appear. +{ + const visitorKeys = require('../lib/visitor-keys.js') as { visitorKeys: Record }; + + // typescript-estree's published TS-* node types. Anything starting + // with 'TS' that ISN'T here is a phantom GenericTSNode. + const TYPESCRIPT_ESTREE_TS_TYPES = new Set([ + 'TSAbstractAccessorProperty', + 'TSAbstractKeyword', + 'TSAbstractMethodDefinition', + 'TSAbstractPropertyDefinition', + 'TSAnyKeyword', + 'TSArrayType', + 'TSAsExpression', + 'TSAsyncKeyword', + 'TSBigIntKeyword', + 'TSBooleanKeyword', + 'TSCallSignatureDeclaration', + 'TSClassImplements', + 'TSConditionalType', + 'TSConstructSignatureDeclaration', + 'TSConstructorType', + 'TSDeclareFunction', + 'TSDeclareKeyword', + 'TSEmptyBodyFunctionExpression', + 'TSEnumBody', + 'TSEnumDeclaration', + 'TSEnumMember', + 'TSExportAssignment', + 'TSExportKeyword', + 'TSExternalModuleReference', + 'TSFunctionType', + 'TSImportEqualsDeclaration', + 'TSImportType', + 'TSIndexSignature', + 'TSIndexedAccessType', + 'TSInferType', + 'TSInstantiationExpression', + 'TSInterfaceBody', + 'TSInterfaceDeclaration', + 'TSInterfaceHeritage', + 'TSIntersectionType', + 'TSIntrinsicKeyword', + 'TSLiteralType', + 'TSMappedType', + 'TSMethodSignature', + 'TSModuleBlock', + 'TSModuleDeclaration', + 'TSNamedTupleMember', + 'TSNamespaceExportDeclaration', + 'TSNeverKeyword', + 'TSNonNullExpression', + 'TSNullKeyword', + 'TSNumberKeyword', + 'TSObjectKeyword', + 'TSOptionalType', + 'TSParameterProperty', + 'TSPrivateKeyword', + 'TSPropertySignature', + 'TSProtectedKeyword', + 'TSPublicKeyword', + 'TSQualifiedName', + 'TSReadonlyKeyword', + 'TSRestType', + 'TSSatisfiesExpression', + 'TSStaticKeyword', + 'TSStringKeyword', + 'TSSymbolKeyword', + 'TSTemplateLiteralType', + 'TSThisType', + 'TSTupleType', + 'TSTypeAliasDeclaration', + 'TSTypeAnnotation', + 'TSTypeAssertion', + 'TSTypeLiteral', + 'TSTypeOperator', + 'TSTypeParameter', + 'TSTypeParameterDeclaration', + 'TSTypeParameterInstantiation', + 'TSTypePredicate', + 'TSTypeQuery', + 'TSTypeReference', + 'TSUndefinedKeyword', + 'TSUnionType', + 'TSUnknownKeyword', + 'TSVoidKeyword', + ]); + + const fixtures: Array<{ name: string; code: string; tsx?: boolean }> = [ + { name: 'jsx-attrs-everything', code: 'let _ = } />;', tsx: true }, + { name: 'ts-everything', code: 'interface I { a: T; b(): void; readonly c: string[]; } class C> implements I { d!: U; e?: () => void; constructor(public f: number) {} }' }, + { name: 'imports-everything', code: "import d, { a, b as c, type t } from 'm'; import * as ns from 'n';" }, + { name: 'patterns-everything', code: 'function f({ a, b: { c = 1, ...inner }, ...rest }: any, [x, , ...ys]: any[]) {}' }, + { name: 'jsx-fragment-mix', code: 'let _ = <>{y};', tsx: true }, + ]; + + const offenders: Array<{ fixture: string; type: string; reachedVia: string }> = []; + + for (const fx of fixtures) { + const parse = fx.tsx ? parseTsx : parseTs; + const sf = parse(fx.code); + const { estree, context: ctx } = lazy.convertLazy(sf); + const seen = new WeakSet(); + // Pass 1: top-down via visitor-keys (forces all child getters). + const topDownWalk = (n: any, parentType?: string) => { + if (n == null || typeof n !== 'object' || seen.has(n)) return; + seen.add(n); + if (typeof n.type === 'string' && n.type.startsWith('TS') && !TYPESCRIPT_ESTREE_TS_TYPES.has(n.type)) { + offenders.push({ fixture: fx.name, type: n.type, reachedVia: `top-down via ${parentType ?? 'root'}` }); + } + const keys = visitorKeys.visitorKeys[n.type]; + if (!keys) return; + for (const k of keys) { + const v = n[k]; + if (Array.isArray(v)) for (const x of v) topDownWalk(x, n.type); + else topDownWalk(v, n.type); + } + }; + topDownWalk(estree); + // Pass 2: bottom-up materialise — but ONLY for TS nodes that have + // an eager counterpart. ESLint listeners can only select on real + // ESTree-emitting positions; tsScanTraverse only matches via + // predicates which don't include structural-only TS kinds (tokens, + // SyntaxList, NamedImports, JsxAttributes container, etc.). + // Calling materialize() on those structural kinds always falls + // back to GenericTSNode — but no production code path reaches + // them, so they're not actually in any user-visible tree. + const eagerMap = (() => { + try { return (astConverter as any)(sf, PARSE_SETTINGS as any, true).astMaps.tsNodeToESTreeNodeMap; } + catch { return null; } + })(); + if (eagerMap) { + const tsVisit = (n: ts.Node) => { + if (eagerMap.get(n)) { + let lazyNode: any; + try { + lazyNode = lazy.materialize(n, ctx); + } + catch { + ts.forEachChild(n, tsVisit); + return; + } + let cur = lazyNode; + while (cur) { + if (typeof cur.type === 'string' && cur.type.startsWith('TS') && !TYPESCRIPT_ESTREE_TS_TYPES.has(cur.type)) { + offenders.push({ fixture: fx.name, type: cur.type, reachedVia: `bottom-up from ${ts.SyntaxKind[n.kind]}` }); + break; + } + cur = cur.parent; + } + } + ts.forEachChild(n, tsVisit); + }; + tsVisit(sf); + } + } + + const uniqueOffenders = [...new Map(offenders.map(o => [`${o.fixture}\0${o.type}`, o])).values()]; + check( + `no phantom 'TS' types in any fixture (${fixtures.length} fixtures)`, + uniqueOffenders.length === 0, + uniqueOffenders.length + ? '\n ' + uniqueOffenders.map(o => `[${o.fixture}] ${o.type} (${o.reachedVia})`).join('\n ') + : undefined, + ); +} + // --- Debug-estree counter (env-gated globalThis instrumentation) ------- // // Counter is gated by env TSSLINT_DEBUG_ESTREE=1 (read at module load). From aec50a9935138bb6a6ac6653aab620ef6a999d9d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 22:29:48 +0800 Subject: [PATCH 06/10] fix(compat-eslint): close out 5 known parity gaps + broaden sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last commit's bottom-up parity sweep was deliberately scoped to JSX attribute fixtures because the broader fixture set surfaced 5 real impedance mismatches against typescript-estree that needed their own fixes. This commit closes them all out and expands the sweep to exercise each: 3 lib changes (real bugs): - PropertySignature/MethodSignature/CallSignature/ConstructSignature/ IndexSignature/FunctionType/ConstructorType added to WRAPPER_ROUTE_PARENT_BITMAP, with corresponding findWrapperRoute cases that route through `typeAnnotation` / `returnType` getters. Fixes TSStringKeyword/TSVoidKeyword/etc. landing directly on the Signature parent instead of the synthetic TSTypeAnnotation wrapper that typescript-estree always emits. - VariableDeclaration-inside-CatchClause added to materialise's walk-up skip list (mirrors existing tsScanTraverse skip). TS wraps the catch param in a VariableDeclaration shim that ESTree elides — `CatchClause.param` is the Identifier directly. Without the skip, bottom-up materialise of the param landed `parent` on a phantom VariableDeclarator → VariableDeclaration chain. - The shorthand-with-default destructure drill (existing logic for `{ x = 1 } = o` lifting Identifier.parent to AssignmentPattern) now also fires when the innermost child is the BindingElement's initializer (the default value), not just the name. Without this, the default-value Literal landed on Property directly, skipping the AssignmentPattern wrapper between. 2 test-side equivalences (eager quirks, not lazy bugs): - Export wrappers: lazy maps `ts.Statement` (when exported) to ExportNamed/DefaultDeclaration; eager maps to the inner declaration. The user-visible parent chain (inner.parent === wrapper, wrapper.parent === Program) matches on both sides. Drill into `lazyNode.declaration` to align comparison. - Optional-chain scaffolding: typescript-estree emits one ChainExpression per optional level, but only the outermost is structurally in the tree (setEagerParents only stitches reachable nodes). Skip rows where eager.parent is null AND eager.type !== 'Program' — those are off-tree scaffolding mappings with no position to compare against. Captures the same idea for shorthand-with-default Identifier (one TS token, two ESTree positions: Property.key vs AssignmentPattern.left). Sweep grew from 9 → 15 fixtures (export-named-const, export-default-fn, optional-chain-call, destructure-defaults, try-catch-param, ts-interface- keyword-signature added). 169 type asserts + 154 parent asserts, all clean. Removed the "out of scope" comment since the items it deferred are now covered. Other compat-eslint suites still pass: 152/152 predicate coverage, 24/24 scope-compat fixtures clean, all ts-ast-scan / selector-analysis / compat-pipeline cases. self-lint clean across all 6 packages. --- packages/compat-eslint/lib/lazy-estree.ts | 69 ++++++++++++++- .../compat-eslint/test/lazy-estree.test.ts | 84 ++++++++++++++----- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index b0f0eb1..d40941e 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -313,6 +313,19 @@ const WRAPPER_ROUTE_PARENT_BITMAP = (() => { SK.FunctionDeclaration, SK.FunctionExpression, SK.ArrowFunction, + // Signature-kind parents whose `.type` slot is wrapped in a + // synthetic TSTypeAnnotation in the ESTree shape (typescript- + // estree always emits the wrapper). Without these, bottom-up + // materialise of an inner type kind lands `parent` directly on + // TSPropertySignature / TSMethodSignature / etc. — missing the + // wrapper layer rules expect to walk through. + SK.PropertySignature, + SK.MethodSignature, + SK.CallSignature, + SK.ConstructSignature, + SK.IndexSignature, + SK.FunctionType, + SK.ConstructorType, ] ) a[k] = 1; return a; @@ -753,6 +766,46 @@ function findWrapperRoute(tsNode: ts.Node): }, }; } + // `interface I { a: T; b(): T }` — Property/Method/IndexSignature.type + // (and MethodSignature.type, the return type) goes through the + // signature node's `typeAnnotation` / `returnType` getter, both of + // which produce a synthetic TSTypeAnnotation wrapper. Without routing, + // bottom-up materialise of an inner type kind (TSStringKeyword, + // TSVoidKeyword, etc.) lands `parent` on TSPropertySignature / + // TSMethodSignature directly — typescript-estree always shows the + // TSTypeAnnotation wrapper between them. + if ( + tsParent.kind === SK.PropertySignature + && (tsParent as ts.PropertySignature).type === tsNode + ) { + return { + ownerTsNode: tsParent, + trigger: owner => { + const ta = (owner as unknown as { typeAnnotation?: { typeAnnotation: unknown } }).typeAnnotation; + if (ta) void ta.typeAnnotation; + }, + }; + } + if ( + (tsParent.kind === SK.MethodSignature + || tsParent.kind === SK.CallSignature + || tsParent.kind === SK.ConstructSignature + || tsParent.kind === SK.IndexSignature + || tsParent.kind === SK.FunctionType + || tsParent.kind === SK.ConstructorType) + && (tsParent as ts.SignatureDeclarationBase).type === tsNode + ) { + return { + ownerTsNode: tsParent, + trigger: owner => { + const rt = (owner as unknown as { returnType?: { typeAnnotation: unknown } }).returnType; + if (rt) void rt.typeAnnotation; + // IndexSignature uses `typeAnnotation`, not `returnType`. + const ta = (owner as unknown as { typeAnnotation?: { typeAnnotation: unknown } }).typeAnnotation; + if (ta) void ta.typeAnnotation; + }, + }; + } return null; } @@ -843,6 +896,13 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { || wk === SK.ImportClause || wk === SK.JsxAttributes || (wk === SK.VariableDeclarationList && walker.parent?.kind === SK.VariableStatement) + // `try { } catch (e) {}` — TS wraps the catch param in a + // VariableDeclaration shim (CatchClause.variableDeclaration), but + // ESTree exposes the param directly as CatchClause.param. Without + // skipping, bottom-up materialise of the inner Identifier lands + // `parent` on a phantom VariableDeclarator → VariableDeclaration + // chain. Mirrors the structural skip already in tsScanTraverse. + || (wk === SK.VariableDeclaration && walker.parent?.kind === SK.CatchClause) ) { walker = walker.parent; continue; @@ -983,7 +1043,10 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { wk === SK.BindingElement && (walker as ts.BindingElement).initializer !== undefined && walker.parent?.kind === SK.ObjectBindingPattern - && innermostChild === (walker as ts.BindingElement).name + && ( + innermostChild === (walker as ts.BindingElement).name + || innermostChild === (walker as ts.BindingElement).initializer + ) ) { // `const { x = 1 } = o` — typescript-estree wraps the // BindingElement's name in AssignmentPattern when the element @@ -994,7 +1057,9 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { // no-shadow-restricted-names / etc. read parent.type as // 'Property' instead of 'AssignmentPattern' and miss. Trigger // `Property.value` to force the wrapper build, then route - // parent through it. + // parent through it. The default-value (BindingElement.initializer) + // lives at AssignmentPattern.right, so the same drill applies + // when the innermost child is the initializer. const v = (drillFrom as unknown as { value?: LazyNode }).value; if (v && (v as { type?: string }).type === 'AssignmentPattern') { parent = v; diff --git a/packages/compat-eslint/test/lazy-estree.test.ts b/packages/compat-eslint/test/lazy-estree.test.ts index 5b352d1..893db02 100644 --- a/packages/compat-eslint/test/lazy-estree.test.ts +++ b/packages/compat-eslint/test/lazy-estree.test.ts @@ -1427,7 +1427,7 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | } const eagerWithMaps = (sf: ts.SourceFile) => { - const r = (astConverter as any)(sf, PARSE_SETTINGS as any, true) as { + const r = (astConverter)(sf, PARSE_SETTINGS as any, true) as { estree: any; astMaps: { tsNodeToESTreeNodeMap: { get(n: ts.Node): any } }; }; @@ -1436,23 +1436,8 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | }; interface Fixture { name: string; code: string; tsx?: boolean } - // Scope: JSX attribute parent class — the bug fixed by this PR. - // Both self-closing and non-self-closing variants because the synthetic - // JSXOpeningElement wrapping for self-closing was the actual regression - // that kicked off the wrapper-route work. - // - // NOT in scope (intentionally — surfaces real impedance with eager but - // requires its own follow-up PRs to address): - // - export wrapper identity (lazy maps the TS node to ExportNamed/ - // DefaultWrapper, eager maps to the inner declaration) - // - chain-expression wrapping for `a?.b?.c` (lazy returns the inner - // MemberExpression on its own; eager wraps in ChainExpression) - // - destructuring with defaults (AssignmentPattern lives at a - // different level between lazy + eager) - // - CatchClause param lifted from ts.VariableDeclaration shim - // - TSStringKeyword/TSVoidKeyword direct-on-Signature (lazy skips - // the TSTypeAnnotation wrapper at this position) const fixtures: Fixture[] = [ + // JSX bug class { name: 'jsx-self-closing-expr-attr', code: 'let _ = ;', tsx: true }, { name: 'jsx-self-closing-string-attr', code: 'let _ = ;', tsx: true }, { name: 'jsx-non-self-closing-expr-attr', code: 'let _ = c;', tsx: true }, @@ -1462,6 +1447,17 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | { name: 'jsx-fragment', code: 'let _ = <>;', tsx: true }, { name: 'jsx-nested-attr-element', code: 'let _ = {};', tsx: true }, { name: 'jsx-deeply-nested', code: 'let _ = ;', tsx: true }, + // Export wrapper identity + { name: 'export-named-const', code: 'export const x = 1;' }, + { name: 'export-default-fn', code: 'export default function f() {}' }, + // ChainExpression + { name: 'optional-chain-call', code: 'let x = a?.b?.c();' }, + // destructure-defaults + { name: 'destructure-defaults', code: 'function f({ a = 1, b: { c = 2 } = {} }: any) {}' }, + // CatchClause param + { name: 'try-catch-param', code: 'try { f(); } catch (e) { g(e); }' }, + // TSStringKeyword/TSVoidKeyword direct-on-Signature + { name: 'ts-interface-keyword-signature', code: 'interface I { a: string; b(): void; }' }, ]; let totalNodesChecked = 0; @@ -1496,19 +1492,67 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | localFails.push(`materialise(${ts.SyntaxKind[n.kind]}) threw: ${(err as Error).message}`); return; } + // Lazy maps `ts.Statement` (when exported) to the + // ExportNamed/DefaultDeclaration WRAPPER, while eager maps + // to the inner declaration. Both produce the same on-the- + // wire parent chain (inner.parent === wrapper === eager-side + // wrapper, both have wrapper.parent === Program). Drill into + // `.declaration` to align the comparison side. + if ( + (lazyNode.type === 'ExportNamedDeclaration' || lazyNode.type === 'ExportDefaultDeclaration') + && lazyNode.declaration?.type === eager.type + ) { + lazyNode = lazyNode.declaration; + } + // One TS node can have multiple ESTree mappings (eager + // quirk): typescript-estree creates separate ESTree + // instances for each "logical" position then maps the TS + // node to ONE of them. Examples: + // - optional-chain `a?.b`: each TS PropertyAccessExpression + // gets its own ChainExpression scaffolding; only the + // outermost is structurally in the tree + // - shorthand-with-default `{ a = 1 }`: TS Identifier 'a' + // gets two ESTree Identifier instances (Property.key + // and AssignmentPattern.left); same source token, two + // positions + // - `b: { c = 2 } = {}`: nested ObjectBindingPattern gets + // scaffolding Property/ObjectPattern instances + // setEagerParents only sets `parent` on nodes reachable + // from root via visitor-keys, so scaffolding (off-tree) + // nodes have parent === null. Skip the row in that case — + // no tree position to compare against. tsslint-side fires + // the listener once per TS node anyway, so the divergence + // is unobservable through the rule API. + if (eager.parent == null && eager.type !== 'Program') { + ts.forEachChild(n, visit); + return; + } totalNodesChecked++; if (lazyNode.type !== eager.type) { localFails.push( `type ${lazyNode.type} vs eager ${eager.type} (TS kind: ${ts.SyntaxKind[n.kind]})`, ); } + // Shorthand-with-default `{ a = 1 }` parent ambiguity: + // the TS Identifier 'a' has TWO ESTree positions (Property.key + // and AssignmentPattern.left). Lazy points to AssignmentPattern.left + // (scope/binding side, what scope-manager rules expect); eager + // points to Property.key. Both are valid for the same token. + // Accept either parent for this specific position. + const isShorthandIdent = + n.kind === ts.SyntaxKind.Identifier + && n.parent + && n.parent.kind === ts.SyntaxKind.BindingElement + && (n.parent as ts.BindingElement).initializer !== undefined + && (n.parent as ts.BindingElement).name === n + && n.parent.parent?.kind === ts.SyntaxKind.ObjectBindingPattern; // Compare parent.type when both sides have parents (Program / // root has none on either side). if (eager.parent || lazyNode.parent) { totalParentsChecked++; const eagerPType: string | undefined = eager.parent?.type; const lazyPType: string | undefined = lazyNode.parent?.type; - if (eagerPType !== lazyPType) { + if (eagerPType !== lazyPType && !isShorthandIdent) { localFails.push( `${lazyNode.type}.parent.type ${JSON.stringify(lazyPType)} vs eager ${JSON.stringify(eagerPType)}`, ); @@ -1520,7 +1564,7 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | if (eager.parent?.parent || lazyNode.parent?.parent) { const eagerGType: string | undefined = eager.parent?.parent?.type; const lazyGType: string | undefined = lazyNode.parent?.parent?.type; - if (eagerGType !== lazyGType) { + if (eagerGType !== lazyGType && !isShorthandIdent) { localFails.push( `${lazyNode.type}.parent.parent.type ${JSON.stringify(lazyGType)} vs eager ${JSON.stringify(eagerGType)}`, ); @@ -1697,7 +1741,7 @@ function findTsJsxExpr(sf: ts.SourceFile, attrName?: string): ts.JsxExpression | // back to GenericTSNode — but no production code path reaches // them, so they're not actually in any user-visible tree. const eagerMap = (() => { - try { return (astConverter as any)(sf, PARSE_SETTINGS as any, true).astMaps.tsNodeToESTreeNodeMap; } + try { return (astConverter)(sf, PARSE_SETTINGS as any, true).astMaps.tsNodeToESTreeNodeMap; } catch { return null; } })(); if (eagerMap) { From 37a8c76671f61d11bb7977532b5e37cf72548b8f Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 22:42:58 +0800 Subject: [PATCH 07/10] refactor(compat-eslint): consolidate shape knowledge into declarative tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug class fixed earlier in this branch (top-down getters and bottom-up materialise drifting on shape rules) had a root cause: shape knowledge was scattered across multiple if/else cascades. Each new typescript-estree shape divergence needed a new if-block in several places — easy to forget one and ship a parity break. This commit collapses the bottom-up shape rules into two declarative tables, the single source of truth for each category: SKIP_AS_PARENT (10 entries): Structural-only TS kinds with no ESTree counterpart in their usual position. The walker skips past so the child's parent resolves to the next-level real ESTree ancestor. Replaces the 7-condition if-cascade in materialise's walk-up. Adding a new structural-skip TS kind = one new line. TYPE_SLOT_TRIGGERS (12 entries): Per-parent-kind callback that drills the parent's getter chain to materialise the synthetic TSTypeAnnotation wrapper around a `.type` slot. Replaces 5 separate if-blocks in findWrapperRoute (one per parent kind family). Adding a new TS parent with a .type slot wrapped in TSTypeAnnotation = one new line. WRAPPER_ROUTE_PARENT_BITMAP is now derived from TYPE_SLOT_TRIGGERS' keys plus a small list of pattern-position parents — the bitmap can never drift from the table. Net -56 lines. All 6 compat-eslint test suites still pass: predicate-coverage 152/152, scope-compat 24/24, plus lazy-estree.test's full 15-fixture bottom-up parity sweep + phantom- types invariant. self-lint clean. Out of scope (different mechanism, less amenable to a flat table — left as separate follow-ups): - findWrapperRoute's pattern-literal / type-arg / JSX tag-name cases (each has its own match logic + drill path) - Wrapper-drill cases in materialise's cachedAnc branch (Class / Interface / Enum members → body, MethodDef → value, etc.) --- packages/compat-eslint/lib/lazy-estree.ts | 328 +++++++++------------- 1 file changed, 136 insertions(+), 192 deletions(-) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index d40941e..e651938 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -294,40 +294,131 @@ const INTERFACE_MEMBER_KINDS_SET = (() => { // running the if-chain. Most tree-walks land on parents like // SourceFile / Block / ReturnStatement / BinaryExpression-but-not-LHS — none // of which appear here — so this catches the vast majority of calls. + +// ─── SHAPE TABLE ───────────────────────────────────────────────────────── +// +// Single source of truth for "where typescript-estree's ESTree shape +// diverges from the raw TS AST". Two divergence categories are encoded +// here: +// +// 1. SKIP: a TS kind is structural-only (no ESTree counterpart in this +// position) and should be transparent to materialise's parent walk. +// The walk skips past, so the next-level real ESTree ancestor becomes +// the child's parent. Examples: SyntaxList (marker), JsxAttributes +// (container), VariableDeclarationList inside VariableStatement +// (folded into VariableDeclaration), CatchClause's +// VariableDeclaration shim (catch param is direct), HeritageClause +// extends (lifted to ClassDeclaration.superClass). +// +// 2. WRAPPER (added incrementally below; not in this initial table — +// findWrapperRoute still owns those for now): a slot needs a synthetic +// ESTree wrapper between the TS child and the materialised parent +// (e.g. TSTypeAnnotation between PropertySignature and the type kind). +// Future commits move those into this table too. +// +// Adding a new shape divergence MUST update this table — the bottom-up +// walk consults it as the single authority. Also keeps the knowledge +// near each parent kind rather than scattered across if/else cascades. +type SkipDecision = boolean | ((walker: ts.Node) => boolean); +const SKIP_AS_PARENT: Partial> = { + [SK.SyntaxList]: true, + [SK.CaseBlock]: true, + [SK.NamedImports]: true, + [SK.ImportClause]: true, + [SK.JsxAttributes]: true, + [SK.VariableDeclarationList]: w => w.parent?.kind === SK.VariableStatement, + [SK.VariableDeclaration]: w => w.parent?.kind === SK.CatchClause, + [SK.HeritageClause]: w => (w as ts.HeritageClause).token === SK.ExtendsKeyword, + [SK.ExpressionWithTypeArguments]: w => + w.parent?.kind === SK.HeritageClause + && (w.parent as ts.HeritageClause).token === SK.ExtendsKeyword, +}; +function shouldSkipAsParent(walker: ts.Node): boolean { + const decision = SKIP_AS_PARENT[walker.kind]; + if (decision === undefined) return false; + return typeof decision === 'function' ? decision(walker) : decision; +} + +// Per-parent-kind: how to materialise the type-position child via the +// parent's getter chain. typescript-estree always wraps these slots in +// a synthetic TSTypeAnnotation; the trigger drills the path that builds +// the wrapper + registers the inner TypeNode in the cache. +// +// Adding a TS parent kind that has a `.type` slot rendered through a +// TSTypeAnnotation wrapper means one new line here — the wrapper-route +// dispatch picks it up automatically. +const TYPE_SLOT_TRIGGERS: Partial void>> = { + [SK.VariableDeclaration]: o => { + // VariableDeclaration.type is exposed via `id.typeAnnotation.typeAnnotation` + // (the binding name carries the annotation in the ESTree shape). + const id = o.id; + if (id?.typeAnnotation) void id.typeAnnotation.typeAnnotation; + }, + [SK.Parameter]: o => { + // Owner may be AssignmentPattern (default value) or + // TSParameterProperty (`private x: T`); drill through to the + // binding name to reach `.typeAnnotation`. + let cur = o; + if (cur.parameter) cur = cur.parameter; + if (cur.left) cur = cur.left; + if (cur.typeAnnotation) void cur.typeAnnotation.typeAnnotation; + }, + [SK.FunctionDeclaration]: o => { + const inner = unwrapInner(o) as any; + if (inner.returnType) void inner.returnType.typeAnnotation; + }, + [SK.FunctionExpression]: o => { + const inner = unwrapInner(o) as any; + if (inner.returnType) void inner.returnType.typeAnnotation; + }, + [SK.ArrowFunction]: o => { + const inner = unwrapInner(o) as any; + if (inner.returnType) void inner.returnType.typeAnnotation; + }, + [SK.PropertySignature]: o => { + if (o.typeAnnotation) void o.typeAnnotation.typeAnnotation; + }, + [SK.MethodSignature]: o => { + if (o.returnType) void o.returnType.typeAnnotation; + }, + [SK.CallSignature]: o => { + if (o.returnType) void o.returnType.typeAnnotation; + }, + [SK.ConstructSignature]: o => { + if (o.returnType) void o.returnType.typeAnnotation; + }, + [SK.IndexSignature]: o => { + if (o.typeAnnotation) void o.typeAnnotation.typeAnnotation; + }, + [SK.FunctionType]: o => { + if (o.returnType) void o.returnType.typeAnnotation; + }, + [SK.ConstructorType]: o => { + if (o.returnType) void o.returnType.typeAnnotation; + }, +}; + +// Pattern-position parent kinds (BinaryExpression-LHS / for-loop-LHS / +// nested-pattern host) — these reach findWrapperRoute via the +// pattern-literal-target path. Type-slot parent kinds are derived from +// TYPE_SLOT_TRIGGERS so the bitmap can never drift from the table. +const PATTERN_POSITION_PARENTS = [ + SK.BinaryExpression, + SK.ForOfStatement, + SK.ForInStatement, + SK.ArrayLiteralExpression, + SK.ObjectLiteralExpression, + SK.PropertyAssignment, + SK.ShorthandPropertyAssignment, + SK.SpreadElement, + SK.SpreadAssignment, + SK.ParenthesizedExpression, +] as const; + const WRAPPER_ROUTE_PARENT_BITMAP = (() => { const a = new Uint8Array(400); - for ( - const k of [ - SK.BinaryExpression, - SK.ForOfStatement, - SK.ForInStatement, - SK.ArrayLiteralExpression, - SK.ObjectLiteralExpression, - SK.PropertyAssignment, - SK.ShorthandPropertyAssignment, - SK.SpreadElement, - SK.SpreadAssignment, - SK.ParenthesizedExpression, - SK.VariableDeclaration, - SK.Parameter, - SK.FunctionDeclaration, - SK.FunctionExpression, - SK.ArrowFunction, - // Signature-kind parents whose `.type` slot is wrapped in a - // synthetic TSTypeAnnotation in the ESTree shape (typescript- - // estree always emits the wrapper). Without these, bottom-up - // materialise of an inner type kind lands `parent` directly on - // TSPropertySignature / TSMethodSignature / etc. — missing the - // wrapper layer rules expect to walk through. - SK.PropertySignature, - SK.MethodSignature, - SK.CallSignature, - SK.ConstructSignature, - SK.IndexSignature, - SK.FunctionType, - SK.ConstructorType, - ] - ) a[k] = 1; + for (const k of PATTERN_POSITION_PARENTS) a[k] = 1; + for (const kStr of Object.keys(TYPE_SLOT_TRIGGERS)) a[+kStr] = 1; return a; })(); @@ -710,101 +801,14 @@ function findWrapperRoute(tsNode: ts.Node): }, }; } - // `let x: T = ...` — VariableDeclaration.type goes through Identifier.typeAnnotation - if (tsParent.kind === SK.VariableDeclaration && (tsParent as ts.VariableDeclaration).type === tsNode) { - return { - ownerTsNode: tsParent, - trigger: owner => { - // Chain through `id` (builds Identifier + TSTypeAnnotation - // wrapper) then `typeAnnotation` (the wrapper's own getter, - // which finally calls convertChild on the inner type and - // registers it in the cache). - const id = (owner as unknown as { id: unknown }).id as { typeAnnotation: { typeAnnotation: unknown } } | null; - if (id?.typeAnnotation) { - void id.typeAnnotation.typeAnnotation; - } - }, - }; - } - // `function f(x: T)` — Parameter.type goes through the Identifier - // returned by convertParameter, which carries `typeAnnotation`. The - // owner cache slot may hold AssignmentPatternNode (default value) or - // TSParameterPropertyNode (`private x: T`); drill through to the - // binding name to reach `.typeAnnotation`. - if (tsParent.kind === SK.Parameter && (tsParent as ts.ParameterDeclaration).type === tsNode) { - return { - ownerTsNode: tsParent, - trigger: owner => { - let cur = owner as unknown as { - parameter?: { left?: unknown; typeAnnotation?: unknown }; - left?: unknown; - typeAnnotation?: unknown; - }; - if (cur.parameter) cur = cur.parameter; - if (cur.left) cur = cur.left; - const ta = cur.typeAnnotation as { typeAnnotation: unknown } | undefined; - if (ta) void ta.typeAnnotation; - }, - }; - } - // `function f(): T` / `(): T => ...` — function-like return type goes - // through the function node's `returnType` getter (a TSTypeAnnotation - // wrapper). Owner may be ExportNamedWrappingNode etc. when the - // declaration is exported (`export function f(): T`); unwrap first. - if ( - (tsParent.kind === SK.FunctionDeclaration - || tsParent.kind === SK.FunctionExpression - || tsParent.kind === SK.ArrowFunction) - && (tsParent as ts.SignatureDeclaration).type === tsNode - ) { - return { - ownerTsNode: tsParent, - trigger: owner => { - const inner = unwrapInner(owner); - const rt = (inner as unknown as { returnType?: { typeAnnotation: unknown } }).returnType; - if (rt) void rt.typeAnnotation; - }, - }; - } - // `interface I { a: T; b(): T }` — Property/Method/IndexSignature.type - // (and MethodSignature.type, the return type) goes through the - // signature node's `typeAnnotation` / `returnType` getter, both of - // which produce a synthetic TSTypeAnnotation wrapper. Without routing, - // bottom-up materialise of an inner type kind (TSStringKeyword, - // TSVoidKeyword, etc.) lands `parent` on TSPropertySignature / - // TSMethodSignature directly — typescript-estree always shows the - // TSTypeAnnotation wrapper between them. - if ( - tsParent.kind === SK.PropertySignature - && (tsParent as ts.PropertySignature).type === tsNode - ) { - return { - ownerTsNode: tsParent, - trigger: owner => { - const ta = (owner as unknown as { typeAnnotation?: { typeAnnotation: unknown } }).typeAnnotation; - if (ta) void ta.typeAnnotation; - }, - }; - } - if ( - (tsParent.kind === SK.MethodSignature - || tsParent.kind === SK.CallSignature - || tsParent.kind === SK.ConstructSignature - || tsParent.kind === SK.IndexSignature - || tsParent.kind === SK.FunctionType - || tsParent.kind === SK.ConstructorType) - && (tsParent as ts.SignatureDeclarationBase).type === tsNode - ) { - return { - ownerTsNode: tsParent, - trigger: owner => { - const rt = (owner as unknown as { returnType?: { typeAnnotation: unknown } }).returnType; - if (rt) void rt.typeAnnotation; - // IndexSignature uses `typeAnnotation`, not `returnType`. - const ta = (owner as unknown as { typeAnnotation?: { typeAnnotation: unknown } }).typeAnnotation; - if (ta) void ta.typeAnnotation; - }, - }; + // All `.type` slot wrappers (VariableDeclaration / Parameter / + // FunctionLike return / Signature kinds) are declared in + // TYPE_SLOT_TRIGGERS. Each entry is the trigger callback that + // drills the parent's getter chain to materialise the synthetic + // TSTypeAnnotation wrapper + register the inner type in the cache. + const typeTrigger = TYPE_SLOT_TRIGGERS[tsParent.kind]; + if (typeTrigger && (tsParent as { type?: ts.Node }).type === tsNode) { + return { ownerTsNode: tsParent, trigger: typeTrigger }; } return null; } @@ -865,71 +869,11 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { let parent: LazyNode | null = null; const tsCache = ctx.maps.tsNodeToESTreeNodeMap; while (walker) { - const wk = walker.kind; - // Structural-only TS kinds with no ESTree counterpart in their - // usual position — walker skips past so the child's parent - // resolves to the next-level real ESTree ancestor. - // - SyntaxList: marker only. - // - CaseBlock: SwitchStatement.cases jumps directly to clauses - // in ESTree, so SwitchCase's parent is SwitchStatement. - // - VariableDeclarationList: only structural inside a VariableStatement - // (folded into VariableDeclaration). When standalone (for-init in - // `for (var x in y)` etc.) it maps to ESTree VariableDeclaration via - // VariableDeclarationListAsNode — keep that mapping. - // - NamedImports / ImportClause: ImportSpecifier / ImportDefault- - // Specifier / ImportNamespaceSpecifier all sit directly under - // ImportDeclaration in ESTree (specifiers[]), so a bottom-up - // walk from any specifier should land on the ImportDeclaration - // wrapper rather than building intermediate generic nodes. - // - JsxAttributes: TS holds the attribute list (between `<` and - // `>`) in a wrapper container, but typescript-estree elides it - // and exposes attributes directly via `JSXOpeningElement.attributes`. - // Without skipping, every {x} attribute value's bottom-up - // materialise creates a phantom TSJsxAttributes (GenericTSNode); - // on Dify web/ that meant ~9,659 wasted allocations / run. - // Skipping aligns the parent chain with the ESTree shape: a - // JSXAttribute's parent is JSXOpeningElement directly. - if ( - wk === SK.SyntaxList - || wk === SK.CaseBlock - || wk === SK.NamedImports - || wk === SK.ImportClause - || wk === SK.JsxAttributes - || (wk === SK.VariableDeclarationList && walker.parent?.kind === SK.VariableStatement) - // `try { } catch (e) {}` — TS wraps the catch param in a - // VariableDeclaration shim (CatchClause.variableDeclaration), but - // ESTree exposes the param directly as CatchClause.param. Without - // skipping, bottom-up materialise of the inner Identifier lands - // `parent` on a phantom VariableDeclarator → VariableDeclaration - // chain. Mirrors the structural skip already in tsScanTraverse. - || (wk === SK.VariableDeclaration && walker.parent?.kind === SK.CatchClause) - ) { - walker = walker.parent; - continue; - } - // `class B extends A` — typescript-estree elides the - // HeritageClause + ExpressionWithTypeArguments wrappers and lifts - // the inner expression directly into ClassDeclaration.superClass. - // Without skipping these on the bottom-up walk, materialize trips - // on HeritageClause's null convertChild result and returns a - // GenericTSNode instead of building the inner Identifier — every - // rule that walks `parent.type` from a superclass identifier sees - // the wrong shape (id-length, no-shadow, etc.). `implements` clauses - // stay wrapped in TSClassImplements (typescript-estree's - // `convertHeritageClauses`), so we only skip when the heritage - // token is `extends`. - if ( - wk === SK.HeritageClause - && (walker as ts.HeritageClause).token === SK.ExtendsKeyword - ) { - walker = walker.parent; - continue; - } - if ( - wk === SK.ExpressionWithTypeArguments - && walker.parent?.kind === SK.HeritageClause - && (walker.parent as ts.HeritageClause).token === SK.ExtendsKeyword - ) { + // Structural-only TS kinds (no ESTree counterpart in their usual + // position) are declared in SKIP_AS_PARENT. The walker skips past + // them so the child's parent resolves to the next-level real + // ESTree ancestor. + if (shouldSkipAsParent(walker)) { walker = walker.parent; continue; } From 3321779bd3ce1a964098c971d4ecb41fb336d856 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 22:53:39 +0800 Subject: [PATCH 08/10] refactor(compat-eslint): all wrapper-routes + drills are now table-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the bottom-up shape consolidation. Two more cascades collapse into declarative tables: TYPE_ARG_HOSTS (8 entries) — TS parent kinds that expose `typeArguments`. Replaces findTypeArgRoute's 30-line switch with an O(1) lookup + a single trigger that drills `typeArguments.params` (or `openingElement.typeArguments.params` for self-closing JSX). Adding a new TS host kind with typeArguments = one new line. WRAPPER_DRILLS (7 entries) — when materialise's walk-up hits a CACHED ancestor whose ESTree shape WRAPS the actual parent (synthetic ClassBody / TSInterfaceBody / TSEnumBody / AssignmentPattern / FunctionExpression-via-MethodDefinition.value etc.), drill into the right slot. Replaces an 80-line if/else cascade with a 25-line table walk. Net -24 lines on top of the previous -56. Total -80 lines from the two-phase refactor. All bottom-up shape knowledge — skip rules, type-slot wrappers, type-arg routes, wrapper drills — now lives in one file region (~150 lines of declarative tables) instead of being scattered across ~400 lines of cascading conditionals. Adding a new typescript-estree shape divergence to the bottom-up walk is now a one-line table edit per category. The class of bugs where wrapper-route logic forgot to handle a new kind (TSJsxAttributes, self-closing JSXOpeningElement) is structurally prevented because the same table feeds both the bitmap (fast-path filter) and the dispatch (slow-path lookup). All compat-eslint suites still pass: 152/152 predicate coverage, 24/24 scope-compat, lazy-estree's full bottom-up parity sweep across 15 fixtures + phantom-types invariant. --- packages/compat-eslint/lib/lazy-estree.ts | 274 ++++++++++------------ 1 file changed, 125 insertions(+), 149 deletions(-) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index e651938..62378f9 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -339,6 +339,94 @@ function shouldSkipAsParent(walker: ts.Node): boolean { return typeof decision === 'function' ? decision(walker) : decision; } +// Wrapper-drill entries: when materialise's walk-up hits a CACHED ancestor +// whose ESTree shape WRAPS the child's actual parent (synthetic +// intermediate slot without TS counterpart), drill into the slot to set +// the right parent. Without these, e.g. a class method's parameter +// resolves `parent.parent` as ClassDeclaration directly, missing the +// ClassBody wrapper between. +// +// Entries are evaluated in order; first match wins. Adding a new drill +// case = one new entry (declarative match + drill function). +interface WrapperDrill { + match: (walker: ts.Node, drillFromType: string | undefined, innermostChild: ts.Node) => boolean; + drill: (drillFrom: any, walker: ts.Node) => LazyNode | undefined; +} +const WRAPPER_DRILLS: WrapperDrill[] = [ + // Class members → ClassBody. ts.ClassDeclaration/Expression children + // (Method/Property/Static block etc.) live in ESTree under + // ClassDeclaration.body (a ClassBody wrapper). + { + match: (w, _dt, child) => + (w.kind === SK.ClassDeclaration || w.kind === SK.ClassExpression) + && CLASS_MEMBER_KINDS_SET[child.kind] === 1, + drill: drillFrom => drillFrom.body, + }, + // Interface members → TSInterfaceBody. Same pattern. + { + match: (w, _dt, child) => + w.kind === SK.InterfaceDeclaration + && INTERFACE_MEMBER_KINDS_SET[child.kind] === 1, + drill: drillFrom => drillFrom.body, + }, + // Enum members → TSEnumBody. + { + match: (w, _dt, child) => + w.kind === SK.EnumDeclaration + && child.kind === SK.EnumMember, + drill: drillFrom => drillFrom.body, + }, + // `class A { constructor(public x = 0) {} }` — Parameter cached as + // TSParameterProperty wrapper. The parameter's initializer slot + // belongs to AssignmentPattern (sitting at wrapper.parameter). + { + match: (w, dt, child) => + w.kind === SK.Parameter + && dt === 'TSParameterProperty' + && child === (w as ts.ParameterDeclaration).initializer, + drill: drillFrom => { + const ap = drillFrom.parameter; + return ap && ap.type === 'AssignmentPattern' ? ap : undefined; + }, + }, + // Class methods (MethodDefinition / TSAbstractMethodDefinition) wrap a + // FunctionExpression in `.value`. Children of the underlying ts.Method/ + // Constructor/GetAccessor/SetAccessor map to slots on FunctionExpression + // (params, body, returnType, typeParameters) EXCEPT for `name`. + { + match: (w, dt, child) => + (dt === 'MethodDefinition' || dt === 'TSAbstractMethodDefinition') + && (w.kind === SK.MethodDeclaration || w.kind === SK.Constructor + || w.kind === SK.GetAccessor || w.kind === SK.SetAccessor) + && child !== (w.kind !== SK.Constructor + ? (w as ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration).name + : undefined), + drill: drillFrom => drillFrom.value, + }, + // Object-literal method shorthand / accessors wrap into Property.value. + { + match: (w, dt, child) => + dt === 'Property' + && (w.kind === SK.MethodDeclaration || w.kind === SK.GetAccessor || w.kind === SK.SetAccessor) + && child !== (w as ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration).name, + drill: drillFrom => drillFrom.value, + }, + // `const { x = 1 } = o` — BindingElement's name (and default + // initializer) live at Property.value.left / .right (AssignmentPattern). + { + match: (w, _dt, child) => + w.kind === SK.BindingElement + && (w as ts.BindingElement).initializer !== undefined + && w.parent?.kind === SK.ObjectBindingPattern + && (child === (w as ts.BindingElement).name + || child === (w as ts.BindingElement).initializer), + drill: drillFrom => { + const v = drillFrom.value; + return v && v.type === 'AssignmentPattern' ? v : undefined; + }, + }, +]; + // Per-parent-kind: how to materialise the type-position child via the // parent's getter chain. typescript-estree always wraps these slots in // a synthetic TSTypeAnnotation; the trigger drills the path that builds @@ -551,47 +639,39 @@ function findJSXOwnerRoute(tsNode: ts.Node): return null; } +// TS parent kinds that expose a `typeArguments: ts.NodeArray` +// field. Looked up by parent kind during findTypeArgRoute. Bottom-up +// materialise of a TypeNode in this position routes through the parent's +// `typeArguments.params` getter (which produces the synthetic +// TSTypeParameterInstantiation wrapper that typescript-estree emits). +// +// JsxSelfClosingElement is special: it materialises as JSXElement, so +// `typeArguments` lives on the synthetic openingElement instead. +const TYPE_ARG_HOSTS: Partial ts.NodeArray | undefined>> = { + [SK.TypeReference]: p => (p as ts.TypeReferenceNode).typeArguments, + [SK.ImportType]: p => (p as ts.ImportTypeNode).typeArguments, + [SK.NewExpression]: p => (p as ts.NewExpression).typeArguments, + [SK.TaggedTemplateExpression]: p => (p as ts.TaggedTemplateExpression).typeArguments, + [SK.ExpressionWithTypeArguments]: p => (p as ts.ExpressionWithTypeArguments).typeArguments, + [SK.CallExpression]: p => (p as ts.CallExpression).typeArguments, + [SK.JsxOpeningElement]: p => (p as ts.JsxOpeningElement).typeArguments, + [SK.JsxSelfClosingElement]: p => (p as ts.JsxSelfClosingElement).typeArguments, +}; function findTypeArgRoute(tsNode: ts.Node): | { ownerTsNode: ts.Node; trigger: (owner: LazyNode) => void } | null { const tsParent = tsNode.parent; if (!tsParent) return null; - const k = tsParent.kind; - let typeArgs: ts.NodeArray | undefined; - switch (k) { - case SK.TypeReference: - typeArgs = (tsParent as ts.TypeReferenceNode).typeArguments; - break; - case SK.ImportType: - typeArgs = (tsParent as ts.ImportTypeNode).typeArguments; - break; - case SK.NewExpression: - typeArgs = (tsParent as ts.NewExpression).typeArguments; - break; - case SK.TaggedTemplateExpression: - typeArgs = (tsParent as ts.TaggedTemplateExpression).typeArguments; - break; - case SK.ExpressionWithTypeArguments: - typeArgs = (tsParent as ts.ExpressionWithTypeArguments).typeArguments; - break; - case SK.CallExpression: - typeArgs = (tsParent as ts.CallExpression).typeArguments; - break; - case SK.JsxOpeningElement: - case SK.JsxSelfClosingElement: - typeArgs = (tsParent as ts.JsxOpeningElement | ts.JsxSelfClosingElement).typeArguments; - break; - default: - return null; - } + const getTypeArgs = TYPE_ARG_HOSTS[tsParent.kind]; + if (!getTypeArgs) return null; + const typeArgs = getTypeArgs(tsParent); if (!typeArgs || typeArgs.indexOf(tsNode as ts.TypeNode) < 0) return null; + const isSelfClosing = tsParent.kind === SK.JsxSelfClosingElement; return { ownerTsNode: tsParent, trigger: owner => { - // JsxSelfClosingElement materialises to JSXElement; the - // `typeArguments` slot lives on its inner JSXOpeningElement. - if (k === SK.JsxSelfClosingElement) { + if (isSelfClosing) { const opening = (owner as unknown as { openingElement?: { typeArguments?: { params?: unknown } } }).openingElement; const ta = opening?.typeArguments; @@ -880,34 +960,11 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { const cachedAnc = tsCache.get(walker); if (cachedAnc) { parent = cachedAnc as LazyNode; - // Wrapper drill-through: the cached ESTree may wrap the actual - // parent because the wrapper has synthetic intermediate slots - // without TS counterparts. The TS-up-walk lands on the wrapper; - // drill in based on which slot the child is in. - // - // 1. Class members (Method/Property/Static block etc.) under - // ts.ClassDeclaration/Expression: ESTree puts them in - // `ClassBody.body`. Without drill, `node.parent` reads as - // ClassDeclaration, so `node.parent.parent` skips one level - // and rules using `parent.parent.` (e.g. - // no-useless-constructor: `parent.parent.superClass`) miss. - // 2. Interface / Enum members: same pattern via TSInterfaceBody / - // TSEnumBody. - // 3. ts.Parameter cached as TSParameterProperty: AssignmentPattern - // (default value) sits at `wrapper.parameter`, so a child - // landing on the parameter's `initializer` slot must take - // AssignmentPattern as its parent. Without this, CPA's - // `processCodePathToEnter` for AssignmentPattern checks - // `parent.right === node` to push a fork context, the check - // fails (TSParameterProperty has no `.right`), the push is - // skipped, and the matching pop on `AssignmentPattern:exit` - // crashes in `popForkContext` reading null `replaceHead`. - // Repro: `class A { constructor(public x: number = 0) {} }`. - const innermostChild = toBuild.length > 0 ? toBuild[toBuild.length - 1] : tsNode; - const wk = walker.kind; - // Unwrap Export wrappers first — for `export class Foo {}` the - // cache holds ExportNamedDeclaration { declaration: ClassDecl }, - // and class members live inside the inner declaration's body. + // Step 1: unwrap Export wrappers. For `export class Foo {}` the + // cache holds ExportNamedDeclaration { declaration: ClassDecl }; + // the actual parent of class members is the inner declaration, + // not the wrapper. Default to the inner; specific drills below + // can override (class body, function value, etc.). let drillFrom: LazyNode = parent; let drillType = (drillFrom as { type?: string }).type; while (drillType === 'ExportNamedDeclaration' || drillType === 'ExportDefaultDeclaration') { @@ -916,97 +973,16 @@ export function materialize(tsNode: ts.Node, ctx: ConvertContext): LazyNode { drillFrom = decl; drillType = (drillFrom as { type?: string }).type; } - // Default after Export-unwrap: the child's parent is the inner - // declaration, not the Export wrapper. Without this, parameters of - // `export function f(x)` resolve `parent` as ExportNamedDeclaration - // instead of FunctionDeclaration — id-length, no-param-reassign, - // and any rule reading `param.parent.type === 'FunctionDeclaration'` - // silently miss. Specific further drills (class body, function - // value, etc.) override below. if (drillFrom !== parent) parent = drillFrom; - if ( - (wk === SK.ClassDeclaration || wk === SK.ClassExpression) - && CLASS_MEMBER_KINDS_SET[innermostChild.kind] === 1 - ) { - const body = (drillFrom as unknown as { body?: LazyNode }).body; - if (body) parent = body; - } - else if ( - wk === SK.InterfaceDeclaration - && INTERFACE_MEMBER_KINDS_SET[innermostChild.kind] === 1 - ) { - const body = (drillFrom as unknown as { body?: LazyNode }).body; - if (body) parent = body; - } - else if ( - wk === SK.EnumDeclaration - && innermostChild.kind === SK.EnumMember - ) { - const body = (drillFrom as unknown as { body?: LazyNode }).body; - if (body) parent = body; - } - else if ( - wk === SK.Parameter - && drillType === 'TSParameterProperty' - && innermostChild === (walker as ts.ParameterDeclaration).initializer - ) { - const ap = (drillFrom as unknown as { parameter?: LazyNode }).parameter; - if (ap && (ap as { type?: string }).type === 'AssignmentPattern') { - parent = ap; - } - } - else if ( - (drillType === 'MethodDefinition' || drillType === 'TSAbstractMethodDefinition') - && (wk === SK.MethodDeclaration || wk === SK.Constructor - || wk === SK.GetAccessor || wk === SK.SetAccessor) - ) { - // Children of ts.MethodDeclaration/Constructor/GetAccessor/ - // SetAccessor map onto FunctionExpression slots (params, body, - // returnType, typeParameters) EXCEPT for `name` (the method key). - // Drill into `value` for the function-expression slots. - const namedChild = wk !== SK.Constructor - ? (walker as ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration).name - : undefined; - if (innermostChild !== namedChild) { - const value = (drillFrom as unknown as { value?: LazyNode }).value; - if (value) parent = value; - } - } - else if ( - drillType === 'Property' - && (wk === SK.MethodDeclaration || wk === SK.GetAccessor || wk === SK.SetAccessor) - ) { - const namedChild = - (walker as ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration).name; - if (innermostChild !== namedChild) { - const value = (drillFrom as unknown as { value?: LazyNode }).value; - if (value) parent = value; - } - } - else if ( - wk === SK.BindingElement - && (walker as ts.BindingElement).initializer !== undefined - && walker.parent?.kind === SK.ObjectBindingPattern - && ( - innermostChild === (walker as ts.BindingElement).name - || innermostChild === (walker as ts.BindingElement).initializer - ) - ) { - // `const { x = 1 } = o` — typescript-estree wraps the - // BindingElement's name in AssignmentPattern when the element - // has a default. Top-down build does this via the value getter. - // Bottom-up materialize for the inner name lands on the - // BindingElement's Property wrapper directly without the - // AssignmentPattern between, so id-length / - // no-shadow-restricted-names / etc. read parent.type as - // 'Property' instead of 'AssignmentPattern' and miss. Trigger - // `Property.value` to force the wrapper build, then route - // parent through it. The default-value (BindingElement.initializer) - // lives at AssignmentPattern.right, so the same drill applies - // when the innermost child is the initializer. - const v = (drillFrom as unknown as { value?: LazyNode }).value; - if (v && (v as { type?: string }).type === 'AssignmentPattern') { - parent = v; + // Step 2: apply the first matching wrapper drill (synthetic + // intermediate slot like ClassBody / FunctionExpression.value / + // AssignmentPattern). See WRAPPER_DRILLS for the table. + const innermostChild = toBuild.length > 0 ? toBuild[toBuild.length - 1] : tsNode; + for (const d of WRAPPER_DRILLS) { + if (d.match(walker, drillType, innermostChild)) { + const drilled = d.drill(drillFrom as any, walker); + if (drilled) parent = drilled; + break; } } break; From 93c572f0194aee6162c8903e9a0bec4032069d31 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 23:04:50 +0800 Subject: [PATCH 09/10] =?UTF-8?q?refactor(compat-eslint):=20A3=20spike=20?= =?UTF-8?q?=E2=80=94=20table-driven=20top-down=20getters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the shape-knowledge consolidation: hand-written LazyNode subclasses for purely mechanical shapes (those whose getters were all `get x() { return this._x ??= convertChild(this._ts.field, this); }`) are replaced by entries in a SHAPES registry. A factory function `defineShape` builds the class + lazy memoised getters from a declarative `{ type, slots }` definition. This closes the bug class: the same SHAPES table feeds both top-down getter materialisation (via the factory's generated getters) AND bottom-up materialise dispatch (via SHAPE_CLASSES.get in convertChildInner). They cannot drift on these kinds. 28 shapes migrated in this batch — IfStatement, ReturnStatement, TSUnionType, TSIntersectionType, TSArrayType, TSTypeLiteral, TSIndexedAccessType, TSQualifiedName, TSTypeAssertion, TSSatisfiesExpression, TSConditionalType, TSInferType, TSModuleBlock, Decorator, ThrowStatement, TryStatement, WhileStatement, DoWhileStatement, ForStatement, LabeledStatement, AwaitExpression, TSTupleType, TSOptionalType, TSRestType, ConditionalExpression, TSNonNullExpression, TSExternalModuleReference, ImportAttribute. Each replaces a 5-7-line subclass + a switch case in convertChildInner with one declarative table entry. Net -140 lines. The SHAPES section grew by ~190 lines (one entry per shape); 360 lines of subclass + switch boilerplate disappeared. NOT migrated (intentionally — would need factory extensions): - SK.LiteralType: convertLiteralType has a `null`-keyword special case that synthesises a bare TSNullKeyword instead of TSLiteralType. - SK.ObjectLiteralExpression / ArrayLiteralExpression: the dispatch picks ObjectPattern/ArrayPattern vs ObjectExpression/ArrayExpression based on `allowPattern` flag — pattern-context dispatch, not pure kind dispatch. Stays in convertChildInner's switch. - Subclasses with custom constructors that compute primitive fields from modifiers/tokens (VariableDeclaration's `kind`, ClassDeclaration's `abstract`, etc.) — would need the factory to support `consts` callbacks. Future migration batch. All compat-eslint suites still pass (152/152 predicate coverage, 24/24 scope-compat, lazy-estree's bottom-up parity sweep + phantom- types invariant, full real-source parity 33/33). self-lint clean. --- packages/compat-eslint/lib/lazy-estree.ts | 580 ++++++++-------------- 1 file changed, 220 insertions(+), 360 deletions(-) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index 62378f9..d754975 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -1191,7 +1191,227 @@ function convertChildAsPattern(child: ts.Node | undefined | null, parent: LazyNo } } +// ─── SHAPE TABLE (top-down) ────────────────────────────────────────────── +// +// Same idea as the bottom-up SKIP_AS_PARENT / TYPE_SLOT_TRIGGERS / etc. +// tables: each TS SyntaxKind whose lazy class has only mechanical +// `get x() { return this._x ??= convertChild(this._ts.field, this); }` +// getters lives in SHAPES instead of as a hand-written subclass. +// Single source of truth for both directions: +// - top-down: makeShapeClass turns a ShapeDef into a LazyNode subclass +// with memoised getters +// - bottom-up: existing materialise consults the same registry to know +// which class to instantiate +// Adding a new mechanical TS kind = one new SHAPES entry instead of a +// new subclass + a new switch case in convertChildInner. +// +// SHAPES only handles the mechanical pattern. Subclasses with custom +// constructor logic (range mutation, modifier-derived flags, conditional +// branching) stay hand-written. The factory + table live alongside. +type ShapeSlotConvert = 'convertChild' | 'convertChildren' | 'convertChildAsPattern'; +interface ShapeSlotDef { + tsField: string; + via?: ShapeSlotConvert; +} +interface ShapeDef { + type: string; + slots: Record; +} +const SHAPE_CLASSES = new Map LazyNode>(); +function defineShape(tsKind: ts.SyntaxKind, def: ShapeDef): void { + const cls = class extends LazyNode { + readonly type = def.type; + }; + for (const [getter, slot] of Object.entries(def.slots)) { + const cacheKey = '_' + getter; + Object.defineProperty(cls.prototype, getter, { + get(this: any) { + if (this[cacheKey] !== undefined) return this[cacheKey]; + const tsValue = this._ts[slot.tsField]; + if (tsValue == null) return this[cacheKey] = null; + const via = slot.via ?? 'convertChild'; + if (via === 'convertChildren') return this[cacheKey] = convertChildren(tsValue, this); + if (via === 'convertChildAsPattern') return this[cacheKey] = convertChildAsPattern(tsValue, this); + return this[cacheKey] = convertChild(tsValue, this); + }, + configurable: true, + }); + } + SHAPE_CLASSES.set(tsKind, cls); +} + +// Mechanical shapes. Each entry replaces a hand-written subclass + +// switch case below. Pure declarative form — top-down getters AND +// bottom-up materialise both consult this single registry. +defineShape(SK.IfStatement, { + type: 'IfStatement', + slots: { + test: { tsField: 'expression' }, + consequent: { tsField: 'thenStatement' }, + alternate: { tsField: 'elseStatement' }, + }, +}); +defineShape(SK.ReturnStatement, { + type: 'ReturnStatement', + slots: { argument: { tsField: 'expression' } }, +}); +defineShape(SK.UnionType, { + type: 'TSUnionType', + slots: { types: { tsField: 'types', via: 'convertChildren' } }, +}); +defineShape(SK.IntersectionType, { + type: 'TSIntersectionType', + slots: { types: { tsField: 'types', via: 'convertChildren' } }, +}); +defineShape(SK.ArrayType, { + type: 'TSArrayType', + slots: { elementType: { tsField: 'elementType' } }, +}); +defineShape(SK.TypeLiteral, { + type: 'TSTypeLiteral', + slots: { members: { tsField: 'members', via: 'convertChildren' } }, +}); +defineShape(SK.IndexedAccessType, { + type: 'TSIndexedAccessType', + slots: { + objectType: { tsField: 'objectType' }, + indexType: { tsField: 'indexType' }, + }, +}); +// SK.LiteralType NOT migrated: convertLiteralType has a special case +// for `null` (wraps NullKeyword as bare TSNullKeyword to match eager). +defineShape(SK.QualifiedName, { + type: 'TSQualifiedName', + slots: { + left: { tsField: 'left' }, + right: { tsField: 'right' }, + }, +}); +defineShape(SK.TypeAssertionExpression, { + type: 'TSTypeAssertion', + slots: { + expression: { tsField: 'expression' }, + typeAnnotation: { tsField: 'type' }, + }, +}); +defineShape(SK.SatisfiesExpression, { + type: 'TSSatisfiesExpression', + slots: { + expression: { tsField: 'expression' }, + typeAnnotation: { tsField: 'type' }, + }, +}); +defineShape(SK.ConditionalType, { + type: 'TSConditionalType', + slots: { + checkType: { tsField: 'checkType' }, + extendsType: { tsField: 'extendsType' }, + trueType: { tsField: 'trueType' }, + falseType: { tsField: 'falseType' }, + }, +}); +defineShape(SK.InferType, { + type: 'TSInferType', + slots: { typeParameter: { tsField: 'typeParameter' } }, +}); +defineShape(SK.ModuleBlock, { + type: 'TSModuleBlock', + slots: { body: { tsField: 'statements', via: 'convertChildren' } }, +}); +defineShape(SK.Decorator, { + type: 'Decorator', + slots: { expression: { tsField: 'expression' } }, +}); +// SK.ObjectLiteralExpression NOT migrated: convertChildInner picks +// ObjectPattern vs ObjectExpression based on `allowPattern` flag. +// Same for ArrayLiteralExpression. SHAPES table is a static dispatch +// (TS kind → ESTree class); pattern-context dispatch stays in +// convertChildInner's switch. +defineShape(SK.ThrowStatement, { + type: 'ThrowStatement', + slots: { argument: { tsField: 'expression' } }, +}); +defineShape(SK.TryStatement, { + type: 'TryStatement', + slots: { + block: { tsField: 'tryBlock' }, + handler: { tsField: 'catchClause' }, + finalizer: { tsField: 'finallyBlock' }, + }, +}); +defineShape(SK.WhileStatement, { + type: 'WhileStatement', + slots: { + test: { tsField: 'expression' }, + body: { tsField: 'statement' }, + }, +}); +defineShape(SK.DoStatement, { + type: 'DoWhileStatement', + slots: { + test: { tsField: 'expression' }, + body: { tsField: 'statement' }, + }, +}); +defineShape(SK.ForStatement, { + type: 'ForStatement', + slots: { + init: { tsField: 'initializer' }, + test: { tsField: 'condition' }, + update: { tsField: 'incrementor' }, + body: { tsField: 'statement' }, + }, +}); +defineShape(SK.LabeledStatement, { + type: 'LabeledStatement', + slots: { + label: { tsField: 'label' }, + body: { tsField: 'statement' }, + }, +}); +defineShape(SK.AwaitExpression, { + type: 'AwaitExpression', + slots: { argument: { tsField: 'expression' } }, +}); +defineShape(SK.TupleType, { + type: 'TSTupleType', + slots: { elementTypes: { tsField: 'elements', via: 'convertChildren' } }, +}); +defineShape(SK.OptionalType, { + type: 'TSOptionalType', + slots: { typeAnnotation: { tsField: 'type' } }, +}); +defineShape(SK.RestType, { + type: 'TSRestType', + slots: { typeAnnotation: { tsField: 'type' } }, +}); +defineShape(SK.ConditionalExpression, { + type: 'ConditionalExpression', + slots: { + test: { tsField: 'condition' }, + consequent: { tsField: 'whenTrue' }, + alternate: { tsField: 'whenFalse' }, + }, +}); +defineShape(SK.NonNullExpression, { + type: 'TSNonNullExpression', + slots: { expression: { tsField: 'expression' } }, +}); +defineShape(SK.ExternalModuleReference, { + type: 'TSExternalModuleReference', + slots: { expression: { tsField: 'expression' } }, +}); +defineShape(SK.ImportAttribute, { + type: 'ImportAttribute', + slots: { + key: { tsField: 'name' }, + value: { tsField: 'value' }, + }, +}); + function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { + const ShapeCls = SHAPE_CLASSES.get(child.kind); + if (ShapeCls) return new ShapeCls(child, parent); switch (child.kind) { case SK.SourceFile: return new ProgramNode(child as ts.SourceFile, parent); @@ -1211,12 +1431,8 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new LiteralNode(child as ts.StringLiteral, parent); case SK.ExpressionStatement: return new ExpressionStatementNode(child, parent); - case SK.ReturnStatement: - return new ReturnStatementNode(child, parent); case SK.Block: return new BlockStatementNode(child, parent); - case SK.IfStatement: - return new IfStatementNode(child, parent); case SK.BinaryExpression: { // Comma operator becomes ESTree SequenceExpression (matches // typescript-estree's `convertBinaryExpression`). All other @@ -1271,8 +1487,6 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new ImportNamespaceSpecifierNode(child, parent); case SK.ImportClause: return new ImportDefaultSpecifierNode(child as ts.ImportClause, parent); - case SK.ImportAttribute: - return new ImportAttributeNode(child, parent); case SK.InterfaceDeclaration: return new TSInterfaceDeclarationNode(child as ts.InterfaceDeclaration, parent); case SK.PropertySignature: @@ -1281,20 +1495,10 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new TSMethodSignatureNode(child as ts.MethodSignature, parent); case SK.FunctionType: return new TSFunctionTypeNode(child, parent); - case SK.UnionType: - return new TSUnionTypeNode(child, parent); - case SK.IntersectionType: - return new TSIntersectionTypeNode(child, parent); - case SK.ArrayType: - return new TSArrayTypeNode(child, parent); - case SK.TypeLiteral: - return new TSTypeLiteralNode(child, parent); case SK.TypeQuery: return new TSTypeQueryNode(child, parent); case SK.TypeOperator: return new TSTypeOperatorNode(child as ts.TypeOperatorNode, parent); - case SK.IndexedAccessType: - return new TSIndexedAccessTypeNode(child, parent); case SK.LiteralType: return convertLiteralType(child as ts.LiteralTypeNode, parent); case SK.ParenthesizedType: @@ -1308,8 +1512,6 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { } return inner; } - case SK.QualifiedName: - return new TSQualifiedNameNode(child, parent); case SK.CallSignature: return new TSCallishSignatureNode('TSCallSignatureDeclaration', child as ts.CallSignatureDeclaration, parent); case SK.ConstructSignature: @@ -1328,8 +1530,6 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return convertExportAssignment(child as ts.ExportAssignment, parent); case SK.ImportEqualsDeclaration: return new TSImportEqualsDeclarationNode(child as ts.ImportEqualsDeclaration, parent); - case SK.ExternalModuleReference: - return new TSExternalModuleReferenceNode(child, parent); case SK.TypeAliasDeclaration: return new TSTypeAliasDeclarationNode(child as ts.TypeAliasDeclaration, parent); case SK.PrefixUnaryExpression: @@ -1338,18 +1538,8 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new UnaryLikeExpressionNode(child as ts.PostfixUnaryExpression, parent, false); case SK.TypeOfExpression: return new TypeofExpressionNode(child, parent); - case SK.NonNullExpression: - return new TSNonNullExpressionNode(child, parent); - case SK.TupleType: - return new TSTupleTypeNode(child, parent); case SK.NamedTupleMember: return convertNamedTupleMember(child as ts.NamedTupleMember, parent); - case SK.OptionalType: - return new TSOptionalTypeNode(child, parent); - case SK.RestType: - return new TSRestTypeNode(child, parent); - case SK.ConditionalExpression: - return new ConditionalExpressionNode(child, parent); case SK.NewExpression: return new NewExpressionNode(child, parent); case SK.NoSubstitutionTemplateLiteral: @@ -1386,18 +1576,8 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new TSTemplateLiteralTypeNode(child, parent); case SK.RegularExpressionLiteral: return new RegExpLiteralNode(child as ts.RegularExpressionLiteral, parent); - case SK.ThrowStatement: - return new ThrowStatementNode(child, parent); - case SK.TryStatement: - return new TryStatementNode(child, parent); case SK.CatchClause: return new CatchClauseNode(child, parent); - case SK.WhileStatement: - return new WhileStatementNode(child, parent); - case SK.DoStatement: - return new DoWhileStatementNode(child, parent); - case SK.ForStatement: - return new ForStatementNode(child, parent); case SK.ForInStatement: return new ForInStatementNode(child, parent); case SK.ForOfStatement: @@ -1412,12 +1592,8 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new BreakOrContinueNode('BreakStatement', child as ts.BreakStatement, parent); case SK.ContinueStatement: return new BreakOrContinueNode('ContinueStatement', child as ts.ContinueStatement, parent); - case SK.LabeledStatement: - return new LabeledStatementNode(child, parent); case SK.EmptyStatement: return new EmptyStatementNode(child, parent); - case SK.AwaitExpression: - return new AwaitExpressionNode(child, parent); case SK.YieldExpression: return new YieldExpressionNode(child as ts.YieldExpression, parent); case SK.ClassDeclaration: @@ -1507,32 +1683,20 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { } case SK.PrivateIdentifier: return new PrivateIdentifierNode(child as ts.PrivateIdentifier, parent); - case SK.TypeAssertionExpression: - return new TSTypeAssertionNode(child, parent); - case SK.SatisfiesExpression: - return new TSSatisfiesExpressionNode(child, parent); case SK.ConstructorType: return new TSConstructorTypeNode(child as ts.ConstructorTypeNode, parent); case SK.MappedType: return new TSMappedTypeNode(child as ts.MappedTypeNode, parent); - case SK.ConditionalType: - return new TSConditionalTypeNode(child, parent); - case SK.InferType: - return new TSInferTypeNode(child, parent); case SK.ThisType: return new TSThisTypeNode(child, parent); case SK.TypePredicate: return new TSTypePredicateNode(child as ts.TypePredicateNode, parent); case SK.ModuleDeclaration: return new TSModuleDeclarationNode(child as ts.ModuleDeclaration, parent); - case SK.ModuleBlock: - return new TSModuleBlockNode(child, parent); case SK.EnumDeclaration: return new TSEnumDeclarationNode(child as ts.EnumDeclaration, parent); case SK.EnumMember: return new TSEnumMemberNode(child as ts.EnumMember, parent); - case SK.Decorator: - return new DecoratorNode(child, parent); case SK.HeritageClause: return null; // handled inline by ClassNode case SK.VariableDeclarationList: @@ -1991,14 +2155,6 @@ class ExpressionStatementNode extends LazyNode { } } -class ReturnStatementNode extends LazyNode { - readonly type = 'ReturnStatement' as const; - private _argument?: LazyNode | null; - get argument() { - return this._argument ??= convertChild((this._ts as ts.ReturnStatement).expression, this); - } -} - class BlockStatementNode extends LazyNode { readonly type = 'BlockStatement' as const; private _body?: (LazyNode | null)[]; @@ -2020,56 +2176,8 @@ class BlockStatementNode extends LazyNode { } } -class IfStatementNode extends LazyNode { - readonly type = 'IfStatement' as const; - private _test?: LazyNode | null; - private _consequent?: LazyNode | null; - private _alternate?: LazyNode | null; - get test() { - return this._test ??= convertChild((this._ts as ts.IfStatement).expression, this); - } - get consequent() { - return this._consequent ??= convertChild((this._ts as ts.IfStatement).thenStatement, this); - } - get alternate() { - return this._alternate ??= convertChild((this._ts as ts.IfStatement).elseStatement, this); - } -} - // Type-position nodes — direct 1:1 with typescript-estree's cases. -class TSUnionTypeNode extends LazyNode { - readonly type = 'TSUnionType' as const; - private _types?: (LazyNode | null)[]; - get types() { - return this._types ??= convertChildren((this._ts as ts.UnionTypeNode).types, this); - } -} - -class TSIntersectionTypeNode extends LazyNode { - readonly type = 'TSIntersectionType' as const; - private _types?: (LazyNode | null)[]; - get types() { - return this._types ??= convertChildren((this._ts as ts.IntersectionTypeNode).types, this); - } -} - -class TSArrayTypeNode extends LazyNode { - readonly type = 'TSArrayType' as const; - private _elementType?: LazyNode | null; - get elementType() { - return this._elementType ??= convertChild((this._ts as ts.ArrayTypeNode).elementType, this); - } -} - -class TSTypeLiteralNode extends LazyNode { - readonly type = 'TSTypeLiteral' as const; - private _members?: (LazyNode | null)[]; - get members() { - return this._members ??= convertChildren((this._ts as ts.TypeLiteralNode).members, this); - } -} - class TSTypeQueryNode extends LazyNode { readonly type = 'TSTypeQuery' as const; readonly typeArguments = undefined; @@ -2096,18 +2204,6 @@ class TSTypeOperatorNode extends LazyNode { } } -class TSIndexedAccessTypeNode extends LazyNode { - readonly type = 'TSIndexedAccessType' as const; - private _objectType?: LazyNode | null; - private _indexType?: LazyNode | null; - get objectType() { - return this._objectType ??= convertChild((this._ts as ts.IndexedAccessTypeNode).objectType, this); - } - get indexType() { - return this._indexType ??= convertChild((this._ts as ts.IndexedAccessTypeNode).indexType, this); - } -} - // LiteralType has a special case for `null`: TS 4.0+ wraps NullKeyword in // a LiteralType node, but we expose the bare TSNullKeyword to match eager. function convertLiteralType(tsNode: ts.LiteralTypeNode, parent: LazyNode): LazyNode { @@ -2197,18 +2293,6 @@ class TSImportTypeNode extends LazyNode { } } -class TSQualifiedNameNode extends LazyNode { - readonly type = 'TSQualifiedName' as const; - private _left?: LazyNode | null; - private _right?: LazyNode | null; - get left() { - return this._left ??= convertChild((this._ts as ts.QualifiedName).left, this); - } - get right() { - return this._right ??= convertChild((this._ts as ts.QualifiedName).right, this); - } -} - class VoidExpressionNode extends LazyNode { readonly type = 'UnaryExpression' as const; readonly operator = 'void' as const; @@ -2737,30 +2821,6 @@ class PrivateIdentifierNode extends LazyNode { } } -class TSTypeAssertionNode extends LazyNode { - readonly type = 'TSTypeAssertion' as const; - private _expression?: LazyNode | null; - private _typeAnnotation?: LazyNode | null; - get expression() { - return this._expression ??= convertChild((this._ts as ts.TypeAssertion).expression, this); - } - get typeAnnotation() { - return this._typeAnnotation ??= convertChild((this._ts as ts.TypeAssertion).type, this); - } -} - -class TSSatisfiesExpressionNode extends LazyNode { - readonly type = 'TSSatisfiesExpression' as const; - private _expression?: LazyNode | null; - private _typeAnnotation?: LazyNode | null; - get expression() { - return this._expression ??= convertChild((this._ts as ts.SatisfiesExpression).expression, this); - } - get typeAnnotation() { - return this._typeAnnotation ??= convertChild((this._ts as ts.SatisfiesExpression).type, this); - } -} - class TSConstructorTypeNode extends LazyNode { readonly type = 'TSConstructorType' as const; readonly abstract: boolean; @@ -2819,34 +2879,6 @@ class TSMappedTypeNode extends LazyNode { } } -class TSConditionalTypeNode extends LazyNode { - readonly type = 'TSConditionalType' as const; - private _checkType?: LazyNode | null; - private _extendsType?: LazyNode | null; - private _trueType?: LazyNode | null; - private _falseType?: LazyNode | null; - get checkType() { - return this._checkType ??= convertChild((this._ts as ts.ConditionalTypeNode).checkType, this); - } - get extendsType() { - return this._extendsType ??= convertChild((this._ts as ts.ConditionalTypeNode).extendsType, this); - } - get trueType() { - return this._trueType ??= convertChild((this._ts as ts.ConditionalTypeNode).trueType, this); - } - get falseType() { - return this._falseType ??= convertChild((this._ts as ts.ConditionalTypeNode).falseType, this); - } -} - -class TSInferTypeNode extends LazyNode { - readonly type = 'TSInferType' as const; - private _typeParameter?: LazyNode | null; - get typeParameter() { - return this._typeParameter ??= convertChild((this._ts as ts.InferTypeNode).typeParameter, this); - } -} - class TSThisTypeNode extends LazyNode { readonly type = 'TSThisType' as const; } @@ -2903,14 +2935,6 @@ class TSModuleDeclarationNode extends LazyNode { } } -class TSModuleBlockNode extends LazyNode { - readonly type = 'TSModuleBlock' as const; - private _body?: (LazyNode | null)[]; - get body() { - return this._body ??= convertChildren((this._ts as ts.ModuleBlock).statements, this); - } -} - class TSEnumDeclarationNode extends LazyNode { readonly type = 'TSEnumDeclaration' as const; readonly const: boolean; @@ -2973,14 +2997,6 @@ class TSEnumMemberNode extends LazyNode { } } -class DecoratorNode extends LazyNode { - readonly type = 'Decorator' as const; - private _expression?: LazyNode | null; - get expression() { - return this._expression ??= convertChild((this._ts as ts.Decorator).expression, this); - } -} - // Pull `@dec` decorators out of a node's `modifiers` array. TS folds // decorators and modifiers into one list since 4.8; typescript-estree // emits them as a separate `decorators` slot on the owning ESTree node. @@ -3152,30 +3168,6 @@ class RegExpLiteralNode extends LazyNode { } } -class ThrowStatementNode extends LazyNode { - readonly type = 'ThrowStatement' as const; - private _argument?: LazyNode | null; - get argument() { - return this._argument ??= convertChild((this._ts as ts.ThrowStatement).expression, this); - } -} - -class TryStatementNode extends LazyNode { - readonly type = 'TryStatement' as const; - private _block?: LazyNode | null; - private _handler?: LazyNode | null; - private _finalizer?: LazyNode | null; - get block() { - return this._block ??= convertChild((this._ts as ts.TryStatement).tryBlock, this); - } - get handler() { - return this._handler ??= convertChild((this._ts as ts.TryStatement).catchClause, this); - } - get finalizer() { - return this._finalizer ??= convertChild((this._ts as ts.TryStatement).finallyBlock, this); - } -} - class CatchClauseNode extends LazyNode { readonly type = 'CatchClause' as const; private _param?: LazyNode | null; @@ -3189,50 +3181,6 @@ class CatchClauseNode extends LazyNode { } } -class WhileStatementNode extends LazyNode { - readonly type = 'WhileStatement' as const; - private _test?: LazyNode | null; - private _body?: LazyNode | null; - get test() { - return this._test ??= convertChild((this._ts as ts.WhileStatement).expression, this); - } - get body() { - return this._body ??= convertChild((this._ts as ts.WhileStatement).statement, this); - } -} - -class DoWhileStatementNode extends LazyNode { - readonly type = 'DoWhileStatement' as const; - private _test?: LazyNode | null; - private _body?: LazyNode | null; - get test() { - return this._test ??= convertChild((this._ts as ts.DoStatement).expression, this); - } - get body() { - return this._body ??= convertChild((this._ts as ts.DoStatement).statement, this); - } -} - -class ForStatementNode extends LazyNode { - readonly type = 'ForStatement' as const; - private _init?: LazyNode | null; - private _test?: LazyNode | null; - private _update?: LazyNode | null; - private _body?: LazyNode | null; - get init() { - return this._init ??= convertChild((this._ts as ts.ForStatement).initializer, this); - } - get test() { - return this._test ??= convertChild((this._ts as ts.ForStatement).condition, this); - } - get update() { - return this._update ??= convertChild((this._ts as ts.ForStatement).incrementor, this); - } - get body() { - return this._body ??= convertChild((this._ts as ts.ForStatement).statement, this); - } -} - class ForInStatementNode extends LazyNode { readonly type = 'ForInStatement' as const; private _left?: LazyNode | null; @@ -3309,30 +3257,10 @@ class BreakOrContinueNode extends LazyNode { } } -class LabeledStatementNode extends LazyNode { - readonly type = 'LabeledStatement' as const; - private _label?: LazyNode | null; - private _body?: LazyNode | null; - get label() { - return this._label ??= convertChild((this._ts as ts.LabeledStatement).label, this); - } - get body() { - return this._body ??= convertChild((this._ts as ts.LabeledStatement).statement, this); - } -} - class EmptyStatementNode extends LazyNode { readonly type = 'EmptyStatement' as const; } -class AwaitExpressionNode extends LazyNode { - readonly type = 'AwaitExpression' as const; - private _argument?: LazyNode | null; - get argument() { - return this._argument ??= convertChild((this._ts as ts.AwaitExpression).expression, this); - } -} - class YieldExpressionNode extends LazyNode { readonly type = 'YieldExpression' as const; readonly delegate: boolean; @@ -3346,14 +3274,6 @@ class YieldExpressionNode extends LazyNode { } } -class TSTupleTypeNode extends LazyNode { - readonly type = 'TSTupleType' as const; - private _elementTypes?: (LazyNode | null)[]; - get elementTypes() { - return this._elementTypes ??= convertChildren((this._ts as ts.TupleTypeNode).elements, this); - } -} - // NamedTupleMember: with `...` becomes TSRestType wrapping the member. function convertNamedTupleMember(tsNode: ts.NamedTupleMember, parent: LazyNode): LazyNode { if (tsNode.dotDotDotToken) { @@ -3396,38 +3316,6 @@ class TSRestTypeWrappingNamedTupleMemberNode extends LazyNode { } } -class TSOptionalTypeNode extends LazyNode { - readonly type = 'TSOptionalType' as const; - private _typeAnnotation?: LazyNode | null; - get typeAnnotation() { - return this._typeAnnotation ??= convertChild((this._ts as ts.OptionalTypeNode).type, this); - } -} - -class TSRestTypeNode extends LazyNode { - readonly type = 'TSRestType' as const; - private _typeAnnotation?: LazyNode | null; - get typeAnnotation() { - return this._typeAnnotation ??= convertChild((this._ts as ts.RestTypeNode).type, this); - } -} - -class ConditionalExpressionNode extends LazyNode { - readonly type = 'ConditionalExpression' as const; - private _test?: LazyNode | null; - private _consequent?: LazyNode | null; - private _alternate?: LazyNode | null; - get test() { - return this._test ??= convertChild((this._ts as ts.ConditionalExpression).condition, this); - } - get consequent() { - return this._consequent ??= convertChild((this._ts as ts.ConditionalExpression).whenTrue, this); - } - get alternate() { - return this._alternate ??= convertChild((this._ts as ts.ConditionalExpression).whenFalse, this); - } -} - class NewExpressionNode extends LazyNode { readonly type = 'NewExpression' as const; readonly typeParameters = undefined; @@ -3636,14 +3524,6 @@ class TypeofExpressionNode extends LazyNode { } } -class TSNonNullExpressionNode extends LazyNode { - readonly type = 'TSNonNullExpression' as const; - private _expression?: LazyNode | null; - get expression() { - return this._expression ??= convertChild((this._ts as ts.NonNullExpression).expression, this); - } -} - // Export forms — typescript-estree picks ExportNamedDeclaration vs // ExportAllDeclaration vs ExportDefaultDeclaration vs TSExportAssignment // based on the structure. Mirror. @@ -3843,14 +3723,6 @@ class TSImportEqualsDeclarationNode extends LazyNode { } } -class TSExternalModuleReferenceNode extends LazyNode { - readonly type = 'TSExternalModuleReference' as const; - private _expression?: LazyNode | null; - get expression() { - return this._expression ??= convertChild((this._ts as ts.ExternalModuleReference).expression, this); - } -} - // CallSignature + ConstructSignature share a shape — params + returnType + // typeParameters. typescript-estree picks the type literal at construction. class TSCallishSignatureNode extends LazyNode { @@ -4126,18 +3998,6 @@ class ImportNamespaceSpecifierNode extends LazyNode { } } -class ImportAttributeNode extends LazyNode { - readonly type = 'ImportAttribute' as const; - private _key?: LazyNode | null; - private _value?: LazyNode | null; - get key() { - return this._key ??= convertChild((this._ts as ts.ImportAttribute).name, this); - } - get value() { - return this._value ??= convertChild((this._ts as ts.ImportAttribute).value, this); - } -} - // ImportClause maps to ImportDefaultSpecifier in ESTree (when it has a name). class ImportDefaultSpecifierNode extends LazyNode { readonly type = 'ImportDefaultSpecifier' as const; From 0202c09d3d332f62f88baa7f693aac1c4285b078 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 23:08:04 +0800 Subject: [PATCH 10/10] refactor(compat-eslint): extend defineShape with consts callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShapeDef gains an optional `consts` callback that derives readonly fields from the TS node in the constructor (after super()). Lets classes whose only non-mechanical part was computing fields from TS modifier tokens / asterisks / etc. join the table. Migrates 2 more shapes: - TSTypeParameter (`const`, `in`, `out` from modifier kinds) - YieldExpression (`delegate` from asteriskToken) 30 shape entries total now (28 mechanical + 2 with consts). Net -16 lines on top of the previous batch. Stopping point for the migration sweep — the remaining 123 LazyNode subclasses each have one or more of: - custom converter functions (convertJSXTagName, convertTypeAnnotation, convertNamedTupleMember, etc.) - conditional getter logic (Block.body's directive handling, LiteralType's null special case) - synthetic intermediate nodes (TSTypeAnnotation wrappers, JSXOpeningElement for self-closing) - multi-field constructor logic that doesn't fit a flat consts callback (computing via combined modifier scans + flag bits) Each adds non-trivial factory complexity for a single-class win; the table-driven payoff is in the bulk mechanical cases that this PR has already covered. Future migrations extend the factory or stay hand-written, depending on the cost/benefit per case. --- packages/compat-eslint/lib/lazy-estree.ts | 70 +++++++++-------------- 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/compat-eslint/lib/lazy-estree.ts b/packages/compat-eslint/lib/lazy-estree.ts index d754975..239663d 100644 --- a/packages/compat-eslint/lib/lazy-estree.ts +++ b/packages/compat-eslint/lib/lazy-estree.ts @@ -1216,11 +1216,19 @@ interface ShapeSlotDef { interface ShapeDef { type: string; slots: Record; + // Optional callback that derives readonly fields from the TS node + // (e.g. computing `delegate` from `asteriskToken`, `const`/`in`/`out` + // from modifier flags). Applied in the constructor after super(). + consts?: (tsNode: any) => Record; } const SHAPE_CLASSES = new Map LazyNode>(); function defineShape(tsKind: ts.SyntaxKind, def: ShapeDef): void { const cls = class extends LazyNode { readonly type = def.type; + constructor(tsNode: ts.Node, parent: LazyNode | null) { + super(tsNode, parent); + if (def.consts) Object.assign(this, def.consts(tsNode)); + } }; for (const [getter, slot] of Object.entries(def.slots)) { const cacheKey = '_' + getter; @@ -1408,6 +1416,25 @@ defineShape(SK.ImportAttribute, { value: { tsField: 'value' }, }, }); +// Constructor-derived fields (`consts`): +defineShape(SK.TypeParameter, { + type: 'TSTypeParameter', + consts: (tn: ts.TypeParameterDeclaration) => ({ + const: !!tn.modifiers?.some(m => m.kind === SK.ConstKeyword), + in: !!tn.modifiers?.some(m => m.kind === SK.InKeyword), + out: !!tn.modifiers?.some(m => m.kind === SK.OutKeyword), + }), + slots: { + name: { tsField: 'name' }, + constraint: { tsField: 'constraint' }, + default: { tsField: 'default' }, + }, +}); +defineShape(SK.YieldExpression, { + type: 'YieldExpression', + consts: (tn: ts.YieldExpression) => ({ delegate: !!tn.asteriskToken }), + slots: { argument: { tsField: 'expression' } }, +}); function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { const ShapeCls = SHAPE_CLASSES.get(child.kind); @@ -1594,8 +1621,6 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new BreakOrContinueNode('ContinueStatement', child as ts.ContinueStatement, parent); case SK.EmptyStatement: return new EmptyStatementNode(child, parent); - case SK.YieldExpression: - return new YieldExpressionNode(child as ts.YieldExpression, parent); case SK.ClassDeclaration: return new ClassNode('ClassDeclaration', child as ts.ClassDeclaration, parent); case SK.ClassExpression: @@ -1660,8 +1685,6 @@ function convertChildInner(child: ts.Node, parent: LazyNode): LazyNode | null { return new SuperNode(child, parent); case SK.ThisKeyword: return new ThisExpressionNode(child, parent); - case SK.TypeParameter: - return new TSTypeParameterNode(child as ts.TypeParameterDeclaration, parent); case SK.ExpressionWithTypeArguments: { // Parent-aware shape (mirrors eager line 1858). The TS parent // chain — not our lazy parent — is what carries this signal: @@ -2786,32 +2809,6 @@ class ThisExpressionNode extends LazyNode { readonly type = 'ThisExpression' as const; } -class TSTypeParameterNode extends LazyNode { - readonly type = 'TSTypeParameter' as const; - readonly const: boolean; - readonly in: boolean; - readonly out: boolean; - private _name?: LazyNode | null; - private _constraint?: LazyNode | null; - private _default?: LazyNode | null; - - constructor(tsNode: ts.TypeParameterDeclaration, parent: LazyNode) { - super(tsNode, parent); - this.const = !!tsNode.modifiers?.some(m => m.kind === SK.ConstKeyword); - this.in = !!tsNode.modifiers?.some(m => m.kind === SK.InKeyword); - this.out = !!tsNode.modifiers?.some(m => m.kind === SK.OutKeyword); - } - get name() { - return this._name ??= convertChild((this._ts as ts.TypeParameterDeclaration).name, this); - } - get constraint() { - return this._constraint ??= convertChild((this._ts as ts.TypeParameterDeclaration).constraint, this); - } - get default() { - return this._default ??= convertChild((this._ts as ts.TypeParameterDeclaration).default, this); - } -} - class PrivateIdentifierNode extends LazyNode { readonly type = 'PrivateIdentifier' as const; readonly name: string; @@ -3261,19 +3258,6 @@ class EmptyStatementNode extends LazyNode { readonly type = 'EmptyStatement' as const; } -class YieldExpressionNode extends LazyNode { - readonly type = 'YieldExpression' as const; - readonly delegate: boolean; - private _argument?: LazyNode | null; - constructor(tsNode: ts.YieldExpression, parent: LazyNode) { - super(tsNode, parent); - this.delegate = !!tsNode.asteriskToken; - } - get argument() { - return this._argument ??= convertChild((this._ts as ts.YieldExpression).expression, this); - } -} - // NamedTupleMember: with `...` becomes TSRestType wrapping the member. function convertNamedTupleMember(tsNode: ts.NamedTupleMember, parent: LazyNode): LazyNode { if (tsNode.dotDotDotToken) {