perf(typecheck): pin Bun as a devDependency and typecheck with it (#30149)

* perf(typecheck): pnpm tc uses the pinned Bun checker when available

bun check (Bun 1.4.3, TypeScript 7.0.2 parity, already used by CI) checks the
node project in ~29 CPU-s / ~2 GB peak vs tsc's ~98 CPU-s / ~16.6 GB. tsc's
incremental cache rarely helps agents: noEmit builds store no declaration
signatures, so the first edit to any file after a cold check re-checks every
transitive importer (a comment in src/shared/constants.ts re-checks all 18k files).

pnpm tc / tc:node / tc:web / tc:cli now pick the Bun from config/.bun-version when
it is on PATH and fall back to tsc with a one-line hint otherwise;
--compiler=native forces tsc. The pinned-Bun probe is shared with the
localization extraction check.

* build: pin Bun as a devDependency and make it the only default checker

The PATH probe left anyone without the exact pinned Bun (including a 1.4.2
global) on tsc's expensive path. bun@1.4.3 is now a devDependency, its
postinstall is allowed, and pnpm tc / typecheck:ci run that binary. A missing
binary is an error pointing at pnpm install rather than a silent fallback;
--compiler=native still selects tsc. A test keeps the devDependency equal to
config/.bun-version.

* ci: link the pinned Bun after the script-free install

--ignore-scripts leaves node_modules/.bin/bun as bun's postinstall stub, so the
typecheck runner found no binary and any pnpm-run script calling bun would hit
the stub before setup-bun's copy. pnpm rebuild bun runs only that allowed build.
This commit is contained in:
Neil
2026-10-17 22:43:00 -07:00
committed by GitHub
parent dbb84f4ad9
commit dcfe8c9e6c
9 changed files with 294 additions and 57 deletions
+5 -2
View File
@@ -72,8 +72,11 @@ pnpm build
```
For repeated local typechecks, use `pnpm tc` (or `pnpm tc:node`, `pnpm tc:web`,
and `pnpm tc:cli`). These keep TypeScript's incremental cache. CI uses
`pnpm typecheck:ci` with Bun for full checks. Shared tests are checked by the
and `pnpm tc:cli`). These run `bun check` from the pinned `bun` devDependency —
about a third of tsc's CPU and an eighth of its memory, so several agents can
typecheck at once. Pass `--compiler=native` to use tsc and its incremental
cache instead. CI uses `pnpm typecheck:ci`, the same Bun check with CI's
concurrency. Shared tests are checked by the
Node project; shared production code is checked in all three environments.
Run `pnpm tc:node` after editing shared tests, or `pnpm tc` to check everything.
@@ -209,6 +209,14 @@ runs:
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
fi
# Why: the script-free install leaves node_modules/.bin/bun as a stub that shadows setup-bun under `pnpm run`.
- name: Link pinned Bun
shell: bash
run: |
if [ -d node_modules/bun ]; then
pnpm rebuild bun
fi
# Why --if-present: release cuts run main's workflows against older release tags that predate this script.
- name: Build internal packages
shell: bash
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
export function readPinnedBunVersion(root) {
return readFileSync(path.join(root, 'config', '.bun-version'), 'utf8').trim()
}
/**
* Absolute path of the `bun` devDependency when it runs and is exactly the version in
* config/.bun-version, otherwise null. Its binary is only real once the postinstall ran.
*/
export function resolvePinnedBunExecutable(root, runProcessSync) {
try {
const manifestPath = createRequire(path.join(root, 'package.json')).resolve('bun/package.json')
const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8'))
const executable = path.join(path.dirname(manifestPath), bin.bun)
const probe = runProcessSync({
program: executable,
args: ['--version'],
cwd: root,
env: process.env,
timeoutMs: 2000,
maxOutputBytes: 4096
})
return probe.code === 0 &&
!probe.signal &&
!probe.timedOut &&
probe.stdout.trim() === readPinnedBunVersion(root)
? executable
: null
} catch {
return null
}
}
@@ -1,9 +1,10 @@
import { spawnProcess } from '@orca/process-host'
import { runProcessSync, spawnProcess } from '@orca/process-host'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { availableParallelism, totalmem } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { readPinnedBunVersion, resolvePinnedBunExecutable } from './pinned-bun-executable.mjs'
const BYTES_PER_GIB = 1024 ** 3
@@ -70,15 +71,31 @@ export function resolveProjectCompiler(config, root = repoRoot) {
return resolveProjectCompilerInfo(config, root).program
}
// Bun 1.4.3 targets TypeScript 7.0.2; compiler upgrades require a new parity comparison.
export const BUN_TYPESCRIPT_VERSION = '7.0.2'
export function parseTypecheckOptions(args) {
const options = { compiler: 'native', bunConcurrency: 1, bunThreads: 4 }
const options = {
compiler: 'bun',
bunConcurrency: 2,
bunThreads: 4,
projects: TYPECHECK_PROJECTS
}
const selected = []
for (const argument of args) {
if (argument === '--compiler=bun' || argument === '--compiler=native') {
if (/^--compiler=(bun|native)$/.test(argument)) {
options.compiler = argument.slice('--compiler='.length)
} else if (argument.startsWith('--bun-concurrency=')) {
options.bunConcurrency = Number(argument.slice('--bun-concurrency='.length))
} else if (argument.startsWith('--bun-threads=')) {
options.bunThreads = Number(argument.slice('--bun-threads='.length))
} else if (argument.startsWith('--project=')) {
const config = argument.slice('--project='.length)
const project = TYPECHECK_PROJECTS.find((candidate) => candidate.config === config)
if (!project) {
throw new Error(`Unknown typecheck project: ${config}`)
}
selected.push(project)
} else {
throw new Error(`Unknown typecheck option: ${argument}`)
}
@@ -88,9 +105,29 @@ export function parseTypecheckOptions(args) {
throw new Error('Bun concurrency and threads must be integers from 1 to 4')
}
}
if (selected.length > 0) {
options.projects = selected
}
return options
}
/**
* Bun is the default: on the node project it uses ~1/3 of tsc's CPU and ~2 GB instead of
* ~16 GB, and tsc's incremental cache rarely survives an edit to an export.
*/
export function selectTypecheckCompiler(compiler, { root, resolveBunExecutable }) {
if (compiler === 'native') {
return { compiler }
}
const bunExecutable = resolveBunExecutable(root)
if (!bunExecutable) {
throw new Error(
`Bun ${readPinnedBunVersion(root)} from devDependencies is missing; run pnpm install, or pass --compiler=native.`
)
}
return { compiler, bunExecutable }
}
export function typecheckBatches(projects, { compiler, bunConcurrency, memoryBytes, parallelism }) {
return planTypecheckBatches(
compiler === 'bun' ? projects.map((project) => ({ ...project, heapGib: 4 })) : projects,
@@ -101,17 +138,16 @@ export function typecheckBatches(projects, { compiler, bunConcurrency, memoryByt
)
}
function checkProject(config, { compiler, bunThreads, root, spawnCompiler }) {
function checkProject(config, { compiler, bunExecutable, bunThreads, root, spawnCompiler }) {
return new Promise((resolve, reject) => {
const installedCompiler = resolveProjectCompilerInfo(config, root)
// Bun 1.4.3 targets TypeScript 7.0.2; compiler upgrades require a new parity comparison.
if (compiler === 'bun' && installedCompiler.version !== '7.0.2') {
if (compiler === 'bun' && installedCompiler.version !== BUN_TYPESCRIPT_VERSION) {
throw new Error(
`Bun CI compiler targets TypeScript 7.0.2, but ${config} resolves ${installedCompiler.version}`
`Bun CI compiler targets TypeScript ${BUN_TYPESCRIPT_VERSION}, but ${config} resolves ${installedCompiler.version}`
)
}
const child = spawnCompiler({
program: compiler === 'bun' ? 'bun' : process.execPath,
program: compiler === 'bun' ? bunExecutable : process.execPath,
args:
compiler === 'bun'
? ['check', '-p', config, '--threads', String(bunThreads), '--no-pretty', '--all']
@@ -134,6 +170,7 @@ function checkProject(config, { compiler, bunThreads, root, spawnCompiler }) {
export async function runTypecheckProjects({
compiler = 'native',
bunExecutable = 'bun',
bunConcurrency = 1,
bunThreads = 4,
root = repoRoot,
@@ -153,7 +190,13 @@ export async function runTypecheckProjects({
while (next < pending.length) {
const index = next++
try {
await checkProject(pending[index].config, { compiler, bunThreads, root, spawnCompiler })
await checkProject(pending[index].config, {
compiler,
bunExecutable,
bunThreads,
root,
spawnCompiler
})
} catch (reason) {
failures.push({ index, reason })
}
@@ -167,7 +210,7 @@ export async function runTypecheckProjects({
for (const batch of batches) {
const results = await Promise.allSettled(
batch.map((project) =>
checkProject(project.config, { compiler, bunThreads, root, spawnCompiler })
checkProject(project.config, { compiler, bunExecutable, bunThreads, root, spawnCompiler })
)
)
for (const result of results) {
@@ -180,7 +223,12 @@ export async function runTypecheckProjects({
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const failures = await runTypecheckProjects(parseTypecheckOptions(process.argv.slice(2)))
const options = parseTypecheckOptions(process.argv.slice(2))
const selection = selectTypecheckCompiler(options.compiler, {
root: repoRoot,
resolveBunExecutable: (root) => resolvePinnedBunExecutable(root, runProcessSync)
})
const failures = await runTypecheckProjects({ ...options, ...selection })
if (failures.length > 0) {
for (const failure of failures) {
console.error(failure.message ?? failure)
@@ -18,6 +18,7 @@ import {
planTypecheckBatches,
resolveProjectCompiler,
parseTypecheckOptions,
selectTypecheckCompiler,
typecheckBatches,
runTypecheckProjects
} from './run-typecheck-projects-in-parallel.mjs'
@@ -132,16 +133,57 @@ describe('workspace package test typecheck', () => {
})
describe('CI compiler selection', () => {
it('keeps native incremental checking as the local default', () => {
expect(parseTypecheckOptions([]).compiler).toBe('native')
it('checks with Bun by default', () => {
expect(parseTypecheckOptions([]).compiler).toBe('bun')
})
it.each(['--compiler=typo', '--bun-concurrency=0', '--bun-threads=NaN', '--bun-concurrency=5'])(
'rejects unsafe or unknown options: %s',
(argument) => {
expect(() => parseTypecheckOptions([argument])).toThrow()
}
)
it('pins the Bun devDependency to config/.bun-version', () => {
const manifest = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'))
expect(manifest.devDependencies.bun).toBe(
readFileSync(join(repoRoot, 'config', '.bun-version'), 'utf8').trim()
)
})
it('narrows to the requested projects and rejects unknown ones', () => {
expect(parseTypecheckOptions(['--project=config/tsconfig.node.json']).projects).toEqual([
TYPECHECK_PROJECTS[0]
])
expect(parseTypecheckOptions([]).projects).toEqual(TYPECHECK_PROJECTS)
expect(() => parseTypecheckOptions(['--project=config/tsconfig.json'])).toThrow()
})
it('runs the resolved Bun and refuses to silently fall back when it is missing', () => {
expect(
selectTypecheckCompiler('bun', {
root: repoRoot,
resolveBunExecutable: () => '/repo/node_modules/bun/bin/bun.exe'
})
).toEqual({ compiler: 'bun', bunExecutable: '/repo/node_modules/bun/bin/bun.exe' })
expect(() =>
selectTypecheckCompiler('bun', { root: repoRoot, resolveBunExecutable: () => null })
).toThrow('run pnpm install')
})
it('never probes Bun when tsc is requested', () => {
expect(
selectTypecheckCompiler('native', {
root: repoRoot,
resolveBunExecutable: () => {
throw new Error('probed')
}
})
).toEqual({ compiler: 'native' })
})
it.each([
'--compiler=typo',
'--bun-concurrency=0',
'--bun-threads=NaN',
'--bun-concurrency=5',
'--project'
])('rejects unsafe or unknown options: %s', (argument) => {
expect(() => parseTypecheckOptions([argument])).toThrow()
})
it('bounds Bun project concurrency and reserves memory for overlapping checks', () => {
const options = parseTypecheckOptions([
@@ -79,39 +79,14 @@ async function extractToTemporaryCatalog(root, tempDir) {
const outputPattern = path.join(tempDir, '{{language}}.json')
// Why: extraction output is evidence for this check, not another committed
// catalog that feature authors must keep synchronized.
const [{ runProcessSync }, { describeProcessFailure }] = await Promise.all([
import('@orca/process-host'),
import('./process-failure-message.mjs')
])
let program = process.execPath
try {
const version = (await fs.readFile(path.join(root, 'config', '.bun-version'), 'utf8')).trim()
const probe = runProcessSync({
program: 'bun',
args: [
'-e',
'console.log(JSON.stringify({ version: process.versions.bun, executable: process.execPath }))'
],
cwd: root,
env: process.env,
timeoutMs: 2000,
maxOutputBytes: 4096
})
const identity = JSON.parse(probe.stdout)
if (
probe.code === 0 &&
!probe.signal &&
!probe.timedOut &&
!probe.outputTruncated &&
identity.version === version &&
typeof identity.executable === 'string' &&
path.isAbsolute(identity.executable)
) {
program = identity.executable
}
} catch {
// Missing or unusable optional Bun keeps the existing parent runtime.
}
const [{ runProcessSync }, { describeProcessFailure }, { resolvePinnedBunExecutable }] =
await Promise.all([
import('@orca/process-host'),
import('./process-failure-message.mjs'),
import('./pinned-bun-executable.mjs')
])
// Missing or unusable optional Bun keeps the existing parent runtime.
const program = resolvePinnedBunExecutable(root, runProcessSync) ?? process.execPath
const result = runProcessSync({
program,
args: [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
+4 -3
View File
@@ -66,9 +66,9 @@
"tc:cli": "pnpm run typecheck:cli",
"tc:web": "pnpm run typecheck:web",
"tc": "pnpm run typecheck",
"typecheck:node": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:node": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs --project=config/tsconfig.node.json",
"typecheck:cli": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs --project=config/tsconfig.tc.cli.json",
"typecheck:web": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs --project=config/tsconfig.tc.web.json",
"typecheck:e2e": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.e2e.json",
"typecheck": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs",
"typecheck:ci": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs --compiler=bun",
@@ -268,6 +268,7 @@
"@xterm/addon-web-links": "0.13.0-beta.301",
"@xterm/addon-webgl": "0.20.0-beta.300",
"@xterm/xterm": "6.1.0-beta.304",
"bun": "1.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+124
View File
@@ -435,6 +435,9 @@ importers:
'@xterm/xterm':
specifier: 6.1.0-beta.304
version: 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)
bun:
specifier: 1.4.3
version: 1.4.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1529,6 +1532,70 @@ packages:
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@oven/bun-darwin-aarch64@1.4.3':
resolution: {integrity: sha512-u2wAM9RzqyF4baO5oFVhcFUVWO1CCMLG6yC5/aPWCYGZPfL5ULMbeHt+0/3I0h3yaPx1m+PKSANO/61jPtDZcQ==}
cpu: [arm64]
os: [darwin]
'@oven/bun-darwin-x64@1.4.3':
resolution: {integrity: sha512-wRGeciWOyKyG/BfTHq4CAyqoOfuzj5UJkKA8FlJETW2yhtDh+Ba3EgPnsVclFyzIw7E0HJxpsonoj17g3HPK/Q==}
cpu: [x64]
os: [darwin]
'@oven/bun-freebsd-aarch64@1.4.3':
resolution: {integrity: sha512-z4LiDRPTJMKA8JvtP+Z+I5m1syJr8HGYFainIJoCAdkWaHMyQMaEEYr0N+FzTK4pECUHUmscCTNfC9XJwTe4dQ==}
cpu: [arm64]
os: [freebsd]
'@oven/bun-freebsd-x64@1.4.3':
resolution: {integrity: sha512-YpwGnfvtSMRMnnViJI5wCGmS5HpCBAl0wLhBflJfmZYkgOHfplzeKUkUVDW2bNOZyrym3YN+EDaLWooaJpX8Eg==}
cpu: [x64]
os: [freebsd]
'@oven/bun-linux-aarch64-android@1.4.3':
resolution: {integrity: sha512-KCsYD+HHawd6xht4BOffTnLZXNf+xzq8l52MsIF2yRtIfUvGTCumdnJPXxuo969iXukb7cWtz+WMkpYlKMG+sg==}
cpu: [arm64]
os: [android]
'@oven/bun-linux-aarch64-musl@1.4.3':
resolution: {integrity: sha512-5OJIbcVspZxIN/zQtrFYwLuJgaOlciGcynmwCtwKyg0rmXHJoNG0QyspiiLnDUDoVTSGY/nrn0QEbWKhu0QbEQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oven/bun-linux-aarch64@1.4.3':
resolution: {integrity: sha512-ylogDFxDD+aRKO0TJauwDzCWM+rULvc85gRNZQ9CnY6rm2BU4GC2XUDi20bh67CIuB4Wt/0PL1xpsJ2hqplMww==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oven/bun-linux-x64-android@1.4.3':
resolution: {integrity: sha512-5CdcvAc+iTqf/p6S0VkoGF8harKMjTfEkMOXJHslwBzl5GcY5alth1mkhD3T7SqLA5aUal95vzfiNjoAZXkPOw==}
cpu: [x64]
os: [android]
'@oven/bun-linux-x64-musl@1.4.3':
resolution: {integrity: sha512-L0gTTjTncLqv7VsV/2iLz7Zk4vd969xqRgbtGnskmV3MvL+IJgzTyQr5zm8GGGWSm/SO/ab2vnn4GXgPomoGbg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oven/bun-linux-x64@1.4.3':
resolution: {integrity: sha512-RArgnpjeXjaGzjp5h7Rs89cUYJutO8IyqR3itE8OBaBzEt8KNm2jp/YozxBHq4eFovwrQLryeSwTAcmiT+zcoA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oven/bun-windows-aarch64@1.4.3':
resolution: {integrity: sha512-a3Vyu9KI0J9vfIS+/LJMmjWI/qsBpph8u0R7d6Tm+YaSOqZeN2LRULfVqz13dgQRTvqg6aWBxOv9jE4Rl0lAAQ==}
cpu: [arm64]
os: [win32]
'@oven/bun-windows-x64@1.4.3':
resolution: {integrity: sha512-9pV16qXHTFvZDH61S8hpdpVBbWmvEqekczLF54zTnQHKP28D6yRCNLrD/7jVbXYR61fQYAx4FSBgzCxGCKb3rQ==}
cpu: [x64]
os: [win32]
'@oxc-parser/binding-android-arm-eabi@0.148.0':
resolution: {integrity: sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4242,6 +4309,12 @@ packages:
resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
engines: {node: '>=14.0.0'}
bun@1.4.3:
resolution: {integrity: sha512-EvQQ2dUJlixjKgJjeXZvdsHUWIKnVfYufrQNDHa++0YM4XksQ4GtGofEdRnQSiyNp/yUWiX6SlqS8zugZCo+2A==}
cpu: [arm64, x64]
os: [darwin, linux, android, freebsd, win32]
hasBin: true
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -8762,6 +8835,42 @@ snapshots:
'@opentelemetry/api@1.9.1':
optional: true
'@oven/bun-darwin-aarch64@1.4.3':
optional: true
'@oven/bun-darwin-x64@1.4.3':
optional: true
'@oven/bun-freebsd-aarch64@1.4.3':
optional: true
'@oven/bun-freebsd-x64@1.4.3':
optional: true
'@oven/bun-linux-aarch64-android@1.4.3':
optional: true
'@oven/bun-linux-aarch64-musl@1.4.3':
optional: true
'@oven/bun-linux-aarch64@1.4.3':
optional: true
'@oven/bun-linux-x64-android@1.4.3':
optional: true
'@oven/bun-linux-x64-musl@1.4.3':
optional: true
'@oven/bun-linux-x64@1.4.3':
optional: true
'@oven/bun-windows-aarch64@1.4.3':
optional: true
'@oven/bun-windows-x64@1.4.3':
optional: true
'@oxc-parser/binding-android-arm-eabi@0.148.0':
optional: true
@@ -11160,6 +11269,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
bun@1.4.3:
optionalDependencies:
'@oven/bun-darwin-aarch64': 1.4.3
'@oven/bun-darwin-x64': 1.4.3
'@oven/bun-freebsd-aarch64': 1.4.3
'@oven/bun-freebsd-x64': 1.4.3
'@oven/bun-linux-aarch64': 1.4.3
'@oven/bun-linux-aarch64-android': 1.4.3
'@oven/bun-linux-aarch64-musl': 1.4.3
'@oven/bun-linux-x64': 1.4.3
'@oven/bun-linux-x64-android': 1.4.3
'@oven/bun-linux-x64-musl': 1.4.3
'@oven/bun-windows-aarch64': 1.4.3
'@oven/bun-windows-x64': 1.4.3
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
+1
View File
@@ -106,6 +106,7 @@ allowBuilds:
'@swc/core': false
'@vscode/windows-process-tree': false
agent-browser: false
bun: true
cpu-features: true
electron-winstaller: false
esbuild: true