@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.
- package/dist/index.d.ts +91 -0
- package/dist/index.js +752 -0
- package/dist/report.d.ts +15 -0
- package/dist/report.js +174 -0
- package/package.json +56 -0
package/dist/index.d.ts
ADDED
|
@@ -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; } });
|
package/dist/report.d.ts
ADDED
|
@@ -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
|
+
}
|