diff --git a/plugins/config-audit/scanners/lib/humanizer.mjs b/plugins/config-audit/scanners/lib/humanizer.mjs new file mode 100644 index 0000000..62f1c46 --- /dev/null +++ b/plugins/config-audit/scanners/lib/humanizer.mjs @@ -0,0 +1,196 @@ +/** + * Plain-language humanizer for config-audit findings. + * + * Pure functions. Never mutate inputs. Translates technical scanner output + * into user-friendly language at output-formatting time. Adds three new + * fields to each finding: + * - userImpactCategory: human-readable label per scanner (research/02) + * - userActionLanguage: one-line urgency phrase per severity + * - relevanceContext: deterministic file-pattern heuristic + * + * Original id, scanner, severity, file, line, evidence, category, autoFixable + * are preserved exactly. Title, description, recommendation are replaced when + * a translation is found; otherwise the originals are kept. + * + * Lookup order (per scanner prefix): + * 1. exact title in TRANSLATIONS[prefix].static + * 2. first regex match in TRANSLATIONS[prefix].patterns + * 3. TRANSLATIONS[prefix]._default + * 4. fallthrough: original strings (when scanner prefix has no entry) + * + * Zero external dependencies. + */ + +import { TRANSLATIONS } from './humanizer-data.mjs'; + +/** + * Map scanner prefix to user-facing impact-category label (research/02 line 124). + */ +const SCANNER_TO_CATEGORY = { + CML: 'Configuration mistake', + SET: 'Configuration mistake', + HKV: 'Configuration mistake', + RUL: 'Configuration mistake', + MCP: 'Configuration mistake', + IMP: 'Configuration mistake', + CNF: 'Conflict', + COL: 'Conflict', + TOK: 'Wasted tokens', + CPS: 'Wasted tokens', + DIS: 'Dead config', + GAP: 'Missed opportunity', + PLH: 'Configuration mistake', +}; + +/** + * Map severity to one-line action-language phrase (research/02 line 134). + */ +const SEVERITY_TO_ACTION = { + critical: 'Fix this now', + high: 'Fix soon', + medium: 'Fix when convenient', + low: 'Optional cleanup', + info: 'FYI', +}; + +/** + * Compute relevance context from a finding's file path. Deterministic, in-process, + * no subprocess. Conservative — defaults to 'affects-everyone' when ambiguous. + * + * @param {string|null|undefined} filePath + * @returns {'test-fixture-no-impact' | 'affects-this-machine-only' | 'affects-everyone'} + */ +export function computeRelevanceContext(filePath) { + if (typeof filePath !== 'string' || filePath.length === 0) { + return 'affects-everyone'; + } + if (filePath.includes('/tests/fixtures/') || filePath.includes('/test/fixtures/')) { + return 'test-fixture-no-impact'; + } + // Match basename pattern *.local.* (e.g., settings.local.json, claude.local.md) + const basename = filePath.split('/').pop() || ''; + if (/\.local\./.test(basename)) { + return 'affects-this-machine-only'; + } + return 'affects-everyone'; +} + +/** + * Look up translation for a finding by scanner prefix and title. + * Returns the translation object or null when no match (caller falls through to original). + * + * @param {string} scanner + * @param {string} title + * @returns {{title:string, description:string, recommendation:string} | null} + */ +function lookupTranslation(scanner, title) { + const entry = TRANSLATIONS[scanner]; + if (!entry) return null; + + // 1. Exact static match + if (typeof title === 'string' && entry.static && Object.prototype.hasOwnProperty.call(entry.static, title)) { + return entry.static[title]; + } + + // 2. Pattern match + if (Array.isArray(entry.patterns) && typeof title === 'string') { + for (const p of entry.patterns) { + if (p.regex instanceof RegExp && p.regex.test(title)) { + return p.translation; + } + } + } + + // 3. Default + if (entry._default) { + return entry._default; + } + + return null; +} + +/** + * Humanize a single finding. Pure — never mutates input. Returns a new object. + * + * @param {object} finding - finding object from scanner output + * @returns {object} new finding with translated title/description/recommendation + + * userImpactCategory, userActionLanguage, relevanceContext fields + */ +export function humanizeFinding(finding) { + if (!finding || typeof finding !== 'object') { + return finding; + } + + const translation = lookupTranslation(finding.scanner, finding.title); + const category = SCANNER_TO_CATEGORY[finding.scanner] || 'Other'; + const action = SEVERITY_TO_ACTION[finding.severity] || 'FYI'; + const relevance = computeRelevanceContext(finding.file); + + const out = { + // Preserve identifying / structural fields exactly + id: finding.id, + scanner: finding.scanner, + severity: finding.severity, + // Replace prose if a translation exists; otherwise keep originals + title: translation ? translation.title : finding.title, + description: translation ? translation.description : finding.description, + file: finding.file ?? null, + line: finding.line ?? null, + evidence: finding.evidence ?? null, + category: finding.category ?? null, + recommendation: translation ? translation.recommendation : finding.recommendation, + autoFixable: finding.autoFixable ?? false, + // New humanized fields + userImpactCategory: category, + userActionLanguage: action, + relevanceContext: relevance, + }; + + // Preserve optional details payload if present (v5 N6) + if (finding.details && typeof finding.details === 'object') { + out.details = finding.details; + } + + return out; +} + +/** + * Humanize an array of findings. Pure — returns a new array of new objects. + * + * @param {object[]} findings + * @returns {object[]} + */ +export function humanizeFindings(findings) { + if (!Array.isArray(findings)) return findings; + return findings.map(humanizeFinding); +} + +/** + * Humanize a top-level envelope produced by `runAllScanners`. Walks + * `env.scanners[].findings`. Pure — returns a new envelope with new + * scanner objects and new finding objects. The envelope-level shape + * (scanners array, target_path, total_duration_ms, aggregate, etc.) + * is preserved. + * + * @param {object} env + * @returns {object} + */ +export function humanizeEnvelope(env) { + if (!env || typeof env !== 'object' || !Array.isArray(env.scanners)) { + return env; + } + + const newScanners = env.scanners.map((s) => { + if (!s || typeof s !== 'object') return s; + if (!Array.isArray(s.findings)) return s; + return { + ...s, + findings: humanizeFindings(s.findings), + }; + }); + + return { + ...env, + scanners: newScanners, + }; +} diff --git a/plugins/config-audit/tests/lib/humanizer.test.mjs b/plugins/config-audit/tests/lib/humanizer.test.mjs new file mode 100644 index 0000000..a3f04e9 --- /dev/null +++ b/plugins/config-audit/tests/lib/humanizer.test.mjs @@ -0,0 +1,302 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + humanizeFinding, + humanizeFindings, + humanizeEnvelope, + computeRelevanceContext, +} from '../../scanners/lib/humanizer.mjs'; + +// ─── helpers ──────────────────────────────────────────────────────────── + +function makeFinding(overrides = {}) { + return { + id: 'CA-CML-001', + scanner: 'CML', + severity: 'medium', + title: 'No CLAUDE.md found', + description: 'No CLAUDE.md file at the project root.', + file: '/Users/test/project/CLAUDE.md', + line: null, + evidence: 'evidence text', + category: 'config', + recommendation: 'Create one.', + autoFixable: false, + ...overrides, + }; +} + +// ─── purity ───────────────────────────────────────────────────────────── + +test('humanizeFinding does not mutate its input', () => { + const input = makeFinding(); + const before = JSON.parse(JSON.stringify(input)); + humanizeFinding(input); + assert.deepEqual(input, before, 'input was mutated'); +}); + +test('humanizeFindings does not mutate its input array', () => { + const input = [makeFinding(), makeFinding({ id: 'CA-CML-002', title: 'Repeated content detected' })]; + const before = JSON.parse(JSON.stringify(input)); + humanizeFindings(input); + assert.deepEqual(input, before, 'input array was mutated'); +}); + +test('humanizeEnvelope does not mutate its input', () => { + const env = { + target_path: '/tmp', + scanners: [ + { scanner: 'CML', status: 'ok', findings: [makeFinding()], counts: {} }, + ], + }; + const before = JSON.parse(JSON.stringify(env)); + humanizeEnvelope(env); + assert.deepEqual(env, before, 'envelope was mutated'); +}); + +// ─── field preservation ──────────────────────────────────────────────── + +test('humanizeFinding preserves id, scanner, severity, file, line, evidence, category, autoFixable', () => { + const input = makeFinding({ + id: 'CA-CML-042', + scanner: 'CML', + severity: 'high', + file: '/tmp/x.md', + line: 17, + evidence: 'specific snippet', + category: 'cml', + autoFixable: true, + }); + const out = humanizeFinding(input); + assert.equal(out.id, 'CA-CML-042'); + assert.equal(out.scanner, 'CML'); + assert.equal(out.severity, 'high'); + assert.equal(out.file, '/tmp/x.md'); + assert.equal(out.line, 17); + assert.equal(out.evidence, 'specific snippet'); + assert.equal(out.category, 'cml'); + assert.equal(out.autoFixable, true); +}); + +test('humanizeFinding preserves optional details payload', () => { + const input = makeFinding(); + input.details = { foo: 'bar', count: 7 }; + const out = humanizeFinding(input); + assert.deepEqual(out.details, { foo: 'bar', count: 7 }); +}); + +// ─── translation lookup ──────────────────────────────────────────────── + +test('humanizeFinding rewrites title for known static title', () => { + const input = makeFinding({ scanner: 'CML', title: 'No CLAUDE.md found' }); + const out = humanizeFinding(input); + assert.notEqual(out.title, input.title, 'title should be translated'); + assert.ok(out.title.toLowerCase().includes('instructions') || out.title.toLowerCase().includes('claude'), + `humanized title should mention instructions or claude, got: ${out.title}`); +}); + +test('humanizeFinding falls back to _default when title unknown', () => { + const input = makeFinding({ scanner: 'CML', title: 'Unrecognized brand-new finding title' }); + const out = humanizeFinding(input); + assert.notEqual(out.title, input.title, '_default should kick in'); + // CML _default mentions "instructions file" + assert.ok(/instructions file/i.test(out.title), `expected CML _default title, got: ${out.title}`); +}); + +test('humanizeFinding passes through original strings when scanner prefix unknown', () => { + const input = makeFinding({ scanner: 'XXX', title: 'whatever' }); + const out = humanizeFinding(input); + assert.equal(out.title, 'whatever'); + assert.equal(out.description, input.description); + assert.equal(out.recommendation, input.recommendation); +}); + +test('humanizeFinding matches pattern entries (template-literal titles)', () => { + const input = makeFinding({ + scanner: 'COL', + title: 'Skill name "okr-helper" used by multiple plugins', + }); + const out = humanizeFinding(input); + assert.notEqual(out.title, input.title); + assert.ok(/two plugins|same name|multiple/i.test(out.title) || + /two plugins|same name|multiple/i.test(out.description), + `expected pattern match for COL multiple-plugins case, got title: ${out.title}`); +}); + +// ─── userActionLanguage ──────────────────────────────────────────────── + +test('humanizeFinding maps severity=critical -> "Fix this now"', () => { + const out = humanizeFinding(makeFinding({ severity: 'critical' })); + assert.equal(out.userActionLanguage, 'Fix this now'); +}); + +test('humanizeFinding maps severity=high -> "Fix soon"', () => { + const out = humanizeFinding(makeFinding({ severity: 'high' })); + assert.equal(out.userActionLanguage, 'Fix soon'); +}); + +test('humanizeFinding maps severity=medium -> "Fix when convenient"', () => { + const out = humanizeFinding(makeFinding({ severity: 'medium' })); + assert.equal(out.userActionLanguage, 'Fix when convenient'); +}); + +test('humanizeFinding maps severity=low -> "Optional cleanup"', () => { + const out = humanizeFinding(makeFinding({ severity: 'low' })); + assert.equal(out.userActionLanguage, 'Optional cleanup'); +}); + +test('humanizeFinding maps severity=info -> "FYI"', () => { + const out = humanizeFinding(makeFinding({ severity: 'info' })); + assert.equal(out.userActionLanguage, 'FYI'); +}); + +test('humanizeFinding falls back to "FYI" for unknown severity', () => { + const out = humanizeFinding(makeFinding({ severity: 'mystery' })); + assert.equal(out.userActionLanguage, 'FYI'); +}); + +// ─── userImpactCategory ──────────────────────────────────────────────── + +test('humanizeFinding sets category Configuration mistake for CML/SET/HKV/RUL/MCP/IMP/PLH', () => { + for (const s of ['CML', 'SET', 'HKV', 'RUL', 'MCP', 'IMP', 'PLH']) { + const out = humanizeFinding(makeFinding({ scanner: s })); + assert.equal(out.userImpactCategory, 'Configuration mistake', `${s} should map to Configuration mistake`); + } +}); + +test('humanizeFinding sets category Conflict for CNF/COL', () => { + for (const s of ['CNF', 'COL']) { + const out = humanizeFinding(makeFinding({ scanner: s })); + assert.equal(out.userImpactCategory, 'Conflict'); + } +}); + +test('humanizeFinding sets category Wasted tokens for TOK/CPS', () => { + for (const s of ['TOK', 'CPS']) { + const out = humanizeFinding(makeFinding({ scanner: s })); + assert.equal(out.userImpactCategory, 'Wasted tokens'); + } +}); + +test('humanizeFinding sets category Dead config for DIS', () => { + const out = humanizeFinding(makeFinding({ scanner: 'DIS' })); + assert.equal(out.userImpactCategory, 'Dead config'); +}); + +test('humanizeFinding sets category Missed opportunity for GAP', () => { + const out = humanizeFinding(makeFinding({ scanner: 'GAP', title: 'No CLAUDE.md file' })); + assert.equal(out.userImpactCategory, 'Missed opportunity'); +}); + +test('humanizeFinding sets category Other for unknown scanner', () => { + const out = humanizeFinding(makeFinding({ scanner: 'XXX' })); + assert.equal(out.userImpactCategory, 'Other'); +}); + +// ─── relevanceContext ────────────────────────────────────────────────── + +test('computeRelevanceContext detects test-fixture paths', () => { + assert.equal(computeRelevanceContext('/repo/tests/fixtures/foo/CLAUDE.md'), 'test-fixture-no-impact'); + assert.equal(computeRelevanceContext('/repo/test/fixtures/bar.json'), 'test-fixture-no-impact'); +}); + +test('computeRelevanceContext detects local-only paths via .local. infix', () => { + assert.equal(computeRelevanceContext('/repo/.claude/settings.local.json'), 'affects-this-machine-only'); + assert.equal(computeRelevanceContext('/repo/CLAUDE.local.md'), 'affects-this-machine-only'); +}); + +test('computeRelevanceContext defaults to affects-everyone for normal paths', () => { + assert.equal(computeRelevanceContext('/repo/CLAUDE.md'), 'affects-everyone'); + assert.equal(computeRelevanceContext('/repo/.claude/settings.json'), 'affects-everyone'); +}); + +test('computeRelevanceContext defaults to affects-everyone for null/empty paths', () => { + assert.equal(computeRelevanceContext(null), 'affects-everyone'); + assert.equal(computeRelevanceContext(undefined), 'affects-everyone'); + assert.equal(computeRelevanceContext(''), 'affects-everyone'); +}); + +test('humanizeFinding sets relevanceContext from file', () => { + const f = makeFinding({ file: '/repo/tests/fixtures/x.json' }); + assert.equal(humanizeFinding(f).relevanceContext, 'test-fixture-no-impact'); + + const g = makeFinding({ file: '/repo/.claude/settings.local.json' }); + assert.equal(humanizeFinding(g).relevanceContext, 'affects-this-machine-only'); + + const h = makeFinding({ file: '/repo/CLAUDE.md' }); + assert.equal(humanizeFinding(h).relevanceContext, 'affects-everyone'); +}); + +// ─── humanizeFindings & humanizeEnvelope ────────────────────────────── + +test('humanizeFindings translates each finding in the array', () => { + const findings = [ + makeFinding({ scanner: 'CML', title: 'No CLAUDE.md found' }), + makeFinding({ id: 'CA-CML-002', scanner: 'CML', title: 'Uses HTML comments' }), + ]; + const out = humanizeFindings(findings); + assert.equal(out.length, 2); + assert.notEqual(out[0].title, findings[0].title); + assert.notEqual(out[1].title, findings[1].title); +}); + +test('humanizeFindings returns input unchanged if not an array', () => { + assert.equal(humanizeFindings(null), null); + assert.equal(humanizeFindings(undefined), undefined); +}); + +test('humanizeEnvelope walks scanners[].findings and humanizes each', () => { + const env = { + target_path: '/tmp', + scanners: [ + { + scanner: 'CML', + status: 'ok', + findings: [makeFinding({ scanner: 'CML', title: 'No CLAUDE.md found' })], + counts: {}, + }, + { + scanner: 'TOK', + status: 'ok', + findings: [makeFinding({ + id: 'CA-TOK-001', + scanner: 'TOK', + severity: 'low', + title: 'Cache-breaking volatile content at top of CLAUDE.md', + file: '/tmp/CLAUDE.md', + })], + counts: {}, + }, + ], + }; + const out = humanizeEnvelope(env); + assert.equal(out.target_path, '/tmp'); + assert.equal(out.scanners.length, 2); + assert.notEqual(out.scanners[0].findings[0].title, env.scanners[0].findings[0].title); + assert.notEqual(out.scanners[1].findings[0].title, env.scanners[1].findings[0].title); + assert.equal(out.scanners[1].findings[0].userImpactCategory, 'Wasted tokens'); +}); + +test('humanizeEnvelope returns input unchanged if shape is wrong', () => { + assert.equal(humanizeEnvelope(null), null); + assert.equal(humanizeEnvelope({}).scanners, undefined); // unchanged object + assert.equal(humanizeEnvelope({ scanners: 'not-an-array' }).scanners, 'not-an-array'); +}); + +// ─── new fields presence ─────────────────────────────────────────────── + +test('humanizeFinding always sets the three new fields', () => { + const out = humanizeFinding(makeFinding()); + assert.equal(typeof out.userImpactCategory, 'string'); + assert.equal(typeof out.userActionLanguage, 'string'); + assert.equal(typeof out.relevanceContext, 'string'); +}); + +// ─── ANSI-free guarantee ─────────────────────────────────────────────── + +test('humanized output contains no ANSI escape sequences', () => { + const out = humanizeFinding(makeFinding({ scanner: 'CML', title: 'No CLAUDE.md found' })); + const allText = `${out.title} ${out.description} ${out.recommendation}`; + // eslint-disable-next-line no-control-regex + assert.equal(/\[/.test(allText), false, 'ANSI escape detected in humanized output'); +});