Skip to content

compat-eslint: factory DSL phase 1 — defaults / via-callback / whenAbsent + 16 shape migrations#89

Merged
johnsoncodehk merged 4 commits into
masterfrom
refactor/factory-dsl-phase1
May 2, 2026
Merged

compat-eslint: factory DSL phase 1 — defaults / via-callback / whenAbsent + 16 shape migrations#89
johnsoncodehk merged 4 commits into
masterfrom
refactor/factory-dsl-phase1

Conversation

@johnsoncodehk
Copy link
Copy Markdown
Owner

Why

Phase 1 of the 4-phase plan toward A3 root-cause termination. Extends the defineShape factory's DSL with capabilities that unlock medium-complexity LazyNode subclasses, then migrates a meaningful batch.

Each migrated shape moves from "hand-written subclass + switch case in convertChildInner (two places to keep in sync)" to "one declarative table entry — top-down getter and bottom-up dispatch read the same source". For these kinds, drift is structurally impossible.

What's here

Factory DSL extensions

Three new ShapeDef options:

Option Use case Replaces hand-written
defaults: Record<string, unknown> Static field values that don't depend on the TS node (e.g. UnaryExpression.operator: 'void', ObjectPattern.decorators: EMPTY_ARRAY) readonly x = '...' class fields
ShapeSlotDef.via: function Custom converter for slots that route through helpers (convertTypeAnnotation, convertTypeArguments, convertTypeParameters) instead of the stock convertChild family t ? convertX(t, this) : ... getter bodies
ShapeSlotDef.whenAbsent: 'null' | 'undefined' Per-slot null-vs-undefined choice. Type-position slots (returnType, typeAnnotation) use 'undefined'; value slots default to 'null' The : undefined arm of t ? ... : undefined

Backed by a SHAPE_UNSET sentinel so the cache distinguishes "not yet computed" from "computed and got null/undefined" — the existing ??= pattern in hand-written getters conflates the two.

16 new migrations

8 mechanical-with-defaults:

  • AsExpression → TSAsExpression
  • ExpressionStatement (with directive default)
  • TypeQuery → TSTypeQuery
  • NamespaceImport → ImportNamespaceSpecifier
  • ObjectBindingPattern → ObjectPattern
  • VoidExpression → UnaryExpression(void)
  • DeleteExpression → UnaryExpression(delete)
  • TypeOfExpression → UnaryExpression(typeof)

8 with type-position slots via custom converter:

  • TypeReference (typeArguments via convertTypeArguments)
  • ConstructorType (abstract from modifiers + 3 type slots)
  • ModuleDeclaration (declare/global/kind from flags)
  • EnumMember (computed from name kind)
  • TypeAliasDeclaration (declare from modifiers)
  • ImportEqualsDeclaration (importKind from isTypeOnly)
  • IndexSignature (readonly/static from modifiers)
  • PropertyAssignment (kind/method/optional/shorthand defaults + computed)

Each replaces a 5-30-line hand-written class + a switch case in convertChildInner with one defineShape({ ... }) entry.

Status

Before this PR After
SHAPES entries 30 46
Hand-written LazyNode subclasses 122 107
lib/lazy-estree.ts lines ~4900 ~4700

15 net subclasses migrated this PR (one was an existing in-place edit that didn't move counts). Phase 1's target was 30-50 — falling short because most remaining hand-written classes need factory capabilities not yet added (synthetic intermediates, conditional getters, range mutation hooks, multi-field-to-single-slot mappings). Those are Phase 2/3.

What's NOT migratable yet

For the curious — what's blocking each remaining class category:

  • Synthetic intermediates (JSX self-closing's openingElement, ChainExpression wrapping, ExportNamed/DefaultWrapping, TSTypeAnnotation, BindingAssignmentPattern): need a defineSynthetic registry. Phase 3.
  • Conditional getter logic (Block.body's directive handling, MethodDefinition's value computation): need a via callback that can also read this (current via is pure (tsValue, parent) → result). Phase 2.
  • Range mutation in constructor (some classes set range to include synthetic positions): need a constructor hook. Phase 2.
  • Multi-field source (one ESTree slot computed from multiple TS fields): need source callback. Phase 2.
  • Special-case dispatch (LiteralType's null special case, ObjectLiteralExpression's pattern-aware dispatch, ExportAssignment's helper): can't move to SHAPES because dispatch isn't pure-kind. Stay hand-written but get auto-registered in SYNTHETICS table in Phase 3.

Test plan

  • pnpm build clean
  • All 6 compat-eslint suites pass — predicate-coverage 152/152, scope-compat 24/24, lazy-estree (incl. bottom-up parity sweep + phantom-types invariant), ts-ast-scan, selector-analysis, compat-pipeline
  • Self-lint clean: 6 packages, 0 messages

…Absent

Phase 1 of the A3 follow-up: extend defineShape's capabilities so
more hand-written subclasses become declarative.

Three new ShapeDef options:

  defaults: Record<string, unknown>
    Per-instance static field assignment (matches `readonly x = '...'`
    class-field semantics). Unlocks shapes whose ESTree side has
    fixed-value fields like UnaryExpression's `operator: 'void'`,
    ObjectPattern's `decorators: EMPTY_ARRAY`.

  ShapeSlotDef.via: function
    Custom converter callback for slots that go through helper
    functions (`convertTypeAnnotation`, `convertTypeArguments`,
    `convertJSXTagName`, etc.) instead of the stock convertChild
    family. Unlocks type-position slots that wrap in synthetic
    intermediates.

  ShapeSlotDef.whenAbsent: 'null' | 'undefined'
    Per-slot absent-value choice (default 'null'). Type-position
    slots like `returnType` / `typeAnnotation` use 'undefined' to
    match typescript-estree's distinction. Backed by SHAPE_UNSET
    sentinel so the cache distinguishes "not yet computed" from
    "computed and got null/undefined" — the existing `??=` pattern
    in hand-written getters conflates the two.

8 new mechanical-with-defaults migrations using the new capabilities:

  AsExpression → TSAsExpression
  ExpressionStatement (with directive default)
  TypeQuery → TSTypeQuery (with typeArguments default)
  NamespaceImport → ImportNamespaceSpecifier
  ObjectBindingPattern → ObjectPattern (with decorators / optional /
    typeAnnotation defaults)
  VoidExpression → UnaryExpression(void)
  DeleteExpression → UnaryExpression(delete)
  TypeOfExpression → UnaryExpression(typeof)

Total: 38 SHAPES entries (up from 30), 115 hand-written subclasses
(down from 123). Net -23 lines on top of merged baseline.

All compat-eslint suites pass.
…back

Migrates 8 more hand-written subclasses using the via-callback +
whenAbsent='undefined' factory capabilities added in the previous
commit:

  TypeReference            (typeArguments via convertTypeArguments)
  ConstructorType          (typeParameters / returnType)
  ModuleDeclaration        (declare/global/kind from flags)
  EnumMember               (computed from name kind)
  TypeAliasDeclaration     (declare from modifiers)
  ImportEqualsDeclaration  (importKind from isTypeOnly)
  IndexSignature           (readonly/static from modifiers)
  PropertyAssignment       (kind/method/optional/shorthand defaults
                            + computed from name kind)

Each replaces a hand-written class + switch case with a declarative
defineShape entry. Custom converter callbacks (convertTypeArguments,
convertTypeParameters, convertTypeAnnotation) plug in via the slot's
`via` option without bespoke factory extensions.

Status: 46 shapes (up from 38), 107 hand-written subclasses (down
from 115). Net -94 lines this commit.

All compat-eslint suites pass.
Addresses review on PR #89:

  defineShape<TsT extends ts.Node>(...): each call site now binds the
  TS node type via generic. tsField is constrained to keys of TsT, so
  typos like `tsField: 'thenSttement'` fail at compile time. consts
  callback parameter is auto-inferred (no per-shape `(tn: ts.X) =>`
  annotation needed).

  46 defineShape sites updated with their concrete TS type parameter.
  All build + tests still pass.

  Two focused factory unit tests in lazy-estree.test:
    - TSTypeQuery.typeArguments returns undefined (not null) for absent
      slot, AND is an own-property (eager parity), AND memoises on
      second read.
    - ReturnStatement.argument returns null (not undefined) for bare
      `return;` — the default whenAbsent='null' is honoured.

Also confirmed via Dify bench that there's no perf regression — cold
~11s (improved vs PR #88's 13s baseline; the earlier 14s reading was
disk warmup), warm ~6s. The factory's hidden-class shape transitions
land on V8's monomorphic happy path.
@johnsoncodehk johnsoncodehk merged commit bff2c19 into master May 2, 2026
1 check passed
@johnsoncodehk johnsoncodehk deleted the refactor/factory-dsl-phase1 branch May 27, 2026 17:28
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