@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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Shared call-edge resolution — used by both the full build pipeline
3
+ * (build-edges.ts) and the incremental watch path (incremental.ts).
4
+ *
5
+ * Both callers supply a `CallNodeLookup` adapter that abstracts their
6
+ * node-lookup mechanism (pre-loaded Maps vs. per-query SQLite statements).
7
+ * The resolution logic lives here exactly once.
8
+ */
9
+ import { computeConfidence } from '../resolve.js';
10
+
11
+ // ── Public interface ─────────────────────────────────────────────────────
12
+
13
+ export interface CallNodeLookup {
14
+ byNameAndFile(
15
+ name: string,
16
+ file: string,
17
+ ): ReadonlyArray<{ id: number; file: string; kind?: string }>;
18
+ byName(name: string): ReadonlyArray<{ id: number; file: string; kind?: string }>;
19
+ isBarrel(file: string): boolean;
20
+ resolveBarrel(barrelFile: string, symbolName: string): string | null;
21
+ nodeId(name: string, kind: string, file: string, line: number): { id: number } | undefined;
22
+ }
23
+
24
+ export const RECEIVER_KINDS = new Set(['class', 'struct', 'interface', 'type', 'module']);
25
+
26
+ // ── Shared resolution functions ──────────────────────────────────────────
27
+
28
+ export function findCaller(
29
+ lookup: CallNodeLookup,
30
+ call: { line: number },
31
+ definitions: ReadonlyArray<{
32
+ name: string;
33
+ kind: string;
34
+ line: number;
35
+ endLine?: number | null;
36
+ }>,
37
+ relPath: string,
38
+ fileNodeRow: { id: number },
39
+ ): { id: number } {
40
+ let caller: { id: number } | null = null;
41
+ let callerSpan = Infinity;
42
+ for (const def of definitions) {
43
+ if (def.line <= call.line) {
44
+ const end = def.endLine || Infinity;
45
+ if (call.line <= end) {
46
+ const span = end - def.line;
47
+ if (span < callerSpan) {
48
+ const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
49
+ if (row) {
50
+ caller = row;
51
+ callerSpan = span;
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ return caller ?? fileNodeRow;
58
+ }
59
+
60
+ export function resolveByMethodOrGlobal(
61
+ lookup: CallNodeLookup,
62
+ call: { name: string; receiver?: string | null },
63
+ relPath: string,
64
+ typeMap: Map<string, unknown>,
65
+ ): ReadonlyArray<{ id: number; file: string }> {
66
+ if (call.receiver) {
67
+ const typeEntry = typeMap.get(call.receiver);
68
+ const typeName = typeEntry
69
+ ? typeof typeEntry === 'string'
70
+ ? typeEntry
71
+ : (typeEntry as { type?: string }).type
72
+ : null;
73
+ if (typeName) {
74
+ const typed = lookup.byName(`${typeName}.${call.name}`).filter((n) => n.kind === 'method');
75
+ if (typed.length > 0) return typed;
76
+ }
77
+ }
78
+ if (
79
+ !call.receiver ||
80
+ call.receiver === 'this' ||
81
+ call.receiver === 'self' ||
82
+ call.receiver === 'super'
83
+ ) {
84
+ return lookup.byName(call.name).filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
85
+ }
86
+ return [];
87
+ }
88
+
89
+ export function resolveCallTargets(
90
+ lookup: CallNodeLookup,
91
+ call: { name: string; receiver?: string | null },
92
+ relPath: string,
93
+ importedNames: Map<string, string>,
94
+ typeMap: Map<string, unknown>,
95
+ ): { targets: Array<{ id: number; file: string }>; importedFrom: string | undefined } {
96
+ const importedFrom = importedNames.get(call.name);
97
+ let targets: ReadonlyArray<{ id: number; file: string }> | undefined;
98
+
99
+ if (importedFrom) {
100
+ targets = lookup.byNameAndFile(call.name, importedFrom);
101
+ if (targets.length === 0 && lookup.isBarrel(importedFrom)) {
102
+ const actualSource = lookup.resolveBarrel(importedFrom, call.name);
103
+ if (actualSource) {
104
+ targets = lookup.byNameAndFile(call.name, actualSource);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (!targets || targets.length === 0) {
110
+ targets = lookup.byNameAndFile(call.name, relPath);
111
+ if (targets.length === 0) {
112
+ targets = resolveByMethodOrGlobal(lookup, call, relPath, typeMap);
113
+ }
114
+ }
115
+
116
+ const resolved = [...(targets ?? [])];
117
+ if (resolved.length > 1) {
118
+ resolved.sort((a, b) => {
119
+ const confA = computeConfidence(relPath, a.file, importedFrom ?? null);
120
+ const confB = computeConfidence(relPath, b.file, importedFrom ?? null);
121
+ return confB - confA;
122
+ });
123
+ }
124
+ return { targets: resolved, importedFrom };
125
+ }
126
+
127
+ /**
128
+ * Resolve the receiver-type edge for a call site.
129
+ * Returns the edge tuple to insert, or null if nothing matched or the edge
130
+ * was already seen. Callers are responsible for the actual DB/array insert.
131
+ *
132
+ * Receiver resolution collects all same-file candidates first (no kind
133
+ * filter), falls back to global candidates only when the same-file set is
134
+ * entirely empty, then filters the chosen set by RECEIVER_KINDS. This
135
+ * matches the native Rust build path: if a file imports a name that happens
136
+ * to be emitted as `kind='function'` in the importer, the same-file set is
137
+ * non-empty and blocks the global fallback, so no receiver edge is emitted.
138
+ * Keeping this behaviour identical to the Rust path maintains engine parity.
139
+ */
140
+ export function resolveReceiverEdge(
141
+ lookup: CallNodeLookup,
142
+ call: { name: string; receiver: string },
143
+ caller: { id: number },
144
+ relPath: string,
145
+ typeMap: Map<string, unknown>,
146
+ seenCallEdges: Set<string>,
147
+ ): { callerId: number; receiverId: number; confidence: number } | null {
148
+ const typeEntry = typeMap.get(call.receiver);
149
+ const typeName = typeEntry
150
+ ? typeof typeEntry === 'string'
151
+ ? typeEntry
152
+ : ((typeEntry as { type?: string }).type ?? null)
153
+ : null;
154
+ const typeConfidence =
155
+ typeEntry && typeof typeEntry !== 'string'
156
+ ? ((typeEntry as { confidence?: number }).confidence ?? null)
157
+ : null;
158
+ const effectiveReceiver = typeName || call.receiver;
159
+ // Filter-before: apply RECEIVER_KINDS to same-file candidates first, then
160
+ // fall back to global candidates (also filtered) only when same-file yields
161
+ // nothing. This prevents an imported name emitted as kind='function' in the
162
+ // importing file from blocking the fallback to the actual class/struct/etc.
163
+ // node in the defining file.
164
+ const sameFileCandidates = lookup
165
+ .byNameAndFile(effectiveReceiver, relPath)
166
+ .filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
167
+ const candidates =
168
+ sameFileCandidates.length > 0
169
+ ? sameFileCandidates
170
+ : lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
171
+ if (candidates.length === 0) return null;
172
+ const recvTarget = candidates[0]!;
173
+ const recvKey = `recv|${caller.id}|${recvTarget.id}`;
174
+ if (seenCallEdges.has(recvKey)) return null;
175
+ seenCallEdges.add(recvKey);
176
+ return {
177
+ callerId: caller.id,
178
+ receiverId: recvTarget.id,
179
+ confidence: typeConfidence ?? (typeName ? 0.9 : 0.7),
180
+ };
181
+ }
@@ -76,108 +76,117 @@ export function passesIncludeExclude(
76
76
  return true;
77
77
  }
78
78
 
79
+ /** Per-walk state computed once at the top-level invocation. */
80
+ interface CollectContext {
81
+ readonly rootDir: string;
82
+ readonly includeRegexes: readonly RegExp[];
83
+ readonly excludeRegexes: readonly RegExp[];
84
+ readonly hasGlobFilters: boolean;
85
+ readonly extraIgnore: Set<string> | null;
86
+ readonly visited: Set<string>;
87
+ }
88
+
89
+ /** Detect a symlink loop for `dir`. Returns true if `dir` was already visited. */
90
+ function isSymlinkLoop(dir: string, visited: Set<string>): boolean {
91
+ let realDir: string;
92
+ try {
93
+ realDir = fs.realpathSync(dir);
94
+ } catch {
95
+ return true;
96
+ }
97
+ if (visited.has(realDir)) {
98
+ warn(`Symlink loop detected, skipping: ${dir}`);
99
+ return true;
100
+ }
101
+ visited.add(realDir);
102
+ return false;
103
+ }
104
+
105
+ /** Read directory entries, returning null on error (already logged). */
106
+ function readDirSafe(dir: string): fs.Dirent[] | null {
107
+ try {
108
+ return fs.readdirSync(dir, { withFileTypes: true });
109
+ } catch (err: unknown) {
110
+ warn(`Cannot read directory ${dir}: ${(err as Error).message}`);
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** True if `entry` is a source file we should collect under `ctx`. */
116
+ function isCollectableSourceFile(full: string, entry: fs.Dirent, ctx: CollectContext): boolean {
117
+ if (!EXTENSIONS.has(path.extname(entry.name))) return false;
118
+ if (!ctx.hasGlobFilters) return true;
119
+ const rel = normalizePath(path.relative(ctx.rootDir, full));
120
+ return passesIncludeExclude(rel, ctx.includeRegexes, ctx.excludeRegexes);
121
+ }
122
+
123
+ function walkCollect(
124
+ dir: string,
125
+ files: string[],
126
+ directories: Set<string> | null,
127
+ ctx: CollectContext,
128
+ ): void {
129
+ if (isSymlinkLoop(dir, ctx.visited)) return;
130
+
131
+ const entries = readDirSafe(dir);
132
+ if (!entries) return;
133
+
134
+ let hasFiles = false;
135
+ for (const entry of entries) {
136
+ if (shouldSkipEntry(entry, ctx.extraIgnore)) continue;
137
+
138
+ const full = path.join(dir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ walkCollect(full, files, directories, ctx);
141
+ } else if (isCollectableSourceFile(full, entry, ctx)) {
142
+ files.push(full);
143
+ hasFiles = true;
144
+ }
145
+ }
146
+ if (directories && hasFiles) {
147
+ directories.add(dir);
148
+ }
149
+ }
150
+
79
151
  /**
80
152
  * Recursively collect all source files under `dir`.
81
153
  * When `directories` is a Set, also tracks which directories contain files.
82
154
  *
83
- * The first invocation establishes `dir` as the project root against which
84
- * `config.include` / `config.exclude` globs are matched.
155
+ * `dir` establishes the project root against which `config.include` /
156
+ * `config.exclude` globs are matched.
85
157
  */
86
158
  export function collectFiles(
87
159
  dir: string,
88
160
  files: string[],
89
161
  config: Partial<CodegraphConfig>,
90
162
  directories: Set<string>,
91
- _visited?: Set<string>,
92
- _rootDir?: string,
93
- _includeRegexes?: readonly RegExp[],
94
- _excludeRegexes?: readonly RegExp[],
95
163
  ): { files: string[]; directories: Set<string> };
96
164
  export function collectFiles(
97
165
  dir: string,
98
166
  files?: string[],
99
167
  config?: Partial<CodegraphConfig>,
100
168
  directories?: null,
101
- _visited?: Set<string>,
102
- _rootDir?: string,
103
- _includeRegexes?: readonly RegExp[],
104
- _excludeRegexes?: readonly RegExp[],
105
169
  ): string[];
106
170
  export function collectFiles(
107
171
  dir: string,
108
172
  files: string[] = [],
109
173
  config: Partial<CodegraphConfig> = {},
110
174
  directories: Set<string> | null = null,
111
- _visited: Set<string> = new Set(),
112
- _rootDir?: string,
113
- _includeRegexes?: readonly RegExp[],
114
- _excludeRegexes?: readonly RegExp[],
115
175
  ): string[] | { files: string[]; directories: Set<string> } {
116
176
  const trackDirs = directories instanceof Set;
117
- let hasFiles = false;
118
-
119
- // First call: compute root and compile include/exclude patterns once,
120
- // then pass them down recursive calls so we don't recompile per directory.
121
- const rootDir = _rootDir ?? dir;
122
- const includeRegexes = _includeRegexes ?? compileGlobs(config.include);
123
- const excludeRegexes = _excludeRegexes ?? compileGlobs(config.exclude);
124
- const hasGlobFilters = includeRegexes.length > 0 || excludeRegexes.length > 0;
125
-
126
- // Merge config ignoreDirs with defaults
127
- const extraIgnore = config.ignoreDirs ? new Set(config.ignoreDirs) : null;
128
-
129
- // Detect symlink loops (before I/O to avoid wasted readdirSync)
130
- let realDir: string;
131
- try {
132
- realDir = fs.realpathSync(dir);
133
- } catch {
134
- return trackDirs ? { files, directories: directories as Set<string> } : files;
135
- }
136
- if (_visited.has(realDir)) {
137
- warn(`Symlink loop detected, skipping: ${dir}`);
138
- return trackDirs ? { files, directories: directories as Set<string> } : files;
139
- }
140
- _visited.add(realDir);
141
-
142
- let entries: fs.Dirent[];
143
- try {
144
- entries = fs.readdirSync(dir, { withFileTypes: true });
145
- } catch (err: unknown) {
146
- warn(`Cannot read directory ${dir}: ${(err as Error).message}`);
147
- return trackDirs ? { files, directories: directories as Set<string> } : files;
148
- }
177
+ const includeRegexes = compileGlobs(config.include);
178
+ const excludeRegexes = compileGlobs(config.exclude);
179
+ const ctx: CollectContext = {
180
+ rootDir: dir,
181
+ includeRegexes,
182
+ excludeRegexes,
183
+ hasGlobFilters: includeRegexes.length > 0 || excludeRegexes.length > 0,
184
+ extraIgnore: config.ignoreDirs ? new Set(config.ignoreDirs) : null,
185
+ visited: new Set(),
186
+ };
149
187
 
150
- for (const entry of entries) {
151
- if (shouldSkipEntry(entry, extraIgnore)) continue;
188
+ walkCollect(dir, files, trackDirs ? (directories as Set<string>) : null, ctx);
152
189
 
153
- const full = path.join(dir, entry.name);
154
- if (entry.isDirectory()) {
155
- if (trackDirs) {
156
- collectFiles(
157
- full,
158
- files,
159
- config,
160
- directories as Set<string>,
161
- _visited,
162
- rootDir,
163
- includeRegexes,
164
- excludeRegexes,
165
- );
166
- } else {
167
- collectFiles(full, files, config, null, _visited, rootDir, includeRegexes, excludeRegexes);
168
- }
169
- } else if (EXTENSIONS.has(path.extname(entry.name))) {
170
- if (hasGlobFilters) {
171
- const rel = normalizePath(path.relative(rootDir, full));
172
- if (!passesIncludeExclude(rel, includeRegexes, excludeRegexes)) continue;
173
- }
174
- files.push(full);
175
- hasFiles = true;
176
- }
177
- }
178
- if (trackDirs && hasFiles) {
179
- (directories as Set<string>).add(dir);
180
- }
181
190
  return trackDirs ? { files, directories: directories as Set<string> } : files;
182
191
  }
183
192