@optave/codegraph 2.6.0 → 3.0.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 +111 -54
- package/package.json +5 -5
- package/src/ast.js +418 -0
- package/src/batch.js +93 -3
- package/src/builder.js +371 -103
- package/src/cfg.js +1452 -0
- package/src/change-journal.js +130 -0
- package/src/cli.js +415 -139
- package/src/complexity.js +8 -8
- package/src/dataflow.js +1190 -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 +193 -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 +5 -2
- package/src/index.js +52 -4
- package/src/mcp.js +403 -222
- package/src/paginate.js +3 -3
- package/src/parser.js +24 -0
- package/src/queries.js +362 -36
- package/src/structure.js +64 -8
- package/src/viewer.js +948 -0
- package/src/watcher.js +36 -1
package/src/builder.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { performance } from 'node:perf_hooks';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
|
|
7
|
-
import { closeDb, getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js';
|
|
7
|
+
import { closeDb, getBuildMeta, initSchema, MIGRATIONS, openDb, setBuildMeta } from './db.js';
|
|
8
8
|
import { readJournal, writeJournalHeader } from './journal.js';
|
|
9
9
|
import { debug, info, warn } from './logger.js';
|
|
10
10
|
import { getActiveEngine, parseFilesAuto } from './parser.js';
|
|
@@ -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,22 @@ 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/
|
|
451
|
+
// Check for engine/schema mismatch — auto-promote to full rebuild
|
|
452
|
+
// Only trigger on engine change or schema version change (not every patch/minor bump)
|
|
453
|
+
const CURRENT_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
454
|
+
let forceFullRebuild = false;
|
|
356
455
|
if (incremental) {
|
|
357
456
|
const prevEngine = getBuildMeta(db, 'engine');
|
|
358
|
-
const prevVersion = getBuildMeta(db, 'codegraph_version');
|
|
359
457
|
if (prevEngine && prevEngine !== engineName) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
);
|
|
458
|
+
info(`Engine changed (${prevEngine} → ${engineName}), promoting to full rebuild.`);
|
|
459
|
+
forceFullRebuild = true;
|
|
363
460
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
461
|
+
const prevSchema = getBuildMeta(db, 'schema_version');
|
|
462
|
+
if (prevSchema && Number(prevSchema) !== CURRENT_SCHEMA_VERSION) {
|
|
463
|
+
info(
|
|
464
|
+
`Schema version changed (${prevSchema} → ${CURRENT_SCHEMA_VERSION}), promoting to full rebuild.`,
|
|
367
465
|
);
|
|
466
|
+
forceFullRebuild = true;
|
|
368
467
|
}
|
|
369
468
|
}
|
|
370
469
|
|
|
@@ -384,21 +483,85 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
384
483
|
);
|
|
385
484
|
}
|
|
386
485
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
486
|
+
// ── Scoped rebuild: rebuild only specified files ──────────────────
|
|
487
|
+
let files, discoveredDirs, parseChanges, metadataUpdates, removed, isFullBuild;
|
|
488
|
+
|
|
489
|
+
if (opts.scope) {
|
|
490
|
+
const scopedFiles = opts.scope.map((f) => normalizePath(f));
|
|
491
|
+
const existing = [];
|
|
492
|
+
const missing = [];
|
|
493
|
+
for (const rel of scopedFiles) {
|
|
494
|
+
const abs = path.join(rootDir, rel);
|
|
495
|
+
if (fs.existsSync(abs)) {
|
|
496
|
+
existing.push({ file: abs, relPath: rel });
|
|
497
|
+
} else {
|
|
498
|
+
missing.push(rel);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
files = existing.map((e) => e.file);
|
|
502
|
+
// Derive discoveredDirs from scoped files' parent directories
|
|
503
|
+
discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
|
|
504
|
+
parseChanges = existing;
|
|
505
|
+
metadataUpdates = [];
|
|
506
|
+
removed = missing;
|
|
507
|
+
isFullBuild = false;
|
|
508
|
+
info(`Scoped rebuild: ${existing.length} files to rebuild, ${missing.length} to purge`);
|
|
509
|
+
} else {
|
|
510
|
+
const collected = collectFiles(rootDir, [], config, new Set());
|
|
511
|
+
files = collected.files;
|
|
512
|
+
discoveredDirs = collected.directories;
|
|
513
|
+
info(`Found ${files.length} files to parse`);
|
|
514
|
+
|
|
515
|
+
// Check for incremental build
|
|
516
|
+
const increResult =
|
|
517
|
+
incremental && !forceFullRebuild
|
|
518
|
+
? getChangedFiles(db, files, rootDir)
|
|
519
|
+
: { changed: files.map((f) => ({ file: f })), removed: [], isFullBuild: true };
|
|
520
|
+
removed = increResult.removed;
|
|
521
|
+
isFullBuild = increResult.isFullBuild;
|
|
522
|
+
|
|
523
|
+
// Separate metadata-only updates (mtime/size self-heal) from real changes
|
|
524
|
+
parseChanges = increResult.changed.filter((c) => !c.metadataOnly);
|
|
525
|
+
metadataUpdates = increResult.changed.filter((c) => c.metadataOnly);
|
|
526
|
+
}
|
|
400
527
|
|
|
401
528
|
if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
|
|
529
|
+
// Check if default analyses were never computed (e.g. legacy DB)
|
|
530
|
+
const needsCfg =
|
|
531
|
+
opts.cfg !== false &&
|
|
532
|
+
(() => {
|
|
533
|
+
try {
|
|
534
|
+
return db.prepare('SELECT COUNT(*) as c FROM cfg_blocks').get().c === 0;
|
|
535
|
+
} catch {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
})();
|
|
539
|
+
const needsDataflow =
|
|
540
|
+
opts.dataflow !== false &&
|
|
541
|
+
(() => {
|
|
542
|
+
try {
|
|
543
|
+
return db.prepare('SELECT COUNT(*) as c FROM dataflow').get().c === 0;
|
|
544
|
+
} catch {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
})();
|
|
548
|
+
|
|
549
|
+
if (needsCfg || needsDataflow) {
|
|
550
|
+
info('No file changes. Running pending analysis pass...');
|
|
551
|
+
const analysisSymbols = await parseFilesAuto(files, rootDir, engineOpts);
|
|
552
|
+
if (needsCfg) {
|
|
553
|
+
const { buildCFGData } = await import('./cfg.js');
|
|
554
|
+
await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
|
|
555
|
+
}
|
|
556
|
+
if (needsDataflow) {
|
|
557
|
+
const { buildDataflowEdges } = await import('./dataflow.js');
|
|
558
|
+
await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts);
|
|
559
|
+
}
|
|
560
|
+
closeDb(db);
|
|
561
|
+
writeJournalHeader(rootDir, Date.now());
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
402
565
|
// Still update metadata for self-healing even when no real changes
|
|
403
566
|
if (metadataUpdates.length > 0) {
|
|
404
567
|
try {
|
|
@@ -435,7 +598,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
435
598
|
|
|
436
599
|
if (isFullBuild) {
|
|
437
600
|
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;';
|
|
601
|
+
'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
602
|
db.exec(
|
|
440
603
|
hasEmbeddings
|
|
441
604
|
? `${deletions.replace('PRAGMA foreign_keys = ON;', '')} DELETE FROM embeddings; PRAGMA foreign_keys = ON;`
|
|
@@ -446,29 +609,33 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
446
609
|
// Find files with edges pointing TO changed/removed files.
|
|
447
610
|
// Their nodes stay intact (preserving IDs), but outgoing edges are
|
|
448
611
|
// 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
|
-
|
|
612
|
+
// When opts.noReverseDeps is true (e.g. agent rollback to same version),
|
|
613
|
+
// skip this cascade — the agent knows exports didn't change.
|
|
457
614
|
const reverseDeps = new Set();
|
|
458
|
-
if (
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
615
|
+
if (!opts.noReverseDeps) {
|
|
616
|
+
const changedRelPaths = new Set();
|
|
617
|
+
for (const item of parseChanges) {
|
|
618
|
+
changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
|
|
619
|
+
}
|
|
620
|
+
for (const relPath of removed) {
|
|
621
|
+
changedRelPaths.add(relPath);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (changedRelPaths.size > 0) {
|
|
625
|
+
const findReverseDeps = db.prepare(`
|
|
626
|
+
SELECT DISTINCT n_src.file FROM edges e
|
|
627
|
+
JOIN nodes n_src ON e.source_id = n_src.id
|
|
628
|
+
JOIN nodes n_tgt ON e.target_id = n_tgt.id
|
|
629
|
+
WHERE n_tgt.file = ? AND n_src.file != n_tgt.file AND n_src.kind != 'directory'
|
|
630
|
+
`);
|
|
631
|
+
for (const relPath of changedRelPaths) {
|
|
632
|
+
for (const row of findReverseDeps.all(relPath)) {
|
|
633
|
+
if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
|
|
634
|
+
// Verify the file still exists on disk
|
|
635
|
+
const absPath = path.join(rootDir, row.file);
|
|
636
|
+
if (fs.existsSync(absPath)) {
|
|
637
|
+
reverseDeps.add(row.file);
|
|
638
|
+
}
|
|
472
639
|
}
|
|
473
640
|
}
|
|
474
641
|
}
|
|
@@ -482,47 +649,16 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
482
649
|
debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`);
|
|
483
650
|
if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`);
|
|
484
651
|
// 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 = ?)',
|
|
652
|
+
const changePaths = parseChanges.map(
|
|
653
|
+
(item) => item.relPath || normalizePath(path.relative(rootDir, item.file)),
|
|
496
654
|
);
|
|
497
|
-
|
|
498
|
-
'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
499
|
-
);
|
|
500
|
-
let deleteComplexityForFile;
|
|
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
|
-
}
|
|
655
|
+
purgeFilesFromGraph(db, [...removed, ...changePaths], { purgeHashes: false });
|
|
523
656
|
|
|
524
657
|
// Process reverse deps: delete only outgoing edges (nodes/IDs preserved)
|
|
525
658
|
// then add them to the parse list so they participate in edge building
|
|
659
|
+
const deleteOutgoingEdgesForFile = db.prepare(
|
|
660
|
+
'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
|
|
661
|
+
);
|
|
526
662
|
for (const relPath of reverseDeps) {
|
|
527
663
|
deleteOutgoingEdgesForFile.run(relPath);
|
|
528
664
|
}
|
|
@@ -533,7 +669,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
533
669
|
}
|
|
534
670
|
|
|
535
671
|
const insertNode = db.prepare(
|
|
536
|
-
'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
|
|
672
|
+
'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)',
|
|
537
673
|
);
|
|
538
674
|
const getNodeId = db.prepare(
|
|
539
675
|
'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
|
|
@@ -583,16 +719,65 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
583
719
|
}
|
|
584
720
|
}
|
|
585
721
|
|
|
722
|
+
// Bulk-fetch all node IDs for a file in one query (replaces per-node getNodeId calls)
|
|
723
|
+
const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
|
|
724
|
+
|
|
586
725
|
const insertAll = db.transaction(() => {
|
|
587
726
|
for (const [relPath, symbols] of allSymbols) {
|
|
588
727
|
fileSymbols.set(relPath, symbols);
|
|
589
728
|
|
|
590
|
-
|
|
729
|
+
// Phase 1: Insert file node + definitions + exports (no children yet)
|
|
730
|
+
insertNode.run(relPath, 'file', relPath, 0, null, null);
|
|
591
731
|
for (const def of symbols.definitions) {
|
|
592
|
-
insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null);
|
|
732
|
+
insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null);
|
|
593
733
|
}
|
|
594
734
|
for (const exp of symbols.exports) {
|
|
595
|
-
insertNode.run(exp.name, exp.kind, relPath, exp.line, null);
|
|
735
|
+
insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Phase 2: Bulk-fetch IDs for file + definitions
|
|
739
|
+
const nodeIdMap = new Map();
|
|
740
|
+
for (const row of bulkGetNodeIds.all(relPath)) {
|
|
741
|
+
nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Phase 3: Insert children with parent_id from the map
|
|
745
|
+
for (const def of symbols.definitions) {
|
|
746
|
+
if (!def.children?.length) continue;
|
|
747
|
+
const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
|
|
748
|
+
if (!defId) continue;
|
|
749
|
+
for (const child of def.children) {
|
|
750
|
+
insertNode.run(child.name, child.kind, relPath, child.line, child.endLine || null, defId);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Phase 4: Re-fetch to include children IDs
|
|
755
|
+
nodeIdMap.clear();
|
|
756
|
+
for (const row of bulkGetNodeIds.all(relPath)) {
|
|
757
|
+
nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Phase 5: Insert edges using the cached ID map
|
|
761
|
+
const fileId = nodeIdMap.get(`${relPath}|file|0`);
|
|
762
|
+
for (const def of symbols.definitions) {
|
|
763
|
+
const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
|
|
764
|
+
// File → top-level definition contains edge
|
|
765
|
+
if (fileId && defId) {
|
|
766
|
+
insertEdge.run(fileId, defId, 'contains', 1.0, 0);
|
|
767
|
+
}
|
|
768
|
+
if (def.children?.length && defId) {
|
|
769
|
+
for (const child of def.children) {
|
|
770
|
+
const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
|
|
771
|
+
if (childId) {
|
|
772
|
+
// Parent → child contains edge
|
|
773
|
+
insertEdge.run(defId, childId, 'contains', 1.0, 0);
|
|
774
|
+
// Parameter → parent parameter_of edge (inverse direction)
|
|
775
|
+
if (child.kind === 'parameter') {
|
|
776
|
+
insertEdge.run(childId, defId, 'parameter_of', 1.0, 0);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
596
781
|
}
|
|
597
782
|
|
|
598
783
|
// Update file hash with real mtime+size for incremental builds
|
|
@@ -772,7 +957,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
772
957
|
// N+1 optimization: pre-load all nodes into a lookup map for edge building
|
|
773
958
|
const allNodes = db
|
|
774
959
|
.prepare(
|
|
775
|
-
`SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface')`,
|
|
960
|
+
`SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`,
|
|
776
961
|
)
|
|
777
962
|
.all();
|
|
778
963
|
const nodesByName = new Map();
|
|
@@ -789,7 +974,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
789
974
|
|
|
790
975
|
// Second pass: build edges
|
|
791
976
|
_t.edges0 = performance.now();
|
|
792
|
-
let edgeCount = 0;
|
|
793
977
|
const buildEdges = db.transaction(() => {
|
|
794
978
|
for (const [relPath, symbols] of fileSymbols) {
|
|
795
979
|
// Skip barrel-only files — loaded for resolution, edges already in DB
|
|
@@ -805,7 +989,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
805
989
|
if (targetRow) {
|
|
806
990
|
const edgeKind = imp.reexport ? 'reexports' : imp.typeOnly ? 'imports-type' : 'imports';
|
|
807
991
|
insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0);
|
|
808
|
-
edgeCount++;
|
|
809
992
|
|
|
810
993
|
if (!imp.reexport && isBarrelFile(resolvedPath)) {
|
|
811
994
|
const resolvedSources = new Set();
|
|
@@ -827,7 +1010,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
827
1010
|
0.9,
|
|
828
1011
|
0,
|
|
829
1012
|
);
|
|
830
|
-
edgeCount++;
|
|
831
1013
|
}
|
|
832
1014
|
}
|
|
833
1015
|
}
|
|
@@ -928,7 +1110,29 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
928
1110
|
seenCallEdges.add(edgeKey);
|
|
929
1111
|
const confidence = computeConfidence(relPath, t.file, importedFrom);
|
|
930
1112
|
insertEdge.run(caller.id, t.id, 'calls', confidence, isDynamic);
|
|
931
|
-
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Receiver edge: caller → receiver type node
|
|
1117
|
+
if (
|
|
1118
|
+
call.receiver &&
|
|
1119
|
+
!BUILTIN_RECEIVERS.has(call.receiver) &&
|
|
1120
|
+
call.receiver !== 'this' &&
|
|
1121
|
+
call.receiver !== 'self' &&
|
|
1122
|
+
call.receiver !== 'super'
|
|
1123
|
+
) {
|
|
1124
|
+
const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
|
|
1125
|
+
// Same-file first, then global
|
|
1126
|
+
const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || [];
|
|
1127
|
+
const candidates = samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || [];
|
|
1128
|
+
const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
|
|
1129
|
+
if (receiverNodes.length > 0 && caller) {
|
|
1130
|
+
const recvTarget = receiverNodes[0];
|
|
1131
|
+
const recvKey = `recv|${caller.id}|${recvTarget.id}`;
|
|
1132
|
+
if (!seenCallEdges.has(recvKey)) {
|
|
1133
|
+
seenCallEdges.add(recvKey);
|
|
1134
|
+
insertEdge.run(caller.id, recvTarget.id, 'receiver', 0.7, 0);
|
|
1135
|
+
}
|
|
932
1136
|
}
|
|
933
1137
|
}
|
|
934
1138
|
}
|
|
@@ -944,7 +1148,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
944
1148
|
if (sourceRow) {
|
|
945
1149
|
for (const t of targetRows) {
|
|
946
1150
|
insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0);
|
|
947
|
-
edgeCount++;
|
|
948
1151
|
}
|
|
949
1152
|
}
|
|
950
1153
|
}
|
|
@@ -960,7 +1163,6 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
960
1163
|
if (sourceRow) {
|
|
961
1164
|
for (const t of targetRows) {
|
|
962
1165
|
insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0);
|
|
963
|
-
edgeCount++;
|
|
964
1166
|
}
|
|
965
1167
|
}
|
|
966
1168
|
}
|
|
@@ -1047,7 +1249,9 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1047
1249
|
}
|
|
1048
1250
|
try {
|
|
1049
1251
|
const { buildStructure } = await import('./structure.js');
|
|
1050
|
-
|
|
1252
|
+
// Pass changed file paths so incremental builds can scope the rebuild
|
|
1253
|
+
const changedFilePaths = isFullBuild ? null : [...allSymbols.keys()];
|
|
1254
|
+
buildStructure(db, fileSymbols, rootDir, lineCountMap, relDirs, changedFilePaths);
|
|
1051
1255
|
} catch (err) {
|
|
1052
1256
|
debug(`Structure analysis failed: ${err.message}`);
|
|
1053
1257
|
}
|
|
@@ -1068,16 +1272,75 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1068
1272
|
}
|
|
1069
1273
|
_t.rolesMs = performance.now() - _t.roles0;
|
|
1070
1274
|
|
|
1275
|
+
// For incremental builds, filter out reverse-dep-only files from AST/complexity
|
|
1276
|
+
// — their content didn't change, so existing ast_nodes/function_complexity rows are valid.
|
|
1277
|
+
let astComplexitySymbols = allSymbols;
|
|
1278
|
+
if (!isFullBuild) {
|
|
1279
|
+
const reverseDepFiles = new Set(
|
|
1280
|
+
filesToParse.filter((item) => item._reverseDepOnly).map((item) => item.relPath),
|
|
1281
|
+
);
|
|
1282
|
+
if (reverseDepFiles.size > 0) {
|
|
1283
|
+
astComplexitySymbols = new Map();
|
|
1284
|
+
for (const [relPath, symbols] of allSymbols) {
|
|
1285
|
+
if (!reverseDepFiles.has(relPath)) {
|
|
1286
|
+
astComplexitySymbols.set(relPath, symbols);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
debug(
|
|
1290
|
+
`AST/complexity: processing ${astComplexitySymbols.size} changed files (skipping ${reverseDepFiles.size} reverse-deps)`,
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// AST node extraction (calls, new, string, regex, throw, await)
|
|
1296
|
+
// Must run before complexity which releases _tree references
|
|
1297
|
+
_t.ast0 = performance.now();
|
|
1298
|
+
if (opts.ast !== false) {
|
|
1299
|
+
try {
|
|
1300
|
+
const { buildAstNodes } = await import('./ast.js');
|
|
1301
|
+
await buildAstNodes(db, astComplexitySymbols, rootDir, engineOpts);
|
|
1302
|
+
} catch (err) {
|
|
1303
|
+
debug(`AST node extraction failed: ${err.message}`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
_t.astMs = performance.now() - _t.ast0;
|
|
1307
|
+
|
|
1071
1308
|
// Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
|
|
1072
1309
|
_t.complexity0 = performance.now();
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1310
|
+
if (opts.complexity !== false) {
|
|
1311
|
+
try {
|
|
1312
|
+
const { buildComplexityMetrics } = await import('./complexity.js');
|
|
1313
|
+
await buildComplexityMetrics(db, astComplexitySymbols, rootDir, engineOpts);
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
debug(`Complexity analysis failed: ${err.message}`);
|
|
1316
|
+
}
|
|
1078
1317
|
}
|
|
1079
1318
|
_t.complexityMs = performance.now() - _t.complexity0;
|
|
1080
1319
|
|
|
1320
|
+
// CFG analysis (skip with --no-cfg)
|
|
1321
|
+
if (opts.cfg !== false) {
|
|
1322
|
+
_t.cfg0 = performance.now();
|
|
1323
|
+
try {
|
|
1324
|
+
const { buildCFGData } = await import('./cfg.js');
|
|
1325
|
+
await buildCFGData(db, allSymbols, rootDir, engineOpts);
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
debug(`CFG analysis failed: ${err.message}`);
|
|
1328
|
+
}
|
|
1329
|
+
_t.cfgMs = performance.now() - _t.cfg0;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Dataflow analysis (skip with --no-dataflow)
|
|
1333
|
+
if (opts.dataflow !== false) {
|
|
1334
|
+
_t.dataflow0 = performance.now();
|
|
1335
|
+
try {
|
|
1336
|
+
const { buildDataflowEdges } = await import('./dataflow.js');
|
|
1337
|
+
await buildDataflowEdges(db, allSymbols, rootDir, engineOpts);
|
|
1338
|
+
} catch (err) {
|
|
1339
|
+
debug(`Dataflow analysis failed: ${err.message}`);
|
|
1340
|
+
}
|
|
1341
|
+
_t.dataflowMs = performance.now() - _t.dataflow0;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1081
1344
|
// Release any remaining cached WASM trees for GC
|
|
1082
1345
|
for (const [, symbols] of allSymbols) {
|
|
1083
1346
|
symbols._tree = null;
|
|
@@ -1085,7 +1348,8 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1085
1348
|
}
|
|
1086
1349
|
|
|
1087
1350
|
const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
|
|
1088
|
-
|
|
1351
|
+
const actualEdgeCount = db.prepare('SELECT COUNT(*) as c FROM edges').get().c;
|
|
1352
|
+
info(`Graph built: ${nodeCount} nodes, ${actualEdgeCount} edges`);
|
|
1089
1353
|
info(`Stored in ${dbPath}`);
|
|
1090
1354
|
|
|
1091
1355
|
// Verify incremental build didn't diverge significantly from previous counts
|
|
@@ -1097,11 +1361,11 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1097
1361
|
const prevE = Number(prevEdges);
|
|
1098
1362
|
if (prevN > 0) {
|
|
1099
1363
|
const nodeDrift = Math.abs(nodeCount - prevN) / prevN;
|
|
1100
|
-
const edgeDrift = prevE > 0 ? Math.abs(
|
|
1364
|
+
const edgeDrift = prevE > 0 ? Math.abs(actualEdgeCount - prevE) / prevE : 0;
|
|
1101
1365
|
const driftThreshold = config.build?.driftThreshold ?? 0.2;
|
|
1102
1366
|
if (nodeDrift > driftThreshold || edgeDrift > driftThreshold) {
|
|
1103
1367
|
warn(
|
|
1104
|
-
`Incremental build diverged significantly from previous counts (nodes: ${prevN}→${nodeCount} [${(nodeDrift * 100).toFixed(1)}%], edges: ${prevE}→${
|
|
1368
|
+
`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
1369
|
);
|
|
1106
1370
|
}
|
|
1107
1371
|
}
|
|
@@ -1130,9 +1394,10 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1130
1394
|
engine: engineName,
|
|
1131
1395
|
engine_version: engineVersion || '',
|
|
1132
1396
|
codegraph_version: CODEGRAPH_VERSION,
|
|
1397
|
+
schema_version: String(CURRENT_SCHEMA_VERSION),
|
|
1133
1398
|
built_at: new Date().toISOString(),
|
|
1134
1399
|
node_count: nodeCount,
|
|
1135
|
-
edge_count:
|
|
1400
|
+
edge_count: actualEdgeCount,
|
|
1136
1401
|
});
|
|
1137
1402
|
} catch (err) {
|
|
1138
1403
|
warn(`Failed to write build metadata: ${err.message}`);
|
|
@@ -1167,7 +1432,10 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1167
1432
|
edgesMs: +_t.edgesMs.toFixed(1),
|
|
1168
1433
|
structureMs: +_t.structureMs.toFixed(1),
|
|
1169
1434
|
rolesMs: +_t.rolesMs.toFixed(1),
|
|
1435
|
+
astMs: +_t.astMs.toFixed(1),
|
|
1170
1436
|
complexityMs: +_t.complexityMs.toFixed(1),
|
|
1437
|
+
...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
|
|
1438
|
+
...(_t.dataflowMs != null && { dataflowMs: +_t.dataflowMs.toFixed(1) }),
|
|
1171
1439
|
},
|
|
1172
1440
|
};
|
|
1173
1441
|
}
|