Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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
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
7 changes: 5 additions & 2 deletions src/db/repository/graph-read.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CORE_SYMBOL_KINDS } from '../../kinds.js';
import { cachedStmt } from './cached-stmt.js';

// ─── Statement caches (one prepared statement per db instance) ────────────
Expand All @@ -6,16 +7,18 @@ const _getCallEdgesStmt = new WeakMap();
const _getFileNodesAllStmt = new WeakMap();
const _getImportEdgesStmt = new WeakMap();

const CALLABLE_KINDS_SQL = CORE_SYMBOL_KINDS.map((k) => `'${k}'`).join(',');

/**
* Get callable nodes (function/method/class) for community detection.
* Get callable nodes (all core symbol kinds) for graph construction.
* @param {object} db
* @returns {{ id: number, name: string, kind: string, file: string }[]}
*/
export function getCallableNodes(db) {
return cachedStmt(
_getCallableNodesStmt,
db,
"SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class')",
`SELECT id, name, kind, file FROM nodes WHERE kind IN (${CALLABLE_KINDS_SQL})`,
).all();
}

Expand Down
Loading
Loading