diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a60bf9403d2..3dedf6a52b9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4086,3 +4086,18 @@ one-time config-file seed preserves existing choices, then later starts use the database without loading home `.env` files or repairing worktree config. See [Cloud runtime configuration](cloud-runtime-configuration.md) for field ownership, secret handling, initialization order and migration interfaces. + +### Candidate compatibility with trusted test-project typechecks + +Trusted PR CI checks the server in low-memory layers, then runs: + +```sh +node scripts/typecheck-test-projects.mjs --low-memory --reuse-declarations +``` + +Run that command immediately after a successful server typecheck on the same +commit. It checks maintenance support, lifecycle, Runner E2E, and Speko projects +against fresh server declarations and rejects accidental server source imports. +For a standalone check, omit `--reuse-declarations` so the driver prepares and +builds those declarations first. The ordinary project configs remain available +to editors and Vitest; CI uses their separate `tsconfig.layers.json` files. 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..22d44f7b07f --- /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"], +]; + +export 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/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/package.json b/server/package.json index 0265e98d04e..6556e9164a2 100644 --- a/server/package.json +++ b/server/package.json @@ -43,7 +43,7 @@ "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/typecheck-layers.mjs b/server/scripts/typecheck-layers.mjs new file mode 100644 index 00000000000..95b0f22e9ee --- /dev/null +++ b/server/scripts/typecheck-layers.mjs @@ -0,0 +1,280 @@ +// 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. +// +// 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"; +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"); + +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__/"); + +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, + }; +} + +/** 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, +}) { + 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`, + ); + } + + 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 }); + // 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)), ".."), + declarationsOnly: process.argv.includes("--declarations-only"), + }); +} diff --git a/server/scripts/typecheck-layers.test.mjs b/server/scripts/typecheck-layers.test.mjs new file mode 100644 index 00000000000..53ee817ad32 --- /dev/null +++ b/server/scripts/typecheck-layers.test.mjs @@ -0,0 +1,749 @@ +import { spawnSync } from "node:child_process"; +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"; + +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 { 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; + +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("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 = []; + + 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("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 = []; + + 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("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`); + + 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("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-")); + 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([]); + }); + +}); + +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/server/src/__tests__/helpers/maintenance/tsconfig.json b/server/src/__tests__/helpers/maintenance/tsconfig.json new file mode 100644 index 00000000000..864a6b58de6 --- /dev/null +++ b/server/src/__tests__/helpers/maintenance/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../../../../.." + }, + "include": ["*.ts", "../../maintenance-*.test.ts", "../../decision-target-work.test.ts", "../../../types/express.d.ts"] +} 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/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/api-skill-update-prerequisite.test.ts b/tests/runner-e2e/api-skill-update-prerequisite.test.ts index 3ebcd1addac..f810dc14c47 100644 --- a/tests/runner-e2e/api-skill-update-prerequisite.test.ts +++ b/tests/runner-e2e/api-skill-update-prerequisite.test.ts @@ -18,7 +18,7 @@ it("uses the exact authenticated update endpoint and returns real successful pre it.each(["skill_source_ref_unavailable", "skill_source_github_rate_limited", "skill_source_github_read_failed"])("retains only the supported closed %s/status while withholding private body fields", async code => { const { api } = fixture(); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ code, error: "Bearer private-error-canary", details: { code, status: 429, token: "secret-value", repository: "private-origin" } }, { status: 422 }))); const error = await api.getSkillSourceUpdateStatus(company, skill).catch(error => error); - expect(error).toBeInstanceOf(SkillSourceUpdateReadError); expect(error).toMatchObject({ status: 422, reason: code, providerStatus: 429 }); + expect(error).toBeInstanceOf(SkillSourceUpdateReadError); if (!(error instanceof SkillSourceUpdateReadError)) throw error; expect(error).toMatchObject({ status: 422, reason: code, providerStatus: 429 }); expect(JSON.stringify(error)).not.toMatch(/private-error|secret-value|private-origin|Bearer/); expect(error.message).not.toMatch(/private-error|secret-value|private-origin|Bearer/); }); it.each(["unknown code", "mismatched code", "oversized", "invalid JSON", "missing code", "invalid provider status"])("does not infer a reason or leak response bytes for %s", async fault => { @@ -32,7 +32,7 @@ it.each(["unknown code", "mismatched code", "oversized", "invalid JSON", "missin if (fault === "invalid provider status") body = JSON.stringify({ code, details: { status: "private-error-canary" } }); vi.stubGlobal("fetch", vi.fn(async () => new Response(body, { status: 422 }))); const error = await api.getSkillSourceUpdateStatus(company, skill).catch(error => error); - expect(error).toBeInstanceOf(SkillSourceUpdateReadError); + expect(error).toBeInstanceOf(SkillSourceUpdateReadError); if (!(error instanceof SkillSourceUpdateReadError)) throw error; expect(error.reason).toBe(fault === "invalid provider status" ? code : "unclassified"); expect(error.providerStatus).toBeNull(); expect(JSON.stringify(error)).not.toContain("private-error-canary"); expect(error.message).not.toContain("private-error-canary"); }); diff --git a/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts b/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts index aeaed2f3d5d..7b642cdbea0 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts @@ -48,7 +48,7 @@ interface PrivateFixture { endpoint: Awaited>; } const fixtures = new WeakMap(); -interface PersonalSetupAgent { id: string; companyId: string; lifecycleState: string; status: string; lifecycleError?: string | null; lifecycleHolds: string[]; adapterType: string; adapterConfig: Record } +interface PersonalSetupAgent { id: string; companyId: string; lifecycleState: string; status: string; lifecycleError?: string | null; lifecycleHolds: string[]; adapterType: string; adapterConfig: Record & { env?: Record } } const missingBindingLifecycleError = "Execution configuration check failed. Paperclip could not complete this step. It will retry automatically. If this continues, contact your instance administrator."; export function personalSecretUnboundConfigHash(config: Record): string { const env = { ...(config.env as Record | undefined) }; delete env.PUBLISHING_API_KEY; diff --git a/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts b/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts index 17c2b9673b0..9fcfbdb009b 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts @@ -2,7 +2,7 @@ import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCat import { expect } from "@playwright/test"; import { createIssueThreadInteractionSchema } from "../../../packages/shared/src/validators/issue.js"; import { pollUntil } from "../api.js"; -import { checks, project, entity, hasLink, putDocument } from "./internal-support.js"; +import { checks, project, entity, hasLink, putDocument, type Entity } from "./internal-support.js"; import { identityDefinition, identityObservation, observeIdentity, openStandingChat, readAgentResource, waitForReply, remainingIdentityObservationBudget, type Interaction } from "./identity-support.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-goal-hierarchy-v2.test.ts b/tests/runner-e2e/chat-catalog-domains/internal-goal-hierarchy-v2.test.ts index b53bcee58fb..89d5b42ef17 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-goal-hierarchy-v2.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-goal-hierarchy-v2.test.ts @@ -49,7 +49,7 @@ it.each(["wrong parent", "missing child", "foreign goal", "no saved state", "mis expect(grade_goals(o).passed).toBe(false); }); it("does not allow a retry to repair an invalid first parent or first foreign owner", () => { - for (const field of ["parentId", "ownerAgentId"]) { + for (const field of ["parentId", "ownerAgentId"] as const) { const o = valid(), first = o.state.firstCreation as InternalObservation; first.goals[1]![field] = "foreign"; expect(grade_goals(o).passed).toBe(false); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-goals.ts b/tests/runner-e2e/chat-catalog-domains/internal-goals.ts index 01d75a6844f..8e9341b2adb 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-goals.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-goals.ts @@ -1,5 +1,5 @@ import type { ChatCatalogCase } from "../chat-catalog-contract.js"; -import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js"; +import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, internalResultLink, type Entity, type InternalObservation } from "./internal-support.js"; import { makeDefinition, retryDefinition, fixtureWorker } from "./internal-definition.js"; export function grade_goals(value: unknown) { diff --git a/tests/runner-e2e/source-sdk-audit-capture.test.ts b/tests/runner-e2e/source-sdk-audit-capture.test.ts index a80d8d17ecb..3f3cc13faeb 100644 --- a/tests/runner-e2e/source-sdk-audit-capture.test.ts +++ b/tests/runner-e2e/source-sdk-audit-capture.test.ts @@ -21,7 +21,7 @@ async function fixture(){ await writeFile(path.join(dir,"index.js"),body);const outputs=createHash("sha256").update(JSON.stringify(["index.js",body.length])).update(body).digest("hex"); await writeFile(path.join(dir,".paperclip-build-complete"),JSON.stringify({sources:"a".repeat(64),outputs})); } - const context={root:handoff,private:true,runId:"123",attempt:"1",executionIds:[id],cellReportProducer:await inspectCellReportProducer()}; + const context={root:handoff,private:true as const,runId:"123",attempt:"1",executionIds:[id],cellReportProducer:await inspectCellReportProducer()}; const source=path.join(temp,"artifacts-private/cases",scenario,"snapshots/source-sdk-privacy-observations.json"),destination=path.join(cell,"snapshots/source-sdk-privacy-observations.json"); const reject=()=>retainPreparedSourceSdk({repositoryRoot:repo,temporaryRoot:temp,executionIds:[id],sourceRevision:"a".repeat(40),installed:false}); const copy=()=>copySharedEvidence(capture,cell,temp,[secret],{executionId:id,context}); 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" }] +}