From 9e5a917eddb8d1f1ab32dc0421e415ddb71ff3e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:25:44 +0900 Subject: [PATCH 1/6] fix(test): promote Bun 1.4.4 with independent runner role verification --- .github/actions/setup-project-bun/action.yml | 4 +-- .github/workflows/ci.yml | 10 +++--- CONTRIBUTING.md | 6 ++-- docs-site/src/content/docs/contributing.md | 6 ++-- package.json | 2 +- structure/runtime.md | 4 +-- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/install-scripts.test.ts | 6 ++-- tests/test-layout-tooling.test.ts | 34 ++++++++++++++++---- 9 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1ab22dd1dc9..cf28f6f9d43 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -8,8 +8,8 @@ description: >- # (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to # 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after # finalizers ran inside an earlier call and spin past its own timeout (#4956). -# Rejoin only after the upstream runner crash is fixed and a green lane=all -# dispatch proves the unified version; then delete testRunnerBun and this role input. +# Both roles now default to the package's 1.4.4 security transition, retaining role validation. +# Promotion requires a green full cross-platform lane=all run on the exact candidate head. inputs: role: description: Bun role to install (runtime or test-runner). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca318..7bcd0eaa3f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -578,12 +578,10 @@ jobs: printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" - # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs - # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). - # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch - # proves the unified version, then delete testRunnerBun and the action's role input. + # Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json. + # Keep role validation after the historical isolate preload and spawnSync failures + # (#6713, #4956). Promotion requires a green full cross-platform lane=all run on + # the exact candidate head; focused local results do not replace that proof. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4aa94f580..bbcd248f8f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831b..d0021b7f12a 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` -The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` by default. If a full run is disproportionately expensive for the task size, available machine resources, or concurrent worktrees, you must at least run focused regression tests diff --git a/package.json b/package.json index 89176da2851..a270427faf2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "trustedDependencies": [ "bun" ], - "testRunnerBun": "1.4.3", + "testRunnerBun": "1.4.4", "scripts": { "dev": "bun run src/cli/index.ts start", "dev:proxy": "bun run src/cli/index.ts start", diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457f..763f3e90c3d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ The minute sweep checks persisted activation deadlines locally; only missing dea ## Bun runtime and test runner -`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.4 and the test runner (`testRunnerBun`) to 1.4.3; `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. +`package.json` pins both the shipped npm runtime (`dependencies.bun`) and the test runner (`testRunnerBun`) to 1.4.4; the roles remain independently validated and `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. It left 1.4.0 for 1.4.3 because of oven-sh/bun#44581 (#4956). Bun 1.4.0's `Bun.spawnSync` pointed the VM at its private wait loop, so a finalizer that ran during one call (an earlier file's stdout/stderr sink, a stream reader) released its poll on that loop; the cached loop's poll count drifted to zero and a later call stopped polling for its child, spinning past its own `timeout` until the 120-second batch deadline. In a multi-file `--isolate` process that depends on what earlier files left to finalize, so single-file attribution stayed clean. `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` reproduces the mechanism in a child under a bounded parent and fails on 1.4.0. #6713 run 37631558263 reproduces that 1.4.2 preload segfault on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works. @@ -12,7 +12,7 @@ The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload se Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin. Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin. -`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split. +`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading. The caller need not already run the test pin; only a version match permits reusing its executable. `tests/test-layout-tooling.test.ts` verifies matching/different caller fixtures, the selected isolate command, and refusal before spawning tests for a wrong-version override. The 1.4.4 security transition aligns the default versions while retaining the role fields, resolver and CI role checks. Promotion requires successful full cross-platform `lane=all` proof on the exact candidate head; focused local tests do not establish that proof. ## Resolved static model policy diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index f780d71aa90..0729ab6945d 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -65,7 +65,7 @@ describe("an edit to the setup action alone", () => { }); }); -// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need the testRunnerBun pin (1.4.3, #4956). +// Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6fc..23ed7b7461e 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -94,10 +94,10 @@ describe("install scripts", () => { // and spun past its own timeout. Scratch run 38213764818 stalled 8 of 252 Linux shard 1/4 // batch-5 runs that way (CLI child a zombie, its pidfd armed but never polled). // tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts keeps the runner above it. - // Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch - // proves the unified version; then delete testRunnerBun and the setup action's role. + // The security transition now defaults both roles to 1.4.4. Keep their independent + // validation; promotion still requires a green full cross-platform lane=all candidate run. expect(pkg.dependencies?.bun).toBe("1.4.4"); - expect(pkg.testRunnerBun).toBe("1.4.3"); + expect(pkg.testRunnerBun).toBe("1.4.4"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 354e3943fe0..cb4a7482819 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import * as runnerBun from "../scripts/lib/test-runner-bun"; import { testRunnerBun } from "../package.json"; import { anchors, @@ -469,24 +470,43 @@ describe("move end to end", () => { } }); - test("domain verification launches isolate tests through the pinned runner", () => { + const otherBunVersion = testRunnerBun === "1.4.3" ? "1.4.4" : "1.4.3"; + test.each([ + ["matching caller", testRunnerBun, testRunnerBun, false], + ["different caller", otherBunVersion, testRunnerBun, false], + ["wrong override version", otherBunVersion, otherBunVersion, true], + ] as const)("domain verification preserves the configured runner role: %s", (_name, currentVersion, overrideVersion, refused) => { const { root, cleanup } = scratchRepo(); const layoutPath = join(root, "scripts", "test-layout", "layout.json"); try { runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const binaryName = process.platform === "win32" ? "bun.exe" : "bun"; + const pinnedRunner = join(root, "test-runner", binaryName); + const caller = currentVersion === testRunnerBun ? pinnedRunner : join(root, "runtime", binaryName); + const probes: string[] = []; + // Keep role resolution real, but version probes independent of the host and the spawn mock. + const select = spyOn(runnerBun, "getTestRunnerBun").mockImplementation(() => runnerBun.resolveTestRunnerBun({ + pin: testRunnerBun, currentVersion, execPath: caller, + env: { OCX_TEST_RUNNER_BUN: pinnedRunner }, pathEntries: [], homeDir: root, platform: process.platform, + probeVersion: binary => { probes.push(binary); return binary === pinnedRunner ? overrideVersion : undefined; }, + })); const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), } as ReturnType); try { - const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); - expect(report.testExit).toBe(0); - // The test itself runs on the required pin, so its executable is the resolver's first choice. - expect(Bun.version).toBe(testRunnerBun); - expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ - process.execPath, "test", "--isolate", "tests/server", "tests/providers", + const verify = () => runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + if (refused) expect(verify).toThrow(`OCX_TEST_RUNNER_BUN reports ${overrideVersion}`); + else expect(verify()).toMatchObject({ ok: true, testExit: 0 }); + expect(select).toHaveBeenCalledTimes(1); + expect(probes).toEqual(currentVersion === testRunnerBun ? [] : [pinnedRunner]); + const testSpawns = spawn.mock.calls.filter(([command]) => Array.isArray(command) && command[1] === "test"); + expect(testSpawns.map(([command]) => command)).toEqual(refused ? [] : [ + [pinnedRunner, "test", "--isolate", "tests/server", "tests/providers"], ]); + if (!refused) expect(testSpawns[0]?.[1]).toMatchObject({ cwd: root, stdout: "inherit", stderr: "inherit" }); } finally { spawn.mockRestore(); + select.mockRestore(); } } finally { cleanup(); From 1deb67d274c8246df9ad539bff93f958782032cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:38:09 +0900 Subject: [PATCH 2/6] docs: align Docker Bun comments with unified role defaults --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82fe86f4cf1..54e0153e0f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,12 @@ # Keep the runtime aligned with package.json and pin the multi-platform image index. # Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS -# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3 -# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync -# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the -# upstream crash fix passes a unified-version lane=all dispatch, then delete -# package.json's testRunnerBun pin and setup-project-bun's role input. +# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner +# defaults are both 1.4.4; keep package.json's testRunnerBun pin and +# setup-project-bun's role input independently validated. Promotion requires +# an exact-head cross-platform lane=all dispatch, including multi-file --isolate: +# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0 +# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks. ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d FROM ${BUN_IMAGE} AS manifest From 41f08fffd3e7dea64a6eed79af918aed74a153dd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:07:35 +0900 Subject: [PATCH 3/6] test: use a draining TLS origin for early upload replies --- .../claude-picker-upload.test.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..95c6351f04d 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -3,14 +3,39 @@ import { Agent, createServer, request } from "node:https"; import { connect, constants, type ClientHttp2Session, type ClientHttp2Stream } from "node:http2"; import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; +import { createServer as createTlsServer } from "node:tls"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; import { startPickerListener, type PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; -async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => void, +async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => void) | "early-fin", options: Partial = {}) { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); - const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); + const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean }> = []; + // A wire-level origin sends the reply and FIN while continuing to drain input. Bun's HTTP + // server also tears down unfinished requests; overriding destroySoon alone does not prevent + // that reset racing ahead of the reply on Windows with a buffered upload. + const upstream = handler === "early-fin" ? createTlsServer({ cert: leaf.certPem, key: leaf.keyPem, allowHalfOpen: true }, socket => { + socket.on("error", () => socket.destroy()); + let headers = ""; + let replied = false; + socket.on("data", chunk => { + if (replied) return; + headers += chunk.toString("latin1"); + const end = headers.indexOf("\r\n\r\n"); + if (end < 0) return; + replied = true; + const early = headers.startsWith("POST /early "); + const body = early ? "early" : "ordinary"; + const length = /\r\ncontent-length: (\d+)\r\n/i.exec(headers.slice(0, end + 2)); + const requestBody = headers.slice(end + 4); + const observation = { requestComplete: length ? requestBody.length >= Number(length[1]) + : requestBody.endsWith("0\r\n\r\n"), flushed: false }; + if (early) earlyReplies.push(observation); + socket.once("finish", () => { observation.flushed = true; }); + socket.end(`HTTP/1.1 ${early ? "413 Payload Too Large" : "200 OK"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`); + }); + }) : createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); const sockets = new Set(); for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); @@ -34,7 +59,7 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v rejectUnauthorized: true, agent: h1Agent, path, method, headers: { Host: "claude.ai", ...headers } }); clients.push(req); return req; }; - return { logs, h2, h1, relay, + return { logs, h2, h1, relay, earlyReplies, async close() { const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client)); await relay.close(); @@ -353,16 +378,7 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture((req, res) => { - // Answer like a real origin that replies early: keep reading the body and close with a FIN. - // Bun's own server would close this unfinished request with destroySoon(), a reset that - // Windows delivers before the reply bytes, so the relay could never read what it should relay. - req.on("error", () => res.destroy()); req.resume(); - const socket = req.socket; - socket.destroySoon = () => { socket.end(); return socket; }; - if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } - else res.end("ordinary"); - }); + const f = await fixture("early-fin"); try { const session = f.h2(); const upload = protocol === "h2" @@ -373,6 +389,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); + expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From a9c3d127d3728f50533df32504b787823b5220ee Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:09:36 +0900 Subject: [PATCH 4/6] test: confine draining origin to buffered HTTP/1 upload --- .../claude-integration/claude-picker-upload.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 95c6351f04d..d0bb998b873 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,7 +378,16 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture("early-fin"); + const f = await fixture(input === "buffered" ? "early-fin" : (req, res) => { + // Answer like a real origin that replies early: keep reading the body and close with a FIN. + // Bun's own server would close this unfinished request with destroySoon(), a reset that + // Windows delivers before the reply bytes, so the relay could never read what it should relay. + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } + else res.end("ordinary"); + }); try { const session = f.h2(); const upload = protocol === "h2" @@ -389,7 +398,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); - expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); + if (input === "buffered") expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From 70f875683d119f7151d68413b468017c1ad016ea Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 13:14:30 +0000 Subject: [PATCH 5/6] perf(tooling): use Bun 1.4.4 native type checking Use bun check --no-script --noEmit for developer checks and retain the installed TypeScript CLI as an explicit reference command. Preserve CI and release checks. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- AGENTS.md | 2 +- docs-site/src/content/docs/contributing.md | 15 +++- docs-site/src/content/docs/fr/contributing.md | 4 +- docs-site/src/content/docs/ja/contributing.md | 4 +- docs-site/src/content/docs/ko/contributing.md | 4 +- docs-site/src/content/docs/ru/contributing.md | 4 +- docs-site/src/content/docs/tr/contributing.md | 2 +- .../src/content/docs/zh-cn/contributing.md | 4 +- .../src/content/docs/zh-tw/contributing.md | 4 +- package.json | 3 +- structure/ops/cross-platform-ci.md | 7 ++ .../ci-gui-typecheck-gate.test.ts | 80 ++++++++++++++++++- tests/ci-workflows/cold-spawn-warmup.test.ts | 4 +- 13 files changed, 114 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ba03009389..822700b696d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,7 @@ it binds you regardless of which mechanism is within reach. ```bash bun install -bun run typecheck # bun x tsc --noEmit (strict) +bun run typecheck # bun check --no-script --noEmit (strict) bun run test:changed # import-graph tests against the resolved `dev` merge base bun run test # full tests/ suite (default before review) bun run lint:gui # GUI eslint diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831b..b658e33785b 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -15,7 +15,7 @@ bun install bun run setup:hooks # retire the managed pre-push and post-merge hooks bun run dev:proxy # proxy API in dev mode bun run dev:gui # dashboard dev server (another terminal) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # full suite (default) ``` @@ -33,10 +33,11 @@ yourself — the dev server is unaffected because it rebuilds on demand. ## Build and test commands The root package is Bun-native TypeScript; there is no separate server compile step. Use the checked-in -scripts so local commands match CI: +scripts for local verification: ```bash -bun run typecheck # strict TypeScript check +bun run typecheck # strict Bun-native type check +bun run typecheck:tsc # reference check with the installed TypeScript compiler bun run test:changed # import-graph tests against the resolved dev merge base bun run test # full suite (default) bun test tests/routing/router.test.ts # focused test file @@ -45,6 +46,12 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` +`typecheck` uses Bun's native checker with the root `tsconfig.json`, preserving its `src/` scope +and strict compiler options without emitting files. `--no-script` keeps it on the type checker +if a `check` package script is added later. The installed Bun dependency supplies the checker; +`typecheck:tsc` retains the TypeScript compiler for comparisons and fallback. CI and release preflight +continue to invoke `tsc` directly, and the GUI retains its own TypeScript build check. + The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. `scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. @@ -179,7 +186,7 @@ does not change `main`/`preview` review rules or allow direct pushes, force-push ## Conventions -- **ES Modules only** (`import`/`export`), TypeScript, `strict` mode. Keep `bun x tsc --noEmit` clean. +- **ES Modules only** (`import`/`export`), TypeScript, `strict` mode. Keep `bun run typecheck` clean. - **~500 lines per file max** — split by responsibility (the `web-search/` and `vision/` sidecars are good examples of small, focused modules behind a single `index.ts`). - **Handle async errors at boundaries** — sidecars never throw into the request path; they degrade to diff --git a/docs-site/src/content/docs/fr/contributing.md b/docs-site/src/content/docs/fr/contributing.md index 279b582c754..78c5ba4ae7f 100644 --- a/docs-site/src/content/docs/fr/contributing.md +++ b/docs-site/src/content/docs/fr/contributing.md @@ -15,7 +15,7 @@ bun install bun run setup:hooks # retirer les anciens hooks gérés pre-push et post-merge bun run dev:proxy # proxy API in dev mode bun run dev:gui # dashboard dev server (another terminal) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # suite complète (par défaut) ``` @@ -163,7 +163,7 @@ du dépôt et des chemins sensibles du point de vue de la sécurité est déclar ## Conventions -- **Modules ES uniquement** (`import`/`export`), TypeScript, mode `strict`. Gardez `bun x tsc --noEmit` propre. +- **Modules ES uniquement** (`import`/`export`), TypeScript, mode `strict`. Gardez `bun run typecheck` propre. - **Environ 500 lignes au maximum par fichier** — répartissez le code par responsabilité. Les services auxiliaires `web-search/` et `vision/` sont bons exemples de petits modules ciblés derrière un seul `index.ts`). - **Gérer les erreurs asynchrones aux frontières** — les services auxiliaires ne propagent jamais d’exception dans le diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 9ad672176ec..ba08d50ca25 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -12,7 +12,7 @@ bun install bun run setup:hooks # 旧管理対象 pre-push・post-merge の削除 bun run dev:proxy # 開発モードのプロキシ API bun run dev:gui # ダッシュボード dev サーバー(別ターミナル) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # 全テストスイート(既定) ``` @@ -122,7 +122,7 @@ Go ネイティブポートを担っていた `dev2-go` は廃止し、2 本の ## 規約 -- **ES Modules のみ**(`import`/`export`)、TypeScript、`strict` モード。`bun x tsc --noEmit` をクリーンに +- **ES Modules のみ**(`import`/`export`)、TypeScript、`strict` モード。`bun run typecheck` をクリーンに 保ってください。 - **ファイルあたり最大約 500 行** — 責任ごとに分割してください。単一の `index.ts` の後に小さく集中したモジュールを置いた `web-search/` と `vision/` サイドカーが良い例です。 diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index cb8ba43379e..bcaaa4610e1 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -12,7 +12,7 @@ bun install bun run setup:hooks # 기존 관리형 pre-push·post-merge 제거 bun run dev:proxy # 개발 모드 프록시 API bun run dev:gui # 대시보드 dev 서버(다른 터미널) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # 전체 테스트 스위트 (기본) ``` @@ -121,7 +121,7 @@ Go 네이티브 포트를 담당했던 `dev2-go`는 정리했고, 두 라인을 ## 컨벤션 -- **ES Modules 전용**(`import`/`export`), TypeScript, `strict` 모드. `bun x tsc --noEmit`을 깨끗하게 +- **ES Modules 전용**(`import`/`export`), TypeScript, `strict` 모드. `bun run typecheck`을 깨끗하게 유지하세요. - **파일당 최대 약 500줄** — 책임별로 나누세요. 단일 `index.ts` 뒤에 작고 집중된 모듈을 둔 `web-search/`와 `vision/` 사이드카가 좋은 예입니다. diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 84da5ac93e5..f8e9e58f2cf 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -12,7 +12,7 @@ bun install bun run setup:hooks # удалить прежние управляемые pre-push и post-merge bun run dev:proxy # прокси-API в режиме разработки bun run dev:gui # dev-сервер дашборда (другой терминал) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # полный набор тестов (по умолчанию) ``` @@ -123,7 +123,7 @@ Pull request'ы с ребейзом приветствуются: ребейз ## Конвенции -- **Только ES Modules** (`import`/`export`), TypeScript, режим `strict`. Держите `bun x tsc --noEmit` +- **Только ES Modules** (`import`/`export`), TypeScript, режим `strict`. Держите `bun run typecheck` без ошибок. - **Не более ~500 строк на файл** — разделяйте по ответственности (сайдкары `web-search/` и `vision/` — хорошие примеры небольших сфокусированных модулей за единым `index.ts`). diff --git a/docs-site/src/content/docs/tr/contributing.md b/docs-site/src/content/docs/tr/contributing.md index ff5c86f2cb3..02e00b8e397 100644 --- a/docs-site/src/content/docs/tr/contributing.md +++ b/docs-site/src/content/docs/tr/contributing.md @@ -17,7 +17,7 @@ bun install bun run setup:hooks # eski yönetilen pre-push ve post-merge kancalarını kaldır bun run dev:proxy # geliştirme modunda proxy API bun run dev:gui # kontrol paneli geliştirme sunucusu (başka bir terminalde) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # tam test paketi (varsayılan) ``` diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index 8aa8bb4cef5..3f5e3c898cb 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -12,7 +12,7 @@ bun install bun run setup:hooks # 移除旧的托管 pre-push 和 post-merge bun run dev:proxy # 开发模式代理 API bun run dev:gui # 仪表盘 dev 服务器(另一个终端) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # 完整测试套件(默认) ``` @@ -112,7 +112,7 @@ bun run release:watch # 观察最新的 Release workflow run ## 约定 - **仅使用 ES Modules**(`import`/`export`)、TypeScript 和 `strict` mode。保持 - `bun x tsc --noEmit` 无报错。 + `bun run typecheck` 无报错。 - **每个文件最多约 500 行** —— 按职责拆分。`web-search/` 和 `vision/` sidecar 是很好的例子: 小而专注的 module 位于单一 `index.ts` 之后。 - **在边界处理异步错误** —— sidecar 不会把异常抛进请求路径,而会降级成合适的 marker。 diff --git a/docs-site/src/content/docs/zh-tw/contributing.md b/docs-site/src/content/docs/zh-tw/contributing.md index 1180a1c3208..b027688293b 100644 --- a/docs-site/src/content/docs/zh-tw/contributing.md +++ b/docs-site/src/content/docs/zh-tw/contributing.md @@ -12,7 +12,7 @@ bun install bun run setup:hooks # 移除舊的受管理 pre-push 與 post-merge bun run dev:proxy # 開發模式代理 API bun run dev:gui # 儀表板 dev 伺服器(另一個終端) -bun run typecheck # bun x tsc --noEmit +bun run typecheck # bun check --no-script --noEmit bun run test # 完整測試套件(預設) ``` @@ -132,7 +132,7 @@ bun run release:watch # 觀察最新的 Release workflow run ## 約定 - **僅使用 ES Modules**(`import`/`export`)、TypeScript 和 `strict` mode。保持 - `bun x tsc --noEmit` 無報錯。 + `bun run typecheck` 無報錯。 - **每個檔案最多約 500 行** —— 按職責拆分。`web-search/` 和 `vision/` sidecar 是很好的例子: 小而專注的 module 位於單一 `index.ts` 之後。 - **在邊界處理非同步錯誤** —— sidecar 不會把例外拋進請求路徑,而會降級成合適的 marker。 diff --git a/package.json b/package.json index 89176da2851..b4bbcc38077 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "start": "bun run src/cli/index.ts start", "test": "bun scripts/test.ts", "test:changed": "bun scripts/test.ts --changed=dev", - "typecheck": "bun x tsc --noEmit", + "typecheck": "bun check --no-script --noEmit", + "typecheck:tsc": "bun x tsc --noEmit", "typecheck:gui:if-changed": "bun scripts/typecheck-gui-if-changed.ts", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 080aaaa5146..076790eac03 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -102,6 +102,13 @@ already `bun-` of the runtime being started it is kept unchanged, and a nests one more `bun-` level beneath it. Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` (`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). +Local `bun run typecheck` uses the installed Bun runtime's native checker with the root +`tsconfig.json`, strict options and `src/` scope. `--no-script` prevents a future package `check` +script from replacing type checking, and the command emits no files. `bun run typecheck:tsc` +retains the installed TypeScript compiler for comparison and fallback. CI and release preflight +invoke `tsc` directly; GUI build checks and the separate `testRunnerBun` pin keep their own roles. +`tests/ci-workflows/ci-gui-typecheck-gate.test.ts` exercises both package commands against the root +compiler options, including strict failures in unimported source files and a conflicting `check` script. Across the jobs, the workflow runs: ```bash diff --git a/tests/ci-workflows/ci-gui-typecheck-gate.test.ts b/tests/ci-workflows/ci-gui-typecheck-gate.test.ts index ef22cc92bb0..6fb0d4cdd38 100644 --- a/tests/ci-workflows/ci-gui-typecheck-gate.test.ts +++ b/tests/ci-workflows/ci-gui-typecheck-gate.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { repoPath } from "../helpers/repo-root"; -import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; /** * Every spawnSync below is bounded by the shared spawned-child deadline: Bun's test @@ -185,3 +186,78 @@ describe("gui typecheck if-changed gate", () => { expect((guiBuild!.run ?? "").split(/\r?\n/).map(line => line.trim()).filter(Boolean)).toEqual(["cd gui", "bun run build"]); }); }); + +const scripts = JSON.parse(readFileSync(repoPath("package.json"), "utf8")).scripts as Record; +const config = readFileSync(repoPath("tsconfig.json"), "utf8"); +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); + +function nativeTypecheckFixture(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-native-typecheck-")); + roots.push(root); + writeFileSync(join(root, "tsconfig.json"), config); + writeFileSync(join(root, "package.json"), JSON.stringify({ + type: "module", + scripts: { typecheck: scripts.typecheck, "typecheck:tsc": scripts["typecheck:tsc"], check: "bun check-trap.ts" }, + })); + writeFileSync(join(root, "check-trap.ts"), "process.exit(23);\n"); + // Package scripts must select the installed runtime, even on the older test-runner Bun. + symlinkSync(repoPath("node_modules"), join(root, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + const files = { + "src/config.json": '{"port":8080}', + "src/marker.d.mts": "export interface Marker { id: string }\n", + "src/main.ts": [ + 'import { z } from "zod";', + 'import defaults from "./config.json";', + 'import type { Marker } from "./marker.mjs";', + 'const Schema = z.object({ port: z.number(), kind: z.literal("port") });', + 'export const parsed: z.infer = { port: defaults.port, kind: "port" };', + 'export const marker: Marker = { id: "marker" };', + 'export const pending: Promise = Bun.file("fixture.txt").text();', + ].join("\n"), + "gui/ignored.ts": 'export const intentionallyExcluded: number = "bad";\n', + }; + for (const [path, source] of Object.entries(files)) { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source); + } + return root; +} + +function runTypecheck(root: string, script: string) { + const result = Bun.spawnSync([process.execPath, "run", script], { + cwd: root, timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", stdin: "ignore", + }); + return { exitCode: result.exitCode, output: result.stdout.toString() + result.stderr.toString() }; +} + +test("native and reference typecheck honor root scope and declarations without running a check script or emitting files", () => { + const root = nativeTypecheckFixture(); + for (const script of ["typecheck", "typecheck:tsc"]) { + const result = runTypecheck(root, script); + expect(result.exitCode, result.output).toBe(0); + expect(existsSync(join(root, "dist"))).toBe(false); + expect(existsSync(join(root, "src", "main.js"))).toBe(false); + } +}, SPAWN_BUDGET_MS); + +test("native and reference typecheck reject strict errors in an unimported source file", () => { + const root = nativeTypecheckFixture(); + writeFileSync(join(root, "src", "broken.ts"), [ + "export function untyped(value) { return value; }", + 'export const count: number = "text";', + "const candidate: { port: number } | undefined = Math.random() ? { port: 1 } : undefined;", + "export const port: number = candidate.port;", + 'export const labels: Record<"ready" | "missing", string> = { ready: "Ready" };', + "// @ts-expect-error", + "export const okay: number = 42;", + ].join("\n")); + for (const script of ["typecheck", "typecheck:tsc"]) { + const result = runTypecheck(root, script); + expect(result.exitCode, result.output).not.toBe(0); + const codes = [...new Set([...result.output.matchAll(/\bTS(\d+)\b/g)].map(match => Number(match[1])))]; + expect(codes.sort((left, right) => left - right), result.output).toEqual([2322, 2578, 2741, 7006, 18048]); + expect(existsSync(join(root, "dist"))).toBe(false); + } +}, SPAWN_BUDGET_MS); diff --git a/tests/ci-workflows/cold-spawn-warmup.test.ts b/tests/ci-workflows/cold-spawn-warmup.test.ts index 4410a2f110a..2b14659612e 100644 --- a/tests/ci-workflows/cold-spawn-warmup.test.ts +++ b/tests/ci-workflows/cold-spawn-warmup.test.ts @@ -60,8 +60,8 @@ const DISPOSITIONS: Readonly> = { warmed: false, why: "Its children run scripts/typecheck-gui-if-changed.ts, which imports only node builtins, " - + "or git itself, so an import scan has no repository module graph to warm and a registered " - + "warm-up would fail closed. The spawnSync bounds exist because an unbounded child pinned " + + "git itself, or type checkers on synthetic projects, so there is no repository graph to warm. " + + "The spawnSync bounds exist because an unbounded child pinned " + "Linux test 1/4 batch 4 until the 120s shard deadline cut it (run 37730984813).", }, "tests/ci-workflows/test-runner.test.ts": { From 77653545e2e53e0b4dd477d0e8ee7b10ac7b694e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 01:13:58 +0900 Subject: [PATCH 6/6] fix: preserve reference compiler in prepublish gate --- docs-site/src/content/docs/contributing.md | 2 + package.json | 2 +- structure/ops/cross-platform-ci.md | 5 +- .../ci-gui-typecheck-gate.test.ts | 84 ++++++++++++++++++- 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index b658e33785b..ee98ad36ae6 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -51,6 +51,8 @@ and strict compiler options without emitting files. `--no-script` keeps it on th if a `check` package script is added later. The installed Bun dependency supplies the checker; `typecheck:tsc` retains the TypeScript compiler for comparisons and fallback. CI and release preflight continue to invoke `tsc` directly, and the GUI retains its own TypeScript build check. +The `prepublishOnly` hook also uses `typecheck:tsc`, between the audit and GUI build steps; +an audit or compiler failure stops the remaining steps. The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. `scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. diff --git a/package.json b/package.json index b4bbcc38077..3214f228507 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:macos": "swift run --package-path app MenuBarCoreTests", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", - "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", + "prepublishOnly": "bun run audit:high && bun run typecheck:tsc && bun run build:gui", "release": "bun scripts/release.ts", "release:watch": "bun scripts/release.ts watch", "prepush": "bun run typecheck && bun run typecheck:gui:if-changed && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 076790eac03..7f61301dc25 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -107,8 +107,11 @@ Local `bun run typecheck` uses the installed Bun runtime's native checker with t script from replacing type checking, and the command emits no files. `bun run typecheck:tsc` retains the installed TypeScript compiler for comparison and fallback. CI and release preflight invoke `tsc` directly; GUI build checks and the separate `testRunnerBun` pin keep their own roles. +The `prepublishOnly` hook runs `typecheck:tsc` after `audit:high` and before `build:gui`, +stopping on either audit or compiler failure. `tests/ci-workflows/ci-gui-typecheck-gate.test.ts` exercises both package commands against the root -compiler options, including strict failures in unimported source files and a conflicting `check` script. +compiler options, including strict failures in unimported source files and a conflicting `check` script, +and runs the publish hook with safe audit/build recorders to verify compiler selection and fail-fast order. Across the jobs, the workflow runs: ```bash diff --git a/tests/ci-workflows/ci-gui-typecheck-gate.test.ts b/tests/ci-workflows/ci-gui-typecheck-gate.test.ts index 6fb0d4cdd38..55d3def3f70 100644 --- a/tests/ci-workflows/ci-gui-typecheck-gate.test.ts +++ b/tests/ci-workflows/ci-gui-typecheck-gate.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { repoPath } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -190,7 +190,11 @@ describe("gui typecheck if-changed gate", () => { const scripts = JSON.parse(readFileSync(repoPath("package.json"), "utf8")).scripts as Record; const config = readFileSync(repoPath("tsconfig.json"), "utf8"); const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +const compilerLinks: string[] = []; +afterEach(() => { + for (const path of compilerLinks.splice(0)) unlinkSync(path); + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); function nativeTypecheckFixture(): string { const root = mkdtempSync(join(tmpdir(), "ocx-native-typecheck-")); @@ -261,3 +265,77 @@ test("native and reference typecheck reject strict errors in an unimported sourc expect(existsSync(join(root, "dist"))).toBe(false); } }, SPAWN_BUDGET_MS); + +function prepublishFixture(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-prepublish-typecheck-")); + roots.push(root); + mkdirSync(join(root, "node_modules", ".bin"), { recursive: true }); + const compiler = join(root, "node_modules", "typescript"); + symlinkSync(repoPath("node_modules", "typescript"), compiler, process.platform === "win32" ? "junction" : "dir"); + compilerLinks.push(compiler); + if (process.platform === "win32") { + for (const name of ["tsc.exe", "tsc.bunx"]) { + copyFileSync(repoPath("node_modules", ".bin", name), join(root, "node_modules", ".bin", name)); + } + } else { + const shim = join(root, "node_modules", ".bin", "tsc"); + symlinkSync(repoPath("node_modules", "typescript", "bin", "tsc"), shim); + compilerLinks.push(shim); + } + writeFileSync(join(root, "package.json"), JSON.stringify({ type: "module", scripts: { + // Exercise the real hook and compiler; only audit/build side effects are replaced. + prepublishOnly: scripts.prepublishOnly, + "typecheck:tsc": `${scripts["typecheck:tsc"]} && bun phase.ts compiler`, + typecheck: `${scripts.typecheck} && bun phase.ts native`, + "audit:high": "bun phase.ts audit", + "build:gui": "bun phase.ts build", + } })); + writeFileSync(join(root, "phase.ts"), [ + 'import { appendFileSync } from "node:fs";', + 'appendFileSync("phases.log", process.argv[2] + "\\n");', + 'if (process.env.FAIL_PHASE === process.argv[2]) process.exit(29);', + ].join("\n")); + writeFileSync(join(root, "tsconfig.json"), JSON.stringify({ + compilerOptions: { strict: true, types: [], outDir: "dist" }, files: ["main.ts"], + })); + writeFileSync(join(root, "main.ts"), "export const count: number = 1;\n"); + return root; +} + +function runPrepublish(root: string, failPhase?: string) { + // Reuse the caller runtime, without the repository's installed Bun shadowing it. + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path")); + const result = Bun.spawnSync([process.execPath, "run", "prepublishOnly"], { + cwd: root, timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", stdin: "ignore", + env: { ...env, PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ""}`, FAIL_PHASE: failPhase ?? "" }, + }); + return { + exitCode: result.exitCode, + output: result.stdout.toString() + result.stderr.toString(), + phases: readFileSync(join(root, "phases.log"), "utf8").trim().split(/\r?\n/), + }; +} + +test("prepublishOnly runs the reference compiler between audit and build without emitting files", () => { + const root = prepublishFixture(); + const result = runPrepublish(root); + expect(result.exitCode, result.output).toBe(0); + expect(result.phases).toEqual(["audit", "compiler", "build"]); + expect(existsSync(join(root, "dist"))).toBe(false); + expect(existsSync(join(root, "main.js"))).toBe(false); +}, SPAWN_BUDGET_MS); + +test("prepublishOnly stops before typecheck and build when audit fails", () => { + const result = runPrepublish(prepublishFixture(), "audit"); + expect(result.exitCode, result.output).toBe(29); + expect(result.phases).toEqual(["audit"]); +}, SPAWN_BUDGET_MS); + +test("prepublishOnly stops before build when the reference compiler rejects strict errors", () => { + const root = prepublishFixture(); + writeFileSync(join(root, "main.ts"), 'export const count: number = "bad";\n'); + const result = runPrepublish(root); + expect(result.exitCode, result.output).not.toBe(0); + expect(result.output).toContain("TS2322"); + expect(result.phases).toEqual(["audit"]); +}, SPAWN_BUDGET_MS);