161 lines
6.6 KiB
JavaScript
161 lines
6.6 KiB
JavaScript
// doc-consistency.test.mjs — Static asserts that prose documentation
|
||
// stays aligned with the v2 risk-scoring model in scanners/lib/severity.mjs.
|
||
//
|
||
// Background: v7.0.0 introduced the severity-dominated v2 risk-score model
|
||
// (BLOCK ≥65, WARNING ≥15) but several prose surfaces (commands/, agents/)
|
||
// continued to emit the v1 formula (`critical*25 + ...`, BLOCK ≥61,
|
||
// WARNING ≥21). v7.1.1 fixed two of them (agents/skill-scanner-agent.md,
|
||
// templates/unified-report.md). Batch B → v7.2.0 closes the trifecta:
|
||
// commands/scan.md, commands/audit.md, agents/mcp-scanner-agent.md.
|
||
//
|
||
// This test pins the closure. If any future edit re-introduces v1 formula
|
||
// tokens in commands/ or agents/, this test fails fast.
|
||
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||
import { join, dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const PLUGIN_ROOT = resolve(dirname(__filename), '..', '..');
|
||
|
||
// v1 formula tokens that must NOT appear in commands/ or agents/.
|
||
// These are the patterns the brief's verification step 4 grep checks.
|
||
const V1_TOKENS = [
|
||
/\bscore\s*[><]?=\s*61\b/, // verdict cutoff
|
||
/\bscore\s*[><]?=\s*21\b/, // verdict cutoff
|
||
/score\s*≥\s*61/, // unicode variant
|
||
/score\s*≥\s*21/, // unicode variant
|
||
/critical\s*\*\s*25/, // formula multiplier
|
||
/Critical\s*[×x]\s*25/, // formula multiplier (table form)
|
||
/min\(\s*100\s*,\s*critical\s*\*\s*25/i, // full v1 formula prefix
|
||
];
|
||
|
||
function* walkMarkdown(dir) {
|
||
for (const entry of readdirSync(dir)) {
|
||
const full = join(dir, entry);
|
||
const stat = statSync(full);
|
||
if (stat.isDirectory()) {
|
||
yield* walkMarkdown(full);
|
||
} else if (entry.endsWith('.md')) {
|
||
yield full;
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('doc-consistency — v1 risk-formula tokens are absent from prose', () => {
|
||
const COMMANDS_DIR = join(PLUGIN_ROOT, 'commands');
|
||
const AGENTS_DIR = join(PLUGIN_ROOT, 'agents');
|
||
|
||
for (const dir of [COMMANDS_DIR, AGENTS_DIR]) {
|
||
for (const file of walkMarkdown(dir)) {
|
||
const rel = file.replace(PLUGIN_ROOT + '/', '');
|
||
it(`${rel} contains no v1 formula tokens`, () => {
|
||
const content = readFileSync(file, 'utf-8');
|
||
for (const token of V1_TOKENS) {
|
||
assert.equal(
|
||
token.test(content),
|
||
false,
|
||
`${rel} still contains v1 formula token matching ${token}. ` +
|
||
`v7.2.0 unified all command/agent prose to v2 (BLOCK ≥65, WARNING ≥15). ` +
|
||
`If a new file legitimately needs to reference v1 (e.g. CHANGELOG history), ` +
|
||
`move that file out of commands/ or agents/.`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
describe('doc-consistency — v2 cutoffs are documented in unified prose', () => {
|
||
it('commands/scan.md mentions the v2 BLOCK cutoff (≥ 65)', () => {
|
||
const content = readFileSync(join(PLUGIN_ROOT, 'commands', 'scan.md'), 'utf-8');
|
||
assert.match(content, /score\s*[≥>=]+\s*65/);
|
||
});
|
||
|
||
it('commands/audit.md references riskScore() (v2 helper)', () => {
|
||
const content = readFileSync(join(PLUGIN_ROOT, 'commands', 'audit.md'), 'utf-8');
|
||
assert.match(content, /riskScore/);
|
||
});
|
||
|
||
it('agents/mcp-scanner-agent.md mentions the v2 BLOCK cutoff (≥ 65)', () => {
|
||
const content = readFileSync(join(PLUGIN_ROOT, 'agents', 'mcp-scanner-agent.md'), 'utf-8');
|
||
assert.match(content, /score\s*[≥>=]+\s*65/);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// D4 (Batch C, Wave D): Hooks count must stay synchronized across three
|
||
// surfaces — the CLAUDE.md `## Hooks (N)` header, the markdown table directly
|
||
// underneath that header, and the canonical hooks/hooks.json definition.
|
||
// Drift previously masked a missing `pre-compact-scan.mjs` row in CLAUDE.md.
|
||
// This block fails fast if any of the three surfaces drift.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('doc-consistency — Hooks count consistency (D4)', () => {
|
||
const CLAUDE_MD = join(PLUGIN_ROOT, 'CLAUDE.md');
|
||
const HOOKS_JSON = join(PLUGIN_ROOT, 'hooks', 'hooks.json');
|
||
|
||
function readHeaderNumber(text) {
|
||
const match = text.match(/^##\s+Hooks\s*\((\d+)\)\s*$/m);
|
||
if (!match) throw new Error('No `## Hooks (N)` header found in CLAUDE.md');
|
||
return parseInt(match[1], 10);
|
||
}
|
||
|
||
function readTableRowCount(text) {
|
||
// Section spans from `## Hooks (N)` to next `^## ` heading.
|
||
const startIdx = text.search(/^##\s+Hooks\s*\(\d+\)\s*$/m);
|
||
if (startIdx < 0) throw new Error('Hooks header not found');
|
||
const tail = text.slice(startIdx);
|
||
const nextHeader = tail.search(/\n##\s+\S/);
|
||
const section = nextHeader > 0 ? tail.slice(0, nextHeader) : tail;
|
||
// Count rows that look like `| \`<name>.mjs\` | ...`.
|
||
// Excludes the header row (which uses bare `Script` not a backtick).
|
||
const rows = section.match(/^\|\s*`[^`|]+\.mjs`\s*\|/gm) || [];
|
||
return rows.length;
|
||
}
|
||
|
||
function readJsonHookCount(jsonText) {
|
||
const parsed = JSON.parse(jsonText);
|
||
const seen = new Set();
|
||
for (const eventArr of Object.values(parsed.hooks || {})) {
|
||
for (const entry of eventArr) {
|
||
for (const h of entry.hooks || []) {
|
||
// Dedupe by command path — a hook registered to multiple events
|
||
// counts as one script.
|
||
if (h.command) seen.add(h.command);
|
||
}
|
||
}
|
||
}
|
||
return seen.size;
|
||
}
|
||
|
||
it('header count, table row count, and hooks.json count agree', () => {
|
||
const claudeText = readFileSync(CLAUDE_MD, 'utf-8');
|
||
const hooksJsonText = readFileSync(HOOKS_JSON, 'utf-8');
|
||
|
||
const headerNumber = readHeaderNumber(claudeText);
|
||
const tableRowCount = readTableRowCount(claudeText);
|
||
const jsonHookCount = readJsonHookCount(hooksJsonText);
|
||
|
||
assert.equal(
|
||
headerNumber,
|
||
jsonHookCount,
|
||
`CLAUDE.md '## Hooks (${headerNumber})' header disagrees with hooks/hooks.json (${jsonHookCount} hooks). ` +
|
||
`Update the header to match.`,
|
||
);
|
||
assert.equal(
|
||
tableRowCount,
|
||
jsonHookCount,
|
||
`CLAUDE.md hooks table has ${tableRowCount} rows but hooks/hooks.json defines ${jsonHookCount} hooks. ` +
|
||
`Add/remove rows in the table to match.`,
|
||
);
|
||
assert.equal(
|
||
headerNumber,
|
||
tableRowCount,
|
||
`CLAUDE.md header (${headerNumber}) and table row count (${tableRowCount}) disagree. ` +
|
||
`These two surfaces must stay in sync.`,
|
||
);
|
||
});
|
||
});
|