diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 9e6873c20..1586be324 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 8677f022c..e40d9d7a0 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 3aee8a7e0..1b2d297e7 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 2d15e8cf9..ae2d11db2 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 1607a27c3..5f2779d10 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 daf09b33a..5cf4c47f5 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 141a10ed2..403f758c9 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 adb9fb8e6..4d3994099 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 5899e62f7..87dfc6a23 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 cf7366bd2..930776faa 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 cb4d54981..62229e59b 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 c81baee85..ce3bd2e3c 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 498ad2102..205d0cfb3 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 e76cc539b..bca996253 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 97d835157..fc4ca7aae 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 2a92b16ac..9fce5e84d 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 1e8702a22..cf4fd5e66 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'); }); });