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,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
|
+
}
|