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>
187 lines
6.1 KiB
JavaScript
187 lines
6.1 KiB
JavaScript
#!/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);
|
||
});
|
||
}
|