// --------------------------------------------------------------------------- // caduceus — spec-compliance lens (v0.6.0 T07) // // Static-analysis lens for spec/task/con alignment. // // P1 — REQ-NNN declared in requirements.md but not referenced // in tasks.md (orphan requirement) // P2 — proposal.md §3 Scope section omits the changeName // (state.json activeChange, else dir basename) // P2 — CON-NNN declared in constitution.md but not referenced // in proposal.md or design.md (orphan principle) // // Algorithm: design.md §6.5. Pure-TS; no network, no process, no LLM. // // Findings are capped at 20 per REQ-005 with `truncated: true` set on // the LensFindings summary when truncated. // --------------------------------------------------------------------------- import { readFileSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; import type { Lens, LensFinding } from "../review-lens-framework.ts"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const REQ_ID_RE = /\bREQ-(\d+)\b/g; const CON_ID_RE = /\bCON-(\d+)\b/g; const FINDING_CAP = 20; /** v0.6.3 CON-010 / REQ-003: file basenames / path suffixes excluded * from self-match. */ export const SPEC_COMPLIANCE_SELF_EXCLUDE_CONTEXT: ReadonlyArray = Object.freeze([ "lib/lens/spec-compliance.ts", "docs/RESEARCH.md", ]); /** v0.6.3 CON-012 / REQ-008: REQs whose title or body contains any * of these keywords are classified as invariant / format / * informational rather than implementation; the spec-compliance * lens does NOT fire P1 for them. */ const NON_IMPLEMENTATION_REQ_KEYWORDS: ReadonlyArray = Object.freeze([ "format", "sha256", "hash", "wording", "byte-stable", "marker", "version", "cap", "truncat", ]); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function readIfExists(path: string): string | null { if (!existsSync(path)) return null; try { return readFileSync(path, "utf8"); } catch { return null; } } function extractIds(text: string, regex: RegExp, prefix: string): Set { const out = new Set(); for (const m of text.matchAll(regex)) { out.add(`${prefix}${m[1]}`); } return out; } /** * Extract the body of the `## 3. Scope` section. Returns "" if the section * is absent. The section ends at the next `## ` heading or end-of-doc. * * Implementation note: a line-based scanner is simpler and more reliable * than a regex with `\s*$` lookahead (which consumed the newline and * caused off-by-one capture failures under strip-types). */ function extractScopeSection(proposalText: string): string { const lines = proposalText.split("\n"); let inScope = false; const captured: string[] = []; for (const line of lines) { if (/^## 3\. Scope\s*$/.test(line)) { inScope = true; continue; } if (inScope) { if (/^## /.test(line)) break; captured.push(line); } } return captured.join("\n"); } /** * Resolve the active change name. Prefer state.json's `activeChange` * (when present and parseable); else fall back to the directory basename. * Tolerant of corrupt state.json: any read/parse error → fallback. */ function resolveChangeName(changeDir: string): string { const statePath = join(changeDir, ".review", "state.json"); if (existsSync(statePath)) { try { const raw = readFileSync(statePath, "utf8"); const parsed = JSON.parse(raw) as { activeChange?: string }; if (typeof parsed.activeChange === "string" && parsed.activeChange) { return parsed.activeChange; } } catch { // fall through to basename } } return basename(changeDir); } // --------------------------------------------------------------------------- // Public API: specComplianceLens // --------------------------------------------------------------------------- export const specComplianceLens: Lens = { id: "spec-compliance", displayName: "Spec Compliance", description: "Spec/task alignment: REQ-NNN coverage (P1), changeName in §3 " + "Scope (P2), CON-NNN referenced in proposal/design (P2).", async run(changeDir) { const t0 = Date.now(); const findings: LensFinding[] = []; const requirementsText = readIfExists(join(changeDir, "requirements.md")) ?? ""; const proposalText = readIfExists(join(changeDir, "proposal.md")) ?? ""; const designText = readIfExists(join(changeDir, "design.md")) ?? ""; const tasksText = readIfExists(join(changeDir, "tasks.md")) ?? ""; const constitutionText = readIfExists(join(changeDir, "constitution.md")) ?? ""; // ----- P2 (calibrated from P1 in v0.6.3): // REQ-NNN declared but not covered by any task. // v0.6.0 emitted P1; the calibration pass (this change) // observed that on real archived changes 20+ such P1 // findings fire per change, drowning the signal. The // "orphan REQ" condition is a documentation gap, not a // blocking concern — it warrants P2 severity. { const declaredReqs = extractIds(requirementsText, REQ_ID_RE, "REQ-"); const taskReqs = extractIds(tasksText, REQ_ID_RE, "REQ-"); for (const req of declaredReqs) { if (!taskReqs.has(req)) { // v0.6.3 CON-012 / REQ-008: skip invariant / format / // informational REQs. if (!isImplementationReq(req, requirementsText)) continue; // v0.6.3 CON-010 / REQ-003: skip self-matched REQs // (those declared inside the lens's own source / docs). if (isSelfExcludedReq(req, requirementsText)) continue; findings.push({ severity: "P2", summary: `${req} declared in requirements.md but no task references it`, location: "requirements.md", recommendation: `Either add a task referencing ${req} or remove ` + `${req} from requirements.md.`, }); } } } // ----- P2: proposal.md §3 Scope mentions the changeName { const changeName = resolveChangeName(changeDir); if (changeName && proposalText) { const scopeBody = extractScopeSection(proposalText); if (!scopeBody.includes(changeName)) { findings.push({ severity: "P2", summary: `proposal.md §3 Scope does not mention changeName '${changeName}'`, location: "proposal.md §3", recommendation: `Add an explicit reference to '${changeName}' in the ` + `§3 Scope section (state.json activeChange or dir basename).`, }); } } } // ----- P2: CON-NNN declared but not referenced in proposal/design { const declaredCons = extractIds(constitutionText, CON_ID_RE, "CON-"); if (declaredCons.size > 0) { const proposalCons = extractIds(proposalText, CON_ID_RE, "CON-"); const designCons = extractIds(designText, CON_ID_RE, "CON-"); for (const con of declaredCons) { if (!proposalCons.has(con) && !designCons.has(con)) { findings.push({ severity: "P2", summary: `${con} declared in constitution.md but not ` + `referenced in proposal.md or design.md`, location: "constitution.md", recommendation: `Either reference ${con} in proposal.md or design.md, ` + `or remove it from constitution.md if no longer needed.`, }); } } } } // ----- Apply dedup helper before cap (lens-internal hygiene). let deduped: LensFinding[]; if (typeof dedupeFindings === "function") { deduped = dedupeFindings(findings); } else { deduped = 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: "spec-compliance", findings: Object.freeze(finalFindings), durationMs: Date.now() - t0, truncated, }; }, }; // --------------------------------------------------------------------------- // Helpers (continued): v0.6.3 calibration // --------------------------------------------------------------------------- /** v0.6.3 CON-012 / REQ-008: returns false when the matched REQ is * classified as invariant / format / informational (its title or * body contains any of NON_IMPLEMENTATION_REQ_KEYWORDS), true * when it is an implementation REQ. * * Notes on dash variants: keywords are normalized by removing `-` * before matching, so "SHA-256" matches the keyword "sha256" via * the unhyphenated comparison. */ export function isImplementationReq( reqId: string, requirementsText: string, ): boolean { const blockRe = new RegExp( `###\\s+${reqId}\\b[\\s\\S]*?(?=\\n###\\s+|$)`, "i", ); const m = blockRe.exec(requirementsText); if (!m) return true; const blockNormalized = m[0].toLowerCase().replace(/-/g, ""); const keywordsNormalized = NON_IMPLEMENTATION_REQ_KEYWORDS.map((kw) => kw.replace(/-/g, ""), ); const isInvariant = keywordsNormalized.some((kw) => blockNormalized.includes(kw), ); return !isInvariant; } /** v0.6.3 CON-010 / REQ-003: returns true when the REQ is declared * inside a file path that matches `SPEC_COMPLIANCE_SELF_EXCLUDE_CONTEXT`. * In practice this catches REQs that are listed in * `docs/RESEARCH.md` (the lens's own documentation). The default * check just verifies the requirementsText itself mentions * the lens's own excluded file path. */ function isSelfExcludedReq(reqId: string, _requirementsText: string): boolean { // Defensive: if REQ is REQ-999 in our own fixture (or any // REQ inside one of the self-exclude files' text), skip. // The implementation looks up the requirements block and // checks if any self-exclude path appears in it. const blockRe = new RegExp( `###\\s+${reqId}\\b[\\s\\S]*?(?=\\n###\\s+|$)`, "i", ); const m = blockRe.exec(_requirementsText); if (!m) return false; const block = m[0]; return SPEC_COMPLIANCE_SELF_EXCLUDE_CONTEXT.some((p) => block.includes(p), ); } // Local dedupe (mirrors `lib/lens/risk.ts`'s helper shape; spec- // compliance findings are per-section, but hygiene is shared). 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; }