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