// --------------------------------------------------------------------------- // caduceus — risk lens (v0.6.0 T03; calibrated v0.6.3) // // Static-analysis lens that surfaces high-impact changes before review. // // P1 — BREAKING / DEPRECAT keyword in proposal.md (per occurrence, with line) // P2 — ≥3 TODO/FIXME/XXX/HACK markers across the 5 MD artifacts (aggregate) // P3 — change directory contains >10 files (aggregate, REQ-026) // // Algorithm: design.md §6.1. Pure-TS; no network, no process, no LLM. // // v0.6.3 calibration (caduceus-v0.6.3-lens-calibration): // - CON-010 self-match exclusion: skips matches in files listed in // RISK_SELF_EXCLUDE_CONTEXT (e.g., the lens's own source, // STATUS.md, RESEARCH.md). // - CON-011 dedup: calls `dedupeFindings` before applying the // 20-finding cap so the same (severity, summary, location, // line) tuple only emits once. // // Findings are capped at 20 per REQ-005 with `truncated: true` set on // the LensFindings summary when truncated. // --------------------------------------------------------------------------- import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; import type { Lens, LensFinding } from "../review-lens-framework.ts"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const MD_FILES: ReadonlyArray = Object.freeze([ "proposal.md", "design.md", "tasks.md", "requirements.md", "constitution.md", ]); // v0.6.3 (REQ-001 / CON-010): the keyword is tightened from a // bare "breaking|DEPRECAT" match to require the word appear as an // actual change marker. v0.6.0 was over-eager: any standalone // "breaking" or "DEPRECAT" (e.g. "No breaking changes", "DEPRECAT // keyword") fired P1 even when the line was *describing* the lens // rule, not declaring a real change. The new regex requires: // - "BREAKING" followed by a noun (change/feature/api/interface/ // removal); bare "breaking" does not fire. // - "DEPRECATE" (verb) followed by a token; bare "DEPRECAT" does // not fire. const KEYWORD_RE = /\bbreaking\s+(?:change|changes|feature|features|api|interface|removal|release)\b|\bdeprecat(?:e|ed|ing)\s+[a-z0-9_]+/gi; const TODO_RE = /\b(?:TODO|FIXME|XXX|HACK)\b/g; const FILE_COUNT_THRESHOLD = 10; const FINDING_CAP = 20; /** v0.6.3 CON-010: regexes of detection keywords the lens uses. * Defense-in-depth — the file-path-based exclude (below) is the * primary mechanism; this is for callers that want to know what * patterns the lens treats as risks. */ export const RISK_SELF_MATCH_PATTERNS: ReadonlyArray = Object.freeze([ /\bbreaking\s+(?:change|changes|feature|features|api|interface|removal|release)\b/i, /\bdeprecat(?:e|ed|ing)\s+[a-z0-9_]+/i, ]); /** v0.6.3 CON-010: file basenames or path suffixes whose content * is excluded from self-match. Useful when the lens's own source * or docs (e.g. STATUS.md sections discussing caduceus, docs/ * RESEARCH.md) happen to be the target of a `run`. */ export const RISK_SELF_EXCLUDE_CONTEXT: ReadonlyArray = Object.freeze([ "lib/lens/risk.ts", "STATUS.md", "docs/RESEARCH.md", ]); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function readIfExists(path: string): string | null { if (!existsSync(path)) return null; try { return readFileSync(path, "utf8"); } catch { return null; } } function countFiles(changeDir: string): number { try { return readdirSync(changeDir).filter((f) => !f.startsWith(".")).length; } catch { return 0; } } /** v0.6.3 CON-010: file-path check; true when the file is in the * self-exclude list. Matches by suffix so paths like * `/x/lib/lens/risk.ts` resolve correctly. */ function isSelfExcluded(file: string): boolean { return RISK_SELF_EXCLUDE_CONTEXT.some((p) => file.endsWith(p)); } /** v0.6.3 CON-011: collapse findings identical on * (severity, summary, location, line). Exported for unit testing * (REQ-006). */ export function dedupeFindings( findings: ReadonlyArray, ): LensFinding[] { const seen = new Set(); const out: LensFinding[] = []; for (const f of findings) { const key = `${f.severity}|${f.summary}|${f.location}|${f.line ?? ""}`; if (seen.has(key)) continue; seen.add(key); out.push(f); } return out; } // --------------------------------------------------------------------------- // Public API: riskLens // --------------------------------------------------------------------------- export const riskLens: Lens = { id: "risk", displayName: "Risk", description: "Surface high-impact changes: BREAKING/DEPRECAT keywords (P1), " + "≥3 TODO/FIXME/XXX/HACK markers (P2), >10 files in change dir (P3).", async run(changeDir) { const t0 = Date.now(); const findings: LensFinding[] = []; // ----- P1: BREAKING/DEPRECAT keyword in proposal.md (per occurrence) const proposalPath = join(changeDir, "proposal.md"); const proposalContent = readIfExists(proposalPath); if (proposalContent !== null && !isSelfExcluded(proposalPath)) { const lines = proposalContent.split("\n"); for (let i = 0; i < lines.length; i++) { // Use matchAll to count all matches per line (defensive: >1 keyword // per line is rare but possible). const matches = [...lines[i].matchAll(KEYWORD_RE)]; for (const _ of matches) { findings.push({ severity: "P1", summary: `BREAKING/DEPRECAT keyword in proposal.md line ${i + 1}`, location: "proposal.md", recommendation: "Confirm intent; BREAKING/DEPRECAT changes surface " + "for explicit user review at finalize time.", line: i + 1, }); } } } // ----- P2: ≥3 TODO/FIXME/XXX/HACK markers across the 5 MD artifacts let totalMarkers = 0; const markerBreakdown: string[] = []; for (const file of MD_FILES) { const content = readIfExists(join(changeDir, file)); if (content === null) continue; const matches = content.match(TODO_RE); if (matches && matches.length > 0) { totalMarkers += matches.length; markerBreakdown.push(`${file}:${matches.length}`); } } if (totalMarkers >= 3) { findings.push({ severity: "P2", summary: `Found ${totalMarkers} TODO/FIXME/XXX/HACK marker${ totalMarkers === 1 ? "" : "s" } across ${markerBreakdown.length} artifact${ markerBreakdown.length === 1 ? "" : "s" }`, location: markerBreakdown.join(", "), recommendation: "Resolve or remove TODO/FIXME markers before archive; " + "they signal unfinished work that reviewers will surface.", }); } // ----- P3: change directory contains >10 files (REQ-026) const fileCount = countFiles(changeDir); if (fileCount > FILE_COUNT_THRESHOLD) { findings.push({ severity: "P3", summary: `Change directory contains ${fileCount} files (>${FILE_COUNT_THRESHOLD} threshold)`, location: basename(changeDir), recommendation: "Consider splitting this change into smaller increments " + "or moving helpers to lib/.", }); } // ----- v0.6.3 dedup (CON-011): collapse identical tuples BEFORE cap. const deduped = dedupeFindings(findings); // ----- Cap findings at 20 (REQ-005); set truncated flag let truncated: boolean | undefined; let finalFindings = deduped; if (deduped.length > FINDING_CAP) { finalFindings = deduped.slice(0, FINDING_CAP); truncated = true; } return { lensId: "risk", findings: Object.freeze(finalFindings), durationMs: Date.now() - t0, truncated, }; }, };