The B3a cache filter already exposes discovery.staleCacheVersions; surface them
as a finding so the user knows the superseded plugin versions are safe to delete.
Honesty (Verifiseringsplikt): the finding loads on ZERO turns, so it must NOT
read as a per-turn token cost. TOK normally humanizes to "Wasted tokens"; a new
per-finding category override ('plugin-cache-hygiene' -> "Dead config") plus a
dedicated humanizer translation ("Old plugin versions are sitting on disk (safe
to delete) ... cost zero tokens per turn ... housekeeping, not a performance
problem") keep the prose accurate instead of the generic "using more space"
default. evidence carries the explicit "zero live-context impact" note.
- token-hotspots: Pattern H emits CA-TOK (low) from discovery.staleCacheVersions
when stale versions exist (`--global`); silent otherwise.
- humanizer: CATEGORY_TO_IMPACT lets a finding's category override the
scanner-default impact label (raw `category` field unchanged -> --json/--raw
byte-stable). humanizer-data: honest static translation for the finding.
- Tests: finding fires/severity/category, lists stale keys + zero-impact note,
silent when none; humanizer override -> Dead config; honest translation locked.
- Docs: tokens command render note (disk-cleanup, not a token problem) +
--no-exclude-cache flag; README + CLAUDE.md rows (7 patterns, cache-aware).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
6.7 KiB
JavaScript
211 lines
6.7 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',
|
|
OPT: 'Missed opportunity',
|
|
};
|
|
|
|
/**
|
|
* Per-finding `category` values that override the scanner-default impact label.
|
|
* Needed when one finding inside a scanner means something different from the
|
|
* scanner's usual bucket — e.g. stale plugin-cache versions are emitted by TOK
|
|
* (normally "Wasted tokens") but load on ZERO turns, so their honest impact is
|
|
* "Dead config" (present on disk, never loaded), not wasted per-turn tokens.
|
|
*/
|
|
const CATEGORY_TO_IMPACT = {
|
|
'plugin-cache-hygiene': 'Dead config',
|
|
};
|
|
|
|
/**
|
|
* 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 =
|
|
CATEGORY_TO_IMPACT[finding.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,
|
|
};
|
|
}
|