diff --git a/package-lock.json b/package-lock.json index dceb33e5..7916b2bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "better-sqlite3": "^12.6.2", "commander": "^14.0.3", + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.2", "web-tree-sitter": "^0.26.5" }, "bin": { @@ -1566,7 +1568,7 @@ "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -3354,6 +3356,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -3866,6 +3877,63 @@ "dev": true, "license": "ISC" }, + "node_modules/graphology": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.25.4.tgz", + "integrity": "sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "obliterator": "^2.0.2" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4316,7 +4384,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/jsonparse": { @@ -4874,6 +4942,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -5006,6 +5083,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5081,6 +5164,15 @@ "node": ">=6" } }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6557,7 +6649,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/package.json b/package.json index 3a14a812..3b575a37 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "dependencies": { "better-sqlite3": "^12.6.2", "commander": "^14.0.3", + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.2", "web-tree-sitter": "^0.26.5" }, "peerDependencies": { diff --git a/src/cli.js b/src/cli.js index 8b3ed6e1..21a23aae 100644 --- a/src/cli.js +++ b/src/cli.js @@ -137,8 +137,8 @@ program .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') - .action((opts) => { - stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json }); + .action(async (opts) => { + await stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json }); }); program @@ -742,6 +742,27 @@ program }); }); +program + .command('communities') + .description('Detect natural module boundaries using Louvain community detection') + .option('--functions', 'Function-level instead of file-level') + .option('--resolution ', 'Louvain resolution parameter (default 1.0)', '1.0') + .option('--drift', 'Show only drift analysis') + .option('-d, --db ', 'Path to graph.db') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-j, --json', 'Output as JSON') + .action(async (opts) => { + const { communities } = await import('./communities.js'); + communities(opts.db, { + functions: opts.functions, + resolution: parseFloat(opts.resolution), + drift: opts.drift, + noTests: resolveNoTests(opts), + json: opts.json, + }); + }); + program .command('branch-compare ') .description('Compare code structure between two branches/refs') diff --git a/src/communities.js b/src/communities.js new file mode 100644 index 00000000..7eba4071 --- /dev/null +++ b/src/communities.js @@ -0,0 +1,303 @@ +import path from 'node:path'; +import Graph from 'graphology'; +import louvain from 'graphology-communities-louvain'; +import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './queries.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 = db + .prepare("SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class')") + .all(); + 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 = db.prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls'").all(); + 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 = db.prepare("SELECT id, name, file FROM nodes WHERE kind = 'file'").all(); + 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 = db + .prepare("SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type')") + .all(); + 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) { + const dir = path.dirname(filePath); + return dir === '.' ? '(root)' : dir; +} + +// ─── Core Analysis ──────────────────────────────────────────────────── + +/** + * Run Louvain community detection and return structured data. + * + * @param {string} [customDbPath] - Path to graph.db + * @param {object} [opts] + * @param {boolean} [opts.functions] - Function-level instead of file-level + * @param {number} [opts.resolution] - Louvain resolution (default 1.0) + * @param {boolean} [opts.noTests] - Exclude test files + * @param {boolean} [opts.drift] - Drift-only mode (omit community member lists) + * @param {boolean} [opts.json] - JSON output (used by CLI wrapper only) + * @returns {{ communities: object[], modularity: number, drift: object, summary: object }} + */ +export function communitiesData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + const resolution = opts.resolution ?? 1.0; + + const graph = buildGraphologyGraph(db, { + functions: opts.functions, + noTests: opts.noTests, + }); + db.close(); + + // Handle empty or trivial graphs + if (graph.order === 0 || graph.size === 0) { + return { + communities: [], + modularity: 0, + drift: { splitCandidates: [], mergeCandidates: [] }, + summary: { communityCount: 0, modularity: 0, nodeCount: graph.order, driftScore: 0 }, + }; + } + + // Run Louvain + const details = louvain.detailed(graph, { resolution }); + const assignments = details.communities; // node → community id + const modularity = details.modularity; + + // Group nodes by community + const communityMap = new Map(); // community id → node keys[] + graph.forEachNode((key) => { + const cid = assignments[key]; + if (!communityMap.has(cid)) communityMap.set(cid, []); + communityMap.get(cid).push(key); + }); + + // Build community objects + const communities = []; + const communityDirs = new Map(); // community id → Set + + for (const [cid, members] of communityMap) { + const dirCounts = {}; + const memberData = []; + for (const key of members) { + const attrs = graph.getNodeAttributes(key); + const dir = getDirectory(attrs.file); + dirCounts[dir] = (dirCounts[dir] || 0) + 1; + memberData.push({ + name: attrs.label, + file: attrs.file, + ...(attrs.kind ? { kind: attrs.kind } : {}), + }); + } + + communityDirs.set(cid, new Set(Object.keys(dirCounts))); + + communities.push({ + id: cid, + size: members.length, + directories: dirCounts, + ...(opts.drift ? {} : { members: memberData }), + }); + } + + // Sort by size descending + communities.sort((a, b) => b.size - a.size); + + // ─── Drift Analysis ───────────────────────────────────────────── + + // Split candidates: directories with members in 2+ communities + const dirToCommunities = new Map(); // dir → Set + for (const [cid, dirs] of communityDirs) { + for (const dir of dirs) { + if (!dirToCommunities.has(dir)) dirToCommunities.set(dir, new Set()); + dirToCommunities.get(dir).add(cid); + } + } + const splitCandidates = []; + for (const [dir, cids] of dirToCommunities) { + if (cids.size >= 2) { + splitCandidates.push({ directory: dir, communityCount: cids.size }); + } + } + splitCandidates.sort((a, b) => b.communityCount - a.communityCount); + + // Merge candidates: communities spanning 2+ directories + const mergeCandidates = []; + for (const c of communities) { + const dirCount = Object.keys(c.directories).length; + if (dirCount >= 2) { + mergeCandidates.push({ + communityId: c.id, + size: c.size, + directoryCount: dirCount, + directories: Object.keys(c.directories), + }); + } + } + 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; + + const totalComms = communities.length; + const mergeComms = mergeCandidates.length; + const mergeRatio = totalComms > 0 ? mergeComms / totalComms : 0; + + const driftScore = Math.round(((splitRatio + mergeRatio) / 2) * 100); + + return { + communities: opts.drift ? [] : communities, + modularity: +modularity.toFixed(4), + drift: { splitCandidates, mergeCandidates }, + summary: { + communityCount: communities.length, + modularity: +modularity.toFixed(4), + nodeCount: graph.order, + driftScore, + }, + }; +} + +/** + * Lightweight summary for stats integration. + * + * @param {string} [customDbPath] + * @param {object} [opts] + * @param {boolean} [opts.noTests] + * @returns {{ communityCount: number, modularity: number, driftScore: number }} + */ +export function communitySummaryForStats(customDbPath, opts = {}) { + const data = communitiesData(customDbPath, { ...opts, drift: true }); + return data.summary; +} + +// ─── CLI Display ────────────────────────────────────────────────────── + +/** + * CLI entry point: run community detection and print results. + * + * @param {string} [customDbPath] + * @param {object} [opts] + */ +export function communities(customDbPath, opts = {}) { + const data = communitiesData(customDbPath, opts); + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + if (data.summary.communityCount === 0) { + console.log( + '\nNo communities detected. The graph may be too small or disconnected.\n' + + 'Run "codegraph build" first to populate the graph.\n', + ); + return; + } + + const mode = opts.functions ? 'Function' : 'File'; + console.log(`\n# ${mode}-Level Communities\n`); + console.log( + ` ${data.summary.communityCount} communities | ${data.summary.nodeCount} nodes | modularity: ${data.summary.modularity} | drift: ${data.summary.driftScore}%\n`, + ); + + if (!opts.drift) { + for (const c of data.communities) { + const dirs = Object.entries(c.directories) + .sort((a, b) => b[1] - a[1]) + .map(([d, n]) => `${d} (${n})`) + .join(', '); + console.log(` Community ${c.id} (${c.size} members): ${dirs}`); + if (c.members) { + const shown = c.members.slice(0, 8); + for (const m of shown) { + const kind = m.kind ? ` [${m.kind}]` : ''; + console.log(` - ${m.name}${kind} ${m.file}`); + } + if (c.members.length > 8) { + console.log(` ... and ${c.members.length - 8} more`); + } + } + } + } + + // Drift analysis + const d = data.drift; + if (d.splitCandidates.length > 0 || d.mergeCandidates.length > 0) { + console.log(`\n# Drift Analysis (score: ${data.summary.driftScore}%)\n`); + + if (d.splitCandidates.length > 0) { + console.log(' Split candidates (directories spanning multiple communities):'); + for (const s of d.splitCandidates.slice(0, 10)) { + console.log(` - ${s.directory} → ${s.communityCount} communities`); + } + } + + if (d.mergeCandidates.length > 0) { + console.log(' Merge candidates (communities spanning multiple directories):'); + for (const m of d.mergeCandidates.slice(0, 10)) { + console.log( + ` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`, + ); + } + } + } + + console.log(); +} diff --git a/src/index.js b/src/index.js index 2801ca9d..1bc45a7f 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,8 @@ export { computeCoChanges, scanGitHistory, } from './cochange.js'; +// Community detection +export { communities, communitiesData, communitySummaryForStats } from './communities.js'; // Complexity metrics export { COMPLEXITY_RULES, diff --git a/src/mcp.js b/src/mcp.js index 93dc8300..eb05054e 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -432,6 +432,32 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'communities', + description: + 'Detect natural module boundaries using Louvain community detection. Compares discovered communities against directory structure and surfaces architectural drift.', + inputSchema: { + type: 'object', + properties: { + functions: { + type: 'boolean', + description: 'Function-level instead of file-level', + default: false, + }, + resolution: { + type: 'number', + description: 'Louvain resolution parameter (higher = more communities)', + default: 1.0, + }, + drift: { + type: 'boolean', + description: 'Show only drift analysis (omit community member lists)', + default: false, + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + }, + }, + }, ]; const LIST_REPOS_TOOL = { @@ -762,6 +788,16 @@ export async function startMCPServer(customDbPath, options = {}) { }); break; } + case 'communities': { + const { communitiesData } = await import('./communities.js'); + result = communitiesData(dbPath, { + functions: args.functions, + resolution: args.resolution, + drift: args.drift, + noTests: args.no_tests, + }); + break; + } case 'list_repos': { const { listRepos, pruneRegistry } = await import('./registry.js'); pruneRegistry(); diff --git a/src/queries.js b/src/queries.js index 29894c43..e7549516 100644 --- a/src/queries.js +++ b/src/queries.js @@ -1400,8 +1400,17 @@ export function statsData(customDbPath, opts = {}) { }; } -export function stats(customDbPath, opts = {}) { +export async function stats(customDbPath, opts = {}) { const data = statsData(customDbPath, { noTests: opts.noTests }); + + // Community detection summary (async import for lazy-loading) + try { + const { communitySummaryForStats } = await import('./communities.js'); + data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); + } catch { + /* graphology may not be available */ + } + if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -1517,6 +1526,14 @@ export function stats(customDbPath, opts = {}) { ); } + // Communities + if (data.communities) { + const cm = data.communities; + console.log( + `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, + ); + } + console.log(); } diff --git a/tests/integration/communities.test.js b/tests/integration/communities.test.js new file mode 100644 index 00000000..cdd1224e --- /dev/null +++ b/tests/integration/communities.test.js @@ -0,0 +1,261 @@ +/** + * Integration tests for community detection (Louvain). + * + * Uses a hand-crafted in-file DB with multi-directory structure: + * + * src/auth/login.js + src/auth/session.js → tight auth cluster + * src/data/db.js + src/data/cache.js → tight data cluster + * src/api/handler.js → imports from both clusters (bridge) + * lib/format.js → depends on data modules (drift signal) + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { communitiesData, communitySummaryForStats } from '../../src/communities.js'; +import { initSchema } from '../../src/db.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-communities-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // ── File nodes (multi-directory) ── + const fAuthLogin = insertNode(db, 'src/auth/login.js', 'file', 'src/auth/login.js', 0); + const fAuthSession = insertNode(db, 'src/auth/session.js', 'file', 'src/auth/session.js', 0); + const fDataDb = insertNode(db, 'src/data/db.js', 'file', 'src/data/db.js', 0); + const fDataCache = insertNode(db, 'src/data/cache.js', 'file', 'src/data/cache.js', 0); + const fApiHandler = insertNode(db, 'src/api/handler.js', 'file', 'src/api/handler.js', 0); + const fLibFormat = insertNode(db, 'lib/format.js', 'file', 'lib/format.js', 0); + const fTestAuth = insertNode(db, 'tests/auth.test.js', 'file', 'tests/auth.test.js', 0); + + // ── Function nodes ── + const fnLogin = insertNode(db, 'login', 'function', 'src/auth/login.js', 5); + const fnCreateSession = insertNode(db, 'createSession', 'function', 'src/auth/session.js', 5); + const fnValidateSession = insertNode( + db, + 'validateSession', + 'function', + 'src/auth/session.js', + 20, + ); + const fnQuery = insertNode(db, 'query', 'function', 'src/data/db.js', 5); + const fnGetCache = insertNode(db, 'getCache', 'function', 'src/data/cache.js', 5); + const fnSetCache = insertNode(db, 'setCache', 'function', 'src/data/cache.js', 15); + const fnHandleRequest = insertNode(db, 'handleRequest', 'function', 'src/api/handler.js', 5); + const fnFormatOutput = insertNode(db, 'formatOutput', 'function', 'lib/format.js', 5); + const fnTestLogin = insertNode(db, 'testLogin', 'function', 'tests/auth.test.js', 5); + + // ── File-level import edges ── + // Auth cluster: login <-> session + insertEdge(db, fAuthLogin, fAuthSession, 'imports'); + insertEdge(db, fAuthSession, fAuthLogin, 'imports'); + + // Data cluster: db <-> cache + insertEdge(db, fDataDb, fDataCache, 'imports'); + insertEdge(db, fDataCache, fDataDb, 'imports'); + + // Bridge: api/handler imports from both clusters + insertEdge(db, fApiHandler, fAuthLogin, 'imports'); + insertEdge(db, fApiHandler, fDataDb, 'imports'); + + // Drift signal: lib/format depends on data modules + insertEdge(db, fLibFormat, fDataDb, 'imports'); + insertEdge(db, fLibFormat, fDataCache, 'imports'); + + // Test file imports + insertEdge(db, fTestAuth, fAuthLogin, 'imports'); + + // ── Function-level call edges ── + // Auth cluster calls + insertEdge(db, fnLogin, fnCreateSession, 'calls'); + insertEdge(db, fnLogin, fnValidateSession, 'calls'); + insertEdge(db, fnCreateSession, fnValidateSession, 'calls'); + + // Data cluster calls + insertEdge(db, fnQuery, fnGetCache, 'calls'); + insertEdge(db, fnQuery, fnSetCache, 'calls'); + insertEdge(db, fnGetCache, fnSetCache, 'calls'); + + // Bridge: handleRequest calls across clusters + insertEdge(db, fnHandleRequest, fnLogin, 'calls'); + insertEdge(db, fnHandleRequest, fnQuery, 'calls'); + insertEdge(db, fnHandleRequest, fnFormatOutput, 'calls'); + + // lib/format calls data + insertEdge(db, fnFormatOutput, fnGetCache, 'calls'); + + // Test calls + insertEdge(db, fnTestLogin, fnLogin, 'calls'); + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── File-Level Tests ────────────────────────────────────────────────── + +describe('communitiesData (file-level)', () => { + test('returns valid community structure', () => { + const data = communitiesData(dbPath); + expect(data.communities).toBeInstanceOf(Array); + expect(data.communities.length).toBeGreaterThan(0); + for (const c of data.communities) { + expect(c).toHaveProperty('id'); + expect(c).toHaveProperty('size'); + expect(c.size).toBeGreaterThan(0); + expect(c).toHaveProperty('members'); + expect(c.members.length).toBe(c.size); + expect(c).toHaveProperty('directories'); + } + }); + + test('detects 2+ communities from distinct clusters', () => { + const data = communitiesData(dbPath); + expect(data.summary.communityCount).toBeGreaterThanOrEqual(2); + }); + + test('modularity is between 0 and 1', () => { + const data = communitiesData(dbPath); + expect(data.modularity).toBeGreaterThanOrEqual(0); + expect(data.modularity).toBeLessThanOrEqual(1); + }); + + test('drift analysis finds split candidates', () => { + const data = communitiesData(dbPath); + // At minimum, lib/format.js groups with data but lives in a different dir + expect(data.drift).toHaveProperty('splitCandidates'); + expect(data.drift.splitCandidates).toBeInstanceOf(Array); + }); + + test('drift analysis finds merge candidates', () => { + const data = communitiesData(dbPath); + expect(data.drift).toHaveProperty('mergeCandidates'); + expect(data.drift.mergeCandidates).toBeInstanceOf(Array); + }); + + test('drift score is 0-100', () => { + const data = communitiesData(dbPath); + expect(data.summary.driftScore).toBeGreaterThanOrEqual(0); + expect(data.summary.driftScore).toBeLessThanOrEqual(100); + }); + + test('noTests excludes test files', () => { + const withTests = communitiesData(dbPath); + const withoutTests = communitiesData(dbPath, { noTests: true }); + + const allMembers = withTests.communities.flatMap((c) => c.members.map((m) => m.file)); + const filteredMembers = withoutTests.communities.flatMap((c) => c.members.map((m) => m.file)); + + expect(allMembers.some((f) => f.includes('.test.'))).toBe(true); + expect(filteredMembers.some((f) => f.includes('.test.'))).toBe(false); + }); + + test('higher resolution produces >= same number of communities', () => { + const low = communitiesData(dbPath, { resolution: 0.5 }); + const high = communitiesData(dbPath, { resolution: 2.0 }); + expect(high.summary.communityCount).toBeGreaterThanOrEqual(low.summary.communityCount); + }); +}); + +// ─── Function-Level Tests ────────────────────────────────────────────── + +describe('communitiesData (function-level)', () => { + test('returns function-level results with kind field', () => { + const data = communitiesData(dbPath, { functions: true }); + expect(data.communities.length).toBeGreaterThan(0); + for (const c of data.communities) { + for (const m of c.members) { + expect(m).toHaveProperty('kind'); + expect(['function', 'method', 'class']).toContain(m.kind); + } + } + }); + + test('function-level detects 2+ communities', () => { + const data = communitiesData(dbPath, { functions: true }); + expect(data.summary.communityCount).toBeGreaterThanOrEqual(2); + }); +}); + +// ─── Drift-Only Mode ────────────────────────────────────────────────── + +describe('drift-only mode', () => { + test('drift: true returns empty communities array', () => { + const data = communitiesData(dbPath, { drift: true }); + expect(data.communities).toEqual([]); + expect(data.drift.splitCandidates).toBeInstanceOf(Array); + expect(data.drift.mergeCandidates).toBeInstanceOf(Array); + expect(data.summary.communityCount).toBeGreaterThan(0); + }); +}); + +// ─── Stats Integration ──────────────────────────────────────────────── + +describe('communitySummaryForStats', () => { + test('returns lightweight summary with expected fields', () => { + const summary = communitySummaryForStats(dbPath); + expect(summary).toHaveProperty('communityCount'); + expect(summary).toHaveProperty('modularity'); + expect(summary).toHaveProperty('driftScore'); + expect(summary).toHaveProperty('nodeCount'); + expect(typeof summary.communityCount).toBe('number'); + expect(typeof summary.modularity).toBe('number'); + expect(typeof summary.driftScore).toBe('number'); + }); +}); + +// ─── Empty Graph ────────────────────────────────────────────────────── + +describe('empty graph', () => { + let emptyTmpDir, emptyDbPath; + + beforeAll(() => { + emptyTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-communities-empty-')); + fs.mkdirSync(path.join(emptyTmpDir, '.codegraph')); + emptyDbPath = path.join(emptyTmpDir, '.codegraph', 'graph.db'); + + const db = new Database(emptyDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + db.close(); + }); + + afterAll(() => { + if (emptyTmpDir) fs.rmSync(emptyTmpDir, { recursive: true, force: true }); + }); + + test('empty graph returns zero communities', () => { + const data = communitiesData(emptyDbPath); + expect(data.communities).toEqual([]); + expect(data.summary.communityCount).toBe(0); + expect(data.summary.modularity).toBe(0); + expect(data.summary.driftScore).toBe(0); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 8587be8a..2712b520 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -31,6 +31,7 @@ const ALL_TOOL_NAMES = [ 'execution_flow', 'list_entry_points', 'complexity', + 'communities', 'list_repos', ];