From 6ef37398c8d799c5142a3a6590ecc70ad352b663 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 3 Nov 2026 08:53:35 -0800 Subject: [PATCH] fix(build): fit typecheck and image build in 4 GiB (#18737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors, agents, and image builds run `tsc` to check and compile the repository. > - The repository uses TypeScript 7, which is a native binary. Node heap flags do not limit it. > - The server is one large program, so one `tsc` process needs about 10 GiB. > - The operating system stops `tsc` under a 4 GiB memory limit. > - This pull request checks the server in layers, removes the server from the CLI program, and removes declarations and checks from the image emit. > - The benefit is that the full typecheck and the image `tsc` step now complete in 4 GiB. ## Linked Issues or Issue Description I found no duplicate pull request or issue. A search for `tsc`, memory, typecheck, and OOM found only runtime memory work, which is a different subject. **What happened?** `tsc` used too much memory, and the kernel stopped it with exit code 137 in resource-constrained environments. Measured peaks before this change: | Command | Peak | | --- | --- | | Server `tsc --noEmit` | About 10 GiB | | Server `tsc` emit, as in the Docker build | About 10.7 GiB | | CLI `tsc --noEmit` | About 7 GiB | | `pnpm -r typecheck`, whole process tree | About 10.2 GiB | **Expected behavior** The typecheck and the image `tsc` step complete under a 4 GiB memory limit. **Steps to reproduce** 1. Start a Linux container with `--memory=4g --memory-swap=4g --cpus=4`. 2. Install the dependencies on the base commit. 3. Run `pnpm -r typecheck`. The kernel stops the process with exit code 137. 4. Run `pnpm --filter @paperclipai/server build`. The `tsc` step needs more than 4 GiB. **Paperclip version or commit** Base commit `09162fe893`, with TypeScript 7.0.2. **Deployment mode** Not applicable. This issue applies to development, CI, and image builds. It does not change the server at runtime. ## What Changed - Add `server/scripts/typecheck-layers.mjs`. It reads the import graph on each run and runs one `tsc` process per layer. The layers are the files below the largest import cycle, the cycle, the files above the cycle, and the tests. Each layer reads the earlier layers as declarations. - Add `scripts/run-tsc.mjs`. It adds `--singleThreaded` when `PAPERCLIP_TYPECHECK_LOW_MEMORY=1` is set. The server layers and the UI typecheck use it. - Add `scripts/typecheck-low-memory.mjs`. It checks one package at a time in this mode. It has no `package.json` entry, as `.greptile/rules.md` requires. - Change the server `typecheck` script to build only the Runner TypeScript output. It does not build the Runner release binary now. `pnpm build` and `typecheck:build-gaps` still run the full Runner build and its generated-file checks. - Change the `tsc` step of the server `build` script to `scripts/run-tsc.mjs`. The `Dockerfile` sets `PAPERCLIP_TSC_EMIT_ONLY=1` for that build, so the image emits JavaScript without checks or declarations. Without this variable, `build` still does a full check for npm packages and CI. - Use a non-literal specifier for the `@paperclipai/server` import in `cli/src/commands/run.ts`. The import result already had no useful type, and the literal specifier added the full server to the CLI program. - Add a layered server typecheck step to the `pr-trusted` typecheck job, because `typecheck:build-gaps` does not run this script. - Update `AGENTS.md` and `doc/DEVELOPING.md`. ## Verification Results on Linux use a clean copy of this branch, a new `pnpm install`, Node 24, Rust 1.97.1, 4 CPUs, and a 4 GiB memory limit with no swap. Results before the merge of `master`: | Command | Result | Peak non-reclaimable memory | | --- | --- | --- | | `node scripts/typecheck-low-memory.mjs` | Exit 0, 0 OOM kills | 3,492 MiB | | `PAPERCLIP_TSC_EMIT_ONLY=1 pnpm --filter @paperclipai/server build` | Exit 0 | 3,656 MiB, from the Rust release build | | Server single-program `tsc --noEmit --singleThreaded` | Exit 137, 1 OOM kill | Reached the limit | Results on macOS on the merged head, with peak memory for the full process tree: | Command | Before | After | | --- | --- | --- | | `pnpm -r typecheck` | 10.2 GiB, exit 0 | 5.47 GiB, exit 0 | | `node scripts/typecheck-low-memory.mjs` | Not available | 3.81 GiB, exit 0 | | CLI typecheck | 7.06 GiB | 1.68 GiB, before the merge | Other checks: - Compiler tests: new tests run the real compiler on a small fixture. They prove that the layers emit declarations and pass, that a renamed export fails the layer that imports it, and that a changed type fails the test layer. - Declaration imports: a new compiler test proves that types from `.d.mts` and JSON imports stay correct in later layers. On the real server, a wrong use of `AgentFileManifest` in the cycle layer failed with `TS2353`. - Coverage: a test on the real server source asserts that each source file is in one layer and that no import goes to a later layer. The test layer contains the remaining files. `src/__tests__` stays out of the root list, as in `server/tsconfig.json`. - Error detection: I added one type error to one file in each of the four layers. The typecheck reported all four errors and exited with 1. - Cross-layer errors: I renamed an export in the lower layer. The cycle and upper layers reported the error and the typecheck exited with 1. I reverted these temporary edits. - Emit: full `tsc` and `--noCheck --declaration false --declarationMap false` gave byte-identical JavaScript and source maps for 2,249 files on the base commit. - Runtime: `scripts/server-boot-smoke.sh node server/dist-runtime/index.mjs` passed with a bundle from the emit-only build. - CLI: the esbuild bundle keeps the dynamic import, and `node --check` passed. - Tests on the merged head: `typecheck-layers`, `prepare-runner-vendor`, `cli-invocation-safety`, the CLI `runtime` tests, and all 503 `.github/scripts/tests` tests passed. Before the merge, `release-verify-workflow` and `runner-release-crate-cache` passed. - Full `pnpm --filter @paperclipai/server build` passed before the merge. Failures during this work: - The first low-memory run failed in the UI with `TS5023: Unknown compiler option '-b'`, because the flag came before `-b`. I corrected the argument order and added a test that runs build mode with the flag. - The first rename check did not rename the export, because BSD `sed` does not support `\b`. Its exit 0 was not evidence. I ran the check again with `perl`, and the result above is from that run. - The first version of the real-compiler fixture had a `TS2774` error in its own source. The test found this error, and I corrected the fixture. - CI failed `server-package-build-script.test.ts` on `1abc5e5a43`, because that test reads the steps of the server `build` script, and I had moved them to `build:prepare` and `build:assets`. My first search for readers of this script did not include `server/src`. I removed those scripts and `build:image`, restored one `build` script, and kept the test unchanged. - `GOMEMLIMIT` did not help enough, and I removed it from the plan. On these steps, `3GiB` saved at most about 300 MiB. With default checkers, the cycle layer was stopped with and without it, and the lower layer went from 7 seconds to 151 seconds. Not run or not proven: - I did not run the full `pnpm test:run`, a root `pnpm build`, or a full `docker build`. I expect CI to run the test and build gates. - I did not measure the Docker build steps other than the server build. - I did not run the 4 GiB checks on x86-64. - I did not measure the merged head in the 4 GiB container. The macOS result on the merged head is 3.81 GiB, and the merge added one source file to the lower layer. ## Risks - The margin is small. In the 4 GiB container, the cycle layer peaks about 600 MiB below the limit, and the Rust release build in the image build peaks about 440 MiB below it. Growth of the largest import cycle uses this margin first. - `node scripts/typecheck-low-memory.mjs` applies only to `pnpm -r` packages. These commands still check the server as one program and need more than 4 GiB: `pnpm build`, `test:e2e:runner:typecheck`, `test:lifecycle-baseline:typecheck`, and the `tests/speko-native` project. - A local flow that used the Runner binary from a previous typecheck must now run `pnpm --filter @paperclipai/paperclip-runner build`. - The image does not include server declarations now. I found no consumer in this repository. A downstream image that reads `server/dist/*.d.ts` must change. - An image build does not find type errors now. A typecheck in CI must find them before the image build. - The import scan uses a regular expression. A false edge can only make a layer larger. A missed edge causes a `TS6307` failure, so it cannot skip a file. - The new CI step adds about one minute to the typecheck job. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with reasoning, tool use, and code execution. The runtime does not show the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude --- .github/workflows/pr-trusted.yml | 5 + AGENTS.md | 2 + Dockerfile | 5 +- cli/src/commands/run.ts | 7 +- doc/DEVELOPING.md | 40 ++ scripts/run-tsc.mjs | 47 +++ scripts/typecheck-low-memory.mjs | 41 ++ server/package.json | 4 +- server/scripts/prepare-runner-vendor.mjs | 2 +- server/scripts/typecheck-layers.mjs | 257 +++++++++++++ server/scripts/typecheck-layers.test.mjs | 471 +++++++++++++++++++++++ ui/package.json | 2 +- 12 files changed, 876 insertions(+), 7 deletions(-) create mode 100644 scripts/run-tsc.mjs create mode 100644 scripts/typecheck-low-memory.mjs create mode 100644 server/scripts/typecheck-layers.mjs create mode 100644 server/scripts/typecheck-layers.test.mjs diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 09e6144e041..e0db2a58a2a 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -416,6 +416,11 @@ jobs: - name: Typecheck workspaces whose build scripts skip TypeScript run: pnpm run typecheck:build-gaps + - name: Typecheck the server in low-memory layers + env: + PAPERCLIP_TYPECHECK_LOW_MEMORY: "1" + run: pnpm --filter @paperclipai/server typecheck + - name: Verify repository-wide CLI guidance safety run: pnpm exec vitest run --project @paperclipai/server server/src/__tests__/cli-invocation-safety.test.ts diff --git a/AGENTS.md b/AGENTS.md index 31c37642966..800d3cc973e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,6 +223,8 @@ pnpm test:run pnpm build ``` +If memory is limited, use `node scripts/typecheck-low-memory.mjs` instead of `pnpm -r typecheck`. See `doc/DEVELOPING.md`. + If anything cannot be run, explicitly report what was not run and why. ## 8. API and Auth Expectations diff --git a/Dockerfile b/Dockerfile index 5360d08396a..e392be44845 100644 --- a/Dockerfile +++ b/Dockerfile @@ -146,8 +146,11 @@ RUN pnpm --filter @paperclipai/plugin-sdk build # next RUN as an environment variable. The production stage below declares the # same ARG again for the runtime fallback; an ARG goes out of scope at the # end of its stage. Empty for local `docker build`, which then writes no stamp. +# NODE_OPTIONS covers the Node build steps. tsc is a native binary and ignores it. ENV NODE_OPTIONS=--max-old-space-size=4096 -RUN pnpm --filter @paperclipai/server build +# The image needs no declarations, and CI type-checks this source separately. +# Emitting without either cuts the tsc step from more than 10 GiB to about 2.5 GiB. +RUN PAPERCLIP_TSC_EMIT_ONLY=1 pnpm --filter @paperclipai/server build RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) RUN pnpm --filter @paperclipai/server build:runtime \ && test -f server/dist-runtime/index.mjs || (echo "ERROR: server runtime bundle missing" && exit 1) diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index a141a9a42ce..5a1d9fa7f03 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -232,8 +232,11 @@ async function importServerEntry(): Promise { // Production mode: import the published @paperclipai/server package try { - const mod = await import("@paperclipai/server"); - return await startServerFromModule(mod, "@paperclipai/server"); + // A literal specifier would pull the whole server program into the CLI + // typecheck. This import is untyped either way, like the dev import above. + const serverPackage = "@paperclipai/server"; + const mod = await import(serverPackage); + return await startServerFromModule(mod, serverPackage); } catch (err) { const missingSpecifier = getMissingModuleSpecifier(err); const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server"; diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index d7608647f99..cb6a7d2a57e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1132,6 +1132,46 @@ falls back to the fresh path automatically, so the cache can make a suite slower but never fail it or skip it: a migration that cannot apply still fails the suite on the fresh path. +#### Typecheck memory + +TypeScript 7 is a native binary, so `NODE_OPTIONS=--max-old-space-size` does not +limit `tsc`. Its peak memory depends on how much source one process checks and on +the number of checkers, because each checker keeps its own copy of the types. + +`pnpm --filter @paperclipai/server typecheck` does not check one program. +`server/scripts/typecheck-layers.mjs` reads the import graph and runs one `tsc` +process per layer. Each layer reads the earlier layers as declarations: + +| Layer | Files | +| --- | --- | +| `below` | Files the largest import cycle depends on, and unrelated files | +| `cycle` | The largest import cycle | +| `above` | Files that depend on the cycle | +| `tests` | Test files | + +The generated configs and declarations go in +`server/node_modules/.cache/paperclip-typecheck`. You do not maintain a layer list. +`server/tsconfig.json` remains the single program for editors and `pnpm build`. +The server typecheck builds only the Runner's TypeScript output. Use +`pnpm --filter @paperclipai/paperclip-runner build` when a local check needs the +Runner binary. + +If the memory available to the task is limited, use this command: + +```sh +node scripts/typecheck-low-memory.mjs +``` + +It peaks at about 3.8 GiB, against about 5.5 GiB for `pnpm -r typecheck` on 16 +cores. It also takes about twice as long. + +It checks one package at a time and uses one thread for the server layers and the +UI. Do not add `GOMEMLIMIT` to this path. It saves at most 300 MiB here, and it +slows `tsc` by up to 20 times when the live heap goes above the limit. +`pnpm build` still checks the whole server program and needs more than 8 GiB. The +Docker image sets `PAPERCLIP_TSC_EMIT_ONLY=1` for that build, which emits +JavaScript without checks or declarations. + #### Reused Runner build in the server build The server build vendors `packages/paperclip-runner/dist`, so its diff --git a/scripts/run-tsc.mjs b/scripts/run-tsc.mjs new file mode 100644 index 00000000000..143c5f341e7 --- /dev/null +++ b/scripts/run-tsc.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Runs the calling package's tsc, honoring the repository's low-memory mode. +// +// TypeScript 7 is a native binary, so Node heap flags do not limit it. Each +// checker keeps its own copy of the types, which makes the checker count the +// most useful control a package has over its peak memory. +// scripts/typecheck-low-memory.mjs sets PAPERCLIP_TYPECHECK_LOW_MEMORY=1 so the +// largest programs check on a single thread. +// +// A build that only needs JavaScript sets PAPERCLIP_TSC_EMIT_ONLY=1. That build +// skips type checks and declarations, which use most of the memory of an emit. +// The Docker image uses this mode. +// +// Usage: node ../scripts/run-tsc.mjs [tsc arguments] + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const LOW_MEMORY_VARIABLE = "PAPERCLIP_TYPECHECK_LOW_MEMORY"; +export const EMIT_ONLY_VARIABLE = "PAPERCLIP_TSC_EMIT_ONLY"; + +export function lowMemoryTscArgs(env = process.env) { + return env[LOW_MEMORY_VARIABLE] === "1" ? ["--singleThreaded"] : []; +} + +export function emitOnlyTscArgs(env = process.env) { + return env[EMIT_ONLY_VARIABLE] === "1" + ? ["--noCheck", "--declaration", "false", "--declarationMap", "false"] + : []; +} + +export function runTsc(args, { cwd = process.cwd(), env = process.env } = {}) { + const tsc = path.join(cwd, "node_modules", "typescript", "bin", "tsc"); + // tsc only accepts -b as its first argument, so the caller's arguments go first. + const result = spawnSync(process.execPath, [tsc, ...args, ...lowMemoryTscArgs(env), ...emitOnlyTscArgs(env)], { + cwd, + env, + stdio: "inherit", + }); + if (result.error) throw result.error; + return { status: result.status ?? 1, signal: result.signal }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = runTsc(process.argv.slice(2)).status; +} diff --git a/scripts/typecheck-low-memory.mjs b/scripts/typecheck-low-memory.mjs new file mode 100644 index 00000000000..a4c54dce846 --- /dev/null +++ b/scripts/typecheck-low-memory.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Runs the workspace typecheck with less memory, but more slowly. It checks one +// package at a time, and scripts/run-tsc.mjs uses one thread for the largest +// programs. See "Typecheck memory" in doc/DEVELOPING.md. +// +// Usage: node scripts/typecheck-low-memory.mjs + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { LOW_MEMORY_VARIABLE } from "./run-tsc.mjs"; + +export const STEPS = [ + ["run", "preflight:workspace-links"], + ["-r", "--workspace-concurrency=1", "typecheck"], +]; + +function runPnpm(args, options) { + const result = spawnSync("pnpm", args, { + ...options, + stdio: "inherit", + // A bare `pnpm` on Windows is pnpm.cmd, which needs the shell to resolve. + shell: process.platform === "win32", + }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +export function typecheckLowMemory({ cwd, env = process.env, run = runPnpm }) { + for (const args of STEPS) { + const status = run(args, { cwd, env: { ...env, [LOW_MEMORY_VARIABLE]: "1" } }); + if (status !== 0) return status; + } + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = typecheckLowMemory({ + cwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), + }); +} diff --git a/server/package.json b/server/package.json index 0265e98d04e..acf37f6a691 100644 --- a/server/package.json +++ b/server/package.json @@ -36,14 +36,14 @@ "dev": "tsx src/index.ts", "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", - "build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && cp ../packages/paperclip-runner/scripts/install-copilot-assets.mjs ../packages/paperclip-runner/scripts/materialize-copilot-binary.mjs ../packages/paperclip-runner/scripts/copilot-inner-distribution.mjs dist/ && node dist/install-copilot-assets.mjs && node scripts/write-build-stamp.mjs", + "build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && node ../scripts/run-tsc.mjs && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && cp ../packages/paperclip-runner/scripts/install-copilot-assets.mjs ../packages/paperclip-runner/scripts/materialize-copilot-binary.mjs ../packages/paperclip-runner/scripts/copilot-inner-distribution.mjs dist/ && node dist/install-copilot-assets.mjs && node scripts/write-build-stamp.mjs", "prepack": "pnpm run prepare:ui-dist && pnpm run build", "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "build:runtime": "node scripts/bundle-runtime.mjs", "start": "node dist/index.js", "prepare:runner-vendor": "node scripts/prepare-runner-vendor.mjs", - "typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", + "typecheck": "pnpm --filter @paperclipai/paperclip-runner run build:typescript && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/typecheck-layers.mjs", "postinstall": "node scripts/install-copilot-assets.mjs" }, "dependencies": { diff --git a/server/scripts/prepare-runner-vendor.mjs b/server/scripts/prepare-runner-vendor.mjs index d41e86bd149..f774d440f53 100644 --- a/server/scripts/prepare-runner-vendor.mjs +++ b/server/scripts/prepare-runner-vendor.mjs @@ -1,7 +1,7 @@ // Makes sure packages/paperclip-runner/dist is a current build before the // server build vendors it, rebuilding the Runner only when that is needed. // -// The server build (and typecheck) used to run the Runner's full `build` +// The server build used to run the Runner's full `build` // unconditionally here. That is what lets a standalone // `pnpm --filter @paperclipai/server build` produce a current vendored Runner // on its own, which the Dockerfile relies on. But inside `pnpm -r build` the diff --git a/server/scripts/typecheck-layers.mjs b/server/scripts/typecheck-layers.mjs new file mode 100644 index 00000000000..99cfb37bfb6 --- /dev/null +++ b/server/scripts/typecheck-layers.mjs @@ -0,0 +1,257 @@ +// Typechecks the server in layers so that no single tsc process holds the whole +// program in memory. +// +// tsc keeps every type it creates until it exits, so its peak grows with the +// source it checks. The server is about 1,700 source files and 500 test files, +// and one program over all of them needs more than 8 GiB at any setting. Most +// of those files are not in an import cycle, so they can be checked separately +// and read back as declarations: +// +// below files the largest import cycle depends on, plus unrelated files +// cycle the largest import cycle, reading `below` as declarations +// above files that depend on the cycle, reading both as declarations +// tests test files, reading all three as declarations +// +// The layers come from the import graph on every run, so nothing has to move +// and no list can go stale. The import scan does not have to be perfect: if it +// misses an edge, a file imports one outside its layer's references and tsc +// fails with TS6307 instead of skipping the file. +// +// server/tsconfig.json stays the single program that editors and `pnpm build` +// use. +// +// Usage: node scripts/typecheck-layers.mjs + +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runTsc } from "../../scripts/run-tsc.mjs"; + +const IMPORT_SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.[^"']+)["']/g; +const OUTPUT_DIRECTORY = path.join("node_modules", ".cache", "paperclip-typecheck"); + +const isTestFile = (file) => /\.test\.tsx?$/.test(file) || file.split("/").includes("__tests__"); +// tsconfig.json excludes this tree. A test reaches its helpers through imports. +const isExcludedFromProgram = (file) => file.startsWith("src/__tests__/"); + +export function listSourceFiles(serverRoot) { + const files = []; + const walk = (directory) => { + for (const entry of readdirSync(path.join(serverRoot, directory), { withFileTypes: true })) { + const relative = `${directory}/${entry.name}`; + if (entry.isDirectory()) walk(relative); + else if (/\.tsx?$/.test(entry.name)) files.push(relative); + } + }; + walk("src"); + return files.sort(); +} + +/** Resolves the relative imports of each file. `files` maps a path to its text. */ +export function readImports(files) { + const imports = new Map(); + for (const [file, text] of files) { + const modules = new Set(); + const json = new Set(); + for (const [, specifier] of text.matchAll(IMPORT_SPECIFIER)) { + const target = path.posix.join(path.posix.dirname(file), specifier); + if (target.endsWith(".json")) { + json.add(target); + continue; + } + const candidates = [ + target, + target.replace(/\.js$/, ".ts"), + target.replace(/\.js$/, ".tsx"), + `${target}.ts`, + `${target}/index.ts`, + ]; + const resolved = candidates.find((candidate) => files.has(candidate)); + if (resolved) modules.add(resolved); + } + imports.set(file, { modules, json }); + } + return imports; +} + +/** Tarjan's algorithm, iterative because import chains can be very deep. */ +function largestCycle(graph) { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack = []; + let next = 0; + let largest = []; + + for (const root of graph.keys()) { + if (index.has(root)) continue; + const work = [{ node: root, edges: [...graph.get(root)], position: 0 }]; + index.set(root, next); + low.set(root, next); + next += 1; + stack.push(root); + onStack.add(root); + while (work.length > 0) { + const frame = work[work.length - 1]; + if (frame.position < frame.edges.length) { + const edge = frame.edges[frame.position]; + frame.position += 1; + if (!index.has(edge)) { + index.set(edge, next); + low.set(edge, next); + next += 1; + stack.push(edge); + onStack.add(edge); + work.push({ node: edge, edges: [...graph.get(edge)], position: 0 }); + } else if (onStack.has(edge)) { + low.set(frame.node, Math.min(low.get(frame.node), index.get(edge))); + } + continue; + } + work.pop(); + const parent = work[work.length - 1]; + if (parent) low.set(parent.node, Math.min(low.get(parent.node), low.get(frame.node))); + if (low.get(frame.node) === index.get(frame.node)) { + const component = []; + let member; + do { + member = stack.pop(); + onStack.delete(member); + component.push(member); + } while (member !== frame.node); + if (component.length > largest.length) largest = component; + } + } + } + return largest.length > 1 ? new Set(largest) : new Set(); +} + +function reachableFrom(start, graph) { + const seen = new Set(start); + const pending = [...start]; + while (pending.length > 0) { + for (const edge of graph.get(pending.pop()) ?? []) { + if (!seen.has(edge)) { + seen.add(edge); + pending.push(edge); + } + } + } + return seen; +} + +/** + * Sorts every file into the layers, in check order. `files` maps a path to its + * text. Declaration files are ambient, so every layer lists them. + */ +export function planLayers(files) { + const declarations = [...files.keys()].filter((file) => file.endsWith(".d.ts")); + const candidates = new Map([...files].filter(([file]) => !file.endsWith(".d.ts"))); + const imports = readImports(candidates); + + const graph = new Map(); + const reversed = new Map(); + for (const file of candidates.keys()) { + if (!isTestFile(file)) { + graph.set(file, new Set()); + reversed.set(file, new Set()); + } + } + for (const file of graph.keys()) { + for (const target of imports.get(file).modules) { + if (graph.has(target)) { + graph.get(file).add(target); + reversed.get(target).add(file); + } + } + } + + const cycle = largestCycle(graph); + const dependents = reachableFrom(cycle, reversed); + const above = [...dependents].filter((file) => !cycle.has(file)); + const below = [...graph.keys()].filter((file) => !dependents.has(file)); + const tests = [...candidates.keys()].filter((file) => isTestFile(file) && !isExcludedFromProgram(file)); + + const layer = (name, members, references) => { + const sorted = [...members].sort(); + const json = new Set(sorted.flatMap((file) => [...imports.get(file).json])); + return { name, sourceFiles: sorted, files: [...sorted, ...[...json].sort(), ...declarations], references }; + }; + return [ + layer("below", below, []), + layer("cycle", cycle, ["below"]), + layer("above", above, ["below", "cycle"]), + layer("tests", tests, ["below", "cycle", "above"]), + ].filter((candidate) => candidate.sourceFiles.length > 0); +} + +/** Builds the tsconfig for one layer. Paths are relative to the output directory. */ +export function layerConfig(layer, { emptyLayers = new Set() } = {}) { + const fromOutput = (file) => path.posix.join("../../..", file); + const references = layer.references + .filter((name) => !emptyLayers.has(name)) + .map((name) => ({ path: `./tsconfig.${name}.json` })); + const output = + layer.name === "tests" + ? { composite: false, declaration: false, declarationMap: false, sourceMap: false, noEmit: true } + : { + composite: true, + declaration: true, + declarationMap: false, + sourceMap: false, + emitDeclarationOnly: true, + outDir: "./dts", + tsBuildInfoFile: `./${layer.name}.tsbuildinfo`, + }; + return { + extends: fromOutput("tsconfig.json"), + compilerOptions: { ...output, rootDir: fromOutput("src") }, + files: layer.files.map(fromOutput), + include: [], + references, + }; +} + +export function typecheckLayers({ + serverRoot, + run = runTsc, + log = console.log, +}) { + const sourceFiles = listSourceFiles(serverRoot); + const layers = planLayers( + new Map(sourceFiles.map((file) => [file, readFileSync(path.join(serverRoot, file), "utf8")])), + ); + const planned = new Set(layers.map((layer) => layer.name)); + const emptyLayers = new Set(["below", "cycle", "above", "tests"].filter((name) => !planned.has(name))); + + const outputRoot = path.join(serverRoot, OUTPUT_DIRECTORY); + rmSync(outputRoot, { recursive: true, force: true }); + mkdirSync(outputRoot, { recursive: true }); + // Without this, the path under node_modules would make the declarations CommonJS. + writeFileSync(path.join(outputRoot, "package.json"), `${JSON.stringify({ type: "module" })}\n`); + for (const layer of layers) { + writeFileSync( + path.join(outputRoot, `tsconfig.${layer.name}.json`), + `${JSON.stringify(layerConfig(layer, { emptyLayers }), null, 2)}\n`, + ); + } + + let exitCode = 0; + for (const layer of layers) { + log(`typecheck:layers: ${layer.name} (${layer.sourceFiles.length} files)`); + const config = path.join(OUTPUT_DIRECTORY, `tsconfig.${layer.name}.json`); + const { status, signal } = run(["-p", config], { cwd: serverRoot }); + // A killed process leaves no declarations, so later layers would only add noise. + if (signal) return status || 1; + // Later layers still run after type errors. Each file is checked in one + // layer, so this reports everything a single tsc run would. + if (status !== 0) exitCode = status; + } + return exitCode; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = typecheckLayers({ + serverRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), + }); +} diff --git a/server/scripts/typecheck-layers.test.mjs b/server/scripts/typecheck-layers.test.mjs new file mode 100644 index 00000000000..eec3f2171e8 --- /dev/null +++ b/server/scripts/typecheck-layers.test.mjs @@ -0,0 +1,471 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { emitOnlyTscArgs, lowMemoryTscArgs, runTsc } from "../../scripts/run-tsc.mjs"; +import { STEPS, typecheckLowMemory } from "../../scripts/typecheck-low-memory.mjs"; +import { layerConfig, listSourceFiles, planLayers, readImports, typecheckLayers } from "./typecheck-layers.mjs"; + +const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourcesOf = (layers, name) => layers.find((layer) => layer.name === name)?.sourceFiles; + +describe("readImports", () => { + it("resolves every import form that TypeScript follows", () => { + const files = new Map([ + [ + "src/a.ts", + [ + `import { b } from "./b.js";`, + `export * from "./c.js";`, + `import "./d.js";`, + `const e = await import("./e.js");`, + `type F = typeof import("./f.js");`, + `import g from "./g/index.js";`, + `import h from "./h.js";`, + `import data from "./data.json" with { type: "json" };`, + `import { z } from "zod";`, + ].join("\n"), + ], + ["src/b.ts", ""], + ["src/c.ts", ""], + ["src/d.ts", ""], + ["src/e.ts", ""], + ["src/f.ts", ""], + ["src/g/index.ts", ""], + ["src/h.tsx", ""], + ]); + + const { modules, json } = readImports(files).get("src/a.ts"); + + expect([...modules].sort()).toEqual([ + "src/b.ts", + "src/c.ts", + "src/d.ts", + "src/e.ts", + "src/f.ts", + "src/g/index.ts", + "src/h.tsx", + ]); + expect([...json]).toEqual(["src/data.json"]); + }); +}); + +describe("planLayers", () => { + it("separates the largest cycle from what it depends on and what depends on it", () => { + const layers = planLayers( + new Map([ + ["src/leaf.ts", ""], + ["src/unrelated.ts", ""], + ["src/one.ts", `import "./two.js"; import "./leaf.js";`], + ["src/two.ts", `import "./one.js";`], + ["src/route.ts", `import "./one.js";`], + ["src/route.test.ts", `import "./route.js";`], + ["src/types/ambient.d.ts", ""], + ]), + ); + + expect(sourcesOf(layers, "below")).toEqual(["src/leaf.ts", "src/unrelated.ts"]); + expect(sourcesOf(layers, "cycle")).toEqual(["src/one.ts", "src/two.ts"]); + expect(sourcesOf(layers, "above")).toEqual(["src/route.ts"]); + expect(sourcesOf(layers, "tests")).toEqual(["src/route.test.ts"]); + for (const layer of layers) expect(layer.files).toContain("src/types/ambient.d.ts"); + }); + + it("keeps test support files in the test layer and leaves src/__tests__ to imports", () => { + const layers = planLayers( + new Map([ + ["src/a.ts", ""], + ["src/services/__tests__/fixtures.ts", ""], + ["src/__tests__/helpers/db.ts", ""], + ["src/__tests__/big.test.ts", ""], + ]), + ); + + expect(sourcesOf(layers, "below")).toEqual(["src/a.ts"]); + expect(sourcesOf(layers, "tests")).toEqual(["src/services/__tests__/fixtures.ts"]); + }); + + it("lists imported JSON in the layer that imports it", () => { + const layers = planLayers( + new Map([ + ["src/a.ts", `import data from "./data.json";`], + ["src/one.ts", `import "./two.js"; import data from "./cycle.json";`], + ["src/two.ts", `import "./one.js";`], + ]), + ); + + expect(layers.find((layer) => layer.name === "below").files).toContain("src/data.json"); + expect(layers.find((layer) => layer.name === "cycle").files).toContain("src/cycle.json"); + }); + + it("omits layers with no files, as when the source has no cycle", () => { + const layers = planLayers(new Map([["src/a.ts", ""], ["src/b.ts", `import "./a.js";`]])); + + expect(layers.map((layer) => layer.name)).toEqual(["below"]); + expect(sourcesOf(layers, "below")).toEqual(["src/a.ts", "src/b.ts"]); + }); + + it("covers the real server: one layer per file, and no import reaches a later layer", () => { + const sourceFiles = listSourceFiles(serverRoot); + const files = new Map( + sourceFiles.map((file) => [file, readFileSync(path.join(serverRoot, file), "utf8")]), + ); + const layers = planLayers(files); + + const planned = layers.flatMap((layer) => layer.sourceFiles); + expect(new Set(planned).size).toBe(planned.length); + expect(planned.sort()).toEqual( + sourceFiles.filter((file) => !file.endsWith(".d.ts") && !file.startsWith("src/__tests__/")).sort(), + ); + + const position = new Map( + layers.flatMap((layer, index) => layer.sourceFiles.map((file) => [file, index])), + ); + const imports = readImports(new Map([...files].filter(([file]) => !file.endsWith(".d.ts")))); + for (const layer of layers.filter((candidate) => candidate.name !== "tests")) { + for (const file of layer.sourceFiles) { + for (const target of imports.get(file).modules) { + if (position.has(target)) expect(position.get(target)).toBeLessThanOrEqual(position.get(file)); + } + } + } + }); +}); + +describe("layerConfig", () => { + const layer = { name: "cycle", files: ["src/a.ts"], references: ["below"] }; + + it("emits declarations for source layers and references earlier ones", () => { + const config = layerConfig(layer); + + expect(config.compilerOptions).toMatchObject({ composite: true, emitDeclarationOnly: true, outDir: "./dts" }); + expect(config.extends).toBe("../../../tsconfig.json"); + expect(config.files).toEqual(["../../../src/a.ts"]); + expect(config.references).toEqual([{ path: "./tsconfig.below.json" }]); + }); + + it("emits nothing for tests and drops references to empty layers", () => { + const config = layerConfig( + { name: "tests", files: [], references: ["below", "cycle"] }, + { emptyLayers: new Set(["cycle"]) }, + ); + + expect(config.compilerOptions).toMatchObject({ composite: false, noEmit: true }); + expect(config.references).toEqual([{ path: "./tsconfig.below.json" }]); + }); +}); + +describe("typecheckLayers", () => { + const roots = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + function serverFixture() { + const root = mkdtempSync(path.join(tmpdir(), "typecheck-layers-")); + roots.push(root); + mkdirSync(path.join(root, "src")); + writeFileSync(path.join(root, "src", "leaf.ts"), ""); + writeFileSync(path.join(root, "src", "one.ts"), `import "./two.js"; import "./leaf.js";`); + writeFileSync(path.join(root, "src", "two.ts"), `import "./one.js";`); + writeFileSync(path.join(root, "src", "route.ts"), `import "./one.js";`); + writeFileSync(path.join(root, "src", "route.test.ts"), `import "./route.js";`); + return root; + } + + it("runs the layers in order and marks the output directory as ESM", () => { + const root = serverFixture(); + const configs = []; + + const status = typecheckLayers({ + serverRoot: root, + log: () => {}, + run: (args) => { + configs.push(path.basename(args[1])); + return { status: 0, signal: null }; + }, + }); + + expect(status).toBe(0); + expect(configs).toEqual([ + "tsconfig.below.json", + "tsconfig.cycle.json", + "tsconfig.above.json", + "tsconfig.tests.json", + ]); + const output = path.join(root, "node_modules", ".cache", "paperclip-typecheck"); + expect(JSON.parse(readFileSync(path.join(output, "package.json"), "utf8"))).toEqual({ type: "module" }); + }); + + it("reports type errors from every layer", () => { + const root = serverFixture(); + const configs = []; + + const status = typecheckLayers({ + serverRoot: root, + log: () => {}, + run: (args) => { + configs.push(args[1]); + return { status: configs.length === 1 ? 2 : 0, signal: null }; + }, + }); + + expect(status).toBe(2); + expect(configs).toHaveLength(4); + }); + + it("stops when a process is killed, because later layers have no declarations", () => { + const root = serverFixture(); + const configs = []; + + const status = typecheckLayers({ + serverRoot: root, + log: () => {}, + run: (args) => { + configs.push(args[1]); + return { status: 1, signal: "SIGKILL" }; + }, + }); + + expect(status).toBe(1); + expect(configs).toHaveLength(1); + }); +}); + +describe("typecheckLayers with the real compiler", () => { + const tsc = path.join(serverRoot, "node_modules", "typescript", "bin", "tsc"); + const roots = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + function compilerFixture() { + const root = mkdtempSync(path.join(tmpdir(), "typecheck-layers-real-")); + roots.push(root); + mkdirSync(path.join(root, "src")); + writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ type: "module" })}\n`); + writeFileSync( + path.join(root, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + target: "ES2023", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + types: [], + outDir: "dist", + rootDir: "src", + resolveJsonModule: true, + skipLibCheck: true, + }, + include: ["src"], + })}\n`, + ); + const sources = { + "leaf.ts": `export const leaf = 1;\n`, + "one.ts": `import { two } from "./two.js";\nimport { leaf } from "./leaf.js";\nexport const one = () => leaf + two();\n`, + "two.ts": `import { one } from "./one.js";\nexport const two = (): number => one.length;\n`, + "route.ts": `import { one } from "./one.js";\nexport const route = () => one();\n`, + "route.test.ts": `import { route } from "./route.js";\nexport const checked: number = route();\n`, + }; + for (const [name, text] of Object.entries(sources)) { + writeFileSync(path.join(root, "src", name), text); + } + return root; + } + + function check(root) { + const output = []; + const status = typecheckLayers({ + serverRoot: root, + log: () => {}, + run: (args, { cwd }) => { + const result = spawnSync(process.execPath, [tsc, ...args], { cwd, encoding: "utf8" }); + output.push(result.stdout, result.stderr); + return { status: result.status ?? 1, signal: result.signal }; + }, + }); + return { status, output: output.join("") }; + } + + it("emits declarations between layers and checks every layer", () => { + const root = compilerFixture(); + + const { status, output } = check(root); + + expect(output).toBe(""); + expect(status).toBe(0); + const declarations = path.join(root, "node_modules", ".cache", "paperclip-typecheck", "dts"); + for (const name of ["leaf.d.ts", "one.d.ts", "two.d.ts", "route.d.ts"]) { + expect(existsSync(path.join(declarations, name))).toBe(true); + } + expect(existsSync(path.join(declarations, "route.test.d.ts"))).toBe(false); + }, 60_000); + + it("reports a changed export in the layers that depend on it", () => { + const root = compilerFixture(); + writeFileSync(path.join(root, "src", "leaf.ts"), `export const renamedLeaf = 1;\n`); + + const { status, output } = check(root); + + expect(status).not.toBe(0); + expect(output).toMatch(/src\/one\.ts\(\d+,\d+\): error TS\d+/); + expect(output).toContain("leaf"); + }, 60_000); + + it("reports a type change through declarations in the test layer", () => { + const root = compilerFixture(); + writeFileSync( + path.join(root, "src", "route.ts"), + `import { one } from "./one.js";\nexport const route = (): string => String(one());\n`, + ); + + const { status, output } = check(root); + + expect(status).not.toBe(0); + expect(output).toMatch(/src\/route\.test\.ts\(\d+,\d+\): error TS2322/); + }, 60_000); + + // Emitted declarations keep imports of .d.mts and JSON files, which tsc does not + // copy to the output directory. If those imports stopped resolving, skipLibCheck + // would hide it and the types would become `any`, so these errors would disappear. + it("keeps types from .d.mts and JSON imports in later layers", () => { + const root = compilerFixture(); + mkdirSync(path.join(root, "src", "scripts")); + writeFileSync(path.join(root, "src", "scripts", "helper.mjs"), `export {};\n`); + writeFileSync(path.join(root, "src", "scripts", "helper.d.mts"), `export interface Manifest { version: number }\n`); + writeFileSync(path.join(root, "src", "catalog.json"), `${JSON.stringify({ tools: [{ name: "a" }] })}\n`); + writeFileSync( + path.join(root, "src", "leaf.ts"), + [ + `import catalog from "./catalog.json" with { type: "json" };`, + `import type { Manifest } from "./scripts/helper.mjs";`, + `export type { Manifest };`, + `export type Tool = (typeof catalog.tools)[number];`, + `export const leaf = 1;`, + ``, + ].join("\n"), + ); + const misuse = [ + `import type { Manifest, Tool } from "./leaf.js";`, + `export const manifest: Manifest = { wrong: 1 };`, + `export const tool: Tool = { wrong: 1 };`, + ``, + ].join("\n"); + writeFileSync(path.join(root, "src", "cycle-misuse.ts"), `import { one } from "./one.js";\nexport const used = one;\n${misuse}`); + writeFileSync( + path.join(root, "src", "two.ts"), + `import { one } from "./one.js";\nimport { used } from "./cycle-misuse.js";\nexport const two = (): number => one.length + used.length;\n`, + ); + writeFileSync(path.join(root, "src", "misuse.test.ts"), misuse); + + const { status, output } = check(root); + + expect(status).not.toBe(0); + for (const file of ["cycle-misuse", "misuse\\.test"]) { + expect(output).toMatch(new RegExp(`src/${file}\\.ts\\(\\d+,\\d+\\): error TS2353.*'Manifest'`)); + expect(output).toMatch(new RegExp(`src/${file}\\.ts\\(\\d+,\\d+\\): error TS2353.*'\\{ name: string; \\}'`)); + } + }, 60_000); +}); + +describe("typecheckLowMemory", () => { + it("runs every step in low-memory mode and stops at a failure", () => { + const calls = []; + const run = (args, { env }) => { + calls.push([args, env.PAPERCLIP_TYPECHECK_LOW_MEMORY]); + return calls.length === 1 ? 0 : 3; + }; + + expect(typecheckLowMemory({ cwd: serverRoot, env: {}, run })).toBe(3); + expect(calls).toEqual(STEPS.map((args) => [args, "1"])); + }); + + it("does not run the typecheck after a failed preflight", () => { + const calls = []; + + const status = typecheckLowMemory({ + cwd: serverRoot, + env: {}, + run: (args) => { + calls.push(args); + return 1; + }, + }); + + expect(status).toBe(1); + expect(calls).toHaveLength(1); + }); +}); + +describe("runTsc", () => { + it("emits JavaScript without checks or declarations in emit-only mode", () => { + const root = mkdtempSync(path.join(tmpdir(), "run-tsc-emit-only-")); + try { + mkdirSync(path.join(root, "src")); + mkdirSync(path.join(root, "node_modules")); + symlinkSync( + realpathSync(path.join(serverRoot, "node_modules", "typescript")), + path.join(root, "node_modules", "typescript"), + ); + writeFileSync( + path.join(root, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { declaration: true, declarationMap: true, types: [], outDir: "dist", rootDir: "src" }, + include: ["src"], + })}\n`, + ); + writeFileSync(path.join(root, "src", "a.ts"), `export const value: number = "not a number";\n`); + + const { status } = runTsc([], { cwd: root, env: { ...process.env, PAPERCLIP_TSC_EMIT_ONLY: "1" } }); + + expect(status).toBe(0); + expect(existsSync(path.join(root, "dist", "a.js"))).toBe(true); + expect(existsSync(path.join(root, "dist", "a.d.ts"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + + it("accepts build mode with low-memory mode on", () => { + const { status } = runTsc(["-b", "--dry"], { + cwd: serverRoot, + env: { ...process.env, PAPERCLIP_TYPECHECK_LOW_MEMORY: "1" }, + }); + + expect(status).toBe(0); + }); +}); + +describe("emitOnlyTscArgs", () => { + it("removes checks and declarations only in emit-only mode", () => { + expect(emitOnlyTscArgs({ PAPERCLIP_TSC_EMIT_ONLY: "1" })).toEqual([ + "--noCheck", + "--declaration", + "false", + "--declarationMap", + "false", + ]); + expect(emitOnlyTscArgs({})).toEqual([]); + }); + + it("is what the Dockerfile selects for the server build", () => { + const dockerfile = readFileSync(path.join(serverRoot, "..", "Dockerfile"), "utf8"); + + expect(dockerfile).toContain("RUN PAPERCLIP_TSC_EMIT_ONLY=1 pnpm --filter @paperclipai/server build\n"); + }); +}); + +describe("lowMemoryTscArgs", () => { + it("uses one thread only when low-memory mode is on", () => { + expect(lowMemoryTscArgs({ PAPERCLIP_TYPECHECK_LOW_MEMORY: "1" })).toEqual(["--singleThreaded"]); + expect(lowMemoryTscArgs({ PAPERCLIP_TYPECHECK_LOW_MEMORY: "0" })).toEqual([]); + expect(lowMemoryTscArgs({})).toEqual([]); + }); +}); diff --git a/ui/package.json b/ui/package.json index e59f87e405d..28f185fa2e6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -20,7 +20,7 @@ "storybook": "storybook dev -p 6006 -c storybook/.storybook", "build-storybook": "storybook build -c storybook/.storybook -o storybook-static", "preview": "vite preview", - "typecheck": "node ../scripts/storybook-catalog.mjs && tsc -b", + "typecheck": "node ../scripts/storybook-catalog.mjs && node ../scripts/run-tsc.mjs -b", "clean": "rm -rf dist dist-preview storybook-static tsconfig.tsbuildinfo", "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs", "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"