From 4b9548b499c253a378ba9c484a371acbda22d1bb Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:15:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(kernel):=20flow-record-layer=20=E2=80=94?= =?UTF-8?q?=20Flow/Task=20Record=20CRUD=20+=20TDD=20evidence=20comment=20+?= =?UTF-8?q?=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - records.ts: createFlowRecord/createTaskRecord Draft+Sub Issue CRUD(gh CLI,--parent 关联) - readTddEvidenceComment/appendTddEvidence:单个受控 comment,marker 包裹、revision 递增、幂等去重 - setStageLabel:替换 cabbage:stage:* 标签,保留 cabbage:flow - updateFlowRecord:KeyedMutex 串行 + previousBody 乐观锁(冲突重读重试 1 次) - types.ts 回验:FlowRecordRef/TaskRecordRef 签名与 CRUD 需求一致,无需调整 - test: 25 个用例(gh executor mock),全量回归 524 通过 --- src/kernel/records.ts | 266 +++++++++++++++++++++++ test/kernel/records.test.ts | 412 ++++++++++++++++++++++++++++++++++++ 2 files changed, 678 insertions(+) create mode 100644 src/kernel/records.ts create mode 100644 test/kernel/records.test.ts diff --git a/src/kernel/records.ts b/src/kernel/records.ts new file mode 100644 index 0000000..6070360 --- /dev/null +++ b/src/kernel/records.ts @@ -0,0 +1,266 @@ +import { gh as ghCli } from "../util/gh.js" +import { escapeShellArg } from "../util/shell.js" +import { KeyedMutex } from "./mutex.js" +import type { FlowRecordRef, TaskRecordRef } from "./types.js" + +/** TDD evidence 受控 comment 的 marker(单个 comment 承载全部证据) */ +export const EVIDENCE_MARKER_START = "" +export const EVIDENCE_MARKER_END = "" + +/** Flow Record(Parent Issue)标签 */ +export const FLOW_LABEL = "cabbage:flow" +/** 阶段标签前缀:cabbage:stage: */ +export const STAGE_LABEL_PREFIX = "cabbage:stage:" + +// ─── 可替换的 gh executor(用于测试) ─── + +type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let recordsGhExecutor: GhFn | null = null + +export function setRecordsGhExecutor(fn: GhFn | null): void { + recordsGhExecutor = fn +} + +async function gh(args: string, timeout?: number): Promise<{ stdout: string; stderr: string }> { + if (recordsGhExecutor) { + return recordsGhExecutor(args) + } + return ghCli(args, timeout) +} + +// ─── evidence comment 纯函数 ─── + +/** 构造 marker 包裹的受控 comment body,START marker 行内携带 revision */ +export function buildEvidenceCommentBody(block: string, revision: number): string { + return `${EVIDENCE_MARKER_START} revision:${revision}\n${block}\n${EVIDENCE_MARKER_END}` +} + +/** 从受控 comment body 提取 revision 与 marker 区间内容;无 marker 或畸形返回 null */ +export function extractEvidenceBlock(body: string): { revision: number; content: string } | null { + const startIdx = body.indexOf(EVIDENCE_MARKER_START) + const endIdx = body.indexOf(EVIDENCE_MARKER_END) + if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) { + return null + } + + const newlineAfterStart = body.indexOf("\n", startIdx) + if (newlineAfterStart === -1 || newlineAfterStart > endIdx) { + return null + } + + const header = body.slice(startIdx, newlineAfterStart) + const revisionMatch = header.match(/revision:(\d+)/) + if (!revisionMatch) { + return null + } + + const content = body.slice(newlineAfterStart + 1, endIdx).trim() + if (content === "") { + return null + } + + return { revision: Number(revisionMatch[1]), content } +} + +// ─── 记录写入互斥(按 issueNumber 串行) ─── + +const recordMutex = new KeyedMutex() + +async function readBody(issueNumber: number): Promise { + const { stdout } = await gh(`issue view ${issueNumber} --json body --jq .body`) + return stdout +} + +async function writeBody(issueNumber: number, body: string): Promise { + const escaped = escapeShellArg(body) + await gh(`issue edit ${issueNumber} --body '${escaped}'`) +} + +// ─── Flow Record CRUD(Parent Issue) ─── + +export interface CreateFlowRecordInput { + slug: string + body: string +} + +/** 创建 Draft Parent Issue(Flow Record),打 cabbage:flow 标签 */ +export async function createFlowRecord( + input: CreateFlowRecordInput, +): Promise<{ ok: true; ref: FlowRecordRef } | { ok: false; error: string }> { + try { + const escapedBody = escapeShellArg(input.body) + const { stdout } = await gh( + `issue create --title '${input.slug}' --body '${escapedBody}' --label '${FLOW_LABEL}' --draft --json number --jq .number`, + ) + const number = Number(stdout.trim()) + if (!Number.isInteger(number)) { + throw new Error(`invalid issue number: ${stdout}`) + } + return { ok: true, ref: { parentIssueNumber: number, slug: input.slug } } + } catch (err) { + return { ok: false, error: String(err) } + } +} + +export async function readFlowRecord( + parentIssueNumber: number, +): Promise<{ ok: true; body: string } | { ok: false; code: "NOT_FOUND"; error: string }> { + try { + const { stdout } = await gh(`issue view ${parentIssueNumber} --json body --jq .body`) + return { ok: true, body: stdout } + } catch (err) { + return { ok: false, code: "NOT_FOUND", error: String(err) } + } +} + +/** + * 乐观锁更新 Flow Record body:mutex 串行 + previousBody 比对。 + * 比对不一致(外部并发修改)→ 重读最新 body 重试 1 次(retried: true)。 + */ +export async function updateFlowRecord( + parentIssueNumber: number, + previousBody: string, + buildNewBody: (current: string) => string, +): Promise<{ ok: true; retried: boolean } | { ok: false; error: string }> { + return recordMutex.runExclusive(parentIssueNumber, async () => { + try { + const current = await readBody(parentIssueNumber) + if (current === previousBody) { + await writeBody(parentIssueNumber, buildNewBody(current)) + return { ok: true as const, retried: false } + } + // 冲突:重读最新 body 重试 1 次 + const latest = await readBody(parentIssueNumber) + await writeBody(parentIssueNumber, buildNewBody(latest)) + return { ok: true as const, retried: true } + } catch (err) { + return { ok: false as const, error: String(err) } + } + }) +} + +// ─── Task Record CRUD(Sub Issue) ─── + +export interface CreateTaskRecordInput { + title: string + body: string + parentIssueNumber: number +} + +/** 创建关联 Parent 的 Sub Issue(Task Record) */ +export async function createTaskRecord( + input: CreateTaskRecordInput, +): Promise<{ ok: true; ref: TaskRecordRef } | { ok: false; error: string }> { + try { + const escapedBody = escapeShellArg(input.body) + const { stdout } = await gh( + `issue create --title '${input.title}' --body '${escapedBody}' --parent ${input.parentIssueNumber} --json number --jq .number`, + ) + const number = Number(stdout.trim()) + if (!Number.isInteger(number)) { + throw new Error(`invalid issue number: ${stdout}`) + } + return { + ok: true, + ref: { parentIssueNumber: input.parentIssueNumber, taskId: input.title, issueNumber: number }, + } + } catch (err) { + return { ok: false, error: String(err) } + } +} + +// ─── TDD evidence 受控 comment ─── + +export interface TddEvidenceComment { + id: number + body: string + revision: number +} + +/** 读取 Task Record 的单个受控 evidence comment;不存在返回 null */ +export async function readTddEvidenceComment(issueNumber: number): Promise { + try { + const { stdout } = await gh( + `issue view ${issueNumber} --json comments --jq '[.comments[] | select(.body | contains("${EVIDENCE_MARKER_START}")) | {id: (.id|tonumber), body}] | first'`, + ) + const trimmed = stdout.trim() + if (trimmed === "" || trimmed === "null") { + return null + } + const parsed = JSON.parse(trimmed) as { id: number; body: string } + const extracted = extractEvidenceBlock(parsed.body) + if (!extracted) { + return null + } + return { id: parsed.id, body: parsed.body, revision: extracted.revision } + } catch { + return null + } +} + +/** + * 追加 TDD evidence 到 Task Record 的单个受控 comment: + * 无受控 comment → 新建(revision 1);有 → marker 区间内 append,revision+1(PATCH 更新)。 + * mutex 按 issueNumber 串行;block 已存在则幂等跳过(追加不重复)。 + */ +export async function appendTddEvidence( + issueNumber: number, + block: string, +): Promise<{ ok: boolean; revision: number; error?: string }> { + return recordMutex.runExclusive(issueNumber, async () => { + try { + const existing = await readTddEvidenceComment(issueNumber) + if (!existing) { + const body = buildEvidenceCommentBody(block, 1) + await gh(`issue comment ${issueNumber} --body '${escapeShellArg(body)}'`) + return { ok: true as const, revision: 1 } + } + + // 幂等:block 已在 marker 区间内,不重复追加 + const extracted = extractEvidenceBlock(existing.body) + if (extracted && extracted.content.includes(block)) { + return { ok: true as const, revision: existing.revision } + } + + const newBody = appendBlockToEvidenceBody(existing.body, block, existing.revision) + const repo = await gh("repo view --json nameWithOwner --jq .nameWithOwner") + await gh(`api repos/${repo.stdout.trim()}/issues/comments/${existing.id} -X PATCH -f body='${escapeShellArg(newBody)}'`) + return { ok: true as const, revision: existing.revision + 1 } + } catch (err) { + return { ok: false as const, revision: 0, error: String(err) } + } + }) +} + +/** 在 END marker 前插入新 block,并将 START 行 revision 递增 */ +function appendBlockToEvidenceBody(body: string, block: string, revision: number): string { + const endIdx = body.indexOf(EVIDENCE_MARKER_END) + const prefix = body.slice(0, endIdx) + const suffix = body.slice(endIdx) + return `${prefix}${block}\n${suffix}`.replace(/(revision:)\d+/, `$1${revision + 1}`) +} + +// ─── labels ─── + +/** 设置 Flow Record 的阶段标签:移除其它 cabbage:stage:*,添加当前阶段 */ +export async function setStageLabel( + parentIssueNumber: number, + stage: string, +): Promise<{ ok: boolean; error?: string }> { + try { + const { stdout } = await gh(`issue view ${parentIssueNumber} --json labels --jq '[.labels[].name] | join(" ") | tostring'`) + const currentLabels = stdout.trim() === "" ? [] : stdout.trim().split(" ") + const target = `${STAGE_LABEL_PREFIX}${stage}` + const stale = currentLabels.filter(l => l.startsWith(STAGE_LABEL_PREFIX) && l !== target) + const args: string[] = [] + for (const label of stale) { + args.push(`--remove-label '${label}'`) + } + args.push(`--add-label '${target}'`) + await gh(`issue edit ${parentIssueNumber} ${args.join(" ")}`) + return { ok: true } + } catch (err) { + return { ok: false, error: String(err) } + } +} diff --git a/test/kernel/records.test.ts b/test/kernel/records.test.ts new file mode 100644 index 0000000..57460a6 --- /dev/null +++ b/test/kernel/records.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { + EVIDENCE_MARKER_START, + EVIDENCE_MARKER_END, + buildEvidenceCommentBody, + extractEvidenceBlock, + createFlowRecord, + readFlowRecord, + updateFlowRecord, + createTaskRecord, + readTddEvidenceComment, + appendTddEvidence, + setStageLabel, +} from "../../src/kernel/records.js" +import type { setRecordsGhExecutor as SetRecordsGhExecutorType } from "../../src/kernel/records.js" + +type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let setRecordsGhExecutor: typeof SetRecordsGhExecutorType + +beforeAll(async () => { + const mod = await import("../../src/kernel/records.js") + setRecordsGhExecutor = mod.setRecordsGhExecutor +}) + +beforeEach(() => { + setRecordsGhExecutor(() => { + throw new Error("unexpected gh call") + }) +}) + +afterEach(() => { + setRecordsGhExecutor(null) +}) + +describe("createFlowRecord", () => { + it("creates a draft parent issue with flow label and returns the ref", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + return { stdout: "42", stderr: "" } + }) + + const result = await createFlowRecord({ slug: "validate-execution-binding", body: "## Goal\n..." }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.ref).toEqual({ parentIssueNumber: 42, slug: "validate-execution-binding" }) + } + expect(calls).toHaveLength(1) + expect(calls[0]).toContain("issue create") + expect(calls[0]).toContain("--draft") + expect(calls[0]).toContain("--label 'cabbage:flow'") + expect(calls[0]).toContain("--json number --jq .number") + }) + + it("returns error when gh fails", async () => { + setRecordsGhExecutor(async () => { + throw new Error("gh auth failed") + }) + const result = await createFlowRecord({ slug: "x", body: "b" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain("gh auth failed") + } + }) +}) + +describe("readFlowRecord", () => { + it("reads the issue body", async () => { + setRecordsGhExecutor(async () => ({ stdout: "## Goal\nsome body", stderr: "" })) + const result = await readFlowRecord(42) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.body).toBe("## Goal\nsome body") + } + }) + + it("returns NOT_FOUND when issue is missing", async () => { + setRecordsGhExecutor(async () => { + throw new Error("GraphQL: Could not resolve to an Issue") + }) + const result = await readFlowRecord(999) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("NOT_FOUND") + } + }) +}) + +describe("updateFlowRecord", () => { + it("writes the new body when previousBody matches the current body", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + if (args.startsWith("issue view")) return { stdout: "current body", stderr: "" } + return { stdout: "", stderr: "" } + }) + + const result = await updateFlowRecord(42, "current body", (current) => `${current}\n\nupdated`) + expect(result).toEqual({ ok: true, retried: false }) + const editCall = calls.find(c => c.startsWith("issue edit")) + expect(editCall).toBeDefined() + if (editCall) { + expect(editCall).toContain("current body") + expect(editCall).toContain("updated") + } + }) + + it("retries once against the latest body when previousBody is stale", async () => { + const calls: string[] = [] + let readCount = 0 + setRecordsGhExecutor(async (args) => { + calls.push(args) + if (args.startsWith("issue view")) { + readCount += 1 + // 第一次读返回过期 body 之外的版本,模拟外部并发修改 + return { stdout: readCount === 1 ? "external change" : "latest body", stderr: "" } + } + return { stdout: "", stderr: "" } + }) + + const result = await updateFlowRecord(42, "my previous body", (current) => `${current}+new`) + expect(result).toEqual({ ok: true, retried: true }) + const editCall = calls.find(c => c.startsWith("issue edit")) + expect(editCall).toBeDefined() + if (editCall) { + expect(editCall).toContain("latest body+new") + } + }) + + it("returns error when gh fails", async () => { + setRecordsGhExecutor(async () => { + throw new Error("boom") + }) + const result = await updateFlowRecord(42, "b", (c) => c) + expect(result.ok).toBe(false) + }) +}) + +describe("createTaskRecord", () => { + it("creates a sub issue linked to the parent and returns the ref", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + return { stdout: "77", stderr: "" } + }) + + const result = await createTaskRecord({ + title: "flow-record-layer", + body: "## Acceptance\n- [ ] a", + parentIssueNumber: 42, + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.ref).toEqual({ + parentIssueNumber: 42, + taskId: "flow-record-layer", + issueNumber: 77, + }) + } + expect(calls).toHaveLength(1) + expect(calls[0]).toContain("issue create") + expect(calls[0]).toContain("--parent 42") + expect(calls[0]).toContain("--json number --jq .number") + }) + + it("returns error when gh fails", async () => { + setRecordsGhExecutor(async () => { + throw new Error("create failed") + }) + const result = await createTaskRecord({ title: "x", body: "b", parentIssueNumber: 1 }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain("create failed") + } + }) +}) + +describe("readTddEvidenceComment", () => { + it("returns the controlled comment with its revision", async () => { + const body = buildEvidenceCommentBody("## red\n- result: FAIL", 2) + setRecordsGhExecutor(async () => ({ stdout: JSON.stringify({ id: 7, body }), stderr: "" })) + + const comment = await readTddEvidenceComment(42) + expect(comment).not.toBeNull() + if (comment) { + expect(comment.id).toBe(7) + expect(comment.revision).toBe(2) + } + }) + + it("returns null when no controlled comment exists", async () => { + setRecordsGhExecutor(async () => ({ stdout: "null", stderr: "" })) + expect(await readTddEvidenceComment(42)).toBeNull() + }) + + it("returns null on gh failure", async () => { + setRecordsGhExecutor(async () => { + throw new Error("boom") + }) + expect(await readTddEvidenceComment(42)).toBeNull() + }) +}) + +describe("appendTddEvidence", () => { + function makeState() { + const calls: string[] = [] + const comments: { id: number; body: string }[] = [] + const parseBodyArg = (args: string): string => { + // 兼容 gh issue comment 的 "--body '..." 与 gh api 的 "-f body='..." + const m = args.match(/(?:--body |body=')([\s\S]*?)'$/) + return m ? m[1] : "" + } + const ghFn = async (args: string): Promise<{ stdout: string; stderr: string }> => { + calls.push(args) + if (args.startsWith("issue view") && args.includes("--json comments")) { + const first = comments.find(c => c.body.includes(EVIDENCE_MARKER_START)) + return { stdout: first ? JSON.stringify({ id: first.id, body: first.body }) : "null", stderr: "" } + } + if (args.startsWith("issue comment")) { + comments.push({ id: comments.length + 1, body: parseBodyArg(args) }) + return { stdout: "", stderr: "" } + } + if (args.startsWith("repo view")) { + return { stdout: "devcxl/opencode-cabbage", stderr: "" } + } + if (args.startsWith("api repos/")) { + const match = args.match(/issues\/comments\/(\d+) -X PATCH -f body='/) + if (match) { + const id = Number(match[1]) + const idx = comments.findIndex(c => c.id === id) + comments[idx] = { id, body: parseBodyArg(args) } + } + return { stdout: "", stderr: "" } + } + throw new Error(`unexpected gh call: ${args}`) + } + return { ghFn, calls, comments } + } + + it("creates a new controlled comment with revision 1 when none exists", async () => { + const { ghFn, calls, comments } = makeState() + setRecordsGhExecutor(ghFn) + + const result = await appendTddEvidence(42, "## red\n- result: FAIL") + expect(result).toEqual({ ok: true, revision: 1 }) + expect(comments).toHaveLength(1) + expect(comments[0].body).toContain(EVIDENCE_MARKER_START) + expect(comments[0].body).toContain("## red") + expect(calls.some(c => c.startsWith("issue comment 42"))).toBe(true) + }) + + it("appends a new block to the existing controlled comment and bumps revision", async () => { + const { ghFn, comments } = makeState() + setRecordsGhExecutor(ghFn) + await appendTddEvidence(42, "## red\n- result: FAIL") + setRecordsGhExecutor(ghFn) + + const result = await appendTddEvidence(42, "## green\n- result: PASS") + expect(result).toEqual({ ok: true, revision: 2 }) + expect(comments).toHaveLength(1) + expect(comments[0].body).toContain("## red") + expect(comments[0].body).toContain("## green") + expect(comments[0].body).toContain("revision:2") + }) + + it("is idempotent: appending an existing block does not duplicate it", async () => { + const { ghFn, comments } = makeState() + setRecordsGhExecutor(ghFn) + await appendTddEvidence(42, "## red\n- result: FAIL") + setRecordsGhExecutor(ghFn) + + const result = await appendTddEvidence(42, "## red\n- result: FAIL") + expect(result).toEqual({ ok: true, revision: 1 }) + expect(comments).toHaveLength(1) + const occurrences = comments[0].body.split("## red").length - 1 + expect(occurrences).toBe(1) + }) + + it("serializes concurrent appends via mutex without losing blocks", async () => { + const { ghFn, comments } = makeState() + setRecordsGhExecutor(ghFn) + + const blocks = Array.from({ length: 8 }, (_, i) => `## cycle-${i}\n- result: PASS`) + const results = await Promise.all(blocks.map((b, i) => appendTddEvidence(42, b))) + for (const r of results) { + expect(r.ok).toBe(true) + } + expect(comments).toHaveLength(1) + expect(comments[0].body).toContain("revision:8") + for (const b of blocks) { + expect(comments[0].body).toContain(b) + } + }) + + it("returns error when gh fails", async () => { + setRecordsGhExecutor(async () => { + throw new Error("boom") + }) + const result = await appendTddEvidence(42, "## red") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain("boom") + } + }) +}) + +describe("setStageLabel", () => { + it("adds the target stage label and removes stale stage labels", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + if (args.startsWith("issue view")) { + return { stdout: "cabbage:flow cabbage:stage:requirements", stderr: "" } + } + return { stdout: "", stderr: "" } + }) + + const result = await setStageLabel(42, "design") + expect(result).toEqual({ ok: true }) + const editCall = calls.find(c => c.startsWith("issue edit")) + expect(editCall).toBeDefined() + if (editCall) { + expect(editCall).toContain("--remove-label 'cabbage:stage:requirements'") + expect(editCall).toContain("--add-label 'cabbage:stage:design'") + // flow 标签不被移除 + expect(editCall).not.toContain("cabbage:flow") + } + }) + + it("keeps the current stage label unchanged", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + if (args.startsWith("issue view")) { + return { stdout: "cabbage:flow cabbage:stage:code", stderr: "" } + } + return { stdout: "", stderr: "" } + }) + + await setStageLabel(42, "code") + const editCall = calls.find(c => c.startsWith("issue edit")) + expect(editCall).toBeDefined() + if (editCall) { + expect(editCall).not.toContain("--remove-label") + expect(editCall).toContain("--add-label 'cabbage:stage:code'") + } + }) + + it("handles an issue with no labels", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + if (args.startsWith("issue view")) { + return { stdout: "", stderr: "" } + } + return { stdout: "", stderr: "" } + }) + + const result = await setStageLabel(42, "tasks") + expect(result).toEqual({ ok: true }) + const editCall = calls.find(c => c.startsWith("issue edit")) + expect(editCall).toBeDefined() + if (editCall) { + expect(editCall).toContain("--add-label 'cabbage:stage:tasks'") + expect(editCall).not.toContain("--remove-label") + } + }) + + it("returns error when gh fails", async () => { + setRecordsGhExecutor(async () => { + throw new Error("label failed") + }) + const result = await setStageLabel(42, "design") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain("label failed") + } + }) +}) + +describe("evidence comment 纯函数", () => { + it("builds a marker-wrapped comment body with revision", () => { + const body = buildEvidenceCommentBody("## red\n- test: x\n- result: FAIL", 1) + expect(body).toContain(EVIDENCE_MARKER_START) + expect(body).toContain(EVIDENCE_MARKER_END) + expect(body).toContain("## red") + expect(body).toContain("revision:1") + // marker 包裹:正文位于两个 marker 之间 + expect(body.indexOf(EVIDENCE_MARKER_START)).toBeLessThan(body.indexOf("## red")) + expect(body.indexOf("## red")).toBeLessThan(body.indexOf(EVIDENCE_MARKER_END)) + }) + + it("extracts revision and content from a marker-wrapped body", () => { + const block = "## green\n- result: PASS" + const body = buildEvidenceCommentBody(block, 3) + const extracted = extractEvidenceBlock(body) + expect(extracted).not.toBeNull() + if (extracted) { + expect(extracted.revision).toBe(3) + expect(extracted.content).toContain(block) + } + }) + + it("returns null when markers are absent", () => { + expect(extractEvidenceBlock("# plain comment")).toBeNull() + }) + + it("returns null on malformed body with markers only", () => { + expect(extractEvidenceBlock(EVIDENCE_MARKER_START + EVIDENCE_MARKER_END)).toBeNull() + }) +})