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
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 });

// 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