From 16785c18c47db704d2921131f97f05752bed080e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:44:14 -0600 Subject: [PATCH 1/3] fix: recognize same-file constant consumption in dead code detector Constants consumed via identifier reference (not function calls) had no inbound call edges, causing fanIn=0 and incorrect dead-leaf classification. Add hasActiveFileSiblings flag so constants in files with active callables are classified as leaf instead of dead-leaf. Closes #841 --- src/features/structure.ts | 18 +++++++++ src/graph/classifiers/roles.ts | 17 +++++++-- tests/graph/classifiers/roles.test.ts | 54 ++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/features/structure.ts b/src/features/structure.ts index b361fb04..d34beb98 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -582,6 +582,15 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm prodFanInMap.set(r.target_id, r.cnt); } + // Files with at least one callable (non-constant) that has outbound calls. + // Constants in these files are likely consumed locally via identifier reference. + const activeFiles = new Set(); + for (const r of rows) { + if (r.fan_out > 0 && r.kind !== 'constant') { + activeFiles.add(r.file); + } + } + // Delegate classification to the pure-logic classifier const classifierInput = rows.map((r) => ({ id: String(r.id), @@ -592,6 +601,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm fanOut: r.fan_out, isExported: exportedIds.has(r.id), productionFanIn: prodFanInMap.get(r.id) || 0, + hasActiveFileSiblings: activeFiles.has(r.file), })); const roleMap = classifyRoles(classifierInput); @@ -720,6 +730,13 @@ function classifyNodeRolesIncremental( } // 5. Classify affected nodes using global medians + const activeFiles = new Set(); + for (const r of rows) { + if (r.fan_out > 0 && r.kind !== 'constant') { + activeFiles.add(r.file); + } + } + const classifierInput = rows.map((r) => ({ id: String(r.id), name: r.name, @@ -729,6 +746,7 @@ function classifyNodeRolesIncremental( fanOut: r.fan_out, isExported: exportedIds.has(r.id), productionFanIn: prodFanInMap.get(r.id) || 0, + hasActiveFileSiblings: activeFiles.has(r.file), })); const roleMap = classifyRoles(classifierInput, globalMedians); diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 7c5517b6..2ad8e999 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -74,6 +74,8 @@ export interface RoleClassificationNode { isExported: boolean; testOnlyFanIn?: number; productionFanIn?: number; + /** True when the same file contains at least one callable with fanOut > 0. */ + hasActiveFileSiblings?: boolean; } /** @@ -115,10 +117,17 @@ export function classifyRoles( if (isFrameworkEntry) { role = 'entry'; } else if (node.fanIn === 0 && !node.isExported) { - role = - node.testOnlyFanIn != null && node.testOnlyFanIn > 0 - ? 'test-only' - : classifyDeadSubRole(node); + if (node.kind === 'constant' && node.hasActiveFileSiblings) { + // Constants consumed via identifier reference (not calls) have no + // inbound call edges. If the same file has active callables, the + // constant is almost certainly used locally — classify as leaf. + role = 'leaf'; + } else { + role = + node.testOnlyFanIn != null && 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/tests/graph/classifiers/roles.test.ts b/tests/graph/classifiers/roles.test.ts index bca99625..218f1b4c 100644 --- a/tests/graph/classifiers/roles.test.ts +++ b/tests/graph/classifiers/roles.test.ts @@ -121,7 +121,7 @@ describe('classifyRoles', () => { expect(roles.get('1')).toBe('dead-leaf'); }); - it('classifies dead-leaf for constants', () => { + it('classifies dead-leaf for constants without active siblings', () => { const nodes = [ { id: '1', @@ -137,6 +137,32 @@ describe('classifyRoles', () => { expect(roles.get('1')).toBe('dead-leaf'); }); + it('classifies constant as leaf when same file has active callables', () => { + const nodes = [ + { + id: '1', + name: 'DEFAULT_WEIGHTS', + kind: 'constant', + file: 'src/risk.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + hasActiveFileSiblings: true, + }, + { + id: '2', + name: 'scoreRisk', + kind: 'function', + file: 'src/risk.ts', + fanIn: 3, + fanOut: 2, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); + it('classifies dead-ffi for Rust files', () => { const nodes = [ { @@ -265,6 +291,32 @@ describe('classifyRoles', () => { expect(roles.get('1')).toBe('dead-leaf'); }); + it('classifies constant as leaf even in CLI command file when active siblings exist', () => { + const nodes = [ + { + id: '1', + name: 'MAX', + kind: 'constant', + file: 'src/cli/commands/build.js', + fanIn: 0, + fanOut: 0, + isExported: false, + hasActiveFileSiblings: true, + }, + { + id: '2', + 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('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); From f91fde0d5bd103befd78581b1a4e1fc3aee86500 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:26:14 -0600 Subject: [PATCH 2/3] fix: widen active-file heuristic to cover pure-sink siblings and scope flag to constants Widen activeFiles criterion from fan_out > 0 to (fan_in > 0 || fan_out > 0) so constants in files with pure-sink functions (called but making no outbound calls) are correctly rescued from dead-leaf. Scope hasActiveFileSiblings assignment to constant nodes only for clarity. --- src/features/structure.ts | 10 +++++----- tests/graph/classifiers/roles.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/features/structure.ts b/src/features/structure.ts index d34beb98..36910641 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -582,11 +582,11 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm prodFanInMap.set(r.target_id, r.cnt); } - // Files with at least one callable (non-constant) that has outbound calls. + // Files with at least one callable (non-constant) connected to the graph. // Constants in these files are likely consumed locally via identifier reference. const activeFiles = new Set(); for (const r of rows) { - if (r.fan_out > 0 && r.kind !== 'constant') { + if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') { activeFiles.add(r.file); } } @@ -601,7 +601,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm fanOut: r.fan_out, isExported: exportedIds.has(r.id), productionFanIn: prodFanInMap.get(r.id) || 0, - hasActiveFileSiblings: activeFiles.has(r.file), + hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined, })); const roleMap = classifyRoles(classifierInput); @@ -732,7 +732,7 @@ function classifyNodeRolesIncremental( // 5. Classify affected nodes using global medians const activeFiles = new Set(); for (const r of rows) { - if (r.fan_out > 0 && r.kind !== 'constant') { + if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') { activeFiles.add(r.file); } } @@ -746,7 +746,7 @@ function classifyNodeRolesIncremental( fanOut: r.fan_out, isExported: exportedIds.has(r.id), productionFanIn: prodFanInMap.get(r.id) || 0, - hasActiveFileSiblings: activeFiles.has(r.file), + hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined, })); const roleMap = classifyRoles(classifierInput, globalMedians); diff --git a/tests/graph/classifiers/roles.test.ts b/tests/graph/classifiers/roles.test.ts index 218f1b4c..5de923d4 100644 --- a/tests/graph/classifiers/roles.test.ts +++ b/tests/graph/classifiers/roles.test.ts @@ -291,6 +291,32 @@ describe('classifyRoles', () => { expect(roles.get('1')).toBe('dead-leaf'); }); + it('classifies constant as leaf when sibling is a pure-sink function (fan_in > 0, fan_out === 0)', () => { + const nodes = [ + { + id: '1', + name: 'MAX_LENGTH', + kind: 'constant', + file: 'src/validators.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + hasActiveFileSiblings: true, + }, + { + id: '2', + name: 'validate', + kind: 'function', + file: 'src/validators.ts', + fanIn: 10, + fanOut: 0, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); + it('classifies constant as leaf even in CLI command file when active siblings exist', () => { const nodes = [ { From 3e21c999cb3fd4c97c0ab3c00e35a78c93254277 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:10:48 -0600 Subject: [PATCH 3/3] fix: update stale JSDoc on hasActiveFileSiblings to match widened criterion --- src/graph/classifiers/roles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 2ad8e999..7fe56618 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -74,7 +74,7 @@ export interface RoleClassificationNode { isExported: boolean; testOnlyFanIn?: number; productionFanIn?: number; - /** True when the same file contains at least one callable with fanOut > 0. */ + /** True when the same file contains at least one non-constant callable connected to the graph (fanIn > 0 or fanOut > 0). */ hasActiveFileSiblings?: boolean; }