compat-eslint: factory DSL phase 1 — defaults / via-callback / whenAbsent + 16 shape migrations#89
Merged
Merged
Conversation
…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.
This was referenced May 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Phase 1 of the 4-phase plan toward A3 root-cause termination. Extends the
defineShapefactory'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
ShapeDefoptions:defaults: Record<string, unknown>UnaryExpression.operator: 'void',ObjectPattern.decorators: EMPTY_ARRAY)readonly x = '...'class fieldsShapeSlotDef.via: functionconvertTypeAnnotation,convertTypeArguments,convertTypeParameters) instead of the stock convertChild familyt ? convertX(t, this) : ...getter bodiesShapeSlotDef.whenAbsent: 'null' | 'undefined'returnType,typeAnnotation) use 'undefined'; value slots default to 'null': undefinedarm oft ? ... : undefinedBacked by a
SHAPE_UNSETsentinel 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→ TSAsExpressionExpressionStatement(withdirectivedefault)TypeQuery→ TSTypeQueryNamespaceImport→ ImportNamespaceSpecifierObjectBindingPattern→ ObjectPatternVoidExpression→ 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
convertChildInnerwith onedefineShape({ ... })entry.Status
SHAPESentriesLazyNodesubclasseslib/lazy-estree.tslines15 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:
defineSyntheticregistry. Phase 3.viacallback that can also readthis(currentviais pure (tsValue, parent) → result). Phase 2.Test plan
pnpm buildclean