|
| 1 | +import { mkdtemp, rm } from "node:fs/promises" |
| 2 | +import os from "node:os" |
| 3 | +import path from "node:path" |
| 4 | + |
| 5 | +export type CoverageRecord = { |
| 6 | + lines: { found: number; hit: number } |
| 7 | + functions: { found: number; hit: number } |
| 8 | +} |
| 9 | + |
| 10 | +export type CoverageThreshold = { |
| 11 | + file: string |
| 12 | + lines: number |
| 13 | + functions: number |
| 14 | +} |
| 15 | + |
| 16 | +export function parseLcov(input: string) { |
| 17 | + return input |
| 18 | + .split("end_of_record") |
| 19 | + .map((record) => record.trim().split(/\r?\n/)) |
| 20 | + .reduce((report, lines) => { |
| 21 | + const file = field(lines, "SF") |
| 22 | + if (!file) return report |
| 23 | + report.set(file, { |
| 24 | + lines: { |
| 25 | + found: Number(field(lines, "LF") ?? 0), |
| 26 | + hit: Number(field(lines, "LH") ?? 0), |
| 27 | + }, |
| 28 | + functions: { |
| 29 | + found: Number(field(lines, "FNF") ?? 0), |
| 30 | + hit: Number(field(lines, "FNH") ?? 0), |
| 31 | + }, |
| 32 | + }) |
| 33 | + return report |
| 34 | + }, new Map<string, CoverageRecord>()) |
| 35 | +} |
| 36 | + |
| 37 | +export function assertCoverage(report: ReadonlyMap<string, CoverageRecord>, thresholds: readonly CoverageThreshold[]) { |
| 38 | + const failures = thresholds.flatMap((threshold) => { |
| 39 | + const record = report.get(threshold.file) |
| 40 | + if (!record) return [`${threshold.file}: missing from LCOV report`] |
| 41 | + const lines = percentage(record.lines) |
| 42 | + const functions = percentage(record.functions) |
| 43 | + return [ |
| 44 | + ...(lines < threshold.lines |
| 45 | + ? [`${threshold.file}: lines ${lines.toFixed(2)}% < ${threshold.lines.toFixed(2)}%`] |
| 46 | + : []), |
| 47 | + ...(functions < threshold.functions |
| 48 | + ? [`${threshold.file}: functions ${functions.toFixed(2)}% < ${threshold.functions.toFixed(2)}%`] |
| 49 | + : []), |
| 50 | + ] |
| 51 | + }) |
| 52 | + if (failures.length > 0) throw new Error(`DAG core coverage gate failed:\n${failures.join("\n")}`) |
| 53 | +} |
| 54 | + |
| 55 | +export async function runDagCoreCoverageGate() { |
| 56 | + const root = path.resolve(import.meta.dir, "../../..") |
| 57 | + const output = await mkdtemp(path.join(os.tmpdir(), "opencode-dag-core-coverage-")) |
| 58 | + try { |
| 59 | + yieldMessage("Core state machine, store, and transition seams") |
| 60 | + await runCoverageSuite({ |
| 61 | + cwd: path.join(root, "packages/core"), |
| 62 | + output: path.join(output, "core"), |
| 63 | + tests: [ |
| 64 | + "test/dag-core.test.ts", |
| 65 | + "test/dag-store-wake.test.ts", |
| 66 | + "test/dag-node-cancelled-projection.test.ts", |
| 67 | + "test/dag-store-summaries.test.ts", |
| 68 | + "test/dag-projector-drift.test.ts", |
| 69 | + ], |
| 70 | + thresholds: [ |
| 71 | + { file: "src/dag/core/graph.ts", lines: 70, functions: 60 }, |
| 72 | + { file: "src/dag/core/replan.ts", lines: 90, functions: 95 }, |
| 73 | + { file: "src/dag/core/scheduling.ts", lines: 92, functions: 80 }, |
| 74 | + { file: "src/dag/core/transitions.ts", lines: 94, functions: 75 }, |
| 75 | + { file: "src/dag/core/types.ts", lines: 92, functions: 85 }, |
| 76 | + { file: "src/dag/store.ts", lines: 75, functions: 65 }, |
| 77 | + ], |
| 78 | + }) |
| 79 | + |
| 80 | + yieldMessage("OpenCode DAG public API and runtime seams") |
| 81 | + await runCoverageSuite({ |
| 82 | + cwd: path.join(root, "packages/opencode"), |
| 83 | + output: path.join(output, "opencode"), |
| 84 | + tests: ["test/dag"], |
| 85 | + thresholds: [ |
| 86 | + { file: "../core/src/dag/projector.ts", lines: 98, functions: 95 }, |
| 87 | + { file: "../core/src/dag/store.ts", lines: 85, functions: 80 }, |
| 88 | + { file: "src/dag/dag.ts", lines: 98, functions: 95 }, |
| 89 | + { file: "src/dag/runtime/loop.ts", lines: 90, functions: 88 }, |
| 90 | + { file: "src/dag/runtime/recovery.ts", lines: 95, functions: 75 }, |
| 91 | + { file: "src/dag/runtime/spawn.ts", lines: 90, functions: 70 }, |
| 92 | + { file: "src/dag/runtime/summary-publisher.ts", lines: 95, functions: 90 }, |
| 93 | + { file: "src/tool/workflow.ts", lines: 88, functions: 75 }, |
| 94 | + ], |
| 95 | + }) |
| 96 | + |
| 97 | + yieldMessage("Schema manifest and generated SDK contract") |
| 98 | + await run(["bun", "test", "test/event-manifest.test.ts", "--only-failures"], path.join(root, "packages/schema")) |
| 99 | + await run(["bun", "run", "check:generated"], path.join(root, "packages/sdk/js")) |
| 100 | + |
| 101 | + yieldMessage("TUI projection and inspector seams") |
| 102 | + await runCoverageSuite({ |
| 103 | + cwd: path.join(root, "packages/tui"), |
| 104 | + output: path.join(output, "tui"), |
| 105 | + tests: [ |
| 106 | + "test/cli/cmd/tui/sync-dag.test.tsx", |
| 107 | + "test/feature-plugins/dag-inspector.test.tsx", |
| 108 | + "test/feature-plugins/dag-inspector-utils.test.ts", |
| 109 | + ], |
| 110 | + thresholds: [ |
| 111 | + { file: "src/feature-plugins/system/dag-inspector-utils.ts", lines: 98, functions: 95 }, |
| 112 | + { file: "src/feature-plugins/system/dag-inspector.tsx", lines: 90, functions: 88 }, |
| 113 | + ], |
| 114 | + }) |
| 115 | + } finally { |
| 116 | + await rm(output, { recursive: true, force: true }) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +function field(lines: readonly string[], key: string) { |
| 121 | + const prefix = `${key}:` |
| 122 | + const line = lines.find((item) => item.startsWith(prefix)) |
| 123 | + return line?.slice(prefix.length) |
| 124 | +} |
| 125 | + |
| 126 | +function percentage(value: { found: number; hit: number }) { |
| 127 | + if (value.found === 0) return 100 |
| 128 | + return (value.hit / value.found) * 100 |
| 129 | +} |
| 130 | + |
| 131 | +async function runCoverageSuite(input: { |
| 132 | + cwd: string |
| 133 | + output: string |
| 134 | + tests: string[] |
| 135 | + thresholds: CoverageThreshold[] |
| 136 | +}) { |
| 137 | + await run( |
| 138 | + [ |
| 139 | + "bun", |
| 140 | + "test", |
| 141 | + ...input.tests, |
| 142 | + "--only-failures", |
| 143 | + "--timeout=30000", |
| 144 | + "--coverage", |
| 145 | + "--coverage-reporter=lcov", |
| 146 | + `--coverage-dir=${input.output}`, |
| 147 | + ], |
| 148 | + input.cwd, |
| 149 | + ) |
| 150 | + assertCoverage(parseLcov(await Bun.file(path.join(input.output, "lcov.info")).text()), input.thresholds) |
| 151 | +} |
| 152 | + |
| 153 | +async function run(command: string[], cwd: string) { |
| 154 | + const child = Bun.spawn(command, { cwd, stdout: "inherit", stderr: "inherit" }) |
| 155 | + const exitCode = await child.exited |
| 156 | + if (exitCode !== 0) throw new Error(`${command.join(" ")} failed with exit code ${exitCode}`) |
| 157 | +} |
| 158 | + |
| 159 | +function yieldMessage(message: string) { |
| 160 | + console.log(`\n[DAG core gate] ${message}`) |
| 161 | +} |
| 162 | + |
| 163 | +if (import.meta.main) { |
| 164 | + await runDagCoreCoverageGate() |
| 165 | + console.log("\n[DAG core gate] all critical behavior and coverage floors passed") |
| 166 | +} |
0 commit comments