`import-resolver` follows @import targets; a path written in ordinary prose was checked by nothing. CA-CML-013 resolves those too — one finding per file, severity low, against both the CLAUDE.md's own directory and the scan root, because a nested file may legitimately write repo-root-relative paths. The design work here is the SILENCE list, and every entry on it was measured against 407 real CLAUDE.md files rather than argued for: - Bare filenames excluded: admitting them tripled the output (2350 vs 810), led by name-drops of tools that exist elsewhere on the machine. - Org/repo slugs, npm packages, pytest node ids and prose enumerations excluded: 111 fires, inspected, all false positives. - Bare folder names excluded on the same reasoning one level up: 183 of the remaining 699 fires (26%), led by `open/` — a Forgejo remote namespace prefix, not a directory. This one overturned a premise the fasit had asserted without measuring; the deviation is recorded rather than the prediction quietly edited. - Containment is checked against the scan root, not the file's own dir: a base a `..` chain can escape is not a base. Measured — without it, `../../../../etc/passwd` resolved to the real file and silenced its own finding, while a legitimate `../docs/x.md` still resolves. Rule ORDER is the reported reason (first match wins), so `npm test` is silenced as a command rather than as a bare token, and two silences with different causes keep their own fixtures. Twelve classes, pinned by name. Both load-bearing rules were seen RED against their own defect: deleting containment fails 1 test, deleting the slug rule fails 6. Dogfooded through the argv the command template itself constructs, which found a true positive in our own CLAUDE.md — `lib/humanizer.mjs` where the file is `scanners/lib/humanizer.mjs`. Fixed here. Suite 1662 -> 1701, 0 failing. Frozen v5.0.0 and default-output baselines: 0 changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJbfM3N8zWQ1wA2voTrZxz
116 lines
4.7 KiB
JavaScript
116 lines
4.7 KiB
JavaScript
/**
|
|
* Registry invariants for the finding-code scheme (M-BUG-28).
|
|
*
|
|
* These are blanket assertions over the whole registry, never a relation between
|
|
* two chosen entries: a per-entry check goes green on a partial conversion, which
|
|
* is the failure mode #51/#57/#58 kept reproducing.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { FINDING_CODES, RETIRED_CODES, codeNumber, findingId, allFindingIds } from '../../scanners/lib/finding-codes.mjs';
|
|
import { GAP_CHECKS, LEVERS } from '../../scanners/feature-gap-scanner.mjs';
|
|
|
|
describe('finding-code registry', () => {
|
|
it('gives every check a distinct number within its scanner', () => {
|
|
const collisions = [];
|
|
for (const [scanner, table] of Object.entries(FINDING_CODES)) {
|
|
const seen = new Map();
|
|
for (const [key, n] of Object.entries(table)) {
|
|
if (seen.has(n)) collisions.push(`${scanner}: ${seen.get(n)} and ${key} both claim ${n}`);
|
|
seen.set(n, key);
|
|
}
|
|
}
|
|
assert.deepEqual(collisions, []);
|
|
});
|
|
|
|
it('uses positive integers only', () => {
|
|
const bad = [];
|
|
for (const [scanner, table] of Object.entries(FINDING_CODES)) {
|
|
for (const [key, n] of Object.entries(table)) {
|
|
if (!Number.isInteger(n) || n < 1) bad.push(`${scanner}.${key} = ${n}`);
|
|
}
|
|
}
|
|
assert.deepEqual(bad, []);
|
|
});
|
|
|
|
it('never lets a retired key stay active', () => {
|
|
const resurrected = [];
|
|
for (const [scanner, keys] of Object.entries(RETIRED_CODES)) {
|
|
for (const key of keys) {
|
|
if (FINDING_CODES[scanner] && key in FINDING_CODES[scanner]) {
|
|
resurrected.push(`${scanner}.${key}`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(resurrected, []);
|
|
});
|
|
|
|
it('never reissues a retired number', () => {
|
|
// The whole point of the tombstone: D1 retired GAP t3_8 and shifted three
|
|
// live IDs down by one. Reusing a retired number would repeat that silently.
|
|
assert.ok(RETIRED_CODES.GAP.includes('t3_8'), 'D1 tombstone missing');
|
|
});
|
|
|
|
it('declares exactly the GAP dimensions the scanner ships, plus its levers', () => {
|
|
const declared = new Set(Object.keys(FINDING_CODES.GAP));
|
|
const shipped = GAP_CHECKS.map((g) => g.id);
|
|
|
|
const missing = shipped.filter((id) => !declared.has(id));
|
|
assert.deepEqual(missing, [], 'a GAP dimension has no declared code');
|
|
|
|
// Derived from the scanner, not listed again here: a hand-written copy of
|
|
// this list is the drift class the registry exists to prevent.
|
|
const levers = Object.values(LEVERS).map((l) => l.code);
|
|
const orphans = [...declared].filter((k) => !shipped.includes(k) && !levers.includes(k));
|
|
assert.deepEqual(orphans, [], 'a declared GAP code matches no shipped dimension');
|
|
});
|
|
|
|
it('throws on an undeclared code instead of inventing an ID', () => {
|
|
assert.throws(() => codeNumber('GAP', 'nope'), /undeclared check/);
|
|
assert.throws(() => codeNumber('NOSUCH', 't1_1'), /unknown scanner/);
|
|
assert.throws(() => codeNumber('GAP', undefined), /missing "code"/);
|
|
});
|
|
|
|
it('names retirement explicitly when a retired key is used', () => {
|
|
assert.throws(() => codeNumber('GAP', 't3_8'), /RETIRED/);
|
|
});
|
|
|
|
it('renders the published ID format', () => {
|
|
assert.equal(findingId('GAP', 't1_1'), 'CA-GAP-001');
|
|
assert.equal(findingId('PLH', 'skills-array-entry'), 'CA-PLH-016');
|
|
assert.ok(allFindingIds().has('CA-SKL-003'));
|
|
});
|
|
});
|
|
|
|
describe('published finding IDs (pinned exhaustively — README is a contract)', () => {
|
|
// Every number that shipped in README, CLAUDE.md or command copy before the
|
|
// registry existed. Spot-checking these would let a renumber through.
|
|
const PUBLISHED = [
|
|
['SKL', 'description-over-cap', 'CA-SKL-001'],
|
|
['SKL', 'aggregate-listing-budget', 'CA-SKL-002'],
|
|
['SKL', 'oversized-body', 'CA-SKL-003'],
|
|
['OST', 'strips-coding-instructions', 'CA-OST-001'],
|
|
['OST', 'plugin-forces-style', 'CA-OST-002'],
|
|
['OST', 'style-not-found', 'CA-OST-003'],
|
|
['TOK', 'volatile-top', 'CA-TOK-001'],
|
|
['TOK', 'redundant-permissions', 'CA-TOK-002'],
|
|
['TOK', 'deep-import-chain', 'CA-TOK-003'],
|
|
['TOK', 'mcp-schema-budget', 'CA-TOK-005'],
|
|
['TOK', 'mcp-schema-deferral', 'CA-TOK-006'],
|
|
['PLH', 'plugin-json-shadows-default', 'CA-PLH-015'],
|
|
['PLH', 'skills-array-entry', 'CA-PLH-016'],
|
|
['OPT', 'procedure-should-be-skill', 'CA-OPT-001'],
|
|
['AGT', 'description-bloat', 'CA-AGT-001'],
|
|
['AGT', 'aggregate-listing-budget', 'CA-AGT-002'],
|
|
['CPS', 'volatile-in-prefix', 'CA-CPS-001'],
|
|
['COL', 'skill-user-vs-plugin', 'CA-COL-001'],
|
|
['CML', 'dead-prose-reference', 'CA-CML-013'],
|
|
];
|
|
|
|
for (const [scanner, key, expected] of PUBLISHED) {
|
|
it(`${expected} still names ${key}`, () => {
|
|
assert.equal(findingId(scanner, key), expected);
|
|
});
|
|
}
|
|
});
|