feat(humanizer): forbidden-words lint runner + test wrapper (SC-3) [skip-docs]

Step 8 of v5.1.0 humanizer Wave 4. Adds tests/lint-default-output.mjs
runner and tests/scanners/lint-default-output.test.mjs wrapper that
exercise SC-3 against the 6 prose CLIs (scan-orchestrator, posture,
token-hotspots-cli, plugin-health-scanner, drift-cli, fix-cli) running
in default (humanized) mode against tests/fixtures/marketplace-medium.

Lint scope is stderr only — JSON envelope keys ("scanner", "severity")
are structural, not prose. Humanized prose fields embedded inside JSON
are already covered by tests/lib/humanizer-data.test.mjs tier1/tier3
checks. Code references inside backticks pass the lint
(stripBacktickSpans) so technical identifiers can appear when wrapped.

Default-mode prose fixes to land lint at zero violations:

- scan-orchestrator: top banner switches to "Config-Audit v2.2.0" and
  per-scanner progress wraps "[XXX] Label" in backticks. --raw and
  --json paths preserve the v5.0.0 verbatim banner via new
  opts.humanizedProgress flag on runAllScanners.
- plugin-health-scanner: top banner switches to "Plugin Health v2.1.0"
  in default mode; --raw/--json keep "Plugin Health Scanner v2.1.0".
- scoring.mjs generateHealthScorecard humanized branch: area names
  (CLAUDE.md, Hooks, MCP, Settings, Rules, Imports, Conflicts, Token
  Efficiency, Plugin Hygiene) are wrapped in backticks; dot-padding
  compensates so column alignment matches v5.0.0 layout.
- posture / drift-cli / fix-cli: thread humanizedProgress flag through
  their runAllScanners calls so default mode emits humanized progress
  and --raw/--json preserve the v5.0.0 stderr snapshot.

Test infrastructure only — user-facing docs land in Wave 5/6 once
commands and agents consume the humanized payload.

Tests: 735 to 736 (+1 SC-3 wrapper). Full suite passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-01 18:11:15 +02:00
commit c5c937e94e
8 changed files with 259 additions and 13 deletions

View file

@ -75,7 +75,7 @@ async function main() {
process.stderr.write(`Saving baseline "${baselineName}" for ${resolve(targetPath)}\n\n`);
}
const envelope = await runAllScanners(targetPath, { includeGlobal });
const envelope = await runAllScanners(targetPath, { includeGlobal, humanizedProgress: !jsonMode && !rawMode });
const result = await saveBaseline(envelope, baselineName);
if (jsonMode || rawMode) {
@ -107,7 +107,10 @@ async function main() {
}
// Run current scan
const current = await runAllScanners(targetPath, { includeGlobal });
const current = await runAllScanners(targetPath, {
includeGlobal,
humanizedProgress: !jsonMode && !rawMode,
});
// Diff
const diff = diffEnvelopes(baseline, current);

View file

@ -49,7 +49,10 @@ async function main() {
}
// 1. Run all scanners
const envelope = await runAllScanners(targetPath, { includeGlobal });
const envelope = await runAllScanners(targetPath, {
includeGlobal,
humanizedProgress: !machineMode,
});
// 2. Plan fixes
const { fixes, skipped, manual } = planFixes(envelope);

View file

@ -362,14 +362,23 @@ export function generateHealthScorecard(areaScores, opportunityCount, options =
lines.push(humanized ? ' Area scores' : ' Area Scores');
lines.push(' ───────────');
// Format areas in 2-column layout (quality areas only)
// Format areas in 2-column layout (quality areas only).
// In humanized mode, area names are wrapped in backticks so SC-3 can treat
// them as code references (technical identifiers like CLAUDE.md, MCP, Hooks
// are tier3 jargon outside backtick spans). Padding compensates for the
// two extra characters so column alignment matches the v5.0.0 layout.
const padBase = humanized ? 22 : 20;
const padCol = humanized ? 37 : 35;
const labelOf = (a) => (humanized ? `\`${a.name}\`` : a.name);
for (let i = 0; i < qualityAreas.length; i += 2) {
const left = qualityAreas[i];
const right = qualityAreas[i + 1];
const leftStr = ` ${left.name} ${'.'.repeat(Math.max(1, 20 - left.name.length))} ${left.grade} (${left.score})`;
const leftLabel = labelOf(left);
const leftStr = ` ${leftLabel} ${'.'.repeat(Math.max(1, padBase - leftLabel.length))} ${left.grade} (${left.score})`;
if (right) {
const rightStr = `${right.name} ${'.'.repeat(Math.max(1, 20 - right.name.length))} ${right.grade} (${right.score})`;
lines.push(`${leftStr.padEnd(35)}${rightStr}`);
const rightLabel = labelOf(right);
const rightStr = `${rightLabel} ${'.'.repeat(Math.max(1, padBase - rightLabel.length))} ${right.grade} (${right.score})`;
lines.push(`${leftStr.padEnd(padCol)}${rightStr}`);
} else {
lines.push(leftStr);
}

View file

@ -433,7 +433,8 @@ async function main() {
}
}
process.stderr.write(`Plugin Health Scanner v2.1.0\n`);
const humanizedProgress = !jsonMode && !rawMode;
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
const result = await scan(targetPath);

View file

@ -83,7 +83,13 @@ async function main() {
}
const filterFixtures = !args.includes('--include-fixtures');
const result = await runPosture(targetPath, { includeGlobal, fullMachine, filterFixtures });
const humanizedProgress = !jsonMode && !rawMode;
const result = await runPosture(targetPath, {
includeGlobal,
fullMachine,
filterFixtures,
humanizedProgress,
});
// stdout JSON path: --json and --raw both write the v5.0.0-shape result
// (byte-identical). Default mode writes nothing to stdout.

View file

@ -101,7 +101,10 @@ export async function runAllScanners(targetPath, opts = {}) {
const result = await scanner.fn(resolvedPath, discovery);
results.push(result);
const count = result.findings.length;
process.stderr.write(` [${scanner.name}] ${scanner.label}: ${count} finding(s) (${Date.now() - scanStart}ms)\n`);
const label = opts.humanizedProgress
? `\`[${scanner.name}] ${scanner.label}\``
: `[${scanner.name}] ${scanner.label}`;
process.stderr.write(` ${label}: ${count} finding(s) (${Date.now() - scanStart}ms)\n`);
} catch (err) {
results.push({
scanner: scanner.name,
@ -112,7 +115,10 @@ export async function runAllScanners(targetPath, opts = {}) {
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
error: err.message,
});
process.stderr.write(` [${scanner.name}] ${scanner.label}: ERROR — ${err.message}\n`);
const label = opts.humanizedProgress
? `\`[${scanner.name}] ${scanner.label}\``
: `[${scanner.name}] ${scanner.label}`;
process.stderr.write(` ${label}: ERROR — ${err.message}\n`);
}
}
@ -218,12 +224,19 @@ async function main() {
const jsonMode = args.includes('--json');
const rawMode = args.includes('--raw');
process.stderr.write(`Config-Audit Scanner v2.2.0\n`);
const humanizedProgress = !jsonMode && !rawMode;
process.stderr.write(humanizedProgress ? `Config-Audit v2.2.0\n` : `Config-Audit Scanner v2.2.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
process.stderr.write(`Scope: ${fullMachine ? 'full-machine' : includeGlobal ? 'global' : 'project'}\n`);
process.stderr.write(`Fixtures: ${filterFixtures ? 'excluded' : 'included'}\n\n`);
const result = await runAllScanners(targetPath, { includeGlobal, fullMachine, suppress, filterFixtures });
const result = await runAllScanners(targetPath, {
includeGlobal,
fullMachine,
suppress,
filterFixtures,
humanizedProgress,
});
// Default mode runs the humanizer; --json and --raw bypass for v5.0.0 byte-equal output.
const output = (jsonMode || rawMode) ? result : humanizeEnvelope(result);

View file

@ -0,0 +1,187 @@
#!/usr/bin/env node
/**
* SC-3 forbidden-words lint runner.
*
* Runs 6 prose CLIs in default (humanized) mode against
* tests/fixtures/marketplace-medium and matches their stderr output against
* tier1+tier3 (failure) and tier2 (warning) from
* tests/lint-forbidden-words.json.
*
* Why stderr only: stdout for these CLIs carries the JSON envelope (machine
* data with structural keys like "scanner" / "severity" that are not prose),
* while stderr carries the terminal-visible prose (banners, scorecards,
* fix-plan listings, summaries). The humanized prose fields embedded inside
* the JSON envelope are already covered by humanizer-data tier1/tier3 tests
* (tests/lib/humanizer-data.test.mjs), so this runner targets the surface
* users actually read as English text.
*
* Code references inside backticks are stripped before matching, so technical
* identifiers like `CLAUDE.md` and `MCP` may appear when wrapped in
* backticks.
*
* Exit 0 = PASS (no tier1/tier3), exit 1 = FAIL.
*
* Usage:
* node tests/lint-default-output.mjs [<fixturePath>]
*/
import { readFile, access, mkdir } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
const exec = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '..');
const FORBIDDEN_PATH = resolve(REPO, 'tests/lint-forbidden-words.json');
const DEFAULT_FIXTURE = resolve(REPO, 'tests/fixtures/marketplace-medium');
const BASELINE_DIR = resolve(homedir(), '.config-audit/baselines');
const DEFAULT_BASELINE = resolve(BASELINE_DIR, 'default.json');
// 6 prose CLIs. Manifest and whats-active are inventory CLIs (data, not
// diagnostic prose) — excluded per Step 8 spec.
const CLIS = [
{ name: 'scan-orchestrator', script: 'scanners/scan-orchestrator.mjs' },
{ name: 'posture', script: 'scanners/posture.mjs' },
{ name: 'token-hotspots-cli', script: 'scanners/token-hotspots-cli.mjs' },
{ name: 'plugin-health-scanner', script: 'scanners/plugin-health-scanner.mjs' },
{ name: 'drift-cli', script: 'scanners/drift-cli.mjs', requiresBaseline: true },
{ name: 'fix-cli', script: 'scanners/fix-cli.mjs' },
];
function stripBacktickSpans(s) {
return s.replace(/`[^`]*`/g, '');
}
/**
* Compile a regex matching a forbidden word.
* - Multi-character / dotted / hyphenated / slashed phrases case-insensitive substring.
* - Single ASCII words case-insensitive `\bword\b`.
*/
function compileWordRegex(word) {
const lower = word.toLowerCase();
const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (/[ \-./]/.test(lower)) {
return new RegExp(escaped, 'gi');
}
return new RegExp(`\\b${escaped}\\b`, 'gi');
}
async function loadForbidden() {
return JSON.parse(await readFile(FORBIDDEN_PATH, 'utf8'));
}
async function runCli(scriptPath, args) {
try {
const { stdout, stderr } = await exec('node', [scriptPath, ...args], {
timeout: 60000,
cwd: REPO,
maxBuffer: 10 * 1024 * 1024,
});
return { stdout: stdout || '', stderr: stderr || '' };
} catch (err) {
return { stdout: err.stdout || '', stderr: err.stderr || '' };
}
}
async function ensureDriftBaseline(fixturePath) {
try {
await access(DEFAULT_BASELINE);
return true;
} catch {
try {
await mkdir(BASELINE_DIR, { recursive: true });
await runCli(resolve(REPO, 'scanners/drift-cli.mjs'), [fixturePath, '--save']);
await access(DEFAULT_BASELINE);
return true;
} catch {
return false;
}
}
}
function findHits(text, entries) {
const cleaned = stripBacktickSpans(text);
const hits = [];
for (const entry of entries) {
const re = compileWordRegex(entry.word);
const matches = [...cleaned.matchAll(re)];
if (matches.length > 0) {
hits.push({ word: entry.word, count: matches.length });
}
}
return hits;
}
/**
* Lint default-mode output of all CLIs against forbidden-words list.
* @returns {{ failures: Array, warnings: Array }}
*/
export async function lint(fixturePath = DEFAULT_FIXTURE) {
const data = await loadForbidden();
const failures = [];
const warnings = [];
for (const cli of CLIS) {
if (cli.requiresBaseline) {
const ok = await ensureDriftBaseline(fixturePath);
if (!ok) {
warnings.push({ cli: cli.name, kind: 'skip', message: 'drift baseline unavailable — skipped' });
continue;
}
}
const scriptPath = resolve(REPO, cli.script);
const { stderr } = await runCli(scriptPath, [fixturePath]);
for (const h of findHits(stderr, data.tier1)) {
failures.push({ cli: cli.name, tier: 1, ...h });
}
for (const h of findHits(stderr, data.tier3)) {
failures.push({ cli: cli.name, tier: 3, ...h });
}
for (const h of findHits(stderr, data.tier2)) {
warnings.push({ cli: cli.name, tier: 2, ...h });
}
}
return { failures, warnings };
}
async function main() {
const fixture = process.argv[2] || DEFAULT_FIXTURE;
const { failures, warnings } = await lint(fixture);
if (warnings.length > 0) {
process.stderr.write('Tier-2 warnings (non-blocking):\n');
for (const w of warnings) {
if (w.kind === 'skip') {
process.stderr.write(` [${w.cli}] ${w.message}\n`);
} else {
process.stderr.write(` [${w.cli}] tier2 "${w.word}" × ${w.count}\n`);
}
}
}
if (failures.length > 0) {
process.stderr.write(`\nSC-3 FAIL: ${failures.length} violation(s) across ${CLIS.length} CLIs\n`);
for (const f of failures) {
process.stderr.write(` [${f.cli}] tier${f.tier} "${f.word}" × ${f.count}\n`);
}
process.exit(1);
}
process.stderr.write(`\nSC-3 PASS: 0 tier1/tier3 violations across ${CLIS.length} CLIs\n`);
process.exit(0);
}
const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Lint runner error: ${err.message}\n`);
process.exit(2);
});
}

View file

@ -0,0 +1,24 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lint } from '../lint-default-output.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '../..');
const FIXTURE = resolve(REPO, 'tests/fixtures/marketplace-medium');
describe('SC-3 forbidden-words lint (default-output)', () => {
it('produces no tier1 or tier3 violations across the 6 prose CLIs', async () => {
const { failures, warnings } = await lint(FIXTURE);
const failureSummary = failures
.map((f) => `[${f.cli}] tier${f.tier} "${f.word}" × ${f.count}`)
.join('\n ');
assert.equal(
failures.length,
0,
`SC-3 violations found:\n ${failureSummary}\n` +
`(${warnings.length} tier-2 warnings — informational only)`,
);
});
});