@imayuur/contexthub-knowledge-graph 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ import type { CodeGraph, CodeGraphNode, CodeGraphEdge, GraphDiff } from '@imayuur/contexthub-shared-types';
2
+ export declare class CodeGraphManager {
3
+ private repoPath;
4
+ private contexthubPath;
5
+ private graphDirPath;
6
+ private graphPath;
7
+ private metaPath;
8
+ private snapshotDirPath;
9
+ private parser;
10
+ private security;
11
+ constructor(repoPath: string);
12
+ /**
13
+ * Helper to compute prefixed node ID and repository-relative path based on monorepo roots.
14
+ */
15
+ getNodeIdAndRelPath(filePath: string, roots: string[]): {
16
+ id: string;
17
+ relPath: string;
18
+ };
19
+ /**
20
+ * Build a completely fresh code graph of the repository.
21
+ */
22
+ buildCodeGraph(): Promise<CodeGraph>;
23
+ /**
24
+ * Incrementally update the graph for a list of changed files.
25
+ */
26
+ patchCodeGraph(changedPaths: string[]): Promise<CodeGraph>;
27
+ /**
28
+ * Get related symbols based on a file path or symbol ID.
29
+ */
30
+ getRelatedSymbols(fileOrSymbol: string, limit?: number): Promise<CodeGraphNode[]>;
31
+ /**
32
+ * Get the transitive blast radius of a file or symbol.
33
+ * Traverses backward (incoming edges: who imports or calls this node).
34
+ */
35
+ getBlastRadius(fileOrSymbol: string, depth?: number): Promise<{
36
+ nodes: CodeGraphNode[];
37
+ edges: CodeGraphEdge[];
38
+ }>;
39
+ /**
40
+ * Trace the shortest path from node A to node B using BFS.
41
+ */
42
+ tracePath(fromId: string, toId: string, maxHops?: number): Promise<Array<{
43
+ type: string;
44
+ id: string;
45
+ label: string;
46
+ }> | null>;
47
+ /**
48
+ * Get "god-nodes" — files with the highest total degree (in + out edges).
49
+ * These are the most interconnected files in the codebase.
50
+ */
51
+ getGodNodes(limit?: number): Promise<Array<{
52
+ id: string;
53
+ path: string;
54
+ degree: number;
55
+ inDegree: number;
56
+ outDegree: number;
57
+ }>>;
58
+ /**
59
+ * Detect communities using connected components on file-level nodes.
60
+ * Returns groups of files that are transitively connected via imports.
61
+ * Capped at 50 communities max.
62
+ */
63
+ detectCommunities(): Promise<Array<{
64
+ id: number;
65
+ files: string[];
66
+ size: number;
67
+ }>>;
68
+ /**
69
+ * Load the code graph from disk.
70
+ */
71
+ loadGraph(): Promise<CodeGraph>;
72
+ /**
73
+ * Save the code graph to disk atomically with encryption.
74
+ */
75
+ saveGraph(graph: CodeGraph): Promise<void>;
76
+ /**
77
+ * Create a snapshot of the current code graph and return its ID.
78
+ * Auto-cleans up keeping only the 5 most recent snapshots.
79
+ */
80
+ createGraphSnapshot(): Promise<string>;
81
+ /**
82
+ * Load a specific graph snapshot.
83
+ */
84
+ loadGraphSnapshot(snapshotId: string): Promise<CodeGraph | null>;
85
+ /**
86
+ * Diff two CodeGraphs and return the delta.
87
+ */
88
+ diffCodeGraph(oldGraph: CodeGraph, newGraph: CodeGraph): GraphDiff;
89
+ }
90
+ export { generateGraphReport, writeGraphReport } from './report';
91
+ export type { GraphReportOptions } from './report';
package/dist/index.js ADDED
@@ -0,0 +1,752 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.writeGraphReport = exports.generateGraphReport = exports.CodeGraphManager = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const crypto = __importStar(require("crypto"));
40
+ const contexthub_repo_parser_1 = require("@imayuur/contexthub-repo-parser");
41
+ const contexthub_core_1 = require("@imayuur/contexthub-core");
42
+ class CodeGraphManager {
43
+ constructor(repoPath) {
44
+ this.repoPath = path.resolve(repoPath);
45
+ this.contexthubPath = path.join(this.repoPath, '.contexthub');
46
+ this.graphDirPath = path.join(this.contexthubPath, 'graph');
47
+ this.graphPath = path.join(this.graphDirPath, 'code-graph.json');
48
+ this.metaPath = path.join(this.graphDirPath, 'index-meta.json');
49
+ this.snapshotDirPath = path.join(this.graphDirPath, 'snapshots');
50
+ this.parser = new contexthub_repo_parser_1.RepoParser(this.repoPath);
51
+ this.security = new contexthub_core_1.SecurityManager(this.repoPath);
52
+ // Ensure directory exists with secure permissions
53
+ if (!fs.existsSync(this.graphDirPath)) {
54
+ fs.mkdirSync(this.graphDirPath, { recursive: true });
55
+ this.security.setSecurePermissions(this.graphDirPath, true);
56
+ }
57
+ if (!fs.existsSync(this.snapshotDirPath)) {
58
+ fs.mkdirSync(this.snapshotDirPath, { recursive: true });
59
+ this.security.setSecurePermissions(this.snapshotDirPath, true);
60
+ }
61
+ }
62
+ /**
63
+ * Helper to compute prefixed node ID and repository-relative path based on monorepo roots.
64
+ */
65
+ getNodeIdAndRelPath(filePath, roots) {
66
+ const absPath = path.resolve(this.repoPath, filePath);
67
+ // Find the best matching root
68
+ let bestRoot = '.';
69
+ let bestRootLen = -1;
70
+ for (const r of roots) {
71
+ const absRoot = path.resolve(this.repoPath, r);
72
+ if (absPath.startsWith(absRoot + path.sep) || absPath === absRoot) {
73
+ if (r.length > bestRootLen) {
74
+ bestRoot = r;
75
+ bestRootLen = r.length;
76
+ }
77
+ }
78
+ }
79
+ const absRootPath = path.resolve(this.repoPath, bestRoot);
80
+ const relToRoot = path.relative(absRootPath, absPath).replace(/\\/g, '/');
81
+ const relToRepo = path.relative(this.repoPath, absPath).replace(/\\/g, '/');
82
+ if (bestRoot === '.') {
83
+ return { id: relToRepo, relPath: relToRepo };
84
+ }
85
+ else {
86
+ const prefix = `pkg:${path.basename(bestRoot)}`;
87
+ return { id: `${prefix}#${relToRoot}`, relPath: relToRepo };
88
+ }
89
+ }
90
+ // ─── Core Graph Operations ────────────────────────────────────────────────
91
+ /**
92
+ * Build a completely fresh code graph of the repository.
93
+ */
94
+ async buildCodeGraph() {
95
+ const config = (0, contexthub_core_1.loadConfig)(this.repoPath);
96
+ const roots = config.roots || ['.'];
97
+ const parsedFiles = [];
98
+ for (const r of roots) {
99
+ const rootDir = path.resolve(this.repoPath, r);
100
+ if (fs.existsSync(rootDir)) {
101
+ const pf = await this.parser.parseDirectory(rootDir);
102
+ parsedFiles.push(...pf);
103
+ }
104
+ }
105
+ // Deduplicate by path
106
+ const uniqueFiles = new Map();
107
+ for (const pf of parsedFiles) {
108
+ uniqueFiles.set(pf.path, pf);
109
+ }
110
+ const deduplicatedParsedFiles = Array.from(uniqueFiles.values());
111
+ const graph = {
112
+ version: '1.0.0',
113
+ updatedAt: Date.now(),
114
+ nodes: [],
115
+ edges: []
116
+ };
117
+ // Helper maps
118
+ const parsedFilesMap = new Map();
119
+ const symbolNodesMap = new Map();
120
+ // 1. Create all nodes (files and symbols)
121
+ for (const pf of deduplicatedParsedFiles) {
122
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
123
+ parsedFilesMap.set(relPath, pf);
124
+ // File node
125
+ graph.nodes.push({
126
+ id: fileNodeId,
127
+ kind: 'file',
128
+ path: relPath,
129
+ lang: pf.language
130
+ });
131
+ // Symbol nodes
132
+ for (const sym of pf.symbols) {
133
+ const symId = `${fileNodeId}#${sym.name}`;
134
+ const symNode = {
135
+ id: symId,
136
+ kind: 'symbol',
137
+ path: relPath,
138
+ name: sym.name
139
+ };
140
+ graph.nodes.push(symNode);
141
+ symbolNodesMap.set(symId, symNode);
142
+ // Contains edge
143
+ graph.edges.push({
144
+ from: fileNodeId,
145
+ to: symId,
146
+ kind: 'contains'
147
+ });
148
+ }
149
+ }
150
+ // 2. Resolve imports and build import edges
151
+ const possibleExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java'];
152
+ for (const pf of deduplicatedParsedFiles) {
153
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
154
+ for (const imp of pf.imports) {
155
+ if (imp.source.startsWith('.') || imp.source.startsWith('/')) {
156
+ // Resolve relative or absolute path within repo
157
+ const absoluteImportPath = path.resolve(path.dirname(pf.path), imp.source);
158
+ let targetRelPath = null;
159
+ for (const ext of possibleExtensions) {
160
+ const fullPath = absoluteImportPath + ext;
161
+ const rel = path.relative(this.repoPath, fullPath).replace(/\\/g, '/');
162
+ if (parsedFilesMap.has(rel)) {
163
+ targetRelPath = rel;
164
+ break;
165
+ }
166
+ }
167
+ if (targetRelPath) {
168
+ const targetAbsPath = path.resolve(this.repoPath, targetRelPath);
169
+ const { id: targetFileNodeId } = this.getNodeIdAndRelPath(targetAbsPath, roots);
170
+ // Add file-to-file import edge
171
+ graph.edges.push({
172
+ from: fileNodeId,
173
+ to: targetFileNodeId,
174
+ kind: 'imports'
175
+ });
176
+ // Add symbol import edges if specified
177
+ for (const name of imp.imported) {
178
+ const symId = `${targetFileNodeId}#${name}`;
179
+ if (symbolNodesMap.has(symId)) {
180
+ graph.edges.push({
181
+ from: fileNodeId,
182
+ to: symId,
183
+ kind: 'imports'
184
+ });
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ // 3. Simple calls edge extraction (best-effort)
192
+ // For each file, search for usages of other files' symbols
193
+ for (const pf of deduplicatedParsedFiles) {
194
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
195
+ let content = '';
196
+ try {
197
+ content = fs.readFileSync(pf.path, 'utf8');
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ for (const targetNode of graph.nodes) {
203
+ if (targetNode.kind === 'symbol' && targetNode.path !== relPath && targetNode.name) {
204
+ // Check if symbol name is called/referenced in the file content
205
+ // Using a word boundary RegExp to avoid false partial matches
206
+ const regex = new RegExp('\\b' + targetNode.name + '\\b');
207
+ if (regex.test(content)) {
208
+ graph.edges.push({
209
+ from: fileNodeId,
210
+ to: targetNode.id,
211
+ kind: 'calls'
212
+ });
213
+ }
214
+ }
215
+ }
216
+ }
217
+ await this.saveGraph(graph);
218
+ return graph;
219
+ }
220
+ /**
221
+ * Incrementally update the graph for a list of changed files.
222
+ */
223
+ async patchCodeGraph(changedPaths) {
224
+ let graph;
225
+ try {
226
+ graph = await this.loadGraph();
227
+ }
228
+ catch {
229
+ return this.buildCodeGraph();
230
+ }
231
+ const config = (0, contexthub_core_1.loadConfig)(this.repoPath);
232
+ const roots = config.roots || ['.'];
233
+ const absChangedPaths = changedPaths.map(p => path.resolve(this.repoPath, p));
234
+ // 1. Remove old nodes and edges for modified files
235
+ for (const absPath of absChangedPaths) {
236
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(absPath, roots);
237
+ // Filter out nodes
238
+ graph.nodes = graph.nodes.filter(n => n.path !== relPath);
239
+ // Filter out edges
240
+ graph.edges = graph.edges.filter(e => {
241
+ const isFromFile = e.from === fileNodeId || e.from.startsWith(fileNodeId + '#');
242
+ const isToFile = e.to === fileNodeId || e.to.startsWith(fileNodeId + '#');
243
+ return !isFromFile && !isToFile;
244
+ });
245
+ }
246
+ // 2. Re-parse and insert updated files
247
+ const possibleExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java'];
248
+ const updatedFiles = [];
249
+ for (const absPath of absChangedPaths) {
250
+ if (fs.existsSync(absPath)) {
251
+ const pf = await this.parser.parseFile(absPath);
252
+ updatedFiles.push(pf);
253
+ }
254
+ }
255
+ const symbolNodesMap = new Map();
256
+ for (const node of graph.nodes) {
257
+ if (node.kind === 'symbol') {
258
+ symbolNodesMap.set(node.id, node);
259
+ }
260
+ }
261
+ // Track active parsed files mapping for resolving imports
262
+ const parsedFilesMap = new Map();
263
+ // First, seed with existing nodes in graph
264
+ for (const node of graph.nodes) {
265
+ if (node.kind === 'file') {
266
+ parsedFilesMap.set(node.path, {
267
+ path: path.resolve(this.repoPath, node.path),
268
+ language: node.lang || 'unknown',
269
+ symbols: [],
270
+ imports: [],
271
+ exports: []
272
+ });
273
+ }
274
+ }
275
+ // Update with new files
276
+ for (const pf of updatedFiles) {
277
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
278
+ parsedFilesMap.set(relPath, pf);
279
+ // Add file node
280
+ graph.nodes.push({
281
+ id: fileNodeId,
282
+ kind: 'file',
283
+ path: relPath,
284
+ lang: pf.language
285
+ });
286
+ // Add symbols
287
+ for (const sym of pf.symbols) {
288
+ const symId = `${fileNodeId}#${sym.name}`;
289
+ const symNode = {
290
+ id: symId,
291
+ kind: 'symbol',
292
+ path: relPath,
293
+ name: sym.name
294
+ };
295
+ graph.nodes.push(symNode);
296
+ symbolNodesMap.set(symId, symNode);
297
+ // Contains edge
298
+ graph.edges.push({
299
+ from: fileNodeId,
300
+ to: symId,
301
+ kind: 'contains'
302
+ });
303
+ }
304
+ }
305
+ // Resolve imports for updated files
306
+ for (const pf of updatedFiles) {
307
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
308
+ for (const imp of pf.imports) {
309
+ if (imp.source.startsWith('.') || imp.source.startsWith('/')) {
310
+ const absoluteImportPath = path.resolve(path.dirname(pf.path), imp.source);
311
+ let targetRelPath = null;
312
+ for (const ext of possibleExtensions) {
313
+ const fullPath = absoluteImportPath + ext;
314
+ const rel = path.relative(this.repoPath, fullPath).replace(/\\/g, '/');
315
+ if (parsedFilesMap.has(rel)) {
316
+ targetRelPath = rel;
317
+ break;
318
+ }
319
+ }
320
+ if (targetRelPath) {
321
+ const targetAbsPath = path.resolve(this.repoPath, targetRelPath);
322
+ const { id: targetFileNodeId } = this.getNodeIdAndRelPath(targetAbsPath, roots);
323
+ graph.edges.push({
324
+ from: fileNodeId,
325
+ to: targetFileNodeId,
326
+ kind: 'imports'
327
+ });
328
+ for (const name of imp.imported) {
329
+ const symId = `${targetFileNodeId}#${name}`;
330
+ if (symbolNodesMap.has(symId)) {
331
+ graph.edges.push({
332
+ from: fileNodeId,
333
+ to: symId,
334
+ kind: 'imports'
335
+ });
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ // Add calls from updated files to all symbols
343
+ for (const pf of updatedFiles) {
344
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
345
+ let content = '';
346
+ try {
347
+ content = fs.readFileSync(pf.path, 'utf8');
348
+ }
349
+ catch {
350
+ continue;
351
+ }
352
+ for (const targetNode of graph.nodes) {
353
+ if (targetNode.kind === 'symbol' && targetNode.path !== relPath && targetNode.name) {
354
+ const regex = new RegExp('\\b' + targetNode.name + '\\b');
355
+ if (regex.test(content)) {
356
+ graph.edges.push({
357
+ from: fileNodeId,
358
+ to: targetNode.id,
359
+ kind: 'calls'
360
+ });
361
+ }
362
+ }
363
+ }
364
+ }
365
+ // Also, search all other files for calls to newly added symbols in updated files
366
+ for (const pf of updatedFiles) {
367
+ const { id: fileNodeId, relPath } = this.getNodeIdAndRelPath(pf.path, roots);
368
+ for (const sym of pf.symbols) {
369
+ const symId = `${fileNodeId}#${sym.name}`;
370
+ // Scan other files on disk for calls to this new symbol
371
+ for (const node of graph.nodes) {
372
+ if (node.kind === 'file' && node.path !== relPath) {
373
+ try {
374
+ const otherAbsPath = path.resolve(this.repoPath, node.path);
375
+ const otherContent = fs.readFileSync(otherAbsPath, 'utf8');
376
+ const regex = new RegExp('\\b' + sym.name + '\\b');
377
+ if (regex.test(otherContent)) {
378
+ graph.edges.push({
379
+ from: node.id,
380
+ to: symId,
381
+ kind: 'calls'
382
+ });
383
+ }
384
+ }
385
+ catch {
386
+ // Ignore
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+ graph.updatedAt = Date.now();
393
+ await this.saveGraph(graph);
394
+ return graph;
395
+ }
396
+ // ─── Query Helpers ────────────────────────────────────────────────────────
397
+ /**
398
+ * Get related symbols based on a file path or symbol ID.
399
+ */
400
+ async getRelatedSymbols(fileOrSymbol, limit = contexthub_core_1.DEFAULT_QUERY_LIMIT) {
401
+ const graph = await this.loadGraph();
402
+ const related = new Set();
403
+ // Check if target is a node
404
+ const targetNode = graph.nodes.find(n => n.id === fileOrSymbol || n.path === fileOrSymbol);
405
+ if (!targetNode)
406
+ return [];
407
+ // Find all directly connected nodes (incoming or outgoing edges)
408
+ for (const edge of graph.edges) {
409
+ if (edge.from === targetNode.id) {
410
+ const match = graph.nodes.find(n => n.id === edge.to && n.kind === 'symbol');
411
+ if (match)
412
+ related.add(match);
413
+ }
414
+ if (edge.to === targetNode.id) {
415
+ const match = graph.nodes.find(n => n.id === edge.from && n.kind === 'symbol');
416
+ if (match)
417
+ related.add(match);
418
+ }
419
+ }
420
+ // If it's a file, add its own contained symbols
421
+ if (targetNode.kind === 'file') {
422
+ const contained = graph.nodes.filter(n => n.path === targetNode.path && n.kind === 'symbol');
423
+ for (const c of contained)
424
+ related.add(c);
425
+ }
426
+ return Array.from(related).slice(0, limit);
427
+ }
428
+ /**
429
+ * Get the transitive blast radius of a file or symbol.
430
+ * Traverses backward (incoming edges: who imports or calls this node).
431
+ */
432
+ async getBlastRadius(fileOrSymbol, depth = 2) {
433
+ const graph = await this.loadGraph();
434
+ const visited = new Set();
435
+ const resultNodes = [];
436
+ const resultEdges = [];
437
+ // Resolve target node(s)
438
+ let startIds = graph.nodes.filter(n => n.id === fileOrSymbol || n.path === fileOrSymbol).map(n => n.id);
439
+ if (startIds.length === 0)
440
+ return { nodes: [], edges: [] };
441
+ let currentQueue = [...startIds];
442
+ visited.add(fileOrSymbol);
443
+ for (let d = 0; d < depth; d++) {
444
+ const nextQueue = [];
445
+ for (const currentId of currentQueue) {
446
+ // Find edges pointing *to* currentId (importers/callers)
447
+ const incomingEdges = graph.edges.filter(e => e.to === currentId);
448
+ for (const edge of incomingEdges) {
449
+ if (!visited.has(edge.from)) {
450
+ visited.add(edge.from);
451
+ nextQueue.push(edge.from);
452
+ const node = graph.nodes.find(n => n.id === edge.from);
453
+ if (node) {
454
+ resultNodes.push(node);
455
+ resultEdges.push(edge);
456
+ }
457
+ }
458
+ }
459
+ }
460
+ if (nextQueue.length === 0)
461
+ break;
462
+ currentQueue = nextQueue;
463
+ }
464
+ return { nodes: resultNodes, edges: resultEdges };
465
+ }
466
+ /**
467
+ * Trace the shortest path from node A to node B using BFS.
468
+ */
469
+ async tracePath(fromId, toId, maxHops = 5) {
470
+ const graph = await this.loadGraph();
471
+ // Map graph to adjacency list
472
+ const adj = new Map();
473
+ for (const edge of graph.edges) {
474
+ if (!adj.has(edge.from))
475
+ adj.set(edge.from, []);
476
+ adj.get(edge.from).push(edge.to);
477
+ }
478
+ // BFS setup
479
+ const queue = [[fromId]];
480
+ const visited = new Set([fromId]);
481
+ while (queue.length > 0) {
482
+ const pathArr = queue.shift();
483
+ const curr = pathArr[pathArr.length - 1];
484
+ if (curr === toId) {
485
+ // Construct detailed trace output
486
+ return pathArr.map(id => {
487
+ const node = graph.nodes.find(n => n.id === id);
488
+ return {
489
+ type: node?.kind || 'unknown',
490
+ id,
491
+ label: node?.name || path.basename(id)
492
+ };
493
+ });
494
+ }
495
+ if (pathArr.length - 1 >= maxHops)
496
+ continue;
497
+ const neighbors = adj.get(curr) || [];
498
+ for (const neighbor of neighbors) {
499
+ if (!visited.has(neighbor)) {
500
+ visited.add(neighbor);
501
+ queue.push([...pathArr, neighbor]);
502
+ }
503
+ }
504
+ }
505
+ return null;
506
+ }
507
+ // ─── God-nodes & Communities ────────────────────────────────────────────────
508
+ /**
509
+ * Get "god-nodes" — files with the highest total degree (in + out edges).
510
+ * These are the most interconnected files in the codebase.
511
+ */
512
+ async getGodNodes(limit = contexthub_core_1.DEFAULT_QUERY_LIMIT) {
513
+ const graph = await this.loadGraph();
514
+ // Count in-degree and out-degree for file nodes only
515
+ const inDegree = new Map();
516
+ const outDegree = new Map();
517
+ for (const node of graph.nodes) {
518
+ if (node.kind === 'file') {
519
+ inDegree.set(node.id, 0);
520
+ outDegree.set(node.id, 0);
521
+ }
522
+ }
523
+ for (const edge of graph.edges) {
524
+ // Only count edges between file nodes (imports), skip contains edges
525
+ if (edge.kind === 'contains')
526
+ continue;
527
+ // Resolve to file-level: if edge.from is a symbol, use its file path
528
+ const fromNode = graph.nodes.find(n => n.id === edge.from);
529
+ const toNode = graph.nodes.find(n => n.id === edge.to);
530
+ if (!fromNode || !toNode)
531
+ continue;
532
+ const fromFile = fromNode.kind === 'file' ? fromNode.id : fromNode.path;
533
+ const toFile = toNode.kind === 'file' ? toNode.id : toNode.path;
534
+ if (fromFile && toFile && fromFile !== toFile) {
535
+ outDegree.set(fromFile, (outDegree.get(fromFile) || 0) + 1);
536
+ inDegree.set(toFile, (inDegree.get(toFile) || 0) + 1);
537
+ }
538
+ }
539
+ // Compute total degree and sort
540
+ const scored = graph.nodes
541
+ .filter(n => n.kind === 'file')
542
+ .map(n => ({
543
+ id: n.id,
544
+ path: n.path || n.id,
545
+ inDegree: inDegree.get(n.id) || 0,
546
+ outDegree: outDegree.get(n.id) || 0,
547
+ degree: (inDegree.get(n.id) || 0) + (outDegree.get(n.id) || 0)
548
+ }))
549
+ .filter(n => n.degree > 0)
550
+ .sort((a, b) => b.degree - a.degree);
551
+ return scored.slice(0, limit);
552
+ }
553
+ /**
554
+ * Detect communities using connected components on file-level nodes.
555
+ * Returns groups of files that are transitively connected via imports.
556
+ * Capped at 50 communities max.
557
+ */
558
+ async detectCommunities() {
559
+ const graph = await this.loadGraph();
560
+ const MAX_COMMUNITIES = 50;
561
+ // Build undirected adjacency list for file nodes only
562
+ const fileNodes = new Set();
563
+ for (const node of graph.nodes) {
564
+ if (node.kind === 'file')
565
+ fileNodes.add(node.id);
566
+ }
567
+ const adj = new Map();
568
+ for (const fileId of fileNodes) {
569
+ adj.set(fileId, new Set());
570
+ }
571
+ for (const edge of graph.edges) {
572
+ if (edge.kind === 'contains')
573
+ continue;
574
+ const fromNode = graph.nodes.find(n => n.id === edge.from);
575
+ const toNode = graph.nodes.find(n => n.id === edge.to);
576
+ if (!fromNode || !toNode)
577
+ continue;
578
+ const fromFile = fromNode.kind === 'file' ? fromNode.id : fromNode.path;
579
+ const toFile = toNode.kind === 'file' ? toNode.id : toNode.path;
580
+ if (fromFile && toFile && fileNodes.has(fromFile) && fileNodes.has(toFile) && fromFile !== toFile) {
581
+ adj.get(fromFile).add(toFile);
582
+ adj.get(toFile).add(fromFile);
583
+ }
584
+ }
585
+ // BFS to find connected components
586
+ const visited = new Set();
587
+ const communities = [];
588
+ let communityId = 0;
589
+ for (const fileId of fileNodes) {
590
+ if (visited.has(fileId))
591
+ continue;
592
+ if (communities.length >= MAX_COMMUNITIES)
593
+ break;
594
+ const component = [];
595
+ const queue = [fileId];
596
+ visited.add(fileId);
597
+ while (queue.length > 0) {
598
+ const current = queue.shift();
599
+ component.push(current);
600
+ for (const neighbor of (adj.get(current) || [])) {
601
+ if (!visited.has(neighbor)) {
602
+ visited.add(neighbor);
603
+ queue.push(neighbor);
604
+ }
605
+ }
606
+ }
607
+ communities.push({
608
+ id: communityId++,
609
+ files: component.sort(),
610
+ size: component.length
611
+ });
612
+ }
613
+ // Sort by size descending (largest communities first)
614
+ return communities.sort((a, b) => b.size - a.size);
615
+ }
616
+ // ─── File Utilities (Secure & Atomic) ─────────────────────────────────────
617
+ /**
618
+ * Load the code graph from disk.
619
+ */
620
+ async loadGraph() {
621
+ if (!fs.existsSync(this.graphPath)) {
622
+ throw new Error('Code graph file does not exist');
623
+ }
624
+ // Check file size
625
+ this.security.checkFileSize(this.graphPath);
626
+ const raw = fs.readFileSync(this.graphPath, 'utf8').trim();
627
+ if (raw.length === 0) {
628
+ throw new Error('Code graph file is empty');
629
+ }
630
+ // Decrypt if it's stored encrypted, else parse directly (with auto-migration support)
631
+ try {
632
+ if (this.security.isEncrypted(raw)) {
633
+ const decrypted = this.security.decrypt(raw);
634
+ return JSON.parse(decrypted);
635
+ }
636
+ const parsed = JSON.parse(raw);
637
+ // Auto-migrate
638
+ await this.saveGraph(parsed);
639
+ return parsed;
640
+ }
641
+ catch {
642
+ throw new Error('Failed to load code graph');
643
+ }
644
+ }
645
+ /**
646
+ * Save the code graph to disk atomically with encryption.
647
+ */
648
+ async saveGraph(graph) {
649
+ const jsonStr = JSON.stringify(graph, null, 2);
650
+ const encrypted = this.security.encrypt(jsonStr);
651
+ // Atomic write
652
+ const tmpPath = this.graphPath + `.tmp.${crypto.randomBytes(4).toString('hex')}`;
653
+ try {
654
+ fs.writeFileSync(tmpPath, encrypted, { mode: 0o600 });
655
+ fs.renameSync(tmpPath, this.graphPath);
656
+ this.security.setSecurePermissions(this.graphPath);
657
+ }
658
+ catch (e) {
659
+ try {
660
+ fs.unlinkSync(tmpPath);
661
+ }
662
+ catch { }
663
+ throw e;
664
+ }
665
+ // Also write metadata
666
+ const meta = {
667
+ updatedAt: graph.updatedAt,
668
+ nodeCount: graph.nodes.length,
669
+ edgeCount: graph.edges.length,
670
+ version: graph.version
671
+ };
672
+ const metaTmpPath = this.metaPath + `.tmp.${crypto.randomBytes(4).toString('hex')}`;
673
+ try {
674
+ fs.writeFileSync(metaTmpPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
675
+ fs.renameSync(metaTmpPath, this.metaPath);
676
+ this.security.setSecurePermissions(this.metaPath);
677
+ }
678
+ catch (e) {
679
+ try {
680
+ fs.unlinkSync(metaTmpPath);
681
+ }
682
+ catch { }
683
+ }
684
+ }
685
+ // ─── Snapshots & Diffing ────────────────────────────────────────────────
686
+ /**
687
+ * Create a snapshot of the current code graph and return its ID.
688
+ * Auto-cleans up keeping only the 5 most recent snapshots.
689
+ */
690
+ async createGraphSnapshot() {
691
+ if (!fs.existsSync(this.graphPath)) {
692
+ return '';
693
+ }
694
+ const snapshotId = `snapshot-${Date.now()}`;
695
+ const targetPath = path.join(this.snapshotDirPath, `${snapshotId}.json`);
696
+ // Copy the current graph
697
+ fs.copyFileSync(this.graphPath, targetPath);
698
+ this.security.setSecurePermissions(targetPath);
699
+ // Cleanup old snapshots
700
+ try {
701
+ const files = fs.readdirSync(this.snapshotDirPath);
702
+ const snapshots = files
703
+ .filter(f => f.startsWith('snapshot-') && f.endsWith('.json'))
704
+ .map(f => ({ name: f, time: fs.statSync(path.join(this.snapshotDirPath, f)).mtimeMs }))
705
+ .sort((a, b) => b.time - a.time);
706
+ if (snapshots.length > 5) {
707
+ for (const s of snapshots.slice(5)) {
708
+ fs.unlinkSync(path.join(this.snapshotDirPath, s.name));
709
+ }
710
+ }
711
+ }
712
+ catch {
713
+ // Ignore cleanup errors
714
+ }
715
+ return snapshotId;
716
+ }
717
+ /**
718
+ * Load a specific graph snapshot.
719
+ */
720
+ async loadGraphSnapshot(snapshotId) {
721
+ const targetPath = path.join(this.snapshotDirPath, `${snapshotId}.json`);
722
+ if (!fs.existsSync(targetPath))
723
+ return null;
724
+ try {
725
+ const content = fs.readFileSync(targetPath, 'utf8');
726
+ const decrypted = this.security.decrypt(content);
727
+ return JSON.parse(decrypted);
728
+ }
729
+ catch {
730
+ return null;
731
+ }
732
+ }
733
+ /**
734
+ * Diff two CodeGraphs and return the delta.
735
+ */
736
+ diffCodeGraph(oldGraph, newGraph) {
737
+ const oldNodesMap = new Map(oldGraph.nodes.map(n => [n.id, n]));
738
+ const newNodesMap = new Map(newGraph.nodes.map(n => [n.id, n]));
739
+ const oldEdgesSet = new Set(oldGraph.edges.map(e => `${e.from}::${e.to}::${e.kind}`));
740
+ const newEdgesSet = new Set(newGraph.edges.map(e => `${e.from}::${e.to}::${e.kind}`));
741
+ const addedNodes = newGraph.nodes.filter(n => !oldNodesMap.has(n.id));
742
+ const removedNodes = oldGraph.nodes.filter(n => !newNodesMap.has(n.id));
743
+ const addedEdges = newGraph.edges.filter(e => !oldEdgesSet.has(`${e.from}::${e.to}::${e.kind}`));
744
+ const removedEdges = oldGraph.edges.filter(e => !newEdgesSet.has(`${e.from}::${e.to}::${e.kind}`));
745
+ return { addedNodes, removedNodes, addedEdges, removedEdges };
746
+ }
747
+ }
748
+ exports.CodeGraphManager = CodeGraphManager;
749
+ // Re-export report utilities
750
+ var report_1 = require("./report");
751
+ Object.defineProperty(exports, "generateGraphReport", { enumerable: true, get: function () { return report_1.generateGraphReport; } });
752
+ Object.defineProperty(exports, "writeGraphReport", { enumerable: true, get: function () { return report_1.writeGraphReport; } });
@@ -0,0 +1,15 @@
1
+ export interface GraphReportOptions {
2
+ repoPath: string;
3
+ memoryCount?: number;
4
+ gitBranch?: string;
5
+ lastPatch?: string;
6
+ }
7
+ /**
8
+ * Generate a GRAPH_REPORT.md summarising the knowledge graph state.
9
+ * Called from watch batches, update_knowledge_graph, and the CLI.
10
+ */
11
+ export declare function generateGraphReport(options: GraphReportOptions): Promise<string>;
12
+ /**
13
+ * Write the graph report to .contexthub/GRAPH_REPORT.md
14
+ */
15
+ export declare function writeGraphReport(options: GraphReportOptions): Promise<string>;
package/dist/report.js ADDED
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.generateGraphReport = generateGraphReport;
37
+ exports.writeGraphReport = writeGraphReport;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const crypto = __importStar(require("crypto"));
41
+ const index_1 = require("./index");
42
+ /**
43
+ * Generate a GRAPH_REPORT.md summarising the knowledge graph state.
44
+ * Called from watch batches, update_knowledge_graph, and the CLI.
45
+ */
46
+ async function generateGraphReport(options) {
47
+ const graphManager = new index_1.CodeGraphManager(options.repoPath);
48
+ let graph;
49
+ try {
50
+ graph = await graphManager.loadGraph();
51
+ }
52
+ catch {
53
+ return '# ContextHub Graph Report\n\n> No code graph available. Run `contexthub watch` or `contexthub setup` first.\n';
54
+ }
55
+ // Stats
56
+ const fileNodes = graph.nodes.filter(n => n.kind === 'file');
57
+ const symbolNodes = graph.nodes.filter(n => n.kind === 'symbol');
58
+ const importEdges = graph.edges.filter(e => e.kind === 'imports');
59
+ const containsEdges = graph.edges.filter(e => e.kind === 'contains');
60
+ // Languages
61
+ const langCounts = {};
62
+ for (const node of fileNodes) {
63
+ const lang = node.lang || 'unknown';
64
+ langCounts[lang] = (langCounts[lang] || 0) + 1;
65
+ }
66
+ const langTable = Object.entries(langCounts)
67
+ .sort((a, b) => b[1] - a[1])
68
+ .map(([lang, count]) => `| ${lang} | ${count} |`)
69
+ .join('\n');
70
+ // God-nodes (top 10)
71
+ let godNodesSection = '';
72
+ try {
73
+ const godNodes = await graphManager.getGodNodes(10);
74
+ if (godNodes.length > 0) {
75
+ const rows = godNodes.map((gn, i) => `| ${i + 1} | \`${gn.path}\` | ${gn.degree} | ${gn.inDegree} | ${gn.outDegree} |`).join('\n');
76
+ godNodesSection = `## God-nodes (Top 10 Most Connected Files)
77
+
78
+ | Rank | File | Degree | In | Out |
79
+ |------|------|--------|----|-----|
80
+ ${rows}
81
+ `;
82
+ }
83
+ else {
84
+ godNodesSection = '## God-nodes\n\nNo high-connectivity files detected.\n';
85
+ }
86
+ }
87
+ catch {
88
+ godNodesSection = '## God-nodes\n\nUnable to compute god-nodes.\n';
89
+ }
90
+ // Communities
91
+ let communitiesSection = '';
92
+ try {
93
+ const communities = await graphManager.detectCommunities();
94
+ if (communities.length > 0) {
95
+ const topCommunities = communities.slice(0, 10);
96
+ const rows = topCommunities.map(c => {
97
+ const preview = c.files.slice(0, 5).map(f => `\`${f}\``).join(', ');
98
+ const more = c.size > 5 ? ` (+${c.size - 5} more)` : '';
99
+ return `| ${c.id} | ${c.size} | ${preview}${more} |`;
100
+ }).join('\n');
101
+ communitiesSection = `## Communities (Connected Components)
102
+
103
+ Total: **${communities.length}** communities across **${communities.reduce((s, c) => s + c.size, 0)}** files.
104
+
105
+ | ID | Size | Files (preview) |
106
+ |----|------|-----------------|
107
+ ${rows}
108
+ `;
109
+ }
110
+ else {
111
+ communitiesSection = '## Communities\n\nNo communities detected.\n';
112
+ }
113
+ }
114
+ catch {
115
+ communitiesSection = '## Communities\n\nUnable to compute communities.\n';
116
+ }
117
+ const updatedAt = graph.updatedAt ? new Date(graph.updatedAt).toISOString() : 'unknown';
118
+ const report = `# ContextHub Graph Report
119
+
120
+ > Auto-generated by ContextHub. Do not edit manually.
121
+ > Last updated: ${updatedAt}
122
+
123
+ ## Overview
124
+
125
+ | Metric | Count |
126
+ |--------|-------|
127
+ | Files | ${fileNodes.length} |
128
+ | Symbols | ${symbolNodes.length} |
129
+ | Total Nodes | ${graph.nodes.length} |
130
+ | Import Edges | ${importEdges.length} |
131
+ | Contains Edges | ${containsEdges.length} |
132
+ | Total Edges | ${graph.edges.length} |
133
+ | Graph Version | ${graph.version} |
134
+ ${options.memoryCount !== undefined ? `| Memories | ${options.memoryCount} |\n` : ''}${options.gitBranch ? `| Git Branch | ${options.gitBranch} |\n` : ''}${options.lastPatch ? `| Last Patch | ${options.lastPatch} |\n` : ''}
135
+ ## Languages
136
+
137
+ | Language | Files |
138
+ |----------|-------|
139
+ ${langTable}
140
+
141
+ ${godNodesSection}
142
+ ${communitiesSection}
143
+ ---
144
+
145
+ *Generated by [ContextHub](https://github.com/iMayuuR/contexthub)*
146
+ `;
147
+ return report;
148
+ }
149
+ /**
150
+ * Write the graph report to .contexthub/GRAPH_REPORT.md
151
+ */
152
+ async function writeGraphReport(options) {
153
+ const report = await generateGraphReport(options);
154
+ const outputPath = path.join(options.repoPath, '.contexthub', 'GRAPH_REPORT.md');
155
+ // Ensure .contexthub exists
156
+ const dir = path.dirname(outputPath);
157
+ if (!fs.existsSync(dir)) {
158
+ fs.mkdirSync(dir, { recursive: true });
159
+ }
160
+ // Atomic write
161
+ const tmpPath = outputPath + `.tmp.${crypto.randomBytes(4).toString('hex')}`;
162
+ try {
163
+ fs.writeFileSync(tmpPath, report, { mode: 0o600 });
164
+ fs.renameSync(tmpPath, outputPath);
165
+ }
166
+ catch (e) {
167
+ try {
168
+ fs.unlinkSync(tmpPath);
169
+ }
170
+ catch { }
171
+ throw e;
172
+ }
173
+ return outputPath;
174
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@imayuur/contexthub-knowledge-graph",
3
+ "version": "1.0.0",
4
+ "description": "Code knowledge graph builder and query engine for ContextHub",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/iMayuuR/contexthub.git",
9
+ "directory": "packages/knowledge-graph"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "!dist/__tests__"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "dev": "tsc --watch",
26
+ "test": "tsc && node --test dist/__tests__/*.test.js",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "dependencies": {
30
+ "@imayuur/contexthub-shared-types": "^1.0.0",
31
+ "@imayuur/contexthub-core": "^1.0.0",
32
+ "@imayuur/contexthub-repo-parser": "^1.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^18.0.0",
36
+ "typescript": "^5.0.0"
37
+ },
38
+ "author": "Mayur Dattatray Patil",
39
+ "bugs": {
40
+ "url": "https://github.com/iMayuuR/contexthub/issues"
41
+ },
42
+ "homepage": "https://github.com/iMayuuR/contexthub#readme",
43
+ "keywords": [
44
+ "contexthub",
45
+ "mcp",
46
+ "ai-memory",
47
+ "cursor",
48
+ "claude"
49
+ ],
50
+ "exports": {
51
+ ".": {
52
+ "types": "./dist/index.d.ts",
53
+ "default": "./dist/index.js"
54
+ }
55
+ }
56
+ }