@optave/codegraph 3.11.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 (223) hide show
  1. package/README.md +38 -31
  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/visitor-utils.d.ts +3 -0
  6. package/dist/ast-analysis/visitor-utils.d.ts.map +1 -1
  7. package/dist/ast-analysis/visitor-utils.js +83 -49
  8. package/dist/ast-analysis/visitor-utils.js.map +1 -1
  9. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  10. package/dist/ast-analysis/visitors/ast-store-visitor.js +78 -62
  11. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  12. package/dist/ast-analysis/visitors/dataflow-visitor.d.ts.map +1 -1
  13. package/dist/ast-analysis/visitors/dataflow-visitor.js +61 -42
  14. package/dist/ast-analysis/visitors/dataflow-visitor.js.map +1 -1
  15. package/dist/cli/commands/embed.d.ts.map +1 -1
  16. package/dist/cli/commands/embed.js +49 -4
  17. package/dist/cli/commands/embed.js.map +1 -1
  18. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  19. package/dist/domain/analysis/dependencies.js +106 -80
  20. package/dist/domain/analysis/dependencies.js.map +1 -1
  21. package/dist/domain/analysis/fn-impact.d.ts.map +1 -1
  22. package/dist/domain/analysis/fn-impact.js +77 -52
  23. package/dist/domain/analysis/fn-impact.js.map +1 -1
  24. package/dist/domain/analysis/module-map.d.ts.map +1 -1
  25. package/dist/domain/analysis/module-map.js +132 -121
  26. package/dist/domain/analysis/module-map.js.map +1 -1
  27. package/dist/domain/graph/builder/helpers.d.ts +4 -4
  28. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  29. package/dist/domain/graph/builder/helpers.js +47 -33
  30. package/dist/domain/graph/builder/helpers.js.map +1 -1
  31. package/dist/domain/graph/builder/incremental.d.ts +6 -0
  32. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/incremental.js +142 -76
  34. package/dist/domain/graph/builder/incremental.js.map +1 -1
  35. package/dist/domain/graph/builder/pipeline.d.ts +1 -44
  36. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/pipeline.js +10 -766
  38. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  39. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  40. package/dist/domain/graph/builder/stages/build-edges.js +133 -96
  41. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  42. package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
  43. package/dist/domain/graph/builder/stages/build-structure.js +82 -65
  44. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  45. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  46. package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
  47. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  48. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  49. package/dist/domain/graph/builder/stages/finalize.js +60 -51
  50. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  51. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
  52. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
  54. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
  56. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
  57. package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
  58. package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
  59. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
  60. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
  61. package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
  62. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
  63. package/dist/domain/graph/cycles.d.ts +6 -4
  64. package/dist/domain/graph/cycles.d.ts.map +1 -1
  65. package/dist/domain/graph/cycles.js +50 -55
  66. package/dist/domain/graph/cycles.js.map +1 -1
  67. package/dist/domain/graph/journal.d.ts.map +1 -1
  68. package/dist/domain/graph/journal.js +89 -70
  69. package/dist/domain/graph/journal.js.map +1 -1
  70. package/dist/domain/graph/watcher.d.ts.map +1 -1
  71. package/dist/domain/graph/watcher.js +5 -2
  72. package/dist/domain/graph/watcher.js.map +1 -1
  73. package/dist/domain/parser.d.ts +12 -23
  74. package/dist/domain/parser.d.ts.map +1 -1
  75. package/dist/domain/parser.js +126 -79
  76. package/dist/domain/parser.js.map +1 -1
  77. package/dist/domain/search/generator.d.ts +3 -1
  78. package/dist/domain/search/generator.d.ts.map +1 -1
  79. package/dist/domain/search/generator.js +68 -45
  80. package/dist/domain/search/generator.js.map +1 -1
  81. package/dist/domain/search/models.d.ts +2 -0
  82. package/dist/domain/search/models.d.ts.map +1 -1
  83. package/dist/domain/search/models.js +37 -3
  84. package/dist/domain/search/models.js.map +1 -1
  85. package/dist/domain/search/search/hybrid.d.ts.map +1 -1
  86. package/dist/domain/search/search/hybrid.js +49 -40
  87. package/dist/domain/search/search/hybrid.js.map +1 -1
  88. package/dist/domain/search/search/semantic.d.ts.map +1 -1
  89. package/dist/domain/search/search/semantic.js +69 -49
  90. package/dist/domain/search/search/semantic.js.map +1 -1
  91. package/dist/domain/wasm-worker-entry.js +201 -136
  92. package/dist/domain/wasm-worker-entry.js.map +1 -1
  93. package/dist/extractors/elixir.js +95 -71
  94. package/dist/extractors/elixir.js.map +1 -1
  95. package/dist/extractors/gleam.d.ts.map +1 -1
  96. package/dist/extractors/gleam.js +23 -31
  97. package/dist/extractors/gleam.js.map +1 -1
  98. package/dist/extractors/helpers.d.ts +79 -1
  99. package/dist/extractors/helpers.d.ts.map +1 -1
  100. package/dist/extractors/helpers.js +137 -0
  101. package/dist/extractors/helpers.js.map +1 -1
  102. package/dist/extractors/java.d.ts.map +1 -1
  103. package/dist/extractors/java.js +37 -49
  104. package/dist/extractors/java.js.map +1 -1
  105. package/dist/extractors/javascript.d.ts.map +1 -1
  106. package/dist/extractors/javascript.js +44 -44
  107. package/dist/extractors/javascript.js.map +1 -1
  108. package/dist/extractors/julia.js +27 -34
  109. package/dist/extractors/julia.js.map +1 -1
  110. package/dist/extractors/r.d.ts.map +1 -1
  111. package/dist/extractors/r.js +33 -58
  112. package/dist/extractors/r.js.map +1 -1
  113. package/dist/extractors/solidity.d.ts.map +1 -1
  114. package/dist/extractors/solidity.js +38 -61
  115. package/dist/extractors/solidity.js.map +1 -1
  116. package/dist/features/boundaries.d.ts.map +1 -1
  117. package/dist/features/boundaries.js +49 -39
  118. package/dist/features/boundaries.js.map +1 -1
  119. package/dist/features/cfg.d.ts.map +1 -1
  120. package/dist/features/cfg.js +90 -63
  121. package/dist/features/cfg.js.map +1 -1
  122. package/dist/features/check.d.ts.map +1 -1
  123. package/dist/features/check.js +43 -34
  124. package/dist/features/check.js.map +1 -1
  125. package/dist/features/cochange.d.ts.map +1 -1
  126. package/dist/features/cochange.js +68 -56
  127. package/dist/features/cochange.js.map +1 -1
  128. package/dist/features/complexity.d.ts.map +1 -1
  129. package/dist/features/complexity.js +105 -75
  130. package/dist/features/complexity.js.map +1 -1
  131. package/dist/features/dataflow.d.ts.map +1 -1
  132. package/dist/features/dataflow.js +37 -29
  133. package/dist/features/dataflow.js.map +1 -1
  134. package/dist/features/flow.d.ts.map +1 -1
  135. package/dist/features/flow.js +31 -22
  136. package/dist/features/flow.js.map +1 -1
  137. package/dist/features/graph-enrichment.d.ts.map +1 -1
  138. package/dist/features/graph-enrichment.js +77 -70
  139. package/dist/features/graph-enrichment.js.map +1 -1
  140. package/dist/features/owners.d.ts +17 -26
  141. package/dist/features/owners.d.ts.map +1 -1
  142. package/dist/features/owners.js +120 -109
  143. package/dist/features/owners.js.map +1 -1
  144. package/dist/features/sequence.d.ts.map +1 -1
  145. package/dist/features/sequence.js +59 -54
  146. package/dist/features/sequence.js.map +1 -1
  147. package/dist/features/structure-query.d.ts.map +1 -1
  148. package/dist/features/structure-query.js +60 -60
  149. package/dist/features/structure-query.js.map +1 -1
  150. package/dist/features/structure.js +28 -36
  151. package/dist/features/structure.js.map +1 -1
  152. package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
  153. package/dist/graph/algorithms/leiden/optimiser.js +100 -69
  154. package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
  155. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  156. package/dist/graph/classifiers/roles.js +63 -59
  157. package/dist/graph/classifiers/roles.js.map +1 -1
  158. package/dist/infrastructure/config.d.ts +1 -1
  159. package/dist/infrastructure/config.d.ts.map +1 -1
  160. package/dist/infrastructure/config.js +1 -1
  161. package/dist/infrastructure/config.js.map +1 -1
  162. package/dist/presentation/cfg.d.ts.map +1 -1
  163. package/dist/presentation/cfg.js +44 -29
  164. package/dist/presentation/cfg.js.map +1 -1
  165. package/dist/presentation/flow.d.ts.map +1 -1
  166. package/dist/presentation/flow.js +58 -38
  167. package/dist/presentation/flow.js.map +1 -1
  168. package/dist/types.d.ts +1 -1
  169. package/dist/types.d.ts.map +1 -1
  170. package/package.json +7 -7
  171. package/src/ast-analysis/engine.ts +145 -61
  172. package/src/ast-analysis/visitor-utils.ts +86 -46
  173. package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
  174. package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
  175. package/src/cli/commands/embed.ts +54 -4
  176. package/src/domain/analysis/dependencies.ts +166 -85
  177. package/src/domain/analysis/fn-impact.ts +120 -50
  178. package/src/domain/analysis/module-map.ts +175 -140
  179. package/src/domain/graph/builder/helpers.ts +85 -76
  180. package/src/domain/graph/builder/incremental.ts +217 -90
  181. package/src/domain/graph/builder/pipeline.ts +19 -957
  182. package/src/domain/graph/builder/stages/build-edges.ts +198 -140
  183. package/src/domain/graph/builder/stages/build-structure.ts +115 -82
  184. package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
  185. package/src/domain/graph/builder/stages/finalize.ts +72 -70
  186. package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
  187. package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
  188. package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
  189. package/src/domain/graph/cycles.ts +51 -49
  190. package/src/domain/graph/journal.ts +84 -69
  191. package/src/domain/graph/watcher.ts +8 -2
  192. package/src/domain/parser.ts +143 -66
  193. package/src/domain/search/generator.ts +132 -74
  194. package/src/domain/search/models.ts +39 -3
  195. package/src/domain/search/search/hybrid.ts +53 -42
  196. package/src/domain/search/search/semantic.ts +105 -65
  197. package/src/domain/wasm-worker-entry.ts +235 -152
  198. package/src/extractors/elixir.ts +91 -64
  199. package/src/extractors/gleam.ts +33 -37
  200. package/src/extractors/helpers.ts +205 -1
  201. package/src/extractors/java.ts +42 -45
  202. package/src/extractors/javascript.ts +44 -43
  203. package/src/extractors/julia.ts +28 -35
  204. package/src/extractors/r.ts +38 -56
  205. package/src/extractors/solidity.ts +43 -71
  206. package/src/features/boundaries.ts +64 -46
  207. package/src/features/cfg.ts +145 -74
  208. package/src/features/check.ts +60 -43
  209. package/src/features/cochange.ts +95 -72
  210. package/src/features/complexity.ts +134 -79
  211. package/src/features/dataflow.ts +57 -34
  212. package/src/features/flow.ts +48 -24
  213. package/src/features/graph-enrichment.ts +105 -70
  214. package/src/features/owners.ts +186 -146
  215. package/src/features/sequence.ts +99 -69
  216. package/src/features/structure-query.ts +94 -79
  217. package/src/features/structure.ts +56 -56
  218. package/src/graph/algorithms/leiden/optimiser.ts +142 -87
  219. package/src/graph/classifiers/roles.ts +64 -54
  220. package/src/infrastructure/config.ts +1 -1
  221. package/src/presentation/cfg.ts +48 -32
  222. package/src/presentation/flow.ts +100 -52
  223. package/src/types.ts +1 -1
@@ -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
 
@@ -31,6 +31,9 @@ function prepareWatcherStatements(db: ReturnType<typeof openDb>): IncrementalStm
31
31
  'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)',
32
32
  ),
33
33
  countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'),
34
+ countEdges: db.prepare(
35
+ 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)',
36
+ ),
34
37
  findNodeInFile: db.prepare(
35
38
  "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?",
36
39
  ),
@@ -52,6 +55,7 @@ interface RebuildResult {
52
55
  nodesAdded: number;
53
56
  nodesRemoved: number;
54
57
  edgesAdded: number;
58
+ edgesBefore: number;
55
59
  }
56
60
 
57
61
  /** Process a batch of pending file changes: rebuild, journal, and log. */
@@ -107,7 +111,7 @@ function writeJournalAndChangeEvents(rootDir: string, updates: RebuildResult[]):
107
111
  buildChangeEvent(r.file, r.event, r.symbolDiff, {
108
112
  nodesBefore: r.nodesBefore,
109
113
  nodesAfter: r.nodesAfter,
110
- edgesAdded: r.edgesAdded,
114
+ edgesAdded: r.edgesAdded - r.edgesBefore,
111
115
  }),
112
116
  );
113
117
  try {
@@ -125,7 +129,9 @@ function logRebuildResults(updates: RebuildResult[]): void {
125
129
  if (r.deleted) {
126
130
  info(`Removed: ${r.file} (-${r.nodesRemoved} nodes)`);
127
131
  } else {
128
- 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)`);
129
135
  }
130
136
  }
131
137
  }
@@ -322,12 +322,15 @@ export function getParser(parsers: Map<string, Parser | null>, filePath: string)
322
322
  * without _tree", which was the source of #1036 — a single file missing one
323
323
  * analysis triggered a full-build re-parse of every WASM-parseable file.
324
324
  */
325
- export async function ensureWasmTrees(
325
+ /**
326
+ * Select files from `fileSymbols` that still need analysis data and are
327
+ * parseable by an installed WASM grammar. Pure (no I/O) — safe to unit-test.
328
+ */
329
+ function collectBackfillPending(
326
330
  fileSymbols: Map<string, any>,
327
331
  rootDir: string,
328
332
  needsFn?: (relPath: string, symbols: any) => boolean,
329
- ): Promise<void> {
330
- // Collect files that still need analysis data and are parseable by WASM.
333
+ ): Array<{ relPath: string; absPath: string; symbols: any }> {
331
334
  const pending: Array<{ relPath: string; absPath: string; symbols: any }> = [];
332
335
  for (const [relPath, symbols] of fileSymbols) {
333
336
  if (symbols._tree) continue; // legacy path — leave existing trees alone
@@ -335,6 +338,15 @@ export async function ensureWasmTrees(
335
338
  if (needsFn && !needsFn(relPath, symbols)) continue;
336
339
  pending.push({ relPath, absPath: path.join(rootDir, relPath), symbols });
337
340
  }
341
+ return pending;
342
+ }
343
+
344
+ export async function ensureWasmTrees(
345
+ fileSymbols: Map<string, any>,
346
+ rootDir: string,
347
+ needsFn?: (relPath: string, symbols: any) => boolean,
348
+ ): Promise<void> {
349
+ const pending = collectBackfillPending(fileSymbols, rootDir, needsFn);
338
350
  if (pending.length === 0) return;
339
351
 
340
352
  const pool = getWasmWorkerPool();
@@ -352,30 +364,37 @@ export async function ensureWasmTrees(
352
364
  }
353
365
  }
354
366
 
355
- /**
356
- * Merge pre-computed analysis data from a worker result onto existing symbols.
357
- * Only fills gaps — never overwrites fields the caller already populated.
358
- * Used to patch native-parsed symbols with worker-produced astNodes / dataflow /
359
- * per-definition complexity and cfg.
360
- */
361
- function mergeAnalysisData(symbols: any, worker: ExtractorOutput): void {
367
+ /** Fill gap-only scalar metadata (`_langId`, `_lineCount`) from the worker output. */
368
+ function mergeScalarMetadata(symbols: any, worker: ExtractorOutput): void {
362
369
  if (!symbols._langId && worker._langId) symbols._langId = worker._langId;
363
370
  if (!symbols._lineCount && worker._lineCount) symbols._lineCount = worker._lineCount;
371
+ }
372
+
373
+ /** Fill gap-only analysis arrays (`astNodes`, `dataflow`) from the worker output. */
374
+ function mergeAnalysisArrays(symbols: any, worker: ExtractorOutput): void {
364
375
  if (!Array.isArray(symbols.astNodes) && Array.isArray(worker.astNodes)) {
365
376
  symbols.astNodes = worker.astNodes;
366
377
  }
367
378
  if (!symbols.dataflow && worker.dataflow) symbols.dataflow = worker.dataflow;
368
- if (worker.typeMap && worker.typeMap.size > 0) {
369
- if (!symbols.typeMap || !(symbols.typeMap instanceof Map)) {
370
- symbols.typeMap = new Map(worker.typeMap);
371
- } else {
372
- for (const [k, v] of worker.typeMap) {
373
- if (!symbols.typeMap.has(k)) symbols.typeMap.set(k, v);
374
- }
375
- }
379
+ }
380
+
381
+ /** Merge worker typeMap into existing symbols.typeMap with first-wins semantics. */
382
+ function mergeTypeMap(symbols: any, worker: ExtractorOutput): void {
383
+ if (!worker.typeMap || worker.typeMap.size === 0) return;
384
+ if (!symbols.typeMap || !(symbols.typeMap instanceof Map)) {
385
+ symbols.typeMap = new Map(worker.typeMap);
386
+ return;
376
387
  }
388
+ for (const [k, v] of worker.typeMap) {
389
+ if (!symbols.typeMap.has(k)) symbols.typeMap.set(k, v);
390
+ }
391
+ }
392
+
393
+ /** Patch existing definitions with worker complexity/cfg when absent. */
394
+ function mergeDefinitionAnalysis(symbols: any, worker: ExtractorOutput): void {
377
395
  const existingDefs: any[] = Array.isArray(symbols.definitions) ? symbols.definitions : [];
378
396
  const workerDefs: any[] = Array.isArray(worker.definitions) ? worker.definitions : [];
397
+ if (existingDefs.length === 0 || workerDefs.length === 0) return;
379
398
  // Index existing defs by (kind, name, line) — mirrors engine.ts matching key.
380
399
  const byKey = new Map<string, any>();
381
400
  for (const d of existingDefs) byKey.set(`${d.kind}|${d.name}|${d.line}`, d);
@@ -389,6 +408,19 @@ function mergeAnalysisData(symbols: any, worker: ExtractorOutput): void {
389
408
  }
390
409
  }
391
410
 
411
+ /**
412
+ * Merge pre-computed analysis data from a worker result onto existing symbols.
413
+ * Only fills gaps — never overwrites fields the caller already populated.
414
+ * Used to patch native-parsed symbols with worker-produced astNodes / dataflow /
415
+ * per-definition complexity and cfg.
416
+ */
417
+ function mergeAnalysisData(symbols: any, worker: ExtractorOutput): void {
418
+ mergeScalarMetadata(symbols, worker);
419
+ mergeAnalysisArrays(symbols, worker);
420
+ mergeTypeMap(symbols, worker);
421
+ mergeDefinitionAnalysis(symbols, worker);
422
+ }
423
+
392
424
  /**
393
425
  * Check whether the required WASM grammar files exist on disk.
394
426
  */
@@ -539,25 +571,36 @@ export function classifyNativeDrops(relPaths: Iterable<string>): NativeDropClass
539
571
  }
540
572
 
541
573
  /**
542
- * Render `{ ext → paths[] }` as `ext (n: sample.ext, ...)` slices for log lines.
543
- * Caps at 3 sample paths per extension and 6 extensions total to keep warnings
544
- * readable when many languages are dropped at once. Extensions are sorted by
545
- * descending file count so the loudest offender shows up first; ties keep
546
- * insertion order. Pure function safe to unit-test independently.
574
+ * Render `{ ext → paths[] }` as a multi-line tabular breakdown for log lines.
575
+ * Each extension occupies its own line so a long warning scans like a table
576
+ * instead of a wall of semicolon-separated slices. Caps at 3 sample paths per
577
+ * extension and 6 extensions total to keep output bounded when many languages
578
+ * are dropped at once. Extensions are sorted by descending file count so the
579
+ * loudest offender shows up first; ties keep insertion order.
580
+ *
581
+ * Returns the empty string for empty input, and otherwise a string that
582
+ * begins with `\n` so callers can append it directly after the header line
583
+ * (`"Backfilling via WASM:" + formatDropExtensionSummary(...)`).
584
+ *
585
+ * Pure function — safe to unit-test independently.
547
586
  */
548
587
  export function formatDropExtensionSummary(buckets: Map<string, string[]>): string {
549
588
  const MAX_EXTS = 6;
550
589
  const MAX_SAMPLES = 3;
551
590
  const entries = Array.from(buckets.entries()).sort((a, b) => b[1].length - a[1].length);
552
- const shown = entries.slice(0, MAX_EXTS).map(([ext, paths]) => {
591
+ if (entries.length === 0) return '';
592
+ const shown = entries.slice(0, MAX_EXTS);
593
+ const extWidth = Math.max(...shown.map(([ext]) => ext.length));
594
+ const countWidth = Math.max(...shown.map(([, paths]) => String(paths.length).length));
595
+ const lines = shown.map(([ext, paths]) => {
553
596
  const sample = paths.slice(0, MAX_SAMPLES).join(', ');
554
- const more = paths.length > MAX_SAMPLES ? `, +${paths.length - MAX_SAMPLES} more` : '';
555
- return `${ext} (${paths.length}: ${sample}${more})`;
597
+ const more = paths.length > MAX_SAMPLES ? ` (+${paths.length - MAX_SAMPLES} more)` : '';
598
+ return ` ${ext.padEnd(extWidth)} ${String(paths.length).padStart(countWidth)} ${sample}${more}`;
556
599
  });
557
600
  if (entries.length > MAX_EXTS) {
558
- shown.push(`+${entries.length - MAX_EXTS} more extension(s)`);
601
+ lines.push(` (+${entries.length - MAX_EXTS} more extension(s))`);
559
602
  }
560
- return shown.join('; ');
603
+ return `\n${lines.join('\n')}`;
561
604
  }
562
605
 
563
606
  // ── Unified API ──────────────────────────────────────────────────────────────
@@ -592,24 +635,36 @@ function patchDefinitions(definitions: any[]): void {
592
635
  }
593
636
  }
594
637
 
638
+ /**
639
+ * Field renames applied to each import record to bridge older native binaries
640
+ * that emit snake_case names. Each `[camel, snake]` pair becomes:
641
+ * `if (imp[camel] === undefined) imp[camel] = imp[snake];`
642
+ * Defined as data so the loop body stays trivially linear in cognitive complexity.
643
+ */
644
+ const IMPORT_FIELD_RENAMES: ReadonlyArray<readonly [string, string]> = [
645
+ ['typeOnly', 'type_only'],
646
+ ['wildcardReexport', 'wildcard_reexport'],
647
+ ['pythonImport', 'python_import'],
648
+ ['goImport', 'go_import'],
649
+ ['rustUse', 'rust_use'],
650
+ ['javaImport', 'java_import'],
651
+ ['csharpUsing', 'csharp_using'],
652
+ ['rubyRequire', 'ruby_require'],
653
+ ['phpUse', 'php_use'],
654
+ ['cInclude', 'c_include'],
655
+ ['kotlinImport', 'kotlin_import'],
656
+ ['swiftImport', 'swift_import'],
657
+ ['scalaImport', 'scala_import'],
658
+ ['bashSource', 'bash_source'],
659
+ ['dynamicImport', 'dynamic_import'],
660
+ ];
661
+
595
662
  /** Patch import fields for backward compat with older native binaries. */
596
663
  function patchImports(imports: any[]): void {
597
664
  for (const i of imports) {
598
- if (i.typeOnly === undefined) i.typeOnly = i.type_only;
599
- if (i.wildcardReexport === undefined) i.wildcardReexport = i.wildcard_reexport;
600
- if (i.pythonImport === undefined) i.pythonImport = i.python_import;
601
- if (i.goImport === undefined) i.goImport = i.go_import;
602
- if (i.rustUse === undefined) i.rustUse = i.rust_use;
603
- if (i.javaImport === undefined) i.javaImport = i.java_import;
604
- if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using;
605
- if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require;
606
- if (i.phpUse === undefined) i.phpUse = i.php_use;
607
- if (i.cInclude === undefined) i.cInclude = i.c_include;
608
- if (i.kotlinImport === undefined) i.kotlinImport = i.kotlin_import;
609
- if (i.swiftImport === undefined) i.swiftImport = i.swift_import;
610
- if (i.scalaImport === undefined) i.scalaImport = i.scala_import;
611
- if (i.bashSource === undefined) i.bashSource = i.bash_source;
612
- if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import;
665
+ for (const [camel, snake] of IMPORT_FIELD_RENAMES) {
666
+ if (i[camel] === undefined) i[camel] = i[snake];
667
+ }
613
668
  }
614
669
  }
615
670
 
@@ -1159,18 +1214,16 @@ export async function parseFilesWasmForBackfill(
1159
1214
  }
1160
1215
 
1161
1216
  /**
1162
- * Parse multiple files in bulk and return a Map<relPath, symbols>.
1217
+ * Run the native engine over `filePaths` and ingest the results into `result`.
1218
+ * Returns the set of file paths the native engine successfully parsed and the
1219
+ * TS/TSX files that need a typeMap backfill pass.
1163
1220
  */
1164
- export async function parseFilesAuto(
1221
+ function ingestNativeResults(
1222
+ native: any,
1165
1223
  filePaths: string[],
1166
1224
  rootDir: string,
1167
- opts: ParseEngineOpts = {},
1168
- ): Promise<Map<string, ExtractorOutput>> {
1169
- const { native } = resolveEngine(opts);
1170
-
1171
- if (!native) return parseFilesWasm(filePaths, rootDir);
1172
-
1173
- const result = new Map<string, ExtractorOutput>();
1225
+ result: Map<string, ExtractorOutput>,
1226
+ ): { nativeParsed: Set<string>; needsTypeMap: { filePath: string; relPath: string }[] } {
1174
1227
  // Always extract all analysis data (dataflow + AST nodes) during native parse.
1175
1228
  // This eliminates the need for any downstream WASM re-parse or native standalone calls.
1176
1229
  const nativeResults = native.parseFilesFull
@@ -1193,27 +1246,51 @@ export async function parseFilesAuto(
1193
1246
  needsTypeMap.push({ filePath: r.file, relPath });
1194
1247
  }
1195
1248
  }
1196
- if (needsTypeMap.length > 0) {
1197
- await backfillTypeMapBatch(needsTypeMap, result);
1198
- }
1249
+ return { nativeParsed, needsTypeMap };
1250
+ }
1199
1251
 
1200
- // Engine parity: native may silently drop files whose extensions are in
1201
- // SUPPORTED_EXTENSIONS (because a WASM grammar exists) but whose Rust
1202
- // extractor/grammar is missing or fails. WASM handles these fall back so
1203
- // both engines process the same file set (#967). Restrict to installed WASM
1204
- // grammars so we don't warn about files that neither engine can parse.
1252
+ /**
1253
+ * Engine parity: native may silently drop files whose extensions are in
1254
+ * SUPPORTED_EXTENSIONS (because a WASM grammar exists) but whose Rust
1255
+ * extractor/grammar is missing or fails. WASM handles these fall back so
1256
+ * both engines process the same file set (#967). Restrict to installed WASM
1257
+ * grammars so we don't warn about files that neither engine can parse.
1258
+ */
1259
+ async function backfillNativeDrops(
1260
+ filePaths: string[],
1261
+ nativeParsed: Set<string>,
1262
+ rootDir: string,
1263
+ result: Map<string, ExtractorOutput>,
1264
+ ): Promise<void> {
1205
1265
  const installedExts = getInstalledWasmExtensions();
1206
1266
  const dropped = filePaths.filter(
1207
1267
  (f) => !nativeParsed.has(f) && installedExts.has(path.extname(f).toLowerCase()),
1208
1268
  );
1209
- if (dropped.length > 0) {
1210
- warn(`Native engine dropped ${dropped.length} file(s); falling back to WASM for parity`);
1211
- const wasmResults = await parseFilesWasmForBackfill(dropped, rootDir);
1212
- for (const [relPath, symbols] of wasmResults) {
1213
- result.set(relPath, symbols);
1214
- }
1269
+ if (dropped.length === 0) return;
1270
+ warn(`Native engine dropped ${dropped.length} file(s); falling back to WASM for parity`);
1271
+ const wasmResults = await parseFilesWasmForBackfill(dropped, rootDir);
1272
+ for (const [relPath, symbols] of wasmResults) {
1273
+ result.set(relPath, symbols);
1215
1274
  }
1275
+ }
1276
+
1277
+ /**
1278
+ * Parse multiple files in bulk and return a Map<relPath, symbols>.
1279
+ */
1280
+ export async function parseFilesAuto(
1281
+ filePaths: string[],
1282
+ rootDir: string,
1283
+ opts: ParseEngineOpts = {},
1284
+ ): Promise<Map<string, ExtractorOutput>> {
1285
+ const { native } = resolveEngine(opts);
1286
+ if (!native) return parseFilesWasm(filePaths, rootDir);
1216
1287
 
1288
+ const result = new Map<string, ExtractorOutput>();
1289
+ const { nativeParsed, needsTypeMap } = ingestNativeResults(native, filePaths, rootDir, result);
1290
+ if (needsTypeMap.length > 0) {
1291
+ await backfillTypeMapBatch(needsTypeMap, result);
1292
+ }
1293
+ await backfillNativeDrops(filePaths, nativeParsed, rootDir, result);
1217
1294
  return result;
1218
1295
  }
1219
1296