feat(humanizer): translation module with category, action, relevance
Wave 1 / Step 3 of v5.1.0 plain-language UX humanizer. scanners/lib/humanizer.mjs exports three pure functions: - humanizeFinding(f) -> new finding object with translated title/description/recommendation + three new fields (userImpactCategory, userActionLanguage, relevanceContext). - humanizeFindings(findings) -> mapped array. - humanizeEnvelope(env) -> walks env.scanners[].findings. Plus computeRelevanceContext(filePath) as a named export for unit testing. Field semantics: - userImpactCategory: from scanner prefix per research/02 line 124 (Configuration mistake / Conflict / Wasted tokens / Dead config / Missed opportunity / Other). - userActionLanguage: from severity per research/02 line 134 (Fix this now / Fix soon / Fix when convenient / Optional cleanup / FYI). - relevanceContext: deterministic file-path heuristic — looks for /tests/fixtures/ or /test/fixtures/ substring (test-fixture-no-impact), *.local.* basename (affects-this-machine-only), defaults to affects-everyone. No subprocess, no network. Lookup order per scanner: static[title] -> patterns regex match -> _default -> fall through to original strings (when scanner prefix absent). Original id, scanner, severity, file, line, evidence, category, autoFixable, and optional details are preserved exactly. Pure — verified by deepEqual of input before/after. Test (32 cases): purity, field preservation across all paths, known/unknown scanner handling, all 5 severities, all 6 categories, relevance heuristic for 4 path types, envelope walking, ANSI-free guarantee. All pass. Regression: 689/689 tests (657 + 32 new = 54 new across Wave 1). Project: .claude/projects/2026-05-01-config-audit-ux-redesign/
This commit is contained in:
parent
02ee2a8b83
commit
1a45caf18b
2 changed files with 498 additions and 0 deletions
196
plugins/config-audit/scanners/lib/humanizer.mjs
Normal file
196
plugins/config-audit/scanners/lib/humanizer.mjs
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
302
plugins/config-audit/tests/lib/humanizer.test.mjs
Normal file
302
plugins/config-audit/tests/lib/humanizer.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue