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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +686 -0
- package/LICENSES.md +141 -0
- package/README.md +331 -0
- package/electron/main.cjs +2849 -0
- package/electron/plugin-loader.cjs +184 -0
- package/electron/preload.cjs +108 -0
- package/package.json +216 -0
- package/packages/core/bin/codesynapt-mcp.cjs +611 -0
- package/packages/core/bin/codesynapt.cjs +1933 -0
- package/packages/core/legacy.js +300 -0
- package/packages/core/lib/control-server.cjs +1539 -0
- package/packages/core/lib/embedding.cjs +89 -0
- package/packages/core/lib/logger.cjs +63 -0
- package/packages/core/lib/search-cache.cjs +140 -0
- package/packages/core/lib/search-worker.cjs +255 -0
- package/packages/core/lib/search.cjs +211 -0
- package/packages/core/lib/symbol-graph.cjs +402 -0
- package/packages/core/lib/symbol-parser-js.cjs +542 -0
- package/packages/core/lib/symbol-parser-misc.cjs +394 -0
- package/packages/core/lib/symbol-parser-py.cjs +215 -0
- package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
- package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
- package/packages/core/monorepo.js +310 -0
- package/packages/core/parser.js +2234 -0
- package/packages/core/scanner.js +623 -0
- package/plugin-api/LICENSE +21 -0
- package/plugin-api/README.md +114 -0
- package/plugin-api/docs/01-getting-started.md +197 -0
- package/plugin-api/docs/02-concepts.md +269 -0
- package/plugin-api/docs/api-reference.md +463 -0
- package/plugin-api/docs/troubleshooting.md +332 -0
- package/plugin-api/docs/types/exporter.md +377 -0
- package/plugin-api/docs/types/theme.md +312 -0
- package/plugin-api/examples/hello-world-plugin/README.md +70 -0
- package/plugin-api/examples/hello-world-plugin/main.js +36 -0
- package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
- package/plugin-api/examples/mermaid-exporter/README.md +125 -0
- package/plugin-api/examples/mermaid-exporter/main.js +58 -0
- package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
- package/plugin-api/examples/rust-parser/README.md +71 -0
- package/plugin-api/examples/rust-parser/main.js +123 -0
- package/plugin-api/examples/rust-parser/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/README.md +95 -0
- package/plugin-api/examples/sunset-theme/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/theme.css +31 -0
- package/plugin-api/package.json +20 -0
- package/plugin-api/types.d.ts +395 -0
- package/public/app.js +6837 -0
- package/public/backend.js +285 -0
- package/public/index.html +647 -0
- package/public/plugin-host.js +321 -0
- package/public/style.css +4359 -0
- package/public/vendor/three.module.js +53044 -0
- package/scripts/competitor-watch.mjs +144 -0
- package/scripts/copy-vendor.js +21 -0
- package/scripts/download-bundled-node.cjs +53 -0
- package/scripts/fuses-after-pack.cjs +34 -0
- package/scripts/license-check.js +119 -0
- package/scripts/perf-test.js +200 -0
- 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.
|