@optave/codegraph 3.12.0 → 3.13.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.
Files changed (144) hide show
  1. package/README.md +71 -35
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +2 -1
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/batch.d.ts.map +1 -1
  6. package/dist/cli/commands/batch.js +1 -0
  7. package/dist/cli/commands/batch.js.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/build.js +6 -1
  10. package/dist/cli/commands/build.js.map +1 -1
  11. package/dist/cli/commands/config.d.ts +3 -0
  12. package/dist/cli/commands/config.d.ts.map +1 -0
  13. package/dist/cli/commands/config.js +272 -0
  14. package/dist/cli/commands/config.js.map +1 -0
  15. package/dist/cli/commands/triage.js +1 -1
  16. package/dist/cli/commands/triage.js.map +1 -1
  17. package/dist/cli/index.d.ts.map +1 -1
  18. package/dist/cli/index.js +10 -0
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/shared/options.d.ts +2 -1
  21. package/dist/cli/shared/options.d.ts.map +1 -1
  22. package/dist/cli/shared/options.js +11 -1
  23. package/dist/cli/shared/options.js.map +1 -1
  24. package/dist/cli/types.d.ts +2 -0
  25. package/dist/cli/types.d.ts.map +1 -1
  26. package/dist/db/migrations.js +1 -1
  27. package/dist/db/migrations.js.map +1 -1
  28. package/dist/domain/graph/builder/call-resolver.d.ts +12 -8
  29. package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -1
  30. package/dist/domain/graph/builder/call-resolver.js +93 -38
  31. package/dist/domain/graph/builder/call-resolver.js.map +1 -1
  32. package/dist/domain/graph/builder/cha.d.ts +9 -1
  33. package/dist/domain/graph/builder/cha.d.ts.map +1 -1
  34. package/dist/domain/graph/builder/cha.js +17 -2
  35. package/dist/domain/graph/builder/cha.js.map +1 -1
  36. package/dist/domain/graph/builder/helpers.d.ts +8 -0
  37. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  38. package/dist/domain/graph/builder/helpers.js +22 -3
  39. package/dist/domain/graph/builder/helpers.js.map +1 -1
  40. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  41. package/dist/domain/graph/builder/incremental.js +1 -1
  42. package/dist/domain/graph/builder/incremental.js.map +1 -1
  43. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  44. package/dist/domain/graph/builder/pipeline.js +37 -2
  45. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  46. package/dist/domain/graph/builder/stages/build-edges.d.ts +0 -2
  47. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  48. package/dist/domain/graph/builder/stages/build-edges.js +88 -318
  49. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  50. package/dist/domain/graph/builder/stages/detect-changes.js +1 -1
  51. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/finalize.js +4 -0
  54. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -1
  56. package/dist/domain/graph/builder/stages/native-orchestrator.js +341 -82
  57. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -1
  58. package/dist/domain/graph/builder/stages/resolve-imports.js +1 -1
  59. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  60. package/dist/domain/parser.d.ts +4 -5
  61. package/dist/domain/parser.d.ts.map +1 -1
  62. package/dist/domain/parser.js +46 -15
  63. package/dist/domain/parser.js.map +1 -1
  64. package/dist/domain/wasm-worker-entry.js +10 -2
  65. package/dist/domain/wasm-worker-entry.js.map +1 -1
  66. package/dist/domain/wasm-worker-pool.d.ts.map +1 -1
  67. package/dist/domain/wasm-worker-pool.js +2 -0
  68. package/dist/domain/wasm-worker-pool.js.map +1 -1
  69. package/dist/domain/wasm-worker-protocol.d.ts +1 -0
  70. package/dist/domain/wasm-worker-protocol.d.ts.map +1 -1
  71. package/dist/extractors/cpp.d.ts.map +1 -1
  72. package/dist/extractors/cpp.js +42 -1
  73. package/dist/extractors/cpp.js.map +1 -1
  74. package/dist/extractors/cuda.d.ts.map +1 -1
  75. package/dist/extractors/cuda.js +42 -1
  76. package/dist/extractors/cuda.js.map +1 -1
  77. package/dist/extractors/helpers.d.ts +11 -0
  78. package/dist/extractors/helpers.d.ts.map +1 -1
  79. package/dist/extractors/helpers.js +40 -0
  80. package/dist/extractors/helpers.js.map +1 -1
  81. package/dist/extractors/java.d.ts.map +1 -1
  82. package/dist/extractors/java.js +8 -7
  83. package/dist/extractors/java.js.map +1 -1
  84. package/dist/extractors/javascript.js +137 -6
  85. package/dist/extractors/javascript.js.map +1 -1
  86. package/dist/features/structure-query.d.ts +1 -1
  87. package/dist/features/structure-query.d.ts.map +1 -1
  88. package/dist/features/structure-query.js +6 -6
  89. package/dist/features/structure-query.js.map +1 -1
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +1 -1
  93. package/dist/index.js.map +1 -1
  94. package/dist/infrastructure/config.d.ts +77 -4
  95. package/dist/infrastructure/config.d.ts.map +1 -1
  96. package/dist/infrastructure/config.js +395 -21
  97. package/dist/infrastructure/config.js.map +1 -1
  98. package/dist/infrastructure/registry.d.ts +27 -0
  99. package/dist/infrastructure/registry.d.ts.map +1 -1
  100. package/dist/infrastructure/registry.js +59 -1
  101. package/dist/infrastructure/registry.js.map +1 -1
  102. package/dist/presentation/structure.d.ts +1 -1
  103. package/dist/presentation/structure.d.ts.map +1 -1
  104. package/dist/presentation/structure.js +2 -2
  105. package/dist/presentation/structure.js.map +1 -1
  106. package/dist/types.d.ts +37 -0
  107. package/dist/types.d.ts.map +1 -1
  108. package/grammars/tree-sitter-gleam.wasm +0 -0
  109. package/package.json +7 -8
  110. package/src/cli/commands/audit.ts +2 -1
  111. package/src/cli/commands/batch.ts +1 -0
  112. package/src/cli/commands/build.ts +6 -1
  113. package/src/cli/commands/config.ts +353 -0
  114. package/src/cli/commands/triage.ts +1 -1
  115. package/src/cli/index.ts +10 -0
  116. package/src/cli/shared/options.ts +11 -1
  117. package/src/cli/types.ts +2 -0
  118. package/src/db/migrations.ts +1 -1
  119. package/src/domain/graph/builder/call-resolver.ts +99 -41
  120. package/src/domain/graph/builder/cha.ts +18 -1
  121. package/src/domain/graph/builder/helpers.ts +24 -4
  122. package/src/domain/graph/builder/incremental.ts +1 -0
  123. package/src/domain/graph/builder/pipeline.ts +49 -2
  124. package/src/domain/graph/builder/stages/build-edges.ts +130 -399
  125. package/src/domain/graph/builder/stages/detect-changes.ts +1 -1
  126. package/src/domain/graph/builder/stages/finalize.ts +4 -0
  127. package/src/domain/graph/builder/stages/native-orchestrator.ts +396 -92
  128. package/src/domain/graph/builder/stages/resolve-imports.ts +1 -1
  129. package/src/domain/parser.ts +45 -14
  130. package/src/domain/wasm-worker-entry.ts +10 -2
  131. package/src/domain/wasm-worker-pool.ts +1 -0
  132. package/src/domain/wasm-worker-protocol.ts +1 -0
  133. package/src/extractors/cpp.ts +44 -1
  134. package/src/extractors/cuda.ts +44 -1
  135. package/src/extractors/helpers.ts +43 -0
  136. package/src/extractors/java.ts +8 -7
  137. package/src/extractors/javascript.ts +127 -6
  138. package/src/features/structure-query.ts +7 -7
  139. package/src/index.ts +5 -1
  140. package/src/infrastructure/config.ts +481 -22
  141. package/src/infrastructure/registry.ts +82 -1
  142. package/src/presentation/structure.ts +3 -3
  143. package/src/types.ts +41 -0
  144. package/grammars/tree-sitter-erlang.wasm +0 -0
@@ -49,13 +49,14 @@ import type { PipelineContext } from '../context.js';
49
49
  import {
50
50
  batchInsertEdges,
51
51
  batchInsertNodes,
52
+ CHA_DISPATCH_PENALTY,
53
+ CHA_TYPED_DISPATCH_CONFIDENCE,
52
54
  collectFiles as collectFilesUtil,
53
55
  fileHash,
54
56
  fileStat,
55
57
  readFileSafe,
56
58
  } from '../helpers.js';
57
59
  import { NativeDbProxy } from '../native-db-proxy.js';
58
- import { CHA_DISPATCH_PENALTY } from './build-edges.js';
59
60
  import { closeNativeDb } from './native-db-lifecycle.js';
60
61
 
61
62
  // ── Native orchestrator types ──────────────────────────────────────────
@@ -389,7 +390,7 @@ async function runPostNativeAnalysis(
389
390
  }
390
391
 
391
392
  /**
392
- * Phase 8.5: CHA expansion post-pass for the native orchestrator path.
393
+ * Phase 8.6: CHA expansion post-pass for the native orchestrator path.
393
394
  *
394
395
  * The Rust build pipeline resolves typed receiver calls (e.g. `worker.doWork()`
395
396
  * where `worker: IWorker`) to the interface method declaration only. This
@@ -401,12 +402,28 @@ async function runPostNativeAnalysis(
401
402
  * Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
402
403
  * which WASM-re-parses JS/TS files to obtain raw call site receiver info.
403
404
  *
405
+ * `changedFiles` controls candidate scoping on incremental builds:
406
+ * - null → full build; scan all call→method edges (existing behaviour).
407
+ * - array → incremental; two cheap gate queries decide scope:
408
+ * Gate A: any class/interface/trait/struct/record nodes in changed files?
409
+ * If yes, a new implementor may have appeared — full scan required.
410
+ * Gate B: any `calls` edges from changed-file sources targeting
411
+ * class/constructor/function-kind nodes? If yes, the RTA set may
412
+ * have grown (also covers the older-schema fallback where
413
+ * constructor calls target `constructor`/`function` nodes instead
414
+ * of `class` nodes) — full scan required.
415
+ * If neither gate fires: scope `callToMethods` to `src.file IN changedFiles`
416
+ * (safe because no hierarchy or RTA evidence changed).
417
+ *
404
418
  * Returns the count of newly inserted CHA edges plus the set of files containing
405
419
  * the new edges' endpoints, so the caller can scope role re-classification to the
406
420
  * nodes whose fan-in/out actually changed. A zero count means no edges were added
407
421
  * and role re-classification is unnecessary.
408
422
  */
409
- function runPostNativeCha(db: BetterSqlite3Database): {
423
+ function runPostNativeCha(
424
+ db: BetterSqlite3Database,
425
+ changedFiles: string[] | null,
426
+ ): {
410
427
  newEdgeCount: number;
411
428
  affectedFiles: Set<string>;
412
429
  } {
@@ -474,19 +491,142 @@ function runPostNativeCha(db: BetterSqlite3Database): {
474
491
  debug('runPostNativeCha: no constructor-call evidence found — proceeding without RTA filter');
475
492
  }
476
493
 
494
+ // ── Incremental candidate scoping ──────────────────────────────────────────
495
+ // On incremental builds, two gate queries decide whether to restrict the
496
+ // candidate scan to changed-file call sites or run the full graph scan.
497
+ //
498
+ // Gate A: did a changed file add/change a class hierarchy node?
499
+ // A new `extends`/`implements` edge means a previously-untracked implementor
500
+ // is now in the hierarchy — unchanged call sites in OTHER files may gain new
501
+ // valid expansions, so the full scan is required.
502
+ // Note: *removed* class nodes are safe — Rust's `purge_changed_files` runs
503
+ // before this post-pass and deletes stale nodes and their hierarchy edges, so
504
+ // Gate A queries the post-purge DB. A deleted class returns no row here, which
505
+ // is correct: its stale CHA edges were already cleaned up by the Rust purge.
506
+ //
507
+ // Gate B: did a changed file add new RTA evidence (`new ConcreteX()`)?
508
+ // A new `calls` edge to a class/constructor/function-kind target means the
509
+ // instantiated set grew — previously RTA-filtered expansions in unchanged
510
+ // caller files become admissible, so the full scan is required.
511
+ // (`constructor`/`function` cover the older native engine fallback schema.)
512
+ //
513
+ // If neither gate fires, the hierarchy and RTA set are unchanged for all files
514
+ // outside changedFiles, so restricting to changed-file sources is safe.
515
+ let scopeToChangedFiles = false; // true → add WHERE src.file IN changedFiles
516
+ if (changedFiles !== null && changedFiles.length > 0) {
517
+ // Gate A: class/interface/trait/struct/record nodes in changed files?
518
+ const CHUNK_SIZE = 500;
519
+ let gateAFired = false;
520
+ for (let i = 0; i < changedFiles.length && !gateAFired; i += CHUNK_SIZE) {
521
+ const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
522
+ const ph = chunk.map(() => '?').join(',');
523
+ const row = db
524
+ .prepare(
525
+ `SELECT 1 FROM nodes
526
+ WHERE file IN (${ph})
527
+ AND kind IN ('class', 'interface', 'trait', 'struct', 'record')
528
+ LIMIT 1`,
529
+ )
530
+ .get(...chunk);
531
+ if (row) gateAFired = true;
532
+ }
533
+
534
+ // Gate B: calls from changed-file sources to class/instantiable-kind targets
535
+ // (also covers older-schema fallback and future CHA extensions to struct/record).
536
+ // Includes class/interface/trait/struct/record (future CHA extension safety) and
537
+ // constructor/function (older native engine schema fallback).
538
+ let gateBFired = false;
539
+ if (!gateAFired) {
540
+ for (let i = 0; i < changedFiles.length && !gateBFired; i += CHUNK_SIZE) {
541
+ const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
542
+ const ph = chunk.map(() => '?').join(',');
543
+ const row = db
544
+ .prepare(
545
+ `SELECT 1 FROM edges e
546
+ JOIN nodes src ON e.source_id = src.id
547
+ JOIN nodes tgt ON e.target_id = tgt.id
548
+ WHERE e.kind = 'calls'
549
+ AND tgt.kind IN ('class', 'interface', 'trait', 'struct', 'record', 'constructor', 'function')
550
+ AND src.file IN (${ph})
551
+ LIMIT 1`,
552
+ )
553
+ .get(...chunk);
554
+ if (row) gateBFired = true;
555
+ }
556
+ }
557
+
558
+ if (!gateAFired && !gateBFired) {
559
+ scopeToChangedFiles = true;
560
+ debug(
561
+ `runPostNativeCha: neither gate fired — scoping candidate scan to ${changedFiles.length} changed file(s)`,
562
+ );
563
+ } else {
564
+ debug(
565
+ `runPostNativeCha: ${gateAFired ? 'Gate A (hierarchy)' : 'Gate B (RTA)'} fired — running full scan`,
566
+ );
567
+ }
568
+ }
569
+
477
570
  // Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
478
- // Include the caller node's file so confidence can be computed file-pair-aware,
479
- // matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula.
480
- const callToMethods = db
481
- .prepare(`
482
- SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
483
- FROM edges e
484
- JOIN nodes tgt ON e.target_id = tgt.id
485
- JOIN nodes src ON e.source_id = src.id
486
- WHERE e.kind = 'calls' AND tgt.kind = 'method'
487
- AND INSTR(tgt.name, '.') > 0
488
- `)
489
- .all() as Array<{ source_id: number; method_name: string; caller_file: string | null }>;
571
+ // Include caller_file and method_file so affectedFiles can be populated for
572
+ // incremental role reclassification; confidence uses CHA_TYPED_DISPATCH_CONFIDENCE matching runChaPostPass.
573
+ // When scopeToChangedFiles is true, restrict to call sites in the changed files
574
+ // (safe because no hierarchy or RTA evidence changed outside those files).
575
+ let callToMethods: Array<{
576
+ source_id: number;
577
+ caller_name: string;
578
+ method_name: string;
579
+ caller_file: string | null;
580
+ }>;
581
+ if (scopeToChangedFiles && changedFiles && changedFiles.length > 0) {
582
+ const CHUNK_SIZE = 500;
583
+ const rows: Array<{
584
+ source_id: number;
585
+ caller_name: string;
586
+ method_name: string;
587
+ caller_file: string | null;
588
+ }> = [];
589
+ for (let i = 0; i < changedFiles.length; i += CHUNK_SIZE) {
590
+ const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
591
+ const ph = chunk.map(() => '?').join(',');
592
+ const chunkRows = db
593
+ .prepare(
594
+ `SELECT e.source_id, src.name AS caller_name, tgt.name AS method_name, src.file AS caller_file
595
+ FROM edges e
596
+ JOIN nodes tgt ON e.target_id = tgt.id
597
+ JOIN nodes src ON e.source_id = src.id
598
+ WHERE e.kind = 'calls' AND tgt.kind = 'method'
599
+ AND INSTR(tgt.name, '.') > 0
600
+ AND (e.technique IS NULL OR e.technique != 'cha-expanded')
601
+ AND src.file IN (${ph})`,
602
+ )
603
+ .all(...chunk) as Array<{
604
+ source_id: number;
605
+ caller_name: string;
606
+ method_name: string;
607
+ caller_file: string | null;
608
+ }>;
609
+ rows.push(...chunkRows);
610
+ }
611
+ callToMethods = rows;
612
+ } else {
613
+ callToMethods = db
614
+ .prepare(`
615
+ SELECT e.source_id, src.name AS caller_name, tgt.name AS method_name, src.file AS caller_file
616
+ FROM edges e
617
+ JOIN nodes tgt ON e.target_id = tgt.id
618
+ JOIN nodes src ON e.source_id = src.id
619
+ WHERE e.kind = 'calls' AND tgt.kind = 'method'
620
+ AND INSTR(tgt.name, '.') > 0
621
+ AND (e.technique IS NULL OR e.technique != 'cha-expanded')
622
+ `)
623
+ .all() as Array<{
624
+ source_id: number;
625
+ caller_name: string;
626
+ method_name: string;
627
+ caller_file: string | null;
628
+ }>;
629
+ }
490
630
 
491
631
  // Seed seen-pairs only from the source_ids we'll be expanding — avoids loading every
492
632
  // call edge in the DB (which would be O(all edges)) for large codebases.
@@ -540,16 +680,12 @@ function runPostNativeCha(db: BetterSqlite3Database): {
540
680
  method_file: string | null;
541
681
  }>;
542
682
  for (const methodNode of methodNodes) {
683
+ if (methodNode.id === source_id) continue; // skip self-loops
543
684
  const key = `${source_id}|${methodNode.id}`;
544
685
  if (seen.has(key)) continue;
545
686
  seen.add(key);
546
- // Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - CHA_DISPATCH_PENALTY)
547
- // Skip zero-confidence edges to match buildFileCallEdges / buildChaPostPass behaviour.
548
- const conf =
549
- computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) -
550
- CHA_DISPATCH_PENALTY;
551
- if (conf <= 0) continue;
552
- newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
687
+ const conf = CHA_TYPED_DISPATCH_CONFIDENCE;
688
+ newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha-expanded']);
553
689
  newEdgeCount++;
554
690
  if (caller_file) affectedFiles.add(caller_file);
555
691
  if (methodNode.method_file) affectedFiles.add(methodNode.method_file);
@@ -564,6 +700,9 @@ function runPostNativeCha(db: BetterSqlite3Database): {
564
700
 
565
701
  if (newEdges.length > 0) {
566
702
  db.transaction(() => batchInsertEdges(db, newEdges))();
703
+ // Account for post-pass edges excluded from the build summary line (#1452),
704
+ // mirroring the this/super dispatch post-pass insertion log.
705
+ debug(`CHA expansion post-pass: inserted ${newEdgeCount} edge(s)`);
567
706
  }
568
707
  return { newEdgeCount, affectedFiles };
569
708
  }
@@ -580,8 +719,13 @@ const THIS_DISPATCH_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs'
580
719
  * `this`/`super` receivers, then resolves them through the class hierarchy stored
581
720
  * in DB `extends` edges — mirroring what `buildChaPostPass` does on the WASM path.
582
721
  *
583
- * Only runs when `extends` edges exist in the DB; if there is no inheritance
584
- * hierarchy there is nothing to resolve via `this`/`super` dispatch.
722
+ * Also handles function-as-object-property methods (`f.h = function() { this.g() }`):
723
+ * these use `this` to reference sibling properties on the same object (`f`), so
724
+ * `resolveThisDispatch` resolves them by treating the dot-prefix of the caller name
725
+ * (`f` from `f.h`) as the class and looking up `f.g` directly — no `extends` edge needed.
726
+ *
727
+ * Runs when either `extends` edges exist (class inheritance) OR dot-named `method`
728
+ * nodes exist (func-prop assignments); skips only when neither is present.
585
729
  */
586
730
  async function runPostNativeThisDispatch(
587
731
  db: BetterSqlite3Database,
@@ -589,31 +733,44 @@ async function runPostNativeThisDispatch(
589
733
  changedFiles: string[] | undefined,
590
734
  isFullBuild: boolean,
591
735
  ): Promise<{ elapsedMs: number; targetIds: Set<number>; affectedFiles: Set<string> }> {
592
- const t0 = Date.now();
736
+ const t0 = performance.now();
593
737
  const targetIds = new Set<number>();
594
738
  // Files containing endpoints of newly inserted edges — lets the caller scope
595
739
  // role re-classification to the nodes whose fan-in/out actually changed.
596
740
  const affectedFiles = new Set<string>();
597
- // Fast guard: need at least one extends edge for this/super to have meaning
598
- const hasExtends = db.prepare(`SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1`).get();
599
- if (!hasExtends) return { elapsedMs: 0, targetIds, affectedFiles };
600
741
 
601
- // Build parents map: child class direct parent class (from `extends` edges)
602
- const parentRows = db
603
- .prepare(`
604
- SELECT src.name AS child_name, tgt.name AS parent_name
605
- FROM edges e
606
- JOIN nodes src ON e.source_id = src.id
607
- JOIN nodes tgt ON e.target_id = tgt.id
608
- WHERE e.kind = 'extends'
609
- `)
610
- .all() as Array<{ child_name: string; parent_name: string }>;
742
+ // Fast guard: need at least one extends edge (class inheritance) OR a dot-named
743
+ // method node (func-prop assignment: `f.h = function() { this.g() }`) for
744
+ // this/super dispatch to produce any edges.
745
+ const hasExtends = db.prepare(`SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1`).get();
746
+ const hasFuncPropMethod = db
747
+ .prepare(`SELECT 1 FROM nodes WHERE kind = 'method' AND INSTR(name, '.') > 0 LIMIT 1`)
748
+ .get();
749
+ if (!hasExtends && !hasFuncPropMethod) return { elapsedMs: 0, targetIds, affectedFiles };
750
+
751
+ // Build parents map: child class direct parent class (from `extends` edges).
752
+ // May be empty when only func-prop methods exist (no class inheritance) —
753
+ // resolveThisDispatch handles that case via direct class-prefix lookup.
754
+ const parentRows = hasExtends
755
+ ? (db
756
+ .prepare(`
757
+ SELECT src.name AS child_name, tgt.name AS parent_name
758
+ FROM edges e
759
+ JOIN nodes src ON e.source_id = src.id
760
+ JOIN nodes tgt ON e.target_id = tgt.id
761
+ WHERE e.kind = 'extends'
762
+ `)
763
+ .all() as Array<{ child_name: string; parent_name: string }>)
764
+ : [];
611
765
 
612
766
  const parents = new Map<string, string>();
613
767
  for (const row of parentRows) {
614
768
  if (!parents.has(row.child_name)) parents.set(row.child_name, row.parent_name);
615
769
  }
616
- if (parents.size === 0) return { elapsedMs: 0, targetIds, affectedFiles };
770
+ // Note: parents may be empty when hasFuncPropMethod but !hasExtends that is
771
+ // intentional. resolveThisDispatch still resolves `this.g()` inside `f.h` by
772
+ // treating `f` (the dot-prefix of callerName `f.h`) as the class and looking
773
+ // up `f.g` directly via lookup.byName(), without traversing the parents chain.
617
774
 
618
775
  const chaCtx: ChaContext = {
619
776
  implementors: new Map(), // not needed for this/super resolution
@@ -626,13 +783,11 @@ async function runPostNativeThisDispatch(
626
783
  // On a full build we do NOT re-parse every JS/TS file — that would WASM-parse
627
784
  // the entire project on top of the native pass, causing a massive regression
628
785
  // (measured: +358% ms/file on codegraph itself). Instead we restrict to files
629
- // that are part of the class inheritance hierarchy: both subclass files (which
630
- // contain `super.X()` calls dispatching to a parent) and parent-class files
631
- // (whose method bodies contain `this.X()` calls that CHA must resolve). Any
632
- // file not in the hierarchy has no `extends` relationship, so `this`/`super`
633
- // calls in it either resolve locally (same-class dispatch, already handled by
634
- // the direct-call edge) or have no class context — and will be skipped by
635
- // `resolveThisDispatch` anyway.
786
+ // that are part of the class inheritance hierarchy (both subclass files with
787
+ // `super.X()` calls and parent-class files with `this.X()` calls) OR that
788
+ // contain dot-named method nodes (func-prop assignments whose bodies may call
789
+ // `this.sibling()`). Any file not in either set has no class or object context
790
+ // where `this`/`super` dispatch would produce new edges.
636
791
  let relFiles: string[];
637
792
  if (isFullBuild || !changedFiles) {
638
793
  const rows = db
@@ -647,6 +802,21 @@ async function runPostNativeThisDispatch(
647
802
  FROM edges e
648
803
  JOIN nodes tgt ON e.target_id = tgt.id
649
804
  WHERE e.kind = 'extends' AND tgt.file IS NOT NULL
805
+ UNION
806
+ -- Files with func-prop method definitions (e.g. f.h = function(){this.g()}).
807
+ -- Only include files where the method's owner prefix is NOT a known class name —
808
+ -- this keeps the re-parse set small (func-prop files only, not all class-method files).
809
+ -- AND name IS NOT NULL guards the NOT IN sub-select: if any class node had a NULL
810
+ -- name the entire NOT IN clause would silently return no rows (SQL NULL semantics).
811
+ SELECT n.file AS file
812
+ FROM nodes n
813
+ WHERE n.kind = 'method'
814
+ AND INSTR(n.name, '.') > 0
815
+ AND n.file IS NOT NULL
816
+ AND SUBSTR(n.name, 1, INSTR(n.name, '.') - 1) NOT IN (
817
+ SELECT name FROM nodes WHERE kind IN ('class', 'struct', 'interface', 'type')
818
+ AND name IS NOT NULL
819
+ )
650
820
  )
651
821
  `)
652
822
  .all() as Array<{ file: string }>;
@@ -786,15 +956,20 @@ async function runPostNativeThisDispatch(
786
956
  call.receiver as 'this' | 'super',
787
957
  chaCtx,
788
958
  lookup,
959
+ relPath,
789
960
  );
790
961
 
791
962
  for (const t of targets) {
963
+ if (t.id === callerRow.id) continue; // skip self-loops
792
964
  const key = `${callerRow.id}|${t.id}`;
793
965
  if (seen.has(key)) continue;
794
966
  seen.add(key);
795
967
  const conf = computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
796
968
  if (conf <= 0) continue;
797
- newEdges.push([callerRow.id, t.id, 'calls', conf, 0, 'cha']);
969
+ // Tag super-dispatch edges distinctly so runPostNativeCha can exclude them
970
+ // from further CHA expansion (super calls are not virtual dispatch).
971
+ const technique = call.receiver === 'super' ? 'super-dispatch' : 'cha';
972
+ newEdges.push([callerRow.id, t.id, 'calls', conf, 0, technique]);
798
973
  targetIds.add(t.id);
799
974
  affectedFiles.add(relPath);
800
975
  if (t.file) affectedFiles.add(t.file);
@@ -821,7 +996,15 @@ async function runPostNativeThisDispatch(
821
996
  (symbols as { _tree?: unknown; _langId?: unknown })._langId = undefined;
822
997
  }
823
998
 
824
- return { elapsedMs: Date.now() - t0, targetIds, affectedFiles };
999
+ return { elapsedMs: performance.now() - t0, targetIds, affectedFiles };
1000
+ }
1001
+
1002
+ interface PostPassTimings {
1003
+ gapDetectMs: number;
1004
+ chaMs: number;
1005
+ thisDispatchMs: number;
1006
+ reclassifyMs: number;
1007
+ techniqueBackfillMs: number;
825
1008
  }
826
1009
 
827
1010
  /** Format timing result from native orchestrator phases + JS post-processing. */
@@ -829,7 +1012,7 @@ function formatNativeTimingResult(
829
1012
  p: Record<string, number>,
830
1013
  structurePatchMs: number,
831
1014
  analysisTiming: { astMs: number; complexityMs: number; cfgMs: number; dataflowMs: number },
832
- thisDispatchMs: number,
1015
+ postPass: PostPassTimings,
833
1016
  ): BuildResult {
834
1017
  return {
835
1018
  phases: {
@@ -842,7 +1025,11 @@ function formatNativeTimingResult(
842
1025
  edgesMs: +(p.edgesMs ?? 0).toFixed(1),
843
1026
  structureMs: +((p.structureMs ?? 0) + structurePatchMs).toFixed(1),
844
1027
  rolesMs: +(p.rolesMs ?? 0).toFixed(1),
845
- thisDispatchMs: +thisDispatchMs.toFixed(1),
1028
+ gapDetectMs: +postPass.gapDetectMs.toFixed(1),
1029
+ chaMs: +postPass.chaMs.toFixed(1),
1030
+ thisDispatchMs: +postPass.thisDispatchMs.toFixed(1),
1031
+ reclassifyMs: +postPass.reclassifyMs.toFixed(1),
1032
+ techniqueBackfillMs: +postPass.techniqueBackfillMs.toFixed(1),
846
1033
  astMs: +(analysisTiming.astMs ?? 0).toFixed(1),
847
1034
  complexityMs: +(analysisTiming.complexityMs ?? 0).toFixed(1),
848
1035
  cfgMs: +(analysisTiming.cfgMs ?? 0).toFixed(1),
@@ -1043,10 +1230,35 @@ async function backfillNativeDroppedFiles(
1043
1230
 
1044
1231
  if (missingAbs.length === 0) return;
1045
1232
 
1233
+ // Parse all missing files via WASM first so we can distinguish real native
1234
+ // extractor failures (WASM finds symbols but native didn't) from files the
1235
+ // Rust engine legitimately skipped (gitignored artifacts, empty declaration
1236
+ // files, etc. where WASM also produces 0 symbols). Both categories are
1237
+ // backfilled — only the former triggers a WARN (#1566).
1238
+ const wasmResults = await parseFilesWasmForBackfill(missingAbs, ctx.rootDir);
1239
+
1240
+ // Build two sets from wasmResults:
1241
+ // wasmParsedFiles — rel-paths present in wasmResults (WASM succeeded, even 0 symbols)
1242
+ // wasmFoundSymbols — subset where WASM found ≥1 symbol
1243
+ // Files absent from wasmParsedFiles were skipped by WASM entirely (extension
1244
+ // not in _extToLang, wasmExtractSymbols returned null, or a read error).
1245
+ // Those files do NOT end up in the batchInsertNodes loop below.
1246
+ const wasmParsedFiles = new Set<string>();
1247
+ const wasmFoundSymbols = new Set<string>();
1248
+ for (const [relPath, symbols] of wasmResults) {
1249
+ wasmParsedFiles.add(relPath);
1250
+ if ((symbols.definitions?.length ?? 0) > 0 || (symbols.exports?.length ?? 0) > 0) {
1251
+ wasmFoundSymbols.add(relPath);
1252
+ }
1253
+ }
1254
+
1046
1255
  // Classify drops so users see per-extension reasons instead of just a count
1047
1256
  // (#1011). `unsupported-by-native` is a legitimate parser limit (no Rust
1048
1257
  // extractor); `native-extractor-failure` indicates a real native bug since
1049
- // the language IS supported by the addon yet the file was dropped anyway.
1258
+ // the language IS supported by the addon yet WASM found symbols the native
1259
+ // engine should have extracted. Files where both engines produce 0 symbols
1260
+ // are legitimately empty (e.g. gitignored napi-generated declaration stubs)
1261
+ // and logged at debug level only.
1050
1262
  const { byReason, totals } = classifyNativeDrops(missingRel);
1051
1263
  if (totals['unsupported-by-native'] > 0) {
1052
1264
  const buckets = byReason['unsupported-by-native'];
@@ -1055,12 +1267,54 @@ async function backfillNativeDroppedFiles(
1055
1267
  );
1056
1268
  }
1057
1269
  if (totals['native-extractor-failure'] > 0) {
1058
- const buckets = byReason['native-extractor-failure'];
1059
- warn(
1060
- `Native orchestrator dropped ${totals['native-extractor-failure']} file(s) across ${buckets.size} extension(s) in natively-supported languages — likely a Rust extractor bug. Backfilling via WASM:${formatDropExtensionSummary(buckets)}`,
1061
- );
1270
+ // Three-way split of native-extractor-failure files:
1271
+ // realFailureBuckets — WASM found symbols → real Rust extractor bug (WARN)
1272
+ // emptyFileBuckets — WASM parsed but found 0 symbols gitignored/empty (debug)
1273
+ // These DO receive a file-node insert in the loop below.
1274
+ // wasmSkipBuckets — WASM skipped entirely (ext unknown or parse error) →
1275
+ // no file-node insert, and no WARN (debug only, distinct
1276
+ // message to avoid overstating backfill coverage).
1277
+ const allFailurePaths = byReason['native-extractor-failure'];
1278
+ const realFailureBuckets = new Map<string, string[]>();
1279
+ const emptyFileBuckets = new Map<string, string[]>();
1280
+ const wasmSkipBuckets = new Map<string, string[]>();
1281
+ for (const [ext, paths] of allFailurePaths) {
1282
+ for (const relPath of paths) {
1283
+ let bucket: Map<string, string[]>;
1284
+ if (wasmFoundSymbols.has(relPath)) {
1285
+ bucket = realFailureBuckets;
1286
+ } else if (wasmParsedFiles.has(relPath)) {
1287
+ bucket = emptyFileBuckets;
1288
+ } else {
1289
+ bucket = wasmSkipBuckets;
1290
+ }
1291
+ let list = bucket.get(ext);
1292
+ if (!list) {
1293
+ list = [];
1294
+ bucket.set(ext, list);
1295
+ }
1296
+ list.push(relPath);
1297
+ }
1298
+ }
1299
+ if (realFailureBuckets.size > 0) {
1300
+ const realCount = [...realFailureBuckets.values()].reduce((s, a) => s + a.length, 0);
1301
+ warn(
1302
+ `Native orchestrator dropped ${realCount} file(s) across ${realFailureBuckets.size} extension(s) in natively-supported languages — likely a Rust extractor bug. Backfilling via WASM:${formatDropExtensionSummary(realFailureBuckets)}`,
1303
+ );
1304
+ }
1305
+ if (emptyFileBuckets.size > 0) {
1306
+ const emptyCount = [...emptyFileBuckets.values()].reduce((s, a) => s + a.length, 0);
1307
+ debug(
1308
+ `Native orchestrator skipped ${emptyCount} file(s) in natively-supported languages that also produced 0 symbols via WASM (likely gitignored or empty); backfilling file nodes:${formatDropExtensionSummary(emptyFileBuckets)}`,
1309
+ );
1310
+ }
1311
+ if (wasmSkipBuckets.size > 0) {
1312
+ const skipCount = [...wasmSkipBuckets.values()].reduce((s, a) => s + a.length, 0);
1313
+ debug(
1314
+ `Native orchestrator skipped ${skipCount} file(s) in natively-supported languages that WASM also could not parse (unregistered extension or parse error); no file-node inserted:${formatDropExtensionSummary(wasmSkipBuckets)}`,
1315
+ );
1316
+ }
1062
1317
  }
1063
- const wasmResults = await parseFilesWasmForBackfill(missingAbs, ctx.rootDir);
1064
1318
 
1065
1319
  const rows: unknown[][] = [];
1066
1320
  const exportKeys: unknown[][] = [];
@@ -1302,7 +1556,7 @@ export async function tryNativeOrchestrator(
1302
1556
  // Even on no-op rebuilds, dropped-language files added since the last
1303
1557
  // full build are still missing from `nodes`/`file_hashes` (#1083), and
1304
1558
  // WASM-only files deleted from disk leave stale rows behind (#1073).
1305
- // The orchestrator's file_collector skipped them, so its earlyExit
1559
+ // The orchestrator's collect_files skipped them, so its earlyExit
1306
1560
  // doesn't imply DB consistency. Run the gap repair before returning.
1307
1561
  const gap = detectDroppedLanguageGap(ctx);
1308
1562
  if (gap.missingAbs.length > 0 || gap.staleRel.length > 0) {
@@ -1344,9 +1598,9 @@ export async function tryNativeOrchestrator(
1344
1598
  built_at: new Date().toISOString(),
1345
1599
  });
1346
1600
 
1347
- info(
1348
- `Native build orchestrator completed: ${result.nodeCount ?? 0} nodes, ${result.edgeCount ?? 0} edges, ${result.fileCount ?? 0} files`,
1349
- );
1601
+ // The build summary is logged after the JS edge-writing post-passes below
1602
+ // (dropped-language backfill, CHA, this/super dispatch) so the reported
1603
+ // counts include their edges (#1452).
1350
1604
 
1351
1605
  // ── Post-native structure + analysis ──────────────────────────────
1352
1606
  let analysisTiming = {
@@ -1381,8 +1635,14 @@ export async function tryNativeOrchestrator(
1381
1635
  ctx.db = openDb(ctx.dbPath);
1382
1636
  ctx.nativeFirstProxy = false;
1383
1637
  } else if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
1384
- // DB reopen failed — return partial result
1385
- return formatNativeTimingResult(p, 0, analysisTiming, 0);
1638
+ // DB reopen failed — return partial result (no post-pass phases completed)
1639
+ return formatNativeTimingResult(p, 0, analysisTiming, {
1640
+ gapDetectMs: 0,
1641
+ chaMs: 0,
1642
+ thisDispatchMs: 0,
1643
+ reclassifyMs: 0,
1644
+ techniqueBackfillMs: 0,
1645
+ });
1386
1646
  }
1387
1647
  }
1388
1648
 
@@ -1393,41 +1653,25 @@ export async function tryNativeOrchestrator(
1393
1653
  // stale native binaries). WASM handles those — backfill via WASM so both
1394
1654
  // engines process the same file set (#967).
1395
1655
  //
1396
- // Detect the gap once (fs walk + 2 DB queries, ~20–30ms) and use it for
1397
- // both gating and the backfill itself. On dirty incrementals/full builds
1398
- // the orchestrator signals trigger backfill, so the walk happens once
1399
- // (instead of redundantly inside backfill). On quiet incrementals we
1400
- // still pay the walk so we can detect brand-new files in dropped-language
1401
- // extensions a gap that the orchestrator's `detect_removed_files`
1402
- // filter (#1070) leaves open (#1083, #1091). The pre-check is cheap
1403
- // because the expensive part (WASM re-parse of the missing set) is
1404
- // gated below.
1405
- const removedCount = result.removedCount ?? 0;
1406
- const changedCount = result.changedCount ?? 0;
1656
+ // Detect the gap once (fs walk + 2 DB queries) and use it for both gating
1657
+ // and the backfill itself. On quiet incrementals we still pay the walk so
1658
+ // we can detect brand-new files in dropped-language extensions a gap that
1659
+ // the orchestrator's `detect_removed_files` filter (#1070) leaves open
1660
+ // (#1083, #1091). The pre-check is cheap because the expensive part (WASM
1661
+ // re-parse of the missing set) is gated below.
1662
+ const gapDetectStart = performance.now();
1407
1663
  const gap = detectDroppedLanguageGap(ctx);
1408
- if (
1409
- result.isFullBuild ||
1410
- removedCount > 0 ||
1411
- changedCount > 0 ||
1412
- gap.missingAbs.length > 0 ||
1413
- gap.staleRel.length > 0
1414
- ) {
1664
+ const backfillHappened = gap.missingAbs.length > 0 || gap.staleRel.length > 0;
1665
+ if (backfillHappened) {
1415
1666
  await backfillNativeDroppedFiles(ctx, gap);
1416
1667
  }
1417
-
1418
- // Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
1419
- // Returns the affected files so role re-classification below can be scoped to
1420
- // the nodes whose fan-in/out actually changed.
1421
- //
1422
- // Function-as-object-property methods (`fn.method = function() {}`) are extracted
1423
- // natively by the Rust engine (#1432) and resolved in-build by its edge builder, so
1424
- // no WASM re-parse post-pass is needed for them. `Foo.prototype.bar = fn` likewise.
1425
- const { newEdgeCount: chaEdgeCount, affectedFiles: chaAffectedFiles } = runPostNativeCha(
1426
- ctx.db as unknown as BetterSqlite3Database,
1427
- );
1668
+ const gapDetectMs = performance.now() - gapDetectStart;
1428
1669
 
1429
1670
  // Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
1430
1671
  // whose raw receiver info the Rust pipeline does not persist to DB.
1672
+ // Runs BEFORE the CHA expansion pass so that super.method() → Parent.method edges
1673
+ // (technique='cha') are in the DB when runPostNativeCha expands them to sibling
1674
+ // class overrides (e.g. PostMixin.m → B.m when PostMixin and B both extend A).
1431
1675
  const {
1432
1676
  elapsedMs: thisDispatchMs,
1433
1677
  targetIds: thisDispatchTargetIds,
@@ -1439,6 +1683,26 @@ export async function tryNativeOrchestrator(
1439
1683
  !!result.isFullBuild,
1440
1684
  );
1441
1685
 
1686
+ // Phase 8.6: expand CHA call edges (interface dispatch → concrete implementations).
1687
+ // Returns the affected files so role re-classification below can be scoped to
1688
+ // the nodes whose fan-in/out actually changed.
1689
+ //
1690
+ // Runs AFTER this/super dispatch so super.method() edges are already in the DB.
1691
+ // The 'cha-expanded' technique tag on this pass's own output prevents re-expansion
1692
+ // of those edges in subsequent incremental builds, while 'cha'-tagged edges from
1693
+ // this/super dispatch remain eligible for expansion here.
1694
+ //
1695
+ // Function-as-object-property methods (`fn.method = function() {}`) are extracted
1696
+ // natively by the Rust engine (#1432) and resolved in-build by its edge builder, so
1697
+ // no WASM re-parse post-pass is needed for them. `Foo.prototype.bar = fn` likewise.
1698
+ const chaStart = performance.now();
1699
+ const { newEdgeCount: chaEdgeCount, affectedFiles: chaAffectedFiles } = runPostNativeCha(
1700
+ ctx.db as unknown as BetterSqlite3Database,
1701
+ // null = full build (scan all call→method edges); array = incremental (gate queries decide scope)
1702
+ result.isFullBuild ? null : (result.changedFiles ?? null),
1703
+ );
1704
+ const chaMs = performance.now() - chaStart;
1705
+
1442
1706
  // Role re-classification after JS edge-writing post-passes.
1443
1707
  // The Rust orchestrator classifies roles before these post-passes (CHA,
1444
1708
  // this-dispatch) add edges, so roles for the edge endpoints are stale.
@@ -1447,6 +1711,7 @@ export async function tryNativeOrchestrator(
1447
1711
  // files restores correctness without re-running the classifier over the
1448
1712
  // whole graph (which cost ~130ms per build on codegraph itself and was a
1449
1713
  // major part of the v3.12.0 native full-build benchmark regression).
1714
+ let reclassifyMs = 0;
1450
1715
  if (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0) {
1451
1716
  const affectedFiles = [...new Set([...chaAffectedFiles, ...thisDispatchAffectedFiles])];
1452
1717
  // When edges were inserted but all their endpoint nodes have null `file`
@@ -1455,6 +1720,7 @@ export async function tryNativeOrchestrator(
1455
1720
  // case — scoped classification with an empty set would be a no-op, leaving
1456
1721
  // roles stale for those nodes.
1457
1722
  const scopedFiles = affectedFiles.length > 0 ? affectedFiles : null;
1723
+ const reclassifyStart = performance.now();
1458
1724
  try {
1459
1725
  const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1460
1726
  classifyNodeRoles: (
@@ -1471,13 +1737,45 @@ export async function tryNativeOrchestrator(
1471
1737
  } catch (err) {
1472
1738
  debug(`Post-pass role re-classification failed: ${toErrorMessage(err)}`);
1473
1739
  }
1740
+ reclassifyMs = performance.now() - reclassifyStart;
1474
1741
  }
1475
1742
 
1476
1743
  // Backfill the `technique` column on `calls` edges written by the Rust
1477
1744
  // orchestrator, which does not write the column. Runs after all edge-writing
1478
1745
  // phases (including the WASM dropped-language backfill, CHA post-pass, and
1479
1746
  // this/super dispatch) so every new edge in this build cycle gets a label.
1747
+ const techniqueBackfillStart = performance.now();
1480
1748
  backfillEdgeTechniquesAfterNativeOrchestrator(ctx.db, !!result.isFullBuild, result.changedFiles);
1749
+ const techniqueBackfillMs = performance.now() - techniqueBackfillStart;
1750
+
1751
+ // Re-count nodes/edges now that all edge-writing post-passes have run: the
1752
+ // Rust orchestrator captured its counts before the JS post-passes added
1753
+ // edges, so both its summary and build_meta under-report (#1452).
1754
+ //
1755
+ // Fast path: skip the COUNT(*) scan when no post-pass wrote any edges.
1756
+ // COUNT(*) on large tables (50K+ edges) is non-trivial, especially via the
1757
+ // NativeDbProxy napi-rs round-trip. When all post-passes were no-ops, the
1758
+ // Rust orchestrator's counts are still accurate — no re-count needed.
1759
+ let finalNodeCount = result.nodeCount ?? 0;
1760
+ let finalEdgeCount = result.edgeCount ?? 0;
1761
+ const postPassWroteData = backfillHappened || chaEdgeCount > 0 || thisDispatchTargetIds.size > 0;
1762
+ if (postPassWroteData) {
1763
+ try {
1764
+ const counts = (ctx.db as unknown as BetterSqlite3Database)
1765
+ .prepare('SELECT (SELECT COUNT(*) FROM nodes) AS n, (SELECT COUNT(*) FROM edges) AS e')
1766
+ .get() as { n: number; e: number };
1767
+ if (counts.n !== finalNodeCount || counts.e !== finalEdgeCount) {
1768
+ finalNodeCount = counts.n;
1769
+ finalEdgeCount = counts.e;
1770
+ setBuildMeta(ctx.db, { node_count: finalNodeCount, edge_count: finalEdgeCount });
1771
+ }
1772
+ } catch (err) {
1773
+ debug(`Post-pass node/edge re-count failed: ${toErrorMessage(err)}`);
1774
+ }
1775
+ }
1776
+ info(
1777
+ `Native build orchestrator completed: ${finalNodeCount} nodes, ${finalEdgeCount} edges, ${result.fileCount ?? 0} files`,
1778
+ );
1481
1779
 
1482
1780
  // ── Structure and analysis fallback (run after edge-writing so roles see full graph) ──
1483
1781
  // Reconstruct fileSymbols once for both structure and analysis to avoid two
@@ -1501,5 +1799,11 @@ export async function tryNativeOrchestrator(
1501
1799
  }
1502
1800
 
1503
1801
  closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb });
1504
- return formatNativeTimingResult(p, structurePatchMs, analysisTiming, thisDispatchMs);
1802
+ return formatNativeTimingResult(p, structurePatchMs, analysisTiming, {
1803
+ gapDetectMs,
1804
+ chaMs,
1805
+ thisDispatchMs,
1806
+ reclassifyMs,
1807
+ techniqueBackfillMs,
1808
+ });
1505
1809
  }