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:
Magal 2026-05-19 14:25:52 -03:00
parent 79b3f7d9dc
commit 792fb50745
3 changed files with 564 additions and 0 deletions

View File

@ -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"]
}
]
}
]
}

View File

@ -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);
});

View File

@ -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 {}
}
});
});