diff --git a/test/adversarial/01-bad-slug.test.ts b/test/adversarial/01-bad-slug.test.ts new file mode 100644 index 0000000..887d9fe --- /dev/null +++ b/test/adversarial/01-bad-slug.test.ts @@ -0,0 +1,104 @@ +/** + * 对抗测试 #1 — 坏 slug(低质量模型典型输入): + * `Task 001` / `task-1` / `implement-task` / 纯序号等必须被 validateTitle 拒绝, + * 且 create-flow / create-task 工具不得继续创建(返回对应错误码,不做任何 gh 写调用)。 + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { validateTitle, deriveSlug } from "../../src/kernel/slug.js" +import { createFlow } from "../../src/kernel/flow.js" +import { createFlowControlTool } from "../../src/plugin/flow-control.js" +import { createTaskControlTool } from "../../src/plugin/task-control.js" +import { setFlowGhExecutor } from "../../src/kernel/flow.js" +import { setRecordsGhExecutor } from "../../src/kernel/records.js" +import { setTaskGhExecutor, setTaskGitExecutor } from "../../src/kernel/task.js" + +const BAD_TITLES: Array<{ title: string; code: string }> = [ + { title: "Task 001", code: "INVALID_FORMAT" }, + { title: "task-1", code: "GENERIC_TITLE" }, + { title: "implement-task", code: "GENERIC_TITLE" }, + { title: "fix-bug", code: "GENERIC_TITLE" }, + { title: "123", code: "GENERIC_TITLE" }, + { title: "Validate-execution-binding", code: "INVALID_FORMAT" }, +] + +const primaryClient = { + session: { + get: async () => ({ data: { parentID: null } }), + update: async () => {}, + }, +} + +function makeCtx(agent: string, sessionID: string) { + return { agent, sessionID, messageID: "m1", directory: ".", worktree: "." } +} + +describe("adversarial #1 — 坏 slug 必须被拒绝", () => { + beforeEach(() => { + // 所有 gh/git 写调用都必须抛错:若校验失效会走到真实写路径 → 测试失败 + setFlowGhExecutor(() => { + throw new Error("unexpected flow gh: gate should reject before any call") + }) + setRecordsGhExecutor(() => { + throw new Error("unexpected records gh: gate should reject before any call") + }) + setTaskGhExecutor(() => { + throw new Error("unexpected task gh: gate should reject before any call") + }) + setTaskGitExecutor(() => { + throw new Error("unexpected task git: gate should reject before any call") + }) + }) + + afterEach(() => { + setFlowGhExecutor(null) + setRecordsGhExecutor(null) + setTaskGhExecutor(null) + setTaskGitExecutor(null) + }) + + it.each(BAD_TITLES)("validateTitle 拒绝 %s → %s", ({ title, code }) => { + const result = validateTitle(title) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe(code) + } + }) + + it("deriveSlug 对无意义输入返回空串(调用方据此判定,不做兜底)", () => { + expect(deriveSlug("Task 001")).toBe("task-001") + expect(deriveSlug("!!!")).toBe("") + }) + + it("createFlow(kernel 层)对坏标题返回对应错误码且不写任何 gh", async () => { + const dir = await mkdtemp(join(tmpdir(), "cabbage-adv-slug-")) + try { + const result = await createFlow({ projectDir: dir, title: "Task 001", sessionID: "s" }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("INVALID_FORMAT") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it("create-flow 工具拒绝坏标题(INVALID_FORMAT)", async () => { + const t = createFlowControlTool({ projectDir: ".", sessionClient: primaryClient }) + const out = await t.execute({ op: "create-flow", title: "implement-task" }, makeCtx("dev-lifecycle", "sess-1") as never) + const resp = JSON.parse(String(out)) as { ok: boolean; error?: { code: string } } + expect(resp.ok).toBe(false) + expect(resp.error?.code).toBe("GENERIC_TITLE") + }) + + it("create-task 工具拒绝坏标题(INVALID_FORMAT)且不做任何 gh 写调用", async () => { + const t = createTaskControlTool({ projectDir: ".", sessionClient: primaryClient }) + const out = await t.execute( + { op: "create-task", parent_issue_number: 12, title: "Task 001" }, + makeCtx("dev-lifecycle", "sess-1") as never, + ) + const resp = JSON.parse(String(out)) as { ok: boolean; error?: { code: string } } + expect(resp.ok).toBe(false) + expect(resp.error?.code).toBe("INVALID_FORMAT") + }) +}) diff --git a/test/adversarial/02-worktree-start.test.ts b/test/adversarial/02-worktree-start.test.ts new file mode 100644 index 0000000..cabcd70 --- /dev/null +++ b/test/adversarial/02-worktree-start.test.ts @@ -0,0 +1,102 @@ +/** + * 对抗测试 #2 — 非法 branch/worktree 路径: + * 分支已存在未合并 / 已合并残留 / 目录检出错误分支 → worktree-start 必须拒绝, + * 且任何 git 调用不得出现 --force(不做暴力覆盖)。 + */ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { worktreeStart } from "../../src/kernel/worktree.js" +import type { setWorktreeGitExecutor as SetGitExecutorType } from "../../src/kernel/worktree.js" + +type GitFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let setGit: typeof SetGitExecutorType +let projectDir: string + +beforeAll(async () => { + const mod = await import("../../src/kernel/worktree.js") + setGit = mod.setWorktreeGitExecutor +}) + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(os.tmpdir(), "cabbage-adv-worktree-")) + setGit(() => { + throw new Error("unexpected git call") + }) +}) + +afterEach(async () => { + setGit(null) + await rm(projectDir, { recursive: true, force: true }) +}) + +describe("adversarial #2 — 非法 branch/worktree 路径必须被拒绝", () => { + it("分支已存在未合并 → BRANCH_EXISTS_UNMERGED,不执行 worktree add,无 --force", async () => { + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" } + if (args.includes("merge-base --is-ancestor")) throw new Error("not an ancestor") + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("BRANCH_EXISTS_UNMERGED") + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + for (const c of calls) expect(c).not.toContain("--force") + }) + + it("分支已存在且已合并(残留)→ BRANCH_EXISTS,要求人工清理而非强制复用", async () => { + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" } + if (args.includes("merge-base --is-ancestor")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("BRANCH_EXISTS") + expect(result.message).toMatch(/manually/i) + } + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) + + it("目录已存在但检出错误分支 → BRANCH_MISMATCH,拒绝复用", async () => { + await mkdir(path.join(projectDir, ".worktree/user-auth-login"), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "main", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("BRANCH_MISMATCH") + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) + + it("模型无法通过路径逃逸绕过:目录/分支不一致一律拒绝,不落任何写命令", async () => { + // 恶意提交 "./.worktree/../../other-dir" 式的路径由 slug 约束([a-z0-9-])阻断, + // 这里验证 worktreeStart 对已存在目录的严格校验本身不产生 --force + await mkdir(path.join(projectDir, ".worktree/user-auth-login"), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "feat/other", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("BRANCH_MISMATCH") + for (const c of calls) expect(c).not.toContain("--force") + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) +}) diff --git a/test/adversarial/03-submit-no-red-evidence.test.ts b/test/adversarial/03-submit-no-red-evidence.test.ts new file mode 100644 index 0000000..7885452 --- /dev/null +++ b/test/adversarial/03-submit-no-red-evidence.test.ts @@ -0,0 +1,164 @@ +/** + * 对抗测试 #3 — 缺 RED evidence 直接提交 PR: + * 低质量模型跳过 TDD 直接 submit-task → 必须被 TDD_EVIDENCE_INCOMPLETE 拒绝, + * 且不得创建 PR(无 gh pr create / git push)。 + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { submitTask, setTaskGhExecutor, setTaskGitExecutor, freezeTddPolicy } from "../../src/kernel/task.js" +import { setRecordsGhExecutor } from "../../src/kernel/records.js" +import { setWorktreeGitExecutor } from "../../src/kernel/worktree.js" +import { setMergeGhExecutor } from "../../src/kernel/review.js" + +function writeTaskState(dir: string) { + return (async () => { + const { writeFile } = await import("node:fs/promises") + await mkdir(join(dir, ".opencode", "opencode-cabbage", "task-state"), { recursive: true }) + await writeFile( + join(dir, ".opencode", "opencode-cabbage", "task-state", "user-auth-login.json"), + JSON.stringify({ + slug: "user-auth-login", + parentIssueNumber: 12, + issueNumber: 13, + branch: "feat/user-auth-login", + worktreePath: join(dir, ".worktree", "user-auth-login"), + policy: freezeTddPolicy({ + testCommand: "cabbage-test-tool run", + regressionCommand: null, + testFilePatterns: [], + implementationFilePatterns: [], + tddDefaultMode: "strict", + versionBumpRule: null, + versionFile: null, + tagFormat: null, + releaseWorkflowPath: null, + riskPatterns: [], + }), + baseline: { + digest: { algorithm: "sha256-content-v1", value: "abc" }, + capturedAt: new Date().toISOString(), + testFilePatterns: [], + implementationFilePatterns: [], + }, + startedAt: new Date().toISOString(), + }), + "utf8", + ) + })() +} + +function buildTaskBody(criteriaLine: string): string { + return [ + "# Task: user-auth-login", + "", + "## Acceptance Criteria", + "", + criteriaLine, + "", + "## Dependencies", + "", + "- none", + "", + "## Status", + "", + "- [ ] running", + "- [ ] reviewing", + "- [ ] merged", + "", + ].join("\n") +} + +describe("adversarial #3 — 缺 RED evidence 直接 submit 必须被拒", () => { + beforeEach(async () => { + setTaskGhExecutor(() => { + throw new Error("unexpected task gh") + }) + setTaskGitExecutor(() => { + throw new Error("unexpected task git") + }) + setRecordsGhExecutor(() => { + throw new Error("unexpected records gh") + }) + setWorktreeGitExecutor(() => { + throw new Error("unexpected worktree git") + }) + setMergeGhExecutor(() => { + throw new Error("unexpected merge gh") + }) + }) + + afterEach(() => { + setTaskGhExecutor(null) + setTaskGitExecutor(null) + setRecordsGhExecutor(null) + setWorktreeGitExecutor(null) + setMergeGhExecutor(() => { + throw new Error("unexpected merge gh") + }) + }) + + it("完全没有 evidence comment → TDD_EVIDENCE_INCOMPLETE,且无任何写调用", async () => { + const dir = await mkdtemp(join(tmpdir(), "cabbage-adv-submit-")) + try { + await writeTaskState(dir) + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) return { stdout: buildTaskBody("- [ ] c1: x [tdd]"), stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) return { stdout: "null", stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("TDD_EVIDENCE_INCOMPLETE") + expect(result.missing).toContain("evidence-comment") + } + expect(calls.some(c => c.includes("pr create"))).toBe(false) + expect(calls.some(c => c.includes("push"))).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it("只有 RED 记录、无 final-regression/verification → 仍被拒(证据不完整)", async () => { + const dir = await mkdtemp(join(tmpdir(), "cabbage-adv-submit-")) + try { + await writeTaskState(dir) + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) { + return { stdout: buildTaskBody("- [ ] c1: x [tdd]"), stderr: "" } + } + throw new Error(`unexpected task gh: ${args}`) + }) + const redOnlyComment = + " revision:1\n### stage: red\n- criterion: c1\n- cycle: cyc-1\n- status: red\n" + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) { + return { stdout: JSON.stringify({ id: 5, body: redOnlyComment }), stderr: "" } + } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("TDD_EVIDENCE_INCOMPLETE") + expect(result.missing?.length ?? 0).toBeGreaterThan(0) + } + expect(calls.some(c => c.includes("pr create"))).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/adversarial/04-red-impl-changed.test.ts b/test/adversarial/04-red-impl-changed.test.ts new file mode 100644 index 0000000..194fe64 --- /dev/null +++ b/test/adversarial/04-red-impl-changed.test.ts @@ -0,0 +1,106 @@ +/** + * 对抗测试 #4 — RED 阶段改实现文件: + * 低质量模型在 RED 阶段提前动实现(相对 cycle 基线)→ tdd_checkpoint red 必须拒绝 + * (IMPL_CHANGED_IN_RED),不落任何 evidence。 + */ +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, setTddCheckpointGhExecutor } from "../../src/plugin/tdd-checkpoint.js" +import { setRecordsGhExecutor } from "../../src/kernel/records.js" +import { executeRedCheck } from "../../src/kernel/tdd/adapter.js" +import { computeWorkspaceDigest } from "../../src/kernel/tdd/digest.js" +import { + BASELINE_IMPL, + CHANGED_IMPL, + INPUT_DIGEST, + makeRun, + makeRecordsState, + makeTaskBody, + makeSessionClient, + makeCtx, + resetDigestMocks, +} from "./tdd-helpers.js" + +vi.mock("../../src/kernel/tdd/adapter.js", () => ({ executeRedCheck: vi.fn() })) +vi.mock("../../src/kernel/tdd/digest.js", () => ({ computeWorkspaceDigest: vi.fn() })) + +let projectDir: string +let recordsState: ReturnType + +beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), "cabbage-adv-tdd-red-")) + 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}`) + }) + resetDigestMocks() +}) + +afterEach(async () => { + setRecordsGhExecutor(null) + setTddCheckpointGhExecutor(null) + await rm(projectDir, { recursive: true, force: true }) +}) + +async function runTool(args: Record, role: "developer" | "primary" = "developer") { + const t = createTddCheckpointTool({ projectDir, sessionClient: makeSessionClient(role) }) + const agent = role === "primary" ? "dev-lifecycle" : "developer" + const out = await t.execute(args as never, makeCtx(agent) as never) + return JSON.parse(String(out)) as Record +} + +async function setupCycle() { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + const start = await runTool({ + 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", + }) + expect(start.ok).toBe(true) +} + +describe("adversarial #4 — RED 阶段改实现文件必须被拒", () => { + it("实现 digest 相对基线变化 → IMPL_CHANGED_IN_RED,且不写入 evidence", async () => { + await setupCycle() + + 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") + // 拒绝时不追加 RED evidence:仅 cycle-start 写入的一条 comment,且不含 red 块 + expect(recordsState.comments).toHaveLength(1) + expect(recordsState.comments[0].body).not.toContain("stage: red") + }) + + it("模型谎报 RED 也无法掩盖实现文件变化(digest 由工具亲自计算)", async () => { + await setupCycle() + 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") + }) +}) diff --git a/test/adversarial/05-green-selector-swapped.test.ts b/test/adversarial/05-green-selector-swapped.test.ts new file mode 100644 index 0000000..149725d --- /dev/null +++ b/test/adversarial/05-green-selector-swapped.test.ts @@ -0,0 +1,117 @@ +/** + * 对抗测试 #5 — RED 后偷换测试 selector: + * 低质量模型 RED 用一个测试、GREEN 换另一个测试/改配置(executionInputDigest 变化) + * → tdd_checkpoint green 必须拒绝(SELECTOR_SWAPPED),不写入 GREEN evidence。 + */ +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, setTddCheckpointGhExecutor } from "../../src/plugin/tdd-checkpoint.js" +import { setRecordsGhExecutor } from "../../src/kernel/records.js" +import { executeRedCheck } from "../../src/kernel/tdd/adapter.js" +import { computeWorkspaceDigest } from "../../src/kernel/tdd/digest.js" +import { + BASELINE_IMPL, + CHANGED_IMPL, + INPUT_DIGEST, + OTHER_INPUT, + makeRun, + passingRun, + makeRecordsState, + makeTaskBody, + makeSessionClient, + makeCtx, + resetDigestMocks, +} from "./tdd-helpers.js" + +vi.mock("../../src/kernel/tdd/adapter.js", () => ({ executeRedCheck: vi.fn() })) +vi.mock("../../src/kernel/tdd/digest.js", () => ({ computeWorkspaceDigest: vi.fn() })) + +let projectDir: string +let recordsState: ReturnType + +beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), "cabbage-adv-tdd-green-")) + 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}`) + }) + resetDigestMocks() +}) + +afterEach(async () => { + setRecordsGhExecutor(null) + setTddCheckpointGhExecutor(null) + await rm(projectDir, { recursive: true, force: true }) +}) + +async function runTool(args: Record, role: "developer" | "primary" = "developer") { + const t = createTddCheckpointTool({ projectDir, sessionClient: makeSessionClient(role) }) + const agent = role === "primary" ? "dev-lifecycle" : "developer" + const out = await t.execute(args as never, makeCtx(agent) as never) + return JSON.parse(String(out)) as Record +} + +/** 完成 cycle-start → red(RED 证据以 INPUT_DIGEST 落盘) */ +async function setupRedCycle() { + vi.mocked(computeWorkspaceDigest).mockResolvedValueOnce(BASELINE_IMPL) + const start = await runTool({ + 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", + }) + 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" }) +} + +describe("adversarial #5 — GREEN 偷换测试 selector 必须被拒", () => { + it("执行输入 digest 与 RED 不一致 → SELECTOR_SWAPPED,不写入 GREEN evidence", async () => { + const redResp = await setupRedCycle() + expect(redResp.ok).toBe(true) + + // 换一个测试文件/配置(digest 不同),即便测试通过也拒绝 + 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/bar.test.ts", + }) + + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("SELECTOR_SWAPPED") + // 仅 RED evidence 存在,无 GREEN 追加 + expect(recordsState.comments).toHaveLength(1) + expect(recordsState.comments[0].body).not.toContain("stage: green") + }) + + it("模型在 GREEN 谎报通过但实现未变化 → IMPLEMENTATION_CHANGE_REQUIRED(防空转)", 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") + }) +}) diff --git a/test/adversarial/06-permission-bypass.test.ts b/test/adversarial/06-permission-bypass.test.ts new file mode 100644 index 0000000..aafd4ab --- /dev/null +++ b/test/adversarial/06-permission-bypass.test.ts @@ -0,0 +1,66 @@ +/** + * 对抗测试 #6 — 模型绕过工具直接 gh pr create/merge / git push / git worktree remove: + * OpenCode permission 模型(bash 规则 + 最后匹配优先 + auto 模式 deny 生效)必须拒绝, + * 高风险写命令不得出现在 dev-lifecycle(primary)的 allow 集合。 + */ +import { describe, it, expect } from "vitest" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { loadAgents, type AgentEntry } from "../../src/plugin/agents.js" +import { matchPermission, isAllowedInAutoMode } from "../../src/kernel/permission.js" + +const AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/agents") + +/** 直接绕过生命周期工具的高风险写命令(spec §12 #6 / PRD 验收 3) */ +const BYPASS_WRITE_COMMANDS = [ + "gh pr create --title x", + "gh pr merge 42", + "git push origin feat/x", + "git worktree remove .worktree/x", + "git worktree add .worktree/x", + "git checkout -b feat/x", + "git tag v1.0.0", + "gh issue close 42", + "gh issue create --title x", + "gh release create v1.0.0", + "npm publish", +] + +function devLifecycleBashRules(): Record { + const agents = loadAgents(AGENTS_DIR) + const dev = agents.find((a: AgentEntry) => a.key === "dev-lifecycle") + expect(dev, "dev-lifecycle agent must exist").toBeDefined() + const bash = dev!.permission?.bash as Record | undefined + expect(bash, "dev-lifecycle must declare bash permission rules").toBeDefined() + return bash! +} + +describe("adversarial #6 — 直接 gh/git 写命令绕过必须被拒", () => { + it("dev-lifecycle 的 bash 规则对全部写命令解析为非 allow(deny/ask)", () => { + const rules = devLifecycleBashRules() + for (const cmd of BYPASS_WRITE_COMMANDS) { + expect(matchPermission(rules, cmd), `${cmd} 不应 allow`).not.toBe("allow") + } + }) + + it("auto 模式下写命令一律拒绝(仅 allow 放行)", () => { + const rules = devLifecycleBashRules() + for (const cmd of BYPASS_WRITE_COMMANDS) { + expect(isAllowedInAutoMode(rules, cmd), `${cmd} 在 auto 模式必须被拒`).toBe(false) + } + }) + + it("只读查询命令仍在 allow 集合(工作流不被锁死)", () => { + const rules = devLifecycleBashRules() + for (const cmd of ["git status", "git log --oneline -5", "gh pr view 42", "gh issue view 5"]) { + expect(matchPermission(rules, cmd), `${cmd} 应为 allow`).toBe("allow") + } + }) + + it("最后匹配优先:deny 置尾覆盖前序 allow,规避模型注入规则顺序", () => { + const rules = { "*": "allow", "git push*": "deny" } + expect(matchPermission(rules, "git push origin main")).toBe("deny") + const rules2 = { "*": "deny", "git status*": "allow" } + expect(matchPermission(rules2, "gh pr merge 1")).toBe("deny") + }) +}) diff --git a/test/adversarial/07-dirty-worktree-destroy.test.ts b/test/adversarial/07-dirty-worktree-destroy.test.ts new file mode 100644 index 0000000..307f74a --- /dev/null +++ b/test/adversarial/07-dirty-worktree-destroy.test.ts @@ -0,0 +1,104 @@ +/** + * 对抗测试 #7 — 脏 worktree 销毁: + * 有未提交变更的 worktree 必须被 destroyWorktree 拒绝(DIRTY_WORKTREE), + * 无任何 --force 路径;未合并 PR / 未推送分支同样需要人工确认。 + */ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { destroyWorktree } from "../../src/kernel/worktree.js" +import type { setWorktreeGitExecutor as SetGitExecutorType } from "../../src/kernel/worktree.js" + +type GitFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let setGit: typeof SetGitExecutorType +let projectDir: string + +beforeAll(async () => { + const mod = await import("../../src/kernel/worktree.js") + setGit = mod.setWorktreeGitExecutor +}) + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(os.tmpdir(), "cabbage-adv-destroy-")) + setGit(() => { + throw new Error("unexpected git call") + }) +}) + +afterEach(async () => { + setGit(null) + await rm(projectDir, { recursive: true, force: true }) +}) + +const worktreeDir = () => path.join(projectDir, ".worktree/user-auth-login") +const branch = "feat/user-auth-login" + +describe("adversarial #7 — 脏 worktree 销毁必须被拒", () => { + it("worktree 脏 → DIRTY_WORKTREE,不执行 remove,无 --force", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: " M src/kernel/worktree.ts", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: true, userConfirmed: false }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("DIRTY_WORKTREE") + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(false) + for (const c of calls) expect(c).not.toContain("--force") + }) + + it("未合并 PR + 无确认 → PR_NOT_MERGED,不查推送、不删除", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: false }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("PR_NOT_MERGED") + expect(calls.some(c => c.includes("ls-remote"))).toBe(false) + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(false) + }) + + it("已确认但分支未推送 → BRANCH_NOT_PUSHED(拒绝删除未备份分支)", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("ls-remote")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: true }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("BRANCH_NOT_PUSHED") + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(false) + }) + + it("任何成功删除路径都不含 --force(preflight 语义,无强制删除面)", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("ls-remote")) return { stdout: `abc\trefs/heads/${branch}`, stderr: "" } + if (args.startsWith("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc", stderr: "" } + if (args.startsWith("branch -D")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: true }) + expect(result.ok).toBe(true) + for (const c of calls) expect(c).not.toContain("--force") + }) +}) diff --git a/test/adversarial/08-caller-unauthorized.test.ts b/test/adversarial/08-caller-unauthorized.test.ts new file mode 100644 index 0000000..9005eb3 --- /dev/null +++ b/test/adversarial/08-caller-unauthorized.test.ts @@ -0,0 +1,87 @@ +/** + * 对抗测试 #8 — 工具 caller 越权: + * developer 调 task_control / reviewer 调写 op 等 → requireToolCaller 必须拒绝 + * (CALLER_NOT_AUTHORIZED);config 层第二道门(configureLifecycleTools)同步收紧。 + */ +import { describe, it, expect } from "vitest" +import { requireToolCaller, resolveCaller, CALLER_NOT_AUTHORIZED, type CallerContext } from "../../src/kernel/caller.js" +import { configureLifecycleTools } from "../../src/plugin/server.js" + +function sessionClient(role: "primary" | "developer" | "reviewer" | "architect" | "goal-verify") { + const parentID = role === "primary" ? null : "parent" + return { + session: { + get() { + return Promise.resolve({ data: { parentID } }) + }, + }, + } +} + +const ctx = (agent: string, sessionID = "sess_x"): CallerContext => ({ agent, sessionID }) + +const childOf = (role: "developer" | "reviewer" | "architect" | "goal-verify"): CallerContext => ctx(role) + +describe("adversarial #8 — caller 越权必须被拒(CALLER_NOT_AUTHORIZED)", () => { + it("developer 调 task_control → 拒绝", async () => { + const denied = await requireToolCaller(childOf("developer"), "task_control", "create-task", sessionClient("developer")) + expect(denied).not.toBeNull() + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + }) + + it("developer 调 flow_control / release_control / setup_control → 拒绝", async () => { + for (const tool of ["flow_control", "release_control", "setup_control"]) { + const denied = await requireToolCaller(childOf("developer"), tool, "create-flow", sessionClient("developer")) + expect(denied).not.toBeNull() + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + } + }) + + it("developer 调 tdd_checkpoint 允许(矩阵唯一放行),但 not-applicable 拒绝", async () => { + const allowed = await requireToolCaller(childOf("developer"), "tdd_checkpoint", "red", sessionClient("developer")) + expect(allowed).toBeNull() + const denied = await requireToolCaller(childOf("developer"), "tdd_checkpoint", "not-applicable", sessionClient("developer")) + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + }) + + it("reviewer 无任何生命周期工具 → 全部拒绝", async () => { + for (const tool of ["setup_control", "flow_control", "task_control", "tdd_checkpoint", "release_control"]) { + const denied = await requireToolCaller(childOf("reviewer"), tool, "probe", sessionClient("reviewer")) + expect(denied).not.toBeNull() + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + } + }) + + it("architect 仅 flow_control.status 只读;调 task_control 拒绝", async () => { + const status = await requireToolCaller(childOf("architect"), "flow_control", "status", sessionClient("architect")) + expect(status).toBeNull() + const denied = await requireToolCaller(childOf("architect"), "task_control", "merge-task", sessionClient("architect")) + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + }) + + it("未知 agent 保守视为 reviewer(fail-closed)", async () => { + await expect(resolveCaller(ctx("ghost"), sessionClient("reviewer"))).resolves.toBe("reviewer") + const denied = await requireToolCaller(ctx("ghost"), "task_control", "create-task", sessionClient("reviewer")) + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + }) + + it("config 层第二道门:developer 仅获得 tdd_checkpoint,reviewer 无任何工具", () => { + const config: { agent: Record }> } = { + agent: { + "dev-lifecycle": { tools: {} }, + developer: { tools: {} }, + reviewer: { tools: {} }, + architect: { tools: {} }, + "goal-verify": { tools: {} }, + }, + } + configureLifecycleTools(config) + expect(config.agent["developer"].tools.task_control).toBe(false) + expect(config.agent["developer"].tools.flow_control).toBe(false) + expect(config.agent["developer"].tools.tdd_checkpoint).toBe(true) + for (const tool of ["setup_control", "flow_control", "task_control", "tdd_checkpoint", "release_control"]) { + expect(config.agent["reviewer"].tools[tool]).toBe(false) + } + expect(config.agent["dev-lifecycle"].tools.task_control).toBe(true) + }) +}) diff --git a/test/adversarial/09-session-conflict.test.ts b/test/adversarial/09-session-conflict.test.ts new file mode 100644 index 0000000..84f67d8 --- /dev/null +++ b/test/adversarial/09-session-conflict.test.ts @@ -0,0 +1,64 @@ +/** + * 对抗测试 #9 — 双 session 抢占同一 Flow: + * 一个 Flow 已被 active session 绑定时,另一 session 调用 bindSession 必须拒绝 + * (FLOW_SESSION_CONFLICT);takeover 必须显式确认(TAKEOVER_NOT_CONFIRMED)。 + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { bindSession, takeoverSession, writeIndex, readIndex, type SessionIndex } from "../../src/kernel/session-index.js" + +let projectDir: string + +beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), "cabbage-adv-session-")) +}) + +afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }) +}) + +const activeIndex = (sessionID: string): SessionIndex => ({ + flows: { "12": { sessionID, status: "active", continuationCount: 0, updatedAt: 0 } }, +}) + +describe("adversarial #9 — 双 session 抢占必须被拒", () => { + it("另一 active session 已绑定 → FLOW_SESSION_CONFLICT,绑定不变", async () => { + await writeIndex(projectDir, activeIndex("sess-A")) + const result = await bindSession(projectDir, 12, "sess-B") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("FLOW_SESSION_CONFLICT") + + const index = await readIndex(projectDir) + expect(index.flows["12"].sessionID).toBe("sess-A") + }) + + it("同一 session 重复绑定幂等通过(自续绑不视为冲突)", async () => { + await writeIndex(projectDir, activeIndex("sess-A")) + const result = await bindSession(projectDir, 12, "sess-A") + expect(result.ok).toBe(true) + if (result.ok) expect(result.entry.sessionID).toBe("sess-A") + }) + + it("takeover 未确认 → TAKEOVER_NOT_CONFIRMED,旧绑定保持 active", async () => { + await writeIndex(projectDir, activeIndex("sess-A")) + const result = await takeoverSession(projectDir, 12, "sess-B", false) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("TAKEOVER_NOT_CONFIRMED") + + const index = await readIndex(projectDir) + expect(index.flows["12"]).toMatchObject({ sessionID: "sess-A", status: "active" }) + }) + + it("takeover 确认后旧 session 置 paused、新 session 绑定", async () => { + await writeIndex(projectDir, activeIndex("sess-A")) + const result = await takeoverSession(projectDir, 12, "sess-B", true) + expect(result.ok).toBe(true) + if (result.ok) expect(result.entry.sessionID).toBe("sess-B") + + const index = await readIndex(projectDir) + expect(index.flows["12"]).toMatchObject({ sessionID: "sess-B", status: "active" }) + expect(index.flows["12"].status).toBe("active") + }) +}) diff --git a/test/adversarial/10-legacy-flow-detect.test.ts b/test/adversarial/10-legacy-flow-detect.test.ts new file mode 100644 index 0000000..ee88c14 --- /dev/null +++ b/test/adversarial/10-legacy-flow-detect.test.ts @@ -0,0 +1,48 @@ +/** + * 对抗测试 #10 — 旧 FlowRun 混入: + * 旧架构(cabbage-flow-run cabinet)Issue 必须被检测并阻断(LEGACY_FLOW_DETECTED), + * 提示不迁移(不自动读取/改写旧状态)。 + */ +import { describe, it, expect, afterEach } from "vitest" +import { containsLegacyMarker, detectLegacyFlowRun } from "../../src/kernel/legacy.js" +import { createFlow, setFlowGhExecutor } from "../../src/kernel/flow.js" + +const LEGACY_BODY = + "\n```json\n{\"flowRunId\": \"fr-123\", \"stage\": \"code\"}\n```\n" + +afterEach(() => { + setFlowGhExecutor(null) +}) + +describe("adversarial #10 — legacy FlowRun 必须被检测且不迁移", () => { + it("containsLegacyMarker 需同时命中 start/end 标记(单边 marker 不算)", () => { + expect(containsLegacyMarker(LEGACY_BODY)).toBe(true) + expect(containsLegacyMarker(" only")).toBe(false) + expect(containsLegacyMarker(" only")).toBe(false) + }) + + it("detectLegacyFlowRun 命中旧 FlowRun 并提取 flowRunId", async () => { + const gh = async () => ({ stdout: LEGACY_BODY, stderr: "" }) + const result = await detectLegacyFlowRun(12, gh) + expect(result).toEqual({ legacy: true, flowRunId: "fr-123" }) + }) + + it("create-flow 绑定旧 Issue → LEGACY_FLOW_DETECTED(不迁移)", async () => { + setFlowGhExecutor(async (args) => { + if (args.includes("issue view")) return { stdout: LEGACY_BODY, stderr: "" } + throw new Error(`unexpected flow gh: ${args}`) + }) + const result = await createFlow({ projectDir: ".", title: "x", sessionID: "s", parentIssueNumber: 12 }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("LEGACY_FLOW_DETECTED") + expect(result.message).toMatch(/old plugin|0\.x|not auto-migrate/i) + } + }) + + it("新架构 Issue 不受影响(legacy false)", async () => { + const gh = async () => ({ stdout: "## Stages\n\n- [ ] requirements\n- [ ] design", stderr: "" }) + const result = await detectLegacyFlowRun(12, gh) + expect(result).toEqual({ legacy: false }) + }) +}) diff --git a/test/adversarial/11-risk-escalation.test.ts b/test/adversarial/11-risk-escalation.test.ts new file mode 100644 index 0000000..7c9996c --- /dev/null +++ b/test/adversarial/11-risk-escalation.test.ts @@ -0,0 +1,39 @@ +/** + * 对抗测试 #11 — 高风险 diff 自动升级(只升不降): + * 低质量模型初判 low,但 diff 命中 migrations/auth/permissions/release workflow/密钥 → 内核升级 high; + * 模型谎报 high 不得被降级。 + */ +import { describe, it, expect } from "vitest" +import { escalateRisk, matchRiskPattern, BUILTIN_RISK_PATTERNS } from "../../src/kernel/task.js" + +describe("adversarial #11 — 高风险 diff 升级(只升不降)", () => { + it.each([ + ["src/db/migrations/001.sql"], + ["src/service/auth/login.ts"], + ["src/policy/permissions/grant.ts"], + [".github/workflows/release.yml"], + [".env"], + ["secrets/prod.key"], + ])("diff 命中 %s → 内核把模型 low 升级为 high", (file) => { + expect(escalateRisk("low", [file], BUILTIN_RISK_PATTERNS)).toBe("high") + }) + + it("模型初判 high 即使 diff 干净也不降级", () => { + expect(escalateRisk("high", ["README.md"], BUILTIN_RISK_PATTERNS)).toBe("high") + expect(escalateRisk("high", ["src/util/fs.ts"], BUILTIN_RISK_PATTERNS)).toBe("high") + }) + + it("干净 diff + 模型 low → 维持 low", () => { + expect(escalateRisk("low", ["src/util/fs.ts"], BUILTIN_RISK_PATTERNS)).toBe("low") + }) + + it("matchRiskPattern 精确匹配(前缀/目录不得误伤)", () => { + expect(matchRiskPattern("src/db/migrations/001.sql", "**/migrations/**")).toBe(true) + expect(matchRiskPattern("src/util/fs.ts", "**/migrations/**")).toBe(false) + expect(matchRiskPattern("README.md", "**/auth/**")).toBe(false) + }) + + it("Profile 追加的风险 pattern 与内置合并生效", () => { + expect(escalateRisk("low", ["src/money/calc.ts"], [...BUILTIN_RISK_PATTERNS, "**/money/**"])).toBe("high") + }) +}) diff --git a/test/adversarial/12-unclassified-version.test.ts b/test/adversarial/12-unclassified-version.test.ts new file mode 100644 index 0000000..38e6298 --- /dev/null +++ b/test/adversarial/12-unclassified-version.test.ts @@ -0,0 +1,51 @@ +/** + * 对抗测试 #12 — 未分类变更版本提议: + * 低质量模型给出含未分类 commit 的变更集 → proposeVersion 必须拒绝 + * (UNCLASSIFIED_CHANGES),版本提议前必须完成分类。 + */ +import { describe, it, expect } from "vitest" +import { proposeVersion, classifyChanges, type ReleaseChange } from "../../src/kernel/release.js" + +const change = (title: string, category: ReleaseChange["category"] = "fix", breaking = false): ReleaseChange => ({ + sha: "abc", + title, + category, + breaking, +}) + +describe("adversarial #12 — 未分类变更版本提议必须被拒", () => { + it("含 unclassified 变更 → UNCLASSIFIED_CHANGES,列出未分类项", () => { + const result = proposeVersion("1.4.2", [ + change("fix(core): bug", "fix"), + change("random commit", "unclassified"), + ]) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("UNCLASSIFIED_CHANGES") + if (result.code === "UNCLASSIFIED_CHANGES") { + expect(result.unclassified).toEqual([{ sha: "abc", title: "random commit" }]) + } + } + }) + + it("全部变更已分类 → 正常提议(不被误伤)", () => { + const result = proposeVersion("1.4.2", [ + change("feat(core): new api", "feature"), + change("fix(core): bug", "fix"), + ]) + expect(result.ok).toBe(true) + if (result.ok) expect(result.proposed).toBe("1.5.0") + }) + + it("非 conventional 标题被 classifyChanges 标记为 unclassified(防漏网)", () => { + const classified = classifyChanges([{ sha: "s1", title: "wip stuff" }, { sha: "s2", title: "feat: x" }]) + expect(classified[0].category).toBe("unclassified") + expect(classified[1].category).toBe("feature") + }) + + it("非法当前版本 → INVALID_VERSION", () => { + const result = proposeVersion("banana", [change("fix: x", "fix")]) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("INVALID_VERSION") + }) +}) diff --git a/test/adversarial/tdd-helpers.ts b/test/adversarial/tdd-helpers.ts new file mode 100644 index 0000000..9937140 --- /dev/null +++ b/test/adversarial/tdd-helpers.ts @@ -0,0 +1,119 @@ +/** + * test/adversarial 共享测试设施(tdd_checkpoint 场景 #4/#5 复用)。 + * 无测试定义,vitest include 只匹配 *.test.ts,本文件不会被当作测试收集。 + */ +import { vi } from "vitest" +import { executeRedCheck } from "../../src/kernel/tdd/adapter.js" +import { computeWorkspaceDigest } from "../../src/kernel/tdd/digest.js" +import { TASK_TDD_TAG } from "../../src/plugin/tdd-checkpoint.js" +import type { TddPolicy, TddCommandEvidence, VersionedDigest } from "../../src/kernel/types.js" + +export const BASELINE_IMPL: VersionedDigest = { algorithm: "sha256-content-v1", value: "b".repeat(64) } +export const CHANGED_IMPL: VersionedDigest = { algorithm: "sha256-content-v1", value: "c".repeat(64) } +export const INPUT_DIGEST: VersionedDigest = { algorithm: "sha256-content-v1", value: "d".repeat(64) } +export const OTHER_INPUT: VersionedDigest = { algorithm: "sha256-content-v1", value: "e".repeat(64) } + +export 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, + } +} + +export function makeCriteria() { + return [ + { id: "AC-1", description: "TDD behavior", verification: "tdd" as const }, + { id: "AC-2", description: "regression stays green", verification: "regression" as const }, + ] +} + +export 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: BASELINE_IMPL, + executionInputDigest: INPUT_DIGEST, + summary: "1/3 tests failed", + ...overrides, + } +} + +export function passingRun(): TddCommandEvidence { + return makeRun({ exitCode: 0, failureKind: null, testsFailed: 0, summary: "3/3 tests passed" }) +} + +/** 有状态的 records gh mock:支持 evidence comment 新建与 PATCH 更新(与既有 tdd-checkpoint 测试一致) */ +export 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 } +} + +export function makeTaskBody(): string { + return `## Task Record\n\n${TASK_TDD_TAG}\n${JSON.stringify({ + policy: makePolicy(), + criteria: makeCriteria(), + worktreeDir: ".worktree/tdd-x", + })}` +} + +export function makeSessionClient(role: "developer" | "primary" = "developer") { + return { session: { get: async () => ({ data: { parentID: role === "primary" ? null : "parent" } }) } } +} + +export function makeCtx(agent: string) { + return { agent, sessionID: "sess-1", messageID: "m1", directory: ".", worktree: "." } +} + +/** 重置 adapter/digest 的 vi mock(须在 vi.mock 之后调用) */ +export function resetDigestMocks() { + vi.mocked(executeRedCheck).mockReset() + vi.mocked(computeWorkspaceDigest).mockReset() +}