Skip to content

refactor(core): replace ts-morph with tsgo/Corsa API#339

Draft
toiroakr wants to merge 15 commits into
mainfrom
ts7
Draft

refactor(core): replace ts-morph with tsgo/Corsa API#339
toiroakr wants to merge 15 commits into
mainfrom
ts7

Conversation

@toiroakr

@toiroakr toiroakr commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Full cutover from ts-morph to TypeScript's native tsgo/Corsa API (@typescript/native-preview) for issue #200, building on the TsHost abstraction from the earlier commit in this PR.

  • TsgoHost implements TsHost by resolving temporary type aliases through a virtual-FS overlay (the Corsa API is read-only at the AST level, so there's no live SourceFile to mutate). Each call always marks its target root file(s) as changed on the underlying project, since reusing the same virtual tsconfig path across calls with different overlay content otherwise leaves the server serving a stale parse.
  • SchemaDetector, GetterResolver, ImportResolver, BrandDetector, and SchemaReferenceAnalyzer are reimplemented against the Corsa ast/checker API.
  • ImportResolver's cross-file resolution now goes through Checker.getSymbolAtLocation (real module resolution) instead of ts-morph's getModuleSpecifierSourceFile - which incidentally fixes a known limitation resolving named imports through an intermediate re-export index file (see the updated test in import-resolver.test.ts).
  • When a tsconfig.json is supplied, its resolved CompilerOptions are sanitized before being re-embedded into our virtual tsconfig: enum fields come back from the API as raw numbers and lib entries as resolved filenames (both invalid tsconfig.json syntax), and rootDir/outDir/types/typeRoots are tied to the original project's directory layout rather than ours.
  • @typescript/native-preview moves from a dev-only dependency (previously used just for the tsgo typecheck script) to a runtime dependency. ts-morph is dropped entirely.
  • SchemaDetector/BrandDetector are no longer exported from the package - they weren't documented in the README's Library API section or used anywhere outside the package, and now that they operate on a @typescript/native-preview SourceFile, there's no supported way to construct one to call them with (TsgoHost isn't exported either). Everything they exposed is already available through ExtractResult#isExported/#brands and ZodTypeExtractor#getSchemaNames.

Behavior notes

A handful of generated snapshots changed to reflect the new checker's (arguably more correct) member ordering: object/mapped types now print in source declaration order, and z.enum([...])-derived string literal unions print alphabetically.

ZodTypeExtractor's public methods and the top-level extractZodTypes/extractAndFormat/extractAllSchemas helpers are unaffected.

Verified the CLI end-to-end against tsconfig-based projects, cross-file imports, subpath imports (package.json imports field), and multi-file glob processing.

Release status

@typescript/native-preview is still a 7.1.0-dev preview build, and we already found real checker-behavior divergences from ts-morph's classic checker during this migration (see above), so this isn't going straight to latest.

Currently published as zinfer@1.0.0-next.0 under the npm next dist-tag (npm install zinfer@next), via changesets pre-release mode (.changeset/pre.json, tag next) scoped to the ts7 branch. release.yml temporarily also triggers on push to ts7 so publishing doesn't need a separate npm Trusted Publisher entry. main's normal releases and the latest dist-tag (currently 0.2.5) are untouched.

Before merging into main

  • Run pnpm changeset pre exit on ts7
  • Remove the ts7 branch trigger from .github/workflows/release.yml (see comment left in the file)
  • Decide the real version this becomes on main/latest (currently previewing as 1.0.0)
  • Confirm no further tsgo-behavior divergences turned up while next was being tried out

Related Issues

Closes #200

toiroakr added 2 commits July 15, 2026 13:44
Move type-resolution primitives (project creation, temporary type-alias
injection/cleanup, type text resolution with enum expansion) behind a
TsHost interface implemented by TsMorphHost. Behavior is unchanged; this
narrows the seam a future tsgo/Corsa-API-based host would need to fill
per issue #200.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cde41b64-e305-4f2d-bcfb-2aab255f3437

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is misleading: this PR adds a TsHost abstraction and a ts-morph-backed host, not a replacement with tsgo/Corsa APIs. Rename it to reflect the actual change, e.g. "refactor(core): add TsHost abstraction for type resolution".
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ts7

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/zinfer@339

commit: 7b24d3f

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/extractor.ts`:
- Line 166: Wrap the imported/local extraction and post-processing phase
following host.ensureNormalizeType(sourceFile) in a try/finally, ensuring the
existing cleanup that removes __Normalize from the shared SourceFile always
runs. Preserve the current extraction behavior and error propagation while
guaranteeing cleanup when any resolution or processing step throws.

In `@src/core/ts-morph-host.ts`:
- Around line 25-32: Track the exact alias nodes inserted by ensureNormalizeType
and resolveTypes instead of identifying ownership through fixed-name lookups.
Store each newly added alias node and have cleanupNormalizeType remove only the
tracked node, preserving any pre-existing __Normalize declaration and applying
the same ownership-safe cleanup to all temporary aliases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bb9cd4f4-c7e9-4c04-94f1-373683527ba4

📥 Commits

Reviewing files that changed from the base of the PR and between d8ac7e9 and 08b0f77.

📒 Files selected for processing (4)
  • .changeset/quiet-plums-relax.md
  • src/core/extractor.ts
  • src/core/ts-host.ts
  • src/core/ts-morph-host.ts
📜 Review details
🔇 Additional comments (4)
src/core/ts-host.ts (1)

1-51: LGTM!

src/core/ts-morph-host.ts (1)

1-23: LGTM!

Also applies to: 54-133

src/core/extractor.ts (1)

1-8: LGTM!

Also applies to: 31-46, 130-144

.changeset/quiet-plums-relax.md (1)

1-5: LGTM!

Comment thread src/core/extractor.ts

// Inject __Normalize once for the main source file
this.ensureNormalizeType(sourceFile);
this.host.ensureNormalizeType(sourceFile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guarantee main-source cleanup with finally.

If any local resolution or post-processing throws, Line 271 is skipped and __Normalize remains attached to the shared SourceFile. Wrap the imported/local extraction phase in try/finally.

Proposed structure
 this.host.ensureNormalizeType(sourceFile);
-
-// Extract imported and local schemas
-this.host.cleanupNormalizeType(sourceFile);
+try {
+  // Extract imported and local schemas
+} finally {
+  this.host.cleanupNormalizeType(sourceFile);
+}

Also applies to: 168-271

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/extractor.ts` at line 166, Wrap the imported/local extraction and
post-processing phase following host.ensureNormalizeType(sourceFile) in a
try/finally, ensuring the existing cleanup that removes __Normalize from the
shared SourceFile always runs. Preserve the current extraction behavior and
error propagation while guaranteeing cleanup when any resolution or processing
step throws.

Comment thread src/core/ts-morph-host.ts Outdated
Comment on lines +25 to +32
ensureNormalizeType(sourceFile: SourceFile): void {
if (!sourceFile.getTypeAlias("__Normalize")) {
sourceFile.addStatements([NORMALIZE_TYPE_DEFINITION]);
}
}

cleanupNormalizeType(sourceFile: SourceFile): void {
sourceFile.getTypeAlias("__Normalize")?.remove();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find existing collision coverage and all fixed synthetic-name usages.
rg -n -C3 '__Normalize|__TempInput|__TempOutput|__TempExplicit' src test tests 2>/dev/null || true

Repository: toiroakr/zinfer

Length of output: 7889


Track the injected aliases, not the fixed names. cleanupNormalizeType() can delete a pre-existing __Normalize declaration because ownership is inferred from getTypeAlias("__Normalize"); the same fixed-name lookup pattern applies to the temporary aliases in resolveTypes. Keep the exact inserted nodes and remove those nodes directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ts-morph-host.ts` around lines 25 - 32, Track the exact alias nodes
inserted by ensureNormalizeType and resolveTypes instead of identifying
ownership through fixed-name lookups. Store each newly added alias node and have
cleanupNormalizeType remove only the tracked node, preserving any pre-existing
__Normalize declaration and applying the same ownership-safe cleanup to all
temporary aliases.

Full cutover from ts-morph to TypeScript's native tsgo/Corsa API
(@typescript/native-preview), completing the migration started with the
TsHost abstraction. ts-morph is dropped as a dependency entirely.

- TsgoHost implements TsHost by resolving temporary type aliases through
  a virtual-FS overlay, since the Corsa API is read-only at the AST level.
  Always marks the relevant root file(s) as changed on every project open,
  since reusing the same virtual tsconfig path across calls with different
  overlay content otherwise leaves the server serving a stale parse.
- SchemaDetector, GetterResolver, ImportResolver, BrandDetector, and
  SchemaReferenceAnalyzer are reimplemented against the Corsa ast/checker
  API. ImportResolver's cross-file resolution now goes through
  Checker.getSymbolAtLocation (real module resolution) instead of
  ts-morph's getModuleSpecifierSourceFile, incidentally fixing a known
  limitation resolving named imports through an intermediate re-export
  index file.
- When a tsconfig.json is supplied, its resolved CompilerOptions are
  sanitized before being re-embedded in the virtual tsconfig: enum fields
  come back as raw numbers and lib entries as resolved filenames (both
  invalid tsconfig.json syntax), and rootDir/outDir/types/typeRoots are
  tied to the original project's directory layout rather than ours.
- @typescript/native-preview moves from a dev-only dependency (used only
  for the tsgo typecheck script) to a runtime dependency.

A handful of generated snapshots changed to reflect the new checker's
member ordering: object/mapped types print in source declaration order,
and z.enum([...]) string literal unions print alphabetically.

SchemaDetector and BrandDetector are part of the public API and now
operate on a @typescript/native-preview SourceFile instead of a ts-morph
one. ZodTypeExtractor's public surface and the top-level extractZodTypes/
extractAndFormat/extractAllSchemas helpers are unaffected.
@toiroakr toiroakr changed the title refactor(core): introduce TsHost abstraction over ts-morph in extractor refactor(core): replace ts-morph with tsgo/Corsa API Jul 15, 2026
toiroakr and others added 12 commits July 15, 2026 22:21
Neither is documented in the README's Library API section or used
anywhere outside the package itself. Now that they operate on a
@typescript/native-preview SourceFile, there's no public, supported way
to construct one to call them with (TsgoHost isn't exported either),
so keeping them exported would be a half-usable API. Everything they
exposed is already available through ZodTypeExtractor's output
(ExtractResult#isExported/#brands) and #getSchemaNames.

Also fixes a stale README line still crediting ts-morph.
Ship the ts-morph -> tsgo/Corsa API migration as a prerelease under the
next npm dist-tag rather than straight to latest, given tsgo is still a
dev-preview build with known checker-behavior divergences from mainline
TypeScript (see PR discussion on #200). Run 'changeset pre exit' once
this has baked long enough to promote to latest.

Also drop json/jsonc from oxlint's pre-commit glob: oxlint has no
standalone JSON linting support and errors out with 'no files found'
when a commit touches only json/jsonc files (as this one does), a
pre-existing gap this changeset file happened to trip. oxfmt still
formats those extensions.
Entering pre mode on main would have swept every subsequent changeset
(not just this one) into next-tagged prereleases, blocking latest
releases entirely until someone remembered to run pre exit. Repo-wide
pre-release mode is the wrong tool here; a one-off snapshot release
(changeset version --snapshot / changeset publish --tag) is the right
way to ship just this change under a non-latest tag without touching
the normal release queue.
Add a release workflow scoped to the ts7 branch only (release-ts7.yml),
separate from main's release.yml, and enter changesets pre-release mode
(tag: next) so pushes to ts7 publish -next.N prereleases without
touching main's normal release cadence or the latest dist-tag.

Before merging ts7 into main: run 'pnpm changeset pre exit' and delete
release-ts7.yml, so main never inherits pre-release mode.
Full engine swap plus removal of public SchemaDetector/BrandDetector
exports warrants a major bump rather than continuing to ride the 0.x
minor-for-breaking-changes convention.
chore: release (ts7 preview) (next)
The dedicated release-ts7.yml workflow published under an npm OIDC
trusted-publishing identity npm doesn't recognize (trusted publishing is
scoped to this exact workflow file by name), causing a 404 on publish.
Simpler fix: just add ts7 to release.yml's own branch triggers instead
of introducing a second workflow file.

Remove the ts7 branch trigger once ts7 is merged into main.
…tructing options

Replace parseConfigFile()-based CompilerOptions reconstruction with
serving a patched copy of the real tsconfig.json at its own path: parse
it with jsonc-parser (tolerating comments/trailing commas, which real
tsconfig.json files do support - verified empirically against tsc,
tsgo, and parseConfigFile()) and append the target file to `files`,
leaving everything else - including `extends` - untouched.

This sidesteps two problems in the previous approach:
- parseConfigFile() returns fully-resolved CompilerOptions (numeric enum
  values, resolved lib filenames) that isn't valid tsconfig.json syntax
  to write back, requiring a hand-rolled reconstruction.
- Serving the config from a synthetic path elsewhere (e.g. os.tmpdir())
  broke relative extends/typeRoots resolution, which the earlier
  typeRoots workaround was compensating for.

Since the patched config is served at the *same* absolute path as the
original, tsgo resolves extends, typeRoots, and every other
config-relative path exactly as it would for the real project - no
special-casing needed. Verified against a real extends chain with
comments in the base config.

Only the no-tsconfig-given default path still serves a synthetic config
(there's no real file to preserve resolution semantics for).
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.

Plan migration off ts-morph for TypeScript 7 / tsgo compatibility

1 participant