Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The
| `embeddings/` | Embedding subsystem: model management, vector generation, semantic/keyword/hybrid search, CLI formatting |
| `db.js` | SQLite schema and operations (`better-sqlite3`) |
| `mcp.js` | MCP server exposing graph queries to AI agents; single-repo by default, `--multi-repo` to enable cross-repo access |
| `cycles.js` | Circular dependency detection |
| `graph/` | Unified graph model: `CodeGraph` class (`model.js`), algorithms (Tarjan SCC, Louvain, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) |
| `cycles.js` | Circular dependency detection (delegates to `graph/` subsystem) |
| `export.js` | DOT/Mermaid/JSON graph export |
| `watcher.js` | Watch mode for incremental rebuilds |
| `config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution |
Expand All @@ -58,11 +59,11 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The
| `resolve.js` | Import resolution (supports native batch mode) |
| `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 |
| `complexity.js` | Cognitive, cyclomatic, Halstead, MI computation from AST; `complexity` CLI command |
| `communities.js` | Louvain community detection, drift analysis |
| `communities.js` | Louvain community detection, drift analysis (delegates to `graph/` subsystem) |
| `manifesto.js` | Configurable rule engine with warn/fail thresholds; CI gate |
| `audit.js` | Composite audit command: explain + impact + health in one call |
| `batch.js` | Batch querying for multi-agent dispatch |
| `triage.js` | Risk-ranked audit priority queue |
| `triage.js` | Risk-ranked audit priority queue (delegates scoring to `graph/classifiers/`) |
| `check.js` | CI validation predicates (cycles, complexity, blast radius, boundaries) |
| `boundaries.js` | Architecture boundary rules with onion architecture preset |
| `owners.js` | CODEOWNERS integration for ownership queries |
Expand Down
102 changes: 15 additions & 87 deletions src/communities.js
Original file line number Diff line number Diff line change
@@ -1,79 +1,9 @@
import path from 'node:path';
import Graph from 'graphology';
import louvain from 'graphology-communities-louvain';
import {
getCallableNodes,
getCallEdges,
getFileNodesAll,
getImportEdges,
openReadonlyOrFail,
} from './db.js';
import { isTestFile } from './infrastructure/test-filter.js';
import { openReadonlyOrFail } from './db.js';
import { louvainCommunities } from './graph/algorithms/louvain.js';
import { buildDependencyGraph } from './graph/builders/dependency.js';
import { paginateResult } from './paginate.js';

// ─── Graph Construction ───────────────────────────────────────────────

/**
* Build a graphology graph from the codegraph SQLite database.
*
* @param {object} db - open better-sqlite3 database (readonly)
* @param {object} opts
* @param {boolean} [opts.functions] - Function-level instead of file-level
* @param {boolean} [opts.noTests] - Exclude test files
* @returns {Graph}
*/
function buildGraphologyGraph(db, opts = {}) {
const graph = new Graph({ type: 'undirected' });

if (opts.functions) {
// Function-level: nodes = function/method/class symbols, edges = calls
let nodes = getCallableNodes(db);
if (opts.noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

const nodeIds = new Set();
for (const n of nodes) {
const key = String(n.id);
graph.addNode(key, { label: n.name, file: n.file, kind: n.kind });
nodeIds.add(n.id);
}

const edges = getCallEdges(db);
for (const e of edges) {
if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
const src = String(e.source_id);
const tgt = String(e.target_id);
if (src === tgt) continue;
if (!graph.hasEdge(src, tgt)) {
graph.addEdge(src, tgt);
}
}
} else {
// File-level: nodes = files, edges = imports + imports-type (deduplicated, cross-file)
let nodes = getFileNodesAll(db);
if (opts.noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

const nodeIds = new Set();
for (const n of nodes) {
const key = String(n.id);
graph.addNode(key, { label: n.file, file: n.file });
nodeIds.add(n.id);
}

const edges = getImportEdges(db);
for (const e of edges) {
if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
const src = String(e.source_id);
const tgt = String(e.target_id);
if (src === tgt) continue;
if (!graph.hasEdge(src, tgt)) {
graph.addEdge(src, tgt);
}
}
}

return graph;
}

// ─── Directory Helpers ────────────────────────────────────────────────

function getDirectory(filePath) {
Expand All @@ -97,39 +27,38 @@ function getDirectory(filePath) {
*/
export function communitiesData(customDbPath, opts = {}) {
const db = openReadonlyOrFail(customDbPath);
const resolution = opts.resolution ?? 1.0;
let graph;
try {
graph = buildGraphologyGraph(db, {
functions: opts.functions,
graph = buildDependencyGraph(db, {
fileLevel: !opts.functions,
noTests: opts.noTests,
});
} finally {
db.close();
}

// Handle empty or trivial graphs
if (graph.order === 0 || graph.size === 0) {
if (graph.nodeCount === 0 || graph.edgeCount === 0) {
return {
communities: [],
modularity: 0,
drift: { splitCandidates: [], mergeCandidates: [] },
summary: { communityCount: 0, modularity: 0, nodeCount: graph.order, driftScore: 0 },
summary: { communityCount: 0, modularity: 0, nodeCount: graph.nodeCount, driftScore: 0 },
};
}

// Run Louvain
const details = louvain.detailed(graph, { resolution });
const assignments = details.communities; // node → community id
const modularity = details.modularity;
const resolution = opts.resolution ?? 1.0;
const { assignments, modularity } = louvainCommunities(graph, { resolution });

// Group nodes by community
const communityMap = new Map(); // community id → node keys[]
graph.forEachNode((key) => {
const cid = assignments[key];
for (const [key] of graph.nodes()) {
const cid = assignments.get(key);
if (cid == null) continue;
if (!communityMap.has(cid)) communityMap.set(cid, []);
communityMap.get(cid).push(key);
});
}

// Build community objects
const communities = [];
Expand All @@ -139,7 +68,7 @@ export function communitiesData(customDbPath, opts = {}) {
const dirCounts = {};
const memberData = [];
for (const key of members) {
const attrs = graph.getNodeAttributes(key);
const attrs = graph.getNodeAttrs(key);
const dir = getDirectory(attrs.file);
dirCounts[dir] = (dirCounts[dir] || 0) + 1;
memberData.push({
Expand Down Expand Up @@ -196,7 +125,6 @@ export function communitiesData(customDbPath, opts = {}) {
mergeCandidates.sort((a, b) => b.directoryCount - a.directoryCount);

// Drift score: 0-100 based on how much directory structure diverges from communities
// Higher = more drift (directories don't match communities)
const totalDirs = dirToCommunities.size;
const splitDirs = splitCandidates.length;
const splitRatio = totalDirs > 0 ? splitDirs / totalDirs : 0;
Expand All @@ -214,7 +142,7 @@ export function communitiesData(customDbPath, opts = {}) {
summary: {
communityCount: communities.length,
modularity: +modularity.toFixed(4),
nodeCount: graph.order,
nodeCount: graph.nodeCount,
driftScore,
},
};
Expand Down
115 changes: 30 additions & 85 deletions src/cycles.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { isTestFile } from './infrastructure/test-filter.js';
import { tarjan } from './graph/algorithms/tarjan.js';
import { buildDependencyGraph } from './graph/builders/dependency.js';
import { CodeGraph } from './graph/model.js';
import { loadNative } from './native.js';

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

// Build adjacency list from SQLite (stays in JS — only the algorithm can move to Rust)
let edges;
if (fileLevel) {
edges = db
.prepare(`
SELECT DISTINCT n1.file AS source, n2.file AS target
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type')
`)
.all();
if (noTests) {
edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
}
} else {
edges = db
.prepare(`
SELECT DISTINCT
(n1.name || '|' || n1.file) AS source,
(n2.name || '|' || n2.file) AS target
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
AND e.kind = 'calls'
AND n1.id != n2.id
`)
.all();
if (noTests) {
edges = edges.filter((e) => {
const sourceFile = e.source.split('|').pop();
const targetFile = e.target.split('|').pop();
return !isTestFile(sourceFile) && !isTestFile(targetFile);
});
const graph = buildDependencyGraph(db, { fileLevel, noTests });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function-level cycle detection silently drops 7 node kinds

The old findCycles function-level query explicitly filtered for 10 node kinds:

WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')

The new code delegates to buildDependencyGraph(..., { fileLevel: false }), which calls buildFunctionLevelGraph, which in turn calls getCallableNodes(db). That function only returns nodes matching kind IN ('function','method','class') — omitting interface, type, struct, enum, trait, record, and module.

As a result, any cycle that passes through one of those 7 kinds will now be silently missed by the JS fallback path. The native Rust path receives the same narrowed edge list, so it is affected equally.

This is a behavioral regression for the function-level (--functions) cycle detection command on codebases that use TypeScript interfaces, enums, Go structs, Rust traits, etc. involved in circular calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d3a71b. getCallableNodes now queries all 10 CORE_SYMBOL_KINDS instead of the hardcoded 3. The SQL is generated from the CORE_SYMBOL_KINDS array in kinds.js, so it stays in sync automatically if kinds are added in the future.


// Build a label map: DB string ID → human-readable key
// File-level: file path; Function-level: name|file composite (for native Rust compat)
const idToLabel = new Map();
for (const [id, attrs] of graph.nodes()) {
if (fileLevel) {
idToLabel.set(id, attrs.file);
} else {
idToLabel.set(id, `${attrs.label}|${attrs.file}`);
}
}

// Build edge array with human-readable keys (for native engine)
const edges = graph.toEdgeArray().map((e) => ({
source: idToLabel.get(e.source),
target: idToLabel.get(e.target),
}));

// Try native Rust implementation
const native = loadNative();
if (native) {
return native.detectCycles(edges);
}

// Fallback: JS Tarjan
return findCyclesJS(edges);
// Fallback: JS Tarjan via graph subsystem
// Re-key graph with human-readable labels for consistent output
const labelGraph = new CodeGraph();
for (const { source, target } of edges) {
labelGraph.addEdge(source, target);
}
return tarjan(labelGraph);
}

/**
* Pure-JS Tarjan's SCC implementation.
* Kept for backward compatibility — accepts raw {source, target}[] edges.
*/
export function findCyclesJS(edges) {
const graph = new Map();
const graph = new CodeGraph();
for (const { source, target } of edges) {
if (!graph.has(source)) graph.set(source, []);
graph.get(source).push(target);
if (!graph.has(target)) graph.set(target, []);
graph.addEdge(source, target);
}

// Tarjan's strongly connected components algorithm
let index = 0;
const stack = [];
const onStack = new Set();
const indices = new Map();
const lowlinks = new Map();
const sccs = [];

function strongconnect(v) {
indices.set(v, index);
lowlinks.set(v, index);
index++;
stack.push(v);
onStack.add(v);

for (const w of graph.get(v) || []) {
if (!indices.has(w)) {
strongconnect(w);
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
} else if (onStack.has(w)) {
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
}
}

if (lowlinks.get(v) === indices.get(v)) {
const scc = [];
let w;
do {
w = stack.pop();
onStack.delete(w);
scc.push(w);
} while (w !== v);
if (scc.length > 1) sccs.push(scc);
}
}

for (const node of graph.keys()) {
if (!indices.has(node)) strongconnect(node);
}

return sccs;
return tarjan(graph);
}

/**
Expand Down
49 changes: 49 additions & 0 deletions src/graph/algorithms/bfs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Breadth-first traversal on a CodeGraph.
*
* @param {import('../model.js').CodeGraph} graph
* @param {string|string[]} startIds - One or more starting node IDs
* @param {{ maxDepth?: number, direction?: 'forward'|'backward'|'both' }} [opts]
* @returns {Map<string, number>} nodeId → depth from nearest start node
*/
export function bfs(graph, startIds, opts = {}) {
const maxDepth = opts.maxDepth ?? Infinity;
const direction = opts.direction ?? 'forward';
const starts = Array.isArray(startIds) ? startIds : [startIds];

const depths = new Map();
const queue = [];

for (const id of starts) {
const key = String(id);
if (graph.hasNode(key)) {
depths.set(key, 0);
queue.push(key);
}
}

let head = 0;
while (head < queue.length) {
const current = queue[head++];
const depth = depths.get(current);
if (depth >= maxDepth) continue;

let neighbors;
if (direction === 'forward') {
neighbors = graph.successors(current);
} else if (direction === 'backward') {
neighbors = graph.predecessors(current);
} else {
neighbors = graph.neighbors(current);
}

for (const n of neighbors) {
if (!depths.has(n)) {
depths.set(n, depth + 1);
queue.push(n);
}
}
}

return depths;
}
16 changes: 16 additions & 0 deletions src/graph/algorithms/centrality.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Fan-in / fan-out centrality for all nodes in a CodeGraph.
*
* @param {import('../model.js').CodeGraph} graph
* @returns {Map<string, { fanIn: number, fanOut: number }>}
*/
export function fanInOut(graph) {
const result = new Map();
for (const id of graph.nodeIds()) {
result.set(id, {
fanIn: graph.inDegree(id),
fanOut: graph.outDegree(id),
});
}
return result;
}
5 changes: 5 additions & 0 deletions src/graph/algorithms/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { bfs } from './bfs.js';
export { fanInOut } from './centrality.js';
export { louvainCommunities } from './louvain.js';
export { shortestPath } from './shortest-path.js';
export { tarjan } from './tarjan.js';
Loading
Loading