From ddef53ca168eab625239caf1708e42c0b780cac0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 15 Sep 2026 21:55:16 +0000 Subject: [PATCH 1/5] fix(ci): preserve coverage cache for verifier changes --- src/package.json | 2 +- src/scripts/verify-coverage-cache-inputs.mjs | 76 ++++++++++++++++++++ src/turbo.json | 16 +++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/scripts/verify-coverage-cache-inputs.mjs diff --git a/src/package.json b/src/package.json index 0f26ad0a6c..9ead6895a1 100644 --- a/src/package.json +++ b/src/package.json @@ -442,7 +442,7 @@ "lint": "eslint . --ext=ts --max-warnings=0", "check-types": "tsc --noEmit", "test": "vitest run", - "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", + "verify:coverage-contract": "node --test scripts/verify-coverage-cache-inputs.mjs && node scripts/verify-coverage-contract.mjs", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", "test:coverage": "vitest run --coverage", diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs new file mode 100644 index 0000000000..08f525bb17 --- /dev/null +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -0,0 +1,76 @@ +import { spawnSync } from "node:child_process" +import { readFileSync, writeFileSync } from "node:fs" +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"] + +const hashes = () => { + const result = spawnSync( + command, + [...args, "turbo", "run", ...lanes.map((lane) => `test:coverage:${lane}`), "--filter=zoo-code", "--dry=json"], + { 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 Object.fromEntries( + 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 [lane, task.hash] + }), + ) +} + +const withChangedFiles = (paths, run) => { + const originals = paths.map((path) => [path, readFileSync(resolve(root, path), "utf8")]) + try { + for (const [path, contents] of originals) + writeFileSync(resolve(root, path), `${contents}\n// cache-input-test\n`) + return run() + } finally { + for (const [path, contents] of originals) writeFileSync(resolve(root, path), contents) + } +} + +const changedLanes = (before, after) => lanes.filter((lane) => before[lane] !== after[lane]) + +test("coverage lane hashes ignore post-coverage verifier implementation", () => { + const before = hashes() + for (const path of [ + "src/scripts/coverage-contract.mjs", + "src/scripts/verify-coverage-cache-inputs.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(", ")}`) + } +}) + +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"}`) +}) + +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"}`) +}) 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}", From b0886e3a114c3fcb80cb14af113fdcf0c4203705 Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 15 Sep 2026 23:29:31 +0000 Subject: [PATCH 2/5] fix(ci): isolate coverage input mutation check --- .github/workflows/code-qa.yml | 4 ++++ src/package.json | 3 ++- src/scripts/verify-coverage-cache-inputs.mjs | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index abb344dbd5..eed9380936 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -247,3 +247,7 @@ jobs: packages/core/coverage/unit/lcov.info packages/core/coverage/integration/lcov.info retention-days: 7 + # This mutates tracked inputs briefly. Keep it last so an interrupted + # ephemeral runner cannot publish or consume the changed workspace. + - name: Verify coverage cache inputs + run: pnpm --dir src run verify:coverage-cache-inputs diff --git a/src/package.json b/src/package.json index 9ead6895a1..9ed45a2cf6 100644 --- a/src/package.json +++ b/src/package.json @@ -442,7 +442,8 @@ "lint": "eslint . --ext=ts --max-warnings=0", "check-types": "tsc --noEmit", "test": "vitest run", - "verify:coverage-contract": "node --test scripts/verify-coverage-cache-inputs.mjs && node scripts/verify-coverage-contract.mjs", + "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", + "verify:coverage-cache-inputs": "node --test scripts/verify-coverage-cache-inputs.mjs", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", "test:coverage": "vitest run --coverage", diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs index 08f525bb17..21114fa42d 100644 --- a/src/scripts/verify-coverage-cache-inputs.mjs +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -4,6 +4,9 @@ import { resolve } from "node:path" import process from "node:process" import { test } from "node:test" +if (process.env.GITHUB_ACTIONS !== "true" && process.env.ZOO_ALLOW_COVERAGE_INPUT_MUTATION !== "true") + throw new Error("Coverage cache input verification requires an ephemeral runner or explicit local opt-in") + 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") From e491855b8b4bf99cfac49a5cfd04fe49ea9f1c59 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 16 Sep 2026 00:14:42 +0000 Subject: [PATCH 3/5] fix(ci): avoid self-mutating coverage verifier --- src/scripts/verify-coverage-cache-inputs.mjs | 21 +++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs index 21114fa42d..2372e93ba5 100644 --- a/src/scripts/verify-coverage-cache-inputs.mjs +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -14,7 +14,7 @@ const command = process.platform === "win32" ? process.execPath : pnpm const args = process.platform === "win32" ? [pnpm] : [] const lanes = ["api", "core", "services", "misc", "tree-sitter"] -const hashes = () => { +const coverageTasks = () => { const result = spawnSync( command, [...args, "turbo", "run", ...lanes.map((lane) => `test:coverage:${lane}`), "--filter=zoo-code", "--dry=json"], @@ -25,15 +25,15 @@ const hashes = () => { throw new Error(details || `pnpm exited with status ${result.status ?? "unknown"}`) } const graph = JSON.parse(result.stdout) - return Object.fromEntries( - 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 [lane, task.hash] - }), - ) + 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(root, path), "utf8")]) try { @@ -49,9 +49,12 @@ const changedLanes = (before, after) => lanes.filter((lane) => before[lane] !== 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-cache-inputs.mjs", "src/scripts/verify-coverage-contract.mjs", "src/scripts/verify-lcov.mjs", ]) { From a05faece4b3a5bb343de0cae5bc4eb9f0357570d Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 16 Sep 2026 00:29:07 +0000 Subject: [PATCH 4/5] fix(ci): isolate coverage hash probes --- src/scripts/verify-coverage-cache-inputs.mjs | 44 ++++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs index 2372e93ba5..f2f28320cc 100644 --- a/src/scripts/verify-coverage-cache-inputs.mjs +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -1,11 +1,9 @@ import { spawnSync } from "node:child_process" -import { readFileSync, writeFileSync } from "node:fs" +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" - -if (process.env.GITHUB_ACTIONS !== "true" && process.env.ZOO_ALLOW_COVERAGE_INPUT_MUTATION !== "true") - throw new Error("Coverage cache input verification requires an ephemeral runner or explicit local opt-in") +import { after, test } from "node:test" const root = resolve(import.meta.dirname, "../..") const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" @@ -13,11 +11,39 @@ 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"] +const probeRoot = mkdtempSync(resolve(tmpdir(), "zoo-code-coverage-cache-inputs-")) + +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"}`) + } +} + +git(["worktree", "add", "--detach", probeRoot, "HEAD"]) +after(() => { + try { + git(["worktree", "remove", "--force", probeRoot]) + } finally { + rmSync(probeRoot, { recursive: true, force: true }) + } +}) const coverageTasks = () => { const result = spawnSync( command, - [...args, "turbo", "run", ...lanes.map((lane) => `test:coverage:${lane}`), "--filter=zoo-code", "--dry=json"], + [ + ...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) { @@ -35,13 +61,13 @@ const coverageTasks = () => { 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(root, path), "utf8")]) + const originals = paths.map((path) => [path, readFileSync(resolve(probeRoot, path), "utf8")]) try { for (const [path, contents] of originals) - writeFileSync(resolve(root, path), `${contents}\n// cache-input-test\n`) + writeFileSync(resolve(probeRoot, path), `${contents}\n// cache-input-test\n`) return run() } finally { - for (const [path, contents] of originals) writeFileSync(resolve(root, path), contents) + for (const [path, contents] of originals) writeFileSync(resolve(probeRoot, path), contents) } } From 21335351b9f3f4bf65972baa2574be3dd00a0e12 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 16 Sep 2026 02:10:33 +0000 Subject: [PATCH 5/5] fix(ci): validate cache before publication --- .github/workflows/code-qa.yml | 7 +- src/scripts/verify-coverage-cache-inputs.mjs | 98 ++++++++++++-------- 2 files changed, 64 insertions(+), 41 deletions(-) diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 657212dc96..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 @@ -248,7 +251,3 @@ jobs: packages/core/coverage/unit/lcov.info packages/core/coverage/integration/lcov.info retention-days: 7 - # Hash probes mutate only a disposable worktree and run last to keep - # verification overhead off the critical coverage path. - - name: Verify coverage cache inputs - run: pnpm --dir src run verify:coverage-cache-inputs diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs index f2f28320cc..1337bddef9 100644 --- a/src/scripts/verify-coverage-cache-inputs.mjs +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { resolve } from "node:path" import process from "node:process" -import { after, test } from "node:test" +import { test } from "node:test" const root = resolve(import.meta.dirname, "../..") const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" @@ -11,7 +11,7 @@ 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"] -const probeRoot = mkdtempSync(resolve(tmpdir(), "zoo-code-coverage-cache-inputs-")) +let probeRoot const git = (gitArgs) => { const result = spawnSync("git", gitArgs, { cwd: root, encoding: "utf8" }) @@ -21,15 +21,6 @@ const git = (gitArgs) => { } } -git(["worktree", "add", "--detach", probeRoot, "HEAD"]) -after(() => { - try { - git(["worktree", "remove", "--force", probeRoot]) - } finally { - rmSync(probeRoot, { recursive: true, force: true }) - } -}) - const coverageTasks = () => { const result = spawnSync( command, @@ -73,36 +64,69 @@ const withChangedFiles = (paths, run) => { const changedLanes = (before, after) => lanes.filter((lane) => before[lane] !== after[lane]) -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}`) +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 }) + } } - 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(", ")}`) + 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) -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) + try { + git(["worktree", "add", "--detach", probeRoot, "HEAD"]) + worktreeAdded = true - if (changed.join(",") !== lanes.join(",")) - throw new Error(`Shared production change invalidated ${changed.join(", ") || "no lanes"}`) -}) + 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) -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(",") !== lanes.join(",")) + throw new Error(`Shared production change invalidated ${changed.join(", ") || "no lanes"}`) + }) - if (changed.join(",") !== "api") throw new Error(`API test 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() + } })