// --------------------------------------------------------------------------- // caduceus — risk lens tests (v0.6.0 T03) // // TDD micro-cycle (T03 of caduceus-v0.6.0): // RED → this file (lib/lens/risk.ts not yet implemented) // GREEN → lib/lens/risk.ts implements design.md §6.1 algorithm // TRIANGULATE → truncation at 20 + truncated flag // REFACTOR → extract regex constants // // Per-lens canonical tests: each lens MUST produce a finding on a // "dirty" change that contains the issue it detects, and zero findings // on a clean canonical change (REQ-024 / REQ-025). // --------------------------------------------------------------------------- import assert from "node:assert/strict"; import { test } from "node:test"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { riskLens } from "../../lib/lens/risk.ts"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Create a temp change dir with the 5 canonical MD files. Caller-supplied * content overrides defaults; missing entries get a minimal stub. */ function makeChangeDir(overrides: Record = {}): string { const dir = mkdtempSync(join(tmpdir(), "caduceus-risk-")); for (const f of [ "proposal.md", "design.md", "tasks.md", "requirements.md", "constitution.md", ]) { const body = overrides[f] ?? `# ${f}\n`; writeFileSync(join(dir, f), body, "utf8"); } return dir; } // --------------------------------------------------------------------------- // Test 1 (RED): dirty-keyword — "BREAKING" in proposal.md → 1+ P1 with line // --------------------------------------------------------------------------- test("T03-R-RISK-1: P1 finding for BREAKING keyword in proposal.md (with line)", async () => { const dir = makeChangeDir({ "proposal.md": "# Proposal\n\nLine 2 has nothing.\n\nThis is a BREAKING change.\n", }); try { assert.ok(riskLens.run, "risk lens must export a run function"); const out = await riskLens.run(dir); const p1 = out.findings.find((f) => f.severity === "P1"); assert.ok(p1, `expected P1 finding; got: ${JSON.stringify(out.findings)}`); assert.equal(p1.location, "proposal.md"); assert.equal(typeof p1.line, "number"); assert.ok(p1.line! >= 4, `expected line ≥ 4, got ${p1.line}`); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // Test 2 (RED): dirty-todo — ≥3 TODO/FIXME markers across artifacts → 1 P2 // --------------------------------------------------------------------------- test("T03-R-RISK-2: P2 finding when ≥3 TODO/FIXME markers across artifacts", async () => { const dir = makeChangeDir({ "proposal.md": "TODO: refactor this", "design.md": "FIXME: line too long\nFIXME: also here", "tasks.md": "TODO: third marker", }); try { const out = await riskLens.run(dir); const p2 = out.findings.find((f) => f.severity === "P2"); assert.ok(p2, `expected P2 finding; got: ${JSON.stringify(out.findings)}`); assert.match(p2.summary, /3|TODO|FIXME/); // P2 is a section-level finding; no `line` field expected assert.equal(p2.line, undefined); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // Test 3 (RED): dirty-files — >10 files in change dir → 1 P3 // --------------------------------------------------------------------------- test("T03-R-RISK-3: P3 finding when change dir has >10 files", async () => { const dir = makeChangeDir(); // Add 11 extra files to push the count from 5 → 16 for (let i = 1; i <= 11; i++) { writeFileSync(join(dir, `extra-${i}.md`), `extra ${i}\n`, "utf8"); } try { const out = await riskLens.run(dir); const p3 = out.findings.find((f) => f.severity === "P3"); assert.ok(p3, `expected P3 finding; got: ${JSON.stringify(out.findings)}`); assert.match(p3.summary, />\s*10|files/); assert.equal(p3.line, undefined); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // Test 4 (RED): clean canonical change → 0 findings // --------------------------------------------------------------------------- test("T03-R-RISK-4: zero findings on clean canonical change", async () => { const dir = makeChangeDir(); try { const out = await riskLens.run(dir); assert.equal(out.findings.length, 0); assert.equal(out.truncated, undefined); assert.equal(out.lensId, "risk"); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // Test 5 (TRIANGULATE): >20 BREAKING keywords → capped at 20 + truncated: true // --------------------------------------------------------------------------- test("T03-R-RISK-5: findings capped at 20 with truncated: true (TRIANGULATE)", async () => { const lines: string[] = ["# Proposal"]; for (let i = 0; i < 30; i++) { // v0.6.3 calibration: each line carries a real change marker // (REQUIRED-... e.g. "BREAKING CHANGE #N"). The bare // "BREAKING keyword" wording in v0.6.0 was the over-eager // self-match that v0.6.3 fixes. The test fixture is updated // to use the post-v0.6.3 calibrated wording (per task // caduceus-v0.6.3-lens-calibration/T20). lines.push(`Line ${i}: this documents a BREAKING CHANGE #${i}`); } const dir = makeChangeDir({ "proposal.md": lines.join("\n"), }); try { const out = await riskLens.run(dir); assert.equal(out.findings.length, 20); assert.equal(out.truncated, true); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // Test 6 (TRIANGULATE): lens id / display name / description are stable // --------------------------------------------------------------------------- test("T03-R-RISK-6: lens metadata (id/displayName/description) is set", () => { assert.equal(riskLens.id, "risk"); assert.equal(riskLens.displayName, "Risk"); assert.ok(riskLens.description.length > 10); }); // --------------------------------------------------------------------------- // Test 7: keyword match is case-insensitive (BREAKING / breaking / Breaking) // --------------------------------------------------------------------------- test("T03-R-RISK-7: keyword match is case-insensitive (BREAKING / breaking / Breaking)", async () => { // v0.6.3 calibration: each line carries a real change marker // following the keyword. The v0.6.0 wording ("BREAKING one" etc.) // was the over-eager match that calibration tightened. Updated // to post-v0.6.3 wording (per task caduceus-v0.6.3-lens- // calibration/T20). const dir = makeChangeDir({ "proposal.md": "BREAKING CHANGE one\nbreaking change two\nBreaking change three\nDeprecate alpha four\ndeprecate beta five", }); try { const out = await riskLens.run(dir); const p1s = out.findings.filter((f) => f.severity === "P1"); assert.equal(p1s.length, 5, `expected 5 P1 findings; got ${p1s.length}`); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // Test 8: hidden files (e.g. .review/) do NOT count toward the >10 threshold // --------------------------------------------------------------------------- test("T03-R-RISK-8: hidden files (starting with '.') excluded from file count", async () => { const dir = makeChangeDir(); // Add 20 hidden files; should not trigger P3 for (let i = 1; i <= 20; i++) { writeFileSync(join(dir, `.hidden-${i}`), "x", "utf8"); } try { const out = await riskLens.run(dir); const p3 = out.findings.find((f) => f.severity === "P3"); assert.equal(p3, undefined, `expected NO P3 (hidden files don't count)`); } finally { rmSync(dir, { recursive: true }); } }); // --------------------------------------------------------------------------- // v0.6.3 calibration tests (caduceus-v0.6.3-lens-calibration) // // T03 — risk self-match (REQ-001, CON-010) // T06 — risk dedupe (REQ-006, CON-011) // --------------------------------------------------------------------------- const FIXTURE_RISK_SELF_DESC = join( import.meta.dirname, "..", "fixtures", "risk-self-desc", ); test("T06.3-R-RISK-SELF: risk.run skips self-match on risk-self-desc fixture", async () => { // The fixture's proposal.md contains the words BREAKING and DEPRECATED // inside documentation describing the lens rule. With CON-010 // self-match exclusion applied, no P1 finding should fire. const out = await riskLens.run(FIXTURE_RISK_SELF_DESC); const p1 = out.findings.filter( (f) => f.severity === "P1" && /BREAKING|DEPRECAT/.test(f.summary), ); assert.equal( p1.length, 0, `expected NO P1 from self-match; got ${p1.length}: ${JSON.stringify(p1)}`, ); }); test("T06.3-R-RISK-DEDUPE: dedupeFindings collapses identical findings", async () => { // Dynamic import to allow RED state (function may not exist yet). const mod = await import("../../lib/lens/risk.ts"); const dedupe = (mod as unknown as { dedupeFindings?: (findings: ReadonlyArray) => unknown[]; }).dedupeFindings; assert.equal(typeof dedupe, "function", "dedupeFindings must be exported"); const findings = [ { severity: "P1", summary: "X", location: "proposal.md", recommendation: "R", line: 5 }, { severity: "P1", summary: "X", location: "proposal.md", recommendation: "R", line: 5 }, { severity: "P1", summary: "X", location: "proposal.md", recommendation: "R", line: 6 }, ]; const out = (dedupe as (f: unknown[]) => unknown[])(findings); assert.equal(out.length, 2, `expected 2 unique findings, got ${out.length}`); });