@optave/codegraph 3.9.6 → 3.11.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 (186) hide show
  1. package/README.md +26 -12
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +1 -1
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/rules/index.d.ts.map +1 -1
  6. package/dist/ast-analysis/rules/index.js +77 -0
  7. package/dist/ast-analysis/rules/index.js.map +1 -1
  8. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  9. package/dist/ast-analysis/visitors/ast-store-visitor.js +50 -8
  10. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  11. package/dist/cli/commands/audit.js +1 -1
  12. package/dist/cli/commands/audit.js.map +1 -1
  13. package/dist/cli/commands/build.d.ts.map +1 -1
  14. package/dist/cli/commands/build.js +2 -0
  15. package/dist/cli/commands/build.js.map +1 -1
  16. package/dist/cli/commands/check.js +1 -1
  17. package/dist/cli/commands/check.js.map +1 -1
  18. package/dist/cli/commands/children.js +1 -1
  19. package/dist/cli/commands/children.js.map +1 -1
  20. package/dist/cli/commands/diff-impact.js +1 -1
  21. package/dist/cli/commands/diff-impact.js.map +1 -1
  22. package/dist/cli/commands/roles.js +1 -1
  23. package/dist/cli/commands/roles.js.map +1 -1
  24. package/dist/cli/commands/structure.js +1 -1
  25. package/dist/cli/commands/structure.js.map +1 -1
  26. package/dist/cli/shared/options.js +1 -1
  27. package/dist/cli/shared/options.js.map +1 -1
  28. package/dist/db/connection.d.ts.map +1 -1
  29. package/dist/db/connection.js +8 -0
  30. package/dist/db/connection.js.map +1 -1
  31. package/dist/domain/graph/builder/context.d.ts +10 -0
  32. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/context.js +10 -0
  34. package/dist/domain/graph/builder/context.js.map +1 -1
  35. package/dist/domain/graph/builder/helpers.d.ts +7 -2
  36. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/helpers.js +7 -2
  38. package/dist/domain/graph/builder/helpers.js.map +1 -1
  39. package/dist/domain/graph/builder/incremental.d.ts +0 -6
  40. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  41. package/dist/domain/graph/builder/incremental.js +6 -23
  42. package/dist/domain/graph/builder/incremental.js.map +1 -1
  43. package/dist/domain/graph/builder/pipeline.d.ts +44 -0
  44. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  45. package/dist/domain/graph/builder/pipeline.js +348 -42
  46. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  47. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  48. package/dist/domain/graph/builder/stages/build-edges.js +8 -2
  49. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  50. package/dist/domain/graph/builder/stages/collect-files.d.ts.map +1 -1
  51. package/dist/domain/graph/builder/stages/collect-files.js +8 -0
  52. package/dist/domain/graph/builder/stages/collect-files.js.map +1 -1
  53. package/dist/domain/graph/builder/stages/detect-changes.d.ts +24 -0
  54. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  55. package/dist/domain/graph/builder/stages/detect-changes.js +117 -3
  56. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  57. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  58. package/dist/domain/graph/builder/stages/finalize.js +9 -6
  59. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  60. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +30 -0
  61. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  62. package/dist/domain/graph/builder/stages/insert-nodes.js +36 -13
  63. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  64. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  65. package/dist/domain/graph/builder/stages/resolve-imports.js +73 -22
  66. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  67. package/dist/domain/graph/watcher.d.ts.map +1 -1
  68. package/dist/domain/graph/watcher.js +23 -18
  69. package/dist/domain/graph/watcher.js.map +1 -1
  70. package/dist/domain/parser.d.ts +14 -1
  71. package/dist/domain/parser.d.ts.map +1 -1
  72. package/dist/domain/parser.js +104 -11
  73. package/dist/domain/parser.js.map +1 -1
  74. package/dist/domain/search/models.d.ts +16 -0
  75. package/dist/domain/search/models.d.ts.map +1 -1
  76. package/dist/domain/search/models.js +36 -2
  77. package/dist/domain/search/models.js.map +1 -1
  78. package/dist/domain/wasm-worker-entry.js +20 -13
  79. package/dist/domain/wasm-worker-entry.js.map +1 -1
  80. package/dist/extractors/c.js +25 -6
  81. package/dist/extractors/c.js.map +1 -1
  82. package/dist/extractors/cpp.js +47 -6
  83. package/dist/extractors/cpp.js.map +1 -1
  84. package/dist/extractors/cuda.js +90 -14
  85. package/dist/extractors/cuda.js.map +1 -1
  86. package/dist/extractors/elixir.js +83 -3
  87. package/dist/extractors/elixir.js.map +1 -1
  88. package/dist/extractors/erlang.js +56 -20
  89. package/dist/extractors/erlang.js.map +1 -1
  90. package/dist/extractors/fsharp.d.ts +7 -0
  91. package/dist/extractors/fsharp.d.ts.map +1 -1
  92. package/dist/extractors/fsharp.js +94 -0
  93. package/dist/extractors/fsharp.js.map +1 -1
  94. package/dist/extractors/gleam.js +6 -2
  95. package/dist/extractors/gleam.js.map +1 -1
  96. package/dist/extractors/groovy.js +41 -1
  97. package/dist/extractors/groovy.js.map +1 -1
  98. package/dist/extractors/haskell.js +48 -4
  99. package/dist/extractors/haskell.js.map +1 -1
  100. package/dist/extractors/julia.js +172 -41
  101. package/dist/extractors/julia.js.map +1 -1
  102. package/dist/extractors/kotlin.js +4 -0
  103. package/dist/extractors/kotlin.js.map +1 -1
  104. package/dist/extractors/objc.js +184 -47
  105. package/dist/extractors/objc.js.map +1 -1
  106. package/dist/extractors/python.js +7 -4
  107. package/dist/extractors/python.js.map +1 -1
  108. package/dist/extractors/r.js +93 -52
  109. package/dist/extractors/r.js.map +1 -1
  110. package/dist/extractors/scala.d.ts.map +1 -1
  111. package/dist/extractors/scala.js +18 -32
  112. package/dist/extractors/scala.js.map +1 -1
  113. package/dist/extractors/solidity.js +18 -9
  114. package/dist/extractors/solidity.js.map +1 -1
  115. package/dist/extractors/verilog.js +80 -15
  116. package/dist/extractors/verilog.js.map +1 -1
  117. package/dist/infrastructure/config.d.ts +1 -0
  118. package/dist/infrastructure/config.d.ts.map +1 -1
  119. package/dist/infrastructure/config.js +1 -0
  120. package/dist/infrastructure/config.js.map +1 -1
  121. package/dist/mcp/server.d.ts.map +1 -1
  122. package/dist/mcp/server.js +14 -8
  123. package/dist/mcp/server.js.map +1 -1
  124. package/dist/mcp/tool-registry.d.ts +1 -1
  125. package/dist/mcp/tool-registry.d.ts.map +1 -1
  126. package/dist/mcp/tool-registry.js +23 -5
  127. package/dist/mcp/tool-registry.js.map +1 -1
  128. package/dist/mcp/tools/semantic-search.d.ts +1 -0
  129. package/dist/mcp/tools/semantic-search.d.ts.map +1 -1
  130. package/dist/mcp/tools/semantic-search.js +1 -0
  131. package/dist/mcp/tools/semantic-search.js.map +1 -1
  132. package/dist/types.d.ts +16 -1
  133. package/dist/types.d.ts.map +1 -1
  134. package/grammars/tree-sitter-erlang.wasm +0 -0
  135. package/grammars/tree-sitter-fsharp.wasm +0 -0
  136. package/grammars/tree-sitter-fsharp_signature.wasm +0 -0
  137. package/grammars/tree-sitter-gleam.wasm +0 -0
  138. package/package.json +11 -10
  139. package/src/ast-analysis/engine.ts +3 -1
  140. package/src/ast-analysis/rules/index.ts +87 -0
  141. package/src/ast-analysis/visitors/ast-store-visitor.ts +45 -9
  142. package/src/cli/commands/audit.ts +1 -1
  143. package/src/cli/commands/build.ts +2 -0
  144. package/src/cli/commands/check.ts +1 -1
  145. package/src/cli/commands/children.ts +1 -1
  146. package/src/cli/commands/diff-impact.ts +1 -1
  147. package/src/cli/commands/roles.ts +1 -1
  148. package/src/cli/commands/structure.ts +1 -1
  149. package/src/cli/shared/options.ts +1 -1
  150. package/src/db/connection.ts +8 -0
  151. package/src/domain/graph/builder/context.ts +10 -0
  152. package/src/domain/graph/builder/helpers.ts +8 -3
  153. package/src/domain/graph/builder/incremental.ts +6 -41
  154. package/src/domain/graph/builder/pipeline.ts +404 -41
  155. package/src/domain/graph/builder/stages/build-edges.ts +9 -2
  156. package/src/domain/graph/builder/stages/collect-files.ts +9 -0
  157. package/src/domain/graph/builder/stages/detect-changes.ts +130 -4
  158. package/src/domain/graph/builder/stages/finalize.ts +9 -6
  159. package/src/domain/graph/builder/stages/insert-nodes.ts +38 -14
  160. package/src/domain/graph/builder/stages/resolve-imports.ts +79 -25
  161. package/src/domain/graph/watcher.ts +21 -23
  162. package/src/domain/parser.ts +110 -10
  163. package/src/domain/search/models.ts +37 -2
  164. package/src/domain/wasm-worker-entry.ts +20 -13
  165. package/src/extractors/c.ts +27 -8
  166. package/src/extractors/cpp.ts +50 -8
  167. package/src/extractors/cuda.ts +90 -16
  168. package/src/extractors/elixir.ts +75 -3
  169. package/src/extractors/erlang.ts +63 -20
  170. package/src/extractors/fsharp.ts +104 -0
  171. package/src/extractors/gleam.ts +7 -2
  172. package/src/extractors/groovy.ts +45 -1
  173. package/src/extractors/haskell.ts +45 -4
  174. package/src/extractors/julia.ts +164 -43
  175. package/src/extractors/kotlin.ts +4 -0
  176. package/src/extractors/objc.ts +171 -47
  177. package/src/extractors/python.ts +5 -3
  178. package/src/extractors/r.ts +88 -48
  179. package/src/extractors/scala.ts +24 -36
  180. package/src/extractors/solidity.ts +17 -8
  181. package/src/extractors/verilog.ts +83 -15
  182. package/src/infrastructure/config.ts +1 -0
  183. package/src/mcp/server.ts +16 -9
  184. package/src/mcp/tool-registry.ts +28 -5
  185. package/src/mcp/tools/semantic-search.ts +2 -0
  186. package/src/types.ts +16 -0
@@ -27,7 +27,7 @@ interface FileHashRow {
27
27
  }
28
28
 
29
29
  interface FileStat {
30
- mtimeMs: number;
30
+ mtime: number;
31
31
  size: number;
32
32
  }
33
33
 
@@ -182,7 +182,7 @@ function mtimeAndHashTiers(
182
182
  if (!stat) continue;
183
183
  const storedMtime = record.mtime || 0;
184
184
  const storedSize = record.size || 0;
185
- if (storedSize > 0 && Math.floor(stat.mtimeMs) === storedMtime && stat.size === storedSize) {
185
+ if (storedSize > 0 && stat.mtime === storedMtime && stat.size === storedSize) {
186
186
  skipped.push(relPath);
187
187
  continue;
188
188
  }
@@ -512,6 +512,132 @@ function handleIncrementalBuild(ctx: PipelineContext): void {
512
512
  purgeAndAddReverseDeps(ctx, changePaths, reverseDeps);
513
513
  }
514
514
 
515
+ /**
516
+ * Read-only pre-flight check for the native orchestrator.
517
+ *
518
+ * Returns true iff every collected source file has matching mtime+size in
519
+ * `file_hashes` and no DB-tracked file has been removed. When true, the
520
+ * caller can short-circuit before invoking the native orchestrator —
521
+ * matching WASM's ~20 ms early-exit path and avoiding the ~2s flat
522
+ * per-call native rebuild overhead seen in CI (#1054).
523
+ *
524
+ * Intentionally Tier-0/Tier-1 only (journal + mtime/size). Tier-2 content
525
+ * hashing is left to the native side: when this returns false the caller
526
+ * falls through to the orchestrator, which performs its own complete
527
+ * detection and is the source of truth.
528
+ *
529
+ * Conservatively returns false when CFG or dataflow analysis is enabled
530
+ * but the corresponding tables are empty — otherwise the fast-skip would
531
+ * silently suppress the pending-analysis pass that the JS path runs via
532
+ * `runPendingAnalysis`, and CFG/dataflow data would never populate on
533
+ * repos where source files don't change between builds.
534
+ *
535
+ * Pure read of `db` and the filesystem — never mutates either.
536
+ */
537
+ export function detectNoChanges(
538
+ db: BetterSqlite3Database,
539
+ allFiles: string[],
540
+ rootDir: string,
541
+ opts?: Record<string, unknown>,
542
+ ): boolean {
543
+ // Diagnostic logging gated by env var — used by the bench gate to surface
544
+ // why the fast-skip is not firing on CI runners (#1066). Off by default to
545
+ // avoid noise on every regular incremental build.
546
+ const diag = process.env.CODEGRAPH_FAST_SKIP_DIAG === '1';
547
+ const log = (reason: string): void => {
548
+ if (diag) info(`[fast-skip] ${reason}`);
549
+ };
550
+
551
+ let hasTable = false;
552
+ try {
553
+ db.prepare('SELECT 1 FROM file_hashes LIMIT 1').get();
554
+ hasTable = true;
555
+ } catch {
556
+ /* table missing — first build */
557
+ }
558
+ if (!hasTable) {
559
+ log('false: file_hashes table missing');
560
+ return false;
561
+ }
562
+
563
+ const rows = db.prepare('SELECT file, hash, mtime, size FROM file_hashes').all() as FileHashRow[];
564
+ if (rows.length === 0) {
565
+ log('false: file_hashes table empty');
566
+ return false;
567
+ }
568
+ const existing = new Map<string, FileHashRow>(rows.map((r) => [r.file, r]));
569
+
570
+ const currentFiles = new Set<string>();
571
+ for (const file of allFiles) {
572
+ currentFiles.add(normalizePath(path.relative(rootDir, file)));
573
+ }
574
+ for (const existingFile of existing.keys()) {
575
+ if (!currentFiles.has(existingFile)) {
576
+ log(`false: tracked file no longer collected: ${existingFile}`);
577
+ return false;
578
+ }
579
+ }
580
+
581
+ for (const file of allFiles) {
582
+ const relPath = normalizePath(path.relative(rootDir, file));
583
+ const record = existing.get(relPath);
584
+ if (!record) {
585
+ log(`false: collected file missing from file_hashes: ${relPath}`);
586
+ return false;
587
+ }
588
+ const stat = fileStat(file) as FileStat | undefined;
589
+ if (!stat) {
590
+ log(`false: stat failed for ${relPath}`);
591
+ return false;
592
+ }
593
+ const storedMtime = record.mtime || 0;
594
+ const storedSize = record.size || 0;
595
+ if (storedSize <= 0) {
596
+ log(`false: stored size <= 0 for ${relPath} (stored=${record.size})`);
597
+ return false;
598
+ }
599
+ if (stat.mtime !== storedMtime || stat.size !== storedSize) {
600
+ log(
601
+ `false: mtime/size diff for ${relPath}: stat=${stat.mtime}/${stat.size} stored=${storedMtime}/${storedSize}`,
602
+ );
603
+ return false;
604
+ }
605
+ }
606
+
607
+ // Pending-analysis guard: if CFG/dataflow is enabled but the corresponding
608
+ // table is empty (analysis newly enabled, or tables wiped between builds),
609
+ // fall through so the orchestrator / JS pipeline can run runPendingAnalysis.
610
+ // Mirrors the check at the top of runPendingAnalysis (see line ~244).
611
+ if (opts) {
612
+ if (opts.cfg !== false && hasEmptyAnalysisTable(db, 'cfg_blocks')) {
613
+ log('false: pending-analysis guard — cfg_blocks is empty');
614
+ return false;
615
+ }
616
+ if (opts.dataflow !== false && hasEmptyAnalysisTable(db, 'dataflow')) {
617
+ log('false: pending-analysis guard — dataflow is empty');
618
+ return false;
619
+ }
620
+ }
621
+
622
+ log(`true: all checks passed (${allFiles.length} files)`);
623
+ return true;
624
+ }
625
+
626
+ /**
627
+ * Returns true if `table` exists and has zero rows, matching the empty-table
628
+ * semantics of `runPendingAnalysis`. A missing table is treated as empty
629
+ * (the conservative outcome), so the caller falls through to the orchestrator
630
+ * which will create the schema and populate it.
631
+ */
632
+ function hasEmptyAnalysisTable(db: BetterSqlite3Database, table: string): boolean {
633
+ try {
634
+ const row = db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number } | undefined;
635
+ return (row?.c ?? 0) === 0;
636
+ } catch {
637
+ return true;
638
+ }
639
+ }
640
+
515
641
  export async function detectChanges(ctx: PipelineContext): Promise<void> {
516
642
  const start = performance.now();
517
643
  try {
@@ -537,7 +663,7 @@ export async function detectChanges(ctx: PipelineContext): Promise<void> {
537
663
  relPath: c.relPath,
538
664
  content: c.content,
539
665
  hash: c.hash,
540
- stat: c.stat ? { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size } : undefined,
666
+ stat: c.stat ? { mtime: c.stat.mtime, size: c.stat.size } : undefined,
541
667
  _reverseDepOnly: c._reverseDepOnly,
542
668
  }));
543
669
  ctx.metadataUpdates = increResult.changed
@@ -548,7 +674,7 @@ export async function detectChanges(ctx: PipelineContext): Promise<void> {
548
674
  .map((c) => ({
549
675
  relPath: c.relPath,
550
676
  hash: c.hash,
551
- stat: { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size },
677
+ stat: { mtime: c.stat.mtime, size: c.stat.size },
552
678
  }));
553
679
  if (!ctx.isFullBuild && ctx.parseChanges.length === 0 && ctx.removed.length === 0) {
554
680
  const ranAnalysis = await runPendingAnalysis(ctx);
@@ -82,13 +82,16 @@ function persistBuildMetadata(
82
82
  ): void {
83
83
  const useNativeDb = ctx.engineName === 'native' && !!ctx.nativeDb;
84
84
  if (!ctx.isFullBuild && ctx.allSymbols.size <= 3) return;
85
- // When the native engine is active, persist the Rust addon version so that
86
- // checkEngineSchemaMismatch compares against the same value on the next build.
87
- // Writing CODEGRAPH_VERSION (the npm package version) here would create a
88
- // permanent mismatch whenever npm and crate versions diverge, forcing every
89
- // subsequent build to be a full rebuild.
85
+ // When the native engine is active, persist the binary's CARGO_PKG_VERSION
86
+ // (ctx.nativeBinaryVersion). The Rust orchestrator's check_version_mismatch
87
+ // compares against that exact value, so writing the platform package.json
88
+ // version (ctx.engineVersion) which can drift from the binary in CI
89
+ // hot-swap flows (#1066) would force every subsequent native build to
90
+ // be a full rebuild.
90
91
  const codeVersionToWrite =
91
- ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION;
92
+ ctx.engineName === 'native' && ctx.nativeBinaryVersion
93
+ ? ctx.nativeBinaryVersion
94
+ : CODEGRAPH_VERSION;
92
95
  // Persist the repo root so downstream commands (e.g. `codegraph embed`)
93
96
  // can resolve relative file paths regardless of the invoking cwd.
94
97
  // Use realpathSync (symlink-resolving) to match the Rust engine's
@@ -12,10 +12,12 @@ import path from 'node:path';
12
12
  import { performance } from 'node:perf_hooks';
13
13
  import { bulkNodeIdsByFile } from '../../../../db/index.js';
14
14
  import { debug } from '../../../../infrastructure/logger.js';
15
+ import { normalizePath } from '../../../../shared/constants.js';
15
16
  import { toErrorMessage } from '../../../../shared/errors.js';
16
17
  import type {
17
18
  BetterSqlite3Database,
18
19
  ExtractorOutput,
20
+ FileToParse,
19
21
  MetadataUpdate,
20
22
  SqliteStatement,
21
23
  } from '../../../../types.js';
@@ -90,16 +92,30 @@ function marshalSymbolBatches(allSymbols: Map<string, ExtractorOutput>): InsertN
90
92
  return batches;
91
93
  }
92
94
 
93
- /** Build file hash entries from parsed symbols and precomputed/metadata sources. */
94
- function buildFileHashes(
95
- allSymbols: Map<string, ExtractorOutput>,
95
+ /**
96
+ * Build file hash entries for every collected file, including those that
97
+ * produced zero symbols (empty files, parsers that silently no-op'd, or
98
+ * optional-language extensions whose grammar wasn't installed). Iterating the
99
+ * symbol map instead would skip such files and leave them missing from
100
+ * `file_hashes`, which permanently breaks the JS-side fast-skip pre-flight on
101
+ * any subsequent no-op rebuild (#1068).
102
+ *
103
+ * Exported for unit testing.
104
+ */
105
+ export function buildFileHashes(
106
+ filesToParse: FileToParse[],
96
107
  precomputedData: Map<string, PrecomputedFileData>,
97
108
  metadataUpdates: MetadataUpdate[],
98
109
  rootDir: string,
99
110
  ): Array<{ file: string; hash: string; mtime: number; size: number }> {
100
111
  const fileHashes: Array<{ file: string; hash: string; mtime: number; size: number }> = [];
112
+ const seen = new Set<string>();
113
+
114
+ for (const item of filesToParse) {
115
+ const relPath = item.relPath ?? normalizePath(path.relative(rootDir, item.file));
116
+ if (seen.has(relPath)) continue;
117
+ seen.add(relPath);
101
118
 
102
- for (const [relPath] of allSymbols) {
103
119
  const precomputed = precomputedData.get(relPath);
104
120
  if (precomputed?._reverseDepOnly) {
105
121
  continue; // file unchanged, hash already correct
@@ -112,7 +128,7 @@ function buildFileHashes(
112
128
  size = precomputed.stat.size;
113
129
  } else {
114
130
  const rawStat = fileStat(path.join(rootDir, relPath));
115
- mtime = rawStat ? Math.floor(rawStat.mtimeMs) : 0;
131
+ mtime = rawStat ? rawStat.mtime : 0;
116
132
  size = rawStat ? rawStat.size : 0;
117
133
  }
118
134
  fileHashes.push({ file: relPath, hash: precomputed.hash, mtime, size });
@@ -127,7 +143,7 @@ function buildFileHashes(
127
143
  }
128
144
  if (code !== null) {
129
145
  const stat = fileStat(absPath);
130
- const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
146
+ const mtime = stat ? stat.mtime : 0;
131
147
  const size = stat ? stat.size : 0;
132
148
  fileHashes.push({ file: relPath, hash: fileHash(code), mtime, size });
133
149
  }
@@ -136,7 +152,7 @@ function buildFileHashes(
136
152
 
137
153
  // Also include metadata-only updates (self-heal mtime/size without re-parse)
138
154
  for (const item of metadataUpdates) {
139
- const mtime = item.stat ? Math.floor(item.stat.mtime) : 0;
155
+ const mtime = item.stat ? item.stat.mtime : 0;
140
156
  const size = item.stat ? item.stat.size : 0;
141
157
  fileHashes.push({ file: item.relPath, hash: item.hash, mtime, size });
142
158
  }
@@ -157,7 +173,7 @@ function tryNativeInsert(ctx: PipelineContext): boolean {
157
173
  for (const item of filesToParse) {
158
174
  if (item.relPath) precomputedData.set(item.relPath, item as PrecomputedFileData);
159
175
  }
160
- const fileHashes = buildFileHashes(allSymbols, precomputedData, metadataUpdates, rootDir);
176
+ const fileHashes = buildFileHashes(filesToParse, precomputedData, metadataUpdates, rootDir);
161
177
 
162
178
  // In native-first mode (single rusqlite connection), no WAL dance is needed.
163
179
  // In dual-connection mode, checkpoint JS side before native write, then
@@ -321,7 +337,7 @@ function insertChildrenAndEdges(
321
337
 
322
338
  function updateFileHashes(
323
339
  _db: BetterSqlite3Database,
324
- allSymbols: Map<string, ExtractorOutput>,
340
+ filesToParse: FileToParse[],
325
341
  precomputedData: Map<string, PrecomputedFileData>,
326
342
  metadataUpdates: MetadataUpdate[],
327
343
  rootDir: string,
@@ -329,7 +345,15 @@ function updateFileHashes(
329
345
  ): void {
330
346
  if (!upsertHash) return;
331
347
 
332
- for (const [relPath] of allSymbols) {
348
+ // Iterate every collected file (#1068): files that produced zero symbols
349
+ // (empty, parser no-op, or grammar-missing optional language) still need a
350
+ // hash row, otherwise the next no-op rebuild's fast-skip pre-flight rejects.
351
+ const seen = new Set<string>();
352
+ for (const item of filesToParse) {
353
+ const relPath = item.relPath ?? normalizePath(path.relative(rootDir, item.file));
354
+ if (seen.has(relPath)) continue;
355
+ seen.add(relPath);
356
+
333
357
  const precomputed = precomputedData.get(relPath);
334
358
  if (precomputed?._reverseDepOnly) {
335
359
  // no-op: file unchanged, hash already correct
@@ -341,7 +365,7 @@ function updateFileHashes(
341
365
  size = precomputed.stat.size;
342
366
  } else {
343
367
  const rawStat = fileStat(path.join(rootDir, relPath));
344
- mtime = rawStat ? Math.floor(rawStat.mtimeMs) : 0;
368
+ mtime = rawStat ? rawStat.mtime : 0;
345
369
  size = rawStat ? rawStat.size : 0;
346
370
  }
347
371
  upsertHash.run(relPath, precomputed.hash, mtime, size);
@@ -356,7 +380,7 @@ function updateFileHashes(
356
380
  }
357
381
  if (code !== null) {
358
382
  const stat = fileStat(absPath);
359
- const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
383
+ const mtime = stat ? stat.mtime : 0;
360
384
  const size = stat ? stat.size : 0;
361
385
  upsertHash.run(relPath, fileHash(code), mtime, size);
362
386
  }
@@ -365,7 +389,7 @@ function updateFileHashes(
365
389
 
366
390
  // Also update metadata-only entries (self-heal mtime/size without re-parse)
367
391
  for (const item of metadataUpdates) {
368
- const mtime = item.stat ? Math.floor(item.stat.mtime) : 0;
392
+ const mtime = item.stat ? item.stat.mtime : 0;
369
393
  const size = item.stat ? item.stat.size : 0;
370
394
  upsertHash.run(item.relPath, item.hash, mtime, size);
371
395
  }
@@ -415,7 +439,7 @@ export async function insertNodes(ctx: PipelineContext): Promise<void> {
415
439
  const insertAll = ctx.db.transaction(() => {
416
440
  insertDefinitionsAndExports(ctx.db, allSymbols);
417
441
  insertChildrenAndEdges(ctx.db, allSymbols);
418
- updateFileHashes(ctx.db, allSymbols, precomputedData, metadataUpdates, rootDir, upsertHash);
442
+ updateFileHashes(ctx.db, filesToParse, precomputedData, metadataUpdates, rootDir, upsertHash);
419
443
  });
420
444
 
421
445
  insertAll();
@@ -33,15 +33,23 @@ function buildReexportMap(ctx: PipelineContext): void {
33
33
  }
34
34
 
35
35
  /**
36
- * Find barrel files related to changed files for scoped re-parsing.
37
- * For small incremental builds (<=smallFilesThreshold files), only barrels that re-export from
38
- * or are imported by the changed files. For larger changes, all barrels.
36
+ * Find barrel files related to `fromRelPaths` for scoped re-parsing.
37
+ * For small frontiers (<=smallFilesThreshold files), only barrels that re-export from
38
+ * or are imported by `fromRelPaths`. For larger frontiers, all barrels.
39
+ *
40
+ * `firstPass` gates the reexport-from DB scan: re-parsed barrels haven't
41
+ * changed content, so subsequent passes can't surface new reexport-from
42
+ * candidates and only need to follow imports of newly-merged barrels
43
+ * (mirrors the Rust orchestrator's seed-only `collect_reexport_from_barrels`).
39
44
  */
40
- function findBarrelCandidates(ctx: PipelineContext): Array<{ file: string }> {
45
+ function findBarrelCandidates(
46
+ ctx: PipelineContext,
47
+ fromRelPaths: readonly string[],
48
+ firstPass: boolean,
49
+ ): Array<{ file: string }> {
41
50
  const { db, fileSymbols, rootDir, aliases } = ctx;
42
- const changedRelPaths = new Set<string>(fileSymbols.keys());
43
51
 
44
- if (changedRelPaths.size <= ctx.config.build.smallFilesThreshold) {
52
+ if (fromRelPaths.length <= ctx.config.build.smallFilesThreshold) {
45
53
  const allBarrelFiles = new Set(
46
54
  (
47
55
  db
@@ -56,9 +64,9 @@ function findBarrelCandidates(ctx: PipelineContext): Array<{ file: string }> {
56
64
 
57
65
  const barrels = new Set<string>();
58
66
 
59
- // Find barrels imported by changed files using parsed import data
67
+ // Find barrels imported by `fromRelPaths` using parsed import data
60
68
  // (can't query DB edges -- they were purged for the changed files).
61
- for (const relPath of changedRelPaths) {
69
+ for (const relPath of fromRelPaths) {
62
70
  const symbols = fileSymbols.get(relPath);
63
71
  if (!symbols) continue;
64
72
  for (const imp of symbols.imports) {
@@ -71,16 +79,17 @@ function findBarrelCandidates(ctx: PipelineContext): Array<{ file: string }> {
71
79
  }
72
80
  }
73
81
 
74
- // Also find barrels that re-export from the changed files
75
- const reexportSourceStmt = db.prepare(
76
- `SELECT DISTINCT n1.file FROM edges e
77
- JOIN nodes n1 ON e.source_id = n1.id
78
- JOIN nodes n2 ON e.target_id = n2.id
79
- WHERE e.kind = 'reexports' AND n1.kind = 'file' AND n2.file = ?`,
80
- );
81
- for (const relPath of changedRelPaths) {
82
- for (const row of reexportSourceStmt.all(relPath) as Array<{ file: string }>) {
83
- barrels.add(row.file);
82
+ if (firstPass) {
83
+ const reexportSourceStmt = db.prepare(
84
+ `SELECT DISTINCT n1.file FROM edges e
85
+ JOIN nodes n1 ON e.source_id = n1.id
86
+ JOIN nodes n2 ON e.target_id = n2.id
87
+ WHERE e.kind = 'reexports' AND n1.kind = 'file' AND n2.file = ?`,
88
+ );
89
+ for (const relPath of fromRelPaths) {
90
+ for (const row of reexportSourceStmt.all(relPath) as Array<{ file: string }>) {
91
+ barrels.add(row.file);
92
+ }
84
93
  }
85
94
  }
86
95
  return [...barrels].map((file) => ({ file }));
@@ -95,11 +104,22 @@ function findBarrelCandidates(ctx: PipelineContext): Array<{ file: string }> {
95
104
  .all() as Array<{ file: string }>;
96
105
  }
97
106
 
98
- /** Re-parse barrel files and update fileSymbols/reexportMap with fresh data. */
107
+ /**
108
+ * Re-parse barrel files and update fileSymbols/reexportMap with fresh data.
109
+ * Returns the relative paths of newly-merged files so the caller can scan
110
+ * them for the next level of barrel candidates.
111
+ *
112
+ * A re-parsed file is marked `barrel-only` only when it really is one (the
113
+ * `isBarrelFile` check — reexports >= ownDefs). The previous unconditional
114
+ * `.add(relPath)` caused hybrid barrels with many local defs (e.g. a file
115
+ * with one `export type ... from` and dozens of internal functions) to drop
116
+ * all their non-reexport imports in build-edges, since the barrel-only branch
117
+ * skips them (#1174).
118
+ */
99
119
  async function reparseBarrelFiles(
100
120
  ctx: PipelineContext,
101
121
  barrelCandidates: Array<{ file: string }>,
102
- ): Promise<void> {
122
+ ): Promise<string[]> {
103
123
  const { db, fileSymbols, rootDir, engineOpts } = ctx;
104
124
 
105
125
  const barrelPaths: string[] = [];
@@ -109,18 +129,27 @@ async function reparseBarrelFiles(
109
129
  }
110
130
  }
111
131
 
112
- if (barrelPaths.length === 0) return;
132
+ if (barrelPaths.length === 0) return [];
113
133
 
134
+ // Preserve `contains` and `parameter_of` — those are emitted by insertNodes,
135
+ // which only runs on the original (changed + reverse-dep) fileSymbols. Barrel
136
+ // candidates are merged here *after* insertNodes, so wiping those kinds
137
+ // would permanently drop them (mirrors the Rust orchestrator's Stage 6b
138
+ // delete in build_pipeline.rs).
114
139
  const deleteOutgoingEdges = db.prepare(
115
- 'DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
140
+ `DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)
141
+ AND kind NOT IN ('contains', 'parameter_of')`,
116
142
  );
117
143
 
144
+ const added: string[] = [];
118
145
  try {
119
146
  const barrelSymbols = await parseFilesAuto(barrelPaths, rootDir, engineOpts);
120
147
  for (const [relPath, fileSym] of barrelSymbols) {
121
148
  deleteOutgoingEdges.run(relPath);
122
149
  fileSymbols.set(relPath, fileSym);
123
- ctx.barrelOnlyFiles.add(relPath);
150
+ if (isBarrelFile(ctx, relPath)) {
151
+ ctx.barrelOnlyFiles.add(relPath);
152
+ }
124
153
  const reexports = fileSym.imports.filter((imp: Import) => imp.reexport);
125
154
  if (reexports.length > 0) {
126
155
  ctx.reexportMap.set(
@@ -132,10 +161,12 @@ async function reparseBarrelFiles(
132
161
  })),
133
162
  );
134
163
  }
164
+ added.push(relPath);
135
165
  }
136
166
  } catch (e: unknown) {
137
167
  debug(`Barrel re-parse failed (non-fatal): ${(e as Error).message}`);
138
168
  }
169
+ return added;
139
170
  }
140
171
 
141
172
  export async function resolveImports(ctx: PipelineContext): Promise<void> {
@@ -156,8 +187,31 @@ export async function resolveImports(ctx: PipelineContext): Promise<void> {
156
187
 
157
188
  ctx.barrelOnlyFiles = new Set<string>();
158
189
  if (!isFullBuild) {
159
- const barrelCandidates = findBarrelCandidates(ctx);
160
- await reparseBarrelFiles(ctx, barrelCandidates);
190
+ // Iteratively discover and re-parse barrel chains. A barrel that imports
191
+ // another barrel (e.g. `parser.ts → extractors/index.ts → extractors/<lang>.ts`)
192
+ // needs both loaded so build-edges can emit the barrel-through edges from
193
+ // the first barrel to the leaf targets. Without iteration, only the first
194
+ // level of barrels gets merged into fileSymbols; the deeper chain has no
195
+ // entry in reexportMap and the resolver silently drops the affected edges
196
+ // on every incremental rebuild (#1174).
197
+ //
198
+ // Convergence is guaranteed because fileSymbols grows monotonically and
199
+ // is bounded by the set of barrel files in the project — each iteration
200
+ // either adds a previously-unseen barrel or terminates.
201
+ //
202
+ // Subsequent passes only walk newly-merged barrels' imports (`frontier`
203
+ // = paths returned by reparseBarrelFiles), matching the Rust
204
+ // orchestrator's `&newly_added` slice. Without this, every pass would
205
+ // re-query the DB for every key in `fileSymbols`.
206
+ let frontier: readonly string[] = [...fileSymbols.keys()];
207
+ let firstPass = true;
208
+ while (frontier.length > 0) {
209
+ const barrelCandidates = findBarrelCandidates(ctx, frontier, firstPass);
210
+ const added = await reparseBarrelFiles(ctx, barrelCandidates);
211
+ if (added.length === 0) break;
212
+ frontier = added;
213
+ firstPass = false;
214
+ }
161
215
  }
162
216
  }
163
217
 
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { closeDb, getNodeId as getNodeIdQuery, initSchema, openDb } from '../../db/index.js';
4
- import { debug, info } from '../../infrastructure/logger.js';
4
+ import { debug, info, warn } from '../../infrastructure/logger.js';
5
5
  import { isSupportedFile, normalizePath, shouldIgnore } from '../../shared/constants.js';
6
6
  import { DbError } from '../../shared/errors.js';
7
7
  import { createParseTreeCache, getActiveEngine } from '../parser.js';
@@ -16,12 +16,13 @@ function shouldIgnorePath(filePath: string): boolean {
16
16
 
17
17
  /** Prepare all SQL statements needed by the watcher's incremental rebuild. */
18
18
  function prepareWatcherStatements(db: ReturnType<typeof openDb>): IncrementalStmts {
19
- const stmts = {
19
+ return {
20
20
  insertNode: db.prepare(
21
21
  'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
22
22
  ),
23
23
  getNodeId: {
24
- get: (name: string, kind: string, file: string, line: number) => {
24
+ get: (...params: unknown[]) => {
25
+ const [name, kind, file, line] = params as [string, string, string, number];
25
26
  const id = getNodeIdQuery(db, name, kind, file, line);
26
27
  return id != null ? { id } : undefined;
27
28
  },
@@ -29,10 +30,7 @@ function prepareWatcherStatements(db: ReturnType<typeof openDb>): IncrementalStm
29
30
  insertEdge: db.prepare(
30
31
  'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
31
32
  ),
32
- deleteNodes: db.prepare('DELETE FROM nodes WHERE file = ?'),
33
- deleteEdgesForFile: null as { run: (f: string) => void } | null,
34
33
  countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'),
35
- countEdgesForFile: null as { get: (f: string) => { c: number } | undefined } | null,
36
34
  findNodeInFile: db.prepare(
37
35
  "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?",
38
36
  ),
@@ -41,19 +39,6 @@ function prepareWatcherStatements(db: ReturnType<typeof openDb>): IncrementalStm
41
39
  ),
42
40
  listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"),
43
41
  };
44
-
45
- const origDeleteEdges = db.prepare(
46
- `DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f) OR target_id IN (SELECT id FROM nodes WHERE file = @f)`,
47
- );
48
- const origCountEdges = db.prepare(
49
- `SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = @f) OR target_id IN (SELECT id FROM nodes WHERE file = @f)`,
50
- );
51
- stmts.deleteEdgesForFile = { run: (f: string) => origDeleteEdges.run({ f }) };
52
- stmts.countEdgesForFile = {
53
- get: (f: string) => origCountEdges.get({ f }) as { c: number } | undefined,
54
- };
55
-
56
- return stmts as IncrementalStmts;
57
42
  }
58
43
 
59
44
  /** Rebuild result shape from rebuildFile. */
@@ -80,10 +65,23 @@ async function processPendingFiles(
80
65
  ): Promise<void> {
81
66
  const results: RebuildResult[] = [];
82
67
  for (const filePath of files) {
83
- const result = (await rebuildFile(db, rootDir, filePath, stmts, engineOpts, cache, {
84
- diffSymbols: diffSymbols as (old: unknown[], new_: unknown[]) => unknown,
85
- })) as RebuildResult | null;
86
- if (result) results.push(result);
68
+ // Per-file try/catch so one bad rebuild doesn't crash the watcher loop.
69
+ // The watcher is a long-running session any SQLite error, parse failure,
70
+ // or filesystem race must be reported and skipped, not propagated. Issue #1176.
71
+ try {
72
+ const result = (await rebuildFile(db, rootDir, filePath, stmts, engineOpts, cache, {
73
+ diffSymbols: diffSymbols as (old: unknown[], new_: unknown[]) => unknown,
74
+ })) as RebuildResult | null;
75
+ if (result) results.push(result);
76
+ } catch (err: unknown) {
77
+ const relPath = normalizePath(path.relative(rootDir, filePath));
78
+ // Narrow with `instanceof` instead of casting: a non-Error throw (a plain
79
+ // string, `null`, or any value a third-party dependency throws) would log
80
+ // `(err as Error).message` as `undefined`. See Greptile review on #1182.
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ warn(`Failed to rebuild ${relPath}: ${message} — skipping`);
83
+ debug(err instanceof Error ? (err.stack ?? message) : String(err));
84
+ }
87
85
  }
88
86
 
89
87
  if (results.length > 0) {