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