/** * OPT Scanner — Optimization Lens / mechanism-fit (v5.7 Fase 1 Chunk 2a) * * The first detector of the "is the config OPTIMAL?" axis (vs. the existing * "is it CORRECT?" scanners). It reads the machine-readable best-practices * register (knowledge/best-practices.json) and flags config that works but uses * a mechanism a better one would fit — the deterministic half of the hybrid * motor (the opus analyzer for prose-judgment cases is Chunk 2b). * * CA-OPT-001 A multi-step procedure in CLAUDE.md should be a SKILL (BP-MECH-003). * CLAUDE.md is for facts Claude holds every turn; a procedure there * costs always-loaded tokens whether or not you run it, and a skill's * body loads only on invoke. Detection is deliberately CONSERVATIVE * (a run of >= 6 consecutive numbered steps) to keep precision high — * the negative corpus in the tests proves null false-positives. * Framed as a Missed opportunity (humanizer), severity LOW. * * Provenance: the recommendation + claim come from the register entry (only a * `confirmed` entry is used user-facing — Verifiseringsplikt); an inline default * is the graceful fallback if the register is unavailable. Fixture-gated: the * marketplace-medium CLAUDE.md has no numbered lists, so it emits nothing (SC-5 * byte-stable; the additive OPT scanner entry is stripped from frozen baselines). * * Zero external dependencies. */ import { readFile } from 'node:fs/promises'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { parseFrontmatter } from './lib/yaml-parser.mjs'; import { loadRegister, getEntry } from './lib/best-practices-register.mjs'; const SCANNER = 'OPT'; const STEP_THRESHOLD = 6; const STEP_RE = /^\s*\d+\.\s+\S/; const PROCEDURE_TITLE = 'A multi-step procedure in CLAUDE.md belongs in a skill'; // Graceful fallback if the register is missing/unreadable (the register is the // source of truth; this keeps the scanner working without it). const DEFAULT_MECH_003 = { claim: 'A multi-step procedure in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude ' + 'should hold all the time; procedures belong in skills.', recommendation: 'Extract the procedure into .claude/skills/; its body then loads only on invoke instead ' + 'of every turn.', }; /** Return the confirmed register entry for `id`, or null (→ caller uses default). */ function confirmedEntry(id) { try { const e = getEntry(loadRegister(), id); return e && e.confidence === 'confirmed' ? e : null; } catch { return null; } } /** * Longest run of consecutive numbered-list items. Blank lines and indented * continuation lines neither extend nor break a run; any other non-step line * breaks it. Conservative by design (a wrapped, non-indented step line ends the * run → undercount, never overcount). * @param {string} text * @returns {{count:number, startIndex:number, firstStep:string}} */ function longestNumberedRun(text) { const lines = String(text).split('\n'); let maxRun = 0; let maxStartIdx = 0; let maxFirstStep = ''; let run = 0; let runStartIdx = 0; let runFirstStep = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (STEP_RE.test(line)) { if (run === 0) { runStartIdx = i; runFirstStep = line.trim(); } run++; if (run > maxRun) { maxRun = run; maxStartIdx = runStartIdx; maxFirstStep = runFirstStep; } } else if (line.trim() === '' || /^\s+\S/.test(line)) { // blank or indented continuation — part of the list, neither step nor break } else { run = 0; } } return { count: maxRun, startIndex: maxStartIdx, firstStep: maxFirstStep }; } export async function scan(targetPath, discovery) { const start = Date.now(); const findings = []; const claudeMdFiles = ((discovery && discovery.files) || []).filter((f) => f.type === 'claude-md'); const entry = confirmedEntry('BP-MECH-003'); const claim = (entry && entry.claim) || DEFAULT_MECH_003.claim; const recommendation = (entry && entry.recommendation) || DEFAULT_MECH_003.recommendation; let filesScanned = 0; for (const file of claudeMdFiles) { let content; try { content = await readFile(file.absPath, 'utf-8'); } catch { continue; } filesScanned++; const parsed = parseFrontmatter(content); const body = parsed.body || content; const bodyStartLine = parsed.bodyStartLine || 1; const run = longestNumberedRun(body); if (run.count >= STEP_THRESHOLD) { findings.push( finding({ scanner: SCANNER, severity: SEVERITY.low, title: PROCEDURE_TITLE, description: claim, file: file.relPath || file.absPath, line: bodyStartLine + run.startIndex, evidence: run.firstStep, recommendation, category: 'mechanism-fit', details: { mechanism: 'skill', steps: run.count, register: entry ? entry.id : null, source: entry ? entry.source.url : null, confidence: entry ? entry.confidence : null, }, }), ); } } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }