@optave/codegraph 2.1.1-dev.3c12b64 → 2.2.1
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/README.md +87 -45
- package/package.json +5 -5
- package/src/builder.js +238 -33
- package/src/cli.js +96 -12
- package/src/config.js +1 -1
- package/src/cycles.js +13 -1
- package/src/db.js +4 -0
- package/src/embedder.js +1 -1
- package/src/export.js +20 -7
- package/src/extractors/csharp.js +6 -1
- package/src/extractors/go.js +6 -1
- package/src/extractors/java.js +4 -1
- package/src/extractors/javascript.js +145 -5
- package/src/extractors/php.js +8 -2
- package/src/extractors/python.js +8 -1
- package/src/extractors/ruby.js +4 -1
- package/src/extractors/rust.js +12 -2
- package/src/index.js +6 -0
- package/src/journal.js +109 -0
- package/src/mcp.js +131 -7
- package/src/parser.js +1 -0
- package/src/queries.js +1143 -38
- package/src/structure.js +21 -7
- package/src/watcher.js +25 -0
package/src/builder.js
CHANGED
|
@@ -5,12 +5,44 @@ import path from 'node:path';
|
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
|
|
7
7
|
import { initSchema, openDb } from './db.js';
|
|
8
|
+
import { readJournal, writeJournalHeader } from './journal.js';
|
|
8
9
|
import { debug, warn } from './logger.js';
|
|
9
10
|
import { getActiveEngine, parseFilesAuto } from './parser.js';
|
|
10
11
|
import { computeConfidence, resolveImportPath, resolveImportsBatch } from './resolve.js';
|
|
11
12
|
|
|
12
13
|
export { resolveImportPath } from './resolve.js';
|
|
13
14
|
|
|
15
|
+
const BUILTIN_RECEIVERS = new Set([
|
|
16
|
+
'console',
|
|
17
|
+
'Math',
|
|
18
|
+
'JSON',
|
|
19
|
+
'Object',
|
|
20
|
+
'Array',
|
|
21
|
+
'String',
|
|
22
|
+
'Number',
|
|
23
|
+
'Boolean',
|
|
24
|
+
'Date',
|
|
25
|
+
'RegExp',
|
|
26
|
+
'Map',
|
|
27
|
+
'Set',
|
|
28
|
+
'WeakMap',
|
|
29
|
+
'WeakSet',
|
|
30
|
+
'Promise',
|
|
31
|
+
'Symbol',
|
|
32
|
+
'Error',
|
|
33
|
+
'TypeError',
|
|
34
|
+
'RangeError',
|
|
35
|
+
'Proxy',
|
|
36
|
+
'Reflect',
|
|
37
|
+
'Intl',
|
|
38
|
+
'globalThis',
|
|
39
|
+
'window',
|
|
40
|
+
'document',
|
|
41
|
+
'process',
|
|
42
|
+
'Buffer',
|
|
43
|
+
'require',
|
|
44
|
+
]);
|
|
45
|
+
|
|
14
46
|
export function collectFiles(dir, files = [], config = {}, directories = null) {
|
|
15
47
|
const trackDirs = directories !== null;
|
|
16
48
|
let entries;
|
|
@@ -81,8 +113,24 @@ function fileHash(content) {
|
|
|
81
113
|
return createHash('md5').update(content).digest('hex');
|
|
82
114
|
}
|
|
83
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Stat a file, returning { mtimeMs, size } or null on error.
|
|
118
|
+
*/
|
|
119
|
+
function fileStat(filePath) {
|
|
120
|
+
try {
|
|
121
|
+
const s = fs.statSync(filePath);
|
|
122
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
84
128
|
/**
|
|
85
129
|
* Determine which files have changed since last build.
|
|
130
|
+
* Three-tier cascade:
|
|
131
|
+
* Tier 0 — Journal: O(changed) when watcher was running
|
|
132
|
+
* Tier 1 — mtime+size: O(n) stats, O(changed) reads
|
|
133
|
+
* Tier 2 — Hash comparison: O(changed) reads (fallback from Tier 1)
|
|
86
134
|
*/
|
|
87
135
|
function getChangedFiles(db, allFiles, rootDir) {
|
|
88
136
|
// Check if file_hashes table exists
|
|
@@ -95,7 +143,6 @@ function getChangedFiles(db, allFiles, rootDir) {
|
|
|
95
143
|
}
|
|
96
144
|
|
|
97
145
|
if (!hasTable) {
|
|
98
|
-
// No hash table = first build, everything is new
|
|
99
146
|
return {
|
|
100
147
|
changed: allFiles.map((f) => ({ file: f })),
|
|
101
148
|
removed: [],
|
|
@@ -105,36 +152,140 @@ function getChangedFiles(db, allFiles, rootDir) {
|
|
|
105
152
|
|
|
106
153
|
const existing = new Map(
|
|
107
154
|
db
|
|
108
|
-
.prepare('SELECT file, hash FROM file_hashes')
|
|
155
|
+
.prepare('SELECT file, hash, mtime, size FROM file_hashes')
|
|
109
156
|
.all()
|
|
110
|
-
.map((r) => [r.file, r
|
|
157
|
+
.map((r) => [r.file, r]),
|
|
111
158
|
);
|
|
112
159
|
|
|
113
|
-
|
|
160
|
+
// Build set of current files for removal detection
|
|
114
161
|
const currentFiles = new Set();
|
|
162
|
+
for (const file of allFiles) {
|
|
163
|
+
currentFiles.add(normalizePath(path.relative(rootDir, file)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const removed = [];
|
|
167
|
+
for (const existingFile of existing.keys()) {
|
|
168
|
+
if (!currentFiles.has(existingFile)) {
|
|
169
|
+
removed.push(existingFile);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Tier 0: Journal ──────────────────────────────────────────────
|
|
174
|
+
const journal = readJournal(rootDir);
|
|
175
|
+
if (journal.valid) {
|
|
176
|
+
// Validate journal timestamp against DB — journal should be from after the last build
|
|
177
|
+
const dbMtimes = db.prepare('SELECT MAX(mtime) as latest FROM file_hashes').get();
|
|
178
|
+
const latestDbMtime = dbMtimes?.latest || 0;
|
|
179
|
+
|
|
180
|
+
// Empty journal = no watcher was running, fall to Tier 1 for safety
|
|
181
|
+
const hasJournalEntries = journal.changed.length > 0 || journal.removed.length > 0;
|
|
182
|
+
|
|
183
|
+
if (hasJournalEntries && journal.timestamp >= latestDbMtime) {
|
|
184
|
+
debug(
|
|
185
|
+
`Tier 0: journal valid, ${journal.changed.length} changed, ${journal.removed.length} removed`,
|
|
186
|
+
);
|
|
187
|
+
const changed = [];
|
|
188
|
+
|
|
189
|
+
for (const relPath of journal.changed) {
|
|
190
|
+
const absPath = path.join(rootDir, relPath);
|
|
191
|
+
const stat = fileStat(absPath);
|
|
192
|
+
if (!stat) continue;
|
|
193
|
+
|
|
194
|
+
let content;
|
|
195
|
+
try {
|
|
196
|
+
content = fs.readFileSync(absPath, 'utf-8');
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const hash = fileHash(content);
|
|
201
|
+
const record = existing.get(relPath);
|
|
202
|
+
if (!record || record.hash !== hash) {
|
|
203
|
+
changed.push({ file: absPath, content, hash, relPath, stat });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Merge journal removals with filesystem removals (dedup)
|
|
208
|
+
const removedSet = new Set(removed);
|
|
209
|
+
for (const relPath of journal.removed) {
|
|
210
|
+
if (existing.has(relPath)) removedSet.add(relPath);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { changed, removed: [...removedSet], isFullBuild: false };
|
|
214
|
+
}
|
|
215
|
+
debug(
|
|
216
|
+
`Tier 0: skipped (${hasJournalEntries ? 'timestamp stale' : 'no entries'}), falling to Tier 1`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Tier 1: mtime+size fast-path ─────────────────────────────────
|
|
221
|
+
const needsHash = []; // Files that failed mtime+size check
|
|
222
|
+
const skipped = []; // Files that passed mtime+size check
|
|
115
223
|
|
|
116
224
|
for (const file of allFiles) {
|
|
117
225
|
const relPath = normalizePath(path.relative(rootDir, file));
|
|
118
|
-
|
|
226
|
+
const record = existing.get(relPath);
|
|
227
|
+
|
|
228
|
+
if (!record) {
|
|
229
|
+
// New file — needs full read+hash
|
|
230
|
+
needsHash.push({ file, relPath });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const stat = fileStat(file);
|
|
235
|
+
if (!stat) continue;
|
|
236
|
+
|
|
237
|
+
const storedMtime = record.mtime || 0;
|
|
238
|
+
const storedSize = record.size || 0;
|
|
239
|
+
|
|
240
|
+
// size > 0 guard: pre-v4 rows have size=0, always fall through to hash
|
|
241
|
+
if (storedSize > 0 && Math.floor(stat.mtimeMs) === storedMtime && stat.size === storedSize) {
|
|
242
|
+
skipped.push(relPath);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
needsHash.push({ file, relPath, stat });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (needsHash.length > 0) {
|
|
250
|
+
debug(`Tier 1: ${skipped.length} skipped by mtime+size, ${needsHash.length} need hash check`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Tier 2: Hash comparison ──────────────────────────────────────
|
|
254
|
+
const changed = [];
|
|
119
255
|
|
|
256
|
+
for (const item of needsHash) {
|
|
120
257
|
let content;
|
|
121
258
|
try {
|
|
122
|
-
content = fs.readFileSync(file, 'utf-8');
|
|
259
|
+
content = fs.readFileSync(item.file, 'utf-8');
|
|
123
260
|
} catch {
|
|
124
261
|
continue;
|
|
125
262
|
}
|
|
126
263
|
const hash = fileHash(content);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
264
|
+
const stat = item.stat || fileStat(item.file);
|
|
265
|
+
const record = existing.get(item.relPath);
|
|
266
|
+
|
|
267
|
+
if (!record || record.hash !== hash) {
|
|
268
|
+
changed.push({ file: item.file, content, hash, relPath: item.relPath, stat });
|
|
269
|
+
} else if (stat) {
|
|
270
|
+
// Hash matches but mtime/size was stale — self-heal by updating stored metadata
|
|
271
|
+
changed.push({
|
|
272
|
+
file: item.file,
|
|
273
|
+
content,
|
|
274
|
+
hash,
|
|
275
|
+
relPath: item.relPath,
|
|
276
|
+
stat,
|
|
277
|
+
metadataOnly: true,
|
|
278
|
+
});
|
|
130
279
|
}
|
|
131
280
|
}
|
|
132
281
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
282
|
+
// Filter out metadata-only updates from the "changed" list for parsing,
|
|
283
|
+
// but keep them so the caller can update file_hashes
|
|
284
|
+
const parseChanged = changed.filter((c) => !c.metadataOnly);
|
|
285
|
+
if (needsHash.length > 0) {
|
|
286
|
+
debug(
|
|
287
|
+
`Tier 2: ${parseChanged.length} actually changed, ${changed.length - parseChanged.length} metadata-only`,
|
|
288
|
+
);
|
|
138
289
|
}
|
|
139
290
|
|
|
140
291
|
return { changed, removed, isFullBuild: false };
|
|
@@ -180,9 +331,33 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
180
331
|
? getChangedFiles(db, files, rootDir)
|
|
181
332
|
: { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
|
|
182
333
|
|
|
183
|
-
|
|
334
|
+
// Separate metadata-only updates (mtime/size self-heal) from real changes
|
|
335
|
+
const parseChanges = changed.filter((c) => !c.metadataOnly);
|
|
336
|
+
const metadataUpdates = changed.filter((c) => c.metadataOnly);
|
|
337
|
+
|
|
338
|
+
if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
|
|
339
|
+
// Still update metadata for self-healing even when no real changes
|
|
340
|
+
if (metadataUpdates.length > 0) {
|
|
341
|
+
try {
|
|
342
|
+
const healHash = db.prepare(
|
|
343
|
+
'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)',
|
|
344
|
+
);
|
|
345
|
+
const healTx = db.transaction(() => {
|
|
346
|
+
for (const item of metadataUpdates) {
|
|
347
|
+
const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
|
|
348
|
+
const size = item.stat ? item.stat.size : 0;
|
|
349
|
+
healHash.run(item.relPath, item.hash, mtime, size);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
healTx();
|
|
353
|
+
debug(`Self-healed mtime/size for ${metadataUpdates.length} files`);
|
|
354
|
+
} catch {
|
|
355
|
+
/* ignore heal errors */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
184
358
|
console.log('No changes detected. Graph is up to date.');
|
|
185
359
|
db.close();
|
|
360
|
+
writeJournalHeader(rootDir, Date.now());
|
|
186
361
|
return;
|
|
187
362
|
}
|
|
188
363
|
|
|
@@ -191,7 +366,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
191
366
|
'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM nodes; PRAGMA foreign_keys = ON;',
|
|
192
367
|
);
|
|
193
368
|
} else {
|
|
194
|
-
console.log(`Incremental: ${
|
|
369
|
+
console.log(`Incremental: ${parseChanges.length} changed, ${removed.length} removed`);
|
|
195
370
|
// Remove metrics/edges/nodes for changed and removed files
|
|
196
371
|
const deleteNodesForFile = db.prepare('DELETE FROM nodes WHERE file = ?');
|
|
197
372
|
const deleteEdgesForFile = db.prepare(`
|
|
@@ -206,7 +381,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
206
381
|
deleteMetricsForFile.run(relPath);
|
|
207
382
|
deleteNodesForFile.run(relPath);
|
|
208
383
|
}
|
|
209
|
-
for (const item of
|
|
384
|
+
for (const item of parseChanges) {
|
|
210
385
|
const relPath = item.relPath || normalizePath(path.relative(rootDir, item.file));
|
|
211
386
|
deleteEdgesForFile.run({ f: relPath });
|
|
212
387
|
deleteMetricsForFile.run(relPath);
|
|
@@ -224,11 +399,11 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
224
399
|
'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
|
|
225
400
|
);
|
|
226
401
|
|
|
227
|
-
// Prepare hash upsert
|
|
402
|
+
// Prepare hash upsert (with size column from migration v4)
|
|
228
403
|
let upsertHash;
|
|
229
404
|
try {
|
|
230
405
|
upsertHash = db.prepare(
|
|
231
|
-
'INSERT OR REPLACE INTO file_hashes (file, hash, mtime) VALUES (?, ?, ?)',
|
|
406
|
+
'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)',
|
|
232
407
|
);
|
|
233
408
|
} catch {
|
|
234
409
|
upsertHash = null;
|
|
@@ -246,17 +421,17 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
246
421
|
// We'll fill these in during the parse pass + edge pass
|
|
247
422
|
}
|
|
248
423
|
|
|
249
|
-
const filesToParse = isFullBuild ? files.map((f) => ({ file: f })) :
|
|
424
|
+
const filesToParse = isFullBuild ? files.map((f) => ({ file: f })) : parseChanges;
|
|
250
425
|
|
|
251
426
|
// ── Unified parse via parseFilesAuto ───────────────────────────────
|
|
252
427
|
const filePaths = filesToParse.map((item) => item.file);
|
|
253
428
|
const allSymbols = await parseFilesAuto(filePaths, rootDir, engineOpts);
|
|
254
429
|
|
|
255
|
-
// Build a
|
|
256
|
-
const
|
|
430
|
+
// Build a lookup from incremental data (changed items may carry pre-computed hashes + stats)
|
|
431
|
+
const precomputedData = new Map();
|
|
257
432
|
for (const item of filesToParse) {
|
|
258
|
-
if (item.
|
|
259
|
-
|
|
433
|
+
if (item.relPath) {
|
|
434
|
+
precomputedData.set(item.relPath, item);
|
|
260
435
|
}
|
|
261
436
|
}
|
|
262
437
|
|
|
@@ -272,11 +447,14 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
272
447
|
insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
|
|
273
448
|
}
|
|
274
449
|
|
|
275
|
-
// Update file hash for incremental builds
|
|
450
|
+
// Update file hash with real mtime+size for incremental builds
|
|
276
451
|
if (upsertHash) {
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
279
|
-
|
|
452
|
+
const precomputed = precomputedData.get(relPath);
|
|
453
|
+
if (precomputed?.hash) {
|
|
454
|
+
const stat = precomputed.stat || fileStat(path.join(rootDir, relPath));
|
|
455
|
+
const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
|
|
456
|
+
const size = stat ? stat.size : 0;
|
|
457
|
+
upsertHash.run(relPath, precomputed.hash, mtime, size);
|
|
280
458
|
} else {
|
|
281
459
|
const absPath = path.join(rootDir, relPath);
|
|
282
460
|
let code;
|
|
@@ -286,11 +464,23 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
286
464
|
code = null;
|
|
287
465
|
}
|
|
288
466
|
if (code !== null) {
|
|
289
|
-
|
|
467
|
+
const stat = fileStat(absPath);
|
|
468
|
+
const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
|
|
469
|
+
const size = stat ? stat.size : 0;
|
|
470
|
+
upsertHash.run(relPath, fileHash(code), mtime, size);
|
|
290
471
|
}
|
|
291
472
|
}
|
|
292
473
|
}
|
|
293
474
|
}
|
|
475
|
+
|
|
476
|
+
// Also update metadata-only entries (self-heal mtime/size without re-parse)
|
|
477
|
+
if (upsertHash) {
|
|
478
|
+
for (const item of metadataUpdates) {
|
|
479
|
+
const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
|
|
480
|
+
const size = item.stat ? item.stat.size : 0;
|
|
481
|
+
upsertHash.run(item.relPath, item.hash, mtime, size);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
294
484
|
});
|
|
295
485
|
insertAll();
|
|
296
486
|
|
|
@@ -458,7 +648,9 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
458
648
|
}
|
|
459
649
|
|
|
460
650
|
// Call edges with confidence scoring — using pre-loaded lookup maps (N+1 fix)
|
|
651
|
+
const seenCallEdges = new Set();
|
|
461
652
|
for (const call of symbols.calls) {
|
|
653
|
+
if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
|
|
462
654
|
let caller = null;
|
|
463
655
|
for (const def of symbols.definitions) {
|
|
464
656
|
if (def.line <= call.line) {
|
|
@@ -493,10 +685,18 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
493
685
|
);
|
|
494
686
|
if (methodCandidates.length > 0) {
|
|
495
687
|
targets = methodCandidates;
|
|
496
|
-
} else
|
|
497
|
-
|
|
498
|
-
|
|
688
|
+
} else if (
|
|
689
|
+
!call.receiver ||
|
|
690
|
+
call.receiver === 'this' ||
|
|
691
|
+
call.receiver === 'self' ||
|
|
692
|
+
call.receiver === 'super'
|
|
693
|
+
) {
|
|
694
|
+
// Scoped fallback — same-dir or parent-dir only, not global
|
|
695
|
+
targets = (nodesByName.get(call.name) || []).filter(
|
|
696
|
+
(n) => computeConfidence(relPath, n.file, null) >= 0.5,
|
|
697
|
+
);
|
|
499
698
|
}
|
|
699
|
+
// else: method call on a receiver — skip global fallback entirely
|
|
500
700
|
}
|
|
501
701
|
}
|
|
502
702
|
|
|
@@ -509,7 +709,9 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
509
709
|
}
|
|
510
710
|
|
|
511
711
|
for (const t of targets) {
|
|
512
|
-
|
|
712
|
+
const edgeKey = `${caller.id}|${t.id}`;
|
|
713
|
+
if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) {
|
|
714
|
+
seenCallEdges.add(edgeKey);
|
|
513
715
|
const confidence = computeConfidence(relPath, t.file, importedFrom);
|
|
514
716
|
insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
|
|
515
717
|
edgeCount++;
|
|
@@ -582,6 +784,9 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
582
784
|
console.log(`Stored in ${dbPath}`);
|
|
583
785
|
db.close();
|
|
584
786
|
|
|
787
|
+
// Write journal header after successful build
|
|
788
|
+
writeJournalHeader(rootDir, Date.now());
|
|
789
|
+
|
|
585
790
|
if (!opts.skipRegistry) {
|
|
586
791
|
const tmpDir = path.resolve(os.tmpdir());
|
|
587
792
|
const resolvedRoot = path.resolve(rootDir);
|
package/src/cli.js
CHANGED
|
@@ -11,7 +11,10 @@ import { buildEmbeddings, MODELS, search } from './embedder.js';
|
|
|
11
11
|
import { exportDOT, exportJSON, exportMermaid } from './export.js';
|
|
12
12
|
import { setVerbose } from './logger.js';
|
|
13
13
|
import {
|
|
14
|
+
ALL_SYMBOL_KINDS,
|
|
15
|
+
context,
|
|
14
16
|
diffImpact,
|
|
17
|
+
explain,
|
|
15
18
|
fileDeps,
|
|
16
19
|
fnDeps,
|
|
17
20
|
fnImpact,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
moduleMap,
|
|
20
23
|
queryName,
|
|
21
24
|
stats,
|
|
25
|
+
where,
|
|
22
26
|
} from './queries.js';
|
|
23
27
|
import {
|
|
24
28
|
listRepos,
|
|
@@ -58,18 +62,20 @@ program
|
|
|
58
62
|
.command('query <name>')
|
|
59
63
|
.description('Find a function/class, show callers and callees')
|
|
60
64
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
65
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
61
66
|
.option('-j, --json', 'Output as JSON')
|
|
62
67
|
.action((name, opts) => {
|
|
63
|
-
queryName(name, opts.db, { json: opts.json });
|
|
68
|
+
queryName(name, opts.db, { noTests: !opts.tests, json: opts.json });
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
program
|
|
67
72
|
.command('impact <file>')
|
|
68
73
|
.description('Show what depends on this file (transitive)')
|
|
69
74
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
75
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
70
76
|
.option('-j, --json', 'Output as JSON')
|
|
71
77
|
.action((file, opts) => {
|
|
72
|
-
impactAnalysis(file, opts.db, { json: opts.json });
|
|
78
|
+
impactAnalysis(file, opts.db, { noTests: !opts.tests, json: opts.json });
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
program
|
|
@@ -77,27 +83,30 @@ program
|
|
|
77
83
|
.description('High-level module overview with most-connected nodes')
|
|
78
84
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
79
85
|
.option('-n, --limit <number>', 'Number of top nodes', '20')
|
|
86
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
80
87
|
.option('-j, --json', 'Output as JSON')
|
|
81
88
|
.action((opts) => {
|
|
82
|
-
moduleMap(opts.db, parseInt(opts.limit, 10), { json: opts.json });
|
|
89
|
+
moduleMap(opts.db, parseInt(opts.limit, 10), { noTests: !opts.tests, json: opts.json });
|
|
83
90
|
});
|
|
84
91
|
|
|
85
92
|
program
|
|
86
93
|
.command('stats')
|
|
87
94
|
.description('Show graph health overview: nodes, edges, languages, cycles, hotspots, embeddings')
|
|
88
95
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
96
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
89
97
|
.option('-j, --json', 'Output as JSON')
|
|
90
98
|
.action((opts) => {
|
|
91
|
-
stats(opts.db, { json: opts.json });
|
|
99
|
+
stats(opts.db, { noTests: !opts.tests, json: opts.json });
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
program
|
|
95
103
|
.command('deps <file>')
|
|
96
104
|
.description('Show what this file imports and what imports it')
|
|
97
105
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
106
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
98
107
|
.option('-j, --json', 'Output as JSON')
|
|
99
108
|
.action((file, opts) => {
|
|
100
|
-
fileDeps(file, opts.db, { json: opts.json });
|
|
109
|
+
fileDeps(file, opts.db, { noTests: !opts.tests, json: opts.json });
|
|
101
110
|
});
|
|
102
111
|
|
|
103
112
|
program
|
|
@@ -105,11 +114,19 @@ program
|
|
|
105
114
|
.description('Function-level dependencies: callers, callees, and transitive call chain')
|
|
106
115
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
107
116
|
.option('--depth <n>', 'Transitive caller depth', '3')
|
|
117
|
+
.option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
|
|
118
|
+
.option('-k, --kind <kind>', 'Filter to a specific symbol kind')
|
|
108
119
|
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
109
120
|
.option('-j, --json', 'Output as JSON')
|
|
110
121
|
.action((name, opts) => {
|
|
122
|
+
if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
|
|
123
|
+
console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
111
126
|
fnDeps(name, opts.db, {
|
|
112
127
|
depth: parseInt(opts.depth, 10),
|
|
128
|
+
file: opts.file,
|
|
129
|
+
kind: opts.kind,
|
|
113
130
|
noTests: !opts.tests,
|
|
114
131
|
json: opts.json,
|
|
115
132
|
});
|
|
@@ -120,16 +137,77 @@ program
|
|
|
120
137
|
.description('Function-level impact: what functions break if this one changes')
|
|
121
138
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
122
139
|
.option('--depth <n>', 'Max transitive depth', '5')
|
|
140
|
+
.option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
|
|
141
|
+
.option('-k, --kind <kind>', 'Filter to a specific symbol kind')
|
|
123
142
|
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
124
143
|
.option('-j, --json', 'Output as JSON')
|
|
125
144
|
.action((name, opts) => {
|
|
145
|
+
if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
|
|
146
|
+
console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
126
149
|
fnImpact(name, opts.db, {
|
|
127
150
|
depth: parseInt(opts.depth, 10),
|
|
151
|
+
file: opts.file,
|
|
152
|
+
kind: opts.kind,
|
|
153
|
+
noTests: !opts.tests,
|
|
154
|
+
json: opts.json,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
program
|
|
159
|
+
.command('context <name>')
|
|
160
|
+
.description('Full context for a function: source, deps, callers, tests, signature')
|
|
161
|
+
.option('-d, --db <path>', 'Path to graph.db')
|
|
162
|
+
.option('--depth <n>', 'Include callee source up to N levels deep', '0')
|
|
163
|
+
.option('-f, --file <path>', 'Scope search to functions in this file (partial match)')
|
|
164
|
+
.option('-k, --kind <kind>', 'Filter to a specific symbol kind')
|
|
165
|
+
.option('--no-source', 'Metadata only (skip source extraction)')
|
|
166
|
+
.option('--include-tests', 'Include test source code')
|
|
167
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
168
|
+
.option('-j, --json', 'Output as JSON')
|
|
169
|
+
.action((name, opts) => {
|
|
170
|
+
if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
|
|
171
|
+
console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
context(name, opts.db, {
|
|
175
|
+
depth: parseInt(opts.depth, 10),
|
|
176
|
+
file: opts.file,
|
|
177
|
+
kind: opts.kind,
|
|
178
|
+
noSource: !opts.source,
|
|
128
179
|
noTests: !opts.tests,
|
|
180
|
+
includeTests: opts.includeTests,
|
|
129
181
|
json: opts.json,
|
|
130
182
|
});
|
|
131
183
|
});
|
|
132
184
|
|
|
185
|
+
program
|
|
186
|
+
.command('explain <target>')
|
|
187
|
+
.description('Structural summary of a file or function (no LLM needed)')
|
|
188
|
+
.option('-d, --db <path>', 'Path to graph.db')
|
|
189
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
190
|
+
.option('-j, --json', 'Output as JSON')
|
|
191
|
+
.action((target, opts) => {
|
|
192
|
+
explain(target, opts.db, { noTests: !opts.tests, json: opts.json });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
program
|
|
196
|
+
.command('where [name]')
|
|
197
|
+
.description('Find where a symbol is defined and used (minimal, fast lookup)')
|
|
198
|
+
.option('-d, --db <path>', 'Path to graph.db')
|
|
199
|
+
.option('-f, --file <path>', 'File overview: list symbols, imports, exports')
|
|
200
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
201
|
+
.option('-j, --json', 'Output as JSON')
|
|
202
|
+
.action((name, opts) => {
|
|
203
|
+
if (!name && !opts.file) {
|
|
204
|
+
console.error('Provide a symbol name or use --file <path>');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const target = opts.file || name;
|
|
208
|
+
where(target, opts.db, { file: !!opts.file, noTests: !opts.tests, json: opts.json });
|
|
209
|
+
});
|
|
210
|
+
|
|
133
211
|
program
|
|
134
212
|
.command('diff-impact [ref]')
|
|
135
213
|
.description('Show impact of git changes (unstaged, staged, or vs a ref)')
|
|
@@ -156,10 +234,11 @@ program
|
|
|
156
234
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
157
235
|
.option('-f, --format <format>', 'Output format: dot, mermaid, json', 'dot')
|
|
158
236
|
.option('--functions', 'Function-level graph instead of file-level')
|
|
237
|
+
.option('-T, --no-tests', 'Exclude test/spec files')
|
|
159
238
|
.option('-o, --output <file>', 'Write to file instead of stdout')
|
|
160
239
|
.action((opts) => {
|
|
161
240
|
const db = new Database(findDbPath(opts.db), { readonly: true });
|
|
162
|
-
const exportOpts = { fileLevel: !opts.functions };
|
|
241
|
+
const exportOpts = { fileLevel: !opts.functions, noTests: !opts.tests };
|
|
163
242
|
|
|
164
243
|
let output;
|
|
165
244
|
switch (opts.format) {
|
|
@@ -167,7 +246,7 @@ program
|
|
|
167
246
|
output = exportMermaid(db, exportOpts);
|
|
168
247
|
break;
|
|
169
248
|
case 'json':
|
|
170
|
-
output = JSON.stringify(exportJSON(db), null, 2);
|
|
249
|
+
output = JSON.stringify(exportJSON(db, exportOpts), null, 2);
|
|
171
250
|
break;
|
|
172
251
|
default:
|
|
173
252
|
output = exportDOT(db, exportOpts);
|
|
@@ -189,10 +268,11 @@ program
|
|
|
189
268
|
.description('Detect circular dependencies in the codebase')
|
|
190
269
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
191
270
|
.option('--functions', 'Function-level cycle detection')
|
|
271
|
+
.option('-T, --no-tests', 'Exclude test/spec files')
|
|
192
272
|
.option('-j, --json', 'Output as JSON')
|
|
193
273
|
.action((opts) => {
|
|
194
274
|
const db = new Database(findDbPath(opts.db), { readonly: true });
|
|
195
|
-
const cycles = findCycles(db, { fileLevel: !opts.functions });
|
|
275
|
+
const cycles = findCycles(db, { fileLevel: !opts.functions, noTests: !opts.tests });
|
|
196
276
|
db.close();
|
|
197
277
|
|
|
198
278
|
if (opts.json) {
|
|
@@ -294,7 +374,7 @@ program
|
|
|
294
374
|
.action(() => {
|
|
295
375
|
console.log('\nAvailable embedding models:\n');
|
|
296
376
|
for (const [key, config] of Object.entries(MODELS)) {
|
|
297
|
-
const def = key === '
|
|
377
|
+
const def = key === 'nomic-v1.5' ? ' (default)' : '';
|
|
298
378
|
console.log(` ${key.padEnd(12)} ${String(config.dim).padStart(4)}d ${config.desc}${def}`);
|
|
299
379
|
}
|
|
300
380
|
console.log('\nUsage: codegraph embed --model <name>');
|
|
@@ -308,8 +388,8 @@ program
|
|
|
308
388
|
)
|
|
309
389
|
.option(
|
|
310
390
|
'-m, --model <name>',
|
|
311
|
-
'Embedding model: minilm, jina-small, jina-base, jina-code
|
|
312
|
-
'
|
|
391
|
+
'Embedding model: minilm, jina-small, jina-base, jina-code, nomic, nomic-v1.5 (default), bge-large. Run `codegraph models` for details',
|
|
392
|
+
'nomic-v1.5',
|
|
313
393
|
)
|
|
314
394
|
.action(async (dir, opts) => {
|
|
315
395
|
const root = path.resolve(dir || '.');
|
|
@@ -322,7 +402,7 @@ program
|
|
|
322
402
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
323
403
|
.option('-m, --model <name>', 'Override embedding model (auto-detects from DB)')
|
|
324
404
|
.option('-n, --limit <number>', 'Max results', '15')
|
|
325
|
-
.option('-T, --no-tests', 'Exclude test/spec files')
|
|
405
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
326
406
|
.option('--min-score <score>', 'Minimum similarity threshold', '0.2')
|
|
327
407
|
.option('-k, --kind <kind>', 'Filter by kind: function, method, class')
|
|
328
408
|
.option('--file <pattern>', 'Filter by file path pattern')
|
|
@@ -347,6 +427,7 @@ program
|
|
|
347
427
|
.option('-d, --db <path>', 'Path to graph.db')
|
|
348
428
|
.option('--depth <n>', 'Max directory depth')
|
|
349
429
|
.option('--sort <metric>', 'Sort by: cohesion | fan-in | fan-out | density | files', 'files')
|
|
430
|
+
.option('-T, --no-tests', 'Exclude test/spec files')
|
|
350
431
|
.option('-j, --json', 'Output as JSON')
|
|
351
432
|
.action(async (dir, opts) => {
|
|
352
433
|
const { structureData, formatStructure } = await import('./structure.js');
|
|
@@ -354,6 +435,7 @@ program
|
|
|
354
435
|
directory: dir,
|
|
355
436
|
depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
|
|
356
437
|
sort: opts.sort,
|
|
438
|
+
noTests: !opts.tests,
|
|
357
439
|
});
|
|
358
440
|
if (opts.json) {
|
|
359
441
|
console.log(JSON.stringify(data, null, 2));
|
|
@@ -371,6 +453,7 @@ program
|
|
|
371
453
|
.option('-n, --limit <number>', 'Number of results', '10')
|
|
372
454
|
.option('--metric <metric>', 'fan-in | fan-out | density | coupling', 'fan-in')
|
|
373
455
|
.option('--level <level>', 'file | directory', 'file')
|
|
456
|
+
.option('-T, --no-tests', 'Exclude test/spec files from results')
|
|
374
457
|
.option('-j, --json', 'Output as JSON')
|
|
375
458
|
.action(async (opts) => {
|
|
376
459
|
const { hotspotsData, formatHotspots } = await import('./structure.js');
|
|
@@ -378,6 +461,7 @@ program
|
|
|
378
461
|
metric: opts.metric,
|
|
379
462
|
level: opts.level,
|
|
380
463
|
limit: parseInt(opts.limit, 10),
|
|
464
|
+
noTests: !opts.tests,
|
|
381
465
|
});
|
|
382
466
|
if (opts.json) {
|
|
383
467
|
console.log(JSON.stringify(data, null, 2));
|
package/src/config.js
CHANGED
|
@@ -19,7 +19,7 @@ export const DEFAULTS = {
|
|
|
19
19
|
defaultDepth: 3,
|
|
20
20
|
defaultLimit: 20,
|
|
21
21
|
},
|
|
22
|
-
embeddings: { model: '
|
|
22
|
+
embeddings: { model: 'nomic-v1.5', llmProvider: null },
|
|
23
23
|
llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null },
|
|
24
24
|
search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 },
|
|
25
25
|
ci: { failOnCycles: false, impactThreshold: null },
|