mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(evals): include the trusted test-project typecheck closure
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
77954ba7a7
commit
cd4e868aac
19 files changed
+1317
-9
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)), ".."),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../../../node_modules/.cache/paperclip-typecheck/tsconfig.declarations.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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ interface PrivateFixture {
|
||||
endpoint: Awaited<ReturnType<typeof secretTestEndpoint>>;
|
||||
}
|
||||
const fixtures = new WeakMap<ChatCatalogContext, PrivateFixture>();
|
||||
interface PersonalSetupAgent { id: string; companyId: string; lifecycleState: string; status: string; lifecycleError?: string | null; lifecycleHolds: string[]; adapterType: string; adapterConfig: Record<string, unknown> }
|
||||
interface PersonalSetupAgent { id: string; companyId: string; lifecycleState: string; status: string; lifecycleError?: string | null; lifecycleHolds: string[]; adapterType: string; adapterConfig: Record<string, unknown> & { env?: Record<string, unknown> } }
|
||||
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, unknown>): string {
|
||||
const env = { ...(config.env as Record<string, unknown> | undefined) }; delete env.PUBLISHING_API_KEY;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
Reference in new issue
Block a user