Skip to content

compat-eslint: factory DSL extensions + migrate 32 hand-written classes#94

Closed
johnsoncodehk wants to merge 10 commits into
masterfrom
refactor/full-migration
Closed

compat-eslint: factory DSL extensions + migrate 32 hand-written classes#94
johnsoncodehk wants to merge 10 commits into
masterfrom
refactor/full-migration

Conversation

@johnsoncodehk
Copy link
Copy Markdown
Owner

Summary

Per request to fully migrate hand-written classes to the SHAPES factory now (rather than incrementally over time), this PR pushes the migration program forward by extending the factory DSL and converting 32 more hand-written classes to declarative entries.

Hand-written classes: 95 → 63 (down 32)
SHAPES entries: 57 → 93 (up 36, +5 routers)
Net diff: −300 lines

Factory DSL extensions

The existing type / slots / defaults / consts / whenAbsent DSL absorbs most mechanical patterns. Four new features unlock the rest:

Feature Use
type: (tsNode) => KnownEstreeType One TS kind → multiple ESTree variants (e.g. PrefixUnaryExpressionUnaryExpression vs UpdateExpression based on operator)
range: (tsNode, ctx) => [start, end] Classes that customise the default tsNode-derived range
init: (instance, tsNode) => void Post-construction setup needing the constructed instance — re-parenting wrapped nodes, registering extra cache entries, defining instance-level getters
defineShapeRouter(kind, route) Context-aware dispatch — same TS kind → different class based on dispatch-time info (parent kind, modifiers, isExportEquals). First-class SHAPES entries

consts(tsNode, instance) — second arg lets consts callbacks read other already-set fields if needed.

Migrated classes (32 total)

Control flow / statements
CatchClauseNode, ForInStatementNode, ForOfStatementNode, SwitchStatementNode, SwitchCaseNode (CaseClause + DefaultClause), BreakOrContinueNode (BreakStatement + ContinueStatement), BlockStatementNode

Expressions
NewExpressionNode, TaggedTemplateExpressionNode, ShorthandPropertyNode, RegExpLiteralNode, NoSubstitutionTemplateNode

Imports / exports (via routers)
ImportSpecifierNode, ImportDeclarationNode, ExportSpecifierNode, ExportNamedDeclarationNode + ExportAllDeclarationNode (router on SK.ExportDeclaration), ExportDefaultDeclarationNode + TSExportAssignmentNode (router on SK.ExportAssignment)

TS types
TSPropertySignatureNode, TSMethodSignatureNode, TSFunctionTypeNode, TSCallishSignatureNode (CallSignature + ConstructSignature), TSMappedTypeNode, TSTypePredicateNode, TSNamedTupleMemberNode, TSTypeOperatorNode, plus a new entry for ConstructorType (was falling through to GenericTSNode)

Structural / declarative
TSEnumDeclarationNode, TSInterfaceDeclarationNode, ArrayPatternNode, VariableDeclarationListAsNode

JSX
JSXAttributeNode, JSXFragmentNode, JSXNamespacedNameNode

What remains

63 hand-written classes still in place. Categories:

  • Multi-context dispatch (need router refactor) — ClassNode, MethodDefinitionNode / ObjectMethodPropertyNode / ObjectAccessorPropertyNode, PropertyDefinitionNode, BindingElementNode, UnaryLikeExpressionNode, ExpressionWithTypeArgumentsNode, LiteralNode, BoolLiteralNode
  • Synthetic intermediates (already SyntheticLazyNode, but with constructor side-effects beyond what the factory exposes) — 14 classes
  • Pattern-context dispatchArrayPatternFromLiteralNode, ObjectPatternFromLiteralNode, ArrayExpressionNode, ObjectExpressionNode, RestElementFromSpreadNode, SpreadElementNode
  • Heavy stateTemplateLiteralNode, TSTemplateLiteralTypeNode, IdentifierNode (typeAnnotation mutation), ProgramNode, MetaPropertyNode, LiteralNode
  • Function-likesFunctionDeclarationNode, FunctionExpressionNode, ArrowFunctionExpressionNode, MethodFunctionExpressionNode
  • OtherMemberExpressionNode, CallExpressionNode, BinaryLikeExpressionNode, SequenceExpressionNode, JSXElementNode, JSXOpeningElementNode, JSXIdentifierNode, JSXMemberExpressionNode, JSXTextNode, JSXExpressionContainerNode, JSXSpreadChildNode, etc.

The DSL extensions in this PR are the foundation. Subsequent rounds chip through the remaining classes with the same pattern: defineShape entry → remove class → remove switch case. No new infrastructure required for the bulk; a few of the genuinely-pattern-dispatched cases will need additional routers.

Test plan

  • predicate-coverage (152/152 covered)
  • lazy-estree.test: 15-fixture parity sweep + dogfood corpus (~64.5k type / parent asserts) — all pass
  • scope-compat, selector-analysis, ts-ast-scan, compat-pipeline, jsx-react-x — all pass
  • dogfood.js — 30 files × 107 rules, 0 divergences, 0 crashes
  • Dify cold lint within noise of master

The 15-fixture parity sweep covers known bug classes; once it passes,
the next drift surface is uncovered slot/wrapper combinations in real
production code. Replay the same node-level invariant across the
dogfood corpus — every TS file in the monorepo, ~65k type asserts and
~64.7k parent asserts.

Six bugs surfaced by the broader sweep, fixed in this PR:

1. PropertyDeclaration / MethodDeclaration / GetAccessor missing from
   TYPE_SLOT_TRIGGERS. Bottom-up materialise of a type child of a
   class field / method skipped the synthetic TSTypeAnnotation
   wrapper, landing parent on PropertyDefinition / MethodDefinition
   directly.

2. TypePredicate not in TYPE_SLOT_TRIGGERS. `function f(x): x is T`
   has a nested TSTypeAnnotation around the inner type; without the
   trigger, bottom-up of T landed parent on TSTypePredicate without
   the inner wrapper.

3. AssignmentPatternNode (parameter default value) didn't re-parent
   its inner binding. `function f(x = 1)` — the bottom-up Identifier
   x reported parent.type === 'FunctionDeclaration' instead of
   'AssignmentPattern'. Same fix pattern as
   BindingAssignmentPatternNode (which was already correct).

4. NamedExports / TemplateSpan missing from SKIP_AS_PARENT. ESTree
   flattens both — the wrappers have no ESTree counterpart — but the
   walker built them as GenericTSNode, producing 'TSNamedExports' /
   'TSTemplateSpan' parent types.

5. MappedType TypeParameter scaffolding. `type X = { [K in keyof T]: ... }`
   — typescript-estree exposes K bare on TSMappedType.key, but lazy
   built a TSTypeParameter wrapper from SHAPES dispatch when the
   walker climbed through ts.TypeParameter. Skip TypeParameter as
   parent when its TS parent is MappedType.

6. ImportType argument LiteralType. `import('foo')` — eager exposes
   the bare StringLiteral as TSImportType.argument; lazy wrapped it
   in TSLiteralType. Skip the wrapper in SKIP_AS_PARENT when the TS
   parent is ImportType, plus flatten the top-down argument getter
   to skip the same wrapper. Drill TSTypeQuery.exprName for the
   typeof-import case.

Three documented divergences carry forward as accept-skips in the
sweep — each is a known scaffolding choice or missing wrapper that
the rule corpus doesn't observe:

- ChainExpression scaffolding (lazy single outer wrapper vs eager
  per-link)
- TSInterfaceHeritage missing (interface extends Y wraps Y in eager;
  lazy emits the bare Identifier)
- TSAssertClause missing (import-assertion wraps entries in eager;
  lazy emits ImportAttribute children directly)

The skip filter is structural: when a known wrapper is missing on
either side, the comparison is skipped at parent AND grandparent
levels (otherwise the off-by-one ripples). Re-investigating these
gaps later is straightforward — adding the wrapper class +
removing the skip entry.

Tested: predicate-coverage, lazy-estree, scope-compat, selector-
analysis, ts-ast-scan, compat-pipeline, jsx-react-x — all pass.
Bench/dogfood: 0 divergences across 30 files × 107 rules. Dify cold
4.9-5.8s on this branch vs 5.1-5.5s on master — within noise.
Each class had only `type` plus simple convertChild/convertChildren
slot getters — exactly the mechanical pattern the factory was designed
to absorb. Migration eliminates 11 hand-written subclass + switch-case
pairs and pushes them through the single-source-of-truth registry that
both top-down getters and bottom-up materialise consult.

Migrated:

- Pure type tag: SuperNode, ThisExpressionNode, TSThisTypeNode,
  EmptyStatementNode, JSXOpeningFragmentNode, JSXClosingFragmentNode
- defaults (static fields): NullLiteralNode (value/raw)
- consts (derived from tsNode): PrivateIdentifierNode (name)
- single-slot convertChild: JSXSpreadAttributeNode
- slot via callback: JSXClosingElementNode (convertJSXTagName),
  StaticBlockNode (decorators default + body via callback for nested
  block.statements access)

Hand-written class count 91 → 81; SHAPES entries 46 → 57. Net -56 lines.

Tested: predicate-coverage / lazy-estree / scope-compat /
selector-analysis / ts-ast-scan / compat-pipeline / jsx-react-x +
dogfood (107 rules × 30 files clean) + Dify cold/warm bench (no
regression).
Factory DSL extensions:
- `type` accepts `(tsNode) => KnownEstreeType` for kinds with multiple
  ESTree variants (e.g. PrefixUnaryExpression → UnaryExpression vs
  UpdateExpression based on operator).
- `range: (tsNode, ctx) => [start, end]` for classes that customize the
  default tsNode-derived range.
- `init: (instance, tsNode) => void` for post-construction setup that
  needs the constructed instance — re-parenting wrapped nodes,
  registering extra cache entries, defining instance-level getters.
- `consts(tsNode, instance)` — second argument lets consts callbacks
  read other already-set fields if needed.
- `defineShapeRouter(kind, route)` for context-aware dispatch — same
  TS kind → different ESTree class based on dispatch-time info
  (parent kind, modifiers, isExportEquals). Routers are first-class
  SHAPES entries; dispatch flows through the same path as fixed
  classes.

24 hand-written classes migrated to SHAPES (down 95 → 71):

Statement/control flow:
  CatchClauseNode, ForInStatementNode, ForOfStatementNode,
  SwitchStatementNode, SwitchCaseNode (CaseClause + DefaultClause),
  BreakOrContinueNode (BreakStatement + ContinueStatement)

Expressions:
  NewExpressionNode, TaggedTemplateExpressionNode, ShorthandPropertyNode,
  RegExpLiteralNode

Imports/exports (via routers):
  ImportSpecifierNode, ExportSpecifierNode,
  ExportNamedDeclarationNode, ExportAllDeclarationNode (router on
  SK.ExportDeclaration based on exportClause kind),
  ExportDefaultDeclarationNode, TSExportAssignmentNode (router on
  SK.ExportAssignment based on isExportEquals)

TS types:
  TSPropertySignatureNode, TSMethodSignatureNode, TSFunctionTypeNode,
  TSCallishSignatureNode (CallSignature + ConstructSignature),
  ConstructorType (new entry — was falling through to GenericTSNode),
  TSMappedTypeNode, TSTypePredicateNode, TSNamedTupleMemberNode

JSX:
  JSXAttributeNode, JSXFragmentNode, JSXNamespacedNameNode

Tested: predicate-coverage / lazy-estree / scope-compat /
selector-analysis / ts-ast-scan / compat-pipeline + dogfood
(107 rules × 30 files clean) + parity sweep (15 fixtures + 30 dogfood
files, 67k type asserts + 66k parent asserts — all pass).
Dify cold ~7s on this branch (within noise of master).

Net diff: -200 lines. 71 hand-written classes remain (down from 95);
SHAPES table at 85 entries (up from 57). Follow-up rounds can chip
through the rest using the same DSL.
…tural)

Hand-written classes 71 → 63; SHAPES entries 85 → 93.

Migrated:
- TSTypeOperatorNode → defineShape with operator const + typeAnnotation slot
- ArrayPatternNode → defineShape with elements via callback (handles
  OmittedExpression sentinel)
- VariableDeclarationListAsNode → defineShape with kind const (var/let/
  const/using/await using from NodeFlags)
- BlockStatementNode → defineShape with body via callback (function-like
  parents allow leading-string directives)
- TSEnumDeclarationNode → defineShape with body wrapper init for the
  TSEnumBody synthetic intermediate
- TSInterfaceDeclarationNode → defineShape with extends/body/typeParameters
  slots; uses `tsField: 'name'` (always present) so via callbacks always
  run even when heritageClauses/members are absent
- NoSubstitutionTemplateNode → defineShape with init hook for lazy
  TemplateElement quasi synthesis
- ImportDeclarationNode → defineShape with specifiers/attributes via
  callbacks; routes to existing convertChild for ImportClause +
  NamespaceImport instead of synthesising plain objects inline.
  `assertions` exposed as deprecated alias getter via init.

Tested: predicate-coverage / lazy-estree (parity sweep + dogfood, ~64.5k
type / parent asserts) / scope-compat / selector-analysis / ts-ast-scan
/ compat-pipeline / bench / dogfood — all pass.
… % done)

Migrated via SHAPES (some via routers using `defineShapeRouter`):

Pattern routers (allowPattern context):
- ArrayLiteralExpression → ArrayExpression vs ArrayPatternFromLiteral
- ObjectLiteralExpression → ObjectExpression vs ObjectPatternFromLiteral
- SpreadElement / SpreadAssignment → SpreadElement vs RestElementFromSpread
- NamedTupleMember → TSNamedTupleMember vs TSRestType (rest case wraps)

Multi-type via discriminator function:
- NumericLiteral / StringLiteral → Literal (with parent-aware unescape for
  JSX attribute strings)
- TrueKeyword / FalseKeyword → Literal { value, raw }
- PrefixUnaryExpression / PostfixUnaryExpression → UpdateExpression vs
  UnaryExpression based on operator
- BinaryExpression → SequenceExpression (comma) vs BinaryExpression /
  LogicalExpression / AssignmentExpression (others)

Routers wrapping with chain / type query:
- PropertyAccessExpression / ElementAccessExpression → MemberExpression +
  wrapChainIfNeeded for optional-chain semantics
- CallExpression → ImportExpression (import keyword) vs CallExpression +
  chain wrap
- LiteralType → bare TSNullKeyword (when literal is NullKeyword) vs
  TSLiteralType (others), preserving the dual cache registration
- ImportType → wrap in TSTypeQuery when isTypeOf

Other migrations (full SHAPES):
- Identifier — defaults for decorators/optional/typeAnnotation
- VariableStatement / VariableDeclaration — declarationList kind
  computation + id-with-typeAnnotation init
- MetaProperty — synthesized meta plain object via init hook
- TemplateExpression / TemplateLiteralType — quasis synthesis via init
  hook (shared `buildTemplateQuasis` helper)
- JsxText, JsxExpression (router for spread vs container with synthetic
  empty)

`wrapChainIfNeeded` signature widened to accept LazyNode (callers now
flow through the SHAPES dispatch path, where the pre-wrap class identity
is structural only).

Tested: predicate-coverage / lazy-estree (parity sweep + dogfood ~64.5k
asserts) / scope-compat / selector-analysis / ts-ast-scan /
compat-pipeline / bench / dogfood — all pass.

Hand-written count: 95 → 36 (71 % migrated). SHAPES table at 118
entries plus context-aware routers.
5 more migrations (95 → 31 left, 75 % done):

- ImportDefaultSpecifierNode → defineShape with custom range function
  (narrows to local name's range)
- FunctionDeclarationNode → defineShape with type discriminator
  (FunctionDeclaration vs TSDeclareFunction when body is absent)
- FunctionExpressionNode → defineShape (id + typeParameters + params +
  body + returnType slots, async/generator consts)
- ArrowFunctionExpressionNode → defineShape with `expression` const
  derived from body kind
- ClassNode → defineShapeRouter dispatching to ClassDeclaration vs
  ClassExpression shapes; uses `tsField: 'members'` (always defined)
  for slots that derive from heritageClauses/modifiers so the factory's
  null-short-circuit doesn't bypass the via callback. Implements
  clauses delegate to a small `classImplementsShape` helper for the
  `TSClassImplements` wrapper around each entry.

Hand-written count: 36 → 31. SHAPES table at 124 entries.
…eArguments

6 more migrations (95 → 25 left, 74 % done):

- ExpressionWithTypeArgumentsNode → defineShape with type discriminator
  inspecting the TS parent (HeritageClause + class/interface owner) to
  pick TSInterfaceHeritage / TSClassImplements / TSInstantiationExpression
- PropertyDefinitionNode → defineShape with type discriminator on
  modifiers (PropertyDefinition / TSAbstractPropertyDefinition /
  AccessorProperty / TSAbstractAccessorProperty)
- BindingElementNode → defineShapeRouter splitting object-binding
  (Property / RestElement) from array-binding (collapses to inner /
  RestElement / AssignmentPattern wrapping)
- MethodDeclaration → router: Property{method:true} in object literals,
  MethodDefinition in classes
- GetAccessor / SetAccessor → router: Property{kind:'get'|'set'} in
  object literals, MethodDefinition in classes
- Constructor → unconditionally MethodDefinition

Method shapes use `tsField: 'parameters'` (always defined for all four
kinds including Constructor which has no `name`) so the via callbacks
that derive key/value/decorators always run.

Hand-written count: 31 → 25. SHAPES table at 131 entries.
JSXElementNode → defineShapeRouter on SK.JsxElement and
SK.JsxSelfClosingElement. The shared shape covers both kinds; getters
inspect this._ts.kind to pick between the JsxElement path (real
opening / closing / children) and the JsxSelfClosingElement path
(synthetic JSXOpeningElement, null closing, empty children).

Hand-written count: 25 → 24. SHAPES table at 133 entries plus 14
context-aware routers.

Remaining 24 hand-written classes are categorically different:
- 14 SyntheticLazyNode subclasses (TSTypeAnnotation, ClassBody,
  MethodFunctionExpression, ChainExpressionWrapping, etc.) — these are
  constructed with extra arguments from inside other SHAPES classes /
  helpers, not dispatched via convertChildInner. The factory's
  one-class-per-SK model doesn't fit them; would need a separate
  registration path for "synthetic-from-parent-getter" classes.
- 4 JSX classes (JSXOpeningElement, JSXIdentifier, JSXMemberExpression,
  JSXNamespacedName) — dispatched via convertJSXTagName /
  convertJSXNamespaceOrIdentifier helpers rather than convertChildInner.
  Migrating requires updating those helpers to go through SHAPES.
- 6 special-purpose / synthetic: GenericTSNode (catch-all),
  ProgramNode (root, created by convertLazy), TypeKeywordNode (synthetic
  with external `type` arg), RestElementNode + AssignmentPatternNode +
  TSParameterPropertyNode (created by convertParameter helper from
  Parameter dispatch).
…95 → 9 left, 91 % done)

Factory DSL extension:
- `registersInMaps?: (tsNode) => boolean` lets a factory class opt out
  of cache registration like SyntheticLazyNode does. Hybrid classes
  (JSXOpeningElement: real for JsxOpeningElement, synthetic for the
  synthetic JsxSelfClosingElement opening) use it directly.

Migrations (15 more — runs 10-12):
- JSXOpeningElementNode → factory shape with registersInMaps based on
  wrapped TS kind
- JSXIdentifierNode + JSXMemberExpressionNode + JSXNamespacedNameNode →
  factory shapes; convertJSXTagName / convertJSXNamespaceOrIdentifier
  helpers updated to instantiate them. SHAPES dispatch on
  JsxNamespacedName routes to the same shape.
- JSXElementNode → router on JsxElement / JsxSelfClosingElement
- ConstructorKeyIdentifierNode, JSXEmptyExpressionNode → factory shapes
  (synthetic via registersInMaps:false)
- MethodFunctionExpressionNode → factory shape
- RestElementNode, AssignmentPatternNode → factory shapes; convertParameter
  builds the inner via the factory's slot getter (which routes through
  convertChild's cache, so subsequent typeAnnotation attachment shares
  the same instance)
- ClassBodyNode, TSEnumBodyNode, TSInterfaceBodyNode, BindingAssignmentPatternNode,
  TSTypeAnnotationNode → factory shapes (synthetic, range computed
  from tsNode where possible — TSTypeAnnotation's range still depends
  on the parent kind, so `convertTypeAnnotation` helper sets it after
  construction)
- TypeKeywordNode → factory shape (`type` derived from
  `'TS' + ts.SyntaxKind[kind]`); kept the legacy callable signature so
  the explicit `type` argument still wins for the convertLiteralType
  null edge case

Hand-written count: 24 → 9. SHAPES + makeShapeClass instantiations
total 134 + ~20 helper-built shapes.

Remaining 9 hand-written classes are intrinsically constructor-arg-based
or special-purpose:
- ChainExpressionWrappingNode, TSTypeQueryWrappingNode,
  ExportNamedWrappingNode, ExportDefaultWrappingNode,
  TSParameterPropertyNode — each takes a pre-built inner LazyNode as
  a constructor arg; the wrapping happens AFTER the inner is converted.
  Migrating would require a different mechanism than the factory's
  (tsNode, parent) signature.
- TSTypeParameterDeclarationNode, TSTypeParameterInstantiationNode —
  take a NodeArray (not a Node), no SK to dispatch from.
- GenericTSNode (catch-all fallback), ProgramNode (root, created by
  convertLazy directly).

Tested: predicate-coverage / lazy-estree (parity sweep + dogfood,
~64.5k asserts) / scope-compat / selector-analysis / ts-ast-scan /
compat-pipeline / bench / dogfood — all pass.
… left, 93 % done)

Both fit (tsNode, parent) cleanly — they're real ESTree classes, not
synthetic helpers. With these two migrated, every "real ESTree class"
that's dispatched via convertChildInner / SHAPES now flows through the
factory; the 7 hand-written classes that remain are all
construct-with-extras helpers (5 wrappers taking a pre-built inner
LazyNode + 2 NodeArray-based TypeParameter declarations).

DSL extension: factory class constructor accepts an optional
`context?: ConvertContext` 3rd arg, passed through to LazyNode's super.
Required for ProgramNode (root, no parent to inherit ctx from) and
GenericTSNode (used as materialise's fallback when the TS parent chain
is exhausted).

GenericTSNode → makeShapeClass with `type: tn => 'TS' + SK[tn.kind]`
discriminator and the GENERIC_TS_NODE_MARKER as a defaults entry.

ProgramNode → makeShapeClass with sourceType/comments/tokens consts
(per-instance arrays — comments/tokens get mutated by SourceCode), a
custom range that ends at endOfFileToken, and a body slot via
convertBodyWithDirectives. Public-API ProgramShape type alias for
callers that read .body / .sourceType.

Also removes the now-dead `case SK.SourceFile:` in convertChildInner —
SourceFile only appears at the root, never as a convertChildInner
child.

Hand-written count: 9 → 7. Remaining are all "wrapper-with-extras" that
intrinsically need the inner ESTree as a constructor arg:
- ChainExpressionWrappingNode, TSTypeQueryWrappingNode,
  ExportNamedWrappingNode, ExportDefaultWrappingNode,
  TSParameterPropertyNode (5 wrappers)
- TSTypeParameterDeclarationNode, TSTypeParameterInstantiationNode
  (NodeArray-based, no SK to dispatch from)

Tested: predicate-coverage / lazy-estree (parity sweep + dogfood,
~64.5k asserts) / scope-compat / selector-analysis / ts-ast-scan /
compat-pipeline / bench / dogfood — all pass.
@johnsoncodehk
Copy link
Copy Markdown
Owner Author

Superseded by #95 — squashed and consolidated.

@johnsoncodehk johnsoncodehk deleted the refactor/full-migration branch May 27, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant