BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.
Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.
scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.
IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.
unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.
Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.
Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.
Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).
Suite 1535 -> 1573, 0 failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
import { scan } from '../../scanners/claude-md-linter.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
describe('CML scanner — healthy project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
|
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.strictEqual(result.status, 'ok');
|
|
});
|
|
|
|
it('scans at least 1 file', () => {
|
|
assert.ok(result.files_scanned >= 1);
|
|
});
|
|
|
|
it('has scanner prefix CML', () => {
|
|
assert.strictEqual(result.scanner, 'CML');
|
|
});
|
|
|
|
it('has all severity count keys', () => {
|
|
for (const key of ['critical', 'high', 'medium', 'low', 'info']) {
|
|
assert.ok(key in result.counts, `Missing count key: ${key}`);
|
|
}
|
|
});
|
|
|
|
it('finds no critical or high issues in healthy project', () => {
|
|
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
|
|
assert.strictEqual(serious.length, 0, `Found serious issues: ${serious.map(f => f.title).join(', ')}`);
|
|
});
|
|
|
|
it('all finding IDs match CA-CML-NNN pattern', () => {
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-CML-\d{3}$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — broken project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
|
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
|
});
|
|
|
|
it('detects long CLAUDE.md (>200 lines)', () => {
|
|
const found = result.findings.some(f => f.title.includes('exceeds'));
|
|
assert.ok(found, 'Should detect oversized CLAUDE.md');
|
|
});
|
|
|
|
it('detects missing headings', () => {
|
|
const found = result.findings.some(f => f.title.includes('no markdown headings'));
|
|
assert.ok(found, 'Should detect lack of headings');
|
|
});
|
|
|
|
it('detects TODO markers', () => {
|
|
const found = result.findings.some(f => f.title.includes('TODO'));
|
|
assert.ok(found, 'Should detect TODO markers');
|
|
});
|
|
|
|
it('detects repeated content', () => {
|
|
const found = result.findings.some(f => f.title.includes('Repeated content'));
|
|
assert.ok(found, 'Should detect repeated lines');
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — broken project: 200-tier stays MEDIUM (regression lock)', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
|
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
|
});
|
|
|
|
it('the >200 length finding is MEDIUM', () => {
|
|
const f = result.findings.find(x => /exceeds recommended 200/.test(x.title || ''));
|
|
assert.ok(f, 'expected the 200-line recommendation finding');
|
|
assert.strictEqual(f.severity, 'medium');
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — large cascade (>500 lines): reframed, not absolute-adherence HIGH', () => {
|
|
// large-cascade/CLAUDE.md is 1024 lines. CC 2.1.169 scales the "too long"
|
|
// threshold by context window, and the plugin's own
|
|
// configuration-best-practices.md:97 footnote says raw line count is a
|
|
// Sonnet-era heuristic superseded by cache-prefix stability. So the absolute
|
|
// HIGH@500 + "significantly reduce adherence" claim is now-wrong.
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'large-cascade'));
|
|
result = await scan(resolve(FIXTURES, 'large-cascade'), discovery);
|
|
});
|
|
|
|
it('flags the >500 finding as MEDIUM, not HIGH', () => {
|
|
const f = result.findings.find(x => /exceeds 500/.test(x.title || ''));
|
|
assert.ok(f, 'expected the >500 length finding');
|
|
assert.strictEqual(f.severity, 'medium');
|
|
});
|
|
|
|
it('drops the absolute "significantly reduce adherence" claim', () => {
|
|
const f = result.findings.find(x => /exceeds 500/.test(x.title || ''));
|
|
assert.doesNotMatch(String(f?.description || ''), /significantly reduce/i);
|
|
});
|
|
|
|
it('reframes toward token cost / context window / cache-prefix', () => {
|
|
const f = result.findings.find(x => /exceeds 500/.test(x.title || ''));
|
|
assert.match(
|
|
`${f?.description || ''} ${f?.recommendation || ''}`,
|
|
/every turn|context window|cache/i,
|
|
);
|
|
});
|
|
|
|
it('produces no HIGH-severity length finding', () => {
|
|
const highLen = result.findings.filter(f => f.severity === 'high' && /exceeds/.test(f.title || ''));
|
|
assert.strictEqual(highLen.length, 0, `unexpected HIGH length finding: ${highLen.map(f => f.title).join(', ')}`);
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — char budget mirrors CC startup warning (CC 2.1.169)', () => {
|
|
// large-claude-chars/CLAUDE.md is 48,531 chars across 100 lines: it crosses
|
|
// Claude Code's ~40.0k-char startup-warning threshold while staying under the
|
|
// 200-line count, so it isolates the char-budget check from the line check.
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'large-claude-chars'));
|
|
result = await scan(resolve(FIXTURES, 'large-claude-chars'), discovery);
|
|
});
|
|
|
|
const charFinding = (r) =>
|
|
r.findings.find((f) => /performance-warning threshold/i.test(f.title || ''));
|
|
|
|
it('flags a CLAUDE.md over ~40k chars', () => {
|
|
assert.ok(charFinding(result), 'expected a char-budget finding for a >40k-char CLAUDE.md');
|
|
});
|
|
|
|
it('the char-budget finding is MEDIUM (token cost, not an adherence cliff)', () => {
|
|
assert.strictEqual(charFinding(result)?.severity, 'medium');
|
|
});
|
|
|
|
it('anchors on CC\'s 40.0k figure and discloses context-window scaling', () => {
|
|
const f = charFinding(result);
|
|
const text = `${f?.description || ''} ${f?.evidence || ''}`;
|
|
assert.match(text, /40\.0k/, 'should mirror CC\'s 40.0k startup-warning figure');
|
|
assert.match(text, /context window|scales|1,000,000/i, 'should disclose context-window scaling');
|
|
// Lock the scaling arithmetic: 40.0k anchor x (1M / 200k) = 200,000 chars @ 1M.
|
|
assert.match(text, /200,000/, 'should disclose the ~200,000-char relaxed threshold at 1M context');
|
|
});
|
|
|
|
it('does NOT fire the line-count findings (chars high, lines under 200)', () => {
|
|
const lineFinding = result.findings.find((f) => /exceeds (recommended 200|500)/.test(f.title || ''));
|
|
assert.ok(!lineFinding, `char fixture should not trip a line finding: ${lineFinding?.title || ''}`);
|
|
});
|
|
|
|
it('the char-budget finding ID matches CA-CML-NNN', () => {
|
|
assert.match(charFinding(result)?.id || '', /^CA-CML-\d{3}$/);
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — context-window calibration (B8)', () => {
|
|
const FIXTURE = resolve(FIXTURES, 'large-claude-chars'); // 48,531 chars
|
|
const charFinding = (r) =>
|
|
r.findings.find((f) => /performance-warning threshold/i.test(f.title || ''));
|
|
|
|
async function scanWithCtx(contextWindow) {
|
|
const discovery = await discoverConfigFiles(FIXTURE);
|
|
return scan(FIXTURE, discovery, { contextWindow });
|
|
}
|
|
|
|
it('--context-window 1000000 relaxes the 40k char threshold so a 48k-char file does NOT fire', async () => {
|
|
const at1m = await scanWithCtx({ window: 1_000_000, advisory: false });
|
|
assert.equal(charFinding(at1m), undefined,
|
|
'48,531 chars is under the ~200,000-char threshold at a 1M window');
|
|
});
|
|
|
|
it('an unknown (advisory) window keeps the 40k anchor but downgrades to info', async () => {
|
|
const advisory = await scanWithCtx({ window: 200_000, advisory: true });
|
|
const f = charFinding(advisory);
|
|
assert.ok(f, 'still surfaces the measurement at the conservative anchor');
|
|
assert.equal(f.severity, 'info', 'advisory downgrades it from medium to info');
|
|
});
|
|
|
|
it('no opts (default) is unchanged: fires medium at the 40k anchor', async () => {
|
|
const discovery = await discoverConfigFiles(FIXTURE);
|
|
const result = await scan(FIXTURE, discovery);
|
|
assert.equal(charFinding(result)?.severity, 'medium', 'default must stay byte-stable: medium');
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — large-by-lines but under the char budget (no false char finding)', () => {
|
|
// large-cascade/CLAUDE.md is 1024 lines but only 37,393 chars (short lines):
|
|
// under CC's 40.0k char threshold, so the char-budget finding must NOT fire —
|
|
// proving the check keys on chars, not raw size or line count.
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'large-cascade'));
|
|
result = await scan(resolve(FIXTURES, 'large-cascade'), discovery);
|
|
});
|
|
|
|
it('does not emit a char-budget finding under 40k chars', () => {
|
|
const f = result.findings.find((x) => /performance-warning threshold/i.test(x.title || ''));
|
|
assert.ok(!f, 'a 37k-char file (under 40.0k) must not trip the char-budget finding');
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — empty project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
|
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
|
});
|
|
|
|
it('detects missing CLAUDE.md', () => {
|
|
const found = result.findings.some(f => f.title.includes('No CLAUDE.md'));
|
|
assert.ok(found, 'Should report missing CLAUDE.md');
|
|
});
|
|
|
|
it('returns high severity for missing CLAUDE.md', () => {
|
|
const f = result.findings.find(f => f.title.includes('No CLAUDE.md'));
|
|
assert.strictEqual(f?.severity, 'high');
|
|
});
|
|
});
|
|
|
|
describe('CML scanner — minimal project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
|
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
|
});
|
|
|
|
it('detects nearly empty CLAUDE.md', () => {
|
|
const found = result.findings.some(f => f.title.includes('nearly empty'));
|
|
assert.ok(found, 'Should detect nearly empty CLAUDE.md');
|
|
});
|
|
});
|
|
|
|
describe('CML — nested CLAUDE.md not re-injected after compaction (A)', () => {
|
|
// Only the project-root CLAUDE.md is re-injected after a compaction; a nested
|
|
// (subdirectory) CLAUDE.md is lost until a file in that dir is read again
|
|
// (V3, context-window.md). Hermetic temp fixture.
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-cml-nested-'));
|
|
await mkdir(join(tmpRoot, 'src'), { recursive: true });
|
|
await writeFile(join(tmpRoot, 'CLAUDE.md'), '# Root\n\nProject overview goes here.\n', 'utf8');
|
|
await writeFile(
|
|
join(tmpRoot, 'src', 'CLAUDE.md'),
|
|
'# Src rules\n\n' + Array.from({ length: 10 }, (_, i) => `- nested rule ${i + 1}`).join('\n') + '\n',
|
|
'utf8',
|
|
);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('flags the nested CLAUDE.md as low (compaction durability)', () => {
|
|
const f = result.findings.find(x =>
|
|
x.scanner === 'CML' && /compaction/i.test(x.title || '') &&
|
|
/src/.test(`${x.file || ''}${x.evidence || ''}${x.description || ''}`));
|
|
assert.ok(f, `expected nested durability finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low');
|
|
});
|
|
|
|
it('does NOT flag the project-root CLAUDE.md', () => {
|
|
const rootFinding = result.findings.find(x =>
|
|
x.scanner === 'CML' && /compaction/i.test(x.title || '') && !/src/.test(x.file || ''));
|
|
assert.equal(rootFinding, undefined, 'project-root CLAUDE.md must not get the compaction finding');
|
|
});
|
|
});
|