@optave/codegraph 3.11.0 → 3.11.2

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 (230) 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/call-resolver.d.ts +71 -0
  28. package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -0
  29. package/dist/domain/graph/builder/call-resolver.js +130 -0
  30. package/dist/domain/graph/builder/call-resolver.js.map +1 -0
  31. package/dist/domain/graph/builder/helpers.d.ts +4 -4
  32. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/helpers.js +47 -33
  34. package/dist/domain/graph/builder/helpers.js.map +1 -1
  35. package/dist/domain/graph/builder/incremental.d.ts +6 -0
  36. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  37. package/dist/domain/graph/builder/incremental.js +214 -127
  38. package/dist/domain/graph/builder/incremental.js.map +1 -1
  39. package/dist/domain/graph/builder/pipeline.d.ts +1 -44
  40. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  41. package/dist/domain/graph/builder/pipeline.js +10 -766
  42. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  43. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  44. package/dist/domain/graph/builder/stages/build-edges.js +151 -192
  45. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  46. package/dist/domain/graph/builder/stages/build-structure.d.ts.map +1 -1
  47. package/dist/domain/graph/builder/stages/build-structure.js +82 -65
  48. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  49. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  50. package/dist/domain/graph/builder/stages/detect-changes.js +84 -56
  51. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/finalize.js +60 -51
  54. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/insert-nodes.d.ts +8 -6
  56. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  57. package/dist/domain/graph/builder/stages/insert-nodes.js +107 -122
  58. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  59. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts +14 -0
  60. package/dist/domain/graph/builder/stages/native-db-lifecycle.d.ts.map +1 -0
  61. package/dist/domain/graph/builder/stages/native-db-lifecycle.js +77 -0
  62. package/dist/domain/graph/builder/stages/native-db-lifecycle.js.map +1 -0
  63. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts +62 -0
  64. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -0
  65. package/dist/domain/graph/builder/stages/native-orchestrator.js +747 -0
  66. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -0
  67. package/dist/domain/graph/cycles.d.ts +6 -4
  68. package/dist/domain/graph/cycles.d.ts.map +1 -1
  69. package/dist/domain/graph/cycles.js +50 -55
  70. package/dist/domain/graph/cycles.js.map +1 -1
  71. package/dist/domain/graph/journal.d.ts.map +1 -1
  72. package/dist/domain/graph/journal.js +89 -70
  73. package/dist/domain/graph/journal.js.map +1 -1
  74. package/dist/domain/graph/watcher.d.ts.map +1 -1
  75. package/dist/domain/graph/watcher.js +10 -4
  76. package/dist/domain/graph/watcher.js.map +1 -1
  77. package/dist/domain/parser.d.ts +12 -23
  78. package/dist/domain/parser.d.ts.map +1 -1
  79. package/dist/domain/parser.js +126 -79
  80. package/dist/domain/parser.js.map +1 -1
  81. package/dist/domain/search/generator.d.ts +3 -1
  82. package/dist/domain/search/generator.d.ts.map +1 -1
  83. package/dist/domain/search/generator.js +68 -45
  84. package/dist/domain/search/generator.js.map +1 -1
  85. package/dist/domain/search/models.d.ts +2 -0
  86. package/dist/domain/search/models.d.ts.map +1 -1
  87. package/dist/domain/search/models.js +37 -3
  88. package/dist/domain/search/models.js.map +1 -1
  89. package/dist/domain/search/search/hybrid.d.ts.map +1 -1
  90. package/dist/domain/search/search/hybrid.js +49 -40
  91. package/dist/domain/search/search/hybrid.js.map +1 -1
  92. package/dist/domain/search/search/semantic.d.ts.map +1 -1
  93. package/dist/domain/search/search/semantic.js +69 -49
  94. package/dist/domain/search/search/semantic.js.map +1 -1
  95. package/dist/domain/wasm-worker-entry.js +201 -136
  96. package/dist/domain/wasm-worker-entry.js.map +1 -1
  97. package/dist/extractors/elixir.js +95 -71
  98. package/dist/extractors/elixir.js.map +1 -1
  99. package/dist/extractors/gleam.d.ts.map +1 -1
  100. package/dist/extractors/gleam.js +23 -31
  101. package/dist/extractors/gleam.js.map +1 -1
  102. package/dist/extractors/helpers.d.ts +79 -1
  103. package/dist/extractors/helpers.d.ts.map +1 -1
  104. package/dist/extractors/helpers.js +137 -0
  105. package/dist/extractors/helpers.js.map +1 -1
  106. package/dist/extractors/java.d.ts.map +1 -1
  107. package/dist/extractors/java.js +37 -49
  108. package/dist/extractors/java.js.map +1 -1
  109. package/dist/extractors/javascript.d.ts.map +1 -1
  110. package/dist/extractors/javascript.js +44 -44
  111. package/dist/extractors/javascript.js.map +1 -1
  112. package/dist/extractors/julia.js +27 -34
  113. package/dist/extractors/julia.js.map +1 -1
  114. package/dist/extractors/r.d.ts.map +1 -1
  115. package/dist/extractors/r.js +33 -58
  116. package/dist/extractors/r.js.map +1 -1
  117. package/dist/extractors/solidity.d.ts.map +1 -1
  118. package/dist/extractors/solidity.js +38 -61
  119. package/dist/extractors/solidity.js.map +1 -1
  120. package/dist/features/boundaries.d.ts.map +1 -1
  121. package/dist/features/boundaries.js +49 -39
  122. package/dist/features/boundaries.js.map +1 -1
  123. package/dist/features/cfg.d.ts.map +1 -1
  124. package/dist/features/cfg.js +90 -63
  125. package/dist/features/cfg.js.map +1 -1
  126. package/dist/features/check.d.ts.map +1 -1
  127. package/dist/features/check.js +43 -34
  128. package/dist/features/check.js.map +1 -1
  129. package/dist/features/cochange.d.ts.map +1 -1
  130. package/dist/features/cochange.js +68 -56
  131. package/dist/features/cochange.js.map +1 -1
  132. package/dist/features/complexity.d.ts.map +1 -1
  133. package/dist/features/complexity.js +105 -75
  134. package/dist/features/complexity.js.map +1 -1
  135. package/dist/features/dataflow.d.ts.map +1 -1
  136. package/dist/features/dataflow.js +37 -29
  137. package/dist/features/dataflow.js.map +1 -1
  138. package/dist/features/flow.d.ts.map +1 -1
  139. package/dist/features/flow.js +31 -22
  140. package/dist/features/flow.js.map +1 -1
  141. package/dist/features/graph-enrichment.d.ts.map +1 -1
  142. package/dist/features/graph-enrichment.js +77 -70
  143. package/dist/features/graph-enrichment.js.map +1 -1
  144. package/dist/features/owners.d.ts +17 -26
  145. package/dist/features/owners.d.ts.map +1 -1
  146. package/dist/features/owners.js +120 -109
  147. package/dist/features/owners.js.map +1 -1
  148. package/dist/features/sequence.d.ts.map +1 -1
  149. package/dist/features/sequence.js +59 -54
  150. package/dist/features/sequence.js.map +1 -1
  151. package/dist/features/structure-query.d.ts.map +1 -1
  152. package/dist/features/structure-query.js +60 -60
  153. package/dist/features/structure-query.js.map +1 -1
  154. package/dist/features/structure.d.ts.map +1 -1
  155. package/dist/features/structure.js +149 -52
  156. package/dist/features/structure.js.map +1 -1
  157. package/dist/graph/algorithms/leiden/optimiser.d.ts.map +1 -1
  158. package/dist/graph/algorithms/leiden/optimiser.js +100 -69
  159. package/dist/graph/algorithms/leiden/optimiser.js.map +1 -1
  160. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  161. package/dist/graph/classifiers/roles.js +63 -59
  162. package/dist/graph/classifiers/roles.js.map +1 -1
  163. package/dist/infrastructure/config.d.ts +1 -1
  164. package/dist/infrastructure/config.d.ts.map +1 -1
  165. package/dist/infrastructure/config.js +1 -1
  166. package/dist/infrastructure/config.js.map +1 -1
  167. package/dist/presentation/cfg.d.ts.map +1 -1
  168. package/dist/presentation/cfg.js +44 -29
  169. package/dist/presentation/cfg.js.map +1 -1
  170. package/dist/presentation/flow.d.ts.map +1 -1
  171. package/dist/presentation/flow.js +58 -38
  172. package/dist/presentation/flow.js.map +1 -1
  173. package/dist/types.d.ts +1 -1
  174. package/dist/types.d.ts.map +1 -1
  175. package/grammars/tree-sitter-erlang.wasm +0 -0
  176. package/package.json +9 -9
  177. package/src/ast-analysis/engine.ts +145 -61
  178. package/src/ast-analysis/visitor-utils.ts +86 -46
  179. package/src/ast-analysis/visitors/ast-store-visitor.ts +104 -69
  180. package/src/ast-analysis/visitors/dataflow-visitor.ts +86 -47
  181. package/src/cli/commands/embed.ts +54 -4
  182. package/src/domain/analysis/dependencies.ts +166 -85
  183. package/src/domain/analysis/fn-impact.ts +120 -50
  184. package/src/domain/analysis/module-map.ts +175 -140
  185. package/src/domain/graph/builder/call-resolver.ts +181 -0
  186. package/src/domain/graph/builder/helpers.ts +85 -76
  187. package/src/domain/graph/builder/incremental.ts +321 -152
  188. package/src/domain/graph/builder/pipeline.ts +19 -957
  189. package/src/domain/graph/builder/stages/build-edges.ts +229 -275
  190. package/src/domain/graph/builder/stages/build-structure.ts +115 -82
  191. package/src/domain/graph/builder/stages/detect-changes.ts +107 -64
  192. package/src/domain/graph/builder/stages/finalize.ts +72 -70
  193. package/src/domain/graph/builder/stages/insert-nodes.ts +154 -120
  194. package/src/domain/graph/builder/stages/native-db-lifecycle.ts +74 -0
  195. package/src/domain/graph/builder/stages/native-orchestrator.ts +942 -0
  196. package/src/domain/graph/cycles.ts +51 -49
  197. package/src/domain/graph/journal.ts +84 -69
  198. package/src/domain/graph/watcher.ts +12 -4
  199. package/src/domain/parser.ts +143 -66
  200. package/src/domain/search/generator.ts +132 -74
  201. package/src/domain/search/models.ts +39 -3
  202. package/src/domain/search/search/hybrid.ts +53 -42
  203. package/src/domain/search/search/semantic.ts +105 -65
  204. package/src/domain/wasm-worker-entry.ts +235 -152
  205. package/src/extractors/elixir.ts +91 -64
  206. package/src/extractors/gleam.ts +33 -37
  207. package/src/extractors/helpers.ts +205 -1
  208. package/src/extractors/java.ts +42 -45
  209. package/src/extractors/javascript.ts +44 -43
  210. package/src/extractors/julia.ts +28 -35
  211. package/src/extractors/r.ts +38 -56
  212. package/src/extractors/solidity.ts +43 -71
  213. package/src/features/boundaries.ts +64 -46
  214. package/src/features/cfg.ts +145 -74
  215. package/src/features/check.ts +60 -43
  216. package/src/features/cochange.ts +95 -72
  217. package/src/features/complexity.ts +134 -79
  218. package/src/features/dataflow.ts +57 -34
  219. package/src/features/flow.ts +48 -24
  220. package/src/features/graph-enrichment.ts +105 -70
  221. package/src/features/owners.ts +186 -146
  222. package/src/features/sequence.ts +99 -69
  223. package/src/features/structure-query.ts +94 -79
  224. package/src/features/structure.ts +199 -79
  225. package/src/graph/algorithms/leiden/optimiser.ts +142 -87
  226. package/src/graph/classifiers/roles.ts +64 -54
  227. package/src/infrastructure/config.ts +1 -1
  228. package/src/presentation/cfg.ts +48 -32
  229. package/src/presentation/flow.ts +100 -52
  230. package/src/types.ts +1 -1
@@ -573,6 +573,90 @@ interface SetupResult {
573
573
  dataflowVisitor: Visitor | null;
574
574
  }
575
575
 
576
+ /**
577
+ * Build the AST-store visitor for `langId`. Returns `null` when AST is
578
+ * disabled or the language has no AST type map. db-free — passes an empty
579
+ * nodeIdMap. The main thread re-resolves parent node IDs in
580
+ * `features/ast.ts::collectFileAstRows`.
581
+ */
582
+ function buildAstVisitor(
583
+ langId: string,
584
+ defs: ExtractorOutput['definitions'],
585
+ relPath: string,
586
+ enabled: boolean,
587
+ ): Visitor | null {
588
+ if (!enabled) return null;
589
+ const astTypeMap = AST_TYPE_MAPS.get(langId);
590
+ if (!astTypeMap) return null;
591
+ const stringConfig = AST_STRING_CONFIGS.get(langId);
592
+ return createAstStoreVisitor(
593
+ astTypeMap,
594
+ defs,
595
+ relPath,
596
+ new Map<string, number>(),
597
+ stringConfig,
598
+ astStopRecurseKinds(langId),
599
+ );
600
+ }
601
+
602
+ /**
603
+ * Build the complexity visitor when enabled, the language has complexity
604
+ * rules, and at least one definition still lacks a `complexity` payload.
605
+ * Side-effect: extends `walkerOpts` with nesting-node types and a
606
+ * `getFunctionName` resolver suitable for this language.
607
+ */
608
+ function buildComplexityVisitor(
609
+ langId: string,
610
+ defs: ExtractorOutput['definitions'],
611
+ enabled: boolean,
612
+ walkerOpts: WalkOptions,
613
+ ): Visitor | null {
614
+ if (!enabled) return null;
615
+ const cRules = COMPLEXITY_RULES.get(langId);
616
+ if (!cRules || !defs.some((d) => hasFuncBody(d) && !d.complexity)) return null;
617
+
618
+ const hRules = HALSTEAD_RULES.get(langId);
619
+ const visitor = createComplexityVisitor(cRules, hRules, { fileLevelWalk: true, langId });
620
+ for (const t of cRules.nestingNodes) walkerOpts.nestingNodeTypes?.add(t);
621
+ const dfRules = DATAFLOW_RULES.get(langId);
622
+ walkerOpts.getFunctionName = (node: TreeSitterNode): string | null => {
623
+ const nameNode = node.childForFieldName('name');
624
+ if (nameNode) return nameNode.text;
625
+ // dfRules shape varies per language; visitor-utils accepts any shape
626
+ if (dfRules) return getFuncName(node, dfRules as any);
627
+ return null;
628
+ };
629
+ return visitor;
630
+ }
631
+
632
+ /** Build the CFG visitor when enabled and at least one definition still lacks blocks. */
633
+ function buildCfgVisitor(
634
+ langId: string,
635
+ defs: ExtractorOutput['definitions'],
636
+ enabled: boolean,
637
+ ): Visitor | null {
638
+ if (!enabled) return null;
639
+ const cfgRulesForLang = CFG_RULES.get(langId);
640
+ if (!cfgRulesForLang) return null;
641
+ const needsCfg = defs.some(
642
+ (d) => hasFuncBody(d) && d.cfg !== null && !Array.isArray(d.cfg?.blocks),
643
+ );
644
+ if (!needsCfg) return null;
645
+ return createCfgVisitor(cfgRulesForLang);
646
+ }
647
+
648
+ /** Build the dataflow visitor when enabled and `symbols.dataflow` is not yet populated. */
649
+ function buildDataflowVisitor(
650
+ langId: string,
651
+ symbols: ExtractorOutput,
652
+ enabled: boolean,
653
+ ): Visitor | null {
654
+ if (!enabled) return null;
655
+ const dfRules = DATAFLOW_RULES.get(langId);
656
+ if (!dfRules || symbols.dataflow) return null;
657
+ return createDataflowVisitor(dfRules);
658
+ }
659
+
576
660
  function setupVisitorsLocal(
577
661
  symbols: ExtractorOutput,
578
662
  relPath: string,
@@ -580,82 +664,161 @@ function setupVisitorsLocal(
580
664
  opts: WorkerParseRequest['opts'],
581
665
  ): SetupResult {
582
666
  const defs = symbols.definitions || [];
583
- const visitors: Visitor[] = [];
584
667
  const walkerOpts: WalkOptions = {
585
668
  functionNodeTypes: new Set<string>(),
586
669
  nestingNodeTypes: new Set<string>(),
587
670
  getFunctionName: (_node: TreeSitterNode) => null,
588
671
  };
589
672
 
590
- // AST-store: db-free pass an empty nodeIdMap. The main thread re-resolves
591
- // parent node IDs in features/ast.ts::collectFileAstRows.
592
- let astVisitor: Visitor | null = null;
593
- if (opts.ast) {
594
- const astTypeMap = AST_TYPE_MAPS.get(langId);
595
- if (astTypeMap) {
596
- const stringConfig = AST_STRING_CONFIGS.get(langId);
597
- astVisitor = createAstStoreVisitor(
598
- astTypeMap,
599
- defs,
600
- relPath,
601
- new Map<string, number>(),
602
- stringConfig,
603
- astStopRecurseKinds(langId),
604
- );
605
- visitors.push(astVisitor);
606
- }
607
- }
673
+ const astVisitor = buildAstVisitor(langId, defs, relPath, !!opts.ast);
674
+ const complexityVisitor = buildComplexityVisitor(langId, defs, !!opts.complexity, walkerOpts);
675
+ const cfgVisitor = buildCfgVisitor(langId, defs, !!opts.cfg);
676
+ const dataflowVisitor = buildDataflowVisitor(langId, symbols, !!opts.dataflow);
608
677
 
609
- // Complexity
610
- let complexityVisitor: Visitor | null = null;
611
- if (opts.complexity) {
612
- const cRules = COMPLEXITY_RULES.get(langId);
613
- if (cRules && defs.some((d) => hasFuncBody(d) && !d.complexity)) {
614
- const hRules = HALSTEAD_RULES.get(langId);
615
- complexityVisitor = createComplexityVisitor(cRules, hRules, {
616
- fileLevelWalk: true,
617
- langId,
618
- });
619
- for (const t of cRules.nestingNodes) walkerOpts.nestingNodeTypes?.add(t);
620
- const dfRules = DATAFLOW_RULES.get(langId);
621
- walkerOpts.getFunctionName = (node: TreeSitterNode): string | null => {
622
- const nameNode = node.childForFieldName('name');
623
- if (nameNode) return nameNode.text;
624
- // dfRules shape varies per language; visitor-utils accepts any shape
625
- if (dfRules) return getFuncName(node, dfRules as any);
626
- return null;
627
- };
628
- visitors.push(complexityVisitor);
629
- }
630
- }
678
+ const visitors: Visitor[] = [];
679
+ if (astVisitor) visitors.push(astVisitor);
680
+ if (complexityVisitor) visitors.push(complexityVisitor);
681
+ if (cfgVisitor) visitors.push(cfgVisitor);
682
+ if (dataflowVisitor) visitors.push(dataflowVisitor);
631
683
 
632
- // CFG
633
- let cfgVisitor: Visitor | null = null;
634
- if (opts.cfg) {
635
- const cfgRulesForLang = CFG_RULES.get(langId);
636
- if (
637
- cfgRulesForLang &&
638
- defs.some((d) => hasFuncBody(d) && d.cfg !== null && !Array.isArray(d.cfg?.blocks))
639
- ) {
640
- cfgVisitor = createCfgVisitor(cfgRulesForLang);
641
- visitors.push(cfgVisitor);
642
- }
684
+ return { visitors, walkerOpts, astVisitor, complexityVisitor, cfgVisitor, dataflowVisitor };
685
+ }
686
+
687
+ // ── Main parse handler ──────────────────────────────────────────────────────
688
+
689
+ /**
690
+ * Run tree-sitter parse + extractor on `code`. Returns `null` when either
691
+ * step yields no usable output. Throws (for the caller to report back to the
692
+ * pool) only on a hard tree-sitter parse error.
693
+ */
694
+ function parseAndExtract(
695
+ parser: Parser,
696
+ entry: LanguageRegistryEntry,
697
+ filePath: string,
698
+ code: string,
699
+ ): { tree: Tree; symbols: ExtractorOutput } | null {
700
+ let tree: Tree | null;
701
+ try {
702
+ tree = parser.parse(code);
703
+ } catch (e: unknown) {
704
+ // Parse error — report back but keep worker alive.
705
+ throw new Error(`parse failed: ${(e as Error).message}`);
643
706
  }
707
+ if (!tree) return null;
644
708
 
645
- // Dataflow
646
- let dataflowVisitor: Visitor | null = null;
647
- if (opts.dataflow) {
648
- const dfRules = DATAFLOW_RULES.get(langId);
649
- if (dfRules && !symbols.dataflow) {
650
- dataflowVisitor = createDataflowVisitor(dfRules);
651
- visitors.push(dataflowVisitor);
652
- }
709
+ // Extractor — on failure, skip file (ok:true, null) to match parser.ts
710
+ // behavior where extractor issues don't crash the build. Dispose the tree
711
+ // before returning null so WASM linear memory doesn't accumulate in the worker.
712
+ let symbols: ExtractorOutput | null;
713
+ try {
714
+ const query = _queries.get(entry.id);
715
+ // tree-sitter's Tree/Query are structurally compatible with
716
+ // TreeSitterTree/TreeSitterQuery at runtime — same cast style as
717
+ // parser.ts::wasmExtractSymbols (parser.ts:789).
718
+ symbols = entry.extractor(tree as any, filePath, query as any) ?? null;
719
+ } catch {
720
+ disposeTree(tree);
721
+ return null;
653
722
  }
723
+ if (!symbols) {
724
+ disposeTree(tree);
725
+ return null;
726
+ }
727
+ return { tree, symbols };
728
+ }
654
729
 
655
- return { visitors, walkerOpts, astVisitor, complexityVisitor, cfgVisitor, dataflowVisitor };
730
+ /**
731
+ * Project the visitor `ast-store` rows into the wire-safe shape returned to
732
+ * the main thread. Strips `file` and `parentNodeId` — both are re-resolved in
733
+ * `features/ast.ts::collectFileAstRows`. Always returns an array (even empty)
734
+ * so `engine.ts::fileNeedsWasmTree` doesn't treat the file as un-walked and
735
+ * trigger a full ensureWasmTrees re-parse (#1036).
736
+ */
737
+ function projectAstNodes(results: WalkResults): SerializedExtractorOutput['astNodes'] {
738
+ const astRows = (results['ast-store'] || []) as Array<{
739
+ line: number;
740
+ kind: string;
741
+ name: string | null | undefined;
742
+ text: string | null;
743
+ receiver: string | null;
744
+ file?: string;
745
+ parentNodeId?: number | null;
746
+ }>;
747
+ return astRows.map((n) => ({
748
+ line: n.line,
749
+ kind: n.kind,
750
+ name: n.name ?? '',
751
+ text: n.text ?? undefined,
752
+ receiver: n.receiver ?? undefined,
753
+ }));
656
754
  }
657
755
 
658
- // ── Main parse handler ──────────────────────────────────────────────────────
756
+ /**
757
+ * Run the configured visitor walk over `tree.rootNode` and apply each
758
+ * visitor's results back onto `symbols`. Returns the serialized astNodes
759
+ * (or `undefined` when AST is disabled / no rows produced).
760
+ *
761
+ * Mirrors engine.ts:791-829. Runs BEFORE `tree.delete()` because
762
+ * storeComplexityResults / storeCfgResults read `funcNode` off live nodes.
763
+ */
764
+ function runVisitorWalk(
765
+ tree: Tree,
766
+ symbols: ExtractorOutput,
767
+ langId: string,
768
+ setup: SetupResult,
769
+ ): SerializedExtractorOutput['astNodes'] {
770
+ if (setup.visitors.length === 0) return undefined;
771
+ // rootNode shape matches TreeSitterNode at runtime — same cast as parser.ts:789.
772
+ const results = walkWithVisitors(tree.rootNode as any, setup.visitors, langId, setup.walkerOpts);
773
+ const defs = symbols.definitions || [];
774
+ let serializedAstNodes: SerializedExtractorOutput['astNodes'];
775
+ if (setup.astVisitor) serializedAstNodes = projectAstNodes(results);
776
+ if (setup.complexityVisitor) storeComplexityResults(results, defs, langId);
777
+ if (setup.cfgVisitor) storeCfgResults(results, defs);
778
+ if (setup.dataflowVisitor) symbols.dataflow = results.dataflow as DataflowResult;
779
+ return serializedAstNodes;
780
+ }
781
+
782
+ /**
783
+ * Pack the in-memory ExtractorOutput into the structured-clone-safe shape
784
+ * sent back across the worker boundary. Converts the typeMap into a tuple
785
+ * array and intentionally omits `_tree` (cannot cross the boundary).
786
+ */
787
+ function serializeExtractorOutput(
788
+ symbols: ExtractorOutput,
789
+ langId: LanguageId,
790
+ code: string,
791
+ astNodes: SerializedExtractorOutput['astNodes'],
792
+ ): SerializedExtractorOutput {
793
+ return {
794
+ definitions: symbols.definitions,
795
+ calls: symbols.calls,
796
+ imports: symbols.imports,
797
+ classes: symbols.classes,
798
+ exports: symbols.exports,
799
+ typeMap: Array.from(symbols.typeMap.entries()),
800
+ _langId: langId,
801
+ _lineCount: code.split('\n').length,
802
+ dataflow: symbols.dataflow,
803
+ astNodes,
804
+ };
805
+ }
806
+
807
+ /**
808
+ * Release WASM linear memory backing a tree. Best-effort — swallows errors so
809
+ * the worker keeps serving requests. Deferring this would let trees accumulate
810
+ * in the worker's WASM heap and defeat the point of isolating parse calls.
811
+ */
812
+ function disposeTree(tree: Tree | null): void {
813
+ if (!tree) return;
814
+ const deletable = tree as unknown as { delete?: () => void };
815
+ if (typeof deletable.delete !== 'function') return;
816
+ try {
817
+ deletable.delete();
818
+ } catch {
819
+ // best-effort cleanup — swallow; worker continues.
820
+ }
821
+ }
659
822
 
660
823
  async function handleParse(msg: WorkerParseRequest): Promise<SerializedExtractorOutput | null> {
661
824
  const ext = path.extname(msg.filePath).toLowerCase();
@@ -666,100 +829,20 @@ async function handleParse(msg: WorkerParseRequest): Promise<SerializedExtractor
666
829
  const parser = await loadLanguageLazy(entry);
667
830
  if (!parser) return null;
668
831
 
669
- let tree: Tree | null = null;
670
- try {
671
- try {
672
- tree = parser.parse(msg.code);
673
- } catch (e: unknown) {
674
- // Parse error — report back but keep worker alive.
675
- throw new Error(`parse failed: ${(e as Error).message}`);
676
- }
677
- if (!tree) return null;
678
-
679
- // Extractor — on failure, skip file (ok:true, null) to match parser.ts
680
- // behavior where extractor issues don't crash the build.
681
- let symbols: ExtractorOutput | null;
682
- try {
683
- const query = _queries.get(entry.id);
684
- // tree-sitter's Tree/Query are structurally compatible with
685
- // TreeSitterTree/TreeSitterQuery at runtime — same cast style as
686
- // parser.ts::wasmExtractSymbols (parser.ts:789).
687
- symbols = entry.extractor(tree as any, msg.filePath, query as any) ?? null;
688
- } catch {
689
- return null;
690
- }
691
- if (!symbols) return null;
692
-
693
- // Unified visitor walk — mirrors engine.ts:791-829. Runs BEFORE tree.delete()
694
- // because storeComplexityResults/storeCfgResults read funcNode off live nodes.
695
- const { visitors, walkerOpts, astVisitor, complexityVisitor, cfgVisitor, dataflowVisitor } =
696
- setupVisitorsLocal(symbols, msg.filePath, entry.id, msg.opts);
832
+ const parsed = parseAndExtract(parser, entry, msg.filePath, msg.code);
833
+ if (!parsed) return null;
834
+ const { tree, symbols } = parsed;
697
835
 
698
- // astNodes are kept in the serialized shape (without `file`/`parentNodeId`),
836
+ try {
837
+ const setup = setupVisitorsLocal(symbols, msg.filePath, entry.id, msg.opts);
838
+ // astNodes kept in the serialized shape (without `file`/`parentNodeId`),
699
839
  // not assigned back to symbols.astNodes — ExtractorOutput.astNodes is
700
840
  // ASTNodeRow[] (DB row shape with node_id), which is a different type.
701
- let serializedAstNodes: SerializedExtractorOutput['astNodes'];
702
-
703
- if (visitors.length > 0) {
704
- // rootNode shape matches TreeSitterNode at runtime — same cast as parser.ts:789.
705
- const results = walkWithVisitors(tree.rootNode as any, visitors, entry.id, walkerOpts);
706
-
707
- const defs = symbols.definitions || [];
708
- if (astVisitor) {
709
- const astRows = (results['ast-store'] || []) as Array<{
710
- line: number;
711
- kind: string;
712
- name: string | null | undefined;
713
- text: string | null;
714
- receiver: string | null;
715
- file?: string;
716
- parentNodeId?: number | null;
717
- }>;
718
- // Always set an array (even empty) — leaving astNodes undefined makes
719
- // engine.ts::fileNeedsWasmTree treat the file as un-walked and trigger
720
- // a full ensureWasmTrees re-parse of every WASM-parseable file (#1036).
721
- // Strip `file` and `parentNodeId` — main thread re-resolves both in
722
- // features/ast.ts::collectFileAstRows.
723
- serializedAstNodes = astRows.map((n) => ({
724
- line: n.line,
725
- kind: n.kind,
726
- name: n.name ?? '',
727
- text: n.text ?? undefined,
728
- receiver: n.receiver ?? undefined,
729
- }));
730
- }
731
-
732
- if (complexityVisitor) storeComplexityResults(results, defs, entry.id);
733
- if (cfgVisitor) storeCfgResults(results, defs);
734
- if (dataflowVisitor) symbols.dataflow = results.dataflow as DataflowResult;
735
- }
736
-
737
- // Serialize — convert Map<string, TypeMapEntry> to tuple array for the wire.
738
- const serialized: SerializedExtractorOutput = {
739
- definitions: symbols.definitions,
740
- calls: symbols.calls,
741
- imports: symbols.imports,
742
- classes: symbols.classes,
743
- exports: symbols.exports,
744
- typeMap: Array.from(symbols.typeMap.entries()),
745
- _langId: entry.id as LanguageId,
746
- _lineCount: msg.code.split('\n').length,
747
- dataflow: symbols.dataflow,
748
- astNodes: serializedAstNodes,
749
- };
750
- // _tree is deliberately not serialized — it cannot cross the worker boundary.
751
- return serialized;
841
+ const serializedAstNodes = runVisitorWalk(tree, symbols, entry.id, setup);
842
+ return serializeExtractorOutput(symbols, entry.id as LanguageId, msg.code, serializedAstNodes);
752
843
  } finally {
753
- // ALWAYS release WASM memory before responding. Deferring this would let
754
- // trees accumulate in the worker's WASM heap across requests and defeat
755
- // the point of isolating parse calls.
756
- if (tree && typeof (tree as unknown as { delete?: () => void }).delete === 'function') {
757
- try {
758
- (tree as unknown as { delete: () => void }).delete();
759
- } catch {
760
- // best-effort cleanup — swallow; worker continues.
761
- }
762
- }
844
+ // ALWAYS release WASM memory before responding (see disposeTree note).
845
+ disposeTree(tree);
763
846
  }
764
847
  }
765
848
 
@@ -5,7 +5,7 @@ import type {
5
5
  TreeSitterNode,
6
6
  TreeSitterTree,
7
7
  } from '../types.js';
8
- import { findChild, nodeEndLine } from './helpers.js';
8
+ import { findChild, iterChildren, nodeEndLine, PUNCTUATION_TOKENS } from './helpers.js';
9
9
 
10
10
  /**
11
11
  * Extract symbols from Elixir files.
@@ -197,77 +197,104 @@ function extractElixirParams(defCallNode: TreeSitterNode): SubDeclaration[] {
197
197
  }
198
198
 
199
199
  /**
200
- * Recursively walk a parameter pattern and emit each bound identifier as a
201
- * `parameter` child. Handles bare identifiers, default-value `a \\ default`,
202
- * list-cons `[head | tail]`, list `[a, b, c]`, tuple `{x, y}`, and
203
- * map / struct destructuring (`%{k: v}`, `%Foo{k: v}`).
200
+ * Walk a parameter pattern and emit each bound identifier as a `parameter`
201
+ * child. Handles bare identifiers, default-value `a \\ default`, list-cons
202
+ * `[head | tail]`, list `[a, b, c]`, tuple `{x, y}`, and map / struct
203
+ * destructuring (`%{k: v}`, `%Foo{k: v}`).
204
+ *
205
+ * Implemented as an iterative worklist (rather than recursion + helpers) so
206
+ * the call graph has no function-level cycle: only one function performs the
207
+ * traversal and it invokes only leaf helpers (`pushSubNodes`, `pushMapValues`).
204
208
  */
205
- function collectElixirParamIdentifiers(node: TreeSitterNode, out: SubDeclaration[]): void {
206
- switch (node.type) {
207
- case 'identifier':
208
- out.push({ name: node.text, kind: 'parameter', line: node.startPosition.row + 1 });
209
- return;
210
- case 'binary_operator': {
211
- // `name \\ default` (default-value) binds the left operand only.
212
- // `head | tail` (list-cons, appears inside a `list` pattern) binds both operands.
213
- const op = node.child(1);
214
- if (!op) return;
215
- if (op.type === '\\\\') {
216
- const left = node.child(0);
217
- if (left) collectElixirParamIdentifiers(left, out);
218
- return;
219
- }
220
- if (op.type === '|') {
221
- const left = node.child(0);
222
- const right = node.child(2);
223
- if (left) collectElixirParamIdentifiers(left, out);
224
- if (right) collectElixirParamIdentifiers(right, out);
225
- return;
226
- }
227
- return;
209
+ function collectElixirParamIdentifiers(root: TreeSitterNode, out: SubDeclaration[]): void {
210
+ const stack: TreeSitterNode[] = [root];
211
+ while (stack.length > 0) {
212
+ const node = stack.pop();
213
+ if (!node) continue;
214
+ switch (node.type) {
215
+ case 'identifier':
216
+ out.push({ name: node.text, kind: 'parameter', line: node.startPosition.row + 1 });
217
+ break;
218
+ case 'binary_operator':
219
+ pushElixirBinaryOperatorOperands(node, stack);
220
+ break;
221
+ case 'list':
222
+ case 'tuple':
223
+ pushElixirSequenceItems(node, stack);
224
+ break;
225
+ case 'map':
226
+ pushElixirMapValues(node, stack);
227
+ break;
228
228
  }
229
- case 'list':
230
- // `[a, b, c]` or `[head | tail]` — walk children, skipping punctuation. The
231
- // `|` cons case is handled by the `binary_operator` arm when we recurse.
232
- for (let i = 0; i < node.childCount; i++) {
233
- const c = node.child(i);
234
- if (!c || c.type === '[' || c.type === ']' || c.type === ',') continue;
235
- collectElixirParamIdentifiers(c, out);
236
- }
237
- return;
238
- case 'tuple':
239
- for (let i = 0; i < node.childCount; i++) {
240
- const c = node.child(i);
241
- if (!c || c.type === '{' || c.type === '}' || c.type === ',') continue;
242
- collectElixirParamIdentifiers(c, out);
243
- }
244
- return;
245
- case 'map':
246
- // `%{k: v}` or `%Foo{k: v}` — walk map_content > keywords > pair and emit each
247
- // pair's value side (the bound name). The struct alias (`Foo`) is a type, not a
248
- // bound identifier, so the leading `struct` child is intentionally skipped.
249
- for (let i = 0; i < node.childCount; i++) {
250
- const c = node.child(i);
251
- if (c && c.type === 'map_content') collectElixirMapBindings(c, out);
252
- }
253
- return;
254
229
  }
255
230
  }
256
231
 
257
- function collectElixirMapBindings(content: TreeSitterNode, out: SubDeclaration[]): void {
258
- for (let i = 0; i < content.childCount; i++) {
259
- const kws = content.child(i);
260
- if (!kws || kws.type !== 'keywords') continue;
261
- for (let j = 0; j < kws.childCount; j++) {
262
- const pair = kws.child(j);
263
- if (!pair || pair.type !== 'pair') continue;
264
- for (let k = 0; k < pair.childCount; k++) {
265
- const part = pair.child(k);
266
- if (!part || part.type === 'keyword') continue;
267
- collectElixirParamIdentifiers(part, out);
232
+ /**
233
+ * Push the binding-relevant operands of a `binary_operator` parameter onto the
234
+ * worklist:
235
+ * - `name \\ default` (default-value) binds the left operand only.
236
+ * - `head | tail` (list-cons, appears inside a `list` pattern) binds both.
237
+ */
238
+ function pushElixirBinaryOperatorOperands(node: TreeSitterNode, stack: TreeSitterNode[]): void {
239
+ const op = node.child(1);
240
+ if (!op) return;
241
+ if (op.type === '\\\\') {
242
+ const left = node.child(0);
243
+ if (left) stack.push(left);
244
+ return;
245
+ }
246
+ if (op.type === '|') {
247
+ const right = node.child(2);
248
+ const left = node.child(0);
249
+ if (right) stack.push(right);
250
+ if (left) stack.push(left);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Push the binding-relevant elements of a `list` or `tuple` parameter onto
256
+ * the worklist, skipping punctuation tokens.
257
+ *
258
+ * Items are pushed in reverse document order so that, with a LIFO stack, they
259
+ * are processed left-to-right — preserving the source ordering of bound names.
260
+ */
261
+ function pushElixirSequenceItems(node: TreeSitterNode, stack: TreeSitterNode[]): void {
262
+ const items: TreeSitterNode[] = [...iterChildren(node, PUNCTUATION_TOKENS)];
263
+ for (let i = items.length - 1; i >= 0; i--) {
264
+ stack.push(items[i] as TreeSitterNode);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Push the value side of every pair in a `map` or `%Foo{...}` parameter onto
270
+ * the worklist. The struct alias (`Foo`) is a type, not a bound identifier, so
271
+ * the leading `struct` child is intentionally skipped.
272
+ *
273
+ * Items are collected in document order and pushed in reverse so that, with a
274
+ * LIFO stack, they are processed left-to-right — preserving source ordering.
275
+ */
276
+ function pushElixirMapValues(node: TreeSitterNode, stack: TreeSitterNode[]): void {
277
+ const parts: TreeSitterNode[] = [];
278
+ for (let i = 0; i < node.childCount; i++) {
279
+ const content = node.child(i);
280
+ if (!content || content.type !== 'map_content') continue;
281
+ for (let j = 0; j < content.childCount; j++) {
282
+ const kws = content.child(j);
283
+ if (!kws || kws.type !== 'keywords') continue;
284
+ for (let k = 0; k < kws.childCount; k++) {
285
+ const pair = kws.child(k);
286
+ if (!pair || pair.type !== 'pair') continue;
287
+ for (let p = 0; p < pair.childCount; p++) {
288
+ const part = pair.child(p);
289
+ if (!part || part.type === 'keyword') continue;
290
+ parts.push(part);
291
+ }
268
292
  }
269
293
  }
270
294
  }
295
+ for (let i = parts.length - 1; i >= 0; i--) {
296
+ stack.push(parts[i] as TreeSitterNode);
297
+ }
271
298
  }
272
299
 
273
300
  function handleDefprotocol(node: TreeSitterNode, ctx: ExtractorOutput): void {