#!/usr/bin/env node // Optional coverage gate. Runs vitest with --coverage against apps/api and // apps/worker source roots, parses the JSON summary, and asserts each target // stays at or above its configured threshold. // // Requires @vitest/coverage-v8 (workspace-level dev dep) to be installed. // When the package is missing, the gate degrades to a SKIP with a clear // install hint so CI does not block on tooling that has not been adopted yet. // // Targets are declared in scripts/coverage-targets.json so future work can // adjust thresholds without touching this runner. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", ".."); const targetsPath = path.join(repoRoot, "scripts", "coverage-targets.json"); const coverageGatePath = path.join(repoRoot, "coverage-gate"); const summaryPath = path.join(coverageGatePath, "coverage-summary.json"); function readTargets() { const raw = readFileSync(targetsPath, "utf8"); return JSON.parse(raw); } function coverageDepInstalled() { try { const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); const devDeps = pkg.devDependencies ?? {}; return Boolean(devDeps["@vitest/coverage-v8"] ?? devDeps["@vitest/coverage-c8"]); } catch { return false; } } export function buildVitestCoverageArgs(includeGlobs) { return ["exec", "vitest", "run", "--coverage", "--pool=threads", ...includeGlobs]; } export function buildVitestCoverageEnv(env = process.env) { return { ...env, TOOLCTRL_COVERAGE_GATE: "1" }; } export function isIgnorableCoverageTempFailure({ status, output, summaryExists }) { if (status === 0 || !summaryExists) return false; if (!/ENOENT: no such file or directory/.test(output)) return false; if (!/[\\/]coverage(?:-gate)?[\\/]\.tmp(?:[\\/]|')/.test(output)) return false; const testFilesLine = output .split(/\r?\n/) .reverse() .find((line) => line.includes("Test Files")); const testsLine = output .split(/\r?\n/) .reverse() .find((line) => /^\s*Tests\s+/.test(line)); return Boolean(testFilesLine && testsLine && !/\bfailed\b/i.test(testFilesLine) && !/\bfailed\b/i.test(testsLine)); } function runVitestCoverage(includeGlobs) { if (existsSync(coverageGatePath)) { rmSync(coverageGatePath, { recursive: true, force: true }); } // vitest 0.29 CLI does not support dotted --coverage.reporter flags; the // reporters (text-summary, json-summary) are configured in vitest.config.ts. // // Keep this gate on the threads pool: the broad multi-package V8 coverage // run can race fork teardown against coverage temp-file cleanup, producing a // coverage-only ENOENT after all tests have passed and the JSON summary has // been written. const args = buildVitestCoverageArgs(includeGlobs); const result = spawnSync("pnpm", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 50 * 1024 * 1024, env: buildVitestCoverageEnv() }); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; if (isIgnorableCoverageTempFailure({ status: result.status, output, summaryExists: existsSync(summaryPath) })) { console.warn("WARN ignored Vitest V8 coverage temp cleanup ENOENT after successful test run and summary generation"); return true; } return result.status === 0; } function readSummary() { if (!existsSync(summaryPath)) return null; return JSON.parse(readFileSync(summaryPath, "utf8")); } function canSkipEmptyTarget(target) { if (target.criticalPath === true) return false; return target.allowEmpty === true || (target.statements === 0 && target.branches === 0 && /\btype-only\b/i.test(target.name)); } export function evaluateTargets(targets, summary) { const failures = []; const passes = []; for (const target of targets) { const filtered = Object.entries(summary).filter(([file]) => { if (file === "total") return false; const rel = path.relative(repoRoot, file); return target.pathPrefixes.some((prefix) => rel.startsWith(prefix)); }); if (filtered.length === 0) { const tag = target.criticalPath ? " [CRITICAL]" : ""; if (canSkipEmptyTarget(target)) { passes.push(`SKIP ${target.name}${tag} no covered files matched`); } else { failures.push(`FAIL ${target.name}${tag} no covered files matched`); } continue; } const totals = filtered.reduce( (acc, [, m]) => ({ statements: acc.statements + m.statements.covered, statementsTotal: acc.statementsTotal + m.statements.total, branches: acc.branches + m.branches.covered, branchesTotal: acc.branchesTotal + m.branches.total, }), { statements: 0, statementsTotal: 0, branches: 0, branchesTotal: 0 } ); const statementsPct = totals.statementsTotal > 0 ? (totals.statements / totals.statementsTotal) * 100 : 0; const branchesPct = totals.branchesTotal > 0 ? (totals.branches / totals.branchesTotal) * 100 : 0; const sprintTarget = target.sprintTarget; const sprintGap = sprintTarget ? ` sprintTarget=${sprintTarget.statements}%/${sprintTarget.branches}% (gap statements=${(sprintTarget.statements - statementsPct).toFixed(1)} branches=${(sprintTarget.branches - branchesPct).toFixed(1)})` : ""; const tag = target.criticalPath ? " [CRITICAL]" : ""; const line = `${target.name}${tag} statements=${statementsPct.toFixed(1)}% (target ${target.statements}%) branches=${branchesPct.toFixed(1)}% (target ${target.branches}%)${sprintGap}`; if (statementsPct >= target.statements && branchesPct >= target.branches) { passes.push(`PASS ${line}`); } else { failures.push(`FAIL ${line}`); } } return { passes, failures }; } function main() { if (!coverageDepInstalled()) { console.log("SKIP @vitest/coverage-v8 not installed"); console.log(""); console.log("To enable real coverage thresholds:"); console.log(" pnpm add -D -w @vitest/coverage-v8@~0.29"); console.log(""); console.log("Thresholds in scripts/coverage-targets.json will then be enforced by this gate."); console.log("Until adoption, scripts/verify-test-density.mjs covers structural test-per-source ratios."); process.exit(0); } const targets = readTargets(); const includeGlobs = [...new Set(targets.flatMap((t) => t.includeGlobs))]; const success = runVitestCoverage(includeGlobs); if (!success) { console.error("FAIL vitest run with --coverage exited non-zero"); process.exit(1); } const summary = readSummary(); if (!summary) { console.error("FAIL coverage-gate/coverage-summary.json missing — verify @vitest/coverage-v8 reporter config"); process.exit(1); } const { passes, failures } = evaluateTargets(targets, summary); for (const line of passes) console.log(line); for (const line of failures) console.error(line); console.log(`Summary: ${passes.length} passed, 0 observed, ${failures.length} failed`); process.exit(failures.length > 0 ? 1 : 0); } if (import.meta.url === pathToFileURL(process.argv[1]).href) { main(); }