Skip to content

Commit 4f66ebf

Browse files
authored
fix: recognize same-file constant consumption in dead code detector (#859)
* 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 * 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. * fix: update stale JSDoc on hasActiveFileSiblings to match widened criterion
1 parent 06208bb commit 4f66ebf

File tree

3 files changed

+110
-5
lines changed

3 files changed

+110
-5
lines changed

src/features/structure.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,15 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
582582
prodFanInMap.set(r.target_id, r.cnt);
583583
}
584584

585+
// Files with at least one callable (non-constant) connected to the graph.
586+
// Constants in these files are likely consumed locally via identifier reference.
587+
const activeFiles = new Set<string>();
588+
for (const r of rows) {
589+
if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
590+
activeFiles.add(r.file);
591+
}
592+
}
593+
585594
// Delegate classification to the pure-logic classifier
586595
const classifierInput = rows.map((r) => ({
587596
id: String(r.id),
@@ -592,6 +601,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
592601
fanOut: r.fan_out,
593602
isExported: exportedIds.has(r.id),
594603
productionFanIn: prodFanInMap.get(r.id) || 0,
604+
hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
595605
}));
596606

597607
const roleMap = classifyRoles(classifierInput);
@@ -720,6 +730,13 @@ function classifyNodeRolesIncremental(
720730
}
721731

722732
// 5. Classify affected nodes using global medians
733+
const activeFiles = new Set<string>();
734+
for (const r of rows) {
735+
if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
736+
activeFiles.add(r.file);
737+
}
738+
}
739+
723740
const classifierInput = rows.map((r) => ({
724741
id: String(r.id),
725742
name: r.name,
@@ -729,6 +746,7 @@ function classifyNodeRolesIncremental(
729746
fanOut: r.fan_out,
730747
isExported: exportedIds.has(r.id),
731748
productionFanIn: prodFanInMap.get(r.id) || 0,
749+
hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
732750
}));
733751

734752
const roleMap = classifyRoles(classifierInput, globalMedians);

src/graph/classifiers/roles.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export interface RoleClassificationNode {
7474
isExported: boolean;
7575
testOnlyFanIn?: number;
7676
productionFanIn?: number;
77+
/** True when the same file contains at least one non-constant callable connected to the graph (fanIn > 0 or fanOut > 0). */
78+
hasActiveFileSiblings?: boolean;
7779
}
7880

7981
/**
@@ -115,10 +117,17 @@ export function classifyRoles(
115117
if (isFrameworkEntry) {
116118
role = 'entry';
117119
} else if (node.fanIn === 0 && !node.isExported) {
118-
role =
119-
node.testOnlyFanIn != null && node.testOnlyFanIn > 0
120-
? 'test-only'
121-
: classifyDeadSubRole(node);
120+
if (node.kind === 'constant' && node.hasActiveFileSiblings) {
121+
// Constants consumed via identifier reference (not calls) have no
122+
// inbound call edges. If the same file has active callables, the
123+
// constant is almost certainly used locally — classify as leaf.
124+
role = 'leaf';
125+
} else {
126+
role =
127+
node.testOnlyFanIn != null && node.testOnlyFanIn > 0
128+
? 'test-only'
129+
: classifyDeadSubRole(node);
130+
}
122131
} else if (node.fanIn === 0 && node.isExported) {
123132
role = 'entry';
124133
} else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0) {

tests/graph/classifiers/roles.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ describe('classifyRoles', () => {
121121
expect(roles.get('1')).toBe('dead-leaf');
122122
});
123123

124-
it('classifies dead-leaf for constants', () => {
124+
it('classifies dead-leaf for constants without active siblings', () => {
125125
const nodes = [
126126
{
127127
id: '1',
@@ -137,6 +137,32 @@ describe('classifyRoles', () => {
137137
expect(roles.get('1')).toBe('dead-leaf');
138138
});
139139

140+
it('classifies constant as leaf when same file has active callables', () => {
141+
const nodes = [
142+
{
143+
id: '1',
144+
name: 'DEFAULT_WEIGHTS',
145+
kind: 'constant',
146+
file: 'src/risk.ts',
147+
fanIn: 0,
148+
fanOut: 0,
149+
isExported: false,
150+
hasActiveFileSiblings: true,
151+
},
152+
{
153+
id: '2',
154+
name: 'scoreRisk',
155+
kind: 'function',
156+
file: 'src/risk.ts',
157+
fanIn: 3,
158+
fanOut: 2,
159+
isExported: true,
160+
},
161+
];
162+
const roles = classifyRoles(nodes);
163+
expect(roles.get('1')).toBe('leaf');
164+
});
165+
140166
it('classifies dead-ffi for Rust files', () => {
141167
const nodes = [
142168
{
@@ -265,6 +291,58 @@ describe('classifyRoles', () => {
265291
expect(roles.get('1')).toBe('dead-leaf');
266292
});
267293

294+
it('classifies constant as leaf when sibling is a pure-sink function (fan_in > 0, fan_out === 0)', () => {
295+
const nodes = [
296+
{
297+
id: '1',
298+
name: 'MAX_LENGTH',
299+
kind: 'constant',
300+
file: 'src/validators.ts',
301+
fanIn: 0,
302+
fanOut: 0,
303+
isExported: false,
304+
hasActiveFileSiblings: true,
305+
},
306+
{
307+
id: '2',
308+
name: 'validate',
309+
kind: 'function',
310+
file: 'src/validators.ts',
311+
fanIn: 10,
312+
fanOut: 0,
313+
isExported: true,
314+
},
315+
];
316+
const roles = classifyRoles(nodes);
317+
expect(roles.get('1')).toBe('leaf');
318+
});
319+
320+
it('classifies constant as leaf even in CLI command file when active siblings exist', () => {
321+
const nodes = [
322+
{
323+
id: '1',
324+
name: 'MAX',
325+
kind: 'constant',
326+
file: 'src/cli/commands/build.js',
327+
fanIn: 0,
328+
fanOut: 0,
329+
isExported: false,
330+
hasActiveFileSiblings: true,
331+
},
332+
{
333+
id: '2',
334+
name: 'execute',
335+
kind: 'function',
336+
file: 'src/cli/commands/build.js',
337+
fanIn: 0,
338+
fanOut: 3,
339+
isExported: false,
340+
},
341+
];
342+
const roles = classifyRoles(nodes);
343+
expect(roles.get('1')).toBe('leaf');
344+
});
345+
268346
it('falls back to dead-unresolved when no kind/file info', () => {
269347
const nodes = [{ id: '1', name: 'mystery', fanIn: 0, fanOut: 0, isExported: false }];
270348
const roles = classifyRoles(nodes);

0 commit comments

Comments
 (0)