Skip to content

Commit 412874a

Browse files
committed
refactor: extract unified graph model subsystem (ROADMAP 3.11)
Extract shared src/graph/ subsystem from duplicated graph construction across cycles.js, communities.js, viewer.js, structure.js, and triage.js. - CodeGraph class: nodes, edges, adjacency, filtering, graphology conversion - Algorithms: Tarjan SCC, Louvain communities, BFS, shortest path, centrality - Classifiers: role classification (from structure.js), risk scoring (from triage.js) - Builders: dependency graph, structure graph, temporal graph (from DB) - All consumers refactored to use graph subsystem; public APIs unchanged - 66 new unit tests, all 1670 existing tests still pass
1 parent 637fc01 commit 412874a

32 files changed

Lines changed: 1613 additions & 306 deletions

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The
4848
| `embeddings/` | Embedding subsystem: model management, vector generation, semantic/keyword/hybrid search, CLI formatting |
4949
| `db.js` | SQLite schema and operations (`better-sqlite3`) |
5050
| `mcp.js` | MCP server exposing graph queries to AI agents; single-repo by default, `--multi-repo` to enable cross-repo access |
51-
| `cycles.js` | Circular dependency detection |
51+
| `graph/` | Unified graph model: `CodeGraph` class (`model.js`), algorithms (Tarjan SCC, Louvain, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) |
52+
| `cycles.js` | Circular dependency detection (delegates to `graph/` subsystem) |
5253
| `export.js` | DOT/Mermaid/JSON graph export |
5354
| `watcher.js` | Watch mode for incremental rebuilds |
5455
| `config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution |
@@ -58,11 +59,11 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The
5859
| `resolve.js` | Import resolution (supports native batch mode) |
5960
| `ast-analysis/` | Unified AST analysis framework: shared DFS walker (`visitor.js`), engine orchestrator (`engine.js`), extracted metrics (`metrics.js`), and pluggable visitors for complexity, dataflow, and AST-store |
6061
| `complexity.js` | Cognitive, cyclomatic, Halstead, MI computation from AST; `complexity` CLI command |
61-
| `communities.js` | Louvain community detection, drift analysis |
62+
| `communities.js` | Louvain community detection, drift analysis (delegates to `graph/` subsystem) |
6263
| `manifesto.js` | Configurable rule engine with warn/fail thresholds; CI gate |
6364
| `audit.js` | Composite audit command: explain + impact + health in one call |
6465
| `batch.js` | Batch querying for multi-agent dispatch |
65-
| `triage.js` | Risk-ranked audit priority queue |
66+
| `triage.js` | Risk-ranked audit priority queue (delegates scoring to `graph/classifiers/`) |
6667
| `check.js` | CI validation predicates (cycles, complexity, blast radius, boundaries) |
6768
| `boundaries.js` | Architecture boundary rules with onion architecture preset |
6869
| `owners.js` | CODEOWNERS integration for ownership queries |

src/communities.js

Lines changed: 15 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,9 @@
11
import path from 'node:path';
2-
import Graph from 'graphology';
3-
import louvain from 'graphology-communities-louvain';
4-
import {
5-
getCallableNodes,
6-
getCallEdges,
7-
getFileNodesAll,
8-
getImportEdges,
9-
openReadonlyOrFail,
10-
} from './db.js';
11-
import { isTestFile } from './infrastructure/test-filter.js';
2+
import { openReadonlyOrFail } from './db.js';
3+
import { louvainCommunities } from './graph/algorithms/louvain.js';
4+
import { buildDependencyGraph } from './graph/builders/dependency.js';
125
import { paginateResult } from './paginate.js';
136

14-
// ─── Graph Construction ───────────────────────────────────────────────
15-
16-
/**
17-
* Build a graphology graph from the codegraph SQLite database.
18-
*
19-
* @param {object} db - open better-sqlite3 database (readonly)
20-
* @param {object} opts
21-
* @param {boolean} [opts.functions] - Function-level instead of file-level
22-
* @param {boolean} [opts.noTests] - Exclude test files
23-
* @returns {Graph}
24-
*/
25-
function buildGraphologyGraph(db, opts = {}) {
26-
const graph = new Graph({ type: 'undirected' });
27-
28-
if (opts.functions) {
29-
// Function-level: nodes = function/method/class symbols, edges = calls
30-
let nodes = getCallableNodes(db);
31-
if (opts.noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
32-
33-
const nodeIds = new Set();
34-
for (const n of nodes) {
35-
const key = String(n.id);
36-
graph.addNode(key, { label: n.name, file: n.file, kind: n.kind });
37-
nodeIds.add(n.id);
38-
}
39-
40-
const edges = getCallEdges(db);
41-
for (const e of edges) {
42-
if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
43-
const src = String(e.source_id);
44-
const tgt = String(e.target_id);
45-
if (src === tgt) continue;
46-
if (!graph.hasEdge(src, tgt)) {
47-
graph.addEdge(src, tgt);
48-
}
49-
}
50-
} else {
51-
// File-level: nodes = files, edges = imports + imports-type (deduplicated, cross-file)
52-
let nodes = getFileNodesAll(db);
53-
if (opts.noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
54-
55-
const nodeIds = new Set();
56-
for (const n of nodes) {
57-
const key = String(n.id);
58-
graph.addNode(key, { label: n.file, file: n.file });
59-
nodeIds.add(n.id);
60-
}
61-
62-
const edges = getImportEdges(db);
63-
for (const e of edges) {
64-
if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
65-
const src = String(e.source_id);
66-
const tgt = String(e.target_id);
67-
if (src === tgt) continue;
68-
if (!graph.hasEdge(src, tgt)) {
69-
graph.addEdge(src, tgt);
70-
}
71-
}
72-
}
73-
74-
return graph;
75-
}
76-
777
// ─── Directory Helpers ────────────────────────────────────────────────
788

799
function getDirectory(filePath) {
@@ -97,39 +27,38 @@ function getDirectory(filePath) {
9727
*/
9828
export function communitiesData(customDbPath, opts = {}) {
9929
const db = openReadonlyOrFail(customDbPath);
100-
const resolution = opts.resolution ?? 1.0;
10130
let graph;
10231
try {
103-
graph = buildGraphologyGraph(db, {
104-
functions: opts.functions,
32+
graph = buildDependencyGraph(db, {
33+
fileLevel: !opts.functions,
10534
noTests: opts.noTests,
10635
});
10736
} finally {
10837
db.close();
10938
}
11039

11140
// Handle empty or trivial graphs
112-
if (graph.order === 0 || graph.size === 0) {
41+
if (graph.nodeCount === 0 || graph.edgeCount === 0) {
11342
return {
11443
communities: [],
11544
modularity: 0,
11645
drift: { splitCandidates: [], mergeCandidates: [] },
117-
summary: { communityCount: 0, modularity: 0, nodeCount: graph.order, driftScore: 0 },
46+
summary: { communityCount: 0, modularity: 0, nodeCount: graph.nodeCount, driftScore: 0 },
11847
};
11948
}
12049

12150
// Run Louvain
122-
const details = louvain.detailed(graph, { resolution });
123-
const assignments = details.communities; // node → community id
124-
const modularity = details.modularity;
51+
const resolution = opts.resolution ?? 1.0;
52+
const { assignments, modularity } = louvainCommunities(graph, { resolution });
12553

12654
// Group nodes by community
12755
const communityMap = new Map(); // community id → node keys[]
128-
graph.forEachNode((key) => {
129-
const cid = assignments[key];
56+
for (const [key] of graph.nodes()) {
57+
const cid = assignments.get(key);
58+
if (cid == null) continue;
13059
if (!communityMap.has(cid)) communityMap.set(cid, []);
13160
communityMap.get(cid).push(key);
132-
});
61+
}
13362

13463
// Build community objects
13564
const communities = [];
@@ -139,7 +68,7 @@ export function communitiesData(customDbPath, opts = {}) {
13968
const dirCounts = {};
14069
const memberData = [];
14170
for (const key of members) {
142-
const attrs = graph.getNodeAttributes(key);
71+
const attrs = graph.getNodeAttrs(key);
14372
const dir = getDirectory(attrs.file);
14473
dirCounts[dir] = (dirCounts[dir] || 0) + 1;
14574
memberData.push({
@@ -196,7 +125,6 @@ export function communitiesData(customDbPath, opts = {}) {
196125
mergeCandidates.sort((a, b) => b.directoryCount - a.directoryCount);
197126

198127
// Drift score: 0-100 based on how much directory structure diverges from communities
199-
// Higher = more drift (directories don't match communities)
200128
const totalDirs = dirToCommunities.size;
201129
const splitDirs = splitCandidates.length;
202130
const splitRatio = totalDirs > 0 ? splitDirs / totalDirs : 0;
@@ -214,7 +142,7 @@ export function communitiesData(customDbPath, opts = {}) {
214142
summary: {
215143
communityCount: communities.length,
216144
modularity: +modularity.toFixed(4),
217-
nodeCount: graph.order,
145+
nodeCount: graph.nodeCount,
218146
driftScore,
219147
},
220148
};

src/cycles.js

Lines changed: 30 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { isTestFile } from './infrastructure/test-filter.js';
1+
import { tarjan } from './graph/algorithms/tarjan.js';
2+
import { buildDependencyGraph } from './graph/builders/dependency.js';
3+
import { CodeGraph } from './graph/model.js';
24
import { loadNative } from './native.js';
35

46
/**
@@ -12,107 +14,50 @@ export function findCycles(db, opts = {}) {
1214
const fileLevel = opts.fileLevel !== false;
1315
const noTests = opts.noTests || false;
1416

15-
// Build adjacency list from SQLite (stays in JS — only the algorithm can move to Rust)
16-
let edges;
17-
if (fileLevel) {
18-
edges = db
19-
.prepare(`
20-
SELECT DISTINCT n1.file AS source, n2.file AS target
21-
FROM edges e
22-
JOIN nodes n1 ON e.source_id = n1.id
23-
JOIN nodes n2 ON e.target_id = n2.id
24-
WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type')
25-
`)
26-
.all();
27-
if (noTests) {
28-
edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
29-
}
30-
} else {
31-
edges = db
32-
.prepare(`
33-
SELECT DISTINCT
34-
(n1.name || '|' || n1.file) AS source,
35-
(n2.name || '|' || n2.file) AS target
36-
FROM edges e
37-
JOIN nodes n1 ON e.source_id = n1.id
38-
JOIN nodes n2 ON e.target_id = n2.id
39-
WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
40-
AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
41-
AND e.kind = 'calls'
42-
AND n1.id != n2.id
43-
`)
44-
.all();
45-
if (noTests) {
46-
edges = edges.filter((e) => {
47-
const sourceFile = e.source.split('|').pop();
48-
const targetFile = e.target.split('|').pop();
49-
return !isTestFile(sourceFile) && !isTestFile(targetFile);
50-
});
17+
const graph = buildDependencyGraph(db, { fileLevel, noTests });
18+
19+
// Build a label map: DB string ID → human-readable key
20+
// File-level: file path; Function-level: name|file composite (for native Rust compat)
21+
const idToLabel = new Map();
22+
for (const [id, attrs] of graph.nodes()) {
23+
if (fileLevel) {
24+
idToLabel.set(id, attrs.file);
25+
} else {
26+
idToLabel.set(id, `${attrs.label}|${attrs.file}`);
5127
}
5228
}
5329

30+
// Build edge array with human-readable keys (for native engine)
31+
const edges = graph.toEdgeArray().map((e) => ({
32+
source: idToLabel.get(e.source),
33+
target: idToLabel.get(e.target),
34+
}));
35+
5436
// Try native Rust implementation
5537
const native = loadNative();
5638
if (native) {
5739
return native.detectCycles(edges);
5840
}
5941

60-
// Fallback: JS Tarjan
61-
return findCyclesJS(edges);
42+
// Fallback: JS Tarjan via graph subsystem
43+
// Re-key graph with human-readable labels for consistent output
44+
const labelGraph = new CodeGraph();
45+
for (const { source, target } of edges) {
46+
labelGraph.addEdge(source, target);
47+
}
48+
return tarjan(labelGraph);
6249
}
6350

6451
/**
6552
* Pure-JS Tarjan's SCC implementation.
53+
* Kept for backward compatibility — accepts raw {source, target}[] edges.
6654
*/
6755
export function findCyclesJS(edges) {
68-
const graph = new Map();
56+
const graph = new CodeGraph();
6957
for (const { source, target } of edges) {
70-
if (!graph.has(source)) graph.set(source, []);
71-
graph.get(source).push(target);
72-
if (!graph.has(target)) graph.set(target, []);
58+
graph.addEdge(source, target);
7359
}
74-
75-
// Tarjan's strongly connected components algorithm
76-
let index = 0;
77-
const stack = [];
78-
const onStack = new Set();
79-
const indices = new Map();
80-
const lowlinks = new Map();
81-
const sccs = [];
82-
83-
function strongconnect(v) {
84-
indices.set(v, index);
85-
lowlinks.set(v, index);
86-
index++;
87-
stack.push(v);
88-
onStack.add(v);
89-
90-
for (const w of graph.get(v) || []) {
91-
if (!indices.has(w)) {
92-
strongconnect(w);
93-
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
94-
} else if (onStack.has(w)) {
95-
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
96-
}
97-
}
98-
99-
if (lowlinks.get(v) === indices.get(v)) {
100-
const scc = [];
101-
let w;
102-
do {
103-
w = stack.pop();
104-
onStack.delete(w);
105-
scc.push(w);
106-
} while (w !== v);
107-
if (scc.length > 1) sccs.push(scc);
108-
}
109-
}
110-
111-
for (const node of graph.keys()) {
112-
if (!indices.has(node)) strongconnect(node);
113-
}
114-
115-
return sccs;
60+
return tarjan(graph);
11661
}
11762

11863
/**

src/graph/algorithms/bfs.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Breadth-first traversal on a CodeGraph.
3+
*
4+
* @param {import('../model.js').CodeGraph} graph
5+
* @param {string|string[]} startIds - One or more starting node IDs
6+
* @param {{ maxDepth?: number, direction?: 'forward'|'backward'|'both' }} [opts]
7+
* @returns {Map<string, number>} nodeId → depth from nearest start node
8+
*/
9+
export function bfs(graph, startIds, opts = {}) {
10+
const maxDepth = opts.maxDepth ?? Infinity;
11+
const direction = opts.direction ?? 'forward';
12+
const starts = Array.isArray(startIds) ? startIds : [startIds];
13+
14+
const depths = new Map();
15+
const queue = [];
16+
17+
for (const id of starts) {
18+
const key = String(id);
19+
if (graph.hasNode(key)) {
20+
depths.set(key, 0);
21+
queue.push(key);
22+
}
23+
}
24+
25+
let head = 0;
26+
while (head < queue.length) {
27+
const current = queue[head++];
28+
const depth = depths.get(current);
29+
if (depth >= maxDepth) continue;
30+
31+
let neighbors;
32+
if (direction === 'forward') {
33+
neighbors = graph.successors(current);
34+
} else if (direction === 'backward') {
35+
neighbors = graph.predecessors(current);
36+
} else {
37+
neighbors = graph.neighbors(current);
38+
}
39+
40+
for (const n of neighbors) {
41+
if (!depths.has(n)) {
42+
depths.set(n, depth + 1);
43+
queue.push(n);
44+
}
45+
}
46+
}
47+
48+
return depths;
49+
}

src/graph/algorithms/centrality.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Fan-in / fan-out centrality for all nodes in a CodeGraph.
3+
*
4+
* @param {import('../model.js').CodeGraph} graph
5+
* @returns {Map<string, { fanIn: number, fanOut: number }>}
6+
*/
7+
export function fanInOut(graph) {
8+
const result = new Map();
9+
for (const id of graph.nodeIds()) {
10+
result.set(id, {
11+
fanIn: graph.inDegree(id),
12+
fanOut: graph.outDegree(id),
13+
});
14+
}
15+
return result;
16+
}

src/graph/algorithms/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { bfs } from './bfs.js';
2+
export { fanInOut } from './centrality.js';
3+
export { louvainCommunities } from './louvain.js';
4+
export { shortestPath } from './shortest-path.js';
5+
export { tarjan } from './tarjan.js';

0 commit comments

Comments
 (0)