diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 9f4a52ba80..f1ba2a10cb 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -178,6 +178,9 @@ jobs: mkdir -p src/coverage/merged pnpm --dir src run merge:coverage node src/scripts/verify-lcov.mjs src/coverage/merged/lcov.info + # Validate cache boundaries before publishing any new Turbo entries. + - name: Verify coverage cache inputs + run: pnpm --dir src run verify:coverage-cache-inputs - name: Save Turbo cache if: steps.turbo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/src/package.json b/src/package.json index ede058efd9..e047f661d8 100644 --- a/src/package.json +++ b/src/package.json @@ -443,6 +443,7 @@ "check-types": "tsc --noEmit", "test": "vitest run", "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", + "verify:coverage-cache-inputs": "node --test scripts/verify-coverage-cache-inputs.mjs", "merge:coverage": "node scripts/merge-lcov.mjs coverage/merged/lcov.info coverage/api/lcov.info coverage/core/lcov.info coverage/services/lcov.info coverage/misc/lcov.info coverage/tree-sitter/lcov.info", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs new file mode 100644 index 0000000000..1337bddef9 --- /dev/null +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -0,0 +1,132 @@ +import { spawnSync } from "node:child_process" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { resolve } from "node:path" +import process from "node:process" +import { test } from "node:test" + +const root = resolve(import.meta.dirname, "../..") +const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" +if (!pnpm) throw new Error("pnpm executable path is unavailable") +const command = process.platform === "win32" ? process.execPath : pnpm +const args = process.platform === "win32" ? [pnpm] : [] +const lanes = ["api", "core", "services", "misc", "tree-sitter"] +let probeRoot + +const git = (gitArgs) => { + const result = spawnSync("git", gitArgs, { cwd: root, encoding: "utf8" }) + if (result.status !== 0) { + const details = [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n") + throw new Error(details || `git exited with status ${result.status ?? "unknown"}`) + } +} + +const coverageTasks = () => { + const result = spawnSync( + command, + [ + ...args, + "turbo", + "--cwd", + probeRoot, + "run", + ...lanes.map((lane) => `test:coverage:${lane}`), + "--filter=zoo-code", + "--dry=json", + "--no-daemon", + ], + { cwd: root, encoding: "utf8" }, + ) + if (result.status !== 0) { + const details = [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n") + throw new Error(details || `pnpm exited with status ${result.status ?? "unknown"}`) + } + const graph = JSON.parse(result.stdout) + return lanes.map((lane) => { + const task = graph.tasks.find(({ taskId }) => taskId === `zoo-code#test:coverage:${lane}`) + if (!task) throw new Error(`Coverage lane missing from Turbo graph: ${lane}`) + return task + }) +} + +const hashes = () => Object.fromEntries(coverageTasks().map((task) => [task.task.split(":").at(-1), task.hash])) + +const withChangedFiles = (paths, run) => { + const originals = paths.map((path) => [path, readFileSync(resolve(probeRoot, path), "utf8")]) + try { + for (const [path, contents] of originals) + writeFileSync(resolve(probeRoot, path), `${contents}\n// cache-input-test\n`) + return run() + } finally { + for (const [path, contents] of originals) writeFileSync(resolve(probeRoot, path), contents) + } +} + +const changedLanes = (before, after) => lanes.filter((lane) => before[lane] !== after[lane]) + +test("coverage cache input contract", async (context) => { + probeRoot = mkdtempSync(resolve(tmpdir(), "zoo-code-coverage-cache-inputs-")) + let worktreeAdded = false + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + try { + if (worktreeAdded) git(["worktree", "remove", "--force", probeRoot]) + } finally { + rmSync(probeRoot, { recursive: true, force: true }) + } + } + const terminate = (signal) => { + cleanup() + process.kill(process.pid, signal) + } + const onSigint = () => terminate("SIGINT") + const onSigterm = () => terminate("SIGTERM") + process.once("SIGINT", onSigint) + process.once("SIGTERM", onSigterm) + + try { + git(["worktree", "add", "--detach", probeRoot, "HEAD"]) + worktreeAdded = true + + await context.test("coverage lane hashes ignore post-coverage verifier implementation", () => { + const before = hashes() + const self = "scripts/verify-coverage-cache-inputs.mjs" + for (const task of coverageTasks()) { + if (Object.hasOwn(task.inputs, self)) throw new Error(`${self} is an input of ${task.taskId}`) + } + for (const path of [ + "src/scripts/coverage-contract.mjs", + "src/scripts/verify-coverage-contract.mjs", + "src/scripts/verify-lcov.mjs", + ]) { + const after = withChangedFiles([path], hashes) + const changed = changedLanes(before, after) + if (changed.length !== 0) throw new Error(`${path} invalidated coverage lanes: ${changed.join(", ")}`) + } + }) + + await context.test("shared production changes invalidate every coverage lane that can import them", () => { + const before = hashes() + const after = withChangedFiles(["src/utils/path.ts"], hashes) + const changed = changedLanes(before, after) + + if (changed.join(",") !== lanes.join(",")) + throw new Error(`Shared production change invalidated ${changed.join(", ") || "no lanes"}`) + }) + + await context.test("lane-owned tests invalidate only their general coverage lane", () => { + const before = hashes() + const after = withChangedFiles(["src/api/providers/__tests__/anthropic.spec.ts"], hashes) + const changed = changedLanes(before, after) + + if (changed.join(",") !== "api") + throw new Error(`API test change invalidated ${changed.join(", ") || "no lanes"}`) + }) + } finally { + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + cleanup() + } +}) diff --git a/src/turbo.json b/src/turbo.json index 0d023b5598..eac6f30f3c 100644 --- a/src/turbo.json +++ b/src/turbo.json @@ -24,6 +24,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!core/**/*.{test,spec}.{ts,tsx}", "!services/**/*.{test,spec}.{ts,tsx}", "!__tests__/**/*.{test,spec}.{ts,tsx}", @@ -42,6 +46,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!api/**/*.{test,spec}.{ts,tsx}", "!services/**/*.{test,spec}.{ts,tsx}", "!__tests__/**/*.{test,spec}.{ts,tsx}", @@ -60,6 +68,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!api/**/*.{test,spec}.{ts,tsx}", "!core/**/*.{test,spec}.{ts,tsx}", "!services/tree-sitter/**/*.{test,spec}.{ts,tsx}", @@ -79,6 +91,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!api/**/*.{test,spec}.{ts,tsx}", "!core/**/*.{test,spec}.{ts,tsx}", "!services/**/*.{test,spec}.{ts,tsx}",