Merge current master build and task-memory updates

Preserve the qualified425 migration and domain history source. Carry the20 disjoint upstream paths without changing owned behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-03 11:09:46 -06:00
commit 35a0294eee
20 files changed
+1466 -10

No files matched your search

+2 -2
View File
@@ -36,14 +36,14 @@
"dev": "tsx src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && cp ../packages/paperclip-runner/scripts/install-copilot-assets.mjs ../packages/paperclip-runner/scripts/materialize-copilot-binary.mjs ../packages/paperclip-runner/scripts/copilot-inner-distribution.mjs dist/ && node dist/install-copilot-assets.mjs && node scripts/write-build-stamp.mjs",
"build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && node ../scripts/run-tsc.mjs && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && cp ../packages/paperclip-runner/scripts/install-copilot-assets.mjs ../packages/paperclip-runner/scripts/materialize-copilot-binary.mjs ../packages/paperclip-runner/scripts/copilot-inner-distribution.mjs dist/ && node dist/install-copilot-assets.mjs && node scripts/write-build-stamp.mjs",
"prepack": "pnpm run prepare:ui-dist && pnpm run build",
"postpack": "rm -rf ui-dist",
"clean": "rm -rf dist",
"build:runtime": "node scripts/bundle-runtime.mjs",
"start": "node dist/index.js",
"prepare:runner-vendor": "node scripts/prepare-runner-vendor.mjs",
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"typecheck": "pnpm --filter @paperclipai/paperclip-runner run build:typescript && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/typecheck-layers.mjs",
"postinstall": "node scripts/install-copilot-assets.mjs"
},
"dependencies": {
+1 -1
View File
@@ -1,7 +1,7 @@
// Makes sure packages/paperclip-runner/dist is a current build before the
// server build vendors it, rebuilding the Runner only when that is needed.
//
// The server build (and typecheck) used to run the Runner's full `build`
// The server build used to run the Runner's full `build`
// unconditionally here. That is what lets a standalone
// `pnpm --filter @paperclipai/server build` produce a current vendored Runner
// on its own, which the Dockerfile relies on. But inside `pnpm -r build` the
+257
View File
@@ -0,0 +1,257 @@
// Typechecks the server in layers so that no single tsc process holds the whole
// program in memory.
//
// tsc keeps every type it creates until it exits, so its peak grows with the
// source it checks. The server is about 1,700 source files and 500 test files,
// and one program over all of them needs more than 8 GiB at any setting. Most
// of those files are not in an import cycle, so they can be checked separately
// and read back as declarations:
//
// below files the largest import cycle depends on, plus unrelated files
// cycle the largest import cycle, reading `below` as declarations
// above files that depend on the cycle, reading both as declarations
// tests test files, reading all three as declarations
//
// The layers come from the import graph on every run, so nothing has to move
// and no list can go stale. The import scan does not have to be perfect: if it
// misses an edge, a file imports one outside its layer's references and tsc
// fails with TS6307 instead of skipping the file.
//
// server/tsconfig.json stays the single program that editors and `pnpm build`
// use.
//
// Usage: node scripts/typecheck-layers.mjs
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runTsc } from "../../scripts/run-tsc.mjs";
const IMPORT_SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.[^"']+)["']/g;
const OUTPUT_DIRECTORY = path.join("node_modules", ".cache", "paperclip-typecheck");
const isTestFile = (file) => /\.test\.tsx?$/.test(file) || file.split("/").includes("__tests__");
// tsconfig.json excludes this tree. A test reaches its helpers through imports.
const isExcludedFromProgram = (file) => file.startsWith("src/__tests__/");
export function listSourceFiles(serverRoot) {
const files = [];
const walk = (directory) => {
for (const entry of readdirSync(path.join(serverRoot, directory), { withFileTypes: true })) {
const relative = `${directory}/${entry.name}`;
if (entry.isDirectory()) walk(relative);
else if (/\.tsx?$/.test(entry.name)) files.push(relative);
}
};
walk("src");
return files.sort();
}
/** Resolves the relative imports of each file. `files` maps a path to its text. */
export function readImports(files) {
const imports = new Map();
for (const [file, text] of files) {
const modules = new Set();
const json = new Set();
for (const [, specifier] of text.matchAll(IMPORT_SPECIFIER)) {
const target = path.posix.join(path.posix.dirname(file), specifier);
if (target.endsWith(".json")) {
json.add(target);
continue;
}
const candidates = [
target,
target.replace(/\.js$/, ".ts"),
target.replace(/\.js$/, ".tsx"),
`${target}.ts`,
`${target}/index.ts`,
];
const resolved = candidates.find((candidate) => files.has(candidate));
if (resolved) modules.add(resolved);
}
imports.set(file, { modules, json });
}
return imports;
}
/** Tarjan's algorithm, iterative because import chains can be very deep. */
function largestCycle(graph) {
const index = new Map();
const low = new Map();
const onStack = new Set();
const stack = [];
let next = 0;
let largest = [];
for (const root of graph.keys()) {
if (index.has(root)) continue;
const work = [{ node: root, edges: [...graph.get(root)], position: 0 }];
index.set(root, next);
low.set(root, next);
next += 1;
stack.push(root);
onStack.add(root);
while (work.length > 0) {
const frame = work[work.length - 1];
if (frame.position < frame.edges.length) {
const edge = frame.edges[frame.position];
frame.position += 1;
if (!index.has(edge)) {
index.set(edge, next);
low.set(edge, next);
next += 1;
stack.push(edge);
onStack.add(edge);
work.push({ node: edge, edges: [...graph.get(edge)], position: 0 });
} else if (onStack.has(edge)) {
low.set(frame.node, Math.min(low.get(frame.node), index.get(edge)));
}
continue;
}
work.pop();
const parent = work[work.length - 1];
if (parent) low.set(parent.node, Math.min(low.get(parent.node), low.get(frame.node)));
if (low.get(frame.node) === index.get(frame.node)) {
const component = [];
let member;
do {
member = stack.pop();
onStack.delete(member);
component.push(member);
} while (member !== frame.node);
if (component.length > largest.length) largest = component;
}
}
}
return largest.length > 1 ? new Set(largest) : new Set();
}
function reachableFrom(start, graph) {
const seen = new Set(start);
const pending = [...start];
while (pending.length > 0) {
for (const edge of graph.get(pending.pop()) ?? []) {
if (!seen.has(edge)) {
seen.add(edge);
pending.push(edge);
}
}
}
return seen;
}
/**
* Sorts every file into the layers, in check order. `files` maps a path to its
* text. Declaration files are ambient, so every layer lists them.
*/
export function planLayers(files) {
const declarations = [...files.keys()].filter((file) => file.endsWith(".d.ts"));
const candidates = new Map([...files].filter(([file]) => !file.endsWith(".d.ts")));
const imports = readImports(candidates);
const graph = new Map();
const reversed = new Map();
for (const file of candidates.keys()) {
if (!isTestFile(file)) {
graph.set(file, new Set());
reversed.set(file, new Set());
}
}
for (const file of graph.keys()) {
for (const target of imports.get(file).modules) {
if (graph.has(target)) {
graph.get(file).add(target);
reversed.get(target).add(file);
}
}
}
const cycle = largestCycle(graph);
const dependents = reachableFrom(cycle, reversed);
const above = [...dependents].filter((file) => !cycle.has(file));
const below = [...graph.keys()].filter((file) => !dependents.has(file));
const tests = [...candidates.keys()].filter((file) => isTestFile(file) && !isExcludedFromProgram(file));
const layer = (name, members, references) => {
const sorted = [...members].sort();
const json = new Set(sorted.flatMap((file) => [...imports.get(file).json]));
return { name, sourceFiles: sorted, files: [...sorted, ...[...json].sort(), ...declarations], references };
};
return [
layer("below", below, []),
layer("cycle", cycle, ["below"]),
layer("above", above, ["below", "cycle"]),
layer("tests", tests, ["below", "cycle", "above"]),
].filter((candidate) => candidate.sourceFiles.length > 0);
}
/** Builds the tsconfig for one layer. Paths are relative to the output directory. */
export function layerConfig(layer, { emptyLayers = new Set() } = {}) {
const fromOutput = (file) => path.posix.join("../../..", file);
const references = layer.references
.filter((name) => !emptyLayers.has(name))
.map((name) => ({ path: `./tsconfig.${name}.json` }));
const output =
layer.name === "tests"
? { composite: false, declaration: false, declarationMap: false, sourceMap: false, noEmit: true }
: {
composite: true,
declaration: true,
declarationMap: false,
sourceMap: false,
emitDeclarationOnly: true,
outDir: "./dts",
tsBuildInfoFile: `./${layer.name}.tsbuildinfo`,
};
return {
extends: fromOutput("tsconfig.json"),
compilerOptions: { ...output, rootDir: fromOutput("src") },
files: layer.files.map(fromOutput),
include: [],
references,
};
}
export function typecheckLayers({
serverRoot,
run = runTsc,
log = console.log,
}) {
const sourceFiles = listSourceFiles(serverRoot);
const layers = planLayers(
new Map(sourceFiles.map((file) => [file, readFileSync(path.join(serverRoot, file), "utf8")])),
);
const planned = new Set(layers.map((layer) => layer.name));
const emptyLayers = new Set(["below", "cycle", "above", "tests"].filter((name) => !planned.has(name)));
const outputRoot = path.join(serverRoot, OUTPUT_DIRECTORY);
rmSync(outputRoot, { recursive: true, force: true });
mkdirSync(outputRoot, { recursive: true });
// Without this, the path under node_modules would make the declarations CommonJS.
writeFileSync(path.join(outputRoot, "package.json"), `${JSON.stringify({ type: "module" })}\n`);
for (const layer of layers) {
writeFileSync(
path.join(outputRoot, `tsconfig.${layer.name}.json`),
`${JSON.stringify(layerConfig(layer, { emptyLayers }), null, 2)}\n`,
);
}
let exitCode = 0;
for (const layer of layers) {
log(`typecheck:layers: ${layer.name} (${layer.sourceFiles.length} files)`);
const config = path.join(OUTPUT_DIRECTORY, `tsconfig.${layer.name}.json`);
const { status, signal } = run(["-p", config], { cwd: serverRoot });
// A killed process leaves no declarations, so later layers would only add noise.
if (signal) return status || 1;
// Later layers still run after type errors. Each file is checked in one
// layer, so this reports everything a single tsc run would.
if (status !== 0) exitCode = status;
}
return exitCode;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = typecheckLayers({
serverRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
});
}
+471
View File
@@ -0,0 +1,471 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { emitOnlyTscArgs, lowMemoryTscArgs, runTsc } from "../../scripts/run-tsc.mjs";
import { STEPS, typecheckLowMemory } from "../../scripts/typecheck-low-memory.mjs";
import { layerConfig, listSourceFiles, planLayers, readImports, typecheckLayers } from "./typecheck-layers.mjs";
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcesOf = (layers, name) => layers.find((layer) => layer.name === name)?.sourceFiles;
describe("readImports", () => {
it("resolves every import form that TypeScript follows", () => {
const files = new Map([
[
"src/a.ts",
[
`import { b } from "./b.js";`,
`export * from "./c.js";`,
`import "./d.js";`,
`const e = await import("./e.js");`,
`type F = typeof import("./f.js");`,
`import g from "./g/index.js";`,
`import h from "./h.js";`,
`import data from "./data.json" with { type: "json" };`,
`import { z } from "zod";`,
].join("\n"),
],
["src/b.ts", ""],
["src/c.ts", ""],
["src/d.ts", ""],
["src/e.ts", ""],
["src/f.ts", ""],
["src/g/index.ts", ""],
["src/h.tsx", ""],
]);
const { modules, json } = readImports(files).get("src/a.ts");
expect([...modules].sort()).toEqual([
"src/b.ts",
"src/c.ts",
"src/d.ts",
"src/e.ts",
"src/f.ts",
"src/g/index.ts",
"src/h.tsx",
]);
expect([...json]).toEqual(["src/data.json"]);
});
});
describe("planLayers", () => {
it("separates the largest cycle from what it depends on and what depends on it", () => {
const layers = planLayers(
new Map([
["src/leaf.ts", ""],
["src/unrelated.ts", ""],
["src/one.ts", `import "./two.js"; import "./leaf.js";`],
["src/two.ts", `import "./one.js";`],
["src/route.ts", `import "./one.js";`],
["src/route.test.ts", `import "./route.js";`],
["src/types/ambient.d.ts", ""],
]),
);
expect(sourcesOf(layers, "below")).toEqual(["src/leaf.ts", "src/unrelated.ts"]);
expect(sourcesOf(layers, "cycle")).toEqual(["src/one.ts", "src/two.ts"]);
expect(sourcesOf(layers, "above")).toEqual(["src/route.ts"]);
expect(sourcesOf(layers, "tests")).toEqual(["src/route.test.ts"]);
for (const layer of layers) expect(layer.files).toContain("src/types/ambient.d.ts");
});
it("keeps test support files in the test layer and leaves src/__tests__ to imports", () => {
const layers = planLayers(
new Map([
["src/a.ts", ""],
["src/services/__tests__/fixtures.ts", ""],
["src/__tests__/helpers/db.ts", ""],
["src/__tests__/big.test.ts", ""],
]),
);
expect(sourcesOf(layers, "below")).toEqual(["src/a.ts"]);
expect(sourcesOf(layers, "tests")).toEqual(["src/services/__tests__/fixtures.ts"]);
});
it("lists imported JSON in the layer that imports it", () => {
const layers = planLayers(
new Map([
["src/a.ts", `import data from "./data.json";`],
["src/one.ts", `import "./two.js"; import data from "./cycle.json";`],
["src/two.ts", `import "./one.js";`],
]),
);
expect(layers.find((layer) => layer.name === "below").files).toContain("src/data.json");
expect(layers.find((layer) => layer.name === "cycle").files).toContain("src/cycle.json");
});
it("omits layers with no files, as when the source has no cycle", () => {
const layers = planLayers(new Map([["src/a.ts", ""], ["src/b.ts", `import "./a.js";`]]));
expect(layers.map((layer) => layer.name)).toEqual(["below"]);
expect(sourcesOf(layers, "below")).toEqual(["src/a.ts", "src/b.ts"]);
});
it("covers the real server: one layer per file, and no import reaches a later layer", () => {
const sourceFiles = listSourceFiles(serverRoot);
const files = new Map(
sourceFiles.map((file) => [file, readFileSync(path.join(serverRoot, file), "utf8")]),
);
const layers = planLayers(files);
const planned = layers.flatMap((layer) => layer.sourceFiles);
expect(new Set(planned).size).toBe(planned.length);
expect(planned.sort()).toEqual(
sourceFiles.filter((file) => !file.endsWith(".d.ts") && !file.startsWith("src/__tests__/")).sort(),
);
const position = new Map(
layers.flatMap((layer, index) => layer.sourceFiles.map((file) => [file, index])),
);
const imports = readImports(new Map([...files].filter(([file]) => !file.endsWith(".d.ts"))));
for (const layer of layers.filter((candidate) => candidate.name !== "tests")) {
for (const file of layer.sourceFiles) {
for (const target of imports.get(file).modules) {
if (position.has(target)) expect(position.get(target)).toBeLessThanOrEqual(position.get(file));
}
}
}
});
});
describe("layerConfig", () => {
const layer = { name: "cycle", files: ["src/a.ts"], references: ["below"] };
it("emits declarations for source layers and references earlier ones", () => {
const config = layerConfig(layer);
expect(config.compilerOptions).toMatchObject({ composite: true, emitDeclarationOnly: true, outDir: "./dts" });
expect(config.extends).toBe("../../../tsconfig.json");
expect(config.files).toEqual(["../../../src/a.ts"]);
expect(config.references).toEqual([{ path: "./tsconfig.below.json" }]);
});
it("emits nothing for tests and drops references to empty layers", () => {
const config = layerConfig(
{ name: "tests", files: [], references: ["below", "cycle"] },
{ emptyLayers: new Set(["cycle"]) },
);
expect(config.compilerOptions).toMatchObject({ composite: false, noEmit: true });
expect(config.references).toEqual([{ path: "./tsconfig.below.json" }]);
});
});
describe("typecheckLayers", () => {
const roots = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function serverFixture() {
const root = mkdtempSync(path.join(tmpdir(), "typecheck-layers-"));
roots.push(root);
mkdirSync(path.join(root, "src"));
writeFileSync(path.join(root, "src", "leaf.ts"), "");
writeFileSync(path.join(root, "src", "one.ts"), `import "./two.js"; import "./leaf.js";`);
writeFileSync(path.join(root, "src", "two.ts"), `import "./one.js";`);
writeFileSync(path.join(root, "src", "route.ts"), `import "./one.js";`);
writeFileSync(path.join(root, "src", "route.test.ts"), `import "./route.js";`);
return root;
}
it("runs the layers in order and marks the output directory as ESM", () => {
const root = serverFixture();
const configs = [];
const status = typecheckLayers({
serverRoot: root,
log: () => {},
run: (args) => {
configs.push(path.basename(args[1]));
return { status: 0, signal: null };
},
});
expect(status).toBe(0);
expect(configs).toEqual([
"tsconfig.below.json",
"tsconfig.cycle.json",
"tsconfig.above.json",
"tsconfig.tests.json",
]);
const output = path.join(root, "node_modules", ".cache", "paperclip-typecheck");
expect(JSON.parse(readFileSync(path.join(output, "package.json"), "utf8"))).toEqual({ type: "module" });
});
it("reports type errors from every layer", () => {
const root = serverFixture();
const configs = [];
const status = typecheckLayers({
serverRoot: root,
log: () => {},
run: (args) => {
configs.push(args[1]);
return { status: configs.length === 1 ? 2 : 0, signal: null };
},
});
expect(status).toBe(2);
expect(configs).toHaveLength(4);
});
it("stops when a process is killed, because later layers have no declarations", () => {
const root = serverFixture();
const configs = [];
const status = typecheckLayers({
serverRoot: root,
log: () => {},
run: (args) => {
configs.push(args[1]);
return { status: 1, signal: "SIGKILL" };
},
});
expect(status).toBe(1);
expect(configs).toHaveLength(1);
});
});
describe("typecheckLayers with the real compiler", () => {
const tsc = path.join(serverRoot, "node_modules", "typescript", "bin", "tsc");
const roots = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function compilerFixture() {
const root = mkdtempSync(path.join(tmpdir(), "typecheck-layers-real-"));
roots.push(root);
mkdirSync(path.join(root, "src"));
writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ type: "module" })}\n`);
writeFileSync(
path.join(root, "tsconfig.json"),
`${JSON.stringify({
compilerOptions: {
target: "ES2023",
module: "NodeNext",
moduleResolution: "NodeNext",
strict: true,
types: [],
outDir: "dist",
rootDir: "src",
resolveJsonModule: true,
skipLibCheck: true,
},
include: ["src"],
})}\n`,
);
const sources = {
"leaf.ts": `export const leaf = 1;\n`,
"one.ts": `import { two } from "./two.js";\nimport { leaf } from "./leaf.js";\nexport const one = () => leaf + two();\n`,
"two.ts": `import { one } from "./one.js";\nexport const two = (): number => one.length;\n`,
"route.ts": `import { one } from "./one.js";\nexport const route = () => one();\n`,
"route.test.ts": `import { route } from "./route.js";\nexport const checked: number = route();\n`,
};
for (const [name, text] of Object.entries(sources)) {
writeFileSync(path.join(root, "src", name), text);
}
return root;
}
function check(root) {
const output = [];
const status = typecheckLayers({
serverRoot: root,
log: () => {},
run: (args, { cwd }) => {
const result = spawnSync(process.execPath, [tsc, ...args], { cwd, encoding: "utf8" });
output.push(result.stdout, result.stderr);
return { status: result.status ?? 1, signal: result.signal };
},
});
return { status, output: output.join("") };
}
it("emits declarations between layers and checks every layer", () => {
const root = compilerFixture();
const { status, output } = check(root);
expect(output).toBe("");
expect(status).toBe(0);
const declarations = path.join(root, "node_modules", ".cache", "paperclip-typecheck", "dts");
for (const name of ["leaf.d.ts", "one.d.ts", "two.d.ts", "route.d.ts"]) {
expect(existsSync(path.join(declarations, name))).toBe(true);
}
expect(existsSync(path.join(declarations, "route.test.d.ts"))).toBe(false);
}, 60_000);
it("reports a changed export in the layers that depend on it", () => {
const root = compilerFixture();
writeFileSync(path.join(root, "src", "leaf.ts"), `export const renamedLeaf = 1;\n`);
const { status, output } = check(root);
expect(status).not.toBe(0);
expect(output).toMatch(/src\/one\.ts\(\d+,\d+\): error TS\d+/);
expect(output).toContain("leaf");
}, 60_000);
it("reports a type change through declarations in the test layer", () => {
const root = compilerFixture();
writeFileSync(
path.join(root, "src", "route.ts"),
`import { one } from "./one.js";\nexport const route = (): string => String(one());\n`,
);
const { status, output } = check(root);
expect(status).not.toBe(0);
expect(output).toMatch(/src\/route\.test\.ts\(\d+,\d+\): error TS2322/);
}, 60_000);
// Emitted declarations keep imports of .d.mts and JSON files, which tsc does not
// copy to the output directory. If those imports stopped resolving, skipLibCheck
// would hide it and the types would become `any`, so these errors would disappear.
it("keeps types from .d.mts and JSON imports in later layers", () => {
const root = compilerFixture();
mkdirSync(path.join(root, "src", "scripts"));
writeFileSync(path.join(root, "src", "scripts", "helper.mjs"), `export {};\n`);
writeFileSync(path.join(root, "src", "scripts", "helper.d.mts"), `export interface Manifest { version: number }\n`);
writeFileSync(path.join(root, "src", "catalog.json"), `${JSON.stringify({ tools: [{ name: "a" }] })}\n`);
writeFileSync(
path.join(root, "src", "leaf.ts"),
[
`import catalog from "./catalog.json" with { type: "json" };`,
`import type { Manifest } from "./scripts/helper.mjs";`,
`export type { Manifest };`,
`export type Tool = (typeof catalog.tools)[number];`,
`export const leaf = 1;`,
``,
].join("\n"),
);
const misuse = [
`import type { Manifest, Tool } from "./leaf.js";`,
`export const manifest: Manifest = { wrong: 1 };`,
`export const tool: Tool = { wrong: 1 };`,
``,
].join("\n");
writeFileSync(path.join(root, "src", "cycle-misuse.ts"), `import { one } from "./one.js";\nexport const used = one;\n${misuse}`);
writeFileSync(
path.join(root, "src", "two.ts"),
`import { one } from "./one.js";\nimport { used } from "./cycle-misuse.js";\nexport const two = (): number => one.length + used.length;\n`,
);
writeFileSync(path.join(root, "src", "misuse.test.ts"), misuse);
const { status, output } = check(root);
expect(status).not.toBe(0);
for (const file of ["cycle-misuse", "misuse\\.test"]) {
expect(output).toMatch(new RegExp(`src/${file}\\.ts\\(\\d+,\\d+\\): error TS2353.*'Manifest'`));
expect(output).toMatch(new RegExp(`src/${file}\\.ts\\(\\d+,\\d+\\): error TS2353.*'\\{ name: string; \\}'`));
}
}, 60_000);
});
describe("typecheckLowMemory", () => {
it("runs every step in low-memory mode and stops at a failure", () => {
const calls = [];
const run = (args, { env }) => {
calls.push([args, env.PAPERCLIP_TYPECHECK_LOW_MEMORY]);
return calls.length === 1 ? 0 : 3;
};
expect(typecheckLowMemory({ cwd: serverRoot, env: {}, run })).toBe(3);
expect(calls).toEqual(STEPS.map((args) => [args, "1"]));
});
it("does not run the typecheck after a failed preflight", () => {
const calls = [];
const status = typecheckLowMemory({
cwd: serverRoot,
env: {},
run: (args) => {
calls.push(args);
return 1;
},
});
expect(status).toBe(1);
expect(calls).toHaveLength(1);
});
});
describe("runTsc", () => {
it("emits JavaScript without checks or declarations in emit-only mode", () => {
const root = mkdtempSync(path.join(tmpdir(), "run-tsc-emit-only-"));
try {
mkdirSync(path.join(root, "src"));
mkdirSync(path.join(root, "node_modules"));
symlinkSync(
realpathSync(path.join(serverRoot, "node_modules", "typescript")),
path.join(root, "node_modules", "typescript"),
);
writeFileSync(
path.join(root, "tsconfig.json"),
`${JSON.stringify({
compilerOptions: { declaration: true, declarationMap: true, types: [], outDir: "dist", rootDir: "src" },
include: ["src"],
})}\n`,
);
writeFileSync(path.join(root, "src", "a.ts"), `export const value: number = "not a number";\n`);
const { status } = runTsc([], { cwd: root, env: { ...process.env, PAPERCLIP_TSC_EMIT_ONLY: "1" } });
expect(status).toBe(0);
expect(existsSync(path.join(root, "dist", "a.js"))).toBe(true);
expect(existsSync(path.join(root, "dist", "a.d.ts"))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 60_000);
it("accepts build mode with low-memory mode on", () => {
const { status } = runTsc(["-b", "--dry"], {
cwd: serverRoot,
env: { ...process.env, PAPERCLIP_TYPECHECK_LOW_MEMORY: "1" },
});
expect(status).toBe(0);
});
});
describe("emitOnlyTscArgs", () => {
it("removes checks and declarations only in emit-only mode", () => {
expect(emitOnlyTscArgs({ PAPERCLIP_TSC_EMIT_ONLY: "1" })).toEqual([
"--noCheck",
"--declaration",
"false",
"--declarationMap",
"false",
]);
expect(emitOnlyTscArgs({})).toEqual([]);
});
it("is what the Dockerfile selects for the server build", () => {
const dockerfile = readFileSync(path.join(serverRoot, "..", "Dockerfile"), "utf8");
expect(dockerfile).toContain("RUN PAPERCLIP_TSC_EMIT_ONLY=1 pnpm --filter @paperclipai/server build\n");
});
});
describe("lowMemoryTscArgs", () => {
it("uses one thread only when low-memory mode is on", () => {
expect(lowMemoryTscArgs({ PAPERCLIP_TYPECHECK_LOW_MEMORY: "1" })).toEqual(["--singleThreaded"]);
expect(lowMemoryTscArgs({ PAPERCLIP_TYPECHECK_LOW_MEMORY: "0" })).toEqual([]);
expect(lowMemoryTscArgs({})).toEqual([]);
});
});