From 51177cf4f06da653686dcf7221b7ebeb0ad8a0e0 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:49:47 +0800 Subject: [PATCH 1/2] feat(kernel): migrate tdd pure functions from flowrun to kernel/tdd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state/adapter/digest/evaluator 纯函数迁入 src/kernel/tdd/,仅改 import 路径(TDD 类型迁入 kernel/types.ts),逻辑零改动;迁移测试 test/kernel/tdd/*.test.ts 复用 flowrun 对应测试逻辑。flowrun 原 文件保留,由批 15 dead-code-removal 统一清理。 --- src/kernel/tdd/adapter.ts | 350 +++++++++++++ src/kernel/tdd/digest.ts | 354 +++++++++++++ src/kernel/tdd/evaluator.ts | 258 +++++++++ src/kernel/tdd/state.ts | 451 ++++++++++++++++ src/kernel/types.ts | 200 +++++++ test/kernel/tdd/adapter.test.ts | 200 +++++++ test/kernel/tdd/digest.test.ts | 283 ++++++++++ test/kernel/tdd/evaluator.test.ts | 598 +++++++++++++++++++++ test/kernel/tdd/state.test.ts | 843 ++++++++++++++++++++++++++++++ 9 files changed, 3537 insertions(+) create mode 100644 src/kernel/tdd/adapter.ts create mode 100644 src/kernel/tdd/digest.ts create mode 100644 src/kernel/tdd/evaluator.ts create mode 100644 src/kernel/tdd/state.ts create mode 100644 test/kernel/tdd/adapter.test.ts create mode 100644 test/kernel/tdd/digest.test.ts create mode 100644 test/kernel/tdd/evaluator.test.ts create mode 100644 test/kernel/tdd/state.test.ts diff --git a/src/kernel/tdd/adapter.ts b/src/kernel/tdd/adapter.ts new file mode 100644 index 0000000..1fa1ff5 --- /dev/null +++ b/src/kernel/tdd/adapter.ts @@ -0,0 +1,350 @@ +import { createHash } from "node:crypto" +import { spawn } from "node:child_process" +import type { TddRunnerPolicy, TddCommandEvidence, TddFailureKind, VersionedDigest } from "../types.js" + +// ─── 类型 ─── + +export interface VitestOutput { + exitCode: number | null + testsCollected: number | null + testsFailed: number | null + stdout: string + stderr: string + timedOut: boolean +} + +/** classifyVitestFailure 的输入 */ +export interface FailureInput { + exitCode: number | null + testsCollected: number | null + testsFailed: number | null + stderr: string + timedOut?: boolean +} + +// ─── Selector 校验 ─── + +/** 危险字符正则 */ +const DANGEROUS_SELECTOR = /[|;&$()`\x00-\x08\x0b\x0c\x0e-\x1f\r\n]/ + +/** + * 校验 test selector 语法安全。 + * 拒绝 shell 元字符、命令替换、换行等。 + */ +export function validateSelector(selector: string): void { + if (!selector || !selector.trim()) { + throw new Error("Selector must not be empty") + } + if (DANGEROUS_SELECTOR.test(selector)) { + throw new Error(`Selector contains invalid or dangerous characters: ${JSON.stringify(selector)}`) + } +} + +// ─── 命令构建 ─── + +/** + * 将 baseCommand 字符串解析为 argv 数组并附加 selector。 + */ +export function buildVitestArgs(policy: TddRunnerPolicy, selector: string): string[] { + const args = policy.baseCommand.trim().split(/\s+/).filter(Boolean) + args.push(selector) + return args +} + +// ─── 进程执行 ─── + +/** + * 执行 vitest 命令并捕获输出。 + */ +export function executeVitest(args: string[], cwd: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn(args[0], args.slice(1), { + cwd, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }) + + let stdout = "" + let stderr = "" + let timedOut = false + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8") + }) + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf-8") + }) + + child.on("error", (err: NodeJS.ErrnoException) => { + // spawn 本身失败(如找不到命令) + if (err.code === "ENOENT") { + resolve({ + exitCode: 127, + testsCollected: null, + testsFailed: null, + stdout, + stderr: `${stderr}\nCommand not found: ${args[0]}`, + timedOut: false, + }) + } else { + reject(err) + } + }) + + child.on("close", (code: number | null, signal: string | null) => { + timedOut = signal === "SIGTERM" || signal === "SIGKILL" + resolve({ + exitCode: code, + testsCollected: null, // will be enriched later + testsFailed: null, + stdout, + stderr, + timedOut, + }) + }) + }) +} + +// ─── 输出解析 ─── + +/** + * 从 vitest stdout 解析 JSON reporter 输出,提取测试统计数据。 + */ +function parseVitestJson(stdout: string): { testsCollected: number; testsFailed: number } | null { + // 在 stdout 中查找 vitest JSON reporter 的输出(通常是一个 JSON 对象) + // 支持多行 JSON 输出,查找以 "numTotalTests" 为特征的 JSON + const jsonMatch = stdout.match(/\{[\s\S]*"numTotalTests"\s*:\s*\d+[\s\S]*\}/) + if (!jsonMatch) return null + + try { + const json = JSON.parse(jsonMatch[0]) + const collected = typeof json.numTotalTests === "number" ? json.numTotalTests : null + const failed = typeof json.numFailedTests === "number" ? json.numFailedTests : null + if (collected === null || failed === null) return null + return { testsCollected: collected, testsFailed: failed } + } catch { + return null + } +} + +/** + * 从 stderr 解析 vitest 错误,提取测试计数(兜底解析)。 + */ +function parseVitestSummary(stderr: string): { testsCollected: number | null; testsFailed: number | null } { + // vitest verbose 输出: "Tests 2 passed (2) | 1 failed (1)" + // 或 "Tests 2 passed (2)" + const summaryMatch = stderr.match(/Tests\s+\d+\s+failed\s*\((\d+)\)/) + const totalMatch = stderr.match(/Tests\s+\d+\s+failed[\s\S]*?\|\s*(\d+)\s+passed/) + const passedMatch = stderr.match(/(\d+)\s+passed/) + + let testsCollected: number | null = null + let testsFailed: number | null = null + + if (summaryMatch) { + testsFailed = parseInt(summaryMatch[1], 10) + } else { + // 检查是否有 "No test files found" 或 "0 tests" + if (stderr.includes("No test files found") || stderr.includes("no tests found")) { + return { testsCollected: 0, testsFailed: 0 } + } + } + + // 尝试计算 collected = passed + failed + const totalPassedMatch = stderr.match(/(\d+)\s+passed/) + if (totalPassedMatch && testsFailed !== null) { + testsCollected = parseInt(totalPassedMatch[1], 10) + testsFailed + } else if (testsFailed === null) { + // 可能全部通过 + const allPassedMatch = stderr.match(/Tests\s+(\d+)\s+passed/) + if (allPassedMatch) { + testsCollected = parseInt(allPassedMatch[1], 10) + testsFailed = 0 + } + } + + return { testsCollected, testsFailed } +} + +// ─── 失败分类 ─── + +/** + * 根据 vitest 输出分类失败类型。 + * + * 规则: + * - timeout → "timeout" + * - 成功启动 + 收集测试 + assertion 失败 → "assertion"(RED) + * - 成功启动 + 目标代码不存在 → "missing-behavior"(RED) + * - config/transform/dep 错误 → "infrastructure" + * - 零测试收集 → "infrastructure" + */ +export function classifyVitestFailure(input: FailureInput): TddFailureKind | null { + // timeout + if (input.timedOut) return "timeout" + + // 测试全部通过 + if (input.exitCode === 0 && input.testsFailed === 0) return null + + // 退出码正常但无法判断 + if (input.exitCode === 0 && input.testsFailed === null) return null + + // 失败的退出码 + if (input.exitCode !== null && input.exitCode !== 0) { + const stderr = input.stderr + + // ── 基础设施错误 ── + + // 配置加载失败 + if (/failed to load config/i.test(stderr) || + /cannot find module.*vitest/i.test(stderr) || + /cannot find package.*vitest/i.test(stderr)) { + return "infrastructure" + } + + // Transform 错误 + if (/transform failed/i.test(stderr) || + /unexpected token/i.test(stderr)) { + return "infrastructure" + } + + // 依赖缺失(非实现代码的模块) + if (/cannot find module/i.test(stderr)) { + // 区分:如果缺失的模块匹配实现文件路径模式,则是 missing-behavior + // 否则是 infrastructure(缺少外部依赖) + const moduleMatch = stderr.match(/cannot find module\s+['"]([^'"]+)['"]/i) + if (moduleMatch) { + const missingModule = moduleMatch[1] + // 相对路径导入 → missing-behavior(目标代码不存在) + if (missingModule.startsWith(".") || missingModule.startsWith("/")) { + return "missing-behavior" + } + // 绝对包名 → infrastructure(缺少依赖) + return "infrastructure" + } + // 无法判断是什么模块,默认为 infrastructure + return "infrastructure" + } + + // 零测试收集 + if (input.testsCollected === 0) return "infrastructure" + + // ── assertion 失败 ── + // 需要 testsCollected > 0 AND testsFailed > 0 才算是 assertion + if (input.testsCollected !== null && input.testsCollected > 0 && + input.testsFailed !== null && input.testsFailed > 0) { + return "assertion" + } + + // ── 有测试收集但 testsFailed 为 0/null 且 exitCode 非零 ── 无法确定失败原因 + if (input.testsCollected !== null && input.testsCollected > 0) { + return "unknown" + } + + // 无法分类 + return "unknown" + } + + // 无退出码(被 kill 等) + if (input.exitCode === null && input.testsCollected !== null && input.testsCollected === 0) { + return "infrastructure" + } + + return "unknown" +} + +// ─── SHA-256 辅助 ─── + +function sha256Hex(content: string): string { + return createHash("sha256").update(content, "utf-8").digest("hex") +} + +// ─── 主函数 ─── + +/** + * 执行 RED check:运行 vitest focused test,返回 TddCommandEvidence。 + * + * @param policy - TDD runner policy(含 baseCommand、timeoutMs 等) + * @param selector - focused test selector(文件路径或 vitest flag) + * @param cwd - 工作目录 + */ +export async function executeRedCheck( + policy: TddRunnerPolicy, + selector: string, + cwd: string, +): Promise { + // 1. 校验 selector + validateSelector(selector) + + // 2. 构建命令 + const args = buildVitestArgs(policy, selector) + const commandStr = args.join(" ") + + // 3. 执行 + const startedAt = new Date().toISOString() + const startMs = Date.now() + const output = await executeVitest(args, cwd, policy.timeoutMs) + const durationMs = Date.now() - startMs + const finishedAt = new Date().toISOString() + + // 4. 解析 vitest 输出获取测试统计数据 + const jsonStats = parseVitestJson(output.stdout) + const summaryStats = parseVitestSummary(output.stderr) + + const testsCollected = jsonStats?.testsCollected ?? summaryStats.testsCollected ?? null + const testsFailed = jsonStats?.testsFailed ?? summaryStats.testsFailed ?? null + + // 5. 分类失败 + const failureKind = classifyVitestFailure({ + exitCode: output.exitCode, + testsCollected, + testsFailed, + stderr: output.stderr, + timedOut: output.timedOut, + }) + + // 6. 计算 outputDigest(stdout + stderr 的 SHA-256) + const outputDigest: VersionedDigest = { + algorithm: "sha256-output-v1", + value: sha256Hex(output.stdout + "\n" + output.stderr), + } + + // 7. workspaceDigest 和 executionInputDigest 留空(由调用方/broker 填充) + const placeholderDigest: VersionedDigest = { + algorithm: "sha256-content-v1", + value: "0".repeat(64), + } + + // 8. 构建 summary + let summary: string + if (output.timedOut) { + summary = `Test execution timed out after ${policy.timeoutMs}ms` + } else if (failureKind === "assertion") { + summary = `${testsFailed}/${testsCollected} tests failed (assertion)` + } else if (failureKind === "missing-behavior") { + summary = `Target code not found: ${selector}` + } else if (failureKind === "infrastructure") { + summary = `Infrastructure error: ${output.stderr.slice(0, 200)}` + } else if (output.exitCode === 0) { + summary = `${testsCollected ?? "?"} tests passed` + } else { + summary = `Exit code ${output.exitCode}: ${output.stderr.slice(0, 200)}` + } + + return { + command: commandStr, + testSelector: selector, + exitCode: output.exitCode, + failureKind, + testsCollected, + testsFailed, + startedAt, + finishedAt, + durationMs, + changedFiles: [], + outputDigest, + workspaceDigest: placeholderDigest, + executionInputDigest: placeholderDigest, + summary, + } +} diff --git a/src/kernel/tdd/digest.ts b/src/kernel/tdd/digest.ts new file mode 100644 index 0000000..99bc7d8 --- /dev/null +++ b/src/kernel/tdd/digest.ts @@ -0,0 +1,354 @@ +import { createHash } from "node:crypto" +import { readFile, lstat, readdir, realpath } from "node:fs/promises" +import { join, relative, resolve, sep, isAbsolute } from "node:path" +import { execFileSync } from "node:child_process" +import type { VersionedDigest } from "../types.js" + +// ─── 类型 ─── + +export interface DigestOptions { + testFilePatterns: string[] + implementationFilePatterns: string[] + generatedArtifactPatterns: string[] +} + +// ─── 常量 ─── + +/** 始终忽略的目录 */ +const ALWAYS_IGNORED = new Set([ + ".git", + "node_modules", + "coverage", + "dist", + ".nyc_output", +]) + +/** 始终忽略的前缀(目录 + 分隔符) */ +function isAlwaysIgnored(relPath: string): boolean { + const parts = relPath.split(sep) + if (parts.length === 0) return false + const top = parts[0] + if (!top) return false + return ALWAYS_IGNORED.has(top) +} + +/** 危险字符正则:拒绝可能逃逸路径的字符 */ +const DANGEROUS_PATH = /[\x00-\x1f\x7f]/ + +// ─── Git 辅助 ─── + +function runGit(args: string[], cwd: string): string[] { + try { + const output = execFileSync("git", args, { + cwd, + encoding: "utf-8", + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, + maxBuffer: 10 * 1024 * 1024, + }) + return output.trim().split("\n").filter(Boolean) + } catch { + return [] + } +} + +/** 获取 Git 已跟踪的文件列表(repo-relative paths) */ +function getTrackedFiles(cwd: string): Set { + const lines = runGit(["ls-files", "--cached", "-z"], cwd) + if (lines.length === 0) return new Set() + // -z 输出的行以 null 分隔,但 execFileSync 返回时 null 被转为换行 + // 实际用 -z 时输出用 \0 分隔,这里按 \0 分割 + const raw = execFileSync("git", ["ls-files", "--cached", "-z"], { + cwd, encoding: "utf-8", + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, + maxBuffer: 10 * 1024 * 1024, + }) + return new Set(raw.split("\0").filter(Boolean)) +} + +/** 获取已跟踪但已删除的文件列表 */ +function getDeletedFiles(cwd: string): Set { + try { + const raw = execFileSync("git", ["ls-files", "--deleted", "-z"], { + cwd, encoding: "utf-8", + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, + maxBuffer: 10 * 1024 * 1024, + }) + return new Set(raw.split("\0").filter(Boolean)) + } catch { + return new Set() + } +} + +// ─── 模式匹配 ─── + +/** + * 简单的 glob 匹配(支持 **、*) + * 不做完整 micromatch,仅覆盖常见 pattern + */ +function matchPattern(filePath: string, pattern: string): boolean { + // 将路径分隔符统一为 / + const normalized = filePath.split(sep).join("/") + + // 构建正则 + let regexStr = "" + let i = 0 + while (i < pattern.length) { + if (pattern[i] === "*" && pattern[i + 1] === "*") { + // **:零或多个路径段 + i += 2 + // 跳过可选的 /(**/ 匹配零或多个目录) + if (i < pattern.length && pattern[i] === "/") { + regexStr += "(.*/)?" + i++ + } else { + // 独立的 **(末尾):匹配所有 + regexStr += ".*" + } + } else if (pattern[i] === "*") { + // *:匹配单段内的任意字符(除 /) + regexStr += "[^/]*" + i++ + } else if (pattern[i] === ".") { + regexStr += "\\." + i++ + } else if (pattern[i] === "?") { + regexStr += "[^/]" + i++ + } else { + // 转义正则特殊字符 + if ("+^$(){}[]|\\".includes(pattern[i])) { + regexStr += "\\" + pattern[i] + } else { + regexStr += pattern[i] + } + i++ + } + } + + const regex = new RegExp(`^${regexStr}$`) + return regex.test(normalized) +} + +function matchesAnyPattern(filePath: string, patterns: string[]): boolean { + return patterns.some(p => matchPattern(filePath, p)) +} + +// ─── 路径校验 ─── + +function validateRelPath(relPath: string): void { + // 拒绝包含危险字符的路径 + if (DANGEROUS_PATH.test(relPath)) { + throw new Error(`Dangerous characters in path: ${relPath}`) + } + // 拒绝以 .. 开头的路径 + const normalized = relPath.split(sep).join("/") + if (normalized.startsWith("..") || normalized.includes("/../")) { + throw new Error(`Path escapes worktree: ${relPath}`) + } + if (isAbsolute(relPath)) { + throw new Error(`Path must be relative: ${relPath}`) + } +} + +// ─── 文件扫描 ─── + +/** + * 递归列出目录下所有文件(repo-relative paths) + * 忽略 always-ignored 目录 + */ +async function scanDir(root: string, subDir: string = "", results: string[] = []): Promise { + const fullPath = subDir ? join(root, subDir) : root + let dirents + try { + dirents = await readdir(fullPath, { withFileTypes: true }) + } catch { + return results + } + + for (const entry of dirents) { + const relPath = subDir ? `${subDir}${sep}${entry.name}` : entry.name + const relPathSlash = relPath.split(sep).join("/") + + // 跳过 always-ignored 顶层目录 + if (isAlwaysIgnored(relPath)) continue + + if (entry.isDirectory()) { + await scanDir(root, relPath, results) + } else if (entry.isFile() || entry.isSymbolicLink()) { + results.push(relPathSlash) + } + } + + return results +} + +// ─── 获取文件信息 ─── + +interface FileEntry { + path: string + mode: number + content: Buffer | null // null = 删除标记 +} + +async function collectFileEntries( + root: string, + tracked: Set, + deleted: Set, + untrackedPaths: Set, + generatedPatterns: string[], +): Promise { + const entries: FileEntry[] = [] + + // 处理已跟踪文件 + for (const relPath of tracked) { + validateRelPath(relPath) + + // 跳过生成的 artifacts + if (matchesAnyPattern(relPath, generatedPatterns)) continue + // 跳过 always-ignored 目录 + if (isAlwaysIgnored(relPath)) continue + + // 已删除文件:使用删除标记 + if (deleted.has(relPath)) { + entries.push({ path: relPath, mode: 0, content: null }) + continue + } + + const absPath = join(root, relPath) + try { + const fileLstat = await lstat(absPath) + + // 检查 symlink 是否逃逸 + if (fileLstat.isSymbolicLink()) { + const resolved = await realpath(absPath) + const resolvedNorm = resolve(resolved) + const rootNorm = resolve(root) + if (!resolvedNorm.startsWith(rootNorm + sep) && resolvedNorm !== rootNorm) { + throw new Error(`Symlink escapes worktree: ${relPath} → ${resolved}`) + } + } + + if (fileLstat.isFile() || fileLstat.isSymbolicLink()) { + const content = await readFile(absPath) + // mode: 只保留权限位(低 9 位),用于区分可执行位 + const mode = fileLstat.mode & 0o777 + entries.push({ path: relPath, mode, content }) + } + // 目录、设备文件等跳过 + } catch (err: unknown) { + if (err instanceof Error && err.message.startsWith("Symlink escapes")) { + throw err + } + // 文件不存在(可能被外部删除),添加删除标记 + entries.push({ path: relPath, mode: 0, content: null }) + } + } + + // 处理 policy 范围内的 untracked 文件 + for (const relPath of untrackedPaths) { + validateRelPath(relPath) + + // 跳过已跟踪文件的重复 + if (tracked.has(relPath)) continue + // 跳过 always-ignored + if (isAlwaysIgnored(relPath)) continue + // 跳过生成的 artifacts + if (matchesAnyPattern(relPath, generatedPatterns)) continue + + const absPath = join(root, relPath) + try { + const fileLstat = await lstat(absPath) + + if (fileLstat.isSymbolicLink()) { + const resolved = await realpath(absPath) + const resolvedNorm = resolve(resolved) + const rootNorm = resolve(root) + if (!resolvedNorm.startsWith(rootNorm + sep) && resolvedNorm !== rootNorm) { + throw new Error(`Symlink escapes worktree: ${relPath} → ${resolved}`) + } + } + + if (fileLstat.isFile() || fileLstat.isSymbolicLink()) { + const content = await readFile(absPath) + const mode = fileLstat.mode & 0o777 + entries.push({ path: relPath, mode, content }) + } + } catch (err: unknown) { + if (err instanceof Error && err.message.startsWith("Symlink escapes")) { + throw err + } + // 无法读取的文件跳过 + } + } + + return entries +} + +// ─── 主函数 ─── + +/** + * 计算 worktree 的 Canonical Content Digest。 + * + * 规则: + * 1. repo-relative paths,按 UTF-8 字节序排序 + * 2. 纳入文件类型(含可执行位)、原始字节、删除标记 + * 3. 拒绝逃逸 worktree 的 symlink + * 4. 忽略 .git/、node_modules/、coverage/、dist/、policy 声明的 generated artifacts + * 5. tracked + policy 范围内 untracked 文件均纳入 + */ +export async function computeWorkspaceDigest( + root: string, + options: DigestOptions, +): Promise { + const rootNorm = resolve(root) + + // 1. 获取 Git 跟踪状态 + const tracked = getTrackedFiles(rootNorm) + const deleted = getDeletedFiles(rootNorm) + + // 2. 扫描文件系统获取 policy 范围内的 untracked 文件 + const allFiles = await scanDir(rootNorm) + const includedPatterns = [...options.testFilePatterns, ...options.implementationFilePatterns] + const untrackedPaths = new Set( + allFiles.filter(f => !tracked.has(f) && matchesAnyPattern(f, includedPatterns)) + ) + + // 3. 收集文件条目 + const entries = await collectFileEntries( + rootNorm, + tracked, + deleted, + untrackedPaths, + options.generatedArtifactPatterns, + ) + + // 4. 按 UTF-8 字节序排序 + entries.sort((a, b) => { + const bufA = Buffer.from(a.path, "utf-8") + const bufB = Buffer.from(b.path, "utf-8") + return Buffer.compare(bufA, bufB) + }) + + // 5. 构建 canonical 内容 + const hash = createHash("sha256") + for (const entry of entries) { + // path + hash.update(entry.path) + hash.update("\0") + // mode (8 进制字符串) + hash.update(entry.mode.toString(8)) + hash.update("\0") + // content 或删除标记 + if (entry.content === null) { + hash.update("\0DELETED\0") + } else { + hash.update(entry.content) + } + hash.update("\0") + } + + return { + algorithm: "sha256-content-v1", + value: hash.digest("hex"), + } +} diff --git a/src/kernel/tdd/evaluator.ts b/src/kernel/tdd/evaluator.ts new file mode 100644 index 0000000..802ca3e --- /dev/null +++ b/src/kernel/tdd/evaluator.ts @@ -0,0 +1,258 @@ +import type { + TddPolicy, + TddEvidence, + AcceptanceCriterion, +} from "../types.js" + +/** + * evaluateTddCompliance 的结果类型。 + * + * - status = pass → 合规(advisory 下缺口记 warning) + * - status = fail → 不合规,阻断 + * - status = waived → 豁免(bypass 模式下替代验证全部通过) + */ +export interface EvaluationResult { + status: "pass" | "fail" | "waived" + warnings: string[] +} + +/** + * 判定 TDD 模式是否合规的纯函数。 + * + * 规则(参见 Spec Section 4.3): + * + * **strict**: + * - 必须至少有一个 verification=tDD 的 criterion + * - 每个 TDD criterion 都必须有 status=pass 的 cycle 覆盖 + * - verification=regression 的 criterion 由 final regression 覆盖 + * - verification=manual 不允许在 strict 模式下使用 + * - final regression 和 final verification 必须 pass + * + * **relaxed**: + * - 仅要求 final regression 为 pass,不检查 cycle + * - final verification 必须 pass + * + * **bypass**: + * - 必须有 exception 且其 alternativeValidation 全部完成(evidence 中对应 status=pass) + * - final verification 仍必须 pass + * - 不要求 runner / cycle / regression + * + * **enforcement**: + * - advisory:缺失 evidence 仅产生 warning,不阻断(仍返回 pass/waived) + * - runtime:缺失 evidence 直接阻断(返回 fail) + */ +export function evaluateTddCompliance( + policy: TddPolicy, + evidence: TddEvidence, + criteria: AcceptanceCriterion[] +): EvaluationResult { + const warnings: string[] = [] + const isAdvisory = policy.enforcement === "advisory" + + // ── 通用检查:final verification ── + + const verificationOk = evidence.verification.status === "pass" + if (!verificationOk) { + if (isAdvisory) { + warnings.push("final verification 未通过(advisory 模式:仅记录 warning)") + } else { + warnings.push("final verification 未通过") + return { status: "fail", warnings } + } + } + + // ── bypass 模式 ── + + if (policy.mode === "bypass") { + return evaluateBypass(policy, evidence, isAdvisory, warnings, verificationOk) + } + + // ── strict 模式 ── + + if (policy.mode === "strict") { + return evaluateStrict(policy, evidence, criteria, isAdvisory, warnings, verificationOk) + } + + // ── relaxed 模式 ── + + return evaluateRelaxed(evidence, isAdvisory, warnings, verificationOk) +} + +// ── 私有辅助函数 ── + +function evaluateBypass( + policy: TddPolicy, + evidence: TddEvidence, + isAdvisory: boolean, + warnings: string[], + verificationOk: boolean +): EvaluationResult { + // bypass 必须有 exception + if (!policy.exception) { + if (isAdvisory) { + warnings.push("bypass 模式缺少 exception(advisory 模式:仅记录 warning)") + return { status: "waived", warnings } + } + warnings.push("bypass 模式缺少 exception") + return { status: "fail", warnings } + } + + // verification 失败时已在主函数中返回 fail,此处 verificationOk 保证为 true(或 advisory 已记 warning) + if (!verificationOk) { + // advisory 下 verification 失败已记 warning,继续检查替代验证 + // 统一返回 waived(advisory 不阻断) + if (isAdvisory) { + checkAltValidations(policy, evidence, isAdvisory, warnings) + return { status: "waived", warnings } + } + return { status: "fail", warnings } + } + + // 检查替代验证 + const altOk = checkAltValidations(policy, evidence, isAdvisory, warnings) + + if (isAdvisory) { + return { status: "waived", warnings } + } + + if (!altOk) { + return { status: "fail", warnings } + } + + return { status: "waived", warnings: warnings.filter(w => w.length > 0) } +} + +function evaluateStrict( + policy: TddPolicy, + evidence: TddEvidence, + criteria: AcceptanceCriterion[], + isAdvisory: boolean, + warnings: string[], + verificationOk: boolean +): EvaluationResult { + // verification 已前置检查,runtime 下失败则已在主函数返回 fail + if (!verificationOk && !isAdvisory) { + return { status: "fail", warnings } + } + + const tddCriteria = criteria.filter(c => c.verification === "tdd") + const regressionCriteria = criteria.filter(c => c.verification === "regression") + const manualCriteria = criteria.filter(c => c.verification === "manual") + + let hasFailure = false + + // strict 必须至少有一个 TDD criterion + if (tddCriteria.length === 0) { + if (isAdvisory) { + warnings.push("strict 模式要求至少一个 verification=tdd 的 criterion(advisory 模式:仅记录 warning)") + } else { + warnings.push("strict 模式要求至少一个 verification=tdd 的 criterion") + hasFailure = true + } + } + + // manual criterion 不允许在 strict 模式 + if (manualCriteria.length > 0) { + if (isAdvisory) { + warnings.push(`strict 模式不允许 verification=manual 的 criterion: ${manualCriteria.map(c => c.id).join(", ")}(advisory 模式:仅记录 warning)`) + } else { + warnings.push(`strict 模式不允许 verification=manual 的 criterion: ${manualCriteria.map(c => c.id).join(", ")}`) + hasFailure = true + } + } + + // 每个 TDD criterion 都需要有 pass cycle 覆盖 + for (const c of tddCriteria) { + const matchingCycles = evidence.cycles.filter(cycle => cycle.criterionId === c.id) + const hasPassCycle = matchingCycles.some(cycle => cycle.status === "pass") + + if (!hasPassCycle) { + if (isAdvisory) { + warnings.push(`TDD criterion "${c.id}" 缺少有效 cycle(advisory 模式:仅记录 warning)`) + } else { + warnings.push(`TDD criterion "${c.id}" 缺少有效 cycle`) + hasFailure = true + } + } + } + + // regression criterion 由 final regression 覆盖 + if (regressionCriteria.length > 0) { + const regressionOk = evidence.regression.status === "pass" + if (!regressionOk) { + if (isAdvisory) { + warnings.push("final regression 未通过,无法覆盖 regression criterion(advisory 模式:仅记录 warning)") + } else { + warnings.push("final regression 未通过,无法覆盖 regression criterion") + hasFailure = true + } + } + } + + if (isAdvisory) { + return { status: "pass", warnings } + } + + if (hasFailure) { + return { status: "fail", warnings } + } + + return { status: "pass", warnings } +} + +function evaluateRelaxed( + evidence: TddEvidence, + isAdvisory: boolean, + warnings: string[], + verificationOk: boolean +): EvaluationResult { + if (!verificationOk && !isAdvisory) { + return { status: "fail", warnings } + } + + const regressionOk = evidence.regression.status === "pass" + + if (!regressionOk) { + if (isAdvisory) { + warnings.push("relaxed 模式要求 final regression 通过(advisory 模式:仅记录 warning)") + return { status: "pass", warnings } + } + warnings.push("relaxed 模式要求 final regression 通过") + return { status: "fail", warnings } + } + + return { status: "pass", warnings } +} + +/** + * 检查 bypass 模式下所有 alternative validation 是否完成。 + * 返回 true 表示全部通过,false 表示有缺失或失败。 + */ +function checkAltValidations( + policy: TddPolicy, + evidence: TddEvidence, + isAdvisory: boolean, + warnings: string[] +): boolean { + if (!policy.exception) { + return false + } + + let allOk = true + + for (const av of policy.exception.alternativeValidation) { + const matchingEvidence = evidence.alternativeValidation.filter(ev => ev.validationId === av.validationId) + const hasPass = matchingEvidence.some(ev => ev.status === "pass") + + if (!hasPass) { + if (isAdvisory) { + warnings.push(`替代验证 "${av.validationId}" 未完成(advisory 模式:仅记录 warning)`) + } else { + warnings.push(`替代验证 "${av.validationId}" 未完成`) + allOk = false + } + } + } + + return allOk +} diff --git a/src/kernel/tdd/state.ts b/src/kernel/tdd/state.ts new file mode 100644 index 0000000..f3236f1 --- /dev/null +++ b/src/kernel/tdd/state.ts @@ -0,0 +1,451 @@ +import type { + TddEvidence, + TddCycleEvidence, + TddCommandEvidence, + AcceptanceCriterion, + VersionedDigest, +} from "../types.js" + +// ─── 错误类型 ─── + +export interface StateError { + code: string + message: string +} + +export type StateResult = + | { ok: true; value: T } + | { ok: false; error: StateError } + +// ─── 辅助函数 ─── + +function findCycle(evidence: TddEvidence, cycleId: string): TddCycleEvidence | undefined { + return evidence.cycles.find(c => c.cycleId === cycleId) +} + +function replaceCycle(evidence: TddEvidence, updated: TddCycleEvidence): TddCycleEvidence[] { + return evidence.cycles.map(c => c.cycleId === updated.cycleId ? updated : c) +} + +function bumpRevision(evidence: TddEvidence): TddEvidence { + return { ...evidence, revision: evidence.revision + 1, updatedAt: new Date().toISOString() } +} + +/** + * 简单 glob 匹配(支持 *、**) + */ +function matchSimpleGlob(filePath: string, pattern: string): boolean { + const normalized = filePath.split("/").join("/") + let regexStr = "" + let i = 0 + while (i < pattern.length) { + if (pattern[i] === "*" && pattern[i + 1] === "*") { + i += 2 + if (i < pattern.length && pattern[i] === "/") { + regexStr += "(.*/)?" + i++ + } else { + regexStr += ".*" + } + } else if (pattern[i] === "*") { + regexStr += "[^/]*" + i++ + } else if (pattern[i] === ".") { + regexStr += "\\." + i++ + } else if (pattern[i] === "?") { + regexStr += "[^/]" + i++ + } else { + if ("+^$(){}[]|\\".includes(pattern[i])) { + regexStr += "\\" + pattern[i] + } else { + regexStr += pattern[i] + } + i++ + } + } + return new RegExp(`^${regexStr}$`).test(normalized) +} + +function matchesAnyPattern(path: string, patterns: string[]): boolean { + if (patterns.length === 0) return false + return patterns.some(p => matchSimpleGlob(path, p)) +} + +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 +} + +/** + * 判断 RED failure 是否有效: + * 只有 assertion 和 missing-behavior 才是有效的 RED failure。 + */ +function isValidRedFailure(failureKind: string | null): boolean { + return failureKind === "assertion" || failureKind === "missing-behavior" +} + +// ─── 公开函数 ─── + +/** + * 创建初始 TddEvidence(status=not-recorded, revision=0)。 + */ +export function createTaskEvidence(): TddEvidence { + return { + revision: 0, + reworkRevision: 0, + status: "not-recorded", + taskStart: { status: "pending", headSha: null, treeSha: null, startedAt: null }, + cycles: [], + regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, + alternativeValidation: [], + reworks: [], + warnings: [], + updatedAt: null, + } +} + +/** + * 启动一个新的 TDD cycle。 + * + * 验证: + * - criterion 存在且 verification=tdd + * - testPaths 匹配 testFilePatterns + * - 同 cycleId + 同 workspace digest → 幂等返回原 evidence + * - 同 cycleId + 不同 workspace digest → CYCLE_CONFLICT + */ +export function startCycle( + evidence: TddEvidence, + cycleId: string, + criterionId: string, + testPaths: string[], + testSelector: string, + workspaceDigest: VersionedDigest, + acceptanceCriteria: AcceptanceCriterion[], + testFilePatterns: string[], +): StateResult<{ evidence: TddEvidence; cycle: TddCycleEvidence }> { + // ── 验证 criterion 存在且为 tdd ── + const criterion = acceptanceCriteria.find(c => c.id === criterionId) + if (!criterion || criterion.verification !== "tdd") { + return { + ok: false, + error: { code: "CRITERION_NOT_FOUND", message: `Criterion "${criterionId}" not found or not verification=tdd` }, + } + } + + // ── 验证 testPaths 匹配 testFilePatterns ── + for (const tp of testPaths) { + if (!matchesAnyPattern(tp, testFilePatterns)) { + return { + ok: false, + error: { code: "POLICY_INVALID", message: `Test path "${tp}" does not match testFilePatterns` }, + } + } + } + + // ── 检查已有 cycle ── + const existing = findCycle(evidence, cycleId) + if (existing) { + // 幂等:相同 workspace digest + if (digestEqual(existing.startWorkspaceDigest, workspaceDigest)) { + return { ok: true, value: { evidence, cycle: existing } } + } + // 冲突 + return { + ok: false, + error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" already exists with different workspace digest` }, + } + } + + // ── 创建新 cycle ── + const cycle: TddCycleEvidence = { + cycleId, + criterionId, + reworkRevision: evidence.reworkRevision, + status: "started", + startWorkspaceDigest: workspaceDigest, + testFiles: testPaths, + redTestDigest: null, + redAttempts: [], + greenAttempts: [], + } + + const newEvidence = bumpRevision({ + ...evidence, + status: evidence.status === "not-recorded" ? "in-progress" : evidence.status, + cycles: [...evidence.cycles, cycle], + }) + + return { ok: true, value: { evidence: newEvidence, cycle } } +} + +/** + * 记录 RED 尝试。 + * + * 验证: + * - cycle 存在且 status ∈ {started, red} + * - failureKind 为 assertion 或 missing-behavior(有效 RED) + * - 幂等:相同 outputDigest → 返回原结果 + */ +export function recordRed( + evidence: TddEvidence, + cycleId: string, + redEvidence: TddCommandEvidence, +): StateResult<{ evidence: TddEvidence; cycle: TddCycleEvidence }> { + const existing = findCycle(evidence, cycleId) + if (!existing) { + return { + ok: false, + error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` }, + } + } + + // cycle 必须处于 started 或 red 状态 + if (existing.status !== "started" && existing.status !== "red") { + return { + ok: false, + error: { code: "INVALID_TRANSITION", message: `Cycle "${cycleId}" is in "${existing.status}" state, cannot record RED` }, + } + } + + // ── 失败分类校验 ── + const exitCode = redEvidence.exitCode + + // 测试通过(exitCode === 0)→ RED_EXPECTED_FAILURE + if (exitCode === 0) { + return { + ok: false, + error: { code: "RED_EXPECTED_FAILURE", message: "RED requires test failure (exitCode !== 0)" }, + } + } + + // timeout + if (redEvidence.failureKind === "timeout") { + return { + ok: false, + error: { code: "COMMAND_TIMEOUT", message: "Test execution timed out" }, + } + } + + // infrastructure 或 unknown → 不算 RED + if (!isValidRedFailure(redEvidence.failureKind)) { + return { + ok: false, + error: { code: "RED_INFRASTRUCTURE_FAILURE", message: `Failure kind "${redEvidence.failureKind}" is not a valid RED failure (expected assertion or missing-behavior)` }, + } + } + + // ── 幂等检查:相同 outputDigest ── + const dup = existing.redAttempts.find(a => digestEqual(a.outputDigest, redEvidence.outputDigest)) + if (dup) { + // 已经记录过相同的 RED,返回原 evidence(不修改) + return { ok: true, value: { evidence, cycle: existing } } + } + + // ── 记录 RED ── + const updatedCycle: TddCycleEvidence = { + ...existing, + status: "red", + redTestDigest: redEvidence.executionInputDigest, + redAttempts: [...existing.redAttempts, redEvidence], + } + + const newEvidence = bumpRevision({ + ...evidence, + cycles: replaceCycle(evidence, updatedCycle), + }) + + return { ok: true, value: { evidence: newEvidence, cycle: updatedCycle } } +} + +/** + * 记录 GREEN 尝试。 + * + * 验证: + * - cycle 存在且已有有效 RED(redTestDigest !== null) + * - GREEN exitCode === 0 + * - implementation files 有变化 + * - 幂等:相同 outputDigest → 返回原结果 + */ +export function recordGreen( + evidence: TddEvidence, + cycleId: string, + greenEvidence: TddCommandEvidence, + implementationFilesChanged: boolean, +): StateResult<{ evidence: TddEvidence; cycle: TddCycleEvidence }> { + const existing = findCycle(evidence, cycleId) + if (!existing) { + return { + ok: false, + error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` }, + } + } + + // 必须有 RED + if (existing.redTestDigest === null || existing.redAttempts.length === 0) { + return { + ok: false, + error: { code: "INVALID_TRANSITION", message: `Cycle "${cycleId}" has no RED recorded` }, + } + } + + // GREEN 测试必须通过 + if (greenEvidence.exitCode !== 0) { + return { + ok: false, + error: { code: "GREEN_EXPECTED_PASS", message: "GREEN requires all tests to pass (exitCode === 0)" }, + } + } + + // 必须有实现变化 + if (!implementationFilesChanged) { + return { + ok: false, + error: { code: "IMPLEMENTATION_CHANGE_REQUIRED", message: "GREEN requires implementation file changes" }, + } + } + + // ── 幂等检查:相同 outputDigest ── + const dup = existing.greenAttempts.find(a => digestEqual(a.outputDigest, greenEvidence.outputDigest)) + if (dup) { + return { ok: true, value: { evidence, cycle: existing } } + } + + // ── 记录 GREEN ── + const updatedCycle: TddCycleEvidence = { + ...existing, + status: "pass", + greenAttempts: [...existing.greenAttempts, greenEvidence], + } + + const newEvidence = bumpRevision({ + ...evidence, + cycles: replaceCycle(evidence, updatedCycle), + }) + + return { ok: true, value: { evidence: newEvidence, cycle: updatedCycle } } +} + +/** + * 记录最终 regression 结果。 + * + * 验证: + * - runs 非空 + * - 所有 run exitCode === 0 且无 timeout 则 pass,否则 fail + */ +export function recordFinalRegression( + evidence: TddEvidence, + headSha: string, + treeSha: string, + runs: TddCommandEvidence[], +): StateResult { + if (runs.length === 0) { + return { + ok: false, + error: { code: "REGRESSION_FAILED", message: "Regression requires at least one run" }, + } + } + + const allPass = runs.every(r => r.exitCode === 0 && r.failureKind !== "timeout") + const status = allPass ? "pass" : "fail" + + const newEvidence = bumpRevision({ + ...evidence, + regression: { + status, + headSha, + treeSha, + reworkRevision: evidence.reworkRevision, + runs, + }, + }) + + return { ok: true, value: newEvidence } +} + +/** + * 记录最终 verification 结果。 + * + * 规则: + * - runs 为空 → pass(未配置 verifyCommands) + * - 所有 run exitCode === 0 则 pass,否则 fail + */ +export function recordFinalVerification( + evidence: TddEvidence, + headSha: string, + treeSha: string, + runs: TddCommandEvidence[], +): StateResult { + if (runs.length === 0) { + // 未配置 verifyCommands,视为 pass + const newEvidence = bumpRevision({ + ...evidence, + verification: { + status: "pass", + headSha, + treeSha, + runs: [], + }, + }) + return { ok: true, value: newEvidence } + } + + const allPass = runs.every(r => r.exitCode === 0) + const status = allPass ? "pass" : "fail" + + const newEvidence = bumpRevision({ + ...evidence, + verification: { + status, + headSha, + treeSha, + runs, + }, + }) + + return { ok: true, value: newEvidence } +} + +/** + * 放弃一个 cycle。 + * + * 规则: + * - cycle 必须存在 + * - cycle 状态不能是 "pass"(已通过的 cycle 不能放弃) + * - 保留审计记录,reason 写入 warnings + */ +export function abandonCycle( + evidence: TddEvidence, + cycleId: string, + reason: string, +): StateResult { + const existing = findCycle(evidence, cycleId) + if (!existing) { + return { + ok: false, + error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` }, + } + } + + if (existing.status === "pass") { + return { + ok: false, + error: { code: "INVALID_TRANSITION", message: `Cannot abandon cycle "${cycleId}" that has already passed` }, + } + } + + const updatedCycle: TddCycleEvidence = { + ...existing, + status: "abandoned", + } + + const newEvidence = bumpRevision({ + ...evidence, + cycles: replaceCycle(evidence, updatedCycle), + warnings: [...evidence.warnings, reason], + }) + + return { ok: true, value: newEvidence } +} diff --git a/src/kernel/types.ts b/src/kernel/types.ts index 7113c45..b7b34b6 100644 --- a/src/kernel/types.ts +++ b/src/kernel/types.ts @@ -10,3 +10,203 @@ export interface TaskRecordRef { taskId: string issueNumber: number } + +// ─── TDD 证据模型(自 flowrun/types.ts 迁入,结构保持与源一致) ─── + +export interface AcceptanceCriterion { + id: string + description: string + verification: "tdd" | "regression" | "manual" +} + +export type TddMode = "strict" | "relaxed" | "bypass" +export type TddEnforcement = "advisory" | "runtime" + +export interface TddRunnerPolicy { + adapter: "vitest" + baseCommand: string + timeoutMs: number + executionInputPatterns: string[] +} + +export interface VersionedDigest { + algorithm: "sha256-content-v1" | "sha256-output-v1" | "git-tree-v1" + value: string +} + +export type TddApproval = + | { + kind: "planning-pr" + repo: string + prNumber: number + reviewId: number + approver: string + mergedCommitSha: string + policyDigest: string + } + | { + kind: "issue-comment" + repo: string + issueNumber: number + commentId: number + approver: string + commentBodyDigest: VersionedDigest + policyDigest: string + } + | { + kind: "legacy-migration" + fromSchemaVersion: 1 + } + +export interface TaskCommand { + command: string + cwd: string + timeoutMs: number + env: Record +} + +export type AlternativeValidation = + | { validationId: string; kind: "command"; command: TaskCommand } + | { validationId: string; kind: "manual"; description: string } + +export interface TddException { + reason: string + alternativeValidation: AlternativeValidation[] + approval: TddApproval +} + +export interface TddPolicy { + mode: TddMode + enforcement: TddEnforcement + runner: TddRunnerPolicy | null + testFilePatterns: string[] + implementationFilePatterns: string[] + generatedArtifactPatterns: string[] + exception: TddException | null + source: { + manifestPath: string + revisionSha: string + } +} + +export type TddFailureKind = + | "assertion" + | "missing-behavior" + | "infrastructure" + | "timeout" + | "unknown" + +export interface TddCommandEvidence { + command: string + testSelector: string | null + exitCode: number | null + failureKind: TddFailureKind | null + testsCollected: number | null + testsFailed: number | null + startedAt: string + finishedAt: string + durationMs: number + changedFiles: string[] + outputDigest: VersionedDigest + workspaceDigest: VersionedDigest + executionInputDigest: VersionedDigest + summary: string +} + +export interface TddTaskStartEvidence { + status: "pending" | "pass" | "fail" + headSha: string | null + treeSha: string | null + startedAt: string | null +} + +export interface TddCycleEvidence { + cycleId: string + criterionId: string + reworkRevision: number + status: "started" | "red" | "pass" | "failed" | "abandoned" + startWorkspaceDigest: VersionedDigest + testFiles: string[] + redTestDigest: VersionedDigest | null + redAttempts: TddCommandEvidence[] + greenAttempts: TddCommandEvidence[] +} + +export interface TddRegressionEvidence { + status: "pending" | "pass" | "fail" | "skipped" + headSha: string | null + treeSha: string | null + reworkRevision: number + runs: TddCommandEvidence[] +} + +export interface FinalVerificationEvidence { + status: "pending" | "pass" | "fail" + headSha: string | null + treeSha: string | null + runs: TddCommandEvidence[] +} + +export type AlternativeValidationEvidence = + | { + validationId: string + kind: "command" + status: "pass" | "fail" + headSha: string + treeSha: string + reworkRevision: number + evidence: TddCommandEvidence + } + | { + validationId: string + kind: "manual" + status: "pass" | "fail" + headSha: string + treeSha: string + reworkRevision: number + reviewRef: string + reviewer: string + contentDigest: VersionedDigest + policyDigest: string + summary: string + } + +export interface ReworkApproval { + reworkRevision: number + kind: "refactor" + headSha: string + treeSha: string + reviewerSessionId: string + reviewerMessageId: string + contentDigest: VersionedDigest + policyDigest: string +} + +export interface TddReworkEvidence { + reworkRevision: number + kind: "behavior" | "refactor" + affectedCriterionIds: string[] + status: "started" | "evidence-ready" | "pass" | "fail" + startHeadSha: string + approval: ReworkApproval | null +} + +export interface TddEvidence { + revision: number + reworkRevision: number + status: + | "not-recorded" + | "pending" + | "in-progress" + | "pass" + | "fail" + | "waived" + taskStart: TddTaskStartEvidence + cycles: TddCycleEvidence[] + regression: TddRegressionEvidence + verification: FinalVerificationEvidence + alternativeValidation: AlternativeValidationEvidence[] + reworks: TddReworkEvidence[] + warnings: string[] + updatedAt: string | null +} diff --git a/test/kernel/tdd/adapter.test.ts b/test/kernel/tdd/adapter.test.ts new file mode 100644 index 0000000..685de79 --- /dev/null +++ b/test/kernel/tdd/adapter.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "vitest" +import type { TddRunnerPolicy } from "../../../src/kernel/types.js" +import { validateSelector, classifyVitestFailure, buildVitestArgs } from "../../../src/kernel/tdd/adapter.js" + +// ─── 辅助函数 ─── + +function makePolicy(overrides: Partial = {}): TddRunnerPolicy { + return { + adapter: "vitest", + baseCommand: "npx vitest run", + timeoutMs: 30000, + executionInputPatterns: ["package.json", "vitest.config.ts"], + ...overrides, + } +} + +// ─── selector 语法校验 ─── + +describe("validateSelector", () => { + it("accepts valid test file paths", () => { + expect(() => validateSelector("test/sum.test.ts")).not.toThrow() + expect(() => validateSelector("test/foo/bar.test.ts")).not.toThrow() + expect(() => validateSelector("src/__tests__/sum.test.ts")).not.toThrow() + }) + + it("accepts valid vitest selectors like --grep or --bail", () => { + expect(() => validateSelector("--bail=1")).not.toThrow() + expect(() => validateSelector("--reporter=verbose")).not.toThrow() + expect(() => validateSelector("--update")).not.toThrow() + }) + + it("rejects selector containing pipe", () => { + expect(() => validateSelector("test/sum.test.ts | cat")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects selector containing semicolon", () => { + expect(() => validateSelector("test/sum.test.ts; rm -rf /")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects selector containing ampersand", () => { + expect(() => validateSelector("test/sum.test.ts &")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects selector containing dollar sign", () => { + expect(() => validateSelector("$(whoami)")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects selector containing backtick", () => { + expect(() => validateSelector("`whoami`")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects selector containing parentheses used for command substitution", () => { + expect(() => validateSelector("test/foo)(); rm -rf /")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects empty selector", () => { + expect(() => validateSelector("")).toThrow(/selector|empty/i) + }) + + it("rejects whitespace-only selector", () => { + expect(() => validateSelector(" ")).toThrow(/selector|empty/i) + }) + + it("rejects selector with newline", () => { + expect(() => validateSelector("test/sum.test.ts\nrm -rf /")).toThrow(/selector|invalid|dangerous/i) + }) + + it("rejects selector with carriage return", () => { + expect(() => validateSelector("test/sum.test.ts\rrm -rf /")).toThrow(/selector|invalid|dangerous/i) + }) +}) + +// ─── vitest args 构建 ─── + +describe("buildVitestArgs", () => { + it("parses simple baseCommand and appends selector", () => { + const policy = makePolicy({ baseCommand: "npx vitest run" }) + const args = buildVitestArgs(policy, "test/sum.test.ts") + expect(args).toEqual(["npx", "vitest", "run", "test/sum.test.ts"]) + }) + + it("handles baseCommand with extra spaces", () => { + const policy = makePolicy({ baseCommand: " npx vitest run " }) + const args = buildVitestArgs(policy, "test/sum.test.ts") + expect(args[0]).toBe("npx") + }) + + it("appends selector after baseCommand args", () => { + const policy = makePolicy({ baseCommand: "npx vitest run --reporter=json" }) + const args = buildVitestArgs(policy, "test/sum.test.ts") + expect(args[args.length - 1]).toBe("test/sum.test.ts") + expect(args).toContain("--reporter=json") + }) +}) + +// ─── 失败分类 ─── + +describe("classifyVitestFailure", () => { + it("classifies assertion failure when tests collected and exit code non-zero", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 3, + testsFailed: 1, + stderr: "AssertionError: expected 2 to be 3\n ❯ test/sum.test.ts:5:25", + }) + expect(result).toBe("assertion") + }) + + it("classifies missing-behavior when module resolution error for implementation file", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 0, + testsFailed: 0, + stderr: "Error: Cannot find module '../src/sum.js' imported from test/sum.test.ts", + }) + expect(result).toBe("missing-behavior") + }) + + it("classifies as infrastructure for config error", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 0, + testsFailed: 0, + stderr: "failed to load config from vitest.config.ts", + }) + expect(result).toBe("infrastructure") + }) + + it("classifies as infrastructure for dependency missing", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 0, + testsFailed: 0, + stderr: "Error: Cannot find package 'vitest' imported from ...", + }) + expect(result).toBe("infrastructure") + }) + + it("classifies as infrastructure for transform error", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 0, + testsFailed: 0, + stderr: "Transform failed with 1 error: src/broken.ts:1:0: ERROR: Unexpected token", + }) + expect(result).toBe("infrastructure") + }) + + it("classifies as infrastructure when zero tests collected", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 0, + testsFailed: 0, + stderr: "No test files found matching the pattern", + }) + expect(result).toBe("infrastructure") + }) + + it("classifies as timeout when process timed out", () => { + const result = classifyVitestFailure({ + exitCode: null, // killed by timeout + testsCollected: null, + testsFailed: null, + stderr: "", + timedOut: true, + }) + expect(result).toBe("timeout") + }) + + it("returns null when all tests pass", () => { + const result = classifyVitestFailure({ + exitCode: 0, + testsCollected: 5, + testsFailed: 0, + stderr: "", + timedOut: false, + }) + expect(result).toBeNull() + }) + + it("returns unknown for unrecognized failure pattern", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 1, + testsFailed: 0, // exit code 1 but no test failed — unusual + stderr: "Something went wrong", + }) + expect(result).toBe("unknown") + }) + + it("classifies infrastructure for cant find module to vitest deps (not implementation)", () => { + const result = classifyVitestFailure({ + exitCode: 1, + testsCollected: 0, + testsFailed: 0, + stderr: "Error: Cannot find module '@testing-library/react'", + }) + expect(result).toBe("infrastructure") + }) +}) diff --git a/test/kernel/tdd/digest.test.ts b/test/kernel/tdd/digest.test.ts new file mode 100644 index 0000000..62868cc --- /dev/null +++ b/test/kernel/tdd/digest.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest" +import { mkdtempSync, writeFileSync, symlinkSync, chmodSync, rmSync, mkdirSync } from "node:fs" +import { join, dirname } from "node:path" +import { tmpdir } from "node:os" +import { execSync } from "node:child_process" +import { computeWorkspaceDigest, type DigestOptions } from "../../../src/kernel/tdd/digest.js" + +// ─── 辅助函数 ─── + +let tmpDir: string +let worktree: string + +function git(args: string, cwd: string = worktree): string { + try { + return execSync(`git ${args}`, { cwd, encoding: "utf-8", env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: tmpDir } }) + } catch { + return "" + } +} + +function setupGitRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "cabbage-digest-test-")) + execSync("git init", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync("git config user.email test@test.com", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync("git config user.name test", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + return dir +} + +/** 写入文件前自动创建父目录 */ +function writeFile(filePath: string, content: string): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, content) +} + +function defaultOptions(overrides: Partial = {}): DigestOptions { + return { + testFilePatterns: ["test/**/*.ts"], + implementationFilePatterns: ["src/**/*.ts"], + generatedArtifactPatterns: [], + ...overrides, + } +} + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), "cabbage-digest-")) + worktree = setupGitRepo() +}) + +afterAll(() => { + try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ } +}) + +// ─── 测试 ─── + +describe("computeWorkspaceDigest", () => { + it("returns a VersionedDigest with sha256-content-v1 algorithm", async () => { + writeFile(join(worktree, "README.md"), "hello") + git("add README.md") + git('commit -m "init"') + + const result = await computeWorkspaceDigest(worktree, defaultOptions()) + expect(result.algorithm).toBe("sha256-content-v1") + expect(result.value).toMatch(/^[a-f0-9]{64}$/) + }) + + it("produces deterministic digest for same workspace content", async () => { + // clean workspace with tracked file + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + writeFile(join(dir, "test", "index.test.ts"), 'import { x } from "../src/index.js"') + execSync("git add src/index.ts test/index.test.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const opts = defaultOptions() + const d1 = await computeWorkspaceDigest(dir, opts) + const d2 = await computeWorkspaceDigest(dir, opts) + + expect(d1.value).toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("produces different digest when tracked file content changes", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const opts = defaultOptions() + const d1 = await computeWorkspaceDigest(dir, opts) + + writeFile(join(dir, "src", "index.ts"), "export const x = 2") + const d2 = await computeWorkspaceDigest(dir, opts) + + expect(d1.value).not.toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("includes executable bit in digest", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "script.sh"), "#!/bin/sh\necho hi") + chmodSync(join(dir, "script.sh"), 0o755) + execSync("git add script.sh", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const d1 = await computeWorkspaceDigest(dir, defaultOptions()) + + chmodSync(join(dir, "script.sh"), 0o644) + const d2 = await computeWorkspaceDigest(dir, defaultOptions()) + + expect(d1.value).not.toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("includes delete marker for tracked but deleted files", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const opts = defaultOptions() + const d1 = await computeWorkspaceDigest(dir, opts) + + rmSync(join(dir, "src", "index.ts")) + const d2 = await computeWorkspaceDigest(dir, opts) + + // 删除文件 vs 存在文件,digest 应不同 + expect(d1.value).not.toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("ignores .git/ directory", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const d1 = await computeWorkspaceDigest(dir, defaultOptions()) + + // 修改 .git 内部文件不应影响 digest + writeFile(join(dir, ".git", "some-file"), "should be ignored") + const d2 = await computeWorkspaceDigest(dir, defaultOptions()) + + expect(d1.value).toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("ignores node_modules/ directory", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const d1 = await computeWorkspaceDigest(dir, defaultOptions()) + + mkdirSync(join(dir, "node_modules", "pkg"), { recursive: true }) + writeFile(join(dir, "node_modules", "pkg", "index.js"), "module.exports = {}") + const d2 = await computeWorkspaceDigest(dir, defaultOptions()) + + expect(d1.value).toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("ignores coverage/ and dist/ directories", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const d1 = await computeWorkspaceDigest(dir, defaultOptions()) + + mkdirSync(join(dir, "coverage"), { recursive: true }) + writeFile(join(dir, "coverage", "report.json"), "{}") + mkdirSync(join(dir, "dist"), { recursive: true }) + writeFile(join(dir, "dist", "index.js"), "x") + const d2 = await computeWorkspaceDigest(dir, defaultOptions()) + + expect(d1.value).toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("ignores generated artifacts declared in policy", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "index.ts"), "export const x = 1") + execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const opts = defaultOptions({ + generatedArtifactPatterns: ["coverage/**", "build/**"], + }) + const d1 = await computeWorkspaceDigest(dir, opts) + + mkdirSync(join(dir, "coverage"), { recursive: true }) + writeFile(join(dir, "coverage", "lcov.info"), "SF:src/index.ts") + mkdirSync(join(dir, "build"), { recursive: true }) + writeFile(join(dir, "build", "output.js"), "console.log(1)") + const d2 = await computeWorkspaceDigest(dir, opts) + + expect(d1.value).toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("includes policy-scoped untracked files", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "existing.ts"), "export const y = 2") + execSync("git add src/existing.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const opts = defaultOptions() + const d1 = await computeWorkspaceDigest(dir, opts) + + // 添加一个匹配 implementationFilePatterns 的 untracked 文件 + writeFile(join(dir, "src", "new-feature.ts"), "export const z = 3") + const d2 = await computeWorkspaceDigest(dir, opts) + + expect(d1.value).not.toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("sorts paths by UTF-8 byte order", async () => { + // 验证排序对 digest 的影响:使用两个不同路径前缀确保一致性 + const dir = setupGitRepo() + writeFile(join(dir, "a.ts"), "a") + writeFile(join(dir, "b.ts"), "b") + execSync("git add a.ts b.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + // 即使文件系统返回不同顺序,digest 应一致 + const d1 = await computeWorkspaceDigest(dir, defaultOptions()) + const d2 = await computeWorkspaceDigest(dir, defaultOptions()) + + expect(d1.value).toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) + + it("rejects symlinks that escape the worktree", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "safe.ts"), "a") + execSync("git add src/safe.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + // 创建指向 worktree 外部的 symlink 并加入 git 跟踪 + // 注意:外部目标路径必须唯一(与 test/flowrun/digest.test.ts 的同名测试并行运行,避免固定路径冲突) + const outsidePath = join(tmpdir(), `cabbage-digest-outside-${Math.random().toString(36).slice(2)}`) + writeFileSync(outsidePath, "secret") + symlinkSync(outsidePath, join(dir, "src", "escape-link.ts")) + + // 确保 symlink 被 git 跟踪,这样 digest 必须处理它 + execSync("git add src/escape-link.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + await expect(computeWorkspaceDigest(dir, defaultOptions())).rejects.toThrow(/symlink|escape|outside/i) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + try { rmSync(outsidePath) } catch { /* ignore */ } + }) + + it("includes untracked test files matching testFilePatterns", async () => { + const dir = setupGitRepo() + writeFile(join(dir, "src", "lib.ts"), "export const fn = () => 1") + execSync("git add src/lib.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const opts = defaultOptions() + const d1 = await computeWorkspaceDigest(dir, opts) + + // 添加 untracked 测试文件 + writeFile(join(dir, "test", "lib.test.ts"), 'import { fn } from "../src/lib.js";\ndescribe("test", () => { it("works", () => expect(fn()).toBe(1)) })') + const d2 = await computeWorkspaceDigest(dir, opts) + + expect(d1.value).not.toBe(d2.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) +}) diff --git a/test/kernel/tdd/evaluator.test.ts b/test/kernel/tdd/evaluator.test.ts new file mode 100644 index 0000000..31b33bd --- /dev/null +++ b/test/kernel/tdd/evaluator.test.ts @@ -0,0 +1,598 @@ +import { describe, it, expect } from "vitest" +import type { + TddPolicy, + TddEvidence, + TddCycleEvidence, + TddRegressionEvidence, + FinalVerificationEvidence, + AlternativeValidationEvidence, + AcceptanceCriterion, + VersionedDigest, +} from "../../../src/kernel/types.js" +import { evaluateTddCompliance } from "../../../src/kernel/tdd/evaluator.js" + +// ─── 辅助工厂函数 ─── + +function emptyDigest(): VersionedDigest { + return { algorithm: "sha256-content-v1", value: "abc123" } +} + +function makePolicy(overrides: Partial): TddPolicy { + return { + mode: "strict", + enforcement: "advisory", + runner: null, + testFilePatterns: [], + implementationFilePatterns: [], + generatedArtifactPatterns: [], + exception: null, + source: { manifestPath: "test/manifest.yml", revisionSha: "sha1" }, + ...overrides, + } +} + +function makeEvidence(overrides: Partial = {}): TddEvidence { + return { + revision: 1, + reworkRevision: 0, + status: "in-progress", + taskStart: { status: "pass", headSha: "head1", treeSha: "tree1", startedAt: "2026-01-01T00:00:00Z" }, + cycles: [], + regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, + alternativeValidation: [], + reworks: [], + warnings: [], + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + } +} + +function makeCycle(criterionId: string, status: TddCycleEvidence["status"] = "pass"): TddCycleEvidence { + return { + cycleId: `cycle-${criterionId}`, + criterionId, + reworkRevision: 0, + status, + startWorkspaceDigest: emptyDigest(), + testFiles: [], + redTestDigest: emptyDigest(), + redAttempts: [], + greenAttempts: [], + } +} + +function makeRegression(status: TddRegressionEvidence["status"] = "pass"): TddRegressionEvidence { + return { + status, + headSha: "head1", + treeSha: "tree1", + reworkRevision: 0, + runs: [], + } +} + +function makeVerification(status: FinalVerificationEvidence["status"] = "pass"): FinalVerificationEvidence { + return { status, headSha: "head1", treeSha: "tree1", runs: [] } +} + +function makeAltEvidence(validationId: string, status: "pass" | "fail" = "pass"): AlternativeValidationEvidence { + return { + validationId, + kind: "command", + status, + headSha: "head1", + treeSha: "tree1", + reworkRevision: 0, + evidence: { + command: "test", + testSelector: null, + exitCode: 0, + failureKind: null, + testsCollected: 1, + testsFailed: 0, + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + durationMs: 1000, + changedFiles: [], + outputDigest: emptyDigest(), + workspaceDigest: emptyDigest(), + executionInputDigest: emptyDigest(), + summary: "ok", + }, + } +} + +const tddCriterion: AcceptanceCriterion = { id: "AC-1", description: "tdd test", verification: "tdd" } +const regressionCriterion: AcceptanceCriterion = { id: "AC-2", description: "regression test", verification: "regression" } +const manualCriterion: AcceptanceCriterion = { id: "AC-3", description: "manual test", verification: "manual" } + +// ─── 测试 ─── + +describe("evaluateTddCompliance", () => { + // ── 基础类型安全 ── + + it("returns a well-structured result", () => { + const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result).toHaveProperty("status") + expect(result).toHaveProperty("warnings") + expect(["pass", "fail", "waived"]).toContain(result.status) + expect(Array.isArray(result.warnings)).toBe(true) + }) + + // ── strict + advisory ── + + it("strict + advisory + all cycles pass → pass with no warnings", () => { + const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings).toEqual([]) + }) + + it("strict + advisory + missing cycle → pass with warnings", () => { + const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings.length).toBeGreaterThan(0) + expect(result.warnings.some((w: string) => w.includes("AC-1"))).toBe(true) + }) + + it("strict + advisory + regression fail → pass with warnings", () => { + const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression("fail"), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + it("strict + advisory + verification fail → pass with warnings", () => { + const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification("fail"), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + // ── strict + runtime ── + + it("strict + runtime + all criteria covered → pass", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings).toEqual([]) + }) + + it("strict + runtime + missing cycle for TDD criterion → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("fail") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + it("strict + runtime + failed cycle → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1", "failed")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("fail") + }) + + it("strict + runtime + abandoned cycle → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1", "abandoned")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("fail") + }) + + it("strict + runtime + regression fail → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression("fail"), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) + expect(result.status).toBe("fail") + }) + + it("strict + runtime + verification fail → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification("fail"), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("fail") + }) + + it("strict + runtime + multiple TDD criteria, one uncovered → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const criterion2: AcceptanceCriterion = { id: "AC-2", description: "second tdd", verification: "tdd" } + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, criterion2]) + expect(result.status).toBe("fail") + }) + + it("strict + runtime + multiple TDD criteria, all covered → pass", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const criterion2: AcceptanceCriterion = { id: "AC-2", description: "second tdd", verification: "tdd" } + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1"), makeCycle("AC-2")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, criterion2]) + expect(result.status).toBe("pass") + }) + + // ── strict: no TDD criteria → fail ── + + it("strict + runtime + no TDD criteria → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("fail") + }) + + // ── strict: manual criterion not allowed ── + + it("strict + runtime + has manual criterion → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, manualCriterion]) + expect(result.status).toBe("fail") + }) + + // ── relaxed + advisory ── + + it("relaxed + advisory + regression pass → pass (no cycles needed)", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings).toEqual([]) + }) + + it("relaxed + advisory + regression fail → pass with warnings", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression("fail"), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + it("relaxed + advisory + pending regression → pass with warnings", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + // ── relaxed + runtime ── + + it("relaxed + runtime + regression pass → pass", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("pass") + }) + + it("relaxed + runtime + regression fail → fail", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression("fail"), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("fail") + }) + + it("relaxed + runtime + pending regression → fail", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("fail") + }) + + it("relaxed + runtime + verification fail → fail", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification("fail"), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("fail") + }) + + // ── bypass + advisory ── + + it("bypass + advisory + all alt validations done → waived", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "advisory", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [makeAltEvidence("ALT-1")], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("waived") + }) + + it("bypass + advisory + missing alt validation → waived with warnings", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "advisory", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("waived") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + // ── bypass + runtime ── + + it("bypass + runtime + all alt validations done → waived", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "runtime", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [makeAltEvidence("ALT-1")], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("waived") + }) + + it("bypass + runtime + missing alt validation → fail", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "runtime", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("fail") + }) + + it("bypass + runtime + failed alt validation → fail", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "runtime", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [makeAltEvidence("ALT-1", "fail")], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("fail") + }) + + it("bypass + runtime + no exception → fail", () => { + const policy = makePolicy({ mode: "bypass", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("fail") + }) + + // ── bypass: verification must still pass ── + + it("bypass + runtime + verification fail → fail", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "runtime", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification("fail"), + alternativeValidation: [makeAltEvidence("ALT-1")], + }) + const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) + expect(result.status).toBe("fail") + }) + + // ── empty criteria array ── + + it("strict + runtime + empty criteria → fail", () => { + const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, []) + expect(result.status).toBe("fail") + }) + + // ── relaxed mode: no regression criteria needed, just checks regression evidence ── + + it("relaxed + runtime + regression pass + no criteria → pass", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, []) + expect(result.status).toBe("pass") + }) + + // ── strict + advisory + all pass → no warnings ── + + it("strict + advisory + perfect evidence → pass with no warnings", () => { + const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [makeCycle("AC-1")], + regression: makeRegression(), + verification: makeVerification(), + }) + const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings).toEqual([]) + }) + + // ── relaxed + advisory + verification fail → warnings ── + + it("relaxed + advisory + verification fail → pass with warnings", () => { + const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) + const evidence = makeEvidence({ + cycles: [], + regression: makeRegression(), + verification: makeVerification("fail"), + }) + const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) + expect(result.status).toBe("pass") + expect(result.warnings.length).toBeGreaterThan(0) + }) + + // ── bypass: no criteria needed ── + + it("bypass + runtime + all alt validations done + no criteria → waived", () => { + const policy = makePolicy({ + mode: "bypass", + enforcement: "runtime", + exception: { + reason: "visual only", + alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], + approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, + }, + }) + const evidence = makeEvidence({ + cycles: [], + regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: makeVerification(), + alternativeValidation: [makeAltEvidence("ALT-1")], + }) + const result = evaluateTddCompliance(policy, evidence, []) + expect(result.status).toBe("waived") + }) +}) diff --git a/test/kernel/tdd/state.test.ts b/test/kernel/tdd/state.test.ts new file mode 100644 index 0000000..6b116cb --- /dev/null +++ b/test/kernel/tdd/state.test.ts @@ -0,0 +1,843 @@ +import { describe, it, expect } from "vitest" +import type { + TddEvidence, + TddCycleEvidence, + TddCommandEvidence, + AcceptanceCriterion, + VersionedDigest, +} from "../../../src/kernel/types.js" +import { + createTaskEvidence, + startCycle, + recordRed, + recordGreen, + recordFinalRegression, + recordFinalVerification, + abandonCycle, +} from "../../../src/kernel/tdd/state.js" + +// ─── 辅助工厂 ─── + +function emptyDigest(algorithm?: VersionedDigest["algorithm"]): VersionedDigest { + return { algorithm: algorithm ?? "sha256-content-v1", value: "a".repeat(64) } +} + +function makeCycleEvidence(overrides: Partial = {}): TddCycleEvidence { + return { + cycleId: "cycle-1", + criterionId: "AC-1", + reworkRevision: 0, + status: "started", + startWorkspaceDigest: emptyDigest(), + testFiles: ["test/foo.test.ts"], + redTestDigest: null, + redAttempts: [], + greenAttempts: [], + ...overrides, + } +} + +function makeCommandEvidence(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: ["test/foo.test.ts"], + outputDigest: emptyDigest("sha256-output-v1"), + workspaceDigest: emptyDigest(), + executionInputDigest: emptyDigest(), + summary: "1/3 tests failed", + ...overrides, + } +} + +function passingEvidence(): TddCommandEvidence { + return makeCommandEvidence({ + exitCode: 0, + failureKind: null, + testsFailed: 0, + summary: "3/3 tests passed", + }) +} + +const tddCriterion: AcceptanceCriterion = { id: "AC-1", description: "TDD test", verification: "tdd" } +const regressionCriterion: AcceptanceCriterion = { id: "AC-2", description: "regression", verification: "regression" } + +const testFilePatterns = ["test/**/*.test.ts"] + +// ─── createTaskEvidence ─── + +describe("createTaskEvidence", () => { + it("returns initialized TddEvidence with revision=0 and status=not-recorded", () => { + const evidence = createTaskEvidence() + expect(evidence.revision).toBe(0) + expect(evidence.status).toBe("not-recorded") + expect(evidence.cycles).toEqual([]) + expect(evidence.taskStart.status).toBe("pending") + expect(evidence.regression.status).toBe("pending") + expect(evidence.verification.status).toBe("pending") + expect(evidence.warnings).toEqual([]) + expect(evidence.updatedAt).toBeNull() + }) +}) + +// ─── startCycle ─── + +describe("startCycle", () => { + it("creates a new cycle and adds it to evidence", () => { + const evidence = createTaskEvidence() + const result = startCycle( + evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + emptyDigest(), + [tddCriterion], + testFilePatterns, + ) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + + const { evidence: newEvidence, cycle } = result.value + expect(cycle.cycleId).toBe("cycle-1") + expect(cycle.criterionId).toBe("AC-1") + expect(cycle.status).toBe("started") + expect(cycle.testFiles).toEqual(["test/foo.test.ts"]) + expect(cycle.redTestDigest).toBeNull() + expect(cycle.redAttempts).toEqual([]) + expect(cycle.greenAttempts).toEqual([]) + + expect(newEvidence.cycles).toHaveLength(1) + expect(newEvidence.cycles[0].cycleId).toBe("cycle-1") + expect(newEvidence.revision).toBe(evidence.revision + 1) + }) + + it("sets evidence status to in-progress on first cycle", () => { + const evidence = createTaskEvidence() + const result = startCycle( + evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + emptyDigest(), + [tddCriterion], + testFilePatterns, + ) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + expect(result.value.evidence.status).toBe("in-progress") + }) + + it("fails when criterion does not exist", () => { + const evidence = createTaskEvidence() + const result = startCycle( + evidence, + "cycle-1", + "NONEXISTENT", + ["test/foo.test.ts"], + "test/foo.test.ts", + emptyDigest(), + [tddCriterion], + testFilePatterns, + ) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("CRITERION_NOT_FOUND") + }) + + it("fails when criterion is not verification=tdd", () => { + const evidence = createTaskEvidence() + const result = startCycle( + evidence, + "cycle-1", + "AC-2", + ["test/foo.test.ts"], + "test/foo.test.ts", + emptyDigest(), + [regressionCriterion], + testFilePatterns, + ) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("CRITERION_NOT_FOUND") + }) + + it("fails when testPaths do not match testFilePatterns", () => { + const evidence = createTaskEvidence() + const result = startCycle( + evidence, + "cycle-1", + "AC-1", + ["src/foo.ts"], // implementation file, not test pattern + "test/foo.test.ts", + emptyDigest(), + [tddCriterion], + testFilePatterns, + ) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("POLICY_INVALID") + }) + + it("is idempotent — same cycleId and workspace digest returns existing cycle", () => { + const evidence = createTaskEvidence() + const wsDigest = emptyDigest() + + const r1 = startCycle( + evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + wsDigest, + [tddCriterion], + testFilePatterns, + ) + expect(r1.ok).toBe(true) + if (!r1.ok) throw new Error("expected ok") + + // Same call again with same workspace digest + const r2 = startCycle( + r1.value.evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + wsDigest, + [tddCriterion], + testFilePatterns, + ) + + expect(r2.ok).toBe(true) + if (!r2.ok) throw new Error("expected ok") + // Should return the SAME evidence object (no revision change) + expect(r2.value.evidence).toBe(r1.value.evidence) + expect(r2.value.evidence.revision).toBe(r1.value.evidence.revision) + }) + + it("fails with CYCLE_CONFLICT when same cycleId but different params", () => { + const evidence = createTaskEvidence() + const wsDigest1 = emptyDigest() + + const r1 = startCycle( + evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + wsDigest1, + [tddCriterion], + testFilePatterns, + ) + expect(r1.ok).toBe(true) + if (!r1.ok) throw new Error("expected ok") + + const wsDigest2 = { algorithm: "sha256-content-v1" as const, value: "b".repeat(64) } + const r2 = startCycle( + r1.value.evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + wsDigest2, // different workspace digest + [tddCriterion], + testFilePatterns, + ) + + expect(r2.ok).toBe(false) + if (r2.ok) throw new Error("expected error") + expect(r2.error.code).toBe("CYCLE_CONFLICT") + }) +}) + +// ─── recordRed ─── + +describe("recordRed", () => { + it("records a RED attempt and advances cycle status to red", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, + "cycle-1", + "AC-1", + ["test/foo.test.ts"], + "test/foo.test.ts", + emptyDigest(), + [tddCriterion], + testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence({ + failureKind: "assertion", + exitCode: 1, + changedFiles: ["test/foo.test.ts"], + }) + const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + + const { evidence: newEvidence, cycle } = result.value + expect(cycle.status).toBe("red") + expect(cycle.redAttempts).toHaveLength(1) + expect(cycle.redAttempts[0].failureKind).toBe("assertion") + expect(cycle.redTestDigest).toBe(redEvidence.executionInputDigest) + expect(newEvidence.revision).toBeGreaterThan(startResult.value.evidence.revision) + }) + + it("rejects when RED passes (exitCode 0)", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }) + const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("RED_EXPECTED_FAILURE") + }) + + it("rejects infrastructure failures as RED", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence({ failureKind: "infrastructure", exitCode: 1 }) + const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("RED_INFRASTRUCTURE_FAILURE") + }) + + it("rejects timeout as RED", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence({ failureKind: "timeout", exitCode: null }) + const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("COMMAND_TIMEOUT") + }) + + it("rejects unknown failure as RED", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence({ failureKind: "unknown", exitCode: 1 }) + const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("RED_INFRASTRUCTURE_FAILURE") + }) + + it("accepts missing-behavior as valid RED", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence({ failureKind: "missing-behavior", exitCode: 1, testsCollected: 0 }) + const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + + expect(result.ok).toBe(true) + }) + + it("allows multiple RED attempts (retry on flaky test config)", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const red1 = makeCommandEvidence({ failureKind: "infrastructure", exitCode: 1 }) + // infrastructure is rejected, not stored in redAttempts + const r1 = recordRed(startResult.value.evidence, "cycle-1", red1) + expect(r1.ok).toBe(false) + + const red2 = makeCommandEvidence({ failureKind: "assertion", exitCode: 1 }) + const r2 = recordRed(startResult.value.evidence, "cycle-1", red2) + expect(r2.ok).toBe(true) + if (!r2.ok) throw new Error("expected ok") + + // Another assertion attempt (e.g., test adjusted) + const red3 = makeCommandEvidence({ + failureKind: "assertion", + exitCode: 1, + outputDigest: emptyDigest("sha256-output-v1"), + }) + // Override to have different output + red3.outputDigest = { algorithm: "sha256-output-v1", value: "b".repeat(64) } + const r3 = recordRed(r2.value.evidence, "cycle-1", red3) + expect(r3.ok).toBe(true) + if (!r3.ok) throw new Error("expected ok") + expect(r3.value.cycle.redAttempts).toHaveLength(2) + // redTestDigest should update to the latest valid RED + }) + + it("is idempotent for same RED evidence", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence() + const r1 = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + expect(r1.ok).toBe(true) + if (!r1.ok) throw new Error("expected ok") + + const r2 = recordRed(r1.value.evidence, "cycle-1", redEvidence) + expect(r2.ok).toBe(true) + if (!r2.ok) throw new Error("expected ok") + // Same evidence, should be same state (same revision) + expect(r2.value.evidence).toBe(r1.value.evidence) + expect(r2.value.cycle).toBe(r1.value.cycle) + }) + + it("fails when cycle does not exist", () => { + const evidence = createTaskEvidence() + const result = recordRed(evidence, "nonexistent", makeCommandEvidence()) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("CYCLE_CONFLICT") + }) +}) + +// ─── recordGreen ─── + +describe("recordGreen", () => { + function setupWithRed(): { evidence: TddEvidence } { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence() + const redResult = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + if (!redResult.ok) throw new Error("expected ok") + + return { evidence: redResult.value.evidence } + } + + it("records GREEN and marks cycle as pass", () => { + const { evidence } = setupWithRed() + const greenEvidence = passingEvidence() + greenEvidence.changedFiles = ["src/foo.ts"] // implementation change + + const result = recordGreen(evidence, "cycle-1", greenEvidence, true) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + + const { evidence: newEvidence, cycle } = result.value + expect(cycle.status).toBe("pass") + expect(cycle.greenAttempts).toHaveLength(1) + expect(cycle.greenAttempts[0].exitCode).toBe(0) + expect(newEvidence.revision).toBeGreaterThan(evidence.revision) + }) + + it("rejects GREEN when no RED has been recorded", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const greenEvidence = passingEvidence() + const result = recordGreen(startResult.value.evidence, "cycle-1", greenEvidence, true) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("INVALID_TRANSITION") + }) + + it("rejects GREEN when GREEN test still fails", () => { + const { evidence } = setupWithRed() + const greenEvidence = makeCommandEvidence({ exitCode: 1, failureKind: "assertion" }) + greenEvidence.changedFiles = ["src/foo.ts"] + + const result = recordGreen(evidence, "cycle-1", greenEvidence, true) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("GREEN_EXPECTED_PASS") + }) + + it("rejects GREEN when no implementation files changed", () => { + const { evidence } = setupWithRed() + const greenEvidence = passingEvidence() + + const result = recordGreen(evidence, "cycle-1", greenEvidence, false) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("IMPLEMENTATION_CHANGE_REQUIRED") + }) + + it("is idempotent for same GREEN evidence", () => { + const { evidence } = setupWithRed() + const greenEvidence = passingEvidence() + greenEvidence.changedFiles = ["src/foo.ts"] + + const r1 = recordGreen(evidence, "cycle-1", greenEvidence, true) + expect(r1.ok).toBe(true) + if (!r1.ok) throw new Error("expected ok") + + const r2 = recordGreen(r1.value.evidence, "cycle-1", greenEvidence, true) + expect(r2.ok).toBe(true) + if (!r2.ok) throw new Error("expected ok") + expect(r2.value.evidence).toBe(r1.value.evidence) + }) + + it("allows GREEN retries after failed GREEN attempt", () => { + const { evidence } = setupWithRed() + const failedGreen = makeCommandEvidence({ exitCode: 1, failureKind: "assertion" }) + failedGreen.changedFiles = ["src/foo.ts"] + + const r1 = recordGreen(evidence, "cycle-1", failedGreen, true) + expect(r1.ok).toBe(false) // GREEN failed + + const passGreen = passingEvidence() + passGreen.changedFiles = ["src/foo.ts"] + const r2 = recordGreen(evidence, "cycle-1", passGreen, true) + expect(r2.ok).toBe(true) + if (!r2.ok) throw new Error("expected ok") + expect(r2.value.cycle.greenAttempts).toHaveLength(1) + expect(r2.value.cycle.status).toBe("pass") + }) + + it("fails when cycle does not exist", () => { + const evidence = createTaskEvidence() + const result = recordGreen(evidence, "nonexistent", passingEvidence(), true) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("CYCLE_CONFLICT") + }) +}) + +// ─── recordFinalRegression ─── + +describe("recordFinalRegression", () => { + it("records regression as pass when all runs succeed", () => { + const evidence = createTaskEvidence() + const runs = [ + makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), + ] + + const result = recordFinalRegression(evidence, "headSha1", "treeSha1", runs) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + + const newEvidence = result.value + expect(newEvidence.regression.status).toBe("pass") + expect(newEvidence.regression.headSha).toBe("headSha1") + expect(newEvidence.regression.treeSha).toBe("treeSha1") + expect(newEvidence.regression.runs).toEqual(runs) + }) + + it("records regression as fail when a run has non-zero exit code", () => { + const evidence = createTaskEvidence() + const runs = [ + makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), + makeCommandEvidence({ exitCode: 1, failureKind: "assertion", testsFailed: 1 }), + ] + + const result = recordFinalRegression(evidence, "headSha1", "treeSha1", runs) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + expect(result.value.regression.status).toBe("fail") + }) + + it("records regression as fail when a run times out", () => { + const evidence = createTaskEvidence() + const runs = [ + makeCommandEvidence({ failureKind: "timeout", exitCode: null }), + ] + + const result = recordFinalRegression(evidence, "headSha1", "treeSha1", runs) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + expect(result.value.regression.status).toBe("fail") + }) + + it("rejects empty runs", () => { + const evidence = createTaskEvidence() + const result = recordFinalRegression(evidence, "headSha1", "treeSha1", []) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("REGRESSION_FAILED") + }) +}) + +// ─── recordFinalVerification ─── + +describe("recordFinalVerification", () => { + it("records verification as pass when all runs succeed", () => { + const evidence = createTaskEvidence() + const runs = [ + makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), + ] + + const result = recordFinalVerification(evidence, "headSha1", "treeSha1", runs) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + + const newEvidence = result.value + expect(newEvidence.verification.status).toBe("pass") + expect(newEvidence.verification.headSha).toBe("headSha1") + expect(newEvidence.verification.treeSha).toBe("treeSha1") + expect(newEvidence.verification.runs).toEqual(runs) + }) + + it("records verification as fail when a run fails", () => { + const evidence = createTaskEvidence() + const runs = [ + makeCommandEvidence({ exitCode: 1, failureKind: "unknown", testsFailed: null }), + ] + + const result = recordFinalVerification(evidence, "headSha1", "treeSha1", runs) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + expect(result.value.verification.status).toBe("fail") + }) + + it("allows empty runs (no verifyCommands configured)", () => { + const evidence = createTaskEvidence() + const result = recordFinalVerification(evidence, "headSha1", "treeSha1", []) + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + expect(result.value.verification.status).toBe("pass") + }) +}) + +// ─── abandonCycle ─── + +describe("abandonCycle", () => { + it("abandons a cycle that was started", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const result = abandonCycle(startResult.value.evidence, "cycle-1", "test design wrong") + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + + const newEvidence = result.value + const cycle = newEvidence.cycles.find((c: TddCycleEvidence) => c.cycleId === "cycle-1") + expect(cycle).toBeDefined() + expect(cycle!.status).toBe("abandoned") + // warn 中应包含 reason + expect(newEvidence.warnings).toContain("test design wrong") + }) + + it("abandons a cycle that was in RED state", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence() + const redResult = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + if (!redResult.ok) throw new Error("expected ok") + + const result = abandonCycle(redResult.value.evidence, "cycle-1", "found design flaw") + + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("expected ok") + expect(result.value.cycles[0].status).toBe("abandoned") + }) + + it("rejects abandoning a cycle that has already passed", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const redEvidence = makeCommandEvidence() + const redResult = recordRed(startResult.value.evidence, "cycle-1", redEvidence) + if (!redResult.ok) throw new Error("expected ok") + + const greenEvidence = passingEvidence() + greenEvidence.changedFiles = ["src/foo.ts"] + const greenResult = recordGreen(redResult.value.evidence, "cycle-1", greenEvidence, true) + if (!greenResult.ok) throw new Error("expected ok") + + // Now abandon a passed cycle + const result = abandonCycle(greenResult.value.evidence, "cycle-1", "oops") + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("INVALID_TRANSITION") + }) + + it("fails when cycle does not exist", () => { + const evidence = createTaskEvidence() + const result = abandonCycle(evidence, "nonexistent", "reason") + + expect(result.ok).toBe(false) + if (result.ok) throw new Error("expected error") + expect(result.error.code).toBe("CYCLE_CONFLICT") + }) + + it("after abandon, a new cycle with different id can be started", () => { + const evidence = createTaskEvidence() + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!startResult.ok) throw new Error("expected ok") + + const abandonResult = abandonCycle(startResult.value.evidence, "cycle-1", "restart") + if (!abandonResult.ok) throw new Error("expected ok") + + // Start a new cycle with different id + const restartResult = startCycle( + abandonResult.value, + "cycle-2", "AC-1", + ["test/foo-v2.test.ts"], "test/foo-v2.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + expect(restartResult.ok).toBe(true) + if (!restartResult.ok) throw new Error("expected ok") + + // Old cycle still abandoned + const oldCycle = restartResult.value.evidence.cycles.find((c: TddCycleEvidence) => c.cycleId === "cycle-1") + expect(oldCycle!.status).toBe("abandoned") + // New cycle started + const newCycle = restartResult.value.evidence.cycles.find((c: TddCycleEvidence) => c.cycleId === "cycle-2") + expect(newCycle!.status).toBe("started") + }) +}) + +// ─── 全生命周期 ─── + +describe("full lifecycle", () => { + it("startCycle → recordRed → recordGreen → complete", () => { + let evidence = createTaskEvidence() + expect(evidence.status).toBe("not-recorded") + + // 1. startCycle + const startResult = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + expect(startResult.ok).toBe(true) + if (!startResult.ok) throw new Error("expected ok") + evidence = startResult.value.evidence + expect(evidence.status).toBe("in-progress") + expect(evidence.cycles[0].status).toBe("started") + + // 2. recordRed + const redEvidence = makeCommandEvidence() + const redResult = recordRed(evidence, "cycle-1", redEvidence) + expect(redResult.ok).toBe(true) + if (!redResult.ok) throw new Error("expected ok") + evidence = redResult.value.evidence + expect(evidence.cycles[0].status).toBe("red") + expect(evidence.cycles[0].redAttempts).toHaveLength(1) + + // 3. recordGreen + const greenEvidence = passingEvidence() + greenEvidence.changedFiles = ["src/foo.ts"] + const greenResult = recordGreen(evidence, "cycle-1", greenEvidence, true) + expect(greenResult.ok).toBe(true) + if (!greenResult.ok) throw new Error("expected ok") + evidence = greenResult.value.evidence + expect(evidence.cycles[0].status).toBe("pass") + expect(evidence.cycles[0].greenAttempts).toHaveLength(1) + + // Evidence revision should have increased through the lifecycle + expect(evidence.revision).toBeGreaterThan(0) + }) + + it("revision increments on each state change", () => { + let evidence = createTaskEvidence() + + const s = startCycle( + evidence, "cycle-1", "AC-1", + ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), + [tddCriterion], testFilePatterns, + ) + if (!s.ok) throw new Error("expected ok") + const revAfterStart = s.value.evidence.revision + + const r = recordRed(s.value.evidence, "cycle-1", makeCommandEvidence()) + if (!r.ok) throw new Error("expected ok") + const revAfterRed = r.value.evidence.revision + expect(revAfterRed).toBeGreaterThan(revAfterStart) + + const g = recordGreen(r.value.evidence, "cycle-1", { ...passingEvidence(), changedFiles: ["src/foo.ts"] }, true) + if (!g.ok) throw new Error("expected ok") + const revAfterGreen = g.value.evidence.revision + expect(revAfterGreen).toBeGreaterThan(revAfterRed) + }) +}) From 123bcf185c56422cfe3b13727790d55913297b26 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:49:53 +0800 Subject: [PATCH 2/2] feat(kernel): tdd evidence baseline and append via records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evidence.ts 提供 worktree-start 基线快照(captureWorkspaceBaseline, computeWorkspaceDigest 封装)、evidence block 组装(buildEvidenceBlock) 与追加入口(recordTddEvidence,委托 records.appendTddEvidence 写入 Task Record 受控 comment)。新增 mock 测试验证 append 链路。 --- src/kernel/tdd/evidence.ts | 81 +++++++++++++++++ test/kernel/tdd/evidence.test.ts | 150 +++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/kernel/tdd/evidence.ts create mode 100644 test/kernel/tdd/evidence.test.ts diff --git a/src/kernel/tdd/evidence.ts b/src/kernel/tdd/evidence.ts new file mode 100644 index 0000000..33ef6d5 --- /dev/null +++ b/src/kernel/tdd/evidence.ts @@ -0,0 +1,81 @@ +import { appendTddEvidence } from "../records.js" +import { computeWorkspaceDigest, type DigestOptions } from "./digest.js" +import type { TddFailureKind, VersionedDigest } from "../types.js" + +// ─── 基线记录(worktree-start 时快照) ─── + +/** worktree-start 时记录的干净基线快照 */ +export interface WorkspaceBaseline { + digest: VersionedDigest + capturedAt: string + testFilePatterns: string[] + implementationFilePatterns: string[] +} + +/** + * 计算 worktree 的干净基线 digest 快照。 + * 供 task_control start-task 在 worktree-start 时调用,作为 RED 阶段 + * "实现文件相对基线未变" 与 GREEN "输入未偷换" 校验的基准。 + */ +export async function captureWorkspaceBaseline( + worktreeDir: string, + options: DigestOptions, +): Promise { + const digest = await computeWorkspaceDigest(worktreeDir, options) + return { + digest, + capturedAt: new Date().toISOString(), + testFilePatterns: options.testFilePatterns, + implementationFilePatterns: options.implementationFilePatterns, + } +} + +// ─── evidence block 组装 ─── + +/** 组装 evidence block 的输入(criterion/cycle/命令/exit code/failure kind/digest/状态) */ +export interface EvidenceBlockInput { + stage: string + criterionId?: string + cycleId?: string + command: string + testSelector: string | null + exitCode: number | null + failureKind: TddFailureKind | null + status: string + workspaceDigest: VersionedDigest | null + summary: string +} + +/** + * 将结构化证据组装为可追加到 Task Record 受控 comment 的 markdown block。 + * 纯函数:内容含 criterion、cycle、命令、exit code、failure kind、digest、状态、summary。 + */ +export function buildEvidenceBlock(input: EvidenceBlockInput): string { + const lines = [`### stage: ${input.stage}`] + if (input.criterionId) lines.push(`- criterion: ${input.criterionId}`) + if (input.cycleId) lines.push(`- cycle: ${input.cycleId}`) + lines.push(`- command: \`${input.command}\``) + if (input.testSelector) lines.push(`- selector: \`${input.testSelector}\``) + lines.push(`- exitCode: ${input.exitCode ?? "null"}`) + lines.push(`- failureKind: ${input.failureKind ?? "null"}`) + lines.push(`- status: ${input.status}`) + if (input.workspaceDigest) { + lines.push(`- workspaceDigest: ${input.workspaceDigest.algorithm}:${input.workspaceDigest.value}`) + } + lines.push(`- summary: ${input.summary}`) + return lines.join("\n") +} + +// ─── evidence 追加(复用 records.appendTddEvidence) ─── + +/** + * 组装 evidence block 并追加到 Task Record 的单个受控 comment。 + * 委托 records.appendTddEvidence 完成写入(含 mutex 串行与 revision 管理)。 + */ +export async function recordTddEvidence( + issueNumber: number, + input: EvidenceBlockInput, +): Promise<{ ok: boolean; revision: number; error?: string }> { + const block = buildEvidenceBlock(input) + return appendTddEvidence(issueNumber, block) +} diff --git a/test/kernel/tdd/evidence.test.ts b/test/kernel/tdd/evidence.test.ts new file mode 100644 index 0000000..d8e07c8 --- /dev/null +++ b/test/kernel/tdd/evidence.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs" +import { join, dirname } from "node:path" +import { tmpdir } from "node:os" +import { execSync } from "node:child_process" +import { + captureWorkspaceBaseline, + buildEvidenceBlock, + recordTddEvidence, + type EvidenceBlockInput, +} from "../../../src/kernel/tdd/evidence.js" +import type { setRecordsGhExecutor as SetRecordsGhExecutorType } from "../../../src/kernel/records.js" + +type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let setRecordsGhExecutor: typeof SetRecordsGhExecutorType + +beforeAll(async () => { + const mod = await import("../../../src/kernel/records.js") + setRecordsGhExecutor = mod.setRecordsGhExecutor +}) + +beforeEach(() => { + setRecordsGhExecutor(() => { + throw new Error("unexpected gh call") + }) +}) + +afterEach(() => { + setRecordsGhExecutor(null) +}) + +function setupGitRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "cabbage-evidence-test-")) + execSync("git init", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync("git config user.email test@test.com", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync("git config user.name test", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + return dir +} + +function makeInput(overrides: Partial = {}): EvidenceBlockInput { + return { + stage: "red", + criterionId: "AC-1", + cycleId: "cycle-1", + command: "npx vitest run test/foo.test.ts", + testSelector: "test/foo.test.ts", + exitCode: 1, + failureKind: "assertion", + status: "red", + workspaceDigest: { algorithm: "sha256-content-v1", value: "a".repeat(64) }, + summary: "1/3 tests failed", + ...overrides, + } +} + +// ─── buildEvidenceBlock(纯函数) ─── + +describe("buildEvidenceBlock", () => { + it("includes criterion, cycle, command, exit code, failure kind, status, digest and summary", () => { + const block = buildEvidenceBlock(makeInput()) + + expect(block).toContain("AC-1") + expect(block).toContain("cycle-1") + expect(block).toContain("npx vitest run test/foo.test.ts") + expect(block).toContain("exitCode: 1") + expect(block).toContain("failureKind: assertion") + expect(block).toContain("status: red") + expect(block).toContain("a".repeat(64)) + expect(block).toContain("1/3 tests failed") + }) + + it("handles optional fields being absent", () => { + const block = buildEvidenceBlock( + makeInput({ criterionId: undefined, cycleId: undefined, testSelector: null, failureKind: null, workspaceDigest: null }), + ) + expect(block).toContain("stage: red") + expect(block).not.toContain("criterionId") + }) +}) + +// ─── captureWorkspaceBaseline ─── + +describe("captureWorkspaceBaseline", () => { + it("captures a stable digest snapshot with patterns and timestamp", async () => { + const dir = setupGitRepo() + mkdirSync(join(dir, "src"), { recursive: true }) + mkdirSync(join(dir, "test"), { recursive: true }) + writeFileSync(join(dir, "src", "index.ts"), "export const x = 1") + writeFileSync(join(dir, "test", "index.test.ts"), "import { x } from '../src/index.js'") + execSync("git add src/index.ts test/index.test.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) + + const options = { + testFilePatterns: ["test/**/*.ts"], + implementationFilePatterns: ["src/**/*.ts"], + generatedArtifactPatterns: [], + } + const baseline = await captureWorkspaceBaseline(dir, options) + + expect(baseline.digest.algorithm).toBe("sha256-content-v1") + expect(baseline.digest.value).toMatch(/^[a-f0-9]{64}$/) + expect(baseline.testFilePatterns).toEqual(options.testFilePatterns) + expect(baseline.implementationFilePatterns).toEqual(options.implementationFilePatterns) + expect(baseline.capturedAt).toBeTruthy() + + // 同内容快照 digest 稳定 + const baseline2 = await captureWorkspaceBaseline(dir, options) + expect(baseline2.digest.value).toBe(baseline.digest.value) + + try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } + }) +}) + +// ─── recordTddEvidence(调用 records.appendTddEvidence) ─── + +describe("recordTddEvidence", () => { + it("builds a block and appends it via records.appendTddEvidence (creates comment)", async () => { + const calls: string[] = [] + setRecordsGhExecutor(async (args) => { + calls.push(args) + if (args.startsWith("issue view 42")) { + return { stdout: "null", stderr: "" } + } + return { stdout: "", stderr: "" } + }) + + const result = await recordTddEvidence(42, makeInput()) + + expect(result.ok).toBe(true) + expect(result.revision).toBe(1) + // appendTddEvidence 创建受控 comment + const commentCall = calls.find(c => c.startsWith("issue comment 42")) + expect(commentCall).toBeDefined() + expect(commentCall).toContain("") + expect(commentCall).toContain("cycle-1") + }) + + it("returns error when gh fails", async () => { + setRecordsGhExecutor(async () => { + throw new Error("gh auth failed") + }) + + const result = await recordTddEvidence(42, makeInput()) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain("gh auth failed") + } + }) +})