diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 1eecbe823b2..455512ee2de 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -421,6 +421,9 @@ jobs: PAPERCLIP_TYPECHECK_LOW_MEMORY: "1" run: pnpm --filter @paperclipai/server typecheck + - name: Typecheck test projects against the server declarations + run: node scripts/typecheck-test-projects.mjs --low-memory --reuse-declarations + - 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/doc/DEVELOPING.md b/doc/DEVELOPING.md index a55052001e4..182afe1d850 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1208,6 +1208,34 @@ slows `tsc` by up to 20 times when the live heap goes above the limit. Docker image sets `PAPERCLIP_TSC_EMIT_ONLY=1` for that build, which emits JavaScript without checks or declarations. +Test projects outside `pnpm -r` import server source through relative paths. Each +project's `tsconfig.layers.json` reads that source as layer declarations. This +command builds the declarations and then checks every project: + +```sh +node scripts/typecheck-test-projects.mjs +``` + +Name a project to check only that project. `pnpm test:e2e:runner:typecheck` and +`pnpm test:lifecycle-baseline:typecheck` do this for their projects. If the memory +available to the task is limited, add `--low-memory`, or set +`PAPERCLIP_TYPECHECK_LOW_MEMORY=1` for the pnpm commands. + +The command fails if a project loads server source, because that is what makes +these programs large. It does not limit memory. CI runs it with +`--reuse-declarations` straight after the server typecheck, so it does not build +the declarations again. Do not use that option when the declarations can be stale. + +Add each new test project that imports server source to `PROJECTS` in +`scripts/typecheck-test-projects.mjs`. + +Keep the project references out of each project's `tsconfig.json`. Vitest reads +that file, and it fails when a referenced config does not exist yet. The +`tsconfig.json` files remain single programs for editors, and +`tests/runner-e2e/native-completion-checks.mjs` still checks that project as one +program. That program peaks at about 15 GiB at default settings and about 8 GiB on +one thread. + #### Reused Runner build in the server build The server build vendors `packages/paperclip-runner/dist`, so its diff --git a/package.json b/package.json index d4e1c1afe35..597f549b436 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "test:e2e:runner:browser-support": "playwright test --config tests/runner-e2e/playwright-support.config.ts", "test:e2e:runner:unit": "node tests/runner-e2e/run-unit.mjs", "test:e2e:runner:stock-harness": "node tests/runner-e2e/stock-harness-checks.mjs", - "test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json", + "test:e2e:runner:typecheck": "node scripts/typecheck-test-projects.mjs tests/runner-e2e", "test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts", "test:runner-workflow-evals": "pnpm --filter @paperclipai/paperclip-eval-kernel build && pnpm --filter @paperclipai/paperclip-runner test:runner-workflow-evals", "test:e2e:mcp-user-stories": "node scripts/e2e-mcp-user-stories.mjs", @@ -94,7 +94,7 @@ "connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs", "test:lifecycle-baseline": "node tests/lifecycle-baseline/run.mjs", "test:lifecycle-baseline:support": "node --test tests/lifecycle-baseline/report.test.mjs", - "test:lifecycle-baseline:typecheck": "tsc -p tests/lifecycle-baseline/tsconfig.json" + "test:lifecycle-baseline:typecheck": "node scripts/typecheck-test-projects.mjs tests/lifecycle-baseline" }, "devDependencies": { "@playwright/test": "^1.62.1", diff --git a/scripts/typecheck-low-memory.mjs b/scripts/typecheck-low-memory.mjs index a4c54dce846..22d44f7b07f 100644 --- a/scripts/typecheck-low-memory.mjs +++ b/scripts/typecheck-low-memory.mjs @@ -15,7 +15,7 @@ export const STEPS = [ ["-r", "--workspace-concurrency=1", "typecheck"], ]; -function runPnpm(args, options) { +export function runPnpm(args, options) { const result = spawnSync("pnpm", args, { ...options, stdio: "inherit", diff --git a/scripts/typecheck-test-projects.mjs b/scripts/typecheck-test-projects.mjs new file mode 100644 index 00000000000..8fd8cef014f --- /dev/null +++ b/scripts/typecheck-test-projects.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Typechecks the test projects outside `pnpm -r`. +// +// Each project imports server source through relative paths, so its tsconfig.json +// is one program over most of the server. Its tsconfig.layers.json reads that +// source as the declarations from server/scripts/typecheck-layers.mjs, which this +// script builds first. It then fails if a project still loads server source, +// because that is what makes these programs large. See "Typecheck memory" in +// doc/DEVELOPING.md. +// +// --reuse-declarations skips the build. Use it only straight after a server +// typecheck on the same commit, because stale declarations pass silently. +// +// Usage: node scripts/typecheck-test-projects.mjs [--low-memory] [--reuse-declarations] [project ...] + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { LOW_MEMORY_VARIABLE, runTsc } from "./run-tsc.mjs"; +import { runPnpm } from "./typecheck-low-memory.mjs"; +import { isTestFile, typecheckLayers } from "../server/scripts/typecheck-layers.mjs"; + +export const PROJECTS = [ + "server/src/__tests__/helpers/maintenance", + "tests/lifecycle-baseline", + "tests/runner-e2e", + "tests/speko-native", +]; + +// The same steps as the server's typecheck script. +export const PREPARE_STEPS = [ + ["--filter", "@paperclipai/paperclip-runner", "run", "build:typescript"], + ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"], +]; + +/** + * Finds the server source in a program's file list, as paths relative to the + * server. Test support files have no layer, and declaration and JSON files add + * little, so only other source files count. + */ +export function findServerSource(files, serverRoot) { + return files + .map((file) => path.relative(serverRoot, file).split(path.sep).join("/")) + .filter( + (file) => + file.startsWith("src/") && /\.[cm]?[jt]sx?$/.test(file) && !/\.d\.[cm]?ts$/.test(file) && !isTestFile(file), + ); +} + +function listProgramFiles(config, { cwd, env }) { + const tsc = path.join(cwd, "node_modules", "typescript", "bin", "tsc"); + const result = spawnSync(process.execPath, [tsc, "-p", config, "--listFilesOnly"], { + cwd, + env, + encoding: "utf8", + maxBuffer: 1 << 26, + }); + if (result.error) throw result.error; + return { status: result.status ?? 1, files: result.stdout.split("\n").filter(Boolean), output: result.stdout }; +} + +export function typecheckTestProjects({ + root, + projects = PROJECTS, + reuseDeclarations = false, + env = process.env, + pnpm = runPnpm, + run = runTsc, + listFiles = listProgramFiles, + log = console.log, + logError = console.error, +}) { + const serverRoot = path.join(root, "server"); + if (!reuseDeclarations) { + for (const args of PREPARE_STEPS) { + const status = pnpm(args, { cwd: root, env }); + if (status !== 0) return status; + } + + const layers = typecheckLayers({ + serverRoot, + declarationsOnly: true, + run: (args, options) => run(args, { ...options, env }), + log, + }); + // Report the server's own errors first. They would repeat in each project. + if (layers !== 0) return layers; + } + + let exitCode = 0; + for (const project of projects) { + log(`typecheck:test-projects: ${project}`); + const config = path.posix.join(project, "tsconfig.layers.json"); + const { status, signal } = run(["-p", config], { cwd: root, env }); + if (signal) return status || 1; + if (status !== 0) exitCode = status; + + const listing = listFiles(config, { cwd: root, env }); + if (listing.status !== 0) { + logError(`typecheck:test-projects: could not list the files of ${project}\n${listing.output ?? ""}`); + exitCode ||= listing.status; + continue; + } + const source = findServerSource(listing.files, serverRoot); + if (source.length > 0) { + logError( + `typecheck:test-projects: ${project} loads ${source.length} server source files. ` + + `It must read them as layer declarations. First files: ${source.slice(0, 5).join(", ")}`, + ); + exitCode ||= 1; + } + } + return exitCode; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const flags = args.filter((arg) => arg.startsWith("--")); + const projects = args.filter((arg) => !arg.startsWith("--")); + const unknownFlags = flags.filter((flag) => !["--low-memory", "--reuse-declarations"].includes(flag)); + const unknown = projects.filter((project) => !PROJECTS.includes(project)); + if (unknownFlags.length > 0) { + console.error(`Unknown option: ${unknownFlags.join(", ")}`); + process.exitCode = 2; + } else if (unknown.length > 0) { + console.error(`Unknown project: ${unknown.join(", ")}. Known projects: ${PROJECTS.join(", ")}`); + process.exitCode = 2; + } else { + process.exitCode = typecheckTestProjects({ + root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), + projects: projects.length > 0 ? projects : PROJECTS, + reuseDeclarations: flags.includes("--reuse-declarations"), + env: args.includes("--low-memory") ? { ...process.env, [LOW_MEMORY_VARIABLE]: "1" } : process.env, + }); + } +} diff --git a/server/scripts/typecheck-layers.mjs b/server/scripts/typecheck-layers.mjs index 99cfb37bfb6..95b0f22e9ee 100644 --- a/server/scripts/typecheck-layers.mjs +++ b/server/scripts/typecheck-layers.mjs @@ -20,7 +20,11 @@ // server/tsconfig.json stays the single program that editors and `pnpm build` // use. // -// Usage: node scripts/typecheck-layers.mjs +// Projects outside the server read the source layers as declarations through a +// `tsconfig.layers.json` that references tsconfig.declarations.json in the output +// directory. --declarations-only builds those layers and skips the tests layer. +// +// Usage: node scripts/typecheck-layers.mjs [--declarations-only] import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; @@ -30,7 +34,7 @@ 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__"); +export 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__/"); @@ -212,8 +216,20 @@ export function layerConfig(layer, { emptyLayers = new Set() } = {}) { }; } +/** Builds the config other projects reference to read the source layers as declarations. */ +export function declarationsConfig(layers) { + return { + compilerOptions: { composite: true }, + files: [], + references: layers + .filter((layer) => layer.name !== "tests") + .map((layer) => ({ path: `./tsconfig.${layer.name}.json` })), + }; +} + export function typecheckLayers({ serverRoot, + declarationsOnly = false, run = runTsc, log = console.log, }) { @@ -236,8 +252,14 @@ export function typecheckLayers({ ); } + writeFileSync( + path.join(outputRoot, "tsconfig.declarations.json"), + `${JSON.stringify(declarationsConfig(layers), null, 2)}\n`, + ); + let exitCode = 0; for (const layer of layers) { + if (declarationsOnly && layer.name === "tests") continue; 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 }); @@ -253,5 +275,6 @@ export function typecheckLayers({ 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)), ".."), + declarationsOnly: process.argv.includes("--declarations-only"), }); } diff --git a/server/scripts/typecheck-layers.test.mjs b/server/scripts/typecheck-layers.test.mjs index eec3f2171e8..82b782e3391 100644 --- a/server/scripts/typecheck-layers.test.mjs +++ b/server/scripts/typecheck-layers.test.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,7 +8,8 @@ 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"; +import { findServerSource, PREPARE_STEPS, PROJECTS, typecheckTestProjects } from "../../scripts/typecheck-test-projects.mjs"; +import { declarationsConfig, 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; @@ -159,6 +160,16 @@ describe("layerConfig", () => { }); }); +describe("declarationsConfig", () => { + it("references the source layers that exist, and not tests", () => { + const config = declarationsConfig([{ name: "below" }, { name: "above" }, { name: "tests" }]); + + expect(config.compilerOptions.composite).toBe(true); + expect(config.files).toEqual([]); + expect(config.references).toEqual([{ path: "./tsconfig.below.json" }, { path: "./tsconfig.above.json" }]); + }); +}); + describe("typecheckLayers", () => { const roots = []; @@ -202,6 +213,30 @@ describe("typecheckLayers", () => { expect(JSON.parse(readFileSync(path.join(output, "package.json"), "utf8"))).toEqual({ type: "module" }); }); + it("skips the tests layer when only declarations are needed", () => { + const root = serverFixture(); + const configs = []; + + const status = typecheckLayers({ + serverRoot: root, + declarationsOnly: true, + 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"]); + const output = path.join(root, "node_modules", ".cache", "paperclip-typecheck"); + expect(JSON.parse(readFileSync(path.join(output, "tsconfig.declarations.json"), "utf8")).references).toEqual([ + { path: "./tsconfig.below.json" }, + { path: "./tsconfig.cycle.json" }, + { path: "./tsconfig.above.json" }, + ]); + }); + it("reports type errors from every layer", () => { const root = serverFixture(); const configs = []; @@ -308,6 +343,57 @@ describe("typecheckLayers with the real compiler", () => { expect(existsSync(path.join(declarations, "route.test.d.ts"))).toBe(false); }, 60_000); + it("lets a project outside the server read the source layers as declarations", () => { + const root = compilerFixture(); + const outside = path.join(root, "outside"); + mkdirSync(outside); + writeFileSync(path.join(outside, "use.ts"), `import { route } from "../src/route.js";\nexport const used: number = route();\n`); + const config = path.join(outside, "tsconfig.layers.json"); + writeFileSync( + config, + `${JSON.stringify({ + extends: "../tsconfig.json", + compilerOptions: { noEmit: true, rootDir: "..", outDir: null }, + include: ["*.ts"], + references: [{ path: "../node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }], + })}\n`, + ); + const buildDeclarations = () => + typecheckLayers({ + serverRoot: root, + declarationsOnly: true, + log: () => {}, + run: (args, { cwd }) => { + const result = spawnSync(process.execPath, [tsc, ...args], { cwd, encoding: "utf8" }); + return { status: result.status ?? 1, signal: result.signal }; + }, + }); + const tscOutside = (...args) => spawnSync(process.execPath, [tsc, "-p", config, ...args], { cwd: root, encoding: "utf8" }); + + expect(buildDeclarations()).toBe(0); + const passing = tscOutside(); + expect(passing.stdout).toBe(""); + expect(passing.status).toBe(0); + const loaded = tscOutside("--listFilesOnly").stdout; + expect(loaded).toContain("paperclip-typecheck/dts/route.d.ts"); + expect(loaded).not.toContain("src/route.ts"); + expect(findServerSource(loaded.split("\n").filter(Boolean), root)).toEqual([]); + + const layered = readFileSync(config, "utf8"); + const { references: _references, ...withoutReferences } = JSON.parse(layered); + writeFileSync(config, `${JSON.stringify(withoutReferences)}\n`); + const allSource = tscOutside("--listFilesOnly").stdout.split("\n").filter(Boolean); + expect(findServerSource(allSource, root)).toContain("src/route.ts"); + writeFileSync(config, layered); + + writeFileSync(path.join(root, "src", "route.ts"), `import { one } from "./one.js";\nexport const route = () => String(one());\n`); + expect(buildDeclarations()).toBe(0); + const failing = tscOutside(); + expect(failing.stdout).toContain("outside/use.ts"); + expect(failing.stdout).toContain("TS2322"); + expect(failing.status).not.toBe(0); + }, 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`); @@ -404,6 +490,203 @@ describe("typecheckLowMemory", () => { }); }); +describe("typecheckTestProjects", () => { + const roots = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + function repositoryFixture() { + const root = mkdtempSync(path.join(tmpdir(), "typecheck-test-projects-")); + roots.push(root); + mkdirSync(path.join(root, "server", "src"), { recursive: true }); + writeFileSync(path.join(root, "server", "src", "leaf.ts"), ""); + writeFileSync(path.join(root, "server", "src", "one.ts"), `import "./two.js"; import "./leaf.js";`); + writeFileSync(path.join(root, "server", "src", "two.ts"), `import "./one.js";`); + writeFileSync(path.join(root, "server", "src", "route.ts"), `import "./one.js";`); + writeFileSync(path.join(root, "server", "src", "route.test.ts"), `import "./route.js";`); + return root; + } + + function runWith( + root, + { + pnpmStatus = () => 0, + tscResult = () => ({ status: 0, signal: null }), + listFiles = () => ({ status: 0, files: [] }), + ...options + } = {}, + ) { + const calls = []; + const errors = []; + const status = typecheckTestProjects({ + root, + listFiles, + log: () => {}, + logError: (message) => errors.push(message), + pnpm: (args, { env }) => { + calls.push({ tool: "pnpm", args, env }); + return pnpmStatus(args); + }, + run: (args, { env }) => { + const target = path.basename(path.dirname(args[1])) === "paperclip-typecheck" ? path.basename(args[1]) : args[1]; + calls.push({ tool: "tsc", target, env }); + return tscResult(target); + }, + ...options, + }); + return { status, calls, errors }; + } + + it("prepares, builds declarations without the tests layer, and then checks the projects", () => { + const env = { MARKER: "1" }; + + const { status, calls } = runWith(repositoryFixture(), { projects: ["tests/a", "tests/b"], env }); + + expect(status).toBe(0); + expect(calls.filter((call) => call.tool === "pnpm").map((call) => call.args)).toEqual(PREPARE_STEPS); + expect(calls.filter((call) => call.tool === "tsc").map((call) => call.target)).toEqual([ + "tsconfig.below.json", + "tsconfig.cycle.json", + "tsconfig.above.json", + "tests/a/tsconfig.layers.json", + "tests/b/tsconfig.layers.json", + ]); + expect(calls.every((call) => call.env === env)).toBe(true); + }); + + it("stops when the preparation or the server layers fail", () => { + const prepare = runWith(repositoryFixture(), { pnpmStatus: () => 3 }); + expect(prepare.status).toBe(3); + expect(prepare.calls).toHaveLength(1); + + const layers = runWith(repositoryFixture(), { + projects: ["tests/a"], + tscResult: (target) => ({ status: target === "tsconfig.cycle.json" ? 2 : 0, signal: null }), + }); + expect(layers.status).toBe(2); + expect(layers.calls.some((call) => call.target === "tests/a/tsconfig.layers.json")).toBe(false); + }); + + it("checks every project after a type error, and stops when a process is killed", () => { + const errors = runWith(repositoryFixture(), { + projects: ["tests/a", "tests/b"], + tscResult: (target) => ({ status: target === "tests/a/tsconfig.layers.json" ? 2 : 0, signal: null }), + }); + expect(errors.status).toBe(2); + expect(errors.calls.at(-1).target).toBe("tests/b/tsconfig.layers.json"); + + const killed = runWith(repositoryFixture(), { + projects: ["tests/a", "tests/b"], + tscResult: (target) => + target === "tests/a/tsconfig.layers.json" ? { status: 1, signal: "SIGKILL" } : { status: 0, signal: null }, + }); + expect(killed.status).toBe(1); + expect(killed.calls.at(-1).target).toBe("tests/a/tsconfig.layers.json"); + }); + + it("reuses declarations without preparing or building layers", () => { + const { status, calls } = runWith(repositoryFixture(), { projects: ["tests/a"], reuseDeclarations: true }); + + expect(status).toBe(0); + expect(calls.map((call) => call.target ?? call.tool)).toEqual(["tests/a/tsconfig.layers.json"]); + }); + + it("fails when a project loads server source, even if its types pass", () => { + const root = repositoryFixture(); + const server = path.join(root, "server"); + + const { status, errors } = runWith(root, { + projects: ["tests/a", "tests/b"], + reuseDeclarations: true, + listFiles: (config) => ({ + status: 0, + files: config.startsWith("tests/a/") ? [path.join(server, "src", "route.ts")] : [], + }), + }); + + expect(status).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("tests/a loads 1 server source files"); + expect(errors[0]).toContain("src/route.ts"); + }); + + it("fails when a project's files cannot be listed, and keeps a type error's status", () => { + const listing = runWith(repositoryFixture(), { + projects: ["tests/a"], + reuseDeclarations: true, + listFiles: () => ({ status: 3, files: [], output: "TS6053" }), + }); + expect(listing.status).toBe(3); + expect(listing.errors[0]).toContain("TS6053"); + + const both = runWith(repositoryFixture(), { + projects: ["tests/a"], + reuseDeclarations: true, + tscResult: () => ({ status: 2, signal: null }), + listFiles: () => ({ status: 3, files: [], output: "" }), + }); + expect(both.status).toBe(2); + }); + + it("counts only source files that a layer should provide", () => { + const server = path.join(path.sep, "repo", "server"); + const file = (relative) => path.join(server, ...relative.split("/")); + + expect( + findServerSource( + [ + file("src/route.ts"), + file("src/view.tsx"), + file("src/legacy.js"), + file("src/types/express.d.ts"), + file("src/data.json"), + file("src/route.test.ts"), + file("src/__tests__/helpers/home.ts"), + file("node_modules/.cache/paperclip-typecheck/dts/route.d.ts"), + file("test-baselines/lifecycle.test.ts"), + path.join(path.sep, "repo", "tests", "a.ts"), + ], + server, + ), + ).toEqual(["src/route.ts", "src/view.tsx", "src/legacy.js"]); + }); + + it("covers every project under tests/, or names why it does not", () => { + // This project imports one small server file, so it stays one small program. + const singlePrograms = ["tests/runner-acceptance"]; + const testsRoot = path.join(serverRoot, "..", "tests"); + const projects = readdirSync(testsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(path.join(testsRoot, entry.name, "tsconfig.json"))) + .map((entry) => `tests/${entry.name}`); + + expect(projects.sort()).toEqual( + [...PROJECTS.filter((project) => project.startsWith("tests/")), ...singlePrograms].sort(), + ); + }); + + it("prepares the same way as the server typecheck", () => { + const { scripts } = JSON.parse(readFileSync(path.join(serverRoot, "package.json"), "utf8")); + for (const step of PREPARE_STEPS) { + expect(scripts.typecheck).toContain(`pnpm ${step.join(" ")}`); + } + }); + + it.each(PROJECTS)("keeps the generated references out of the tsconfig.json of %s", (project) => { + const directory = path.join(serverRoot, "..", project); + const readConfig = (name) => JSON.parse(readFileSync(path.join(directory, name), "utf8")); + + // Vitest reads tsconfig.json and fails when a referenced config does not exist yet. + expect(readConfig("tsconfig.json").references).toBeUndefined(); + const layered = readConfig("tsconfig.layers.json"); + expect(layered.extends).toBe("./tsconfig.json"); + expect(layered.references.map((reference) => path.resolve(directory, reference.path))).toEqual([ + path.join(serverRoot, "node_modules", ".cache", "paperclip-typecheck", "tsconfig.declarations.json"), + ]); + }); +}); + describe("runTsc", () => { it("emits JavaScript without checks or declarations in emit-only mode", () => { const root = mkdtempSync(path.join(tmpdir(), "run-tsc-emit-only-")); diff --git a/server/src/__tests__/helpers/maintenance/tsconfig.layers.json b/server/src/__tests__/helpers/maintenance/tsconfig.layers.json new file mode 100644 index 00000000000..589c934fec9 --- /dev/null +++ b/server/src/__tests__/helpers/maintenance/tsconfig.layers.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false + }, + "references": [{ "path": "../../../../node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }] +} diff --git a/tests/lifecycle-baseline/README.md b/tests/lifecycle-baseline/README.md index 1e9136eda3d..094b32222e4 100644 --- a/tests/lifecycle-baseline/README.md +++ b/tests/lifecycle-baseline/README.md @@ -47,7 +47,7 @@ pnpm test:lifecycle-baseline integration pnpm test:lifecycle-baseline grading pnpm test:lifecycle-baseline pnpm test:lifecycle-baseline:support -pnpm exec tsc -p tests/lifecycle-baseline/tsconfig.json +pnpm test:lifecycle-baseline:typecheck ``` All four lanes are credential-free. The integration lane uses disposable embedded diff --git a/tests/lifecycle-baseline/tsconfig.json b/tests/lifecycle-baseline/tsconfig.json index 0fa94c55b6f..e6a31f8654b 100644 --- a/tests/lifecycle-baseline/tsconfig.json +++ b/tests/lifecycle-baseline/tsconfig.json @@ -7,5 +7,5 @@ "types": ["node"], "typeRoots": ["../../cli/node_modules/@types", "../../server/node_modules/@types"] }, - "include": ["./*.ts", "../../server/test-baselines/*.ts"] + "include": ["./*.ts", "../../server/test-baselines/*.ts", "../../server/src/types/express.d.ts"] } diff --git a/tests/lifecycle-baseline/tsconfig.layers.json b/tests/lifecycle-baseline/tsconfig.layers.json new file mode 100644 index 00000000000..f1c68632282 --- /dev/null +++ b/tests/lifecycle-baseline/tsconfig.layers.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false + }, + "references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }] +} diff --git a/tests/runner-e2e/tsconfig.layers.json b/tests/runner-e2e/tsconfig.layers.json new file mode 100644 index 00000000000..f1c68632282 --- /dev/null +++ b/tests/runner-e2e/tsconfig.layers.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false + }, + "references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }] +} diff --git a/tests/speko-native/tsconfig.layers.json b/tests/speko-native/tsconfig.layers.json new file mode 100644 index 00000000000..f1c68632282 --- /dev/null +++ b/tests/speko-native/tsconfig.layers.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false + }, + "references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }] +}