Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion src/builder/incremental.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { BUILTIN_RECEIVERS, readFileSafe } from './helpers.js';
* @param {Function} [options.diffSymbols] - Symbol diff function
* @returns {Promise<object|null>} Update result or null on failure
*/
export async function rebuildFile(db, rootDir, filePath, stmts, engineOpts, cache, options = {}) {
export async function rebuildFile(_db, rootDir, filePath, stmts, engineOpts, cache, options = {}) {
const { diffSymbols } = options;
const relPath = normalizePath(path.relative(rootDir, filePath));
const oldNodes = stmts.countNodes.get(relPath)?.c || 0;
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 });

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