From f73a0d4ce09abfa389961bfab7cadbe7f83029c8 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:31:19 +0800 Subject: [PATCH 1/3] feat(kernel): tdd_checkpoint not-applicable restricted to primary --- src/kernel/caller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/kernel/caller.ts b/src/kernel/caller.ts index 7359e40..fd7664a 100644 --- a/src/kernel/caller.ts +++ b/src/kernel/caller.ts @@ -25,12 +25,15 @@ const TOOL_ROLES: Record = { release_control: ["primary"], } -/** op 级覆盖(最后匹配优先):architect 仅 flow_control.status 只读;goal-verify 仅 complete-flow */ +/** op 级覆盖(最后匹配优先):architect 仅 flow_control.status 只读;goal-verify 仅 complete-flow;tdd_checkpoint.not-applicable 仅 primary(spec §2.3 纯文档豁免) */ const OP_ROLES: Record> = { flow_control: { "complete-flow": ["goal-verify"], status: ["primary", "architect"], }, + tdd_checkpoint: { + "not-applicable": ["primary"], + }, } /** From 4164ec2a51c52f1125118b037911db7d3e2a7dcb Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:31:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(plugin):=20tdd-checkpoint-tool=20?= =?UTF-8?q?=E2=80=94=20runtime=20TDD=20evidence=20+=20submit=20gate=20(#10?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin/tdd-checkpoint.ts | 724 +++++++++++++++++++++++++++++ test/plugin/tdd-checkpoint.test.ts | 663 ++++++++++++++++++++++++++ 2 files changed, 1387 insertions(+) create mode 100644 src/plugin/tdd-checkpoint.ts create mode 100644 test/plugin/tdd-checkpoint.test.ts diff --git a/src/plugin/tdd-checkpoint.ts b/src/plugin/tdd-checkpoint.ts new file mode 100644 index 0000000..9e0e9ad --- /dev/null +++ b/src/plugin/tdd-checkpoint.ts @@ -0,0 +1,724 @@ +/** + * tdd_checkpoint 工具(spec §2.3 tdd_checkpoint + §4 Runtime TDD 硬门禁 + PRD R6)。 + * + * 与旧 `plugin/tdd-tool.ts`(Phase C 占位,接受内联 evidence)不同: + * - 工具亲自执行 RED/GREEN(复用 kernel/tdd/adapter.executeRedCheck),不接受内联 evidence。 + * - RED 三重校验:失败分类(assertion/missing-behavior)+ 实现文件相对基线未变 + 测试输入未偷换。 + * - GREEN:同一测试通过 + 执行输入 digest 与 RED 一致 + 实现文件有变化。 + * - evidence 写入 Task Record 单个受控 comment(records.appendTddEvidence,marker/revision/幂等)。 + * - 状态以 `` JSON 块存于 comment,恢复时取最后一个(最新)。 + * + * caller:developer + primary(矩阵见 kernel/caller.ts);not-applicable 仅 primary。 + * server.ts 接线由后续批次统一处理(本批提供工厂 + registerTddCheckpoint,不注册)。 + */ +import { tool } from "@opencode-ai/plugin/tool" +import { createHash } from "node:crypto" +import { resolve } from "node:path" +import { requireToolCaller, CALLER_NOT_AUTHORIZED } from "../kernel/caller.js" +import type { CallerSessionClient } from "../kernel/caller.js" +import { executeRedCheck, executeVitest } from "../kernel/tdd/adapter.js" +import type { VitestOutput } from "../kernel/tdd/adapter.js" +import { computeWorkspaceDigest } from "../kernel/tdd/digest.js" +import { + startCycle, + recordRed, + recordGreen, + recordFinalRegression, + recordFinalVerification, + abandonCycle, + createTaskEvidence, +} from "../kernel/tdd/state.js" +import { evaluateTddCompliance } from "../kernel/tdd/evaluator.js" +import { buildEvidenceBlock } from "../kernel/tdd/evidence.js" +import type { EvidenceBlockInput } from "../kernel/tdd/evidence.js" +import { appendTddEvidence, readTddEvidenceComment, extractEvidenceBlock } from "../kernel/records.js" +import { gh as ghCli } from "../util/gh.js" +import type { + TddEvidence, + TddPolicy, + AcceptanceCriterion, + TddCommandEvidence, + VersionedDigest, +} from "../kernel/types.js" + +// ─── 类型 ─── + +export type TddCheckpointOp = + | "cycle-start" + | "red" + | "green" + | "final-regression" + | "final-verification" + | "abandon-cycle" + | "status" + | "not-applicable" + | "exempt-request" + +export interface TddCheckpointDeps { + projectDir: string + sessionClient: CallerSessionClient +} + +/** 从 Task Record 解析出的工具执行上下文 */ +export interface TddTaskContext { + /** Task Record(Sub Issue)编号 */ + issueNumber: number + policy: TddPolicy + criteria: AcceptanceCriterion[] + /** 绝对路径(基于 projectDir 解析) */ + worktreeDir: string +} + +export interface TddCheckpointToolResponse { + ok: boolean + cycle?: { cycleId: string; criterionId: string; status: string } + evidence?: { + revision: number + status: string + cycles: number + regression: string + verification: string + warnings: string[] + } + error?: { code: string; message: string } +} + +// ─── 可替换的 gh executor(用于测试) ─── + +type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let tddGhExecutor: GhFn | null = null + +export function setTddCheckpointGhExecutor(fn: GhFn | null): void { + tddGhExecutor = fn +} + +async function gh(args: string): Promise<{ stdout: string; stderr: string }> { + if (tddGhExecutor) return tddGhExecutor(args) + return ghCli(args) +} + +// ─── Task Record TDD 区块 ─── + +/** Task Record body 中承载 TDD policy/criteria/worktree 的 JSON 区块 marker */ +export const TASK_TDD_TAG = "" + +export interface TaskTddBlock { + policy: TddPolicy + criteria: AcceptanceCriterion[] + worktreeDir: string +} + +/** 从 Task Record body 解析 TDD 区块(纯函数);无 marker 或畸形返回 null */ +export function parseTaskTddBlock(body: string): TaskTddBlock | null { + const idx = body.indexOf(TASK_TDD_TAG) + if (idx === -1) return null + const jsonText = body.slice(idx + TASK_TDD_TAG.length).trim() + try { + const parsed = JSON.parse(jsonText) as Partial + if (!parsed || typeof parsed !== "object") return null + if (!parsed.policy || !Array.isArray(parsed.criteria) || typeof parsed.worktreeDir !== "string") return null + return { policy: parsed.policy, criteria: parsed.criteria, worktreeDir: parsed.worktreeDir } + } catch { + return null + } +} + +/** + * 解析 Task Record 上下文:按 title 定位 Sub Issue → 读 body → 解析 TDD 区块。 + * 由批 9(task_control 写 Task Record)遵循此区块格式;批 15 接线时可替换为正式解析。 + */ +export async function resolveTaskContext( + parentIssueNumber: number, + taskId: string, + projectDir: string, +): Promise { + const { stdout } = await gh( + `issue list --parent ${parentIssueNumber} --json number,title --jq '[.[] | select(.title == ${JSON.stringify(taskId)}) | .number] | first'`, + ) + const issueNumber = Number(stdout.trim()) + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + throw new Error(`TASK_NOT_FOUND: Task "${taskId}" not found under parent #${parentIssueNumber}`) + } + const bodyResp = await gh(`issue view ${issueNumber} --json body --jq .body`) + const block = parseTaskTddBlock(bodyResp.stdout) + if (!block) { + throw new Error(`TASK_TDD_BLOCK_MISSING: Task Record #${issueNumber} has no ${TASK_TDD_TAG} block`) + } + return { + issueNumber, + policy: block.policy, + criteria: block.criteria, + worktreeDir: resolve(projectDir, block.worktreeDir), + } +} + +// ─── 状态序列化(存于 evidence comment) ─── + +/** evidence comment 内状态 JSON 块 marker;解析时取最后一个(最新) */ +export const TDD_STATE_TAG = "" + +export function serializeState(evidence: TddEvidence): string { + return `${TDD_STATE_TAG}\n${JSON.stringify({ schema: 1, evidence })}` +} + +/** 从 marker 区间内容解析最新状态;无状态块或畸形返回 null */ +export function parseStateFromContent(content: string): TddEvidence | null { + const idx = content.lastIndexOf(TDD_STATE_TAG) + if (idx === -1) return null + const jsonText = content.slice(idx + TDD_STATE_TAG.length).trim() + try { + const parsed = JSON.parse(jsonText) as { schema?: number; evidence?: TddEvidence } + if (!parsed || parsed.schema !== 1 || !parsed.evidence) return null + return parsed.evidence + } catch { + return null + } +} + +/** 恢复 Task Record 的 TDD evidence 状态;无受控 comment → 初始状态 */ +async function restoreEvidence(issueNumber: number): Promise { + const comment = await readTddEvidenceComment(issueNumber) + if (!comment) return createTaskEvidence() + const extracted = extractEvidenceBlock(comment.body) + const state = extracted ? parseStateFromContent(extracted.content) : null + if (!state) { + throw new Error(`EVIDENCE_STATE_CORRUPTED: Task Record #${issueNumber} evidence comment is malformed`) + } + return state +} + +/** 人读 block + 状态 JSON 追加到受控 comment(appendTddEvidence 幂等去重) */ +async function persistEvidence( + issueNumber: number, + evidence: TddEvidence, + input: EvidenceBlockInput, +): Promise<{ ok: boolean; revision: number; error?: string }> { + const block = `${buildEvidenceBlock(input)}\n${serializeState(evidence)}` + return appendTddEvidence(issueNumber, block) +} + +// ─── digest 辅助 ─── + +function digestEqual(a: VersionedDigest | null, b: VersionedDigest | null): boolean { + if (a === null || b === null) return a === b + return a.algorithm === b.algorithm && a.value === b.value +} + +/** 实现文件子集 digest:作为 cycle 基线(cycle-start 快照)与 RED/GREEN 对比对象 */ +async function computeImplDigest(worktreeDir: string, policy: TddPolicy): Promise { + return computeWorkspaceDigest(worktreeDir, { + testFilePatterns: [], + implementationFilePatterns: policy.implementationFilePatterns, + generatedArtifactPatterns: policy.generatedArtifactPatterns, + }) +} + +/** 测试输入 digest(executionInputPatterns):防 RED/GREEN 偷换测试配置/selector */ +async function computeExecutionInputDigest(worktreeDir: string, policy: TddPolicy): Promise { + const patterns = policy.runner?.executionInputPatterns ?? [] + return computeWorkspaceDigest(worktreeDir, { + testFilePatterns: patterns, + implementationFilePatterns: [], + generatedArtifactPatterns: [], + }) +} + +// ─── 响应辅助 ─── + +const PLACEHOLDER: VersionedDigest = { algorithm: "sha256-content-v1", value: "0".repeat(64) } + +function okResponse(overrides: Partial = {}): string { + return JSON.stringify({ ok: true, ...overrides }, null, 2) +} + +function errorResponse(code: string, message: string): string { + return JSON.stringify({ ok: false, error: { code, message } }, null, 2) +} + +function evidenceSummary(evidence: TddEvidence): TddCheckpointToolResponse["evidence"] { + return { + revision: evidence.revision, + status: evidence.status, + cycles: evidence.cycles.length, + regression: evidence.regression.status, + verification: evidence.verification.status, + warnings: evidence.warnings, + } +} + +function bumpEvidence(evidence: TddEvidence, patch: Partial): TddEvidence { + return { ...evidence, ...patch, revision: evidence.revision + 1, updatedAt: new Date().toISOString() } +} + +// ─── op handlers ─── + +type OpArgs = Record + +function requireRefs(args: OpArgs): { parentIssueNumber: number; taskId: string } | null { + const parentIssueNumber = Number(args.parent_issue_number) + const taskId = args.task_id as string | undefined + if (!Number.isInteger(parentIssueNumber) || !taskId) return null + return { parentIssueNumber, taskId } +} + +async function handleCycleStart(args: OpArgs, deps: TddCheckpointDeps): Promise { + const cycleId = args.cycle_id as string | undefined + const criterionId = args.criterion_id as string | undefined + const testPaths = args.test_paths as string[] | undefined + const testSelector = args.test_selector as string | undefined + if (!cycleId || !criterionId || !testSelector || !Array.isArray(testPaths) || testPaths.length === 0) { + return { ok: false, error: { code: "POLICY_INVALID", message: "cycle-start requires cycle_id, criterion_id, test_paths, test_selector" } } + } + + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + const evidence = await restoreEvidence(task.issueNumber) + + // 基线:实现文件 digest 快照 + const implDigest = await computeImplDigest(task.worktreeDir, task.policy) + const res = startCycle( + evidence, + cycleId, + criterionId, + testPaths, + testSelector, + implDigest, + task.criteria, + task.policy.testFilePatterns, + ) + if (!res.ok) return { ok: false, error: { code: res.error.code, message: res.error.message } } + + if (res.value.evidence !== evidence) { + const persisted = await persistEvidence(task.issueNumber, res.value.evidence, { + stage: "cycle-start", + criterionId, + cycleId, + command: "cycle-start", + testSelector, + exitCode: null, + failureKind: null, + status: "started", + workspaceDigest: implDigest, + summary: `cycle ${cycleId} started (criterion ${criterionId})`, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + } + + return { + ok: true, + cycle: { cycleId, criterionId, status: res.value.cycle.status }, + evidence: evidenceSummary(res.value.evidence), + } +} + +async function handleRed(args: OpArgs, deps: TddCheckpointDeps): Promise { + const cycleId = args.cycle_id as string | undefined + const testSelector = args.test_selector as string | undefined + if (!cycleId || !testSelector) { + return { ok: false, error: { code: "POLICY_INVALID", message: "red requires cycle_id and test_selector" } } + } + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + if (!task.policy.runner) { + return { ok: false, error: { code: "RUNNER_UNSUPPORTED", message: "RED requires a configured test runner" } } + } + + const evidence = await restoreEvidence(task.issueNumber) + const cycle = evidence.cycles.find(c => c.cycleId === cycleId) + if (!cycle) { + return { ok: false, error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` } } + } + + // 工具亲自执行 + const run = await executeRedCheck(task.policy.runner, testSelector, task.worktreeDir) + const inputDigest = await computeExecutionInputDigest(task.worktreeDir, task.policy) + const implDigest = await computeImplDigest(task.worktreeDir, task.policy) + + // 实现文件相对基线未变 + if (!digestEqual(implDigest, cycle.startWorkspaceDigest)) { + return { ok: false, error: { code: "IMPL_CHANGED_IN_RED", message: "Implementation files changed since cycle baseline; RED must only add/modify tests" } } + } + + // 测试输入未偷换(与上一次 RED attempt 一致) + const lastRed = cycle.redAttempts[cycle.redAttempts.length - 1] + if (lastRed && !digestEqual(inputDigest, lastRed.executionInputDigest)) { + return { ok: false, error: { code: "INPUT_SWAPPED", message: "Execution input digest differs from the previous RED attempt" } } + } + + const redEvidence: TddCommandEvidence = { ...run, executionInputDigest: inputDigest } + const res = recordRed(evidence, cycleId, redEvidence) + if (!res.ok) return { ok: false, error: { code: res.error.code, message: res.error.message } } + + if (res.value.evidence !== evidence) { + const persisted = await persistEvidence(task.issueNumber, res.value.evidence, { + stage: "red", + criterionId: cycle.criterionId, + cycleId, + command: redEvidence.command, + testSelector, + exitCode: redEvidence.exitCode, + failureKind: redEvidence.failureKind, + status: "red", + workspaceDigest: implDigest, + summary: redEvidence.summary, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + } + + return { + ok: true, + cycle: { cycleId, criterionId: cycle.criterionId, status: res.value.cycle.status }, + evidence: evidenceSummary(res.value.evidence), + } +} + +async function handleGreen(args: OpArgs, deps: TddCheckpointDeps): Promise { + const cycleId = args.cycle_id as string | undefined + const testSelector = args.test_selector as string | undefined + if (!cycleId || !testSelector) { + return { ok: false, error: { code: "POLICY_INVALID", message: "green requires cycle_id and test_selector" } } + } + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + if (!task.policy.runner) { + return { ok: false, error: { code: "RUNNER_UNSUPPORTED", message: "GREEN requires a configured test runner" } } + } + + const evidence = await restoreEvidence(task.issueNumber) + const cycle = evidence.cycles.find(c => c.cycleId === cycleId) + if (!cycle) { + return { ok: false, error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` } } + } + + // 工具亲自执行同一测试 + const run = await executeRedCheck(task.policy.runner, testSelector, task.worktreeDir) + const inputDigest = await computeExecutionInputDigest(task.worktreeDir, task.policy) + const implDigest = await computeImplDigest(task.worktreeDir, task.policy) + const implChanged = !digestEqual(implDigest, cycle.startWorkspaceDigest) + + // 同一测试 selector(执行输入 digest 与 RED 一致,防偷换) + if (cycle.redTestDigest && !digestEqual(inputDigest, cycle.redTestDigest)) { + return { ok: false, error: { code: "SELECTOR_SWAPPED", message: "Execution input digest differs from RED; test selector/config must not change" } } + } + + const greenEvidence: TddCommandEvidence = { ...run, executionInputDigest: inputDigest } + const res = recordGreen(evidence, cycleId, greenEvidence, implChanged) + if (!res.ok) return { ok: false, error: { code: res.error.code, message: res.error.message } } + + if (res.value.evidence !== evidence) { + const persisted = await persistEvidence(task.issueNumber, res.value.evidence, { + stage: "green", + criterionId: cycle.criterionId, + cycleId, + command: greenEvidence.command, + testSelector, + exitCode: greenEvidence.exitCode, + failureKind: greenEvidence.failureKind, + status: "pass", + workspaceDigest: implDigest, + summary: greenEvidence.summary, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + } + + return { + ok: true, + cycle: { cycleId, criterionId: cycle.criterionId, status: res.value.cycle.status }, + evidence: evidenceSummary(res.value.evidence), + } +} + +function buildRegressionRun(command: string, output: VitestOutput): TddCommandEvidence { + const outputDigest = createHash("sha256").update(output.stdout + "\n" + output.stderr).digest("hex") + const now = new Date().toISOString() + return { + command, + testSelector: null, + exitCode: output.exitCode, + failureKind: output.timedOut ? "timeout" : null, + testsCollected: output.testsCollected, + testsFailed: output.testsFailed, + startedAt: now, + finishedAt: now, + durationMs: 0, + changedFiles: [], + outputDigest: { algorithm: "sha256-output-v1", value: outputDigest }, + workspaceDigest: PLACEHOLDER, + executionInputDigest: PLACEHOLDER, + summary: output.exitCode === 0 ? "full regression passed" : `full regression failed (exit ${output.exitCode})`, + } +} + +async function handleFinalRegression(args: OpArgs, deps: TddCheckpointDeps): Promise { + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + if (!task.policy.runner) { + return { ok: false, error: { code: "RUNNER_UNSUPPORTED", message: "final-regression requires a configured test runner" } } + } + + const baseArgs = task.policy.runner.baseCommand.trim().split(/\s+/).filter(Boolean) + if (baseArgs.length === 0) { + return { ok: false, error: { code: "REGRESSION_FAILED", message: "No test command configured for final regression" } } + } + + // 工具亲自执行全量回归 + const output = await executeVitest(baseArgs, task.worktreeDir, task.policy.runner.timeoutMs) + const run = buildRegressionRun(baseArgs.join(" "), output) + + const evidence = await restoreEvidence(task.issueNumber) + const res = recordFinalRegression(evidence, "worktree", "worktree", [run]) + if (!res.ok) return { ok: false, error: { code: res.error.code, message: res.error.message } } + + const status = res.value.regression.status === "pass" ? "pass" : "fail" + const persisted = await persistEvidence(task.issueNumber, res.value, { + stage: "final-regression", + command: run.command, + testSelector: null, + exitCode: run.exitCode, + failureKind: run.failureKind, + status, + workspaceDigest: null, + summary: run.summary, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + + if (res.value.regression.status !== "pass") { + return { ok: false, error: { code: "REGRESSION_FAILED", message: "Final regression must pass before submit" } } + } + return { ok: true, evidence: evidenceSummary(res.value) } +} + +async function handleFinalVerification(args: OpArgs, deps: TddCheckpointDeps): Promise { + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + + const evidence = await restoreEvidence(task.issueNumber) + + // 对照 acceptance criteria 逐条核验:每个 tdd criterion 必须有 pass cycle + const tddCriteria = task.criteria.filter(c => c.verification === "tdd") + const uncovered = tddCriteria.filter( + c => !evidence.cycles.some(cy => cy.criterionId === c.id && cy.status === "pass"), + ) + if (uncovered.length > 0) { + return { + ok: false, + error: { code: "CRITERIA_NOT_COVERED", message: `Criteria without a passing cycle: ${uncovered.map(c => c.id).join(", ")}` }, + } + } + + const res = recordFinalVerification(evidence, "worktree", "worktree", []) + if (!res.ok) return { ok: false, error: { code: res.error.code, message: res.error.message } } + + const persisted = await persistEvidence(task.issueNumber, res.value, { + stage: "final-verification", + command: "final-verification", + testSelector: null, + exitCode: null, + failureKind: null, + status: "pass", + workspaceDigest: null, + summary: `verified ${tddCriteria.length} tdd criteria against passing cycles`, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + + return { ok: true, evidence: evidenceSummary(res.value) } +} + +async function handleAbandonCycle(args: OpArgs, deps: TddCheckpointDeps): Promise { + const cycleId = args.cycle_id as string | undefined + const reason = args.reason as string | undefined + if (!cycleId || !reason) { + return { ok: false, error: { code: "POLICY_INVALID", message: "abandon-cycle requires cycle_id and reason" } } + } + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + + const evidence = await restoreEvidence(task.issueNumber) + const res = abandonCycle(evidence, cycleId, reason) + if (!res.ok) return { ok: false, error: { code: res.error.code, message: res.error.message } } + + if (res.value !== evidence) { + const persisted = await persistEvidence(task.issueNumber, res.value, { + stage: "abandon-cycle", + cycleId, + command: "abandon-cycle", + testSelector: null, + exitCode: null, + failureKind: null, + status: "abandoned", + workspaceDigest: null, + summary: reason, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + } + + return { ok: true, evidence: evidenceSummary(res.value) } +} + +async function handleStatus(args: OpArgs, deps: TddCheckpointDeps): Promise { + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + const evidence = await restoreEvidence(task.issueNumber) + return { ok: true, evidence: evidenceSummary(evidence) } +} + +async function handleWaiver( + args: OpArgs, + deps: TddCheckpointDeps, + kind: "not-applicable" | "exempt-request", +): Promise { + if (kind === "exempt-request" && args.user_confirmed !== true) { + return { ok: false, error: { code: "EXEMPT_REQUIRES_CONFIRMATION", message: "exempt-request requires user_confirmed: true" } } + } + const refs = requireRefs(args) + if (!refs) return { ok: false, error: { code: "POLICY_INVALID", message: "parent_issue_number and task_id are required" } } + const task = await resolveTaskContext(refs.parentIssueNumber, refs.taskId, deps.projectDir) + + const evidence0 = await restoreEvidence(task.issueNumber) + const reason = (args.reason as string | undefined) ?? (kind === "not-applicable" ? "pure documentation change" : "exempt request") + const evidence = bumpEvidence(evidence0, { + status: "waived", + warnings: [...evidence0.warnings, `${kind}: ${reason}`], + }) + + const persisted = await persistEvidence(task.issueNumber, evidence, { + stage: kind, + command: kind, + testSelector: null, + exitCode: null, + failureKind: null, + status: "waived", + workspaceDigest: null, + summary: reason, + }) + if (!persisted.ok) return { ok: false, error: { code: "EVIDENCE_WRITE_FAILED", message: persisted.error ?? "failed to write evidence" } } + + return { ok: true, evidence: evidenceSummary(evidence) } +} + +// ─── 工具工厂 ─── + +const OPS: TddCheckpointOp[] = [ + "cycle-start", "red", "green", "final-regression", "final-verification", + "abandon-cycle", "status", "not-applicable", "exempt-request", +] + +export function createTddCheckpointTool(deps: TddCheckpointDeps) { + return tool({ + description: `Record Runtime TDD evidence for a running Task (spec §2.3 tdd_checkpoint, PRD R6). + +The tool executes tests itself — it never accepts inline evidence. + +Operations: +- cycle-start: start a TDD cycle. Requires criterion_id, test_paths, test_selector. Records the implementation-file digest baseline. +- red: run the focused test (must fail with assertion or missing-behavior), verify implementation files are unchanged since baseline and the execution input digest is stable, then record RED evidence. +- green: run the same test (must pass), verify the execution input digest matches RED, then record GREEN evidence. +- final-regression: run the full test suite (must pass). +- final-verification: verify each tdd criterion has a passing cycle. +- abandon-cycle: abandon a cycle with a reason. +- status: read the current evidence summary. +- not-applicable: pure-documentation waiver (primary only). +- exempt-request: other waivers require user_confirmed. + +Caller: developer + primary.`, + args: { + op: tool.schema.enum(OPS).describe("TDD checkpoint operation"), + parent_issue_number: tool.schema.number().optional().describe("Parent GitHub Issue number (Flow Record)"), + task_id: tool.schema.string().optional().describe("Task Record title (Sub Issue title)"), + cycle_id: tool.schema.string().optional().describe("Cycle id (for cycle-start/red/green/abandon-cycle)"), + criterion_id: tool.schema.string().optional().describe("Acceptance criterion id (for cycle-start)"), + test_paths: tool.schema.array(tool.schema.string()).optional().describe("Test file paths for the cycle"), + test_selector: tool.schema.string().optional().describe("Focused test selector (file path)"), + reason: tool.schema.string().optional().describe("Reason for abandon-cycle / waiver"), + user_confirmed: tool.schema.boolean().optional().describe("Human approval (required for exempt-request)"), + }, + async execute(args: Record, ctx: any): Promise { + const op = args.op as string + + // caller 门禁(矩阵单一来源,§2.2):developer + primary;not-applicable 仅 primary + const denied = await requireToolCaller(ctx, "tdd_checkpoint", op, deps.sessionClient) + if (denied) return errorResponse(CALLER_NOT_AUTHORIZED, denied) + + try { + const resp = await dispatch(op, args, deps) + return resp.ok ? okResponse({ cycle: resp.cycle, evidence: resp.evidence }) : errorResponse(resp.error!.code, resp.error!.message) + } catch (err) { + return errorResponse("INTERNAL_ERROR", String(err)) + } + }, + }) +} + +async function dispatch(op: string, args: OpArgs, deps: TddCheckpointDeps): Promise { + switch (op) { + case "cycle-start": + return handleCycleStart(args, deps) + case "red": + return handleRed(args, deps) + case "green": + return handleGreen(args, deps) + case "final-regression": + return handleFinalRegression(args, deps) + case "final-verification": + return handleFinalVerification(args, deps) + case "abandon-cycle": + return handleAbandonCycle(args, deps) + case "status": + return handleStatus(args, deps) + case "not-applicable": + return handleWaiver(args, deps, "not-applicable") + case "exempt-request": + return handleWaiver(args, deps, "exempt-request") + default: + return { ok: false, error: { code: "POLICY_INVALID", message: `Unknown op: "${op}"` } } + } +} + +/** 独立注册辅助:把 tdd_checkpoint 挂到工具注册表(后续接线批次使用) */ +export function registerTddCheckpoint(registry: Record, deps: TddCheckpointDeps): void { + registry.tdd_checkpoint = createTddCheckpointTool(deps) +} + +// ─── submit 门禁(spec §4.4,供 task_control submit-task 调用) ─── + +export interface TddSubmitGateInput { + /** Task Record(Sub Issue)编号 */ + issueNumber: number + policy: TddPolicy + criteria: AcceptanceCriterion[] +} + +export type TddSubmitGateResult = + | { ok: true; status: string; warnings: string[] } + | { ok: false; code: string; message: string } + +/** + * TDD 硬门禁:evidence 必须由 kernel 亲自写入(受控 comment 存在且状态可解析), + * 再经 evaluateTddCompliance 判定;runtime 下不完整 → 拒绝。 + */ +export async function checkTddSubmitGate(input: TddSubmitGateInput): Promise { + const comment = await readTddEvidenceComment(input.issueNumber) + if (!comment) { + return { ok: false, code: "TDD_EVIDENCE_INCOMPLETE", message: "No TDD evidence comment found on the Task Record" } + } + + const extracted = extractEvidenceBlock(comment.body) + const evidence = extracted ? parseStateFromContent(extracted.content) : null + if (!evidence) { + return { ok: false, code: "TDD_EVIDENCE_INCOMPLETE", message: "Evidence comment exists but the TDD state is missing or malformed" } + } + + const compliance = evaluateTddCompliance(input.policy, evidence, input.criteria) + if (compliance.status !== "pass" && input.policy.enforcement === "runtime") { + return { ok: false, code: "TDD_EVIDENCE_INCOMPLETE", message: compliance.warnings.join("; ") } + } + return { ok: true, status: compliance.status, warnings: compliance.warnings } +} diff --git a/test/plugin/tdd-checkpoint.test.ts b/test/plugin/tdd-checkpoint.test.ts new file mode 100644 index 0000000..1ab740b --- /dev/null +++ b/test/plugin/tdd-checkpoint.test.ts @@ -0,0 +1,663 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + createTddCheckpointTool, + registerTddCheckpoint, + setTddCheckpointGhExecutor, + checkTddSubmitGate, + TASK_TDD_TAG, + TDD_STATE_TAG, + parseStateFromContent, + type TddTaskContext, +} from "../../src/plugin/tdd-checkpoint.js" +import { setRecordsGhExecutor, extractEvidenceBlock } from "../../src/kernel/records.js" +import { executeRedCheck, executeVitest } from "../../src/kernel/tdd/adapter.js" +import { computeWorkspaceDigest } from "../../src/kernel/tdd/digest.js" +import { createTaskEvidence } from "../../src/kernel/tdd/state.js" +import type { + TddPolicy, + AcceptanceCriterion, + TddCommandEvidence, + TddEvidence, + VersionedDigest, +} from "../../src/kernel/types.js" + +vi.mock("../../src/kernel/tdd/adapter.js", () => ({ + executeRedCheck: vi.fn(), + executeVitest: vi.fn(), +})) +vi.mock("../../src/kernel/tdd/digest.js", () => ({ + computeWorkspaceDigest: vi.fn(), +})) + +// ─── 常量 ─── + +const BASELINE_IMPL: VersionedDigest = { algorithm: "sha256-content-v1", value: "b".repeat(64) } +const CHANGED_IMPL: VersionedDigest = { algorithm: "sha256-content-v1", value: "c".repeat(64) } +const INPUT_DIGEST: VersionedDigest = { algorithm: "sha256-content-v1", value: "d".repeat(64) } +const OTHER_INPUT: VersionedDigest = { algorithm: "sha256-content-v1", value: "e".repeat(64) } +const PLACEHOLDER: VersionedDigest = { algorithm: "sha256-content-v1", value: "0".repeat(64) } + +// ─── 辅助工厂 ─── + +function makePolicy(overrides: Partial = {}): TddPolicy { + return { + mode: "strict", + enforcement: "runtime", + runner: { + adapter: "vitest", + baseCommand: "npx vitest run", + timeoutMs: 30000, + executionInputPatterns: ["package.json", "vitest.config.ts"], + }, + testFilePatterns: ["test/**/*.test.ts"], + implementationFilePatterns: ["src/**/*.ts"], + generatedArtifactPatterns: [], + exception: null, + source: { manifestPath: "test.yml", revisionSha: "sha1" }, + ...overrides, + } +} + +function makeCriteria(overrides: Partial[] = []): AcceptanceCriterion[] { + const base: AcceptanceCriterion[] = [ + { id: "AC-1", description: "TDD behavior", verification: "tdd" }, + { id: "AC-2", description: "regression stays green", verification: "regression" }, + ] + return base.map((c, i) => ({ ...c, ...(overrides[i] ?? {}) })) +} + +function makeRun(overrides: Partial = {}): TddCommandEvidence { + return { + command: "npx vitest run test/foo.test.ts", + testSelector: "test/foo.test.ts", + exitCode: 1, + failureKind: "assertion", + testsCollected: 3, + testsFailed: 1, + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + durationMs: 1000, + changedFiles: [], + outputDigest: { algorithm: "sha256-output-v1", value: "a".repeat(64) }, + workspaceDigest: PLACEHOLDER, + executionInputDigest: PLACEHOLDER, + summary: "1/3 tests failed", + ...overrides, + } +} + +function passingRun(): TddCommandEvidence { + return makeRun({ exitCode: 0, failureKind: null, testsFailed: 0, summary: "3/3 tests passed" }) +} + +// ─── 环境 mock(records gh + tdd gh) ─── + +function makeRecordsState() { + const comments: { id: number; body: string }[] = [] + const parseBodyArg = (args: string): string => { + const m = args.match(/(?:--body |body=')([\s\S]*?)'$/) + return m ? m[1] : "" + } + const ghFn = async (args: string): Promise<{ stdout: string; stderr: string }> => { + if (args.startsWith("issue view") && args.includes("--json comments")) { + const first = comments.find(c => c.body.includes("")) + 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='([\s\S]*?)'$/) + if (match) { + const id = Number(match[1]) + const idx = comments.findIndex(c => c.id === id) + comments[idx] = { id, body: match[2] } + } + return { stdout: "", stderr: "" } + } + throw new Error(`unexpected records gh: ${args}`) + } + return { ghFn, comments } +} + +function makeTaskBody(overrides: Partial = {}): string { + const ctx: TddTaskContext = { + issueNumber: 77, + policy: makePolicy(), + criteria: makeCriteria(), + worktreeDir: ".worktree/tdd-x", + ...overrides, + } + return `## Task Record\n\n${TASK_TDD_TAG}\n${JSON.stringify({ + policy: ctx.policy, + criteria: ctx.criteria, + worktreeDir: ctx.worktreeDir, + })}` +} + +function makeSessionClient(role: "primary" | "developer" | "reviewer" = "developer") { + const parentID = role === "primary" ? null : "parent" + return { session: { get: async () => ({ data: { parentID, metadata: {} } }) } } +} + +function makeCtx(agent: string) { + return { agent, sessionID: "sess-1", messageID: "m1", directory: ".", worktree: "." } +} + +// ─── 测试环境 ─── + +let projectDir: string +let recordsState: ReturnType + +beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), "cabbage-tdd-checkpoint-")) + recordsState = makeRecordsState() + setRecordsGhExecutor(recordsState.ghFn) + setTddCheckpointGhExecutor(async (args: string) => { + if (args.startsWith("issue list --parent")) { + return { stdout: "77", stderr: "" } + } + if (args.startsWith("issue view 77")) { + return { stdout: makeTaskBody(), stderr: "" } + } + throw new Error(`unexpected tdd gh: ${args}`) + }) + vi.mocked(executeRedCheck).mockReset() + vi.mocked(executeVitest).mockReset() + vi.mocked(computeWorkspaceDigest).mockReset() +}) + +afterEach(async () => { + setRecordsGhExecutor(null) + setTddCheckpointGhExecutor(null) + await rm(projectDir, { recursive: true, force: true }) +}) + +async function runTool( + args: Record, + opts: { agent?: string; role?: "primary" | "developer" | "reviewer" } = {}, +): Promise> { + const agent = opts.agent ?? (opts.role === "primary" ? "dev-lifecycle" : opts.role ?? "developer") + const t = createTddCheckpointTool({ projectDir, sessionClient: makeSessionClient(opts.role ?? "developer") }) + const out = await t.execute(args, makeCtx(agent) as never) + return JSON.parse(String(out)) as Record +} + +function commentBody(): string | null { + const comment = recordsState.comments[0] + return comment ? comment.body : null +} + +function latestEvidence(): TddEvidence | null { + const body = commentBody() + if (!body) return null + const extracted = extractEvidenceBlock(body) + if (!extracted) return null + return parseStateFromContent(extracted.content) +} + +function cycleStartArgs(overrides: Record = {}): Record { + return { + op: "cycle-start", + parent_issue_number: 12, + task_id: "tdd-checkpoint-tool", + cycle_id: "cycle-1", + criterion_id: "AC-1", + test_paths: ["test/foo.test.ts"], + test_selector: "test/foo.test.ts", + ...overrides, + } +} + +async function setupRedCycle(): Promise> { + // cycle-start → red,工具层可复用的前置状态 + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + const start = await runTool(cycleStartArgs()) + if (!start.ok) throw new Error(`cycle-start failed: ${start.error?.message}`) + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + return runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) +} + +// ─── args schema ─── + +describe("tdd_checkpoint tool — args schema", () => { + it("defines the nine ops in the args schema", () => { + const t = createTddCheckpointTool({ projectDir: ".", sessionClient: makeSessionClient() }) + const opSchema = t.args.op as { safeParse(value: unknown): { success: boolean } } + const ops = [ + "cycle-start", "red", "green", "final-regression", "final-verification", + "abandon-cycle", "status", "not-applicable", "exempt-request", + ] + for (const op of ops) expect(opSchema.safeParse(op).success).toBe(true) + expect(opSchema.safeParse("alternative-command").success).toBe(false) + expect(opSchema.safeParse("bogus").success).toBe(false) + }) + + it("registerTddCheckpoint mounts the tool in the registry", () => { + const registry: Record = {} + registerTddCheckpoint(registry, { projectDir: ".", sessionClient: makeSessionClient() }) + expect(registry.tdd_checkpoint).toBeDefined() + }) +}) + +// ─── caller 门禁 ─── + +describe("tdd_checkpoint — caller gate", () => { + it("allows developer to run red", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool(cycleStartArgs()) + expect(resp.ok).toBe(true) + }) + + it("rejects reviewer caller", async () => { + const resp = await runTool(cycleStartArgs(), { role: "reviewer" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CALLER_NOT_AUTHORIZED") + }) + + it("allows primary to run cycle-start", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool(cycleStartArgs(), { role: "primary" }) + expect(resp.ok).toBe(true) + }) + + it("not-applicable is restricted to primary", async () => { + const devResp = await runTool({ op: "not-applicable", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(devResp.ok).toBe(false) + expect(devResp.error.code).toBe("CALLER_NOT_AUTHORIZED") + + const priResp = await runTool({ op: "not-applicable", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }, { role: "primary" }) + expect(priResp.ok).toBe(true) + }) +}) + +// ─── cycle-start ─── + +describe("tdd_checkpoint — cycle-start", () => { + it("records the criterion and the implementation-file baseline digest", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool(cycleStartArgs()) + + expect(resp.ok).toBe(true) + expect(resp.cycle.cycleId).toBe("cycle-1") + expect(resp.cycle.status).toBe("started") + + const evidence = latestEvidence() + expect(evidence).not.toBeNull() + expect(evidence!.cycles).toHaveLength(1) + expect(evidence!.cycles[0].criterionId).toBe("AC-1") + expect(evidence!.cycles[0].startWorkspaceDigest).toEqual(BASELINE_IMPL) + expect(evidence!.status).toBe("in-progress") + expect(commentBody()).toContain(TDD_STATE_TAG) + }) + + it("rejects a non-existent criterion", async () => { + const resp = await runTool(cycleStartArgs({ criterion_id: "NONEXISTENT" })) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CRITERION_NOT_FOUND") + }) + + it("rejects test paths that do not match testFilePatterns", async () => { + const resp = await runTool(cycleStartArgs({ test_paths: ["src/foo.ts"] })) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("POLICY_INVALID") + }) + + it("rejects when required args are missing", async () => { + const resp = await runTool({ op: "cycle-start", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("POLICY_INVALID") + }) +}) + +// ─── red ─── + +describe("tdd_checkpoint — red", () => { + it("executes the test, classifies the failure, keeps impl unchanged and input stable", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + + expect(resp.ok).toBe(true) + expect(resp.cycle.status).toBe("red") + expect(executeRedCheck).toHaveBeenCalledTimes(1) + const evidence = latestEvidence() + expect(evidence!.cycles[0].redAttempts).toHaveLength(1) + expect(evidence!.cycles[0].redTestDigest).toEqual(INPUT_DIGEST) + expect(commentBody()).toContain("failureKind: assertion") + }) + + it("rejects when the test unexpectedly passes (exitCode 0)", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("RED_EXPECTED_FAILURE") + }) + + it("rejects an infrastructure failure as RED", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun({ failureKind: "infrastructure", summary: "infra error" })) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("RED_INFRASTRUCTURE_FAILURE") + }) + + it("rejects IMPL_CHANGED_IN_RED when implementation files changed since baseline", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(CHANGED_IMPL) + const resp = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("IMPL_CHANGED_IN_RED") + }) + + it("rejects INPUT_SWAPPED when a retried red changes the execution input digest", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + // 第一次 RED:成功记录 + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + const first = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(first.ok).toBe(true) + + // 重试 RED:输入 digest 变化 → 拒绝 + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(OTHER_INPUT).mockResolvedValueOnce(BASELINE_IMPL) + const retry = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(retry.ok).toBe(false) + expect(retry.error.code).toBe("INPUT_SWAPPED") + }) + + it("rejects red when the cycle does not exist", async () => { + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "nope", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CYCLE_CONFLICT") + }) +}) + +// ─── green ─── + +describe("tdd_checkpoint — green", () => { + it("executes the same test, requires pass and consistent input, then records green", async () => { + const redResp = await setupRedCycle() + expect(redResp.ok).toBe(true) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(CHANGED_IMPL) + const resp = await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + + expect(resp.ok).toBe(true) + expect(resp.cycle.status).toBe("pass") + const evidence = latestEvidence() + expect(evidence!.cycles[0].greenAttempts).toHaveLength(1) + expect(evidence!.cycles[0].status).toBe("pass") + }) + + it("rejects when the test still fails", async () => { + const redResp = await setupRedCycle() + expect(redResp.ok).toBe(true) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(CHANGED_IMPL) + const resp = await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("GREEN_EXPECTED_PASS") + }) + + it("rejects SELECTOR_SWAPPED when the execution input digest differs from RED", async () => { + const redResp = await setupRedCycle() + expect(redResp.ok).toBe(true) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(OTHER_INPUT).mockResolvedValueOnce(CHANGED_IMPL) + const resp = await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("SELECTOR_SWAPPED") + }) + + it("rejects IMPLEMENTATION_CHANGE_REQUIRED when no implementation file changed", async () => { + const redResp = await setupRedCycle() + expect(redResp.ok).toBe(true) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + const resp = await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("IMPLEMENTATION_CHANGE_REQUIRED") + }) + + it("rejects green when no RED was recorded", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(CHANGED_IMPL) + const resp = await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("INVALID_TRANSITION") + }) +}) + +// ─── final-regression ─── + +describe("tdd_checkpoint — final-regression", () => { + it("runs the full suite and records pass when all tests pass", async () => { + vi.mocked(executeVitest).mockResolvedValueOnce({ exitCode: 0, testsCollected: 5, testsFailed: 0, stdout: "", stderr: "", timedOut: false }) + const resp = await runTool({ op: "final-regression", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + + expect(resp.ok).toBe(true) + expect(executeVitest).toHaveBeenCalledTimes(1) + const evidence = latestEvidence() + expect(evidence!.regression.status).toBe("pass") + expect(evidence!.regression.runs).toHaveLength(1) + }) + + it("rejects REGRESSION_FAILED when the full suite has failures", async () => { + vi.mocked(executeVitest).mockResolvedValueOnce({ exitCode: 1, testsCollected: 5, testsFailed: 2, stdout: "", stderr: "2 failed", timedOut: false }) + const resp = await runTool({ op: "final-regression", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("REGRESSION_FAILED") + const evidence = latestEvidence() + expect(evidence!.regression.status).toBe("fail") + }) + + it("rejects when no runner is configured", async () => { + setTddCheckpointGhExecutor(async (args: string) => { + if (args.startsWith("issue list --parent")) return { stdout: "77", stderr: "" } + if (args.startsWith("issue view 77")) { + return { stdout: makeTaskBody({ policy: makePolicy({ runner: null }) }), stderr: "" } + } + throw new Error(`unexpected tdd gh: ${args}`) + }) + const resp = await runTool({ op: "final-regression", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("RUNNER_UNSUPPORTED") + }) +}) + +// ─── final-verification ─── + +describe("tdd_checkpoint — final-verification", () => { + it("verifies each tdd criterion against its pass cycle", async () => { + // 完成一个完整 cycle(cycle-start → red → green) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(CHANGED_IMPL) + const green = await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + expect(green.ok).toBe(true) + + const resp = await runTool({ op: "final-verification", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(resp.ok).toBe(true) + const evidence = latestEvidence() + expect(evidence!.verification.status).toBe("pass") + }) + + it("rejects CRITERIA_NOT_COVERED when a tdd criterion has no pass cycle", async () => { + const resp = await runTool({ op: "final-verification", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CRITERIA_NOT_COVERED") + }) +}) + +// ─── abandon-cycle ─── + +describe("tdd_checkpoint — abandon-cycle", () => { + it("abandons a started cycle and records the reason", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + + const resp = await runTool({ op: "abandon-cycle", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", reason: "wrong test design" }) + expect(resp.ok).toBe(true) + const evidence = latestEvidence() + expect(evidence!.cycles[0].status).toBe("abandoned") + expect(evidence!.warnings).toContain("wrong test design") + }) + + it("rejects abandoning a non-existent cycle", async () => { + const resp = await runTool({ op: "abandon-cycle", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "nope", reason: "oops" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CYCLE_CONFLICT") + }) +}) + +// ─── status ─── + +describe("tdd_checkpoint — status", () => { + it("returns the evidence summary", async () => { + const resp = await runTool({ op: "status", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(resp.ok).toBe(true) + expect(resp.evidence.status).toBe("not-recorded") + expect(resp.evidence.cycles).toBe(0) + }) +}) + +// ─── 豁免 ─── + +describe("tdd_checkpoint — exemptions", () => { + it("not-applicable marks evidence as waived (pure documentation change)", async () => { + const resp = await runTool({ op: "not-applicable", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", reason: "docs only" }, { role: "primary" }) + expect(resp.ok).toBe(true) + const evidence = latestEvidence() + expect(evidence!.status).toBe("waived") + expect(evidence!.warnings.some(w => w.includes("not-applicable"))).toBe(true) + }) + + it("exempt-request requires user_confirmed", async () => { + const denied = await runTool({ op: "exempt-request", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", reason: "external blocker" }, { role: "primary" }) + expect(denied.ok).toBe(false) + expect(denied.error.code).toBe("EXEMPT_REQUIRES_CONFIRMATION") + + const approved = await runTool({ op: "exempt-request", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", reason: "external blocker", user_confirmed: true }, { role: "primary" }) + expect(approved.ok).toBe(true) + const evidence = latestEvidence() + expect(evidence!.status).toBe("waived") + }) +}) + +// ─── 幂等 ─── + +describe("tdd_checkpoint — idempotency", () => { + it("repeating cycle-start with the same baseline does not append another block", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValue(BASELINE_IMPL) + const r1 = await runTool(cycleStartArgs()) + expect(r1.ok).toBe(true) + const rev1 = recordsState.comments[0] ? 1 : 0 + + const r2 = await runTool(cycleStartArgs()) + expect(r2.ok).toBe(true) + // comment 仍只有一条且 revision 未增长(幂等) + expect(recordsState.comments).toHaveLength(1) + const body = recordsState.comments[0].body + expect(body).toContain(`revision:${rev1}`) + expect(body.split("").length - 1).toBe(1) + }) +}) + +// ─── submit 门禁 ─── + +describe("checkTddSubmitGate (spec §4.4)", () => { + it("rejects TDD_EVIDENCE_INCOMPLETE when no evidence comment exists", async () => { + const gate = await checkTddSubmitGate({ issueNumber: 77, policy: makePolicy(), criteria: makeCriteria() }) + expect(gate.ok).toBe(false) + if (!gate.ok) expect(gate.code).toBe("TDD_EVIDENCE_INCOMPLETE") + }) + + it("rejects TDD_EVIDENCE_INCOMPLETE when cycles are missing under strict runtime", async () => { + // 只写一个非 cycle 的 block(无 state JSON 也视为无 evidence) + const gate = await checkTddSubmitGate({ issueNumber: 77, policy: makePolicy(), criteria: makeCriteria() }) + expect(gate.ok).toBe(false) + if (!gate.ok) expect(gate.code).toBe("TDD_EVIDENCE_INCOMPLETE") + }) + + it("passes when a full strict cycle + regression + verification are recorded", async () => { + // 完整证据:cycle-start → red → green → final-regression → final-verification + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + await runTool(cycleStartArgs()) + vi.mocked(executeRedCheck).mockResolvedValueOnce(makeRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(BASELINE_IMPL) + await runTool({ op: "red", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + vi.mocked(executeRedCheck).mockResolvedValueOnce(passingRun()) + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(INPUT_DIGEST).mockResolvedValueOnce(CHANGED_IMPL) + await runTool({ op: "green", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", cycle_id: "cycle-1", test_selector: "test/foo.test.ts" }) + vi.mocked(executeVitest).mockResolvedValueOnce({ exitCode: 0, testsCollected: 5, testsFailed: 0, stdout: "", stderr: "", timedOut: false }) + await runTool({ op: "final-regression", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + const verify = await runTool({ op: "final-verification", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + expect(verify.ok).toBe(true) + + const gate = await checkTddSubmitGate({ issueNumber: 77, policy: makePolicy(), criteria: makeCriteria() }) + expect(gate.ok).toBe(true) + }) + + it("advisory enforcement does not block on missing cycles", async () => { + const policy = makePolicy({ enforcement: "advisory", mode: "relaxed" }) + vi.mocked(executeVitest).mockResolvedValueOnce({ exitCode: 0, testsCollected: 5, testsFailed: 0, stdout: "", stderr: "", timedOut: false }) + await runTool({ op: "final-regression", parent_issue_number: 12, task_id: "tdd-checkpoint-tool" }) + const gate = await checkTddSubmitGate({ issueNumber: 77, policy, criteria: makeCriteria() }) + expect(gate.ok).toBe(true) + }) +}) + +// ─── 状态恢复(防自报) ─── + +describe("tdd_checkpoint — evidence state roundtrip", () => { + it("restores the latest state from the controlled comment", async () => { + vi.mocked(computeWorkspaceDigest).mockResolvedValue(BASELINE_IMPL) + await runTool(cycleStartArgs()) + const first = latestEvidence()! + expect(first.cycles).toHaveLength(1) + + // 第二次 op 从 comment 恢复状态后继续 + const resp = await runTool(cycleStartArgs({ cycle_id: "cycle-2", criterion_id: "AC-1", test_paths: ["test/bar.test.ts"], test_selector: "test/bar.test.ts" })) + expect(resp.ok).toBe(true) + const second = latestEvidence()! + expect(second.cycles).toHaveLength(2) + expect(second.cycles.map(c => c.cycleId)).toEqual(["cycle-1", "cycle-2"]) + }) +}) From f7dc4003ff1c54039b5e38c8e0cb10787c742074 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:55:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(plugin):=20tdd-checkpoint=20=E2=80=94?= =?UTF-8?q?=20=E8=B1=81=E5=85=8D=E6=94=BE=E8=A1=8C=20submit=20gate=20+=20w?= =?UTF-8?q?orktreeDir=20=E9=98=B2=E7=A9=BF=E8=B6=8A=20+=20GREEN=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin/tdd-checkpoint.ts | 17 ++++++++++++++--- test/plugin/tdd-checkpoint.test.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/plugin/tdd-checkpoint.ts b/src/plugin/tdd-checkpoint.ts index 9e0e9ad..e655845 100644 --- a/src/plugin/tdd-checkpoint.ts +++ b/src/plugin/tdd-checkpoint.ts @@ -13,7 +13,7 @@ */ import { tool } from "@opencode-ai/plugin/tool" import { createHash } from "node:crypto" -import { resolve } from "node:path" +import { resolve, sep as pathSep } from "node:path" import { requireToolCaller, CALLER_NOT_AUTHORIZED } from "../kernel/caller.js" import type { CallerSessionClient } from "../kernel/caller.js" import { executeRedCheck, executeVitest } from "../kernel/tdd/adapter.js" @@ -145,11 +145,16 @@ export async function resolveTaskContext( if (!block) { throw new Error(`TASK_TDD_BLOCK_MISSING: Task Record #${issueNumber} has no ${TASK_TDD_TAG} block`) } + // worktreeDir 必须为项目内相对路径(防目录穿越,与 digest.ts validateRelPath 一致) + const worktreeDir = resolve(projectDir, block.worktreeDir) + if (!worktreeDir.startsWith(resolve(projectDir) + pathSep) && worktreeDir !== resolve(projectDir)) { + throw new Error(`TASK_TDD_BLOCK_INVALID: worktreeDir "${block.worktreeDir}" escapes project root`) + } return { issueNumber, policy: block.policy, criteria: block.criteria, - worktreeDir: resolve(projectDir, block.worktreeDir), + worktreeDir, } } @@ -394,7 +399,8 @@ async function handleGreen(args: OpArgs, deps: TddCheckpointDeps): Promise { const evidence = latestEvidence() expect(evidence!.status).toBe("waived") }) + + it("submit gate passes for waived evidence (exemption closes the gate)", async () => { + await runTool({ op: "not-applicable", parent_issue_number: 12, task_id: "tdd-checkpoint-tool", reason: "docs only" }, { role: "primary" }) + const policy = makePolicy() + const criteria = makeCriteria() + const gate = await checkTddSubmitGate({ + issueNumber: 12, + policy, + criteria, + projectDir, + task: { issueNumber: 12, policy, criteria, worktreeDir: projectDir }, + }) + expect(gate.ok).toBe(true) + if (gate.ok) expect(gate.status).toBe("waived") + }) }) // ─── 幂等 ───