@optave/codegraph 3.10.0 → 3.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/README.md +40 -33
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +91 -60
  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/visitor-utils.d.ts +3 -0
  9. package/dist/ast-analysis/visitor-utils.d.ts.map +1 -1
  10. package/dist/ast-analysis/visitor-utils.js +83 -49
  11. package/dist/ast-analysis/visitor-utils.js.map +1 -1
  12. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  13. package/dist/ast-analysis/visitors/ast-store-visitor.js +78 -62
  14. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  15. package/dist/ast-analysis/visitors/dataflow-visitor.d.ts.map +1 -1
  16. package/dist/ast-analysis/visitors/dataflow-visitor.js +61 -42
  17. package/dist/ast-analysis/visitors/dataflow-visitor.js.map +1 -1
  18. package/dist/cli/commands/audit.js +1 -1
  19. package/dist/cli/commands/audit.js.map +1 -1
  20. package/dist/cli/commands/build.d.ts.map +1 -1
  21. package/dist/cli/commands/build.js +2 -0
  22. package/dist/cli/commands/build.js.map +1 -1
  23. package/dist/cli/commands/check.js +1 -1
  24. package/dist/cli/commands/check.js.map +1 -1
  25. package/dist/cli/commands/children.js +1 -1
  26. package/dist/cli/commands/children.js.map +1 -1
  27. package/dist/cli/commands/diff-impact.js +1 -1
  28. package/dist/cli/commands/diff-impact.js.map +1 -1
  29. package/dist/cli/commands/embed.d.ts.map +1 -1
  30. package/dist/cli/commands/embed.js +49 -4
  31. package/dist/cli/commands/embed.js.map +1 -1
  32. package/dist/cli/commands/roles.js +1 -1
  33. package/dist/cli/commands/roles.js.map +1 -1
  34. package/dist/cli/commands/structure.js +1 -1
  35. package/dist/cli/commands/structure.js.map +1 -1
  36. package/dist/cli/shared/options.js +1 -1
  37. package/dist/cli/shared/options.js.map +1 -1
  38. package/dist/db/connection.d.ts.map +1 -1
  39. package/dist/db/connection.js +8 -0
  40. package/dist/db/connection.js.map +1 -1
  41. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  42. package/dist/domain/analysis/dependencies.js +106 -80
  43. package/dist/domain/analysis/dependencies.js.map +1 -1
  44. package/dist/domain/analysis/fn-impact.d.ts.map +1 -1
  45. package/dist/domain/analysis/fn-impact.js +77 -52
  46. package/dist/domain/analysis/fn-impact.js.map +1 -1
  47. package/dist/domain/analysis/module-map.d.ts.map +1 -1
  48. package/dist/domain/analysis/module-map.js +132 -121
  49. package/dist/domain/analysis/module-map.js.map +1 -1
  50. package/dist/domain/graph/builder/helpers.d.ts +4 -4
  51. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  52. package/dist/domain/graph/builder/helpers.js +47 -33
  53. package/dist/domain/graph/builder/helpers.js.map +1 -1
  54. package/dist/domain/graph/builder/incremental.d.ts +6 -6
  55. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  56. package/dist/domain/graph/builder/incremental.js +148 -99
  57. package/dist/domain/graph/builder/incremental.js.map +1 -1
  58. package/dist/domain/graph/builder/pipeline.d.ts +1 -0
  59. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  60. package/dist/domain/graph/builder/pipeline.js +23 -637
  61. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  62. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  63. package/dist/domain/graph/builder/stages/build-edges.js +141 -98
  64. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  65. package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
  66. package/dist/domain/graph/builder/stages/build-structure.js +82 -65
  67. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  68. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  69. package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
  70. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  71. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  72. package/dist/domain/graph/builder/stages/finalize.js +60 -51
  73. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  74. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
  75. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  76. package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
  77. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  78. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
  79. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
  80. package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
  81. package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
  82. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
  83. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
  84. package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
  85. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
  86. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  87. package/dist/domain/graph/builder/stages/resolve-imports.js +73 -22
  88. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  89. package/dist/domain/graph/cycles.d.ts +6 -4
  90. package/dist/domain/graph/cycles.d.ts.map +1 -1
  91. package/dist/domain/graph/cycles.js +50 -55
  92. package/dist/domain/graph/cycles.js.map +1 -1
  93. package/dist/domain/graph/journal.d.ts.map +1 -1
  94. package/dist/domain/graph/journal.js +89 -70
  95. package/dist/domain/graph/journal.js.map +1 -1
  96. package/dist/domain/graph/watcher.d.ts.map +1 -1
  97. package/dist/domain/graph/watcher.js +28 -20
  98. package/dist/domain/graph/watcher.js.map +1 -1
  99. package/dist/domain/parser.d.ts +12 -23
  100. package/dist/domain/parser.d.ts.map +1 -1
  101. package/dist/domain/parser.js +153 -80
  102. package/dist/domain/parser.js.map +1 -1
  103. package/dist/domain/search/generator.d.ts +3 -1
  104. package/dist/domain/search/generator.d.ts.map +1 -1
  105. package/dist/domain/search/generator.js +68 -45
  106. package/dist/domain/search/generator.js.map +1 -1
  107. package/dist/domain/search/models.d.ts +18 -0
  108. package/dist/domain/search/models.d.ts.map +1 -1
  109. package/dist/domain/search/models.js +72 -4
  110. package/dist/domain/search/models.js.map +1 -1
  111. package/dist/domain/search/search/hybrid.d.ts.map +1 -1
  112. package/dist/domain/search/search/hybrid.js +49 -40
  113. package/dist/domain/search/search/hybrid.js.map +1 -1
  114. package/dist/domain/search/search/semantic.d.ts.map +1 -1
  115. package/dist/domain/search/search/semantic.js +69 -49
  116. package/dist/domain/search/search/semantic.js.map +1 -1
  117. package/dist/domain/wasm-worker-entry.js +209 -137
  118. package/dist/domain/wasm-worker-entry.js.map +1 -1
  119. package/dist/extractors/c.js +25 -6
  120. package/dist/extractors/c.js.map +1 -1
  121. package/dist/extractors/cpp.js +47 -6
  122. package/dist/extractors/cpp.js.map +1 -1
  123. package/dist/extractors/cuda.js +90 -14
  124. package/dist/extractors/cuda.js.map +1 -1
  125. package/dist/extractors/elixir.js +108 -4
  126. package/dist/extractors/elixir.js.map +1 -1
  127. package/dist/extractors/erlang.js +56 -20
  128. package/dist/extractors/erlang.js.map +1 -1
  129. package/dist/extractors/fsharp.d.ts +7 -0
  130. package/dist/extractors/fsharp.d.ts.map +1 -1
  131. package/dist/extractors/fsharp.js +94 -0
  132. package/dist/extractors/fsharp.js.map +1 -1
  133. package/dist/extractors/gleam.d.ts.map +1 -1
  134. package/dist/extractors/gleam.js +29 -33
  135. package/dist/extractors/gleam.js.map +1 -1
  136. package/dist/extractors/groovy.js +41 -1
  137. package/dist/extractors/groovy.js.map +1 -1
  138. package/dist/extractors/haskell.js +48 -4
  139. package/dist/extractors/haskell.js.map +1 -1
  140. package/dist/extractors/helpers.d.ts +79 -1
  141. package/dist/extractors/helpers.d.ts.map +1 -1
  142. package/dist/extractors/helpers.js +137 -0
  143. package/dist/extractors/helpers.js.map +1 -1
  144. package/dist/extractors/java.d.ts.map +1 -1
  145. package/dist/extractors/java.js +37 -49
  146. package/dist/extractors/java.js.map +1 -1
  147. package/dist/extractors/javascript.d.ts.map +1 -1
  148. package/dist/extractors/javascript.js +44 -44
  149. package/dist/extractors/javascript.js.map +1 -1
  150. package/dist/extractors/julia.js +198 -74
  151. package/dist/extractors/julia.js.map +1 -1
  152. package/dist/extractors/kotlin.js +4 -0
  153. package/dist/extractors/kotlin.js.map +1 -1
  154. package/dist/extractors/objc.js +184 -47
  155. package/dist/extractors/objc.js.map +1 -1
  156. package/dist/extractors/python.js +7 -4
  157. package/dist/extractors/python.js.map +1 -1
  158. package/dist/extractors/r.d.ts.map +1 -1
  159. package/dist/extractors/r.js +103 -87
  160. package/dist/extractors/r.js.map +1 -1
  161. package/dist/extractors/scala.d.ts.map +1 -1
  162. package/dist/extractors/scala.js +18 -32
  163. package/dist/extractors/scala.js.map +1 -1
  164. package/dist/extractors/solidity.d.ts.map +1 -1
  165. package/dist/extractors/solidity.js +55 -69
  166. package/dist/extractors/solidity.js.map +1 -1
  167. package/dist/extractors/verilog.js +80 -15
  168. package/dist/extractors/verilog.js.map +1 -1
  169. package/dist/features/boundaries.d.ts.map +1 -1
  170. package/dist/features/boundaries.js +49 -39
  171. package/dist/features/boundaries.js.map +1 -1
  172. package/dist/features/cfg.d.ts.map +1 -1
  173. package/dist/features/cfg.js +90 -63
  174. package/dist/features/cfg.js.map +1 -1
  175. package/dist/features/check.d.ts.map +1 -1
  176. package/dist/features/check.js +43 -34
  177. package/dist/features/check.js.map +1 -1
  178. package/dist/features/cochange.d.ts.map +1 -1
  179. package/dist/features/cochange.js +68 -56
  180. package/dist/features/cochange.js.map +1 -1
  181. package/dist/features/complexity.d.ts.map +1 -1
  182. package/dist/features/complexity.js +105 -75
  183. package/dist/features/complexity.js.map +1 -1
  184. package/dist/features/dataflow.d.ts.map +1 -1
  185. package/dist/features/dataflow.js +37 -29
  186. package/dist/features/dataflow.js.map +1 -1
  187. package/dist/features/flow.d.ts.map +1 -1
  188. package/dist/features/flow.js +31 -22
  189. package/dist/features/flow.js.map +1 -1
  190. package/dist/features/graph-enrichment.d.ts.map +1 -1
  191. package/dist/features/graph-enrichment.js +77 -70
  192. package/dist/features/graph-enrichment.js.map +1 -1
  193. package/dist/features/owners.d.ts +17 -26
  194. package/dist/features/owners.d.ts.map +1 -1
  195. package/dist/features/owners.js +120 -109
  196. package/dist/features/owners.js.map +1 -1
  197. package/dist/features/sequence.d.ts.map +1 -1
  198. package/dist/features/sequence.js +59 -54
  199. package/dist/features/sequence.js.map +1 -1
  200. package/dist/features/structure-query.d.ts.map +1 -1
  201. package/dist/features/structure-query.js +60 -60
  202. package/dist/features/structure-query.js.map +1 -1
  203. package/dist/features/structure.js +28 -36
  204. package/dist/features/structure.js.map +1 -1
  205. package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
  206. package/dist/graph/algorithms/leiden/optimiser.js +100 -69
  207. package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
  208. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  209. package/dist/graph/classifiers/roles.js +63 -59
  210. package/dist/graph/classifiers/roles.js.map +1 -1
  211. package/dist/infrastructure/config.d.ts +1 -1
  212. package/dist/infrastructure/config.d.ts.map +1 -1
  213. package/dist/infrastructure/config.js +1 -1
  214. package/dist/infrastructure/config.js.map +1 -1
  215. package/dist/mcp/tool-registry.d.ts.map +1 -1
  216. package/dist/mcp/tool-registry.js +4 -0
  217. package/dist/mcp/tool-registry.js.map +1 -1
  218. package/dist/mcp/tools/semantic-search.d.ts +1 -0
  219. package/dist/mcp/tools/semantic-search.d.ts.map +1 -1
  220. package/dist/mcp/tools/semantic-search.js +1 -0
  221. package/dist/mcp/tools/semantic-search.js.map +1 -1
  222. package/dist/presentation/cfg.d.ts.map +1 -1
  223. package/dist/presentation/cfg.js +44 -29
  224. package/dist/presentation/cfg.js.map +1 -1
  225. package/dist/presentation/flow.d.ts.map +1 -1
  226. package/dist/presentation/flow.js +58 -38
  227. package/dist/presentation/flow.js.map +1 -1
  228. package/dist/types.d.ts +16 -2
  229. package/dist/types.d.ts.map +1 -1
  230. package/grammars/tree-sitter-erlang.wasm +0 -0
  231. package/grammars/tree-sitter-fsharp.wasm +0 -0
  232. package/grammars/tree-sitter-fsharp_signature.wasm +0 -0
  233. package/grammars/tree-sitter-gleam.wasm +0 -0
  234. package/package.json +10 -10
  235. package/src/ast-analysis/engine.ts +145 -61
  236. package/src/ast-analysis/rules/index.ts +87 -0
  237. package/src/ast-analysis/visitor-utils.ts +86 -46
  238. package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
  239. package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
  240. package/src/cli/commands/audit.ts +1 -1
  241. package/src/cli/commands/build.ts +2 -0
  242. package/src/cli/commands/check.ts +1 -1
  243. package/src/cli/commands/children.ts +1 -1
  244. package/src/cli/commands/diff-impact.ts +1 -1
  245. package/src/cli/commands/embed.ts +54 -4
  246. package/src/cli/commands/roles.ts +1 -1
  247. package/src/cli/commands/structure.ts +1 -1
  248. package/src/cli/shared/options.ts +1 -1
  249. package/src/db/connection.ts +8 -0
  250. package/src/domain/analysis/dependencies.ts +166 -85
  251. package/src/domain/analysis/fn-impact.ts +120 -50
  252. package/src/domain/analysis/module-map.ts +175 -140
  253. package/src/domain/graph/builder/helpers.ts +85 -76
  254. package/src/domain/graph/builder/incremental.ts +223 -131
  255. package/src/domain/graph/builder/pipeline.ts +32 -785
  256. package/src/domain/graph/builder/stages/build-edges.ts +207 -142
  257. package/src/domain/graph/builder/stages/build-structure.ts +115 -82
  258. package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
  259. package/src/domain/graph/builder/stages/finalize.ts +72 -70
  260. package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
  261. package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
  262. package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
  263. package/src/domain/graph/builder/stages/resolve-imports.ts +79 -25
  264. package/src/domain/graph/cycles.ts +51 -49
  265. package/src/domain/graph/journal.ts +84 -69
  266. package/src/domain/graph/watcher.ts +29 -25
  267. package/src/domain/parser.ts +170 -67
  268. package/src/domain/search/generator.ts +132 -74
  269. package/src/domain/search/models.ts +75 -4
  270. package/src/domain/search/search/hybrid.ts +53 -42
  271. package/src/domain/search/search/semantic.ts +105 -65
  272. package/src/domain/wasm-worker-entry.ts +243 -153
  273. package/src/extractors/c.ts +27 -8
  274. package/src/extractors/cpp.ts +50 -8
  275. package/src/extractors/cuda.ts +90 -16
  276. package/src/extractors/elixir.ts +103 -4
  277. package/src/extractors/erlang.ts +63 -20
  278. package/src/extractors/fsharp.ts +104 -0
  279. package/src/extractors/gleam.ts +40 -39
  280. package/src/extractors/groovy.ts +45 -1
  281. package/src/extractors/haskell.ts +45 -4
  282. package/src/extractors/helpers.ts +205 -1
  283. package/src/extractors/java.ts +42 -45
  284. package/src/extractors/javascript.ts +44 -43
  285. package/src/extractors/julia.ts +191 -77
  286. package/src/extractors/kotlin.ts +4 -0
  287. package/src/extractors/objc.ts +171 -47
  288. package/src/extractors/python.ts +5 -3
  289. package/src/extractors/r.ts +104 -82
  290. package/src/extractors/scala.ts +24 -36
  291. package/src/extractors/solidity.ts +59 -78
  292. package/src/extractors/verilog.ts +83 -15
  293. package/src/features/boundaries.ts +64 -46
  294. package/src/features/cfg.ts +145 -74
  295. package/src/features/check.ts +60 -43
  296. package/src/features/cochange.ts +95 -72
  297. package/src/features/complexity.ts +134 -79
  298. package/src/features/dataflow.ts +57 -34
  299. package/src/features/flow.ts +48 -24
  300. package/src/features/graph-enrichment.ts +105 -70
  301. package/src/features/owners.ts +186 -146
  302. package/src/features/sequence.ts +99 -69
  303. package/src/features/structure-query.ts +94 -79
  304. package/src/features/structure.ts +56 -56
  305. package/src/graph/algorithms/leiden/optimiser.ts +142 -87
  306. package/src/graph/classifiers/roles.ts +64 -54
  307. package/src/infrastructure/config.ts +1 -1
  308. package/src/mcp/tool-registry.ts +5 -0
  309. package/src/mcp/tools/semantic-search.ts +2 -0
  310. package/src/presentation/cfg.ts +48 -32
  311. package/src/presentation/flow.ts +100 -52
  312. package/src/types.ts +16 -1
@@ -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
 
@@ -3,6 +3,45 @@ import { loadNative } from '../../infrastructure/native.js';
3
3
  import { isTestFile } from '../../infrastructure/test-filter.js';
4
4
  import type { BetterSqlite3Database } from '../../types.js';
5
5
 
6
+ type Edge = { source: string; target: string };
7
+ type DbEdge = { source_id: number; target_id: number };
8
+
9
+ /**
10
+ * Build a label-based edge list from DB rows, filtering to known nodes and
11
+ * deduplicating. Self-loops are skipped (Tarjan treats them as trivial SCCs).
12
+ */
13
+ function buildLabelEdges(dbEdges: DbEdge[], idToLabel: Map<number, string>): Edge[] {
14
+ const edges: Edge[] = [];
15
+ const seen = new Set<string>();
16
+ for (const e of dbEdges) {
17
+ if (e.source_id === e.target_id) continue;
18
+ const src = idToLabel.get(e.source_id);
19
+ const tgt = idToLabel.get(e.target_id);
20
+ if (src === undefined || tgt === undefined) continue;
21
+ const key = `${src}\0${tgt}`;
22
+ if (seen.has(key)) continue;
23
+ seen.add(key);
24
+ edges.push({ source: src, target: tgt });
25
+ }
26
+ return edges;
27
+ }
28
+
29
+ function buildFileLevelEdges(db: BetterSqlite3Database, noTests: boolean): Edge[] {
30
+ let nodes = getFileNodesAll(db);
31
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
32
+ const idToLabel = new Map<number, string>();
33
+ for (const n of nodes) idToLabel.set(n.id, n.file);
34
+ return buildLabelEdges(getImportEdges(db), idToLabel);
35
+ }
36
+
37
+ function buildCallableEdges(db: BetterSqlite3Database, noTests: boolean): Edge[] {
38
+ let nodes = getCallableNodes(db);
39
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
40
+ const idToLabel = new Map<number, string>();
41
+ for (const n of nodes) idToLabel.set(n.id, `${n.name}|${n.file}`);
42
+ return buildLabelEdges(getCallEdges(db), idToLabel);
43
+ }
44
+
6
45
  /**
7
46
  * Find cycles using Tarjan's SCC algorithm.
8
47
  *
@@ -16,66 +55,20 @@ export function findCycles(
16
55
  const fileLevel = opts.fileLevel !== false;
17
56
  const noTests = opts.noTests || false;
18
57
 
19
- const edges: Array<{ source: string; target: string }> = [];
20
- const seen = new Set<string>();
21
-
22
- if (fileLevel) {
23
- let nodes = getFileNodesAll(db);
24
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
25
- const nodeIds = new Set<number>();
26
- const idToFile = new Map<number, string>();
27
- for (const n of nodes) {
28
- nodeIds.add(n.id);
29
- idToFile.set(n.id, n.file);
30
- }
31
- for (const e of getImportEdges(db)) {
32
- if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
33
- if (e.source_id === e.target_id) continue;
34
- const src = idToFile.get(e.source_id)!;
35
- const tgt = idToFile.get(e.target_id)!;
36
- const key = `${src}\0${tgt}`;
37
- if (seen.has(key)) continue;
38
- seen.add(key);
39
- edges.push({ source: src, target: tgt });
40
- }
41
- } else {
42
- let nodes = getCallableNodes(db);
43
- if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
44
- const nodeIds = new Set<number>();
45
- const idToLabel = new Map<number, string>();
46
- for (const n of nodes) {
47
- nodeIds.add(n.id);
48
- idToLabel.set(n.id, `${n.name}|${n.file}`);
49
- }
50
- for (const e of getCallEdges(db)) {
51
- if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
52
- if (e.source_id === e.target_id) continue;
53
- const src = idToLabel.get(e.source_id)!;
54
- const tgt = idToLabel.get(e.target_id)!;
55
- const key = `${src}\0${tgt}`;
56
- if (seen.has(key)) continue;
57
- seen.add(key);
58
- edges.push({ source: src, target: tgt });
59
- }
60
- }
58
+ const edges = fileLevel ? buildFileLevelEdges(db, noTests) : buildCallableEdges(db, noTests);
61
59
 
62
60
  const native = loadNative();
63
61
  if (native) {
64
62
  return native.detectCycles(edges) as string[][];
65
63
  }
66
-
67
64
  return tarjanFromEdges(edges);
68
65
  }
69
66
 
70
- export function findCyclesJS(edges: Array<{ source: string; target: string }>): string[][] {
67
+ export function findCyclesJS(edges: Edge[]): string[][] {
71
68
  return tarjanFromEdges(edges);
72
69
  }
73
70
 
74
- /**
75
- * Run Tarjan's SCC on a flat edge list. Returns SCCs with length > 1 (cycles).
76
- * Uses a simple adjacency-list Map instead of a full CodeGraph.
77
- */
78
- function tarjanFromEdges(edges: Array<{ source: string; target: string }>): string[][] {
71
+ function buildAdjacency(edges: Edge[]): { adj: Map<string, string[]>; allNodes: Set<string> } {
79
72
  const adj = new Map<string, string[]>();
80
73
  const allNodes = new Set<string>();
81
74
  for (const { source, target } of edges) {
@@ -88,6 +81,15 @@ function tarjanFromEdges(edges: Array<{ source: string; target: string }>): stri
88
81
  }
89
82
  list.push(target);
90
83
  }
84
+ return { adj, allNodes };
85
+ }
86
+
87
+ /**
88
+ * Run Tarjan's SCC on a flat edge list. Returns SCCs with length > 1 (cycles).
89
+ * Uses a simple adjacency-list Map instead of a full CodeGraph.
90
+ */
91
+ function tarjanFromEdges(edges: Edge[]): string[][] {
92
+ const { adj, allNodes } = buildAdjacency(edges);
91
93
 
92
94
  let index = 0;
93
95
  const stack: string[] = [];
@@ -91,62 +91,69 @@ function trySteal(lockPath: string): AcquiredLock | null {
91
91
  return { fd, nonce };
92
92
  }
93
93
 
94
- function acquireJournalLock(lockPath: string): AcquiredLock {
95
- const start = Date.now();
96
- for (;;) {
97
- const nonce = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
94
+ /**
95
+ * Try to create the lockfile fresh via `wx`. Returns the acquired lock on
96
+ * success, `null` if another holder exists, or throws on unexpected errors.
97
+ *
98
+ * If the stamp write fails (ENOSPC, I/O error) we release the empty file —
99
+ * leaving it would look stale to concurrent waiters and admit double-acquire.
100
+ */
101
+ function tryFreshAcquire(lockPath: string): AcquiredLock | null {
102
+ const nonce = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
103
+ let fd: number;
104
+ try {
105
+ fd = fs.openSync(lockPath, 'wx');
106
+ } catch (e) {
107
+ if ((e as NodeJS.ErrnoException).code === 'EEXIST') return null;
108
+ throw e;
109
+ }
110
+ try {
111
+ fs.writeSync(fd, `${process.pid}\n${nonce}\n`);
112
+ } catch {
98
113
  try {
99
- const fd = fs.openSync(lockPath, 'wx');
100
- try {
101
- fs.writeSync(fd, `${process.pid}\n${nonce}\n`);
102
- } catch {
103
- // Stamp write failed (ENOSPC, I/O error). An empty lockfile would
104
- // look stale to concurrent waiters (Number('') === 0, isPidAlive(0)
105
- // returns false), so they'd steal our live lock. Release and retry.
106
- try {
107
- fs.closeSync(fd);
108
- } catch {
109
- /* ignore */
110
- }
111
- try {
112
- fs.unlinkSync(lockPath);
113
- } catch {
114
- /* ignore */
115
- }
116
- if (Date.now() - start > LOCK_TIMEOUT_MS) {
117
- throw new Error(
118
- `Failed to acquire journal lock at ${lockPath} within ${LOCK_TIMEOUT_MS}ms`,
119
- );
120
- }
121
- sleepSync(LOCK_RETRY_MS);
122
- continue;
123
- }
124
- return { fd, nonce };
125
- } catch (e) {
126
- if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
114
+ fs.closeSync(fd);
115
+ } catch {
116
+ /* ignore */
127
117
  }
128
-
129
- let holderAlive = true;
130
118
  try {
131
- const pidContent = fs.readFileSync(lockPath, 'utf-8').split('\n')[0]!.trim();
132
- holderAlive = isPidAlive(Number(pidContent));
119
+ fs.unlinkSync(lockPath);
133
120
  } catch {
134
- /* unreadable — fall through to age check */
121
+ /* ignore */
135
122
  }
123
+ return null;
124
+ }
125
+ return { fd, nonce };
126
+ }
136
127
 
137
- let shouldSteal = !holderAlive;
138
- if (holderAlive) {
139
- try {
140
- const stat = fs.statSync(lockPath);
141
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
142
- shouldSteal = true;
143
- }
144
- } catch {
145
- /* stat failed keep retrying */
146
- }
147
- }
128
+ /**
129
+ * Decide whether the current lock holder is stale and should be stolen.
130
+ * Returns true if the PID is dead, or if the lockfile mtime exceeds the
131
+ * staleness threshold.
132
+ */
133
+ function isLockStale(lockPath: string): boolean {
134
+ let holderAlive = true;
135
+ try {
136
+ const pidContent = fs.readFileSync(lockPath, 'utf-8').split('\n')[0]!.trim();
137
+ holderAlive = isPidAlive(Number(pidContent));
138
+ } catch {
139
+ /* unreadable — fall through to age check */
140
+ }
141
+ if (!holderAlive) return true;
142
+ try {
143
+ const stat = fs.statSync(lockPath);
144
+ return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
148
149
 
149
- if (shouldSteal) {
150
+ function acquireJournalLock(lockPath: string): AcquiredLock {
151
+ const start = Date.now();
152
+ for (;;) {
153
+ const fresh = tryFreshAcquire(lockPath);
154
+ if (fresh) return fresh;
155
+
156
+ if (isLockStale(lockPath)) {
150
157
  const stolen = trySteal(lockPath);
151
158
  if (stolen) return stolen;
152
159
  // Steal failed or lost the race — fall through to timeout check & retry.
@@ -227,27 +234,20 @@ interface JournalResult {
227
234
  removed?: string[];
228
235
  }
229
236
 
230
- export function readJournal(rootDir: string): JournalResult {
231
- const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
232
- let content: string;
233
- try {
234
- content = fs.readFileSync(journalPath, 'utf-8');
235
- } catch {
236
- return { valid: false };
237
- }
238
-
239
- const lines = content.split('\n');
240
- if (lines.length === 0 || !lines[0]!.startsWith(HEADER_PREFIX)) {
237
+ function parseJournalHeader(firstLine: string | undefined): number | null {
238
+ if (!firstLine || !firstLine.startsWith(HEADER_PREFIX)) {
241
239
  debug('Journal has malformed or missing header');
242
- return { valid: false };
240
+ return null;
243
241
  }
244
-
245
- const timestamp = Number(lines[0]!.slice(HEADER_PREFIX.length).trim());
242
+ const timestamp = Number(firstLine.slice(HEADER_PREFIX.length).trim());
246
243
  if (!Number.isFinite(timestamp) || timestamp <= 0) {
247
244
  debug('Journal has invalid timestamp');
248
- return { valid: false };
245
+ return null;
249
246
  }
247
+ return timestamp;
248
+ }
250
249
 
250
+ function parseJournalBody(lines: string[]): { changed: string[]; removed: string[] } {
251
251
  const changed: string[] = [];
252
252
  const removed: string[] = [];
253
253
  const seenChanged = new Set<string>();
@@ -263,14 +263,29 @@ export function readJournal(rootDir: string): JournalResult {
263
263
  seenRemoved.add(filePath);
264
264
  removed.push(filePath);
265
265
  }
266
- } else {
267
- if (!seenChanged.has(line)) {
268
- seenChanged.add(line);
269
- changed.push(line);
270
- }
266
+ } else if (!seenChanged.has(line)) {
267
+ seenChanged.add(line);
268
+ changed.push(line);
271
269
  }
272
270
  }
273
271
 
272
+ return { changed, removed };
273
+ }
274
+
275
+ export function readJournal(rootDir: string): JournalResult {
276
+ const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
277
+ let content: string;
278
+ try {
279
+ content = fs.readFileSync(journalPath, 'utf-8');
280
+ } catch {
281
+ return { valid: false };
282
+ }
283
+
284
+ const lines = content.split('\n');
285
+ const timestamp = parseJournalHeader(lines[0]);
286
+ if (timestamp === null) return { valid: false };
287
+
288
+ const { changed, removed } = parseJournalBody(lines);
274
289
  return { valid: true, timestamp, changed, removed };
275
290
  }
276
291
 
@@ -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,10 @@ 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,
34
+ countEdges: db.prepare(
35
+ 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
36
+ ),
36
37
  findNodeInFile: db.prepare(
37
38
  "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?",
38
39
  ),
@@ -41,19 +42,6 @@ function prepareWatcherStatements(db: ReturnType<typeof openDb>): IncrementalStm
41
42
  ),
42
43
  listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"),
43
44
  };
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
45
  }
58
46
 
59
47
  /** Rebuild result shape from rebuildFile. */
@@ -67,6 +55,7 @@ interface RebuildResult {
67
55
  nodesAdded: number;
68
56
  nodesRemoved: number;
69
57
  edgesAdded: number;
58
+ edgesBefore: number;
70
59
  }
71
60
 
72
61
  /** Process a batch of pending file changes: rebuild, journal, and log. */
@@ -80,10 +69,23 @@ async function processPendingFiles(
80
69
  ): Promise<void> {
81
70
  const results: RebuildResult[] = [];
82
71
  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);
72
+ // Per-file try/catch so one bad rebuild doesn't crash the watcher loop.
73
+ // The watcher is a long-running session any SQLite error, parse failure,
74
+ // or filesystem race must be reported and skipped, not propagated. Issue #1176.
75
+ try {
76
+ const result = (await rebuildFile(db, rootDir, filePath, stmts, engineOpts, cache, {
77
+ diffSymbols: diffSymbols as (old: unknown[], new_: unknown[]) => unknown,
78
+ })) as RebuildResult | null;
79
+ if (result) results.push(result);
80
+ } catch (err: unknown) {
81
+ const relPath = normalizePath(path.relative(rootDir, filePath));
82
+ // Narrow with `instanceof` instead of casting: a non-Error throw (a plain
83
+ // string, `null`, or any value a third-party dependency throws) would log
84
+ // `(err as Error).message` as `undefined`. See Greptile review on #1182.
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ warn(`Failed to rebuild ${relPath}: ${message} — skipping`);
87
+ debug(err instanceof Error ? (err.stack ?? message) : String(err));
88
+ }
87
89
  }
88
90
 
89
91
  if (results.length > 0) {
@@ -109,7 +111,7 @@ function writeJournalAndChangeEvents(rootDir: string, updates: RebuildResult[]):
109
111
  buildChangeEvent(r.file, r.event, r.symbolDiff, {
110
112
  nodesBefore: r.nodesBefore,
111
113
  nodesAfter: r.nodesAfter,
112
- edgesAdded: r.edgesAdded,
114
+ edgesAdded: r.edgesAdded - r.edgesBefore,
113
115
  }),
114
116
  );
115
117
  try {
@@ -127,7 +129,9 @@ function logRebuildResults(updates: RebuildResult[]): void {
127
129
  if (r.deleted) {
128
130
  info(`Removed: ${r.file} (-${r.nodesRemoved} nodes)`);
129
131
  } else {
130
- info(`Updated: ${r.file} (${nodeStr} nodes, +${r.edgesAdded} edges)`);
132
+ const edgeDelta = r.edgesAdded - r.edgesBefore;
133
+ const edgeStr = edgeDelta >= 0 ? `+${edgeDelta}` : `${edgeDelta}`;
134
+ info(`Updated: ${r.file} (${nodeStr} nodes, ${edgeStr} edges)`);
131
135
  }
132
136
  }
133
137
  }