Skip to content

feat: @tsc-rs/typescript drop-in package; faster incremental noEmit runs - #96

Merged
t3dotgg merged 27 commits into
mainfrom
feat/npm-typescript-dropin
Oct 21, 2026
Merged

t3dotgg merged 27 commits into
mainfrom
feat/npm-typescript-dropin

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

T3 Code wants to drop typescript 7.0.2 and @effect/tsgo, and stop running effect-tsgo patch at install time. It keeps both only for VS Code, which looks for a package named typescript with a tsc bin. tsc-rs has bin tsc-rs, and its getExePath.js builds the platform package name from package.json name. Separately, an incremental noEmit run after an edit of a widely imported export was slower than a cold run (T3 apps/server: 1.64x).

Drop-in package @tsc-rs/typescript, installed as "typescript": "npm:@tsc-rs/typescript@<v>":

  • Same layout as Go's typescript package: bin tsc, lib/tsc.js, Go's lib/getExePath.js unchanged, lib/version.cjs, dist, schemas, vendor, and the same exports and imports. The tarball equals Go's typescript@7.1.0-dev.20261008.1 plus lib/install.js.
  • It reuses the @tsc-rs/<os>-<arch> platform packages through alias optional dependencies named @typescript/typescript-<os>-<arch>. Go's getExePath.js and the VS Code TypeScript 7 extension (resolveTsdkPathToExe) both resolve that name from the package dir, so the CLI and VS Code (js/ts.tsdk.path: node_modules/typescript/lib) find the tsc-rs binary under any install name.
  • require("typescript").version is the TypeScript version that tsc reports (7.1.0-dev). package.json version stays the npm version.
  • tsc-rs's getExePath.js looks up @tsc-rs/<platform>-<arch> by a fixed name and adds Go's Windows long-path prefix.
  • release.yml packs, verifies on every platform runner and publishes the new package after the platform packages. The placeholder and its trust are on npm.
  • scripts/goport/npm-alias-test.sh installs from a local registry with npm, pnpm (isolated, hoisted, global virtual store) and bun.

Incremental noEmit runs (execute/incremental):

  • Go's getFilesAffectedBy emits the d.ts of each importer, one at a time, to see whether a change spreads; under noEmit the closure is checked again anyway. In a noEmit program of more than 32 files, tsc-rs skips that walk when the pending emit stays Go's, and a changed file with at most 32 importers, none of which affects the global scope, skips its own d.ts emit. Output and diagnostics are Go's; the build info stores versions where Go stores d.ts hashes. GOPORT_DTS_WALK_FALLBACK=0 restores Go's walk. PORTING.md has the rules and what can differ.
  • The referenced files job reuses the old reference set when the same program files come in another order, and compares new and old sets off the loading thread, as Go's per-file work group does.
  • scripts/goport/t3-incremental-bench.sh is the repro. docs/t3-code-dropin.md has the T3 change, the Effect editor parity list and the numbers.

T3 Code, "incremental": true, millisecond paired timing, 31 rounds, incremental/cold: apps/server config.ts edit 1.64 -> 0.78; contracts field edit 0.77 to 1.01 across the 5 projects (apps/server 0.90, contracts 0.87, shared 0.77); ThreadId brand edit (every file affected) 0.98 to 1.02, the residual 5 to 17 ms being the build info read. An edit of a file with declare global stays about 2x a cold run, as on 0.7.0 (Go runs that d.ts step on a work group, tsc-rs on one thread).

Checks: protected goport tests 184,776 retained, 0 lost, 11 new; output identical to R220 on Query core, Hono, zod and effect; every lane passed an independent skeptic (identity runs of 400+ edit steps against main, fresh runs and Go at the pin). On a fresh T3 Code clone with the drop-in: vp i (also with the global virtual store, no lifecycle write outside its own package), vp run typecheck 32/32, npx tsc runs tsc-rs, knip and lint pass, and VS Code 1.139.1 runs the tsc-rs server with the same 62 Effect diagnostics as the CLI. T3 Code PR: pingdotgg/t3code#23023.

Made by Claude Opus 5.5 in Claude Code (running in T3 Code), with Opus 5.5 subagents for the lanes, their skeptics and the VS Code and end-to-end checks.

🤖 Generated with Claude Code

t3dotgg and others added 7 commits October 21, 2026 02:18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An incremental noEmit run after an edit to a widely imported file was
slower than a cold run. Go's getFilesAffectedBy emits the d.ts of each
importer, one at a time; on T3 Code apps/server one edit made 675 serial
d.ts emits (cold 4.49 s, incremental 7.30 s). Go has the same cost.

Under noEmit, without assumeChangesOnlyAffectDirectDependencies, when
more than 32 files import a changed file whose d.ts signature changed,
each file of that closure is affected and stores its version as
signature, with no d.ts emit. Go removes the cached diagnostics of the
whole closure anyway, so the same files are checked and the output is
the same. Only the stored fileInfos signatures differ (and the pending
emit kinds when an emitting build came before). Go's baselines never
reach the limit. GOPORT_DTS_WALK_FALLBACK=0 runs Go's walk.

T3 Code apps/server: incremental 3.52 s against cold 4.49 s (0.78x).
packages/contracts: 0.356 s against 0.346 s (base 1.40 s).

Adds scripts/goport/t3-incremental-bench.sh, unit tests for the wide and
narrow cases, and an end-to-end test of an edit sequence against fresh
runs and Go's walk. PORTING.md describes the deviation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first fallback skipped Go's d.ts walk only when more than 32 files
import the changed file. T3 Code packages/contracts still ran slower
incrementally than cold: an edit of orchestrationV2.ts (11 importers)
made 11 serial d.ts emits, 1.0 s against 0.35 s cold. Two inferred
types of that file exceed the length the compiler serializes (TS7056),
so its own d.ts emit alone took 0.63 s.

Under noEmit, in a program with more than 32 source files that are not
declaration or JSON files (Go's tsc, tsbuild and tscWatch baselines have
at most 23, and at most 6 with noEmit):
- a changed file with a new d.ts signature skips the walk; its closure
  is affected and stores versions, as before;
- a changed file with at most 32 importers (directly or not) also skips
  its own d.ts emit and uses its version, unless it affects the global
  scope or its stored signature is its version. Its closure is checked
  again, so the diagnostics stay the same.

Perf, same results: get_referenced_by returns a shared list, the walk
queues only paths it has not seen, the referenced-by map builds lists
(T3 Code apps/web, 327,661 references: 13 to 14 ms against 32 to 36 ms),
and update_snapshot removes diagnostics in one pass.

T3 setup ("incremental": true, tsc --noEmit in the project), 5 runs:
contracts field edit 0.24 s against 0.35 s cold (main 1.26 s); ThreadId
brand 0.36 s against 0.36 s (main 1.42 s); vp run typecheck after the
field edit 11.37 s against 12.06 s cold (main 12.23 s against 12.00 s);
apps/server config edit 3.49 s against 4.45 s (main 7.27 s).

Each step of a contracts and an apps/server edit sequence prints the
same output as a cold check; the build info differs from main only in
fileInfos signatures. Unit tests cover a large program with a narrow and
a wide change and a small program; the end-to-end test adds a narrow
step. t3-incremental-bench.sh gets -t (T3's setup) and project lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project can now install tsc-rs as `typescript` ("typescript":
"npm:@tsc-rs/typescript@<v>"), so its tsc bin, require("typescript"),
the typescript/unstable/* API and the TypeScript 7 VS Code extension
(js/ts.tsdk.path node_modules/typescript/lib) all run tsc-rs.

- npm-pack.sh --name @tsc-rs/typescript writes Go's main package (bin
  tsc, Go's lib/getExePath.js unchanged) with optionalDependencies that
  alias the tsc-rs platform packages under Go's names
  ("@typescript/typescript-<os>-<arch>": "npm:@tsc-rs/<os>-<arch>@<v>"),
  and a lib/version.cjs that exports the TypeScript version (tscVersion).
  Its platform packages are the tsc-rs ones, byte for byte.
- tsc-rs's getExePath.js finds @tsc-rs/<platform>-<arch> by a fixed name.
- install.js finds npm's .bin link for a scoped install dir too.
- release.yml packs, checks, publishes and releases the new package;
  trust-setup.sh trusts it.
- npm-test.sh --name @tsc-rs/typescript, and npm-alias-test.sh: a local
  registry install as typescript with npm, pnpm (isolated, hoisted,
  global virtual store) and bun, a copy of VS Code's resolver, an LSP
  initialize, the other-platform install and the peer dependency fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. 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: Repository UI
  • Review profile: CHILL
  • Plan: Team
  • Run ID: d24d52ab-62eb-4c9b-94d5-abfed04b6ece

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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

done

P=$repo/target/project-inputs
P=${NPM_TEST_INPUTS:-$repo/target/project-inputs}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium goport/npm-test.sh:132

With NPM_TEST_INPUTS=target/project-inputs, cfgs stores a relative path, but the compiler runs after cd "$out" and resolves it under $out; it therefore cannot load the supplied tsconfigs and the tests fail. Resolve P to an absolute path here, before the loop changes directories.

-P=${NPM_TEST_INPUTS:-$repo/target/project-inputs}
+P=$(cd "${NPM_TEST_INPUTS:-$repo/target/project-inputs}" && pwd)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/goport/npm-test.sh around line 132:

With `NPM_TEST_INPUTS=target/project-inputs`, `cfgs` stores a relative path, but the compiler runs after `cd "$out"` and resolves it under `$out`; it therefore cannot load the supplied tsconfigs and the tests fail. Resolve `P` to an absolute path here, before the loop changes directories.

Comment thread npm/getExePath.js
let exe = path.join(exeDir, process.platform === "win32" ? "tsc.exe" : "tsc");
// As Go's getExePath: a Windows path of 248 characters or more needs the long-path prefix.
if (process.platform === "win32" && exe.length >= 248) {
exe = "\\\\?\\" + exe;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium npm/getExePath.js:31

For a long executable path on a Windows network share, this prepends \\?\ to the UNC path instead of producing the required \\?\UNC\ form, so existsSync reports the installed executable missing and getExePath() throws. Use path.toNamespacedPath(exe) to format both drive and UNC paths correctly.

Suggested change
exe = "\\\\?\\" + exe;
exe = path.toNamespacedPath(exe);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @npm/getExePath.js around line 31:

For a long executable path on a Windows network share, this prepends `\\?\` to the UNC path instead of producing the required `\\?\UNC\` form, so `existsSync` reports the installed executable missing and `getExePath()` throws. Use `path.toNamespacedPath(exe)` to format both drive and UNC paths correctly.

t3dotgg and others added 11 commits October 21, 2026 07:07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The noEmit fallback of t3inc1 made a closure affected without Go's d.ts
walk. After an emitting build that gave the closure a JS pending emit that
Go does not give, so the next build wrote more files (--listEmittedFiles
175 against 134 lines). The walk skip now applies only when each deeper
closure file is already pending the JS part of the full emit kind, and the
own-signature rule only when the closure is pending the full kind, as after
a fresh build. Files that cannot be emitted do not count: Go drops their
pending emit.

A change to a file that affects the global scope made loop 2 compute the
d.ts signature of every file, one at a time (T3 Code apps/web: 4.6 s
against 2.3 s cold). Under the same noEmit condition each file now stores
its version. Every file is affected and pending either way, so only the
stored signatures differ from Go (apps/web: 2.32 s against 2.27 s cold).

t3-incremental-bench.sh gets a web-global case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e change

After a contracts change in T3 Code, an incremental run starts its check
later than a cold run: it compares every file with the old snapshot and
builds the referenced-by lists first, all on the loading thread.

- The referenced-by lists are made on up to 8 threads when a program has
  32,768 references or more. Each list has the same files in the same
  order as one pass. `--singleThreaded` keeps one pass.
- The referenced files job compares the new set with the old one on the
  checker thread, so the loading thread does not look the old set up again.
- A set of program files with the same paths as the old set, in any order,
  is the old set. Sets read from build info are in file id order, so before
  this every file got a new set and a path-by-path compare.
- The default library lookup of the diagnostics copy runs only when its
  flags need it.

stdout, exit codes and tsbuildinfo bytes are the same as goport-t3inc1 at
every step of edit sequences on 5 T3 Code projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The noEmit d.ts fallback gave every file its version as signature when a
change affected the global scope. Every later edit of another file then
counted as a new signature, so in T3 Code apps/web each of the next edits
checked all files again (about a cold run each, where Go checks one file).

handleDtsMayChangeOfAffectedFile now calls update_shape_signature(file,
false) again, as Go does. The global-scope edit is back at about 2x a cold
run (the d.ts emits run one at a time), as on main. The unit test and the
e2e steps now pin Go's computed signatures, and the module note and
PORTING.md say what can still differ from Go after the walk skip and the
own-signature rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The threaded lists of 9ea51e0 saved about 10 ms on T3 Code apps/web
(0.4 percent), and their skeptic found two defects: a refused thread
panicked, and the thread count ignored GOMAXPROCS. Go builds the lists
in one pass. Keep the rest of 9ea51e0: the reuse of an old reference
set with the same program files in any order, and the compare of the
new and old sets in the referenced files job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the fallback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a file of the changed file's closure affects the global scope, the
version as signature made every file of the program affected, where Go's
d.ts emit finds an unchanged d.ts and checks only the changed file. On
T3 Code apps/web a body edit of hostedBrowserWebviewStyle.ts took 2.38 s
against 0.25 s on main. The rule now skips such a closure (skeptic probe
of t3inc3 round 2), with a unit test and the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package, the exact T3 Code change, the Effect editor features and
what T3 Code loses, and the incremental numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
t3dotgg and others added 9 commits October 21, 2026 11:21
A cold program stores no d.ts signature, so its first change counts as
a new signature in Go too, and every file near a global-scope file is
checked. The test now makes a first edit that stores the d.ts
signature, so the second edit shows the round 3 rule. It fails without
the rule's global-scope check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reflow the module note and the rule's doc, say when the own-signature
rule applies with declarations, and add the EMIT7 to EMIT9 sequences to
the matrix numbers (round 3 skeptic problem 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round 3 re-check's low items: a later edit near a global-scope file
checks every file again; Go runs the d.ts errors pass only when the run
reports no other diagnostic; the test harness names its warm-up edit;
the matrix compares with Go's walk, not main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@t3dotgg t3dotgg changed the title feat(npm): @tsc-rs/typescript, a drop-in for the typescript package feat: @tsc-rs/typescript drop-in package; faster incremental noEmit runs Oct 21, 2026
local s e
s=$(date +%s.%N)
if [ -n "$T3MODE" ]; then
(cd "$T3/$2" && "$1" --noEmit --extendedDiagnostics $FLAGS >"$3" 2>&1) || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium goport/t3-incremental-bench.sh:133

The benchmark records missing tsc executables and compiler panics as successful timing samples because both invocations discard their exit status with || true. Since diags ignores non-TS output, two failed runs can also produce same-diags=yes, making a broken compiler appear fast and correct. Preserve the exit status and reject launch or crash failures while allowing nonzero exits that report ordinary TypeScript diagnostics.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/goport/t3-incremental-bench.sh around line 133:

The benchmark records missing `tsc` executables and compiler panics as successful timing samples because both invocations discard their exit status with `|| true`. Since `diags` ignores non-TS output, two failed runs can also produce `same-diags=yes`, making a broken compiler appear fast and correct. Preserve the exit status and reject launch or crash failures while allowing nonzero exits that report ordinary TypeScript diagnostics.

for _ in $(seq "$RUNS"); do
for arg in "$@"; do
label=${arg%%=*} tsc=${arg#*=}
[ "$label" = "$arg" ] && label=${tsc: -14}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium goport/t3-incremental-bench.sh:148

An unlabelled compiler path containing / in its last 14 characters makes the output filename include a nonexistent directory, so the first >>"$k.cold" fails and set -e aborts the benchmark. Both loops derive the label from that path suffix; use the same slash-free mapping, such as the executable basename, in each loop.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/goport/t3-incremental-bench.sh around line 148:

An unlabelled compiler path containing `/` in its last 14 characters makes the output filename include a nonexistent directory, so the first `>>"$k.cold"` fails and `set -e` aborts the benchmark. Both loops derive the label from that path suffix; use the same slash-free mapping, such as the executable basename, in each loop.

@t3dotgg
t3dotgg merged commit 4c032be into main Oct 21, 2026
15 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.

1 participant