@optave/codegraph 2.4.0 → 2.5.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 CHANGED
@@ -55,7 +55,7 @@ cd your-project
55
55
  codegraph build
56
56
  ```
57
57
 
58
- That's it. No config files, no Docker, no JVM, no API keys, no accounts. The graph is ready to query. Add `codegraph mcp` to your AI agent's config and it has full access to your dependency graph through 19 MCP tools.
58
+ That's it. No config files, no Docker, no JVM, no API keys, no accounts. The graph is ready to query. Add `codegraph mcp` to your AI agent's config and it has full access to your dependency graph through 24 MCP tools (25 in multi-repo mode).
59
59
 
60
60
  ### Why it matters
61
61
 
@@ -97,7 +97,7 @@ That's it. No config files, no Docker, no JVM, no API keys, no accounts. The gra
97
97
  | **🔓** | **Zero-cost core, LLM-enhanced when you want** | Full graph analysis with no API keys, no accounts, no cost. Optionally bring your own LLM provider — your code only goes where you choose |
98
98
  | **🔬** | **Function-level, not just files** | Traces `handleAuth()` → `validateToken()` → `decryptJWT()` and shows 14 callers across 9 files break if `decryptJWT` changes |
99
99
  | **🏷️** | **Role classification** | Every symbol auto-tagged as `entry`/`core`/`utility`/`adapter`/`dead`/`leaf` — agents instantly know what they're looking at |
100
- | **🤖** | **Built for AI agents** | 19-tool [MCP server](https://modelcontextprotocol.io/) — AI assistants query your graph directly. Single-repo by default |
100
+ | **🤖** | **Built for AI agents** | 24-tool [MCP server](https://modelcontextprotocol.io/) — AI assistants query your graph directly. Single-repo by default |
101
101
  | **🌐** | **Multi-language, one CLI** | JS/TS + Python + Go + Rust + Java + C# + PHP + Ruby + HCL in a single graph |
102
102
  | **💥** | **Git diff impact** | `codegraph diff-impact` shows changed functions, their callers, and full blast radius — enriched with historically coupled files from git co-change analysis. Ships with a GitHub Actions workflow |
103
103
  | **🧠** | **Semantic search** | Local embeddings by default, LLM-powered when opted in — multi-query with RRF ranking via `"auth; token; JWT"` |
@@ -144,7 +144,7 @@ After modifying code:
144
144
  Or connect directly via MCP:
145
145
 
146
146
  ```bash
147
- codegraph mcp # 19-tool MCP server — AI queries the graph directly
147
+ codegraph mcp # 24-tool MCP server — AI queries the graph directly
148
148
  ```
149
149
 
150
150
  Full agent setup: [AI Agent Guide](docs/guides/ai-agent-guide.md) · [CLAUDE.md template](docs/guides/ai-agent-guide.md#claudemd-template)
@@ -158,7 +158,7 @@ Full agent setup: [AI Agent Guide](docs/guides/ai-agent-guide.md) · [CLAU
158
158
  | 🔍 | **Symbol search** | Find any function, class, or method by name — exact match priority, relevance scoring, `--file` and `--kind` filters |
159
159
  | 📁 | **File dependencies** | See what a file imports and what imports it |
160
160
  | 💥 | **Impact analysis** | Trace every file affected by a change (transitive) |
161
- | 🧬 | **Function-level tracing** | Call chains, caller trees, and function-level impact with qualified call resolution |
161
+ | 🧬 | **Function-level tracing** | Call chains, caller trees, function-level impact, and A→B pathfinding with qualified call resolution |
162
162
  | 🎯 | **Deep context** | `context` gives AI agents source, deps, callers, signature, and tests for a function in one call; `explain` gives structural summaries of files or functions |
163
163
  | 📍 | **Fast lookup** | `where` shows exactly where a symbol is defined and used — minimal, fast |
164
164
  | 📊 | **Diff impact** | Parse `git diff`, find overlapping functions, trace their callers |
@@ -170,8 +170,11 @@ Full agent setup: [AI Agent Guide](docs/guides/ai-agent-guide.md) · [CLAU
170
170
  | 📤 | **Export** | DOT (Graphviz), Mermaid, and JSON graph export |
171
171
  | 🧠 | **Semantic search** | Embeddings-powered natural language search with multi-query RRF ranking |
172
172
  | 👀 | **Watch mode** | Incrementally update the graph as files change |
173
- | 🤖 | **MCP server** | 19-tool MCP server for AI assistants; single-repo by default, opt-in multi-repo |
173
+ | 🤖 | **MCP server** | 24-tool MCP server for AI assistants; single-repo by default, opt-in multi-repo |
174
174
  | ⚡ | **Always fresh** | Three-tier incremental detection — sub-second rebuilds even on large codebases |
175
+ | 🧮 | **Complexity metrics** | Cognitive, cyclomatic, nesting depth, Halstead, and Maintainability Index per function |
176
+ | 🏘️ | **Community detection** | Louvain clustering to discover natural module boundaries and architectural drift |
177
+ | 📜 | **Manifesto rule engine** | Configurable pass/fail rules with warn/fail thresholds for CI gates (exit code 1 on fail) |
175
178
 
176
179
  See [docs/examples](docs/examples) for real-world CLI and MCP usage examples.
177
180
 
@@ -217,6 +220,9 @@ codegraph impact <file> # Transitive reverse dependency trace
217
220
  codegraph fn <name> # Function-level: callers, callees, call chain
218
221
  codegraph fn <name> --no-tests --depth 5
219
222
  codegraph fn-impact <name> # What functions break if this one changes
223
+ codegraph path <from> <to> # Shortest path between two symbols (A calls...calls B)
224
+ codegraph path <from> <to> --reverse # Follow edges backward
225
+ codegraph path <from> <to> --max-depth 5 --kinds calls,imports
220
226
  codegraph diff-impact # Impact of unstaged git changes
221
227
  codegraph diff-impact --staged # Impact of staged changes
222
228
  codegraph diff-impact HEAD~3 # Impact vs a specific ref
@@ -247,6 +253,20 @@ codegraph hotspots # Files with extreme fan-in, fan-out, or density
247
253
  codegraph hotspots --metric coupling --level directory --no-tests
248
254
  ```
249
255
 
256
+ ### Code Health & Architecture
257
+
258
+ ```bash
259
+ codegraph complexity # Per-function cognitive, cyclomatic, nesting, MI
260
+ codegraph complexity --health -T # Full Halstead health view (volume, effort, bugs, MI)
261
+ codegraph complexity --sort mi -T # Sort by worst maintainability index
262
+ codegraph complexity --above-threshold -T # Only functions exceeding warn thresholds
263
+ codegraph communities # Louvain community detection — natural module boundaries
264
+ codegraph communities --drift -T # Drift analysis only — split/merge candidates
265
+ codegraph communities --functions # Function-level community detection
266
+ codegraph manifesto # Pass/fail rule engine (exit code 1 on fail)
267
+ codegraph manifesto -T # Exclude test files from rule evaluation
268
+ ```
269
+
250
270
  ### Export & Visualization
251
271
 
252
272
  ```bash
@@ -316,7 +336,7 @@ codegraph registry remove <name> # Unregister
316
336
  | Flag | Description |
317
337
  |---|---|
318
338
  | `-d, --db <path>` | Custom path to `graph.db` |
319
- | `-T, --no-tests` | Exclude `.test.`, `.spec.`, `__test__` files (available on `fn`, `fn-impact`, `context`, `explain`, `where`, `diff-impact`, `search`, `map`, `hotspots`, `deps`, `impact`) |
339
+ | `-T, --no-tests` | Exclude `.test.`, `.spec.`, `__test__` files (available on `fn`, `fn-impact`, `path`, `context`, `explain`, `where`, `diff-impact`, `search`, `map`, `hotspots`, `roles`, `co-change`, `deps`, `impact`, `complexity`, `communities`, `manifesto`) |
320
340
  | `--depth <n>` | Transitive trace depth (default varies by command) |
321
341
  | `-j, --json` | Output as JSON |
322
342
  | `-v, --verbose` | Enable debug output |
@@ -403,10 +423,12 @@ Self-measured on every release via CI ([build benchmarks](generated/BUILD-BENCHM
403
423
 
404
424
  | Metric | Latest |
405
425
  |---|---|
406
- | Build speed (native) | **1.9 ms/file** |
407
- | Build speed (WASM) | **6.6 ms/file** |
426
+ | Build speed | **5.1 ms/file** |
408
427
  | Query time | **2ms** |
409
- | ~50,000 files (est.) | **~95.0s build** |
428
+ | No-op rebuild | **5ms** |
429
+ | 1-file rebuild | **192ms** |
430
+ | Query: fn-deps | **0.5ms** |
431
+ | ~50,000 files (est.) | **~255.0s build** |
410
432
 
411
433
  Metrics are normalized per file for cross-version comparability. Times above are for a full initial build — incremental rebuilds only re-parse changed files.
412
434
 
@@ -428,7 +450,7 @@ Optional: `@huggingface/transformers` (semantic search), `@modelcontextprotocol/
428
450
 
429
451
  ### MCP Server
430
452
 
431
- Codegraph includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server with 19 tools, so AI assistants can query your dependency graph directly:
453
+ Codegraph includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server with 24 tools (25 in multi-repo mode), so AI assistants can query your dependency graph directly:
432
454
 
433
455
  ```bash
434
456
  codegraph mcp # Single-repo mode (default) — only local project
@@ -462,7 +484,14 @@ This project uses codegraph. The database is at `.codegraph/graph.db`.
462
484
  - `codegraph build .` — rebuild the graph (incremental by default)
463
485
  - `codegraph map` — module overview
464
486
  - `codegraph fn <name> -T` — function call chain
487
+ - `codegraph path <from> <to> -T` — shortest call path between two symbols
465
488
  - `codegraph deps <file>` — file-level dependencies
489
+ - `codegraph roles --role dead -T` — find dead code (unreferenced symbols)
490
+ - `codegraph roles --role core -T` — find core symbols (high fan-in)
491
+ - `codegraph co-change <file>` — files that historically change together
492
+ - `codegraph complexity -T` — per-function complexity metrics (cognitive, cyclomatic, MI)
493
+ - `codegraph communities --drift -T` — module boundary drift analysis
494
+ - `codegraph manifesto -T` — pass/fail rule check (CI gate, exit code 1 on fail)
466
495
  - `codegraph search "<query>"` — semantic search (requires `codegraph embed`)
467
496
  - `codegraph cycles` — check for circular dependencies
468
497
 
@@ -534,10 +563,35 @@ Create a `.codegraphrc.json` in your project root to customize behavior:
534
563
  },
535
564
  "build": {
536
565
  "incremental": true
566
+ },
567
+ "query": {
568
+ "excludeTests": true
537
569
  }
538
570
  }
539
571
  ```
540
572
 
573
+ > **Tip:** `excludeTests` can also be set at the top level as a shorthand — `{ "excludeTests": true }` is equivalent to nesting it under `query`. If both are present, the nested `query.excludeTests` takes precedence.
574
+
575
+ ### Manifesto rules
576
+
577
+ Configure pass/fail thresholds for `codegraph manifesto`:
578
+
579
+ ```json
580
+ {
581
+ "manifesto": {
582
+ "rules": {
583
+ "cognitive_complexity": { "warn": 15, "fail": 30 },
584
+ "cyclomatic_complexity": { "warn": 10, "fail": 20 },
585
+ "nesting_depth": { "warn": 4, "fail": 6 },
586
+ "maintainability_index": { "warn": 40, "fail": 20 },
587
+ "halstead_bugs": { "warn": 0.5, "fail": 1.0 }
588
+ }
589
+ }
590
+ }
591
+ ```
592
+
593
+ When any function exceeds a `fail` threshold, `codegraph manifesto` exits with code 1 — perfect for CI gates.
594
+
541
595
  ### LLM credentials
542
596
 
543
597
  Codegraph supports an `apiKeyCommand` field for secure credential management. Instead of storing API keys in config files or environment variables, you can shell out to a secret manager at runtime:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optave/codegraph",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -57,14 +57,24 @@
57
57
  "dependencies": {
58
58
  "better-sqlite3": "^12.6.2",
59
59
  "commander": "^14.0.3",
60
+ "graphology": "^0.25.4",
61
+ "graphology-communities-louvain": "^2.0.2",
60
62
  "web-tree-sitter": "^0.26.5"
61
63
  },
64
+ "peerDependencies": {
65
+ "@huggingface/transformers": "^3.8.1"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@huggingface/transformers": {
69
+ "optional": true
70
+ }
71
+ },
62
72
  "optionalDependencies": {
63
- "@huggingface/transformers": "^3.8.1",
64
73
  "@modelcontextprotocol/sdk": "^1.0.0",
65
- "@optave/codegraph-darwin-arm64": "2.4.0",
66
- "@optave/codegraph-darwin-x64": "2.4.0",
67
- "@optave/codegraph-linux-x64-gnu": "2.4.0"
74
+ "@optave/codegraph-darwin-arm64": "2.5.0",
75
+ "@optave/codegraph-darwin-x64": "2.5.0",
76
+ "@optave/codegraph-linux-x64-gnu": "2.5.0",
77
+ "@optave/codegraph-win32-x64-msvc": "2.5.0"
68
78
  },
69
79
  "devDependencies": {
70
80
  "@biomejs/biome": "^2.4.4",
package/src/builder.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { performance } from 'node:perf_hooks';
4
5
  import { loadConfig } from './config.js';
5
6
  import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
6
- import { initSchema, openDb } from './db.js';
7
+ import { closeDb, getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js';
7
8
  import { readJournal, writeJournalHeader } from './journal.js';
8
9
  import { debug, info, warn } from './logger.js';
9
10
  import { getActiveEngine, parseFilesAuto } from './parser.js';
@@ -11,6 +12,11 @@ import { computeConfidence, resolveImportPath, resolveImportsBatch } from './res
11
12
 
12
13
  export { resolveImportPath } from './resolve.js';
13
14
 
15
+ const __builderDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1'));
16
+ const CODEGRAPH_VERSION = JSON.parse(
17
+ fs.readFileSync(path.join(__builderDir, '..', 'package.json'), 'utf-8'),
18
+ ).version;
19
+
14
20
  const BUILTIN_RECEIVERS = new Set([
15
21
  'console',
16
22
  'Math',
@@ -346,6 +352,22 @@ export async function buildGraph(rootDir, opts = {}) {
346
352
  const { name: engineName, version: engineVersion } = getActiveEngine(engineOpts);
347
353
  info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
348
354
 
355
+ // Check for engine/version mismatch on incremental builds
356
+ if (incremental) {
357
+ const prevEngine = getBuildMeta(db, 'engine');
358
+ const prevVersion = getBuildMeta(db, 'codegraph_version');
359
+ if (prevEngine && prevEngine !== engineName) {
360
+ warn(
361
+ `Engine changed (${prevEngine} → ${engineName}). Consider rebuilding with --no-incremental for consistency.`,
362
+ );
363
+ }
364
+ if (prevVersion && prevVersion !== CODEGRAPH_VERSION) {
365
+ warn(
366
+ `Codegraph version changed (${prevVersion} → ${CODEGRAPH_VERSION}). Consider rebuilding with --no-incremental for consistency.`,
367
+ );
368
+ }
369
+ }
370
+
349
371
  const aliases = loadPathAliases(rootDir);
350
372
  // Merge config aliases
351
373
  if (config.aliases) {
@@ -397,7 +419,7 @@ export async function buildGraph(rootDir, opts = {}) {
397
419
  }
398
420
  }
399
421
  info('No changes detected. Graph is up to date.');
400
- db.close();
422
+ closeDb(db);
401
423
  writeJournalHeader(rootDir, Date.now());
402
424
  return;
403
425
  }
@@ -420,7 +442,45 @@ export async function buildGraph(rootDir, opts = {}) {
420
442
  : deletions,
421
443
  );
422
444
  } else {
423
- info(`Incremental: ${parseChanges.length} changed, ${removed.length} removed`);
445
+ // ── Reverse-dependency cascade (issue #116) ─────────────────────
446
+ // Find files with edges pointing TO changed/removed files.
447
+ // Their nodes stay intact (preserving IDs), but outgoing edges are
448
+ // deleted so they can be rebuilt during the edge-building pass.
449
+ const changedRelPaths = new Set();
450
+ for (const item of parseChanges) {
451
+ changedRelPaths.add(item.relPath || normalizePath(path.relative(rootDir, item.file)));
452
+ }
453
+ for (const relPath of removed) {
454
+ changedRelPaths.add(relPath);
455
+ }
456
+
457
+ const reverseDeps = new Set();
458
+ if (changedRelPaths.size > 0) {
459
+ const findReverseDeps = db.prepare(`
460
+ SELECT DISTINCT n_src.file FROM edges e
461
+ JOIN nodes n_src ON e.source_id = n_src.id
462
+ JOIN nodes n_tgt ON e.target_id = n_tgt.id
463
+ WHERE n_tgt.file = ? AND n_src.file != n_tgt.file
464
+ `);
465
+ for (const relPath of changedRelPaths) {
466
+ for (const row of findReverseDeps.all(relPath)) {
467
+ if (!changedRelPaths.has(row.file) && !reverseDeps.has(row.file)) {
468
+ // Verify the file still exists on disk
469
+ const absPath = path.join(rootDir, row.file);
470
+ if (fs.existsSync(absPath)) {
471
+ reverseDeps.add(row.file);
472
+ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ info(
479
+ `Incremental: ${parseChanges.length} changed, ${removed.length} removed${reverseDeps.size > 0 ? `, ${reverseDeps.size} reverse-deps` : ''}`,
480
+ );
481
+ if (parseChanges.length > 0)
482
+ debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`);
483
+ if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`);
424
484
  // Remove embeddings/metrics/edges/nodes for changed and removed files
425
485
  // Embeddings must be deleted BEFORE nodes (we need node IDs to find them)
426
486
  const deleteEmbeddingsForFile = hasEmbeddings
@@ -431,13 +491,25 @@ export async function buildGraph(rootDir, opts = {}) {
431
491
  DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f)
432
492
  OR target_id IN (SELECT id FROM nodes WHERE file = @f)
433
493
  `);
494
+ const deleteOutgoingEdgesForFile = db.prepare(
495
+ 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
496
+ );
434
497
  const deleteMetricsForFile = db.prepare(
435
498
  'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
436
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
+ }
437
508
  for (const relPath of removed) {
438
509
  deleteEmbeddingsForFile?.run(relPath);
439
510
  deleteEdgesForFile.run({ f: relPath });
440
511
  deleteMetricsForFile.run(relPath);
512
+ deleteComplexityForFile?.run(relPath);
441
513
  deleteNodesForFile.run(relPath);
442
514
  }
443
515
  for (const item of parseChanges) {
@@ -445,8 +517,19 @@ export async function buildGraph(rootDir, opts = {}) {
445
517
  deleteEmbeddingsForFile?.run(relPath);
446
518
  deleteEdgesForFile.run({ f: relPath });
447
519
  deleteMetricsForFile.run(relPath);
520
+ deleteComplexityForFile?.run(relPath);
448
521
  deleteNodesForFile.run(relPath);
449
522
  }
523
+
524
+ // Process reverse deps: delete only outgoing edges (nodes/IDs preserved)
525
+ // then add them to the parse list so they participate in edge building
526
+ for (const relPath of reverseDeps) {
527
+ deleteOutgoingEdgesForFile.run(relPath);
528
+ }
529
+ for (const relPath of reverseDeps) {
530
+ const absPath = path.join(rootDir, relPath);
531
+ parseChanges.push({ file: absPath, relPath, _reverseDepOnly: true });
532
+ }
450
533
  }
451
534
 
452
535
  const insertNode = db.prepare(
@@ -483,9 +566,14 @@ export async function buildGraph(rootDir, opts = {}) {
483
566
 
484
567
  const filesToParse = isFullBuild ? files.map((f) => ({ file: f })) : parseChanges;
485
568
 
569
+ // ── Phase timing ────────────────────────────────────────────────────
570
+ const _t = {};
571
+
486
572
  // ── Unified parse via parseFilesAuto ───────────────────────────────
487
573
  const filePaths = filesToParse.map((item) => item.file);
574
+ _t.parse0 = performance.now();
488
575
  const allSymbols = await parseFilesAuto(filePaths, rootDir, engineOpts);
576
+ _t.parseMs = performance.now() - _t.parse0;
489
577
 
490
578
  // Build a lookup from incremental data (changed items may carry pre-computed hashes + stats)
491
579
  const precomputedData = new Map();
@@ -508,9 +596,12 @@ export async function buildGraph(rootDir, opts = {}) {
508
596
  }
509
597
 
510
598
  // Update file hash with real mtime+size for incremental builds
599
+ // Skip for reverse-dep files — they didn't actually change
511
600
  if (upsertHash) {
512
601
  const precomputed = precomputedData.get(relPath);
513
- if (precomputed?.hash) {
602
+ if (precomputed?._reverseDepOnly) {
603
+ // no-op: file unchanged, hash already correct
604
+ } else if (precomputed?.hash) {
514
605
  const stat = precomputed.stat || fileStat(path.join(rootDir, relPath));
515
606
  const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
516
607
  const size = stat ? stat.size : 0;
@@ -542,7 +633,9 @@ export async function buildGraph(rootDir, opts = {}) {
542
633
  }
543
634
  }
544
635
  });
636
+ _t.insert0 = performance.now();
545
637
  insertAll();
638
+ _t.insertMs = performance.now() - _t.insert0;
546
639
 
547
640
  const parsed = allSymbols.size;
548
641
  const skipped = filesToParse.length - parsed;
@@ -558,6 +651,7 @@ export async function buildGraph(rootDir, opts = {}) {
558
651
 
559
652
  // ── Batch import resolution ────────────────────────────────────────
560
653
  // Collect all (fromFile, importSource) pairs and resolve in one native call
654
+ _t.resolve0 = performance.now();
561
655
  const batchInputs = [];
562
656
  for (const [relPath, symbols] of fileSymbols) {
563
657
  const absFile = path.join(rootDir, relPath);
@@ -566,6 +660,7 @@ export async function buildGraph(rootDir, opts = {}) {
566
660
  }
567
661
  }
568
662
  const batchResolved = resolveImportsBatch(batchInputs, rootDir, aliases);
663
+ _t.resolveMs = performance.now() - _t.resolve0;
569
664
 
570
665
  function getResolved(absFile, importSource) {
571
666
  if (batchResolved) {
@@ -653,6 +748,7 @@ export async function buildGraph(rootDir, opts = {}) {
653
748
  }
654
749
 
655
750
  // Second pass: build edges
751
+ _t.edges0 = performance.now();
656
752
  let edgeCount = 0;
657
753
  const buildEdges = db.transaction(() => {
658
754
  for (const [relPath, symbols] of fileSymbols) {
@@ -712,10 +808,26 @@ export async function buildGraph(rootDir, opts = {}) {
712
808
  for (const call of symbols.calls) {
713
809
  if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
714
810
  let caller = null;
811
+ let callerSpan = Infinity;
715
812
  for (const def of symbols.definitions) {
716
813
  if (def.line <= call.line) {
717
- const row = getNodeId.get(def.name, def.kind, relPath, def.line);
718
- if (row) caller = row;
814
+ const end = def.endLine || Infinity;
815
+ if (call.line <= end) {
816
+ // Call is inside this definition's range — pick narrowest
817
+ const span = end - def.line;
818
+ if (span < callerSpan) {
819
+ const row = getNodeId.get(def.name, def.kind, relPath, def.line);
820
+ if (row) {
821
+ caller = row;
822
+ callerSpan = span;
823
+ }
824
+ }
825
+ } else if (!caller) {
826
+ // Fallback: def starts before call but call is past end
827
+ // Only use if we haven't found an enclosing scope yet
828
+ const row = getNodeId.get(def.name, def.kind, relPath, def.line);
829
+ if (row) caller = row;
830
+ }
719
831
  }
720
832
  }
721
833
  if (!caller) caller = fileNodeRow;
@@ -779,12 +891,12 @@ export async function buildGraph(rootDir, opts = {}) {
779
891
  }
780
892
  }
781
893
 
782
- // Class extends edges
894
+ // Class extends edges (use pre-loaded maps instead of inline DB queries)
783
895
  for (const cls of symbols.classes) {
784
896
  if (cls.extends) {
785
- const sourceRow = db
786
- .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ?')
787
- .get(cls.name, 'class', relPath);
897
+ const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
898
+ (n) => n.kind === 'class',
899
+ );
788
900
  const targetCandidates = nodesByName.get(cls.extends) || [];
789
901
  const targetRows = targetCandidates.filter((n) => n.kind === 'class');
790
902
  if (sourceRow) {
@@ -796,9 +908,9 @@ export async function buildGraph(rootDir, opts = {}) {
796
908
  }
797
909
 
798
910
  if (cls.implements) {
799
- const sourceRow = db
800
- .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ?')
801
- .get(cls.name, 'class', relPath);
911
+ const sourceRow = (nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find(
912
+ (n) => n.kind === 'class',
913
+ );
802
914
  const targetCandidates = nodesByName.get(cls.implements) || [];
803
915
  const targetRows = targetCandidates.filter(
804
916
  (n) => n.kind === 'interface' || n.kind === 'class',
@@ -814,16 +926,21 @@ export async function buildGraph(rootDir, opts = {}) {
814
926
  }
815
927
  });
816
928
  buildEdges();
929
+ _t.edgesMs = performance.now() - _t.edges0;
817
930
 
818
- // Build line count map for structure metrics
931
+ // Build line count map for structure metrics (prefer cached _lineCount from parser)
819
932
  const lineCountMap = new Map();
820
- for (const [relPath] of fileSymbols) {
821
- const absPath = path.join(rootDir, relPath);
822
- try {
823
- const content = fs.readFileSync(absPath, 'utf-8');
824
- lineCountMap.set(relPath, content.split('\n').length);
825
- } catch {
826
- lineCountMap.set(relPath, 0);
933
+ for (const [relPath, symbols] of fileSymbols) {
934
+ if (symbols._lineCount) {
935
+ lineCountMap.set(relPath, symbols._lineCount);
936
+ } else {
937
+ const absPath = path.join(rootDir, relPath);
938
+ try {
939
+ const content = fs.readFileSync(absPath, 'utf-8');
940
+ lineCountMap.set(relPath, content.split('\n').length);
941
+ } catch {
942
+ lineCountMap.set(relPath, 0);
943
+ }
827
944
  }
828
945
  }
829
946
 
@@ -881,6 +998,7 @@ export async function buildGraph(rootDir, opts = {}) {
881
998
  }
882
999
 
883
1000
  // Build directory structure, containment edges, and metrics
1001
+ _t.structure0 = performance.now();
884
1002
  const relDirs = new Set();
885
1003
  for (const absDir of discoveredDirs) {
886
1004
  relDirs.add(normalizePath(path.relative(rootDir, absDir)));
@@ -891,8 +1009,10 @@ export async function buildGraph(rootDir, opts = {}) {
891
1009
  } catch (err) {
892
1010
  debug(`Structure analysis failed: ${err.message}`);
893
1011
  }
1012
+ _t.structureMs = performance.now() - _t.structure0;
894
1013
 
895
1014
  // Classify node roles (entry, core, utility, adapter, dead, leaf)
1015
+ _t.roles0 = performance.now();
896
1016
  try {
897
1017
  const { classifyNodeRoles } = await import('./structure.js');
898
1018
  const roleSummary = classifyNodeRoles(db);
@@ -904,6 +1024,23 @@ export async function buildGraph(rootDir, opts = {}) {
904
1024
  } catch (err) {
905
1025
  debug(`Role classification failed: ${err.message}`);
906
1026
  }
1027
+ _t.rolesMs = performance.now() - _t.roles0;
1028
+
1029
+ // Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
1030
+ _t.complexity0 = performance.now();
1031
+ try {
1032
+ const { buildComplexityMetrics } = await import('./complexity.js');
1033
+ await buildComplexityMetrics(db, allSymbols, rootDir, engineOpts);
1034
+ } catch (err) {
1035
+ debug(`Complexity analysis failed: ${err.message}`);
1036
+ }
1037
+ _t.complexityMs = performance.now() - _t.complexity0;
1038
+
1039
+ // Release any remaining cached WASM trees for GC
1040
+ for (const [, symbols] of allSymbols) {
1041
+ symbols._tree = null;
1042
+ symbols._langId = null;
1043
+ }
907
1044
 
908
1045
  const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
909
1046
  info(`Graph built: ${nodeCount} nodes, ${edgeCount} edges`);
@@ -925,7 +1062,19 @@ export async function buildGraph(rootDir, opts = {}) {
925
1062
  }
926
1063
  }
927
1064
 
928
- db.close();
1065
+ // Persist build metadata for mismatch detection
1066
+ try {
1067
+ setBuildMeta(db, {
1068
+ engine: engineName,
1069
+ engine_version: engineVersion || '',
1070
+ codegraph_version: CODEGRAPH_VERSION,
1071
+ built_at: new Date().toISOString(),
1072
+ });
1073
+ } catch (err) {
1074
+ warn(`Failed to write build metadata: ${err.message}`);
1075
+ }
1076
+
1077
+ closeDb(db);
929
1078
 
930
1079
  // Write journal header after successful build
931
1080
  writeJournalHeader(rootDir, Date.now());
@@ -945,4 +1094,16 @@ export async function buildGraph(rootDir, opts = {}) {
945
1094
  }
946
1095
  }
947
1096
  }
1097
+
1098
+ return {
1099
+ phases: {
1100
+ parseMs: +_t.parseMs.toFixed(1),
1101
+ insertMs: +_t.insertMs.toFixed(1),
1102
+ resolveMs: +_t.resolveMs.toFixed(1),
1103
+ edgesMs: +_t.edgesMs.toFixed(1),
1104
+ structureMs: +_t.structureMs.toFixed(1),
1105
+ rolesMs: +_t.rolesMs.toFixed(1),
1106
+ complexityMs: +_t.complexityMs.toFixed(1),
1107
+ },
1108
+ };
948
1109
  }