@optave/codegraph 2.6.0 → 3.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/README.md +109 -52
- package/package.json +5 -5
- package/src/ast.js +392 -0
- package/src/batch.js +93 -3
- package/src/builder.js +314 -95
- package/src/cfg.js +1451 -0
- package/src/change-journal.js +130 -0
- package/src/cli.js +411 -139
- package/src/complexity.js +8 -8
- package/src/dataflow.js +1187 -0
- package/src/db.js +96 -0
- package/src/embedder.js +16 -16
- package/src/export.js +305 -0
- package/src/extractors/csharp.js +64 -1
- package/src/extractors/go.js +66 -1
- package/src/extractors/hcl.js +22 -0
- package/src/extractors/java.js +61 -1
- package/src/extractors/javascript.js +142 -0
- package/src/extractors/php.js +79 -0
- package/src/extractors/python.js +134 -0
- package/src/extractors/ruby.js +89 -0
- package/src/extractors/rust.js +71 -1
- package/src/index.js +51 -3
- package/src/mcp.js +403 -222
- package/src/paginate.js +3 -3
- package/src/parser.js +8 -0
- package/src/queries.js +362 -36
- package/src/structure.js +4 -1
- package/src/viewer.js +948 -0
- package/src/watcher.js +36 -1
package/src/builder.js
CHANGED
|
@@ -338,7 +338,103 @@ function getChangedFiles(db, allFiles, rootDir) {
|
|
|
338
338
|
return { changed, removed, isFullBuild: false };
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Purge all graph data for the specified files.
|
|
343
|
+
* Deletes: embeddings → edges (in+out) → node_metrics → function_complexity → dataflow → nodes.
|
|
344
|
+
* Handles missing tables gracefully (embeddings, complexity, dataflow may not exist in older DBs).
|
|
345
|
+
*
|
|
346
|
+
* @param {import('better-sqlite3').Database} db - Open writable database
|
|
347
|
+
* @param {string[]} files - Relative file paths to purge
|
|
348
|
+
* @param {object} [options]
|
|
349
|
+
* @param {boolean} [options.purgeHashes=true] - Also delete file_hashes entries
|
|
350
|
+
*/
|
|
351
|
+
export function purgeFilesFromGraph(db, files, options = {}) {
|
|
352
|
+
const { purgeHashes = true } = options;
|
|
353
|
+
if (!files || files.length === 0) return;
|
|
354
|
+
|
|
355
|
+
// Check if embeddings table exists
|
|
356
|
+
let hasEmbeddings = false;
|
|
357
|
+
try {
|
|
358
|
+
db.prepare('SELECT 1 FROM embeddings LIMIT 1').get();
|
|
359
|
+
hasEmbeddings = true;
|
|
360
|
+
} catch {
|
|
361
|
+
/* table doesn't exist */
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const deleteEmbeddingsForFile = hasEmbeddings
|
|
365
|
+
? db.prepare('DELETE FROM embeddings WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)')
|
|
366
|
+
: null;
|
|
367
|
+
const deleteNodesForFile = db.prepare('DELETE FROM nodes WHERE file = ?');
|
|
368
|
+
const deleteEdgesForFile = db.prepare(`
|
|
369
|
+
DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f)
|
|
370
|
+
OR target_id IN (SELECT id FROM nodes WHERE file = @f)
|
|
371
|
+
`);
|
|
372
|
+
const deleteMetricsForFile = db.prepare(
|
|
373
|
+
'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
374
|
+
);
|
|
375
|
+
let deleteComplexityForFile;
|
|
376
|
+
try {
|
|
377
|
+
deleteComplexityForFile = db.prepare(
|
|
378
|
+
'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
379
|
+
);
|
|
380
|
+
} catch {
|
|
381
|
+
deleteComplexityForFile = null;
|
|
382
|
+
}
|
|
383
|
+
let deleteDataflowForFile;
|
|
384
|
+
try {
|
|
385
|
+
deleteDataflowForFile = db.prepare(
|
|
386
|
+
'DELETE FROM dataflow WHERE source_id IN (SELECT id FROM nodes WHERE file = ?) OR target_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
387
|
+
);
|
|
388
|
+
} catch {
|
|
389
|
+
deleteDataflowForFile = null;
|
|
390
|
+
}
|
|
391
|
+
let deleteHashForFile;
|
|
392
|
+
if (purgeHashes) {
|
|
393
|
+
try {
|
|
394
|
+
deleteHashForFile = db.prepare('DELETE FROM file_hashes WHERE file = ?');
|
|
395
|
+
} catch {
|
|
396
|
+
deleteHashForFile = null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
let deleteAstNodesForFile;
|
|
400
|
+
try {
|
|
401
|
+
deleteAstNodesForFile = db.prepare('DELETE FROM ast_nodes WHERE file = ?');
|
|
402
|
+
} catch {
|
|
403
|
+
deleteAstNodesForFile = null;
|
|
404
|
+
}
|
|
405
|
+
let deleteCfgForFile;
|
|
406
|
+
try {
|
|
407
|
+
deleteCfgForFile = db.prepare(
|
|
408
|
+
'DELETE FROM cfg_edges WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
409
|
+
);
|
|
410
|
+
} catch {
|
|
411
|
+
deleteCfgForFile = null;
|
|
412
|
+
}
|
|
413
|
+
let deleteCfgBlocksForFile;
|
|
414
|
+
try {
|
|
415
|
+
deleteCfgBlocksForFile = db.prepare(
|
|
416
|
+
'DELETE FROM cfg_blocks WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
417
|
+
);
|
|
418
|
+
} catch {
|
|
419
|
+
deleteCfgBlocksForFile = null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const relPath of files) {
|
|
423
|
+
deleteEmbeddingsForFile?.run(relPath);
|
|
424
|
+
deleteEdgesForFile.run({ f: relPath });
|
|
425
|
+
deleteMetricsForFile.run(relPath);
|
|
426
|
+
deleteComplexityForFile?.run(relPath);
|
|
427
|
+
deleteDataflowForFile?.run(relPath, relPath);
|
|
428
|
+
deleteAstNodesForFile?.run(relPath);
|
|
429
|
+
deleteCfgForFile?.run(relPath);
|
|
430
|
+
deleteCfgBlocksForFile?.run(relPath);
|
|
431
|
+
deleteNodesForFile.run(relPath);
|
|
432
|
+
if (purgeHashes) deleteHashForFile?.run(relPath);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
341
436
|
export async function buildGraph(rootDir, opts = {}) {
|
|
437
|
+
rootDir = path.resolve(rootDir);
|
|
342
438
|
const dbPath = path.join(rootDir, '.codegraph', 'graph.db');
|
|
343
439
|
const db = openDb(dbPath);
|
|
344
440
|
initSchema(db);
|
|
@@ -352,19 +448,18 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
352
448
|
const { name: engineName, version: engineVersion } = getActiveEngine(engineOpts);
|
|
353
449
|
info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
|
|
354
450
|
|
|
355
|
-
// Check for engine/version mismatch
|
|
451
|
+
// Check for engine/version mismatch — auto-promote to full rebuild
|
|
452
|
+
let forceFullRebuild = false;
|
|
356
453
|
if (incremental) {
|
|
357
454
|
const prevEngine = getBuildMeta(db, 'engine');
|
|
358
455
|
const prevVersion = getBuildMeta(db, 'codegraph_version');
|
|
359
456
|
if (prevEngine && prevEngine !== engineName) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
);
|
|
457
|
+
info(`Engine changed (${prevEngine} → ${engineName}), promoting to full rebuild.`);
|
|
458
|
+
forceFullRebuild = true;
|
|
363
459
|
}
|
|
364
460
|
if (prevVersion && prevVersion !== CODEGRAPH_VERSION) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
);
|
|
461
|
+
info(`Version changed (${prevVersion} → ${CODEGRAPH_VERSION}), promoting to full rebuild.`);
|
|
462
|
+
forceFullRebuild = true;
|
|
368
463
|
}
|
|
369
464
|
}
|
|
370
465
|
|
|
@@ -384,21 +479,91 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
384
479
|
);
|
|
385
480
|
}
|
|
386
481
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
482
|
+
// ── Scoped rebuild: rebuild only specified files ──────────────────
|
|
483
|
+
let files, discoveredDirs, parseChanges, metadataUpdates, removed, isFullBuild;
|
|
484
|
+
|
|
485
|
+
if (opts.scope) {
|
|
486
|
+
const scopedFiles = opts.scope.map((f) => normalizePath(f));
|
|
487
|
+
const existing = [];
|
|
488
|
+
const missing = [];
|
|
489
|
+
for (const rel of scopedFiles) {
|
|
490
|
+
const abs = path.join(rootDir, rel);
|
|
491
|
+
if (fs.existsSync(abs)) {
|
|
492
|
+
existing.push({ file: abs, relPath: rel });
|
|
493
|
+
} else {
|
|
494
|
+
missing.push(rel);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
files = existing.map((e) => e.file);
|
|
498
|
+
// Derive discoveredDirs from scoped files' parent directories
|
|
499
|
+
discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
|
|
500
|
+
parseChanges = existing;
|
|
501
|
+
metadataUpdates = [];
|
|
502
|
+
removed = missing;
|
|
503
|
+
isFullBuild = false;
|
|
504
|
+
info(`Scoped rebuild: ${existing.length} files to rebuild, ${missing.length} to purge`);
|
|
505
|
+
} else {
|
|
506
|
+
const collected = collectFiles(rootDir, [], config, new Set());
|
|
507
|
+
files = collected.files;
|
|
508
|
+
discoveredDirs = collected.directories;
|
|
509
|
+
info(`Found ${files.length} files to parse`);
|
|
510
|
+
|
|
511
|
+
// Check for incremental build
|
|
512
|
+
const increResult =
|
|
513
|
+
incremental && !forceFullRebuild
|
|
514
|
+
? getChangedFiles(db, files, rootDir)
|
|
515
|
+
: { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
|
|
516
|
+
removed = increResult.removed;
|
|
517
|
+
isFullBuild = increResult.isFullBuild;
|
|
518
|
+
|
|
519
|
+
// Separate metadata-only updates (mtime/size self-heal) from real changes
|
|
520
|
+
parseChanges = increResult.changed.filter((c) => !c.metadataOnly);
|
|
521
|
+
metadataUpdates = increResult.changed.filter((c) => c.metadataOnly);
|
|
522
|
+
}
|
|
400
523
|
|
|
401
524
|
if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
|
|
525
|
+
// Check if optional analysis was requested but never computed
|
|
526
|
+
const needsCfg =
|
|
527
|
+
opts.cfg &&
|
|
528
|
+
(() => {
|
|
529
|
+
try {
|
|
530
|
+
return db.prepare('SELECT COUNT(*) as c FROM cfg_blocks').get().c === 0;
|
|
531
|
+
} catch {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
535
|
+
const needsDataflow =
|
|
536
|
+
opts.dataflow &&
|
|
537
|
+
(() => {
|
|
538
|
+
try {
|
|
539
|
+
return (
|
|
540
|
+
db
|
|
541
|
+
.prepare(
|
|
542
|
+
"SELECT COUNT(*) as c FROM edges WHERE kind IN ('flows_to','returns','mutates')",
|
|
543
|
+
)
|
|
544
|
+
.get().c === 0
|
|
545
|
+
);
|
|
546
|
+
} catch {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
})();
|
|
550
|
+
|
|
551
|
+
if (needsCfg || needsDataflow) {
|
|
552
|
+
info('No file changes. Running pending analysis pass...');
|
|
553
|
+
const analysisSymbols = await parseFilesAuto(files, rootDir, engineOpts);
|
|
554
|
+
if (needsCfg) {
|
|
555
|
+
const { buildCFGData } = await import('./cfg.js');
|
|
556
|
+
await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
|
|
557
|
+
}
|
|
558
|
+
if (needsDataflow) {
|
|
559
|
+
const { buildDataflowEdges } = await import('./dataflow.js');
|
|
560
|
+
await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts);
|
|
561
|
+
}
|
|
562
|
+
closeDb(db);
|
|
563
|
+
writeJournalHeader(rootDir, Date.now());
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
402
567
|
// Still update metadata for self-healing even when no real changes
|
|
403
568
|
if (metadataUpdates.length > 0) {
|
|
404
569
|
try {
|
|
@@ -435,7 +600,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
435
600
|
|
|
436
601
|
if (isFullBuild) {
|
|
437
602
|
const deletions =
|
|
438
|
-
'PRAGMA foreign_keys = OFF; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM function_complexity; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
|
|
603
|
+
'PRAGMA foreign_keys = OFF; DELETE FROM cfg_edges; DELETE FROM cfg_blocks; DELETE FROM node_metrics; DELETE FROM edges; DELETE FROM function_complexity; DELETE FROM dataflow; DELETE FROM ast_nodes; DELETE FROM nodes; PRAGMA foreign_keys = ON;';
|
|
439
604
|
db.exec(
|
|
440
605
|
hasEmbeddings
|
|
441
606
|
? `${deletions.replace('PRAGMA foreign_keys = ON;', '')} DELETE FROM embeddings; PRAGMA foreign_keys = ON;`
|
|
@@ -446,29 +611,33 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
446
611
|
// Find files with edges pointing TO changed/removed files.
|
|
447
612
|
// Their nodes stay intact (preserving IDs), but outgoing edges are
|
|
448
613
|
// deleted so they can be rebuilt during the edge-building pass.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
|
|
452
|
-
}
|
|
453
|
-
for (const relPath of removed) {
|
|
454
|
-
changedRelPaths.add(relPath);
|
|
455
|
-
}
|
|
456
|
-
|
|
614
|
+
// When opts.noReverseDeps is true (e.g. agent rollback to same version),
|
|
615
|
+
// skip this cascade — the agent knows exports didn't change.
|
|
457
616
|
const reverseDeps = new Set();
|
|
458
|
-
if (
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
617
|
+
if (!opts.noReverseDeps) {
|
|
618
|
+
const changedRelPaths = new Set();
|
|
619
|
+
for (const item of parseChanges) {
|
|
620
|
+
changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
|
|
621
|
+
}
|
|
622
|
+
for (const relPath of removed) {
|
|
623
|
+
changedRelPaths.add(relPath);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (changedRelPaths.size > 0) {
|
|
627
|
+
const findReverseDeps = db.prepare(`
|
|
628
|
+
SELECT DISTINCT n_src.file FROM edges e
|
|
629
|
+
JOIN nodes n_src ON e.source_id = n_src.id
|
|
630
|
+
JOIN nodes n_tgt ON e.target_id = n_tgt.id
|
|
631
|
+
WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
|
|
632
|
+
`);
|
|
633
|
+
for (const relPath of changedRelPaths) {
|
|
634
|
+
for (const row of findReverseDeps.all(relPath)) {
|
|
635
|
+
if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
|
|
636
|
+
// Verify the file still exists on disk
|
|
637
|
+
const absPath = path.join(rootDir, row.file);
|
|
638
|
+
if (fs.existsSync(absPath)) {
|
|
639
|
+
reverseDeps.add(row.file);
|
|
640
|
+
}
|
|
472
641
|
}
|
|
473
642
|
}
|
|
474
643
|
}
|
|
@@ -482,47 +651,16 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
482
651
|
debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`);
|
|
483
652
|
if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`);
|
|
484
653
|
// Remove embeddings/metrics/edges/nodes for changed and removed files
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
? db.prepare('DELETE FROM embeddings WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)')
|
|
488
|
-
: null;
|
|
489
|
-
const deleteNodesForFile = db.prepare('DELETE FROM nodes WHERE file = ?');
|
|
490
|
-
const deleteEdgesForFile = db.prepare(`
|
|
491
|
-
DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f)
|
|
492
|
-
OR target_id IN (SELECT id FROM nodes WHERE file = @f)
|
|
493
|
-
`);
|
|
494
|
-
const deleteOutgoingEdgesForFile = db.prepare(
|
|
495
|
-
'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
496
|
-
);
|
|
497
|
-
const deleteMetricsForFile = db.prepare(
|
|
498
|
-
'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
654
|
+
const changePaths = parseChanges.map(
|
|
655
|
+
(item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
|
|
499
656
|
);
|
|
500
|
-
|
|
501
|
-
try {
|
|
502
|
-
deleteComplexityForFile = db.prepare(
|
|
503
|
-
'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
504
|
-
);
|
|
505
|
-
} catch {
|
|
506
|
-
deleteComplexityForFile = null;
|
|
507
|
-
}
|
|
508
|
-
for (const relPath of removed) {
|
|
509
|
-
deleteEmbeddingsForFile?.run(relPath);
|
|
510
|
-
deleteEdgesForFile.run({ f: relPath });
|
|
511
|
-
deleteMetricsForFile.run(relPath);
|
|
512
|
-
deleteComplexityForFile?.run(relPath);
|
|
513
|
-
deleteNodesForFile.run(relPath);
|
|
514
|
-
}
|
|
515
|
-
for (const item of parseChanges) {
|
|
516
|
-
const relPath = item.relPath || normalizePath(path.relative(rootDir, item.file));
|
|
517
|
-
deleteEmbeddingsForFile?.run(relPath);
|
|
518
|
-
deleteEdgesForFile.run({ f: relPath });
|
|
519
|
-
deleteMetricsForFile.run(relPath);
|
|
520
|
-
deleteComplexityForFile?.run(relPath);
|
|
521
|
-
deleteNodesForFile.run(relPath);
|
|
522
|
-
}
|
|
657
|
+
purgeFilesFromGraph(db, [...removed, ...changePaths], { purgeHashes: false });
|
|
523
658
|
|
|
524
659
|
// Process reverse deps: delete only outgoing edges (nodes/IDs preserved)
|
|
525
660
|
// then add them to the parse list so they participate in edge building
|
|
661
|
+
const deleteOutgoingEdgesForFile = db.prepare(
|
|
662
|
+
'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
663
|
+
);
|
|
526
664
|
for (const relPath of reverseDeps) {
|
|
527
665
|
deleteOutgoingEdgesForFile.run(relPath);
|
|
528
666
|
}
|
|
@@ -533,7 +671,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
533
671
|
}
|
|
534
672
|
|
|
535
673
|
const insertNode = db.prepare(
|
|
536
|
-
'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
|
|
674
|
+
'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)',
|
|
537
675
|
);
|
|
538
676
|
const getNodeId = db.prepare(
|
|
539
677
|
'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
|
|
@@ -587,12 +725,39 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
587
725
|
for (const [relPath, symbols] of allSymbols) {
|
|
588
726
|
fileSymbols.set(relPath, symbols);
|
|
589
727
|
|
|
590
|
-
insertNode.run(relPath, 'file', relPath, 0, null);
|
|
728
|
+
insertNode.run(relPath, 'file', relPath, 0, null, null);
|
|
729
|
+
const fileRow = getNodeId.get(relPath, 'file', relPath, 0);
|
|
591
730
|
for (const def of symbols.definitions) {
|
|
592
|
-
insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null);
|
|
731
|
+
insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null);
|
|
732
|
+
const defRow = getNodeId.get(def.name, def.kind, relPath, def.line);
|
|
733
|
+
// File → top-level definition contains edge
|
|
734
|
+
if (fileRow && defRow) {
|
|
735
|
+
insertEdge.run(fileRow.id, defRow.id, 'contains', 1.0, 0);
|
|
736
|
+
}
|
|
737
|
+
if (def.children?.length && defRow) {
|
|
738
|
+
for (const child of def.children) {
|
|
739
|
+
insertNode.run(
|
|
740
|
+
child.name,
|
|
741
|
+
child.kind,
|
|
742
|
+
relPath,
|
|
743
|
+
child.line,
|
|
744
|
+
child.endLine || null,
|
|
745
|
+
defRow.id,
|
|
746
|
+
);
|
|
747
|
+
// Parent → child contains edge
|
|
748
|
+
const childRow = getNodeId.get(child.name, child.kind, relPath, child.line);
|
|
749
|
+
if (childRow) {
|
|
750
|
+
insertEdge.run(defRow.id, childRow.id, 'contains', 1.0, 0);
|
|
751
|
+
// Parameter → parent parameter_of edge (inverse direction)
|
|
752
|
+
if (child.kind === 'parameter') {
|
|
753
|
+
insertEdge.run(childRow.id, defRow.id, 'parameter_of', 1.0, 0);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
593
758
|
}
|
|
594
759
|
for (const exp of symbols.exports) {
|
|
595
|
-
insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
|
|
760
|
+
insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
|
|
596
761
|
}
|
|
597
762
|
|
|
598
763
|
// Update file hash with real mtime+size for incremental builds
|
|
@@ -772,7 +937,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
772
937
|
// N+1 optimization: pre-load all nodes into a lookup map for edge building
|
|
773
938
|
const allNodes = db
|
|
774
939
|
.prepare(
|
|
775
|
-
`SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface')`,
|
|
940
|
+
`SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`,
|
|
776
941
|
)
|
|
777
942
|
.all();
|
|
778
943
|
const nodesByName = new Map();
|
|
@@ -789,7 +954,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
789
954
|
|
|
790
955
|
// Second pass: build edges
|
|
791
956
|
_t.edges0 = performance.now();
|
|
792
|
-
let edgeCount = 0;
|
|
793
957
|
const buildEdges = db.transaction(() => {
|
|
794
958
|
for (const [relPath, symbols] of fileSymbols) {
|
|
795
959
|
// Skip barrel-only files — loaded for resolution, edges already in DB
|
|
@@ -805,7 +969,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
805
969
|
if (targetRow) {
|
|
806
970
|
const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
|
|
807
971
|
insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
|
|
808
|
-
edgeCount++;
|
|
809
972
|
|
|
810
973
|
if (!imp.reexport && isBarrelFile(resolvedPath)) {
|
|
811
974
|
const resolvedSources = new Set();
|
|
@@ -827,7 +990,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
827
990
|
0.9,
|
|
828
991
|
0,
|
|
829
992
|
);
|
|
830
|
-
edgeCount++;
|
|
831
993
|
}
|
|
832
994
|
}
|
|
833
995
|
}
|
|
@@ -928,7 +1090,29 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
928
1090
|
seenCallEdges.add(edgeKey);
|
|
929
1091
|
const confidence = computeConfidence(relPath, t.file, importedFrom);
|
|
930
1092
|
insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
|
|
931
|
-
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Receiver edge: caller → receiver type node
|
|
1097
|
+
if (
|
|
1098
|
+
call.receiver &&
|
|
1099
|
+
!BUILTIN_RECEIVERS.has(call.receiver) &&
|
|
1100
|
+
call.receiver !== 'this' &&
|
|
1101
|
+
call.receiver !== 'self' &&
|
|
1102
|
+
call.receiver !== 'super'
|
|
1103
|
+
) {
|
|
1104
|
+
const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
|
|
1105
|
+
// Same-file first, then global
|
|
1106
|
+
const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || [];
|
|
1107
|
+
const candidates = samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || [];
|
|
1108
|
+
const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
|
|
1109
|
+
if (receiverNodes.length > 0 && caller) {
|
|
1110
|
+
const recvTarget = receiverNodes[0];
|
|
1111
|
+
const recvKey = `recv|${caller.id}|${recvTarget.id}`;
|
|
1112
|
+
if (!seenCallEdges.has(recvKey)) {
|
|
1113
|
+
seenCallEdges.add(recvKey);
|
|
1114
|
+
insertEdge.run(caller.id, recvTarget.id, 'receiver', 0.7, 0);
|
|
1115
|
+
}
|
|
932
1116
|
}
|
|
933
1117
|
}
|
|
934
1118
|
}
|
|
@@ -944,7 +1128,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
944
1128
|
if (sourceRow) {
|
|
945
1129
|
for (const t of targetRows) {
|
|
946
1130
|
insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
|
|
947
|
-
edgeCount++;
|
|
948
1131
|
}
|
|
949
1132
|
}
|
|
950
1133
|
}
|
|
@@ -960,7 +1143,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
960
1143
|
if (sourceRow) {
|
|
961
1144
|
for (const t of targetRows) {
|
|
962
1145
|
insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
|
|
963
|
-
edgeCount++;
|
|
964
1146
|
}
|
|
965
1147
|
}
|
|
966
1148
|
}
|
|
@@ -1068,6 +1250,17 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1068
1250
|
}
|
|
1069
1251
|
_t.rolesMs = performance.now() - _t.roles0;
|
|
1070
1252
|
|
|
1253
|
+
// Always-on AST node extraction (calls, new, string, regex, throw, await)
|
|
1254
|
+
// Must run before complexity which releases _tree references
|
|
1255
|
+
_t.ast0 = performance.now();
|
|
1256
|
+
try {
|
|
1257
|
+
const { buildAstNodes } = await import('./ast.js');
|
|
1258
|
+
await buildAstNodes(db, allSymbols, rootDir, engineOpts);
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
debug(`AST node extraction failed: ${err.message}`);
|
|
1261
|
+
}
|
|
1262
|
+
_t.astMs = performance.now() - _t.ast0;
|
|
1263
|
+
|
|
1071
1264
|
// Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
|
|
1072
1265
|
_t.complexity0 = performance.now();
|
|
1073
1266
|
try {
|
|
@@ -1078,6 +1271,30 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1078
1271
|
}
|
|
1079
1272
|
_t.complexityMs = performance.now() - _t.complexity0;
|
|
1080
1273
|
|
|
1274
|
+
// Opt-in CFG analysis (--cfg)
|
|
1275
|
+
if (opts.cfg) {
|
|
1276
|
+
_t.cfg0 = performance.now();
|
|
1277
|
+
try {
|
|
1278
|
+
const { buildCFGData } = await import('./cfg.js');
|
|
1279
|
+
await buildCFGData(db, allSymbols, rootDir, engineOpts);
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
debug(`CFG analysis failed: ${err.message}`);
|
|
1282
|
+
}
|
|
1283
|
+
_t.cfgMs = performance.now() - _t.cfg0;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Opt-in dataflow analysis (--dataflow)
|
|
1287
|
+
if (opts.dataflow) {
|
|
1288
|
+
_t.dataflow0 = performance.now();
|
|
1289
|
+
try {
|
|
1290
|
+
const { buildDataflowEdges } = await import('./dataflow.js');
|
|
1291
|
+
await buildDataflowEdges(db, allSymbols, rootDir, engineOpts);
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
debug(`Dataflow analysis failed: ${err.message}`);
|
|
1294
|
+
}
|
|
1295
|
+
_t.dataflowMs = performance.now() - _t.dataflow0;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1081
1298
|
// Release any remaining cached WASM trees for GC
|
|
1082
1299
|
for (const [, symbols] of allSymbols) {
|
|
1083
1300
|
symbols._tree = null;
|
|
@@ -1085,7 +1302,8 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1085
1302
|
}
|
|
1086
1303
|
|
|
1087
1304
|
const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
|
|
1088
|
-
|
|
1305
|
+
const actualEdgeCount = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
|
|
1306
|
+
info(`Graph built: ${nodeCount} nodes, ${actualEdgeCount} edges`);
|
|
1089
1307
|
info(`Stored in ${dbPath}`);
|
|
1090
1308
|
|
|
1091
1309
|
// Verify incremental build didn't diverge significantly from previous counts
|
|
@@ -1097,11 +1315,11 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1097
1315
|
const prevE = Number(prevEdges);
|
|
1098
1316
|
if (prevN > 0) {
|
|
1099
1317
|
const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
|
|
1100
|
-
const edgeDrift = prevE > 0 ? Math.abs(
|
|
1318
|
+
const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
|
|
1101
1319
|
const driftThreshold = config.build?.driftThreshold ?? 0.2;
|
|
1102
1320
|
if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
|
|
1103
1321
|
warn(
|
|
1104
|
-
`Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${
|
|
1322
|
+
`Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${actualEdgeCount} [${(edgeDrift * 100).toFixed(1)}%], threshold: ${(driftThreshold * 100).toFixed(0)}%). Consider rebuilding with --no-incremental.`,
|
|
1105
1323
|
);
|
|
1106
1324
|
}
|
|
1107
1325
|
}
|
|
@@ -1132,7 +1350,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1132
1350
|
codegraph_version: CODEGRAPH_VERSION,
|
|
1133
1351
|
built_at: new Date().toISOString(),
|
|
1134
1352
|
node_count: nodeCount,
|
|
1135
|
-
edge_count:
|
|
1353
|
+
edge_count: actualEdgeCount,
|
|
1136
1354
|
});
|
|
1137
1355
|
} catch (err) {
|
|
1138
1356
|
warn(`Failed to write build metadata: ${err.message}`);
|
|
@@ -1168,6 +1386,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1168
1386
|
structureMs: +_t.structureMs.toFixed(1),
|
|
1169
1387
|
rolesMs: +_t.rolesMs.toFixed(1),
|
|
1170
1388
|
complexityMs: +_t.complexityMs.toFixed(1),
|
|
1389
|
+
...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
|
|
1171
1390
|
},
|
|
1172
1391
|
};
|
|
1173
1392
|
}
|