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..239663d 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 @@ -253,27 +294,219 @@ 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; +} + +// 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 +// 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, - ] - ) 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; })(); @@ -406,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; @@ -466,6 +691,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 @@ -619,61 +881,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; - }, - }; + // 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; } @@ -734,89 +949,22 @@ 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. - if ( - wk === SK.SyntaxList - || wk === SK.CaseBlock - || wk === SK.NamedImports - || wk === SK.ImportClause - || (wk === SK.VariableDeclarationList && walker.parent?.kind === SK.VariableStatement) - ) { - 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; } 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') { @@ -825,92 +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 - ) { - // `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. - 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; @@ -1119,7 +1191,254 @@ 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; + // 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; + 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' }, + }, +}); +// 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); + if (ShapeCls) return new ShapeCls(child, parent); switch (child.kind) { case SK.SourceFile: return new ProgramNode(child as ts.SourceFile, parent); @@ -1139,12 +1458,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 @@ -1199,8 +1514,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: @@ -1209,20 +1522,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: @@ -1236,8 +1539,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: @@ -1256,8 +1557,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: @@ -1266,18 +1565,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: @@ -1314,18 +1603,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: @@ -1340,14 +1619,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: return new ClassNode('ClassDeclaration', child as ts.ClassDeclaration, parent); case SK.ClassExpression: @@ -1412,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: @@ -1435,32 +1706,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: @@ -1919,14 +2178,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)[]; @@ -1948,56 +2199,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; @@ -2024,18 +2227,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 { @@ -2125,18 +2316,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; @@ -2630,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; @@ -2665,30 +2818,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; @@ -2747,34 +2876,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; } @@ -2831,14 +2932,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; @@ -2901,14 +2994,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. @@ -3080,30 +3165,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; @@ -3117,50 +3178,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; @@ -3237,51 +3254,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; - 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); - } -} - -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) { @@ -3324,38 +3300,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; @@ -3564,14 +3508,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. @@ -3771,14 +3707,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 { @@ -4054,18 +3982,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; 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 f0d79d6..893db02 100644 --- a/packages/compat-eslint/test/lazy-estree.test.ts +++ b/packages/compat-eslint/test/lazy-estree.test.ts @@ -1157,6 +1157,752 @@ 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')); +} + +// --- 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)(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 } + 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 }, + { 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 }, + // 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; + 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; + } + // 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 && !isShorthandIdent) { + 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 && !isShorthandIdent) { + 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)(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). +// 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`}`);