codesynapt 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +686 -0
  3. package/LICENSES.md +141 -0
  4. package/README.md +331 -0
  5. package/electron/main.cjs +2849 -0
  6. package/electron/plugin-loader.cjs +184 -0
  7. package/electron/preload.cjs +108 -0
  8. package/package.json +216 -0
  9. package/packages/core/bin/codesynapt-mcp.cjs +611 -0
  10. package/packages/core/bin/codesynapt.cjs +1933 -0
  11. package/packages/core/legacy.js +300 -0
  12. package/packages/core/lib/control-server.cjs +1539 -0
  13. package/packages/core/lib/embedding.cjs +89 -0
  14. package/packages/core/lib/logger.cjs +63 -0
  15. package/packages/core/lib/search-cache.cjs +140 -0
  16. package/packages/core/lib/search-worker.cjs +255 -0
  17. package/packages/core/lib/search.cjs +211 -0
  18. package/packages/core/lib/symbol-graph.cjs +402 -0
  19. package/packages/core/lib/symbol-parser-js.cjs +542 -0
  20. package/packages/core/lib/symbol-parser-misc.cjs +394 -0
  21. package/packages/core/lib/symbol-parser-py.cjs +215 -0
  22. package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
  23. package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
  24. package/packages/core/monorepo.js +310 -0
  25. package/packages/core/parser.js +2234 -0
  26. package/packages/core/scanner.js +623 -0
  27. package/plugin-api/LICENSE +21 -0
  28. package/plugin-api/README.md +114 -0
  29. package/plugin-api/docs/01-getting-started.md +197 -0
  30. package/plugin-api/docs/02-concepts.md +269 -0
  31. package/plugin-api/docs/api-reference.md +463 -0
  32. package/plugin-api/docs/troubleshooting.md +332 -0
  33. package/plugin-api/docs/types/exporter.md +377 -0
  34. package/plugin-api/docs/types/theme.md +312 -0
  35. package/plugin-api/examples/hello-world-plugin/README.md +70 -0
  36. package/plugin-api/examples/hello-world-plugin/main.js +36 -0
  37. package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
  38. package/plugin-api/examples/mermaid-exporter/README.md +125 -0
  39. package/plugin-api/examples/mermaid-exporter/main.js +58 -0
  40. package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
  41. package/plugin-api/examples/rust-parser/README.md +71 -0
  42. package/plugin-api/examples/rust-parser/main.js +123 -0
  43. package/plugin-api/examples/rust-parser/manifest.json +12 -0
  44. package/plugin-api/examples/sunset-theme/README.md +95 -0
  45. package/plugin-api/examples/sunset-theme/manifest.json +12 -0
  46. package/plugin-api/examples/sunset-theme/theme.css +31 -0
  47. package/plugin-api/package.json +20 -0
  48. package/plugin-api/types.d.ts +395 -0
  49. package/public/app.js +6837 -0
  50. package/public/backend.js +285 -0
  51. package/public/index.html +647 -0
  52. package/public/plugin-host.js +321 -0
  53. package/public/style.css +4359 -0
  54. package/public/vendor/three.module.js +53044 -0
  55. package/scripts/competitor-watch.mjs +144 -0
  56. package/scripts/copy-vendor.js +21 -0
  57. package/scripts/download-bundled-node.cjs +53 -0
  58. package/scripts/fuses-after-pack.cjs +34 -0
  59. package/scripts/license-check.js +119 -0
  60. package/scripts/perf-test.js +200 -0
  61. package/server.js +132 -0
@@ -0,0 +1,2234 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { parse as babelParse } from '@babel/parser'
4
+ import traverseModule from '@babel/traverse'
5
+ const traverse = traverseModule.default || traverseModule
6
+
7
+ // ─── Public API ───────────────────────────────────────────────
8
+ // Returns { imports, routes?, apiCalls? }
9
+ // imports — file-to-file static dependency edges (see resolveImport)
10
+ // routes — server-side route declarations { method, path } in this file
11
+ // apiCalls — client-side HTTP calls { method, url } from this file
12
+ // Routes + apiCalls are used to draw "full-stack" edges between client code
13
+ // that calls an URL and the server file that handles that URL.
14
+ export function parseFile(absPath, content, ext) {
15
+ if (!content) return { imports: [] }
16
+ try {
17
+ let r
18
+ switch (ext) {
19
+ case 'js': case 'jsx': case 'mjs': case 'cjs':
20
+ case 'ts': case 'tsx':
21
+ r = parseJS(content); break
22
+ case 'vue': case 'svelte': case 'astro':
23
+ r = parseComponentFile(content); break
24
+ case 'py': case 'pyw': case 'pyi':
25
+ r = parsePython(content); break
26
+ case 'ipynb': {
27
+ // Jupyter notebook = JSON wrapper. Extract code cells and parse
28
+ // them as Python (typical) / generic (R/Julia/etc). Bypass the
29
+ // shared trailing logic because we MUST source routes/apiCalls/
30
+ // URLs from the extracted code only — the raw JSON contains
31
+ // hundreds of registry URLs from `metadata` that drown out
32
+ // real API hosts.
33
+ const ipy = parseIpynb(content)
34
+ return {
35
+ imports: ipy.imports,
36
+ routes: extractPyRoutes(ipy.codeContent),
37
+ apiCalls: extractPyApiCalls(ipy.codeContent),
38
+ externalUrls: extractExternalUrls(ipy.codeContent),
39
+ dynamicPatterns: detectDynamicPatterns(ipy.codeContent, 'py'),
40
+ envUsage: extractEnvUsage(ipy.codeContent, 'py'),
41
+ }
42
+ }
43
+ case 'lsp': case 'dcl': case 'lisp': case 'el':
44
+ r = parseLisp(content); break
45
+ case 'css': case 'scss': case 'sass': case 'less': case 'styl':
46
+ r = parseCSS(content); break
47
+ case 'html': case 'htm':
48
+ r = parseHTML(content); break
49
+ case 'md': case 'mdx':
50
+ r = parseMarkdown(content); break
51
+ case 'rs':
52
+ r = parseRust(content); break
53
+ case 'go':
54
+ r = parseGo(content); break
55
+ case 'java': case 'kt':
56
+ r = parseJavaKotlin(content); break
57
+ case 'cs':
58
+ r = parseCSharp(content); break
59
+ case 'swift':
60
+ r = parseSwift(content); break
61
+ case 'dart':
62
+ r = parseDart(content); break
63
+ case 'c': case 'cc': case 'cpp': case 'h': case 'hpp':
64
+ r = parseC(content); break
65
+ case 'rb':
66
+ r = parseRuby(content); break
67
+ case 'php':
68
+ r = parsePHP(content); break
69
+ case 'sh': case 'bash': case 'zsh':
70
+ r = parseShell(content); break
71
+ case 'ps1':
72
+ r = parsePS1(content); break
73
+ case 'clj': case 'scm':
74
+ r = parseClojure(content); break
75
+ case 'rst':
76
+ r = parseRst(content); break
77
+ case 'prisma':
78
+ // Prisma schema file: no imports we'd track, but we DO want
79
+ // db model extraction below. Return empty imports.
80
+ r = { imports: [] }; break
81
+ case 'json': case 'yaml': case 'yml': case 'toml': case 'xml': case 'sql':
82
+ // Skip URL grep on these — package-lock.json and YAML lockfiles
83
+ // contain hundreds of registry URLs that drown out real API hosts.
84
+ return { imports: [] }
85
+ default: {
86
+ const g = parseGeneric(content)
87
+ g.externalUrls = extractExternalUrls(content)
88
+ return g
89
+ }
90
+ }
91
+ // Layer on routes / apiCalls where applicable. Cheap regex passes —
92
+ // false positives are filtered downstream during route↔call matching.
93
+ if (ext === 'js' || ext === 'jsx' || ext === 'mjs' || ext === 'cjs'
94
+ || ext === 'ts' || ext === 'tsx') {
95
+ // JS/TS web frameworks: Express-style + NestJS-style decorators
96
+ r.routes = [...extractJSRoutes(content), ...extractAnnotationRoutes(content)]
97
+ r.apiCalls = extractJSApiCalls(content)
98
+ } else if (ext === 'py' || ext === 'pyw') {
99
+ r.routes = extractPyRoutes(content)
100
+ r.apiCalls = extractPyApiCalls(content)
101
+ } else if (ext === 'go') {
102
+ r.routes = extractGoRoutes(content)
103
+ } else if (ext === 'java' || ext === 'kt') {
104
+ r.routes = extractAnnotationRoutes(content)
105
+ } else if (ext === 'vue' || ext === 'svelte' || ext === 'astro') {
106
+ r.routes = extractJSRoutes(content)
107
+ r.apiCalls = extractJSApiCalls(content)
108
+ }
109
+ r.externalUrls = extractExternalUrls(content)
110
+ r.dynamicPatterns = detectDynamicPatterns(content, ext)
111
+ r.envUsage = extractEnvUsage(content, ext)
112
+ r.dbModels = extractDbModels(content, ext)
113
+ // Cross-language / FFI edges — append to the regular imports list
114
+ // so the existing edge-building pipeline picks them up.
115
+ let ffi = []
116
+ if (['js','jsx','mjs','cjs','ts','tsx'].includes(ext)) ffi = extractJsFfi(content)
117
+ else if (ext === 'py' || ext === 'pyw') ffi = extractPyFfi(content)
118
+ else if (ext === 'java' || ext === 'kt') ffi = extractJavaFfi(content)
119
+ else if (ext === 'rs') ffi = extractRustFfi(content)
120
+ if (ffi.length) {
121
+ r.imports = (r.imports || []).concat(ffi.filter((e) => e.kind === 'ffi'))
122
+ }
123
+ r.confidence = confidenceFor(r.dynamicPatterns, content, ext)
124
+ return r
125
+ } catch {
126
+ const g = parseGeneric(content)
127
+ g.externalUrls = extractExternalUrls(content)
128
+ g.dynamicPatterns = detectDynamicPatterns(content, ext)
129
+ g.envUsage = extractEnvUsage(content, ext)
130
+ g.dbModels = extractDbModels(content, ext)
131
+ g.confidence = confidenceFor(g.dynamicPatterns, content, ext)
132
+ return g
133
+ }
134
+ }
135
+
136
+ // Graph completeness signal per file. Three buckets so the AI can
137
+ // decide how much to trust the import graph for this file:
138
+ // - high : pure static imports, no dynamic patterns
139
+ // - medium : dynamic patterns present but bounded
140
+ // (require(expr) / import(expr) / template literal)
141
+ // - low : reflection / eval / Function-constructor / DI markers —
142
+ // the graph likely misses real edges from this file
143
+ //
144
+ // DI markers (NestJS/Angular decorators, tsyringe inject) are also
145
+ // strong "graph incomplete" signals because DI resolves dependencies
146
+ // at runtime via metadata.
147
+ function confidenceFor(dynamicPatterns, content, ext) {
148
+ const patterns = dynamicPatterns || []
149
+ // 1. Hard "low" signals from dynamic patterns
150
+ const HARD = new Set(['eval', 'new Function', 'exec'])
151
+ if (patterns.some((p) => HARD.has(p))) return 'low'
152
+ // 2. DI framework hints (low even with zero dynamic patterns) — JS/TS only
153
+ const jsLike = ['js','jsx','mjs','cjs','ts','tsx','vue','svelte','astro'].includes(ext)
154
+ if (jsLike && content) {
155
+ // NestJS / Angular decorators: @Injectable / @Component (with parens)
156
+ // also React's bare @Component but those are typed-decorators of classes.
157
+ if (/@\s*(?:Injectable|Module|Controller)\s*\(/.test(content)) return 'low'
158
+ if (/@\s*Component\s*\(/.test(content) &&
159
+ /(?:^|\s)import[^;]*(?:@angular|@nestjs)\b/.test(content)) return 'low'
160
+ // tsyringe / typed-inject: `inject<T>('TOKEN')` or `container.resolve(...)`
161
+ if (/\b(?:container|resolver)\.(?:resolve|inject)\s*\(/.test(content)) return 'low'
162
+ if (/\binject\s*<[^>]+>\s*\(/.test(content)) return 'low'
163
+ }
164
+ // 3. Other dynamic patterns → medium
165
+ if (patterns.length > 0) return 'medium'
166
+ // 4. Pure static → high
167
+ return 'high'
168
+ }
169
+
170
+ // Detect patterns where modules/files are loaded dynamically — these
171
+ // resist static analysis, so a file with `hasDynamicResolution: true`
172
+ // should NEVER be treated as orphan with high confidence and any
173
+ // imports it makes are likely incomplete in our graph.
174
+ function detectDynamicPatterns(content, ext) {
175
+ const found = []
176
+ const jsLike = ['js','jsx','mjs','cjs','ts','tsx','vue','svelte','astro'].includes(ext)
177
+ const pyLike = ['py','pyw'].includes(ext)
178
+ if (jsLike) {
179
+ // require(<expression>) where the arg isn't a plain string literal
180
+ if (/\brequire\s*\(\s*(?!['"`][^'"`]*['"`]\s*\))/g.test(content)) found.push('require(expr)')
181
+ // import(<expression>) - dynamic import
182
+ if (/\bimport\s*\(\s*(?!['"`][^'"`]*['"`]\s*\))/g.test(content)) found.push('import(expr)')
183
+ // Template literal in require/import that interpolates variables
184
+ if (/\b(?:require|import)\s*\(\s*`[^`]*\$\{/g.test(content)) found.push('require/import template literal')
185
+ // eval / new Function — can load/run arbitrary (cross-file) code
186
+ if (/\beval\s*\(/g.test(content)) found.push('eval')
187
+ if (/\bnew\s+Function\s*\(/g.test(content)) found.push('new Function')
188
+ // NOTE: Reflect.* and globalThis[x] are dispatch/access on ALREADY-referenced
189
+ // values — they don't hide a cross-file import edge, so flagging them is just
190
+ // noise (fires on huge fractions of real code). Intentionally NOT marked.
191
+ }
192
+ if (pyLike) {
193
+ if (/\bimportlib\b/.test(content)) found.push('importlib')
194
+ if (/\b__import__\s*\(/.test(content)) found.push('__import__')
195
+ if (/\beval\s*\(/.test(content)) found.push('eval')
196
+ if (/\bexec\s*\(/.test(content)) found.push('exec')
197
+ // NOTE: getattr() is attribute access on an already-imported object — it does
198
+ // NOT hide a cross-file dependency, and fires on ~22% of real Python files.
199
+ // Marking it would drown the real signals (importlib/__import__/eval). Skipped.
200
+ }
201
+ // Cross-language dependency-injection + reflection blind spots: the dependency
202
+ // is real at runtime but its target isn't a static import/use, so the graph
203
+ // can't see it. High-signal patterns only (not every polymorphic call).
204
+ if (ext === 'java' || ext === 'kt') {
205
+ if (/@(?:Autowired|Inject|Resource|Bean|Component|Service|Repository|Provides)\b/.test(content)) found.push('DI annotation')
206
+ if (/\bClass\.forName\s*\(|\.getDeclaredMethod\s*\(|\.getMethod\s*\(|\.newInstance\s*\(/.test(content)) found.push('reflection')
207
+ }
208
+ if (ext === 'cs') {
209
+ if (/\bservices\.(?:AddScoped|AddTransient|AddSingleton)\b|\[Inject\]/.test(content)) found.push('DI registration')
210
+ if (/\bType\.GetType\s*\(|\bActivator\.CreateInstance\s*\(|\bAssembly\.Load\b|\.GetMethod\s*\(/.test(content)) found.push('reflection')
211
+ }
212
+ if (ext === 'go') {
213
+ if (/\breflect\.(?:ValueOf|TypeOf)\b|\.MethodByName\s*\(|\bplugin\.Open\s*\(/.test(content)) found.push('reflection/plugin')
214
+ }
215
+ if (ext === 'php') {
216
+ if (/\bcall_user_func|\bReflectionClass\b|\bclass_exists\s*\(|\$\$|\b(?:app|resolve)\s*\(/.test(content)) found.push('dynamic/DI call')
217
+ }
218
+ if (ext === 'rb') {
219
+ // const_get can load a class (Rails autoload = cross-file); method_missing /
220
+ // define_method synthesize methods. `send`/`public_send` is plain dispatch on
221
+ // an existing object (idiomatic, not file-hiding) → excluded to avoid noise.
222
+ if (/\bconst_get\b|\bmethod_missing\b|\bdefine_method\b/.test(content)) found.push('metaprogramming')
223
+ }
224
+ return found // empty array = no dynamic patterns
225
+ }
226
+
227
+ // Best-effort static string extraction for a Babel node. Catches:
228
+ // - 'foo' StringLiteral
229
+ // - `./foo` TemplateLiteral with no expressions
230
+ // - VAR where const VAR = 'foo' one-deep scope lookup (const binding only)
231
+ // Returns the resolved string or null.
232
+ function staticStringValue(node, scope) {
233
+ if (!node) return null
234
+ if (node.type === 'StringLiteral') return node.value
235
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
236
+ return node.quasis.map((q) => q.value.cooked).join('')
237
+ }
238
+ if (node.type === 'Identifier' && scope) {
239
+ const binding = scope.getBinding?.(node.name)
240
+ if (!binding || binding.kind !== 'const') return null
241
+ const init = binding.path?.node?.init
242
+ if (init?.type === 'StringLiteral') return init.value
243
+ if (init?.type === 'TemplateLiteral' && init.expressions.length === 0) {
244
+ return init.quasis.map((q) => q.value.cooked).join('')
245
+ }
246
+ }
247
+ return null
248
+ }
249
+
250
+ // ─── JS / TS via Babel ────────────────────────────────────────
251
+ function parseJS(content) {
252
+ const imports = []
253
+ let ast
254
+ try {
255
+ ast = babelParse(content, {
256
+ sourceType: 'unambiguous',
257
+ allowImportExportEverywhere: true,
258
+ allowReturnOutsideFunction: true,
259
+ allowAwaitOutsideFunction: true,
260
+ errorRecovery: true,
261
+ plugins: [
262
+ 'jsx', 'typescript', 'decorators-legacy',
263
+ 'classProperties', 'classPrivateProperties', 'classPrivateMethods',
264
+ 'dynamicImport', 'optionalChaining', 'nullishCoalescingOperator',
265
+ 'topLevelAwait', 'importMeta', 'numericSeparator',
266
+ ],
267
+ })
268
+ } catch {
269
+ return parseJSRegex(content)
270
+ }
271
+
272
+ try {
273
+ traverse(ast, {
274
+ ImportDeclaration(p) {
275
+ imports.push({ spec: p.node.source.value, kind: 'import' })
276
+ },
277
+ ExportNamedDeclaration(p) {
278
+ if (p.node.source) imports.push({ spec: p.node.source.value, kind: 'reexport' })
279
+ },
280
+ ExportAllDeclaration(p) {
281
+ if (p.node.source) imports.push({ spec: p.node.source.value, kind: 'reexport' })
282
+ },
283
+ CallExpression(p) {
284
+ const c = p.node.callee
285
+ const args = p.node.arguments
286
+ // require('...') or require(`./foo`) or require(CONST)
287
+ if (c.type === 'Identifier' && c.name === 'require') {
288
+ const spec = staticStringValue(args[0], p.scope)
289
+ if (spec) imports.push({ spec, kind: 'import' })
290
+ }
291
+ // import('...') — JS dynamic import
292
+ if (c.type === 'Import') {
293
+ const spec = staticStringValue(args[0], p.scope)
294
+ if (spec) imports.push({ spec, kind: 'dynamic' })
295
+ }
296
+ // jest.mock('./x') / vi.mock('./x') / proxyquire('./x', ...)
297
+ if (c.type === 'MemberExpression'
298
+ && c.property?.type === 'Identifier'
299
+ && c.property.name === 'mock'
300
+ && (c.object?.name === 'jest' || c.object?.name === 'vi')) {
301
+ const spec = staticStringValue(args[0], p.scope)
302
+ if (spec) imports.push({ spec, kind: 'mock' })
303
+ }
304
+ },
305
+ })
306
+ } catch {
307
+ return parseJSRegex(content)
308
+ }
309
+ return { imports }
310
+ }
311
+
312
+ function parseJSRegex(content) {
313
+ const imports = []
314
+ const patterns = [
315
+ [/import\s+(?:[^'"`;]*\s+from\s+)?['"]([^'"]+)['"]/g, 'import'],
316
+ [/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, 'dynamic'],
317
+ [/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, 'import'],
318
+ [/export\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g, 'reexport'],
319
+ ]
320
+ for (const [re, kind] of patterns) {
321
+ let m
322
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind })
323
+ }
324
+ return { imports }
325
+ }
326
+
327
+ // ─── Vue / Svelte / Astro (extract <script> + parse) ──────────
328
+ function parseComponentFile(content) {
329
+ // Pull out <script>...</script> blocks (approximate)
330
+ const scripts = []
331
+ const scriptRe = /<script[^>]*>([\s\S]*?)<\/script>/g
332
+ let m
333
+ while ((m = scriptRe.exec(content))) scripts.push(m[1])
334
+ const combined = scripts.join('\n')
335
+ return parseJS(combined)
336
+ }
337
+
338
+ // ─── Python ───────────────────────────────────────────────────
339
+ function parsePython(content) {
340
+ const imports = []
341
+ // Split on \r?\n so CRLF files don't leave a trailing \r — JS regex `.`
342
+ // doesn't match \r and `$` won't anchor before it, which would break the
343
+ // `from . import sub` end-anchored capture below.
344
+ const rawLines = content.split(/\r?\n/)
345
+ let inTriple = false
346
+ for (let li = 0; li < rawLines.length; li++) {
347
+ let line = rawLines[li]
348
+ // Skip triple-quoted docstrings / string literals: they frequently
349
+ // contain `from x import y` code examples that are NOT real imports
350
+ // (false-positive edges). Toggle on odd counts of `"""` or `'''`.
351
+ const triples = (line.match(/"""|'''/g) || []).length
352
+ if (inTriple) {
353
+ if (triples % 2 === 1) inTriple = false
354
+ continue
355
+ }
356
+ if (triples % 2 === 1) inTriple = true
357
+ if (line.trim().startsWith('#')) continue
358
+ // Join parenthesized multi-line imports — `from x import (\n a,\n b,\n)`
359
+ // is extremely common in formatted code (black/isort). Accumulate the
360
+ // following lines (import names, not docstrings) until the `)` closes.
361
+ if (/^\s*from\s+[.\w]+\s+import\s*\(/.test(line) && !line.includes(')')) {
362
+ while (li + 1 < rawLines.length && !line.includes(')')) {
363
+ line += ' ' + rawLines[++li].replace(/#.*$/, '').trim()
364
+ }
365
+ line = line.replace(/[()]/g, ' ')
366
+ }
367
+ let m
368
+ // from X import Y (X can include leading dots for relative)
369
+ if ((m = line.match(/^\s*from\s+(\.+|[.\w]+)\s+import\s+(.+)$/))) {
370
+ const from = m[1]
371
+ // `from . import sub` / `from .. import sub`: the imported names are
372
+ // usually SUBMODULES of the relative package, not attributes of its
373
+ // __init__. Emit `.sub` so resolution finds the submodule file; also
374
+ // keep the bare package as a fallback (names defined in __init__).
375
+ if (/^\.+$/.test(from)) {
376
+ for (let n of m[2].replace(/#.*$/, '').split(',')) {
377
+ n = n.trim().replace(/[()]/g, '').split(/\s+as\s+/)[0].trim()
378
+ if (n && n !== '*') imports.push({ spec: from + n, kind: 'import' })
379
+ }
380
+ imports.push({ spec: from, kind: 'import' })
381
+ } else {
382
+ // `from pkg import sub`: the imported names may be SUBMODULES (files)
383
+ // of pkg, not just attributes of its __init__. Emit `pkg.name` so
384
+ // submodule files resolve; non-module names (classes/funcs) simply
385
+ // resolve to nothing. Keep the bare package too (it is also executed,
386
+ // and names may be defined in its __init__).
387
+ for (let n of m[2].replace(/#.*$/, '').split(',')) {
388
+ n = n.trim().replace(/[()]/g, '').split(/\s+as\s+/)[0].trim()
389
+ if (n && n !== '*') imports.push({ spec: from + '.' + n, kind: 'import' })
390
+ }
391
+ imports.push({ spec: from, kind: 'import' })
392
+ }
393
+ }
394
+ else if ((m = line.match(/^\s*from\s+(\.+|[.\w]+)\s+import/))) {
395
+ imports.push({ spec: m[1], kind: 'import' })
396
+ }
397
+ // import X, Y as Z
398
+ else if ((m = line.match(/^\s*import\s+([\w.][\w.\s,]*)/))) {
399
+ for (const name of m[1].split(',')) {
400
+ const clean = name.trim().split(/\s+as\s+/)[0].trim()
401
+ if (clean) imports.push({ spec: clean, kind: 'import' })
402
+ }
403
+ }
404
+ // importlib.import_module('foo') / __import__('foo') — string-arg
405
+ // dynamic imports that are still statically resolvable.
406
+ const dyn = line.match(/\b(?:importlib\.import_module|__import__)\(\s*['"]([^'"]+)['"]/)
407
+ if (dyn) imports.push({ spec: dyn[1], kind: 'dynamic' })
408
+ }
409
+ return { imports }
410
+ }
411
+
412
+ // ─── Jupyter notebook (.ipynb) ────────────────────────────────
413
+ // Parse a notebook's code cells. Returns { imports, codeContent }
414
+ // where codeContent is the concatenated source of every code cell —
415
+ // the caller uses it for URL / route / apiCall extraction so the raw
416
+ // JSON metadata doesn't pollute those signals.
417
+ function parseIpynb(content) {
418
+ let nb
419
+ try { nb = JSON.parse(content) } catch { return { imports: [], codeContent: '' } }
420
+ if (!nb || !Array.isArray(nb.cells)) return { imports: [], codeContent: '' }
421
+ const lang = (nb.metadata?.kernelspec?.language || 'python').toLowerCase()
422
+ const parts = []
423
+ for (const cell of nb.cells) {
424
+ if (cell.cell_type !== 'code') continue
425
+ const src = cell.source
426
+ if (typeof src === 'string') parts.push(src)
427
+ else if (Array.isArray(src)) parts.push(src.join(''))
428
+ }
429
+ const codeContent = parts.join('\n')
430
+ // Strip IPython magics (% / !) so parsePython doesn't get confused.
431
+ // `%matplotlib inline`, `!pip install foo` etc. aren't imports we want.
432
+ const cleaned = codeContent.split('\n')
433
+ .filter((l) => !/^\s*[%!]/.test(l))
434
+ .join('\n')
435
+ // Most notebooks are Python; R/Julia fall back to generic (URL only).
436
+ const r = lang.startsWith('python') ? parsePython(cleaned) : parseGeneric(cleaned)
437
+ return { imports: r.imports || [], codeContent: cleaned }
438
+ }
439
+
440
+ // ─── Lisp / AutoLISP ──────────────────────────────────────────
441
+ function parseLisp(content) {
442
+ const imports = []
443
+ const patterns = [
444
+ /\(\s*load\s+"([^"]+)"/g,
445
+ /\(\s*vl-arx-import\s+"([^"]+)"/g,
446
+ /\(\s*autoload\s+"([^"]+)"/g,
447
+ /\(\s*require\s+(?:'|")([^"')]+)/g,
448
+ ]
449
+ for (const re of patterns) {
450
+ let m
451
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
452
+ }
453
+ return { imports }
454
+ }
455
+
456
+ // ─── CSS family ───────────────────────────────────────────────
457
+ function parseCSS(content) {
458
+ const imports = []
459
+ const patterns = [
460
+ /@import\s+(?:url\()?['"]([^'")]+)['"]/g,
461
+ /@use\s+['"]([^'"]+)['"]/g, // SCSS
462
+ /@forward\s+['"]([^'"]+)['"]/g,
463
+ /url\(\s*['"]([^'")]+)['"]\s*\)/g,
464
+ ]
465
+ for (const re of patterns) {
466
+ let m
467
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
468
+ }
469
+ return { imports }
470
+ }
471
+
472
+ // ─── HTML ─────────────────────────────────────────────────────
473
+ function parseHTML(content) {
474
+ const imports = []
475
+ const patterns = [
476
+ /<script\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
477
+ /<link\s+[^>]*href\s*=\s*['"]([^'"]+)['"]/gi,
478
+ /<img\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
479
+ /<iframe\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
480
+ /<video\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
481
+ /<source\s+[^>]*src\s*=\s*['"]([^'"]+)['"]/gi,
482
+ ]
483
+ for (const re of patterns) {
484
+ let m
485
+ while ((m = re.exec(content))) {
486
+ const spec = m[1]
487
+ if (spec.startsWith('http://') || spec.startsWith('https://') ||
488
+ spec.startsWith('//') || spec.startsWith('data:')) continue
489
+ imports.push({ spec, kind: 'asset' })
490
+ }
491
+ }
492
+ return { imports }
493
+ }
494
+
495
+ // ─── Markdown ─────────────────────────────────────────────────
496
+ function parseMarkdown(content) {
497
+ const imports = []
498
+ // ![alt](path) and [text](path) — exclude URLs
499
+ const re = /\[(?:[^\]]*)\]\(([^)]+)\)/g
500
+ let m
501
+ while ((m = re.exec(content))) {
502
+ const spec = m[1].trim().split(/\s+/)[0]
503
+ if (!spec || /^https?:\/\//.test(spec) || spec.startsWith('#')
504
+ || spec.startsWith('mailto:')) continue
505
+ imports.push({ spec, kind: 'ref' })
506
+ }
507
+ return { imports }
508
+ }
509
+
510
+ // ─── Rust / Go / Java / C / Ruby / PHP / Shell ────────────────
511
+ function parseRust(content) {
512
+ // File modules: `mod X;` / `pub mod X;` / `pub(crate) mod X;` (the `;`
513
+ // matters — inline `mod X { ... }` has no backing file, so we skip it).
514
+ // Imports: `use ...;` / `pub use ...;`, including grouped/nested forms
515
+ // `use a::b::{c, d::e, self};` and renames `use a::b as c;`. Each group
516
+ // member is expanded into its own spec so it resolves to its own file.
517
+ const imports = []
518
+ const VIS = '(?:pub\\s*(?:\\([^)]*\\)\\s*)?)?'
519
+ const modRe = new RegExp('^\\s*' + VIS + 'mod\\s+(\\w+)\\s*;', 'gm')
520
+ let m
521
+ while ((m = modRe.exec(content))) imports.push({ spec: m[1], kind: 'import' })
522
+ // `use` statements can span multiple lines up to the terminating `;`.
523
+ const useRe = new RegExp('^\\s*' + VIS + 'use\\s+([^;]+);', 'gm')
524
+ while ((m = useRe.exec(content))) {
525
+ for (const spec of expandRustUse(m[1])) imports.push({ spec, kind: 'import' })
526
+ }
527
+ return { imports }
528
+ }
529
+
530
+ // Expand a Rust `use` tree (without the leading `use`/trailing `;`) into a
531
+ // flat list of module-path specifiers. Handles nested `{}` groups, `self`
532
+ // (refers to the group prefix), `as` renames, and `*` globs.
533
+ function expandRustUse(body) {
534
+ const s = body.replace(/\s+as\s+\w+/g, '').replace(/\s+/g, '')
535
+ const out = []
536
+ const matchBrace = (str, open) => {
537
+ let depth = 0
538
+ for (let i = open; i < str.length; i++) {
539
+ if (str[i] === '{') depth++
540
+ else if (str[i] === '}') { depth--; if (depth === 0) return i }
541
+ }
542
+ return str.length
543
+ }
544
+ const splitTop = (str) => {
545
+ const parts = []
546
+ let depth = 0, start = 0
547
+ for (let i = 0; i < str.length; i++) {
548
+ if (str[i] === '{') depth++
549
+ else if (str[i] === '}') depth--
550
+ else if (str[i] === ',' && depth === 0) { parts.push(str.slice(start, i)); start = i + 1 }
551
+ }
552
+ parts.push(str.slice(start))
553
+ return parts
554
+ }
555
+ const walk = (seg, prefix) => {
556
+ const brace = seg.indexOf('{')
557
+ if (brace === -1) {
558
+ let leaf = seg.replace(/::\*$/, '').replace(/^\*$/, '')
559
+ if (leaf === 'self' || leaf === '') { if (prefix) out.push(prefix); return }
560
+ out.push(prefix ? prefix + '::' + leaf : leaf)
561
+ return
562
+ }
563
+ const head = seg.slice(0, brace).replace(/::$/, '')
564
+ const newPrefix = head ? (prefix ? prefix + '::' + head : head) : prefix
565
+ const inner = seg.slice(brace + 1, matchBrace(seg, brace))
566
+ for (const part of splitTop(inner)) walk(part, newPrefix)
567
+ }
568
+ walk(s, '')
569
+ return out.filter(Boolean)
570
+ }
571
+
572
+ function parseGo(content) {
573
+ const imports = []
574
+ // import "pkg" or import ( "a" "b" )
575
+ const single = /import\s+"([^"]+)"/g
576
+ const block = /import\s*\(\s*([\s\S]*?)\)/g
577
+ let m
578
+ while ((m = single.exec(content))) imports.push({ spec: m[1], kind: 'import' })
579
+ while ((m = block.exec(content))) {
580
+ const inner = m[1]
581
+ const innerRe = /"([^"]+)"/g
582
+ let mm
583
+ while ((mm = innerRe.exec(inner))) imports.push({ spec: mm[1], kind: 'import' })
584
+ }
585
+ return { imports }
586
+ }
587
+
588
+ function parseJavaKotlin(content) {
589
+ const imports = []
590
+ const re = /^\s*import\s+(?:static\s+)?([\w.]+)/gm
591
+ let m
592
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
593
+ return { imports }
594
+ }
595
+
596
+ function parseC(content) {
597
+ const imports = []
598
+ const re = /#\s*include\s+[<"]([^>"]+)[>"]/g
599
+ let m
600
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
601
+ return { imports }
602
+ }
603
+
604
+ function parseRuby(content) {
605
+ const imports = []
606
+ const re = /^\s*(?:require|require_relative|load)\s+['"]([^'"]+)['"]/gm
607
+ let m
608
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
609
+ return { imports }
610
+ }
611
+
612
+ function parsePHP(content) {
613
+ const imports = []
614
+ // require/include (optionally _once), allowing `__DIR__ . ` concatenation
615
+ // before the string literal (the dominant real-world form).
616
+ const reqRe = /\b(?:require|include)(?:_once)?\b[^;'"]*['"]([^'"]+)['"]/g
617
+ let m
618
+ while ((m = reqRe.exec(content))) imports.push({ spec: m[1], kind: 'require' })
619
+ // `use` namespace imports (PSR-4). Forms handled:
620
+ // use A\B\C; use A\B\C as D; use function A\b; use const A\B;
621
+ // use A\B\{C, D as E, function f}; (PHP 7+ group use)
622
+ // The negative-shape first char (`[A-Za-z_\\]`) skips closure `use ($x)`.
623
+ const useRe = /^\s*use\s+(?:function\s+|const\s+)?([A-Za-z_\\][^;]*);/gm
624
+ while ((m = useRe.exec(content))) {
625
+ for (const spec of expandPhpUse(m[1])) imports.push({ spec, kind: 'use' })
626
+ }
627
+ return { imports }
628
+ }
629
+
630
+ // Expand a PHP `use` body into fully-qualified class names. Handles group
631
+ // use `Prefix\{A, B as C}`, `as` aliases, and `function`/`const` members.
632
+ function expandPhpUse(body) {
633
+ const out = []
634
+ const brace = body.indexOf('{')
635
+ if (brace === -1) {
636
+ const fqcn = body.split(/\s+as\s+/i)[0].trim()
637
+ if (fqcn) out.push(fqcn)
638
+ return out
639
+ }
640
+ const prefix = body.slice(0, brace).replace(/\\\s*$/, '').trim()
641
+ const inner = body.slice(brace + 1, body.lastIndexOf('}'))
642
+ for (let part of inner.split(',')) {
643
+ part = part.replace(/\b(?:function|const)\s+/i, '').split(/\s+as\s+/i)[0].trim()
644
+ if (part) out.push(prefix + '\\' + part.replace(/^\\/, ''))
645
+ }
646
+ return out
647
+ }
648
+
649
+ function parseShell(content) {
650
+ const imports = []
651
+ const re = /^\s*(?:source|\.)\s+(['"]?)([^\s'";]+)\1/gm
652
+ let m
653
+ while ((m = re.exec(content))) imports.push({ spec: m[2], kind: 'import' })
654
+ return { imports }
655
+ }
656
+
657
+ // C# — `using Namespace;`, `using static System.Math;`, `using Alias = Path;`
658
+ function parseCSharp(content) {
659
+ const imports = []
660
+ // Three forms; capture the namespace path in group 1
661
+ const re = /^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?([A-Za-z_][\w.]*)\s*;/gm
662
+ let m
663
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'using' })
664
+ return { imports }
665
+ }
666
+
667
+ // Swift — `import Module`, `@_exported import Module`,
668
+ // `import struct ModuleA.Foo`, `@testable import ModuleB`
669
+ function parseSwift(content) {
670
+ const imports = []
671
+ const re = /^\s*(?:@\w+\s+)?import\s+(?:(?:class|struct|enum|protocol|typealias|func|var|let)\s+)?([A-Za-z_][\w.]*)/gm
672
+ let m
673
+ while ((m = re.exec(content))) imports.push({ spec: m[1], kind: 'import' })
674
+ return { imports }
675
+ }
676
+
677
+ // Dart — `import 'package:foo/bar.dart';`, `import 'src/foo.dart';`,
678
+ // `export 'src/x.dart';`, `part 'foo.g.dart';`, `part of 'main.dart';`
679
+ // All four are recorded; resolveImport handles `dart:` / `package:` /
680
+ // relative resolution. The `part of` directive lands here too — its
681
+ // spec points back at the parent library file, which is a legitimate
682
+ // dependency edge in our graph.
683
+ function parseDart(content) {
684
+ const imports = []
685
+ const re = /^\s*(import|export|part(?:\s+of)?)\s+['"]([^'"]+)['"]/gm
686
+ let m
687
+ while ((m = re.exec(content))) {
688
+ const kw = m[1].split(/\s+/)[0] // 'part of' → 'part'
689
+ imports.push({ spec: m[2], kind: kw })
690
+ }
691
+ return { imports }
692
+ }
693
+
694
+ // PowerShell — `. ./path.ps1` (dot-source), `Import-Module Name`,
695
+ // `Import-Module ./local/Mod.psm1`, `using module ./path`
696
+ function parsePS1(content) {
697
+ const imports = []
698
+ // dot-source: . <path>
699
+ const dotRe = /^\s*\.\s+(?:&\s*)?(['"]?)([^\s'";|]+)\1/gm
700
+ let m
701
+ while ((m = dotRe.exec(content))) {
702
+ if (m[2] && m[2] !== '..') imports.push({ spec: m[2], kind: 'source' })
703
+ }
704
+ // Import-Module Name [-Name] (handles -Name flag)
705
+ const impRe = /(?:Import-Module|using\s+module)\s+(?:-Name\s+)?(['"]?)([^\s'";|,]+)\1/gi
706
+ while ((m = impRe.exec(content))) {
707
+ imports.push({ spec: m[2], kind: 'import-module' })
708
+ }
709
+ return { imports }
710
+ }
711
+
712
+ // Clojure / Scheme — `(require '[ns :as alias])`, `(use 'ns)`,
713
+ // `(:require [ns ...] [ns2 ...])`, `(:use ns)`
714
+ function parseClojure(content) {
715
+ const imports = []
716
+ // Step 1: locate each require/use/import form's body.
717
+ const formRe = /\((?::?(?:require|use|import))\s+([\s\S]*?)\)/g
718
+ let block
719
+ while ((block = formRe.exec(content))) {
720
+ const body = block[1]
721
+ // Step 2a: vector form — `[ns :as alias]` or `[ns :refer [...]]`
722
+ const vecRe = /\[\s*([a-zA-Z][\w.\-]*)/g
723
+ let v
724
+ while ((v = vecRe.exec(body))) imports.push({ spec: v[1], kind: 'require' })
725
+ // Step 2b: quoted symbol form — `'ns`
726
+ const symRe = /'([a-zA-Z][\w.\-]+)/g
727
+ let s
728
+ while ((s = symRe.exec(body))) imports.push({ spec: s[1], kind: 'require' })
729
+ }
730
+ return { imports }
731
+ }
732
+
733
+ // reStructuredText — `.. include:: path`, `.. literalinclude:: path`,
734
+ // `:doc:\`path\``
735
+ function parseRst(content) {
736
+ const imports = []
737
+ const incRe = /^\.\.\s*(?:include|literalinclude|figure|image)\s*::\s*(\S+)/gm
738
+ let m
739
+ while ((m = incRe.exec(content))) imports.push({ spec: m[1], kind: 'include' })
740
+ // :doc:`path` cross-reference
741
+ const docRe = /:doc:`([^`<]+)`/g
742
+ while ((m = docRe.exec(content))) {
743
+ imports.push({ spec: m[1].trim(), kind: 'doc' })
744
+ }
745
+ return { imports }
746
+ }
747
+
748
+ function parseGeneric(content) {
749
+ // Last-resort: catch obviously-relative path-looking string literals
750
+ // (kept restrictive to avoid noise)
751
+ return { imports: [] }
752
+ }
753
+
754
+ // ─── Full-stack: route + API call extraction ────────────────
755
+ //
756
+ // We do NOT try to perfectly understand every framework. We capture the
757
+ // dominant patterns (Express/Fastify/Koa/Hono on Node, Flask/FastAPI on
758
+ // Python) with regex. Anything trickier (route mounting via app.use,
759
+ // generated routes, file-based routing) is out of scope — false positives
760
+ // are filtered later because we only emit an edge if both sides exist.
761
+
762
+ const HTTP_METHODS = ['get','post','put','patch','delete','head','options','all']
763
+
764
+ function extractJSRoutes(content) {
765
+ const routes = []
766
+ // First pass: collect Express-style mount prefixes within this file.
767
+ // Example:
768
+ // const usersRouter = express.Router()
769
+ // usersRouter.get('/list', ...) ← path is '/list'
770
+ // app.use('/api/users', usersRouter) ← gives prefix '/api/users'
771
+ // → emit one route { method:'GET', path:'/api/users/list' }
772
+ //
773
+ // Same-file only — cross-file router mount resolution would need
774
+ // a global pass (deferred).
775
+ const mountRe = /\b(?:app|server|router|api)\.use\s*\(\s*['"`](\/[^'"`]*)['"`]\s*,\s*(\w+)\s*\)/g
776
+ const mounts = new Map() // varName -> prefix
777
+ let mm
778
+ while ((mm = mountRe.exec(content))) {
779
+ const prefix = mm[1].replace(/\/$/, '') // strip trailing slash
780
+ mounts.set(mm[2], prefix)
781
+ }
782
+
783
+ // Method handlers — capture the receiver name too so we can resolve
784
+ // its mount prefix from `mounts`. The trailing capture grabs whatever
785
+ // follows the path string up to the closing `)` so we can pluck a
786
+ // named handler identifier (`getUserHandler` in
787
+ // `app.get('/u', getUserHandler)`).
788
+ const methodRe = new RegExp(
789
+ `\\b(\\w+)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]\\s*,([^)]*)\\)`,
790
+ 'g'
791
+ )
792
+ // Only treat the call as a route if the receiver looks like a
793
+ // router/app variable. We accept:
794
+ // - well-known names: app, router, server, api, fastify, hono, express, route
795
+ // - any variable that is the target of a `<name>.use('/prefix', X)`
796
+ // mount (those *receive* sub-routes after mounting)
797
+ // - any variable assigned from `express.Router()` / `Router()` / `Hono()` / `new Hono()`
798
+ const knownReceivers = new Set(['app', 'router', 'server', 'api', 'fastify', 'hono', 'express', 'route'])
799
+ const factoryRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:new\s+)?(?:express\.Router|Router|Hono)\s*\(/g
800
+ let fm
801
+ while ((fm = factoryRe.exec(content))) knownReceivers.add(fm[1])
802
+ // mounted variables (X in app.use('/p', X)) are also valid receivers
803
+ for (const v of mounts.keys()) knownReceivers.add(v)
804
+
805
+ let m
806
+ while ((m = methodRe.exec(content))) {
807
+ const receiver = m[1]
808
+ if (!knownReceivers.has(receiver)) continue
809
+ const method = m[2].toUpperCase()
810
+ const localPath = m[3]
811
+ // If this receiver is a mounted router, prepend its prefix
812
+ const prefix = mounts.get(receiver) || ''
813
+ const full = prefix && localPath.startsWith('/')
814
+ ? prefix + (localPath === '/' ? '' : localPath)
815
+ : localPath
816
+ // Pull a named handler identifier from the remaining args, if any.
817
+ // Catches `app.get('/u', getUser)` and `app.get('/u', auth, getUser)`
818
+ // but skips inline arrows / function expressions which can't be
819
+ // matched to a symbol anyway.
820
+ const handlerStr = (m[4] || '').trim()
821
+ let handler = null
822
+ const handlerMatch = handlerStr.match(/([A-Za-z_$][\w$]*)\s*$/)
823
+ if (handlerMatch && !/(=>|function|\{)/.test(handlerStr)) {
824
+ handler = handlerMatch[1]
825
+ }
826
+ routes.push({ method, path: full, handler })
827
+ }
828
+ // Fastify object form: fastify.route({ method: 'GET', url: '/users' })
829
+ const fastifyObjRe = /\.route\s*\(\s*\{[^}]*?method\s*:\s*['"`](\w+)['"`][^}]*?url\s*:\s*['"`]([^'"`]+)['"`]/g
830
+ while ((m = fastifyObjRe.exec(content))) {
831
+ routes.push({ method: m[1].toUpperCase(), path: m[2] })
832
+ }
833
+ return routes
834
+ }
835
+
836
+ // Generic "find every http(s)/ws(s) URL string in source" pass. Catches
837
+ // URLs that the specific apiCall extractors miss: variable-built URLs,
838
+ // SDK constants, WebSocket connects, HTML <img src=...>, comments-as-doc,
839
+ // etc. We grep liberally — false positives (e.g. README URLs in
840
+ // comments) are acceptable here because the goal is "what external hosts
841
+ // could this project ever talk to". The renderer/CLI groups by domain so
842
+ // duplicates collapse naturally.
843
+ const EXT_URL_RE = /https?:\/\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+|wss?:\/\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+/g
844
+ const URL_TRIM_RE = /[.,;:'"`)\]}>\\]+$/
845
+ // ─── DB schema extractors ─────────────────────────────────────
846
+ // Three ORM/schema dialects, all heuristic regex (no full parsing):
847
+ // - Prisma (.prisma) `model X { name Type }`
848
+ // - Drizzle (TS/JS) `pgTable('x', { ... })`
849
+ // - SQLAlchemy (Python) `class X(Base): __tablename__ = '...'`
850
+ // Returns: [{ kind, name, tableName?, fields: [{name,type}] }]
851
+
852
+ function parsePrismaSchema(content) {
853
+ const models = []
854
+ // model Foo { ... } and enum Foo { ... }
855
+ const re = /\b(model|enum)\s+(\w+)\s*\{([^}]*)\}/g
856
+ let m
857
+ while ((m = re.exec(content))) {
858
+ const kind = m[1] // 'model' | 'enum'
859
+ const name = m[2]
860
+ const fields = []
861
+ for (const raw of m[3].split('\n')) {
862
+ const line = raw.trim()
863
+ if (!line || line.startsWith('//') || line.startsWith('@@')) continue
864
+ // For enums, each non-empty line is a value
865
+ if (kind === 'enum') { fields.push({ name: line.split(/\s+/)[0], type: 'enum-value' }); continue }
866
+ const fm = line.match(/^(\w+)\s+(\w+)(\[\])?(\?)?/)
867
+ if (fm) fields.push({ name: fm[1], type: fm[2] + (fm[3] || '') + (fm[4] || '') })
868
+ }
869
+ models.push({ kind: 'prisma-' + kind, name, fields })
870
+ }
871
+ return models
872
+ }
873
+
874
+ function extractDrizzleTables(content) {
875
+ if (!/(?:pg|mysql|sqlite)Table\s*\(/.test(content)) return []
876
+ const tables = []
877
+ // pgTable('users', { id: serial('id').primaryKey(), ... })
878
+ // We grab the table name then walk braces manually to handle nested {}.
879
+ const re = /(?:export\s+(?:const|let|var)\s+(\w+)\s*=\s*)?(?:pg|mysql|sqlite)Table\s*\(\s*['"`](\w+)['"`]\s*,\s*\{/g
880
+ let m
881
+ while ((m = re.exec(content))) {
882
+ const varName = m[1] || m[2]
883
+ const tableName = m[2]
884
+ // Find matching }
885
+ const start = re.lastIndex
886
+ let depth = 1, i = start
887
+ while (i < content.length && depth > 0) {
888
+ const c = content[i]
889
+ if (c === '{') depth++
890
+ else if (c === '}') depth--
891
+ i++
892
+ }
893
+ const body = content.slice(start, i - 1)
894
+ const fields = []
895
+ const fre = /(\w+)\s*:\s*(\w+)\(/g
896
+ let fm
897
+ while ((fm = fre.exec(body))) fields.push({ name: fm[1], type: fm[2] })
898
+ tables.push({ kind: 'drizzle', name: varName, tableName, fields })
899
+ }
900
+ return tables
901
+ }
902
+
903
+ function extractSQLAlchemyModels(content) {
904
+ // class X(Base) or class X(db.Model) or class X(sa.orm.DeclarativeBase)
905
+ if (!/class\s+\w+\s*\(\s*(?:Base|db\.Model|DeclarativeBase|orm\.DeclarativeBase)/.test(content)) return []
906
+ const models = []
907
+ // Split on each `class ` start so the per-class body (including blank
908
+ // lines) is captured cleanly, rather than trying to write a regex
909
+ // that handles empty lines + dedent boundaries.
910
+ const chunks = content.split(/(?=^class\s)/m)
911
+ for (const chunk of chunks) {
912
+ const head = chunk.match(/^class\s+(\w+)\s*\(\s*(?:Base|db\.Model|sa\.orm\.DeclarativeBase|DeclarativeBase|orm\.DeclarativeBase)[^)]*\)\s*:/)
913
+ if (!head) continue
914
+ const name = head[1]
915
+ const body = chunk.slice(head[0].length)
916
+ const fields = []
917
+ const fre = /^\s+(\w+)\s*(?::\s*[\w[\],\s]+)?\s*=\s*(?:Column|mapped_column|Mapped|relationship)\s*\(\s*(\w+)?/gm
918
+ let fm
919
+ while ((fm = fre.exec(body))) fields.push({ name: fm[1], type: fm[2] || 'unknown' })
920
+ let tableName = null
921
+ const tn = body.match(/__tablename__\s*=\s*['"](\w+)['"]/)
922
+ if (tn) tableName = tn[1]
923
+ // Skip empty marker classes (like a project's own `class Base(DeclarativeBase)`)
924
+ if (fields.length === 0 && !tableName) continue
925
+ models.push({ kind: 'sqlalchemy', name, tableName, fields })
926
+ }
927
+ return models
928
+ }
929
+
930
+ // Cross-language / FFI imports — point at a non-source artefact
931
+ // that another file in the repo provides (WASM / .node addon /
932
+ // shared library). Returns extra `imports` entries with
933
+ // kind: 'ffi' so the regular resolver can link them when the
934
+ // target file is indexed.
935
+ function extractJsFfi(content) {
936
+ const out = []
937
+ // WebAssembly: fetch('x.wasm') / readFileSync('x.wasm')
938
+ // WebAssembly.compile(await fetch('x.wasm').then(r=>r.arrayBuffer()))
939
+ const wasmRe = /['"`]([^'"`]+\.wasm)['"`]/g
940
+ let m
941
+ while ((m = wasmRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
942
+ // Native node addons: require('./build/Release/foo.node')
943
+ const nodeRe = /['"`]([^'"`]+\.node)['"`]/g
944
+ while ((m = nodeRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
945
+ // node-bindings: require('bindings')('foo')
946
+ const bindingsRe = /require\s*\(\s*['"`]bindings['"`]\s*\)\s*\(\s*['"`]([^'"`]+)['"`]/g
947
+ while ((m = bindingsRe.exec(content))) out.push({ spec: m[1] + '.node', kind: 'ffi' })
948
+ return out
949
+ }
950
+
951
+ function extractPyFfi(content) {
952
+ const out = []
953
+ // ctypes: ctypes.CDLL('./libfoo.so') / CDLL('foo.dylib')
954
+ const dllRe = /(?:ctypes\.)?CDLL\s*\(\s*['"]([^'"]+)['"]\s*\)/g
955
+ let m
956
+ while ((m = dllRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
957
+ // cffi: ffi.dlopen('foo.so')
958
+ const cffiRe = /\.dlopen\s*\(\s*['"]([^'"]+)['"]\s*\)/g
959
+ while ((m = cffiRe.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
960
+ // CPython convention: `import _foo` / `from _foo import ...`
961
+ // (single leading underscore => compiled extension)
962
+ const extRe = /^(?:from\s+_(\w+)\s+import|import\s+_(\w+))/gm
963
+ while ((m = extRe.exec(content))) {
964
+ const mod = m[1] || m[2]
965
+ if (mod) out.push({ spec: '_' + mod, kind: 'ffi' })
966
+ }
967
+ return out
968
+ }
969
+
970
+ // Java JNI: System.loadLibrary("foo") → foo.so / foo.dll target
971
+ function extractJavaFfi(content) {
972
+ const out = []
973
+ const re = /System\.loadLibrary\s*\(\s*"([^"]+)"\s*\)/g
974
+ let m
975
+ while ((m = re.exec(content))) out.push({ spec: m[1], kind: 'ffi' })
976
+ return out
977
+ }
978
+
979
+ // Rust: extern "C" { fn foo(...); } blocks signal FFI boundary.
980
+ // We just count the block existence as a coarse marker.
981
+ function extractRustFfi(content) {
982
+ const out = []
983
+ const re = /extern\s+"C"\s*\{/g
984
+ let count = 0
985
+ while (re.exec(content)) count++
986
+ if (count > 0) out.push({ spec: 'extern-c', kind: 'ffi-marker', count })
987
+ return out
988
+ }
989
+
990
+ function extractMongooseModels(content) {
991
+ if (!/\bmongoose\b/.test(content)) return []
992
+ const models = []
993
+ // `const User = mongoose.model('User', userSchema)`
994
+ // or `mongoose.model('User', new Schema({...}))`
995
+ const re = /(?:(?:const|let|var)\s+(\w+)\s*=\s*)?mongoose\.model\s*\(\s*['"`](\w+)['"`]/g
996
+ let m
997
+ while ((m = re.exec(content))) {
998
+ models.push({ kind: 'mongoose', name: m[2], varName: m[1] || m[2], fields: [] })
999
+ }
1000
+ return models
1001
+ }
1002
+
1003
+ // TypeORM `@Entity()` + `class Foo { @Column() bar: string }` — best-effort
1004
+ function extractTypeOrmEntities(content) {
1005
+ if (!/@Entity\s*\(/.test(content)) return []
1006
+ const out = []
1007
+ const re = /@Entity\s*\(\s*(?:['"`](\w+)['"`])?\s*\)\s*(?:export\s+)?class\s+(\w+)/g
1008
+ let m
1009
+ while ((m = re.exec(content))) {
1010
+ out.push({ kind: 'typeorm', name: m[2], tableName: m[1] || null, fields: [] })
1011
+ }
1012
+ return out
1013
+ }
1014
+
1015
+ function extractDbModels(content, ext) {
1016
+ if (!content) return []
1017
+ if (ext === 'prisma') return parsePrismaSchema(content)
1018
+ if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
1019
+ return [
1020
+ ...extractDrizzleTables(content),
1021
+ ...extractMongooseModels(content),
1022
+ ...extractTypeOrmEntities(content),
1023
+ ]
1024
+ }
1025
+ if (ext === 'py' || ext === 'pyw') return extractSQLAlchemyModels(content)
1026
+ return []
1027
+ }
1028
+
1029
+ // Extract environment-variable references — `process.env.X`,
1030
+ // `os.environ['X']`, `os.Getenv("X")`, shell `${X}`, etc. We require
1031
+ // the name to start with an uppercase letter and contain only
1032
+ // uppercase/digit/underscore to keep false positives low (no random
1033
+ // `process.env.foo` from minified output, etc).
1034
+ function extractEnvUsage(content, ext) {
1035
+ if (!content) return []
1036
+ const found = new Set()
1037
+ const patterns = [
1038
+ // JS / TS / Node / Vite / Next
1039
+ /process\.env\.([A-Z][A-Z0-9_]+)\b/g,
1040
+ /process\.env\[['"`]([A-Z][A-Z0-9_]+)['"`]\]/g,
1041
+ /import\.meta\.env\.([A-Z][A-Z0-9_]+)\b/g,
1042
+ // Python
1043
+ /os\.environ\[['"`]([A-Z][A-Z0-9_]+)['"`]\]/g,
1044
+ /os\.environ\.get\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
1045
+ /os\.getenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
1046
+ // Go / Java / Rust / Ruby / C
1047
+ /os\.Getenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
1048
+ /System\.getenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
1049
+ /env::var\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
1050
+ /ENV\[['"`]([A-Z][A-Z0-9_]+)['"`]\]/g,
1051
+ /\bgetenv\(\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g,
1052
+ ]
1053
+ for (const re of patterns) {
1054
+ re.lastIndex = 0
1055
+ let m
1056
+ while ((m = re.exec(content))) found.add(m[1])
1057
+ }
1058
+ // Shell-style ${VAR} — only on shell-like files to avoid false hits
1059
+ // from JS template literals.
1060
+ if (ext === 'sh' || ext === 'bash' || ext === 'zsh' || ext === 'ps1' || ext === 'psm1') {
1061
+ const sh = /\$\{?([A-Z][A-Z0-9_]+)(?::-[^}]*)?\}?/g
1062
+ let m
1063
+ while ((m = sh.exec(content))) found.add(m[1])
1064
+ }
1065
+ return [...found]
1066
+ }
1067
+
1068
+ function extractExternalUrls(content) {
1069
+ const seen = new Map() // url -> count
1070
+ let m
1071
+ EXT_URL_RE.lastIndex = 0
1072
+ while ((m = EXT_URL_RE.exec(content))) {
1073
+ let url = m[0]
1074
+ // Strip trailing punctuation that the greedy regex captures from
1075
+ // surrounding prose: "see https://x.com." → "https://x.com"
1076
+ url = url.replace(URL_TRIM_RE, '')
1077
+ if (url.length < 12) continue // arbitrary minimum to drop "http://x"
1078
+ seen.set(url, (seen.get(url) || 0) + 1)
1079
+ }
1080
+ return [...seen.entries()].map(([url, count]) => ({ url, count }))
1081
+ }
1082
+
1083
+ function extractJSApiCalls(content) {
1084
+ const calls = []
1085
+ // fetch('/api/users', { method: 'POST' }) or fetch(`/api/${x}`)
1086
+ const fetchRe = /\bfetch\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{([^}]*)\})?/g
1087
+ let m
1088
+ while ((m = fetchRe.exec(content))) {
1089
+ const url = m[1]
1090
+ if (!looksLikeUrl(url)) continue
1091
+ let method = 'GET'
1092
+ if (m[2]) {
1093
+ const mm = m[2].match(/method\s*:\s*['"`](\w+)['"`]/)
1094
+ if (mm) method = mm[1].toUpperCase()
1095
+ }
1096
+ calls.push({ method, url })
1097
+ }
1098
+ // axios.get('/api/users'), axios.post(...), got.get(...), etc.
1099
+ const axiosRe = new RegExp(
1100
+ `\\b(?:axios|got|ky|request|http|api|client)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`,
1101
+ 'g'
1102
+ )
1103
+ while ((m = axiosRe.exec(content))) {
1104
+ const url = m[2]
1105
+ if (!looksLikeUrl(url)) continue
1106
+ calls.push({ method: m[1].toUpperCase(), url })
1107
+ }
1108
+ // axios({ url: '/api/x', method: 'POST' }) / axios.request({ url, method })
1109
+ const axiosObjRe = /\b(?:axios|got|ky|request|http|api|client)(?:\.request)?\s*\(\s*\{([^}]*)\}/g
1110
+ while ((m = axiosObjRe.exec(content))) {
1111
+ const body = m[1]
1112
+ const um = body.match(/url\s*:\s*['"`]([^'"`]+)['"`]/)
1113
+ if (!um) continue
1114
+ const url = um[1]
1115
+ if (!looksLikeUrl(url)) continue
1116
+ let method = 'GET'
1117
+ const mm = body.match(/method\s*:\s*['"`](\w+)['"`]/)
1118
+ if (mm) method = mm[1].toUpperCase()
1119
+ calls.push({ method, url })
1120
+ }
1121
+ // SWR / React Query convenience: useFetch('/api/x'), useSWR('/api/x')
1122
+ const hookRe = /\buse(?:Fetch|SWR|Query|Data|AsyncData)\s*\(\s*['"`]([^'"`]+)['"`]/g
1123
+ while ((m = hookRe.exec(content))) {
1124
+ const url = m[1]
1125
+ if (!looksLikeUrl(url)) continue
1126
+ calls.push({ method: 'GET', url })
1127
+ }
1128
+ // Nuxt 3 / Nitro: $fetch('/api/x'), ofetch('/api/x')
1129
+ const nitroRe = /(?:\$fetch|\bofetch)\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{([^}]*)\})?/g
1130
+ while ((m = nitroRe.exec(content))) {
1131
+ const url = m[1]
1132
+ if (!looksLikeUrl(url)) continue
1133
+ let method = 'GET'
1134
+ if (m[2]) {
1135
+ const mm = m[2].match(/method\s*:\s*['"`](\w+)['"`]/)
1136
+ if (mm) method = mm[1].toUpperCase()
1137
+ }
1138
+ calls.push({ method, url })
1139
+ }
1140
+ // Template literal with leading static prefix:
1141
+ // fetch(`/api/users/${id}`) → emit '/api/users/'
1142
+ // Route matching downstream treats it as a prefix that the route
1143
+ // regex must consume. Limited to fetch/$fetch + identifier.method to
1144
+ // bound false positives.
1145
+ const templateRe = /\b(?:fetch|\$fetch|ofetch|axios|got|ky|api|client|http|request)(?:\.\w+)?\s*\(\s*`(\/[\w/-]+\/)\$\{/g
1146
+ while ((m = templateRe.exec(content))) {
1147
+ const url = m[1] // e.g. '/api/users/'
1148
+ if (!looksLikeUrl(url) || url.length < 5) continue
1149
+ calls.push({ method: 'ANY', url, partial: true })
1150
+ }
1151
+ // ── SDK instance tracking (P2·4) ──────────────────────────
1152
+ // axios.create() / got.extend() / ky.create() / ofetch.create():
1153
+ // const myClient = axios.create({ baseURL: 'https://x' })
1154
+ // myClient.get('/users') ← treat the same as axios.get('/users')
1155
+ // We collect all such variable names first, then run a second method-
1156
+ // match pass using just those names. baseURL is intentionally ignored
1157
+ // (mixing http://prefix and /path is fuzzy; downstream route↔fetch
1158
+ // matcher only needs the relative path).
1159
+ const sdkInstanceRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:axios|got|ky|ofetch)\.(?:create|extend)\s*\(/g
1160
+ const sdkInstances = new Set()
1161
+ while ((m = sdkInstanceRe.exec(content))) sdkInstances.add(m[1])
1162
+ // Also: const api = useApi() / createApi() patterns common in Vue/React
1163
+ // (these resolve to axios-shaped instances)
1164
+ const factoryRe = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:useApi|createApi|createClient|useFetch)\s*\(/g
1165
+ while ((m = factoryRe.exec(content))) sdkInstances.add(m[1])
1166
+ for (const name of sdkInstances) {
1167
+ const reLiteral = new RegExp(`\\b${name}\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`, 'g')
1168
+ let mm
1169
+ while ((mm = reLiteral.exec(content))) {
1170
+ const url = mm[2]
1171
+ if (!looksLikeUrl(url)) continue
1172
+ calls.push({ method: mm[1].toUpperCase(), url, via: 'sdk-instance' })
1173
+ }
1174
+ // Template-literal prefix for the same instance
1175
+ const reTpl = new RegExp(`\\b${name}\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*\`(\\/[\\w/-]+\\/)\\$\\{`, 'g')
1176
+ while ((mm = reTpl.exec(content))) {
1177
+ calls.push({ method: mm[1].toUpperCase(), url: mm[2], via: 'sdk-instance', partial: true })
1178
+ }
1179
+ }
1180
+ // ── tRPC procedure calls (P2·4 — informational, no URL match) ──
1181
+ // trpc.users.list.useQuery() / trpc.posts.create.mutate(...)
1182
+ // We can't match these against server routes (URLs are implicit),
1183
+ // but record them for visibility under apiCalls with method='RPC'.
1184
+ const trpcRe = /\btrpc(?:\.[A-Za-z_][\w]*)+\.(?:useQuery|useMutation|query|mutate)\s*\(/g
1185
+ while ((m = trpcRe.exec(content))) {
1186
+ // extract "trpc.<a>.<b>....method" — keep the procedure path
1187
+ const segs = m[0].replace(/\s*\($/, '').split('.')
1188
+ // ['trpc', 'users', 'list', 'useQuery']
1189
+ const proc = segs.slice(1, -1).join('.') // 'users.list'
1190
+ if (proc) calls.push({ method: 'RPC', url: 'trpc:' + proc, via: 'trpc' })
1191
+ }
1192
+ // Dedup. Same (method, url) → keep the most informative entry:
1193
+ // an entry with `via: 'sdk-instance'` wins over the generic axiosRe
1194
+ // match (because sdk-instance correctly attributed it to the actual
1195
+ // SDK variable, not the bare keyword).
1196
+ const byKey = new Map()
1197
+ for (const c of calls) {
1198
+ const k = c.method + '|' + c.url
1199
+ const prev = byKey.get(k)
1200
+ if (!prev) { byKey.set(k, c); continue }
1201
+ // Prefer entries with via (sdk-instance > unmarked)
1202
+ if (!prev.via && c.via) byKey.set(k, c)
1203
+ }
1204
+ return [...byKey.values()]
1205
+ }
1206
+
1207
+ function extractPyRoutes(content) {
1208
+ const routes = []
1209
+ // Flask:
1210
+ // @app.route('/users', methods=['GET','POST'])
1211
+ // @app.get('/users')
1212
+ // @bp.route('/x')
1213
+ // FastAPI:
1214
+ // @app.get('/users')
1215
+ // @router.post('/users/{id}')
1216
+ const decRe = new RegExp(
1217
+ `@\\s*(?:[\\w]+)\\.(${HTTP_METHODS.join('|')}|route)\\s*\\(\\s*['"]([^'"]+)['"]([^)]*)\\)`,
1218
+ 'g'
1219
+ )
1220
+ let m
1221
+ while ((m = decRe.exec(content))) {
1222
+ const verb = m[1].toLowerCase()
1223
+ const path = m[2]
1224
+ // Look one line below the decorator for `def handler_name(...)`
1225
+ // so the file-graph builder can link the route to its handler.
1226
+ const after = content.slice(m.index + m[0].length)
1227
+ const hm = after.match(/^[^\n]*\n\s*(?:async\s+)?def\s+(\w+)/)
1228
+ const handler = hm ? hm[1] : null
1229
+ if (verb === 'route') {
1230
+ const mm = m[3].match(/methods\s*=\s*\[([^\]]+)\]/i)
1231
+ if (mm) {
1232
+ const methods = [...mm[1].matchAll(/['"](\w+)['"]/g)].map(x => x[1].toUpperCase())
1233
+ for (const method of methods) routes.push({ method, path, handler })
1234
+ } else {
1235
+ routes.push({ method: 'GET', path, handler }) // Flask default
1236
+ }
1237
+ } else {
1238
+ routes.push({ method: verb.toUpperCase(), path, handler })
1239
+ }
1240
+ }
1241
+ return routes
1242
+ }
1243
+
1244
+ // Go / Gin / Echo / Chi router calls
1245
+ // r.GET("/users", getUsers)
1246
+ // router.POST("/u/:id", h.UpdateUser)
1247
+ // e.Any("/x", handler)
1248
+ function extractGoRoutes(content) {
1249
+ const routes = []
1250
+ const re = new RegExp(
1251
+ `\\b(?:r|router|engine|app|e|mux|api|grp|group)\\.(${HTTP_METHODS.join('|')}|Any|Handle|HandleFunc)\\s*\\(\\s*"([^"]+)"\\s*,([^)]*)\\)`,
1252
+ 'gi'
1253
+ )
1254
+ let m
1255
+ while ((m = re.exec(content))) {
1256
+ const method = m[1].toUpperCase() === 'ANY' || m[1].toUpperCase() === 'HANDLE' || m[1].toUpperCase() === 'HANDLEFUNC' ? 'ANY' : m[1].toUpperCase()
1257
+ const path = m[2]
1258
+ const handlerStr = (m[3] || '').trim()
1259
+ const hm = handlerStr.match(/([A-Za-z_][\w.]*)\s*$/)
1260
+ let handler = null
1261
+ if (hm && !/(func\b|\{)/.test(handlerStr)) {
1262
+ // For receiver.Method form, take the rightmost segment
1263
+ handler = hm[1].split('.').pop()
1264
+ }
1265
+ routes.push({ method, path, handler })
1266
+ }
1267
+ return routes
1268
+ }
1269
+
1270
+ // Spring (Java) / NestJS (TS) / Quarkus (Java) annotation routes
1271
+ // @GetMapping("/users") public List<User> getUsers()
1272
+ // @RequestMapping(value="/u", method=RequestMethod.GET)
1273
+ // @Get('/users') getUsers(): User[]
1274
+ // @Post('/u') @Body() body
1275
+ function extractAnnotationRoutes(content) {
1276
+ const routes = []
1277
+ // Spring single-method mappings
1278
+ const springRe = /@(Get|Post|Put|Delete|Patch|Options|Head)Mapping\s*\(\s*(?:value\s*=\s*)?(?:"|')([^"']+)(?:"|')(?:[^)]*)\)\s*[\s\S]{0,200}?(?:public|private|protected)?\s*[\w<>\[\],?\s.]*\s+(\w+)\s*\(/g
1279
+ let m
1280
+ while ((m = springRe.exec(content))) {
1281
+ routes.push({ method: m[1].toUpperCase(), path: m[2], handler: m[3] })
1282
+ }
1283
+ // @RequestMapping(value="/x", method=RequestMethod.GET)
1284
+ const rmRe = /@RequestMapping\s*\([^)]*?value\s*=\s*"([^"]+)"[^)]*?method\s*=\s*RequestMethod\.(\w+)[^)]*\)\s*[\s\S]{0,200}?(?:public|private|protected)?\s*[\w<>\[\],?\s.]*\s+(\w+)\s*\(/g
1285
+ while ((m = rmRe.exec(content))) {
1286
+ routes.push({ method: m[2].toUpperCase(), path: m[1], handler: m[3] })
1287
+ }
1288
+ // NestJS-style decorators (TypeScript)
1289
+ // @Get('/users') getUsers() { ... }
1290
+ const nestRe = /@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*['"`]([^'"`]+)['"`][^)]*\)\s*(?:@\w+\s*\([^)]*\)\s*)*\s*(?:public|private|protected|async)?\s*(\w+)\s*\(/g
1291
+ while ((m = nestRe.exec(content))) {
1292
+ routes.push({ method: m[1].toUpperCase(), path: m[2], handler: m[3] })
1293
+ }
1294
+ return routes
1295
+ }
1296
+
1297
+ // File-system server-route extractors for popular meta-frameworks.
1298
+ // Each helper looks at the path id to decide if a file is a route,
1299
+ // then peeks at the content for method-specific exports.
1300
+
1301
+ const HTTP_VERBS_FS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
1302
+
1303
+ function methodsExportedFromContent(content) {
1304
+ const found = []
1305
+ for (const verb of HTTP_VERBS_FS) {
1306
+ const re = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+${verb}\\b|const\\s+${verb}\\s*=)`)
1307
+ if (re.test(content)) found.push(verb)
1308
+ }
1309
+ return found
1310
+ }
1311
+ function normalizeFsDynamic(p) {
1312
+ return p.replace(/\[\.\.\.(\w+)\]/g, '*').replace(/\[(\w+)\]/g, ':$1')
1313
+ }
1314
+
1315
+ // Next.js: src/app/api/<seg>/route.<ext> or src/pages/api/<seg>.<ext>
1316
+ export function extractNextApiRoutes(id, content) {
1317
+ if (!id || !content) return []
1318
+ let m, basePath
1319
+ if ((m = id.match(/^(?:src\/)?app\/(api\/.+)\/route\.(?:tsx|jsx|ts|js)$/))) {
1320
+ basePath = '/' + m[1]
1321
+ } else if ((m = id.match(/^(?:src\/)?pages\/(api\/.+)\.(?:tsx|jsx|ts|js)$/))) {
1322
+ basePath = '/' + m[1].replace(/\/index$/, '')
1323
+ }
1324
+ if (!basePath) return []
1325
+ basePath = normalizeFsDynamic(basePath)
1326
+ const methods = methodsExportedFromContent(content)
1327
+ if (methods.length > 0) return methods.map((method) => ({ method, path: basePath }))
1328
+ if (/export\s+default\s+(?:async\s+)?function/.test(content)) {
1329
+ return [{ method: 'ANY', path: basePath }]
1330
+ }
1331
+ return []
1332
+ }
1333
+
1334
+ // Nuxt 3 / Nitro: server/api/<seg>.<ext> or server/routes/<seg>.<ext>
1335
+ // Filename suffix can carry method: `foo.post.ts` → POST /api/foo
1336
+ // Otherwise body: `defineEventHandler({ method: 'X' })` or default ANY.
1337
+ export function extractNuxtServerRoutes(id, content) {
1338
+ if (!id || !content) return []
1339
+ const m = id.match(/^server\/(api|routes)\/(.+)\.(?:ts|js|mts|cts)$/)
1340
+ if (!m) return []
1341
+ let basePath = '/' + m[1] + '/' + m[2].replace(/\/index$/, '')
1342
+ // Method suffix in filename
1343
+ const suffixMatch = basePath.match(/^(.*)\.(get|post|put|patch|delete|head|options)$/)
1344
+ let methodFromName = null
1345
+ if (suffixMatch) { basePath = suffixMatch[1]; methodFromName = suffixMatch[2].toUpperCase() }
1346
+ basePath = normalizeFsDynamic(basePath)
1347
+ if (methodFromName) return [{ method: methodFromName, path: basePath }]
1348
+ // defineEventHandler({ method: 'POST', ... })
1349
+ const verbs = []
1350
+ const objRe = /defineEventHandler\s*\(\s*\{[^}]*?method\s*:\s*['"`](\w+)['"`]/g
1351
+ let mm
1352
+ while ((mm = objRe.exec(content))) verbs.push(mm[1].toUpperCase())
1353
+ if (verbs.length > 0) return verbs.map((method) => ({ method, path: basePath }))
1354
+ if (/(?:export\s+default\s+)?defineEventHandler/.test(content)) {
1355
+ return [{ method: 'ANY', path: basePath }]
1356
+ }
1357
+ return []
1358
+ }
1359
+
1360
+ // SvelteKit: src/routes/<seg>/+server.<ext> or src/routes/+server.<ext>
1361
+ export function extractSvelteKitServerRoutes(id, content) {
1362
+ if (!id || !content) return []
1363
+ let m, basePath
1364
+ if ((m = id.match(/^(?:src\/)?routes\/(.+)\/\+server\.(?:ts|js)$/))) {
1365
+ basePath = '/' + m[1]
1366
+ } else if (/^(?:src\/)?routes\/\+server\.(?:ts|js)$/.test(id)) {
1367
+ basePath = '/'
1368
+ }
1369
+ if (!basePath) return []
1370
+ basePath = normalizeFsDynamic(basePath)
1371
+ const methods = methodsExportedFromContent(content)
1372
+ if (methods.length === 0) return []
1373
+ return methods.map((method) => ({ method, path: basePath }))
1374
+ }
1375
+
1376
+ function extractPyApiCalls(content) {
1377
+ const calls = []
1378
+ // requests.get('http://...'), httpx.post('/x'), urllib...
1379
+ const re = new RegExp(
1380
+ `\\b(?:requests|httpx|aiohttp|urllib|httplib2|session)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"]([^'"]+)['"]`,
1381
+ 'g'
1382
+ )
1383
+ let m
1384
+ while ((m = re.exec(content))) {
1385
+ const url = m[2]
1386
+ if (!looksLikeUrl(url)) continue
1387
+ calls.push({ method: m[1].toUpperCase(), url })
1388
+ }
1389
+ // session.request('POST', '/x', ...) / client.request(method='POST', url='/x')
1390
+ const reqRe = /\b(?:requests|httpx|aiohttp|session|client)\.request\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]/g
1391
+ while ((m = reqRe.exec(content))) {
1392
+ const url = m[2]
1393
+ if (!looksLikeUrl(url)) continue
1394
+ calls.push({ method: m[1].toUpperCase(), url })
1395
+ }
1396
+ return calls
1397
+ }
1398
+
1399
+ // Conservative URL filter: keep things that look like an HTTP path or
1400
+ // an absolute http(s) URL. Reject bare specifiers, file paths, anchors.
1401
+ function looksLikeUrl(s) {
1402
+ if (!s) return false
1403
+ if (s.startsWith('/') && !s.startsWith('//')) return true
1404
+ if (/^https?:\/\//i.test(s)) return true
1405
+ return false
1406
+ }
1407
+
1408
+ // Normalize an API call URL or route path into a comparable form:
1409
+ // strips origin, query, hash, trailing slash; lowercases method.
1410
+ // Returns the path string starting with "/".
1411
+ export function normalizeUrlPath(u) {
1412
+ if (!u) return ''
1413
+ let s = u.split('?')[0].split('#')[0].trim()
1414
+ // Strip protocol+host
1415
+ s = s.replace(/^https?:\/\/[^/]+/i, '')
1416
+ // Trim multiple slashes
1417
+ s = s.replace(/\/+/g, '/')
1418
+ // Ensure leading /
1419
+ if (!s.startsWith('/')) s = '/' + s
1420
+ // Trim trailing slash (except root)
1421
+ if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
1422
+ return s
1423
+ }
1424
+
1425
+ // Build a regex from a route path with dynamic segments.
1426
+ // Supports: :id, {id}, <id>, <int:id>, * → matches one path segment.
1427
+ export function routePathToRegex(p) {
1428
+ const norm = normalizeUrlPath(p)
1429
+ // Two-pass: replace dynamic placeholders with sentinels BEFORE we
1430
+ // escape regex metas, then re-substitute the sentinels with the
1431
+ // actual regex patterns. Avoids order-of-escaping headaches.
1432
+ const PARAM = ''
1433
+ const WILD = ''
1434
+ let s = norm
1435
+ .replace(/:[A-Za-z_][A-Za-z0-9_]*/g, PARAM)
1436
+ .replace(/\{[^}]+\}/g, PARAM)
1437
+ .replace(/<[^>]+>/g, PARAM)
1438
+ .replace(/\*/g, WILD)
1439
+ s = s.replace(/[.+?^$()|[\]\\{}]/g, '\\$&')
1440
+ s = s.replace(new RegExp(PARAM, 'g'), '[^/]+')
1441
+ s = s.replace(new RegExp(WILD, 'g'), '.*')
1442
+ return new RegExp('^' + s + '$')
1443
+ }
1444
+
1445
+ // ─── Path resolution ──────────────────────────────────────────
1446
+ const RESOLVE_EXTS = [
1447
+ '', '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
1448
+ '.py', '.pyi',
1449
+ '.css', '.scss', '.sass', '.less',
1450
+ '.html', '.vue', '.svelte', '.astro',
1451
+ '.json', '.md', '.mdx', '.rst', '.svg',
1452
+ '.rs', '.go', '.java', '.kt', '.rb', '.php',
1453
+ '.cs', '.swift', '.dart',
1454
+ '.c', '.cc', '.cpp', '.h', '.hpp',
1455
+ '.lsp', '.dcl', '.clj', '.scm',
1456
+ '.sh', '.bash', '.ps1', '.psm1',
1457
+ ]
1458
+
1459
+ const INDEX_FILES = [
1460
+ 'index.js', 'index.jsx', 'index.ts', 'index.tsx',
1461
+ 'index.mjs', 'index.cjs', 'index.html',
1462
+ '__init__.py', 'mod.rs', 'main.go',
1463
+ ]
1464
+
1465
+ export function resolveImport(fromAbsPath, spec, rootAbs, validIds, fromExt) {
1466
+ if (!spec || typeof spec !== 'string') return null
1467
+
1468
+ // Strip query / hash
1469
+ spec = spec.split('?')[0].split('#')[0].trim()
1470
+ if (!spec) return null
1471
+
1472
+ // URL or data URI → skip
1473
+ if (/^(?:[a-z]+:)?\/\//i.test(spec) || spec.startsWith('data:')) return null
1474
+
1475
+ // CSS leading ~ (webpack alias) → strip
1476
+ if (spec.startsWith('~')) spec = spec.slice(1)
1477
+
1478
+ // Python relative dots (`.`, `..`, `.foo`)
1479
+ // .ipynb notebooks contain Python imports, treat them the same.
1480
+ if (/^(?:py|pyw|ipynb)$/.test(fromExt) && spec.startsWith('.')) {
1481
+ return resolvePythonRelative(fromAbsPath, spec, rootAbs, validIds)
1482
+ }
1483
+
1484
+ // Python dotted module (a.b.c) → search from the import root. Try flat
1485
+ // layout (package at repo root) AND src-layout (`src/<pkg>/`), which is the
1486
+ // modern standard and would otherwise be missed. (Only these two known
1487
+ // layouts — a broad suffix match would create false edges for stdlib/3rd-
1488
+ // party imports that happen to share an internal filename.)
1489
+ if ((fromExt === 'py' || fromExt === 'ipynb') && !spec.startsWith('.') && !spec.startsWith('/')) {
1490
+ const subPath = spec.replace(/\./g, '/')
1491
+ for (const pfx of ['', 'src/']) {
1492
+ for (const tail of [subPath + '.py', subPath + '/__init__.py']) {
1493
+ if (validIds.has(pfx + tail)) return pfx + tail
1494
+ }
1495
+ }
1496
+ return null
1497
+ }
1498
+
1499
+ // C# dotted namespace (System.Collections.Generic) → search.
1500
+ // Standard convention: namespace path matches directory path. We
1501
+ // try the full path first, then progressively shorter prefixes
1502
+ // (handles `using A.B.C` where the file is `A/B/C.cs` OR `A/B.cs`).
1503
+ if (fromExt === 'cs' && /^[A-Za-z]/.test(spec)) {
1504
+ const parts = spec.split('.')
1505
+ for (let i = parts.length; i >= 1; i--) {
1506
+ const sub = parts.slice(0, i).join('/')
1507
+ const cand = path.join(rootAbs, sub + '.cs')
1508
+ const id = idOf(rootAbs, cand)
1509
+ if (validIds.has(id)) return id
1510
+ }
1511
+ // Also try as a folder containing the namespace's .cs files
1512
+ for (let i = parts.length; i >= 1; i--) {
1513
+ const sub = parts.slice(0, i).join('/')
1514
+ // Just check for *any* .cs file inside (return the first match)
1515
+ for (const id of validIds) {
1516
+ if (id.startsWith(sub + '/') && id.endsWith('.cs')) return id
1517
+ }
1518
+ }
1519
+ // Fully-qualified `using Root.Sub` where the root namespace maps to the
1520
+ // scan root (not a directory) — drop leading segments and match the
1521
+ // suffix: `FluentValidation.Internal` → `Internal/…`.
1522
+ for (let start = 1; start < parts.length; start++) {
1523
+ const sub = parts.slice(start).join('/')
1524
+ const cand = idOf(rootAbs, path.join(rootAbs, sub + '.cs'))
1525
+ if (validIds.has(cand)) return cand
1526
+ for (const id of validIds) {
1527
+ if (id.startsWith(sub + '/') && id.endsWith('.cs')) return id
1528
+ }
1529
+ }
1530
+ return null
1531
+ }
1532
+
1533
+ // Dart — `dart:`/`package:` are external (SDK / pub.dev), skip them.
1534
+ // Everything else is a path string relative to the importing file's
1535
+ // directory: `import 'services/x.dart'` from `lib/main.dart` resolves
1536
+ // to `lib/services/x.dart`. Also handles `export` / `part` / `part of`.
1537
+ if (fromExt === 'dart') {
1538
+ if (spec.startsWith('dart:')) return null
1539
+ if (spec.startsWith('package:')) {
1540
+ // `package:<self>/x.dart` → lib/x.dart; other packages are external.
1541
+ const body = spec.slice('package:'.length)
1542
+ const slash = body.indexOf('/')
1543
+ const pkg = slash < 0 ? body : body.slice(0, slash)
1544
+ const sub = slash < 0 ? '' : body.slice(slash + 1)
1545
+ // `package:<name>/sub` → that workspace package's lib/sub (self OR sibling).
1546
+ const pkgs = loadDartPackages(rootAbs, validIds)
1547
+ const dir = pkgs.get(pkg)
1548
+ if (dir != null && sub) {
1549
+ const cand = (dir ? dir + '/' : '') + 'lib/' + sub
1550
+ if (validIds.has(cand)) return cand
1551
+ }
1552
+ return null
1553
+ }
1554
+ const fromDir = path.dirname(fromAbsPath)
1555
+ const cand = path.resolve(fromDir, spec)
1556
+ const id = idOf(rootAbs, cand)
1557
+ if (validIds.has(id)) return id
1558
+ return null
1559
+ }
1560
+
1561
+ // Swift `import ModuleName` → look for a top-level folder of that
1562
+ // name (Sources/ModuleName/ or just ModuleName/) and return one of
1563
+ // its .swift files. SwiftPM convention.
1564
+ if (fromExt === 'swift' && /^[A-Za-z]/.test(spec)) {
1565
+ const moduleName = spec.split('.')[0] // ignore .Submodule for now
1566
+ const patterns = [
1567
+ `Sources/${moduleName}/`,
1568
+ `${moduleName}/Sources/`,
1569
+ `${moduleName}/`,
1570
+ ]
1571
+ for (const pat of patterns) {
1572
+ for (const id of validIds) {
1573
+ if (id.includes(pat) && id.endsWith('.swift')) return id
1574
+ }
1575
+ }
1576
+ return null
1577
+ }
1578
+
1579
+ // Go — `import "github.com/owner/repo/sub/pkg"`.
1580
+ //
1581
+ // Internal vs external is determined by reading the repo's go.mod once
1582
+ // and caching the module declaration; anything prefixed with that path
1583
+ // is internal and the suffix maps to a directory of .go files.
1584
+ if (fromExt === 'go' && /^[A-Za-z0-9_]/.test(spec)) {
1585
+ const loc = goImportLocation(spec, rootAbs, validIds)
1586
+ if (loc) {
1587
+ const prefix = (loc.dir ? loc.dir + '/' : '') + (loc.internalPath ? loc.internalPath + '/' : '')
1588
+ // A representative .go file in the target package dir (immediate child).
1589
+ for (const id of validIds) {
1590
+ if (id.startsWith(prefix) && id.endsWith('.go')
1591
+ && !id.slice(prefix.length).includes('/')) return id
1592
+ }
1593
+ // Fallback: any .go anywhere under the dir
1594
+ for (const id of validIds) {
1595
+ if (id.startsWith(prefix) && id.endsWith('.go')) return id
1596
+ }
1597
+ }
1598
+ return null
1599
+ }
1600
+
1601
+ // Rust — `use crate::a::b::c`, `use self::x`, `use super::x`, `mod x;`.
1602
+ //
1603
+ // Maps the module path to `src/a/b/c.rs`, `src/a/b/c/mod.rs`, or for
1604
+ // `mod x;` resolved relative to the importing file's directory.
1605
+ if (fromExt === 'rs' && /^(?:::)?[A-Za-z_]/.test(spec)) { // allow leading `::` (absolute path)
1606
+ return resolveRustModule(fromAbsPath, spec, rootAbs, validIds)
1607
+ }
1608
+
1609
+ // Java / Kotlin — `import com.foo.bar.Baz` → look for any file ending
1610
+ // in `com/foo/bar/Baz.{java,kt}`. Source roots vary (`src/main/java/`,
1611
+ // `src/`, etc.), so we suffix-match instead of trying to enumerate
1612
+ // them. We also try shortening the FQN (Spring/Guava `import a.b.*`
1613
+ // wildcards end up as just `a.b` — match it as a directory).
1614
+ if ((fromExt === 'java' || fromExt === 'kt') && /^[A-Za-z_]/.test(spec)) {
1615
+ const cleaned = spec.replace(/\.\*$/, '') // strip wildcard
1616
+ const parts = cleaned.split('.')
1617
+ if (parts.length < 2) return null
1618
+ // Try Foo.java / Foo.kt (innermost segment as the class name)
1619
+ const exts = fromExt === 'kt' ? ['.kt', '.java'] : ['.java', '.kt']
1620
+ for (let i = parts.length; i >= 2; i--) {
1621
+ const tail = parts.slice(0, i).join('/')
1622
+ for (const ext of exts) {
1623
+ const suffix = '/' + tail + ext
1624
+ for (const id of validIds) {
1625
+ // endsWith('/'+...) misses files at the source root (id has no
1626
+ // leading segment, e.g. ROOT=src/main/java → id 'com/foo/Bar.java').
1627
+ if (id.endsWith(suffix) || id === tail + ext) return id
1628
+ }
1629
+ }
1630
+ }
1631
+ return null
1632
+ }
1633
+
1634
+ // PowerShell `Import-Module Name` → look for Name.psm1 / Name/Name.psm1
1635
+ if (fromExt === 'ps1' && /^[A-Za-z]/.test(spec) && !spec.includes('/')) {
1636
+ const candidates = [`${spec}.psm1`, `${spec}/${spec}.psm1`, `${spec}.ps1`]
1637
+ for (const cand of candidates) {
1638
+ const id = idOf(rootAbs, path.join(rootAbs, cand))
1639
+ if (validIds.has(id)) return id
1640
+ }
1641
+ return null
1642
+ }
1643
+
1644
+ // Clojure / Scheme dotted/dashed namespace → file path
1645
+ // Convention: `my.cool.ns` → `my/cool/ns.clj` (also `_` for `-`)
1646
+ if ((fromExt === 'clj' || fromExt === 'scm') && /^[a-z]/.test(spec)) {
1647
+ const subPath = spec.replace(/\./g, '/').replace(/-/g, '_')
1648
+ for (const ext of [`.${fromExt}`, '.cljc']) {
1649
+ const cand = path.join(rootAbs, subPath + ext)
1650
+ const id = idOf(rootAbs, cand)
1651
+ if (validIds.has(id)) return id
1652
+ }
1653
+ return null
1654
+ }
1655
+
1656
+ // PHP — `use Vendor\Pkg\Class;` (PSR-4) resolves like a Java FQN: turn the
1657
+ // namespace separators into slashes and suffix-match a `.php` file. The
1658
+ // leading `\`, `as` aliases and group braces were handled in parsePHP.
1659
+ // require/include string paths (no backslash) fall through to relative.
1660
+ if (fromExt === 'php' && spec.includes('\\')) {
1661
+ const fqcn = spec.replace(/^\\+/, '')
1662
+ // Authoritative composer PSR-4 map first.
1663
+ for (const { prefix, dir } of loadComposerPsr4(rootAbs, validIds)) {
1664
+ if (fqcn === prefix || fqcn.startsWith(prefix + '\\')) {
1665
+ const rest = fqcn.slice(prefix.length).replace(/^\\+/, '').replace(/\\/g, '/')
1666
+ const cand = (rest ? (dir ? dir + '/' + rest : rest) : dir) + '.php'
1667
+ if (validIds.has(cand)) return cand
1668
+ }
1669
+ }
1670
+ // Fallback: suffix match (no composer.json, or unmapped namespace).
1671
+ const rel = fqcn.replace(/\\/g, '/') + '.php'
1672
+ if (validIds.has(rel)) return rel
1673
+ for (const id of validIds) {
1674
+ if (id.endsWith('/' + rel)) return id
1675
+ }
1676
+ return null
1677
+ }
1678
+
1679
+ // Ruby — `require_relative "x"` is relative to the requiring file; `require
1680
+ // "gem/x"` is resolved against the load path (conventionally lib/). Try
1681
+ // both, then a `/x.rb` suffix match. Stdlib/gems (`require "json"`) match
1682
+ // nothing → null.
1683
+ if (fromExt === 'rb') {
1684
+ const relTry = tryResolve(path.resolve(path.dirname(fromAbsPath), spec), rootAbs, validIds)
1685
+ if (relTry) return relTry
1686
+ const want = spec.replace(/\.rb$/, '') + '.rb'
1687
+ if (validIds.has(want)) return want
1688
+ if (validIds.has('lib/' + want)) return 'lib/' + want
1689
+ for (const id of validIds) {
1690
+ if (id.endsWith('/' + want)) return id
1691
+ }
1692
+ return null
1693
+ }
1694
+
1695
+ // Relative path
1696
+ if (spec.startsWith('.') || spec.startsWith('/')) {
1697
+ const fromDir = path.dirname(fromAbsPath)
1698
+ const base = spec.startsWith('/')
1699
+ ? path.join(rootAbs, spec)
1700
+ : path.resolve(fromDir, spec)
1701
+ return tryResolve(base, rootAbs, validIds)
1702
+ }
1703
+
1704
+ // HTML / CSS / Markdown / SCSS-style refs commonly omit the `./` prefix.
1705
+ // Treat path-like specifiers (containing `/` or having an extension) as
1706
+ // relative from the source file when the source is one of those formats.
1707
+ const PATH_LIKE_SOURCES = new Set([
1708
+ 'html', 'htm', 'css', 'scss', 'sass', 'less', 'md', 'mdx', 'svelte', 'vue',
1709
+ // C/C++: `#include "x.h"` omits the ./ and is relative to the file's dir
1710
+ // (or found via an -I dir — handled by the basename fallback below).
1711
+ 'c', 'cc', 'cpp', 'h', 'hpp',
1712
+ ])
1713
+ if (PATH_LIKE_SOURCES.has(fromExt) && (spec.includes('/') || spec.includes('.'))) {
1714
+ const fromDir = path.dirname(fromAbsPath)
1715
+ const relTry = tryResolve(path.resolve(fromDir, spec), rootAbs, validIds)
1716
+ if (relTry) return relTry
1717
+ // Also try as if it were rooted (e.g. /assets/x.png in HTML)
1718
+ const rootTry = tryResolve(path.join(rootAbs, spec), rootAbs, validIds)
1719
+ if (rootTry) return rootTry
1720
+ // C/C++ headers are frequently resolved through -I include dirs; fall back
1721
+ // to a unique basename match anywhere in the tree.
1722
+ if (fromExt === 'c' || fromExt === 'cc' || fromExt === 'cpp' || fromExt === 'h' || fromExt === 'hpp') {
1723
+ const base = spec.split('/').pop()
1724
+ for (const id of validIds) {
1725
+ if (id === base || id.endsWith('/' + base)) return id
1726
+ }
1727
+ }
1728
+ return null
1729
+ }
1730
+
1731
+ // Before giving up on a bare specifier, try TypeScript path mapping
1732
+ // (`@excalidraw/common` → `./packages/common/src/index.ts`) for any
1733
+ // ext that uses tsconfig — JS/TS, plus jsconfig for plain JS.
1734
+ if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(fromExt)) {
1735
+ const r = resolveTsconfigPath(spec, rootAbs, validIds)
1736
+ if (r) return r
1737
+ }
1738
+
1739
+ // Monorepo workspace package — `import x from '@scope/utils'` (or a subpath)
1740
+ // where `utils` is a SIBLING workspace package (by its package.json name).
1741
+ // Resolve to that package's source entry / subpath, not node_modules.
1742
+ if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'vue', 'svelte', 'astro'].includes(fromExt)
1743
+ && /^(?:@[\w.-]+\/)?[\w.-]/.test(spec)) {
1744
+ const ws = loadJsWorkspace(rootAbs, validIds)
1745
+ let best = null
1746
+ for (const name of ws.keys()) {
1747
+ if ((spec === name || spec.startsWith(name + '/')) && (!best || name.length > best.length)) best = name
1748
+ }
1749
+ if (best) {
1750
+ const { dir, entry } = ws.get(best)
1751
+ const tryAt = (rel) => rel == null ? null : tryResolve(path.join(rootAbs, rel.split('/').join(path.sep)), rootAbs, validIds)
1752
+ const pfx = dir ? dir + '/' : ''
1753
+ if (spec === best) {
1754
+ const r = tryAt(entry ? pfx + entry.replace(/^\.\//, '') : null) || tryAt(pfx + 'src') || tryAt(dir || '.')
1755
+ if (r) return r
1756
+ } else {
1757
+ const sub = spec.slice(best.length + 1)
1758
+ const r = tryAt(pfx + 'src/' + sub) || tryAt(pfx + sub)
1759
+ if (r) return r
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ // Bare specifier → either an external package or an unresolvable alias.
1765
+ return null
1766
+ }
1767
+
1768
+ // Like resolveImport, but for languages where ONE import pulls in an entire
1769
+ // module/namespace, returns EVERY file in that unit instead of a single
1770
+ // representative — so blast/deps are file-complete. All other languages (and
1771
+ // file-precise imports) return the single resolveImport result as a 1-array,
1772
+ // so the scanner's behaviour is unchanged for them.
1773
+ export function resolveImportAll(fromAbsPath, spec, rootAbs, validIds, fromExt) {
1774
+ spec = (spec || '').split('?')[0].split('#')[0].trim()
1775
+ if (!spec) return []
1776
+
1777
+ // Swift: `import Module` → every .swift under that module's source dir
1778
+ // (a module spans all files in Sources/<Module>/, recursively).
1779
+ if (fromExt === 'swift' && /^[A-Za-z_]/.test(spec)) {
1780
+ const m = spec.split('.')[0]
1781
+ for (const pat of [`Sources/${m}/`, `${m}/Sources/`, `${m}/`]) {
1782
+ const hits = [...validIds].filter((id) => id.includes(pat) && id.endsWith('.swift'))
1783
+ if (hits.length) return hits
1784
+ }
1785
+ return []
1786
+ }
1787
+
1788
+ // C#: `using A.B` → every .cs file that DECLARES namespace A.B. Resolved via
1789
+ // the namespace index (declarations), with C#'s relative lookup through the
1790
+ // importing file's enclosing namespaces. Falls back to a folder match if the
1791
+ // project has no parseable namespace declarations.
1792
+ if (fromExt === 'cs' && /^[A-Za-z]/.test(spec)) {
1793
+ const { nsToFiles, fileToNs } = loadCsNamespaceIndex(rootAbs, validIds)
1794
+ if (nsToFiles.size) {
1795
+ const fromId = idOf(rootAbs, fromAbsPath)
1796
+ const base = (fileToNs.get(fromId) || '').split('.').filter(Boolean)
1797
+ const want = spec.split('.')
1798
+ for (let i = base.length; i >= 0; i--) { // innermost enclosing first
1799
+ const cand = base.slice(0, i).concat(want).join('.')
1800
+ if (nsToFiles.has(cand)) return [...nsToFiles.get(cand)].filter((id) => id !== fromId)
1801
+ }
1802
+ return []
1803
+ }
1804
+ // Fallback: mirrored folder, immediate children only.
1805
+ const parts = spec.split('.')
1806
+ const folders = []
1807
+ for (let i = parts.length; i >= 1; i--) folders.push(parts.slice(0, i).join('/'))
1808
+ for (let s = 1; s < parts.length; s++) folders.push(parts.slice(s).join('/'))
1809
+ for (const sub of folders) {
1810
+ const hits = [...validIds].filter((id) =>
1811
+ id.endsWith('.cs') && id.startsWith(sub + '/') && !id.slice(sub.length + 1).includes('/'))
1812
+ if (hits.length) return hits
1813
+ }
1814
+ return []
1815
+ }
1816
+
1817
+ // Go: `import "mod/sub/pkg"` → every .go file in that package directory
1818
+ // (a Go package is the whole directory; importing it pulls in all its files).
1819
+ if (fromExt === 'go' && /^[A-Za-z0-9_]/.test(spec)) {
1820
+ const loc = goImportLocation(spec, rootAbs, validIds)
1821
+ if (!loc) return []
1822
+ const prefix = (loc.dir ? loc.dir + '/' : '') + (loc.internalPath ? loc.internalPath + '/' : '')
1823
+ return [...validIds].filter((id) =>
1824
+ id.startsWith(prefix) && id.endsWith('.go') && !id.slice(prefix.length).includes('/'))
1825
+ }
1826
+
1827
+ const one = resolveImport(fromAbsPath, spec, rootAbs, validIds, fromExt)
1828
+ return one ? [one] : []
1829
+ }
1830
+
1831
+ function tryResolve(basePath, rootAbs, validIds) {
1832
+ // If basePath itself is an indexed file (e.g. `foo.wasm` / `foo.node`),
1833
+ // match directly. Without this, FFI imports like `require('./addon.node')`
1834
+ // would never resolve because tryResolve only appends extensions.
1835
+ const directId = idOf(rootAbs, basePath)
1836
+ if (validIds.has(directId)) return directId
1837
+ // TypeScript NodeNext: an import written as './x.js' (or .jsx/.mjs/.cjs)
1838
+ // commonly resolves to the sibling TS source './x.ts'. TS-style ESM
1839
+ // *requires* the .js extension in the specifier even though the file on
1840
+ // disk is .ts — so try the TS twin before the generic extension sweep.
1841
+ const tsTwin = basePath.match(/^(.*)\.(jsx|mjs|cjs|js)$/)
1842
+ if (tsTwin) {
1843
+ const twinExts = tsTwin[2] === 'jsx' ? ['.tsx']
1844
+ : tsTwin[2] === 'mjs' ? ['.mts']
1845
+ : tsTwin[2] === 'cjs' ? ['.cts']
1846
+ : ['.ts', '.tsx'] // .js → .ts then .tsx
1847
+ for (const ext of twinExts) {
1848
+ const id = idOf(rootAbs, tsTwin[1] + ext)
1849
+ if (validIds.has(id)) return id
1850
+ }
1851
+ }
1852
+ // Direct + extensions
1853
+ for (const ext of RESOLVE_EXTS) {
1854
+ const cand = basePath + ext
1855
+ const id = idOf(rootAbs, cand)
1856
+ if (validIds.has(id)) return id
1857
+ }
1858
+ // Directory + index files
1859
+ for (const idx of INDEX_FILES) {
1860
+ const cand = path.join(basePath, idx)
1861
+ const id = idOf(rootAbs, cand)
1862
+ if (validIds.has(id)) return id
1863
+ }
1864
+ return null
1865
+ }
1866
+
1867
+ function resolvePythonRelative(fromAbsPath, spec, rootAbs, validIds) {
1868
+ // spec like ".module", "..pkg.sub", "."
1869
+ const dots = spec.match(/^\.+/)[0].length
1870
+ const rest = spec.slice(dots).replace(/\./g, '/')
1871
+ let base = path.dirname(fromAbsPath)
1872
+ for (let i = 1; i < dots; i++) base = path.dirname(base)
1873
+ const candPath = rest ? path.join(base, rest) : base
1874
+ for (const ext of ['.py', '/__init__.py']) {
1875
+ const cand = candPath + ext
1876
+ const id = idOf(rootAbs, cand)
1877
+ if (validIds.has(id)) return id
1878
+ }
1879
+ return null
1880
+ }
1881
+
1882
+ function idOf(rootAbs, absPath) {
1883
+ return path.relative(rootAbs, absPath).split(path.sep).join('/')
1884
+ }
1885
+
1886
+ // ─── tsconfig.json paths cache ──────────────────────────────────
1887
+ // Reads `compilerOptions.paths` (and `baseUrl`) from the project's
1888
+ // root tsconfig.json once per rootAbs. Lets us resolve TypeScript
1889
+ // path mapping like `@excalidraw/common` → `./packages/common/src/index.ts`.
1890
+ //
1891
+ // JSON-with-comments tolerant: strips // line comments and /* block */
1892
+ // comments before JSON.parse so real-world tsconfig files don't fail.
1893
+ const _tsconfigCache = new Map() // rootAbs → { baseUrl, paths: [{ pattern, targets }] }
1894
+ function loadTsconfigPaths(rootAbs) {
1895
+ if (_tsconfigCache.has(rootAbs)) return _tsconfigCache.get(rootAbs)
1896
+ let cfg = { baseUrl: '.', paths: [] }
1897
+ try {
1898
+ // Read root tsconfig.json first; fall back to jsconfig.json.
1899
+ const candidates = [path.join(rootAbs, 'tsconfig.json'), path.join(rootAbs, 'jsconfig.json')]
1900
+ let raw = null
1901
+ for (const c of candidates) {
1902
+ if (fs.existsSync(c)) { raw = fs.readFileSync(c, 'utf8'); break }
1903
+ }
1904
+ if (raw) {
1905
+ const stripped = raw
1906
+ .replace(/\/\*[\s\S]*?\*\//g, '')
1907
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1')
1908
+ .replace(/,(\s*[}\]])/g, '$1') // trailing commas
1909
+ const parsed = JSON.parse(stripped)
1910
+ const co = parsed?.compilerOptions || {}
1911
+ cfg.baseUrl = co.baseUrl || '.'
1912
+ if (co.paths) {
1913
+ for (const [pattern, targets] of Object.entries(co.paths)) {
1914
+ cfg.paths.push({ pattern, targets: Array.isArray(targets) ? targets : [targets] })
1915
+ }
1916
+ }
1917
+ }
1918
+ } catch {}
1919
+ _tsconfigCache.set(rootAbs, cfg)
1920
+ return cfg
1921
+ }
1922
+
1923
+ // Composer PSR-4 autoload map (`composer.json`) — the authoritative way a
1924
+ // PHP `use Vendor\Pkg\Class;` maps to a file. A prefix may strip namespace
1925
+ // segments that aren't mirrored in the path (e.g. `GuzzleHttp\Psr7\` → `src/`),
1926
+ // which a plain suffix match cannot recover. Read once per root (cf. go.mod).
1927
+ // C# namespace index — C# namespaces are declared explicitly and are NOT
1928
+ // bound to file paths (a folder `Resources/Languages/` may declare namespace
1929
+ // `Foo.Resources`). To fan a `using A.B` out to the right files we must read
1930
+ // the `namespace` declarations rather than guess from the folder. Built once
1931
+ // per root (cf. composer / go.mod caches).
1932
+ const _csNsCache = new Map() // rootAbs → { nsToFiles: Map<ns,Set<id>>, fileToNs: Map<id,ns> }
1933
+ function loadCsNamespaceIndex(rootAbs, validIds) {
1934
+ if (_csNsCache.has(rootAbs)) return _csNsCache.get(rootAbs)
1935
+ const nsToFiles = new Map(), fileToNs = new Map()
1936
+ const NS = /^\s*namespace\s+([A-Za-z_][\w.]*)/gm
1937
+ for (const id of validIds) {
1938
+ if (!id.endsWith('.cs')) continue
1939
+ let txt
1940
+ try { txt = fs.readFileSync(path.join(rootAbs, id.split('/').join(path.sep)), 'utf8') } catch { continue }
1941
+ if (txt.charCodeAt(0) === 0xFEFF) txt = txt.slice(1) // strip BOM
1942
+ let m, first = null
1943
+ NS.lastIndex = 0
1944
+ while ((m = NS.exec(txt))) {
1945
+ const ns = m[1]
1946
+ if (!first) first = ns
1947
+ if (!nsToFiles.has(ns)) nsToFiles.set(ns, new Set())
1948
+ nsToFiles.get(ns).add(id)
1949
+ }
1950
+ if (first) fileToNs.set(id, first)
1951
+ }
1952
+ const idx = { nsToFiles, fileToNs }
1953
+ _csNsCache.set(rootAbs, idx)
1954
+ return idx
1955
+ }
1956
+
1957
+ const _composerCache = new Map() // rootAbs → [{ prefix, dir }] longest-first
1958
+ function loadComposerPsr4(rootAbs, validIds) {
1959
+ if (_composerCache.has(rootAbs)) return _composerCache.get(rootAbs)
1960
+ let maps = []
1961
+ // Scan EVERY composer.json (monorepos have one per package), not just the
1962
+ // root — each PSR-4 dir is relative to its own composer.json location.
1963
+ const composerFiles = validIds
1964
+ ? [...validIds].filter((id) => (id === 'composer.json' || id.endsWith('/composer.json')) && !id.includes('vendor/'))
1965
+ : ['composer.json']
1966
+ for (const cf of composerFiles) {
1967
+ try {
1968
+ const abs = path.join(rootAbs, cf.split('/').join(path.sep))
1969
+ if (!fs.existsSync(abs)) continue
1970
+ const parsed = JSON.parse(fs.readFileSync(abs, 'utf8'))
1971
+ const baseDir = cf.includes('/') ? cf.slice(0, cf.lastIndexOf('/')) : ''
1972
+ const sources = [parsed?.autoload?.['psr-4'], parsed?.['autoload-dev']?.['psr-4']]
1973
+ for (const m of sources) {
1974
+ if (!m) continue
1975
+ for (const [prefix, dir] of Object.entries(m)) {
1976
+ for (const one of (Array.isArray(dir) ? dir : [dir])) {
1977
+ let d = String(one).replace(/^\.\//, '').replace(/\/+$/, '')
1978
+ if (baseDir) d = d ? baseDir + '/' + d : baseDir
1979
+ maps.push({ prefix: prefix.replace(/\\+$/, ''), dir: d })
1980
+ }
1981
+ }
1982
+ }
1983
+ } catch {}
1984
+ }
1985
+ maps.sort((a, b) => b.prefix.length - a.prefix.length) // longest prefix wins
1986
+ _composerCache.set(rootAbs, maps)
1987
+ return maps
1988
+ }
1989
+
1990
+ // Dart packages (`pubspec.yaml`) — `package:<name>/x.dart` resolves to that
1991
+ // package's `lib/x.dart`. A melos/workspace repo has many pubspec.yaml, so map
1992
+ // EVERY package name → its dir (not just the root), to resolve both self- and
1993
+ // sibling-package imports. Cached per root.
1994
+ const _pubspecCache = new Map() // rootAbs → Map<name, packageDir>
1995
+ function loadDartPackages(rootAbs, validIds) {
1996
+ if (_pubspecCache.has(rootAbs)) return _pubspecCache.get(rootAbs)
1997
+ const byName = new Map()
1998
+ const files = validIds
1999
+ ? [...validIds].filter((id) => id === 'pubspec.yaml' || id.endsWith('/pubspec.yaml'))
2000
+ : ['pubspec.yaml']
2001
+ for (const pf of files) {
2002
+ try {
2003
+ const abs = path.join(rootAbs, pf.split('/').join(path.sep))
2004
+ if (!fs.existsSync(abs)) continue
2005
+ const m = fs.readFileSync(abs, 'utf8').match(/^name:\s*(\S+)/m)
2006
+ if (m) byName.set(m[1], pf.includes('/') ? pf.slice(0, pf.lastIndexOf('/')) : '')
2007
+ } catch {}
2008
+ }
2009
+ _pubspecCache.set(rootAbs, byName)
2010
+ return byName
2011
+ }
2012
+
2013
+ // JS/TS monorepo workspace index — `import … from '@scope/pkg'` (a sibling
2014
+ // workspace package by its package.json name) should resolve to that package's
2015
+ // SOURCE, not node_modules. Map every package.json name → its dir + entry.
2016
+ // Cached per root. Complements tsconfig path aliases.
2017
+ const _jsWsCache = new Map() // rootAbs → Map<name, { dir, entry }>
2018
+ function loadJsWorkspace(rootAbs, validIds) {
2019
+ if (_jsWsCache.has(rootAbs)) return _jsWsCache.get(rootAbs)
2020
+ const byName = new Map()
2021
+ const files = validIds
2022
+ ? [...validIds].filter((id) => (id === 'package.json' || id.endsWith('/package.json')) && !id.includes('node_modules/'))
2023
+ : []
2024
+ for (const pf of files) {
2025
+ try {
2026
+ const parsed = JSON.parse(fs.readFileSync(path.join(rootAbs, pf.split('/').join(path.sep)), 'utf8'))
2027
+ if (!parsed.name) continue
2028
+ const dir = pf.includes('/') ? pf.slice(0, pf.lastIndexOf('/')) : ''
2029
+ // Source entry: prefer exports['.'] / module / main (these may point at a
2030
+ // built dist/, which simply won't resolve → we fall back to src/index).
2031
+ let entry = null
2032
+ const exp = parsed.exports
2033
+ if (typeof exp === 'string') entry = exp
2034
+ else if (exp && typeof exp === 'object') {
2035
+ const dot = exp['.']
2036
+ entry = typeof dot === 'string' ? dot : (dot && (dot.import || dot.default || dot.require || dot.types))
2037
+ }
2038
+ entry = entry || parsed.module || parsed.main || null
2039
+ byName.set(parsed.name, { dir, entry })
2040
+ } catch {}
2041
+ }
2042
+ _jsWsCache.set(rootAbs, byName)
2043
+ return byName
2044
+ }
2045
+
2046
+ // Resolve an import spec via tsconfig path mapping. Returns the
2047
+ // matched file id, or null if no path mapping applies / no file
2048
+ // matches the resolved target.
2049
+ function resolveTsconfigPath(spec, rootAbs, validIds) {
2050
+ const cfg = loadTsconfigPaths(rootAbs)
2051
+ if (!cfg.paths.length) return null
2052
+ const baseDir = path.resolve(rootAbs, cfg.baseUrl)
2053
+ for (const { pattern, targets } of cfg.paths) {
2054
+ // Patterns may end with `/*` for prefix mapping; otherwise exact match.
2055
+ if (pattern.endsWith('/*')) {
2056
+ const prefix = pattern.slice(0, -2) // '@excalidraw/common'
2057
+ if (spec === prefix || spec.startsWith(prefix + '/')) {
2058
+ const rest = spec === prefix ? '' : spec.slice(prefix.length + 1)
2059
+ for (const tgt of targets) {
2060
+ const tgtRel = tgt.endsWith('/*') ? tgt.slice(0, -2) : tgt
2061
+ const cand = rest
2062
+ ? path.join(baseDir, tgtRel, rest)
2063
+ : path.join(baseDir, tgtRel)
2064
+ const r = tryResolve(cand, rootAbs, validIds)
2065
+ if (r) return r
2066
+ }
2067
+ }
2068
+ } else if (spec === pattern) {
2069
+ for (const tgt of targets) {
2070
+ const cand = path.join(baseDir, tgt)
2071
+ const r = tryResolve(cand, rootAbs, validIds)
2072
+ if (r) return r
2073
+ // Some targets are direct file paths without extension fallback
2074
+ const direct = idOf(rootAbs, cand)
2075
+ if (validIds.has(direct)) return direct
2076
+ }
2077
+ }
2078
+ }
2079
+ return null
2080
+ }
2081
+
2082
+ // ─── Go module prefix cache ─────────────────────────────────────
2083
+ // Reads `module github.com/owner/repo` from the repo's go.mod once per
2084
+ // rootAbs. Used to decide which imports point inside the repo vs
2085
+ // external packages.
2086
+ const _goModCache = new Map()
2087
+ function getGoModulePrefix(rootAbs) {
2088
+ if (_goModCache.has(rootAbs)) return _goModCache.get(rootAbs)
2089
+ let prefix = null
2090
+ try {
2091
+ const p = path.join(rootAbs, 'go.mod')
2092
+ if (fs.existsSync(p)) {
2093
+ const txt = fs.readFileSync(p, 'utf8')
2094
+ const m = txt.match(/^\s*module\s+(\S+)/m)
2095
+ if (m) prefix = m[1]
2096
+ }
2097
+ } catch {}
2098
+ _goModCache.set(rootAbs, prefix)
2099
+ return prefix
2100
+ }
2101
+
2102
+ // Go modules — a repo may hold several modules (go.work / nested go.mod), each
2103
+ // with its own `module <prefix>` declaration rooted at its own dir. Map every
2104
+ // go.mod → { prefix, dir } so an import is resolved within the RIGHT module.
2105
+ const _goModsCache = new Map()
2106
+ function loadGoModules(rootAbs, validIds) {
2107
+ if (_goModsCache.has(rootAbs)) return _goModsCache.get(rootAbs)
2108
+ const mods = []
2109
+ const files = validIds
2110
+ ? [...validIds].filter((id) => id === 'go.mod' || id.endsWith('/go.mod'))
2111
+ : ['go.mod']
2112
+ for (const gf of files) {
2113
+ try {
2114
+ const txt = fs.readFileSync(path.join(rootAbs, gf.split('/').join(path.sep)), 'utf8')
2115
+ const m = txt.match(/^\s*module\s+(\S+)/m)
2116
+ if (m) mods.push({ prefix: m[1], dir: gf.includes('/') ? gf.slice(0, gf.lastIndexOf('/')) : '' })
2117
+ } catch {}
2118
+ }
2119
+ mods.sort((a, b) => b.prefix.length - a.prefix.length) // longest module path first
2120
+ _goModsCache.set(rootAbs, mods)
2121
+ return mods
2122
+ }
2123
+ // Map a Go import path to { dir, internalPath } within a known module, or null.
2124
+ function goImportLocation(spec, rootAbs, validIds) {
2125
+ for (const { prefix, dir } of loadGoModules(rootAbs, validIds)) {
2126
+ if (spec === prefix) return { dir, internalPath: '' }
2127
+ if (spec.startsWith(prefix + '/')) return { dir, internalPath: spec.slice(prefix.length + 1) }
2128
+ }
2129
+ return null
2130
+ }
2131
+
2132
+ // Cargo workspace index — real Rust projects are almost always multi-crate
2133
+ // (a `crates/*/` workspace, or a Tauri `src-tauri/`), so the crate root is NOT
2134
+ // `<scanRoot>/src`. Read every Cargo.toml to learn each crate's name and src
2135
+ // dir, so `crate::`/`self::` resolve within the FILE's crate and cross-crate
2136
+ // `use other_crate::…` resolves into that crate. Cached per root.
2137
+ const _cargoCache = new Map() // rootAbs → { crateSrcs: [srcRootId...], nameToSrc: Map<name_,srcRoot> }
2138
+ function loadCargoWorkspace(rootAbs, validIds) {
2139
+ if (_cargoCache.has(rootAbs)) return _cargoCache.get(rootAbs)
2140
+ const crateSrcs = [], nameToSrc = new Map()
2141
+ for (const id of validIds) {
2142
+ if (!id.endsWith('Cargo.toml') || id.includes('/target/')) continue
2143
+ const dir = id.slice(0, id.length - 'Cargo.toml'.length).replace(/\/$/, '')
2144
+ const srcRoot = dir ? dir + '/src' : 'src'
2145
+ let txt
2146
+ try { txt = fs.readFileSync(path.join(rootAbs, id.split('/').join(path.sep)), 'utf8') } catch { continue }
2147
+ // Only [package] crates have a name + src/; pure [workspace] roots don't.
2148
+ const pkg = txt.match(/\[package\]([\s\S]*?)(?=\n\s*\[|$)/)
2149
+ if (!pkg) continue
2150
+ const nm = pkg[1].match(/\bname\s*=\s*["']([^"']+)["']/)
2151
+ if (!nm) continue
2152
+ nameToSrc.set(nm[1].replace(/-/g, '_'), srcRoot)
2153
+ crateSrcs.push(srcRoot)
2154
+ }
2155
+ crateSrcs.sort((a, b) => b.length - a.length) // longest (most specific) first
2156
+ const idx = { crateSrcs, nameToSrc }
2157
+ _cargoCache.set(rootAbs, idx)
2158
+ return idx
2159
+ }
2160
+
2161
+ // Invalidate the per-root resolution caches. The long-running scanner / desktop
2162
+ // app must call this when the underlying inputs change (a .cs file's namespace,
2163
+ // tsconfig paths, composer PSR-4, pubspec name, go.mod module) — otherwise the
2164
+ // dependency graph silently goes stale after edits, and every project ever
2165
+ // opened leaks its cache entry. Pass a root to clear just that project, or
2166
+ // nothing to clear everything.
2167
+ export function clearParserCaches(rootAbs) {
2168
+ const caches = [_tsconfigCache, _csNsCache, _composerCache, _pubspecCache, _goModCache, _goModsCache, _cargoCache, _jsWsCache]
2169
+ if (rootAbs == null) { for (const c of caches) c.clear(); return }
2170
+ for (const c of caches) c.delete(rootAbs)
2171
+ }
2172
+
2173
+ // ─── Rust module resolution ─────────────────────────────────────
2174
+ // Maps `use crate::a::b`, `use self::x`, `use super::x`, `mod x;` into
2175
+ // a file path inside the repo. Best-effort: we look at `src/` first
2176
+ // (cargo convention) and also resolve relative to the importing file
2177
+ // for `self::` / `super::` / bare `mod x;`.
2178
+ function resolveRustModule(fromAbsPath, spec, rootAbs, validIds) {
2179
+ // Resolve a Rust module path to a file using the real 2018 module tree, and
2180
+ // — crucially for real projects — the Cargo WORKSPACE: a file's crate may
2181
+ // live at `crates/<name>/src/` or `app/src-tauri/src/`, not `<root>/src/`,
2182
+ // and `use other_crate::…` crosses into a sibling crate.
2183
+ const relId = idOf(rootAbs, fromAbsPath)
2184
+ const ws = loadCargoWorkspace(rootAbs, validIds)
2185
+
2186
+ // This file's crate src root (longest matching). Fall back to scan-root
2187
+ // `src/` for a single-crate-at-root layout or synthetic/unit inputs.
2188
+ let crateSrc = null
2189
+ for (const s of ws.crateSrcs) { if (relId === s || relId.startsWith(s + '/')) { crateSrc = s; break } }
2190
+ const srcPrefix = crateSrc ? crateSrc + '/' : (relId.startsWith('src/') ? 'src/' : '')
2191
+
2192
+ // The importing file's module path, relative to its crate src root.
2193
+ let p = relId.startsWith(srcPrefix) ? relId.slice(srcPrefix.length) : relId
2194
+ p = p.replace(/\.rs$/, '')
2195
+ let myMod = p.split('/').filter(Boolean)
2196
+ if (myMod[myMod.length - 1] === 'mod') myMod.pop()
2197
+ else if (myMod.length === 1 && (myMod[0] === 'lib' || myMod[0] === 'main')) myMod = []
2198
+
2199
+ const raw = spec.split('::').filter((s) => s && s !== '*')
2200
+ if (!raw.length) return null
2201
+
2202
+ // `lib.rs`/`main.rs` are reserved crate-root filenames — only the empty path
2203
+ // (the crate root itself) may map to them, never a named submodule.
2204
+ const fileForIn = (pfx, segs) => segs.length
2205
+ ? [pfx + segs.join('/') + '.rs', pfx + segs.join('/') + '/mod.rs']
2206
+ .filter((c) => c !== pfx + 'lib.rs' && c !== pfx + 'main.rs')
2207
+ : [pfx + 'lib.rs', pfx + 'main.rs']
2208
+ // Walk prefixes longest→shortest: the deepest segment that maps to a real
2209
+ // file is the target (segments below it are inline submodules/items in that
2210
+ // file). `rooted` paths may collapse to the crate root; bare paths may not.
2211
+ const walk = (pfx, segs, rooted) => {
2212
+ const minN = rooted ? 0 : segs.length
2213
+ for (let n = segs.length; n >= minN; n--) {
2214
+ for (const cand of fileForIn(pfx, segs.slice(0, n))) if (validIds.has(cand)) return cand
2215
+ }
2216
+ return null
2217
+ }
2218
+
2219
+ if (raw[0] === 'crate') return walk(srcPrefix, raw.slice(1), true)
2220
+ if (raw[0] === 'self' || raw[0] === 'super') {
2221
+ const base = myMod.slice()
2222
+ let i = 0
2223
+ while (i < raw.length && raw[i] === 'super') { if (base.length) base.pop(); i++ }
2224
+ if (raw[i] === 'self') i++
2225
+ return walk(srcPrefix, base.concat(raw.slice(i)), true)
2226
+ }
2227
+ // Bare head: cross-crate `use other_crate::a::b` if it names a workspace
2228
+ // crate (Cargo uses hyphens, code uses underscores — normalize).
2229
+ const head = raw[0].replace(/-/g, '_')
2230
+ if (ws.nameToSrc.has(head)) return walk(ws.nameToSrc.get(head) + '/', raw.slice(1), true)
2231
+ // Otherwise `mod foo;` (a child of the current module) or an external crate
2232
+ // (no repo file → null). Full path only — don't collapse onto self/root.
2233
+ return walk(srcPrefix, myMod.concat(raw), false)
2234
+ }