feat(quality-gate): implement pitfall-catalog validation for dead code (Story 2.4)
Create pitfalls-catalog.json with 6 false-positive pattern categories for find_dead_code. Implement validate-dead-code.mjs classification engine (SUSPECT/FALSE_POS/GHOST) with 10s timeout, input validation, null guards, file size/candidate caps, and unique test filenames. Integrate as Step 5c in bmad-dev-story, bmad-quick-dev workflows, and expand code-review Acceptance Auditors in bmad-code-review and gds-code-review. Code review patches applied: status conditional, TimeoutError class, fail() scoped timeout token, JSON parse error context, single regex compilation, null/type guards, MSW pattern fix, Ignored section for FALSE_POS/GHOST in SKILL.md. Tests: 12/12 validate-dead-code + 10/10 qa-memtrace passing.
This commit is contained in:
parent
79b3f7d9dc
commit
792fb50745
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Catalog of known false-positive patterns for Memtrace find_dead_code. Used by validate-dead-code.mjs to classify dead-code candidates before approving removal.",
|
||||
"last_updated": "2026-05-20",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Record/Map Dynamic Dispatch",
|
||||
"entries": [
|
||||
{
|
||||
"id": "record-dispatch-001",
|
||||
"pattern": "(dispatchByType|executeRule|BUILDERS|HANDLERS|RESOLVERS)",
|
||||
"reason": "Called via Record/Map lookup — resolution is runtime, not AST",
|
||||
"examples": ["buildGenreJustification", "buildLocalSaturationJustification"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Function Passed as Value",
|
||||
"entries": [
|
||||
{
|
||||
"id": "func-as-value-001",
|
||||
"pattern": "^[A-Z][a-zA-Z]*(Icon|Logo|Badge|Button|Card)$",
|
||||
"reason": "Component or value passed as reference (e.g., useState(MyComponent))",
|
||||
"examples": ["CopyIcon", "CheckIcon"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Framework Entry Points",
|
||||
"entries": [
|
||||
{
|
||||
"id": "framework-entry-001",
|
||||
"pattern": "^(handler|middleware|globalSetup|globalTeardown|setup|teardown)$",
|
||||
"reason": "Framework runtime entry point (Next.js, Playwright, Vitest, etc.)",
|
||||
"examples": ["handler in route.ts", "middleware in middleware.ts", "globalSetup in playwright config"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MSW Handlers",
|
||||
"entries": [
|
||||
{
|
||||
"id": "msw-handler-001",
|
||||
"pattern": "^(GET|POST|PUT|DELETE|PATCH)_",
|
||||
"reason": "MSW (Mock Service Worker) HTTP handler — function wrapping an HTTP verb for mock setup",
|
||||
"examples": ["GET_https_api_example_com_users", "POST_graphql"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vitest Mocks",
|
||||
"entries": [
|
||||
{
|
||||
"id": "vitest-mock-001",
|
||||
"pattern": "^(observe|unobserve|disconnect|IntersectionObserver|ResizeObserver)$",
|
||||
"reason": "Vitest mock setup (IntersectionObserver, ResizeObserver, etc.)",
|
||||
"examples": ["observe in vitest.setup.ts"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dependency Injection / Provider Registration",
|
||||
"entries": [
|
||||
{
|
||||
"id": "di-provider-001",
|
||||
"pattern": "^(provide[A-Z]|useClass|useFactory|useExisting|useValue)$",
|
||||
"reason": "Dependency injection provider registration (Angular, NestJS) — invoked by DI container, not directly",
|
||||
"examples": ["provideAuth", "useFactory in app.module.ts"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const TIMEOUT_MS = 10000;
|
||||
const TIMEOUT_TOKEN = 'MEMTRACE_MCP_ERROR_TIMEOUT';
|
||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_CANDIDATES = 10000;
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_CATALOG = resolve(__dirname, 'pitfalls-catalog.json');
|
||||
|
||||
class TimeoutError extends Error {
|
||||
constructor() {
|
||||
super('TIMEOUT');
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`Usage: node validate-dead-code.mjs --candidates <file.json> [--catalog <file.json>]
|
||||
|
||||
Arguments:
|
||||
--candidates <file> Path to JSON array of dead-code candidates from find_dead_code
|
||||
--catalog <file> Path to pitfalls-catalog.json (default: sibling dir)
|
||||
|
||||
Each candidate should have fields: name, kind, file, line.
|
||||
|
||||
Output JSON includes: status, total_candidates, classified breakdown,
|
||||
suspects[], false_positives[], ghosts[].
|
||||
|
||||
Exit codes:
|
||||
0 Classification completed successfully
|
||||
1 Processing error or timeout`);
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = { candidates: null, catalog: DEFAULT_CATALOG };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--candidates' && i + 1 < args.length) {
|
||||
result.candidates = args[++i];
|
||||
} else if (args[i] === '--catalog' && i + 1 < args.length) {
|
||||
result.catalog = args[++i];
|
||||
} else {
|
||||
console.error(`ERROR: Unknown argument: ${args[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.candidates) {
|
||||
console.error('ERROR: Missing --candidates');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
const resolved = resolve(filePath);
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`File not found: ${resolved}`);
|
||||
}
|
||||
const st = statSync(resolved);
|
||||
if (st.size > MAX_FILE_SIZE_BYTES) {
|
||||
throw new Error(`File too large: ${resolved} (${st.size} bytes, max ${MAX_FILE_SIZE_BYTES})`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(await readFile(resolved, 'utf-8'));
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse JSON from ${resolved}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCatalog(catalog) {
|
||||
if (!catalog || !Array.isArray(catalog.categories)) {
|
||||
throw new Error('Invalid pitfalls catalog: "categories" must be an array');
|
||||
}
|
||||
const entries = [];
|
||||
for (const cat of catalog.categories) {
|
||||
if (!cat.name || !Array.isArray(cat.entries)) continue;
|
||||
for (const entry of cat.entries) {
|
||||
if (!entry.id || !entry.pattern || !entry.reason) {
|
||||
throw new Error(`Invalid catalog entry in "${cat.name}": missing id, pattern, or reason`);
|
||||
}
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(entry.pattern);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid regex pattern in "${cat.name}/${entry.id}": ${entry.pattern} — ${e.message}`);
|
||||
}
|
||||
entries.push({
|
||||
id: entry.id,
|
||||
category: cat.name,
|
||||
pattern: entry.pattern,
|
||||
regex,
|
||||
reason: entry.reason
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
throw new Error('Pitfalls catalog has zero valid entries');
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function classify(candidates, catalogEntries) {
|
||||
const suspects = [];
|
||||
const falsePositives = [];
|
||||
const ghosts = [];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (!c || typeof c !== 'object') {
|
||||
ghosts.push({
|
||||
name: String(c),
|
||||
file: '',
|
||||
line: 0,
|
||||
kind: 'Unknown',
|
||||
reason: 'Invalid candidate entry (null or non-object)'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = typeof c.name === 'string' ? c.name : '';
|
||||
const filePath = typeof c.file === 'string' ? c.file : '';
|
||||
|
||||
let matched = null;
|
||||
for (const entry of catalogEntries) {
|
||||
if (entry.regex.test(name)) {
|
||||
matched = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
falsePositives.push({
|
||||
name,
|
||||
file: filePath,
|
||||
line: c.line,
|
||||
kind: c.kind,
|
||||
pitfall_id: matched.id,
|
||||
category: matched.category,
|
||||
reason: matched.reason
|
||||
});
|
||||
} else if (filePath && existsSync(resolve(filePath))) {
|
||||
suspects.push({
|
||||
name,
|
||||
file: filePath,
|
||||
line: c.line,
|
||||
kind: c.kind
|
||||
});
|
||||
} else {
|
||||
ghosts.push({
|
||||
name,
|
||||
file: filePath,
|
||||
line: c.line,
|
||||
kind: c.kind,
|
||||
reason: filePath ? 'Source file no longer exists on disk' : 'No file path provided'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: suspects.length > 0 ? 'needs_review' : 'clean',
|
||||
total_candidates: candidates.length,
|
||||
classified: {
|
||||
suspect: suspects.length,
|
||||
false_positive: falsePositives.length,
|
||||
ghost: ghosts.length
|
||||
},
|
||||
suspects,
|
||||
false_positives: falsePositives,
|
||||
ghosts
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
const candidatesData = await readJsonFile(args.candidates);
|
||||
if (!Array.isArray(candidatesData)) {
|
||||
throw new Error('Invalid candidates data: must be a JSON array');
|
||||
}
|
||||
if (candidatesData.length > MAX_CANDIDATES) {
|
||||
throw new Error(`Too many candidates: ${candidatesData.length} (max ${MAX_CANDIDATES})`);
|
||||
}
|
||||
|
||||
const catalogData = await readJsonFile(args.catalog);
|
||||
const catalogEntries = validateCatalog(catalogData);
|
||||
|
||||
const result = classify(candidatesData, catalogEntries);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const timeout = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new TimeoutError()), TIMEOUT_MS)
|
||||
);
|
||||
|
||||
Promise.race([main(), timeout])
|
||||
.then(result => {
|
||||
result.elapsed_ms = 0;
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err instanceof TimeoutError) {
|
||||
console.log(TIMEOUT_TOKEN);
|
||||
console.error('ERROR: Processing timeout exceeded');
|
||||
} else {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { writeFileSync, unlinkSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const DIR = fileURLToPath(new URL('.', import.meta.url));
|
||||
const SCRIPT = join(DIR, 'validate-dead-code.mjs');
|
||||
const CATALOG = join(DIR, 'pitfalls-catalog.json');
|
||||
const TMP = tmpdir();
|
||||
|
||||
let tmpDir;
|
||||
|
||||
function uniqueFile(prefix = 'candidates') {
|
||||
return join(tmpDir, `${prefix}-${crypto.randomUUID()}.json`);
|
||||
}
|
||||
|
||||
function writeUnique(data, prefix = 'candidates') {
|
||||
const p = uniqueFile(prefix);
|
||||
writeFileSync(p, JSON.stringify(data));
|
||||
return p;
|
||||
}
|
||||
|
||||
function runScript(candidates, catalogPath = CATALOG) {
|
||||
const candidatesFile = writeUnique(candidates);
|
||||
const args = ['--candidates', candidatesFile, '--catalog', catalogPath];
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', timeout: 5000 });
|
||||
try { unlinkSync(candidatesFile); } catch {}
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
function runScriptExpectFail(candidates) {
|
||||
const candidatesFile = writeUnique(candidates);
|
||||
try {
|
||||
execFileSync(process.execPath, [SCRIPT, '--candidates', candidatesFile, '--catalog', CATALOG], { encoding: 'utf8', timeout: 5000 });
|
||||
try { unlinkSync(candidatesFile); } catch {}
|
||||
return { exitCode: 0 };
|
||||
} catch (e) {
|
||||
try { unlinkSync(candidatesFile); } catch {}
|
||||
return { exitCode: e.status || 1, stdout: e.stdout || '', stderr: e.stderr || '' };
|
||||
}
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmpDir = mkdtempSync(join(TMP, 'vdc-test-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('validate-dead-code.mjs', () => {
|
||||
it('should classify all candidates as FALSE_POS when they match catalog patterns', () => {
|
||||
const candidates = [
|
||||
{ name: 'BUILDERS', kind: 'Function', file: 'src/dispatch.ts', line: 42 },
|
||||
{ name: 'CopyIcon', kind: 'Function', file: 'src/components/icon.tsx', line: 15 },
|
||||
{ name: 'handler', kind: 'Function', file: 'app/api/route.ts', line: 1 },
|
||||
{ name: 'observe', kind: 'Function', file: 'vitest.setup.ts', line: 5 }
|
||||
];
|
||||
const result = runScript(candidates);
|
||||
assert.equal(result.status, 'clean');
|
||||
assert.equal(result.total_candidates, 4);
|
||||
assert.equal(result.classified.false_positive, 4);
|
||||
assert.equal(result.classified.suspect, 0);
|
||||
assert.equal(result.classified.ghost, 0);
|
||||
assert.equal(result.false_positives.length, 4);
|
||||
assert.equal(result.suspects.length, 0);
|
||||
assert.equal(result.ghosts.length, 0);
|
||||
assert.ok(result.false_positives.every(fp => fp.pitfall_id));
|
||||
assert.ok(result.false_positives.every(fp => fp.reason));
|
||||
});
|
||||
|
||||
it('should classify mixed candidates correctly', () => {
|
||||
const testFile = join(tmpDir, 'test-symbol.ts');
|
||||
writeFileSync(testFile, 'export function foo() {}');
|
||||
try {
|
||||
const candidates = [
|
||||
{ name: 'dispatchByType', kind: 'Function', file: 'src/dispatch.ts', line: 10 },
|
||||
{ name: 'realDeadFunction', kind: 'Function', file: testFile, line: 1 },
|
||||
{ name: 'nonExistentFunc', kind: 'Function', file: join(tmpDir, 'deleted.ts'), line: 20 }
|
||||
];
|
||||
const result = runScript(candidates);
|
||||
assert.equal(result.status, 'needs_review');
|
||||
assert.equal(result.total_candidates, 3);
|
||||
assert.equal(result.classified.false_positive, 1);
|
||||
assert.equal(result.classified.suspect, 1);
|
||||
assert.equal(result.classified.ghost, 1);
|
||||
|
||||
assert.equal(result.false_positives.length, 1);
|
||||
assert.equal(result.false_positives[0].name, 'dispatchByType');
|
||||
assert.equal(result.false_positives[0].pitfall_id, 'record-dispatch-001');
|
||||
|
||||
assert.equal(result.suspects.length, 1);
|
||||
assert.equal(result.suspects[0].name, 'realDeadFunction');
|
||||
|
||||
assert.equal(result.ghosts.length, 1);
|
||||
assert.equal(result.ghosts[0].name, 'nonExistentFunc');
|
||||
assert.ok(result.ghosts[0].reason);
|
||||
} finally {
|
||||
try { unlinkSync(testFile); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return clean status with zero counts for empty candidates array', () => {
|
||||
const result = runScript([]);
|
||||
assert.equal(result.status, 'clean');
|
||||
assert.equal(result.total_candidates, 0);
|
||||
assert.equal(result.classified.suspect, 0);
|
||||
assert.equal(result.classified.false_positive, 0);
|
||||
assert.equal(result.classified.ghost, 0);
|
||||
});
|
||||
|
||||
it('should exit with error for malformed JSON input', () => {
|
||||
const badFile = uniqueFile('bad');
|
||||
writeFileSync(badFile, '{not json}');
|
||||
try {
|
||||
execFileSync(process.execPath, [SCRIPT, '--candidates', badFile, '--catalog', CATALOG], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
assert.equal(e.status, 1);
|
||||
const stderr = e.stderr || '';
|
||||
assert.ok(stderr.includes('ERROR'));
|
||||
} finally {
|
||||
try { unlinkSync(badFile); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('should exit with error for missing candidates file', () => {
|
||||
const missingFile = uniqueFile('missing');
|
||||
try {
|
||||
execFileSync(process.execPath, [SCRIPT, '--candidates', missingFile, '--catalog', CATALOG], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
assert.equal(e.status, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should exit with error for missing pitfalls catalog', () => {
|
||||
const candidatesFile = writeUnique([]);
|
||||
const badCatalog = uniqueFile('nonexistent-catalog');
|
||||
try {
|
||||
execFileSync(process.execPath, [SCRIPT, '--candidates', candidatesFile, '--catalog', badCatalog], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
assert.equal(e.status, 1);
|
||||
} finally {
|
||||
try { unlinkSync(candidatesFile); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle candidate names with regex special characters', () => {
|
||||
const testFile = join(tmpDir, 'special.ts');
|
||||
writeFileSync(testFile, 'export function bar() {}');
|
||||
try {
|
||||
const candidates = [
|
||||
{ name: 'foo.$bar[0]', kind: 'Function', file: testFile, line: 1 },
|
||||
{ name: 'handler', kind: 'Function', file: 'route.ts', line: 1 },
|
||||
{ name: '$invalid()', kind: 'Function', file: testFile, line: 2 }
|
||||
];
|
||||
const result = runScript(candidates);
|
||||
assert.equal(result.status, 'needs_review');
|
||||
assert.equal(result.total_candidates, 3);
|
||||
assert.equal(result.classified.false_positive, 1);
|
||||
assert.equal(result.classified.suspect, 2);
|
||||
assert.equal(result.classified.ghost, 0);
|
||||
} finally {
|
||||
try { unlinkSync(testFile); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('should use first match when candidate matches multiple pitfalls', () => {
|
||||
const candidates = [
|
||||
{ name: 'handler', kind: 'Function', file: 'app/api/route.ts', line: 1 },
|
||||
{ name: 'GET_https_api_example_com_users', kind: 'Variable', file: 'msw.ts', line: 5 },
|
||||
{ name: 'observe', kind: 'Function', file: 'vitest.setup.ts', line: 3 }
|
||||
];
|
||||
const result = runScript(candidates);
|
||||
assert.equal(result.status, 'clean');
|
||||
assert.equal(result.classified.false_positive, 3);
|
||||
assert.ok(result.false_positives[0].pitfall_id);
|
||||
assert.ok(result.false_positives[1].pitfall_id);
|
||||
assert.ok(result.false_positives[2].pitfall_id);
|
||||
});
|
||||
|
||||
it('should handle null and undefined candidate entries gracefully', () => {
|
||||
const testFile = join(tmpDir, 'real-file.ts');
|
||||
writeFileSync(testFile, 'export function x() {}');
|
||||
try {
|
||||
const candidates = [
|
||||
null,
|
||||
undefined,
|
||||
42,
|
||||
'string',
|
||||
{},
|
||||
{ name: 'realFunc', kind: 'Function', file: testFile, line: 1 }
|
||||
];
|
||||
const result = runScript(candidates);
|
||||
assert.equal(result.status, 'needs_review');
|
||||
assert.equal(result.total_candidates, 6);
|
||||
assert.equal(result.classified.suspect, 1);
|
||||
assert.equal(result.classified.false_positive, 0);
|
||||
assert.equal(result.classified.ghost, 5);
|
||||
assert.equal(result.suspects[0].name, 'realFunc');
|
||||
} finally {
|
||||
try { unlinkSync(testFile); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject too-large candidates file', () => {
|
||||
const largeFile = uniqueFile('large');
|
||||
const largeData = [];
|
||||
for (let i = 0; i < 100001; i++) {
|
||||
largeData.push({ name: `func${i}`, kind: 'Function', file: 'src/test.ts' });
|
||||
}
|
||||
writeFileSync(largeFile, JSON.stringify(largeData));
|
||||
try {
|
||||
execFileSync(process.execPath, [SCRIPT, '--candidates', largeFile, '--catalog', CATALOG], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
assert.equal(e.status, 1);
|
||||
assert.ok((e.stderr || '').includes('Too many candidates') || (e.stdout || '').includes('MEMTRACE_MCP_ERROR_TIMEOUT'));
|
||||
} finally {
|
||||
try { unlinkSync(largeFile); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate --help flag prints usage', () => {
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT, '--help'], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.ok(stdout.includes('Usage:'));
|
||||
assert.ok(stdout.includes('--candidates'));
|
||||
});
|
||||
|
||||
it('should validate --catalog flag with custom catalog', () => {
|
||||
const customCatalog = uniqueFile('custom-catalog');
|
||||
writeFileSync(customCatalog, JSON.stringify({
|
||||
version: '1.0.0',
|
||||
description: 'Custom test catalog',
|
||||
last_updated: '2026-05-20',
|
||||
categories: [
|
||||
{
|
||||
name: 'Custom Patterns',
|
||||
entries: [
|
||||
{ id: 'custom-001', pattern: '^my[A-Z]', reason: 'Custom test pattern', examples: ['myFunc'] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
const testFile = join(tmpDir, 'test-file.ts');
|
||||
writeFileSync(testFile, 'export function regularFunc() {}');
|
||||
try {
|
||||
const candidates = [
|
||||
{ name: 'myFunction', kind: 'Function', file: 'src/test.ts', line: 1 },
|
||||
{ name: 'regularFunc', kind: 'Function', file: testFile, line: 10 }
|
||||
];
|
||||
const result = runScript(candidates, customCatalog);
|
||||
assert.equal(result.classified.false_positive, 1);
|
||||
assert.equal(result.false_positives[0].name, 'myFunction');
|
||||
assert.equal(result.false_positives[0].pitfall_id, 'custom-001');
|
||||
assert.equal(result.classified.suspect, 1);
|
||||
assert.equal(result.suspects[0].name, 'regularFunc');
|
||||
} finally {
|
||||
try { unlinkSync(testFile); } catch {}
|
||||
try { unlinkSync(customCatalog); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue