config-audit/scanners/optimization-lens-scanner.mjs
Kjell Tore Guttormsen e7833b65fc feat(opt): optimization lens CA-OPT-001 (procedure→skill) — v5.7 Fase 1 Chunk 2a
First detector of the 'is the config OPTIMAL?' axis (vs the existing 'correct?' scanners). New orchestrated scanner family CA-OPT (count 14->15), the deterministic half of the hybrid optimization lens.

CA-OPT-001 (low, Missed opportunity): a multi-step procedure in CLAUDE.md (>=6 consecutive numbered steps) that belongs in a skill. Reads recommendation + provenance from the best-practices register (BP-MECH-003). Conservative by design; the negative corpus proves null false-positives. Prose-judgment cases (lifecycle->hook, 'never'->permission) are deferred to the Chunk 2b opus analyzer.

Wiring mirrors OST: orchestrator entry, humanizer (OPT->'Missed opportunity' + family), scoring (OPT->'CLAUDE.md', existing area -> no new posture row -> byte-stable), strip-helper (OST,OPT), SC-5 regenerated under hermetic HOME (additive OPT entry only). 10 new tests; suite 1045->1055, self-audit A/A, readmeCheck passed (all verified with a clean HOME).

Note: the pre-existing TOK test reads the real ~/.claude (non-hermetic) -> run the suite with a clean HOME for deterministic results. Tracked as a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:10:03 +02:00

146 lines
5.3 KiB
JavaScript

/**
* 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);
}