Skip to content

compat-eslint: KnownEstreeType union β€” compile-time gate against phantom types#90

Merged
johnsoncodehk merged 3 commits into
masterfrom
refactor/factory-dsl-phase2
May 2, 2026
Merged

compat-eslint: KnownEstreeType union β€” compile-time gate against phantom types#90
johnsoncodehk merged 3 commits into
masterfrom
refactor/factory-dsl-phase2

Conversation

@johnsoncodehk
Copy link
Copy Markdown
Owner

Why

The bug class fixed by PR #88 β€” phantom ESTree types like 'TSJsxAttributes' slipping through hand-written subclasses β€” was caught by runtime tests. PRs #88 and #89 reduced the surface but the test gate remained the only line of defence: any future subclass could still declare readonly type = 'X' as const for an X that doesn't exist in typescript-estree's spec, and it'd compile fine.

This PR closes that hole at the type level. Every concrete LazyNode subclass's type literal must now be a member of KnownEstreeType β€” a string-literal union enumerating the ~135 ESTree types lazy-estree is allowed to produce. Adding a new shape requires editing the union first (explicit, reviewable). Phantom types are now a compile error, not a silent runtime bug.

The gate caught one already

When the constraint was added on the existing codebase, TypeScript immediately flagged AccessorProperty on PropertyDefinitionNode's type union β€” exactly the kind of "obscure type literal not in spec" that the gate exists to catch. (Turned out to be a real ESTree type; added it to the union.) Validates the design: had this gate existed before PR #88, the original 'TSJsxAttributes' regression would have failed at pnpm build instead of needing a runtime parity test to surface.

What's constrained

abstract class LazyNode {
  abstract readonly type: KnownEstreeType;  // ← was: string
  ...
}

interface ShapeDef<TsT extends ts.Node = ts.Node> {
  type: KnownEstreeType;  // ← was: string
  ...
}

class TypeKeywordNode extends LazyNode {
  readonly type: TypeKeyword;  // ← was: string; 13-entry subset
  constructor(type: TypeKeyword, ...);
}

KnownEstreeType is built from typescript-estree's published node-type list:

  • 70 core ESTree (Identifier, BlockStatement, …)
  • 15 JSX (JSXAttribute, JSXOpeningElement, …)
  • 50 TS-prefixed composite (TSAsExpression, TSTypeReference, …)
  • 13 TS keyword (TSAnyKeyword, TSStringKeyword, …)

GenericTSNode is the deliberate phantom-output safety valve. Its type is dynamic ('TS' + ts.SyntaxKind[kind]) and casts as KnownEstreeType β€” most produced types ARE in the union (TSEnumDeclaration, TSImportType, etc.), but some (TSJsxAttributes, TSEndOfFileToken) are not. The runtime phantom-types invariant in lazy-estree.test (already in master) asserts these never reach a position rules can observe β€” they exist only as transient walk-up objects before being shadowed by a real subclass. The compile-time gate + runtime invariant together cover the whole drift surface.

Bug class status after this PR

Bug Surface
Subclass declares phantom type literal compile fail βœ“
Factory defineShape declares phantom type compile fail βœ“
TypeKeywordNode constructed with non-keyword compile fail βœ“
GenericTSNode produces phantom transiently runtime invariant test catches downstream observability βœ“
Subclass introduces synthetic intermediate without registering in navigation tables runtime parity sweep catches (not yet compile-time β€” Phase 3)

What's NOT in this PR

  • Phase 3 (synthetic-intermediate registry) β€” would extend the gate so even GenericTSNode-style dynamic types must be enumerated, AND every synthetic-creating hand-written subclass declares its synthetics in a registry that the navigation tables auto-derive from. Separate branch.
  • More shape migrations β€” none here. The phase 2 plan included "conditional getter via callback that reads this" + "range mutation hook" capabilities, but the audit showed the marginal class is increasingly complex; pivoted this branch to the higher-leverage architectural gate. Future migrations land as separate small PRs as needed.

Test plan

  • pnpm build clean (compile-time gate active)
  • All 6 compat-eslint suites pass: predicate-coverage 152/152, scope-compat 24/24, lazy-estree (incl. bottom-up parity sweep + phantom-types invariant + 5 new whenAbsent / generic-typed migration tests from compat-eslint: factory DSL phase 1 β€” defaults / via-callback / whenAbsent + 16 shape migrationsΒ #89), ts-ast-scan, selector-analysis, compat-pipeline
  • Self-lint clean: 6 packages Γ— 51 files Γ— 0 messages
  • No new runtime cost β€” KnownEstreeType is a TS-only construct, erased at compile time

… gate

Introduces a `KnownEstreeType` string-literal union listing every
ESTree type lazy-estree is allowed to produce (~135 entries: core
ESTree + JSX + TS-prefixed types + 13 TS keyword types). Used to
constrain three places:

  - `LazyNode.type` (abstract): every concrete subclass's `type`
    literal must be in the union or compilation fails.
  - `defineShape({ type, ... })`: same for factory-built shapes.
  - `TypeKeywordNode` constructor parameter narrowed from `string`
    to a 13-entry `TypeKeyword` subset so the dynamic dispatch
    can't accept a non-keyword name.

When the gate was added it caught one missing entry (`AccessorProperty`
on PropertyDefinitionNode's union) on first build β€” exactly the bug
class this gate exists to prevent. Added it to the union; build clean.

Closes the bug class from PR #88 at the type level: the phantom
`'TSJsxAttributes'` regression that started this whole refactor
would now be a compile error, not a silent silent-replay-stale bug
that needs a parity test to catch. Runtime phantom-types invariant
(in lazy-estree.test) still backs this up for GenericTSNode's
deliberate dynamic-output escape valve, which has to use `as` to
satisfy the constraint and is exhausted by the parity sweep.

Adding a new ESTree shape now requires editing `KnownEstreeType`
first β€” explicit declaration, single source of truth.

All compat-eslint suites still pass: 152/152 predicate coverage,
24/24 scope-compat, full bottom-up parity sweep + phantom-types
invariant. Self-lint clean across 6 packages.
Addresses PR #90 review.

  Phantom-types invariant (lazy-estree.test): the hand-coded set of
  ~80 typescript-estree TS-* types is now derived from
  `@typescript-eslint/typescript-estree`'s exported `AST_NODE_TYPES`
  enum. When upstream adds a new type, the test auto-picks it up
  with no maintenance β€” and conversely, if our KnownEstreeType ever
  drifts to include a non-existent name, fixture runs catch it (any
  produced 'TS<KindName>' not in the enum is flagged as phantom).

  Drops 80 lines of hand-listed strings from the test file.

  Also: comment on TypeKeywordNode said "14 near-identical
  declarations" β€” there are 13 keyword types. Off by one.
@johnsoncodehk johnsoncodehk merged commit 9c0a2e8 into master May 2, 2026
1 check passed
@johnsoncodehk johnsoncodehk deleted the refactor/factory-dsl-phase2 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