diff --git a/agents/devloop-critic.md b/agents/devloop-critic.md index 50e110d..5b5be92 100644 --- a/agents/devloop-critic.md +++ b/agents/devloop-critic.md @@ -20,9 +20,20 @@ Du bist die adversariale Linse (Säule 3). Dein Auftrag ist **nicht** zu bestät ## Output: strukturiertes Verdikt -Liefere ein maschinenlesbares Verdikt, z.B.: +Liefere ein maschinenlesbares JSON-Objekt. Jeder Befund hat **alle** Pflichtfelder (REQ-KEY-2): ```json -{ "verdict": "rejected" | "accepted", - "findings": [ { "req": "REQ-AUTH-1", "severity": "high", "claim": "...", "evidence": "datei:zeile" } ] } +{ "findings": [ { + "req": "REQ-AUTH-1", + "klasse": "req-uncovered", + "datei": "src/auth.ts", + "severity": "blocker", + "claim": "...", + "evidence": "src/auth.ts:42" +} ] } ``` -Im Zweifel **rejected** mit konkretem Befund. Ein leeres „sieht gut aus" ist kein Verdikt. +- `req`: betroffene REQ-ID oder `-`. +- `klasse` (Enum): `req-uncovered` | `happy-path-only` | `spec-deviation` | `convention` | `security` | `other`. +- `datei`: eine Datei (Pfad). `req` + `klasse` + `datei` bilden die Befund-Identität über Runden — kein Zeilennummer-/Freitext-Bestandteil; die Zeile gehört nach `evidence`. +- `severity`: `blocker` | `major` | `nit`. + +Ein Selbst-Label (`verdict`) ist **unerheblich**: Der Kern ignoriert es und **leitet** das Verdikt aus dem Ledger ab (`accepted` genau dann, wenn kein blocker/major offen ist). Fehlt ein Pflichtfeld, ist ein Enum verletzt oder ist die Ausgabe kein JSON, gilt der Lauf als `not-run` — nie als „keine Befunde". Ein leeres „sieht gut aus" ist kein Verdikt; ohne Befunde gib ausdrücklich `{ "findings": [] }` aus. diff --git a/agents/devloop-implement.md b/agents/devloop-implement.md index 530fb4d..e6a7961 100644 --- a/agents/devloop-implement.md +++ b/agents/devloop-implement.md @@ -23,7 +23,7 @@ Du läufst in der **Sandbox** auf dem Dev-Rechner: Lichter aus (`--dangerously-s nie auf `main` und nie im geteilten Arbeitsbaum — so laufen parallele Sessions kollisionsfrei. Gemergte Branches/Worktrees räumt `/devloop:cleanup` später sicher auf. 2. Implementiere minimal gegen die Tests/Spec. **Der volle Gate-Satz läuft hier lokal mit** (`vitest` · Stryker · Semgrep · Traceability) — nicht nur `tsc`/`biome`. Iteriere, bis lokal grün. Dieser lokale Lauf ist **advisorisch, nicht autoritativ** (er ist korrumpierbar) — er kauft *Tempo*, nicht *Vertrauen*. -3. Erst wenn lokal grün: öffne einen **PR** auf den Feature-Branch (die Capability-Grenze; nie Push auf main). Das **Verdikt von Rang** kommt vom **geschützten Runner** (CI = Gate of Record), nicht von deinem lokalen Lauf. +3. Erst wenn lokal grün: öffne einen **PR** auf den Feature-Branch (die Capability-Grenze; nie Push auf main) — als **Draft** (`gh pr create --draft`, REQ-BIND-1). Erst der Driver setzt ihn nach akzeptiertem Critic auf „ready"; ein Nicht-Draft-PR würde bei T0/T1 den Auto-Merge vor dem Critic armieren. Das **Verdikt von Rang** kommt vom **geschützten Runner** (CI = Gate of Record), nicht von deinem lokalen Lauf. ## Spec-Änderung (Amend-Modus) @@ -31,4 +31,4 @@ Liegt eine Spec-Änderung vor (der Spec-PR mit geänderten/neuen `.skip`'ten Tes ## Rückkante (äußere Schleife — CI) -CI re-runt die Gates autoritativ. Kommt ein rotes Verdikt als **Defektsignal** über den schmalen Rückkanal zurück (`gh pr checks`, `gh run view --log-failed`: Datei:Zeile:Regel / überlebende Mutante), behebe den **Defekt** — nicht das Signal. Den **geschützten Satz** dabei nie anfassen (CI-Protected-Set-Ratchet greift sonst). Stagniert es, übernimmt der Driver (frischer Kontext / Eskalation). +CI re-runt die Gates autoritativ. Kommt ein rotes Verdikt als **Defektsignal** über den schmalen Rückkanal zurück (`gh pr checks`, `gh run view --log-failed`: Datei:Zeile:Regel / überlebende Mutante), behebe den **Defekt** — nicht das Signal. Den **geschützten Satz** dabei nie anfassen (CI-Protected-Set-Ratchet greift sonst). Kommen **Critic-Befunde** (Schlüssel `review:::`) zurück, melde in deinem Ergebnis **je adressiertem Befund** den Key **und** `datei:zeile` der Korrektur (REQ-IMPL-1) — der Driver führt sie als `fixed-claimed` (unverifiziert); nichts zu melden heißt nichts adressiert. Stagniert es, übernimmt der Driver (frischer Kontext / Eskalation). diff --git a/dist/cli/critic-verdict.js b/dist/cli/critic-verdict.js new file mode 100644 index 0000000..717490f --- /dev/null +++ b/dist/cli/critic-verdict.js @@ -0,0 +1,27 @@ +// CLI: raw critic output(s) -> validated status, ledger round, DERIVED verdict (REQ-CRITIC-1/2/5). +// stdin: { rawOutputs: string[], ledger: Ledger, addressed: string[] }. Any `status` / `verdict` +// in the input is ignored — status comes from schema validation of rawOutputs, the verdict from +// the ledger. +import { mergeCriticRuns, parseCriticOutput } from "../core/critic.js"; +import { applyRound, deriveVerdict } from "../core/ledger.js"; +import { readStdin } from "./_stdin.js"; +let input = {}; +try { + input = JSON.parse(await readStdin()); +} +catch { + /* unparseable input -> not-run below */ +} +const raws = Array.isArray(input.rawOutputs) ? input.rawOutputs : []; +const runs = raws.map((r) => parseCriticOutput(typeof r === "string" ? r : "")); +const merged = mergeCriticRuns(runs); +if (merged.status !== "completed") { + process.stdout.write(JSON.stringify({ status: "not-run" }) + "\n"); +} +else { + const addressed = Array.isArray(input.addressed) + ? input.addressed.filter((k) => typeof k === "string") + : []; + const round = applyRound(input.ledger ?? {}, { findings: merged.findings, addressed }); + process.stdout.write(JSON.stringify({ status: "completed", verdict: deriveVerdict(round.ledger), round }) + "\n"); +} diff --git a/dist/cli/gate-round.js b/dist/cli/gate-round.js new file mode 100644 index 0000000..c66859b --- /dev/null +++ b/dist/cli/gate-round.js @@ -0,0 +1,24 @@ +// CLI: structured gate findings -> ledger round (REQ-GATE-1..3). +// stdin: { raws, ran, ledger, addressed }. Invalid input -> message on stderr, exit 2, nothing +// on stdout. Only these four fields are read; judged/progress/oscillation come from the ledger. +import { applyGateRound, parseGateRoundInput } from "../core/gate-round.js"; +import { readStdin } from "./_stdin.js"; +function fail(msg) { + process.stderr.write(`gate-round: ${msg}\n`); + process.exit(2); +} +let parsed; +try { + parsed = JSON.parse(await readStdin()); +} +catch { + fail("stdin is not valid JSON"); +} +const input = parseGateRoundInput(parsed); +if (!input) + fail("invalid input: need raws[], ran[], addressed[] with known gate kinds and required fields"); +const ledger = parsed.ledger; +if (typeof ledger !== "object" || ledger === null || Array.isArray(ledger)) + fail("invalid input: ledger must be an object"); +const round = applyGateRound(ledger, input); +process.stdout.write(JSON.stringify({ round }) + "\n"); diff --git a/dist/cli/pr-state.js b/dist/cli/pr-state.js index 088b22a..e75a538 100644 --- a/dist/cli/pr-state.js +++ b/dist/cli/pr-state.js @@ -9,8 +9,12 @@ if (!pr) { process.stderr.write("usage: pr-state \n"); process.exit(64); } -const raw = execFileSync("gh", ["pr", "view", pr, "--json", "headRefName,state,reviewDecision,statusCheckRollup"], { cwd: repo, encoding: "utf8" }); +const raw = execFileSync("gh", ["pr", "view", pr, "--json", "headRefName,state,reviewDecision,statusCheckRollup,isDraft"], { cwd: repo, encoding: "utf8" }); const v = JSON.parse(raw); +if (typeof v.isDraft !== "boolean") { + process.stderr.write("pr-state: gh returned no boolean isDraft; refusing to guess (fail-closed)\n"); + process.exit(1); +} const reviewMap = { APPROVED: "approved", CHANGES_REQUESTED: "changes-requested", @@ -20,5 +24,6 @@ const facts = { merged: v.state === "MERGED", reviewDecision: reviewMap[v.reviewDecision ?? ""] ?? "none", checks: rollupStatus(v.statusCheckRollup ?? []), + draft: v.isDraft, }; process.stdout.write(JSON.stringify({ pr: Number(pr), facts, reconstructed: reconstructState(facts) }, null, 2) + "\n"); diff --git a/dist/core/critic.js b/dist/core/critic.js new file mode 100644 index 0000000..6eb88d5 --- /dev/null +++ b/dist/core/critic.js @@ -0,0 +1,93 @@ +// Critic output parsing + merging (REQ-KEY-2, REQ-CRITIC-2/3). The status comes ONLY from +// validating the raw output against the schema — never from a field the caller (or the critic) +// supplies. Anything that does not validate is "not-run", never "completed without findings". +import { CRITIC_KLASSEN, criticFindingKey } from "./finding-key.js"; +const NOT_RUN = { status: "not-run", findings: [] }; +const SEVERITIES = ["blocker", "major", "nit"]; +const RANK = { nit: 0, major: 1, blocker: 2 }; +// End index (exclusive) of the balanced JSON object starting at `start`, string-aware; -1 if none. +function objectEnd(s, start) { + let depth = 0; + let inStr = false; + for (let i = start; i < s.length; i++) { + const c = s[i]; + if (inStr) { + if (c === "\\") + i++; + else if (c === '"') + inStr = false; + } + else if (c === '"') + inStr = true; + else if (c === "{") + depth++; + else if (c === "}" && --depth === 0) + return i + 1; + } + return -1; +} +// First JSON object in the text that has a `findings` field (text/fences around it are fine). +function findVerdictObject(raw) { + let i = raw.indexOf("{"); + while (i !== -1) { + const end = objectEnd(raw, i); + if (end !== -1) { + try { + const v = JSON.parse(raw.slice(i, end)); + if (v && typeof v === "object" && !Array.isArray(v)) { + if ("findings" in v) + return v; + i = raw.indexOf("{", end); + continue; + } + } + catch { + /* not JSON -> try the next brace */ + } + } + i = raw.indexOf("{", i + 1); + } + return undefined; +} +function toFinding(x) { + if (!x || typeof x !== "object") + return undefined; + const f = x; + const { req, klasse, datei, severity, claim, evidence } = f; + if (typeof req !== "string" || req === "") + return undefined; + if (typeof datei !== "string" || datei === "") + return undefined; + if (typeof claim !== "string" || typeof evidence !== "string") + return undefined; + if (typeof klasse !== "string" || !CRITIC_KLASSEN.includes(klasse)) + return undefined; + if (typeof severity !== "string" || !SEVERITIES.includes(severity)) + return undefined; + return { key: criticFindingKey({ req, klasse, datei }), source: "critic", severity: severity }; +} +export function parseCriticOutput(raw) { + const obj = findVerdictObject(raw); + if (!obj || !Array.isArray(obj.findings)) + return NOT_RUN; + const findings = []; + for (const x of obj.findings) { + const f = toFinding(x); + if (!f) + return NOT_RUN; + findings.push(f); + } + return { status: "completed", findings }; +} +export function mergeCriticRuns(runs) { + if (runs.length === 0 || runs.some((r) => r.status !== "completed")) + return NOT_RUN; + const byKey = new Map(); + for (const r of runs) + for (const f of r.findings) { + const cur = byKey.get(f.key); + if (!cur || RANK[f.severity] > RANK[cur.severity]) + byKey.set(f.key, f); + } + return { status: "completed", findings: [...byKey.values()] }; +} diff --git a/dist/core/driver.js b/dist/core/driver.js index 6e42910..b64c321 100644 --- a/dist/core/driver.js +++ b/dist/core/driver.js @@ -16,6 +16,10 @@ // 5. The back-edge feedback is a defect SIGNAL, never a solution (type-enforced). // 6. T3's irreversible step stays human-gated (the auto-loop runs only on reversible // pre-stages; merge is the t3-merge stop). +// 7. (REQ-BIND-5) No path to merge-pending / DONE without a DERIVED "accepted" of a +// "completed" critic round: MARK_READY (draft -> ready, which arms auto-merge) only comes +// from critiqued + completed + accepted; merge-pending without ready === true goes back to +// the critic; a not-run critic is retried once, then ESCALATE (never read as "no findings"). import { nextLoopDecision } from "./loop.js"; function handleRedGate(state) { if (!state.loop || !state.loopParams) { @@ -46,6 +50,15 @@ function handleMerge(state) { : { kind: "STOP_FOR_HUMAN", stop: "t3-merge" }; } } +// A ready PR: a human "changes requested" is a defect signal -> re-implement (the loop feeds the +// review comments back), and the PR goes back to draft before the next push (REQ-BIND-3). +// Resumable across sessions: the decision lives on GitHub. Otherwise the tier-gated merge. +function handleReady(state) { + if (state.reviewDecision === "changes-requested") { + return { kind: "RE_GEN", feedback: "defect-signal", freshContext: false, markDraft: true }; + } + return handleMerge(state); +} export function nextAction(state) { // Invariant 1: without the guardians, the autonomous loop is refused outright. if (!state.guardians.ok) { @@ -87,13 +100,26 @@ export function nextAction(state) { return state.gateVerdict === "green" ? { kind: "SPAWN_STATION", station: "critic" } : handleRedGate(state); - case "merge-pending": - // A human "changes requested" on the impl PR is a defect signal -> re-implement (the loop - // feeds the review comments back). Resumable across sessions: the decision lives on GitHub. - if (state.reviewDecision === "changes-requested") { - return { kind: "RE_GEN", feedback: "defect-signal", freshContext: false }; + case "critiqued": { + const c = state.critic; + if (!c) + return { kind: "SPAWN_STATION", station: "critic" }; + if (c.status === "not-run") { + // Exactly one restart; a second not-run is an escalation, never "no findings". + return c.retried + ? { kind: "ESCALATE", reason: "critic-not-run" } + : { kind: "SPAWN_STATION", station: "critic" }; } - // Invariant 3 & 6: T3 merge is human-gated; T0/T1 auto-merge. - return handleMerge(state); + if (c.verdict === "rejected") + return handleRedGate(state); // back-edge via nextLoopDecision + // accepted: mark ready first (arms auto-merge), then behave like merge-pending. + return state.ready === true ? handleReady(state) : { kind: "MARK_READY" }; + } + case "merge-pending": + // Invariant 7: merge-pending is only legitimate for a ready PR (= accepted critic round). + // Anything else goes back to the critic. + if (state.ready !== true) + return { kind: "SPAWN_STATION", station: "critic" }; + return handleReady(state); } } diff --git a/dist/core/finding-key.js b/dist/core/finding-key.js new file mode 100644 index 0000000..ee5a314 --- /dev/null +++ b/dist/core/finding-key.js @@ -0,0 +1,31 @@ +// Finding identity (REQ-KEY-1/2). A key identifies a defect across rounds. It contains NO line +// number and NO free text — only stable coordinates (file / function / rule / REQ-ID). +export const CRITIC_KLASSEN = [ + "req-uncovered", + "happy-path-only", + "spec-deviation", + "convention", + "security", + "other", +]; +export function gateFindingKey(raw) { + switch (raw.kind) { + case "test": + return `test:${raw.file}:${raw.testName}`; + case "mutant": + return `mutant:${raw.file}:${raw.fn}:${raw.mutator}`; + case "semgrep": + return `semgrep:${raw.rule}:${raw.file}:${raw.fn}`; + case "trace": + return `trace:${raw.req}`; + } +} +// Deliberately file-granular (not function-granular): fresh critics localise the same defect +// differently. Extra fields (line, claim, evidence) are accepted and ignored. +export function criticFindingKey(c) { + return `review:${c.req}:${c.klasse}:${c.datei}`; +} +// Gate findings are deterministic and always blockers. +export function gateFinding(raw) { + return { key: gateFindingKey(raw), source: "gate", severity: "blocker" }; +} diff --git a/dist/core/gate-round.js b/dist/core/gate-round.js new file mode 100644 index 0000000..5916a7f --- /dev/null +++ b/dist/core/gate-round.js @@ -0,0 +1,61 @@ +// Gate rounds in the core (REQ-GATE-1..3). Pure: translates structured gate findings into keys, +// folds them into the ledger via applyRound, and leaves entries of gate kinds that did NOT run +// this round untouched (absence of a skipped job is no evidence, REQ-GATE-2). +import { gateFinding } from "./finding-key.js"; +import { applyRound } from "./ledger.js"; +const GATE_KINDS = ["test", "mutant", "semgrep", "trace"]; +const isKind = (x) => typeof x === "string" && GATE_KINDS.includes(x); +const nonEmpty = (x) => typeof x === "string" && x.length > 0; +const isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x); +function parseRaw(x) { + if (!isObj(x)) + return undefined; + switch (x.kind) { + case "test": + return nonEmpty(x.file) && nonEmpty(x.testName) ? { kind: "test", file: x.file, testName: x.testName } : undefined; + case "mutant": + return nonEmpty(x.file) && nonEmpty(x.fn) && nonEmpty(x.mutator) + ? { kind: "mutant", file: x.file, fn: x.fn, mutator: x.mutator } + : undefined; + case "semgrep": + return nonEmpty(x.rule) && nonEmpty(x.file) && nonEmpty(x.fn) + ? { kind: "semgrep", rule: x.rule, file: x.file, fn: x.fn } + : undefined; + case "trace": + return nonEmpty(x.req) ? { kind: "trace", req: x.req } : undefined; + default: + return undefined; + } +} +// REQ-GATE-3: strict; anything invalid -> undefined (caller aborts, never a silent empty round). +// Only raws/ran/addressed are read; any other field is dropped. +export function parseGateRoundInput(x) { + if (!isObj(x) || !Array.isArray(x.raws) || !Array.isArray(x.ran) || !Array.isArray(x.addressed)) + return undefined; + if (!x.ran.every(isKind) || !x.addressed.every((k) => typeof k === "string")) + return undefined; + const raws = []; + for (const r of x.raws) { + const p = parseRaw(r); + if (!p || !x.ran.includes(p.kind)) + return undefined; + raws.push(p); + } + return { raws, ran: [...x.ran], addressed: [...x.addressed] }; +} +const kindOf = (key) => key.slice(0, key.indexOf(":")); +export function applyGateRound(prev, input) { + const held = {}; + const active = {}; + for (const [k, e] of Object.entries(prev)) { + const kind = kindOf(k); + if (e.source === "gate" && isKind(kind) && !input.ran.includes(kind)) + held[k] = { ...e }; + else + active[k] = e; + } + const r = applyRound(active, { findings: input.raws.map(gateFinding), addressed: input.addressed }); + // A held open blocker/major was open at round start: the round is judged (REQ-LEDGER-11). + const heldOpen = Object.values(held).some((e) => e.severity !== "nit" && (e.state === "open" || e.state === "reopened")); + return { ...r, judged: r.judged || heldOpen, ledger: { ...r.ledger, ...held } }; +} diff --git a/dist/core/ledger.js b/dist/core/ledger.js new file mode 100644 index 0000000..dd3308f --- /dev/null +++ b/dist/core/ledger.js @@ -0,0 +1,75 @@ +// Finding ledger (REQ-LEDGER-1..11). Pure: applyRound folds one evaluated round into the ledger +// and reports the progress signals nextLoopDecision consumes. Counting findings is NOT progress — +// identity over rounds is (design docs/2026-11-13-befund-ledger-critic-verdikt-design.md). +const RANK = { nit: 0, major: 1, blocker: 2 }; +const higher = (a, b) => (RANK[a] >= RANK[b] ? a : b); +const weighty = (s) => s !== "nit"; // blocker | major +const isOpenish = (s) => s === "open" || s === "reopened"; +const isDone = (s) => s === "closed" || s === "fixed-claimed"; +export function applyRound(prev, round) { + const ledger = {}; + for (const [k, e] of Object.entries(prev)) + ledger[k] = { ...e }; + // Snapshot at round start (REQ-LEDGER-11: before `addressed` is applied). + const start = new Map(); + for (const e of Object.values(ledger)) + start.set(e.key, { severity: e.severity, state: e.state }); + const judged = [...start.values()].some((e) => weighty(e.severity) && isOpenish(e.state)); + // REQ-LEDGER-4: implement's claim moves critic entries to fixed-claimed (before the compare). + for (const key of round.addressed) { + const e = ledger[key]; + if (e && e.source === "critic" && isOpenish(e.state)) + e.state = "fixed-claimed"; + } + // Dedupe the reported findings per key (highest severity). + const reported = new Map(); + for (const f of round.findings) { + const cur = reported.get(f.key); + reported.set(f.key, cur ? { ...cur, severity: higher(cur.severity, f.severity) } : f); + } + const reopenedNow = new Set(); + for (const f of reported.values()) { + const e = ledger[f.key]; + if (!e) { + ledger[f.key] = { key: f.key, source: f.source, severity: f.severity, state: "open", reopenCount: 0 }; + continue; + } + e.severity = higher(e.severity, f.severity); + if (isDone(e.state)) { + e.state = "reopened"; // REQ-LEDGER-5 + e.reopenCount += 1; + reopenedNow.add(e.key); + } + } + // REQ-LEDGER-2/3: absence is evidence for deterministic gate findings only. + for (const e of Object.values(ledger)) { + if (!reported.has(e.key) && e.source === "gate" && isOpenish(e.state)) + e.state = "closed"; + } + const reopenedWeighty = [...reopenedNow].some((k) => weighty(ledger[k].severity)); + const resolvedWeighty = [...start.entries()].some(([k, s]) => weighty(s.severity) && isOpenish(s.state) && isDone(ledger[k].state)); + const progress = judged && resolvedWeighty && !reopenedWeighty; + const oscillation = [...reopenedNow].some((k) => weighty(ledger[k].severity) && ledger[k].reopenCount >= 2); + return { ledger, judged, progress, oscillation }; +} +// REQ-CRITIC-5: the verdict is DERIVED from the ledger; a critic's self-label is never read. +export function deriveVerdict(ledger) { + return Object.values(ledger).some((e) => weighty(e.severity) && isOpenish(e.state)) + ? "rejected" + : "accepted"; +} +// REQ-LEDGER-10: ledger as a markdown table; open blocker/major on top, fixed-claimed explicitly +// labelled as unverified. +export function renderLedgerTable(ledger) { + const label = (s) => (s === "fixed-claimed" ? "behoben (unverifiziert)" : s); + const top = (e) => weighty(e.severity) && isOpenish(e.state); + const rows = Object.values(ledger).sort((a, b) => Number(top(b)) - Number(top(a)) || + RANK[b.severity] - RANK[a.severity] || + a.key.localeCompare(b.key)); + const lines = [ + "| Befund | Quelle | Schweregrad | Status | Wiederöffnet |", + "|---|---|---|---|---|", + ...rows.map((e) => `| ${e.key} | ${e.source} | ${e.severity} | ${label(e.state)} | ${e.reopenCount} |`), + ]; + return lines.join("\n") + "\n"; +} diff --git a/dist/core/loop.js b/dist/core/loop.js index 97b21db..36380f4 100644 --- a/dist/core/loop.js +++ b/dist/core/loop.js @@ -1,14 +1,6 @@ // Back-edge termination + escalation (design §5#2 / §10 duty 2). Pure decision over the // loop state. The thresholds (maxIter, progress criterion) are INJECTED parameters, never // defaulted in code — they are pilot measurements (§1.1/§12), not a design choice here. -function isStagnating(errorCounts, requireStrictlyDecreasing) { - if (errorCounts.length < 2) - return false; // no history yet -> can't judge - const last = errorCounts[errorCounts.length - 1]; - const prev = errorCounts[errorCounts.length - 2]; - const improved = requireStrictlyDecreasing ? last < prev : last <= prev; - return !improved; -} export function nextLoopDecision(state, params) { // Priority 1: tampering with the guardians is an alarm, not progress. if (state.gateChangedNotCode) @@ -16,8 +8,12 @@ export function nextLoopDecision(state, params) { // Priority 2: hard iteration ceiling. if (state.iteration >= params.maxIter) return "ESCALATE_MAX_ITER"; - // Priority 3: stagnation -> one fresh context to break in-session lock-in, then stop. - if (isStagnating(state.errorCounts, params.requireStrictlyDecreasing)) { + // Priority 3: oscillation (a fix reverted twice) -> stop; a fresh context would not help. + if (state.lastRound?.oscillation) + return "ESCALATE_OSCILLATION"; + // Priority 4: stagnation (only a judged round can stagnate) -> one fresh context to break + // in-session lock-in, then stop. + if (state.lastRound?.judged && !state.lastRound.progress) { return state.freshContextUsed ? "ESCALATE_STAGNATION" : "FRESH_CONTEXT_RETRY"; } // Otherwise: feed the defect signal back and re-generate. diff --git a/dist/core/resume.js b/dist/core/resume.js index 24ee84e..fcd2f8f 100644 --- a/dist/core/resume.js +++ b/dist/core/resume.js @@ -33,6 +33,7 @@ export function reconstructState(facts) { // Implementation PR. if (facts.merged) { out.phase = "merge-pending"; + out.ready = true; out.done = true; // feature already merged — nothing to do return out; } @@ -45,9 +46,17 @@ export function reconstructState(facts) { out.phase = "implemented"; // gates still running — observe / await CI return out; } - // checks green -> at the merge gate; the review decision drives what happens next. + if (facts.draft === true) { + // Green but still draft: the critic has not accepted yet (REQ-BIND-4) -> back to the critic, + // never merge-pending, whatever the review decision says. + out.phase = "gated"; + out.gateVerdict = "green"; + return out; + } + // checks green, ready for review -> at the merge gate; the review decision drives what's next. out.phase = "merge-pending"; out.gateVerdict = "green"; + out.ready = true; if (facts.reviewDecision === "approved") { out.humanApprovals["merge-review"] = true; out.humanApprovals["t3-merge"] = true; // GitHub approval satisfies whichever the tier needs diff --git a/docs/2026-11-13-befund-ledger-critic-verdikt-design.md b/docs/2026-11-13-befund-ledger-critic-verdikt-design.md index de97668..9d87686 100644 --- a/docs/2026-11-13-befund-ledger-critic-verdikt-design.md +++ b/docs/2026-11-13-befund-ledger-critic-verdikt-design.md @@ -5,7 +5,7 @@ > Anlass: Vergleich mit dem Elephant-Goldfish Model (google/elephant-goldfish-model, > `skills/egm-review-loop`, `skills/egm-resolve`), dessen Konvergenzregeln Befunde über Runden > verfolgen statt sie zu zählen und einen ausgefallenen Reviewer nie als „keine Befunde" werten. -> Status: **Entwurf v2 (nach einer frischen Critic-Runde), wartet auf Spec-Abnahme.** +> Status: **abgenommen** (v2, 2026-11-13); Nachtrag REQ-LEDGER-11 abgenommen 2026-11-14; Nachtrag REQ-GATE-1..3 abgenommen 2026-11-14. ## Ist-Zustand (origin/main, b7e7115) @@ -112,6 +112,30 @@ reopened verhält sich wie open (kann wieder closed / fixed-claimed werden) - **REQ-LEDGER-10** — Wenn der Driver `STOP_FOR_HUMAN` oder `ESCALATE` liefert, soll der Orchestrator den Ledger als Tabelle in den PR schreiben: offene blocker/major zuoberst, `fixed-claimed` ausdrücklich als „behoben (unverifiziert)". +- **REQ-LEDGER-11** — Wenn zu Rundenbeginn kein Befund mit Schweregrad blocker/major im Zustand + open oder reopened ist, soll das System die Runde weder als Fortschritt noch als Stagnation + werten (`nextLoopDecision` prüft dann keine Stagnation). Nachtrag: ohne diese REQ würde die + erste bewertete Runde als Stagnation gelten und den einzigen Fresh-Retry verbrennen. + +### Gate-Runden im Kern (`src/core/gate-round.ts`, `src/cli/gate-round.ts`) — Nachtrag 2026-11-14 + +Anlass: Ohne diesen Nachtrag hat eine Runde mit rotem Gate keinen Weg in den Ledger; der +Orchestrator müsste `lastRound` selbst bestimmen, und der Fehlfall „falsche Stagnation" am Gate +bliebe im echten Lauf bestehen. + +- **REQ-GATE-1** — Wenn der Orchestrator eine Runde mit rotem Gate meldet, soll das System die + strukturierten Gate-Befunde (`GateRaw`: test / mutant / semgrep / trace) per REQ-KEY-1 in Keys + übersetzen, mit `applyRound` in den Ledger schreiben und `judged/progress/oscillation` + zurückgeben. Die Übersetzung aus den CI-Logs in `GateRaw` bleibt beim Orchestrator (siehe + Bekannte Grenzen). +- **REQ-GATE-2** — Die Eingabe soll die in dieser Runde tatsächlich gelaufenen Gate-Arten + (`ran`) enthalten. Wenn eine Gate-Art nicht gelaufen ist, soll das System ihre Befunde im + Ledger unverändert lassen (REQ-LEDGER-2 gilt nur für gelaufene Arten). Begründung: Viele + Pipelines überspringen Mutation/Semgrep, wenn Tests rot sind; „nicht gelaufen" wäre sonst + „geschlossen" ⇒ falscher Fortschritt, beim nächsten Lauf `reopened` ⇒ falsche Oszillation. +- **REQ-GATE-3** — Wenn die Eingabe nicht valide ist (unbekannte Art, Pflichtfeld fehlt, `ran` + fehlt), soll das System nichts fortschreiben und mit Exit ≠ 0 abbrechen — nie still eine leere + Runde werten. ### Critic-Verdikt im Kern (`src/core/critic.ts`, `src/cli/critic-verdict.ts`, `driver.ts`) @@ -171,6 +195,9 @@ reopened verhält sich wie open (kann wieder closed / fixed-claimed werden) derselbe Key sein) - (j) Schweregrad = letzter statt höchster gesehener - (k) `reconstructState` ignoriert `draft` + - (l) erste Runde (nichts offen zu Rundenbeginn) wird als Stagnation gewertet + - (m) Gate-Befund einer nicht gelaufenen Art wird geschlossen (REQ-GATE-2) + - (n) ungültige Gate-Eingabe wird als leere Runde gewertet statt abgelehnt (REQ-GATE-3) 3. **Key-Stabilität (Pilot, kein Gate):** im nächsten echten Lauf denselben Implement-Stand von zwei frischen Critics prüfen lassen; Anteil gleicher Review-Keys, typisch und Maximum der Abweichung dokumentieren. Unter 50 % Übereinstimmung ⇒ Klassenliste nachschärfen (eigene @@ -181,6 +208,10 @@ reopened verhält sich wie open (kann wieder closed / fixed-claimed werden) ## Bekannte Grenzen +- **Gate-Übersetzung durch den Orchestrator.** REQ-KEY-1 ist für Gate-Ausgaben nur teilweise + erfüllt: Der Kern bildet Keys deterministisch, liest aber keine rohen vitest-/Stryker-/Semgrep- + Reports. Der Orchestrator könnte beim Übersetzen Befunde weglassen oder `ran` falsch angeben — + gleiche Vertrauensstufe wie beim Critic-Output. Report-Parser sind ein eigener Schritt. - **Orchestrator-Vertrauen bleibt.** REQ-CRITIC-1 hebt die Hürde (der Kern validiert selbst, `status` ist kein Eingabefeld), aber ein Orchestrator könnte gültiges JSON ohne Befunde fabrizieren, und er setzt `gh pr ready`. Echte Abhilfe wäre der Critic als CI-Job mit diff --git a/docs/2026-11-14-ledger-mutationsmatrix.md b/docs/2026-11-14-ledger-mutationsmatrix.md new file mode 100644 index 0000000..b6872e3 --- /dev/null +++ b/docs/2026-11-14-ledger-mutationsmatrix.md @@ -0,0 +1,83 @@ +# Befund-Ledger: Mutationsmatrix und Vorher-Nachweis + +Zu `docs/2026-11-13-befund-ledger-critic-verdikt-design.md` (Abnahme 2). Je Pflicht-Probe (a)-(l): +die Mutation und die Tests, die dabei rot werden müssen. Testtitel tragen das Tag `(Probe x)`. +Die Wirkung wurde gegen eine **Wegwerf-Referenzimplementierung** (nicht Teil dieses Branches) +nachgemessen: Baseline alle neuen Tests grün, jede Mutation unten färbt mindestens einen der +genannten Tests rot (Anzahl roter Tests in Klammern). + +| Probe | Mutation | Datei :: Testname (muss rot werden) | +|---|---|---| +| (a) Fortschritt per Anzahl | `progress` aus Befundzahl statt Ledger | `test/core/ledger.test.ts` :: "[REQ-LEDGER-6] (Probe a) Tabellenfall 'falsche Stagnation' ..." (1 -> 2 Befunde muss RE_GEN sein); "... Tabellenfall 'falscher Fortschritt' ..." (2 -> 1 muss FRESH_CONTEXT_RETRY sein); "... Anzahl gleich, aber echter Fortschritt ..."; `test/core/loop.test.ts` :: "[REQ-LEDGER-8] (Probe a) ein veraltetes errorCounts-Feld ..." (beide Richtungen) (gemessen: 6 rote Tests im Ledger/Tabellenfall-Bereich; die `errorCounts`-Tests in `loop.test.ts` töten nur die Loop-Variante dieser Probe, nicht die Ledger-Variante) | +| (b) not-run als completed | `status` bei leer/ungültig/not-run `completed` mit `findings: []` | `test/core/critic.test.ts` :: "[REQ-CRITIC-2] (Probe b) leere Ausgabe ...", "... kein JSON ...", "... JSON ohne findings-Feld ..."; "[REQ-CRITIC-3] (Probe b) eine not-run Instanz ...", "... leeres Array von Läufen ..."; `test/cli/critic-verdict.test.ts` :: "[REQ-CRITIC-2] (Probe b) leere rawOutputs + Eingabefeld status 'completed' ..."; `test/core/driver.test.ts` :: "[REQ-CRITIC-4] (Probe b) not-run führt nie zu MARK_READY oder DONE" (gemessen 5 bzw. 5) | +| (c) Selbst-Label statt abgeleitetem Verdikt | Verdikt aus `verdict` der Critic-Ausgabe | `test/cli/critic-verdict.test.ts` :: "[REQ-CRITIC-5] (Probe c) Selbst-Label 'accepted' mit offenem blocker -> ... rejected" (Label accepted + offener blocker); "[REQ-CRITIC-1] Eingabefeld 'verdict' wird nicht beachtet"; `test/core/ledger.test.ts` :: "[REQ-CRITIC-5] (Probe c) ein einziger offener blocker ..."; `test/core/critic.test.ts` :: "[REQ-CRITIC-5] (Probe c) das Selbst-Label ... wird ignoriert" (gemessen 5) | +| (d) Abwesenheit eines Critic-Befunds = closed | Critic-Einträge, die fehlen, werden closed | `test/core/ledger.test.ts` :: "[REQ-LEDGER-3] (Probe d) fehlender Critic-Befund bleibt unverändert ...", "... frischer Critic zieht ... Verdikt auf rejected"; "[REQ-LEDGER-6] (Probe a) Tabellenfall 'falscher Fortschritt'"; `test/cli/critic-verdict.test.ts` :: "[REQ-LEDGER-3] (Probe d) frischer Critic zieht den offenen Befund nicht ..." (gemessen 6) | +| (e) "kein reopened"-Klausel gestrichen | `progress` ignoriert in der Runde wiedergeöffnete blocker/major | `test/core/ledger.test.ts` :: "[REQ-LEDGER-6] (Probe e) A adressiert, B taucht nach fixed-claimed wieder auf -> kein Fortschritt" (R1 A,B major; R2 addressed [A,B], Critic zieht B; A fixed-claimed, B reopened; Folge: FRESH_CONTEXT_RETRY); "... ein in der Runde wiedergeöffneter major verhindert Fortschritt ..." (gemessen 2) | +| (f) Nits zählen für Fortschritt | Nit-Fix gilt als Fortschritt / Nit-Reopen blockiert Fortschritt | `test/core/ledger.test.ts` :: "[REQ-LEDGER-6] (Probe f) kein Fortschritt, wenn nur ein Nit adressiert wird und der blocker offen bleibt"; "[REQ-LEDGER-9] (Probe f) ein wiedergeöffnetes Nit blockiert den Fortschritt nicht"; `test/core/ledger.test.ts` :: "[REQ-LEDGER-9] Nit mit reopenCount 2 löst keine oscillation aus" (gemessen je 1) | +| (g) REQ-LEDGER-7 nach der Stagnationsprüfung | Oszillation hinter Stagnation geprüft | `test/core/loop.test.ts` :: "[REQ-LEDGER-7] (Probe g) Oszillation eskaliert VOR der Stagnationsprüfung (kein Fresh-Retry)" (lastRound judged, !progress, oscillation, freshContextUsed false -> ESCALATE_OSCILLATION statt FRESH_CONTEXT_RETRY) (gemessen 1) | +| (h) Critic-Neustart zweimal | `retried: true` führt erneut zu `SPAWN_STATION critic` | `test/core/driver.test.ts` :: "[REQ-CRITIC-4] (Probe h) critiqued + not-run NACH dem Neustart -> ESCALATE critic-not-run ..." (gemessen 1) | +| (i) Key enthält Zeilennummer | Zeile/`evidence`/`claim` fließt in den Key | `test/core/finding-key.test.ts` :: "[REQ-KEY-1] (Probe i) Gate-Key ist zeilenunabhängig ... 3 Zeilen verschoben => gleicher Key", "... Critic-Key ist zeilen- und textunabhängig"; `test/core/critic.test.ts` :: "[REQ-CRITIC-3] (Probe i) derselbe Defekt ... verschieden lokalisiert ... ein Befund", "[REQ-KEY-2] Zeile in evidence und Wortlaut von claim beeinflussen den Key nicht"; `test/cli/critic-verdict.test.ts` :: "[REQ-KEY-1] (Probe i) derselbe Befund um 3 Zeilen verschoben trifft denselben Ledger-Eintrag" (gemessen: Gate-Variante 2, Critic-Variante 11); neu: `test/core/critic.test.ts` :: "[REQ-KEY-1] (Probe i) Zusatzfeld line: 10 vs 13 ... gleicher Key" | +| (j) Schweregrad = letzter statt höchster | `severity` wird überschrieben | `test/core/ledger.test.ts` :: "[REQ-LEDGER-1] (Probe j) Schweregrad ist der höchste je gesehene ...", "... herabgestufter Befund bleibt für das Verdikt blocker" (gemessen 2); neu: `test/core/ledger.test.ts` :: "[REQ-LEDGER-1] (Probe j) derselbe Critic-Key zweimal in einer Runde, blocker dann nit => severity blocker" | +| (k) `reconstructState` ignoriert `draft` | Draft-Zweig entfernt | `test/core/resume.test.ts` :: "[REQ-BIND-4] (Probe k) impl PR, green, DRAFT -> gated/green ...", "[REQ-BIND-5] (Probe k) Draft + grün ist für KEINE Review-Entscheidung merge-pending und nie ready", "[REQ-BIND-4] Draft-PR aus resume speist den Driver in den Critic ..." (gemessen 3); neu: `test/cli/pr-state.test.ts` :: "[REQ-BIND-4] pr-state: Draft-PR, alle Checks grün -> facts.draft true, phase gated" (tötet `draft: false` fest verdrahtet im CLI), "... nicht Draft ... ready true", "... isDraft fehlt -> Exit != 0", "... isDraft als String ... Exit != 0"; `test/core/resume.test.ts` :: "[REQ-BIND-4] PrFacts.draft ist Pflichtfeld (Typ-Wächter via npm run typecheck)" (nur im Typecheck rot) | +| (l) erste Runde wird als Stagnation gewertet | `judged` immer true bzw. `nextLoopDecision` ignoriert `judged` | `test/core/ledger.test.ts` :: "[REQ-LEDGER-11] (Probe l) erste Runde auf leerem Ledger ist nicht bewertet ...", "... Ende-zu-Ende: erste bewertete Runde verbrennt den Fresh-Retry nicht"; `test/core/loop.test.ts` :: "[REQ-LEDGER-11] (Probe l) nicht bewertete Runde ... -> RE_GEN" (beide) (gemessen 5 bzw. 3) | + +Konstruktionen der Spec (Draft-Folgen, Label-accepted, 3-Zeilen-Verschiebung, R1/R2-Folge für (e)) sind +1:1 übernommen. Bei (a) ist "Anzahl" als Mutation nicht eindeutig im Ledger darstellbar; gemessen wurde +`progress := Anzahl Befunde der Runde < Anzahl offener blocker/major zu Beginn`. + +## Weitere Killer aus der unabhängigen Messung (Nachtrag) + +- `test/cli/pr-state.test.ts` (4 Tests, `gh`-Stub im PATH, gegen dist): REQ-BIND-4 am echten CLI-Pfad. +- `test/core/critic.test.ts`: Escapes im `claim` (`say "x" }`), `{` im Text vor dem JSON, `req: ""` => not-run, Zusatzfeld `line` ohne Wirkung auf den Key. +- `test/core/ledger.test.ts`: gleicher Critic-Key zweimal in einer Runde (blocker, nit) => blocker. + +## Unabhängige Messung 2026-11-14 + +12/12 Pflichtproben getötet. 38 eigene Mutationen der unabhängigen Messung, 10 überlebten. Nach diesem +Nachtrag geschlossen: o37, o28, o29, o15, i5, j2. +**o18** (`if (c === "\\") i++;` -> `if (false) i++;` in `objectEnd`) wurde zunächst NICHT getötet: der erste Escape-Test hat zwei escapte Quotes im `claim`, dann stimmt die Klammerbilanz ohne Escape-Behandlung zufällig (336/336 grün, Mutation äquivalent für diese Eingabe). Erst mit Nachtrag 2 geschlossen: `test/core/critic.test.ts` :: "[REQ-CRITIC-2] ungerade Zahl escapter Quotes im claim ('5\" Rohr }') ..." (gemessen: mit Mutation 1 rot, ohne Mutation 31/31 grün). Der Test "escapter Backslash direkt vor dem schließenden Quote" tötet o18 nicht, sondern eine naive Variante `s[i-1] !== "\\"` (gemessen rot). + Verbleibend, als äquivalent bewertet: +- **o06**: `higher()` am Eintrag und ein zweites Vorkommen desselben Keys sieht bereits `reopened`; das Ergebnis ist gleich. +- **o10**: Die Severity steigt nur bei Meldung in der Runde; der Eintrag wird dann nie `closed`/`fixed-claimed`, die Abweichung ist nicht beobachtbar. +- **o24**: typgleich, kein beobachtbarer Unterschied. + +## Nachtrag REQ-GATE (2026-11-14) + +Gemessen im Wegwerf-Worktree auf 53171fc, je Mutation `npm run build` + Gate-Round-/Ledger-Tests, +Kontrolle unmutiert 28/28 grün. + +| Probe | Mutation | rot | +|---|---|---| +| (m) | `applyGateRound` hält nicht gelaufene Arten nicht zurück (alle aktiv) | 6 | +| (m) Zusatz | `judged` ohne gehaltene offene Einträge | 1 | +| (n) | `raw.kind` nicht in `ran` wird akzeptiert | 3 | +| (n) | ungültige Eingabe ⇒ leere Runde statt `undefined` | 27 | +| (n) CLI | Exit 2 ⇒ Exit 0 | 19 | +| (n) CLI | Ledger-Prüfung entfernt | 3 | + +Alle getötet. + +## Weitere Abdeckung (keine Pflichtprobe) + +- REQ-LEDGER-10: `renderLedgerTable` (Reihenfolge, "behoben (unverifiziert)") rein testbar; das Schreiben + in den PR durch den Orchestrator (Skill-Prosa) ist nicht pure testbar und hier nicht abgedeckt. +- REQ-BIND-1 (`implement` öffnet Draft-PRs) und REQ-BIND-2 (`gh pr ready`) sind Skill-Prosa. Im Kern getestet + ist nur `MARK_READY` (Driver) und `markDraft` (REQ-BIND-3); ein Test auf Skill-Text wäre ein Quelltext-Wächter + und wurde bewusst nicht geschrieben. + +## Vorher-Nachweis + +`npx tsx test/evidence/before-counter.ts` läuft gegen die **gepinnte Fixture** `test/evidence/loop-before.ts` +(= `src/core/loop.ts` aus Commit 1a74327, byte-gleich bis auf zwei vorangestellte Kommentarzeilen mit +`// @ts-nocheck`), unabhängig vom aktuellen `src/`. Adapter `errorCounts = Anzahl blocker/major je Runde`, +`maxIter: 5, requireStrictlyDecreasing: true`: + +``` +Falsche Stagnation (Gate: A rot -> A behoben, B und C laufen an) + errorCounts=[1,2] heutige Entscheidung=FRESH_CONTEXT_RETRY (Wahrheit: Fortschritt => RE_GEN) +Falscher Fortschritt (Critic: A,B -> implement behebt nichts, frischer Critic zieht nur A) + errorCounts=[2,1] heutige Entscheidung=RE_GEN (Wahrheit: Stagnation => FRESH_CONTEXT_RETRY) +``` + +Beide Entscheidungen sind falsch, kein Absturz. Die Fixture wird von vitest nicht eingesammelt (kein `.test.`) +und stört den Typecheck nicht (`@ts-nocheck`). diff --git a/skills/critic/SKILL.md b/skills/critic/SKILL.md index 72220ef..1e83d83 100644 --- a/skills/critic/SKILL.md +++ b/skills/critic/SKILL.md @@ -7,4 +7,4 @@ description: Adversarialer Review mit frischem Kontext, geprimt auf Widerlegung; Standalone-Form. Im orchestrierten Lauf nutze **`/devloop:loop`**. -Folge `agents/devloop-critic.md`: widerlege, dass die Arbeit fertig ist — nicht abgedeckte `REQ-`-IDs, Happy-Path-only, semantische Drift, überlebende Mutanten. Nur lesen. Bei T3 Security/Threat-Lens **und** Proposal-Prüfung (§6.6b). Output: strukturiertes Verdikt, im Zweifel `rejected` mit konkretem Befund. +Folge `agents/devloop-critic.md`: widerlege, dass die Arbeit fertig ist — nicht abgedeckte `REQ-`-IDs, Happy-Path-only, semantische Drift, überlebende Mutanten. Nur lesen. Bei T3 Security/Threat-Lens **und** Proposal-Prüfung (§6.6b). Output: JSON mit `findings` nach dem Schema in `agents/devloop-critic.md` (je Befund `req`, `klasse`, `datei`, `severity`, `claim`, `evidence`); das Verdikt wird aus dem Ledger abgeleitet, ein Selbst-Label zählt nicht. diff --git a/skills/implement/SKILL.md b/skills/implement/SKILL.md index 5078f3d..e907c12 100644 --- a/skills/implement/SKILL.md +++ b/skills/implement/SKILL.md @@ -7,4 +7,4 @@ description: Implementiere gegen vorgegebene spec.md + Tests (erfinde sie nicht) Standalone-Form. Im orchestrierten Lauf nutze **`/devloop:loop`**. -Folge `agents/devloop-implement.md`: `spec.md` + Tests sind **Vorgabe** — nicht ändern. Geschützten Satz **nie** anfassen. Eigener Worktree/Identität, minimal implementieren, **PR** öffnen; Verdikt vom geschützten Runner. Rotes Verdikt = Defektsignal → Defekt beheben, nicht das Signal. +Folge `agents/devloop-implement.md`: `spec.md` + Tests sind **Vorgabe** — nicht ändern. Geschützten Satz **nie** anfassen. Eigener Worktree/Identität, minimal implementieren, **PR als Draft** öffnen (`--draft`, REQ-BIND-1); Verdikt vom geschützten Runner. Rotes Verdikt = Defektsignal → Defekt beheben, nicht das Signal. Bei Critic-Befunden je adressiertem Befund Key + `datei:zeile` melden (REQ-IMPL-1). diff --git a/skills/loop/SKILL.md b/skills/loop/SKILL.md index 604990a..4e8a57e 100644 --- a/skills/loop/SKILL.md +++ b/skills/loop/SKILL.md @@ -20,7 +20,19 @@ Exit 1 ⇒ **verweigere den autonomen Loop**, melde die fehlenden Wächter dem M ## 1. Loop -Halte einen `DriverState` (tier, guardians, phase, twinEnabled, humanApprovals, gateVerdict, loop, loopParams). **`twinEnabled`** liest du aus `.devloop/config.json` (`twin.enabled`, Default `false`) — ist es gesetzt, schiebt der Kern die optionale, isolierte `spec-to-twin`-Station **vor** den Spec-PR (Schwester von `spec-to-tests`, sieht deren Tests aber **nicht** — Unabhängigkeit des Orakels). `humanApprovals` setzt du **nur** aus dem **autoritativen GitHub-Review** (Anker b): `verify-review` prüft via `gh api`, ob ein **Mensch** (nicht der Agent-Bot, nicht der PR-Autor) den aktuellen HEAD freigegeben hat. Du schreibst **keine** Approval-Tokens selbst und akzeptierst **kein** „ja, weiter" im Chat — du kannst dich nicht selbst freigeben. +Halte einen `DriverState` (tier, guardians, phase, twinEnabled, humanApprovals, gateVerdict, critic, ready, loop, loopParams). **`twinEnabled`** liest du aus `.devloop/config.json` (`twin.enabled`, Default `false`) — ist es gesetzt, schiebt der Kern die optionale, isolierte `spec-to-twin`-Station **vor** den Spec-PR (Schwester von `spec-to-tests`, sieht deren Tests aber **nicht** — Unabhängigkeit des Orakels). `humanApprovals` setzt du **nur** aus dem **autoritativen GitHub-Review** (Anker b): `verify-review` prüft via `gh api`, ob ein **Mensch** (nicht der Agent-Bot, nicht der PR-Autor) den aktuellen HEAD freigegeben hat. Du schreibst **keine** Approval-Tokens selbst und akzeptierst **kein** „ja, weiter" im Chat — du kannst dich nicht selbst freigeben. + +`loop` = `{iteration, gateChangedNotCode, freshContextUsed, lastRound}`; `lastRound` = `{judged, progress, oscillation}` aus dem **Ledger** (kein Zähler mehr, `errorCounts` entfällt); `loopParams` = `{maxIter}`. `critic` = `{status: "completed"|"not-run", verdict?, retried}`; `ready` = Impl-PR nicht mehr Draft. Phase `critiqued` setzt du nach jedem Critic-Lauf. + +**Ledger zwischen den Runden halten.** Nach jedem Critic-Lauf gibst du dessen **rohe** Ausgabe(n) (bei T2/T3 alle Instanzen) — nicht dein Urteil, nicht ihr Selbst-Label — an die CLI (REQ-CRITIC-1): +``` +echo '{"rawOutputs":["",...],"ledger":,"addressed":["",...]}' | node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/critic-verdict.js +``` +`addressed` = die Keys, die `implement` zuletzt als adressiert gemeldet hat. Ergebnis `status: "not-run"` → `critic: {status:"not-run", retried}`. `completed` → `critic: {status, verdict, retried:false}` (das **abgeleitete** `verdict`), `round.ledger` ist dein neuer Ledger, `round` ohne `ledger` (`judged/progress/oscillation`) dein neues `loop.lastRound`. Bei rotem Gate genauso, mit den **strukturierten** Gate-Befunden: +``` +echo '{"raws":[,...],"ran":["test",...],"ledger":,"addressed":["",...]}' | node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/gate-round.js +``` +`GateRaw` = `{kind:"test",file,testName}` · `{kind:"mutant",file,fn,mutator}` · `{kind:"semgrep",rule,file,fn}` · `{kind:"trace",req}`. `ran` = **nur** die Gate-Arten, deren CI-Job in dieser Runde tatsächlich gelaufen ist (übersprungene Jobs **nicht** eintragen — sonst gälten ihre Befunde als geschlossen). Exit 2 = Eingabe falsch: nichts fortschreiben, Eingabe korrigieren statt raten. Sonst `round.ledger` = neuer Ledger, `round` ohne `ledger` = neues `loop.lastRound`. Der Ledger lebt nur in dieser Session (siehe Resume). Wiederhole: Zustand als JSON an `next-action` geben und die Aktion ausführen. ``` @@ -34,9 +46,10 @@ echo '' | node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/next-action.js | `OPEN_SPEC_PR` | `spec.md` + die **geskippten** Tests als **eigenen Spec-PR** auf Branch **`devloop/spec/`** (Bot-Identität) gegen `main` öffnen. Das ist der reviewbare HEAD für den Spec-Review-Stopp (Anker b). Hält `main` grün (Trace zählt Skips, Vitest rötet nicht). Der Branch-Prefix `devloop/spec/` sagt `verify-unskip`, dass hier `spec-to-tests` Tests autoren darf (Implement-PR läuft auf `devloop/`). | | `MERGE_SPEC_PR` | Spec-PR ist gereviewt+grün → mergen, dann `git pull` auf `main`. Damit liegt die Spec auf `main`; `implement` baut darauf auf (Artefakt-Handoff via main, keine Hand-Brücke). | | `RUN_GATES` | Gates auf dem **geschützten Runner** (CI = Gate of Record) triggern; **Verdikt nur von dort** (§5#1), inkl. `devloop-precondition-check`, Protected-Set-Ratchet und **server-berechnetem Tier aus dem Diff** (nicht agent-deklariert, §9). Fehler-Logs über den schmalen Rückkanal lesen (`gh pr checks`, `gh run view --log-failed`). | -| `STOP_FOR_HUMAN` | **Turn beenden.** Übergib Kontext an den Menschen und warte. Der Stopp gilt erst als passiert, wenn ein **Mensch per GitHub-PR-Review** (CODEOWNERS) den aktuellen Stand freigibt — verifiziert durch `verify-review` auf CI (Anker b). T3-Merge ist zusätzlich durch Branch-Protection erzwungen. **Nicht** du gibst frei. | -| `RE_GEN` | Defektsignal (Datei:Zeile:Regel / überlebende Mutante) in eine neue `implement`-Runde geben — als **Signal, nicht Lösung**. Bei `freshContext:true` neue isolierte Instanz. | -| `ESCALATE` | Sauberer Stopp + Kontext-Übergabe an den benannten Owner. | +| `STOP_FOR_HUMAN` | **Turn beenden.** Übergib Kontext an den Menschen und warte. Bei `STOP_FOR_HUMAN` und `ESCALATE` schreibst du vorher den Ledger als Tabelle in den PR (`renderLedgerTable`, REQ-LEDGER-10; offene blocker/major oben, `fixed-claimed` als „behoben (unverifiziert)") per `gh pr comment`. Der Stopp gilt erst als passiert, wenn ein **Mensch per GitHub-PR-Review** (CODEOWNERS) den aktuellen Stand freigibt — verifiziert durch `verify-review` auf CI (Anker b). T3-Merge ist zusätzlich durch Branch-Protection erzwungen. **Nicht** du gibst frei. | +| `MARK_READY` | Critic hat (abgeleitet) akzeptiert → `gh pr ready ` (REQ-BIND-2); armiert bei T0/T1 den Auto-Merge. Danach `ready: true` setzen. Impl-PRs sind bis hierhin **Draft**. | +| `RE_GEN` | Defektsignal (Datei:Zeile:Regel / überlebende Mutante / offene Critic-Befunde) in eine neue `implement`-Runde geben — als **Signal, nicht Lösung**; `implement` meldet je adressiertem Befund Key + `datei:zeile`. Bei `freshContext:true` neue isolierte Instanz. Bei `markDraft:true` **vor** dem nächsten Push `gh pr ready --undo` (REQ-BIND-3). | +| `ESCALATE` | Sauberer Stopp + Kontext-Übergabe an den benannten Owner. Gründe u.a. `ESCALATE_OSCILLATION`, `ESCALATE_STAGNATION`, `critic-not-run` (Critic zweimal ohne gültige Ausgabe). | | `DONE` | Fertig (T0/T1 Auto-Merge bei grün; T2/T3 erst nach Mensch-Stopp). | ## Zwei Schleifen (§10 „Wo der Loop läuft") diff --git a/skills/resume/SKILL.md b/skills/resume/SKILL.md index e80f89f..9547453 100644 --- a/skills/resume/SKILL.md +++ b/skills/resume/SKILL.md @@ -17,18 +17,24 @@ Branch), nicht im Agenten-Kontext; diese Skill rekonstruiert ihn daraus. 1. **Wächter-Vorbedingung:** `node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/check-guardians.js `. 2. **Phase rekonstruieren:** `node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/pr-state.js 42` → liest via - `gh` Head-Branch (→ spec/impl), State (offen/gemergt), `reviewDecision`, CI-Status und mappt sie - auf `{phase, humanApprovals, reviewDecision, gateVerdict, done}`. + `gh` Head-Branch (→ spec/impl), State (offen/gemergt), `reviewDecision`, CI-Status und `isDraft` und + mappt sie auf `{phase, humanApprovals, reviewDecision, gateVerdict, ready, done}`. Ein grüner + **Draft**-Impl-PR kommt als `gated`/`green` zurück → der Critic läuft (nie direkt `merge-pending`). - `done: true` → der PR ist schon gemergt, nichts zu tun. 3. **Tier** ableiten (`derive-tier --repo`) und mit dem rekonstruierten Teil zum `DriverState` mergen. 4. **Weiterlaufen:** `DriverState` an `next-action` geben und die Aktion ausführen — exakt wie im normalen Loop (`/devloop:loop`). Du fährst also mittendrin fort, ohne von vorn zu beginnen. +**Bekannte Grenze:** Der **Ledger** und `loop`/`iteration` werden über Resume **nicht** persistiert — +nach Resume beginnt der Ledger leer (Persistenz ist laut Spec ausdrücklich außerhalb). Der +Critic-*Nachweis* bleibt dagegen erhalten (Draft-Status auf dem PR). + ## „Changes requested" (Reject) = Rückkante Hat der Mensch Änderungen angefordert, liefert `next-action`: - **Impl-PR** (`devloop/`) → `RE_GEN`: lies die Review-Kommentare als **Defektsignal** - (`gh pr view 42 --json reviews` / `gh api repos///pulls/42/comments`), gib sie in eine + (`gh pr view 42 --json reviews` / `gh api repos///pulls/42/comments`), setze den PR vor + dem Push zurück auf Draft (`markDraft` → `gh pr ready 42 --undo`), gib sie in eine neue `implement`-Runde **auf demselben Branch/PR**, push → CI re-run → Review erneut anfordern. - **Spec-PR** (`devloop/spec/`) → `SPAWN_STATION specify`: die Spec gemäß Review amenden (Rückschleife), dann `spec-to-tests` → Spec-PR aktualisieren → erneut Review. diff --git a/src/cli/critic-verdict.ts b/src/cli/critic-verdict.ts new file mode 100644 index 0000000..26d1a7f --- /dev/null +++ b/src/cli/critic-verdict.ts @@ -0,0 +1,31 @@ +// CLI: raw critic output(s) -> validated status, ledger round, DERIVED verdict (REQ-CRITIC-1/2/5). +// stdin: { rawOutputs: string[], ledger: Ledger, addressed: string[] }. Any `status` / `verdict` +// in the input is ignored — status comes from schema validation of rawOutputs, the verdict from +// the ledger. + +import { mergeCriticRuns, parseCriticOutput } from "../core/critic.js"; +import { applyRound, deriveVerdict, type Ledger } from "../core/ledger.js"; +import { readStdin } from "./_stdin.js"; + +let input: { rawOutputs?: unknown; ledger?: Ledger; addressed?: unknown } = {}; +try { + input = JSON.parse(await readStdin()); +} catch { + /* unparseable input -> not-run below */ +} + +const raws = Array.isArray(input.rawOutputs) ? input.rawOutputs : []; +const runs = raws.map((r) => parseCriticOutput(typeof r === "string" ? r : "")); +const merged = mergeCriticRuns(runs); + +if (merged.status !== "completed") { + process.stdout.write(JSON.stringify({ status: "not-run" }) + "\n"); +} else { + const addressed = Array.isArray(input.addressed) + ? input.addressed.filter((k): k is string => typeof k === "string") + : []; + const round = applyRound(input.ledger ?? {}, { findings: merged.findings, addressed }); + process.stdout.write( + JSON.stringify({ status: "completed", verdict: deriveVerdict(round.ledger), round }) + "\n", + ); +} diff --git a/src/cli/gate-round.ts b/src/cli/gate-round.ts new file mode 100644 index 0000000..dc19d61 --- /dev/null +++ b/src/cli/gate-round.ts @@ -0,0 +1,27 @@ +// CLI: structured gate findings -> ledger round (REQ-GATE-1..3). +// stdin: { raws, ran, ledger, addressed }. Invalid input -> message on stderr, exit 2, nothing +// on stdout. Only these four fields are read; judged/progress/oscillation come from the ledger. + +import { applyGateRound, parseGateRoundInput } from "../core/gate-round.js"; +import type { Ledger } from "../core/ledger.js"; +import { readStdin } from "./_stdin.js"; + +function fail(msg: string): never { + process.stderr.write(`gate-round: ${msg}\n`); + process.exit(2); +} + +let parsed: unknown; +try { + parsed = JSON.parse(await readStdin()); +} catch { + fail("stdin is not valid JSON"); +} + +const input = parseGateRoundInput(parsed); +if (!input) fail("invalid input: need raws[], ran[], addressed[] with known gate kinds and required fields"); +const ledger = (parsed as { ledger?: unknown }).ledger; +if (typeof ledger !== "object" || ledger === null || Array.isArray(ledger)) fail("invalid input: ledger must be an object"); + +const round = applyGateRound(ledger as Ledger, input); +process.stdout.write(JSON.stringify({ round }) + "\n"); diff --git a/src/cli/pr-state.ts b/src/cli/pr-state.ts index bbb6eb0..b4bebc2 100644 --- a/src/cli/pr-state.ts +++ b/src/cli/pr-state.ts @@ -14,16 +14,22 @@ if (!pr) { const raw = execFileSync( "gh", - ["pr", "view", pr, "--json", "headRefName,state,reviewDecision,statusCheckRollup"], + ["pr", "view", pr, "--json", "headRefName,state,reviewDecision,statusCheckRollup,isDraft"], { cwd: repo, encoding: "utf8" }, ); const v = JSON.parse(raw) as { headRefName: string; state: string; reviewDecision: string | null; + isDraft?: unknown; statusCheckRollup?: { status?: string; conclusion?: string }[]; }; +if (typeof v.isDraft !== "boolean") { + process.stderr.write("pr-state: gh returned no boolean isDraft; refusing to guess (fail-closed)\n"); + process.exit(1); +} + const reviewMap: Record = { APPROVED: "approved", CHANGES_REQUESTED: "changes-requested", @@ -33,6 +39,7 @@ const facts: PrFacts = { merged: v.state === "MERGED", reviewDecision: reviewMap[v.reviewDecision ?? ""] ?? "none", checks: rollupStatus(v.statusCheckRollup ?? []), + draft: v.isDraft, }; process.stdout.write( diff --git a/src/core/critic.ts b/src/core/critic.ts new file mode 100644 index 0000000..8e57dd4 --- /dev/null +++ b/src/core/critic.ts @@ -0,0 +1,87 @@ +// Critic output parsing + merging (REQ-KEY-2, REQ-CRITIC-2/3). The status comes ONLY from +// validating the raw output against the schema — never from a field the caller (or the critic) +// supplies. Anything that does not validate is "not-run", never "completed without findings". + +import { CRITIC_KLASSEN, criticFindingKey, type Finding, type Severity } from "./finding-key.js"; + +export interface CriticRun { + status: "completed" | "not-run"; + findings: Finding[]; +} + +const NOT_RUN: CriticRun = { status: "not-run", findings: [] }; +const SEVERITIES: readonly string[] = ["blocker", "major", "nit"]; +const RANK: Record = { nit: 0, major: 1, blocker: 2 }; + +// End index (exclusive) of the balanced JSON object starting at `start`, string-aware; -1 if none. +function objectEnd(s: string, start: number): number { + let depth = 0; + let inStr = false; + for (let i = start; i < s.length; i++) { + const c = s[i]; + if (inStr) { + if (c === "\\") i++; + else if (c === '"') inStr = false; + } else if (c === '"') inStr = true; + else if (c === "{") depth++; + else if (c === "}" && --depth === 0) return i + 1; + } + return -1; +} + +// First JSON object in the text that has a `findings` field (text/fences around it are fine). +function findVerdictObject(raw: string): Record | undefined { + let i = raw.indexOf("{"); + while (i !== -1) { + const end = objectEnd(raw, i); + if (end !== -1) { + try { + const v: unknown = JSON.parse(raw.slice(i, end)); + if (v && typeof v === "object" && !Array.isArray(v)) { + if ("findings" in v) return v as Record; + i = raw.indexOf("{", end); + continue; + } + } catch { + /* not JSON -> try the next brace */ + } + } + i = raw.indexOf("{", i + 1); + } + return undefined; +} + +function toFinding(x: unknown): Finding | undefined { + if (!x || typeof x !== "object") return undefined; + const f = x as Record; + const { req, klasse, datei, severity, claim, evidence } = f; + if (typeof req !== "string" || req === "") return undefined; + if (typeof datei !== "string" || datei === "") return undefined; + if (typeof claim !== "string" || typeof evidence !== "string") return undefined; + if (typeof klasse !== "string" || !(CRITIC_KLASSEN as readonly string[]).includes(klasse)) return undefined; + if (typeof severity !== "string" || !SEVERITIES.includes(severity)) return undefined; + return { key: criticFindingKey({ req, klasse, datei }), source: "critic", severity: severity as Severity }; +} + +export function parseCriticOutput(raw: string): CriticRun { + const obj = findVerdictObject(raw); + if (!obj || !Array.isArray(obj.findings)) return NOT_RUN; + const findings: Finding[] = []; + for (const x of obj.findings) { + const f = toFinding(x); + if (!f) return NOT_RUN; + findings.push(f); + } + return { status: "completed", findings }; +} + +export function mergeCriticRuns(runs: CriticRun[]): CriticRun { + if (runs.length === 0 || runs.some((r) => r.status !== "completed")) return NOT_RUN; + const byKey = new Map(); + for (const r of runs) + for (const f of r.findings) { + const cur = byKey.get(f.key); + if (!cur || RANK[f.severity] > RANK[cur.severity]) byKey.set(f.key, f); + } + return { status: "completed", findings: [...byKey.values()] }; +} diff --git a/src/core/driver.ts b/src/core/driver.ts index d0cd26b..c4518d0 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -16,6 +16,10 @@ // 5. The back-edge feedback is a defect SIGNAL, never a solution (type-enforced). // 6. T3's irreversible step stays human-gated (the auto-loop runs only on reversible // pre-stages; merge is the t3-merge stop). +// 7. (REQ-BIND-5) No path to merge-pending / DONE without a DERIVED "accepted" of a +// "completed" critic round: MARK_READY (draft -> ready, which arms auto-merge) only comes +// from critiqued + completed + accepted; merge-pending without ready === true goes back to +// the critic; a not-run critic is retried once, then ESCALATE (never read as "no findings"). import type { Tier } from "./tier.js"; import type { Stop } from "./types.js"; @@ -32,6 +36,7 @@ export type Phase = | "spec-merged" | "implemented" | "gated" + | "critiqued" | "merge-pending"; export type Action = @@ -41,7 +46,8 @@ export type Action = | { kind: "MERGE_SPEC_PR" } // spec approved -> merge it to main; implement builds on main | { kind: "RUN_GATES" } | { kind: "STOP_FOR_HUMAN"; stop: Stop } - | { kind: "RE_GEN"; feedback: "defect-signal"; freshContext: boolean } + | { kind: "MARK_READY" } // critic accepted -> `gh pr ready` (arms the T0/T1 auto-merge) + | { kind: "RE_GEN"; feedback: "defect-signal"; freshContext: boolean; markDraft?: boolean } | { kind: "ESCALATE"; reason: string } | { kind: "DONE" }; @@ -56,6 +62,12 @@ export interface DriverState { // The human's PR review decision (from GitHub). "changes-requested" is a defect signal -> rework. reviewDecision?: "approved" | "changes-requested"; gateVerdict?: "green" | "red"; + // Result of the last critic round, produced by the critic-verdict CLI (never by the critic's + // own label). `retried`: the one allowed restart of a not-run critic was already spent. + critic?: + | { status: "completed"; verdict: "accepted" | "rejected"; retried: boolean } + | { status: "not-run"; retried: boolean }; + ready?: boolean; // the impl PR is marked ready for review (not draft) loop?: LoopState; loopParams?: LoopParams; } @@ -91,6 +103,16 @@ function handleMerge(state: DriverState): Action { } } +// A ready PR: a human "changes requested" is a defect signal -> re-implement (the loop feeds the +// review comments back), and the PR goes back to draft before the next push (REQ-BIND-3). +// Resumable across sessions: the decision lives on GitHub. Otherwise the tier-gated merge. +function handleReady(state: DriverState): Action { + if (state.reviewDecision === "changes-requested") { + return { kind: "RE_GEN", feedback: "defect-signal", freshContext: false, markDraft: true }; + } + return handleMerge(state); +} + export function nextAction(state: DriverState): Action { // Invariant 1: without the guardians, the autonomous loop is refused outright. if (!state.guardians.ok) { @@ -141,13 +163,24 @@ export function nextAction(state: DriverState): Action { ? { kind: "SPAWN_STATION", station: "critic" } : handleRedGate(state); - case "merge-pending": - // A human "changes requested" on the impl PR is a defect signal -> re-implement (the loop - // feeds the review comments back). Resumable across sessions: the decision lives on GitHub. - if (state.reviewDecision === "changes-requested") { - return { kind: "RE_GEN", feedback: "defect-signal", freshContext: false }; + case "critiqued": { + const c = state.critic; + if (!c) return { kind: "SPAWN_STATION", station: "critic" }; + if (c.status === "not-run") { + // Exactly one restart; a second not-run is an escalation, never "no findings". + return c.retried + ? { kind: "ESCALATE", reason: "critic-not-run" } + : { kind: "SPAWN_STATION", station: "critic" }; } - // Invariant 3 & 6: T3 merge is human-gated; T0/T1 auto-merge. - return handleMerge(state); + if (c.verdict === "rejected") return handleRedGate(state); // back-edge via nextLoopDecision + // accepted: mark ready first (arms auto-merge), then behave like merge-pending. + return state.ready === true ? handleReady(state) : { kind: "MARK_READY" }; + } + + case "merge-pending": + // Invariant 7: merge-pending is only legitimate for a ready PR (= accepted critic round). + // Anything else goes back to the critic. + if (state.ready !== true) return { kind: "SPAWN_STATION", station: "critic" }; + return handleReady(state); } } diff --git a/src/core/finding-key.ts b/src/core/finding-key.ts new file mode 100644 index 0000000..512879e --- /dev/null +++ b/src/core/finding-key.ts @@ -0,0 +1,51 @@ +// Finding identity (REQ-KEY-1/2). A key identifies a defect across rounds. It contains NO line +// number and NO free text — only stable coordinates (file / function / rule / REQ-ID). + +export type Severity = "blocker" | "major" | "nit"; +export type Source = "gate" | "critic"; + +export interface Finding { + key: string; + source: Source; + severity: Severity; +} + +export type GateRaw = + | { kind: "test"; file: string; testName: string; line?: number } + | { kind: "mutant"; file: string; fn: string; mutator: string; line?: number } + | { kind: "semgrep"; rule: string; file: string; fn: string; line?: number } + | { kind: "trace"; req: string }; + +export const CRITIC_KLASSEN = [ + "req-uncovered", + "happy-path-only", + "spec-deviation", + "convention", + "security", + "other", +] as const; +export type Klasse = (typeof CRITIC_KLASSEN)[number]; + +export function gateFindingKey(raw: GateRaw): string { + switch (raw.kind) { + case "test": + return `test:${raw.file}:${raw.testName}`; + case "mutant": + return `mutant:${raw.file}:${raw.fn}:${raw.mutator}`; + case "semgrep": + return `semgrep:${raw.rule}:${raw.file}:${raw.fn}`; + case "trace": + return `trace:${raw.req}`; + } +} + +// Deliberately file-granular (not function-granular): fresh critics localise the same defect +// differently. Extra fields (line, claim, evidence) are accepted and ignored. +export function criticFindingKey(c: { req: string; klasse: string; datei: string }): string { + return `review:${c.req}:${c.klasse}:${c.datei}`; +} + +// Gate findings are deterministic and always blockers. +export function gateFinding(raw: GateRaw): Finding { + return { key: gateFindingKey(raw), source: "gate", severity: "blocker" }; +} diff --git a/src/core/gate-round.ts b/src/core/gate-round.ts new file mode 100644 index 0000000..5eb6017 --- /dev/null +++ b/src/core/gate-round.ts @@ -0,0 +1,71 @@ +// Gate rounds in the core (REQ-GATE-1..3). Pure: translates structured gate findings into keys, +// folds them into the ledger via applyRound, and leaves entries of gate kinds that did NOT run +// this round untouched (absence of a skipped job is no evidence, REQ-GATE-2). + +import { gateFinding, type GateRaw } from "./finding-key.js"; +import { applyRound, type Ledger, type RoundResult } from "./ledger.js"; + +export type GateKind = GateRaw["kind"]; +const GATE_KINDS: readonly GateKind[] = ["test", "mutant", "semgrep", "trace"]; + +export interface GateRoundInput { + raws: GateRaw[]; + ran: GateKind[]; + addressed: string[]; +} + +const isKind = (x: unknown): x is GateKind => typeof x === "string" && (GATE_KINDS as readonly string[]).includes(x); +const nonEmpty = (x: unknown): x is string => typeof x === "string" && x.length > 0; +const isObj = (x: unknown): x is Record => typeof x === "object" && x !== null && !Array.isArray(x); + +function parseRaw(x: unknown): GateRaw | undefined { + if (!isObj(x)) return undefined; + switch (x.kind) { + case "test": + return nonEmpty(x.file) && nonEmpty(x.testName) ? { kind: "test", file: x.file, testName: x.testName } : undefined; + case "mutant": + return nonEmpty(x.file) && nonEmpty(x.fn) && nonEmpty(x.mutator) + ? { kind: "mutant", file: x.file, fn: x.fn, mutator: x.mutator } + : undefined; + case "semgrep": + return nonEmpty(x.rule) && nonEmpty(x.file) && nonEmpty(x.fn) + ? { kind: "semgrep", rule: x.rule, file: x.file, fn: x.fn } + : undefined; + case "trace": + return nonEmpty(x.req) ? { kind: "trace", req: x.req } : undefined; + default: + return undefined; + } +} + +// REQ-GATE-3: strict; anything invalid -> undefined (caller aborts, never a silent empty round). +// Only raws/ran/addressed are read; any other field is dropped. +export function parseGateRoundInput(x: unknown): GateRoundInput | undefined { + if (!isObj(x) || !Array.isArray(x.raws) || !Array.isArray(x.ran) || !Array.isArray(x.addressed)) return undefined; + if (!x.ran.every(isKind) || !x.addressed.every((k) => typeof k === "string")) return undefined; + const raws: GateRaw[] = []; + for (const r of x.raws) { + const p = parseRaw(r); + if (!p || !x.ran.includes(p.kind)) return undefined; + raws.push(p); + } + return { raws, ran: [...x.ran] as GateKind[], addressed: [...(x.addressed as string[])] }; +} + +const kindOf = (key: string): string => key.slice(0, key.indexOf(":")); + +export function applyGateRound(prev: Ledger, input: GateRoundInput): RoundResult { + const held: Ledger = {}; + const active: Ledger = {}; + for (const [k, e] of Object.entries(prev)) { + const kind = kindOf(k); + if (e.source === "gate" && isKind(kind) && !input.ran.includes(kind)) held[k] = { ...e }; + else active[k] = e; + } + const r = applyRound(active, { findings: input.raws.map(gateFinding), addressed: input.addressed }); + // A held open blocker/major was open at round start: the round is judged (REQ-LEDGER-11). + const heldOpen = Object.values(held).some( + (e) => e.severity !== "nit" && (e.state === "open" || e.state === "reopened"), + ); + return { ...r, judged: r.judged || heldOpen, ledger: { ...r.ledger, ...held } }; +} diff --git a/src/core/ledger.ts b/src/core/ledger.ts new file mode 100644 index 0000000..7709ac2 --- /dev/null +++ b/src/core/ledger.ts @@ -0,0 +1,113 @@ +// Finding ledger (REQ-LEDGER-1..11). Pure: applyRound folds one evaluated round into the ledger +// and reports the progress signals nextLoopDecision consumes. Counting findings is NOT progress — +// identity over rounds is (design docs/2026-11-13-befund-ledger-critic-verdikt-design.md). + +import type { Finding, Severity, Source } from "./finding-key.js"; + +export type EntryState = "open" | "fixed-claimed" | "closed" | "reopened"; + +export interface LedgerEntry { + key: string; + source: Source; + severity: Severity; // highest ever seen + state: EntryState; + reopenCount: number; +} + +export type Ledger = Record; + +export interface RoundInput { + findings: Finding[]; + addressed: string[]; // keys implement explicitly reports as addressed (critic keys only count) +} + +export interface RoundResult { + ledger: Ledger; + judged: boolean; + progress: boolean; + oscillation: boolean; +} + +const RANK: Record = { nit: 0, major: 1, blocker: 2 }; +const higher = (a: Severity, b: Severity): Severity => (RANK[a] >= RANK[b] ? a : b); +const weighty = (s: Severity) => s !== "nit"; // blocker | major +const isOpenish = (s: EntryState) => s === "open" || s === "reopened"; +const isDone = (s: EntryState) => s === "closed" || s === "fixed-claimed"; + +export function applyRound(prev: Ledger, round: RoundInput): RoundResult { + const ledger: Ledger = {}; + for (const [k, e] of Object.entries(prev)) ledger[k] = { ...e }; + + // Snapshot at round start (REQ-LEDGER-11: before `addressed` is applied). + const start = new Map(); + for (const e of Object.values(ledger)) start.set(e.key, { severity: e.severity, state: e.state }); + const judged = [...start.values()].some((e) => weighty(e.severity) && isOpenish(e.state)); + + // REQ-LEDGER-4: implement's claim moves critic entries to fixed-claimed (before the compare). + for (const key of round.addressed) { + const e = ledger[key]; + if (e && e.source === "critic" && isOpenish(e.state)) e.state = "fixed-claimed"; + } + + // Dedupe the reported findings per key (highest severity). + const reported = new Map(); + for (const f of round.findings) { + const cur = reported.get(f.key); + reported.set(f.key, cur ? { ...cur, severity: higher(cur.severity, f.severity) } : f); + } + + const reopenedNow = new Set(); + for (const f of reported.values()) { + const e = ledger[f.key]; + if (!e) { + ledger[f.key] = { key: f.key, source: f.source, severity: f.severity, state: "open", reopenCount: 0 }; + continue; + } + e.severity = higher(e.severity, f.severity); + if (isDone(e.state)) { + e.state = "reopened"; // REQ-LEDGER-5 + e.reopenCount += 1; + reopenedNow.add(e.key); + } + } + + // REQ-LEDGER-2/3: absence is evidence for deterministic gate findings only. + for (const e of Object.values(ledger)) { + if (!reported.has(e.key) && e.source === "gate" && isOpenish(e.state)) e.state = "closed"; + } + + const reopenedWeighty = [...reopenedNow].some((k) => weighty(ledger[k].severity)); + const resolvedWeighty = [...start.entries()].some( + ([k, s]) => weighty(s.severity) && isOpenish(s.state) && isDone(ledger[k].state), + ); + const progress = judged && resolvedWeighty && !reopenedWeighty; + const oscillation = [...reopenedNow].some((k) => weighty(ledger[k].severity) && ledger[k].reopenCount >= 2); + + return { ledger, judged, progress, oscillation }; +} + +// REQ-CRITIC-5: the verdict is DERIVED from the ledger; a critic's self-label is never read. +export function deriveVerdict(ledger: Ledger): "accepted" | "rejected" { + return Object.values(ledger).some((e) => weighty(e.severity) && isOpenish(e.state)) + ? "rejected" + : "accepted"; +} + +// REQ-LEDGER-10: ledger as a markdown table; open blocker/major on top, fixed-claimed explicitly +// labelled as unverified. +export function renderLedgerTable(ledger: Ledger): string { + const label = (s: EntryState) => (s === "fixed-claimed" ? "behoben (unverifiziert)" : s); + const top = (e: LedgerEntry) => weighty(e.severity) && isOpenish(e.state); + const rows = Object.values(ledger).sort( + (a, b) => + Number(top(b)) - Number(top(a)) || + RANK[b.severity] - RANK[a.severity] || + a.key.localeCompare(b.key), + ); + const lines = [ + "| Befund | Quelle | Schweregrad | Status | Wiederöffnet |", + "|---|---|---|---|---|", + ...rows.map((e) => `| ${e.key} | ${e.source} | ${e.severity} | ${label(e.state)} | ${e.reopenCount} |`), + ]; + return lines.join("\n") + "\n"; +} diff --git a/src/core/loop.ts b/src/core/loop.ts index 824e111..8afeccc 100644 --- a/src/core/loop.ts +++ b/src/core/loop.ts @@ -2,16 +2,23 @@ // loop state. The thresholds (maxIter, progress criterion) are INJECTED parameters, never // defaulted in code — they are pilot measurements (§1.1/§12), not a design choice here. +// Signals of the last evaluated round, produced by the ledger (applyRound). Progress is NOT a +// count of findings but a statement about finding identity over rounds (REQ-LEDGER-6/7/11). +export interface RoundSignal { + judged: boolean; // false: nothing blocker/major was open at round start -> neither progress nor stagnation + progress: boolean; + oscillation: boolean; // a blocker/major key was reopened for the 2nd+ time this round +} + export interface LoopState { iteration: number; - errorCounts: number[]; // history of error/finding counts, oldest -> newest + lastRound?: RoundSignal | null; // null/absent: no round evaluated yet gateChangedNotCode: boolean; // the "fix" was a diff on the protected set freshContextUsed: boolean; // whether the one fresh-context retry was already spent } export interface LoopParams { maxIter: number; - requireStrictlyDecreasing: boolean; // progress := strictly fewer errors than before } export type LoopDecision = @@ -19,23 +26,19 @@ export type LoopDecision = | "FRESH_CONTEXT_RETRY" | "ESCALATE_STAGNATION" | "ESCALATE_MAX_ITER" + | "ESCALATE_OSCILLATION" | "ESCALATE_GATE_TAMPER"; -function isStagnating(errorCounts: number[], requireStrictlyDecreasing: boolean): boolean { - if (errorCounts.length < 2) return false; // no history yet -> can't judge - const last = errorCounts[errorCounts.length - 1]; - const prev = errorCounts[errorCounts.length - 2]; - const improved = requireStrictlyDecreasing ? last < prev : last <= prev; - return !improved; -} - export function nextLoopDecision(state: LoopState, params: LoopParams): LoopDecision { // Priority 1: tampering with the guardians is an alarm, not progress. if (state.gateChangedNotCode) return "ESCALATE_GATE_TAMPER"; // Priority 2: hard iteration ceiling. if (state.iteration >= params.maxIter) return "ESCALATE_MAX_ITER"; - // Priority 3: stagnation -> one fresh context to break in-session lock-in, then stop. - if (isStagnating(state.errorCounts, params.requireStrictlyDecreasing)) { + // Priority 3: oscillation (a fix reverted twice) -> stop; a fresh context would not help. + if (state.lastRound?.oscillation) return "ESCALATE_OSCILLATION"; + // Priority 4: stagnation (only a judged round can stagnate) -> one fresh context to break + // in-session lock-in, then stop. + if (state.lastRound?.judged && !state.lastRound.progress) { return state.freshContextUsed ? "ESCALATE_STAGNATION" : "FRESH_CONTEXT_RETRY"; } // Otherwise: feed the defect signal back and re-generate. diff --git a/src/core/resume.ts b/src/core/resume.ts index cae312e..5082eb6 100644 --- a/src/core/resume.ts +++ b/src/core/resume.ts @@ -11,6 +11,7 @@ export interface PrFacts { merged: boolean; reviewDecision: "approved" | "changes-requested" | "none"; checks: "green" | "red" | "pending"; + draft: boolean; // `gh pr view --json isDraft` } export interface Reconstructed { @@ -18,6 +19,7 @@ export interface Reconstructed { humanApprovals: Partial>; reviewDecision?: "approved" | "changes-requested"; gateVerdict?: "green" | "red"; + ready?: boolean; // impl PR is out of draft (= the critic accepted it) done: boolean; } @@ -51,6 +53,7 @@ export function reconstructState(facts: PrFacts): Reconstructed { // Implementation PR. if (facts.merged) { out.phase = "merge-pending"; + out.ready = true; out.done = true; // feature already merged — nothing to do return out; } @@ -63,9 +66,17 @@ export function reconstructState(facts: PrFacts): Reconstructed { out.phase = "implemented"; // gates still running — observe / await CI return out; } - // checks green -> at the merge gate; the review decision drives what happens next. + if (facts.draft === true) { + // Green but still draft: the critic has not accepted yet (REQ-BIND-4) -> back to the critic, + // never merge-pending, whatever the review decision says. + out.phase = "gated"; + out.gateVerdict = "green"; + return out; + } + // checks green, ready for review -> at the merge gate; the review decision drives what's next. out.phase = "merge-pending"; out.gateVerdict = "green"; + out.ready = true; if (facts.reviewDecision === "approved") { out.humanApprovals["merge-review"] = true; out.humanApprovals["t3-merge"] = true; // GitHub approval satisfies whichever the tier needs diff --git a/test/cli/critic-verdict.test.ts b/test/cli/critic-verdict.test.ts new file mode 100644 index 0000000..2658da5 --- /dev/null +++ b/test/cli/critic-verdict.test.ts @@ -0,0 +1,142 @@ +import { test, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../.."); +// Wie die bestehenden CLI-Tests: gegen das gebaute dist (pretest baut). +const script = resolve(root, "dist/cli/critic-verdict.js"); + +function run(input: unknown) { + const r = spawnSync("node", [script], { encoding: "utf8", input: JSON.stringify(input), cwd: root }); + let out: any; + try { + out = JSON.parse(r.stdout); + } catch { + out = undefined; + } + return { status: r.status, out, stderr: r.stderr }; +} + +const F = (over: Record = {}) => ({ + req: "REQ-X-1", + klasse: "req-uncovered", + datei: "src/a.ts", + severity: "blocker", + claim: "nicht abgedeckt", + evidence: "src/a.ts:10", + ...over, +}); +const raw = (findings: unknown[], label = "rejected") => JSON.stringify({ verdict: label, findings }); +const KEY = "review:REQ-X-1:req-uncovered:src/a.ts"; +const open = (key: string, severity = "major", state = "open") => ({ + [key]: { key, source: "critic", severity, state, reopenCount: 0 }, +}); + +test("[REQ-CRITIC-1] gültige rohe Ausgabe mit offenem blocker -> completed, rejected, Ledger-Runde", () => { + const { status, out } = run({ rawOutputs: [raw([F()])], ledger: {}, addressed: [] }); + expect(status).toBe(0); + expect(out.status).toBe("completed"); + expect(out.verdict).toBe("rejected"); + expect(out.round.ledger[KEY]).toMatchObject({ source: "critic", severity: "blocker", state: "open", reopenCount: 0 }); + expect(out.round.judged).toBe(false); // erste Runde: nichts offen zu Beginn (REQ-LEDGER-11) +}); + +test("[REQ-CRITIC-1] findings: [] bei leerem Ledger -> completed, accepted", () => { + const { out } = run({ rawOutputs: [raw([], "rejected")], ledger: {}, addressed: [] }); + expect(out.status).toBe("completed"); + expect(out.verdict).toBe("accepted"); +}); + +// Probe (b) +test("[REQ-CRITIC-2] (Probe b) leere rawOutputs + Eingabefeld status 'completed' -> trotzdem not-run, ohne verdict/round", () => { + const { out } = run({ rawOutputs: [], status: "completed", verdict: "accepted", ledger: {}, addressed: [] }); + expect(out.status).toBe("not-run"); + expect(out).not.toHaveProperty("verdict"); + expect(out).not.toHaveProperty("round"); +}); + +test("[REQ-CRITIC-2] (Probe b) leerer / nicht-JSON / schemaverletzender Output -> not-run, ohne verdict/round", () => { + for (const bad of ["", "irgendwas ohne JSON", raw([F({ klasse: "stil" })]), JSON.stringify({ verdict: "accepted" })]) { + const { out } = run({ rawOutputs: [bad], ledger: {}, addressed: [] }); + expect(out.status, bad).toBe("not-run"); + expect(out).not.toHaveProperty("verdict"); + expect(out).not.toHaveProperty("round"); + } +}); + +test("[REQ-CRITIC-3] eine gültige und eine ungültige Instanz -> ganze Runde not-run", () => { + const { out } = run({ rawOutputs: [raw([F()]), "kaputt"], ledger: {}, addressed: [] }); + expect(out.status).toBe("not-run"); +}); + +test("[REQ-CRITIC-3] zwei gültige Instanzen: Befunde vereinigt, Duplikate per Key (höchster Schweregrad)", () => { + const a = raw([F({ severity: "nit", evidence: "src/a.ts:3" }), F({ klasse: "security", datei: "src/b.ts" })]); + const b = raw([F({ severity: "blocker", evidence: "src/a.ts:9" })]); + const { out } = run({ rawOutputs: [a, b], ledger: {}, addressed: [] }); + expect(out.status).toBe("completed"); + expect(Object.keys(out.round.ledger).sort()).toEqual([KEY, "review:REQ-X-1:security:src/b.ts"].sort()); + expect(out.round.ledger[KEY].severity).toBe("blocker"); +}); + +// Probe (c) +test("[REQ-CRITIC-5] (Probe c) Selbst-Label 'accepted' mit offenem blocker -> abgeleitetes Verdikt rejected", () => { + const { out } = run({ rawOutputs: [raw([F()], "accepted")], ledger: {}, addressed: [] }); + expect(out.verdict).toBe("rejected"); +}); + +test("[REQ-CRITIC-1] Eingabefeld 'verdict' wird nicht beachtet", () => { + const { out } = run({ rawOutputs: [raw([F()], "accepted")], verdict: "accepted", ledger: {}, addressed: [] }); + expect(out.verdict).toBe("rejected"); +}); + +test("[REQ-CRITIC-1] Eingabefeld 'status: not-run' wird ebenfalls nicht beachtet (Status entsteht nur aus der rohen Ausgabe)", () => { + const { out } = run({ rawOutputs: [raw([])], status: "not-run", ledger: {}, addressed: [] }); + expect(out.status).toBe("completed"); +}); + +// Probe (d) auf CLI-Ebene +test("[REQ-LEDGER-3] (Probe d) frischer Critic zieht den offenen Befund nicht und nichts adressiert -> Ledger offen, rejected", () => { + const { out } = run({ rawOutputs: [raw([])], ledger: open(KEY, "blocker"), addressed: [] }); + expect(out.status).toBe("completed"); + expect(out.round.ledger[KEY].state).toBe("open"); + expect(out.verdict).toBe("rejected"); + expect(out.round.judged).toBe(true); + expect(out.round.progress).toBe(false); +}); + +test("[REQ-LEDGER-4] adressierter Befund + Critic zieht ihn nicht -> fixed-claimed, Fortschritt, accepted", () => { + const { out } = run({ rawOutputs: [raw([])], ledger: open(KEY, "blocker"), addressed: [KEY] }); + expect(out.round.ledger[KEY].state).toBe("fixed-claimed"); + expect(out.round.progress).toBe(true); + expect(out.verdict).toBe("accepted"); +}); + +test("[REQ-LEDGER-5] fixed-claimed Befund, den der Critic wieder zieht -> reopened, rejected", () => { + const { out } = run({ rawOutputs: [raw([F()])], ledger: open(KEY, "blocker", "fixed-claimed"), addressed: [] }); + expect(out.round.ledger[KEY]).toMatchObject({ state: "reopened", reopenCount: 1 }); + expect(out.verdict).toBe("rejected"); + expect(out.round.progress).toBe(false); +}); + +test("[REQ-LEDGER-7] zweites Wiederöffnen im CLI-Ergebnis: oscillation true", () => { + const ledger = { [KEY]: { key: KEY, source: "critic", severity: "blocker", state: "fixed-claimed", reopenCount: 1 } }; + const { out } = run({ rawOutputs: [raw([F()])], ledger, addressed: [] }); + expect(out.round.oscillation).toBe(true); +}); + +test("[REQ-CRITIC-5] nur Nits -> accepted", () => { + const { out } = run({ rawOutputs: [raw([F({ severity: "nit", klasse: "convention" })])], ledger: {}, addressed: [] }); + expect(out.verdict).toBe("accepted"); +}); + +test("[REQ-KEY-1] (Probe i) derselbe Befund um 3 Zeilen verschoben trifft denselben Ledger-Eintrag (kein neuer Key)", () => { + const first = run({ rawOutputs: [raw([F({ evidence: "src/a.ts:10" })])], ledger: {}, addressed: [] }); + const second = run({ + rawOutputs: [raw([F({ evidence: "src/a.ts:13", claim: "anders formuliert" })])], + ledger: first.out.round.ledger, + addressed: [], + }); + expect(Object.keys(second.out.round.ledger)).toEqual([KEY]); +}); diff --git a/test/cli/gate-round.test.ts b/test/cli/gate-round.test.ts new file mode 100644 index 0000000..5715f44 --- /dev/null +++ b/test/cli/gate-round.test.ts @@ -0,0 +1,152 @@ +import { test, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../.."); +// Wie die bestehenden CLI-Tests: gegen das gebaute dist (pretest baut). +const script = resolve(root, "dist/cli/gate-round.js"); + +function runRaw(stdin: string) { + const r = spawnSync("node", [script], { encoding: "utf8", input: stdin, cwd: root }); + let out: any; + try { + out = JSON.parse(r.stdout); + } catch { + out = undefined; + } + return { status: r.status, stdout: r.stdout, stderr: r.stderr, out }; +} +const run = (input: unknown) => runRaw(JSON.stringify(input)); + +const T = (testName: string, line?: number) => ({ kind: "test", file: "t.test.ts", testName, ...(line ? { line } : {}) }); +const M = { kind: "mutant", file: "src/a.ts", fn: "f", mutator: "ArithmeticOperator" }; +const tkey = (n: string) => `test:t.test.ts:${n}`; +const MKEY = "mutant:src/a.ts:f:ArithmeticOperator"; +const e = (key: string, over: Record = {}) => ({ + [key]: { key, source: "gate", severity: "blocker", state: "open", reopenCount: 0, ...over }, +}); + +// ---- REQ-GATE-1 ----------------------------------------------------------------------------- +test("[REQ-GATE-1] Ende zu Ende: Exit 0, stdout { round } mit neuem Ledger, judged/progress/oscillation", () => { + const { status, out } = run({ raws: [T("A")], ran: ["test"], ledger: {}, addressed: [] }); + expect(status).toBe(0); + expect(Object.keys(out)).toEqual(["round"]); + expect(out.round.ledger[tkey("A")]).toEqual({ + key: tkey("A"), + source: "gate", + severity: "blocker", + state: "open", + reopenCount: 0, + }); + expect(out.round.judged).toBe(false); + expect(out.round.progress).toBe(false); + expect(out.round.oscillation).toBe(false); +}); + +test("[REQ-GATE-1] falsche Stagnation behoben: Test A grün, B rot -> progress true", () => { + const r1 = run({ raws: [T("A")], ran: ["test"], ledger: {}, addressed: [] }); + const r2 = run({ raws: [T("B")], ran: ["test"], ledger: r1.out.round.ledger, addressed: [] }); + expect(r2.out.round.judged).toBe(true); + expect(r2.out.round.progress).toBe(true); + expect(r2.out.round.ledger[tkey("A")].state).toBe("closed"); +}); + +test("[REQ-GATE-1] [REQ-KEY-1] andere Zeilennummer -> derselbe Key", () => { + const r1 = run({ raws: [T("A", 5)], ran: ["test"], ledger: {}, addressed: [] }); + const r2 = run({ raws: [T("A", 50)], ran: ["test"], ledger: r1.out.round.ledger, addressed: [] }); + expect(Object.keys(r2.out.round.ledger)).toEqual([tkey("A")]); +}); + +test("[REQ-GATE-1] Critic-Eintrag bleibt unverändert; addressed setzt ihn auf fixed-claimed", () => { + const ck = "review:REQ-X-1:other:src/c.ts"; + const ledger = { [ck]: { key: ck, source: "critic", severity: "major", state: "open", reopenCount: 0 } }; + const a = run({ raws: [T("A")], ran: ["test"], ledger, addressed: [] }); + expect(a.out.round.ledger[ck].state).toBe("open"); + const b = run({ raws: [T("A")], ran: ["test"], ledger, addressed: [ck] }); + expect(b.out.round.ledger[ck].state).toBe("fixed-claimed"); +}); + +test("[REQ-GATE-1] im Input mitgeschickte Felder 'progress', 'judged', 'round' werden ignoriert", () => { + const { status, out } = run({ + raws: [T("A")], + ran: ["test"], + ledger: {}, + addressed: [], + progress: true, + judged: true, + oscillation: true, + round: { progress: true }, + }); + expect(status).toBe(0); + expect(out.round.progress).toBe(false); + expect(out.round.judged).toBe(false); + expect(out.round.oscillation).toBe(false); +}); + +// ---- REQ-GATE-2 (Probe m) -------------------------------------------------------------------- +test("[REQ-GATE-2] (Probe m) mutant offen, ran=[test] -> mutant unverändert, kein Fortschritt", () => { + const r = run({ raws: [T("A")], ran: ["test"], ledger: e(MKEY), addressed: [] }); + expect(r.status).toBe(0); + expect(r.out.round.ledger[MKEY]).toEqual({ key: MKEY, source: "gate", severity: "blocker", state: "open", reopenCount: 0 }); + expect(r.out.round.progress).toBe(false); +}); + +test("[REQ-GATE-2] (Probe m) Gegenprobe: ran=[test,mutant] -> mutant closed, progress true", () => { + const r = run({ raws: [T("A")], ran: ["test", "mutant"], ledger: e(MKEY), addressed: [] }); + expect(r.out.round.ledger[MKEY].state).toBe("closed"); + expect(r.out.round.progress).toBe(true); +}); + +test("[REQ-GATE-2] Folgeprobe über drei CLI-Aufrufe: kein reopened, reopenCount 0, oscillation false", () => { + const r1 = run({ raws: [M], ran: ["test", "mutant"], ledger: {}, addressed: [] }); + const r2 = run({ raws: [T("A")], ran: ["test"], ledger: r1.out.round.ledger, addressed: [] }); + const r3 = run({ raws: [T("A"), M], ran: ["test", "mutant"], ledger: r2.out.round.ledger, addressed: [] }); + expect(r3.out.round.ledger[MKEY].state).toBe("open"); + expect(r3.out.round.ledger[MKEY].reopenCount).toBe(0); + expect(r3.out.round.oscillation).toBe(false); +}); + +// ---- REQ-GATE-3 (Probe n) -------------------------------------------------------------------- +const base = { raws: [T("A")], ran: ["test"], ledger: {}, addressed: [] as unknown[] }; +const without = (k: string) => { + const { [k]: _omit, ...rest } = base as Record; + return rest; +}; +const invalid: [string, string][] = [ + ["stdin leer", ""], + ["stdin kein JSON", "nope {"], + ["stdin JSON null", "null"], + ["stdin JSON Array", "[]"], + ["ran fehlt", JSON.stringify(without("ran"))], + ["raws fehlt", JSON.stringify(without("raws"))], + ["addressed fehlt", JSON.stringify(without("addressed"))], + ["ledger fehlt", JSON.stringify(without("ledger"))], + ["ledger kein Objekt", JSON.stringify({ ...base, ledger: "x" })], + ["ledger null", JSON.stringify({ ...base, ledger: null })], + ["raw.kind nicht in ran", JSON.stringify({ ...base, ran: ["mutant"] })], + ["raw mit unbekannter Art", JSON.stringify({ ...base, raws: [{ kind: "lint", file: "a" }] })], + ["ran-Eintrag unbekannte Art", JSON.stringify({ ...base, ran: ["test", "lint"] })], + ["Pflichtfeld fehlt (test ohne testName)", JSON.stringify({ ...base, raws: [{ kind: "test", file: "t.ts" }] })], + ["Pflichtfeld leer (trace req)", JSON.stringify({ ...base, ran: ["trace"], raws: [{ kind: "trace", req: "" }] })], + ["mutant ohne mutator", JSON.stringify({ ...base, ran: ["mutant"], raws: [{ kind: "mutant", file: "a", fn: "f" }] })], + ["semgrep ohne rule", JSON.stringify({ ...base, ran: ["semgrep"], raws: [{ kind: "semgrep", file: "a", fn: "f" }] })], + ["raws kein Array", JSON.stringify({ ...base, raws: {} })], + ["addressed-Eintrag kein String", JSON.stringify({ ...base, addressed: [1] })], +]; +for (const [name, stdin] of invalid) { + test(`[REQ-GATE-3] (Probe n) ungültig -> Exit 2, Meldung auf stderr, stdout leer: ${name}`, () => { + const r = runRaw(stdin); + expect(r.status).toBe(2); + expect(r.stdout).toBe(""); + expect(r.stderr.length).toBeGreaterThan(0); + }); +} + +test("[REQ-GATE-3] gültige Eingabe: stderr leer, stdout genau ein JSON-Dokument", () => { + const r = run(base); + expect(r.status).toBe(0); + expect(r.out).toBeDefined(); + expect(r.stderr).toBe(""); +}); diff --git a/test/cli/pr-state.test.ts b/test/cli/pr-state.test.ts new file mode 100644 index 0000000..56f03d3 --- /dev/null +++ b/test/cli/pr-state.test.ts @@ -0,0 +1,81 @@ +import { test, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../.."); +const script = resolve(root, "dist/cli/pr-state.js"); + +// `gh`-Stub vorne im PATH: gibt festes JSON aus (Pfad per Umgebung), ignoriert alle Argumente. +function runWithGh(ghJson: unknown) { + const dir = mkdtempSync(join(tmpdir(), "devloop-ghstub-")); + try { + writeFileSync(join(dir, "gh.json"), JSON.stringify(ghJson)); + writeFileSync(join(dir, "gh"), '#!/bin/sh\ncat "$GH_STUB_JSON"\n'); + chmodSync(join(dir, "gh"), 0o755); + const r = spawnSync("node", [script, dir, "7"], { + encoding: "utf8", + cwd: root, + env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GH_STUB_JSON: join(dir, "gh.json") }, + }); + let out: any; + try { + out = JSON.parse(r.stdout); + } catch { + out = undefined; + } + return { status: r.status, out, stderr: r.stderr }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const green = [ + { status: "COMPLETED", conclusion: "SUCCESS" }, + { status: "COMPLETED", conclusion: "SUCCESS" }, +]; +const pr = (over: Record = {}) => ({ + headRefName: "devloop/x", + state: "OPEN", + reviewDecision: null, + statusCheckRollup: green, + isDraft: true, + ...over, +}); + +test("[REQ-BIND-4] pr-state: Draft-PR, alle Checks grün -> facts.draft true, phase gated (Critic steht aus)", () => { + const { status, out } = runWithGh(pr({ isDraft: true })); + expect(status).toBe(0); + expect(out.facts.draft).toBe(true); + expect(out.facts.checks).toBe("green"); + expect(out.reconstructed.phase).toBe("gated"); + expect(out.reconstructed.gateVerdict).toBe("green"); + expect(out.reconstructed.ready).not.toBe(true); +}); + +test("[REQ-BIND-4] pr-state: nicht Draft, grün -> facts.draft false, merge-pending, ready true", () => { + const { status, out } = runWithGh(pr({ isDraft: false })); + expect(status).toBe(0); + expect(out.facts.draft).toBe(false); + expect(out.reconstructed.phase).toBe("merge-pending"); + expect(out.reconstructed.ready).toBe(true); +}); + +test("[REQ-BIND-4] pr-state: isDraft fehlt -> Exit != 0 (fail-closed, kein Raten)", () => { + const body: Record = pr(); + delete body.isDraft; + const { status, out } = runWithGh(body); + expect(status).not.toBe(0); + expect(out).toBeUndefined(); +}); + +test("[REQ-BIND-4] pr-state: isDraft als String 'false' -> Exit != 0 (kein Truthy-Cast)", () => { + for (const bad of ["false", "true", null, 0]) { + const { status, out } = runWithGh(pr({ isDraft: bad })); + expect(status, String(bad)).not.toBe(0); + expect(out).toBeUndefined(); + } +}); diff --git a/test/core/critic.test.ts b/test/core/critic.test.ts new file mode 100644 index 0000000..fa324cf --- /dev/null +++ b/test/core/critic.test.ts @@ -0,0 +1,229 @@ +import { test, expect } from "vitest"; +import { parseCriticOutput, mergeCriticRuns, type CriticRun } from "../../src/core/critic.js"; +import type { Finding } from "../../src/core/finding-key.js"; + +const F = (over: Record = {}) => ({ + req: "REQ-X-1", + klasse: "req-uncovered", + datei: "src/a.ts", + severity: "blocker", + claim: "REQ-X-1 ist nicht abgedeckt", + evidence: "src/a.ts:10", + ...over, +}); +const out = (findings: unknown, extra: Record = {}) => + JSON.stringify({ verdict: "rejected", findings, ...extra }); + +const keyOf = (f: Finding) => f.key; + +// ---- REQ-CRITIC-2 / REQ-KEY-2: Parsing --------------------------------------------------------- +test("[REQ-KEY-2] gültiger Befund -> completed, Finding mit source critic, Schweregrad und Key", () => { + const r = parseCriticOutput(out([F()])); + expect(r.status).toBe("completed"); + expect(r.findings).toEqual([ + { key: "review:REQ-X-1:req-uncovered:src/a.ts", source: "critic", severity: "blocker" }, + ]); +}); + +test("[REQ-KEY-2] alle sechs Klassen und alle drei Schweregrade sind gültig; req '-' ist gültig", () => { + const klassen = ["req-uncovered", "happy-path-only", "spec-deviation", "convention", "security", "other"]; + const sev = ["blocker", "major", "nit"]; + const findings = klassen.map((k, i) => F({ klasse: k, severity: sev[i % 3], req: i === 0 ? "-" : "REQ-X-1" })); + const r = parseCriticOutput(out(findings)); + expect(r.status).toBe("completed"); + expect(r.findings.map((f) => f.severity)).toEqual(klassen.map((_, i) => sev[i % 3])); + expect(r.findings[0].key).toBe("review:-:req-uncovered:src/a.ts"); +}); + +test("[REQ-KEY-2] jedes fehlende Pflichtfeld => not-run", () => { + for (const field of ["req", "klasse", "datei", "severity", "claim", "evidence"]) { + const f: Record = F(); + delete f[field]; + expect(parseCriticOutput(out([f])).status, `fehlt: ${field}`).toBe("not-run"); + } +}); + +test("[REQ-KEY-2] Enum verletzt (klasse / severity) => not-run", () => { + expect(parseCriticOutput(out([F({ klasse: "stil" })])).status).toBe("not-run"); + expect(parseCriticOutput(out([F({ severity: "critical" })])).status).toBe("not-run"); + expect(parseCriticOutput(out([F({ severity: "BLOCKER" })])).status).toBe("not-run"); +}); + +test("[REQ-KEY-2] falscher Feldtyp => not-run", () => { + expect(parseCriticOutput(out([F({ req: 5 })])).status).toBe("not-run"); + expect(parseCriticOutput(out([F({ datei: null })])).status).toBe("not-run"); +}); + +test("[REQ-KEY-2] ein einziger ungültiger Befund neben gültigen macht den ganzen Lauf not-run", () => { + const bad = F(); + delete (bad as Record).claim; + expect(parseCriticOutput(out([F(), bad])).status).toBe("not-run"); +}); + +test("[REQ-KEY-2] Zeile in evidence und Wortlaut von claim beeinflussen den Key nicht", () => { + const a = parseCriticOutput(out([F({ evidence: "src/a.ts:10", claim: "Variante eins" })])); + const b = parseCriticOutput(out([F({ evidence: "src/a.ts:13", claim: "ganz andere Beschreibung" })])); + expect(a.findings[0].key).toBe(b.findings[0].key); +}); + +// Probe (b) +test("[REQ-CRITIC-2] (Probe b) leere Ausgabe => not-run (nicht completed mit findings [])", () => { + for (const raw of ["", " ", "\n\n"]) { + const r = parseCriticOutput(raw); + expect(r.status).toBe("not-run"); + } +}); + +test("[REQ-CRITIC-2] (Probe b) kein JSON => not-run", () => { + expect(parseCriticOutput("Alles sieht gut aus, keine Befunde.").status).toBe("not-run"); + expect(parseCriticOutput("{ kaputt").status).toBe("not-run"); +}); + +test("[REQ-CRITIC-2] (Probe b) JSON ohne findings-Feld / findings kein Array => not-run", () => { + expect(parseCriticOutput(JSON.stringify({ verdict: "accepted" })).status).toBe("not-run"); + expect(parseCriticOutput(JSON.stringify({ verdict: "accepted", findings: "keine" })).status).toBe("not-run"); + expect(parseCriticOutput(JSON.stringify({ verdict: "accepted", findings: null })).status).toBe("not-run"); + expect(parseCriticOutput(JSON.stringify({ verdict: "accepted", findings: {} })).status).toBe("not-run"); + expect(parseCriticOutput("[]").status).toBe("not-run"); +}); + +test("[REQ-CRITIC-2] (Probe b) findings: [] ist gültig: completed ohne Befunde", () => { + expect(parseCriticOutput(out([]))).toEqual({ status: "completed", findings: [] }); +}); + +test("[REQ-CRITIC-2] not-run trägt keine Befunde, auch wenn die Ausgabe teilweise gültige enthält", () => { + const bad = F({ klasse: "stil" }); + const r = parseCriticOutput(out([F(), bad])); + expect(r).toEqual({ status: "not-run", findings: [] }); +}); + +test("[REQ-CRITIC-2] JSON in Text eingebettet und in ```json-Fence wird gefunden", () => { + const body = out([F({ severity: "major" })]); + const fenced = `Hier mein Review:\n\n\`\`\`json\n${body}\n\`\`\`\n\nEnde.`; + const inline = `Ergebnis: ${body} -- das war's`; + for (const raw of [fenced, inline]) { + const r = parseCriticOutput(raw); + expect(r.status).toBe("completed"); + expect(r.findings.map((f) => f.severity)).toEqual(["major"]); + } +}); + +test("[REQ-CRITIC-2] das ERSTE JSON-Objekt mit Feld findings zählt; Objekte ohne findings davor werden übersprungen", () => { + const raw = `Kontext {"nur":"eine Notiz"} dann\n${out([F()])}\nund danach ${out([])}`; + const r = parseCriticOutput(raw); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(1); +}); + +test("[REQ-CRITIC-2] ist das erste Objekt mit findings ungültig, gilt not-run, auch wenn ein späteres gültig ist", () => { + const raw = `${JSON.stringify({ findings: "kaputt" })}\n${out([])}`; + expect(parseCriticOutput(raw).status).toBe("not-run"); +}); + +test("[REQ-CRITIC-2] geschweifte Klammern in Zeichenketten stören das Auffinden nicht", () => { + const raw = `Text\n${out([F({ claim: "nutze { und } sowie \"Quotes\" hier" })])}\nEnde`; + const r = parseCriticOutput(raw); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(1); +}); + +// Probe (c) auf Parse-Ebene +test("[REQ-CRITIC-5] (Probe c) das Selbst-Label 'verdict' wird ignoriert: kein Verdikt-Feld im Ergebnis, Befunde bleiben", () => { + const r = parseCriticOutput(out([F()], { verdict: "accepted" })); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(1); + expect(Object.keys(r).sort()).toEqual(["findings", "status"]); +}); + +test("[REQ-CRITIC-5] (Probe c) auch ein ungültiges Selbst-Label macht den Lauf nicht ungültig", () => { + expect(parseCriticOutput(out([], { verdict: "vielleicht" })).status).toBe("completed"); + expect(parseCriticOutput(JSON.stringify({ findings: [] })).status).toBe("completed"); +}); + +// ---- REQ-CRITIC-3: Zusammenführen ------------------------------------------------------------- +const run = (findings: Finding[]): CriticRun => ({ status: "completed", findings }); +const fnd = (n: string, severity: Finding["severity"] = "major"): Finding => ({ + key: `review:-:other:${n}.ts`, + source: "critic", + severity, +}); + +test("[REQ-CRITIC-3] Vereinigung der Befunde mehrerer Instanzen", () => { + const m = mergeCriticRuns([run([fnd("a")]), run([fnd("b")])]); + expect(m.status).toBe("completed"); + expect(m.findings.map(keyOf).sort()).toEqual([fnd("a").key, fnd("b").key]); +}); + +test("[REQ-CRITIC-3] Duplikate werden per Key zusammengelegt, mit dem höchsten Schweregrad", () => { + const m = mergeCriticRuns([run([fnd("a", "nit")]), run([fnd("a", "blocker")]), run([fnd("a", "major")])]); + expect(m.findings).toEqual([fnd("a", "blocker")]); +}); + +test("[REQ-CRITIC-3] (Probe b) eine not-run Instanz macht die ganze Runde not-run", () => { + const m = mergeCriticRuns([run([fnd("a")]), { status: "not-run", findings: [] }]); + expect(m.status).toBe("not-run"); + expect(m.findings).toEqual([]); +}); + +test("[REQ-CRITIC-3] (Probe b) leeres Array von Läufen => not-run (nie 'completed ohne Befunde')", () => { + expect(mergeCriticRuns([]).status).toBe("not-run"); +}); + +test("[REQ-CRITIC-3] alle completed ohne Befunde => completed ohne Befunde", () => { + expect(mergeCriticRuns([run([]), run([])])).toEqual({ status: "completed", findings: [] }); +}); + +test("[REQ-CRITIC-3] einzelne Instanz bleibt unverändert", () => { + expect(mergeCriticRuns([run([fnd("a", "nit")])])).toEqual(run([fnd("a", "nit")])); +}); + +test("[REQ-CRITIC-3] (Probe i) derselbe Defekt, von zwei Critics verschieden lokalisiert (Zeile/Wortlaut), ergibt einen Befund", () => { + const a = parseCriticOutput(out([F({ evidence: "src/a.ts:10", claim: "A" })])); + const b = parseCriticOutput(out([F({ evidence: "src/a.ts:13", claim: "B" })])); + expect(mergeCriticRuns([a, b]).findings).toHaveLength(1); +}); + +// ---- Nachtrag: Escapes, leere req, Zusatzfeld line ------------------------------------------ +test("[REQ-CRITIC-2] Escapes im claim ('say \\\"x\\\" }') stören das Auffinden nicht: genau ein Befund", () => { + const raw = out([F({ claim: 'say "x" }' })]); + expect(raw).toContain('\\"x\\" }'); + const r = parseCriticOutput(raw); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(1); +}); + +test("[REQ-CRITIC-2] '{' im Text VOR dem JSON stört nicht: genau ein Befund", () => { + const r = parseCriticOutput(`Vorab { unvollständige Notiz\n${out([F({ claim: 'say "x" }' })])}\nEnde`); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(1); +}); + +test("[REQ-KEY-2] Befund mit req '' (leer) => not-run", () => { + expect(parseCriticOutput(out([F({ req: "" })])).status).toBe("not-run"); +}); + +test("[REQ-KEY-1] (Probe i) Zusatzfeld line: 10 vs 13 an sonst gleichem Befund => gleicher Key", () => { + const a = parseCriticOutput(out([F({ line: 10 })])); + const b = parseCriticOutput(out([F({ line: 13 })])); + expect(a.status).toBe("completed"); + expect(a.findings[0].key).toBe(b.findings[0].key); +}); + +// ---- Nachtrag 2: ungerade Zahl escapter Quotes (tötet "Escape-Behandlung entfernt") ------------- +// Der erste Escape-Test (zwei \" im claim) ist für diese Mutation äquivalent: ohne Escape-Behandlung +// schließt und öffnet der String je einmal, die Balance stimmt zufällig. Mit EINEM \" vor "}" kippt sie. +test("[REQ-CRITIC-2] ungerade Zahl escapter Quotes im claim ('5\\\" Rohr }') => completed, genau ein Befund", () => { + const raw = out([F({ claim: '5" Rohr }' })]); + expect(raw).toContain('5\\" Rohr }'); // im Roh-JSON: ein escaptes Quote, gefolgt von } + const r = parseCriticOutput(`Text ${raw} Ende`); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(1); +}); + +test("[REQ-CRITIC-2] escapter Backslash direkt vor dem schließenden Quote ('ende\\\\') => completed, genau ein Befund", () => { + const raw = out([F({ claim: "Pfad C:\\dir\\" }), F({ klasse: "other", claim: "zweiter }" })]); + expect(raw).toContain('dir\\\\"'); // Roh-JSON: \\ unmittelbar vor dem schließenden " + const r = parseCriticOutput(raw); + expect(r.status).toBe("completed"); + expect(r.findings).toHaveLength(2); +}); diff --git a/test/core/driver.test.ts b/test/core/driver.test.ts index 48ee1c7..e2ff2c6 100644 --- a/test/core/driver.test.ts +++ b/test/core/driver.test.ts @@ -5,11 +5,11 @@ import type { LoopState, LoopParams } from "../../src/core/loop.js"; const okGuards = { ok: true, missing: [] as string[] }; const loop: LoopState = { iteration: 1, - errorCounts: [10, 7], gateChangedNotCode: false, freshContextUsed: false, + lastRound: { judged: true, progress: true, oscillation: false }, }; -const loopParams: LoopParams = { maxIter: 5, requireStrictlyDecreasing: true }; +const loopParams: LoopParams = { maxIter: 5 }; const base = (over: Partial = {}): DriverState => ({ tier: "T2", @@ -106,30 +106,30 @@ test("gate tampering escalates (reward-hacking alarm)", () => { // --- Invariant 3: T3-merge stop not skippable; T0/T1 auto-merge ---------------- test("T3 merge stop is not skippable", () => { - const s = base({ tier: "T3", phase: "merge-pending", gateVerdict: "green" }); + const s = base({ tier: "T3", phase: "merge-pending", gateVerdict: "green", ready: true }); expect(nextAction(s)).toEqual({ kind: "STOP_FOR_HUMAN", stop: "t3-merge" }); }); test("T3 proceeds to DONE only with the t3-merge token", () => { - const s = base({ tier: "T3", phase: "merge-pending", gateVerdict: "green", humanApprovals: { "t3-merge": true } }); + const s = base({ tier: "T3", phase: "merge-pending", gateVerdict: "green", ready: true, humanApprovals: { "t3-merge": true } }); expect(nextAction(s)).toEqual({ kind: "DONE" }); }); test("T0/T1 auto-merge at green", () => { for (const tier of ["T0", "T1"] as const) { - const s = base({ tier, phase: "merge-pending", gateVerdict: "green" }); + const s = base({ tier, phase: "merge-pending", gateVerdict: "green", ready: true }); expect(nextAction(s)).toEqual({ kind: "DONE" }); } }); test("T2 stops for the required reviewer before merge", () => { - const s = base({ tier: "T2", phase: "merge-pending", gateVerdict: "green" }); + const s = base({ tier: "T2", phase: "merge-pending", gateVerdict: "green", ready: true }); expect(nextAction(s)).toEqual({ kind: "STOP_FOR_HUMAN", stop: "merge-review" }); }); // --- Human rejection (changes requested) is a back-edge, not a dead end -------- test("merge-pending + changes-requested -> RE_GEN (re-implement with the review as defect signal)", () => { - const s = base({ tier: "T2", phase: "merge-pending", gateVerdict: "green", reviewDecision: "changes-requested" }); + const s = base({ tier: "T2", phase: "merge-pending", gateVerdict: "green", ready: true, reviewDecision: "changes-requested" }); const a = nextAction(s); expect(a.kind).toBe("RE_GEN"); if (a.kind === "RE_GEN") expect(a.feedback).toBe("defect-signal"); @@ -142,7 +142,7 @@ test("spec-pr-open + changes-requested -> re-spec (SPAWN specify, amend per the test("changes-requested does not apply to T0/T1 auto-merge (no review there)", () => { // reviewDecision undefined for T0/T1 -> normal auto-merge - expect(nextAction(base({ tier: "T1", phase: "merge-pending", gateVerdict: "green" }))).toEqual({ kind: "DONE" }); + expect(nextAction(base({ tier: "T1", phase: "merge-pending", gateVerdict: "green", ready: true }))).toEqual({ kind: "DONE" }); }); // --- Optional spec-to-twin station (twin as a pluggable capability) ----------- @@ -173,6 +173,7 @@ test("nextAction never produces artifacts inline (SPAWN_STATION is the only prod "STOP_FOR_HUMAN", "RE_GEN", "ESCALATE", + "MARK_READY", "DONE", ]; const phases: DriverState["phase"][] = [ @@ -184,6 +185,7 @@ test("nextAction never produces artifacts inline (SPAWN_STATION is the only prod "spec-merged", "implemented", "gated", + "critiqued", "merge-pending", ]; const tiers = ["T0", "T1", "T2", "T3"] as const; @@ -203,3 +205,173 @@ test("nextAction never produces artifacts inline (SPAWN_STATION is the only prod if (a.kind !== "SPAWN_STATION") expect("station" in a).toBe(false); } }); + +// ===================================================================================== +// Befund-Ledger + bindendes Critic-Verdikt (REQ-CRITIC-4/6/7, REQ-BIND-2/3/5) +// ===================================================================================== +type Critic = NonNullable; +const notRun = (retried: boolean): Critic => ({ status: "not-run", retried }); +const rejected: Critic = { status: "completed", verdict: "rejected", retried: false }; +const accepted: Critic = { status: "completed", verdict: "accepted", retried: false }; +const critiqued = (critic: Critic | undefined, over: Partial = {}) => + base({ phase: "critiqued", gateVerdict: "green", critic, ...over }); +const TIERS = ["T0", "T1", "T2", "T3"] as const; + +// --- REQ-CRITIC-4 (Probe h): genau ein Neustart ------------------------------------------------ +test("[REQ-CRITIC-4] critiqued + not-run, noch nicht neu gestartet -> critic erneut spawnen", () => { + for (const tier of TIERS) { + expect(nextAction(critiqued(notRun(false), { tier }))).toEqual({ + kind: "SPAWN_STATION", + station: "critic", + }); + } +}); + +test("[REQ-CRITIC-4] (Probe h) critiqued + not-run NACH dem Neustart -> ESCALATE critic-not-run (nicht ein zweiter Neustart)", () => { + for (const tier of TIERS) { + expect(nextAction(critiqued(notRun(true), { tier }))).toEqual({ + kind: "ESCALATE", + reason: "critic-not-run", + }); + } +}); + +test("[REQ-CRITIC-4] (Probe b) not-run führt nie zu MARK_READY oder DONE, in keinem Tier", () => { + for (const tier of TIERS) + for (const retried of [false, true]) + for (const ready of [undefined, false, true]) { + const a = nextAction(critiqued(notRun(retried), { tier, ready, humanApprovals: { "merge-review": true, "t3-merge": true } })); + expect(["MARK_READY", "DONE"]).not.toContain(a.kind); + } +}); + +// --- REQ-CRITIC-6: rejected -> Rückkante über nextLoopDecision --------------------------------------- +test("[REQ-CRITIC-6] rejected -> RE_GEN wie beim roten Gate (gleiche Action)", () => { + for (const tier of TIERS) { + const viaCritic = nextAction(critiqued(rejected, { tier, loop, loopParams })); + const viaRedGate = nextAction(base({ tier, phase: "gated", gateVerdict: "red", loop, loopParams })); + expect(viaCritic).toEqual(viaRedGate); + expect(viaCritic).toEqual({ kind: "RE_GEN", feedback: "defect-signal", freshContext: false }); + } +}); + +test("[REQ-CRITIC-6] rejected + Stagnation -> RE_GEN mit frischem Kontext; danach ESCALATE_STAGNATION", () => { + const stag = { ...loop, lastRound: { judged: true, progress: false, oscillation: false } }; + expect(nextAction(critiqued(rejected, { loop: stag, loopParams }))).toEqual({ + kind: "RE_GEN", + feedback: "defect-signal", + freshContext: true, + }); + expect(nextAction(critiqued(rejected, { loop: { ...stag, freshContextUsed: true }, loopParams }))).toEqual({ + kind: "ESCALATE", + reason: "ESCALATE_STAGNATION", + }); +}); + +test("[REQ-CRITIC-6] rejected + Oszillation / MaxIter / Tamper eskalieren mit dem Entscheidungsnamen", () => { + const osc = { ...loop, lastRound: { judged: true, progress: true, oscillation: true } }; + expect(nextAction(critiqued(rejected, { loop: osc, loopParams }))).toEqual({ kind: "ESCALATE", reason: "ESCALATE_OSCILLATION" }); + expect(nextAction(critiqued(rejected, { loop: { ...loop, iteration: 5 }, loopParams }))).toEqual({ kind: "ESCALATE", reason: "ESCALATE_MAX_ITER" }); + expect(nextAction(critiqued(rejected, { loop: { ...loop, gateChangedNotCode: true }, loopParams }))).toEqual({ kind: "ESCALATE", reason: "ESCALATE_GATE_TAMPER" }); +}); + +test("[REQ-CRITIC-6] rejected ohne loop/loopParams -> ESCALATE loop-params-required (wie beim roten Gate)", () => { + expect(nextAction(critiqued(rejected))).toEqual({ kind: "ESCALATE", reason: "loop-params-required" }); +}); + +test("[REQ-CRITIC-6] rejected führt nie zu MARK_READY oder DONE, auch nicht bei T0/T1 oder mit ready/Approvals", () => { + for (const tier of TIERS) + for (const ready of [undefined, false, true]) { + const a = nextAction(critiqued(rejected, { tier, ready, loop, loopParams, humanApprovals: { "merge-review": true, "t3-merge": true } })); + expect(["MARK_READY", "DONE"]).not.toContain(a.kind); + } +}); + +// --- REQ-CRITIC-7 / REQ-BIND-2: accepted -> MARK_READY, danach merge-pending-Verhalten -------------- +test("[REQ-CRITIC-7] accepted und noch nicht ready -> MARK_READY (jedes Tier)", () => { + for (const tier of TIERS) { + expect(nextAction(critiqued(accepted, { tier }))).toEqual({ kind: "MARK_READY" }); + expect(nextAction(critiqued(accepted, { tier, ready: false }))).toEqual({ kind: "MARK_READY" }); + } +}); + +test("[REQ-BIND-2] MARK_READY kommt vor jedem Merge-Stop: auch bei T3 mit vorhandenem Token zuerst MARK_READY", () => { + expect(nextAction(critiqued(accepted, { tier: "T3", humanApprovals: { "t3-merge": true } }))).toEqual({ kind: "MARK_READY" }); +}); + +test("[REQ-CRITIC-7] accepted + ready verhält sich wie merge-pending (T0/T1 DONE, T2/T3 Stop)", () => { + for (const tier of ["T0", "T1"] as const) { + expect(nextAction(critiqued(accepted, { tier, ready: true }))).toEqual({ kind: "DONE" }); + } + expect(nextAction(critiqued(accepted, { tier: "T2", ready: true }))).toEqual({ kind: "STOP_FOR_HUMAN", stop: "merge-review" }); + expect(nextAction(critiqued(accepted, { tier: "T3", ready: true }))).toEqual({ kind: "STOP_FOR_HUMAN", stop: "t3-merge" }); + expect(nextAction(critiqued(accepted, { tier: "T3", ready: true, humanApprovals: { "t3-merge": true } }))).toEqual({ kind: "DONE" }); +}); + +// --- REQ-BIND-5: kein Pfad nach merge-pending/DONE ohne bewiesenes accepted ---------------------------- +test("[REQ-BIND-5] merge-pending ohne ready === true liefert nie DONE/Merge-Stop, sondern critic (jedes Tier, mit allen Approvals)", () => { + for (const tier of TIERS) + for (const ready of [undefined, false]) + for (const humanApprovals of [{}, { "merge-review": true, "t3-merge": true }] as const) { + expect(nextAction(base({ tier, phase: "merge-pending", gateVerdict: "green", ready, humanApprovals }))).toEqual({ + kind: "SPAWN_STATION", + station: "critic", + }); + } +}); + +test("[REQ-BIND-5] Invariante: DONE nur aus bewiesenem accepted (ready === true) in merge-pending bzw. critiqued+accepted", () => { + const phases: DriverState["phase"][] = [ + "init", "specified", "tests-written", "twin-written", "spec-pr-open", "spec-merged", + "implemented", "gated", "critiqued", "merge-pending", + ]; + const critics: (Critic | undefined)[] = [undefined, notRun(false), notRun(true), rejected, accepted]; + const approvals = [{}, { "spec-review": true, "merge-review": true, "t3-merge": true }] as const; + for (const phase of phases) + for (const tier of TIERS) + for (const gateVerdict of [undefined, "green", "red"] as const) + for (const critic of critics) + for (const ready of [undefined, false, true]) + for (const reviewDecision of [undefined, "changes-requested"] as const) + for (const humanApprovals of approvals) { + const a = nextAction(base({ phase, tier, gateVerdict, critic, ready, reviewDecision, humanApprovals, loop, loopParams })); + if (a.kind === "DONE") { + expect(ready).toBe(true); + expect(["merge-pending", "critiqued"]).toContain(phase); + if (phase === "critiqued") expect(critic).toMatchObject({ status: "completed", verdict: "accepted" }); + } + if (a.kind === "MARK_READY") { + expect(phase).toBe("critiqued"); + expect(critic).toMatchObject({ status: "completed", verdict: "accepted" }); + expect(ready).not.toBe(true); + } + if (a.kind === "STOP_FOR_HUMAN" && (a.stop === "merge-review" || a.stop === "t3-merge")) { + expect(ready).toBe(true); + } + } +}); + +test("[REQ-BIND-5] critiqued ohne critic-Ergebnis liefert nie MARK_READY/DONE", () => { + for (const tier of TIERS) + for (const ready of [undefined, false, true]) { + const a = nextAction(critiqued(undefined, { tier, ready })); + expect(["MARK_READY", "DONE"]).not.toContain(a.kind); + } +}); + +// --- REQ-BIND-3: changes-requested auf ready-PR -> zurück auf Draft ---------------------------------------- +test("[REQ-BIND-3] merge-pending + changes-requested -> RE_GEN mit markDraft: true (jedes Tier)", () => { + for (const tier of TIERS) { + const a = nextAction(base({ tier, phase: "merge-pending", gateVerdict: "green", ready: true, reviewDecision: "changes-requested" })); + expect(a).toEqual({ kind: "RE_GEN", feedback: "defect-signal", freshContext: false, markDraft: true }); + } +}); + +test("[REQ-BIND-3] markDraft kommt nur bei der Human-Rückkante, nicht beim roten Gate und nicht bei Critic-rejected", () => { + const red = nextAction(base({ phase: "gated", gateVerdict: "red", loop, loopParams })); + const rej = nextAction(critiqued(rejected, { loop, loopParams })); + for (const a of [red, rej]) { + expect(a.kind).toBe("RE_GEN"); + expect((a as { markDraft?: boolean }).markDraft ?? false).toBe(false); + } +}); diff --git a/test/core/finding-key.test.ts b/test/core/finding-key.test.ts new file mode 100644 index 0000000..d13cd1e --- /dev/null +++ b/test/core/finding-key.test.ts @@ -0,0 +1,80 @@ +import { test, expect } from "vitest"; +import { gateFindingKey, criticFindingKey, gateFinding } from "../../src/core/finding-key.js"; + +// --- REQ-KEY-1: Key-Ableitung nach Spec-Tabelle, ohne Zeilennummer, ohne Freitext ---------- +test("[REQ-KEY-1] Gate-Key: Test rot = test::", () => { + expect(gateFindingKey({ kind: "test", file: "test/a.test.ts", testName: "does x" })).toBe( + "test:test/a.test.ts:does x", + ); +}); + +test("[REQ-KEY-1] Gate-Key: Mutante überlebt = mutant:::", () => { + expect( + gateFindingKey({ kind: "mutant", file: "src/a.ts", fn: "foo", mutator: "ConditionalExpression" }), + ).toBe("mutant:src/a.ts:foo:ConditionalExpression"); +}); + +test("[REQ-KEY-1] Gate-Key: Semgrep = semgrep:::", () => { + expect(gateFindingKey({ kind: "semgrep", rule: "no-eval", file: "src/a.ts", fn: "foo" })).toBe( + "semgrep:no-eval:src/a.ts:foo", + ); +}); + +test("[REQ-KEY-1] Gate-Key: Trace-Lücke = trace:", () => { + expect(gateFindingKey({ kind: "trace", req: "REQ-X-1" })).toBe("trace:REQ-X-1"); +}); + +test("[REQ-KEY-1] Critic-Key = review:::", () => { + expect(criticFindingKey({ req: "REQ-X-1", klasse: "req-uncovered", datei: "src/a.ts" })).toBe( + "review:REQ-X-1:req-uncovered:src/a.ts", + ); + expect(criticFindingKey({ req: "-", klasse: "convention", datei: "src/b.ts" })).toBe( + "review:-:convention:src/b.ts", + ); +}); + +test("[REQ-KEY-1] verschiedene Bestandteile ergeben verschiedene Keys", () => { + const base = { kind: "test", file: "a", testName: "t" } as const; + expect(gateFindingKey(base)).not.toBe(gateFindingKey({ ...base, testName: "u" })); + expect(gateFindingKey(base)).not.toBe(gateFindingKey({ ...base, file: "b" })); + const c = { req: "REQ-X-1", klasse: "other", datei: "a.ts" }; + expect(criticFindingKey(c)).not.toBe(criticFindingKey({ ...c, klasse: "security" })); + expect(criticFindingKey(c)).not.toBe(criticFindingKey({ ...c, datei: "b.ts" })); + expect(criticFindingKey(c)).not.toBe(criticFindingKey({ ...c, req: "REQ-X-2" })); +}); + +// Probe (i): Key enthält Zeilennummer. Konstruktion: derselbe Befund, 3 Zeilen verschoben. +test("[REQ-KEY-1] (Probe i) Gate-Key ist zeilenunabhängig: gleicher Befund, 3 Zeilen verschoben => gleicher Key", () => { + expect(gateFindingKey({ kind: "test", file: "a", testName: "t", line: 10 })).toBe( + gateFindingKey({ kind: "test", file: "a", testName: "t", line: 13 }), + ); + expect(gateFindingKey({ kind: "test", file: "a", testName: "t", line: 10 })).toBe( + gateFindingKey({ kind: "test", file: "a", testName: "t" }), + ); + expect(gateFindingKey({ kind: "mutant", file: "a", fn: "f", mutator: "M", line: 40 })).toBe( + gateFindingKey({ kind: "mutant", file: "a", fn: "f", mutator: "M", line: 43 }), + ); + expect(gateFindingKey({ kind: "semgrep", rule: "r", file: "a", fn: "f", line: 7 })).toBe( + gateFindingKey({ kind: "semgrep", rule: "r", file: "a", fn: "f", line: 10 }), + ); +}); + +test("[REQ-KEY-1] (Probe i) Critic-Key ist zeilen- und textunabhängig", () => { + const a = { req: "REQ-X-1", klasse: "spec-deviation", datei: "src/a.ts", line: 10, claim: "Fehler hier" }; + const b = { ...a, line: 13, claim: "ganz anders formuliert" }; + expect(criticFindingKey(a)).toBe(criticFindingKey(b)); + expect(criticFindingKey(a)).toBe("review:REQ-X-1:spec-deviation:src/a.ts"); + expect(criticFindingKey(a)).not.toMatch(/\d{2}|Fehler/); +}); + +test("[REQ-KEY-1] gateFinding: source gate, Schweregrad immer blocker, Key aus gateFindingKey", () => { + const raws = [ + { kind: "test", file: "a", testName: "t" }, + { kind: "mutant", file: "a", fn: "f", mutator: "M" }, + { kind: "semgrep", rule: "r", file: "a", fn: "f" }, + { kind: "trace", req: "REQ-X-1" }, + ] as const; + for (const raw of raws) { + expect(gateFinding(raw)).toEqual({ key: gateFindingKey(raw), source: "gate", severity: "blocker" }); + } +}); diff --git a/test/core/gate-round.test.ts b/test/core/gate-round.test.ts new file mode 100644 index 0000000..447f47f --- /dev/null +++ b/test/core/gate-round.test.ts @@ -0,0 +1,215 @@ +import { test, expect } from "vitest"; +import { applyGateRound, parseGateRoundInput, type GateRoundInput } from "../../src/core/gate-round.js"; +import { applyRound, type Ledger, type LedgerEntry } from "../../src/core/ledger.js"; +import { gateFinding, type GateRaw } from "../../src/core/finding-key.js"; + +// ---- Helfer --------------------------------------------------------------------------------- +const tst = (testName: string, line?: number): GateRaw => ({ kind: "test", file: "t.test.ts", testName, ...(line ? { line } : {}) }); +const mut = (fn = "f"): GateRaw => ({ kind: "mutant", file: "src/a.ts", fn, mutator: "ArithmeticOperator" }); +const sem = (): GateRaw => ({ kind: "semgrep", rule: "no-eval", file: "src/a.ts", fn: "f" }); +const trc = (req = "REQ-X-1"): GateRaw => ({ kind: "trace", req }); +const key = (r: GateRaw) => gateFinding(r).key; +const input = (raws: GateRaw[], ran: GateRoundInput["ran"], addressed: string[] = []): GateRoundInput => ({ + raws, + ran, + addressed, +}); +const entry = (k: string, over: Partial = {}): LedgerEntry => ({ + key: k, + source: "gate", + severity: "blocker", + state: "open", + reopenCount: 0, + ...over, +}); +const critKey = "review:REQ-X-1:other:src/c.ts"; +const critEntry = (over: Partial = {}) => entry(critKey, { source: "critic", severity: "major", ...over }); + +// ---- REQ-GATE-1 ----------------------------------------------------------------------------- +test("[REQ-GATE-1] Gate-Befunde aller vier Arten landen unter ihrem gateFinding-Key als open blocker", () => { + const raws = [tst("a"), mut(), sem(), trc()]; + const r = applyGateRound({}, input(raws, ["test", "mutant", "semgrep", "trace"])); + expect(Object.keys(r.ledger).sort()).toEqual(raws.map(key).sort()); + for (const raw of raws) expect(r.ledger[key(raw)]).toEqual(entry(key(raw))); +}); + +test("[REQ-GATE-1] [REQ-KEY-1] derselbe Test mit anderer Zeilennummer -> derselbe Key, kein neuer Eintrag", () => { + const r1 = applyGateRound({}, input([tst("a", 10)], ["test"])); + const r2 = applyGateRound(r1.ledger, input([tst("a", 99)], ["test"])); + expect(Object.keys(r2.ledger)).toEqual([key(tst("a"))]); + expect(Object.keys(r2.ledger)).toEqual(Object.keys(r1.ledger)); + expect(r2.ledger[key(tst("a"))].state).toBe("open"); + expect(r2.ledger[key(tst("a"))].reopenCount).toBe(0); +}); + +test("[REQ-GATE-1] gleiche Eingabe wie applyRound mit gateFinding, wenn alle Arten gelaufen sind", () => { + const prev = applyGateRound({}, input([tst("a"), mut()], ["test", "mutant"])).ledger; + const raws = [tst("b"), mut()]; + const viaGate = applyGateRound(prev, input(raws, ["test", "mutant", "semgrep", "trace"], [])); + const viaRound = applyRound(prev, { findings: raws.map(gateFinding), addressed: [] }); + expect(viaGate).toEqual(viaRound); +}); + +test("[REQ-GATE-1] falsche Stagnation behoben: R1 Test A rot; R2 A grün, B rot -> progress true, judged true", () => { + const r1 = applyGateRound({}, input([tst("A")], ["test"])); + expect(r1.judged).toBe(false); + const r2 = applyGateRound(r1.ledger, input([tst("B")], ["test"])); + expect(r2.judged).toBe(true); + expect(r2.progress).toBe(true); + expect(r2.ledger[key(tst("A"))].state).toBe("closed"); + expect(r2.ledger[key(tst("B"))].state).toBe("open"); +}); + +test("[REQ-GATE-1] derselbe Test bleibt rot -> kein Fortschritt", () => { + const r1 = applyGateRound({}, input([tst("A")], ["test"])); + const r2 = applyGateRound(r1.ledger, input([tst("A")], ["test"])); + expect(r2.judged).toBe(true); + expect(r2.progress).toBe(false); +}); + +test("[REQ-GATE-1] Critic-Einträge bleiben bei Abwesenheit unverändert (auch bei ran mit allen Arten)", () => { + const prev: Ledger = { [critKey]: critEntry() }; + const r = applyGateRound(prev, input([tst("A")], ["test", "mutant", "semgrep", "trace"])); + expect(r.ledger[critKey]).toEqual(critEntry()); +}); + +test("[REQ-GATE-1] addressed setzt Critic-Keys auf fixed-claimed, Gate-Keys nicht", () => { + const gk = key(tst("A")); + const prev: Ledger = { [critKey]: critEntry(), [gk]: entry(gk) }; + const r = applyGateRound(prev, input([tst("A")], ["test"], [critKey, gk])); + expect(r.ledger[critKey].state).toBe("fixed-claimed"); + expect(r.ledger[gk].state).toBe("open"); +}); + +// ---- REQ-GATE-2 (Probe m) -------------------------------------------------------------------- +test("[REQ-GATE-2] (Probe m) mutant-Blocker offen, Runde ran=[test] -> mutant unverändert (open, reopenCount 0), nicht closed, kein Fortschritt", () => { + const mk = key(mut()); + const r1 = applyGateRound({}, input([mut()], ["test", "mutant"])); + expect(r1.ledger[mk].state).toBe("open"); + const r2 = applyGateRound(r1.ledger, input([tst("A")], ["test"])); + expect(r2.ledger[mk]).toEqual(entry(mk, { state: "open", reopenCount: 0 })); + expect(r2.judged).toBe(true); + expect(r2.progress).toBe(false); +}); + +test("[REQ-GATE-2] (Probe m) Gegenprobe: ran=[test,mutant], mutant nicht mehr gemeldet -> closed, progress true", () => { + const mk = key(mut()); + const r1 = applyGateRound({}, input([mut()], ["test", "mutant"])); + const r2 = applyGateRound(r1.ledger, input([tst("A")], ["test", "mutant"])); + expect(r2.ledger[mk].state).toBe("closed"); + expect(r2.progress).toBe(true); +}); + +test("[REQ-GATE-2] (Probe m) jede nicht gelaufene Art bleibt komplett unverändert (Zustand, reopenCount, Schweregrad)", () => { + const prev: Ledger = { + [key(tst("T"))]: entry(key(tst("T")), { state: "reopened", reopenCount: 1 }), + [key(mut())]: entry(key(mut()), { state: "fixed-claimed", reopenCount: 2, severity: "blocker" }), + [key(sem())]: entry(key(sem()), { state: "open", severity: "major" }), + [key(trc())]: entry(key(trc()), { state: "closed", reopenCount: 1 }), + }; + // ran = nur "test"; der Testbefund T bleibt gemeldet, die übrigen drei Arten sind nicht gelaufen + const r = applyGateRound(prev, input([tst("T")], ["test"])); + expect(r.ledger[key(mut())]).toEqual(prev[key(mut())]); + expect(r.ledger[key(sem())]).toEqual(prev[key(sem())]); + expect(r.ledger[key(trc())]).toEqual(prev[key(trc())]); +}); + +test("[REQ-GATE-2] (Probe m) Eingabe-Ledger wird nicht mutiert", () => { + const prev = applyGateRound({}, input([mut()], ["mutant"])).ledger; + const snapshot = JSON.parse(JSON.stringify(prev)); + applyGateRound(prev, input([], ["test"])); + expect(prev).toEqual(snapshot); +}); + +test("[REQ-GATE-2] Folgeprobe: R1 mutant offen; R2 mutant nicht gelaufen; R3 mutant wieder gemeldet -> kein reopened, reopenCount 0, oscillation false", () => { + const mk = key(mut()); + const r1 = applyGateRound({}, input([mut()], ["test", "mutant"])); + const r2 = applyGateRound(r1.ledger, input([tst("A")], ["test"])); + const r3 = applyGateRound(r2.ledger, input([tst("A"), mut()], ["test", "mutant"])); + expect(r3.ledger[mk].state).toBe("open"); + expect(r3.ledger[mk].state).not.toBe("reopened"); + expect(r3.ledger[mk].reopenCount).toBe(0); + expect(r3.oscillation).toBe(false); +}); + +test("[REQ-GATE-2] gelaufene Art, Befund fehlt -> closed (REQ-LEDGER-2 gilt für gelaufene Arten)", () => { + const r1 = applyGateRound({}, input([sem()], ["semgrep"])); + const r2 = applyGateRound(r1.ledger, input([], ["semgrep"])); + expect(r2.ledger[key(sem())].state).toBe("closed"); +}); + +test("[REQ-GATE-2] ran=[] -> alle Gate-Einträge unverändert, kein Fortschritt", () => { + const r1 = applyGateRound({}, input([tst("A"), mut()], ["test", "mutant"])); + const r2 = applyGateRound(r1.ledger, input([], [])); + expect(r2.ledger).toEqual(r1.ledger); + expect(r2.progress).toBe(false); +}); + +// ---- REQ-GATE-3 (Probe n) -------------------------------------------------------------------- +const okBase = { raws: [{ kind: "test", file: "t.ts", testName: "a" }], ran: ["test"], addressed: [] }; +const invalid: [string, unknown][] = [ + ["null", null], + ["String statt Objekt", "x"], + ["Zahl statt Objekt", 42], + ["Array statt Objekt", []], + ["ran fehlt", { raws: okBase.raws, addressed: [] }], + ["raws fehlt", { ran: ["test"], addressed: [] }], + ["addressed fehlt", { raws: okBase.raws, ran: ["test"] }], + ["raws kein Array", { ...okBase, raws: {} }], + ["ran kein Array", { ...okBase, ran: "test" }], + ["addressed kein Array", { ...okBase, addressed: "k" }], + ["raw kein Objekt", { ...okBase, raws: ["test"] }], + ["raw mit unbekannter Art", { ...okBase, raws: [{ kind: "lint", file: "a" }] }], + ["raw ohne kind", { ...okBase, raws: [{ file: "t.ts", testName: "a" }] }], + ["ran-Eintrag unbekannte Art", { ...okBase, ran: ["test", "lint"] }], + ["ran-Eintrag kein String", { ...okBase, ran: ["test", 1] }], + ["raw.kind nicht in ran", { ...okBase, ran: ["mutant"] }], + ["raw.kind nicht in ran (ran leer)", { ...okBase, ran: [] }], + ["addressed-Eintrag kein String", { ...okBase, addressed: [1] }], + ["test ohne file", { ...okBase, raws: [{ kind: "test", testName: "a" }] }], + ["test ohne testName", { ...okBase, raws: [{ kind: "test", file: "t.ts" }] }], + ["test mit leerem file", { ...okBase, raws: [{ kind: "test", file: "", testName: "a" }] }], + ["test mit leerem testName", { ...okBase, raws: [{ kind: "test", file: "t.ts", testName: "" }] }], + ["test mit file als Zahl", { ...okBase, raws: [{ kind: "test", file: 1, testName: "a" }] }], + ["mutant ohne file", { ...okBase, ran: ["mutant"], raws: [{ kind: "mutant", fn: "f", mutator: "m" }] }], + ["mutant ohne fn", { ...okBase, ran: ["mutant"], raws: [{ kind: "mutant", file: "a", mutator: "m" }] }], + ["mutant ohne mutator", { ...okBase, ran: ["mutant"], raws: [{ kind: "mutant", file: "a", fn: "f" }] }], + ["mutant mit leerem mutator", { ...okBase, ran: ["mutant"], raws: [{ kind: "mutant", file: "a", fn: "f", mutator: "" }] }], + ["semgrep ohne rule", { ...okBase, ran: ["semgrep"], raws: [{ kind: "semgrep", file: "a", fn: "f" }] }], + ["semgrep ohne file", { ...okBase, ran: ["semgrep"], raws: [{ kind: "semgrep", rule: "r", fn: "f" }] }], + ["semgrep ohne fn", { ...okBase, ran: ["semgrep"], raws: [{ kind: "semgrep", rule: "r", file: "a" }] }], + ["semgrep mit leerer rule", { ...okBase, ran: ["semgrep"], raws: [{ kind: "semgrep", rule: "", file: "a", fn: "f" }] }], + ["trace ohne req", { ...okBase, ran: ["trace"], raws: [{ kind: "trace" }] }], + ["trace mit leerem req", { ...okBase, ran: ["trace"], raws: [{ kind: "trace", req: "" }] }], + ["gültiger und ungültiger raw gemischt", { ...okBase, raws: [okBase.raws[0], { kind: "test", file: "t.ts" }] }], +]; + +for (const [name, bad] of invalid) { + test(`[REQ-GATE-3] (Probe n) ungültig -> undefined: ${name}`, () => { + expect(parseGateRoundInput(bad)).toBeUndefined(); + }); +} + +test("[REQ-GATE-3] gültige Eingaben aller Arten werden angenommen; line optional und ignoriert", () => { + const raws = [ + { kind: "test", file: "t.ts", testName: "a", line: 3 }, + { kind: "mutant", file: "a", fn: "f", mutator: "m" }, + { kind: "semgrep", rule: "r", file: "a", fn: "f", line: 1 }, + { kind: "trace", req: "REQ-X-1" }, + ]; + const p = parseGateRoundInput({ raws, ran: ["test", "mutant", "semgrep", "trace"], addressed: ["k"] }); + expect(p).toBeDefined(); + expect(p!.raws).toHaveLength(4); + expect(p!.ran).toEqual(["test", "mutant", "semgrep", "trace"]); + expect(p!.addressed).toEqual(["k"]); +}); + +test("[REQ-GATE-3] gültig: leere raws mit leerem ran und leerem addressed", () => { + expect(parseGateRoundInput({ raws: [], ran: [], addressed: [] })).toBeDefined(); +}); + +test("[REQ-GATE-3] zusätzliche Felder in der Eingabe (z. B. progress) machen sie nicht ungültig und tauchen im Ergebnis nicht auf", () => { + const p = parseGateRoundInput({ ...okBase, progress: true, status: "completed" }); + expect(p).toBeDefined(); + expect(p).not.toHaveProperty("progress"); +}); diff --git a/test/core/ledger.test.ts b/test/core/ledger.test.ts new file mode 100644 index 0000000..a55417e --- /dev/null +++ b/test/core/ledger.test.ts @@ -0,0 +1,490 @@ +import { test, expect } from "vitest"; +import { + applyRound, + deriveVerdict, + renderLedgerTable, + type Ledger, + type LedgerEntry, + type EntryState, +} from "../../src/core/ledger.js"; +import type { Finding, Severity, Source } from "../../src/core/finding-key.js"; +import { nextLoopDecision, type LoopState } from "../../src/core/loop.js"; + +// ---- Helfer: Befunde und Ledger von Hand bauen (unabhängig von finding-key.ts) ------------- +const gate = (name: string): Finding => ({ key: `test:f.test.ts:${name}`, source: "gate", severity: "blocker" }); +const crit = (name: string, severity: Severity = "major"): Finding => ({ + key: `review:-:other:${name}.ts`, + source: "critic", + severity, +}); +const gk = (name: string) => gate(name).key; +const ck = (name: string) => crit(name).key; + +const entry = ( + key: string, + source: Source, + severity: Severity, + state: EntryState, + reopenCount = 0, +): LedgerEntry => ({ key, source, severity, state, reopenCount }); +const L = (...es: LedgerEntry[]): Ledger => Object.fromEntries(es.map((e) => [e.key, e])); +const round = (findings: Finding[], addressed: string[] = []) => ({ findings, addressed }); + +function deepFreeze(o: T): T { + if (o && typeof o === "object") { + Object.freeze(o); + for (const v of Object.values(o as object)) deepFreeze(v); + } + return o; +} + +// ---- REQ-LEDGER-1 --------------------------------------------------------------------------- +test("[REQ-LEDGER-1] neuer Befund wird als open mit seinem Schweregrad geführt, reopenCount 0", () => { + const r = applyRound({}, round([gate("a"), crit("b", "nit")])); + expect(r.ledger[gk("a")]).toEqual(entry(gk("a"), "gate", "blocker", "open")); + expect(r.ledger[ck("b")]).toEqual(entry(ck("b"), "critic", "nit", "open")); +}); + +test("[REQ-LEDGER-1] je Key genau ein Eintrag, auch bei doppelt gemeldetem Key; Zustand bleibt im erlaubten Satz", () => { + const r = applyRound({}, round([crit("a", "nit"), crit("a", "blocker")])); + expect(Object.keys(r.ledger)).toEqual([ck("a")]); + expect(["open", "fixed-claimed", "closed", "reopened"]).toContain(r.ledger[ck("a")].state); + expect(r.ledger[ck("a")].severity).toBe("blocker"); +}); + +test("[REQ-LEDGER-1] (Probe j) derselbe Critic-Key zweimal in einer Runde, blocker dann nit => severity blocker", () => { + const r = applyRound({}, round([crit("a", "blocker"), crit("a", "nit")])); + expect(Object.keys(r.ledger)).toEqual([ck("a")]); + expect(r.ledger[ck("a")].severity).toBe("blocker"); +}); + +test("[REQ-LEDGER-1] doppelt gemeldeter Key erhöht reopenCount nur einmal pro Runde", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "closed")); + const r = applyRound(prev, round([gate("a"), gate("a")])); + expect(r.ledger[gk("a")].state).toBe("reopened"); + expect(r.ledger[gk("a")].reopenCount).toBe(1); +}); + +test("[REQ-LEDGER-1] applyRound verändert den übergebenen Vorgänger-Ledger nicht", () => { + const prev = deepFreeze(L(entry(gk("a"), "gate", "blocker", "open"), entry(ck("b"), "critic", "major", "open"))); + expect(() => applyRound(prev, round([crit("c")], [ck("b")]))).not.toThrow(); +}); + +// Probe (j): Schweregrad = letzter statt höchster gesehener +test("[REQ-LEDGER-1] (Probe j) Schweregrad ist der höchste je gesehene, nicht der letzte", () => { + const r1 = applyRound({}, round([crit("a", "blocker")])); + const r2 = applyRound(r1.ledger, round([crit("a", "nit")])); + expect(r2.ledger[ck("a")].severity).toBe("blocker"); + const r3 = applyRound(r2.ledger, round([crit("a", "major")])); + expect(r3.ledger[ck("a")].severity).toBe("blocker"); +}); + +test("[REQ-LEDGER-1] (Probe j) Schweregrad steigt nit -> major -> blocker", () => { + let l: Ledger = {}; + for (const s of ["nit", "major", "blocker"] as const) l = applyRound(l, round([crit("a", s)])).ledger; + expect(l[ck("a")].severity).toBe("blocker"); +}); + +test("[REQ-LEDGER-1] (Probe j) herabgestufter Befund bleibt für das Verdikt blocker", () => { + const r1 = applyRound({}, round([crit("a", "blocker")])); + const r2 = applyRound(r1.ledger, round([crit("a", "nit")])); + expect(deriveVerdict(r2.ledger)).toBe("rejected"); +}); + +// ---- REQ-LEDGER-2 --------------------------------------------------------------------------- +test("[REQ-LEDGER-2] Gate-Befund, der in der Folgerunde fehlt, wird closed", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "open")); + const r = applyRound(prev, round([])); + expect(r.ledger[gk("a")].state).toBe("closed"); +}); + +test("[REQ-LEDGER-2] auch ein reopened Gate-Befund wird closed, wenn er fehlt (in jeder Runde)", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "reopened", 1)); + const r = applyRound(prev, round([gate("other")])); + expect(r.ledger[gk("a")].state).toBe("closed"); + expect(r.ledger[gk("a")].reopenCount).toBe(1); +}); + +test("[REQ-LEDGER-2] Gate-Befunde schließen auch in einer Runde, in der nichts offen war (nur Gate-closed)", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "closed")); + const r = applyRound(prev, round([])); + expect(r.ledger[gk("a")]).toEqual(entry(gk("a"), "gate", "blocker", "closed")); +}); + +test("[REQ-LEDGER-2] ein weiterhin gemeldeter Gate-Befund bleibt offen", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "open")); + const r = applyRound(prev, round([gate("a")])); + expect(r.ledger[gk("a")].state).toBe("open"); +}); + +// ---- REQ-LEDGER-3 (Probe d) ------------------------------------------------------------------- +test("[REQ-LEDGER-3] (Probe d) fehlender Critic-Befund bleibt unverändert, wird NICHT closed", () => { + const states: EntryState[] = ["open", "reopened", "fixed-claimed", "closed"]; + for (const st of states) { + const e = entry(ck("a"), "critic", "major", st, st === "reopened" ? 1 : 0); + const r = applyRound(L(e), round([crit("other")])); + expect(r.ledger[ck("a")]).toEqual(e); + } +}); + +test("[REQ-LEDGER-3] (Probe d) Critic-Befund, den ein frischer Critic nicht zieht, hält das Verdikt auf rejected", () => { + const r1 = applyRound({}, round([crit("a", "blocker")])); + const r2 = applyRound(r1.ledger, round([])); // frischer Critic: keine Befunde, implement meldet nichts + expect(r2.ledger[ck("a")].state).toBe("open"); + expect(deriveVerdict(r2.ledger)).toBe("rejected"); +}); + +// ---- REQ-LEDGER-4 --------------------------------------------------------------------------- +test("[REQ-LEDGER-4] adressierter Critic-Befund (open) wird fixed-claimed", () => { + const prev = L(entry(ck("a"), "critic", "major", "open")); + const r = applyRound(prev, round([], [ck("a")])); + expect(r.ledger[ck("a")].state).toBe("fixed-claimed"); +}); + +test("[REQ-LEDGER-4] adressierter Critic-Befund (reopened) wird fixed-claimed; reopenCount bleibt", () => { + const prev = L(entry(ck("a"), "critic", "major", "reopened", 1)); + const r = applyRound(prev, round([], [ck("a")])); + expect(r.ledger[ck("a")].state).toBe("fixed-claimed"); + expect(r.ledger[ck("a")].reopenCount).toBe(1); +}); + +test("[REQ-LEDGER-4] addressed mit unbekanntem Key wird ignoriert (kein neuer Eintrag)", () => { + const prev = L(entry(ck("a"), "critic", "major", "open")); + const r = applyRound(prev, round([], ["review:-:other:unbekannt.ts"])); + expect(Object.keys(r.ledger)).toEqual([ck("a")]); + expect(r.ledger[ck("a")].state).toBe("open"); +}); + +test("[REQ-LEDGER-4] addressed mit Gate-Key wird ignoriert (Gate entscheidet, nicht implement)", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "open")); + const r = applyRound(prev, round([gate("a")], [gk("a")])); + expect(r.ledger[gk("a")].state).toBe("open"); +}); + +test("[REQ-LEDGER-4] addressed ändert keinen Critic-Eintrag, der schon closed/fixed-claimed ist", () => { + for (const st of ["closed", "fixed-claimed"] as const) { + const e = entry(ck("a"), "critic", "major", st); + expect(applyRound(L(e), round([], [ck("a")])).ledger[ck("a")]).toEqual(e); + } +}); + +// ---- REQ-LEDGER-5 --------------------------------------------------------------------------- +test("[REQ-LEDGER-5] closed Befund erneut gemeldet -> reopened, reopenCount +1", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "closed")); + const r = applyRound(prev, round([gate("a")])); + expect(r.ledger[gk("a")]).toEqual(entry(gk("a"), "gate", "blocker", "reopened", 1)); +}); + +test("[REQ-LEDGER-5] fixed-claimed Befund erneut gemeldet -> reopened, reopenCount +1", () => { + const prev = L(entry(ck("a"), "critic", "major", "fixed-claimed")); + const r = applyRound(prev, round([crit("a")])); + expect(r.ledger[ck("a")]).toEqual(entry(ck("a"), "critic", "major", "reopened", 1)); +}); + +test("[REQ-LEDGER-5] reopenCount zählt weiter: zweites Wiederöffnen -> 2", () => { + let l = L(entry(gk("a"), "gate", "blocker", "closed", 1)); + l = applyRound(l, round([gate("a")])).ledger; + expect(l[gk("a")].reopenCount).toBe(2); +}); + +test("[REQ-LEDGER-5] bekannter Key in open/reopened, erneut gemeldet: Zustand und reopenCount unverändert", () => { + const o = applyRound(L(entry(ck("a"), "critic", "major", "open")), round([crit("a")])); + expect(o.ledger[ck("a")]).toEqual(entry(ck("a"), "critic", "major", "open", 0)); + const re = applyRound(L(entry(ck("a"), "critic", "major", "reopened", 1)), round([crit("a")])); + expect(re.ledger[ck("a")]).toEqual(entry(ck("a"), "critic", "major", "reopened", 1)); +}); + +test("[REQ-LEDGER-5] addressed wird vor dem Abgleich angewendet: adressiert UND erneut gemeldet -> reopened", () => { + const prev = L(entry(ck("a"), "critic", "major", "open")); + const r = applyRound(prev, round([crit("a")], [ck("a")])); + expect(r.ledger[ck("a")].state).toBe("reopened"); + expect(r.ledger[ck("a")].reopenCount).toBe(1); +}); + +test("[REQ-LEDGER-5] reopened verhält sich wie open: kann wieder closed (Gate) werden", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "reopened", 1)); + expect(applyRound(prev, round([])).ledger[gk("a")].state).toBe("closed"); +}); + +// ---- REQ-LEDGER-6 (Fortschritt) ----------------------------------------------------------- +test("[REQ-LEDGER-6] Fortschritt: ein offener Gate-blocker wird in der Runde closed", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "open")); + const r = applyRound(prev, round([])); + expect(r.judged).toBe(true); + expect(r.progress).toBe(true); +}); + +test("[REQ-LEDGER-6] Fortschritt: ein offener Critic-major wird adressiert -> fixed-claimed", () => { + const prev = L(entry(ck("a"), "critic", "major", "open")); + const r = applyRound(prev, round([], [ck("a")])); + expect(r.progress).toBe(true); +}); + +test("[REQ-LEDGER-6] Fortschritt auch für einen zu Rundenbeginn reopened blocker", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "reopened", 1)); + expect(applyRound(prev, round([])).progress).toBe(true); +}); + +test("[REQ-LEDGER-6] kein Fortschritt: offener blocker bleibt offen (nichts adressiert, weiter gemeldet)", () => { + const prev = L(entry(gk("a"), "gate", "blocker", "open")); + const r = applyRound(prev, round([gate("a")])); + expect(r.judged).toBe(true); + expect(r.progress).toBe(false); +}); + +test("[REQ-LEDGER-6] kein Fortschritt: Critic-Befund fehlt nur im Output (Abwesenheit ist keine Evidenz)", () => { + const prev = L(entry(ck("a"), "critic", "major", "open")); + const r = applyRound(prev, round([])); + expect(r.judged).toBe(true); + expect(r.progress).toBe(false); +}); + +// Probe (e): „kein reopened"-Klausel gestrichen +test("[REQ-LEDGER-6] (Probe e) A adressiert, B taucht nach fixed-claimed wieder auf -> kein Fortschritt", () => { + const r1 = applyRound({}, round([crit("A"), crit("B")])); + expect(r1.judged).toBe(false); + const r2 = applyRound(r1.ledger, round([crit("B")], [ck("A"), ck("B")])); + expect(r2.ledger[ck("A")].state).toBe("fixed-claimed"); + expect(r2.ledger[ck("B")].state).toBe("reopened"); + expect(r2.judged).toBe(true); + expect(r2.progress).toBe(false); + expect(r2.oscillation).toBe(false); + const decision = nextLoopDecision( + { iteration: 1, gateChangedNotCode: false, freshContextUsed: false, lastRound: r2 }, + { maxIter: 5 }, + ); + expect(decision).toBe("FRESH_CONTEXT_RETRY"); +}); + +test("[REQ-LEDGER-6] nur in DIESER Runde wiedergeöffnete Befunde blockieren den Fortschritt; ein schon reopened Key tut es nicht", () => { + let l = applyRound({}, round([gate("A")])).ledger; + l = applyRound(l, round([gate("B")])).ledger; // A closed, B open + l = applyRound(l, round([])).ledger; // B closed + l = applyRound(l, round([gate("B"), gate("C")])).ledger; // B reopened, C open (nichts offen zu Beginn) + // Runde 5: C geschlossen (fehlt), B kehrt aus reopened nicht... B bleibt gemeldet (open-artig) + const r5 = applyRound(l, round([gate("B")])); + expect(r5.ledger[gk("C")].state).toBe("closed"); + expect(r5.ledger[gk("B")].state).toBe("reopened"); + // B war schon reopened (nicht neu in dieser Runde) und C wurde geschlossen: Fortschritt. + expect(r5.progress).toBe(true); +}); + +// Probe (f): Nits zählen für Fortschritt +test("[REQ-LEDGER-6] (Probe f) kein Fortschritt, wenn nur ein Nit adressiert wird und der blocker offen bleibt", () => { + const prev = L(entry(gk("A"), "gate", "blocker", "open"), entry(ck("n"), "critic", "nit", "open")); + const r = applyRound(prev, round([gate("A")], [ck("n")])); + expect(r.ledger[ck("n")].state).toBe("fixed-claimed"); + expect(r.judged).toBe(true); + expect(r.progress).toBe(false); +}); + +test("[REQ-LEDGER-9] (Probe f) ein wiedergeöffnetes Nit blockiert den Fortschritt nicht", () => { + const prev = L(entry(gk("A"), "gate", "blocker", "open"), entry(ck("n"), "critic", "nit", "fixed-claimed")); + const r = applyRound(prev, round([crit("n", "nit")])); + expect(r.ledger[ck("n")].state).toBe("reopened"); + expect(r.ledger[gk("A")].state).toBe("closed"); + expect(r.progress).toBe(true); +}); + +test("[REQ-LEDGER-6] ein in der Runde wiedergeöffneter major verhindert Fortschritt, auch wenn ein anderer blocker closed wurde", () => { + const prev = L( + entry(gk("A"), "gate", "blocker", "open"), + entry(ck("m"), "critic", "major", "fixed-claimed"), + ); + const r = applyRound(prev, round([crit("m", "major")])); + expect(r.ledger[gk("A")].state).toBe("closed"); + expect(r.ledger[ck("m")].state).toBe("reopened"); + expect(r.progress).toBe(false); +}); + +// ---- REQ-LEDGER-7 (Oszillation) ---------------------------------------------------------- +test("[REQ-LEDGER-7] zweites Wiederöffnen eines Gate-blockers in einer Runde -> oscillation", () => { + let l = applyRound({}, round([gate("A")])).ledger; // R1 open + l = applyRound(l, round([])).ledger; // R2 closed + const r3 = applyRound(l, round([gate("A")])); // R3 reopened (1) + expect(r3.ledger[gk("A")].reopenCount).toBe(1); + expect(r3.oscillation).toBe(false); + const r4 = applyRound(r3.ledger, round([])); // R4 closed + const r5 = applyRound(r4.ledger, round([gate("A")])); // R5 reopened (2) + expect(r5.ledger[gk("A")].reopenCount).toBe(2); + expect(r5.oscillation).toBe(true); +}); + +test("[REQ-LEDGER-7] erstes Wiederöffnen (reopenCount 1) ist noch keine Oszillation", () => { + const prev = L(entry(gk("A"), "gate", "blocker", "closed")); + const r = applyRound(prev, round([gate("A")])); + expect(r.ledger[gk("A")].reopenCount).toBe(1); + expect(r.oscillation).toBe(false); +}); + +test("[REQ-LEDGER-7] Critic-major, das zum zweiten Mal wiederöffnet, löst oscillation aus", () => { + const prev = L(entry(ck("m"), "critic", "major", "fixed-claimed", 1)); + const r = applyRound(prev, round([crit("m", "major")])); + expect(r.ledger[ck("m")].reopenCount).toBe(2); + expect(r.oscillation).toBe(true); +}); + +test("[REQ-LEDGER-7] oscillation gilt für DIESE Runde: ein schon reopened Key (Count 2), der nur weiter gemeldet wird, löst sie nicht erneut aus", () => { + const prev = L(entry(gk("A"), "gate", "blocker", "reopened", 2)); + const r = applyRound(prev, round([gate("A")])); + expect(r.oscillation).toBe(false); +}); + +test("[REQ-LEDGER-9] Nit mit reopenCount 2 löst keine oscillation aus", () => { + const prev = L(entry(ck("n"), "critic", "nit", "fixed-claimed", 1)); + const r = applyRound(prev, round([crit("n", "nit")])); + expect(r.ledger[ck("n")].reopenCount).toBe(2); + expect(r.oscillation).toBe(false); +}); + +// ---- REQ-LEDGER-11 / Probe (l) -------------------------------------------------------------- +test("[REQ-LEDGER-11] (Probe l) erste Runde auf leerem Ledger ist nicht bewertet (weder Fortschritt noch Stagnation)", () => { + const r = applyRound({}, round([gate("A"), gate("B")])); + expect(r.judged).toBe(false); + expect(r.progress).toBe(false); +}); + +test("[REQ-LEDGER-11] Runde ohne offenen blocker/major zu Rundenbeginn ist nicht bewertet (nur closed/fixed-claimed/nit offen)", () => { + const prev = L( + entry(gk("A"), "gate", "blocker", "closed"), + entry(ck("B"), "critic", "major", "fixed-claimed"), + entry(ck("n"), "critic", "nit", "open"), + ); + const r = applyRound(prev, round([gate("X")])); + expect(r.judged).toBe(false); +}); + +test("[REQ-LEDGER-11] judged = true sobald zu Rundenbeginn ein blocker/major open ODER reopened ist", () => { + expect(applyRound(L(entry(ck("a"), "critic", "major", "open")), round([])).judged).toBe(true); + expect(applyRound(L(entry(ck("a"), "critic", "blocker", "reopened", 1)), round([])).judged).toBe(true); +}); + +test("[REQ-LEDGER-11] 'zu Rundenbeginn' heißt vor addressed: ein in dieser Runde adressierter Befund zählt als offen zu Beginn", () => { + const prev = L(entry(ck("a"), "critic", "major", "open")); + expect(applyRound(prev, round([], [ck("a")])).judged).toBe(true); +}); + +test("[REQ-LEDGER-11] Ende-zu-Ende: erste bewertete Runde verbrennt den Fresh-Retry nicht", () => { + const r = applyRound({}, round([gate("A")])); + const d = nextLoopDecision( + { iteration: 0, gateChangedNotCode: false, freshContextUsed: false, lastRound: r }, + { maxIter: 5 }, + ); + expect(d).toBe("RE_GEN"); +}); + +// ---- Tabellenfälle der Spec Ende-zu-Ende (Abnahme 1) ---------------------------------------- +const mkState = (lastRound: LoopState["lastRound"]): LoopState => ({ + iteration: 1, + gateChangedNotCode: false, + freshContextUsed: false, + lastRound, +}); + +test("[REQ-LEDGER-6] (Probe a) Tabellenfall 'falsche Stagnation': Gate A rot -> A behoben, B und C laufen an und sind rot => RE_GEN", () => { + const r1 = applyRound({}, round([gate("A")])); + const r2 = applyRound(r1.ledger, round([gate("B"), gate("C")])); + expect(r2.ledger[gk("A")].state).toBe("closed"); + expect(r2.judged).toBe(true); + expect(r2.progress).toBe(true); // 1 -> 2 Befunde, trotzdem Fortschritt + expect(nextLoopDecision(mkState(r2), { maxIter: 5 })).toBe("RE_GEN"); +}); + +test("[REQ-LEDGER-6] (Probe a) Tabellenfall 'falscher Fortschritt': Critic A,B -> implement behebt nichts, frischer Critic zieht nur A => FRESH_CONTEXT_RETRY", () => { + const r1 = applyRound({}, round([crit("A"), crit("B")])); + const r2 = applyRound(r1.ledger, round([crit("A")], [])); // frischer Critic sieht nur A; nichts adressiert + expect(r2.ledger[ck("B")].state).toBe("open"); // Abwesenheit ist keine Evidenz + expect(r2.judged).toBe(true); + expect(r2.progress).toBe(false); // 2 -> 1 Befunde, trotzdem Stagnation + expect(nextLoopDecision(mkState(r2), { maxIter: 5 })).toBe("FRESH_CONTEXT_RETRY"); +}); + +test("[REQ-LEDGER-6] (Probe a) Anzahl gleich, aber echter Fortschritt: A closed, B neu => RE_GEN (gleiche Zählerwerte 1 -> 1)", () => { + const r1 = applyRound({}, round([gate("A")])); + const r2 = applyRound(r1.ledger, round([gate("B")])); + expect(r2.progress).toBe(true); + expect(nextLoopDecision(mkState(r2), { maxIter: 5 })).toBe("RE_GEN"); +}); + +// ---- REQ-CRITIC-5: Verdikt aus dem Ledger ableiten (Probe c) ------------------------------------ +test("[REQ-CRITIC-5] leerer Ledger -> accepted", () => { + expect(deriveVerdict({})).toBe("accepted"); +}); + +test("[REQ-CRITIC-5] offener blocker -> rejected; offener major -> rejected; reopened major -> rejected", () => { + expect(deriveVerdict(L(entry(ck("a"), "critic", "blocker", "open")))).toBe("rejected"); + expect(deriveVerdict(L(entry(ck("a"), "critic", "major", "open")))).toBe("rejected"); + expect(deriveVerdict(L(entry(ck("a"), "critic", "major", "reopened", 1)))).toBe("rejected"); +}); + +test("[REQ-CRITIC-5] accepted genau dann, wenn kein blocker/major open|reopened: nits, closed und fixed-claimed zählen nicht", () => { + const l = L( + entry(ck("n"), "critic", "nit", "open"), + entry(ck("n2"), "critic", "nit", "reopened", 2), + entry(ck("a"), "critic", "blocker", "closed"), + entry(ck("b"), "critic", "major", "fixed-claimed"), + ); + expect(deriveVerdict(l)).toBe("accepted"); +}); + +test("[REQ-CRITIC-5] (Probe c) ein einziger offener blocker neben vielen erledigten Einträgen genügt für rejected", () => { + const l = L( + entry(ck("a"), "critic", "major", "closed"), + entry(ck("b"), "critic", "blocker", "open"), + entry(ck("c"), "critic", "nit", "closed"), + ); + expect(deriveVerdict(l)).toBe("rejected"); +}); + +test("[REQ-CRITIC-5] Verdikt aus applyRound: Critic-Run mit nur Nits -> accepted", () => { + const r = applyRound({}, round([crit("n", "nit")])); + expect(deriveVerdict(r.ledger)).toBe("accepted"); +}); + +// ---- REQ-LEDGER-10: Tabelle --------------------------------------------------------------------- +test("[REQ-LEDGER-10] renderLedgerTable: Markdown-Tabelle mit allen Keys", () => { + const l = L( + entry(gk("a"), "gate", "blocker", "open"), + entry(ck("b"), "critic", "major", "closed"), + ); + const t = renderLedgerTable(l); + expect(typeof t).toBe("string"); + expect(t).toContain(gk("a")); + expect(t).toContain(ck("b")); + expect(t.split("\n").filter((x) => x.includes("|")).length).toBeGreaterThanOrEqual(3); // Kopf, Trenner/Zeilen +}); + +test("[REQ-LEDGER-10] offene blocker/major stehen zuoberst, vor allen übrigen Zeilen", () => { + const l = L( + entry("K-closed", "critic", "blocker", "closed"), + entry("K-claimed", "critic", "major", "fixed-claimed"), + entry("K-nit-open", "critic", "nit", "open"), + entry("K-open-major", "critic", "major", "open"), + entry("K-reopened-blocker", "gate", "blocker", "reopened", 1), + ); + const t = renderLedgerTable(l); + const idx = (k: string) => t.indexOf(k); + const top = Math.max(idx("K-open-major"), idx("K-reopened-blocker")); + for (const other of ["K-closed", "K-claimed", "K-nit-open"]) { + expect(idx(other)).toBeGreaterThan(top); + } +}); + +test("[REQ-LEDGER-10] fixed-claimed erscheint ausdrücklich als 'behoben (unverifiziert)'", () => { + const l = L(entry("K-claimed", "critic", "major", "fixed-claimed")); + const line = renderLedgerTable(l).split("\n").find((x) => x.includes("K-claimed")); + expect(line).toBeDefined(); + expect(line).toContain("behoben (unverifiziert)"); +}); + +test("[REQ-LEDGER-10] 'behoben (unverifiziert)' steht nur bei fixed-claimed, nicht bei closed/open", () => { + const l = L( + entry("K-closed", "gate", "blocker", "closed"), + entry("K-open", "critic", "major", "open"), + ); + expect(renderLedgerTable(l)).not.toContain("unverifiziert"); +}); + +test("[REQ-LEDGER-10] leerer Ledger lässt sich rendern", () => { + expect(typeof renderLedgerTable({})).toBe("string"); +}); diff --git a/test/core/loop.test.ts b/test/core/loop.test.ts index e5763a8..0251a79 100644 --- a/test/core/loop.test.ts +++ b/test/core/loop.test.ts @@ -1,25 +1,29 @@ import { test, expect } from "vitest"; -import { - nextLoopDecision, - type LoopState, - type LoopParams, -} from "../../src/core/loop.js"; +import { nextLoopDecision, type LoopState, type LoopParams } from "../../src/core/loop.js"; -const params: LoopParams = { maxIter: 5, requireStrictlyDecreasing: true }; +const params: LoopParams = { maxIter: 5 }; + +type Last = NonNullable; +const PROGRESS: Last = { judged: true, progress: true, oscillation: false }; +const STAGNATION: Last = { judged: true, progress: false, oscillation: false }; const state = (over: Partial = {}): LoopState => ({ iteration: 1, - errorCounts: [10, 7], gateChangedNotCode: false, freshContextUsed: false, + lastRound: PROGRESS, ...over, }); test("gate tampering escalates immediately, with top priority", () => { - // even when also at max iter and stagnating, tamper wins + // even when also at max iter, oscillating and stagnating, tamper wins expect( nextLoopDecision( - state({ gateChangedNotCode: true, iteration: 99, errorCounts: [5, 5] }), + state({ + gateChangedNotCode: true, + iteration: 99, + lastRound: { judged: true, progress: false, oscillation: true }, + }), params, ), ).toBe("ESCALATE_GATE_TAMPER"); @@ -29,22 +33,97 @@ test("reaching max iterations escalates", () => { expect(nextLoopDecision(state({ iteration: 5 }), params)).toBe("ESCALATE_MAX_ITER"); }); -test("strictly decreasing errors under max iter -> RE_GEN", () => { - expect(nextLoopDecision(state({ errorCounts: [10, 7, 4] }), params)).toBe("RE_GEN"); +test("below max iterations does not escalate for max-iter", () => { + expect(nextLoopDecision(state({ iteration: 4 }), params)).toBe("RE_GEN"); +}); + +test("[REQ-LEDGER-7] max-iter has priority over oscillation", () => { + expect( + nextLoopDecision( + state({ iteration: 5, lastRound: { judged: true, progress: false, oscillation: true } }), + params, + ), + ).toBe("ESCALATE_MAX_ITER"); +}); + +test("[REQ-LEDGER-6] Fortschritt der letzten Runde -> RE_GEN", () => { + expect(nextLoopDecision(state({ lastRound: PROGRESS }), params)).toBe("RE_GEN"); +}); + +test("stagnation (bewertete Runde ohne Fortschritt) bricht den Kontext-Lock-in mit einem Fresh-Context-Retry", () => { + expect(nextLoopDecision(state({ lastRound: STAGNATION, freshContextUsed: false }), params)).toBe( + "FRESH_CONTEXT_RETRY", + ); +}); + +test("stagnation nach dem Fresh-Context-Retry eskaliert", () => { + expect(nextLoopDecision(state({ lastRound: STAGNATION, freshContextUsed: true }), params)).toBe( + "ESCALATE_STAGNATION", + ); +}); + +test("first iteration without any round (lastRound null) keeps going (RE_GEN)", () => { + expect(nextLoopDecision(state({ iteration: 0, lastRound: null }), params)).toBe("RE_GEN"); + expect(nextLoopDecision(state({ iteration: 0, lastRound: null, freshContextUsed: true }), params)).toBe( + "RE_GEN", + ); +}); + +// REQ-LEDGER-7 / Probe (g) +test("[REQ-LEDGER-7] (Probe g) Oszillation eskaliert VOR der Stagnationsprüfung (kein Fresh-Retry)", () => { + const osc: Last = { judged: true, progress: false, oscillation: true }; + expect(nextLoopDecision(state({ lastRound: osc, freshContextUsed: false }), params)).toBe( + "ESCALATE_OSCILLATION", + ); + expect(nextLoopDecision(state({ lastRound: osc, freshContextUsed: true }), params)).toBe( + "ESCALATE_OSCILLATION", + ); +}); + +test("[REQ-LEDGER-7] Oszillation eskaliert auch bei gleichzeitigem Fortschritt", () => { + expect( + nextLoopDecision(state({ lastRound: { judged: true, progress: true, oscillation: true } }), params), + ).toBe("ESCALATE_OSCILLATION"); }); -test("stagnation breaks in-session lock-in with one fresh-context retry first", () => { +test("[REQ-LEDGER-7] Oszillation eskaliert auch in einer nicht bewerteten Runde (unabhängig von REQ-LEDGER-11)", () => { expect( - nextLoopDecision(state({ errorCounts: [7, 7], freshContextUsed: false }), params), - ).toBe("FRESH_CONTEXT_RETRY"); + nextLoopDecision(state({ lastRound: { judged: false, progress: false, oscillation: true } }), params), + ).toBe("ESCALATE_OSCILLATION"); }); -test("stagnation after the fresh-context retry escalates", () => { +test("[REQ-LEDGER-7] Gate-Tamper hat Vorrang vor Oszillation", () => { expect( - nextLoopDecision(state({ errorCounts: [7, 7], freshContextUsed: true }), params), - ).toBe("ESCALATE_STAGNATION"); + nextLoopDecision( + state({ gateChangedNotCode: true, lastRound: { judged: true, progress: false, oscillation: true } }), + params, + ), + ).toBe("ESCALATE_GATE_TAMPER"); +}); + +// REQ-LEDGER-11 / Probe (l) +test("[REQ-LEDGER-11] (Probe l) nicht bewertete Runde (judged=false) ist keine Stagnation -> RE_GEN", () => { + const unjudged: Last = { judged: false, progress: false, oscillation: false }; + expect(nextLoopDecision(state({ lastRound: unjudged, freshContextUsed: false }), params)).toBe("RE_GEN"); +}); + +test("[REQ-LEDGER-11] (Probe l) nicht bewertete Runde verbrennt den Fresh-Retry nicht, auch wenn er schon verbraucht ist -> RE_GEN", () => { + const unjudged: Last = { judged: false, progress: false, oscillation: false }; + expect(nextLoopDecision(state({ lastRound: unjudged, freshContextUsed: true }), params)).toBe("RE_GEN"); +}); + +// REQ-LEDGER-8 / Probe (a): errorCounts entfällt, Entscheidung nur aus lastRound +test("[REQ-LEDGER-8] (Probe a) ein veraltetes errorCounts-Feld beeinflusst die Entscheidung nicht (fallend, aber Stagnation)", () => { + const s = { ...state({ lastRound: STAGNATION }), errorCounts: [10, 7, 4] } as unknown as LoopState; + expect(nextLoopDecision(s, params)).toBe("FRESH_CONTEXT_RETRY"); +}); + +test("[REQ-LEDGER-8] (Probe a) ein veraltetes errorCounts-Feld beeinflusst die Entscheidung nicht (steigend, aber Fortschritt)", () => { + const s = { ...state({ lastRound: PROGRESS }), errorCounts: [1, 2] } as unknown as LoopState; + expect(nextLoopDecision(s, params)).toBe("RE_GEN"); }); -test("first iteration without history keeps going (RE_GEN)", () => { - expect(nextLoopDecision(state({ iteration: 0, errorCounts: [10] }), params)).toBe("RE_GEN"); +test("[REQ-LEDGER-8] LoopParams kennt requireStrictlyDecreasing nicht mehr: ein übergebener Wert ändert nichts", () => { + const p = { maxIter: 5, requireStrictlyDecreasing: false } as unknown as LoopParams; + expect(nextLoopDecision(state({ lastRound: STAGNATION }), p)).toBe("FRESH_CONTEXT_RETRY"); }); diff --git a/test/core/resume.test.ts b/test/core/resume.test.ts index 5ac81f9..77c1650 100644 --- a/test/core/resume.test.ts +++ b/test/core/resume.test.ts @@ -1,4 +1,5 @@ import { test, expect } from "vitest"; +import { nextAction } from "../../src/core/driver.js"; import { reconstructState, rollupStatus, type PrFacts } from "../../src/core/resume.js"; const facts = (over: Partial): PrFacts => ({ @@ -6,6 +7,7 @@ const facts = (over: Partial): PrFacts => ({ merged: false, reviewDecision: "none", checks: "green", + draft: false, ...over, }); @@ -17,6 +19,44 @@ test("impl PR, green, changes requested -> merge-pending with a rework signal", expect(r.done).toBe(false); }); +test("[REQ-BIND-4] impl PR, green, nicht draft -> merge-pending und ready", () => { + const r = reconstructState(facts({ checks: "green", draft: false })); + expect(r.phase).toBe("merge-pending"); + expect(r.gateVerdict).toBe("green"); + expect(r.ready).toBe(true); +}); + +// Probe (k): reconstructState ignoriert draft +test("[REQ-BIND-4] (Probe k) impl PR, green, DRAFT -> gated/green (Critic steht aus), nicht merge-pending", () => { + const r = reconstructState(facts({ checks: "green", draft: true })); + expect(r.phase).toBe("gated"); + expect(r.gateVerdict).toBe("green"); + expect(r.done).toBe(false); + expect(r.ready).not.toBe(true); +}); + +test("[REQ-BIND-5] (Probe k) Draft + grün ist für KEINE Review-Entscheidung merge-pending und nie ready", () => { + for (const reviewDecision of ["none", "approved", "changes-requested"] as const) { + const r = reconstructState(facts({ checks: "green", draft: true, reviewDecision })); + expect(r.phase).not.toBe("merge-pending"); + expect(r.ready).not.toBe(true); + expect(r.phase).toBe("gated"); + expect(r.gateVerdict).toBe("green"); + } +}); + +test("[REQ-BIND-4] Draft ändert rote/ausstehende Checks nicht: red -> gated/red, pending -> implemented", () => { + expect(reconstructState(facts({ checks: "red", draft: true })).gateVerdict).toBe("red"); + expect(reconstructState(facts({ checks: "red", draft: true })).phase).toBe("gated"); + expect(reconstructState(facts({ checks: "pending", draft: true })).phase).toBe("implemented"); +}); + +test("[REQ-BIND-4] Draft-PR aus resume speist den Driver in den Critic (nicht in Merge/DONE)", () => { + const r = reconstructState(facts({ checks: "green", draft: true })); + const a = nextAction({ tier: "T1", guardians: { ok: true, missing: [] }, phase: r.phase, gateVerdict: r.gateVerdict, humanApprovals: r.humanApprovals, ready: r.ready }); + expect(a).toEqual({ kind: "SPAWN_STATION", station: "critic" }); +}); + test("impl PR, green, approved -> merge-pending with the merge approvals set", () => { const r = reconstructState(facts({ reviewDecision: "approved" })); expect(r.phase).toBe("merge-pending"); @@ -56,3 +96,11 @@ test("rollupStatus maps GitHub check rollups", () => { expect(rollupStatus([{ status: "COMPLETED", conclusion: "SUCCESS" }, { status: "COMPLETED", conclusion: "SKIPPED" }])).toBe("green"); expect(rollupStatus([{ status: "COMPLETED", conclusion: "SUCCESS" }, { status: "COMPLETED", conclusion: "FAILURE" }])).toBe("red"); }); + +// Typ-Wächter (REQ-BIND-4): `draft` ist Pflichtfeld. Ist es optional, ist das Literal gültig, die +// expect-error-Direktive unbenutzt und `npm run typecheck` wird rot (fehlendes draft wirkte sonst als "nicht Draft" = fail-open). +test("[REQ-BIND-4] PrFacts.draft ist Pflichtfeld (Typ-Wächter via npm run typecheck)", () => { + // @ts-expect-error draft fehlt: muss ein Typfehler sein + const missing: PrFacts = { headBranch: "devloop/x", merged: false, reviewDecision: "none", checks: "green" }; + expect(missing.headBranch).toBe("devloop/x"); +}); diff --git a/test/evidence/before-counter.ts b/test/evidence/before-counter.ts new file mode 100644 index 0000000..c853ac1 --- /dev/null +++ b/test/evidence/before-counter.ts @@ -0,0 +1,18 @@ +// @ts-nocheck +// Vorher-Nachweis (kein vitest-Test, Name ohne ".test."): die beiden Tabellenfälle der Spec als +// errorCounts (= Anzahl blocker/major je Runde) durch die gepinnte Fixture loop-before.ts (= src/core/loop.ts aus 1a74327, alter Zähler-Stand +// mit errorCounts + requireStrictlyDecreasing). Unabhängig vom aktuellen src/ reproduzierbar. +// Aufruf: npx tsx test/evidence/before-counter.ts +import { nextLoopDecision } from "./loop-before.js"; + +const params = { maxIter: 5, requireStrictlyDecreasing: true }; // Parameter aus dem alten loop.test.ts +const decide = (errorCounts: number[]) => + nextLoopDecision({ iteration: 1, errorCounts, gateChangedNotCode: false, freshContextUsed: false }, params); + +const cases = [ + { name: "Falsche Stagnation (Gate: A rot -> A behoben, B und C laufen an)", counts: [1, 2], wahrheit: "Fortschritt => RE_GEN" }, + { name: "Falscher Fortschritt (Critic: A,B -> implement behebt nichts, frischer Critic zieht nur A)", counts: [2, 1], wahrheit: "Stagnation => FRESH_CONTEXT_RETRY" }, +]; +for (const c of cases) { + console.log(`${c.name}\n errorCounts=${JSON.stringify(c.counts)} heutige Entscheidung=${decide(c.counts)} (Wahrheit: ${c.wahrheit})`); +} diff --git a/test/evidence/loop-before.ts b/test/evidence/loop-before.ts new file mode 100644 index 0000000..e882d58 --- /dev/null +++ b/test/evidence/loop-before.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +// Gepinnte Fixture: src/core/loop.ts aus 1a74327 (Stand VOR dem Ledger, errorCounts-Zähler). Nicht ändern. +// Back-edge termination + escalation (design §5#2 / §10 duty 2). Pure decision over the +// loop state. The thresholds (maxIter, progress criterion) are INJECTED parameters, never +// defaulted in code — they are pilot measurements (§1.1/§12), not a design choice here. + +export interface LoopState { + iteration: number; + errorCounts: number[]; // history of error/finding counts, oldest -> newest + gateChangedNotCode: boolean; // the "fix" was a diff on the protected set + freshContextUsed: boolean; // whether the one fresh-context retry was already spent +} + +export interface LoopParams { + maxIter: number; + requireStrictlyDecreasing: boolean; // progress := strictly fewer errors than before +} + +export type LoopDecision = + | "RE_GEN" + | "FRESH_CONTEXT_RETRY" + | "ESCALATE_STAGNATION" + | "ESCALATE_MAX_ITER" + | "ESCALATE_GATE_TAMPER"; + +function isStagnating(errorCounts: number[], requireStrictlyDecreasing: boolean): boolean { + if (errorCounts.length < 2) return false; // no history yet -> can't judge + const last = errorCounts[errorCounts.length - 1]; + const prev = errorCounts[errorCounts.length - 2]; + const improved = requireStrictlyDecreasing ? last < prev : last <= prev; + return !improved; +} + +export function nextLoopDecision(state: LoopState, params: LoopParams): LoopDecision { + // Priority 1: tampering with the guardians is an alarm, not progress. + if (state.gateChangedNotCode) return "ESCALATE_GATE_TAMPER"; + // Priority 2: hard iteration ceiling. + if (state.iteration >= params.maxIter) return "ESCALATE_MAX_ITER"; + // Priority 3: stagnation -> one fresh context to break in-session lock-in, then stop. + if (isStagnating(state.errorCounts, params.requireStrictlyDecreasing)) { + return state.freshContextUsed ? "ESCALATE_STAGNATION" : "FRESH_CONTEXT_RETRY"; + } + // Otherwise: feed the defect signal back and re-generate. + return "RE_GEN"; +}