Skip to content
Merged
3 changes: 2 additions & 1 deletion src/cli/commands/roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,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, test-only, leaf',
options: [
['-d, --db <path>', 'Path to graph.db'],
['--role <role>', `Filter by role (${VALID_ROLES.join(', ')})`],
Expand Down
85 changes: 83 additions & 2 deletions src/domain/analysis/roles.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { openReadonlyOrFail } from '../../db/index.js';
import { warn } from '../../infrastructure/logger.js';
import { isTestFile } from '../../infrastructure/test-filter.js';
import { normalizeSymbol } from '../../shared/normalize.js';
import { paginateResult } from '../../shared/paginate.js';
Expand All @@ -13,9 +14,14 @@ export function rolesData(customDbPath, opts = {}) {
const conditions = ['role IS NOT NULL'];
const params = [];

if (filterRole) {
// When noTests + filtering for 'dead', also include 'test-only' candidates
// (they are stored as non-dead roles but may need reclassification)
if (filterRole && filterRole !== 'test-only') {
conditions.push('role = ?');
params.push(filterRole);
} else if (filterRole === 'test-only') {
// test-only is not stored in DB; we need all symbols to reclassify
// Fetch everything and filter after reclassification
}
if (filterFile) {
conditions.push('file LIKE ?');
Expand All @@ -28,7 +34,27 @@ export function rolesData(customDbPath, opts = {}) {
)
.all(...params);

if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
if (noTests) {
rows = rows.filter((r) => !isTestFile(r.file));
}

if (noTests || filterRole === 'test-only') {
// Reclassify symbols whose only callers are in test files as 'test-only'.
// A symbol that has fanIn > 0 at build time (all edges) but fanIn === 0
// when test-file callers are excluded should be 'test-only' instead of
// whatever role it was assigned with the full graph.
const testOnlyIds = _findTestOnlyCalledIds(db);
for (const r of rows) {
if (testOnlyIds.has(`${r.name}\0${r.file}\0${r.line}`)) {
r.role = 'test-only';
}
}
}

// If we were asked for a specific role, filter now (after reclassification)
if (filterRole) {
rows = rows.filter((r) => r.role === filterRole);
}

const summary = {};
for (const r of rows) {
Expand All @@ -43,3 +69,58 @@ export function rolesData(customDbPath, opts = {}) {
db.close();
}
}

/**
* Find node keys (name\0file\0line) for symbols whose callers are ALL in test files.
* These symbols have fanIn > 0 in the full graph but would have fanIn === 0
* if test-file edges were excluded.
*/
function _findTestOnlyCalledIds(db) {
// Check whether any test-file nodes exist in the graph at all.
// If the graph was built with -T (excluding test files), there will be
// no test callers and the query below would silently return nothing.
const files = db.prepare(`SELECT DISTINCT file FROM nodes WHERE kind = 'file'`).all();
const hasTestNodes = files.some((r) => isTestFile(r.file));
if (!hasTestNodes) {
warn(
'No test file nodes found in the graph — run "codegraph build" without -T to enable test-only detection',
);
return new Set();
}

// Get all non-test symbols that have at least one caller
const rows = db
.prepare(
`SELECT target.name, target.file, target.line,
caller.file AS caller_file
FROM edges e
JOIN nodes target ON e.target_id = target.id
JOIN nodes caller ON e.source_id = caller.id
WHERE e.kind = 'calls'
AND target.kind NOT IN ('file', 'directory')`,
)
.all();

// Group callers by target symbol
const callersByTarget = new Map();
for (const r of rows) {
const key = `${r.name}\0${r.file}\0${r.line}`;
if (!callersByTarget.has(key))
callersByTarget.set(key, { hasTestCaller: false, hasNonTestCaller: false });
const entry = callersByTarget.get(key);
if (isTestFile(r.caller_file)) {
entry.hasTestCaller = true;
} else {
entry.hasNonTestCaller = true;
}
}

// Return keys where ALL callers are in test files
const result = new Set();
for (const [key, entry] of callersByTarget) {
if (entry.hasTestCaller && !entry.hasNonTestCaller) {
result.add(key);
}
}
return result;
}
4 changes: 2 additions & 2 deletions src/features/structure.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export function classifyNodeRoles(db) {
.all();

if (rows.length === 0) {
return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, 'test-only': 0, leaf: 0 };
}

const exportedIds = new Set(
Expand Down Expand Up @@ -363,7 +363,7 @@ export function classifyNodeRoles(db) {
const roleMap = classifyRoles(classifierInput);

// Build summary and updates
const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, 'test-only': 0, leaf: 0 };
const updates = [];
for (const row of rows) {
const role = roleMap.get(String(row.id)) || 'leaf';
Expand Down
6 changes: 3 additions & 3 deletions src/graph/classifiers/roles.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Node role classification — pure logic, no DB.
*
* Roles: entry, core, utility, adapter, leaf, dead
* Roles: entry, core, utility, adapter, leaf, dead, test-only
*/

export const FRAMEWORK_ENTRY_PREFIXES = ['route:', 'event:', 'command:'];
Expand All @@ -15,7 +15,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 }[]} nodes
* @param {{ id: string, name: string, fanIn: number, fanOut: number, isExported: boolean, testOnlyFanIn?: number }[]} nodes
* @returns {Map<string, string>} nodeId → role
*/
export function classifyRoles(nodes) {
Expand Down Expand Up @@ -44,7 +44,7 @@ export function classifyRoles(nodes) {
if (isFrameworkEntry) {
role = 'entry';
} else if (node.fanIn === 0 && !node.isExported) {
role = 'dead';
role = node.testOnlyFanIn > 0 ? 'test-only' : 'dead';
} else if (node.fanIn === 0 && node.isExported) {
role = 'entry';
} else if (highIn && !highOut) {
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tool-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, test-only, leaf) based on connectivity patterns',
inputSchema: {
type: 'object',
properties: {
Expand Down
2 changes: 1 addition & 1 deletion src/shared/kinds.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ 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', 'leaf'];
export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'test-only', 'leaf'];
25 changes: 25 additions & 0 deletions tests/graph/classifiers/roles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,29 @@ describe('classifyRoles', () => {
const roles = classifyRoles(nodes);
expect(roles.get('1')).toBe('leaf');
});

it('classifies test-only when fanIn is 0 but testOnlyFanIn > 0', () => {
const nodes = [
{ id: '1', name: 'helperForTests', fanIn: 0, fanOut: 0, isExported: false, testOnlyFanIn: 3 },
];
const roles = classifyRoles(nodes);
expect(roles.get('1')).toBe('test-only');
});

it('classifies dead when fanIn is 0 and testOnlyFanIn is 0', () => {
const nodes = [
{ id: '1', name: 'reallyDead', fanIn: 0, fanOut: 0, isExported: false, testOnlyFanIn: 0 },
];
const roles = classifyRoles(nodes);
expect(roles.get('1')).toBe('dead');
});

it('ignores testOnlyFanIn when fanIn > 0', () => {
const nodes = [
{ 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('leaf');
});
});
34 changes: 34 additions & 0 deletions tests/integration/roles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ beforeAll(() => {
const helper = insertNode(db, 'helper', 'function', 'lib.js', 1);
const format = insertNode(db, 'format', 'function', 'lib.js', 10);
insertNode(db, 'unused', 'function', 'lib.js', 20);
const testHelper = insertNode(db, 'testHelper', 'function', 'lib.js', 30);
const testFn = insertNode(db, 'testMain', 'function', 'app.test.js', 1);

// Import edges
Expand All @@ -72,11 +73,13 @@ beforeAll(() => {
// processData → format (cross-file) → makes format exported
// helper → format (same file)
// testFn → main (cross-file) → makes main exported
// testFn → testHelper (cross-file) → testHelper only called from test
insertEdge(db, main, process_, 'calls');
insertEdge(db, main, helper, 'calls');
insertEdge(db, process_, format, 'calls');
insertEdge(db, helper, format, 'calls');
insertEdge(db, testFn, main, 'calls');
insertEdge(db, testFn, testHelper, 'calls');

// unused has no callers and no cross-file callers → dead

Expand Down Expand Up @@ -133,6 +136,37 @@ describe('rolesData', () => {
expect(s.file).not.toMatch(/\.test\./);
}
});

test('reclassifies test-only-called symbols when noTests is true', () => {
const data = rolesData(dbPath, { noTests: true });
const th = data.symbols.find((s) => s.name === 'testHelper');
expect(th).toBeDefined();
expect(th.role).toBe('test-only');
});

test('does not reclassify test-only-called symbols when noTests is false', () => {
const data = rolesData(dbPath);
const th = data.symbols.find((s) => s.name === 'testHelper');
expect(th).toBeDefined();
expect(th.role).not.toBe('test-only');
});

test('filters by role=test-only with noTests', () => {
const data = rolesData(dbPath, { noTests: true, role: 'test-only' });
expect(data.count).toBeGreaterThan(0);
for (const s of data.symbols) {
expect(s.role).toBe('test-only');
}
const names = data.symbols.map((s) => s.name);
expect(names).toContain('testHelper');
});

test('unused symbol stays dead with noTests', () => {
const data = rolesData(dbPath, { noTests: true });
const unused = data.symbols.find((s) => s.name === 'unused');
expect(unused).toBeDefined();
expect(unused.role).toBe('dead');
});
});

// ─── statsData includes roles ───────────────────────────────────────────
Expand Down
10 changes: 9 additions & 1 deletion tests/unit/roles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,15 @@ describe('classifyNodeRoles', () => {

it('handles empty graph without crashing', () => {
const summary = classifyNodeRoles(db);
expect(summary).toEqual({ entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 });
expect(summary).toEqual({
entry: 0,
core: 0,
utility: 0,
adapter: 0,
dead: 0,
'test-only': 0,
leaf: 0,
});
});

it('adapts median thresholds to data', () => {
Expand Down
Loading