@optave/codegraph 2.5.1 → 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 +216 -89
- package/package.json +8 -7
- package/src/ast.js +392 -0
- package/src/audit.js +423 -0
- package/src/batch.js +180 -0
- package/src/boundaries.js +346 -0
- package/src/builder.js +375 -92
- package/src/cfg.js +1451 -0
- package/src/change-journal.js +130 -0
- package/src/check.js +432 -0
- package/src/cli.js +734 -107
- package/src/cochange.js +5 -2
- package/src/communities.js +7 -1
- package/src/complexity.js +124 -17
- package/src/config.js +10 -0
- package/src/dataflow.js +1187 -0
- package/src/db.js +96 -0
- package/src/embedder.js +359 -47
- 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/flow.js +4 -4
- package/src/index.js +78 -3
- package/src/manifesto.js +69 -1
- package/src/mcp.js +702 -193
- package/src/owners.js +359 -0
- package/src/paginate.js +37 -2
- package/src/parser.js +8 -0
- package/src/queries.js +590 -50
- package/src/snapshot.js +149 -0
- package/src/structure.js +9 -3
- package/src/triage.js +273 -0
- 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 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
|
|
@@ -687,6 +852,46 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
687
852
|
}
|
|
688
853
|
}
|
|
689
854
|
|
|
855
|
+
// For incremental builds, load unchanged barrel files into reexportMap
|
|
856
|
+
// so barrel-resolved import/call edges aren't dropped for reverse-dep files.
|
|
857
|
+
// These files are loaded only for resolution — they must NOT be iterated
|
|
858
|
+
// in the edge-building loop (their existing edges are still in the DB).
|
|
859
|
+
const barrelOnlyFiles = new Set();
|
|
860
|
+
if (!isFullBuild) {
|
|
861
|
+
const barrelCandidates = db
|
|
862
|
+
.prepare(
|
|
863
|
+
`SELECT DISTINCT n1.file FROM edges e
|
|
864
|
+
JOIN nodes n1 ON e.source_id = n1.id
|
|
865
|
+
WHERE e.kind = 'reexports' AND n1.kind = 'file'`,
|
|
866
|
+
)
|
|
867
|
+
.all();
|
|
868
|
+
for (const { file: relPath } of barrelCandidates) {
|
|
869
|
+
if (fileSymbols.has(relPath)) continue;
|
|
870
|
+
const absPath = path.join(rootDir, relPath);
|
|
871
|
+
try {
|
|
872
|
+
const symbols = await parseFilesAuto([absPath], rootDir, engineOpts);
|
|
873
|
+
const fileSym = symbols.get(relPath);
|
|
874
|
+
if (fileSym) {
|
|
875
|
+
fileSymbols.set(relPath, fileSym);
|
|
876
|
+
barrelOnlyFiles.add(relPath);
|
|
877
|
+
const reexports = fileSym.imports.filter((imp) => imp.reexport);
|
|
878
|
+
if (reexports.length > 0) {
|
|
879
|
+
reexportMap.set(
|
|
880
|
+
relPath,
|
|
881
|
+
reexports.map((imp) => ({
|
|
882
|
+
source: getResolved(absPath, imp.source),
|
|
883
|
+
names: imp.names,
|
|
884
|
+
wildcardReexport: imp.wildcardReexport || false,
|
|
885
|
+
})),
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
/* skip if unreadable */
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
690
895
|
function isBarrelFile(relPath) {
|
|
691
896
|
const symbols = fileSymbols.get(relPath);
|
|
692
897
|
if (!symbols) return false;
|
|
@@ -732,7 +937,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
732
937
|
// N+1 optimization: pre-load all nodes into a lookup map for edge building
|
|
733
938
|
const allNodes = db
|
|
734
939
|
.prepare(
|
|
735
|
-
`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')`,
|
|
736
941
|
)
|
|
737
942
|
.all();
|
|
738
943
|
const nodesByName = new Map();
|
|
@@ -749,9 +954,10 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
749
954
|
|
|
750
955
|
// Second pass: build edges
|
|
751
956
|
_t.edges0 = performance.now();
|
|
752
|
-
let edgeCount = 0;
|
|
753
957
|
const buildEdges = db.transaction(() => {
|
|
754
958
|
for (const [relPath, symbols] of fileSymbols) {
|
|
959
|
+
// Skip barrel-only files — loaded for resolution, edges already in DB
|
|
960
|
+
if (barrelOnlyFiles.has(relPath)) continue;
|
|
755
961
|
const fileNodeRow = getNodeId.get(relPath, 'file', relPath, 0);
|
|
756
962
|
if (!fileNodeRow) continue;
|
|
757
963
|
const fileNodeId = fileNodeRow.id;
|
|
@@ -763,7 +969,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
763
969
|
if (targetRow) {
|
|
764
970
|
const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
|
|
765
971
|
insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
|
|
766
|
-
edgeCount++;
|
|
767
972
|
|
|
768
973
|
if (!imp.reexport && isBarrelFile(resolvedPath)) {
|
|
769
974
|
const resolvedSources = new Set();
|
|
@@ -785,7 +990,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
785
990
|
0.9,
|
|
786
991
|
0,
|
|
787
992
|
);
|
|
788
|
-
edgeCount++;
|
|
789
993
|
}
|
|
790
994
|
}
|
|
791
995
|
}
|
|
@@ -886,7 +1090,29 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
886
1090
|
seenCallEdges.add(edgeKey);
|
|
887
1091
|
const confidence = computeConfidence(relPath, t.file, importedFrom);
|
|
888
1092
|
insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
|
|
889
|
-
|
|
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
|
+
}
|
|
890
1116
|
}
|
|
891
1117
|
}
|
|
892
1118
|
}
|
|
@@ -902,7 +1128,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
902
1128
|
if (sourceRow) {
|
|
903
1129
|
for (const t of targetRows) {
|
|
904
1130
|
insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
|
|
905
|
-
edgeCount++;
|
|
906
1131
|
}
|
|
907
1132
|
}
|
|
908
1133
|
}
|
|
@@ -918,7 +1143,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
918
1143
|
if (sourceRow) {
|
|
919
1144
|
for (const t of targetRows) {
|
|
920
1145
|
insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
|
|
921
|
-
edgeCount++;
|
|
922
1146
|
}
|
|
923
1147
|
}
|
|
924
1148
|
}
|
|
@@ -1026,6 +1250,17 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1026
1250
|
}
|
|
1027
1251
|
_t.rolesMs = performance.now() - _t.roles0;
|
|
1028
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
|
+
|
|
1029
1264
|
// Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
|
|
1030
1265
|
_t.complexity0 = performance.now();
|
|
1031
1266
|
try {
|
|
@@ -1036,6 +1271,30 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1036
1271
|
}
|
|
1037
1272
|
_t.complexityMs = performance.now() - _t.complexity0;
|
|
1038
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
|
+
|
|
1039
1298
|
// Release any remaining cached WASM trees for GC
|
|
1040
1299
|
for (const [, symbols] of allSymbols) {
|
|
1041
1300
|
symbols._tree = null;
|
|
@@ -1043,9 +1302,30 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1043
1302
|
}
|
|
1044
1303
|
|
|
1045
1304
|
const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
|
|
1046
|
-
|
|
1305
|
+
const actualEdgeCount = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
|
|
1306
|
+
info(`Graph built: ${nodeCount} nodes, ${actualEdgeCount} edges`);
|
|
1047
1307
|
info(`Stored in ${dbPath}`);
|
|
1048
1308
|
|
|
1309
|
+
// Verify incremental build didn't diverge significantly from previous counts
|
|
1310
|
+
if (!isFullBuild) {
|
|
1311
|
+
const prevNodes = getBuildMeta(db, 'node_count');
|
|
1312
|
+
const prevEdges = getBuildMeta(db, 'edge_count');
|
|
1313
|
+
if (prevNodes && prevEdges) {
|
|
1314
|
+
const prevN = Number(prevNodes);
|
|
1315
|
+
const prevE = Number(prevEdges);
|
|
1316
|
+
if (prevN > 0) {
|
|
1317
|
+
const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
|
|
1318
|
+
const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
|
|
1319
|
+
const driftThreshold = config.build?.driftThreshold ?? 0.2;
|
|
1320
|
+
if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
|
|
1321
|
+
warn(
|
|
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.`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1049
1329
|
// Warn about orphaned embeddings that no longer match any node
|
|
1050
1330
|
if (hasEmbeddings) {
|
|
1051
1331
|
try {
|
|
@@ -1069,6 +1349,8 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1069
1349
|
engine_version: engineVersion || '',
|
|
1070
1350
|
codegraph_version: CODEGRAPH_VERSION,
|
|
1071
1351
|
built_at: new Date().toISOString(),
|
|
1352
|
+
node_count: nodeCount,
|
|
1353
|
+
edge_count: actualEdgeCount,
|
|
1072
1354
|
});
|
|
1073
1355
|
} catch (err) {
|
|
1074
1356
|
warn(`Failed to write build metadata: ${err.message}`);
|
|
@@ -1104,6 +1386,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1104
1386
|
structureMs: +_t.structureMs.toFixed(1),
|
|
1105
1387
|
rolesMs: +_t.rolesMs.toFixed(1),
|
|
1106
1388
|
complexityMs: +_t.complexityMs.toFixed(1),
|
|
1389
|
+
...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
|
|
1107
1390
|
},
|
|
1108
1391
|
};
|
|
1109
1392
|
}
|