Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1f44ed3
docs: align roadmap and backlog with architecture audit findings
carlos-alm Mar 18, 2026
17a9db0
docs: remove dual-engine architectural note from Phase 6
carlos-alm Mar 18, 2026
926b566
docs: add ADR-001 for dual-engine architecture decision
carlos-alm Mar 18, 2026
8c0b3b0
fix: backfill typeMap via WASM when native binary lacks type-map support
carlos-alm Mar 18, 2026
639d06c
docs: address review feedback on backlog/roadmap consistency (#503)
carlos-alm Mar 18, 2026
d888968
fix: use async fs.promises.readFile in typeMap backfill loop (#503)
carlos-alm Mar 18, 2026
8f40966
fix: reconcile Phase 5.9 Kill List with Phase 6.6 metric scope (#503)
carlos-alm Mar 18, 2026
dd747f1
Merge remote-tracking branch 'origin/main' into docs/roadmap-audit-al…
carlos-alm Mar 18, 2026
7802ab3
docs: fix missing strikethrough on completed/promoted backlog items
carlos-alm Mar 18, 2026
3d841d5
docs: remove dead link to unpublished architecture audit file
carlos-alm Mar 18, 2026
7945a05
Merge remote-tracking branch 'origin/main' into docs/roadmap-audit-al…
carlos-alm Mar 18, 2026
9e23ea3
feat: sub-classify dead role into dead-leaf, dead-entry, dead-ffi, de…
carlos-alm Mar 18, 2026
168cdce
docs: document dead-ffi vs dead-entry priority ordering
carlos-alm Mar 19, 2026
dec7b27
refactor: extract DEAD_ROLE_PREFIX constant to eliminate duplicated p…
carlos-alm Mar 19, 2026
ac87b8f
docs: fix stale JSDoc in roles.test.js (dead -> dead-unresolved)
carlos-alm Mar 19, 2026
057b6ec
fix: use parameterized LIKE binding for DEAD_ROLE_PREFIX queries
carlos-alm Mar 19, 2026
55bb468
fix: use DEAD_ROLE_PREFIX constant in module-map.js
carlos-alm Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions docs/roadmap/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>', 'Path to graph.db'],
['--role <role>', `Filter by role (${VALID_ROLES.join(', ')})`],
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ export const command = {
'risk',
],
['--min-score <score>', 'Only show symbols with risk score >= threshold'],
['--role <role>', 'Filter by role (entry, core, utility, adapter, leaf, dead)'],
[
'--role <role>',
'Filter by role (entry, core, utility, adapter, leaf, dead, dead-leaf, dead-entry, dead-ffi, dead-unresolved)',
],
['-f, --file <path>', 'Scope to a specific file (partial match, repeatable)', collectFile],
['-k, --kind <kind>', 'Filter by symbol kind (function, method, class)'],
['-T, --no-tests', 'Exclude test/spec files from results'],
Expand Down
13 changes: 9 additions & 4 deletions src/db/query-builder.js
Original file line number Diff line number Diff line change
@@ -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 ─────────────────────────────────────────────

Expand Down Expand Up @@ -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;
}

Expand Down
13 changes: 11 additions & 2 deletions src/db/repository/in-memory-repository.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion src/domain/analysis/module-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}

Expand Down
10 changes: 8 additions & 2 deletions src/domain/analysis/roles.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/features/graph-enrichment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
33 changes: 30 additions & 3 deletions src/features/structure.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand All @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions src/graph/classifiers/risk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 51 additions & 3 deletions src/graph/classifiers/roles.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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<string, string>} nodeId → role
*/
export function classifyRoles(nodes) {
Expand Down Expand Up @@ -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) {
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 [dead-leaf, dead-entry, dead-ffi, dead-unresolved], leaf) based on connectivity patterns',
inputSchema: {
type: 'object',
properties: {
Expand Down
15 changes: 14 additions & 1 deletion src/shared/kinds.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Loading
Loading