From 8775bde4ceab59301d075dc6bee5ecdc2b5e65de Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 13 Jul 2026 08:35:30 -0700 Subject: [PATCH] Add runtime asset build-gap guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server package ships runtime asset trees used by built-in agents and onboarding templates. > - A prior server build omitted those source asset trees from `dist`, allowing a built artifact to differ from runtime expectations. > - The copy step is now present, but the existing build-gap gate only checked TypeScript coverage for packages whose build skips `tsc`. > - This pull request extends that standing gate so source asset files under the server runtime asset trees must exist at the matching `dist` paths after build. > - The benefit is that future server asset additions fail loudly in CI instead of silently shipping an incomplete `dist`. ## Linked Issues or Issue Description ### What happened? After a server build, runtime asset files under `server/src/built-ins/**` and `server/src/onboarding-assets/**` could be missing from `dist/` with no build failure. The existing build-gap gate only checked TypeScript coverage for packages that skip `tsc`; it did not verify that non-TypeScript source assets were copied to `dist`. A server build that forgot the `cp -R` step, or that added a new asset tree without updating the copy command, would produce an incomplete `dist` without any CI signal. ### Expected behavior After `pnpm --filter @paperclipai/server build`, every non-TypeScript/non-JavaScript source file under `server/src/built-ins/` and `server/src/onboarding-assets/` must exist at the matching path under `server/dist/`. If any file is missing, the build-gap gate must exit non-zero with a diagnostic listing the missing files and the command to fix them. ### Steps to reproduce 1. Remove a copied runtime asset: `rm server/dist/built-ins/agents/reflection-coach/AGENTS.md` 2. Run the guard: `node scripts/run-typecheck-build-gaps.mjs --runtime-assets-only` 3. Before this fix: the command exits 0 and the missing file goes undetected. ### Paperclip version or commit Reproduced on `master` at `c36f1a4af` (`@paperclipai/server` 0.3.1). ### Deployment mode Not deployment-specific — the build-gap check runs in CI on any checkout. ## What Changed - Extended `scripts/run-typecheck-build-gaps.mjs` with a source-derived server runtime asset parity check for non-`.ts`/non-`.js` files under `server/src/built-ins/**` and `server/src/onboarding-assets/**`. - Added a guard-only mode, `--runtime-assets-only`, for focused pass/fail verification after a server build. - Wired `pnpm run typecheck:build-gaps` to prepare plugin SDK build deps, build the server package, then run the existing build-gap gate plus the new asset check. ## Verification Pass path: ```text $ pnpm --filter @paperclipai/plugin-sdk ensure-build-deps > @paperclipai/plugin-sdk@1.0.0 ensure-build-deps .../packages/plugins/sdk > node ../../../scripts/ensure-plugin-build-deps.mjs $ pnpm --filter @paperclipai/server build > @paperclipai/server@0.3.1 build .../server > tsc && mkdir -p dist/onboarding-assets dist/built-ins && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ $ node scripts/run-typecheck-build-gaps.mjs --runtime-assets-only [typecheck:build-gaps] server runtime assets present in dist: 7 file(s) ``` Regression simulation (guard catches the missing file): ```text $ rm server/dist/built-ins/agents/reflection-coach/AGENTS.md $ node scripts/run-typecheck-build-gaps.mjs --runtime-assets-only [typecheck:build-gaps] Missing server runtime asset(s) in dist: - source: server/src/built-ins/agents/reflection-coach/AGENTS.md expected dist: server/dist/built-ins/agents/reflection-coach/AGENTS.md Run pnpm --filter @paperclipai/server build and ensure source runtime asset trees are copied into dist. ``` Standing gate (full end-to-end): ```text $ pnpm run typecheck:build-gaps [typecheck:build-gaps] typechecking 4 workspace(s): paperclipai, @paperclipai/plugin-authoring-smoke-example, @paperclipai/plugin-llm-wiki, @paperclipai/ui [typecheck:build-gaps] server runtime assets present in dist: 7 file(s) ``` ## Risks Low risk. The check only reads source and dist files during the build-gap gate. The main tradeoff is that the gate now builds `@paperclipai/server` so a clean checkout has generated `dist` content to validate. ## Model Used OpenAI Codex, GPT-5 based coding agent with repository tool use and shell execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- package.json | 2 +- .../run-typecheck-build-gaps.test.mjs | 60 ++++++++++ scripts/run-typecheck-build-gaps.mjs | 109 ++++++++++++++++-- 3 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 scripts/__tests__/run-typecheck-build-gaps.test.mjs diff --git a/package.json b/package.json index 89ef88fd09..0a8ec3fb41 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build-storybook": "pnpm --filter @paperclipai/ui build-storybook", "build": "pnpm run preflight:workspace-links && pnpm -r build", "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", - "typecheck:build-gaps": "pnpm run preflight:workspace-links && node scripts/run-typecheck-build-gaps.mjs", + "typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs", "test": "pnpm run test:run", "test:watch": "pnpm run preflight:workspace-links && vitest", "test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs", diff --git a/scripts/__tests__/run-typecheck-build-gaps.test.mjs b/scripts/__tests__/run-typecheck-build-gaps.test.mjs new file mode 100644 index 0000000000..f3d1bc0799 --- /dev/null +++ b/scripts/__tests__/run-typecheck-build-gaps.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const script = new URL("../run-typecheck-build-gaps.mjs", import.meta.url).pathname; + +function createFixtureRepo() { + return mkdtempSync(path.join(tmpdir(), "run-typecheck-build-gaps-test-")); +} + +function writeFixtureFile(root, relativePath, body = "fixture") { + const filePath = path.join(root, relativePath); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, body); +} + +function runRuntimeAssetGuard(root) { + return spawnSync(process.execPath, [script, "--runtime-assets-only"], { + cwd: root, + encoding: "utf8", + }); +} + +test("passes when all source runtime assets are present in dist", () => { + const root = createFixtureRepo(); + try { + writeFixtureFile(root, "server/src/built-ins/agents/default.md"); + writeFixtureFile(root, "server/dist/built-ins/agents/default.md"); + writeFixtureFile(root, "server/src/onboarding-assets/welcome.txt"); + writeFixtureFile(root, "server/dist/onboarding-assets/welcome.txt"); + writeFixtureFile(root, "server/src/built-ins/ignored.ts"); + + const result = runRuntimeAssetGuard(root); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /server runtime assets present in dist: 2 file\(s\)/); + assert.equal(result.stderr, ""); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("fails with the missing source asset and expected dist path", () => { + const root = createFixtureRepo(); + try { + writeFixtureFile(root, "server/src/built-ins/agents/default.md"); + + const result = runRuntimeAssetGuard(root); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Missing server runtime asset\(s\) in dist/); + assert.match(result.stderr, /source: server\/src\/built-ins\/agents\/default\.md/); + assert.match(result.stderr, /expected dist: server\/dist\/built-ins\/agents\/default\.md/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/run-typecheck-build-gaps.mjs b/scripts/run-typecheck-build-gaps.mjs index e149990bf6..9c2320969e 100644 --- a/scripts/run-typecheck-build-gaps.mjs +++ b/scripts/run-typecheck-build-gaps.mjs @@ -1,9 +1,17 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; const repoRoot = process.cwd(); +const args = new Set(process.argv.slice(2)); +const allowedArgs = new Set(["--runtime-assets-only"]); + +for (const arg of args) { + if (!allowedArgs.has(arg)) { + fail(`Unknown argument: ${arg}`); + } +} function fail(message) { console.error(`[typecheck:build-gaps] ${message}`); @@ -30,6 +38,91 @@ function readJson(filePath) { return JSON.parse(readFileSync(filePath, "utf8")); } +function formatPath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} + +function listFilesRecursive(rootDir) { + if (!existsSync(rootDir)) { + return []; + } + + const files = []; + const entries = readdirSync(rootDir, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + ); + + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursive(entryPath)); + continue; + } + + if (entry.isFile()) { + files.push(entryPath); + } + } + + return files; +} + +function isRuntimeAsset(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return !new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]).has(ext); +} + +function checkServerRuntimeAssets() { + const runtimeAssetTrees = [ + { + sourceDir: path.join(repoRoot, "server", "src", "built-ins"), + distDir: path.join(repoRoot, "server", "dist", "built-ins"), + }, + { + sourceDir: path.join(repoRoot, "server", "src", "onboarding-assets"), + distDir: path.join(repoRoot, "server", "dist", "onboarding-assets"), + }, + ]; + const missingAssets = []; + let checkedCount = 0; + + for (const assetTree of runtimeAssetTrees) { + const sourceAssets = listFilesRecursive(assetTree.sourceDir).filter(isRuntimeAsset); + checkedCount += sourceAssets.length; + + for (const sourcePath of sourceAssets) { + const relativeAssetPath = path.relative(assetTree.sourceDir, sourcePath); + const distPath = path.join(assetTree.distDir, relativeAssetPath); + + if (!existsSync(distPath) || !statSync(distPath).isFile()) { + missingAssets.push({ sourcePath, distPath }); + } + } + } + + if (missingAssets.length > 0) { + const missingList = missingAssets + .map( + ({ sourcePath, distPath }) => + ` - source: ${formatPath(sourcePath)}\n expected dist: ${formatPath(distPath)}`, + ) + .join("\n"); + + fail( + `Missing server runtime asset(s) in dist:\n${missingList}\nRun pnpm --filter @paperclipai/server build and ensure source runtime asset trees are copied into dist.`, + ); + } + + console.log( + `[typecheck:build-gaps] server runtime assets present in dist: ${checkedCount} file(s)`, + ); +} + +if (args.has("--runtime-assets-only")) { + checkServerRuntimeAssets(); + process.exit(0); +} + function listWorkspacePackages() { const result = spawnSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], { cwd: repoRoot, @@ -82,12 +175,12 @@ console.log( `[typecheck:build-gaps] typechecking ${buildGapPackages.length} workspace(s): ${buildGapPackages.map(({ name }) => name).join(", ") || "(none)"}`, ); -if (buildGapPackages.length === 0) { - process.exit(0); +if (buildGapPackages.length > 0) { + run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]); + + for (const workspacePkg of buildGapPackages) { + run("pnpm", ["--filter", workspacePkg.name, "typecheck"]); + } } -run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]); - -for (const workspacePkg of buildGapPackages) { - run("pnpm", ["--filter", workspacePkg.name, "typecheck"]); -} +checkServerRuntimeAssets();