/** * TddPolicy — blocks writes without TDD red signal. * * Checkpoints: pre-write, post-verify-result * * Logic migrated from: * - src/harness/tdd-policy.ts: tddProductionWriteBlockReason, tddVerifyBlockReason */ import type { GateContext, GateFinding } from "../findings.ts"; import type { ActiveRun } from "../../types.ts"; export const TDD_POLICY_NAME = "tdd-policy"; const SOURCE_EXTENSIONS = new Set([ ".java", ".kt", ".kts", ".xml", ".properties", ".yml", ".yaml", ".sql", ".ksql", ".cs", ".py", ]); export function evaluateTddPolicy(ctx: GateContext): GateFinding[] { const findings: GateFinding[] = []; if (ctx.checkpoint === "pre-write") { findings.push(...evaluatePreWrite(ctx)); } else if (ctx.checkpoint === "post-verify-result") { findings.push(...evaluatePostVerifyResult(ctx)); } return findings; } function evaluatePreWrite(ctx: GateContext): GateFinding[] { const findings: GateFinding[] = []; const { path, run, cwd } = ctx; if (!path || !isSourceLikePath(path)) return findings; // Normalize path const normalized = normalizeRelativePath(cwd && isAbsolute(path) ? relative(cwd, path) : path); // Skip .pi/ paths if (normalized.startsWith(".pi/")) return findings; // Skip test files if (isTestLikePath(normalized)) return findings; // Check if run exists and is in execute phase if (!run || run.phase !== "execute") return findings; // Check for TDD red evidence if (!hasValidTddEvidence(run, "red")) { findings.push({ id: "TDP-001", severity: "evidence-required", policy: TDD_POLICY_NAME, message: `写入生产源码 ${normalized} 前缺少 TDD 红证据。`, nextAction: "先创建 failing test 并记录到 evidence/tdd-red.md,再写生产源码。", }); } return findings; } function evaluatePostVerifyResult(ctx: GateContext): GateFinding[] { const findings: GateFinding[] = []; const { run } = ctx; if (!run) return findings; // Validate TDD evidence const problems = [ validateTddEvidence(run, "red"), validateTddEvidence(run, "green"), ].filter((result) => !result.valid); if (problems.length > 0) { findings.push({ id: "TDP-002", severity: "soft-deny", policy: TDD_POLICY_NAME, message: `TDD 证据验证失败:${problems.map((p) => p.reason).join("; ")}`, nextAction: "确保 evidence/tdd-red.md 和 evidence/tdd-green.md 内容有效。", }); } return findings; } // ── Utility functions (reimplemented from src/harness/tdd-policy.ts) ───────── function hasValidTddEvidence(run: ActiveRun, kind: "red" | "green"): boolean { return validateTddEvidence(run, kind).valid; } function validateTddEvidence(run: ActiveRun, kind: "red" | "green"): { valid: boolean; reason?: string } { const evidenceName = kind === "red" ? "evidence/tdd-red.md" : "evidence/tdd-green.md"; const content = run.artifacts?.[evidenceName]; if (!content) return { valid: false, reason: `缺少 ${evidenceName}` }; // Check for uncertainty const uncertainty = uncertaintyReason(content); if (uncertainty) { return { valid: false, reason: `${evidenceName} 内容无效:${uncertainty}` }; } if (kind === "red") { const hasFailure = /red|fail|failed|failure|error|失败|未通过|Exit\s*[::]\s*[1-9]/i.test(content); return hasFailure ? { valid: true } : { valid: false, reason: `${evidenceName} 未包含失败证据` }; } const hasGreenExit = /Exit\s*[::]\s*0|退出码\s*[::]\s*0/i.test(content); const hasSuccess = /green|pass|passed|success|成功|通过/i.test(content); if (hasGreenExit && hasSuccess) return { valid: true }; return { valid: false, reason: `${evidenceName} 未包含成功证据`, }; } function uncertaintyReason(content: string): string | undefined { const patterns: Array<[RegExp, string]> = [ [/需.*验证|需要.*验证|待.*验证|后续.*验证/i, "包含待验证措辞"], [/未验证|未执行|没有执行|无法执行|无法验证/i, "声明未完成验证"], [/TODO|待补充|待完善|假设|预计|理论上|应该可以/i, "包含占位或推测性结论"], [/编译验证通过[^。\n]*(需|需要|待).*验证/i, "同时声明通过和仍需验证,结论矛盾"], ]; for (const [pattern, reason] of patterns) { if (pattern.test(content)) return reason; } return undefined; } function isTestLikePath(path: string): boolean { const normalized = normalizeRelativePath(path).toLowerCase(); const filename = normalized.split("/").at(-1) ?? normalized; return ( normalized.includes("/src/test/") || normalized.includes("/test/") || normalized.includes("/tests/") || normalized.includes("/__tests__/") || /[._-](test|spec)\./.test(filename) || /test\.(java|kt|kts|cs)$/i.test(filename) || /tests\.(cs)$/i.test(filename) || /_test\.py$/i.test(filename) ); } function isSourceLikePath(path: string): boolean { const normalized = normalizeRelativePath(path); const lastSegment = normalized.split("/").at(-1) ?? normalized; const dotIndex = lastSegment.lastIndexOf("."); if (dotIndex < 0) return false; return SOURCE_EXTENSIONS.has(lastSegment.slice(dotIndex).toLowerCase()); } function normalizeRelativePath(path: string): string { return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""); } import { isAbsolute, relative } from "node:path";