config-audit/scanners/lib/humanizer.mjs
Kjell Tore Guttormsen e3b044a476 feat(ost): v5.6 C — output-style scanner (CA-OST, count 13→14)
New orchestrated scanner output-style-scanner.mjs — first new family since
SKL. Three findings, each pinned to a CONFIRMED V-row of the steering-model
plan + re-verified against code.claude.com/docs/en/output-styles:

- CA-OST-001 (medium, V10): user/project custom style missing
  keep-coding-instructions:true (default false) → silently strips built-in
  software-engineering instructions when active. Scoped to user/project.
- CA-OST-002 (low, V11): plugin style with force-for-plugin:true overrides the
  user's selected outputStyle. Verifiseringsplikt correction — the plan bullet
  said "project/user style," but force-for-plugin is plugin-styles-only per the
  docs, so the check keys on source==='plugin'.
- CA-OST-003 (medium): settings outputStyle matching no built-in
  (Default/Explanatory/Learning/Proactive, case-insensitive) nor discovered
  custom style → dead config.

Byte-stability — a scanner addition, not a field addition. Growing the
scanners array + scanners_ok cannot be hidden by a field strip, but re-seeding
frozen v5.0.0 (the SKL precedent) would now bake in B2's hotspot triple +
claudeMd drift. So, per the B2 lesson, frozen v5.0.0 snapshots are PRESERVED
and the OST entry is stripped at compare time via new
tests/helpers/strip-added-scanner.mjs (wired into json/raw-backcompat + the
Step 5/6 humanizer tests); only SC-5 default-output is regenerated (additive
OST entry, diff reviewed). OST is fixture-gated (no output styles on
marketplace-medium / hermetic HOME → silent).

Wiring: orchestrator; humanizer (OST→Configuration mistake) + humanizer-data
OST family (title-coupled); scoring (OST→Settings, keeps 10 areas). Suite
1012→1023 (+11). Badges: scanners 14, tests 1023, TRANSLATIONS families 15.
Lore swept: README, CLAUDE.md, scanner-internals, humanizer.md. self-audit A/A.

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

198 lines
6.1 KiB
JavaScript

/**
* 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',
SKL: 'Wasted tokens',
DIS: 'Dead config',
GAP: 'Missed opportunity',
PLH: 'Configuration mistake',
OST: '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,
};
}