Skip to content

Reach the TypeScript 7 AST and program through unstable/ast - #8691

Closed
knutwannheden wants to merge 2 commits into
ts7-prep-token-navigationfrom
ts7-phase1-unstable-ast
Closed

Reach the TypeScript 7 AST and program through unstable/ast#8691
knutwannheden wants to merge 2 commits into
ts7-prep-token-navigationfrom
ts7-phase1-unstable-ast

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 28, 2026

Copy link
Copy Markdown
Contributor
  • Stacked on Run the JavaScript parser on TypeScript 7 #8690, which is stacked on Upgrade rewrite-javascript to TypeScript 6.0 #8689 — review those first.

  • TypeScript 7.0 ships no compiler API. typescript's main entry is lib/version.cjs, which exports a version string; the AST, checker and program construction live behind unstable/* subpath exports that the 7.1 iteration plan does not schedule for stabilisation. This adds the two pieces the parser will need from there, each pinned by a conformance test against the TypeScript 6 tree it has to reproduce. The parser still runs on 6.

Token navigation

ts7/token-navigation.ts rebuilds the five calls 7 does not expose, from forEachChild (a Node method in 7, not a free function), createScanner, and the source text. Across the parser's own 26 sources the reconstructed token stream is identical to TypeScript 6's native one — kinds, order and offsets — once two equivalences hold:

  • EndOfFileToken is renamed EndOfFile.
  • An elision in a binding pattern (const [a, , b] = …) is a zero-width BindingElement in 7 where 6 emits an OmittedExpression. The equivalence is conditional on zero width; a non-empty BindingElement means the same thing in both.

Both are recorded in the test rather than skipped, so a deviation that is not in that table fails.

Four things had to be right for the offsets to line up, and each was found by the test rather than by reading:

  • A synthetic token's pos is its full start, so getTokenFullStart() is the source, not getTokenStart().
  • A SyntaxList is re-scanned rather than yielding its raw elements, or the leading | of | "a" | "b" disappears.
  • The SyntaxList wrapper is tagged with a Symbol. Tagging it with an elements field silently captured real nodes — NamedImports, ArrayLiteralExpression and others already have elements, so their braces and brackets vanished from the stream.
  • JSDoc is omitted entirely, which matches the parser: it sets jsDocParsingMode: ParseNone, so JSDoc never enters the tree. In 7, JSDoc is reachable only through node.jsDoc and never through forEachChild, and malformed JSDoc raises no syntactic diagnostic — which was the reason ParseNone was set. The 44 visitJSDoc* methods in parser.ts are unreachable under both compilers.

Program construction

ts7/program.ts replaces createProgram and the CompilerHost, neither of which exists in 7. The Go process owns parsing and module resolution, so a program is described to it: in-memory sources are served over filesystem callbacks, and returning undefined defers to the real filesystem. An in-memory file importing another in-memory file, and one importing @types/node from real node_modules, both resolve with full type attribution and no diagnostics. resolveModuleNameLiterals, which parser.ts overrides today because its sources are not on disk, has no counterpart to port.

Why module: node20

TypeScript 7 is ESM-only and this package is CommonJS. Under node16 every unstable/* import is TS1479 (cannot be imported with 'require'). node20 permits require(esm), which Node supports from 22.12.

That floor is now declared — engines.node and the module's CLAUDE.md, which said 18+. It constrains building and testing rather than consumers: with the ts7 modules excluded from the build, the emit under node20 is byte-identical to node16, so nothing about the published output changes.

The ts7 modules were reaching dist while typescript7 is a devDependency, which would have left an unresolvable require in the published package. Only the conformance test loads them, so tsconfig.build.json skips them.

What this deliberately does not do

The parser still runs on TypeScript 6. A visitor cannot span both compilers: kind numbers differ, and it compares node.kind against named members of one enum. Switching is therefore all-at-once, behind a single module that re-exports SyntaxKind and the type guards. Encouragingly all 109 SyntaxKind members and all the isXxx guards the parser uses exist in 7, three under consistency renames (isParameterisParameterDeclaration, and the same for isMethodSignature/isPropertySignature).

Node field drift is likewise small: 87 interfaces are field-identical, and the real changes are ImportAttributes.elements.attributes, TypeParameterDeclaration.default.defaultType, Identifier.escapedText.text, and ImportClause.isTypeOnly: boolean.phaseModifier, an enum carrying import defer/import source. Only the last needs more than a rename.

The checker is not ported here.

Full suite: 2034 passed, 24 skipped, 0 failed.

TypeScript 7 ships no compiler API: `typescript`'s main entry is a version
string, and the AST, checker and program live behind `unstable/*` subpath
exports. This adds the two pieces the parser will need there, each covered by
a conformance test against the TypeScript 6 tree it has to reproduce.

`ts7/token-navigation.ts` rebuilds getChildren/getChildAt/getChildCount/
getFirstToken/getLastToken, which 7 does not expose, from forEachChild,
createScanner and the source text. Across the parser's own 26 sources the
reconstructed token stream is identical to TypeScript 6's native one --
kinds, order and offsets -- once two equivalences are accounted for:
`EndOfFileToken` is renamed `EndOfFile`, and an elision in a binding pattern
is a zero-width `BindingElement` rather than an `OmittedExpression`.

`ts7/program.ts` replaces createProgram and the CompilerHost. The Go process
owns parsing and resolution, so in-memory sources are served over filesystem
callbacks and everything else defers to the real filesystem; node_modules and
the bundled lib files resolve without a module-resolution hook.

The compiler is on `module: node20`, since 7 is ESM-only and this package is
CommonJS. `node16` reports TS1479 on every `unstable/*` import; `node20`
permits require(esm) and leaves the emitted output CommonJS.

TypeScript 6 stays the compiler the parser runs on. The visitor cannot span
both, because kind numbers differ and it compares against named members of
one enum.
`module: node20` reaches the ESM-only `typescript7` from this CommonJS
package via require(esm), which Node supports from 22.12.

The published output is unaffected: with the ts7 modules excluded, the emit
under node20 is byte-identical to node16, so the floor constrains building
and testing rather than consumers of the package.

The ts7 modules were reaching dist while `typescript7` is a devDependency,
which would leave an unresolvable require in the published package. Only the
conformance test loads them, so the build skips them.
@knutwannheden
knutwannheden deleted the ts7-phase1-unstable-ast branch August 28, 2026 04:34
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant