Repository navigation
feat: @tsc-rs/typescript drop-in package; faster incremental noEmit runs - #96
Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
| done | ||
|
|
||
| P=$repo/target/project-inputs | ||
| P=${NPM_TEST_INPUTS:-$repo/target/project-inputs} |
There was a problem hiding this comment.
🟡 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.
| 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; |
There was a problem hiding this comment.
🟡 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.
| 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.
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>
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>
| local s e | ||
| s=$(date +%s.%N) | ||
| if [ -n "$T3MODE" ]; then | ||
| (cd "$T3/$2" && "$1" --noEmit --extendedDiagnostics $FLAGS >"$3" 2>&1) || true |
There was a problem hiding this comment.
🟡 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} |
There was a problem hiding this comment.
🟡 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.
T3 Code wants to drop
typescript7.0.2 and@effect/tsgo, and stop runningeffect-tsgo patchat install time. It keeps both only for VS Code, which looks for a package namedtypescriptwith atscbin.tsc-rshas bintsc-rs, and itsgetExePath.jsbuilds the platform package name frompackage.jsonname. Separately, an incrementalnoEmitrun 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>":typescriptpackage: bintsc,lib/tsc.js, Go'slib/getExePath.jsunchanged,lib/version.cjs,dist,schemas,vendor, and the sameexportsandimports. The tarball equals Go'stypescript@7.1.0-dev.20261008.1pluslib/install.js.@tsc-rs/<os>-<arch>platform packages through alias optional dependencies named@typescript/typescript-<os>-<arch>. Go'sgetExePath.jsand 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").versionis the TypeScript version thattscreports (7.1.0-dev).package.jsonversionstays the npm version.tsc-rs'sgetExePath.jslooks up@tsc-rs/<platform>-<arch>by a fixed name and adds Go's Windows long-path prefix.release.ymlpacks, 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.shinstalls from a local registry with npm, pnpm (isolated, hoisted, global virtual store) and bun.Incremental
noEmitruns (execute/incremental):getFilesAffectedByemits the d.ts of each importer, one at a time, to see whether a change spreads; undernoEmitthe closure is checked again anyway. In anoEmitprogram 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=0restores Go's walk. PORTING.md has the rules and what can differ.scripts/goport/t3-incremental-bench.shis the repro.docs/t3-code-dropin.mdhas the T3 change, the Effect editor parity list and the numbers.T3 Code,
"incremental": true, millisecond paired timing, 31 rounds, incremental/cold: apps/serverconfig.tsedit 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 withdeclare globalstays 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 typecheck32/32,npx tscruns 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