Skip to content

Add TypeScript resolution support to stasis bundle (--typescript, tsconfig paths) - #168

Merged
ChALkeR merged 3 commits into
mainfrom
claude/stasis-typescript-resolution-03wt3s
Aug 29, 2026
Merged

ChALkeR merged 3 commits into
mainfrom
claude/stasis-typescript-resolution-03wt3s

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds tsc-style TypeScript resolution to the bundler via a new --typescript flag: specifiers naming compiled output (e.g. import "./x.js") resolve to their on-disk TypeScript sources (./x.ts), the way tsc resolves imports between TS sources, including through package manifests and tsconfig compilerOptions.paths aliases.

Key Changes

  • New --typescript flag: retries a failed resolution with tsc's mapping. Fallback-only: an on-disk .js always wins over its .ts twin, and a successful resolution is never rewritten.

  • Extension substitution rules:

    • .js → .ts (or .tsx when --jsx is enabled)
    • .jsx → .tsx (only when --jsx is enabled)
    • .mjs → .mts, .cjs → .cts
    • extensionless ./x → ./x.ts, ./dir → ./dir/index.ts (also through a directory's package.json main)
    • type declarations (.d.ts) are never resolution targets
  • Manifest coverage: the same substitution applies to package main targets, exports targets, #name imports targets, and bare subpaths into exports-less packages — so an unbuilt TS-source dependency resolves whether it declares main or exports. Exports maps are never widened: an unexported subpath stays unresolved.

  • tsconfig paths aliases (e.g. "@/*": ["./src/*"]): consulted for bare specifiers nothing else resolved, from the project root's tsconfig.json when present or an explicit --tsconfig=path. JSONC accepted, extends chains followed (paths replaces wholesale, like tsc), targets resolved against baseUrl (else the declaring config's dir), exact keys beat longest-prefix * patterns. A dependency's own imports never see the app's aliases, and --tsconfig without --typescript is rejected.

  • One shared implementation: all tsc-style rules live in the new src/resolve-typescript.js; both resolvers (the built-in Node resolver in scan.js and the legacy-field --mainFields/--metro resolver) complete their misses through the same dispatcher, so the flag behaves identically on every path. '.', '..' and trailing-/ specifiers resolve as directory imports (a literal .ts dotfile can no longer shadow index.ts).

  • Statement-level type erasure (verbatimModuleSyntax / Node type-stripping parity): only import type / export type / export type * statements drop their edge; import {}, import { type A }, export { type A } from, and export {} from still load their modules at runtime, so their edges are recorded (previously all-inline-type imports and export {} from lost their edges). Static import/re-export edges now come off the AST, where statement-level importKind/exportKind carries the distinction oxc's module records cannot.

  • Validation: rejects --typescript for non-JS entries and with --metro-resolver (which cannot substitute).

Notable Implementation Details

  • The recorded edge keeps the original specifier (e.g. ./dep.js, @/dep.js) while the target is the mapped file — stasis run --bundle=load replays the mapping from the import map, so the bundle runs under plain Node with types stripped at load.
  • A companion --lockfile attests the mapped targets (the real bytes on disk), never phantom .js outputs.
  • Test coverage: unit (sibling table, paths matcher incl. JSONC/extends), resolver-level (both engines, exports/imports/main/subpath/dot-specifier shapes, alias no-hijack rules, the type-erasure matrix), and CLI e2e including a bundle → run --bundle=load round-trip verified against plain Node's load set.

https://claude.ai/code/session_011np6aa8nMXi29WPqD6azHh

@exo-nikita exo-nikita changed the title Add TypeScript extension substitution support (--typescript) Add TypeScript resolution support to stasis bundle (--typescript, tsconfig paths) Aug 20, 2026
@exo-nikita
exo-nikita force-pushed the claude/stasis-typescript-resolution-03wt3s branch from f322380 to 9dd1ce6 Compare August 21, 2026 00:35
claude added 3 commits August 21, 2026 01:18
…script

tsc never rewrites specifiers, so TS sources import each other by their
output names (`./x.js` for the file living on disk as `./x.ts`) -- a
mapping Node's resolver refuses. `--typescript` retries a failed
resolution with tsc's extension substitution (.js -> .ts, .mjs -> .mts,
.cjs -> .cts, plus .tsx under --jsx) and TS extension/index probing for
extensionless specifiers (./x -> ./x.ts, ./dir -> ./dir/index.ts), on
both the built-in resolver and the legacy-field (--mainFields/--metro)
one. Fallback-only: an on-disk .js always wins over its .ts twin, and a
successful resolution is never rewritten. Bare package specifiers are
left to their manifests, and .d.ts is never a target. Rejected with
--metro-resolver (the project's metro-resolver can't substitute) and
for non-JS bundles.

Also adds src/resolve-fields.js to the published files list: shipped
files (src/cmd/bundle.js, and now src/scan.js) import it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011np6aa8nMXi29WPqD6azHh
… both resolvers

Adds tsconfig `compilerOptions.paths` alias support to `--typescript`:
`"@/*": ["./src/*"]`-style aliases resolve for bare specifiers nothing
else resolved, from the project root's tsconfig.json when present or an
explicit `--tsconfig=path` (rejected without --typescript, fails closed
on a missing file). The config is read as JSONC, `extends` chains are
followed (relative and node_modules bases, `paths` replacing wholesale),
targets resolve against `baseUrl` (else the declaring config's dir),
exact keys beat longest-prefix `*` patterns, and a dependency's own
imports never see the app's aliases.

The tsc-style rules now live once, in src/resolve-typescript.js, and
both resolvers (scan.js's built-in and the --mainFields/--metro field
resolver) complete their misses through the same dispatcher, closing the
gaps where the flag silently didn't apply: package `exports` targets and
`#name` `imports` targets naming compiled files that exist only as TS
source, a directory `main` under the built-in resolver, bare subpaths
into exports-less packages, and '.', '..' and trailing-'/' specifiers
(which resolve as directory imports, so a literal '.ts' dotfile can no
longer shadow index.ts). Exports maps are never widened: an unexported
subpath stays unresolved. `.d.ts` screening now lives at probe time,
once; typescriptSiblings uses extname so dotfile names can't split into
a bogus substitution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011np6aa8nMXi29WPqD6azHh
…stripping

Match verbatimModuleSyntax / Node semantics for which import statements
survive type erasure: `import type { A }` / `export type { A } from` /
`export type *` vanish whole (no edge), while a statement that merely
lists inline `type` specifiers (`import { type A }`, `export { type B }
from`) -- or none at all (`import {}`, `export {} from`) -- still loads
its module at runtime, so its edge is recorded. Previously any import
whose named specifiers were all inline-`type` lost its edge (breaking
--bundle=load on a module Node actually evaluates), and `export {} from`
lost its edge entirely.

oxc's module records can't draw that line (`import type { A }` and
`import { type A }` yield identical all-isType entries, and
`export {} from` yields no entry at all), so static import/re-export
edges are now read off the AST, where statement-level importKind /
exportKind carry it. Verified against Node 24: a bundle of the mixed
forms loads the exact set plain `node` evaluates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011np6aa8nMXi29WPqD6azHh
@exo-nikita
exo-nikita force-pushed the claude/stasis-typescript-resolution-03wt3s branch from 9dd1ce6 to e484a63 Compare August 21, 2026 01:19
@ChALkeR
ChALkeR merged commit 05e5372 into main Aug 29, 2026
5 checks passed
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.

3 participants