ktg-plugin-marketplace/plugins/config-audit/tests/lint-default-output.mjs
Kjell Tore Guttormsen c5c937e94e 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>
2026-05-01 18:11:15 +02:00

187 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});
}