diff --git a/_bmad/scripts/memtrace/pitfalls-catalog.json b/_bmad/scripts/memtrace/pitfalls-catalog.json new file mode 100644 index 000000000..05d2c0745 --- /dev/null +++ b/_bmad/scripts/memtrace/pitfalls-catalog.json @@ -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"] + } + ] + } + ] +} diff --git a/_bmad/scripts/memtrace/validate-dead-code.mjs b/_bmad/scripts/memtrace/validate-dead-code.mjs new file mode 100644 index 000000000..f50775472 --- /dev/null +++ b/_bmad/scripts/memtrace/validate-dead-code.mjs @@ -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 [--catalog ] + +Arguments: + --candidates Path to JSON array of dead-code candidates from find_dead_code + --catalog 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); + }); diff --git a/_bmad/scripts/memtrace/validate-dead-code.test.mjs b/_bmad/scripts/memtrace/validate-dead-code.test.mjs new file mode 100644 index 000000000..33c392801 --- /dev/null +++ b/_bmad/scripts/memtrace/validate-dead-code.test.mjs @@ -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 {} + } + }); +});