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
321 lines
13 KiB
JavaScript
321 lines
13 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/rules-validator.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
describe('RUL 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('has scanner prefix RUL', () => {
|
|
assert.strictEqual(result.scanner, 'RUL');
|
|
});
|
|
|
|
it('finds no high severity issues', () => {
|
|
const high = result.findings.filter(f => f.severity === 'high' || f.severity === 'critical');
|
|
assert.strictEqual(high.length, 0, `Found: ${high.map(f => f.title + ': ' + f.description).join('\n')}`);
|
|
});
|
|
|
|
it('all finding IDs match CA-RUL-NNN', () => {
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-RUL-\d{3}$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RUL scanner — broken project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
|
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
|
});
|
|
|
|
it('flags a "globs" rule and steers to the documented "paths" field', () => {
|
|
const f = result.findings.find(x => /globs/i.test(x.title));
|
|
assert.ok(f, 'Should flag globs: usage');
|
|
// Verifiseringsplikt: only `paths` is documented; whether CC ever read
|
|
// `globs` is unverified, so the wording must NOT claim it is
|
|
// deprecated/legacy Claude Code syntax.
|
|
assert.ok(
|
|
!/deprecated|legacy/i.test(`${f.title} ${f.description} ${f.recommendation}`),
|
|
'wording must not assert globs is deprecated/legacy CC syntax (unverified)',
|
|
);
|
|
assert.match(
|
|
`${f.description} ${f.recommendation}`,
|
|
/paths/,
|
|
'should steer to the documented paths: field',
|
|
);
|
|
});
|
|
|
|
it('detects dead rule (matches no files)', () => {
|
|
const found = result.findings.some(f => f.title.includes('matches no files'));
|
|
assert.ok(found, 'Should detect dead glob pattern');
|
|
});
|
|
|
|
it('detects large unscoped rule', () => {
|
|
const found = result.findings.some(f => f.title.includes('unscoped'));
|
|
assert.ok(found, 'Should detect big rule without paths: frontmatter');
|
|
});
|
|
|
|
it('marks dead rule as high severity', () => {
|
|
const f = result.findings.find(f => f.title.includes('matches no files'));
|
|
assert.strictEqual(f?.severity, 'high');
|
|
});
|
|
});
|
|
|
|
describe('RUL scanner — empty project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
|
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
|
});
|
|
|
|
it('returns skipped when no rule files', () => {
|
|
assert.strictEqual(result.status, 'skipped');
|
|
});
|
|
|
|
it('has 0 findings', () => {
|
|
assert.strictEqual(result.findings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('RUL — large path-scoped rule lost after compaction (A)', () => {
|
|
// Path-scoped rules are NOT re-injected after a context compaction (V2,
|
|
// context-window.md) — a large one carrying must-hold rules silently drops.
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
async function writeProject(root, ruleBodyLines) {
|
|
await mkdir(join(root, '.claude', 'rules'), { recursive: true });
|
|
await mkdir(join(root, 'src'), { recursive: true });
|
|
// A matching file so the path glob is not flagged as "matches no files".
|
|
await writeFile(join(root, 'src', 'foo.ts'), 'export {};\n', 'utf8');
|
|
const body = Array.from({ length: ruleBodyLines }, (_, i) => `- rule ${i + 1}`).join('\n');
|
|
// Inline paths form: config-audit's lightweight frontmatter parser reads
|
|
// `paths: <inline>` (comma-normalized), not YAML block sequences. The RUL
|
|
// scoped-detection (existing + this check) keys on that parsed value.
|
|
await writeFile(
|
|
join(root, '.claude', 'rules', 'scoped.md'),
|
|
`---\npaths: "src/**/*.ts"\n---\n\n# Scoped rule\n${body}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-durability-'));
|
|
await writeProject(tmpRoot, 60);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('flags a large path-scoped rule as low (compaction durability)', () => {
|
|
const f = result.findings.find(x => x.scanner === 'RUL' && /compaction/i.test(x.title || ''));
|
|
assert.ok(f, `expected durability finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low');
|
|
});
|
|
|
|
it('does NOT flag a small path-scoped rule', async () => {
|
|
const small = await mkdtemp(join(tmpdir(), 'ca-rul-small-'));
|
|
try {
|
|
await writeProject(small, 3);
|
|
const d = await discoverConfigFiles(small);
|
|
const r = await scan(small, d);
|
|
const f = r.findings.find(x => x.scanner === 'RUL' && /compaction/i.test(x.title || ''));
|
|
assert.equal(f, undefined, 'a small scoped rule should not be flagged');
|
|
} finally {
|
|
await rm(small, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RUL — block-sequence-scoped rule is correctly scoped (parser regression)', () => {
|
|
// A rule scoped with a YAML block sequence:
|
|
// paths:
|
|
// - src/**/*.ts
|
|
// was previously misread as unscoped (the lightweight parser only read inline
|
|
// `paths:`), so a large one wrongly fired the "unscoped" finding AND silently
|
|
// skipped the compaction-durability finding. The parser block-sequence fix
|
|
// flows through unchanged rules-validator code.
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
async function writeBlockSeqProject(root, ruleBodyLines) {
|
|
await mkdir(join(root, '.claude', 'rules'), { recursive: true });
|
|
await mkdir(join(root, 'src'), { recursive: true });
|
|
await writeFile(join(root, 'src', 'foo.ts'), 'export {};\n', 'utf8');
|
|
const body = Array.from({ length: ruleBodyLines }, (_, i) => `- rule ${i + 1}`).join('\n');
|
|
// YAML block-sequence paths: form (the regression target).
|
|
await writeFile(
|
|
join(root, '.claude', 'rules', 'scoped-seq.md'),
|
|
`---\npaths:\n - "src/**/*.ts"\n---\n\n# Scoped rule\n${body}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-blockseq-'));
|
|
await writeBlockSeqProject(tmpRoot, 60);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does NOT misflag a block-sequence-scoped rule as unscoped', () => {
|
|
const f = result.findings.find(x => x.scanner === 'RUL' && /unscoped/i.test(x.title || ''));
|
|
assert.equal(f, undefined,
|
|
`block-sequence-scoped rule wrongly flagged unscoped; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
});
|
|
|
|
it('DOES flag the block-sequence-scoped large rule as lost after compaction (low)', () => {
|
|
const f = result.findings.find(x => x.scanner === 'RUL' && /compaction/i.test(x.title || ''));
|
|
assert.ok(f, `expected durability finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low');
|
|
});
|
|
});
|
|
|
|
describe('RUL — nested-repo rule glob resolves to its own project root (M-BUG-9)', () => {
|
|
// A rule living in a NESTED repo's .claude/rules/ scopes its paths: pattern
|
|
// relative to that nested repo's root — NOT relative to the outer scan root.
|
|
// Before the fix, countGlobMatches globbed against the scan root and
|
|
// collectProjectFiles' depth>4 cutoff never reached the deep matching files,
|
|
// so a live rule was wrongly flagged "matches no files / never activates".
|
|
// (Real-machine surface: ~/.claude/plugins/marketplaces/ktg-privat/.claude/rules/.)
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
async function writeNestedRepo(scanRoot) {
|
|
// Nested repo sits 3 dirs below the scan root → its matching files land
|
|
// past the old depth>4 cutoff when walked from the scan root.
|
|
const nested = join(scanRoot, 'level1', 'level2', 'nested-repo');
|
|
await mkdir(join(nested, '.claude', 'rules'), { recursive: true });
|
|
await mkdir(join(nested, 'plugins', 'app'), { recursive: true });
|
|
await mkdir(join(nested, 'plugins', 'app', 'hooks'), { recursive: true });
|
|
// Files that the rule patterns match — relative to the NESTED repo root.
|
|
await writeFile(join(nested, 'plugins', 'app', 'CLAUDE.md'), '# App\n', 'utf8');
|
|
await writeFile(join(nested, 'plugins', 'app', 'hooks', 'hooks.json'), '{}\n', 'utf8');
|
|
// Two rules mirroring the real ktg-privat ones.
|
|
await writeFile(
|
|
join(nested, '.claude', 'rules', 'plugin-convention.md'),
|
|
'---\npaths: "plugins/*/CLAUDE.md"\n---\n\n# Convention\nbody\n',
|
|
'utf8',
|
|
);
|
|
await writeFile(
|
|
join(nested, '.claude', 'rules', 'hook-format.md'),
|
|
'---\npaths: "**/hooks/hooks.json"\n---\n\n# Hook format\nbody\n',
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-nested-'));
|
|
await writeNestedRepo(tmpRoot);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does NOT flag the nested rules as "matches no files"', () => {
|
|
const dead = result.findings.filter(f => f.title.includes('matches no files'));
|
|
assert.equal(
|
|
dead.length,
|
|
0,
|
|
`nested-repo rules matched real files but were flagged dead: ${dead.map(f => f.evidence).join(' | ')}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('RUL — user-global rule is not flagged "matches no files" (M-BUG-9 guard)', () => {
|
|
// A user-global ~/.claude/rules/ rule scopes against whatever project is
|
|
// active at runtime, not a fixed tree — so "matches 0 files here" is not a
|
|
// meaningful dead-rule signal and must not be flagged (and must not trigger a
|
|
// HOME-wide file walk).
|
|
let tmpHome;
|
|
let savedHome;
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
tmpHome = await mkdtemp(join(tmpdir(), 'ca-rul-home-'));
|
|
savedHome = process.env.HOME;
|
|
process.env.HOME = tmpHome;
|
|
await mkdir(join(tmpHome, '.claude', 'rules'), { recursive: true });
|
|
await writeFile(
|
|
join(tmpHome, '.claude', 'rules', 'global.md'),
|
|
'---\npaths: "src/**/*.rs"\n---\n\n# Global rust rule\nbody\n',
|
|
'utf8',
|
|
);
|
|
// Scan from HOME so the rule is discovered AND its derived project root === HOME.
|
|
const discovery = await discoverConfigFiles(tmpHome);
|
|
result = await scan(tmpHome, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
process.env.HOME = savedHome;
|
|
if (tmpHome) await rm(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does NOT flag a user-global rule as "matches no files"', () => {
|
|
const dead = result.findings.filter(f => f.title.includes('matches no files'));
|
|
assert.equal(dead.length, 0, `user-global rule wrongly flagged dead: ${dead.map(f => f.evidence).join(' | ')}`);
|
|
});
|
|
});
|
|
|
|
describe('RUL — mid-pattern /**/ glob matches intermediate dirs (M-BUG-19)', () => {
|
|
// globToRegex restored the {{GLOBSTAR_SLASH}} placeholder to "(?:/.+/|/)"
|
|
// BEFORE the ? → [^/] replacement ran, corrupting the group opener "(?:"
|
|
// into "([^/]:" — so every pattern containing a mid-pattern "/**/" silently
|
|
// matched nothing but the zero-dir "|/" branch, and live rules like
|
|
// "posts/**/post.md" were flagged "matches no files".
|
|
let tmpRoot;
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-globstar-'));
|
|
await mkdir(join(tmpRoot, '.claude', 'rules'), { recursive: true });
|
|
await mkdir(join(tmpRoot, 'posts', '2026-01-23-slug'), { recursive: true });
|
|
await writeFile(join(tmpRoot, 'posts', '2026-01-23-slug', 'post.md'), '# Post\n', 'utf8');
|
|
await writeFile(
|
|
join(tmpRoot, '.claude', 'rules', 'post-scope.md'),
|
|
'---\npaths: "posts/**/post.md"\n---\n\n# Post rule\nbody\n',
|
|
'utf8',
|
|
);
|
|
const discovery = await discoverConfigFiles(tmpRoot);
|
|
result = await scan(tmpRoot, discovery);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does NOT flag a live posts/**/post.md rule as "matches no files"', () => {
|
|
const dead = result.findings.filter(f => f.title.includes('matches no files'));
|
|
assert.equal(
|
|
dead.length,
|
|
0,
|
|
`/**/ rule matched a real file but was flagged dead: ${dead.map(f => f.evidence).join(' | ')}`,
|
|
);
|
|
});
|
|
});
|