Skip to content

Commit 994322d

Browse files
committed
test(dag): enforce core behavior coverage
1 parent d482e4b commit 994322d

7 files changed

Lines changed: 329 additions & 7 deletions

File tree

.github/workflows/ci-typecheck.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
# ============================================================================
22
# 🔍 CI · Typecheck
33
# ----------------------------------------------------------------------------
4-
# Purpose : TypeScript type checking across all packages (bun typecheck)
5-
# plus oxlint warning ratchet (bun run lint, --max-warnings gate)
4+
# Purpose : TypeScript type checking across all packages (bun typecheck),
5+
# oxlint warning ratchet, and the DAG core behavior/coverage gate
66
# Trigger : Push to `main`/`dev`, PRs targeting `main`/`dev`, manual dispatch
77
# Jobs : typecheck — single Linux runner, `bun run lint` + `bun typecheck`
8-
# Gate : Required status check on BOTH `dev` and `main` rulesets — it is
9-
# the fast gate for feat/fix → dev PRs (full test suite only gates
10-
# dev → main, see ci-test.yml). Lint lives inside this job so it
11-
# blocks merges without editing the rulesets' required checks.
8+
# Gate : Required status check on BOTH `dev` and `main` rulesets. The DAG
9+
# core gate protects state-machine and persistence changes before
10+
# they merge to dev; the full suite still gates dev → main.
1211
# Notes : No push trigger on feat/* or fix/* (frequent changes); PRs cover
1312
# them.
1413
# ============================================================================
@@ -42,3 +41,8 @@ jobs:
4241

4342
- name: Run typecheck
4443
run: bun typecheck
44+
45+
- name: Run DAG core behavior and coverage gate
46+
working-directory: packages/opencode
47+
run: bun run test:dag-core
48+
timeout-minutes: 10

docs/dag-core-tdd-ci-matrix.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# DAG core TDD / CI coverage matrix
2+
3+
Baseline: `origin/dev` at `d482e4bb6` (2026-08-09). This matrix treats public behavior as the unit of coverage. It does not use private-helper tests or a repository-wide percentage as a proxy for correctness.
4+
5+
## Confirmed seams
6+
7+
| Seam | Core invariant | Existing behavior tests | Current CI enforcement | Finding |
8+
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
9+
| Workflow tool / `Dag.Service` | Start, extend, replan and controls publish only legal durable transitions; invalid input has no side effects | `workflow-tool.test.ts`, `dag-create-validation.test.ts`, `dag-step-semantics.test.ts`, `dag-node-started-guard.test.ts`, `dag-dynamic-correctness.test.ts` | New DAG gate runs on PRs to `dev` and `main` | **Closed here:** one public-seam test now drives pause/resume/cancel/complete/step through `WorkflowTool.execute` and checks the durable event type. |
10+
| Durable store → replay → recovery | An acknowledged wake is atomic; replay rebuilds the same read model; a restart neither duplicates work nor leaves invented running ownership | `dag-store-wake.test.ts`, `dag-replay-idempotency.test.ts`, `dag-deadline-extended.test.ts`, `dag-recovery.test.ts`, `dag-loop-recovery-integration.test.ts`, `dag-orphan-pending-recovery.test.ts` | New DAG gate runs the behavior suites and enforces critical store/projector floors | Behavior is strong; the missing PR gate and coverage floor are closed here. Store-defect retry semantics remain intentionally undecided, so they are not a missing test for an accepted behavior yet. |
11+
| Runtime state machine | Wake batches are stable and retryable; spawn owns at most one attempt; abort settles live children; lock timeout releases; escalation is delivered before adjudication; every terminal path clears pending escalation | `dag-wake-integration.test.ts`, `spawn-completion.test.ts`, `dag-loop-guards.test.ts`, `dag-workflow-lock.test.ts`, `dag-timeout-escalation*.test.ts`, `dag-escalation-clear-flag.test.ts`, race regressions | New DAG gate runs the complete OpenCode `test/dag` directory | Core P0/P1 behavior is covered. The prior gap was enforcement and measured floors, not another broad behavior suite. |
12+
| Durable event / SDK / TUI projection | Event folds are replay-safe; generated SDK stays fresh; summary events replace the server-derived view and recover missed SSE updates by refetching | `dag-projector-drift.test.ts`, `dag-summary-publisher*.test.ts`, `sync-dag.test.tsx`, `dag-inspector*.test.ts`; SDK `check:generated` | New DAG gate runs Schema, SDK freshness and TUI contract suites on both PR targets | **Closed here:** every DAG durable definition is now checked at its versioned manifest key. The TUI behavior itself was already covered. |
13+
| CI quality gate | A DAG-critical regression must fail before merge to `dev`; critical public modules may not silently lose tested lines/functions | `bun turbo test` covers all package tests; Linux unit + Linux/Windows app E2E; typecheck/lint on `dev` and `main` PRs | `ci-typecheck.yml` now runs `bun run test:dag-core` on PRs to `dev` and `main` | **Closed here:** package-scoped behavior suites plus fixed per-module line/function floors. Bun 1.3.14 LCOV contains no branch records, so transition-matrix tests remain the branch-equivalent guard. |
14+
15+
## Measured baseline
16+
17+
| Surface | Tests | Relevant measured coverage |
18+
| -------------------------------------------------- | ----: | --------------------------------------------------------------------------------------------------------------- |
19+
| Core graph / scheduling / transition / store suite | 90 | scheduling 94.56% lines; transitions 96.00%; types 94.78%; store 78.34% in the Core-only slice |
20+
| OpenCode DAG public/runtime suite | 397 | `dag.ts` 99.55%; loop 93.73%; recovery 99.30%; spawn 94.58%; projector 100%; store 87.43%; workflow tool 90.80% |
21+
| TUI DAG projection / inspector suite | 50 | inspector utils 100%; inspector 91.69%; sync DAG reducer/bootstrap/reconnect has five named behavior tests |
22+
23+
The Core-only projector number (19.23%) is not a defect: projector behavior lives primarily in the OpenCode integration slice, where the same public projector reaches 100% lines. Coverage must therefore be evaluated per agreed suite, not by averaging unrelated package imports.
24+
25+
## Enforced gate policy
26+
27+
`packages/opencode/script/dag-core-coverage.ts` now:
28+
29+
1. run the Core, OpenCode, Schema and TUI DAG contract suites from their package directories;
30+
2. run generated SDK freshness validation;
31+
3. parse LCOV for explicitly named critical files;
32+
4. fail below conservative line/function floors with enough headroom for harmless refactors;
33+
5. keep fault-injection/retry policy out until its shutdown and backoff contract is designed.
34+
35+
The floors are fixed in source and intentionally sit below the measured baseline: Core critical modules range from 70–94% lines and 60–95% functions; OpenCode critical modules range from 85–98% lines and 70–95% functions; TUI critical modules range from 90–98% lines and 88–95% functions. A future change may raise them, but lowering them requires an explicit code review diff.
36+
37+
## Red → green evidence
38+
39+
1. Coverage evaluator: `bun test test/dag/dag-core-coverage-gate.test.ts` first failed with `Cannot find module '../../script/dag-core-coverage'`; after the minimal parser/assertion implementation it passed `1 pass, 0 fail`.
40+
2. Public control dispatch: with the cancel branch deliberately routed to pause, `bun test test/dag/workflow-tool.test.ts --test-name-pattern 'dispatches every public control operation'` failed with `Expected: "dag.workflow.cancelled"; Received: "dag.workflow.paused"`; after restoring the correct public behavior it passed `1 pass, 0 fail`.
41+
3. Durable manifest membership: with the DAG inventory deliberately omitted, `bun test test/event-manifest.test.ts --test-name-pattern 'registers every DAG durable event'` failed with `Received: undefined`; after restoring the inventory it passed `1 pass, 20 expect() calls, 0 fail`.
42+
4. CI failure proof: with only the loop line floor temporarily raised from 90% to 99%, `bun run test:dag-core` ran `399 pass, 0 fail` and still exited 1 with `loop.ts: lines 93.73% < 99.00%`. The committed floor is restored to 90%; this proves coverage loss fails the same command invoked by CI.
43+
5. Final gate: `bun run test:dag-core` passed Core `90`, OpenCode `399`, Schema `3`, and TUI `50` tests; SDK regeneration produced no diff and every critical-file floor passed.

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"scripts": {
99
"typecheck": "tsgo --noEmit",
1010
"test": "bun test --timeout 30000 --only-failures",
11+
"test:dag-core": "bun run script/dag-core-coverage.ts",
1112
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
1213
"test:httpapi:ci": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip --progress --trace",
1314
"bench:test": "bun run script/bench-test-suite.ts",
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from "bun:test"
2+
import { assertCoverage, parseLcov } from "../../script/dag-core-coverage"
3+
4+
describe("DAG core coverage gate", () => {
5+
it("rejects a critical public module below its line floor", () => {
6+
const report = parseLcov(`
7+
SF:src/dag/runtime/loop.ts
8+
FNF:10
9+
FNH:9
10+
LF:100
11+
LH:89
12+
end_of_record
13+
`)
14+
15+
expect(() => assertCoverage(report, [{ file: "src/dag/runtime/loop.ts", lines: 90, functions: 80 }])).toThrow(
16+
"src/dag/runtime/loop.ts: lines 89.00% < 90.00%",
17+
)
18+
})
19+
})

0 commit comments

Comments
 (0)