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,623 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import chokidar from 'chokidar'
4
+ import { EventEmitter } from 'events'
5
+ import { parseFile, resolveImport, resolveImportAll, clearParserCaches, normalizeUrlPath, routePathToRegex,
6
+ extractNextApiRoutes, extractNuxtServerRoutes, extractSvelteKitServerRoutes } from './parser.js'
7
+ import { detectMonorepo, packageForFile } from './monorepo.js'
8
+
9
+ const IGNORE_DIRS = new Set([
10
+ 'node_modules', '.git', '.svn', '.hg',
11
+ 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vercel', '.svelte-kit',
12
+ '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache',
13
+ 'venv', '.venv', 'env', 'site-packages', '.tox',
14
+ 'target', '.cache', '.parcel-cache',
15
+ '.idea', '.vscode', '.DS_Store',
16
+ 'coverage', '.nyc_output',
17
+ '.filegraph3d',
18
+ // Editor/note vaults: Obsidian, etc. — third-party plugin/theme code
19
+ // would otherwise dominate hub/orphan/url stats.
20
+ '.obsidian', '.logseq', '.foam',
21
+ // Mobile / native deps
22
+ 'Pods', 'DerivedData', '.gradle',
23
+ // Misc
24
+ 'vendor', // Go / PHP / Ruby vendored deps
25
+ ])
26
+
27
+ // Prefix-based ignore for variant names (e.g. `.venv-foo`, `venv-bar`).
28
+ // Set lookup above is exact-match only, so `.venv-facefusion` wouldn't
29
+ // match `.venv`. This catches the long tail.
30
+ const IGNORE_DIR_PREFIXES = ['.venv', 'venv-', '.env-py']
31
+
32
+ function isIgnoredDir(name) {
33
+ if (IGNORE_DIRS.has(name)) return true
34
+ for (const p of IGNORE_DIR_PREFIXES) if (name.startsWith(p)) return true
35
+ return false
36
+ }
37
+
38
+ // Parse a .gitignore file into a list of {pattern, negate} entries.
39
+ // Implements a subset that covers the vast majority of real-world
40
+ // .gitignore files: blank lines, comments, leading slash anchoring,
41
+ // trailing slash for directories, `**` glob, `!` negation, `*`/`?`.
42
+ function parseGitignore(text) {
43
+ const lines = text.split(/\r?\n/)
44
+ const rules = []
45
+ for (const raw of lines) {
46
+ const line = raw.trim()
47
+ if (!line || line.startsWith('#')) continue
48
+ let pattern = line
49
+ let negate = false
50
+ if (pattern.startsWith('!')) { negate = true; pattern = pattern.slice(1) }
51
+ const anchored = pattern.startsWith('/')
52
+ if (anchored) pattern = pattern.slice(1)
53
+ const dirOnly = pattern.endsWith('/')
54
+ if (dirOnly) pattern = pattern.slice(0, -1)
55
+ // Build regex. Anchored uses ^ so it must start at the root.
56
+ // Non-anchored allows any directory prefix.
57
+ let re = anchored ? '^' : '(^|/)'
58
+ for (let i = 0; i < pattern.length; i++) {
59
+ const c = pattern[i]
60
+ if (c === '*') {
61
+ if (pattern[i + 1] === '*') { re += '.*'; i++ }
62
+ else re += '[^/]*'
63
+ } else if (c === '?') re += '[^/]'
64
+ else if ('.+^$()|{}\\'.includes(c)) re += '\\' + c
65
+ else re += c
66
+ }
67
+ // For dirOnly: must be followed by '/' (so it matches directory
68
+ // itself or any path inside it). For files: end-of-path or '/'.
69
+ re += dirOnly ? '(/|$)' : '($|/)'
70
+ try { rules.push({ regex: new RegExp(re), negate, dirOnly, raw: line }) }
71
+ catch { /* invalid pattern — skip */ }
72
+ }
73
+ return rules
74
+ }
75
+
76
+ function loadGitignoreRules(root) {
77
+ // Read only the root .gitignore. Per-subdirectory .gitignore is
78
+ // rare in practice and full support adds significant complexity.
79
+ const file = path.join(root, '.gitignore')
80
+ try {
81
+ if (fs.existsSync(file)) {
82
+ return parseGitignore(fs.readFileSync(file, 'utf8'))
83
+ }
84
+ } catch { /* ignore read errors */ }
85
+ return []
86
+ }
87
+
88
+ // Project-local CodeSynapt-specific ignore. Same syntax as .gitignore.
89
+ // Use when you want to keep a folder in git but hide it from the
90
+ // graph (e.g. vendored third-party code you don't edit).
91
+ // Reads .codesynaptignore first, falls back to legacy .fg3dignore.
92
+ function loadFg3dIgnoreRules(root) {
93
+ for (const name of ['.codesynaptignore', '.fg3dignore']) {
94
+ const file = path.join(root, name)
95
+ try {
96
+ if (fs.existsSync(file)) {
97
+ return parseGitignore(fs.readFileSync(file, 'utf8'))
98
+ }
99
+ } catch { /* ignore */ }
100
+ }
101
+ return []
102
+ }
103
+
104
+ function matchedByRules(relPath, isDir, rules) {
105
+ let ignored = false
106
+ for (const rule of rules) {
107
+ // Standard match
108
+ if (rule.regex.test(relPath)) {
109
+ if (!rule.dirOnly || isDir) {
110
+ ignored = !rule.negate
111
+ continue
112
+ }
113
+ }
114
+ // dirOnly rules should also match files inside the matching dir.
115
+ // Check every prefix (path up to each slash) against the dir rule.
116
+ if (rule.dirOnly && !isDir) {
117
+ const parts = relPath.split('/')
118
+ for (let i = 0; i < parts.length - 1; i++) {
119
+ const prefix = parts.slice(0, i + 1).join('/')
120
+ if (rule.regex.test(prefix + '/')) {
121
+ ignored = !rule.negate
122
+ break
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return ignored
128
+ }
129
+
130
+ // Parse a .env file and return the list of keys declared inside.
131
+ // Keys must start with an uppercase letter (POSIX convention) to match
132
+ // our extractEnvUsage filter on the consumption side.
133
+ function parseEnvFileKeys(absPath) {
134
+ let content
135
+ try { content = fs.readFileSync(absPath, 'utf8') } catch { return [] }
136
+ const keys = []
137
+ for (const rawLine of content.split('\n')) {
138
+ const line = rawLine.trim()
139
+ if (!line || line.startsWith('#')) continue
140
+ const m = line.match(/^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/)
141
+ if (m) keys.push(m[1])
142
+ }
143
+ return keys
144
+ }
145
+
146
+ const TRACKED_EXT = new Set([
147
+ // JS / TS family
148
+ 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs',
149
+ // Python
150
+ 'py', 'pyw', 'pyi',
151
+ // Jupyter — JSON wrapper around Python (usually) code cells
152
+ 'ipynb',
153
+ // Lisp / Scheme / Clojure / Emacs
154
+ 'lsp', 'dcl', 'lisp', 'el', 'clj', 'scm', 'cljc',
155
+ // Styles
156
+ 'css', 'scss', 'sass', 'less', 'styl',
157
+ // Markup / Component
158
+ 'html', 'htm', 'vue', 'svelte', 'astro',
159
+ // Config / Data
160
+ 'json', 'yaml', 'yml', 'toml',
161
+ // Docs
162
+ 'md', 'mdx', 'rst',
163
+ // JVM family
164
+ 'java', 'kt',
165
+ // .NET family
166
+ 'cs',
167
+ // Apple family
168
+ 'swift',
169
+ // Dart / Flutter
170
+ 'dart',
171
+ // Systems
172
+ 'rs', 'go', 'rb', 'php',
173
+ 'c', 'cc', 'cpp', 'h', 'hpp',
174
+ // Shell / scripting
175
+ 'sh', 'bash', 'zsh', 'ps1', 'psm1',
176
+ // Data
177
+ 'sql', 'xml',
178
+ // DB schema (Prisma)
179
+ 'prisma',
180
+ // NOTE: dwg/dxf removed — binary CAD files have no import concept,
181
+ // scanning them just creates orphan noise.
182
+ ])
183
+
184
+ export class Scanner extends EventEmitter {
185
+ constructor(root) {
186
+ super()
187
+ this.root = root
188
+ this.files = new Map() // id -> { id, ext, loc, size, imports, absPath, pkg }
189
+ this.edges = []
190
+ this.pkgEdges = [] // package-to-package edges aggregated from file edges
191
+ this.watcher = null
192
+ this._pendingSnapshot = null
193
+ // True once the chokidar 'ready' has fired and the initial walk is done.
194
+ // Used by /search to refuse work while the event loop is still saturated
195
+ // by add events — returning 503 instead of hanging.
196
+ this.initialScanComplete = false
197
+ this.gitignoreRules = loadGitignoreRules(root)
198
+ this.fg3dIgnoreRules = loadFg3dIgnoreRules(root)
199
+ this.envFiles = [] // [{ id, keys: [...] }] — populated on first ready
200
+ // Detect workspace structure once at construction. Cheap (one
201
+ // directory walk capped at depth 6). Result feeds package-level
202
+ // grouping in the UI and the /packages API for AI agents.
203
+ try { this.monorepo = detectMonorepo(root) }
204
+ catch (e) { this.monorepo = { kind: 'none', packages: [], rootIsPackage: false } }
205
+ }
206
+
207
+ toId(absPath) {
208
+ return path.relative(this.root, absPath).split(path.sep).join('/')
209
+ }
210
+
211
+ // ── Third-party folder auto-detection ────────────────────────
212
+ // Heuristic: a sub-folder is "vendored" / "third-party" if it shows
213
+ // any of these signals (combined for confidence):
214
+ // - .git/ subdirectory (nested repo / submodule) +0.5
215
+ // - LICENSE/LICENCE/COPYING file at folder root +0.2
216
+ // - own package.json / pyproject.toml / Cargo.toml /
217
+ // go.mod / Gemfile / pom.xml + the parent has its own +0.3
218
+ // - conventional name: vendor / vendors / third_party / +0.2
219
+ // third-party / external / deps / submodules / tools
220
+ //
221
+ // We only report folders, never auto-ignore — the user can copy
222
+ // suggested entries into `.codesynaptignore`. Reported via `vendorCandidates`
223
+ // on the snapshot.
224
+ scanVendorCandidates() {
225
+ this.vendorCandidates = []
226
+ const CONVENTIONAL_NAMES = new Set([
227
+ 'vendor', 'vendors', 'third_party', 'third-party',
228
+ 'external', 'externals', 'deps', 'submodules',
229
+ ])
230
+ const ROOT_HAS_MANIFEST = (() => {
231
+ for (const name of ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'Gemfile', 'pom.xml']) {
232
+ if (fs.existsSync(path.join(this.root, name))) return true
233
+ }
234
+ return false
235
+ })()
236
+ // Folders we'd normally ignore in scanning but want to walk INTO
237
+ // when looking for vendor candidates (the whole point of this scan).
238
+ const VENDOR_OK = new Set(['vendor', 'vendors', 'third_party', 'third-party',
239
+ 'external', 'externals', 'deps', 'submodules', 'tools'])
240
+ const seen = new Set()
241
+ const walk = (dir, depth, relParts) => {
242
+ if (depth > 3) return // shallow only — vendored libs usually at depth 1-2
243
+ let entries
244
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
245
+ for (const e of entries) {
246
+ if (!e.isDirectory()) continue
247
+ // Skip hard-ignores (node_modules, .git, .venv*, etc.) but keep
248
+ // conventional vendor names — those are what we're looking for.
249
+ if (!VENDOR_OK.has(e.name) && (IGNORE_DIRS.has(e.name) || isIgnoredDir(e.name))) continue
250
+ const full = path.join(dir, e.name)
251
+ const rel = [...relParts, e.name].join('/')
252
+ if (seen.has(rel)) continue
253
+ seen.add(rel)
254
+
255
+ let confidence = 0
256
+ const reasons = []
257
+ try {
258
+ if (fs.existsSync(path.join(full, '.git'))) {
259
+ confidence += 0.5; reasons.push('nested .git (submodule or sub-repo)')
260
+ }
261
+ for (const lic of ['LICENSE', 'LICENCE', 'COPYING', 'LICENSE.md', 'LICENSE.txt']) {
262
+ if (fs.existsSync(path.join(full, lic))) { confidence += 0.2; reasons.push(`has ${lic}`); break }
263
+ }
264
+ if (ROOT_HAS_MANIFEST) {
265
+ for (const mf of ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'Gemfile', 'pom.xml']) {
266
+ if (fs.existsSync(path.join(full, mf))) {
267
+ confidence += 0.3; reasons.push(`own ${mf} (sub-project)`); break
268
+ }
269
+ }
270
+ }
271
+ if (CONVENTIONAL_NAMES.has(e.name.toLowerCase())) {
272
+ confidence += 0.2; reasons.push('conventional vendor folder name')
273
+ }
274
+ } catch {}
275
+
276
+ if (confidence >= 0.3) {
277
+ this.vendorCandidates.push({
278
+ path: rel,
279
+ confidence: Math.min(1, +confidence.toFixed(2)),
280
+ reasons,
281
+ })
282
+ } else {
283
+ // Only recurse into non-obvious folders. Don't dive into
284
+ // anything already flagged (its children would inherit).
285
+ walk(full, depth + 1, [...relParts, e.name])
286
+ }
287
+ }
288
+ }
289
+ walk(this.root, 0, [])
290
+ this.vendorCandidates.sort((a, b) => b.confidence - a.confidence)
291
+ }
292
+
293
+ // ── .env file index ───────────────────────────────────────────
294
+ // We don't add .env files to the graph (they're config, not code),
295
+ // but we DO scan them to know which env vars are declared. The
296
+ // server then cross-references against extractEnvUsage in source.
297
+ scanEnvFiles() {
298
+ this.envFiles = []
299
+ const names = ['.env', '.env.local', '.env.production', '.env.development',
300
+ '.env.test', '.env.example', '.env.sample']
301
+ const walk = (dir, depth) => {
302
+ if (depth > 4) return
303
+ let entries
304
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
305
+ for (const e of entries) {
306
+ if (isIgnoredDir(e.name)) continue
307
+ const full = path.join(dir, e.name)
308
+ if (e.isDirectory()) walk(full, depth + 1)
309
+ else if (e.isFile() && names.includes(e.name)) {
310
+ const keys = parseEnvFileKeys(full)
311
+ this.envFiles.push({ id: this.toId(full), keys })
312
+ }
313
+ }
314
+ }
315
+ walk(this.root, 0)
316
+ }
317
+
318
+ shouldTrack(absPath) {
319
+ const ext = path.extname(absPath).slice(1).toLowerCase()
320
+ return TRACKED_EXT.has(ext)
321
+ }
322
+
323
+ start() {
324
+ const root = this.root
325
+ const rules = this.gitignoreRules
326
+ const fg3dRules = this.fg3dIgnoreRules
327
+ this.watcher = chokidar.watch(root, {
328
+ ignored: (p, stats) => {
329
+ const rel = path.relative(root, p)
330
+ if (!rel) return false
331
+ const segments = rel.split(path.sep)
332
+ if (segments.some(isIgnoredDir)) return true
333
+ // .gitignore matching uses '/'-joined relative path
334
+ const relPosix = segments.join('/')
335
+ const isDir = stats?.isDirectory() ?? false
336
+ if (matchedByRules(relPosix, isDir, rules)) return true
337
+ if (fg3dRules.length && matchedByRules(relPosix, isDir, fg3dRules)) return true
338
+ return false
339
+ },
340
+ ignoreInitial: false,
341
+ persistent: true,
342
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 80 },
343
+ })
344
+
345
+ let initial = true
346
+ let scanCount = 0
347
+ let lastProgressEmit = 0
348
+ this.watcher
349
+ .on('add', (p) => {
350
+ this.handleAdd(p, initial)
351
+ if (initial) {
352
+ scanCount++
353
+ // Throttle progress emission to ~10/sec; final ready emit
354
+ // gives the accurate total.
355
+ const now = Date.now()
356
+ if (now - lastProgressEmit > 100) {
357
+ lastProgressEmit = now
358
+ this.emit('scan-progress', { count: scanCount, done: false })
359
+ }
360
+ }
361
+ })
362
+ .on('change', (p) => this.handleChange(p))
363
+ .on('unlink', (p) => this.handleRemove(p))
364
+ .on('ready', () => {
365
+ initial = false
366
+ this.initialScanComplete = true
367
+ this.emit('scan-progress', { count: scanCount, done: true })
368
+ this.scanEnvFiles()
369
+ this.scanVendorCandidates()
370
+ this.rebuildEdges()
371
+ this.emitSnapshot()
372
+ this.emit('stats', { initialScanComplete: true, fileCount: this.files.size })
373
+ })
374
+ .on('error', (e) => console.error('watcher error:', e.message))
375
+ }
376
+
377
+ stop() {
378
+ // Drop this root's resolution caches so reloading another project doesn't
379
+ // retain (or serve stale) the previous one's index — and return the
380
+ // watcher's close() promise so callers can await full teardown (chokidar
381
+ // close is async; not awaiting leaks fs handles, especially on Windows).
382
+ try { clearParserCaches(this.root) } catch {}
383
+ if (this.watcher) {
384
+ const w = this.watcher
385
+ this.watcher = null
386
+ return w.close()
387
+ }
388
+ }
389
+
390
+ snapshot() {
391
+ return {
392
+ files: [...this.files.values()].map((f) => ({
393
+ id: f.id, ext: f.ext, loc: f.loc, size: f.size,
394
+ importCount: f.imports.length,
395
+ pkg: f.pkg || null,
396
+ hasDynamicResolution: (f.dynamicPatterns || []).length > 0,
397
+ dynamicPatterns: f.dynamicPatterns || [],
398
+ confidence: f.confidence || 'high',
399
+ })),
400
+ edges: this.edges,
401
+ monorepo: this.monorepo,
402
+ pkgEdges: this.pkgEdges,
403
+ }
404
+ }
405
+
406
+ emitSnapshot() {
407
+ // Debounce snapshots during burst changes
408
+ if (this._pendingSnapshot) clearTimeout(this._pendingSnapshot)
409
+ this._pendingSnapshot = setTimeout(() => {
410
+ this._pendingSnapshot = null
411
+ this._lastSnapshotAt = Date.now()
412
+ this.snapshotVersion = (this.snapshotVersion || 0) + 1
413
+ this.emit('snapshot', this.snapshot())
414
+ }, 60)
415
+ }
416
+
417
+ // A change to a .cs file (namespace index) or a resolution-manifest
418
+ // (tsconfig/jsconfig/composer/pubspec/go.mod) makes the per-root parser
419
+ // caches stale — drop them so the next rebuild re-resolves correctly.
420
+ _maybeInvalidateCaches(absPath) {
421
+ const base = path.basename(absPath).toLowerCase()
422
+ const ext = path.extname(absPath).slice(1).toLowerCase()
423
+ if (ext === 'cs' || ['tsconfig.json', 'jsconfig.json', 'composer.json', 'pubspec.yaml', 'go.mod', 'cargo.toml', 'package.json'].includes(base)) {
424
+ clearParserCaches(this.root)
425
+ }
426
+ }
427
+
428
+ async handleAdd(absPath, initial) {
429
+ // These fire-and-forget from chokidar event listeners; an unhandled throw
430
+ // would become a fatal unhandledRejection (Node ≥20). Never let one bad
431
+ // file take down the daemon.
432
+ try {
433
+ if (!this.shouldTrack(absPath)) return
434
+ const file = this.parseOne(absPath)
435
+ if (!file) return
436
+ this.files.set(file.id, file)
437
+ if (!initial) {
438
+ this._maybeInvalidateCaches(absPath)
439
+ this.emit('file-added', { id: file.id, absPath })
440
+ this.rebuildEdges()
441
+ this.emitSnapshot()
442
+ }
443
+ } catch (e) {
444
+ process.stderr.write(`[scanner] handleAdd ${absPath}: ${e && e.message}\n`)
445
+ }
446
+ }
447
+
448
+ async handleChange(absPath) {
449
+ try {
450
+ if (!this.shouldTrack(absPath)) return
451
+ const file = this.parseOne(absPath)
452
+ if (!file) return
453
+ this.files.set(file.id, file)
454
+ this._maybeInvalidateCaches(absPath)
455
+ this.emit('file-changed', { id: file.id, absPath })
456
+ this.rebuildEdges()
457
+ this.emitSnapshot()
458
+ } catch (e) {
459
+ process.stderr.write(`[scanner] handleChange ${absPath}: ${e && e.message}\n`)
460
+ }
461
+ }
462
+
463
+ handleRemove(absPath) {
464
+ const id = this.toId(absPath)
465
+ if (this.files.delete(id)) {
466
+ this._maybeInvalidateCaches(absPath)
467
+ this.emit('file-removed', { id, absPath })
468
+ this.rebuildEdges()
469
+ this.emitSnapshot()
470
+ }
471
+ }
472
+
473
+ parseOne(absPath) {
474
+ let stat
475
+ try { stat = fs.statSync(absPath) } catch { return null }
476
+ const id = this.toId(absPath)
477
+ const ext = path.extname(absPath).slice(1).toLowerCase()
478
+ let content = ''
479
+ // Size + binary gate: huge/generated/minified or binary files (which can
480
+ // slip past the extension filter) would stall or OOM the parser's regex
481
+ // passes. Index them as nodes (size known) but don't parse their content.
482
+ const MAX_PARSE_BYTES = 2 * 1024 * 1024 // 2 MB
483
+ if (stat.size <= MAX_PARSE_BYTES) {
484
+ try { content = fs.readFileSync(absPath, 'utf8') } catch {}
485
+ if (content.indexOf('\u0000') !== -1) content = '' // binary → skip parsing
486
+ }
487
+ const loc = content ? content.split('\n').length : 0
488
+ const { imports, routes, apiCalls, externalUrls, dynamicPatterns, envUsage, dbModels, confidence } = parseFile(absPath, content, ext)
489
+ // Augment with file-system server routes (Next.js / Nuxt 3 /
490
+ // SvelteKit). Conservative: append-only.
491
+ let finalRoutes = routes || []
492
+ if (['ts','tsx','js','jsx','mjs','cjs','mts','cts'].includes(ext)) {
493
+ const fsRoutes = [
494
+ ...extractNextApiRoutes(id, content),
495
+ ...extractNuxtServerRoutes(id, content),
496
+ ...extractSvelteKitServerRoutes(id, content),
497
+ ]
498
+ if (fsRoutes.length > 0) finalRoutes = [...finalRoutes, ...fsRoutes]
499
+ }
500
+ // Tag the file with its owning package (null if outside all
501
+ // packages or no monorepo). Used by UI for package-level grouping
502
+ // and by API endpoints for package-level slicing.
503
+ const pkg = this.monorepo?.packages?.length
504
+ ? packageForFile(id, this.monorepo.packages)
505
+ : null
506
+ return {
507
+ id, ext, loc, size: stat.size, imports, absPath,
508
+ routes: finalRoutes,
509
+ apiCalls: apiCalls || [],
510
+ externalUrls: externalUrls || [],
511
+ dynamicPatterns: dynamicPatterns || [],
512
+ envUsage: envUsage || [],
513
+ dbModels: dbModels || [],
514
+ confidence: confidence || 'high',
515
+ pkg,
516
+ lastSeenAt: Date.now(),
517
+ }
518
+ }
519
+
520
+ rebuildEdges() {
521
+ const edges = []
522
+ const seen = new Set()
523
+ const idSet = new Set(this.files.keys())
524
+
525
+ // 1) Static import edges (file→file dependency)
526
+ // Memoize the fanout languages whose resolution depends only on (ext, spec)
527
+ // — Go/Swift scan the whole id-set per import, and the same package/module
528
+ // is imported many times; without this, rebuildEdges is O(imports × files)
529
+ // on every file change. (Relative/file-precise langs don't scan, so they
530
+ // are left un-memoized — their result also depends on the importing file.)
531
+ const fanoutMemo = new Map()
532
+ for (const file of this.files.values()) {
533
+ for (const imp of file.imports) {
534
+ let targets
535
+ if (file.ext === 'go' || file.ext === 'swift') {
536
+ const key = file.ext + '\u0000' + imp.spec
537
+ targets = fanoutMemo.get(key)
538
+ if (!targets) {
539
+ targets = resolveImportAll(file.absPath, imp.spec, this.root, idSet, file.ext)
540
+ fanoutMemo.set(key, targets)
541
+ }
542
+ } else {
543
+ targets = resolveImportAll(file.absPath, imp.spec, this.root, idSet, file.ext)
544
+ }
545
+ for (const target of targets) {
546
+ if (target && target !== file.id) {
547
+ const key = `${file.id}→${target}:${imp.kind}`
548
+ if (seen.has(key)) continue
549
+ seen.add(key)
550
+ edges.push({ s: file.id, t: target, k: imp.kind })
551
+ }
552
+ }
553
+ }
554
+ }
555
+
556
+ // 2) Full-stack edges (client API call → server route handler)
557
+ //
558
+ // Build a route index across all files, then for each apiCall try
559
+ // to match. A single client URL may match multiple registered
560
+ // routes (e.g. /users/123 matches both /users/:id GET and POST) —
561
+ // we emit edges to each matching handler so the user sees all the
562
+ // server-side files involved. Matching keys on (method, path) so
563
+ // an axios.post matches the POST handler, not the GET one.
564
+ const routeIndex = [] // { fileId, method, regex, raw }
565
+ for (const file of this.files.values()) {
566
+ for (const r of file.routes) {
567
+ try {
568
+ routeIndex.push({
569
+ fileId: file.id,
570
+ method: r.method,
571
+ regex: routePathToRegex(r.path),
572
+ raw: r.path,
573
+ })
574
+ } catch { /* invalid regex — skip */ }
575
+ }
576
+ }
577
+
578
+ if (routeIndex.length > 0) {
579
+ for (const file of this.files.values()) {
580
+ for (const call of file.apiCalls) {
581
+ const p = normalizeUrlPath(call.url)
582
+ if (!p) continue
583
+ for (const route of routeIndex) {
584
+ // Method match: HEAD/OPTIONS handled by ALL; otherwise exact.
585
+ // Also: client GET (the default) matches a route's ALL.
586
+ if (route.method !== 'ALL' && route.method !== call.method) continue
587
+ if (!route.regex.test(p)) continue
588
+ if (route.fileId === file.id) continue // self-call, skip
589
+ const key = `${file.id}→${route.fileId}:api`
590
+ if (seen.has(key)) continue
591
+ seen.add(key)
592
+ edges.push({ s: file.id, t: route.fileId, k: 'api' })
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ this.edges = edges
599
+ this.rebuildPackageEdges()
600
+ }
601
+
602
+ // Aggregate file-level edges into package-level edges. A single edge
603
+ // between two packages can correspond to many file edges — we keep
604
+ // a count so the UI can size them by weight.
605
+ rebuildPackageEdges() {
606
+ if (!this.monorepo?.packages?.length) { this.pkgEdges = []; return }
607
+ const counts = new Map() // key: "src→dst" → { s, t, count, kinds: Set }
608
+ for (const e of this.edges) {
609
+ const sf = this.files.get(e.s)
610
+ const tf = this.files.get(e.t)
611
+ if (!sf || !tf) continue
612
+ const sp = sf.pkg, tp = tf.pkg
613
+ if (!sp || !tp || sp === tp) continue
614
+ const key = sp + '→' + tp
615
+ const c = counts.get(key)
616
+ if (c) { c.count++; c.kinds.add(e.k) }
617
+ else counts.set(key, { s: sp, t: tp, count: 1, kinds: new Set([e.k]) })
618
+ }
619
+ this.pkgEdges = [...counts.values()].map((e) => ({
620
+ s: e.s, t: e.t, count: e.count, kinds: [...e.kinds],
621
+ })).sort((a, b) => b.count - a.count)
622
+ }
623
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wing1008
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.