From 7400074bcdd629f67fb5234bc2a2d813c911a38e Mon Sep 17 00:00:00 2001 From: Josemi Date: Fri, 31 Jul 2026 16:02:00 +0200 Subject: [PATCH] feat: diffstat scoreboard per fase en SUMMARY.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Automated tests and verification - Pattern Audit Report — feat(summary): diffstat scoreboard per fase en SU - Implementer Report — feat(summary): diffstat scoreboard per fase en SUMM --- src/git.ts | 36 +++++++++++ src/metadata.ts | 12 +++- src/runner.ts | 33 +++++++--- src/workspace.ts | 51 ++++++++++++++- test/git.test.ts | 142 ++++++++++++++++++++++++++++++++++++++++- test/metadata.test.ts | 39 +++++++++++ test/runner.test.ts | 65 +++++++++++++++++++ test/workspace.test.ts | 113 +++++++++++++++++++++++++++++++- 8 files changed, 477 insertions(+), 14 deletions(-) diff --git a/src/git.ts b/src/git.ts index 6199d3f..4b05556 100644 --- a/src/git.ts +++ b/src/git.ts @@ -414,6 +414,42 @@ export async function diffStat(base: string, head: string, cwd: string): Promise return result.exitCode === 0 ? result.stdout : "" } +export type DiffTotals = { + files: number + insertions: number + deletions: number +} + +/** + * Returns per-file insertion/deletion counts for the range `base..head` by + * parsing `git diff --numstat`. Returns `undefined` when the range is empty or + * git cannot resolve it (e.g. a read-only phase that made no commit). + * + * Uses `--numstat` instead of `--stat` because its machine-readable + * `\t\t` output is locale-independent and trivially + * summable, whereas the human-readable `--stat` summary line varies across git + * versions and locales. + */ +export async function diffTotals(base: string, head: string, cwd: string): Promise { + const result = await execFile("git", ["diff", "--numstat", `${base}..${head}`], { cwd, allowFailure: true }) + if (result.exitCode !== 0 || result.stdout.trim() === "") return undefined + + let files = 0 + let insertions = 0 + let deletions = 0 + + for (const line of result.stdout.split("\n")) { + if (!line.trim()) continue + const [added, deleted] = line.split("\t") + files++ + // Binary files are represented as "-\t-\t"; count as 1 file, 0 lines. + if (added !== "-") insertions += parseInt(added ?? "0", 10) + if (deleted !== "-") deletions += parseInt(deleted ?? "0", 10) + } + + return files === 0 ? undefined : { files, insertions, deletions } +} + /** Points `` at ``. Used to stash a pre-rewrite HEAD under refs/convoy/. */ export async function updateRef(ref: string, sha: string, cwd: string) { await execFile("git", ["update-ref", ref, sha], { cwd }) diff --git a/src/metadata.ts b/src/metadata.ts index 69c9a53..d7f9543 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { log } from "./log" import { isSafeStepName } from "./pipeline" -import type { RepoSnapshot } from "./git" +import type { DiffTotals, RepoSnapshot } from "./git" import type { ProgressPhaseSnapshot, ProgressStepUsage, @@ -35,6 +35,7 @@ export type PhaseMetadata = { repositoryBaseline?: RepoSnapshot advisor?: AdvisorPhaseAggregate advisorEvents?: AdvisorEvent[] + diff?: DiffTotals } export type RunMetadata = { @@ -68,6 +69,8 @@ export type RunMetadataStore = { repositoryBaseline(name: string): RepoSnapshot | undefined phaseRepositoryBaseline(name: string, baseline: RepoSnapshot): Promise phaseEnded(name: string, status: "completed" | "skipped" | "failed"): void + recordPhaseDiff(name: string, diff: DiffTotals): void + phaseDiff(name: string): DiffTotals | undefined controlState(): RunControlState setControlState(state: RunControlState): Promise flush(): Promise @@ -254,6 +257,13 @@ export async function openRunMetadata( if (entry.startedAt !== undefined) entry.durationMs = entry.endedAt - entry.startedAt void persist() }, + recordPhaseDiff(name, diff) { + phase(name).diff = diff + scheduleSave() + }, + phaseDiff(name) { + return data.phases[name]?.diff + }, controlState() { return data.control.state }, diff --git a/src/runner.ts b/src/runner.ts index fa511b9..ba69102 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -15,7 +15,7 @@ import { opencodeConfig } from "./agents" import { fileParts } from "./attachments" import { Caffeinate } from "./caffeinate" import { ensureClaudeAvailable, promptClaudePhase } from "./claude-code" -import { addAllAndCommit, createCleanRepoSnapshot, describeRepoSnapshotDifference, dirtyFilesPreview, dirtyTreeError, ensureRepoReady, restoreRepoSnapshot, type RepoSnapshot, statusPorcelain, writeDiff } from "./git" +import { addAllAndCommit, createCleanRepoSnapshot, describeRepoSnapshotDifference, diffTotals, dirtyFilesPreview, dirtyTreeError, ensureRepoReady, resolveCommit, restoreRepoSnapshot, type DiffTotals, type RepoSnapshot, statusPorcelain, writeDiff } from "./git" import { hookPhaseNames, hooksForPipeline, runHooks, type HookStage } from "./hooks" import { getSessionEventHub, payloadProperties } from "./event-hub" import { runHumanReviewGate } from "./human" @@ -44,7 +44,7 @@ import { discoverProjectContextFiles } from "./project-context" import { createStepRunnerImpl, stepRunnerFor, stepRunnerModel, type StepRunnerId, type StepRunnerImpl } from "./step-runners" import type { AgentSpec, AgentStep, HookSet, HookSpec, Pipeline, RunOptions, Step } from "./types" import { addTokens, emptyTokens, tokensFromValue } from "./usage" -import { cleanupWorkspace, createWorkspace, opencodeConfigDir, resumeWorkspace, type Workspace, writeSummary } from "./workspace" +import { cleanupWorkspace, createWorkspace, opencodeConfigDir, renderScoreboard, resumeWorkspace, type Workspace, writeSummary } from "./workspace" export type ActiveSession = { client: OpencodeClient @@ -619,10 +619,17 @@ export async function run(options: RunOptions) { progress.message("writing run summary") const advisorSection = advisorNeeds.agents.size > 0 ? renderAdvisorSplit(await readAdvisorSplit(workspace.dir)) : undefined + const scoreboardRows = pipeline.steps.map((step) => ({ + name: step.name, + status: runMetadata.phaseStatus(step.name), + diff: runMetadata.phaseDiff(step.name), + })) + const scoreboard = renderScoreboard(scoreboardRows) await writeSummary( workspace, pipeline.steps.map((step) => step.name), advisorSection ? [advisorSection] : [], + scoreboard, ) postHooksStarted = true await runHooks("post", hookSet.post, { @@ -893,7 +900,8 @@ async function runPhase( const assistantText = await runPhaseWithRetries(client, workspace, phase, options.targetDir, prepared, baseline, progress, shutdown, gitLock, takeover, undefined, advisors) return persistPhaseReport(workspace, phase, assistantText) }) - await gitLock(() => finalizePhaseRepository(phase, reportAbs, options.targetDir, baseline)) + const phaseDiff = await gitLock(() => finalizePhaseRepository(phase, reportAbs, options.targetDir, baseline)) + if (phaseDiff) metadata.recordPhaseDiff(phase.name, phaseDiff) progress.phaseCompleted(phase.name, "report saved and commit checked") } catch (error) { progress.phaseFailed(phase.name, formatSdkError(error)) @@ -1211,14 +1219,16 @@ async function persistPhaseReport(workspace: Workspace, phase: AgentStep, assist return reportAbs } -async function commitPhase(phase: AgentStep, reportAbs: string, targetDir: string) { +async function commitPhase(phase: AgentStep, reportAbs: string, targetDir: string): Promise { const message = `convoy(${phase.name}): ${await summaryFromReport(reportAbs)}` const committed = await addAllAndCommit(message, targetDir) if (!committed) { log.info(`[${phase.name}] no changes - no commit`) - } else { - log.info(`[${phase.name}] commit: ${message}`) + return undefined } + log.info(`[${phase.name}] commit: ${message}`) + // Resolve the new HEAD so the caller can compute a per-phase diffstat. + return resolveCommit("HEAD", targetDir) } export async function finalizePhaseRepository( @@ -1227,17 +1237,20 @@ export async function finalizePhaseRepository( targetDir: string, baseline: RepoSnapshot | undefined, originalError?: unknown, -): Promise { +): Promise { if (!phase.readOnly) { - await commitPhase(phase, reportAbs, targetDir) - return + const newHead = await commitPhase(phase, reportAbs, targetDir) + if (newHead && baseline) { + return diffTotals(baseline.head, newHead, targetDir) + } + return undefined } if (!baseline) throw new Error(`[${phase.name}] read-only step has no clean repository baseline`) const difference = await describeRepoSnapshotDifference(baseline, targetDir) if (!difference) { log.info(`[${phase.name}] read-only step left the repository unchanged`) - return + return undefined } if (originalError instanceof ReadOnlyRepositoryMutationError) throw originalError diff --git a/src/workspace.ts b/src/workspace.ts index d78c5f5..9859cea 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -2,6 +2,9 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises" import { homedir } from "node:os" import { isAbsolute, join, relative, resolve } from "node:path" +import type { DiffTotals } from "./git" +import type { PhaseMetadataStatus } from "./metadata" + export type Workspace = { dir: string runID: string @@ -37,9 +40,55 @@ export async function cleanupWorkspace(workspace: Workspace) { await rm(workspace.dir, { recursive: true, force: true }) } -export async function writeSummary(workspace: Workspace, phaseNames: string[], extraSections: readonly string[] = []) { +export type ScoreboardRow = { + name: string + status: PhaseMetadataStatus | undefined + diff: DiffTotals | undefined +} + +/** + * Renders the per-phase diffstat scoreboard as a Markdown table. + * + * Returns `undefined` when every phase has no diff data (all-read-only or + * no-commit run), so the caller can omit the section entirely. + */ +export function renderScoreboard(rows: ScoreboardRow[]): string | undefined { + const withData = rows.filter((row) => row.diff !== undefined) + if (withData.length === 0) return undefined + + const totalFiles = withData.reduce((sum, row) => sum + row.diff!.files, 0) + const totalInsertions = withData.reduce((sum, row) => sum + row.diff!.insertions, 0) + const totalDeletions = withData.reduce((sum, row) => sum + row.diff!.deletions, 0) + const totalDelta = totalInsertions - totalDeletions + + const lines: string[] = [ + "## Scoreboard", + "", + "| Phase | Files | + | − | Δ net |", + "|---|---|---|---|---|", + ] + + for (const row of rows) { + if (row.diff) { + const delta = row.diff.insertions - row.diff.deletions + const deltaStr = delta >= 0 ? `+${delta}` : `${delta}` + lines.push(`| ${row.name} | ${row.diff.files} | +${row.diff.insertions} | −${row.diff.deletions} | ${deltaStr} |`) + } else { + lines.push(`| ${row.name} | — | — | — | — |`) + } + } + + const totalDeltaStr = totalDelta >= 0 ? `+${totalDelta}` : `${totalDelta}` + lines.push(`| **Total** | **${totalFiles}** | **+${totalInsertions}** | **−${totalDeletions}** | **${totalDeltaStr}** |`) + + return lines.join("\n") +} + +export async function writeSummary(workspace: Workspace, phaseNames: string[], extraSections: readonly string[] = [], scoreboard?: string) { const chunks: string[] = [`# convoy run ${workspace.runID} - summary`, ""] + if (scoreboard) chunks.push(scoreboard, "") + for (const section of extraSections) chunks.push(section, "") for (const name of phaseNames) { diff --git a/test/git.test.ts b/test/git.test.ts index dc34e89..f3f8f3a 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { addAllAndCommit, addWorktree, branchExists, detectBaseRef, ensureRepoReady, findSuspiciousStagedFiles, initializeRepoWithInitialCommit, repoBootstrapStatus } from "../src/git" +import { addAllAndCommit, addWorktree, branchExists, detectBaseRef, diffTotals, ensureRepoReady, findSuspiciousStagedFiles, initializeRepoWithInitialCommit, repoBootstrapStatus } from "../src/git" describe("findSuspiciousStagedFiles", () => { test("flags common secret filenames", () => { @@ -405,3 +405,143 @@ describe("addWorktree", () => { expect(await branch.exited).toBe(0) }) }) + +describe("diffTotals", () => { + const dirs: string[] = [] + afterAll(async () => { + await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }))) + }) + + async function git(args: string[], cwd: string) { + const proc = Bun.spawn(["git", "-c", "commit.gpgsign=false", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "convoy-test", + GIT_AUTHOR_EMAIL: "convoy-test@example.invalid", + GIT_COMMITTER_NAME: "convoy-test", + GIT_COMMITTER_EMAIL: "convoy-test@example.invalid", + }, + }) + const stdout = await new Response(proc.stdout).text() + if ((await proc.exited) !== 0) throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`) + return stdout.trim() + } + + async function repo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "convoy-difftotals-")) + dirs.push(dir) + await git(["init", "-q", "-b", "main"], dir) + await writeFile(join(dir, "base.txt"), "line1\nline2\nline3\n") + await git(["add", "-A"], dir) + await git(["commit", "-q", "-m", "initial"], dir) + return dir + } + + test("returns undefined when the range is empty (no diff)", async () => { + const dir = await repo() + const head = await git(["rev-parse", "HEAD"], dir) + expect(await diffTotals(head, head, dir)).toBeUndefined() + }) + + test("counts insertions for a new file", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + await writeFile(join(dir, "new.ts"), "export const x = 1\nexport const y = 2\n") + await git(["add", "-A"], dir) + await git(["commit", "-q", "-m", "add new file"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toEqual({ files: 1, insertions: 2, deletions: 0 }) + }) + + test("counts deletions for a removed file", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + await git(["rm", "base.txt"], dir) + await git(["commit", "-q", "-m", "remove file"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toEqual({ files: 1, insertions: 0, deletions: 3 }) + }) + + test("counts both insertions and deletions for a modified file", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + await writeFile(join(dir, "base.txt"), "line1\nline2\nnew-line\n") + await git(["add", "-A"], dir) + await git(["commit", "-q", "-m", "modify file"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toEqual({ files: 1, insertions: 1, deletions: 1 }) + }) + + test("aggregates multiple files in the same commit", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + await writeFile(join(dir, "a.ts"), "const a = 1\n") + await writeFile(join(dir, "b.ts"), "const b = 2\nconst c = 3\n") + await git(["add", "-A"], dir) + await git(["commit", "-q", "-m", "add two files"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toEqual({ files: 2, insertions: 3, deletions: 0 }) + }) + + test("counts binary file as 1 file with 0 lines", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + // Write a proper binary with null bytes; git treats null-byte files as binary. + const binaryContent = new Uint8Array(16) + binaryContent[0] = 0x00 // null byte forces git to detect binary + binaryContent[1] = 0x89 + binaryContent[2] = 0x50 + binaryContent[3] = 0x4e + binaryContent[4] = 0x47 + binaryContent.fill(0x00, 5) // rest are null bytes + await Bun.write(join(dir, "image.bin"), binaryContent) + await git(["add", "-A"], dir) + await git(["commit", "-q", "-m", "add binary"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toEqual({ files: 1, insertions: 0, deletions: 0 }) + }) + + test("returns undefined when git cannot resolve the range", async () => { + const dir = await repo() + expect(await diffTotals("nonexistent-sha", "HEAD", dir)).toBeUndefined() + }) + + test("handles file renames correctly", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + await git(["mv", "base.txt", "renamed.txt"], dir) + await git(["commit", "-q", "-m", "rename base.txt to renamed.txt"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toEqual({ files: 1, insertions: 0, deletions: 0 }) + }) + + test("handles a single-commit range correctly", async () => { + const dir = await repo() + const base = await git(["rev-parse", "HEAD"], dir) + await writeFile(join(dir, "feat.ts"), "export const feature = true\n") + await git(["add", "-A"], dir) + await git(["commit", "-q", "-m", "add feature"], dir) + const head = await git(["rev-parse", "HEAD"], dir) + + const result = await diffTotals(base, head, dir) + expect(result).toBeDefined() + expect(result?.files).toBe(1) + expect(result?.insertions).toBeGreaterThan(0) + expect(result?.deletions).toBe(0) + }) +}) diff --git a/test/metadata.test.ts b/test/metadata.test.ts index 915fa42..0861f70 100644 --- a/test/metadata.test.ts +++ b/test/metadata.test.ts @@ -340,4 +340,43 @@ describe("run metadata", () => { expect((await readRunMetadata(join(ws.dir, "metadata.json")))?.control).toEqual({ state: "running" }) }) + + test("recordPhaseDiff persists diff and phaseDiff retrieves it", async () => { + const ws = await workspace() + const store = await openRunMetadata(ws, "/repo", quick) + const diff = { files: 3, insertions: 42, deletions: 7 } + + store.recordPhaseDiff("implementer", diff) + await store.flush() + + const persisted = await readRunMetadata(join(ws.dir, "metadata.json")) + expect(persisted?.phases.implementer?.diff).toEqual(diff) + + const resumed = await openRunMetadata(ws, "/repo", quick) + expect(resumed.phaseDiff("implementer")).toEqual(diff) + }) + + test("phaseDiff returns undefined for a phase with no diff (legacy metadata)", async () => { + const ws = await workspace() + const path = join(ws.dir, "metadata.json") + const now = Date.now() + // Write metadata without any diff field, simulating a v3 run without this feature. + await writeFile( + path, + JSON.stringify({ + schemaVersion: 3, + runID: ws.runID, + targetDir: "/repo", + createdAt: now, + updatedAt: now, + control: { state: "running" }, + phases: { implementer: { status: "completed" } }, + pipeline: quick, + }), + ) + + const store = await openRunMetadata(ws, "/repo", quick) + expect(store.phaseDiff("implementer")).toBeUndefined() + expect(store.phaseDiff("nonexistent")).toBeUndefined() + }) }) diff --git a/test/runner.test.ts b/test/runner.test.ts index 97e4a8a..1607e4e 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -1161,6 +1161,71 @@ describe("watchSession turn scoping", () => { }) }) +describe("finalizePhaseRepository diffstat", () => { + test("returns DiffTotals after committing a write phase", async () => { + const dir = await cleanRepo() + const baseline = await createCleanRepoSnapshot(dir) + if (!baseline) throw new Error("expected a clean baseline") + + const phase = agentStep("implement") + const reportDir = join(dir, "reports") + await mkdir(reportDir, { recursive: true }) + await writeFile(join(reportDir, "implement.md"), "# Report\n\nDone.\n") + await writeFile(join(dir, "feature.ts"), "export const x = 1\nexport const y = 2\n") + + const result = await finalizePhaseRepository(phase, join(reportDir, "implement.md"), dir, baseline) + expect(result).toBeDefined() + expect(result?.files).toBeGreaterThanOrEqual(1) + expect(result?.insertions).toBeGreaterThan(0) + expect(result?.deletions).toBe(0) + }) + + test("returns undefined when a write phase commits nothing", async () => { + const dir = await cleanRepo() + const baseline = await createCleanRepoSnapshot(dir) + if (!baseline) throw new Error("expected a clean baseline") + + const phase = agentStep("implement") + const reportDir = join(dir, "reports") + await mkdir(reportDir, { recursive: true }) + // Pre-commit the report file so the working tree is already clean when + // finalizePhaseRepository runs (addAllAndCommit returns false → undefined diff). + await writeFile(join(reportDir, "implement.md"), "# Report\n") + await git(["add", "-A"], dir) + await git(["commit", "-qm", "pre-commit report"], dir) + + // Now the tree is clean — no changes to commit → addAllAndCommit returns false + const newBaseline = await createCleanRepoSnapshot(dir) + if (!newBaseline) throw new Error("expected clean baseline after pre-commit") + const result = await finalizePhaseRepository(phase, join(reportDir, "implement.md"), dir, newBaseline) + expect(result).toBeUndefined() + }) + + test("returns undefined for a write phase when baseline is undefined", async () => { + const dir = await cleanRepo() + + const phase = agentStep("implement") + const reportDir = join(dir, "reports") + await mkdir(reportDir, { recursive: true }) + await writeFile(join(reportDir, "implement.md"), "# Report\n") + await writeFile(join(dir, "feature.ts"), "export const x = 1\n") + + const result = await finalizePhaseRepository(phase, join(reportDir, "implement.md"), dir, undefined) + expect(result).toBeUndefined() + }) + + test("returns undefined for a read-only phase that made no changes", async () => { + const dir = await cleanRepo() + const baseline = await createCleanRepoSnapshot(dir) + if (!baseline) throw new Error("expected a clean baseline") + + const phase = { ...agentStep("security"), readOnly: true } + // No changes in tree: finalizePhaseRepository for read-only returns undefined when unchanged + const result = await finalizePhaseRepository(phase, "", dir, baseline) + expect(result).toBeUndefined() + }) +}) + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/test/workspace.test.ts b/test/workspace.test.ts index 4e38490..2a079d4 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -4,7 +4,8 @@ import { isAbsolute, join, relative } from "node:path" import { describe, expect, test } from "bun:test" -import { createWorkspace, isValidRunID, runDir, runsRoot } from "../src/workspace" +import { createWorkspace, isValidRunID, renderScoreboard, runDir, runsRoot, writeSummary } from "../src/workspace" +import type { ScoreboardRow } from "../src/workspace" describe("workspace run IDs", () => { test("accepts generated run ID shape", () => { @@ -44,3 +45,113 @@ describe("workspace run IDs", () => { } }) }) + +describe("renderScoreboard", () => { + test("returns undefined when all rows have no diff", () => { + const rows: ScoreboardRow[] = [ + { name: "review", status: "completed", diff: undefined }, + { name: "security", status: "completed", diff: undefined }, + ] + expect(renderScoreboard(rows)).toBeUndefined() + }) + + test("returns undefined for an empty row list", () => { + expect(renderScoreboard([])).toBeUndefined() + }) + + test("renders a scoreboard with one phase that has diff data", () => { + const rows: ScoreboardRow[] = [ + { name: "implement", status: "completed", diff: { files: 2, insertions: 41, deletions: 3 } }, + ] + const result = renderScoreboard(rows) + expect(result).toBeDefined() + expect(result).toContain("## Scoreboard") + expect(result).toContain("| implement | 2 | +41 | −3 | +38 |") + expect(result).toContain("| **Total** | **2** | **+41** | **−3** | **+38** |") + }) + + test("renders dashes for phases without diff data", () => { + const rows: ScoreboardRow[] = [ + { name: "implement", status: "completed", diff: { files: 2, insertions: 41, deletions: 3 } }, + { name: "security", status: "completed", diff: undefined }, + ] + const result = renderScoreboard(rows) + expect(result).toBeDefined() + expect(result).toContain("| implement | 2 | +41 | −3 | +38 |") + expect(result).toContain("| security | — | — | — | — |") + expect(result).toContain("| **Total** | **2** | **+41** | **−3** | **+38** |") + }) + + test("renders negative delta correctly", () => { + const rows: ScoreboardRow[] = [ + { name: "fixer", status: "completed", diff: { files: 1, insertions: 3, deletions: 10 } }, + ] + const result = renderScoreboard(rows) + expect(result).toContain("| fixer | 1 | +3 | −10 | -7 |") + expect(result).toContain("| **Total** | **1** | **+3** | **−10** | **-7** |") + }) + + test("aggregates totals across multiple phases with data", () => { + const rows: ScoreboardRow[] = [ + { name: "implement", status: "completed", diff: { files: 2, insertions: 41, deletions: 3 } }, + { name: "tests", status: "completed", diff: { files: 3, insertions: 20, deletions: 5 } }, + { name: "review", status: "completed", diff: undefined }, + ] + const result = renderScoreboard(rows) + expect(result).toBeDefined() + expect(result).toContain("| **Total** | **5** | **+61** | **−8** | **+53** |") + }) +}) + +describe("writeSummary scoreboard integration", () => { + test("inserts scoreboard after header when provided", async () => { + const root = await mkdtemp(join(tmpdir(), "convoy-summary-scoreboard-")) + const previousHome = process.env.CONVOY_HOME + process.env.CONVOY_HOME = root + + try { + const workspace = await createWorkspace("test prompt") + const scoreboard = "## Scoreboard\n\n| Phase | Files | + | − | Δ net |\n|---|---|---|---|---|\n| implement | 1 | +5 | −0 | +5 |\n| **Total** | **1** | **+5** | **−0** | **+5** |" + + await writeSummary(workspace, ["implement"], [], scoreboard) + + const content = await readFile(join(workspace.dir, "SUMMARY.md"), "utf8") + const headerIndex = content.indexOf("# convoy run") + const scoreboardIndex = content.indexOf("## Scoreboard") + const phaseIndex = content.indexOf("## implement") + + expect(headerIndex).toBeGreaterThanOrEqual(0) + expect(scoreboardIndex).toBeGreaterThan(headerIndex) + expect(phaseIndex).toBeGreaterThan(scoreboardIndex) + } finally { + if (previousHome === undefined) delete process.env.CONVOY_HOME + else process.env.CONVOY_HOME = previousHome + await rm(root, { recursive: true, force: true }) + } + }) + + test("produces identical output to the current contract when no scoreboard is given", async () => { + const root = await mkdtemp(join(tmpdir(), "convoy-summary-no-scoreboard-")) + const previousHome = process.env.CONVOY_HOME + process.env.CONVOY_HOME = root + + try { + const workspace = await createWorkspace("test prompt") + + // Without scoreboard + await writeSummary(workspace, ["implement"], []) + const withoutScoreboard = await readFile(join(workspace.dir, "SUMMARY.md"), "utf8") + + // With undefined (same as not passing) + await writeSummary(workspace, ["implement"], [], undefined) + const withUndefined = await readFile(join(workspace.dir, "SUMMARY.md"), "utf8") + + expect(withUndefined).toBe(withoutScoreboard) + expect(withoutScoreboard).not.toContain("## Scoreboard") + } finally { + if (previousHome === undefined) delete process.env.CONVOY_HOME + else process.env.CONVOY_HOME = previousHome + await rm(root, { recursive: true, force: true }) + } + }) +})