diff --git a/src/features/structure.ts b/src/features/structure.ts index b361fb04..36910641 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) 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_in > 0 || 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: r.kind === 'constant' ? activeFiles.has(r.file) : undefined, })); 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_in > 0 || 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: r.kind === 'constant' ? activeFiles.has(r.file) : undefined, })); const roleMap = classifyRoles(classifierInput, globalMedians); diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 7c5517b6..7fe56618 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 non-constant callable connected to the graph (fanIn > 0 or 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..5de923d4 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,58 @@ 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 = [ + { + 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);