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,332 @@
1
+ // TypeScript-compiler-API symbol parser.
2
+ //
3
+ // Uses the real `typescript` package (Program + TypeChecker) instead
4
+ // of Babel AST walking. The win: every CallExpression resolves to the
5
+ // declaring symbol via the checker, so `user.save()` lands on the
6
+ // exact User.save() method even when several classes define save().
7
+ //
8
+ // Trade-off: heavier — the TS Program loads every file in the project
9
+ // once. For huge monorepos this is slower than the regex/babel path.
10
+ // We register this parser for .ts/.tsx only, and only when the host
11
+ // opts in (CS_SYMBOL_PARSER=tsc, or the default when typescript is
12
+ // installed AND there's a tsconfig.json at the project root).
13
+ //
14
+ // Pass 1 (extractSymbols) and Pass 2 (extractReferences) both rely on
15
+ // a single Program built per-project on the first call; subsequent
16
+ // per-file calls reuse it. The host clears it on project swap.
17
+
18
+ 'use strict'
19
+
20
+ const fs = require('fs')
21
+ const path = require('path')
22
+
23
+ let ts = null
24
+ function loadTS() {
25
+ if (ts) return ts
26
+ try { ts = require('typescript') }
27
+ catch { ts = null }
28
+ return ts
29
+ }
30
+
31
+ // Cache: rootAbs → { program, checker, files: Set<id> }
32
+ const _programCache = new Map()
33
+
34
+ function clearProgramFor(rootAbs) { _programCache.delete(rootAbs) }
35
+ function clearAllPrograms() { _programCache.clear() }
36
+
37
+ function loadProgramFor(rootAbs, allFileIds) {
38
+ const cached = _programCache.get(rootAbs)
39
+ if (cached) return cached
40
+ const t = loadTS()
41
+ if (!t) return null
42
+ // Memory cap. createProgram loads the whole TS source set + AST +
43
+ // TypeChecker bindings; observed RSS on Next.js (20k files) was
44
+ // ~3.7 GB. Above the cap we refuse and let the caller fall back
45
+ // to babel. Override with CS_TSC_MAX_FILES.
46
+ const MAX_FILES = parseInt(process.env.CS_TSC_MAX_FILES || '30000', 10)
47
+ const idArr = [...allFileIds]
48
+ if (idArr.length > MAX_FILES) {
49
+ console.warn(`[symbol-tsc] ${idArr.length} files > CS_TSC_MAX_FILES=${MAX_FILES}; refusing to build TS Program. Set CS_TSC_MAX_FILES higher to override.`)
50
+ return null
51
+ }
52
+ // Find tsconfig.json (or jsconfig.json) — use compiler options if
53
+ // present, otherwise reasonable defaults.
54
+ let compilerOptions = {
55
+ allowJs: true,
56
+ target: t.ScriptTarget.ES2020,
57
+ module: t.ModuleKind.ESNext,
58
+ jsx: t.JsxEmit.ReactJSX,
59
+ moduleResolution: t.ModuleResolutionKind.Node10,
60
+ esModuleInterop: true,
61
+ skipLibCheck: true,
62
+ skipDefaultLibCheck: true,
63
+ noEmit: true,
64
+ isolatedModules: true,
65
+ }
66
+ try {
67
+ const configPath = ['tsconfig.json', 'jsconfig.json']
68
+ .map((c) => path.join(rootAbs, c))
69
+ .find((p) => fs.existsSync(p))
70
+ if (configPath) {
71
+ const raw = fs.readFileSync(configPath, 'utf8')
72
+ const parsed = t.parseConfigFileTextToJson(configPath, raw)
73
+ if (!parsed.error && parsed.config?.compilerOptions) {
74
+ const co = t.convertCompilerOptionsFromJson(
75
+ parsed.config.compilerOptions, path.dirname(configPath))
76
+ if (co.options) Object.assign(compilerOptions, co.options)
77
+ }
78
+ }
79
+ } catch {}
80
+ // Build the Program over the TS/TSX/JS/JSX files we know about.
81
+ const rootNames = [...allFileIds]
82
+ .filter((id) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(id))
83
+ .map((id) => path.join(rootAbs, id))
84
+ if (rootNames.length === 0) return null
85
+ let program
86
+ try {
87
+ program = t.createProgram(rootNames, compilerOptions)
88
+ } catch (e) { return null }
89
+ const checker = program.getTypeChecker()
90
+ const result = { program, checker, files: new Set(allFileIds), rootAbs }
91
+ _programCache.set(rootAbs, result)
92
+ return result
93
+ }
94
+
95
+ function mkId(file, name, line) { return `${file}#${name}@${line}` }
96
+
97
+ function idForDeclaration(decl, rootAbs) {
98
+ if (!decl) return null
99
+ const sf = decl.getSourceFile?.()
100
+ if (!sf) return null
101
+ const fileId = path.relative(rootAbs, sf.fileName).split(path.sep).join('/')
102
+ const t = loadTS()
103
+ if (!t) return null
104
+ const name = declarationName(decl, t)
105
+ if (!name) return null
106
+ const line = sf.getLineAndCharacterOfPosition(decl.getStart()).line + 1
107
+ // Qualify methods by their enclosing class.
108
+ let qualified = name
109
+ const cls = findEnclosingClass(decl, t)
110
+ if (cls?.name) qualified = `${cls.name.text}.${name}`
111
+ return mkId(fileId, qualified, line)
112
+ }
113
+
114
+ function findEnclosingClass(node, t) {
115
+ let cur = node.parent
116
+ while (cur) {
117
+ if (cur.kind === t.SyntaxKind.ClassDeclaration
118
+ || cur.kind === t.SyntaxKind.InterfaceDeclaration) return cur
119
+ cur = cur.parent
120
+ }
121
+ return null
122
+ }
123
+
124
+ function declarationName(decl, t) {
125
+ if (decl.name) return decl.name.text || decl.name.getText?.()
126
+ if (decl.kind === t.SyntaxKind.Constructor) return 'constructor'
127
+ return null
128
+ }
129
+
130
+ function classKindFor(node, t) {
131
+ switch (node.kind) {
132
+ case t.SyntaxKind.ClassDeclaration: return 'class'
133
+ case t.SyntaxKind.InterfaceDeclaration: return 'interface'
134
+ case t.SyntaxKind.EnumDeclaration: return 'enum'
135
+ case t.SyntaxKind.TypeAliasDeclaration: return 'type'
136
+ default: return 'class'
137
+ }
138
+ }
139
+
140
+ function fnKindFor(node, t) {
141
+ if (node.kind === t.SyntaxKind.MethodDeclaration
142
+ || node.kind === t.SyntaxKind.MethodSignature) return 'method'
143
+ if (node.kind === t.SyntaxKind.Constructor) return 'method'
144
+ return 'function'
145
+ }
146
+
147
+ function isExported(node, t) {
148
+ return !!(node.modifiers || []).some((m) =>
149
+ m.kind === t.SyntaxKind.ExportKeyword)
150
+ }
151
+
152
+ // Pass 1 — symbols
153
+ function extractSymbolsFor(fileId, rootAbs, allFileIds) {
154
+ const prog = loadProgramFor(rootAbs, allFileIds)
155
+ if (!prog) return []
156
+ const t = loadTS()
157
+ const sf = prog.program.getSourceFile(path.join(rootAbs, fileId))
158
+ if (!sf) return []
159
+ const out = []
160
+ const visit = (node) => {
161
+ let push = null
162
+ switch (node.kind) {
163
+ case t.SyntaxKind.FunctionDeclaration: {
164
+ const name = node.name?.text
165
+ if (name) push = { name, kind: 'function', node }
166
+ break
167
+ }
168
+ case t.SyntaxKind.ClassDeclaration:
169
+ case t.SyntaxKind.InterfaceDeclaration:
170
+ case t.SyntaxKind.EnumDeclaration:
171
+ case t.SyntaxKind.TypeAliasDeclaration: {
172
+ const name = node.name?.text
173
+ if (name) push = { name, kind: classKindFor(node, t), node }
174
+ break
175
+ }
176
+ case t.SyntaxKind.MethodDeclaration:
177
+ case t.SyntaxKind.MethodSignature:
178
+ case t.SyntaxKind.Constructor: {
179
+ const name = node.name?.text || (node.kind === t.SyntaxKind.Constructor ? 'constructor' : null)
180
+ if (name) {
181
+ const cls = findEnclosingClass(node, t)
182
+ const qn = cls?.name?.text ? `${cls.name.text}.${name}` : name
183
+ push = { name, qualifiedName: qn, kind: 'method', node }
184
+ }
185
+ break
186
+ }
187
+ case t.SyntaxKind.VariableStatement: {
188
+ for (const d of node.declarationList.declarations) {
189
+ if (!d.name?.text) continue
190
+ const init = d.initializer
191
+ if (init && (init.kind === t.SyntaxKind.ArrowFunction
192
+ || init.kind === t.SyntaxKind.FunctionExpression)) {
193
+ out.push(buildSym(fileId, d.name.text, d.name.text, 'function', d, t, init))
194
+ }
195
+ }
196
+ break
197
+ }
198
+ }
199
+ if (push) out.push(buildSym(fileId, push.name, push.qualifiedName || push.name, push.kind, push.node, t))
200
+ t.forEachChild(node, visit)
201
+ }
202
+ visit(sf)
203
+ return out
204
+ }
205
+
206
+ function buildSym(fileId, name, qualifiedName, kind, node, t, bodyNode) {
207
+ const sf = node.getSourceFile()
208
+ const startLine = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1
209
+ const endLine = sf.getLineAndCharacterOfPosition((bodyNode || node).getEnd()).line + 1
210
+ return {
211
+ id: mkId(fileId, qualifiedName, startLine),
212
+ name, qualifiedName, kind,
213
+ file: fileId,
214
+ startLine, endLine,
215
+ signature: node.getText().slice(0, 200).split('\n')[0],
216
+ doc: '',
217
+ exported: isExported(node, t),
218
+ }
219
+ }
220
+
221
+ // Pass 2 — references (call edges resolved by the checker)
222
+ function extractReferencesFor(fileId, rootAbs, allFileIds, index) {
223
+ const prog = loadProgramFor(rootAbs, allFileIds)
224
+ if (!prog) return []
225
+ const t = loadTS()
226
+ const sf = prog.program.getSourceFile(path.join(rootAbs, fileId))
227
+ if (!sf) return []
228
+ const edges = []
229
+ const seen = new Set()
230
+ const fnStack = [] // enclosing symbol ids
231
+
232
+ const visit = (node) => {
233
+ let pushed = false
234
+ if (node.kind === t.SyntaxKind.FunctionDeclaration
235
+ || node.kind === t.SyntaxKind.MethodDeclaration
236
+ || node.kind === t.SyntaxKind.Constructor
237
+ || node.kind === t.SyntaxKind.ArrowFunction
238
+ || node.kind === t.SyntaxKind.FunctionExpression) {
239
+ const id = idForDeclaration(node, rootAbs) || idForArrowParent(node, rootAbs, t)
240
+ if (id) { fnStack.push(id); pushed = true }
241
+ }
242
+ if (node.kind === t.SyntaxKind.CallExpression) {
243
+ const src = fnStack[fnStack.length - 1]
244
+ if (src) {
245
+ // checker.getSymbolAtLocation on the callee gives us the
246
+ // declaration symbol — far more precise than name matching.
247
+ const callee = node.expression
248
+ let sym = prog.checker.getSymbolAtLocation(callee)
249
+ // For `obj.method()` getSymbolAtLocation on the whole
250
+ // expression doesn't always resolve; try the property name.
251
+ if (!sym && callee.kind === t.SyntaxKind.PropertyAccessExpression) {
252
+ sym = prog.checker.getSymbolAtLocation(callee.name)
253
+ }
254
+ if (sym?.declarations?.length) {
255
+ const decl = sym.declarations[0]
256
+ const targetId = idForDeclaration(decl, rootAbs)
257
+ if (targetId && targetId !== src && index.nodes.has(targetId)) {
258
+ const key = src + '|' + targetId + '|call'
259
+ if (!seen.has(key)) {
260
+ seen.add(key)
261
+ const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1
262
+ edges.push({ source: src, target: targetId, kind: 'call', line })
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ if (node.kind === t.SyntaxKind.NewExpression) {
269
+ const src = fnStack[fnStack.length - 1]
270
+ if (src) {
271
+ const sym = prog.checker.getSymbolAtLocation(node.expression)
272
+ if (sym?.declarations?.length) {
273
+ const targetId = idForDeclaration(sym.declarations[0], rootAbs)
274
+ if (targetId && targetId !== src && index.nodes.has(targetId)) {
275
+ const key = src + '|' + targetId + '|call'
276
+ if (!seen.has(key)) {
277
+ seen.add(key)
278
+ const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1
279
+ edges.push({ source: src, target: targetId, kind: 'call', line })
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ t.forEachChild(node, visit)
286
+ if (pushed) fnStack.pop()
287
+ }
288
+ visit(sf)
289
+ return edges
290
+ }
291
+
292
+ function idForArrowParent(node, rootAbs, t) {
293
+ // `const handler = () => { ... }` — id is the variable name.
294
+ const p = node.parent
295
+ if (p?.kind === t.SyntaxKind.VariableDeclaration && p.name?.text) {
296
+ const sf = node.getSourceFile()
297
+ const fileId = path.relative(rootAbs, sf.fileName).split(path.sep).join('/')
298
+ const line = sf.getLineAndCharacterOfPosition(p.getStart()).line + 1
299
+ return mkId(fileId, p.name.text, line)
300
+ }
301
+ return null
302
+ }
303
+
304
+ // Build the parser shape expected by symbol-graph. Per-extension
305
+ // factory so we can stamp out a parser for .ts/.tsx/.js/.jsx.
306
+ function makeParser() {
307
+ return {
308
+ extractSymbols(content, fileId) {
309
+ // Resolve rootAbs from the host's process.cwd? No — we get it
310
+ // from the cached program. fileId is relative to root, so use
311
+ // the most recent program's root.
312
+ const lastRoot = [..._programCache.keys()].pop()
313
+ if (!lastRoot) return []
314
+ const allFileIds = _programCache.get(lastRoot)?.files || new Set()
315
+ return extractSymbolsFor(fileId, lastRoot, allFileIds)
316
+ },
317
+ extractReferences(content, fileId, index) {
318
+ const lastRoot = [..._programCache.keys()].pop()
319
+ if (!lastRoot) return []
320
+ const allFileIds = _programCache.get(lastRoot)?.files || new Set()
321
+ return extractReferencesFor(fileId, lastRoot, allFileIds, index)
322
+ },
323
+ }
324
+ }
325
+
326
+ module.exports = {
327
+ makeParser,
328
+ loadProgramFor,
329
+ clearProgramFor,
330
+ clearAllPrograms,
331
+ isAvailable() { return loadTS() !== null },
332
+ }
@@ -0,0 +1,310 @@
1
+ // Monorepo detection — looks for workspace markers at scan root and
2
+ // returns a structured description of packages. Used by the scanner to
3
+ // tag each file with its owning package, and by the UI/API/MCP layers
4
+ // to expose package-level graphs.
5
+ //
6
+ // Returns: { kind, packages: [{ name, root, relRoot, manifest, kind, language }], rootIsPackage }
7
+ // kind : 'pnpm' | 'npm-workspaces' | 'yarn-workspaces' | 'lerna' |
8
+ // 'turbo' | 'nx' | 'rush' | 'python-uv' | 'multi-package' | 'single' | 'none'
9
+ // packages : [] when 'single' or 'none'; otherwise one entry per package
10
+ // rootIsPackage: true when the scan root itself is also a publishable package
11
+
12
+ import fs from 'fs'
13
+ import path from 'path'
14
+
15
+ const MAX_DEPTH = 6 // how deep we search for package.json / pyproject.toml
16
+
17
+ // Tiny YAML mini-parser — enough for pnpm-workspace.yaml which is just
18
+ // `packages:` followed by a `-` list of glob strings. Not a real YAML
19
+ // parser, just covers the canonical shape.
20
+ function parsePnpmWorkspace(text) {
21
+ const out = []
22
+ let inPackages = false
23
+ for (const raw of text.split(/\r?\n/)) {
24
+ const line = raw.replace(/#.*$/, '').trimEnd()
25
+ if (!line.trim()) continue
26
+ if (/^packages\s*:/i.test(line)) { inPackages = true; continue }
27
+ if (inPackages) {
28
+ const m = line.match(/^\s*-\s*['"]?([^'"]+)['"]?\s*$/)
29
+ if (m) out.push(m[1])
30
+ else if (/^\S/.test(line)) inPackages = false // new top-level key
31
+ }
32
+ }
33
+ return out
34
+ }
35
+
36
+ // Expand a workspace glob pattern like "packages/*" or "apps/**" into
37
+ // concrete package directories that contain package.json or pyproject.toml.
38
+ // Only handles the patterns actually used in practice — single `*` or
39
+ // `**` segments. Negation patterns (`!foo`) are honored.
40
+ function expandWorkspaceGlob(root, pattern, manifestFile = 'package.json') {
41
+ const negate = pattern.startsWith('!')
42
+ const pat = negate ? pattern.slice(1) : pattern
43
+ // Strip leading "./"
44
+ const clean = pat.replace(/^\.\//, '')
45
+ const parts = clean.split('/').filter(Boolean)
46
+ const out = []
47
+ const walk = (dir, idx) => {
48
+ if (idx >= parts.length) {
49
+ const m = path.join(dir, manifestFile)
50
+ if (fs.existsSync(m)) out.push(dir)
51
+ return
52
+ }
53
+ const seg = parts[idx]
54
+ if (seg === '**') {
55
+ // Match zero or more dirs. Try matching the rest at current dir,
56
+ // and recurse into all subdirs.
57
+ walk(dir, idx + 1)
58
+ let entries
59
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
60
+ for (const e of entries) {
61
+ if (!e.isDirectory()) continue
62
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
63
+ walk(path.join(dir, e.name), idx) // stay at same idx (greedy)
64
+ }
65
+ } else if (seg.includes('*')) {
66
+ const re = new RegExp('^' + seg.split('*').map(s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('.*') + '$')
67
+ let entries
68
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
69
+ for (const e of entries) {
70
+ if (!e.isDirectory()) continue
71
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
72
+ if (re.test(e.name)) walk(path.join(dir, e.name), idx + 1)
73
+ }
74
+ } else {
75
+ walk(path.join(dir, seg), idx + 1)
76
+ }
77
+ }
78
+ walk(root, 0)
79
+ return { dirs: out, negate }
80
+ }
81
+
82
+ function expandWorkspacePatterns(root, patterns, manifestFile = 'package.json') {
83
+ const found = new Set()
84
+ for (const p of patterns) {
85
+ const { dirs, negate } = expandWorkspaceGlob(root, p, manifestFile)
86
+ if (negate) for (const d of dirs) found.delete(d)
87
+ else for (const d of dirs) found.add(d)
88
+ }
89
+ return [...found]
90
+ }
91
+
92
+ // Read a JSON file safely.
93
+ function readJsonSafe(p) {
94
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
95
+ }
96
+
97
+ // Find package.json / pyproject.toml files via a bounded BFS. Stops at
98
+ // node_modules, .git, etc. Used as a fallback when no workspace marker
99
+ // is present.
100
+ function findManifests(root, manifestNames, maxDepth = MAX_DEPTH) {
101
+ const found = []
102
+ const skip = new Set(['node_modules', '.git', 'dist', 'build', 'out',
103
+ '.next', '.nuxt', '.turbo', '.vercel', 'venv', '.venv', '__pycache__',
104
+ '.cache', '.parcel-cache', 'target', '.codesynapt', '.filegraph3d', 'coverage'])
105
+ const walk = (dir, depth) => {
106
+ if (depth > maxDepth) return
107
+ let entries
108
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
109
+ // First check for manifest at this level (don't include root itself
110
+ // in caller logic — caller decides separately).
111
+ for (const name of manifestNames) {
112
+ if (entries.some((e) => e.isFile() && e.name === name)) {
113
+ found.push({ dir, manifest: name })
114
+ // Don't recurse into a package's subdirectories — packages are
115
+ // leaves in the workspace tree.
116
+ return
117
+ }
118
+ }
119
+ for (const e of entries) {
120
+ if (!e.isDirectory()) continue
121
+ if (skip.has(e.name) || e.name.startsWith('.')) continue
122
+ walk(path.join(dir, e.name), depth + 1)
123
+ }
124
+ }
125
+ walk(root, 0)
126
+ return found
127
+ }
128
+
129
+ function packageNameFromManifest(absPath, manifestFile, fallbackDir) {
130
+ if (manifestFile === 'package.json') {
131
+ const j = readJsonSafe(absPath)
132
+ if (j?.name) return j.name
133
+ } else if (manifestFile === 'pyproject.toml') {
134
+ try {
135
+ const text = fs.readFileSync(absPath, 'utf8')
136
+ // [project] name = "x" or [tool.poetry] name = "x"
137
+ const m = text.match(/^\s*name\s*=\s*["']([^"']+)["']/m)
138
+ if (m) return m[1]
139
+ } catch {}
140
+ } else if (manifestFile === 'setup.py') {
141
+ try {
142
+ const text = fs.readFileSync(absPath, 'utf8')
143
+ const m = text.match(/name\s*=\s*["']([^"']+)["']/)
144
+ if (m) return m[1]
145
+ } catch {}
146
+ }
147
+ return fallbackDir
148
+ }
149
+
150
+ function relativePosix(root, abs) {
151
+ const r = path.relative(root, abs).split(path.sep).join('/')
152
+ return r === '' ? '.' : r
153
+ }
154
+
155
+ export function detectMonorepo(root) {
156
+ const result = { kind: 'none', packages: [], rootIsPackage: false }
157
+ let entries
158
+ try { entries = fs.readdirSync(root, { withFileTypes: true }) } catch { return result }
159
+ const names = new Set(entries.map((e) => e.name))
160
+
161
+ // ── Detect the kind by marker files ──
162
+ // Layered detection: if multiple markers, prefer the most specific.
163
+ // pnpm > yarn/npm workspaces > lerna > turbo > nx > rush
164
+ const rootPkgJson = names.has('package.json') ? readJsonSafe(path.join(root, 'package.json')) : null
165
+ if (rootPkgJson?.name) result.rootIsPackage = true
166
+
167
+ let kind = 'none'
168
+ let patterns = []
169
+ let manifestFile = 'package.json'
170
+
171
+ // ① pnpm-workspace.yaml
172
+ if (names.has('pnpm-workspace.yaml') || names.has('pnpm-workspace.yml')) {
173
+ try {
174
+ const file = names.has('pnpm-workspace.yaml') ? 'pnpm-workspace.yaml' : 'pnpm-workspace.yml'
175
+ const text = fs.readFileSync(path.join(root, file), 'utf8')
176
+ patterns = parsePnpmWorkspace(text)
177
+ kind = 'pnpm'
178
+ } catch {}
179
+ }
180
+ // ② package.json workspaces field (npm 7+ / yarn)
181
+ if (kind === 'none' && rootPkgJson) {
182
+ const ws = rootPkgJson.workspaces
183
+ if (Array.isArray(ws)) { patterns = ws; kind = 'npm-workspaces' }
184
+ else if (ws && Array.isArray(ws.packages)) { patterns = ws.packages; kind = 'yarn-workspaces' }
185
+ }
186
+ // ③ lerna.json
187
+ if (kind === 'none' && names.has('lerna.json')) {
188
+ const j = readJsonSafe(path.join(root, 'lerna.json'))
189
+ if (j?.packages && Array.isArray(j.packages)) { patterns = j.packages; kind = 'lerna' }
190
+ else { patterns = ['packages/*']; kind = 'lerna' }
191
+ }
192
+ // ④ rush.json (less common but valid)
193
+ if (kind === 'none' && names.has('rush.json')) {
194
+ const j = readJsonSafe(path.join(root, 'rush.json'))
195
+ if (j?.projects) {
196
+ patterns = j.projects.map((p) => p.projectFolder).filter(Boolean)
197
+ kind = 'rush'
198
+ }
199
+ }
200
+ // ⑤ turbo.json / nx.json — these don't themselves define packages,
201
+ // they augment npm/yarn/pnpm workspaces. If we already found patterns,
202
+ // mark the kind as 'turbo'/'nx' (more informative); else treat as a
203
+ // signal to do heuristic search.
204
+ if (names.has('turbo.json') && kind !== 'none') kind = 'turbo'
205
+ else if (names.has('nx.json') && kind !== 'none') kind = 'nx'
206
+
207
+ // ── Expand patterns or fall back to manifest search ──
208
+ let packageDirs = []
209
+ if (kind !== 'none' && patterns.length > 0) {
210
+ packageDirs = expandWorkspacePatterns(root, patterns, 'package.json')
211
+ }
212
+
213
+ // ⑥ Python pyproject.toml multi-package: detect if multiple
214
+ // pyproject.toml files exist at depth > 0.
215
+ let pythonDirs = []
216
+ const pyManifests = findManifests(root, ['pyproject.toml', 'setup.py'])
217
+ // Exclude root from python list — it's the umbrella, not a package
218
+ pythonDirs = pyManifests.filter((m) => m.dir !== root)
219
+ if (kind === 'none' && pythonDirs.length >= 2) {
220
+ kind = 'python-uv'
221
+ packageDirs = pythonDirs.map((m) => m.dir)
222
+ }
223
+
224
+ // ⑦ Generic multi-package fallback: multiple package.json files at
225
+ // depth > 0 with no explicit workspace declaration. This catches
226
+ // bespoke monorepos that don't use any standard tool.
227
+ if (kind === 'none') {
228
+ const jsManifests = findManifests(root, ['package.json']).filter((m) => m.dir !== root)
229
+ if (jsManifests.length >= 2) {
230
+ kind = 'multi-package'
231
+ packageDirs = jsManifests.map((m) => m.dir)
232
+ }
233
+ }
234
+
235
+ // No monorepo signal at all
236
+ if (kind === 'none') {
237
+ if (result.rootIsPackage) {
238
+ result.kind = 'single'
239
+ result.packages = [{
240
+ name: rootPkgJson?.name || path.basename(root),
241
+ root, relRoot: '.', manifest: 'package.json',
242
+ kind: 'single', language: 'js',
243
+ }]
244
+ }
245
+ return result
246
+ }
247
+
248
+ // ── Build package list ──
249
+ const packages = []
250
+ for (const dir of packageDirs) {
251
+ const isPython = fs.existsSync(path.join(dir, 'pyproject.toml')) ||
252
+ fs.existsSync(path.join(dir, 'setup.py'))
253
+ const manifestName = isPython
254
+ ? (fs.existsSync(path.join(dir, 'pyproject.toml')) ? 'pyproject.toml' : 'setup.py')
255
+ : 'package.json'
256
+ const manifestPath = path.join(dir, manifestName)
257
+ const name = packageNameFromManifest(manifestPath, manifestName, path.basename(dir))
258
+ packages.push({
259
+ name, root: dir, relRoot: relativePosix(root, dir),
260
+ manifest: manifestName, kind, language: isPython ? 'python' : 'js',
261
+ })
262
+ }
263
+ // Optionally include root if it's also a publishable package (npm
264
+ // workspaces with a root package).
265
+ if (result.rootIsPackage && !packages.some((p) => p.root === root)) {
266
+ packages.unshift({
267
+ name: rootPkgJson?.name || path.basename(root),
268
+ root, relRoot: '.', manifest: 'package.json',
269
+ kind, language: 'js',
270
+ })
271
+ }
272
+
273
+ result.kind = kind
274
+ result.packages = packages.sort((a, b) => a.relRoot.localeCompare(b.relRoot))
275
+ return result
276
+ }
277
+
278
+ // Given a file id (root-relative, posix slashes) and a packages array,
279
+ // return the owning package's name (longest-matching relRoot prefix),
280
+ // or null if the file lives outside every package boundary.
281
+ export function packageForFile(fileId, packages) {
282
+ let best = null
283
+ let bestLen = -1
284
+ for (const p of packages) {
285
+ const prefix = p.relRoot === '.' ? '' : p.relRoot + '/'
286
+ if (p.relRoot === '.' || fileId === p.relRoot || fileId.startsWith(prefix)) {
287
+ const len = p.relRoot === '.' ? 0 : prefix.length
288
+ if (len > bestLen) { best = p.name; bestLen = len }
289
+ }
290
+ }
291
+ return best
292
+ }
293
+
294
+ // Read declared internal dependencies of a package (cross-package edges
295
+ // via workspace protocol or explicit deps). Used to validate the
296
+ // graph-derived edges against the manifest-declared truth.
297
+ export function declaredPackageDeps(pkg) {
298
+ if (pkg.manifest !== 'package.json') return []
299
+ const j = readJsonSafe(path.join(pkg.root, 'package.json'))
300
+ if (!j) return []
301
+ const out = []
302
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
303
+ const obj = j[field]
304
+ if (!obj) continue
305
+ for (const [name, spec] of Object.entries(obj)) {
306
+ out.push({ name, spec, kind: field })
307
+ }
308
+ }
309
+ return out
310
+ }