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,2849 @@
|
|
|
1
|
+
// Electron main process — desktop app shell for CodeSynapt
|
|
2
|
+
const { app, BrowserWindow, ipcMain, dialog, Menu, shell } = require('electron')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
const { execFile } = require('child_process')
|
|
8
|
+
const { promisify } = require('util')
|
|
9
|
+
const pExecFile = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
// ─── Persistent state (window bounds, recent folders) ──────────
|
|
12
|
+
const STORE_PATH = path.join(app.getPath('userData'), 'state.json')
|
|
13
|
+
let store = {
|
|
14
|
+
windowBounds: null,
|
|
15
|
+
lastFolder: null,
|
|
16
|
+
recentFolders: [], // auto-tracked, capped at 8
|
|
17
|
+
pinnedProjects: [], // user-pinned: [{ path, name, pinnedAt, color? }]
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
store = { ...store, ...JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')) }
|
|
21
|
+
} catch {}
|
|
22
|
+
function saveStore() {
|
|
23
|
+
try { fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2)) } catch {}
|
|
24
|
+
}
|
|
25
|
+
function addRecent(folder) {
|
|
26
|
+
store.recentFolders = [folder, ...store.recentFolders.filter((f) => f !== folder)]
|
|
27
|
+
.slice(0, 8)
|
|
28
|
+
store.lastFolder = folder
|
|
29
|
+
saveStore()
|
|
30
|
+
rebuildMenu()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Scanner (loaded dynamically because it's ESM) ─────────────
|
|
34
|
+
let Scanner = null
|
|
35
|
+
let scanner = null
|
|
36
|
+
let mainWindow = null
|
|
37
|
+
let currentRoot = null
|
|
38
|
+
|
|
39
|
+
async function loadScannerModule() {
|
|
40
|
+
if (Scanner) return Scanner
|
|
41
|
+
const mod = await import('../packages/core/scanner.js')
|
|
42
|
+
Scanner = mod.Scanner
|
|
43
|
+
return Scanner
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Symbol-mode graph (codegraph-equivalent layer). Lazy: built on the
|
|
47
|
+
// first /symbol/* request after a project loads. Reset on every
|
|
48
|
+
// project swap so it never returns stale symbols from a prior repo.
|
|
49
|
+
const { SymbolGraph, registerParser } = require('../packages/core/lib/symbol-graph.cjs')
|
|
50
|
+
const jsSymbolParser = require('../packages/core/lib/symbol-parser-js.cjs')
|
|
51
|
+
const pySymbolParser = require('../packages/core/lib/symbol-parser-py.cjs')
|
|
52
|
+
const miscSymbolParsers = require('../packages/core/lib/symbol-parser-misc.cjs')
|
|
53
|
+
// Stage 3 — tree-sitter exact parsers. Default ON; CS_SYMBOL_PARSER=regex
|
|
54
|
+
// falls back to the regex/Babel parsers above for comparison or when
|
|
55
|
+
// the WASM grammars aren't shipped (e.g. a stripped portable build).
|
|
56
|
+
let _tsParserModule = null
|
|
57
|
+
try { _tsParserModule = require('../packages/core/lib/symbol-parser-treesitter.cjs') } catch {}
|
|
58
|
+
// Stage 4 — TypeScript compiler API integration. Opt-in via
|
|
59
|
+
// CS_SYMBOL_PARSER=tsc, because building the TS Program loads every
|
|
60
|
+
// file in the repo and is slower than babel for medium projects.
|
|
61
|
+
// Worth it for accuracy on heavy-TS codebases where overloaded method
|
|
62
|
+
// names (`save()` on many classes) need real type-checker resolution.
|
|
63
|
+
let _tscParserModule = null
|
|
64
|
+
try { _tscParserModule = require('../packages/core/lib/symbol-parser-tsc.cjs') } catch {}
|
|
65
|
+
const SYMBOL_PARSER_MODE = process.env.CS_SYMBOL_PARSER || 'treesitter'
|
|
66
|
+
|
|
67
|
+
function registerSymbolParsers() {
|
|
68
|
+
// Always register the Stage-1/2 parsers as the fallback set.
|
|
69
|
+
registerParser(['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx'], jsSymbolParser)
|
|
70
|
+
registerParser(['py'], pySymbolParser)
|
|
71
|
+
registerParser(['go'], miscSymbolParsers.go)
|
|
72
|
+
registerParser(['rs'], miscSymbolParsers.rust)
|
|
73
|
+
registerParser(['java', 'kt'], miscSymbolParsers.javaKt)
|
|
74
|
+
registerParser(['swift'], miscSymbolParsers.swift)
|
|
75
|
+
if (SYMBOL_PARSER_MODE !== 'treesitter' || !_tsParserModule) return
|
|
76
|
+
// Override per-extension with tree-sitter parsers where a grammar
|
|
77
|
+
// is available. The fallback set above still answers for languages
|
|
78
|
+
// without a shipped wasm.
|
|
79
|
+
try {
|
|
80
|
+
for (const ext of _tsParserModule.availableExtensions()) {
|
|
81
|
+
// TypeScript / TSX have no dedicated grammar in our bundled
|
|
82
|
+
// tree-sitter-wasms — the JS grammar is missing `interface`,
|
|
83
|
+
// type aliases, generics, etc. Keep babel for those.
|
|
84
|
+
if (ext === 'ts' || ext === 'tsx') continue
|
|
85
|
+
const tsP = _tsParserModule.makeParser(ext)
|
|
86
|
+
if (tsP) registerParser([ext], tsP)
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error('[symbol] tree-sitter init failed, falling back to regex:', e.message)
|
|
90
|
+
}
|
|
91
|
+
// Stage 4 override — TypeScript compiler API for .ts/.tsx/.js/.jsx
|
|
92
|
+
// when the user opts in. Provides true type-checker-resolved call
|
|
93
|
+
// edges (no more random matches across same-named methods).
|
|
94
|
+
if (SYMBOL_PARSER_MODE === 'tsc' && _tscParserModule?.isAvailable?.()) {
|
|
95
|
+
const tscP = _tscParserModule.makeParser()
|
|
96
|
+
for (const ext of ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']) {
|
|
97
|
+
registerParser([ext], tscP)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
registerSymbolParsers()
|
|
102
|
+
let symbolGraph = null // SymbolGraph instance; rebuilt per project
|
|
103
|
+
let _symbolBuilding = null // in-flight build promise (avoid double work)
|
|
104
|
+
|
|
105
|
+
// Path looks like a test file? Used by explore() ranking to push real
|
|
106
|
+
// implementation symbols above test fixtures with similar names.
|
|
107
|
+
// Heavier explore-only auxiliary-path filter than `isTestPath` —
|
|
108
|
+
// covers vendored bundles inside src/ (`compiled/`, `vendor/`),
|
|
109
|
+
// example apps, and tooling code. Match is "any segment is one of
|
|
110
|
+
// these". Distinct from isAuxPath in symbol-graph (that one drives
|
|
111
|
+
// resolver buckets); here it drives explore ranking.
|
|
112
|
+
const EXPLORE_AUX_SEGMENTS = new Set([
|
|
113
|
+
'compiled', 'vendored', 'vendor', '_compiled',
|
|
114
|
+
'examples', 'example', 'samples', 'sample', 'demo', 'demos',
|
|
115
|
+
'scripts', 'script', 'tools', 'tool',
|
|
116
|
+
'build', 'dist', 'out', 'bin',
|
|
117
|
+
'fixtures', 'fixture',
|
|
118
|
+
// Additional rarely-production segments — common in OSS layouts
|
|
119
|
+
// but never the answer to "how does X actually work".
|
|
120
|
+
'mocks', 'mock', '__mocks__',
|
|
121
|
+
'stubs', 'stub',
|
|
122
|
+
'storybook', '.storybook', 'stories',
|
|
123
|
+
'docs-src', 'documentation',
|
|
124
|
+
])
|
|
125
|
+
function isAuxExplorePath(filePath) {
|
|
126
|
+
if (!filePath) return false
|
|
127
|
+
const parts = filePath.toLowerCase().replace(/\\/g, '/').split('/')
|
|
128
|
+
return parts.some((p) => EXPLORE_AUX_SEGMENTS.has(p))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isTestPath(filePath) {
|
|
132
|
+
if (!filePath) return false
|
|
133
|
+
const p = filePath.toLowerCase().replace(/\\/g, '/')
|
|
134
|
+
// path segments: …/tests/, …/test/, …/__tests__/, …/spec/,
|
|
135
|
+
// …/e2e/, …/integration/, …/bench/ (perf test)
|
|
136
|
+
if (/\/(tests?|__tests__|spec|specs|e2e|integration|integration[_-]tests?|fixtures?|bench(es|marks?)?)\//.test('/' + p + '/')) return true
|
|
137
|
+
// suffixes: foo_test.go, foo.test.ts, FooTests.swift, FooTest.java,
|
|
138
|
+
// foo.bench.ts, foo_bench.go, foo.spec.tsx
|
|
139
|
+
if (/(?:_test|\.test|\.spec|\.bench|_bench|\.e2e)\.[a-z]+$/.test(p)) return true
|
|
140
|
+
if (/tests?\.(swift|kt|java)$/.test(p)) return true
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stopwords stripped from the explore query before keyword matching.
|
|
145
|
+
const EXPLORE_STOPWORDS = new Set([
|
|
146
|
+
'a','an','and','are','as','at','be','by','do','does','for','from','how','in',
|
|
147
|
+
'into','is','it','its','of','on','or','the','their','this','to','using','what',
|
|
148
|
+
'when','where','which','who','why','will','with',
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
// Break a token at camelCase / PascalCase / digit boundaries so query
|
|
152
|
+
// "getUserName" also tries [get, user, name]. Acronym-aware: keeps
|
|
153
|
+
// uppercase runs together when followed by another word (HTTPRequest
|
|
154
|
+
// → [HTTP, Request], not [H, T, T, P, Request]). Each alternative
|
|
155
|
+
// covers a specific shape and the order matters:
|
|
156
|
+
// 1. [A-Z]+(?=[A-Z][a-z]) — leading acronym before a word
|
|
157
|
+
// ("HTTP" in "HTTPRequest")
|
|
158
|
+
// 2. [A-Z]?[a-z]+ — normal camel word
|
|
159
|
+
// ("Request" / "get" / "Name")
|
|
160
|
+
// 3. [A-Z]+ — trailing acronym
|
|
161
|
+
// ("HTML" at end of "URL2HTML")
|
|
162
|
+
// 4. [0-9]+ — digit group
|
|
163
|
+
function splitCamelCase(w) {
|
|
164
|
+
const parts = w.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g) || []
|
|
165
|
+
return parts.map((s) => s.toLowerCase())
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Simplified Porter-style stemmer. Two phases:
|
|
169
|
+
// Step 2 — derivational suffix MAP (rewrite, not strip)
|
|
170
|
+
// "authentication" → "authentic + ate" → "authenticate"
|
|
171
|
+
// Step 4 — terminal suffix STRIP if stem stays ≥ 4 chars
|
|
172
|
+
// "processing"/"processed"/"processor" → "process"
|
|
173
|
+
//
|
|
174
|
+
// Mapping suffixes first preserves the "verb form" so different
|
|
175
|
+
// noun/adjective inflections normalize back to it. Plain strip-only
|
|
176
|
+
// (the previous 12-suffix version) wasn't enough — "authentication"
|
|
177
|
+
// would just lose "ation" and become "authentic", which doesn't
|
|
178
|
+
// substring-match "authenticate".
|
|
179
|
+
//
|
|
180
|
+
// Stem length lower bound (>=4) guards against over-stripping
|
|
181
|
+
// "rate" → "r" etc. We deliberately don't strip bare "s" or "es":
|
|
182
|
+
// risks of mangling "process"/"business"/"axis" outweigh the wins.
|
|
183
|
+
const STEM_STEP2 = [
|
|
184
|
+
['ization', 'ize'], ['ational', 'ate'], ['fulness', 'ful'],
|
|
185
|
+
['ousness', 'ous'], ['iveness', 'ive'], ['tional', 'tion'],
|
|
186
|
+
['ation', 'ate'], ['ator', 'ate'], ['ative', 'ate'],
|
|
187
|
+
['izer', 'ize'], ['icate', 'ic'], ['alize', 'al'],
|
|
188
|
+
['ical', 'ic'],
|
|
189
|
+
]
|
|
190
|
+
const STEM_STEP4 = [
|
|
191
|
+
'ement', 'ements', 'ance', 'ances', 'ence', 'ences',
|
|
192
|
+
'ment', 'ments', 'tion', 'tions', 'sion', 'sions',
|
|
193
|
+
'able', 'ables', 'ible', 'ibles', 'ism', 'isms',
|
|
194
|
+
'ness', 'nesses', 'ful', 'fully', 'ous', 'ously',
|
|
195
|
+
'ive', 'ives', 'ize', 'izes', 'ized', 'izing',
|
|
196
|
+
'ing', 'ed', 'ly', 'er', 'or',
|
|
197
|
+
]
|
|
198
|
+
function stem(w) {
|
|
199
|
+
if (w.length < 5) return w
|
|
200
|
+
for (const [from, to] of STEM_STEP2) {
|
|
201
|
+
if (w.endsWith(from) && w.length - from.length >= 3) {
|
|
202
|
+
return w.slice(0, -from.length) + to
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const suf of STEM_STEP4) {
|
|
206
|
+
if (w.endsWith(suf) && w.length - suf.length >= 4) {
|
|
207
|
+
return w.slice(0, -suf.length)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return w
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Deprecated / TODO-remove markers. SymbolGraph.build sets
|
|
214
|
+
// `node.deprecated` from the 5 lines above each symbol (handles
|
|
215
|
+
// babel's quirky export-wrapper leading-comment attachment). We
|
|
216
|
+
// also fall back to scanning doc/signature for parsers that do
|
|
217
|
+
// surface comments correctly (regex/tree-sitter parsers).
|
|
218
|
+
function isDeprecatedSymbol(node) {
|
|
219
|
+
if (node.deprecated === true) return true
|
|
220
|
+
const d = ((node.doc || '') + ' ' + (node.signature || '')).toLowerCase()
|
|
221
|
+
if (!d) return false
|
|
222
|
+
return /(\s|^)@?deprecated\b|todo\s*[:_-]?\s*remove|fixme\s*[:_-]?\s*remove/.test(d)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Public entry detection — symbols that an external caller reaches
|
|
226
|
+
// (a `main`, a route handler, a CLI bin script, an SDK default
|
|
227
|
+
// export) often have in-degree zero in *our* graph because the
|
|
228
|
+
// caller lives outside the codebase (OS shell, HTTP request, npm
|
|
229
|
+
// consumer). Without flagging these, the orphan damping treats
|
|
230
|
+
// them as dead code. We flag only when the symbol is exported AND
|
|
231
|
+
// either the name or the containing file matches an entry pattern
|
|
232
|
+
// — both signals so we don't sweep every exported util into the
|
|
233
|
+
// "entry" bucket.
|
|
234
|
+
const ENTRY_NAMES = new Set([
|
|
235
|
+
'main', 'run', 'start', 'init', 'handler', 'bootstrap',
|
|
236
|
+
'setup', 'listen', 'serve', 'cli', 'app', 'default',
|
|
237
|
+
])
|
|
238
|
+
const ENTRY_FILE_RE = /(?:^|\/)(?:main|index|entry|server|app|cli|bin|run)(?:\.[a-z]+)?$/i
|
|
239
|
+
function isPublicEntry(node) {
|
|
240
|
+
if (!node.exported) return false
|
|
241
|
+
const name = (node.name || '').toLowerCase()
|
|
242
|
+
if (ENTRY_NAMES.has(name)) return true
|
|
243
|
+
if (ENTRY_FILE_RE.test(node.file || '')) return true
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
// ─── mode 3 (classify) — ranking-free response ─────────────────
|
|
249
|
+
// Returns symbols grouped by lifecycle classification, no score
|
|
250
|
+
// sort. AI/user picks the group it cares about (active for "what
|
|
251
|
+
// runs in production", deprecated/legacy/orphan for "what's safe
|
|
252
|
+
// to delete"). Same candidate selection as the ranked mode 2 —
|
|
253
|
+
// keyword expansion + optional semantic — but the only ordering
|
|
254
|
+
// inside each group is in-degree DESC (graph signal, not a
|
|
255
|
+
// computed score).
|
|
256
|
+
//
|
|
257
|
+
// Why a separate mode 3 instead of just sorting mode 2's output?
|
|
258
|
+
// Mode 2 collapses everything into a single ranked list, so a
|
|
259
|
+
// deprecated symbol with a perfect keyword match can still beat
|
|
260
|
+
// the live implementation just by score arithmetic. Mode 3 makes
|
|
261
|
+
// the grouping the contract — `groups.active[0]` is always the
|
|
262
|
+
// live answer even if a deprecated dup has the same name.
|
|
263
|
+
async function buildClassifyResponse(g, query, budget = 8000) {
|
|
264
|
+
const q = (query || '').toLowerCase()
|
|
265
|
+
const rawTokens = q.split(/[^\p{L}\p{N}_]+/u).filter(Boolean)
|
|
266
|
+
const expanded = new Set(rawTokens)
|
|
267
|
+
for (const t of rawTokens) {
|
|
268
|
+
for (const p of splitCamelCase(t)) if (p.length > 1) expanded.add(p)
|
|
269
|
+
const s = stem(t)
|
|
270
|
+
if (s !== t && s.length > 2) expanded.add(s)
|
|
271
|
+
}
|
|
272
|
+
const keywords = [...expanded].filter((t) => t.length > 1 && !EXPLORE_STOPWORDS.has(t))
|
|
273
|
+
if (!keywords.length) {
|
|
274
|
+
return { query, mode: 'classify', keywords: [], groups: {}, counts: {}, snippets: [], note: 'no usable keywords' }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Candidate selection — same byName / byFile index walk mode 2
|
|
278
|
+
// does. No scoring; we just collect every symbol whose name or
|
|
279
|
+
// file path contains at least one keyword as a substring.
|
|
280
|
+
const candidates = new Set()
|
|
281
|
+
for (const k of keywords) {
|
|
282
|
+
for (const [n, ids] of g.byName) {
|
|
283
|
+
if (n.includes(k)) for (const id of ids) candidates.add(id)
|
|
284
|
+
}
|
|
285
|
+
for (const [f, ids] of g.byFile) {
|
|
286
|
+
if (f.toLowerCase().includes(k)) for (const id of ids) candidates.add(id)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Optional semantic candidate enrichment — when embeddings are
|
|
291
|
+
// ready, pull the top 30 symbols by cosine similarity to the
|
|
292
|
+
// raw query and union them in. Lets a query like "auth" find
|
|
293
|
+
// `login` / `signIn` even when the keyword set never hits.
|
|
294
|
+
let semHits = new Map() // id → similarity
|
|
295
|
+
if (g._embedded && query) {
|
|
296
|
+
try {
|
|
297
|
+
const embedding = require('../packages/core/lib/embedding.cjs')
|
|
298
|
+
const qVec = await embedding.embed(query)
|
|
299
|
+
if (qVec) {
|
|
300
|
+
const sims = []
|
|
301
|
+
for (const node of g.nodes.values()) {
|
|
302
|
+
if (!node._embedding) continue
|
|
303
|
+
const sim = embedding.cosineSim(qVec, node._embedding)
|
|
304
|
+
if (sim > 0.3) sims.push({ id: node.id, sim })
|
|
305
|
+
}
|
|
306
|
+
sims.sort((a, b) => b.sim - a.sim)
|
|
307
|
+
for (const { id, sim } of sims.slice(0, 30)) {
|
|
308
|
+
candidates.add(id)
|
|
309
|
+
semHits.set(id, sim)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch {}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Classify every candidate. Two cross-cutting groups (exact_match
|
|
316
|
+
// and semantic) take precedence over the lifecycle classification
|
|
317
|
+
// and are *exclusive* — a symbol that lives in exact_match is
|
|
318
|
+
// intentionally NOT also listed in active/orphan/etc, so the AI
|
|
319
|
+
// doesn't have to dedupe. The lifecycle bucket is preserved as a
|
|
320
|
+
// field on each entry so the consumer still sees "this exact-match
|
|
321
|
+
// is actually deprecated".
|
|
322
|
+
const groups = {
|
|
323
|
+
exact_match: [], semantic: [],
|
|
324
|
+
active: [], entry: [], deprecated: [], legacy: [],
|
|
325
|
+
test: [], aux: [], orphan: [], normal: [],
|
|
326
|
+
}
|
|
327
|
+
const keywordSet = new Set(keywords)
|
|
328
|
+
for (const id of candidates) {
|
|
329
|
+
const node = g.nodes.get(id)
|
|
330
|
+
if (!node) continue
|
|
331
|
+
const inD = g.inAdj.get(id)?.size || 0
|
|
332
|
+
const ouD = g.outAdj.get(id)?.size || 0
|
|
333
|
+
const mtime = node.mtimeMs || 0
|
|
334
|
+
const ageMs = mtime ? (Date.now() - mtime) : 0
|
|
335
|
+
const ONE_YEAR = 365 * 86400_000
|
|
336
|
+
|
|
337
|
+
let cls
|
|
338
|
+
if (isDeprecatedSymbol(node)) cls = 'deprecated'
|
|
339
|
+
else if (isTestPath(node.file)
|
|
340
|
+
|| /^test[A-Z_]/.test(node.name || '')
|
|
341
|
+
|| /(_test$|spec$|Spec$)/.test(node.name || '')) cls = 'test'
|
|
342
|
+
else if (isAuxExplorePath(node.file)) cls = 'aux'
|
|
343
|
+
else if (isPublicEntry(node)) cls = 'entry'
|
|
344
|
+
else if (inD === 0 && ouD === 0) cls = 'orphan'
|
|
345
|
+
else if (ageMs > ONE_YEAR && inD < 2) cls = 'legacy'
|
|
346
|
+
else if (inD >= 3) cls = 'active'
|
|
347
|
+
else cls = 'normal'
|
|
348
|
+
|
|
349
|
+
const reachable = g._reachable ? g._reachable.has(id) : null
|
|
350
|
+
const semOnly = !rawHasSubstring(node, keywords)
|
|
351
|
+
const nameLower = (node.name || '').toLowerCase()
|
|
352
|
+
const isExactName = keywordSet.has(nameLower)
|
|
353
|
+
|
|
354
|
+
const entry = {
|
|
355
|
+
qualifiedName: node.qualifiedName || node.name,
|
|
356
|
+
name: node.name, kind: node.kind,
|
|
357
|
+
file: node.file, startLine: node.startLine, endLine: node.endLine,
|
|
358
|
+
inDegree: inD, outDegree: ouD,
|
|
359
|
+
ageDays: mtime ? Math.floor(ageMs / 86400_000) : null,
|
|
360
|
+
reachable,
|
|
361
|
+
semSim: semHits.get(id) ?? null,
|
|
362
|
+
classification: cls, // lifecycle preserved even when bucketed elsewhere
|
|
363
|
+
semanticOnly: semOnly,
|
|
364
|
+
}
|
|
365
|
+
// Routing order: exact_match wins over everything (user typed
|
|
366
|
+
// the exact name — surface that even if the symbol is orphan).
|
|
367
|
+
// Then semantic (keyword 0 hit, only the embedding pulled it
|
|
368
|
+
// in). Then lifecycle classification.
|
|
369
|
+
if (isExactName) groups.exact_match.push(entry)
|
|
370
|
+
else if (semOnly) groups.semantic.push(entry)
|
|
371
|
+
else groups[cls].push(entry)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Sort each group: in-degree DESC, then semSim DESC (so semantic
|
|
375
|
+
// candidates surface inside their group when present). Cap per
|
|
376
|
+
// group to keep responses manageable.
|
|
377
|
+
const PER_GROUP_CAP = 8
|
|
378
|
+
for (const g_name of Object.keys(groups)) {
|
|
379
|
+
groups[g_name].sort((a, b) => (b.inDegree - a.inDegree) || ((b.semSim || 0) - (a.semSim || 0)))
|
|
380
|
+
groups[g_name] = groups[g_name].slice(0, PER_GROUP_CAP)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Snippets — source bodies for the top members of the most
|
|
384
|
+
// informative groups. `active` first (real implementation),
|
|
385
|
+
// then `entry`, then `normal`. Skips test/aux/orphan/deprecated
|
|
386
|
+
// unless those are the only groups with hits — saves token
|
|
387
|
+
// budget for code AI actually needs to read.
|
|
388
|
+
const snippets = []
|
|
389
|
+
const MAX_LINES_PER_SNIPPET = 40
|
|
390
|
+
let used = 0
|
|
391
|
+
const SNIPPET_ORDER = ['exact_match', 'active', 'entry', 'normal', 'semantic', 'legacy', 'deprecated', 'test', 'orphan', 'aux']
|
|
392
|
+
outer: for (const groupName of SNIPPET_ORDER) {
|
|
393
|
+
const members = groups[groupName]
|
|
394
|
+
if (!members.length) continue
|
|
395
|
+
const perGroupBudget = groupName === 'exact_match' ? 5
|
|
396
|
+
: (groupName === 'active' || groupName === 'entry') ? 3
|
|
397
|
+
: 1
|
|
398
|
+
for (const m of members.slice(0, perGroupBudget)) {
|
|
399
|
+
if (used >= budget) break outer
|
|
400
|
+
try {
|
|
401
|
+
const filePath = path.join(currentRoot, m.file)
|
|
402
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
403
|
+
const end = Math.min(m.endLine, m.startLine + MAX_LINES_PER_SNIPPET - 1)
|
|
404
|
+
const source = lines.slice(m.startLine - 1, end).join('\n')
|
|
405
|
+
const cost = Math.ceil(source.length / 4)
|
|
406
|
+
if (used + cost > budget && snippets.length > 0) break outer
|
|
407
|
+
snippets.push({
|
|
408
|
+
group: groupName, file: m.file, line: m.startLine,
|
|
409
|
+
name: m.name, kind: m.kind, source,
|
|
410
|
+
})
|
|
411
|
+
used += cost
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const counts = {}
|
|
417
|
+
for (const [k, v] of Object.entries(groups)) if (v.length) counts[k] = v.length
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
query, mode: 'classify', keywords,
|
|
421
|
+
groups, counts, snippets,
|
|
422
|
+
embeddingReady: !!g._embedded,
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Whether any keyword raw-substring matches the symbol — used to
|
|
427
|
+
// flag entries that came in via semantic similarity only.
|
|
428
|
+
function rawHasSubstring(node, keywords) {
|
|
429
|
+
const name = (node.name || '').toLowerCase()
|
|
430
|
+
const qn = (node.qualifiedName || '').toLowerCase()
|
|
431
|
+
const file = (node.file || '').toLowerCase()
|
|
432
|
+
for (const k of keywords) {
|
|
433
|
+
if (name.includes(k) || qn.includes(k) || file.includes(k)) return true
|
|
434
|
+
}
|
|
435
|
+
return false
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Legacy audit module — lazy-loaded ESM. Cached by snapshotVersion.
|
|
439
|
+
let _legacyAudit = null
|
|
440
|
+
let _legacyCache = { version: -1, data: null }
|
|
441
|
+
async function loadLegacyAudit() {
|
|
442
|
+
if (_legacyAudit) return _legacyAudit
|
|
443
|
+
const mod = await import('../packages/core/legacy.js')
|
|
444
|
+
_legacyAudit = mod.auditLegacy
|
|
445
|
+
return _legacyAudit
|
|
446
|
+
}
|
|
447
|
+
async function buildLegacyCached() {
|
|
448
|
+
if (!scanner) return null
|
|
449
|
+
const v = scanner.snapshotVersion || 0
|
|
450
|
+
if (_legacyCache.version === v && _legacyCache.data) return _legacyCache.data
|
|
451
|
+
const fn = await loadLegacyAudit()
|
|
452
|
+
const data = fn(scanner)
|
|
453
|
+
if (scanner.snapshotVersion === v) _legacyCache = { version: v, data }
|
|
454
|
+
return data
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function startScanner(root) {
|
|
458
|
+
// Await teardown: scanner.stop() returns chokidar's close() promise. Not
|
|
459
|
+
// awaiting leaks the old watcher's fs handles when we immediately attach a
|
|
460
|
+
// new one (worst on Windows). clearParserCaches also runs inside stop().
|
|
461
|
+
if (scanner) { try { await scanner.stop() } catch {} ; scanner = null }
|
|
462
|
+
await loadScannerModule()
|
|
463
|
+
|
|
464
|
+
if (!fs.existsSync(root)) {
|
|
465
|
+
mainWindow?.webContents.send('error', { message: `Path does not exist: ${root}` })
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
// If a file was dropped instead of a folder, automatically use its
|
|
469
|
+
// parent directory. This is a common interaction — users drag a
|
|
470
|
+
// representative source file rather than a whole folder.
|
|
471
|
+
const stat = fs.statSync(root)
|
|
472
|
+
if (!stat.isDirectory()) {
|
|
473
|
+
if (stat.isFile()) {
|
|
474
|
+
const parent = path.dirname(root)
|
|
475
|
+
mainWindow?.webContents.send('error', {
|
|
476
|
+
message: `Opened parent folder: ${path.basename(parent)}/`,
|
|
477
|
+
})
|
|
478
|
+
root = parent
|
|
479
|
+
} else {
|
|
480
|
+
mainWindow?.webContents.send('error', { message: `Not a directory: ${root}` })
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Stop any in-flight scanner cleanly before installing a new one
|
|
486
|
+
currentRoot = root
|
|
487
|
+
timelineCache = { root: null, data: null, building: false } // invalidate
|
|
488
|
+
// Drop every scanner-version-keyed cache so the next /summary, /packages,
|
|
489
|
+
// /legacy call recomputes against the freshly-loaded project instead of
|
|
490
|
+
// returning stale data from the previous project.
|
|
491
|
+
_summaryCache = { version: -1, data: null }
|
|
492
|
+
_packagesCache = { version: -1, data: null }
|
|
493
|
+
_legacyCache = { version: -1, data: null }
|
|
494
|
+
// Symbol-mode graph belongs to the previous project — drop it. The
|
|
495
|
+
// next /symbol/* request will rebuild against the new file set.
|
|
496
|
+
symbolGraph = null
|
|
497
|
+
_symbolBuilding = null
|
|
498
|
+
// tsc Program cache must also be cleared so we don't keep the old
|
|
499
|
+
// project's SourceFiles around.
|
|
500
|
+
try { _tscParserModule?.clearAllPrograms?.() } catch {}
|
|
501
|
+
migrateLegacyHistoryDir(root)
|
|
502
|
+
addRecent(root)
|
|
503
|
+
scanner = new Scanner(root)
|
|
504
|
+
|
|
505
|
+
scanner.on('snapshot', (data) => {
|
|
506
|
+
mainWindow?.webContents.send('snapshot', { ...data, root })
|
|
507
|
+
})
|
|
508
|
+
scanner.on('stats', (s) => {
|
|
509
|
+
mainWindow?.webContents.send('stats', s)
|
|
510
|
+
})
|
|
511
|
+
scanner.on('scan-progress', (p) => {
|
|
512
|
+
mainWindow?.webContents.send('scan-progress', p)
|
|
513
|
+
})
|
|
514
|
+
scanner.on('file-changed', ({ id, absPath }) => {
|
|
515
|
+
try {
|
|
516
|
+
const stat = fs.statSync(absPath)
|
|
517
|
+
if (stat.size > 2_000_000) return
|
|
518
|
+
const content = fs.readFileSync(absPath, 'utf8')
|
|
519
|
+
snapshotHistory(currentRoot, id, content)
|
|
520
|
+
trackChange(id, content)
|
|
521
|
+
} catch {}
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
mainWindow?.webContents.send('folder-loaded', { root })
|
|
525
|
+
startTraceSession()
|
|
526
|
+
try {
|
|
527
|
+
scanner.start()
|
|
528
|
+
} catch (err) {
|
|
529
|
+
mainWindow?.webContents.send('error', { message: `Failed to start scanner: ${err.message}` })
|
|
530
|
+
scanner = null
|
|
531
|
+
currentRoot = null
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function stopScanner() {
|
|
536
|
+
if (scanner) { Promise.resolve(scanner.stop()).catch(() => {}); scanner = null }
|
|
537
|
+
closeTraceWriteStream()
|
|
538
|
+
currentRoot = null
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── Window ─────────────────────────────────────────────────────
|
|
542
|
+
function createWindow() {
|
|
543
|
+
const bounds = store.windowBounds || { width: 1280, height: 820 }
|
|
544
|
+
mainWindow = new BrowserWindow({
|
|
545
|
+
...bounds,
|
|
546
|
+
minWidth: 720,
|
|
547
|
+
minHeight: 480,
|
|
548
|
+
backgroundColor: '#07090F',
|
|
549
|
+
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
|
550
|
+
title: 'CodeSynapt',
|
|
551
|
+
webPreferences: {
|
|
552
|
+
preload: path.join(__dirname, 'preload.cjs'),
|
|
553
|
+
contextIsolation: true,
|
|
554
|
+
nodeIntegration: false,
|
|
555
|
+
// Sandbox isolates the renderer from Node APIs entirely. Preload uses
|
|
556
|
+
// only contextBridge + ipcRenderer (no fs/path/etc), so sandbox:true
|
|
557
|
+
// is compatible. Reduces blast radius of any renderer-side compromise.
|
|
558
|
+
sandbox: true,
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
mainWindow.loadFile(path.join(__dirname, '..', 'public', 'index.html'))
|
|
563
|
+
|
|
564
|
+
mainWindow.on('close', () => {
|
|
565
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
566
|
+
store.windowBounds = mainWindow.getBounds()
|
|
567
|
+
saveStore()
|
|
568
|
+
}
|
|
569
|
+
})
|
|
570
|
+
mainWindow.on('closed', () => { mainWindow = null })
|
|
571
|
+
|
|
572
|
+
// ─── Visibility events for physics pause ─────────────────────
|
|
573
|
+
//
|
|
574
|
+
// We deliberately listen ONLY to minimize/restore/hide/show —
|
|
575
|
+
// NOT to blur/focus. The simulation should keep running when
|
|
576
|
+
// the user merely clicks into another app, even if our window
|
|
577
|
+
// is fully obscured. We only pause when the OS no longer needs
|
|
578
|
+
// to render us at all (minimized to taskbar, hidden via Cmd+H,
|
|
579
|
+
// or moved to another virtual desktop and explicitly hidden).
|
|
580
|
+
//
|
|
581
|
+
// This matches user intuition: "I didn't tell it to stop, so it
|
|
582
|
+
// shouldn't stop." It also lets the user keep CodeSynapt alive
|
|
583
|
+
// on a second monitor while they work in another app.
|
|
584
|
+
// ─────────────────────────────────────────────────────────────
|
|
585
|
+
const sendVisibility = (visible) => {
|
|
586
|
+
if (!mainWindow?.isDestroyed()) {
|
|
587
|
+
mainWindow.webContents.send('window-visibility', { visible })
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
mainWindow.on('minimize', () => sendVisibility(false))
|
|
591
|
+
mainWindow.on('restore', () => sendVisibility(true))
|
|
592
|
+
mainWindow.on('hide', () => sendVisibility(false))
|
|
593
|
+
mainWindow.on('show', () => sendVisibility(true))
|
|
594
|
+
|
|
595
|
+
// Drag & drop folder onto window
|
|
596
|
+
mainWindow.webContents.on('did-finish-load', () => {
|
|
597
|
+
// Priority: CS_INITIAL_ROOT env (set by `cs ensure --launch`) > last folder
|
|
598
|
+
const envRoot = process.env.CS_INITIAL_ROOT
|
|
599
|
+
if (envRoot && fs.existsSync(envRoot) && fs.statSync(envRoot).isDirectory()) {
|
|
600
|
+
startScanner(path.resolve(envRoot))
|
|
601
|
+
} else if (store.lastFolder && fs.existsSync(store.lastFolder)) {
|
|
602
|
+
startScanner(store.lastFolder)
|
|
603
|
+
} else {
|
|
604
|
+
mainWindow.webContents.send('no-folder')
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
if (process.env.CS_DEVTOOLS === '1' || process.env.FG3D_DEVTOOLS === '1') mainWindow.webContents.openDevTools()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─── Native menu ────────────────────────────────────────────────
|
|
612
|
+
function rebuildMenu() {
|
|
613
|
+
const isMac = process.platform === 'darwin'
|
|
614
|
+
const pinnedItems = (store.pinnedProjects || []).map((p) => ({
|
|
615
|
+
label: `★ ${p.name}`,
|
|
616
|
+
sublabel: p.path,
|
|
617
|
+
click: () => startScanner(p.path),
|
|
618
|
+
}))
|
|
619
|
+
const recentItems = (store.recentFolders || [])
|
|
620
|
+
.filter((f) => !(store.pinnedProjects || []).some((p) => p.path === f))
|
|
621
|
+
.map((f) => ({ label: f, click: () => startScanner(f) }))
|
|
622
|
+
const recentSubmenu = (pinnedItems.length || recentItems.length)
|
|
623
|
+
? [
|
|
624
|
+
...(pinnedItems.length ? pinnedItems : []),
|
|
625
|
+
...(pinnedItems.length && recentItems.length ? [{ type: 'separator' }] : []),
|
|
626
|
+
...recentItems,
|
|
627
|
+
{ type: 'separator' },
|
|
628
|
+
{ label: 'Clear Recent', click: () => {
|
|
629
|
+
store.recentFolders = []
|
|
630
|
+
saveStore()
|
|
631
|
+
rebuildMenu()
|
|
632
|
+
}},
|
|
633
|
+
]
|
|
634
|
+
: [{ label: 'No recent folders', enabled: false }]
|
|
635
|
+
|
|
636
|
+
const template = [
|
|
637
|
+
...(isMac ? [{
|
|
638
|
+
label: app.name,
|
|
639
|
+
submenu: [
|
|
640
|
+
{ role: 'about' },
|
|
641
|
+
{ type: 'separator' },
|
|
642
|
+
{ role: 'services' },
|
|
643
|
+
{ type: 'separator' },
|
|
644
|
+
{ role: 'hide' },
|
|
645
|
+
{ role: 'hideOthers' },
|
|
646
|
+
{ role: 'unhide' },
|
|
647
|
+
{ type: 'separator' },
|
|
648
|
+
{ role: 'quit' },
|
|
649
|
+
],
|
|
650
|
+
}] : []),
|
|
651
|
+
{
|
|
652
|
+
label: 'File',
|
|
653
|
+
submenu: [
|
|
654
|
+
{
|
|
655
|
+
label: 'Open Folder…',
|
|
656
|
+
accelerator: 'CmdOrCtrl+O',
|
|
657
|
+
click: () => pickAndLoadFolder(),
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
label: 'Open Recent',
|
|
661
|
+
submenu: recentSubmenu,
|
|
662
|
+
},
|
|
663
|
+
{ type: 'separator' },
|
|
664
|
+
{
|
|
665
|
+
label: 'Close Folder',
|
|
666
|
+
accelerator: 'CmdOrCtrl+W',
|
|
667
|
+
enabled: !!currentRoot,
|
|
668
|
+
click: () => {
|
|
669
|
+
stopScanner()
|
|
670
|
+
store.lastFolder = null
|
|
671
|
+
saveStore()
|
|
672
|
+
mainWindow?.webContents.send('no-folder')
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
{ type: 'separator' },
|
|
676
|
+
isMac ? { role: 'close' } : { role: 'quit' },
|
|
677
|
+
],
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
label: 'View',
|
|
681
|
+
submenu: [
|
|
682
|
+
{ role: 'reload' },
|
|
683
|
+
{ role: 'toggleDevTools' },
|
|
684
|
+
{ type: 'separator' },
|
|
685
|
+
{ role: 'resetZoom' },
|
|
686
|
+
{ role: 'zoomIn' },
|
|
687
|
+
{ role: 'zoomOut' },
|
|
688
|
+
{ type: 'separator' },
|
|
689
|
+
{ role: 'togglefullscreen' },
|
|
690
|
+
],
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
label: 'Window',
|
|
694
|
+
submenu: [
|
|
695
|
+
{ role: 'minimize' },
|
|
696
|
+
{ role: 'zoom' },
|
|
697
|
+
...(isMac ? [{ type: 'separator' }, { role: 'front' }] : [{ role: 'close' }]),
|
|
698
|
+
],
|
|
699
|
+
},
|
|
700
|
+
]
|
|
701
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function pickAndLoadFolder() {
|
|
705
|
+
if (!mainWindow) return null
|
|
706
|
+
const result = await dialog.showOpenDialog(mainWindow, {
|
|
707
|
+
properties: ['openDirectory'],
|
|
708
|
+
title: 'Select folder to visualize',
|
|
709
|
+
defaultPath: store.lastFolder || app.getPath('home'),
|
|
710
|
+
})
|
|
711
|
+
if (result.canceled || !result.filePaths[0]) return null
|
|
712
|
+
await startScanner(result.filePaths[0])
|
|
713
|
+
return result.filePaths[0]
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ─── IPC handlers ───────────────────────────────────────────────
|
|
717
|
+
ipcMain.handle('pick-folder', () => pickAndLoadFolder())
|
|
718
|
+
ipcMain.handle('load-folder', (_e, folder) => startScanner(folder))
|
|
719
|
+
ipcMain.handle('get-state', () => ({
|
|
720
|
+
currentRoot,
|
|
721
|
+
recentFolders: store.recentFolders,
|
|
722
|
+
}))
|
|
723
|
+
ipcMain.handle('close-folder', () => {
|
|
724
|
+
stopScanner()
|
|
725
|
+
store.lastFolder = null
|
|
726
|
+
saveStore()
|
|
727
|
+
mainWindow?.webContents.send('no-folder')
|
|
728
|
+
})
|
|
729
|
+
// Path traversal guard. Resolves both paths to absolute, then checks
|
|
730
|
+
// that the requested file is INSIDE currentRoot (not just that its
|
|
731
|
+
// string starts with the same prefix, which fails on e.g.
|
|
732
|
+
// /home/user/proj vs /home/user/proj2 false positives).
|
|
733
|
+
function isInsideRoot(root, full) {
|
|
734
|
+
const resolvedRoot = path.resolve(root)
|
|
735
|
+
const resolvedFull = path.resolve(full)
|
|
736
|
+
const rel = path.relative(resolvedRoot, resolvedFull)
|
|
737
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
ipcMain.handle('read-file', (_e, id) => {
|
|
741
|
+
if (!currentRoot || !id) return null
|
|
742
|
+
try {
|
|
743
|
+
const full = path.join(currentRoot, id)
|
|
744
|
+
if (!isInsideRoot(currentRoot, full)) return null
|
|
745
|
+
const stat = fs.statSync(full)
|
|
746
|
+
if (!stat.isFile()) return null
|
|
747
|
+
if (stat.size > 2_000_000) return { content: '[file too large to open]', truncated: true }
|
|
748
|
+
const content = fs.readFileSync(full, 'utf8')
|
|
749
|
+
return { content }
|
|
750
|
+
} catch (e) {
|
|
751
|
+
return { content: `[error: ${e.message}]`, error: true }
|
|
752
|
+
}
|
|
753
|
+
})
|
|
754
|
+
// Shared write helper — used by IPC, HTTP /write, and HTTP /edit.
|
|
755
|
+
// All AI-issued writes flow through here so the audit trail (history
|
|
756
|
+
// snapshot + trace emission) is consistent regardless of entry point.
|
|
757
|
+
function writeFileToRoot(id, content, { source = 'ipc' } = {}) {
|
|
758
|
+
if (!currentRoot || !id || typeof content !== 'string') return { ok: false, error: 'invalid args' }
|
|
759
|
+
if (content.length > 2_000_000) return { ok: false, error: 'content too large (>2MB)' }
|
|
760
|
+
try {
|
|
761
|
+
const full = path.join(currentRoot, id)
|
|
762
|
+
if (!isInsideRoot(currentRoot, full)) return { ok: false, error: 'outside root' }
|
|
763
|
+
fs.writeFileSync(full, content, 'utf8')
|
|
764
|
+
snapshotHistory(currentRoot, id, content)
|
|
765
|
+
// Mark every external write with `tool: 'write'` so the renderer
|
|
766
|
+
// can tint the node green instead of the read-pink.
|
|
767
|
+
emitTrace('write', id)
|
|
768
|
+
return { ok: true, size: Buffer.byteLength(content, 'utf8'), source }
|
|
769
|
+
} catch (e) {
|
|
770
|
+
return { ok: false, error: e.message }
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
ipcMain.handle('write-file', (_e, id, content) => writeFileToRoot(id, content, { source: 'ipc' }))
|
|
775
|
+
ipcMain.handle('set-history-enabled', (_e, enabled) => {
|
|
776
|
+
historyEnabled = !!enabled
|
|
777
|
+
return { ok: true, enabled: historyEnabled }
|
|
778
|
+
})
|
|
779
|
+
ipcMain.handle('get-history-enabled', () => historyEnabled)
|
|
780
|
+
ipcMain.handle('list-history', (_e, id) => listHistory(currentRoot, id))
|
|
781
|
+
ipcMain.handle('read-history', (_e, id, ts) => readHistorySnap(currentRoot, id, ts))
|
|
782
|
+
ipcMain.handle('restore-history', (_e, id, ts) => {
|
|
783
|
+
if (!currentRoot || !id || !ts) return { ok: false }
|
|
784
|
+
const content = readHistorySnap(currentRoot, id, ts)
|
|
785
|
+
if (content === null) return { ok: false, error: 'snapshot not found' }
|
|
786
|
+
try {
|
|
787
|
+
const full = path.join(currentRoot, id)
|
|
788
|
+
if (!isInsideRoot(currentRoot, full)) return { ok: false, error: 'outside root' }
|
|
789
|
+
fs.writeFileSync(full, content, 'utf8')
|
|
790
|
+
snapshotHistory(currentRoot, id, content)
|
|
791
|
+
return { ok: true, content }
|
|
792
|
+
} catch (e) {
|
|
793
|
+
return { ok: false, error: e.message }
|
|
794
|
+
}
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
// ─── Session change log ────────────────────────────────────────
|
|
798
|
+
// Tracks every file modification detected by the scanner during this
|
|
799
|
+
// session — what AI editing agents (Claude Code, Cursor) write hits
|
|
800
|
+
// this list. Captures first-seen content on first detection so we can
|
|
801
|
+
// generate diffs for the "show all changes" view.
|
|
802
|
+
const sessionChanges = new Map() // id -> { firstAt, lastAt, count, firstSeen, currentSize, currentLoc }
|
|
803
|
+
function trackChange(id, content) {
|
|
804
|
+
const existing = sessionChanges.get(id)
|
|
805
|
+
const now = Date.now()
|
|
806
|
+
const loc = content ? content.split('\n').length : 0
|
|
807
|
+
const size = Buffer.byteLength(content || '', 'utf8')
|
|
808
|
+
if (existing) {
|
|
809
|
+
existing.lastAt = now
|
|
810
|
+
existing.count += 1
|
|
811
|
+
existing.currentSize = size
|
|
812
|
+
existing.currentLoc = loc
|
|
813
|
+
} else {
|
|
814
|
+
// First change we see — we already lost the original "before".
|
|
815
|
+
// Capture the current content as "firstSeen" so subsequent changes
|
|
816
|
+
// have something to diff against.
|
|
817
|
+
sessionChanges.set(id, {
|
|
818
|
+
firstAt: now, lastAt: now, count: 1,
|
|
819
|
+
firstSeen: content || '',
|
|
820
|
+
firstSeenSize: size, firstSeenLoc: loc,
|
|
821
|
+
currentSize: size, currentLoc: loc,
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function listSessionChanges() {
|
|
826
|
+
const items = []
|
|
827
|
+
for (const [id, c] of sessionChanges.entries()) {
|
|
828
|
+
items.push({
|
|
829
|
+
id,
|
|
830
|
+
firstAt: c.firstAt, lastAt: c.lastAt, count: c.count,
|
|
831
|
+
sizeBefore: c.firstSeenSize, sizeAfter: c.currentSize,
|
|
832
|
+
locBefore: c.firstSeenLoc, locAfter: c.currentLoc,
|
|
833
|
+
sizeDelta: c.currentSize - c.firstSeenSize,
|
|
834
|
+
locDelta: c.currentLoc - c.firstSeenLoc,
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
items.sort((a, b) => b.lastAt - a.lastAt)
|
|
838
|
+
return items
|
|
839
|
+
}
|
|
840
|
+
function getChangeDiff(id) {
|
|
841
|
+
const c = sessionChanges.get(id)
|
|
842
|
+
if (!c) return null
|
|
843
|
+
let after = null
|
|
844
|
+
try {
|
|
845
|
+
if (!currentRoot) return null
|
|
846
|
+
const full = path.join(currentRoot, id)
|
|
847
|
+
if (!isInsideRoot(currentRoot, full)) return null
|
|
848
|
+
const stat = fs.statSync(full)
|
|
849
|
+
if (stat.size > 2_000_000) return { error: 'file too large' }
|
|
850
|
+
after = fs.readFileSync(full, 'utf8')
|
|
851
|
+
} catch (e) { return { error: e.message } }
|
|
852
|
+
return {
|
|
853
|
+
id, firstAt: c.firstAt, lastAt: c.lastAt, count: c.count,
|
|
854
|
+
before: c.firstSeen,
|
|
855
|
+
after,
|
|
856
|
+
lines: makeLineDiff(c.firstSeen, after),
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// Tiny LCS-based unified diff. Returns array of { tag: 'eq'|'add'|'del', a?: lineNo, b?: lineNo, text }.
|
|
860
|
+
function makeLineDiff(before, after) {
|
|
861
|
+
const A = (before || '').split('\n')
|
|
862
|
+
const B = (after || '').split('\n')
|
|
863
|
+
const n = A.length, m = B.length
|
|
864
|
+
// LCS table — bail out if too large to avoid huge allocations.
|
|
865
|
+
if (n * m > 2_000_000) return [{ tag: 'note', text: 'file too large to diff line-by-line' }]
|
|
866
|
+
const dp = new Uint32Array((n + 1) * (m + 1))
|
|
867
|
+
const W = m + 1
|
|
868
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
869
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
870
|
+
dp[i * W + j] = A[i] === B[j]
|
|
871
|
+
? dp[(i + 1) * W + (j + 1)] + 1
|
|
872
|
+
: Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)])
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const out = []
|
|
876
|
+
let i = 0, j = 0
|
|
877
|
+
while (i < n && j < m) {
|
|
878
|
+
if (A[i] === B[j]) { out.push({ tag: 'eq', a: i + 1, b: j + 1, text: A[i] }); i++; j++ }
|
|
879
|
+
else if (dp[(i + 1) * W + j] >= dp[i * W + (j + 1)]) { out.push({ tag: 'del', a: i + 1, text: A[i] }); i++ }
|
|
880
|
+
else { out.push({ tag: 'add', b: j + 1, text: B[j] }); j++ }
|
|
881
|
+
}
|
|
882
|
+
while (i < n) { out.push({ tag: 'del', a: i + 1, text: A[i] }); i++ }
|
|
883
|
+
while (j < m) { out.push({ tag: 'add', b: j + 1, text: B[j] }); j++ }
|
|
884
|
+
return out
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ─── File history ──────────────────────────────────────────────
|
|
888
|
+
const HISTORY_DIR_NAME = '.codesynapt'
|
|
889
|
+
const LEGACY_HISTORY_DIR_NAME = '.filegraph3d' // renamed in 0.14.6; migrate on first scan
|
|
890
|
+
const HISTORY_MAX_PER_FILE = 3
|
|
891
|
+
let historyEnabled = false // default OFF — user toggles on in settings
|
|
892
|
+
function historyDirFor(root, id) {
|
|
893
|
+
const safe = id.replace(/[\\/:]/g, '__').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
894
|
+
return path.join(root, HISTORY_DIR_NAME, 'history', safe)
|
|
895
|
+
}
|
|
896
|
+
// One-time migration of the per-project data folder (history/ + traces/).
|
|
897
|
+
// If a user upgraded from <0.14.6, their backups live under .filegraph3d/.
|
|
898
|
+
// We rename the whole folder so both history/ and traces/ subdirs move together.
|
|
899
|
+
// Skipped if the new folder already exists (user has both somehow — leave them).
|
|
900
|
+
function migrateLegacyHistoryDir(root) {
|
|
901
|
+
if (!root) return
|
|
902
|
+
try {
|
|
903
|
+
const oldPath = path.join(root, LEGACY_HISTORY_DIR_NAME)
|
|
904
|
+
const newPath = path.join(root, HISTORY_DIR_NAME)
|
|
905
|
+
if (fs.existsSync(oldPath) && !fs.existsSync(newPath) && fs.statSync(oldPath).isDirectory()) {
|
|
906
|
+
fs.renameSync(oldPath, newPath)
|
|
907
|
+
log.info('migrated legacy history dir', { from: LEGACY_HISTORY_DIR_NAME, to: HISTORY_DIR_NAME, root })
|
|
908
|
+
}
|
|
909
|
+
} catch (e) {
|
|
910
|
+
log.warn('history dir migration skipped', { error: e.message })
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function snapshotHistory(root, id, content) {
|
|
914
|
+
if (!historyEnabled) return
|
|
915
|
+
if (!root || !id) return
|
|
916
|
+
try {
|
|
917
|
+
const dir = historyDirFor(root, id)
|
|
918
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
919
|
+
const files = fs.readdirSync(dir)
|
|
920
|
+
.filter((f) => f.endsWith('.snap'))
|
|
921
|
+
.map((f) => ({ name: f, ts: parseInt(f, 10) }))
|
|
922
|
+
.filter((f) => !isNaN(f.ts))
|
|
923
|
+
.sort((a, b) => b.ts - a.ts)
|
|
924
|
+
// Skip if newest snapshot is identical (avoid stutter from chokidar re-firing)
|
|
925
|
+
if (files.length > 0) {
|
|
926
|
+
try {
|
|
927
|
+
const prev = fs.readFileSync(path.join(dir, files[0].name), 'utf8')
|
|
928
|
+
if (prev === content) return
|
|
929
|
+
} catch {}
|
|
930
|
+
}
|
|
931
|
+
fs.writeFileSync(path.join(dir, `${Date.now()}.snap`), content, 'utf8')
|
|
932
|
+
// Prune to last HISTORY_MAX_PER_FILE
|
|
933
|
+
const all = fs.readdirSync(dir)
|
|
934
|
+
.filter((f) => f.endsWith('.snap'))
|
|
935
|
+
.map((f) => ({ name: f, ts: parseInt(f, 10) }))
|
|
936
|
+
.filter((f) => !isNaN(f.ts))
|
|
937
|
+
.sort((a, b) => b.ts - a.ts)
|
|
938
|
+
for (const f of all.slice(HISTORY_MAX_PER_FILE)) {
|
|
939
|
+
try { fs.unlinkSync(path.join(dir, f.name)) } catch {}
|
|
940
|
+
}
|
|
941
|
+
} catch {}
|
|
942
|
+
}
|
|
943
|
+
function listHistory(root, id) {
|
|
944
|
+
if (!root || !id) return []
|
|
945
|
+
try {
|
|
946
|
+
const dir = historyDirFor(root, id)
|
|
947
|
+
if (!fs.existsSync(dir)) return []
|
|
948
|
+
return fs.readdirSync(dir)
|
|
949
|
+
.filter((f) => f.endsWith('.snap'))
|
|
950
|
+
.map((f) => {
|
|
951
|
+
const ts = parseInt(f, 10)
|
|
952
|
+
if (isNaN(ts)) return null
|
|
953
|
+
try {
|
|
954
|
+
const stat = fs.statSync(path.join(dir, f))
|
|
955
|
+
return { ts, size: stat.size }
|
|
956
|
+
} catch { return null }
|
|
957
|
+
})
|
|
958
|
+
.filter(Boolean)
|
|
959
|
+
.sort((a, b) => b.ts - a.ts)
|
|
960
|
+
} catch { return [] }
|
|
961
|
+
}
|
|
962
|
+
function readHistorySnap(root, id, ts) {
|
|
963
|
+
if (!root || !id || !ts) return null
|
|
964
|
+
try {
|
|
965
|
+
const dir = historyDirFor(root, id)
|
|
966
|
+
const file = path.join(dir, `${ts}.snap`)
|
|
967
|
+
if (!fs.existsSync(file)) return null
|
|
968
|
+
return fs.readFileSync(file, 'utf8')
|
|
969
|
+
} catch { return null }
|
|
970
|
+
}
|
|
971
|
+
ipcMain.handle('reveal-in-os', (_e, id) => {
|
|
972
|
+
if (!currentRoot || !id) return
|
|
973
|
+
const full = path.join(currentRoot, id)
|
|
974
|
+
if (isInsideRoot(currentRoot, full) && fs.existsSync(full)) {
|
|
975
|
+
shell.showItemInFolder(full)
|
|
976
|
+
}
|
|
977
|
+
})
|
|
978
|
+
ipcMain.handle('open-in-editor', (_e, id) => {
|
|
979
|
+
if (!currentRoot || !id) return
|
|
980
|
+
const full = path.join(currentRoot, id)
|
|
981
|
+
if (isInsideRoot(currentRoot, full) && fs.existsSync(full)) {
|
|
982
|
+
shell.openPath(full)
|
|
983
|
+
}
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
// ─── Plugin system ──────────────────────────────────────────────
|
|
987
|
+
const pluginLoader = require('./plugin-loader.cjs')
|
|
988
|
+
|
|
989
|
+
ipcMain.handle('list-plugins', () => {
|
|
990
|
+
try {
|
|
991
|
+
return pluginLoader.discoverPlugins()
|
|
992
|
+
} catch (err) {
|
|
993
|
+
console.error('[main] plugin discovery failed:', err)
|
|
994
|
+
return []
|
|
995
|
+
}
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
ipcMain.handle('open-plugin-dir', () => {
|
|
999
|
+
try {
|
|
1000
|
+
const dir = pluginLoader.ensurePluginDir()
|
|
1001
|
+
shell.openPath(dir)
|
|
1002
|
+
return dir
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
console.error('[main] cannot open plugin dir:', err)
|
|
1005
|
+
return null
|
|
1006
|
+
}
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
ipcMain.handle('plugin-dir', () => pluginLoader.getPluginDir())
|
|
1010
|
+
|
|
1011
|
+
// ─── Pinned projects ──────────────────────────────────────────
|
|
1012
|
+
function basenameOf(p) {
|
|
1013
|
+
return p.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean).pop() || p
|
|
1014
|
+
}
|
|
1015
|
+
// ─── Panel data IPCs (renderer → main, bypasses HTTP/CSP) ─────
|
|
1016
|
+
// These mirror the HTTP control API endpoints but are reached via the
|
|
1017
|
+
// preload bridge instead of fetch(). The renderer can't fetch its own
|
|
1018
|
+
// HTTP server from the file:// origin under the current CSP, so we
|
|
1019
|
+
// expose the data through IPC. Same underlying functions are shared
|
|
1020
|
+
// with the HTTP layer.
|
|
1021
|
+
ipcMain.handle('panel:tour', () => buildTour())
|
|
1022
|
+
ipcMain.handle('panel:timeline', async () => await buildTimeline())
|
|
1023
|
+
ipcMain.handle('panel:changes', () => listSessionChanges())
|
|
1024
|
+
ipcMain.handle('panel:change-diff', (_e, id) => getChangeDiff(id))
|
|
1025
|
+
ipcMain.handle('panel:packages', () => buildPackagesCached())
|
|
1026
|
+
ipcMain.handle('panel:package', (_e, name) => buildPackageDetail(name))
|
|
1027
|
+
ipcMain.handle('panel:legacy', async () => await buildLegacyCached())
|
|
1028
|
+
ipcMain.handle('trace:log', (_e, opts = {}) => {
|
|
1029
|
+
let evs = traceLog
|
|
1030
|
+
if (opts.tool) evs = evs.filter((e) => e.tool === opts.tool)
|
|
1031
|
+
if (opts.limit) evs = evs.slice(-opts.limit)
|
|
1032
|
+
return { sessionId: traceSessionId, events: evs, totalAvailable: traceLog.length }
|
|
1033
|
+
})
|
|
1034
|
+
ipcMain.handle('trace:stats', () => ({ sessionId: traceSessionId, ...computeTraceStats(traceLog) }))
|
|
1035
|
+
ipcMain.handle('trace:sessions', () => ({
|
|
1036
|
+
sessions: listTraceSessions(currentRoot), currentSessionId: traceSessionId,
|
|
1037
|
+
}))
|
|
1038
|
+
ipcMain.handle('trace:session', (_e, id) => {
|
|
1039
|
+
const data = readTraceSession(currentRoot, id)
|
|
1040
|
+
if (!data) return null
|
|
1041
|
+
return { ...data, stats: computeTraceStats(data.events) }
|
|
1042
|
+
})
|
|
1043
|
+
ipcMain.handle('trace:clear', () => { traceLog = []; startTraceSession(); return { newSessionId: traceSessionId } })
|
|
1044
|
+
ipcMain.handle('trace:export', async (_e, exportPath) => {
|
|
1045
|
+
if (!exportPath) {
|
|
1046
|
+
const r = await dialog.showSaveDialog(mainWindow, {
|
|
1047
|
+
title: 'Export AI trace session',
|
|
1048
|
+
defaultPath: `cs-trace-${traceSessionId}.json`,
|
|
1049
|
+
filters: [{ name: 'JSON', extensions: ['json'] }],
|
|
1050
|
+
})
|
|
1051
|
+
if (r.canceled || !r.filePath) return { canceled: true }
|
|
1052
|
+
exportPath = r.filePath
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
const stats = computeTraceStats(traceLog)
|
|
1056
|
+
const out = {
|
|
1057
|
+
sessionId: traceSessionId, root: currentRoot,
|
|
1058
|
+
startedAt: traceSessionStartedAt, exportedAt: Date.now(),
|
|
1059
|
+
stats, events: traceLog,
|
|
1060
|
+
}
|
|
1061
|
+
fs.writeFileSync(exportPath, JSON.stringify(out, null, 2), 'utf8')
|
|
1062
|
+
return { ok: true, path: exportPath, eventCount: traceLog.length }
|
|
1063
|
+
} catch (e) { return { error: e.message } }
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
ipcMain.handle('list-projects', () => ({
|
|
1067
|
+
pinned: store.pinnedProjects || [],
|
|
1068
|
+
recent: store.recentFolders || [],
|
|
1069
|
+
current: currentRoot,
|
|
1070
|
+
}))
|
|
1071
|
+
ipcMain.handle('pin-project', (_e, payload) => {
|
|
1072
|
+
const path = (payload && payload.path) || ''
|
|
1073
|
+
if (!path) return { ok: false, error: 'path required' }
|
|
1074
|
+
const name = (payload && payload.name) || basenameOf(path)
|
|
1075
|
+
const color = (payload && payload.color) || null
|
|
1076
|
+
store.pinnedProjects = (store.pinnedProjects || []).filter((p) => p.path !== path)
|
|
1077
|
+
store.pinnedProjects.unshift({ path, name, color, pinnedAt: Date.now() })
|
|
1078
|
+
saveStore()
|
|
1079
|
+
rebuildMenu()
|
|
1080
|
+
return { ok: true, pinned: store.pinnedProjects }
|
|
1081
|
+
})
|
|
1082
|
+
ipcMain.handle('unpin-project', (_e, path) => {
|
|
1083
|
+
store.pinnedProjects = (store.pinnedProjects || []).filter((p) => p.path !== path)
|
|
1084
|
+
saveStore()
|
|
1085
|
+
rebuildMenu()
|
|
1086
|
+
return { ok: true, pinned: store.pinnedProjects }
|
|
1087
|
+
})
|
|
1088
|
+
ipcMain.handle('rename-project', (_e, payload) => {
|
|
1089
|
+
const path = (payload && payload.path) || ''
|
|
1090
|
+
const name = (payload && payload.name) || ''
|
|
1091
|
+
if (!path || !name) return { ok: false, error: 'path and name required' }
|
|
1092
|
+
const list = store.pinnedProjects || []
|
|
1093
|
+
const item = list.find((p) => p.path === path)
|
|
1094
|
+
if (!item) return { ok: false, error: 'not pinned' }
|
|
1095
|
+
item.name = name
|
|
1096
|
+
saveStore()
|
|
1097
|
+
rebuildMenu()
|
|
1098
|
+
return { ok: true, pinned: list }
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
// ─── HTTP control server (for CLI + MCP) ───────────────────────
|
|
1102
|
+
// Exposes read-only graph queries and UI control actions on
|
|
1103
|
+
// http://127.0.0.1:PORT (default 7707). Local-only. Port and
|
|
1104
|
+
// enable/disable are configurable via env vars and persistent settings.
|
|
1105
|
+
const CONTROL_DEFAULT_PORT = parseInt(process.env.CS_PORT || process.env.FG3D_PORT || '7707', 10)
|
|
1106
|
+
let controlServer = null
|
|
1107
|
+
let controlPort = CONTROL_DEFAULT_PORT
|
|
1108
|
+
|
|
1109
|
+
function getGraphState() {
|
|
1110
|
+
if (!scanner) return null
|
|
1111
|
+
return { root: currentRoot, ...scanner.snapshot() }
|
|
1112
|
+
}
|
|
1113
|
+
function findNode(id) {
|
|
1114
|
+
if (!scanner) return null
|
|
1115
|
+
const f = scanner.files.get(id)
|
|
1116
|
+
if (!f) return null
|
|
1117
|
+
return {
|
|
1118
|
+
id: f.id, ext: f.ext, loc: f.loc, size: f.size,
|
|
1119
|
+
importCount: f.imports.length,
|
|
1120
|
+
hasDynamicResolution: (f.dynamicPatterns || []).length > 0,
|
|
1121
|
+
dynamicPatterns: f.dynamicPatterns || [],
|
|
1122
|
+
confidence: f.confidence || 'high',
|
|
1123
|
+
pkg: f.pkg || null,
|
|
1124
|
+
lastSeenAt: f.lastSeenAt,
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Approximate token count — Anthropic's published rule of thumb is
|
|
1129
|
+
// ~3.5–4 chars/token for code. Use 4 conservatively for budgeting.
|
|
1130
|
+
function estimateTokens(obj) {
|
|
1131
|
+
try { return Math.ceil(JSON.stringify(obj).length / 4) } catch { return 0 }
|
|
1132
|
+
}
|
|
1133
|
+
function withMeta(payload, extra = {}) {
|
|
1134
|
+
const meta = {
|
|
1135
|
+
scannedAt: scanner?._lastSnapshotAt || Date.now(),
|
|
1136
|
+
serverTime: Date.now(),
|
|
1137
|
+
...extra,
|
|
1138
|
+
}
|
|
1139
|
+
meta.tokenEstimate = estimateTokens({ ...payload, meta })
|
|
1140
|
+
return { ...payload, meta }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Cached wrapper around buildSummary — recomputes only when the graph
|
|
1144
|
+
// snapshot version changes. Lazy: cost paid only when summary is read.
|
|
1145
|
+
let _summaryCache = { version: -1, data: null }
|
|
1146
|
+
function buildSummaryCached() {
|
|
1147
|
+
if (!scanner) return null
|
|
1148
|
+
const v = scanner.snapshotVersion || 0
|
|
1149
|
+
if (_summaryCache.version === v && _summaryCache.data) return _summaryCache.data
|
|
1150
|
+
const data = buildSummary()
|
|
1151
|
+
// Race guard: only cache if version didn't shift during compute.
|
|
1152
|
+
if (scanner.snapshotVersion === v) _summaryCache = { version: v, data }
|
|
1153
|
+
return data
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Project summary — Layer-1 cheap overview for AI to read first
|
|
1157
|
+
function buildSummary() {
|
|
1158
|
+
if (!scanner) return null
|
|
1159
|
+
const files = [...scanner.files.values()]
|
|
1160
|
+
const byExt = {}
|
|
1161
|
+
let dynamicCount = 0
|
|
1162
|
+
const incoming = new Map()
|
|
1163
|
+
const outgoing = new Map()
|
|
1164
|
+
for (const e of scanner.edges) {
|
|
1165
|
+
incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
|
|
1166
|
+
outgoing.set(e.s, (outgoing.get(e.s) || 0) + 1)
|
|
1167
|
+
}
|
|
1168
|
+
for (const f of files) {
|
|
1169
|
+
byExt[f.ext || 'other'] = (byExt[f.ext || 'other'] || 0) + 1
|
|
1170
|
+
if ((f.dynamicPatterns || []).length > 0) dynamicCount++
|
|
1171
|
+
}
|
|
1172
|
+
// Top hubs
|
|
1173
|
+
const topHubs = files
|
|
1174
|
+
.map((f) => ({ id: f.id, incoming: incoming.get(f.id) || 0, ext: f.ext }))
|
|
1175
|
+
.filter((h) => h.incoming >= 2)
|
|
1176
|
+
.sort((a, b) => b.incoming - a.incoming)
|
|
1177
|
+
.slice(0, 10)
|
|
1178
|
+
// Top folders by file count
|
|
1179
|
+
const folderCount = new Map()
|
|
1180
|
+
for (const f of files) {
|
|
1181
|
+
const p = f.id.includes('/') ? f.id.slice(0, f.id.lastIndexOf('/')) : '(root)'
|
|
1182
|
+
const top = p.split('/')[0] || '(root)'
|
|
1183
|
+
folderCount.set(top, (folderCount.get(top) || 0) + 1)
|
|
1184
|
+
}
|
|
1185
|
+
const topFolders = [...folderCount.entries()]
|
|
1186
|
+
.sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
1187
|
+
.map(([path, files]) => ({ path, files }))
|
|
1188
|
+
// Orphans (no incoming AND no outgoing)
|
|
1189
|
+
let orphanCount = 0
|
|
1190
|
+
for (const f of files) {
|
|
1191
|
+
if ((incoming.get(f.id) || 0) === 0 && (outgoing.get(f.id) || 0) === 0) orphanCount++
|
|
1192
|
+
}
|
|
1193
|
+
// Ext breakdown (top 5)
|
|
1194
|
+
const extMix = Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
1195
|
+
.reduce((o, [k, v]) => (o[k] = v, o), {})
|
|
1196
|
+
// External services (already aggregated)
|
|
1197
|
+
const ext = getExternalUrls()
|
|
1198
|
+
const topExternal = ext.domains.slice(0, 5).map((d) => d.domain)
|
|
1199
|
+
// `asset` edges are HTML/CSS/etc. referencing image/script/style files
|
|
1200
|
+
// (jQuery, jazzy.js, theme CSS, image tags). They're useful when you
|
|
1201
|
+
// ask "what assets does this page link to?" but they shouldn't dominate
|
|
1202
|
+
// the code-structure stats — a 300-page Jazzy-generated docs tree would
|
|
1203
|
+
// make `edgeCount` swing by thousands without representing any source
|
|
1204
|
+
// dependency. Split them into their own counter.
|
|
1205
|
+
let codeEdges = 0, assetEdges = 0
|
|
1206
|
+
for (const e of scanner.edges) {
|
|
1207
|
+
if (e.kind === 'asset' || e.k === 'asset') assetEdges++
|
|
1208
|
+
else codeEdges++
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
root: currentRoot,
|
|
1212
|
+
fileCount: files.length,
|
|
1213
|
+
edgeCount: codeEdges,
|
|
1214
|
+
assetEdgeCount: assetEdges,
|
|
1215
|
+
extMix,
|
|
1216
|
+
topFolders,
|
|
1217
|
+
topHubs,
|
|
1218
|
+
orphanCount,
|
|
1219
|
+
dynamicPatternFileCount: dynamicCount,
|
|
1220
|
+
externalDomainCount: ext.domains.length,
|
|
1221
|
+
externalDomainsTop: topExternal,
|
|
1222
|
+
historyEnabled,
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
function getDeps(id) {
|
|
1226
|
+
if (!scanner) return []
|
|
1227
|
+
return scanner.edges.filter((e) => e.s === id)
|
|
1228
|
+
}
|
|
1229
|
+
function getUsers(id) {
|
|
1230
|
+
if (!scanner) return []
|
|
1231
|
+
return scanner.edges.filter((e) => e.t === id)
|
|
1232
|
+
}
|
|
1233
|
+
// Package-level overview. For each detected package compute file count,
|
|
1234
|
+
// LOC total, edges in/out of the package (file-level), and dependents.
|
|
1235
|
+
// Cached against snapshotVersion like buildSummary.
|
|
1236
|
+
let _packagesCache = { version: -1, data: null }
|
|
1237
|
+
function buildPackagesCached() {
|
|
1238
|
+
if (!scanner) return null
|
|
1239
|
+
const v = scanner.snapshotVersion || 0
|
|
1240
|
+
if (_packagesCache.version === v && _packagesCache.data) return _packagesCache.data
|
|
1241
|
+
const m = scanner.monorepo
|
|
1242
|
+
if (!m || m.kind === 'none' || !m.packages.length) {
|
|
1243
|
+
const empty = { kind: m?.kind || 'none', packages: [], pkgEdges: [], rootIsPackage: !!m?.rootIsPackage }
|
|
1244
|
+
_packagesCache = { version: v, data: empty }
|
|
1245
|
+
return empty
|
|
1246
|
+
}
|
|
1247
|
+
// Bucket files per package
|
|
1248
|
+
const filesByPkg = new Map()
|
|
1249
|
+
for (const f of scanner.files.values()) {
|
|
1250
|
+
if (!f.pkg) continue
|
|
1251
|
+
const arr = filesByPkg.get(f.pkg) || []
|
|
1252
|
+
arr.push(f)
|
|
1253
|
+
filesByPkg.set(f.pkg, arr)
|
|
1254
|
+
}
|
|
1255
|
+
// Incoming/outgoing edge counts per package (file-level)
|
|
1256
|
+
const edgesIn = new Map(), edgesOut = new Map()
|
|
1257
|
+
for (const e of scanner.edges) {
|
|
1258
|
+
const sf = scanner.files.get(e.s), tf = scanner.files.get(e.t)
|
|
1259
|
+
if (!sf || !tf) continue
|
|
1260
|
+
if (sf.pkg && sf.pkg !== tf.pkg) edgesOut.set(sf.pkg, (edgesOut.get(sf.pkg) || 0) + 1)
|
|
1261
|
+
if (tf.pkg && sf.pkg !== tf.pkg) edgesIn.set(tf.pkg, (edgesIn.get(tf.pkg) || 0) + 1)
|
|
1262
|
+
}
|
|
1263
|
+
const packages = m.packages.map((p) => {
|
|
1264
|
+
const files = filesByPkg.get(p.name) || []
|
|
1265
|
+
const loc = files.reduce((s, f) => s + (f.loc || 0), 0)
|
|
1266
|
+
const size = files.reduce((s, f) => s + (f.size || 0), 0)
|
|
1267
|
+
return {
|
|
1268
|
+
name: p.name,
|
|
1269
|
+
relRoot: p.relRoot,
|
|
1270
|
+
manifest: p.manifest,
|
|
1271
|
+
language: p.language,
|
|
1272
|
+
kind: p.kind,
|
|
1273
|
+
fileCount: files.length,
|
|
1274
|
+
loc, size,
|
|
1275
|
+
crossPackageImports: edgesOut.get(p.name) || 0,
|
|
1276
|
+
crossPackageDependents: edgesIn.get(p.name) || 0,
|
|
1277
|
+
}
|
|
1278
|
+
})
|
|
1279
|
+
const data = {
|
|
1280
|
+
kind: m.kind,
|
|
1281
|
+
rootIsPackage: m.rootIsPackage,
|
|
1282
|
+
packages,
|
|
1283
|
+
pkgEdges: scanner.pkgEdges || [],
|
|
1284
|
+
}
|
|
1285
|
+
if (scanner.snapshotVersion === v) _packagesCache = { version: v, data }
|
|
1286
|
+
return data
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Detail view for a single package: files (sorted by mass), declared
|
|
1290
|
+
// dependencies (from manifest), incoming/outgoing cross-package edges
|
|
1291
|
+
// with the specific file pairs that make up each edge.
|
|
1292
|
+
function buildPackageDetail(name) {
|
|
1293
|
+
if (!scanner) return null
|
|
1294
|
+
const m = scanner.monorepo
|
|
1295
|
+
const pkg = m?.packages?.find((p) => p.name === name)
|
|
1296
|
+
if (!pkg) return null
|
|
1297
|
+
const files = []
|
|
1298
|
+
const incoming = new Map() // for sorting by mass
|
|
1299
|
+
for (const e of scanner.edges) incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
|
|
1300
|
+
for (const f of scanner.files.values()) {
|
|
1301
|
+
if (f.pkg !== name) continue
|
|
1302
|
+
files.push({
|
|
1303
|
+
id: f.id, ext: f.ext, loc: f.loc, size: f.size,
|
|
1304
|
+
mass: incoming.get(f.id) || 0,
|
|
1305
|
+
})
|
|
1306
|
+
}
|
|
1307
|
+
files.sort((a, b) => b.mass - a.mass)
|
|
1308
|
+
// Cross-package edges involving this package
|
|
1309
|
+
const outgoingEdges = [] // edges from THIS package to others
|
|
1310
|
+
const incomingEdges = [] // edges from others into THIS package
|
|
1311
|
+
for (const e of scanner.edges) {
|
|
1312
|
+
const sf = scanner.files.get(e.s), tf = scanner.files.get(e.t)
|
|
1313
|
+
if (!sf || !tf || !sf.pkg || !tf.pkg || sf.pkg === tf.pkg) continue
|
|
1314
|
+
if (sf.pkg === name) outgoingEdges.push({ s: e.s, t: e.t, k: e.k, toPkg: tf.pkg })
|
|
1315
|
+
if (tf.pkg === name) incomingEdges.push({ s: e.s, t: e.t, k: e.k, fromPkg: sf.pkg })
|
|
1316
|
+
}
|
|
1317
|
+
// Declared deps from manifest
|
|
1318
|
+
let declared = []
|
|
1319
|
+
try {
|
|
1320
|
+
if (pkg.manifest === 'package.json') {
|
|
1321
|
+
const j = JSON.parse(fs.readFileSync(path.join(pkg.root, 'package.json'), 'utf8'))
|
|
1322
|
+
const collect = (field) => {
|
|
1323
|
+
if (!j[field]) return
|
|
1324
|
+
for (const [k, v] of Object.entries(j[field])) declared.push({ name: k, spec: v, kind: field })
|
|
1325
|
+
}
|
|
1326
|
+
collect('dependencies'); collect('devDependencies'); collect('peerDependencies')
|
|
1327
|
+
}
|
|
1328
|
+
} catch {}
|
|
1329
|
+
return {
|
|
1330
|
+
name, relRoot: pkg.relRoot, manifest: pkg.manifest,
|
|
1331
|
+
language: pkg.language, kind: pkg.kind,
|
|
1332
|
+
fileCount: files.length, files,
|
|
1333
|
+
outgoingEdges, incomingEdges,
|
|
1334
|
+
declared,
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function searchFiles(q) {
|
|
1339
|
+
if (!scanner || !q) return []
|
|
1340
|
+
const needle = q.toLowerCase()
|
|
1341
|
+
const out = []
|
|
1342
|
+
for (const f of scanner.files.values()) {
|
|
1343
|
+
if (f.id.toLowerCase().includes(needle)) out.push(f.id)
|
|
1344
|
+
if (out.length >= 100) break
|
|
1345
|
+
}
|
|
1346
|
+
return out
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Predict the impact of editing a file: BFS through dependents (or
|
|
1350
|
+
// dependencies), tally total size + LOC + categorize by path. Token
|
|
1351
|
+
// estimate uses the ~4-chars-per-token heuristic Anthropic publishes.
|
|
1352
|
+
function computeBlastRadius(id, depth = 3, direction = 'users') {
|
|
1353
|
+
if (!scanner || !scanner.files.has(id)) return null
|
|
1354
|
+
const visited = new Set([id])
|
|
1355
|
+
let frontier = new Set([id])
|
|
1356
|
+
const byDepth = [{ depth: 0, ids: [id] }]
|
|
1357
|
+
for (let d = 1; d <= depth; d++) {
|
|
1358
|
+
const next = new Set()
|
|
1359
|
+
for (const fid of frontier) {
|
|
1360
|
+
const edges = direction === 'users' ? getUsers(fid) : getDeps(fid)
|
|
1361
|
+
for (const e of edges) {
|
|
1362
|
+
const neighbor = direction === 'users' ? e.s : e.t
|
|
1363
|
+
if (visited.has(neighbor)) continue
|
|
1364
|
+
visited.add(neighbor)
|
|
1365
|
+
next.add(neighbor)
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (next.size === 0) break
|
|
1369
|
+
byDepth.push({ depth: d, ids: [...next] })
|
|
1370
|
+
frontier = next
|
|
1371
|
+
}
|
|
1372
|
+
const files = [...visited].map((fid) => {
|
|
1373
|
+
const f = scanner.files.get(fid)
|
|
1374
|
+
return f ? { id: fid, ext: f.ext, loc: f.loc, size: f.size } : null
|
|
1375
|
+
}).filter(Boolean)
|
|
1376
|
+
const totalSize = files.reduce((s, f) => s + f.size, 0)
|
|
1377
|
+
const totalLoc = files.reduce((s, f) => s + f.loc, 0)
|
|
1378
|
+
// Anthropic publishes ~3.5–4 chars/token for code. Use 4 as a round
|
|
1379
|
+
// estimate; this is the same heuristic the SDK docs recommend.
|
|
1380
|
+
const tokenEstimate = Math.round(totalSize / 4)
|
|
1381
|
+
const categories = { tests: 0, source: 0, config: 0, docs: 0, other: 0 }
|
|
1382
|
+
for (const f of files) {
|
|
1383
|
+
if (/(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec)\.[a-z]+$/i.test(f.id)) categories.tests++
|
|
1384
|
+
else if (/\.(?:json|ya?ml|toml|env|config|conf|ini|lock)(?:\.\w+)?$|^\.[a-z]+rc/i.test(f.id)) categories.config++
|
|
1385
|
+
else if (/\.(?:md|mdx|txt|rst|adoc)$/i.test(f.id)) categories.docs++
|
|
1386
|
+
else if (f.ext) categories.source++
|
|
1387
|
+
else categories.other++
|
|
1388
|
+
}
|
|
1389
|
+
return {
|
|
1390
|
+
seed: id, direction, depth,
|
|
1391
|
+
totalFiles: files.length,
|
|
1392
|
+
totalSize, totalLoc, tokenEstimate, categories,
|
|
1393
|
+
files: files.sort((a, b) => b.size - a.size).slice(0, 200),
|
|
1394
|
+
byDepth,
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Build a per-file "first introduced at" timeline from `git log`.
|
|
1399
|
+
// Cached after first build because git log over a large repo is slow.
|
|
1400
|
+
// Returns: { points: [{ ts, hash, subject, addedFiles: [...] }], firstAt, lastAt, isGit, error? }
|
|
1401
|
+
let timelineCache = { root: null, data: null, building: false }
|
|
1402
|
+
async function buildTimeline() {
|
|
1403
|
+
if (!currentRoot) return { error: 'no folder loaded', isGit: false }
|
|
1404
|
+
if (timelineCache.root === currentRoot && timelineCache.data) return timelineCache.data
|
|
1405
|
+
if (timelineCache.building) return { error: 'building', isGit: true, building: true }
|
|
1406
|
+
timelineCache.building = true
|
|
1407
|
+
try {
|
|
1408
|
+
await pExecFile('git', ['rev-parse', '--git-dir'], { cwd: currentRoot })
|
|
1409
|
+
} catch {
|
|
1410
|
+
timelineCache.building = false
|
|
1411
|
+
return { error: 'not a git repository', isGit: false }
|
|
1412
|
+
}
|
|
1413
|
+
try {
|
|
1414
|
+
const { stdout } = await pExecFile(
|
|
1415
|
+
'git',
|
|
1416
|
+
['log', '--reverse', '--diff-filter=A', '--name-only', '--format=__C__%H|%at|%s'],
|
|
1417
|
+
{ cwd: currentRoot, maxBuffer: 100 * 1024 * 1024 }
|
|
1418
|
+
)
|
|
1419
|
+
const points = []
|
|
1420
|
+
let cur = null
|
|
1421
|
+
for (const line of stdout.split('\n')) {
|
|
1422
|
+
if (line.startsWith('__C__')) {
|
|
1423
|
+
const [hash, atStr, ...subj] = line.slice(5).split('|')
|
|
1424
|
+
cur = { hash, ts: parseInt(atStr, 10) * 1000, subject: subj.join('|'), addedFiles: [] }
|
|
1425
|
+
points.push(cur)
|
|
1426
|
+
} else if (line && cur) {
|
|
1427
|
+
// Only keep files we currently track (filters renames + deletes)
|
|
1428
|
+
const id = line.replace(/\\/g, '/')
|
|
1429
|
+
if (scanner?.files?.has(id)) cur.addedFiles.push(id)
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Drop empty commits (commits that added only files we no longer have)
|
|
1433
|
+
const filtered = points.filter((p) => p.addedFiles.length > 0)
|
|
1434
|
+
const data = {
|
|
1435
|
+
isGit: true,
|
|
1436
|
+
points: filtered,
|
|
1437
|
+
firstAt: filtered[0]?.ts || Date.now(),
|
|
1438
|
+
lastAt: filtered[filtered.length - 1]?.ts || Date.now(),
|
|
1439
|
+
commitCount: filtered.length,
|
|
1440
|
+
}
|
|
1441
|
+
timelineCache = { root: currentRoot, data, building: false }
|
|
1442
|
+
return data
|
|
1443
|
+
} catch (e) {
|
|
1444
|
+
timelineCache.building = false
|
|
1445
|
+
return { error: e.message, isGit: true }
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Heuristic-only onboarding tour. Picks likely entry points (index/
|
|
1450
|
+
// main/app/server at the project root or under src/), then the top
|
|
1451
|
+
// hub files by incoming-import count. Each stop has a generated
|
|
1452
|
+
// human-readable hint. An MCP client can call cs_trace({action:'tour'}) to get
|
|
1453
|
+
// the same script for narrating.
|
|
1454
|
+
function buildTour() {
|
|
1455
|
+
if (!scanner) return null
|
|
1456
|
+
const files = [...scanner.files.values()]
|
|
1457
|
+
const stops = []
|
|
1458
|
+
const seen = new Set()
|
|
1459
|
+
const entryRe = /^(?:src\/)?(?:index|main|app|server|cli|bin)(?:\.[a-z]+)+$/i
|
|
1460
|
+
const entries = files.filter((f) => entryRe.test(f.id)).sort((a, b) => a.id.length - b.id.length).slice(0, 3)
|
|
1461
|
+
for (const f of entries) {
|
|
1462
|
+
if (seen.has(f.id)) continue
|
|
1463
|
+
seen.add(f.id)
|
|
1464
|
+
stops.push({
|
|
1465
|
+
id: f.id,
|
|
1466
|
+
kind: 'entry',
|
|
1467
|
+
hint: `Entry point — likely where execution starts. ${f.ext.toUpperCase()} file, ${f.loc} LOC.`,
|
|
1468
|
+
})
|
|
1469
|
+
}
|
|
1470
|
+
// Inbound count per file
|
|
1471
|
+
const inCount = new Map()
|
|
1472
|
+
for (const e of scanner.edges) inCount.set(e.t, (inCount.get(e.t) || 0) + 1)
|
|
1473
|
+
const hubs = files
|
|
1474
|
+
.map((f) => ({ ...f, inCount: inCount.get(f.id) || 0 }))
|
|
1475
|
+
.filter((f) => f.inCount >= 2 && !seen.has(f.id))
|
|
1476
|
+
.sort((a, b) => b.inCount - a.inCount)
|
|
1477
|
+
.slice(0, 5)
|
|
1478
|
+
for (const f of hubs) {
|
|
1479
|
+
seen.add(f.id)
|
|
1480
|
+
stops.push({
|
|
1481
|
+
id: f.id,
|
|
1482
|
+
kind: 'hub',
|
|
1483
|
+
hint: `Hub file — ${f.inCount} other files import this. Core utility or shared module.`,
|
|
1484
|
+
})
|
|
1485
|
+
}
|
|
1486
|
+
// External-call concentrators
|
|
1487
|
+
const ext = getExternalUrls()
|
|
1488
|
+
const topCallers = new Map()
|
|
1489
|
+
for (const d of ext.domains) {
|
|
1490
|
+
for (const c of d.callers) {
|
|
1491
|
+
topCallers.set(c.file, (topCallers.get(c.file) || 0) + 1)
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
const apiFiles = [...topCallers.entries()]
|
|
1495
|
+
.filter(([id]) => !seen.has(id))
|
|
1496
|
+
.sort((a, b) => b[1] - a[1])
|
|
1497
|
+
.slice(0, 3)
|
|
1498
|
+
for (const [id, count] of apiFiles) {
|
|
1499
|
+
seen.add(id)
|
|
1500
|
+
stops.push({
|
|
1501
|
+
id, kind: 'api',
|
|
1502
|
+
hint: `External API integration — calls ${count} different external URL${count === 1 ? '' : 's'}.`,
|
|
1503
|
+
})
|
|
1504
|
+
}
|
|
1505
|
+
return { stops, totalFiles: scanner.files.size }
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function getExternalUrls() {
|
|
1509
|
+
if (!scanner) return { domains: [], totalCalls: 0 }
|
|
1510
|
+
const byDomain = new Map()
|
|
1511
|
+
let total = 0
|
|
1512
|
+
// Helper: register one URL occurrence
|
|
1513
|
+
const add = (rawUrl, fileId, methodHint) => {
|
|
1514
|
+
const m = rawUrl.match(/^(https?|wss?):\/\/([^\/:?#]+)/i)
|
|
1515
|
+
if (!m) return
|
|
1516
|
+
const proto = m[1].toLowerCase()
|
|
1517
|
+
const domain = m[2].toLowerCase()
|
|
1518
|
+
let bucket = byDomain.get(domain)
|
|
1519
|
+
if (!bucket) { bucket = { domain, proto, callers: [] }; byDomain.set(domain, bucket) }
|
|
1520
|
+
bucket.callers.push({ file: fileId, url: rawUrl, method: methodHint || (proto.startsWith('ws') ? 'WS' : 'GET') })
|
|
1521
|
+
total++
|
|
1522
|
+
}
|
|
1523
|
+
for (const f of scanner.files.values()) {
|
|
1524
|
+
// 1. Structured apiCalls — known fetch/axios/requests patterns with method
|
|
1525
|
+
if (f.apiCalls && f.apiCalls.length) {
|
|
1526
|
+
for (const c of f.apiCalls) {
|
|
1527
|
+
if (/^https?:\/\//i.test(c.url)) add(c.url, f.id, c.method || 'GET')
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
// 2. Generic URL grep — catches everything else
|
|
1531
|
+
if (f.externalUrls && f.externalUrls.length) {
|
|
1532
|
+
for (const u of f.externalUrls) add(u.url, f.id, null)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
// De-duplicate identical (file, url, method) triples within each domain
|
|
1536
|
+
for (const bucket of byDomain.values()) {
|
|
1537
|
+
const seen = new Set()
|
|
1538
|
+
bucket.callers = bucket.callers.filter((c) => {
|
|
1539
|
+
const k = c.file + '|' + c.url + '|' + c.method
|
|
1540
|
+
if (seen.has(k)) return false
|
|
1541
|
+
seen.add(k); return true
|
|
1542
|
+
})
|
|
1543
|
+
}
|
|
1544
|
+
// Recompute total after dedup
|
|
1545
|
+
total = 0
|
|
1546
|
+
for (const b of byDomain.values()) total += b.callers.length
|
|
1547
|
+
const domains = [...byDomain.values()].sort((a, b) => b.callers.length - a.callers.length)
|
|
1548
|
+
return { domains, totalCalls: total }
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function writeJson(res, status, data) {
|
|
1552
|
+
const body = JSON.stringify(data)
|
|
1553
|
+
res.writeHead(status, {
|
|
1554
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1555
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1556
|
+
'Access-Control-Allow-Origin': '*',
|
|
1557
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1558
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
1559
|
+
})
|
|
1560
|
+
res.end(body)
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// ─── Trace store ───────────────────────────────────────────────
|
|
1564
|
+
//
|
|
1565
|
+
// Persistent record of every node access (tool, id, timestamp) the
|
|
1566
|
+
// AI/CLI/MCP layer makes. Lets the user see exactly what an AI did
|
|
1567
|
+
// during a coding session, export it for review, or replay it.
|
|
1568
|
+
//
|
|
1569
|
+
// Storage layout: `.codesynapt/traces/session-{startTs}.jsonl`
|
|
1570
|
+
// One line per event. Each session ID is the unix-ms timestamp of
|
|
1571
|
+
// when the scanner started on that root.
|
|
1572
|
+
const TRACE_DIR_NAME = 'traces'
|
|
1573
|
+
const TRACE_MEM_CAP = 10000 // in-memory cap to prevent unbounded growth
|
|
1574
|
+
let traceSessionId = null // ms timestamp; set in startScanner
|
|
1575
|
+
let traceSessionStartedAt = null
|
|
1576
|
+
let traceLog = [] // [{ tool, id, ts }]
|
|
1577
|
+
let traceWriteStream = null
|
|
1578
|
+
|
|
1579
|
+
function traceDirFor(root) { return path.join(root, HISTORY_DIR_NAME, TRACE_DIR_NAME) }
|
|
1580
|
+
function traceFileFor(root, sessionId) {
|
|
1581
|
+
return path.join(traceDirFor(root), `session-${sessionId}.jsonl`)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function startTraceSession() {
|
|
1585
|
+
if (!currentRoot) return
|
|
1586
|
+
traceSessionId = Date.now()
|
|
1587
|
+
traceSessionStartedAt = traceSessionId
|
|
1588
|
+
traceLog = []
|
|
1589
|
+
closeTraceWriteStream()
|
|
1590
|
+
try {
|
|
1591
|
+
fs.mkdirSync(traceDirFor(currentRoot), { recursive: true })
|
|
1592
|
+
traceWriteStream = fs.createWriteStream(traceFileFor(currentRoot, traceSessionId), { flags: 'a' })
|
|
1593
|
+
// First line: session metadata
|
|
1594
|
+
traceWriteStream.write(JSON.stringify({
|
|
1595
|
+
type: 'meta', sessionId: traceSessionId, root: currentRoot, startedAt: traceSessionStartedAt,
|
|
1596
|
+
}) + '\n')
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
traceWriteStream = null
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
function closeTraceWriteStream() {
|
|
1602
|
+
if (traceWriteStream) {
|
|
1603
|
+
try { traceWriteStream.end() } catch {}
|
|
1604
|
+
traceWriteStream = null
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Trust metadata for an AI session-trace event: was the queried/touched file
|
|
1609
|
+
// statically confident, or does it use dynamic/reflective/DI patterns the graph
|
|
1610
|
+
// can't fully resolve? Logging this per query lets a reviewer judge whether the
|
|
1611
|
+
// AI was working from complete data or known-incomplete data.
|
|
1612
|
+
function traceMetaFor(id) {
|
|
1613
|
+
const f = scanner && scanner.files && scanner.files.get(id)
|
|
1614
|
+
if (!f) return null
|
|
1615
|
+
const dyn = (f.dynamicPatterns || [])
|
|
1616
|
+
return { conf: f.confidence || 'high', dyn: dyn.length ? dyn : undefined }
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function emitTrace(tool, id, meta) {
|
|
1620
|
+
if (!id) return
|
|
1621
|
+
const ts = Date.now()
|
|
1622
|
+
// Auto-attach per-file trust meta (confidence / dynamic patterns) unless the
|
|
1623
|
+
// caller passed its own richer meta (e.g. blast impact stats).
|
|
1624
|
+
const ev = { tool, id, ts, ...(meta || traceMetaFor(id) || {}) }
|
|
1625
|
+
// In-memory (cap with sliding window)
|
|
1626
|
+
traceLog.push(ev)
|
|
1627
|
+
if (traceLog.length > TRACE_MEM_CAP) traceLog.splice(0, traceLog.length - TRACE_MEM_CAP)
|
|
1628
|
+
// Disk append (best-effort)
|
|
1629
|
+
if (traceWriteStream) {
|
|
1630
|
+
try { traceWriteStream.write(JSON.stringify(ev) + '\n') } catch {}
|
|
1631
|
+
}
|
|
1632
|
+
mainWindow?.webContents.send('control:trace', { ...ev })
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function listTraceSessions(root) {
|
|
1636
|
+
if (!root) return []
|
|
1637
|
+
const dir = traceDirFor(root)
|
|
1638
|
+
if (!fs.existsSync(dir)) return []
|
|
1639
|
+
const out = []
|
|
1640
|
+
for (const name of fs.readdirSync(dir)) {
|
|
1641
|
+
const m = name.match(/^session-(\d+)\.jsonl$/)
|
|
1642
|
+
if (!m) continue
|
|
1643
|
+
const sessionId = parseInt(m[1], 10)
|
|
1644
|
+
const full = path.join(dir, name)
|
|
1645
|
+
let stat, size = 0, eventCount = 0, endedAt = sessionId
|
|
1646
|
+
try { stat = fs.statSync(full); size = stat.size; endedAt = stat.mtimeMs } catch {}
|
|
1647
|
+
// Cheap line count for event count (subtract 1 for meta line)
|
|
1648
|
+
try {
|
|
1649
|
+
const data = fs.readFileSync(full, 'utf8')
|
|
1650
|
+
eventCount = Math.max(0, data.split('\n').filter((l) => l.trim()).length - 1)
|
|
1651
|
+
} catch {}
|
|
1652
|
+
out.push({
|
|
1653
|
+
sessionId, startedAt: sessionId, endedAt,
|
|
1654
|
+
eventCount, size,
|
|
1655
|
+
isCurrent: sessionId === traceSessionId,
|
|
1656
|
+
})
|
|
1657
|
+
}
|
|
1658
|
+
return out.sort((a, b) => b.startedAt - a.startedAt)
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function readTraceSession(root, sessionId) {
|
|
1662
|
+
if (!root) return null
|
|
1663
|
+
const f = traceFileFor(root, sessionId)
|
|
1664
|
+
if (!fs.existsSync(f)) return null
|
|
1665
|
+
let meta = null
|
|
1666
|
+
const events = []
|
|
1667
|
+
try {
|
|
1668
|
+
const data = fs.readFileSync(f, 'utf8')
|
|
1669
|
+
for (const line of data.split('\n')) {
|
|
1670
|
+
if (!line.trim()) continue
|
|
1671
|
+
try {
|
|
1672
|
+
const j = JSON.parse(line)
|
|
1673
|
+
if (j.type === 'meta') meta = j
|
|
1674
|
+
else events.push(j)
|
|
1675
|
+
} catch {}
|
|
1676
|
+
}
|
|
1677
|
+
} catch { return null }
|
|
1678
|
+
return { sessionId, meta, events, eventCount: events.length }
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Compute stats over an event array. Used by /trace/stats and by the
|
|
1682
|
+
// session-detail endpoint.
|
|
1683
|
+
function computeTraceStats(events) {
|
|
1684
|
+
const byTool = {}
|
|
1685
|
+
const byFile = new Map()
|
|
1686
|
+
let firstAt = null, lastAt = null
|
|
1687
|
+
for (const e of events) {
|
|
1688
|
+
byTool[e.tool] = (byTool[e.tool] || 0) + 1
|
|
1689
|
+
byFile.set(e.id, (byFile.get(e.id) || 0) + 1)
|
|
1690
|
+
if (firstAt === null || e.ts < firstAt) firstAt = e.ts
|
|
1691
|
+
if (lastAt === null || e.ts > lastAt) lastAt = e.ts
|
|
1692
|
+
}
|
|
1693
|
+
const topFiles = [...byFile.entries()]
|
|
1694
|
+
.map(([id, count]) => ({ id, count }))
|
|
1695
|
+
.sort((a, b) => b.count - a.count)
|
|
1696
|
+
.slice(0, 20)
|
|
1697
|
+
// Time histogram — 20 buckets across [firstAt, lastAt]
|
|
1698
|
+
let timeline = []
|
|
1699
|
+
if (firstAt !== null && lastAt !== null && lastAt > firstAt) {
|
|
1700
|
+
const buckets = 20
|
|
1701
|
+
timeline = Array(buckets).fill(0)
|
|
1702
|
+
const span = lastAt - firstAt
|
|
1703
|
+
for (const e of events) {
|
|
1704
|
+
const idx = Math.min(buckets - 1, Math.floor((e.ts - firstAt) / span * buckets))
|
|
1705
|
+
timeline[idx]++
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
eventCount: events.length,
|
|
1710
|
+
fileCount: byFile.size,
|
|
1711
|
+
byTool, topFiles, timeline,
|
|
1712
|
+
firstAt, lastAt,
|
|
1713
|
+
durationMs: (firstAt && lastAt) ? lastAt - firstAt : 0,
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// ─── lib/control-server delegate (Stage 1+2 endpoints) ────────
|
|
1718
|
+
// Lazily create a control-server instance from the shared lib module.
|
|
1719
|
+
// Used as a fallthrough for new endpoints (safety/bundle/env/suggest/
|
|
1720
|
+
// feature/preflight/schema/url/secrets) so the desktop app exposes
|
|
1721
|
+
// them too, without duplicating their logic here. Existing endpoints
|
|
1722
|
+
// remain handled by this file's own router below — append-only.
|
|
1723
|
+
const { createControlServer: _libCreateControlServer } = require('../packages/core/lib/control-server.cjs')
|
|
1724
|
+
const { createLogger } = require('../packages/core/lib/logger.cjs')
|
|
1725
|
+
|
|
1726
|
+
// Structured logger for the main process. Writes NDJSON to
|
|
1727
|
+
// ~/.codesynapt/audit/main-YYYY-MM-DD.jsonl. Level info+ → file; warn+ → stderr.
|
|
1728
|
+
const _logFile = path.join(app.getPath('home'), '.codesynapt', 'audit',
|
|
1729
|
+
`main-${new Date().toISOString().slice(0,10)}.jsonl`)
|
|
1730
|
+
const log = createLogger({ file: _logFile, module: 'main', level: 'info', echoStderr: 'warn' })
|
|
1731
|
+
|
|
1732
|
+
// ─── Search worker (isolated from main event loop) ─────────────
|
|
1733
|
+
// Persistent worker (reused across searches) so its in-memory mtime cache
|
|
1734
|
+
// makes the 2nd+ search fast. The worker is rebuilt only when the scanner
|
|
1735
|
+
// is swapped (different project root). Files > 5 MB are pre-skipped to
|
|
1736
|
+
// avoid the libuv-thread-stall that previously plagued worker reuse.
|
|
1737
|
+
const { Worker } = require('worker_threads')
|
|
1738
|
+
let _searchWorker = null
|
|
1739
|
+
let _searchWorkerReady = false
|
|
1740
|
+
let _searchScannerRef = null
|
|
1741
|
+
let _searchInFlight = null // { reqId, resolve, reject, timer }
|
|
1742
|
+
let _searchReqCounter = 0
|
|
1743
|
+
|
|
1744
|
+
function _teardownSearchWorker() {
|
|
1745
|
+
if (_searchWorker) { try { _searchWorker.terminate() } catch {} }
|
|
1746
|
+
_searchWorker = null
|
|
1747
|
+
_searchWorkerReady = false
|
|
1748
|
+
if (_searchInFlight) {
|
|
1749
|
+
clearTimeout(_searchInFlight.timer)
|
|
1750
|
+
_searchInFlight.reject(new Error('worker recycled'))
|
|
1751
|
+
_searchInFlight = null
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function _ensureSearchWorker() {
|
|
1756
|
+
if (_searchWorker && _searchScannerRef === scanner) return _searchWorker
|
|
1757
|
+
_teardownSearchWorker()
|
|
1758
|
+
const workerPath = path.resolve(__dirname, '..', 'packages', 'core', 'lib', 'search-worker.cjs')
|
|
1759
|
+
const w = new Worker(workerPath)
|
|
1760
|
+
_searchScannerRef = scanner
|
|
1761
|
+
w.on('message', (msg) => {
|
|
1762
|
+
if (msg.type === 'ready') { _searchWorkerReady = true; return }
|
|
1763
|
+
if (!_searchInFlight) return
|
|
1764
|
+
const inflight = _searchInFlight
|
|
1765
|
+
_searchInFlight = null
|
|
1766
|
+
clearTimeout(inflight.timer)
|
|
1767
|
+
if (msg.type === 'result') inflight.resolve(msg.payload)
|
|
1768
|
+
else inflight.reject(new Error(msg.error || 'worker error'))
|
|
1769
|
+
})
|
|
1770
|
+
w.on('error', (e) => {
|
|
1771
|
+
if (_searchInFlight) { clearTimeout(_searchInFlight.timer); _searchInFlight.reject(e); _searchInFlight = null }
|
|
1772
|
+
_searchWorker = null
|
|
1773
|
+
_searchWorkerReady = false
|
|
1774
|
+
})
|
|
1775
|
+
w.on('exit', () => {
|
|
1776
|
+
_searchWorker = null
|
|
1777
|
+
_searchWorkerReady = false
|
|
1778
|
+
if (_searchInFlight) {
|
|
1779
|
+
clearTimeout(_searchInFlight.timer)
|
|
1780
|
+
_searchInFlight.reject(new Error('worker exited unexpectedly'))
|
|
1781
|
+
_searchInFlight = null
|
|
1782
|
+
}
|
|
1783
|
+
})
|
|
1784
|
+
// Keep worker's cache in sync with scanner
|
|
1785
|
+
if (scanner) {
|
|
1786
|
+
scanner.on('file-changed', ({ id }) => { try { w.postMessage({ type: 'invalidate', id }) } catch {} })
|
|
1787
|
+
scanner.on('file-removed', ({ id }) => { try { w.postMessage({ type: 'invalidate', id }) } catch {} })
|
|
1788
|
+
}
|
|
1789
|
+
_searchWorker = w
|
|
1790
|
+
return w
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function _searchInWorker(opts, timeoutMs = 60000) {
|
|
1794
|
+
return new Promise((resolve, reject) => {
|
|
1795
|
+
if (_searchInFlight) return reject(new Error('worker busy — another search is in flight'))
|
|
1796
|
+
if (!scanner) return reject(new Error('scanner not ready'))
|
|
1797
|
+
const w = _ensureSearchWorker()
|
|
1798
|
+
const reqId = ++_searchReqCounter
|
|
1799
|
+
const files = [...scanner.files.values()].map((f) => ({ id: f.id, absPath: f.absPath }))
|
|
1800
|
+
const timer = setTimeout(() => {
|
|
1801
|
+
if (!_searchInFlight || _searchInFlight.reqId !== reqId) return
|
|
1802
|
+
_searchInFlight = null
|
|
1803
|
+
// Worker may be stuck — recycle so future requests are clean
|
|
1804
|
+
_teardownSearchWorker()
|
|
1805
|
+
reject(new Error(`worker timeout ${timeoutMs}ms`))
|
|
1806
|
+
}, timeoutMs)
|
|
1807
|
+
_searchInFlight = { reqId, resolve, reject, timer }
|
|
1808
|
+
// If worker hasn't fired 'ready' yet, queue a one-shot send
|
|
1809
|
+
if (_searchWorkerReady) {
|
|
1810
|
+
w.postMessage({ type: 'search', id: reqId, files, ...opts })
|
|
1811
|
+
} else {
|
|
1812
|
+
const onReady = (msg) => {
|
|
1813
|
+
if (msg.type !== 'ready') return
|
|
1814
|
+
w.off('message', onReady)
|
|
1815
|
+
w.postMessage({ type: 'search', id: reqId, files, ...opts })
|
|
1816
|
+
}
|
|
1817
|
+
w.on('message', onReady)
|
|
1818
|
+
}
|
|
1819
|
+
})
|
|
1820
|
+
}
|
|
1821
|
+
let _libControlHandler = null
|
|
1822
|
+
let _libScannerRef = null // invalidate when scanner is swapped
|
|
1823
|
+
function _ensureLibHandler() {
|
|
1824
|
+
if (!scanner) return null
|
|
1825
|
+
if (_libControlHandler && _libScannerRef === scanner) return _libControlHandler
|
|
1826
|
+
_libScannerRef = scanner
|
|
1827
|
+
const lib = _libCreateControlServer({
|
|
1828
|
+
scanner,
|
|
1829
|
+
getCurrentRoot: () => currentRoot,
|
|
1830
|
+
onBlast: (p) => mainWindow?.webContents.send('control:blast', p),
|
|
1831
|
+
onFocus: (id) => mainWindow?.webContents.send('control:focus', { id }),
|
|
1832
|
+
onOpen: (id) => mainWindow?.webContents.send('control:open', { id }),
|
|
1833
|
+
authToken: process.env.CS_AUTH_TOKEN || null,
|
|
1834
|
+
auditLogDir: path.join(app.getPath('home'), '.codesynapt', 'audit'),
|
|
1835
|
+
})
|
|
1836
|
+
_libControlHandler = lib.handleControlRequest
|
|
1837
|
+
return _libControlHandler
|
|
1838
|
+
}
|
|
1839
|
+
const _LIB_ENDPOINTS = new Set([
|
|
1840
|
+
'safety', 'bundle', 'env', 'suggest', 'feature', 'preflight',
|
|
1841
|
+
'schema', 'url', 'secrets',
|
|
1842
|
+
])
|
|
1843
|
+
|
|
1844
|
+
async function handleControlRequest(req, res) {
|
|
1845
|
+
// DNS-rebinding defense: reject Host headers that aren't loopback.
|
|
1846
|
+
const hostHeader = (req.headers.host || '').split(':')[0].toLowerCase()
|
|
1847
|
+
if (hostHeader !== '127.0.0.1' && hostHeader !== 'localhost' && hostHeader !== '[::1]') {
|
|
1848
|
+
return writeJson(res, 403, { error: 'forbidden host: ' + hostHeader })
|
|
1849
|
+
}
|
|
1850
|
+
if (req.method === 'OPTIONS') {
|
|
1851
|
+
res.writeHead(204, {
|
|
1852
|
+
'Access-Control-Allow-Origin': 'null',
|
|
1853
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1854
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
1855
|
+
})
|
|
1856
|
+
return res.end()
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
1860
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
1861
|
+
const [seg0, ...rest] = parts
|
|
1862
|
+
const idFromRest = () => decodeURIComponent(rest.join('/'))
|
|
1863
|
+
|
|
1864
|
+
// Delegate new endpoints to lib/control-server (Stage 1+2 features)
|
|
1865
|
+
if (_LIB_ENDPOINTS.has(seg0)) {
|
|
1866
|
+
const lib = _ensureLibHandler()
|
|
1867
|
+
if (lib) return lib(req, res)
|
|
1868
|
+
// If scanner isn't ready yet, fall through to 503 below
|
|
1869
|
+
}
|
|
1870
|
+
const traceId = () => { const id = idFromRest(); emitTrace(seg0, id); return id }
|
|
1871
|
+
|
|
1872
|
+
try {
|
|
1873
|
+
if (req.method === 'GET' && parts.length === 0) {
|
|
1874
|
+
return writeJson(res, 200, {
|
|
1875
|
+
name: 'codesynapt',
|
|
1876
|
+
endpoints: [
|
|
1877
|
+
'GET /health', 'GET /graph', 'GET /node/:id', 'GET /file/:id',
|
|
1878
|
+
'GET /deps/:id', 'GET /users/:id', 'GET /find?q=', 'GET /history/:id',
|
|
1879
|
+
'GET /packages', 'GET /package/:name', 'GET /package-graph',
|
|
1880
|
+
'GET /legacy?type=orphan|path|filename|duplicate',
|
|
1881
|
+
'GET /trace', 'GET /trace/stats', 'GET /trace/sessions', 'GET /trace/session/:id',
|
|
1882
|
+
'POST /trace/clear', 'POST /trace/export?path=',
|
|
1883
|
+
'POST /focus/:id', 'POST /open/:id', 'POST /restore/:id?ts=',
|
|
1884
|
+
'POST /write/:id', 'POST /edit/:id',
|
|
1885
|
+
],
|
|
1886
|
+
})
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (req.method === 'GET' && seg0 === 'health') {
|
|
1890
|
+
// Edge count is split into code-structure edges and asset edges
|
|
1891
|
+
// (HTML→image/script/style links), so a docs-heavy repo can't
|
|
1892
|
+
// make health stats lie about the code graph size.
|
|
1893
|
+
let codeEdges = 0, assetEdges = 0
|
|
1894
|
+
if (scanner) for (const e of scanner.edges) {
|
|
1895
|
+
if (e.kind === 'asset' || e.k === 'asset') assetEdges++
|
|
1896
|
+
else codeEdges++
|
|
1897
|
+
}
|
|
1898
|
+
return writeJson(res, 200, {
|
|
1899
|
+
ok: true,
|
|
1900
|
+
root: currentRoot,
|
|
1901
|
+
fileCount: scanner ? scanner.files.size : 0,
|
|
1902
|
+
edgeCount: codeEdges,
|
|
1903
|
+
assetEdgeCount: assetEdges,
|
|
1904
|
+
historyEnabled,
|
|
1905
|
+
})
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
if (!scanner) {
|
|
1909
|
+
// POST /load boots the scanner from scratch — let it through
|
|
1910
|
+
// the 503 gate. Headless instances (CS_HEADLESS=1) never call
|
|
1911
|
+
// startScanner via did-finish-load, so /load is the only path
|
|
1912
|
+
// to get a scanner running.
|
|
1913
|
+
if (!(req.method === 'POST' && seg0 === 'load')) {
|
|
1914
|
+
return writeJson(res, 503, { error: 'no folder loaded' })
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// ─── Symbol mode (codegraph-equivalent layer) ────────────────
|
|
1919
|
+
// First call builds the symbol graph against the current file set;
|
|
1920
|
+
// subsequent calls hit the in-memory cache. POST /symbol/scan forces
|
|
1921
|
+
// a rebuild even if one exists.
|
|
1922
|
+
if (seg0 === 'symbol') {
|
|
1923
|
+
const sub = rest[0] || '' // 'summary' | 'find' | 'callers' | …
|
|
1924
|
+
// Ensure the symbol graph is built before serving any query.
|
|
1925
|
+
const forceRebuild = (req.method === 'POST' && sub === 'scan')
|
|
1926
|
+
if (forceRebuild || !symbolGraph) {
|
|
1927
|
+
if (!_symbolBuilding) {
|
|
1928
|
+
_symbolBuilding = (async () => {
|
|
1929
|
+
// Optional disk cache for big projects. Opt-in via env var
|
|
1930
|
+
// CS_SYMBOL_CACHE=1; defaults off so the in-memory speed
|
|
1931
|
+
// moat stays the default. Cache key is sha-of-root-path +
|
|
1932
|
+
// newest-file-mtime. If anything in the repo has changed
|
|
1933
|
+
// since the cache was written, we rebuild.
|
|
1934
|
+
const cacheEnabled = !forceRebuild && process.env.CS_SYMBOL_CACHE === '1'
|
|
1935
|
+
const cacheDir = path.join(os.homedir(), '.codesynapt', 'symbol-cache')
|
|
1936
|
+
const cacheKey = require('crypto').createHash('sha1').update(currentRoot || '').digest('hex')
|
|
1937
|
+
const cachePath = path.join(cacheDir, cacheKey + '.json')
|
|
1938
|
+
if (cacheEnabled) {
|
|
1939
|
+
try {
|
|
1940
|
+
if (fs.existsSync(cachePath)) {
|
|
1941
|
+
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
|
|
1942
|
+
// Newest mtime in the file set must be older than the
|
|
1943
|
+
// cache mtime for it to be valid.
|
|
1944
|
+
let newest = 0
|
|
1945
|
+
for (const f of scanner.files.values()) {
|
|
1946
|
+
try { const t = fs.statSync(f.absPath).mtimeMs; if (t > newest) newest = t } catch {}
|
|
1947
|
+
}
|
|
1948
|
+
const cacheMtime = fs.statSync(cachePath).mtimeMs
|
|
1949
|
+
if (newest > 0 && cacheMtime >= newest) {
|
|
1950
|
+
const g = new SymbolGraph()
|
|
1951
|
+
for (const n of cached.nodes) g.addNode(n)
|
|
1952
|
+
for (const e of cached.edges) g.addEdge(e)
|
|
1953
|
+
g.fileCount = cached.fileCount
|
|
1954
|
+
g.builtAt = cached.builtAt
|
|
1955
|
+
g.scanMs = 0
|
|
1956
|
+
try { g.computeReachability(isPublicEntry) } catch {}
|
|
1957
|
+
symbolGraph = g
|
|
1958
|
+
_symbolBuilding = null
|
|
1959
|
+
return g
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
} catch {}
|
|
1963
|
+
}
|
|
1964
|
+
const g = new SymbolGraph()
|
|
1965
|
+
const entries = [...scanner.files.values()]
|
|
1966
|
+
.filter((f) => f.absPath && f.ext)
|
|
1967
|
+
.map((f) => ({ id: f.id, absPath: f.absPath, ext: f.ext }))
|
|
1968
|
+
// tsc mode: build the TS Program once over every JS/TS
|
|
1969
|
+
// file in the project before the per-file parser loop
|
|
1970
|
+
// starts. Cached by rootAbs; cleared on project swap.
|
|
1971
|
+
// If loadProgramFor refuses (file count above the memory
|
|
1972
|
+
// cap, or typescript not installed), we re-register the
|
|
1973
|
+
// babel parser so .ts/.tsx files still get symbols.
|
|
1974
|
+
if (SYMBOL_PARSER_MODE === 'tsc' && _tscParserModule?.isAvailable?.()) {
|
|
1975
|
+
try {
|
|
1976
|
+
const prog = _tscParserModule.loadProgramFor(currentRoot, entries.map((e) => e.id))
|
|
1977
|
+
if (!prog) {
|
|
1978
|
+
console.warn('[symbol] tsc Program refused — falling back to babel for ts/tsx/js/jsx')
|
|
1979
|
+
for (const ext of ['ts','tsx','js','jsx','mjs','cjs']) {
|
|
1980
|
+
registerParser([ext], jsSymbolParser)
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
} catch (e) {
|
|
1984
|
+
console.error('[symbol] tsc Program init failed:', e.message)
|
|
1985
|
+
for (const ext of ['ts','tsx','js','jsx','mjs','cjs']) {
|
|
1986
|
+
registerParser([ext], jsSymbolParser)
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
// Feed file-mode imports to the symbol graph so call
|
|
1991
|
+
// resolution can prefer targets in files the caller
|
|
1992
|
+
// actually imports (Phase 2-B cross-file resolver).
|
|
1993
|
+
// Asset edges (HTML→jQuery etc) aren't real imports.
|
|
1994
|
+
const fileImports = new Map()
|
|
1995
|
+
const reexports = new Map()
|
|
1996
|
+
for (const e of scanner.edges) {
|
|
1997
|
+
const kind = e.k || e.kind
|
|
1998
|
+
if (kind === 'asset') continue
|
|
1999
|
+
if (!fileImports.has(e.s)) fileImports.set(e.s, new Set())
|
|
2000
|
+
fileImports.get(e.s).add(e.t)
|
|
2001
|
+
if (kind === 'reexport') {
|
|
2002
|
+
if (!reexports.has(e.s)) reexports.set(e.s, new Set())
|
|
2003
|
+
reexports.get(e.s).add(e.t)
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
// Re-export chain: `import { X } from './barrel'` where
|
|
2007
|
+
// barrel does `export * from './foo'` should resolve X
|
|
2008
|
+
// against foo too. BFS-expand each file's imports through
|
|
2009
|
+
// the re-export graph so chains of any depth are reachable.
|
|
2010
|
+
const reachCache = new Map()
|
|
2011
|
+
function reExportReach(start) {
|
|
2012
|
+
if (reachCache.has(start)) return reachCache.get(start)
|
|
2013
|
+
const out = new Set()
|
|
2014
|
+
const stack = [start]
|
|
2015
|
+
const visited = new Set()
|
|
2016
|
+
while (stack.length) {
|
|
2017
|
+
const cur = stack.pop()
|
|
2018
|
+
if (visited.has(cur)) continue
|
|
2019
|
+
visited.add(cur)
|
|
2020
|
+
out.add(cur)
|
|
2021
|
+
const next = reexports.get(cur)
|
|
2022
|
+
if (next) for (const n of next) stack.push(n)
|
|
2023
|
+
}
|
|
2024
|
+
reachCache.set(start, out)
|
|
2025
|
+
return out
|
|
2026
|
+
}
|
|
2027
|
+
for (const [src, set] of fileImports) {
|
|
2028
|
+
for (const target of [...set]) {
|
|
2029
|
+
const reach = reExportReach(target)
|
|
2030
|
+
for (const r of reach) set.add(r)
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
await g.build(entries, fileImports)
|
|
2034
|
+
// ─── DB schema models → symbol nodes ─────────────────
|
|
2035
|
+
// file-mode already extracts Prisma / Drizzle / Mongoose /
|
|
2036
|
+
// TypeORM / SQLAlchemy model declarations. We register
|
|
2037
|
+
// each model name as a `model` symbol so the normal
|
|
2038
|
+
// resolver picks up `prisma.user.findMany()`,
|
|
2039
|
+
// `db.User.create(...)`, etc., and surfaces them in
|
|
2040
|
+
// `cs_symbol_explore` answers.
|
|
2041
|
+
for (const f of scanner.files.values()) {
|
|
2042
|
+
if (!f.dbModels?.length) continue
|
|
2043
|
+
for (const model of f.dbModels) {
|
|
2044
|
+
const id = `${f.id}#${model.name}@0`
|
|
2045
|
+
if (g.nodes.has(id)) continue
|
|
2046
|
+
g.addNode({
|
|
2047
|
+
id,
|
|
2048
|
+
name: model.name,
|
|
2049
|
+
qualifiedName: model.name,
|
|
2050
|
+
kind: 'model',
|
|
2051
|
+
file: f.id,
|
|
2052
|
+
startLine: 1,
|
|
2053
|
+
endLine: 1,
|
|
2054
|
+
signature: `${model.kind} ${model.name}`
|
|
2055
|
+
+ (model.tableName ? ` (table=${model.tableName})` : ''),
|
|
2056
|
+
doc: (model.fields || []).map((fld) => fld.name).join(', '),
|
|
2057
|
+
exported: true,
|
|
2058
|
+
})
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
// ─── Test ↔ source pairing ───────────────────────────
|
|
2062
|
+
// Detect common test-file naming conventions and emit
|
|
2063
|
+
// `tests` edges from test symbols to their target source
|
|
2064
|
+
// symbol (same name, or whose name is a substring).
|
|
2065
|
+
const findSourcePair = (fileId) => {
|
|
2066
|
+
let m
|
|
2067
|
+
// foo.test.ts → foo.ts / foo.spec.tsx → foo.tsx
|
|
2068
|
+
if ((m = fileId.match(/^(.*)\.(test|spec)\.(\w+)$/))) {
|
|
2069
|
+
return m[1] + '.' + m[3]
|
|
2070
|
+
}
|
|
2071
|
+
// src/foo/__tests__/Bar.test.ts → src/foo/Bar.ts
|
|
2072
|
+
if ((m = fileId.match(/^(.*?)\/__tests__\/(.+?)\.(test|spec)\.(\w+)$/))) {
|
|
2073
|
+
return m[1] + '/' + m[2] + '.' + m[4]
|
|
2074
|
+
}
|
|
2075
|
+
// tests/path/Bar.test.ts → src/path/Bar.ts (best-effort)
|
|
2076
|
+
if ((m = fileId.match(/^tests?\/(.+?)\.(test|spec)\.(\w+)$/))) {
|
|
2077
|
+
const candidate = 'src/' + m[1] + '.' + m[3]
|
|
2078
|
+
return candidate
|
|
2079
|
+
}
|
|
2080
|
+
// Python: tests/test_foo.py → foo.py / src/foo.py
|
|
2081
|
+
if ((m = fileId.match(/^(.*?\/)?tests?\/test_(.+)\.py$/))) {
|
|
2082
|
+
return (m[1] || '') + m[2] + '.py'
|
|
2083
|
+
}
|
|
2084
|
+
if ((m = fileId.match(/^(.*\/)?test_(.+)\.py$/))) {
|
|
2085
|
+
return (m[1] || '') + m[2] + '.py'
|
|
2086
|
+
}
|
|
2087
|
+
// Go: foo_test.go → foo.go
|
|
2088
|
+
if ((m = fileId.match(/^(.+)_test\.go$/))) return m[1] + '.go'
|
|
2089
|
+
// Rust: tests/foo.rs → src/foo.rs
|
|
2090
|
+
if ((m = fileId.match(/^tests\/(.+)\.rs$/))) return 'src/' + m[1] + '.rs'
|
|
2091
|
+
return null
|
|
2092
|
+
}
|
|
2093
|
+
for (const f of scanner.files.values()) {
|
|
2094
|
+
const srcFileId = findSourcePair(f.id)
|
|
2095
|
+
if (!srcFileId || !scanner.files.has(srcFileId)) continue
|
|
2096
|
+
const testSyms = g.byFile.get(f.id)
|
|
2097
|
+
const srcSyms = g.byFile.get(srcFileId)
|
|
2098
|
+
if (!testSyms || !srcSyms) continue
|
|
2099
|
+
for (const testId of testSyms) {
|
|
2100
|
+
const testSym = g.nodes.get(testId)
|
|
2101
|
+
if (!testSym) continue
|
|
2102
|
+
const tn = testSym.name.toLowerCase()
|
|
2103
|
+
for (const srcId of srcSyms) {
|
|
2104
|
+
const srcSym = g.nodes.get(srcId)
|
|
2105
|
+
if (!srcSym) continue
|
|
2106
|
+
const sn = srcSym.name.toLowerCase()
|
|
2107
|
+
// Exact match wins; otherwise substring with a length
|
|
2108
|
+
// floor to avoid `t` / `it` matching everything.
|
|
2109
|
+
if (tn === sn || (sn.length >= 4 && tn.includes(sn))) {
|
|
2110
|
+
g.addEdge({
|
|
2111
|
+
source: testId, target: srcId, kind: 'tests',
|
|
2112
|
+
line: testSym.startLine,
|
|
2113
|
+
})
|
|
2114
|
+
break // one source-symbol target per test symbol is enough
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
// ─── Persist symbol graph to cache (opt-in) ──────────
|
|
2120
|
+
if (cacheEnabled) {
|
|
2121
|
+
try {
|
|
2122
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
2123
|
+
const payload = {
|
|
2124
|
+
nodes: [...g.nodes.values()],
|
|
2125
|
+
edges: g.edges,
|
|
2126
|
+
fileCount: g.fileCount,
|
|
2127
|
+
builtAt: g.builtAt,
|
|
2128
|
+
}
|
|
2129
|
+
fs.writeFileSync(cachePath, JSON.stringify(payload))
|
|
2130
|
+
} catch {}
|
|
2131
|
+
}
|
|
2132
|
+
// ─── Route → handler edges ───────────────────────────
|
|
2133
|
+
// Walk every file's `routes` list (extracted by file
|
|
2134
|
+
// mode) and link the route to the named handler symbol.
|
|
2135
|
+
// codegraph doesn't do this — its index is method/class
|
|
2136
|
+
// only, with no notion of HTTP path. Ours does.
|
|
2137
|
+
for (const f of scanner.files.values()) {
|
|
2138
|
+
if (!f.routes?.length) continue
|
|
2139
|
+
for (const route of f.routes) {
|
|
2140
|
+
if (!route.handler) continue
|
|
2141
|
+
const handlerNode = g.resolveCall(f.id, route.handler, { allowAny: true })
|
|
2142
|
+
if (!handlerNode) continue
|
|
2143
|
+
g.addEdge({
|
|
2144
|
+
source: 'route:' + (route.method || 'ANY') + ' ' + route.path,
|
|
2145
|
+
target: handlerNode.id,
|
|
2146
|
+
kind: 'route',
|
|
2147
|
+
line: 0,
|
|
2148
|
+
meta: { method: route.method, path: route.path, definedIn: f.id },
|
|
2149
|
+
})
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
// Reachability pass — BFS from every detected public
|
|
2153
|
+
// entry. Lets explore tag `unreachable` results as a
|
|
2154
|
+
// weak dead-code hint without paying the BFS cost per
|
|
2155
|
+
// query. Cheap: O(V+E) over the symbol graph.
|
|
2156
|
+
try { g.computeReachability(isPublicEntry) } catch (e) {
|
|
2157
|
+
console.warn('[symbol] reachability pass failed:', e.message)
|
|
2158
|
+
}
|
|
2159
|
+
// Semantic embedding pass — fired without await. The
|
|
2160
|
+
// build returns immediately; embeddings populate in the
|
|
2161
|
+
// background. /symbol/explore checks `g._embedded`
|
|
2162
|
+
// before reranking, so early queries still get fast
|
|
2163
|
+
// keyword-only answers, and later queries get the
|
|
2164
|
+
// semantic upgrade once the index finishes (~1 ms per
|
|
2165
|
+
// symbol on MiniLM-L6, so ~10 s on a 10k-symbol repo
|
|
2166
|
+
// and ~45 s on django's 43k).
|
|
2167
|
+
//
|
|
2168
|
+
// Opt-out via CS_EMBEDDING=0 for users on memory-tight
|
|
2169
|
+
// boxes (the index adds ~200 MB to RSS).
|
|
2170
|
+
if (process.env.CS_EMBEDDING !== '0') {
|
|
2171
|
+
const embedding = require('../packages/core/lib/embedding.cjs')
|
|
2172
|
+
g.embedAllSymbols(embedding.embedBatch).then((ok) => {
|
|
2173
|
+
if (ok) console.log(`[symbol] embeddings ready: ${g.nodes.size} symbols in ${g.embedMs}ms`)
|
|
2174
|
+
}).catch((e) => {
|
|
2175
|
+
console.warn('[symbol] embedding pass failed:', e.message)
|
|
2176
|
+
})
|
|
2177
|
+
}
|
|
2178
|
+
symbolGraph = g
|
|
2179
|
+
_symbolBuilding = null
|
|
2180
|
+
return g
|
|
2181
|
+
})()
|
|
2182
|
+
}
|
|
2183
|
+
await _symbolBuilding
|
|
2184
|
+
}
|
|
2185
|
+
const g = symbolGraph
|
|
2186
|
+
|
|
2187
|
+
if (req.method === 'GET' && (sub === '' || sub === 'summary')) {
|
|
2188
|
+
return writeJson(res, 200, withMeta(g.stats()))
|
|
2189
|
+
}
|
|
2190
|
+
if (req.method === 'GET' && sub === 'find') {
|
|
2191
|
+
const q = url.searchParams.get('q') || ''
|
|
2192
|
+
const limit = Math.min(200, parseInt(url.searchParams.get('limit') || '50', 10))
|
|
2193
|
+
const matches = g.findByName(q, limit)
|
|
2194
|
+
return writeJson(res, 200, withMeta({ query: q, matches }))
|
|
2195
|
+
}
|
|
2196
|
+
if (req.method === 'GET' && sub === 'node' && rest[1]) {
|
|
2197
|
+
const id = decodeURIComponent(rest.slice(1).join('/'))
|
|
2198
|
+
const node = g.nodes.get(id)
|
|
2199
|
+
if (!node) return writeJson(res, 404, { error: 'symbol not found', id })
|
|
2200
|
+
// Pull source between startLine and endLine
|
|
2201
|
+
let source = ''
|
|
2202
|
+
try {
|
|
2203
|
+
const filePath = path.join(currentRoot, node.file)
|
|
2204
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
2205
|
+
source = lines.slice(node.startLine - 1, node.endLine).join('\n')
|
|
2206
|
+
if (source.length > 4000) source = source.slice(0, 4000) + '\n…'
|
|
2207
|
+
} catch {}
|
|
2208
|
+
return writeJson(res, 200, withMeta({ ...node, source }))
|
|
2209
|
+
}
|
|
2210
|
+
if (req.method === 'GET' && sub === 'callers' && rest[1]) {
|
|
2211
|
+
const id = decodeURIComponent(rest.slice(1).join('/'))
|
|
2212
|
+
return writeJson(res, 200, withMeta({ id, callers: g.callersOf(id) }))
|
|
2213
|
+
}
|
|
2214
|
+
if (req.method === 'GET' && sub === 'callees' && rest[1]) {
|
|
2215
|
+
const id = decodeURIComponent(rest.slice(1).join('/'))
|
|
2216
|
+
return writeJson(res, 200, withMeta({ id, callees: g.calleesOf(id) }))
|
|
2217
|
+
}
|
|
2218
|
+
if (req.method === 'GET' && sub === 'explore') {
|
|
2219
|
+
const q = url.searchParams.get('q') || ''
|
|
2220
|
+
const budget = parseInt(url.searchParams.get('budget') || '8000', 10)
|
|
2221
|
+
// /symbol/explore now returns a single response shape: the
|
|
2222
|
+
// classify (grouped) one. The older modes — default (ranked
|
|
2223
|
+
// list), structural, behavioral, dataflow — were retired
|
|
2224
|
+
// after a 99-measurement bench found zero scenarios where
|
|
2225
|
+
// they out-performed classify. Clients that still send a
|
|
2226
|
+
// `mode=...` parameter get a classify response with a `note`
|
|
2227
|
+
// so they can update without breaking.
|
|
2228
|
+
const requestedMode = (url.searchParams.get('mode') || 'classify').toLowerCase()
|
|
2229
|
+
const payload = await buildClassifyResponse(g, q, budget)
|
|
2230
|
+
if (requestedMode !== 'classify') {
|
|
2231
|
+
payload.note = `mode "${requestedMode}" is no longer supported — returning classify shape`
|
|
2232
|
+
}
|
|
2233
|
+
return writeJson(res, 200, withMeta(payload))
|
|
2234
|
+
}
|
|
2235
|
+
if (req.method === 'POST' && sub === 'scan') {
|
|
2236
|
+
return writeJson(res, 200, withMeta(g.stats()))
|
|
2237
|
+
}
|
|
2238
|
+
return writeJson(res, 404, { error: 'unknown symbol endpoint', path: url.pathname })
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
if (req.method === 'GET' && seg0 === 'summary') {
|
|
2242
|
+
const s = buildSummaryCached()
|
|
2243
|
+
if (!s) return writeJson(res, 503, { error: 'no folder loaded' })
|
|
2244
|
+
return writeJson(res, 200, withMeta(s))
|
|
2245
|
+
}
|
|
2246
|
+
if (req.method === 'GET' && seg0 === 'graph') {
|
|
2247
|
+
// filter → sort → paginate. Default sort is mass:desc so a bare
|
|
2248
|
+
// `limit=N` returns the N most-imported files (genuinely useful),
|
|
2249
|
+
// not insertion-order garbage. Pass sort=insertion to opt out.
|
|
2250
|
+
const data = getGraphState()
|
|
2251
|
+
const limit = parseInt(url.searchParams.get('limit') || '0', 10)
|
|
2252
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
|
2253
|
+
const extFilter = url.searchParams.get('ext')
|
|
2254
|
+
const minMass = parseInt(url.searchParams.get('minMass') || '0', 10)
|
|
2255
|
+
const sort = url.searchParams.get('sort') || 'mass:desc'
|
|
2256
|
+
|
|
2257
|
+
// Incoming map (computed once if mass involved in filter or sort)
|
|
2258
|
+
let inc = null
|
|
2259
|
+
const needsInc = sort.startsWith('mass') || minMass > 0
|
|
2260
|
+
if (needsInc) {
|
|
2261
|
+
inc = new Map()
|
|
2262
|
+
for (const e of scanner.edges) inc.set(e.t, (inc.get(e.t) || 0) + 1)
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
let files = data.files.slice()
|
|
2266
|
+
if (extFilter) files = files.filter((f) => f.ext === extFilter)
|
|
2267
|
+
if (minMass > 0) files = files.filter((f) => (inc.get(f.id) || 0) >= minMass)
|
|
2268
|
+
|
|
2269
|
+
// Sort
|
|
2270
|
+
if (sort !== 'insertion') {
|
|
2271
|
+
const [key, dirRaw] = sort.split(':')
|
|
2272
|
+
const dir = dirRaw === 'asc' ? 1 : -1
|
|
2273
|
+
const getter = key === 'mass' ? ((f) => inc.get(f.id) || 0)
|
|
2274
|
+
: key === 'size' ? ((f) => f.size)
|
|
2275
|
+
: key === 'loc' ? ((f) => f.loc)
|
|
2276
|
+
: key === 'id' ? null
|
|
2277
|
+
: null
|
|
2278
|
+
if (getter) files.sort((a, b) => dir * (getter(a) - getter(b)))
|
|
2279
|
+
else if (key === 'id') files.sort((a, b) => dir * a.id.localeCompare(b.id))
|
|
2280
|
+
// else: unknown sort key — silently keep filter order
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
const totalAvailable = files.length
|
|
2284
|
+
const sliced = limit > 0 ? files.slice(offset, offset + limit) : files
|
|
2285
|
+
return writeJson(res, 200, withMeta(
|
|
2286
|
+
{ root: data.root, files: sliced, edges: data.edges },
|
|
2287
|
+
{ totalAvailable, returned: sliced.length, offset, limit: limit || sliced.length,
|
|
2288
|
+
sort, truncated: limit > 0 && (offset + limit) < totalAvailable }
|
|
2289
|
+
))
|
|
2290
|
+
}
|
|
2291
|
+
if (req.method === 'GET' && seg0 === 'node' && rest.length > 0) {
|
|
2292
|
+
const id = traceId()
|
|
2293
|
+
const node = findNode(id)
|
|
2294
|
+
if (!node) return writeJson(res, 404, { error: 'not found' })
|
|
2295
|
+
return writeJson(res, 200, withMeta({
|
|
2296
|
+
...node,
|
|
2297
|
+
imports: getDeps(id),
|
|
2298
|
+
importedBy: getUsers(id),
|
|
2299
|
+
}))
|
|
2300
|
+
}
|
|
2301
|
+
if (req.method === 'POST' && seg0 === 'refresh') {
|
|
2302
|
+
if (rest.length === 0) return writeJson(res, 400, { error: 'usage: POST /refresh/:id' })
|
|
2303
|
+
const id = idFromRest()
|
|
2304
|
+
try {
|
|
2305
|
+
const absPath = path.join(currentRoot, id)
|
|
2306
|
+
if (!isInsideRoot(currentRoot, absPath)) return writeJson(res, 400, { error: 'outside root' })
|
|
2307
|
+
if (!fs.existsSync(absPath)) {
|
|
2308
|
+
// File deleted — drop from graph
|
|
2309
|
+
if (scanner.files.delete(id)) { scanner.rebuildEdges(); scanner.emitSnapshot() }
|
|
2310
|
+
return writeJson(res, 200, withMeta({ ok: true, action: 'removed', id }))
|
|
2311
|
+
}
|
|
2312
|
+
// Force re-parse via scanner internals
|
|
2313
|
+
const file = scanner.parseOne(absPath)
|
|
2314
|
+
if (!file) return writeJson(res, 500, { error: 'parse failed' })
|
|
2315
|
+
scanner.files.set(file.id, file)
|
|
2316
|
+
scanner.rebuildEdges()
|
|
2317
|
+
scanner.emitSnapshot()
|
|
2318
|
+
return writeJson(res, 200, withMeta({ ok: true, action: 'refreshed', id }))
|
|
2319
|
+
} catch (e) { return writeJson(res, 500, { error: e.message }) }
|
|
2320
|
+
}
|
|
2321
|
+
if (req.method === 'GET' && seg0 === 'file' && rest.length > 0) {
|
|
2322
|
+
const id = traceId()
|
|
2323
|
+
const full = path.join(currentRoot, id)
|
|
2324
|
+
if (!isInsideRoot(currentRoot, full)) return writeJson(res, 400, { error: 'outside root' })
|
|
2325
|
+
try {
|
|
2326
|
+
const stat = fs.statSync(full)
|
|
2327
|
+
if (!stat.isFile()) return writeJson(res, 404, { error: 'not a file' })
|
|
2328
|
+
if (stat.size > 2_000_000) return writeJson(res, 413, { error: 'file too large', size: stat.size })
|
|
2329
|
+
return writeJson(res, 200, { id, content: fs.readFileSync(full, 'utf8') })
|
|
2330
|
+
} catch (e) { return writeJson(res, 500, { error: e.message }) }
|
|
2331
|
+
}
|
|
2332
|
+
if (req.method === 'GET' && seg0 === 'deps' && rest.length > 0) {
|
|
2333
|
+
return writeJson(res, 200, getDeps(traceId()))
|
|
2334
|
+
}
|
|
2335
|
+
if (req.method === 'GET' && seg0 === 'users' && rest.length > 0) {
|
|
2336
|
+
return writeJson(res, 200, getUsers(traceId()))
|
|
2337
|
+
}
|
|
2338
|
+
if (req.method === 'GET' && seg0 === 'find') {
|
|
2339
|
+
return writeJson(res, 200, searchFiles(url.searchParams.get('q') || ''))
|
|
2340
|
+
}
|
|
2341
|
+
if (req.method === 'GET' && seg0 === 'search') {
|
|
2342
|
+
// Full-text search across all tracked files (content scan).
|
|
2343
|
+
// Different from /find which only matches file IDs.
|
|
2344
|
+
// Runs in a worker_thread isolated from this event loop.
|
|
2345
|
+
const q = url.searchParams.get('q')
|
|
2346
|
+
if (!q) return writeJson(res, 400, { error: 'q (query) is required' })
|
|
2347
|
+
if (!scanner || !scanner.initialScanComplete) {
|
|
2348
|
+
const fileCount = scanner ? scanner.files.size : 0
|
|
2349
|
+
return writeJson(res, 503, {
|
|
2350
|
+
error: 'scan in progress',
|
|
2351
|
+
fileCount,
|
|
2352
|
+
retryAfterMs: 2000,
|
|
2353
|
+
hint: 'Initial scan still running. Try again in a couple of seconds; /health will keep increasing fileCount.',
|
|
2354
|
+
})
|
|
2355
|
+
}
|
|
2356
|
+
;(async () => {
|
|
2357
|
+
try {
|
|
2358
|
+
const result = await _searchInWorker({
|
|
2359
|
+
q,
|
|
2360
|
+
regex: url.searchParams.get('regex') === '1' || url.searchParams.get('regex') === 'true',
|
|
2361
|
+
caseSensitive: url.searchParams.get('case') === '1' || url.searchParams.get('case') === 'true',
|
|
2362
|
+
max: parseInt(url.searchParams.get('max') || '100', 10),
|
|
2363
|
+
maxPerFile: parseInt(url.searchParams.get('maxPerFile') || '10', 10),
|
|
2364
|
+
})
|
|
2365
|
+
writeJson(res, 200, withMeta(result))
|
|
2366
|
+
} catch (e) {
|
|
2367
|
+
const msg = e.message || String(e)
|
|
2368
|
+
if (msg.includes('busy')) return writeJson(res, 503, { error: msg, retryAfterMs: 1000 })
|
|
2369
|
+
writeJson(res, 500, { error: msg })
|
|
2370
|
+
}
|
|
2371
|
+
})().catch((e) => writeJson(res, 500, { error: e.message }))
|
|
2372
|
+
return
|
|
2373
|
+
}
|
|
2374
|
+
if (req.method === 'GET' && seg0 === 'external') {
|
|
2375
|
+
return writeJson(res, 200, getExternalUrls())
|
|
2376
|
+
}
|
|
2377
|
+
if (req.method === 'GET' && seg0 === 'timeline') {
|
|
2378
|
+
buildTimeline().then((data) => writeJson(res, 200, data))
|
|
2379
|
+
.catch((e) => writeJson(res, 500, { error: e.message }))
|
|
2380
|
+
return
|
|
2381
|
+
}
|
|
2382
|
+
if (req.method === 'GET' && seg0 === 'tour') {
|
|
2383
|
+
const t = buildTour()
|
|
2384
|
+
if (!t) return writeJson(res, 503, { error: 'no folder loaded' })
|
|
2385
|
+
return writeJson(res, 200, t)
|
|
2386
|
+
}
|
|
2387
|
+
if (req.method === 'GET' && seg0 === 'changes' && rest.length === 0) {
|
|
2388
|
+
return writeJson(res, 200, listSessionChanges())
|
|
2389
|
+
}
|
|
2390
|
+
if (req.method === 'GET' && seg0 === 'changes' && rest.length > 0) {
|
|
2391
|
+
const id = idFromRest()
|
|
2392
|
+
const d = getChangeDiff(id)
|
|
2393
|
+
if (!d) return writeJson(res, 404, { error: 'no change recorded for this file' })
|
|
2394
|
+
return writeJson(res, 200, d)
|
|
2395
|
+
}
|
|
2396
|
+
if (req.method === 'GET' && seg0 === 'blast' && rest.length > 0) {
|
|
2397
|
+
const id = idFromRest()
|
|
2398
|
+
const depth = Math.max(1, Math.min(10, parseInt(url.searchParams.get('depth') || '3', 10)))
|
|
2399
|
+
const dir = url.searchParams.get('dir') === 'deps' ? 'deps' : 'users'
|
|
2400
|
+
const r = computeBlastRadius(id, depth, dir)
|
|
2401
|
+
if (!r) { emitTrace('blast', id); return writeJson(res, 404, { error: 'not found' }) }
|
|
2402
|
+
// Log impact-level trust meta: how many impacted files use dynamic patterns
|
|
2403
|
+
// (→ the true blast may be larger than the count shown).
|
|
2404
|
+
const dynHits = r.files.filter((f) => (scanner.files.get(f.id)?.dynamicPatterns || []).length).length
|
|
2405
|
+
emitTrace('blast', id, { n: r.totalFiles, dyn: dynHits || undefined })
|
|
2406
|
+
// Send all impacted node ids to renderer for visual highlight
|
|
2407
|
+
mainWindow?.webContents.send('control:blast', { seed: id, ids: r.files.map((f) => f.id) })
|
|
2408
|
+
return writeJson(res, 200, r)
|
|
2409
|
+
// Send all impacted node ids to renderer for visual highlight
|
|
2410
|
+
mainWindow?.webContents.send('control:blast', { seed: id, ids: r.files.map((f) => f.id) })
|
|
2411
|
+
return writeJson(res, 200, r)
|
|
2412
|
+
}
|
|
2413
|
+
if (req.method === 'GET' && seg0 === 'history' && rest.length > 0) {
|
|
2414
|
+
return writeJson(res, 200, listHistory(currentRoot, traceId()))
|
|
2415
|
+
}
|
|
2416
|
+
if (req.method === 'POST' && seg0 === 'focus' && rest.length > 0) {
|
|
2417
|
+
const id = traceId()
|
|
2418
|
+
if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
|
|
2419
|
+
mainWindow?.webContents.send('control:focus', { id })
|
|
2420
|
+
return writeJson(res, 200, { ok: true, id })
|
|
2421
|
+
}
|
|
2422
|
+
if (req.method === 'POST' && seg0 === 'open' && rest.length > 0) {
|
|
2423
|
+
const id = traceId()
|
|
2424
|
+
if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
|
|
2425
|
+
mainWindow?.webContents.send('control:open', { id })
|
|
2426
|
+
return writeJson(res, 200, { ok: true, id })
|
|
2427
|
+
}
|
|
2428
|
+
if (req.method === 'POST' && seg0 === 'load' && rest.length === 0) {
|
|
2429
|
+
// Body: { path } — load a project folder. If same as currentRoot, no-op.
|
|
2430
|
+
// Used by `cs ensure` to auto-switch projects from CLI/MCP without
|
|
2431
|
+
// requiring the user to click "Open Folder" in the desktop UI.
|
|
2432
|
+
let bodyChunks = []
|
|
2433
|
+
req.on('data', (c) => bodyChunks.push(c))
|
|
2434
|
+
req.on('end', async () => {
|
|
2435
|
+
let target = null
|
|
2436
|
+
try {
|
|
2437
|
+
const bodyStr = Buffer.concat(bodyChunks).toString('utf8')
|
|
2438
|
+
if (bodyStr) target = JSON.parse(bodyStr)?.path || null
|
|
2439
|
+
} catch {}
|
|
2440
|
+
target = target || url.searchParams.get('path')
|
|
2441
|
+
if (!target) return writeJson(res, 400, { error: 'usage: { "path": "..." }' })
|
|
2442
|
+
let abs
|
|
2443
|
+
try {
|
|
2444
|
+
abs = path.resolve(target)
|
|
2445
|
+
// realpathSync normalizes symlinks so two different paths pointing
|
|
2446
|
+
// to the same directory hit the noop branch correctly.
|
|
2447
|
+
if (fs.existsSync(abs)) abs = fs.realpathSync(abs)
|
|
2448
|
+
} catch (e) { return writeJson(res, 400, { error: 'invalid path: ' + e.message }) }
|
|
2449
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
2450
|
+
return writeJson(res, 400, { error: 'not a directory: ' + abs })
|
|
2451
|
+
}
|
|
2452
|
+
// Reject the OS root (C:\ on Windows, / on POSIX) — scanning that
|
|
2453
|
+
// would walk the entire filesystem. Almost certainly a mistake.
|
|
2454
|
+
if (abs === path.parse(abs).root) {
|
|
2455
|
+
return writeJson(res, 400, { error: 'refusing to load OS root: ' + abs })
|
|
2456
|
+
}
|
|
2457
|
+
// Confirm we can actually read it before triggering a scan.
|
|
2458
|
+
try { fs.accessSync(abs, fs.constants.R_OK) }
|
|
2459
|
+
catch { return writeJson(res, 403, { error: 'not readable: ' + abs }) }
|
|
2460
|
+
if (currentRoot === abs && scanner) {
|
|
2461
|
+
return writeJson(res, 200, { ok: true, action: 'noop', root: currentRoot, fileCount: scanner.files.size })
|
|
2462
|
+
}
|
|
2463
|
+
try {
|
|
2464
|
+
await startScanner(abs)
|
|
2465
|
+
return writeJson(res, 200, { ok: true, action: 'loaded', root: currentRoot, fileCount: scanner?.files?.size || 0 })
|
|
2466
|
+
} catch (e) { return writeJson(res, 500, { error: e.message }) }
|
|
2467
|
+
})
|
|
2468
|
+
return
|
|
2469
|
+
}
|
|
2470
|
+
if (req.method === 'GET' && seg0 === 'trace' && rest.length === 0) {
|
|
2471
|
+
// Current session log. Filter by tool, since=, limit.
|
|
2472
|
+
const sinceRaw = url.searchParams.get('since')
|
|
2473
|
+
const toolFilter = url.searchParams.get('tool')
|
|
2474
|
+
const limit = parseInt(url.searchParams.get('limit') || '0', 10)
|
|
2475
|
+
let evs = traceLog
|
|
2476
|
+
if (sinceRaw) {
|
|
2477
|
+
const since = parseInt(sinceRaw, 10)
|
|
2478
|
+
evs = evs.filter((e) => e.ts > since)
|
|
2479
|
+
}
|
|
2480
|
+
if (toolFilter) evs = evs.filter((e) => e.tool === toolFilter)
|
|
2481
|
+
const totalAvailable = evs.length
|
|
2482
|
+
if (limit > 0) evs = evs.slice(-limit) // most recent N
|
|
2483
|
+
return writeJson(res, 200, withMeta({
|
|
2484
|
+
sessionId: traceSessionId,
|
|
2485
|
+
events: evs,
|
|
2486
|
+
}, { totalAvailable, returned: evs.length }))
|
|
2487
|
+
}
|
|
2488
|
+
if (req.method === 'GET' && seg0 === 'trace' && rest[0] === 'stats') {
|
|
2489
|
+
// Stats over current session
|
|
2490
|
+
const stats = computeTraceStats(traceLog)
|
|
2491
|
+
return writeJson(res, 200, withMeta({ sessionId: traceSessionId, ...stats }))
|
|
2492
|
+
}
|
|
2493
|
+
if (req.method === 'GET' && seg0 === 'trace' && rest[0] === 'sessions') {
|
|
2494
|
+
return writeJson(res, 200, withMeta({
|
|
2495
|
+
sessions: listTraceSessions(currentRoot),
|
|
2496
|
+
currentSessionId: traceSessionId,
|
|
2497
|
+
}))
|
|
2498
|
+
}
|
|
2499
|
+
if (req.method === 'GET' && seg0 === 'trace' && rest[0] === 'session' && rest[1]) {
|
|
2500
|
+
const id = parseInt(rest[1], 10)
|
|
2501
|
+
const data = readTraceSession(currentRoot, id)
|
|
2502
|
+
if (!data) return writeJson(res, 404, { error: 'session not found' })
|
|
2503
|
+
const stats = computeTraceStats(data.events)
|
|
2504
|
+
return writeJson(res, 200, withMeta({ ...data, stats }))
|
|
2505
|
+
}
|
|
2506
|
+
if (req.method === 'POST' && seg0 === 'trace' && rest[0] === 'clear') {
|
|
2507
|
+
// Soft clear: drop in-memory log + start a NEW session on disk so
|
|
2508
|
+
// old log file is preserved.
|
|
2509
|
+
traceLog = []
|
|
2510
|
+
startTraceSession()
|
|
2511
|
+
return writeJson(res, 200, { ok: true, newSessionId: traceSessionId })
|
|
2512
|
+
}
|
|
2513
|
+
if (req.method === 'POST' && seg0 === 'trace' && rest[0] === 'export') {
|
|
2514
|
+
// Write current session to a user-chosen path. Body should be
|
|
2515
|
+
// { path } or query ?path=. Returns the absolute output path.
|
|
2516
|
+
let bodyChunks = []
|
|
2517
|
+
req.on('data', (c) => bodyChunks.push(c))
|
|
2518
|
+
req.on('end', () => {
|
|
2519
|
+
let exportPath = url.searchParams.get('path')
|
|
2520
|
+
try {
|
|
2521
|
+
const body = Buffer.concat(bodyChunks).toString('utf8')
|
|
2522
|
+
if (body) {
|
|
2523
|
+
const parsed = JSON.parse(body)
|
|
2524
|
+
exportPath = exportPath || parsed?.path
|
|
2525
|
+
}
|
|
2526
|
+
} catch {}
|
|
2527
|
+
if (!exportPath) return writeJson(res, 400, { error: 'usage: pass ?path= or { "path": "..." }' })
|
|
2528
|
+
try {
|
|
2529
|
+
const stats = computeTraceStats(traceLog)
|
|
2530
|
+
const out = {
|
|
2531
|
+
sessionId: traceSessionId,
|
|
2532
|
+
root: currentRoot,
|
|
2533
|
+
startedAt: traceSessionStartedAt,
|
|
2534
|
+
exportedAt: Date.now(),
|
|
2535
|
+
stats,
|
|
2536
|
+
events: traceLog,
|
|
2537
|
+
}
|
|
2538
|
+
fs.writeFileSync(exportPath, JSON.stringify(out, null, 2), 'utf8')
|
|
2539
|
+
return writeJson(res, 200, { ok: true, path: exportPath, eventCount: traceLog.length })
|
|
2540
|
+
} catch (e) { return writeJson(res, 500, { error: e.message }) }
|
|
2541
|
+
})
|
|
2542
|
+
return
|
|
2543
|
+
}
|
|
2544
|
+
if (req.method === 'GET' && seg0 === 'legacy' && rest.length === 0) {
|
|
2545
|
+
// Async — returns once the legacy module is loaded
|
|
2546
|
+
buildLegacyCached().then((data) => {
|
|
2547
|
+
if (!data) return writeJson(res, 503, { error: 'no folder loaded' })
|
|
2548
|
+
// Optional ?type=orphan|path|filename|duplicate filter
|
|
2549
|
+
const type = url.searchParams.get('type')
|
|
2550
|
+
if (type) {
|
|
2551
|
+
const slice = { summary: data.summary }
|
|
2552
|
+
if (type === 'orphan') slice.orphans = data.orphans
|
|
2553
|
+
else if (type === 'path') slice.pathPatterns = data.pathPatterns
|
|
2554
|
+
else if (type === 'filename') slice.filenamePatterns = data.filenamePatterns
|
|
2555
|
+
else if (type === 'duplicate')slice.duplicates = data.duplicates
|
|
2556
|
+
else return writeJson(res, 400, { error: 'bad type; use orphan|path|filename|duplicate' })
|
|
2557
|
+
return writeJson(res, 200, withMeta(slice))
|
|
2558
|
+
}
|
|
2559
|
+
return writeJson(res, 200, withMeta(data))
|
|
2560
|
+
}).catch((e) => writeJson(res, 500, { error: e.message }))
|
|
2561
|
+
return
|
|
2562
|
+
}
|
|
2563
|
+
if (req.method === 'GET' && seg0 === 'packages' && rest.length === 0) {
|
|
2564
|
+
const data = buildPackagesCached()
|
|
2565
|
+
if (!data) return writeJson(res, 503, { error: 'no folder loaded' })
|
|
2566
|
+
return writeJson(res, 200, withMeta(data))
|
|
2567
|
+
}
|
|
2568
|
+
if (req.method === 'GET' && seg0 === 'package' && rest.length > 0) {
|
|
2569
|
+
const name = idFromRest()
|
|
2570
|
+
emitTrace('package', name)
|
|
2571
|
+
const d = buildPackageDetail(name)
|
|
2572
|
+
if (!d) return writeJson(res, 404, { error: 'package not found', name })
|
|
2573
|
+
return writeJson(res, 200, withMeta(d))
|
|
2574
|
+
}
|
|
2575
|
+
if (req.method === 'GET' && seg0 === 'package-graph') {
|
|
2576
|
+
const data = buildPackagesCached()
|
|
2577
|
+
if (!data) return writeJson(res, 503, { error: 'no folder loaded' })
|
|
2578
|
+
return writeJson(res, 200, withMeta({
|
|
2579
|
+
kind: data.kind,
|
|
2580
|
+
packages: data.packages.map((p) => ({ name: p.name, fileCount: p.fileCount })),
|
|
2581
|
+
edges: data.pkgEdges,
|
|
2582
|
+
}))
|
|
2583
|
+
}
|
|
2584
|
+
if (req.method === 'POST' && (seg0 === 'write' || seg0 === 'edit') && rest.length > 0) {
|
|
2585
|
+
// Body: /write/:id expects { content }
|
|
2586
|
+
// /edit/:id expects { find, replace, replaceAll? }
|
|
2587
|
+
// Both wrapped through writeFileToRoot so audit trail is uniform.
|
|
2588
|
+
const id = idFromRest()
|
|
2589
|
+
const full = path.join(currentRoot, id)
|
|
2590
|
+
if (!isInsideRoot(currentRoot, full)) return writeJson(res, 400, { error: 'outside root' })
|
|
2591
|
+
let bodyChunks = []
|
|
2592
|
+
req.on('data', (c) => bodyChunks.push(c))
|
|
2593
|
+
req.on('end', () => {
|
|
2594
|
+
let body
|
|
2595
|
+
try { body = JSON.parse(Buffer.concat(bodyChunks).toString('utf8')) }
|
|
2596
|
+
catch { return writeJson(res, 400, { error: 'invalid JSON body' }) }
|
|
2597
|
+
if (seg0 === 'write') {
|
|
2598
|
+
if (typeof body.content !== 'string') return writeJson(res, 400, { error: 'usage: { "content": "..." }' })
|
|
2599
|
+
const r = writeFileToRoot(id, body.content, { source: 'http-write' })
|
|
2600
|
+
if (!r.ok) return writeJson(res, 500, r)
|
|
2601
|
+
return writeJson(res, 200, withMeta({ ...r, id }))
|
|
2602
|
+
}
|
|
2603
|
+
// edit
|
|
2604
|
+
if (typeof body.find !== 'string' || typeof body.replace !== 'string') {
|
|
2605
|
+
return writeJson(res, 400, { error: 'usage: { "find": "...", "replace": "...", "replaceAll": false }' })
|
|
2606
|
+
}
|
|
2607
|
+
let content
|
|
2608
|
+
try { content = fs.readFileSync(full, 'utf8') }
|
|
2609
|
+
catch (e) { return writeJson(res, 500, { error: 'read failed: ' + e.message }) }
|
|
2610
|
+
const findStr = body.find
|
|
2611
|
+
if (!findStr) return writeJson(res, 400, { error: 'find string cannot be empty' })
|
|
2612
|
+
// Count occurrences
|
|
2613
|
+
let count = 0, idx = 0
|
|
2614
|
+
while ((idx = content.indexOf(findStr, idx)) !== -1) { count++; idx += findStr.length }
|
|
2615
|
+
if (count === 0) return writeJson(res, 404, { error: 'find string not found', find: findStr })
|
|
2616
|
+
const replaceAll = body.replaceAll === true
|
|
2617
|
+
if (!replaceAll && count > 1) {
|
|
2618
|
+
return writeJson(res, 409, {
|
|
2619
|
+
error: `find string is not unique (${count} occurrences). Pass replaceAll:true or use a more specific find string.`,
|
|
2620
|
+
occurrences: count,
|
|
2621
|
+
})
|
|
2622
|
+
}
|
|
2623
|
+
const next = replaceAll
|
|
2624
|
+
? content.split(findStr).join(body.replace)
|
|
2625
|
+
: content.replace(findStr, body.replace)
|
|
2626
|
+
const r = writeFileToRoot(id, next, { source: 'http-edit' })
|
|
2627
|
+
if (!r.ok) return writeJson(res, 500, r)
|
|
2628
|
+
return writeJson(res, 200, withMeta({ ...r, id, replacements: replaceAll ? count : 1 }))
|
|
2629
|
+
})
|
|
2630
|
+
return
|
|
2631
|
+
}
|
|
2632
|
+
if (req.method === 'POST' && seg0 === 'restore' && rest.length > 0) {
|
|
2633
|
+
const id = idFromRest()
|
|
2634
|
+
emitTrace('write', id)
|
|
2635
|
+
const ts = parseInt(url.searchParams.get('ts') || '0', 10)
|
|
2636
|
+
if (!ts) return writeJson(res, 400, { error: 'missing ts' })
|
|
2637
|
+
const content = readHistorySnap(currentRoot, id, ts)
|
|
2638
|
+
if (content === null) return writeJson(res, 404, { error: 'snapshot not found' })
|
|
2639
|
+
const full = path.join(currentRoot, id)
|
|
2640
|
+
if (!isInsideRoot(currentRoot, full)) return writeJson(res, 400, { error: 'outside root' })
|
|
2641
|
+
try {
|
|
2642
|
+
fs.writeFileSync(full, content, 'utf8')
|
|
2643
|
+
snapshotHistory(currentRoot, id, content)
|
|
2644
|
+
return writeJson(res, 200, { ok: true, id, ts })
|
|
2645
|
+
} catch (e) { return writeJson(res, 500, { error: e.message }) }
|
|
2646
|
+
}
|
|
2647
|
+
return writeJson(res, 404, { error: 'unknown endpoint', path: url.pathname })
|
|
2648
|
+
} catch (e) {
|
|
2649
|
+
return writeJson(res, 500, { error: e.message })
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// Try `startPort` first, then increment up to `maxTries-1` more.
|
|
2654
|
+
// On success: write port to lock file so CLI / MCP can find us.
|
|
2655
|
+
// On exhaustion: log + give up (control API disabled).
|
|
2656
|
+
const CONTROL_PORT_MAX_TRIES = 10
|
|
2657
|
+
function getLockFilePath() {
|
|
2658
|
+
// ~/.codesynapt/port — CLI/MCP looks here when no env var set
|
|
2659
|
+
const homeDir = app.getPath('home')
|
|
2660
|
+
return path.join(homeDir, '.codesynapt', 'port')
|
|
2661
|
+
}
|
|
2662
|
+
function writeLockFile(port) {
|
|
2663
|
+
try {
|
|
2664
|
+
const file = getLockFilePath()
|
|
2665
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
2666
|
+
fs.writeFileSync(file, String(port), 'utf8')
|
|
2667
|
+
} catch (e) {
|
|
2668
|
+
console.warn('[cs] could not write port lock file:', e.message)
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
function clearLockFile() {
|
|
2672
|
+
try { fs.unlinkSync(getLockFilePath()) } catch {}
|
|
2673
|
+
}
|
|
2674
|
+
function startControlServer(startPort = controlPort, attempt = 0) {
|
|
2675
|
+
if (controlServer) return
|
|
2676
|
+
const port = startPort + attempt
|
|
2677
|
+
const server = http.createServer(handleControlRequest)
|
|
2678
|
+
server.once('error', (err) => {
|
|
2679
|
+
if (err.code === 'EADDRINUSE' && attempt < CONTROL_PORT_MAX_TRIES - 1) {
|
|
2680
|
+
// Try next port. Recurse without setting controlServer yet.
|
|
2681
|
+
try { server.close() } catch {}
|
|
2682
|
+
startControlServer(startPort, attempt + 1)
|
|
2683
|
+
} else if (err.code === 'EADDRINUSE') {
|
|
2684
|
+
console.warn(`[cs] all control ports ${startPort}..${startPort + CONTROL_PORT_MAX_TRIES - 1} in use — control API disabled. Set CS_PORT to override.`)
|
|
2685
|
+
controlServer = null
|
|
2686
|
+
} else {
|
|
2687
|
+
console.error('[cs] control server error:', err)
|
|
2688
|
+
controlServer = null
|
|
2689
|
+
}
|
|
2690
|
+
})
|
|
2691
|
+
server.listen(port, '127.0.0.1', () => {
|
|
2692
|
+
controlServer = server
|
|
2693
|
+
controlPort = port
|
|
2694
|
+
writeLockFile(port)
|
|
2695
|
+
if (port !== startPort) {
|
|
2696
|
+
log.info('control API listening (fallback)', { port, requestedPort: startPort, host: '127.0.0.1' })
|
|
2697
|
+
console.log(`[cs] control API listening on http://127.0.0.1:${port} (fallback — ${startPort} was in use)`)
|
|
2698
|
+
} else {
|
|
2699
|
+
log.info('control API listening', { port, host: '127.0.0.1' })
|
|
2700
|
+
console.log(`[cs] control API listening on http://127.0.0.1:${port}`)
|
|
2701
|
+
}
|
|
2702
|
+
})
|
|
2703
|
+
}
|
|
2704
|
+
function stopControlServer() {
|
|
2705
|
+
if (controlServer) {
|
|
2706
|
+
try { controlServer.close() } catch {}
|
|
2707
|
+
controlServer = null
|
|
2708
|
+
}
|
|
2709
|
+
clearLockFile()
|
|
2710
|
+
}
|
|
2711
|
+
ipcMain.handle('control-port', () => controlPort)
|
|
2712
|
+
|
|
2713
|
+
// ─── Log retention (audit + per-project traces) ────────────────
|
|
2714
|
+
// Default 30 days; configurable via env. Prevents unbounded growth of
|
|
2715
|
+
// ~/.codesynapt/audit/YYYY-MM-DD.jsonl and project-local .codesynapt/traces/.
|
|
2716
|
+
function pruneOldLogs() {
|
|
2717
|
+
const days = parseInt(process.env.CS_AUDIT_RETENTION_DAYS || '30', 10)
|
|
2718
|
+
if (!days || days <= 0) return // 0 = keep forever
|
|
2719
|
+
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000
|
|
2720
|
+
const auditDir = path.join(app.getPath('home'), '.codesynapt', 'audit')
|
|
2721
|
+
const dirs = [auditDir]
|
|
2722
|
+
if (currentRoot) dirs.push(traceDirFor(currentRoot))
|
|
2723
|
+
for (const dir of dirs) {
|
|
2724
|
+
try {
|
|
2725
|
+
if (!fs.existsSync(dir)) continue
|
|
2726
|
+
for (const name of fs.readdirSync(dir)) {
|
|
2727
|
+
if (!name.endsWith('.jsonl')) continue
|
|
2728
|
+
const f = path.join(dir, name)
|
|
2729
|
+
try {
|
|
2730
|
+
const stat = fs.statSync(f)
|
|
2731
|
+
if (stat.mtimeMs < cutoffMs) {
|
|
2732
|
+
fs.unlinkSync(f)
|
|
2733
|
+
log.info('log pruned', { file: f, retentionDays: days })
|
|
2734
|
+
}
|
|
2735
|
+
} catch {}
|
|
2736
|
+
}
|
|
2737
|
+
} catch {}
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// CS_HEADLESS=1 boots the control API + scanner without opening a
|
|
2742
|
+
// BrowserWindow. Useful for: bench harnesses, CI runs, or a second
|
|
2743
|
+
// instance on a non-default CS_PORT measuring alongside a live UI
|
|
2744
|
+
// without spawning a second visible window.
|
|
2745
|
+
const HEADLESS = process.env.CS_HEADLESS === '1'
|
|
2746
|
+
|
|
2747
|
+
// ─── Single-instance lock ───────────────────────────────────────
|
|
2748
|
+
// Critical for clean version upgrades: when the NSIS installer launches
|
|
2749
|
+
// the new version (runAfterFinish), if an old build is somehow still
|
|
2750
|
+
// running, route the second-instance event to focus/restore instead of
|
|
2751
|
+
// spawning a duplicate process. Also prevents two desktop windows
|
|
2752
|
+
// fighting over port 7707.
|
|
2753
|
+
// Headless mode skips the lock — bench harnesses and CI runs need to
|
|
2754
|
+
// boot a second instance on a non-default CS_PORT alongside a live
|
|
2755
|
+
// desktop without one killing the other.
|
|
2756
|
+
const gotSingleInstanceLock = HEADLESS ? true : app.requestSingleInstanceLock()
|
|
2757
|
+
if (!gotSingleInstanceLock) {
|
|
2758
|
+
app.quit()
|
|
2759
|
+
} else {
|
|
2760
|
+
app.on('second-instance', (_event, argv) => {
|
|
2761
|
+
if (mainWindow) {
|
|
2762
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
2763
|
+
if (!mainWindow.isVisible()) mainWindow.show()
|
|
2764
|
+
mainWindow.focus()
|
|
2765
|
+
}
|
|
2766
|
+
// If the second instance passed CS_INITIAL_ROOT via env or a path arg,
|
|
2767
|
+
// load that folder into the running window.
|
|
2768
|
+
const envRoot = process.env.CS_INITIAL_ROOT
|
|
2769
|
+
const argRoot = argv && argv.find && argv.find((a) => a && !a.startsWith('-') && fs.existsSync(a) && fs.statSync(a).isDirectory())
|
|
2770
|
+
const target = envRoot && fs.existsSync(envRoot) ? envRoot : argRoot
|
|
2771
|
+
if (target && path.resolve(target) !== currentRoot) startScanner(path.resolve(target))
|
|
2772
|
+
})
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
// ─── App lifecycle ──────────────────────────────────────────────
|
|
2776
|
+
app.whenReady().then(() => {
|
|
2777
|
+
if (!HEADLESS) {
|
|
2778
|
+
rebuildMenu()
|
|
2779
|
+
createWindow()
|
|
2780
|
+
} else {
|
|
2781
|
+
// Electron has no GUI-less mode — without at least one
|
|
2782
|
+
// BrowserWindow the app exits as soon as whenReady() resolves.
|
|
2783
|
+
// A 1×1 hidden window keeps the event loop alive and never
|
|
2784
|
+
// surfaces on screen, which is what we want for bench harnesses
|
|
2785
|
+
// and CI runs that only need the control API.
|
|
2786
|
+
new BrowserWindow({ show: false, width: 1, height: 1 })
|
|
2787
|
+
}
|
|
2788
|
+
startControlServer()
|
|
2789
|
+
pruneOldLogs() // one-shot on boot; daily users get fresh pruning
|
|
2790
|
+
|
|
2791
|
+
app.on('activate', () => {
|
|
2792
|
+
if (!HEADLESS && BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
2793
|
+
})
|
|
2794
|
+
|
|
2795
|
+
// Auto-updater (GitHub Releases). Disabled by env var for users who
|
|
2796
|
+
// want zero outbound network calls. Silently no-ops if no published
|
|
2797
|
+
// releases yet (404 from update feed → updater logs and does nothing).
|
|
2798
|
+
if (process.env.CS_DISABLE_UPDATER !== '1') {
|
|
2799
|
+
try {
|
|
2800
|
+
const { autoUpdater } = require('electron-updater')
|
|
2801
|
+
autoUpdater.autoDownload = false // ask user before downloading
|
|
2802
|
+
autoUpdater.on('error', (e) => log.error('updater error', { message: e.message }))
|
|
2803
|
+
autoUpdater.on('update-available', (info) => {
|
|
2804
|
+
log.info('update available', { version: info.version })
|
|
2805
|
+
// Notify renderer; renderer shows a toast with "download / dismiss".
|
|
2806
|
+
mainWindow?.webContents.send('updater:available', { version: info.version, releaseNotes: info.releaseNotes })
|
|
2807
|
+
})
|
|
2808
|
+
autoUpdater.on('update-downloaded', (info) => {
|
|
2809
|
+
mainWindow?.webContents.send('updater:downloaded', { version: info.version })
|
|
2810
|
+
})
|
|
2811
|
+
// Check ~10s after boot so the scanner gets the network priority.
|
|
2812
|
+
setTimeout(() => { autoUpdater.checkForUpdates().catch(() => {}) }, 10_000)
|
|
2813
|
+
} catch (e) {
|
|
2814
|
+
// electron-updater is optional at runtime; CLI/MCP-only installs skip it
|
|
2815
|
+
log.warn('updater not loaded', { error: e.message })
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
})
|
|
2819
|
+
|
|
2820
|
+
app.on('window-all-closed', () => {
|
|
2821
|
+
// Headless mode never opens a window, so this would fire as soon
|
|
2822
|
+
// as the event loop ticks and quit before the control API became
|
|
2823
|
+
// useful. Stay alive — caller terminates with SIGTERM.
|
|
2824
|
+
if (HEADLESS) return
|
|
2825
|
+
stopScanner()
|
|
2826
|
+
stopControlServer()
|
|
2827
|
+
if (process.platform !== 'darwin') app.quit()
|
|
2828
|
+
})
|
|
2829
|
+
|
|
2830
|
+
app.on('before-quit', () => { stopScanner(); stopControlServer() })
|
|
2831
|
+
|
|
2832
|
+
// Hardening
|
|
2833
|
+
app.on('web-contents-created', (_e, contents) => {
|
|
2834
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
2835
|
+
if (url.startsWith('http')) shell.openExternal(url)
|
|
2836
|
+
return { action: 'deny' }
|
|
2837
|
+
})
|
|
2838
|
+
// Defense-in-depth: block all in-window navigation away from our file://.
|
|
2839
|
+
// CSP already blocks remote loads, but this is one more layer.
|
|
2840
|
+
contents.on('will-navigate', (event, url) => {
|
|
2841
|
+
if (!url.startsWith('file://')) {
|
|
2842
|
+
event.preventDefault()
|
|
2843
|
+
if (url.startsWith('http')) shell.openExternal(url)
|
|
2844
|
+
}
|
|
2845
|
+
})
|
|
2846
|
+
// Deny every permission request — we don't use mic/camera/geolocation/etc.
|
|
2847
|
+
contents.session.setPermissionRequestHandler((_wc, _perm, callback) => callback(false))
|
|
2848
|
+
contents.session.setPermissionCheckHandler(() => false)
|
|
2849
|
+
})
|