diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 9e6873c2..1586be32 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -995,22 +995,15 @@ src/domain/ ## Phase 4 -- Resolution Accuracy -> **Status:** Planned +> **Status:** In Progress **Goal:** Close the most impactful gaps in call graph accuracy before investing in type safety or native acceleration. The entire value proposition — blast radius, impact analysis, dependency chains — rests on the call graph. These targeted improvements make the graph trustworthy. **Why before TypeScript:** These fixes operate on the existing JS codebase and produce measurable accuracy gains immediately. TypeScript types will further improve resolution later, but receiver tracking, dead role fixes, and precision benchmarks don't require types to implement. -### 4.1 -- Fix "Dead" Role Sub-categories +### ~~4.1 -- Fix "Dead" Role Sub-categories~~ ✅ -The current `dead` role classification conflates genuinely different categories, making the tool's own metrics misleading. Of ~509 dead callable symbols in codegraph's own codebase: 151 are Rust FFI (invisible by design), 94 are CLI/MCP entry points (framework dispatch), 26 are AST visitors (dynamic dispatch), 125 are repository methods (receiver type unknown), and only ~94 are genuine dead code or resolution misses. - -- Add sub-categories to role classification: `dead-leaf` (parameters, properties, constants — leaf nodes by definition), `dead-entry` (framework dispatch: CLI commands, MCP tools, event handlers), `dead-ffi` (cross-language FFI boundaries), `dead-unresolved` (genuinely unreferenced callables — the real dead code) -- Update `classifyNodeRoles()` to use the new sub-categories -- Update `roles` command, `audit`, and `triage` to report sub-categories -- MCP `node_roles` tool gains `--role dead-entry`, `--role dead-unresolved` etc. - -**Affected files:** `src/graph/classifiers/roles.js`, `src/shared/kinds.js`, `src/domain/analysis/roles.js`, `src/features/triage.js` +The coarse `dead` role is now sub-classified into four categories: `dead-leaf` (parameters, properties, constants), `dead-entry` (CLI commands, MCP tools, route/handler files), `dead-ffi` (cross-language FFI — `.rs`, `.c`, `.go`, etc.), and `dead-unresolved` (genuinely unreferenced callables). The `--role dead` filter matches all sub-roles for backward compatibility. Risk weights are tuned per sub-role. `VALID_ROLES`, `DEAD_SUB_ROLES` exported from `shared/kinds.js`. Stats, MCP `node_roles`, CLI `roles`/`triage` all updated. ### 4.2 -- Receiver Type Tracking for Method Dispatch diff --git a/src/cli/commands/roles.js b/src/cli/commands/roles.js index 8677f022..e40d9d7a 100644 --- a/src/cli/commands/roles.js +++ b/src/cli/commands/roles.js @@ -4,7 +4,8 @@ import { roles } from '../../presentation/queries-cli.js'; export const command = { name: 'roles', - description: 'Show node role classification: entry, core, utility, adapter, dead, leaf', + description: + 'Show node role classification: entry, core, utility, adapter, dead (dead-leaf, dead-entry, dead-ffi, dead-unresolved), leaf', options: [ ['-d, --db ', 'Path to graph.db'], ['--role ', `Filter by role (${VALID_ROLES.join(', ')})`], diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index 3aee8a7e..1b2d297e 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -53,7 +53,10 @@ export const command = { 'risk', ], ['--min-score ', 'Only show symbols with risk score >= threshold'], - ['--role ', 'Filter by role (entry, core, utility, adapter, leaf, dead)'], + [ + '--role ', + 'Filter by role (entry, core, utility, adapter, leaf, dead, dead-leaf, dead-entry, dead-ffi, dead-unresolved)', + ], ['-f, --file ', 'Scope to a specific file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind (function, method, class)'], ['-T, --no-tests', 'Exclude test/spec files from results'], diff --git a/src/db/query-builder.js b/src/db/query-builder.js index 2d15e8cf..ae2d11db 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -1,5 +1,5 @@ import { DbError } from '../shared/errors.js'; -import { EVERY_EDGE_KIND } from '../shared/kinds.js'; +import { DEAD_ROLE_PREFIX, EVERY_EDGE_KIND } from '../shared/kinds.js'; // ─── Validation Helpers ───────────────────────────────────────────── @@ -243,11 +243,16 @@ export class NodeQuery { return this; } - /** WHERE n.role = ? (no-op if falsy). */ + /** WHERE n.role = ? (no-op if falsy). 'dead' matches all dead-* sub-roles. */ roleFilter(role) { if (!role) return this; - this.#conditions.push('n.role = ?'); - this.#params.push(role); + if (role === DEAD_ROLE_PREFIX) { + this.#conditions.push('n.role LIKE ?'); + this.#params.push(`${DEAD_ROLE_PREFIX}%`); + } else { + this.#conditions.push('n.role = ?'); + this.#params.push(role); + } return this; } diff --git a/src/db/repository/in-memory-repository.js b/src/db/repository/in-memory-repository.js index 1607a27c..5f2779d1 100644 --- a/src/db/repository/in-memory-repository.js +++ b/src/db/repository/in-memory-repository.js @@ -1,5 +1,10 @@ import { ConfigError } from '../../shared/errors.js'; -import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; +import { + CORE_SYMBOL_KINDS, + DEAD_ROLE_PREFIX, + EVERY_SYMBOL_KIND, + VALID_ROLES, +} from '../../shared/kinds.js'; import { escapeLike, normalizeFileFilter } from '../query-builder.js'; import { Repository } from './base.js'; @@ -264,7 +269,11 @@ export class InMemoryRepository extends Repository { if (fileFn) nodes = nodes.filter((n) => fileFn(n.file)); } if (opts.role) { - nodes = nodes.filter((n) => n.role === opts.role); + nodes = nodes.filter((n) => + opts.role === DEAD_ROLE_PREFIX + ? n.role?.startsWith(DEAD_ROLE_PREFIX) + : n.role === opts.role, + ); } const fanInMap = this.#computeFanIn(); diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js index daf09b33..5cf4c47f 100644 --- a/src/domain/analysis/module-map.js +++ b/src/domain/analysis/module-map.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; +import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; import { findCycles } from '../graph/cycles.js'; import { LANGUAGE_REGISTRY } from '../parser.js'; @@ -237,7 +238,12 @@ function countRoles(db, noTests) { .all(); } const roles = {}; - for (const r of roleRows) roles[r.role] = r.c; + let deadTotal = 0; + for (const r of roleRows) { + roles[r.role] = r.c; + if (r.role.startsWith(DEAD_ROLE_PREFIX)) deadTotal += r.c; + } + if (deadTotal > 0) roles.dead = deadTotal; return roles; } diff --git a/src/domain/analysis/roles.js b/src/domain/analysis/roles.js index 141a10ed..403f758c 100644 --- a/src/domain/analysis/roles.js +++ b/src/domain/analysis/roles.js @@ -1,6 +1,7 @@ import { openReadonlyOrFail } from '../../db/index.js'; import { buildFileConditionSQL } from '../../db/query-builder.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; +import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; import { normalizeSymbol } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; @@ -13,8 +14,13 @@ export function rolesData(customDbPath, opts = {}) { const params = []; if (filterRole) { - conditions.push('role = ?'); - params.push(filterRole); + if (filterRole === DEAD_ROLE_PREFIX) { + conditions.push('role LIKE ?'); + params.push(`${DEAD_ROLE_PREFIX}%`); + } else { + conditions.push('role = ?'); + params.push(filterRole); + } } { const fc = buildFileConditionSQL(opts.file, 'file'); diff --git a/src/features/graph-enrichment.js b/src/features/graph-enrichment.js index adb9fb8e..4d399409 100644 --- a/src/features/graph-enrichment.js +++ b/src/features/graph-enrichment.js @@ -162,7 +162,7 @@ function prepareFunctionLevelData(db, noTests, minConf, cfg) { const community = communityMap.get(n.id) ?? null; const directory = path.dirname(n.file); const risk = []; - if (n.role === 'dead') risk.push('dead-code'); + if (n.role?.startsWith('dead')) risk.push('dead-code'); if (fanIn >= (cfg.riskThresholds?.highBlastRadius ?? 10)) risk.push('high-blast-radius'); if (cx && cx.maintainabilityIndex < (cfg.riskThresholds?.lowMI ?? 40)) risk.push('low-mi'); diff --git a/src/features/structure.js b/src/features/structure.js index 5899e62f..87dfc6a2 100644 --- a/src/features/structure.js +++ b/src/features/structure.js @@ -349,7 +349,19 @@ export function classifyNodeRoles(db) { .all(); if (rows.length === 0) { - return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, 'test-only': 0, leaf: 0 }; + return { + entry: 0, + core: 0, + utility: 0, + adapter: 0, + dead: 0, + 'dead-leaf': 0, + 'dead-entry': 0, + 'dead-ffi': 0, + 'dead-unresolved': 0, + 'test-only': 0, + leaf: 0, + }; } const exportedIds = new Set( @@ -385,6 +397,8 @@ export function classifyNodeRoles(db) { const classifierInput = rows.map((r) => ({ id: String(r.id), name: r.name, + kind: r.kind, + file: r.file, fanIn: r.fan_in, fanOut: r.fan_out, isExported: exportedIds.has(r.id), @@ -394,12 +408,25 @@ export function classifyNodeRoles(db) { const roleMap = classifyRoles(classifierInput); // Build summary and updates - const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, 'test-only': 0, leaf: 0 }; + const summary = { + entry: 0, + core: 0, + utility: 0, + adapter: 0, + dead: 0, + 'dead-leaf': 0, + 'dead-entry': 0, + 'dead-ffi': 0, + 'dead-unresolved': 0, + 'test-only': 0, + leaf: 0, + }; const updates = []; for (const row of rows) { const role = roleMap.get(String(row.id)) || 'leaf'; updates.push({ id: row.id, role }); - summary[role]++; + if (role.startsWith('dead')) summary.dead++; + summary[role] = (summary[role] || 0) + 1; } const clearRoles = db.prepare('UPDATE nodes SET role = NULL'); diff --git a/src/graph/classifiers/risk.js b/src/graph/classifiers/risk.js index cf7366bd..930776fa 100644 --- a/src/graph/classifiers/risk.js +++ b/src/graph/classifiers/risk.js @@ -26,6 +26,10 @@ export const ROLE_WEIGHTS = { leaf: 0.2, 'test-only': 0.1, dead: 0.1, + 'dead-leaf': 0.0, + 'dead-entry': 0.3, + 'dead-ffi': 0.05, + 'dead-unresolved': 0.15, }; const DEFAULT_ROLE_WEIGHT = 0.5; diff --git a/src/graph/classifiers/roles.js b/src/graph/classifiers/roles.js index cb4d5498..62229e59 100644 --- a/src/graph/classifiers/roles.js +++ b/src/graph/classifiers/roles.js @@ -1,11 +1,59 @@ /** * Node role classification — pure logic, no DB. * - * Roles: entry, core, utility, adapter, leaf, dead, test-only + * Roles: entry, core, utility, adapter, leaf, dead-*, test-only + * + * Dead sub-categories refine the coarse "dead" bucket: + * dead-leaf — parameters, properties, constants (leaf nodes by definition) + * dead-entry — framework dispatch: CLI commands, MCP tools, event handlers + * dead-ffi — cross-language FFI boundaries (e.g. Rust napi-rs bindings) + * dead-unresolved — genuinely unreferenced callables (the real dead code) */ export const FRAMEWORK_ENTRY_PREFIXES = ['route:', 'event:', 'command:']; +// ── Dead sub-classification helpers ──────────────────────────────── + +const LEAF_KINDS = new Set(['parameter', 'property', 'constant']); + +const FFI_EXTENSIONS = new Set(['.rs', '.c', '.cpp', '.h', '.go', '.java', '.cs']); + +/** Path patterns indicating framework-dispatched entry points. */ +const ENTRY_PATH_PATTERNS = [ + /cli[/\\]commands[/\\]/, + /mcp[/\\]/, + /routes?[/\\]/, + /handlers?[/\\]/, + /middleware[/\\]/, +]; + +/** + * Refine a "dead" classification into a sub-category. + * + * @param {{ kind?: string, file?: string }} node + * @returns {'dead-leaf'|'dead-entry'|'dead-ffi'|'dead-unresolved'} + */ +function classifyDeadSubRole(node) { + // Leaf kinds are dead by definition — they can't have callers + if (node.kind && LEAF_KINDS.has(node.kind)) return 'dead-leaf'; + + if (node.file) { + // Cross-language FFI: compiled-language files in a JS/TS project + // Priority: dead-ffi is checked before dead-entry deliberately — an FFI + // boundary is a more fundamental classification than a path-based hint. + // A .so/.dll in a routes/ directory is still FFI, not an entry point. + const dotIdx = node.file.lastIndexOf('.'); + if (dotIdx !== -1 && FFI_EXTENSIONS.has(node.file.slice(dotIdx))) return 'dead-ffi'; + + // Framework-dispatched entry points (CLI commands, MCP tools, routes) + if (ENTRY_PATH_PATTERNS.some((p) => p.test(node.file))) return 'dead-entry'; + } + + return 'dead-unresolved'; +} + +// ── Helpers ──────────────────────────────────────────────────────── + function median(sorted) { if (sorted.length === 0) return 0; const mid = Math.floor(sorted.length / 2); @@ -15,7 +63,7 @@ function median(sorted) { /** * Classify nodes into architectural roles based on fan-in/fan-out metrics. * - * @param {{ id: string, name: string, fanIn: number, fanOut: number, isExported: boolean, testOnlyFanIn?: number }[]} nodes + * @param {{ id: string, name: string, kind?: string, file?: string, fanIn: number, fanOut: number, isExported: boolean, testOnlyFanIn?: number }[]} nodes * @returns {Map} nodeId → role */ export function classifyRoles(nodes) { @@ -45,7 +93,7 @@ export function classifyRoles(nodes) { if (isFrameworkEntry) { role = 'entry'; } else if (node.fanIn === 0 && !node.isExported) { - role = node.testOnlyFanIn > 0 ? 'test-only' : 'dead'; + role = node.testOnlyFanIn > 0 ? 'test-only' : classifyDeadSubRole(node); } else if (node.fanIn === 0 && node.isExported) { role = 'entry'; } else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0) { diff --git a/src/mcp/tool-registry.js b/src/mcp/tool-registry.js index c81baee8..ce3bd2e3 100644 --- a/src/mcp/tool-registry.js +++ b/src/mcp/tool-registry.js @@ -362,7 +362,7 @@ const BASE_TOOLS = [ { name: 'node_roles', description: - 'Show node role classification (entry, core, utility, adapter, dead, leaf) based on connectivity patterns', + 'Show node role classification (entry, core, utility, adapter, dead [dead-leaf, dead-entry, dead-ffi, dead-unresolved], leaf) based on connectivity patterns', inputSchema: { type: 'object', properties: { diff --git a/src/shared/kinds.js b/src/shared/kinds.js index 498ad210..205d0cfb 100644 --- a/src/shared/kinds.js +++ b/src/shared/kinds.js @@ -47,4 +47,17 @@ export const STRUCTURAL_EDGE_KINDS = ['parameter_of', 'receiver']; // Full set for MCP enum and validation export const EVERY_EDGE_KIND = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS]; -export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'test-only', 'leaf']; +// Dead sub-categories — refine the coarse "dead" bucket +export const DEAD_ROLE_PREFIX = 'dead'; +export const DEAD_SUB_ROLES = ['dead-leaf', 'dead-entry', 'dead-ffi', 'dead-unresolved']; + +export const VALID_ROLES = [ + 'entry', + 'core', + 'utility', + 'adapter', + 'dead', + 'test-only', + 'leaf', + ...DEAD_SUB_ROLES, +]; diff --git a/tests/graph/classifiers/roles.test.js b/tests/graph/classifiers/roles.test.js index e76cc539..bca99625 100644 --- a/tests/graph/classifiers/roles.test.js +++ b/tests/graph/classifiers/roles.test.js @@ -6,12 +6,6 @@ describe('classifyRoles', () => { expect(classifyRoles([]).size).toBe(0); }); - it('classifies dead nodes (no fan-in, not exported)', () => { - const nodes = [{ id: '1', name: 'unused', fanIn: 0, fanOut: 0, isExported: false }]; - const roles = classifyRoles(nodes); - expect(roles.get('1')).toBe('dead'); - }); - it('classifies entry nodes (no fan-in, exported)', () => { const nodes = [{ id: '1', name: 'init', fanIn: 0, fanOut: 3, isExported: true }]; const roles = classifyRoles(nodes); @@ -25,7 +19,6 @@ describe('classifyRoles', () => { }); it('classifies core (high fan-in, low fan-out)', () => { - // Need multiple nodes so median can be computed const nodes = [ { id: '1', name: 'coreLib', fanIn: 10, fanOut: 0, isExported: true }, { id: '2', name: 'caller', fanIn: 0, fanOut: 10, isExported: true }, @@ -69,20 +62,229 @@ describe('classifyRoles', () => { expect(roles.get('1')).toBe('test-only'); }); - it('classifies dead when fanIn is 0 and testOnlyFanIn is 0', () => { + it('ignores testOnlyFanIn when fanIn > 0', () => { const nodes = [ - { id: '1', name: 'reallyDead', fanIn: 0, fanOut: 0, isExported: false, testOnlyFanIn: 0 }, + { id: '1', name: 'normalLeaf', fanIn: 1, fanOut: 0, isExported: false, testOnlyFanIn: 2 }, + { id: '2', name: 'hub', fanIn: 10, fanOut: 10, isExported: true }, ]; const roles = classifyRoles(nodes); - expect(roles.get('1')).toBe('dead'); + expect(roles.get('1')).toBe('leaf'); }); - it('ignores testOnlyFanIn when fanIn > 0', () => { + // ── Dead sub-category tests ─────────────────────────────────────── + + it('classifies dead-unresolved for genuinely unreferenced callables', () => { const nodes = [ - { id: '1', name: 'normalLeaf', fanIn: 1, fanOut: 0, isExported: false, testOnlyFanIn: 2 }, - { id: '2', name: 'hub', fanIn: 10, fanOut: 10, isExported: true }, + { + id: '1', + name: 'unused', + kind: 'function', + file: 'src/lib.js', + fanIn: 0, + fanOut: 0, + isExported: false, + }, ]; const roles = classifyRoles(nodes); - expect(roles.get('1')).toBe('leaf'); + expect(roles.get('1')).toBe('dead-unresolved'); + }); + + it('classifies dead-leaf for parameters', () => { + const nodes = [ + { + id: '1', + name: 'opts', + kind: 'parameter', + file: 'src/lib.js', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-leaf'); + }); + + it('classifies dead-leaf for properties', () => { + const nodes = [ + { + id: '1', + name: 'config.timeout', + kind: 'property', + file: 'src/lib.js', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-leaf'); + }); + + it('classifies dead-leaf for constants', () => { + const nodes = [ + { + id: '1', + name: 'MAX_RETRIES', + kind: 'constant', + file: 'src/lib.js', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-leaf'); + }); + + it('classifies dead-ffi for Rust files', () => { + const nodes = [ + { + id: '1', + name: 'parse_file', + kind: 'function', + file: 'crates/core/src/parser.rs', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-ffi'); + }); + + it('classifies dead-ffi for C files', () => { + const nodes = [ + { + id: '1', + name: 'init_module', + kind: 'function', + file: 'native/binding.c', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-ffi'); + }); + + it('classifies dead-ffi for Go files', () => { + const nodes = [ + { + id: '1', + name: 'BuildGraph', + kind: 'function', + file: 'pkg/graph.go', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-ffi'); + }); + + it('classifies dead-entry for CLI command files', () => { + const nodes = [ + { + id: '1', + name: 'execute', + kind: 'function', + file: 'src/cli/commands/build.js', + fanIn: 0, + fanOut: 3, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-entry'); + }); + + it('classifies dead-entry for MCP handler files', () => { + const nodes = [ + { + id: '1', + name: 'handleQuery', + kind: 'function', + file: 'src/mcp/handlers.js', + fanIn: 0, + fanOut: 2, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-entry'); + }); + + it('classifies dead-entry for route files', () => { + const nodes = [ + { + id: '1', + name: 'getUsers', + kind: 'function', + file: 'src/routes/users.js', + fanIn: 0, + fanOut: 1, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-entry'); + }); + + it('dead-leaf takes priority over dead-ffi (parameter in .rs file)', () => { + const nodes = [ + { + id: '1', + name: 'ctx', + kind: 'parameter', + file: 'crates/core/src/lib.rs', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-leaf'); + }); + + it('dead-leaf takes priority over dead-entry (constant in CLI command)', () => { + const nodes = [ + { + id: '1', + name: 'MAX', + kind: 'constant', + file: 'src/cli/commands/build.js', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-leaf'); + }); + + it('falls back to dead-unresolved when no kind/file info', () => { + const nodes = [{ id: '1', name: 'mystery', fanIn: 0, fanOut: 0, isExported: false }]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-unresolved'); + }); + + it('classifies dead-unresolved when fanIn is 0 and testOnlyFanIn is 0', () => { + const nodes = [ + { + id: '1', + name: 'reallyDead', + kind: 'function', + file: 'src/lib.js', + fanIn: 0, + fanOut: 0, + isExported: false, + testOnlyFanIn: 0, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('dead-unresolved'); }); }); diff --git a/tests/integration/flow.test.js b/tests/integration/flow.test.js index 97d83515..fc4ca7aa 100644 --- a/tests/integration/flow.test.js +++ b/tests/integration/flow.test.js @@ -285,10 +285,10 @@ describe('framework entry point classification fix', () => { } }); - test('orphanFn is classified as dead', () => { + test('orphanFn is classified as dead (sub-role)', () => { const db = new Database(dbPath, { readonly: true }); const row = db.prepare(`SELECT role FROM nodes WHERE name = 'orphanFn'`).get(); db.close(); - expect(row.role).toBe('dead'); + expect(row.role).toMatch(/^dead/); }); }); diff --git a/tests/integration/roles.test.js b/tests/integration/roles.test.js index 2a92b16a..9fce5e84 100644 --- a/tests/integration/roles.test.js +++ b/tests/integration/roles.test.js @@ -110,12 +110,11 @@ describe('rolesData', () => { expect(names).toContain('unused'); }); - test('filters by role', () => { + test('filters by role (dead matches all sub-roles)', () => { const data = rolesData(dbPath, { role: 'dead' }); for (const s of data.symbols) { - expect(s.role).toBe('dead'); + expect(s.role).toMatch(/^dead/); } - expect(data.summary.dead).toBe(data.count); }); test('filters by file', () => { @@ -171,7 +170,7 @@ describe('whereData with roles', () => { const data = whereData('unused', dbPath); const unusedResult = data.results.find((r) => r.name === 'unused'); expect(unusedResult).toBeDefined(); - expect(unusedResult.role).toBe('dead'); + expect(unusedResult.role).toMatch(/^dead/); }); }); diff --git a/tests/unit/roles.test.js b/tests/unit/roles.test.js index 1e8702a2..cf4fd5e6 100644 --- a/tests/unit/roles.test.js +++ b/tests/unit/roles.test.js @@ -9,7 +9,7 @@ * coreFn - high fan_in, low fan_out → core * utilityFn - high fan_in, high fan_out → utility * adapterFn - low fan_in, high fan_out → adapter - * deadFn - fan_in=0, not exported → dead + * deadFn - fan_in=0, not exported → dead-unresolved * leafFn - low fan_in, low fan_out → leaf */ @@ -115,7 +115,7 @@ describe('classifyNodeRoles', () => { // Verify specific node roles const getRole = (name) => db.prepare('SELECT role FROM nodes WHERE name = ?').get(name)?.role; - expect(getRole('deadFn')).toBe('dead'); + expect(getRole('deadFn')).toBe('dead-unresolved'); expect(getRole('coreFn')).toBe('core'); expect(getRole('utilityFn')).toBe('utility'); }); @@ -157,6 +157,10 @@ describe('classifyNodeRoles', () => { utility: 0, adapter: 0, dead: 0, + 'dead-leaf': 0, + 'dead-entry': 0, + 'dead-ffi': 0, + 'dead-unresolved': 0, 'test-only': 0, leaf: 0, }); @@ -178,7 +182,7 @@ describe('classifyNodeRoles', () => { expect(summary.utility).toBe(2); }); - it('classifies nodes with only non-call edges as dead', () => { + it('classifies nodes with only non-call edges as dead-unresolved', () => { const fA = insertNode('a.js', 'file', 'a.js', 0); const fn1 = insertNode('fn1', 'function', 'a.js', 1); // Only import edge, no call edge @@ -186,6 +190,6 @@ describe('classifyNodeRoles', () => { classifyNodeRoles(db); const role = db.prepare("SELECT role FROM nodes WHERE name = 'fn1'").get(); - expect(role.role).toBe('dead'); + expect(role.role).toBe('dead-unresolved'); }); });