@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
@@ -131,6 +131,21 @@ function extractChildExpressionText(node: TreeSitterNode): string | null {
131
131
  return truncate(node.text);
132
132
  }
133
133
 
134
+ /**
135
+ * Count code points cheaply: skip the `[...s]` spread when `s.length` already
136
+ * decides the answer. Each code point is 1 or 2 UTF-16 units, so `.length < 2`
137
+ * implies `< 2` code points and `.length >= 3` already guarantees `>= 2` code
138
+ * points (worst case: one surrogate pair + one BMP char = 2 code points).
139
+ * Only `.length === 2` is genuinely ambiguous (could be a single surrogate
140
+ * pair = 1 code point, or two BMP chars = 2 code points) and needs the spread.
141
+ */
142
+ function codePointCountAtLeast2(s: string): boolean {
143
+ const len = s.length;
144
+ if (len < 2) return false;
145
+ if (len >= 3) return true;
146
+ return [...s].length >= 2;
147
+ }
148
+
134
149
  /**
135
150
  * Extract string content from a string-literal node, mirroring the native
136
151
  * engine's `build_string_node` (`helpers.rs`). Returns `null` when the
@@ -142,15 +157,27 @@ function extractStringContent(node: TreeSitterNode, cfg: AstStringConfig): strin
142
157
 
143
158
  let s = raw;
144
159
  s = trimLeadingChars(s, '@');
145
- s = trimLeadingChars(s, cfg.stringPrefixes);
160
+ if (cfg.stringPrefixes) s = trimLeadingChars(s, cfg.stringPrefixes);
146
161
  if (isRawString) s = trimLeadingChars(s, 'r#');
147
162
  s = trimLeadingChars(s, cfg.quoteChars);
148
163
  if (isRawString) s = trimTrailingChars(s, '#');
149
164
  s = trimTrailingChars(s, cfg.quoteChars);
150
165
 
151
- // Count code points, not UTF-16 code units — matches Rust `chars().count()`.
152
- const codePointCount = [...s].length;
153
- if (codePointCount < 2) return null;
166
+ return codePointCountAtLeast2(s) ? s : null;
167
+ }
168
+
169
+ // Per-astTypeMap cache for the set of node-types that map to kind 'new'.
170
+ // Computed once per unique astTypeMap reference (one per language) instead
171
+ // of once per file.
172
+ const _newTypesCache = new WeakMap<Record<string, string>, Set<string>>();
173
+ function newTypesFor(astTypeMap: Record<string, string>): Set<string> {
174
+ let s = _newTypesCache.get(astTypeMap);
175
+ if (s) return s;
176
+ s = new Set<string>();
177
+ for (const type in astTypeMap) {
178
+ if (astTypeMap[type] === 'new') s.add(type);
179
+ }
180
+ _newTypesCache.set(astTypeMap, s);
154
181
  return s;
155
182
  }
156
183
 
@@ -164,11 +191,12 @@ export function createAstStoreVisitor(
164
191
  ): Visitor {
165
192
  const rows: AstStoreRow[] = [];
166
193
  const matched = new Set<number>();
167
- const newTypes = new Set<string>(
168
- Object.entries(astTypeMap)
169
- .filter(([, kind]) => kind === 'new')
170
- .map(([type]) => type),
171
- );
194
+ const newTypes = newTypesFor(astTypeMap);
195
+ // When nodeIdMap is empty, parentNodeId resolution is wasted work — the
196
+ // worker passes an empty map and the main thread re-resolves against its
197
+ // own DB-populated map in features/ast.ts::collectFileAstRows. Skip the
198
+ // findParentDef linear scan in that case.
199
+ const skipParentLookup = nodeIdMap.size === 0;
172
200
 
173
201
  function findParentDef(line: number): Definition | null {
174
202
  let best: Definition | null = null;
@@ -183,6 +211,7 @@ export function createAstStoreVisitor(
183
211
  }
184
212
 
185
213
  function resolveParentNodeId(line: number): number | null {
214
+ if (skipParentLookup) return null;
186
215
  const parentDef = findParentDef(line);
187
216
  if (!parentDef) return null;
188
217
  return nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
@@ -240,6 +269,13 @@ export function createAstStoreVisitor(
240
269
  // unrelated subtree. The parent call's skipChildren handles the intended case.
241
270
  if (matched.has(node.id)) return;
242
271
 
272
+ // Gate with `hasOwn` because plain-object lookup walks Object.prototype:
273
+ // tree-sitter node types like `constructor` (Haskell sum-types: Left,
274
+ // Right) would otherwise resolve to `Object.prototype.constructor` (the
275
+ // Object() function), which then crashes the worker boundary with
276
+ // "function Object() { [native code] } could not be cloned" when the
277
+ // resulting astNodes row is structured-cloned back to the main thread.
278
+ if (!Object.hasOwn(astTypeMap, node.type)) return;
243
279
  const kind = astTypeMap[node.type];
244
280
  if (!kind) return;
245
281
 
@@ -17,7 +17,7 @@ export const command: CommandDefinition = {
17
17
  ['-T, --no-tests', 'Exclude test/spec files from results'],
18
18
  ['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
19
19
  ['-j, --json', 'Output as JSON'],
20
- ['--limit <number>', 'Max results to return (quick mode)'],
20
+ ['-n, --limit <number>', 'Max results to return (quick mode)'],
21
21
  ['--offset <number>', 'Skip N results (quick mode)'],
22
22
  ['--ndjson', 'Newline-delimited JSON output (quick mode)'],
23
23
  ],
@@ -7,6 +7,7 @@ export const command: CommandDefinition = {
7
7
  name: 'build [dir]',
8
8
  description: 'Parse repo and build graph in .codegraph/graph.db',
9
9
  options: [
10
+ ['-d, --db <path>', 'Path to graph.db (default: <dir>/.codegraph/graph.db)'],
10
11
  ['--no-incremental', 'Force full rebuild (ignore file hashes)'],
11
12
  ['--no-ast', 'Skip AST node extraction (calls, new, string, regex, throw, await)'],
12
13
  ['--no-complexity', 'Skip complexity metrics computation'],
@@ -23,6 +24,7 @@ export const command: CommandDefinition = {
23
24
  engine: engine as EngineMode,
24
25
  dataflow: opts.dataflow as boolean,
25
26
  cfg: opts.cfg as boolean,
27
+ dbPath: opts.db ? path.resolve(opts.db as string) : undefined,
26
28
  });
27
29
  },
28
30
  };
@@ -38,7 +38,7 @@ export const command: CommandDefinition = {
38
38
  ['-T, --no-tests', 'Exclude test/spec files from results'],
39
39
  ['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
40
40
  ['-j, --json', 'Output as JSON'],
41
- ['--limit <number>', 'Max results to return (manifesto mode)'],
41
+ ['-n, --limit <number>', 'Max results to return (manifesto mode)'],
42
42
  ['--offset <number>', 'Skip N results (manifesto mode)'],
43
43
  ['--ndjson', 'Newline-delimited JSON output (manifesto mode)'],
44
44
  ],
@@ -16,7 +16,7 @@ export const command: CommandDefinition = {
16
16
  ['-k, --kind <kind>', 'Filter to a specific symbol kind'],
17
17
  ['-T, --no-tests', 'Exclude test/spec files from results'],
18
18
  ['-j, --json', 'Output as JSON'],
19
- ['--limit <number>', 'Max results to return'],
19
+ ['-n, --limit <number>', 'Max results to return'],
20
20
  ['--offset <number>', 'Skip N results (default: 0)'],
21
21
  ],
22
22
  validate([_name], opts) {
@@ -8,7 +8,7 @@ export const command: CommandDefinition = {
8
8
  ['-d, --db <path>', 'Path to graph.db'],
9
9
  ['-T, --no-tests', 'Exclude test/spec files from results'],
10
10
  ['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
11
- ['--limit <number>', 'Max results to return'],
11
+ ['-n, --limit <number>', 'Max results to return'],
12
12
  ['--offset <number>', 'Skip N results (default: 0)'],
13
13
  ['--ndjson', 'Newline-delimited JSON output'],
14
14
  ['--staged', 'Analyze staged changes instead of unstaged'],
@@ -14,7 +14,7 @@ export const command: CommandDefinition = {
14
14
  ['-T, --no-tests', 'Exclude test/spec files'],
15
15
  ['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
16
16
  ['-j, --json', 'Output as JSON'],
17
- ['--limit <number>', 'Max results to return'],
17
+ ['-n, --limit <number>', 'Max results to return'],
18
18
  ['--offset <number>', 'Skip N results (default: 0)'],
19
19
  ['--ndjson', 'Newline-delimited JSON output'],
20
20
  ],
@@ -12,7 +12,7 @@ export const command: CommandDefinition = {
12
12
  ['-T, --no-tests', 'Exclude test/spec files'],
13
13
  ['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
14
14
  ['-j, --json', 'Output as JSON'],
15
- ['--limit <number>', 'Max results to return'],
15
+ ['-n, --limit <number>', 'Max results to return'],
16
16
  ['--offset <number>', 'Skip N results (default: 0)'],
17
17
  ['--ndjson', 'Newline-delimited JSON output'],
18
18
  ['--modules', 'Show module boundaries (directories with high cohesion)'],
@@ -13,7 +13,7 @@ export function applyQueryOpts(cmd: Command): Command {
13
13
  .option('-T, --no-tests', 'Exclude test/spec files from results')
14
14
  .option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
15
15
  .option('-j, --json', 'Output as JSON')
16
- .option('--limit <number>', 'Max results to return')
16
+ .option('-n, --limit <number>', 'Max results to return')
17
17
  .option('--offset <number>', 'Skip N results (default: 0)')
18
18
  .option('--ndjson', 'Newline-delimited JSON output')
19
19
  .option('--table', 'Output as aligned table')
@@ -292,6 +292,14 @@ export function findDbPath(customPath?: string): string {
292
292
  debug(`findDbPath: stopped at git ceiling ${ceiling}`);
293
293
  break;
294
294
  }
295
+ // Outside a git repo, cwd is the first (and only) directory we'll check.
296
+ // Walking past it risks attaching to a stale .codegraph/ in an unrelated
297
+ // parent — e.g. /private/tmp/.codegraph/ leaking into every /tmp/foo/ run,
298
+ // or $HOME/.codegraph/ leaking into every scratch dir under $HOME.
299
+ if (!ceiling) {
300
+ debug(`findDbPath: no git ceiling, stopping at ${dir}`);
301
+ break;
302
+ }
295
303
  const parent = path.dirname(dir);
296
304
  if (parent === dir) break;
297
305
  dir = parent;
@@ -28,6 +28,16 @@ export class PipelineContext {
28
28
  engineOpts!: EngineOpts;
29
29
  engineName!: 'native' | 'wasm';
30
30
  engineVersion!: string | null;
31
+ /**
32
+ * The version reported by the native binary itself (CARGO_PKG_VERSION at
33
+ * build time), as opposed to `engineVersion` which prefers the platform
34
+ * package.json. The Rust orchestrator's check_version_mismatch compares
35
+ * `build_meta.engine_version` against CARGO_PKG_VERSION, so build_meta
36
+ * writes must use this value to avoid a perpetual full-rebuild loop when
37
+ * the binary and platform package.json drift apart (e.g., CI hot-swap
38
+ * via ci-install-native.mjs — #1066).
39
+ */
40
+ nativeBinaryVersion!: string | null;
31
41
  aliases!: PathAliases;
32
42
  incremental!: boolean;
33
43
  forceFullRebuild: boolean = false;
@@ -222,12 +222,17 @@ export function fileHash(content: string): string {
222
222
  }
223
223
 
224
224
  /**
225
- * Stat a file, returning { mtimeMs, size } or null on error.
225
+ * Stat a file, returning { mtime, size } or null on error.
226
+ *
227
+ * `mtime` is `Math.floor(stat.mtimeMs)` so it matches the integer column
228
+ * stored in the DB. Floor-once-here keeps every consumer honest: storing or
229
+ * comparing a non-floored `mtimeMs` against the integer DB column would cause
230
+ * spurious fast-skip misses on the next build.
226
231
  */
227
- export function fileStat(filePath: string): { mtimeMs: number; size: number } | null {
232
+ export function fileStat(filePath: string): { mtime: number; size: number } | null {
228
233
  try {
229
234
  const s = fs.statSync(filePath);
230
- return { mtimeMs: s.mtimeMs, size: s.size };
235
+ return { mtime: Math.floor(s.mtimeMs), size: s.size };
231
236
  } catch {
232
237
  return null;
233
238
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
- import { bulkNodeIdsByFile } from '../../../db/index.js';
12
+ import { bulkNodeIdsByFile, purgeFileData } from '../../../db/index.js';
13
13
  import { debug, warn } from '../../../infrastructure/logger.js';
14
14
  import { normalizePath } from '../../../shared/constants.js';
15
15
  import type {
@@ -29,8 +29,6 @@ export interface IncrementalStmts {
29
29
  insertNode: { run: (...params: unknown[]) => unknown };
30
30
  insertEdge: { run: (...params: unknown[]) => unknown };
31
31
  getNodeId: { get: (...params: unknown[]) => { id: number } | undefined };
32
- deleteEdgesForFile: { run: (...params: unknown[]) => unknown };
33
- deleteNodes: { run: (...params: unknown[]) => unknown };
34
32
  countNodes: { get: (...params: unknown[]) => { c: number } | undefined };
35
33
  listSymbols: { all: (...params: unknown[]) => unknown[] };
36
34
  findNodeInFile: { all: (...params: unknown[]) => unknown[] };
@@ -208,40 +206,6 @@ function rebuildDirContainment(
208
206
  return 0;
209
207
  }
210
208
 
211
- // ── Ancillary table cleanup ────────────────────────────────────────────
212
-
213
- function purgeAncillaryData(db: BetterSqlite3Database, relPath: string): void {
214
- const tryExec = (sql: string, ...args: string[]): void => {
215
- try {
216
- db.prepare(sql).run(...args);
217
- } catch (err: unknown) {
218
- if (!(err as Error | undefined)?.message?.includes('no such table')) throw err;
219
- }
220
- };
221
- tryExec(
222
- 'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
223
- relPath,
224
- );
225
- tryExec(
226
- 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)',
227
- relPath,
228
- );
229
- tryExec(
230
- 'DELETE FROM cfg_edges WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
231
- relPath,
232
- );
233
- tryExec(
234
- 'DELETE FROM cfg_blocks WHERE function_node_id IN (SELECT id FROM nodes WHERE file = ?)',
235
- relPath,
236
- );
237
- tryExec(
238
- 'DELETE FROM dataflow WHERE source_id IN (SELECT id FROM nodes WHERE file = ?) OR target_id IN (SELECT id FROM nodes WHERE file = ?)',
239
- relPath,
240
- relPath,
241
- );
242
- tryExec('DELETE FROM ast_nodes WHERE file = ?', relPath);
243
- }
244
-
245
209
  // ── Import edge building ────────────────────────────────────────────────
246
210
 
247
211
  // Lazily-cached prepared statements for barrel resolution (avoid re-preparing in hot loops)
@@ -547,10 +511,11 @@ export async function rebuildFile(
547
511
  // Find reverse-deps BEFORE purging (edges still reference the old nodes)
548
512
  const reverseDeps = findReverseDeps(db, relPath);
549
513
 
550
- // Purge ancillary tables, then edges, then nodes
551
- purgeAncillaryData(db, relPath);
552
- stmts.deleteEdgesForFile.run(relPath);
553
- stmts.deleteNodes.run(relPath);
514
+ // Purge ancillary tables (incl. embeddings), edges, and nodes in one pass.
515
+ // Embeddings must be purged before nodes — better-sqlite3 enforces foreign
516
+ // keys by default, and `embeddings.node_id` references `nodes.id`. Issue #1176.
517
+ // `purgeHashes: false` preserves file_hashes for the next incremental build.
518
+ purgeFileData(db, relPath, { purgeHashes: false });
554
519
 
555
520
  if (!fs.existsSync(filePath)) {
556
521
  if (cache) (cache as { remove(p: string): void }).remove(filePath);