diff --git a/scripts/coverage.mjs b/scripts/coverage.mjs index fd56a4f..f7aa961 100644 --- a/scripts/coverage.mjs +++ b/scripts/coverage.mjs @@ -29,6 +29,12 @@ if (minRaw !== undefined && (!Number.isFinite(min) || min < 0 || min > 100)) { process.exit(2) } const reportDir = path.join(repoRoot, ".headlesscode", "coverage") +// PID-unique temp dir: c8 wipes its temp dir at startup, and this script +// runs nested inside itself (coverage.test.mjs spawns filtered coverage +// runs as part of the suite). A shared tmp/ meant each nested run deleted +// every profile the outer run had collected, collapsing full-suite line +// coverage to ~4.5%. Unique dirs also let concurrent runs coexist. +const tempDir = path.join(reportDir, `tmp-${process.pid}`) const testArgs = ["scripts/run-tests.mjs"] if (filter) testArgs.push("--filter", filter) @@ -36,6 +42,7 @@ const result = spawnSync("node_modules/.bin/c8", [ "--reporter=text", "--reporter=json-summary", "--reports-dir", reportDir, + "--temp-directory", tempDir, "--all", "--src", "src", "--exclude", "**/__tests__/**", @@ -43,6 +50,14 @@ const result = spawnSync("node_modules/.bin/c8", [ "--exclude", "src/vendor/**", "node", ...testArgs, ], { cwd: repoRoot, stdio: "inherit", env: process.env }) +// Raw V8 profiles are tens of MB per run; the report is already written by +// now, so remove them on every exit path (pass AND fail) to avoid pile-up. +try { + fs.rmSync(tempDir, { recursive: true, force: true }) +} catch { + // best-effort — a leftover tmp dir is a disk-space nit, never worth + // failing the coverage run over. +} if (result.error) { console.error(`coverage: failed to run c8: ${result.error.message}`) process.exit(1) diff --git a/scripts/coverage.test.mjs b/scripts/coverage.test.mjs index d40c191..d34a3c5 100644 --- a/scripts/coverage.test.mjs +++ b/scripts/coverage.test.mjs @@ -2,10 +2,12 @@ // percentage report on a tiny filtered run (claims tests, a few seconds) // instead of the full suite. Exit 1 with a clear message on any mismatch. import { execFileSync } from "node:child_process" +import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const reportDir = path.join(repoRoot, ".headlesscode", "coverage") function runCoverage(args) { try { @@ -34,4 +36,21 @@ if (floor.status === 0) fail("--min 100 unexpectedly passed") if (!/below the 100% floor/.test(floor.out)) fail("floor-failure message missing") const invalid = runCoverage(["--min", "abc"]) if (invalid.status !== 2) fail(`invalid --min exited ${invalid.status}, want 2`) -console.log(" ok coverage-selftest: summary line, floor pass/fail, invalid arg") +// Nested-run isolation (2026-10-13 incident: nested c8 runs wiped the +// outer run's shared tmp/, collapsing full-suite coverage to ~4.5%). +// Each finished run must clean its own pid-unique tmp dir; a shared bare +// tmp/ must never come back. Pre-existing tmp-* dirs belong to still-live +// outer/concurrent runs, so only NEW leftovers fail. +const before = new Set(fs.existsSync(reportDir) ? fs.readdirSync(reportDir) : []) +runCoverage([...filter, "--min", "0"]) +const after = fs.existsSync(reportDir) ? fs.readdirSync(reportDir) : [] +if (after.includes("tmp")) fail("shared bare tmp/ dir present — nested runs would wipe it") +for (const entry of after) { + if (entry.startsWith("tmp-") && !before.has(entry)) { + fail(`leftover temp dir ${entry} from a finished nested run`) + } + if (entry.startsWith("tmp-") && !/^tmp-\d+$/.test(entry)) { + fail(`non-pid temp dir ${entry}`) + } +} +console.log(" ok coverage-selftest: summary line, floor pass/fail, invalid arg, nested-run tmp isolation")