codesynapt 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +686 -0
  3. package/LICENSES.md +141 -0
  4. package/README.md +331 -0
  5. package/electron/main.cjs +2849 -0
  6. package/electron/plugin-loader.cjs +184 -0
  7. package/electron/preload.cjs +108 -0
  8. package/package.json +216 -0
  9. package/packages/core/bin/codesynapt-mcp.cjs +611 -0
  10. package/packages/core/bin/codesynapt.cjs +1933 -0
  11. package/packages/core/legacy.js +300 -0
  12. package/packages/core/lib/control-server.cjs +1539 -0
  13. package/packages/core/lib/embedding.cjs +89 -0
  14. package/packages/core/lib/logger.cjs +63 -0
  15. package/packages/core/lib/search-cache.cjs +140 -0
  16. package/packages/core/lib/search-worker.cjs +255 -0
  17. package/packages/core/lib/search.cjs +211 -0
  18. package/packages/core/lib/symbol-graph.cjs +402 -0
  19. package/packages/core/lib/symbol-parser-js.cjs +542 -0
  20. package/packages/core/lib/symbol-parser-misc.cjs +394 -0
  21. package/packages/core/lib/symbol-parser-py.cjs +215 -0
  22. package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
  23. package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
  24. package/packages/core/monorepo.js +310 -0
  25. package/packages/core/parser.js +2234 -0
  26. package/packages/core/scanner.js +623 -0
  27. package/plugin-api/LICENSE +21 -0
  28. package/plugin-api/README.md +114 -0
  29. package/plugin-api/docs/01-getting-started.md +197 -0
  30. package/plugin-api/docs/02-concepts.md +269 -0
  31. package/plugin-api/docs/api-reference.md +463 -0
  32. package/plugin-api/docs/troubleshooting.md +332 -0
  33. package/plugin-api/docs/types/exporter.md +377 -0
  34. package/plugin-api/docs/types/theme.md +312 -0
  35. package/plugin-api/examples/hello-world-plugin/README.md +70 -0
  36. package/plugin-api/examples/hello-world-plugin/main.js +36 -0
  37. package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
  38. package/plugin-api/examples/mermaid-exporter/README.md +125 -0
  39. package/plugin-api/examples/mermaid-exporter/main.js +58 -0
  40. package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
  41. package/plugin-api/examples/rust-parser/README.md +71 -0
  42. package/plugin-api/examples/rust-parser/main.js +123 -0
  43. package/plugin-api/examples/rust-parser/manifest.json +12 -0
  44. package/plugin-api/examples/sunset-theme/README.md +95 -0
  45. package/plugin-api/examples/sunset-theme/manifest.json +12 -0
  46. package/plugin-api/examples/sunset-theme/theme.css +31 -0
  47. package/plugin-api/package.json +20 -0
  48. package/plugin-api/types.d.ts +395 -0
  49. package/public/app.js +6837 -0
  50. package/public/backend.js +285 -0
  51. package/public/index.html +647 -0
  52. package/public/plugin-host.js +321 -0
  53. package/public/style.css +4359 -0
  54. package/public/vendor/three.module.js +53044 -0
  55. package/scripts/competitor-watch.mjs +144 -0
  56. package/scripts/copy-vendor.js +21 -0
  57. package/scripts/download-bundled-node.cjs +53 -0
  58. package/scripts/fuses-after-pack.cjs +34 -0
  59. package/scripts/license-check.js +119 -0
  60. package/scripts/perf-test.js +200 -0
  61. package/server.js +132 -0
@@ -0,0 +1,1539 @@
1
+ // lib/control-server.cjs
2
+ //
3
+ // Standalone HTTP control surface for filegraph3d. Factory function —
4
+ // takes a Scanner instance and a `getCurrentRoot()` callback, returns
5
+ // `{ handleControlRequest, startControlServer, stopControlServer }`.
6
+ //
7
+ // Used by:
8
+ // - electron/main.cjs (full UI — passes IPC callbacks)
9
+ // - bin/codesynapt.cjs serve (headless daemon — no IPC)
10
+ //
11
+ // This is a deliberate copy of the read-only endpoint logic that
12
+ // previously lived only in electron/main.cjs. The Electron copy stays
13
+ // untouched in THIS session so the desktop app keeps working unchanged;
14
+ // the next session will switch main.cjs to require this module.
15
+
16
+ const http = require('http')
17
+ const fs = require('fs')
18
+ const path = require('path')
19
+ const crypto = require('crypto')
20
+
21
+ // ─── i18n strings (en + ko) ──────────────────────────────────
22
+ // Keys are stable identifiers; values are functions so we can
23
+ // interpolate variables. `t(key, locale, ...args)` is the single entry
24
+ // point. Default locale is 'en' (international AI clients).
25
+ const I18N = {
26
+ // safety
27
+ 'safety.reason.risky_hub': { en: (n) => `Core hub — ${n} files depend on this`, ko: (n) => `핵심 허브 — ${n}개 파일이 의존` },
28
+ 'safety.reason.routes': { en: (n) => `Defines ${n} backend route(s) — API contract risk`, ko: (n) => `backend 라우트 ${n}개 정의 — API 계약 변경 위험` },
29
+ 'safety.reason.external_api': { en: (n) => `Calls ${n} external API endpoint(s) — keys/URLs at risk`,ko: (n) => `외부 API ${n}곳 호출 — 키/엔드포인트 영향` },
30
+ 'safety.reason.http_client': { en: (n) => `${n} HTTP client call(s)`, ko: (n) => `HTTP 클라이언트 호출 ${n}개` },
31
+ 'safety.reason.dynamic': { en: () => `Dynamic import patterns — graph may be incomplete`, ko: () => `동적 import 패턴 — 그래프가 불완전할 수 있음` },
32
+ 'safety.reason.dependents': { en: (n) => `${n} files depend on this`, ko: (n) => `${n}개 파일이 의존` },
33
+ 'safety.reason.safe': { en: (n) => `Only ${n} dependents, no external impact`, ko: (n) => `의존 파일 ${n}개로 적음, 외부 영향 없음` },
34
+ 'safety.reason.no_tests': { en: () => `No tests would catch breakage here`, ko: () => `이 파일을 깨면 잡아낼 테스트가 없음` },
35
+ 'safety.advice.risky': { en: (id) => `Don't let an AI edit this without human review. High-impact file.`, ko: (id) => `AI에게 시키지 말고 사람이 검토. 영향 큰 파일.` },
36
+ 'safety.advice.caution': { en: (id) => `Before letting an AI edit, ask it to read the dependents first. Use \`cs bundle ${id}\` to pack them into a token budget.`, ko: (id) => `AI에게 시키기 전에 의존 파일을 같이 읽으라고 지시. \`cs bundle ${id}\`로 함께 줄 파일 묶음 만들 수 있음.` },
37
+ 'safety.advice.safe': { en: () => `Safe to let an AI edit as-is.`, ko: () => `AI에게 그대로 시켜도 안전.` },
38
+
39
+ // preflight
40
+ 'preflight.env_undeclared.ok': { en: () => `All used env vars declared in .env*`, ko: () => `모든 코드 ENV 변수가 .env*에 선언됨` },
41
+ 'preflight.env_undeclared.fail': { en: (n) => `${n} env vars used in code but not declared in .env*`, ko: (n) => `미선언 환경 변수 ${n}개` },
42
+ 'preflight.env_undeclared.detail': { en: () => `Will be \`undefined\` at runtime. Ask the AI to add placeholders to .env.example.`, ko: () => `배포 시 undefined로 동작. AI에게 .env.example 추가 요청.` },
43
+ 'preflight.secret_leak.ok': { en: () => `No server-only env exposed to frontend`, ko: () => `frontend에 server-only env 노출 없음` },
44
+ 'preflight.secret_leak.fail': { en: (n) => `${n} server-only env var(s) in frontend code`, ko: (n) => `frontend 코드에 server-only env 변수 ${n}개 노출` },
45
+ 'preflight.secret_leak.detail': { en: () => `Vars without a public prefix (NEXT_PUBLIC_/VITE_) end up in the browser bundle — secret leak.`, ko: () => `public prefix(NEXT_PUBLIC_/VITE_ 등) 없는 변수가 브라우저 번들에 포함됨. 키 유출 위험.` },
46
+ 'preflight.http_urls.ok': { en: () => `All external calls use HTTPS`, ko: () => `외부 호출 모두 HTTPS` },
47
+ 'preflight.http_urls.fail': { en: (n) => `${n} plain http:// external call(s)`, ko: (n) => `평문 http:// 외부 호출 ${n}개` },
48
+ 'preflight.http_urls.detail': { en: () => `Vulnerable to MITM. Switch to https or move host to env var.`, ko: () => `중간자 공격 가능. https 또는 환경 변수로 도메인 분리.` },
49
+ 'preflight.hub_tests.ok': { en: () => `All hub files have at least one test`, ko: () => `주요 hub 파일 모두 테스트로 보호됨` },
50
+ 'preflight.hub_tests.warn': { en: (n) => `${n} hub file(s) (mass≥10) have no tests`, ko: (n) => `테스트 없는 hub ${n}개` },
51
+ 'preflight.hub_tests.detail': { en: () => `Regression risk after deploy.`, ko: () => `mass≥10 파일인데 테스트 없음. 배포 후 회귀 위험.` },
52
+ 'preflight.orphans.ok': { en: (p) => `Orphan ratio ${p}% — normal range`, ko: (p) => `고립 파일 ${p}% — 정상 범위` },
53
+ 'preflight.orphans.warn': { en: (p, n, tot) => `Orphan ratio ${p}% (${n}/${tot})`, ko: (p, n, tot) => `고립 파일 비율 ${p}% (${n}/${tot})` },
54
+ 'preflight.orphans.detail': { en: () => `>30%. Likely dead code — run \`cs legacy\` to clean.`, ko: () => `30% 초과. 죽은 코드 가능성 — \`cs legacy\` 로 정리.` },
55
+ 'preflight.dynamic.info': { en: (p) => `Dynamic import ratio ${p}%`, ko: (p) => `동적 import 비중 ${p}%` },
56
+ 'preflight.dynamic.detail': { en: () => `Static analysis is partial here. Caveat for blast/bundle results.`, ko: () => `정적 분석 불완전. blast/bundle 결과에 caveat 있음.` },
57
+ 'preflight.env_unused.info': { en: (n) => `${n} declared env var(s) never used in code`, ko: (n) => `사용 안 되는 env 변수 ${n}개` },
58
+ 'preflight.env_unused.detail': { en: () => `Cleanup candidates. Old keys still in .env are a leak surface.`, ko: () => `.env* 정리 후보. 옛 키 노출 가능.` },
59
+ }
60
+
61
+ function t(key, locale, ...args) {
62
+ const e = I18N[key]
63
+ if (!e) return key
64
+ const fn = e[locale === 'ko' ? 'ko' : 'en']
65
+ return fn(...args)
66
+ }
67
+
68
+ // Localized strings for buildSuggestions. Larger surface than I18N so
69
+ // kept as a separate nested table for readability.
70
+ const SUGGEST_STRINGS = {
71
+ en: {
72
+ undeclared: {
73
+ title: (n) => `${n} undeclared env var(s)`,
74
+ why: () => `Code reads them but no .env* declares them — will be undefined at runtime.`,
75
+ advice: (s) => `Ask the AI: "Add placeholders for these to .env.example — ${s}"`,
76
+ },
77
+ hub_no_tests: {
78
+ title: (n) => `${n} hub file(s) without tests`,
79
+ why: () => `Lots of files import these, but no test covers them. AI edits can silently break downstream.`,
80
+ advice: (id, m) => `Ask the AI: "Write a unit test for ${id} — ${m} files import it."`,
81
+ },
82
+ orphans: {
83
+ title: (n) => `${n} orphan files (likely dead code)`,
84
+ why: () => `Imported by nothing, importing nothing. Often abandoned experiments / unused components.`,
85
+ advice: (id) => `Ask the AI: "Are ${id} and friends actually used anywhere? Remove them if not." (\`cs legacy\` for the full audit)`,
86
+ },
87
+ unused_env: {
88
+ title: (n) => `${n} unused env var(s)`,
89
+ why: () => `Declared in .env* but no code reads them. Stale keys / leftover surface.`,
90
+ advice: (s) => `Ask the AI: "Remove or confirm usage of these env vars — ${s}"`,
91
+ },
92
+ vendors: {
93
+ title: (n) => `${n} third-party folder(s) in graph`,
94
+ why: (s) => `${s} etc. look vendored. They pollute hub / orphan / env results.`,
95
+ advice: (lines) => `Add to .codesynaptignore (one per line, trailing /):\n ${lines}`,
96
+ },
97
+ dynamic: {
98
+ title: (p) => `Dynamic import ratio ${p}%`,
99
+ why: () => `Static analysis misses some dependency edges. blast / bundle results have caveats here.`,
100
+ advice: (id) => `Ask the AI: "Can we convert ${id || 'the dynamic imports'} to static imports?"`,
101
+ },
102
+ },
103
+ ko: {
104
+ undeclared: {
105
+ title: (n) => `미선언 환경 변수 ${n}개`,
106
+ why: () => `코드는 사용하는데 .env*에 정의 안 됨. 배포시 undefined로 동작하거나 실패.`,
107
+ advice: (s) => `AI에게: ".env.example에 다음 변수들의 placeholder 추가해줘 — ${s}"`,
108
+ },
109
+ hub_no_tests: {
110
+ title: (n) => `테스트 없는 허브 파일 ${n}개`,
111
+ why: () => `많은 파일이 import하는 핵심인데 테스트로 보호 안 됨. AI 수정시 다른 곳 깨질 위험.`,
112
+ advice: (id, m) => `AI에게: "${id} 에 대한 unit test 작성해줘 — ${m}개 파일이 이걸 import함"`,
113
+ },
114
+ orphans: {
115
+ title: (n) => `고립 파일 ${n}개 (의심 죽은 코드)`,
116
+ why: () => `아무도 import하지 않고, 아무것도 import하지 않음. 보통 옛 실험/사용 안 되는 컴포넌트.`,
117
+ advice: (id) => `AI에게: "${id} 등 다음 파일들이 실제 쓰이는지 확인하고 죽은 코드면 제거해줘" (\`cs legacy\` 로 자세히)`,
118
+ },
119
+ unused_env: {
120
+ title: (n) => `사용 안 되는 환경 변수 ${n}개`,
121
+ why: () => `.env*에 있지만 코드에서 아무도 안 씀. 옛 키 노출 위험 + 정리 후보.`,
122
+ advice: (s) => `AI에게: ".env에서 다음 변수들 제거 또는 사용처 확인해줘 — ${s}"`,
123
+ },
124
+ vendors: {
125
+ title: (n) => `Third-party 폴더 ${n}개 graph에 포함됨`,
126
+ why: (s) => `${s} 등이 vendored 코드로 보임. graph 분석/hub/orphan 결과가 오염될 수 있음.`,
127
+ advice: (lines) => `.codesynaptignore에 추가 (한 줄씩, 끝에 /):\n ${lines}`,
128
+ },
129
+ dynamic: {
130
+ title: (p) => `동적 import 패턴 비중 ${p}%`,
131
+ why: () => `정적 분석으로 의존성 추적이 어려움. blast/bundle 결과가 일부 누락됨.`,
132
+ advice: (id) => `AI에게: "${id || '동적 import 사용처'} 등 정적 import로 가능한지 검토"`,
133
+ },
134
+ },
135
+ }
136
+
137
+ function createControlServer(opts) {
138
+ const {
139
+ scanner, // Scanner instance
140
+ getCurrentRoot, // () => absolute path of scanned root
141
+ onBlast, // optional (payload) => void IPC: highlight blast
142
+ onFocus, // optional (id) => void IPC: focus node
143
+ onOpen, // optional (id) => void IPC: open in editor
144
+ authToken, // optional string. If set, require `Authorization: Bearer <token>` on every request.
145
+ auditLogDir, // optional absolute path. If set, every request is appended to <dir>/YYYY-MM-DD.jsonl
146
+ } = opts
147
+ if (!scanner) throw new Error('createControlServer: scanner is required')
148
+ if (typeof getCurrentRoot !== 'function') throw new Error('createControlServer: getCurrentRoot fn is required')
149
+
150
+ // ── Utilities ─────────────────────────────────────────────────
151
+ function writeJson(res, status, data) {
152
+ const body = JSON.stringify(data)
153
+ res.writeHead(status, {
154
+ 'Content-Type': 'application/json; charset=utf-8',
155
+ 'Content-Length': Buffer.byteLength(body),
156
+ 'Access-Control-Allow-Origin': '*',
157
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
158
+ 'Access-Control-Allow-Headers': 'Content-Type',
159
+ })
160
+ res.end(body)
161
+ }
162
+ function isInsideRoot(root, full) {
163
+ const r = path.resolve(root)
164
+ const f = path.resolve(full)
165
+ return f === r || f.startsWith(r + path.sep)
166
+ }
167
+ function estimateTokens(obj) {
168
+ try { return Math.ceil(JSON.stringify(obj).length / 4) } catch { return 0 }
169
+ }
170
+ function withMeta(payload, extra = {}) {
171
+ const meta = {
172
+ scannedAt: scanner._lastSnapshotAt || Date.now(),
173
+ serverTime: Date.now(),
174
+ ...extra,
175
+ }
176
+ meta.tokenEstimate = estimateTokens({ ...payload, meta })
177
+ return { ...payload, meta }
178
+ }
179
+
180
+ // ── Graph state ───────────────────────────────────────────────
181
+ function getGraphState() {
182
+ return { root: getCurrentRoot(), ...scanner.snapshot() }
183
+ }
184
+ // Lazy SHA-256 of file content. Used for fresh-data verification
185
+ // (AI compares its own Read result hash against this; match = fresh).
186
+ // Cached on the file object so repeated calls are O(1).
187
+ function fileContentHash(f) {
188
+ if (!f || !f.absPath) return null
189
+ if (f._cachedHash && f._cachedHashAt === f.lastSeenAt) return f._cachedHash
190
+ try {
191
+ const buf = fs.readFileSync(f.absPath)
192
+ const h = crypto.createHash('sha256').update(buf).digest('hex')
193
+ f._cachedHash = h
194
+ f._cachedHashAt = f.lastSeenAt
195
+ return h
196
+ } catch { return null }
197
+ }
198
+
199
+ function findNode(id) {
200
+ const f = scanner.files.get(id)
201
+ if (!f) return null
202
+ return {
203
+ id: f.id, ext: f.ext, loc: f.loc, size: f.size,
204
+ importCount: f.imports.length,
205
+ hasDynamicResolution: (f.dynamicPatterns || []).length > 0,
206
+ dynamicPatterns: f.dynamicPatterns || [],
207
+ confidence: f.confidence || 'high',
208
+ contentHash: fileContentHash(f),
209
+ lastSeenAt: f.lastSeenAt,
210
+ }
211
+ }
212
+ function getDeps(id) { return scanner.edges.filter((e) => e.s === id) }
213
+ function getUsers(id) { return scanner.edges.filter((e) => e.t === id) }
214
+ function searchFiles(q) {
215
+ if (!q) return []
216
+ const needle = q.toLowerCase()
217
+ const out = []
218
+ for (const f of scanner.files.values()) {
219
+ if (f.id.toLowerCase().includes(needle)) out.push(f.id)
220
+ if (out.length >= 100) break
221
+ }
222
+ return out
223
+ }
224
+
225
+ // ── Summary (cached on snapshotVersion) ───────────────────────
226
+ let _summaryCache = { version: -1, data: null }
227
+ function buildSummary() {
228
+ const files = [...scanner.files.values()]
229
+ const byExt = {}
230
+ let dynamicCount = 0
231
+ const incoming = new Map(), outgoing = new Map()
232
+ for (const e of scanner.edges) {
233
+ incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
234
+ outgoing.set(e.s, (outgoing.get(e.s) || 0) + 1)
235
+ }
236
+ for (const f of files) {
237
+ byExt[f.ext || 'other'] = (byExt[f.ext || 'other'] || 0) + 1
238
+ if ((f.dynamicPatterns || []).length > 0) dynamicCount++
239
+ }
240
+ const topHubs = files
241
+ .map((f) => ({ id: f.id, incoming: incoming.get(f.id) || 0, ext: f.ext }))
242
+ .filter((h) => h.incoming >= 2)
243
+ .sort((a, b) => b.incoming - a.incoming).slice(0, 10)
244
+ const folderCount = new Map()
245
+ for (const f of files) {
246
+ const p = f.id.includes('/') ? f.id.slice(0, f.id.lastIndexOf('/')) : '(root)'
247
+ const top = p.split('/')[0] || '(root)'
248
+ folderCount.set(top, (folderCount.get(top) || 0) + 1)
249
+ }
250
+ const topFolders = [...folderCount.entries()]
251
+ .sort((a, b) => b[1] - a[1]).slice(0, 5)
252
+ .map(([p, n]) => ({ path: p, files: n }))
253
+ let orphanCount = 0
254
+ for (const f of files) {
255
+ if ((incoming.get(f.id) || 0) === 0 && (outgoing.get(f.id) || 0) === 0) orphanCount++
256
+ }
257
+ const extMix = Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 5)
258
+ .reduce((o, [k, v]) => (o[k] = v, o), {})
259
+ const ext = getExternalUrls()
260
+ // Confidence distribution — graph completeness signal per file.
261
+ const conf = { high: 0, medium: 0, low: 0 }
262
+ for (const f of files) {
263
+ const c = f.confidence || 'high'
264
+ if (conf[c] != null) conf[c]++
265
+ else conf.high++
266
+ }
267
+ return {
268
+ root: getCurrentRoot(),
269
+ fileCount: files.length,
270
+ edgeCount: scanner.edges.length,
271
+ extMix, topFolders, topHubs,
272
+ orphanCount,
273
+ dynamicPatternFileCount: dynamicCount,
274
+ confidence: conf,
275
+ externalDomainCount: ext.domains.length,
276
+ externalDomainsTop: ext.domains.slice(0, 5).map((d) => d.domain),
277
+ historyEnabled: false, // headless daemon does not write history
278
+ }
279
+ }
280
+ function buildSummaryCached() {
281
+ const v = scanner.snapshotVersion || 0
282
+ if (_summaryCache.version === v && _summaryCache.data) return _summaryCache.data
283
+ const data = buildSummary()
284
+ if (scanner.snapshotVersion === v) _summaryCache = { version: v, data }
285
+ return data
286
+ }
287
+
288
+ // ── External URL aggregator ───────────────────────────────────
289
+ function getExternalUrls() {
290
+ const byDomain = new Map()
291
+ let total = 0
292
+ const add = (rawUrl, fileId, methodHint) => {
293
+ const m = rawUrl.match(/^(https?|wss?):\/\/([^\/:?#]+)/i)
294
+ if (!m) return
295
+ const proto = m[1].toLowerCase()
296
+ const domain = m[2].toLowerCase()
297
+ let bucket = byDomain.get(domain)
298
+ if (!bucket) { bucket = { domain, proto, callers: [] }; byDomain.set(domain, bucket) }
299
+ bucket.callers.push({ file: fileId, url: rawUrl, method: methodHint || (proto.startsWith('ws') ? 'WS' : 'GET') })
300
+ total++
301
+ }
302
+ for (const f of scanner.files.values()) {
303
+ if (f.apiCalls && f.apiCalls.length) {
304
+ for (const c of f.apiCalls) if (/^https?:\/\//i.test(c.url)) add(c.url, f.id, c.method || 'GET')
305
+ }
306
+ if (f.externalUrls && f.externalUrls.length) {
307
+ for (const u of f.externalUrls) add(u.url, f.id, null)
308
+ }
309
+ }
310
+ for (const bucket of byDomain.values()) {
311
+ const seen = new Set()
312
+ bucket.callers = bucket.callers.filter((c) => {
313
+ const k = c.file + '|' + c.url + '|' + c.method
314
+ if (seen.has(k)) return false
315
+ seen.add(k); return true
316
+ })
317
+ }
318
+ total = 0
319
+ for (const b of byDomain.values()) total += b.callers.length
320
+ const domains = [...byDomain.values()].sort((a, b) => b.callers.length - a.callers.length)
321
+ return { domains, totalCalls: total }
322
+ }
323
+
324
+ // ── Blast radius ──────────────────────────────────────────────
325
+ function computeBlastRadius(id, depth = 3, direction = 'users') {
326
+ if (!scanner.files.has(id)) return null
327
+ const visited = new Set([id])
328
+ let frontier = new Set([id])
329
+ const byDepth = [{ depth: 0, ids: [id] }]
330
+ for (let d = 1; d <= depth; d++) {
331
+ const next = new Set()
332
+ for (const fid of frontier) {
333
+ const edges = direction === 'users' ? getUsers(fid) : getDeps(fid)
334
+ for (const e of edges) {
335
+ const neighbor = direction === 'users' ? e.s : e.t
336
+ if (visited.has(neighbor)) continue
337
+ visited.add(neighbor); next.add(neighbor)
338
+ }
339
+ }
340
+ if (next.size === 0) break
341
+ byDepth.push({ depth: d, ids: [...next] })
342
+ frontier = next
343
+ }
344
+ const files = [...visited].map((fid) => {
345
+ const f = scanner.files.get(fid)
346
+ return f ? { id: fid, ext: f.ext, loc: f.loc, size: f.size } : null
347
+ }).filter(Boolean)
348
+ const totalSize = files.reduce((s, f) => s + f.size, 0)
349
+ const totalLoc = files.reduce((s, f) => s + f.loc, 0)
350
+ const tokenEstimate = Math.round(totalSize / 4)
351
+ const categories = { tests: 0, source: 0, config: 0, docs: 0, other: 0 }
352
+ for (const f of files) {
353
+ if (/(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec)\.[a-z]+$/i.test(f.id)) categories.tests++
354
+ else if (/\.(?:json|ya?ml|toml|env|config|conf|ini|lock)(?:\.\w+)?$|^\.[a-z]+rc/i.test(f.id)) categories.config++
355
+ else if (/\.(?:md|mdx|txt|rst|adoc)$/i.test(f.id)) categories.docs++
356
+ else if (f.ext) categories.source++
357
+ else categories.other++
358
+ }
359
+ // Blind-spot marker: files in the impact set (incl. seed) that use dynamic /
360
+ // reflective / DI patterns the static graph can't resolve. Their true edges
361
+ // may be missing, so the real blast could be LARGER — tell the agent exactly
362
+ // where to look instead of letting it trust the count blindly.
363
+ const dynamicFiles = []
364
+ for (const f of files) {
365
+ const sf = scanner.files.get(f.id)
366
+ if (sf && (sf.dynamicPatterns || []).length) dynamicFiles.push({ id: f.id, patterns: sf.dynamicPatterns })
367
+ }
368
+ const caveat = dynamicFiles.length ? {
369
+ incomplete: true,
370
+ reason: 'dynamic/reflective/DI dependencies are not statically resolvable',
371
+ dynamicFiles: dynamicFiles.slice(0, 50),
372
+ note: `${dynamicFiles.length} file(s) in this impact set use dynamic patterns — the true blast may be larger. Inspect these directly before relying on the count.`,
373
+ } : undefined
374
+
375
+ return {
376
+ seed: id, direction, depth,
377
+ totalFiles: files.length, totalSize, totalLoc, tokenEstimate, categories,
378
+ files: files.sort((a, b) => b.size - a.size).slice(0, 200),
379
+ byDepth,
380
+ caveat,
381
+ }
382
+ }
383
+
384
+ // Token-compact view of a blast result for AI agents: keep every scalar
385
+ // summary (counts, tokenEstimate, categories) but cap the per-hop id lists
386
+ // and the file sample so a large blast doesn't cost tens of thousands of
387
+ // tokens. The full lists remain available via `?compact` off / `full=1`.
388
+ function compactBlast(r, perHop = 25) {
389
+ return {
390
+ seed: r.seed, direction: r.direction, depth: r.depth,
391
+ totalFiles: r.totalFiles, totalLoc: r.totalLoc, totalSize: r.totalSize,
392
+ tokenEstimate: r.tokenEstimate, categories: r.categories,
393
+ topFiles: r.files.slice(0, perHop).map((f) => f.id),
394
+ byDepth: r.byDepth.map((d) => ({
395
+ depth: d.depth,
396
+ count: d.ids.length,
397
+ ids: d.ids.slice(0, perHop),
398
+ truncated: Math.max(0, d.ids.length - perHop),
399
+ })),
400
+ compact: true,
401
+ caveat: r.caveat, // carry the blind-spot marker into the compact view
402
+ note: r.totalFiles > perHop
403
+ ? `compact view: ${r.totalFiles} files total, showing top ${perHop}/hop. Re-query with full=1 for complete lists.`
404
+ : undefined,
405
+ }
406
+ }
407
+
408
+ // ── Secret leak: server-only env in client code ───────────────
409
+ // Detect env vars used in frontend files that don't carry a
410
+ // framework "public" prefix — these will be bundled and shipped to
411
+ // the browser, leaking secrets.
412
+ const PUBLIC_PREFIX_RE = /^(?:NEXT_PUBLIC|VITE|REACT_APP|PUBLIC|EXPO_PUBLIC|NUXT_PUBLIC|GATSBY|STORYBOOK)_/
413
+ function buildSecretLeak() {
414
+ const leaks = []
415
+ for (const f of scanner.files.values()) {
416
+ const usage = f.envUsage || []
417
+ if (usage.length === 0) continue
418
+ const cls = classifyFile(f)
419
+ if (cls !== 'frontend') continue
420
+ // Skip CSS/HTML — they can't read process.env at runtime anyway
421
+ if (/\.(?:css|scss|sass|less|html|htm)$/i.test(f.id)) continue
422
+ for (const v of usage) {
423
+ if (PUBLIC_PREFIX_RE.test(v)) continue // explicitly public — OK
424
+ leaks.push({ var: v, file: f.id })
425
+ }
426
+ }
427
+ // Aggregate by var
428
+ const byVar = new Map()
429
+ for (const l of leaks) {
430
+ if (!byVar.has(l.var)) byVar.set(l.var, [])
431
+ byVar.get(l.var).push(l.file)
432
+ }
433
+ const items = [...byVar.entries()].map(([v, files]) => ({ var: v, files })).sort((a, b) => a.var.localeCompare(b.var))
434
+ return {
435
+ leakCount: leaks.length,
436
+ varCount: items.length,
437
+ vars: items,
438
+ }
439
+ }
440
+
441
+ // ── Frontend URL ↔ file mapping ───────────────────────────────
442
+ // File-system based routing for Next.js (app + pages), Astro, SvelteKit.
443
+ // Converts file id → URL path; resolveUrl(input) finds best match.
444
+ function idToRoute(id) {
445
+ let m, url = null, kind = null
446
+ // Next.js app router: src/app/<seg>/page.<ext>
447
+ if ((m = id.match(/^(?:src\/)?app\/(.+)\/page\.(?:tsx|jsx|ts|js|mdx)$/))) {
448
+ url = '/' + m[1]; kind = 'next-app'
449
+ }
450
+ // Next.js app router root: src/app/page.<ext>
451
+ else if (/^(?:src\/)?app\/page\.(?:tsx|jsx|ts|js|mdx)$/.test(id)) {
452
+ url = '/'; kind = 'next-app'
453
+ }
454
+ // Next.js pages router: src/pages/<seg>.<ext> (skip _app, _document, api/)
455
+ else if ((m = id.match(/^(?:src\/)?pages\/(.+)\.(?:tsx|jsx|ts|js|mdx)$/))) {
456
+ const seg = m[1]
457
+ if (seg === '_app' || seg === '_document' || seg.startsWith('api/')) return null
458
+ url = '/' + seg.replace(/\/index$/, '').replace(/^index$/, ''); kind = 'next-pages'
459
+ if (url === '') url = '/'
460
+ }
461
+ // Astro: src/pages/<seg>.{astro,md,mdx}
462
+ else if ((m = id.match(/^(?:src\/)?pages\/(.+)\.(?:astro|md|mdx)$/))) {
463
+ url = '/' + m[1].replace(/\/index$/, '').replace(/^index$/, '')
464
+ if (url === '') url = '/'
465
+ kind = 'astro'
466
+ }
467
+ // SvelteKit: src/routes/<seg>/+page.<ext> or src/routes/+page.<ext>
468
+ else if ((m = id.match(/^(?:src\/)?routes\/(.+)\/\+page\.(?:svelte|js|ts)$/))) {
469
+ url = '/' + m[1]; kind = 'sveltekit'
470
+ }
471
+ else if (/^(?:src\/)?routes\/\+page\.(?:svelte|js|ts)$/.test(id)) {
472
+ url = '/'; kind = 'sveltekit'
473
+ }
474
+ if (!url) return null
475
+ // Normalize: strip Next.js route groups `(name)/`
476
+ url = url.replace(/\/\([^)]+\)\//g, '/').replace(/^\/\([^)]+\)/, '').replace(/\/\([^)]+\)$/, '')
477
+ // Dynamic segments: [...slug] → *, [slug] → :slug
478
+ url = url.replace(/\[\.\.\.(\w+)\]/g, '*').replace(/\[(\w+)\]/g, ':$1')
479
+ if (url === '') url = '/'
480
+ return { id, url, kind }
481
+ }
482
+ function buildAllRoutes() {
483
+ const routes = []
484
+ for (const f of scanner.files.values()) {
485
+ const r = idToRoute(f.id)
486
+ if (r) routes.push(r)
487
+ }
488
+ // Sort: static segments before dynamic
489
+ const staticness = (u) => -((u.match(/:|\*/g) || []).length)
490
+ routes.sort((a, b) => staticness(b.url) - staticness(a.url) || a.url.localeCompare(b.url))
491
+ return routes
492
+ }
493
+ function matchUrl(input) {
494
+ if (!input) return []
495
+ let q = input.startsWith('/') ? input : '/' + input
496
+ q = q.replace(/\?.*$/, '').replace(/#.*$/, '') // strip query/hash
497
+ if (q.length > 1) q = q.replace(/\/$/, '')
498
+ const all = buildAllRoutes()
499
+ const inSegs = q === '/' ? [''] : q.slice(1).split('/')
500
+ const matches = []
501
+ for (const r of all) {
502
+ const rPath = r.url === '/' ? '/' : r.url.replace(/\/$/, '')
503
+ const rSegs = rPath === '/' ? [''] : rPath.slice(1).split('/')
504
+ // Exact length match unless route has catchall *
505
+ const hasCatchall = rSegs[rSegs.length - 1] === '*'
506
+ if (!hasCatchall && rSegs.length !== inSegs.length) continue
507
+ if (hasCatchall && rSegs.length > inSegs.length) continue
508
+ let ok = true, dynamicCount = 0
509
+ for (let i = 0; i < rSegs.length; i++) {
510
+ const rs = rSegs[i], is = inSegs[i] ?? ''
511
+ if (rs === '*') { dynamicCount++; break }
512
+ if (rs.startsWith(':')) { dynamicCount++; continue }
513
+ if (rs !== is) { ok = false; break }
514
+ }
515
+ if (ok) matches.push({ ...r, dynamicCount })
516
+ }
517
+ // Best match = fewest dynamic segments
518
+ matches.sort((a, b) => a.dynamicCount - b.dynamicCount)
519
+ return matches
520
+ }
521
+ function buildUrlIndex(input) {
522
+ if (input) {
523
+ const matches = matchUrl(input)
524
+ return { query: input, matches, count: matches.length }
525
+ }
526
+ const all = buildAllRoutes()
527
+ const byKind = {}
528
+ for (const r of all) byKind[r.kind] = (byKind[r.kind] || 0) + 1
529
+ return { total: all.length, byKind, routes: all }
530
+ }
531
+
532
+ // ── DB schema index ───────────────────────────────────────────
533
+ // Aggregates models extracted from Prisma / Drizzle / SQLAlchemy
534
+ // across the whole repo and indexes them by name.
535
+ function buildSchemas(filter) {
536
+ const all = [] // { kind, name, tableName, fields, definedIn }
537
+ for (const f of scanner.files.values()) {
538
+ for (const m of (f.dbModels || [])) {
539
+ all.push({ ...m, definedIn: f.id })
540
+ }
541
+ }
542
+ if (filter) {
543
+ // Detail view for one model: full definition + usage in code.
544
+ const matches = all.filter((m) => m.name === filter || m.tableName === filter)
545
+ if (matches.length === 0) return null
546
+ // Heuristic usage: identifier substring grep on tracked files.
547
+ // Skip the schema file itself.
548
+ const usedIn = []
549
+ const re = new RegExp('\\b' + filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b')
550
+ for (const f of scanner.files.values()) {
551
+ if (matches.some((m) => m.definedIn === f.id)) continue
552
+ // Cheap content re-read for usage detection. Models are queried
553
+ // rarely so this is acceptable.
554
+ try {
555
+ const fs = require('fs')
556
+ const content = fs.readFileSync(f.absPath, 'utf8')
557
+ if (re.test(content)) usedIn.push(f.id)
558
+ } catch {}
559
+ if (usedIn.length >= 200) break
560
+ }
561
+ return { model: filter, definitions: matches, usedIn, usedCount: usedIn.length }
562
+ }
563
+ // Overview
564
+ const byKind = {}
565
+ for (const m of all) byKind[m.kind] = (byKind[m.kind] || 0) + 1
566
+ const byFile = new Map()
567
+ for (const m of all) {
568
+ const arr = byFile.get(m.definedIn) || []
569
+ arr.push({ kind: m.kind, name: m.name, tableName: m.tableName, fieldCount: m.fields.length })
570
+ byFile.set(m.definedIn, arr)
571
+ }
572
+ return {
573
+ total: all.length,
574
+ byKind,
575
+ files: [...byFile.entries()].map(([file, models]) => ({ file, models })),
576
+ models: all.map((m) => ({
577
+ kind: m.kind, name: m.name, tableName: m.tableName,
578
+ fieldCount: m.fields.length, definedIn: m.definedIn,
579
+ })),
580
+ }
581
+ }
582
+
583
+ // ── Preflight: deploy-readiness check ─────────────────────────
584
+ // Aggregates existing signals into a single go/no-go for non-devs.
585
+ // Each item has status: 'fail' | 'warn' | 'info' | 'ok'.
586
+ // Overall: 'fail' if any fail; 'warn' if any warn but no fail; else 'ok'.
587
+ function buildPreflight(opts = {}) {
588
+ const checks = []
589
+ const env = buildEnv()
590
+ const files = [...scanner.files.values()]
591
+ const ext = getExternalUrls()
592
+ const loc = opts.locale === 'ko' ? 'ko' : 'en'
593
+
594
+ // 1. Undeclared env vars → FAIL
595
+ const undeclared = env.vars.filter((v) => v.status === 'undeclared')
596
+ checks.push(undeclared.length === 0 ? {
597
+ key: 'env-undeclared', status: 'ok',
598
+ title: t('preflight.env_undeclared.ok', loc),
599
+ } : {
600
+ key: 'env-undeclared', status: 'fail',
601
+ title: t('preflight.env_undeclared.fail', loc, undeclared.length),
602
+ detail: t('preflight.env_undeclared.detail', loc),
603
+ evidence: undeclared.slice(0, 10).map((v) => v.var),
604
+ })
605
+
606
+ // 1.5. Secret leak — server-only env in frontend code → FAIL
607
+ const leaks = buildSecretLeak()
608
+ checks.push(leaks.varCount === 0 ? {
609
+ key: 'secret-leak', status: 'ok',
610
+ title: t('preflight.secret_leak.ok', loc),
611
+ } : {
612
+ key: 'secret-leak', status: 'fail',
613
+ title: t('preflight.secret_leak.fail', loc, leaks.varCount),
614
+ detail: t('preflight.secret_leak.detail', loc),
615
+ evidence: leaks.vars.slice(0, 10).map((v) => ({ var: v.var, sample: v.files[0] })),
616
+ })
617
+
618
+ // 2. Plain http:// external URLs → FAIL (security)
619
+ const httpUrls = []
620
+ for (const d of ext.domains) {
621
+ if (d.proto === 'http') {
622
+ for (const c of d.callers.slice(0, 5)) httpUrls.push({ url: c.url, file: c.file })
623
+ }
624
+ }
625
+ checks.push(httpUrls.length === 0 ? {
626
+ key: 'http-urls', status: 'ok',
627
+ title: t('preflight.http_urls.ok', loc),
628
+ } : {
629
+ key: 'http-urls', status: 'fail',
630
+ title: t('preflight.http_urls.fail', loc, httpUrls.length),
631
+ detail: t('preflight.http_urls.detail', loc),
632
+ evidence: httpUrls.slice(0, 10),
633
+ })
634
+
635
+ // 3. Hubs without tests → WARN
636
+ const incoming = new Map()
637
+ for (const e of scanner.edges) incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
638
+ const hubs = files
639
+ .map((f) => ({ ...f, mass: incoming.get(f.id) || 0 }))
640
+ .filter((f) => f.mass >= 10)
641
+ const testCovered = (id) => {
642
+ const users = scanner.edges.filter((e) => e.t === id)
643
+ return users.some((e) => /(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec)\.[a-z]+$/i.test(e.s))
644
+ }
645
+ const uncoveredHubs = hubs.filter((h) => !testCovered(h.id))
646
+ checks.push(uncoveredHubs.length === 0 ? {
647
+ key: 'hub-tests', status: 'ok',
648
+ title: t('preflight.hub_tests.ok', loc),
649
+ } : {
650
+ key: 'hub-tests', status: 'warn',
651
+ title: t('preflight.hub_tests.warn', loc, uncoveredHubs.length),
652
+ detail: t('preflight.hub_tests.detail', loc),
653
+ evidence: uncoveredHubs.slice(0, 5).map((h) => ({ id: h.id, mass: h.mass })),
654
+ })
655
+
656
+ // 4. Orphan ratio > 30% → WARN
657
+ const outgoing = new Map()
658
+ for (const e of scanner.edges) outgoing.set(e.s, (outgoing.get(e.s) || 0) + 1)
659
+ const orphans = files.filter((f) =>
660
+ (incoming.get(f.id) || 0) === 0 &&
661
+ (outgoing.get(f.id) || 0) === 0 &&
662
+ !/\.(?:md|mdx|rst|json|ya?ml|toml|html?|css|scss|sass|less)$/i.test(f.id)
663
+ )
664
+ const orphanRatio = files.length ? orphans.length / files.length : 0
665
+ const orphanPct = Math.round(orphanRatio * 100)
666
+ checks.push(orphanRatio < 0.3 ? {
667
+ key: 'orphans', status: 'ok',
668
+ title: t('preflight.orphans.ok', loc, orphanPct),
669
+ } : {
670
+ key: 'orphans', status: 'warn',
671
+ title: t('preflight.orphans.warn', loc, orphanPct, orphans.length, files.length),
672
+ detail: t('preflight.orphans.detail', loc),
673
+ })
674
+
675
+ // 5. Dynamic import ratio > 10% → INFO
676
+ const dynamicFiles = files.filter((f) => (f.dynamicPatterns || []).length > 0)
677
+ const dynRatio = files.length ? dynamicFiles.length / files.length : 0
678
+ if (dynRatio > 0.1) {
679
+ checks.push({
680
+ key: 'dynamic', status: 'info',
681
+ title: t('preflight.dynamic.info', loc, Math.round(dynRatio * 100)),
682
+ detail: t('preflight.dynamic.detail', loc),
683
+ })
684
+ }
685
+
686
+ // 6. Unused env → INFO
687
+ const unused = env.vars.filter((v) => v.status === 'unused')
688
+ if (unused.length > 0) {
689
+ checks.push({
690
+ key: 'env-unused', status: 'info',
691
+ title: t('preflight.env_unused.info', loc, unused.length),
692
+ detail: t('preflight.env_unused.detail', loc),
693
+ evidence: unused.slice(0, 10).map((v) => v.var),
694
+ })
695
+ }
696
+
697
+ const failCount = checks.filter((c) => c.status === 'fail').length
698
+ const warnCount = checks.filter((c) => c.status === 'warn').length
699
+ const infoCount = checks.filter((c) => c.status === 'info').length
700
+ const okCount = checks.filter((c) => c.status === 'ok').length
701
+ let overall
702
+ if (failCount > 0) overall = 'fail'
703
+ else if (warnCount > 0) overall = 'warn'
704
+ else overall = 'ok'
705
+ return { overall, counts: { fail: failCount, warn: warnCount, info: infoCount, ok: okCount }, checks }
706
+ }
707
+
708
+ // ── Feature → files clustering ────────────────────────────────
709
+ // Heuristic mapping from a feature keyword ("payment", "auth", …) to
710
+ // related source files, bucketed into frontend / backend / shared.
711
+ // Matching: id substring OR route path substring (case-insensitive).
712
+ // Classification: id-path patterns first (most reliable), then ext.
713
+ function classifyFile(f) {
714
+ const id = f.id.toLowerCase()
715
+ // Explicit backend paths (Next.js api routes, Express, Rails, etc.)
716
+ if (/^(?:src\/)?(?:app\/api|api|server|backend|routes|controllers|handlers)(?:\/|$)/.test(id)) return 'backend'
717
+ // Explicit frontend paths
718
+ if (/^(?:src\/)?(?:app|pages|components|ui|screens|views|widgets|public|styles)(?:\/|$)/.test(id)) {
719
+ // Inside src/app/api/ etc was already caught above
720
+ return 'frontend'
721
+ }
722
+ // Ext fallback
723
+ if (/\.(?:tsx|jsx|vue|svelte|astro|css|scss|sass|less|html|htm)$/i.test(f.id)) return 'frontend'
724
+ if (/\.(?:py|pyi|rb|php|go|java|kt|rs|cs|swift)$/i.test(f.id)) return 'backend'
725
+ return 'shared'
726
+ }
727
+ function buildFeature(keyword) {
728
+ if (!keyword || typeof keyword !== 'string') return null
729
+ const needle = keyword.toLowerCase()
730
+ const matched = []
731
+ for (const f of scanner.files.values()) {
732
+ const idMatch = f.id.toLowerCase().includes(needle)
733
+ let routeMatch = false
734
+ if (!idMatch && f.routes) {
735
+ for (const r of f.routes) {
736
+ if ((r.path || '').toLowerCase().includes(needle)) { routeMatch = true; break }
737
+ }
738
+ }
739
+ let apiMatch = false
740
+ if (!idMatch && !routeMatch && f.apiCalls) {
741
+ for (const c of f.apiCalls) {
742
+ if ((c.url || '').toLowerCase().includes(needle)) { apiMatch = true; break }
743
+ }
744
+ }
745
+ if (idMatch || routeMatch || apiMatch) {
746
+ matched.push({
747
+ id: f.id,
748
+ ext: f.ext,
749
+ via: idMatch ? 'path' : routeMatch ? 'route' : 'api-call',
750
+ })
751
+ }
752
+ }
753
+ const buckets = { frontend: [], backend: [], shared: [] }
754
+ for (const m of matched) {
755
+ const f = scanner.files.get(m.id)
756
+ const c = classifyFile(f)
757
+ buckets[c].push(m)
758
+ }
759
+ for (const b of Object.values(buckets)) b.sort((a, b) => a.id.localeCompare(b.id))
760
+ return {
761
+ keyword,
762
+ total: matched.length,
763
+ counts: {
764
+ frontend: buckets.frontend.length,
765
+ backend: buckets.backend.length,
766
+ shared: buckets.shared.length,
767
+ },
768
+ frontend: buckets.frontend,
769
+ backend: buckets.backend,
770
+ shared: buckets.shared,
771
+ }
772
+ }
773
+
774
+ // ── Next-best-action suggestions ──────────────────────────────
775
+ // Rule-based recommendations for what to ask the AI next. Each
776
+ // suggestion includes a priority (high|medium|low), a one-line title,
777
+ // a reason (why it matters), an advice (what to ask the AI), and
778
+ // concrete evidence (file ids, var names, counts).
779
+ function buildSuggestions(topN = 10, opts = {}) {
780
+ const suggestions = []
781
+ const sum = buildSummaryCached()
782
+ const env = buildEnv()
783
+ const files = [...scanner.files.values()]
784
+ const loc = opts.locale === 'ko' ? 'ko' : 'en'
785
+ const sg = SUGGEST_STRINGS[loc]
786
+
787
+ // 1. Undeclared env vars — code reads them but no .env declares them.
788
+ // Almost always means deploy will fail or read undefined.
789
+ const undeclared = env.vars.filter((v) => v.status === 'undeclared')
790
+ if (undeclared.length > 0) {
791
+ const sample = undeclared.slice(0, 5).map((v) => v.var).join(', ') + (undeclared.length > 5 ? ' …' : '')
792
+ suggestions.push({
793
+ priority: 'high',
794
+ title: sg.undeclared.title(undeclared.length),
795
+ why: sg.undeclared.why(),
796
+ advice: sg.undeclared.advice(sample),
797
+ evidence: { count: undeclared.length, vars: undeclared.slice(0, 10).map((v) => v.var) },
798
+ })
799
+ }
800
+
801
+ // 2. Hubs without tests — files many things import, but no test covers.
802
+ // Risk: AI edit breaks downstream silently.
803
+ const incoming = new Map()
804
+ for (const e of scanner.edges) incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
805
+ const hubs = files
806
+ .map((f) => ({ ...f, mass: incoming.get(f.id) || 0 }))
807
+ .filter((f) => f.mass >= 10)
808
+ .sort((a, b) => b.mass - a.mass)
809
+ const testCovered = (id) => {
810
+ // crude: any file that imports id AND looks like a test
811
+ const users = scanner.edges.filter((e) => e.t === id)
812
+ return users.some((e) => /(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec)\.[a-z]+$/i.test(e.s))
813
+ }
814
+ const uncoveredHubs = hubs.filter((h) => !testCovered(h.id))
815
+ if (uncoveredHubs.length > 0) {
816
+ const worst = uncoveredHubs.slice(0, 3)
817
+ suggestions.push({
818
+ priority: 'high',
819
+ title: sg.hub_no_tests.title(uncoveredHubs.length),
820
+ why: sg.hub_no_tests.why(),
821
+ advice: sg.hub_no_tests.advice(worst[0].id, worst[0].mass),
822
+ evidence: { count: uncoveredHubs.length, top: worst.map((h) => ({ id: h.id, mass: h.mass })) },
823
+ })
824
+ }
825
+
826
+ // 3. Orphans — files imported by nothing AND import nothing. Dead code.
827
+ const outgoing = new Map()
828
+ for (const e of scanner.edges) outgoing.set(e.s, (outgoing.get(e.s) || 0) + 1)
829
+ const orphans = files.filter((f) =>
830
+ (incoming.get(f.id) || 0) === 0 &&
831
+ (outgoing.get(f.id) || 0) === 0 &&
832
+ !/(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec|d)\.[a-z]+$/i.test(f.id) &&
833
+ !/\.(md|mdx|rst|json|ya?ml|toml|html?|css|scss|sass|less)$/i.test(f.id)
834
+ )
835
+ if (orphans.length >= 10) {
836
+ suggestions.push({
837
+ priority: 'medium',
838
+ title: sg.orphans.title(orphans.length),
839
+ why: sg.orphans.why(),
840
+ advice: sg.orphans.advice(orphans[0].id),
841
+ evidence: { count: orphans.length, sample: orphans.slice(0, 5).map((f) => f.id) },
842
+ })
843
+ }
844
+
845
+ // 4. Unused env vars — declared in .env* but no code reads them.
846
+ // Could be: legacy leftover, OR a secret left in .env that's not
847
+ // rotated. Either way, candidate for cleanup.
848
+ const unused = env.vars.filter((v) => v.status === 'unused')
849
+ if (unused.length > 0) {
850
+ const sample = unused.slice(0, 5).map((v) => v.var).join(', ') + (unused.length > 5 ? ' …' : '')
851
+ suggestions.push({
852
+ priority: 'medium',
853
+ title: sg.unused_env.title(unused.length),
854
+ why: sg.unused_env.why(),
855
+ advice: sg.unused_env.advice(sample),
856
+ evidence: { count: unused.length, vars: unused.slice(0, 10).map((v) => v.var) },
857
+ })
858
+ }
859
+
860
+ // 4.5. Vendor folders not ignored — graph quality signal.
861
+ // If a project has third-party folders not in .codesynaptignore,
862
+ // hubs/orphans/env results get polluted by vendored code.
863
+ const vendors = scanner.vendorCandidates || []
864
+ const highConfVendors = vendors.filter((v) => v.confidence >= 0.5)
865
+ if (highConfVendors.length > 0) {
866
+ const sample = highConfVendors.map((v) => v.path).slice(0, 3).join(', ')
867
+ const lines = highConfVendors.slice(0, 5).map((v) => v.path + '/').join('\n ')
868
+ suggestions.push({
869
+ priority: 'medium',
870
+ title: sg.vendors.title(highConfVendors.length),
871
+ why: sg.vendors.why(sample),
872
+ advice: sg.vendors.advice(lines),
873
+ evidence: { count: highConfVendors.length, top: highConfVendors.slice(0, 5) },
874
+ })
875
+ }
876
+
877
+ // 5. Dynamic-pattern share — graph completeness signal.
878
+ // >10% of files using dynamic import means AI/blast suggestions
879
+ // are less reliable here; either refactor or accept caveat.
880
+ const dynamicFiles = files.filter((f) => (f.dynamicPatterns || []).length > 0)
881
+ const dynRatio = files.length ? dynamicFiles.length / files.length : 0
882
+ if (dynRatio > 0.1) {
883
+ suggestions.push({
884
+ priority: 'low',
885
+ title: sg.dynamic.title(Math.round(dynRatio * 100)),
886
+ why: sg.dynamic.why(),
887
+ advice: sg.dynamic.advice(dynamicFiles[0]?.id),
888
+ evidence: { count: dynamicFiles.length, ratio: dynRatio.toFixed(3), sample: dynamicFiles.slice(0, 5).map((f) => f.id) },
889
+ })
890
+ }
891
+
892
+ // Sort by priority then take top N
893
+ const order = { high: 0, medium: 1, low: 2 }
894
+ suggestions.sort((a, b) => order[a.priority] - order[b.priority])
895
+ return {
896
+ suggestions: suggestions.slice(0, topN),
897
+ total: suggestions.length,
898
+ contextSnapshot: {
899
+ fileCount: sum.fileCount,
900
+ edgeCount: sum.edgeCount,
901
+ orphanCount: sum.orphanCount,
902
+ envVarStatus: env.counts,
903
+ },
904
+ }
905
+ }
906
+
907
+ // ── Vendor candidates (third-party auto-detect) ──────────────
908
+ function buildVendors() {
909
+ return {
910
+ candidates: scanner.vendorCandidates || [],
911
+ count: (scanner.vendorCandidates || []).length,
912
+ tip: 'Copy paths into .codesynaptignore (one per line, trailing /) to hide from the graph.',
913
+ }
914
+ }
915
+
916
+ // ── Env var index ─────────────────────────────────────────────
917
+ // Cross-reference vars declared in .env files vs vars actually used
918
+ // in source code. Catches:
919
+ // - declared-but-unused (dead config, possible secret leak)
920
+ // - used-but-undeclared (missing from .env.example → deploy will fail)
921
+ function buildEnv(filter) {
922
+ const declared = new Map() // var -> [envFile.id]
923
+ for (const env of (scanner.envFiles || [])) {
924
+ for (const k of env.keys) {
925
+ if (!declared.has(k)) declared.set(k, [])
926
+ declared.get(k).push(env.id)
927
+ }
928
+ }
929
+ const used = new Map() // var -> [code file.id]
930
+ for (const f of scanner.files.values()) {
931
+ const usage = f.envUsage || []
932
+ for (const v of usage) {
933
+ if (!used.has(v)) used.set(v, [])
934
+ used.get(v).push(f.id)
935
+ }
936
+ }
937
+ // Optional: focus on one var
938
+ if (filter) {
939
+ return {
940
+ var: filter,
941
+ declaredIn: declared.get(filter) || [],
942
+ usedIn: used.get(filter) || [],
943
+ }
944
+ }
945
+ const allVars = new Set([...declared.keys(), ...used.keys()])
946
+ const items = []
947
+ for (const v of allVars) {
948
+ const d = declared.get(v) || []
949
+ const u = used.get(v) || []
950
+ items.push({
951
+ var: v,
952
+ declaredIn: d,
953
+ usedIn: u,
954
+ status: d.length === 0 ? 'undeclared'
955
+ : u.length === 0 ? 'unused'
956
+ : 'ok',
957
+ })
958
+ }
959
+ items.sort((a, b) => a.var.localeCompare(b.var))
960
+ return {
961
+ envFiles: (scanner.envFiles || []).map((e) => ({ id: e.id, keyCount: e.keys.length })),
962
+ vars: items,
963
+ counts: {
964
+ total: items.length,
965
+ ok: items.filter((x) => x.status === 'ok').length,
966
+ unused: items.filter((x) => x.status === 'unused').length,
967
+ undeclared: items.filter((x) => x.status === 'undeclared').length,
968
+ },
969
+ }
970
+ }
971
+
972
+ // ── Safety signal ─────────────────────────────────────────────
973
+ // Quick "is it safe to let an AI edit this?" verdict for non-developers.
974
+ // Three buckets:
975
+ // 🔴 RISKY — high blast OR backend endpoint OR external API
976
+ // 🟡 CAUTION — medium blast OR routes OR dynamic patterns
977
+ // 🟢 SAFE — low blast, leaf-ish
978
+ // Returns { id, level, score, reasons:[], blast:{...}, advice }.
979
+ function buildSafety(id, opts = {}) {
980
+ const f = scanner.files.get(id)
981
+ if (!f) return null
982
+ const blast = computeBlastRadius(id, 3, 'users')
983
+ const dependents = blast.totalFiles - 1 // exclude self
984
+ const routeCount = (f.routes || []).length
985
+ const apiCallCount = (f.apiCalls || []).length
986
+ const externalUrlCount = (f.externalUrls || []).length
987
+ const dynamic = (f.dynamicPatterns || []).length > 0
988
+ const testsInBlast = blast.categories.tests
989
+ const loc = opts.locale === 'ko' ? 'ko' : 'en'
990
+
991
+ const reasons = []
992
+ let level = 'safe'
993
+ if (dependents > 30) {
994
+ level = 'risky'
995
+ reasons.push(t('safety.reason.risky_hub', loc, dependents))
996
+ }
997
+ if (routeCount > 0) {
998
+ if (level === 'safe') level = 'caution'
999
+ reasons.push(t('safety.reason.routes', loc, routeCount))
1000
+ }
1001
+ if (externalUrlCount > 0) {
1002
+ if (level === 'safe') level = 'caution'
1003
+ reasons.push(t('safety.reason.external_api', loc, externalUrlCount))
1004
+ }
1005
+ if (apiCallCount > 0 && level === 'safe') {
1006
+ level = 'caution'
1007
+ reasons.push(t('safety.reason.http_client', loc, apiCallCount))
1008
+ }
1009
+ if (dynamic) {
1010
+ if (level === 'safe') level = 'caution'
1011
+ reasons.push(t('safety.reason.dynamic', loc))
1012
+ }
1013
+ if (dependents > 5 && dependents <= 30 && level === 'safe') {
1014
+ level = 'caution'
1015
+ reasons.push(t('safety.reason.dependents', loc, dependents))
1016
+ }
1017
+ if (level === 'safe') {
1018
+ reasons.push(t('safety.reason.safe', loc, dependents))
1019
+ }
1020
+
1021
+ let advice
1022
+ if (level === 'risky') advice = t('safety.advice.risky', loc, id)
1023
+ else if (level === 'caution') advice = t('safety.advice.caution', loc, id)
1024
+ else advice = t('safety.advice.safe', loc, id)
1025
+
1026
+ if (testsInBlast === 0 && dependents > 0 && level !== 'safe') {
1027
+ reasons.push(t('safety.reason.no_tests', loc))
1028
+ }
1029
+
1030
+ // Honesty: the verdict is only as complete as the static graph. If the seed
1031
+ // OR anything in its impact set uses dynamic/reflective/DI patterns, a
1032
+ // dynamic dependent can't be ruled out — so a 🟢/low result is NOT a
1033
+ // certainty. Surface that as `confidence` (localized to this query, so it
1034
+ // doesn't fire on every file of a messy project) rather than silently
1035
+ // implying completeness. Never let an agent read 🟢 as "definitely safe".
1036
+ const confidence = (dynamic || blast.caveat) ? 'limited' : 'high'
1037
+
1038
+ return {
1039
+ id, level, dependents,
1040
+ routes: routeCount, apiCalls: apiCallCount, externalUrls: externalUrlCount,
1041
+ dynamic, testsInBlast,
1042
+ blastTokenEstimate: blast.tokenEstimate,
1043
+ reasons, advice,
1044
+ confidence,
1045
+ caveat: blast.caveat, // dynamic files in the impact set, if any
1046
+ blastFiles: opts.deep ? blast.files.map((bf) => bf.id) : undefined,
1047
+ }
1048
+ }
1049
+
1050
+ // ── AI context bundle ─────────────────────────────────────────
1051
+ // Pack the seed file + its closest dependents into a context bundle
1052
+ // that fits inside a token budget. The intent is to hand this to an
1053
+ // AI agent ("read these N files before editing X") so the agent has
1054
+ // the right neighbours without burning the whole context window.
1055
+ function buildBundle(id, budgetTokens = 8000, depth = 3) {
1056
+ const seed = scanner.files.get(id)
1057
+ if (!seed) return null
1058
+ const blast = computeBlastRadius(id, depth, 'users')
1059
+ // Order: seed first, then by ascending depth and descending mass
1060
+ // (most-used neighbours first within each depth ring).
1061
+ const incoming = new Map()
1062
+ for (const e of scanner.edges) incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
1063
+ const ordered = []
1064
+ for (const ring of blast.byDepth) {
1065
+ const ringFiles = ring.ids
1066
+ .map((fid) => scanner.files.get(fid))
1067
+ .filter(Boolean)
1068
+ .sort((a, b) => (incoming.get(b.id) || 0) - (incoming.get(a.id) || 0))
1069
+ for (const f of ringFiles) ordered.push({ file: f, depth: ring.depth })
1070
+ }
1071
+ // Greedily pack within token budget. Each file costs ceil(size / 4).
1072
+ const picked = []
1073
+ let usedTokens = 0
1074
+ for (const { file, depth: d } of ordered) {
1075
+ const cost = Math.ceil((file.size || 0) / 4)
1076
+ if (usedTokens + cost > budgetTokens && picked.length > 0) break
1077
+ picked.push({ id: file.id, depth: d, ext: file.ext, loc: file.loc, tokenCost: cost })
1078
+ usedTokens += cost
1079
+ }
1080
+ const remaining = ordered.length - picked.length
1081
+ return {
1082
+ seed: id,
1083
+ budgetTokens,
1084
+ usedTokens,
1085
+ depthSearched: depth,
1086
+ files: picked,
1087
+ filesIncluded: picked.length,
1088
+ filesOmitted: remaining,
1089
+ totalCandidates: ordered.length,
1090
+ }
1091
+ }
1092
+
1093
+ // ── Packages (monorepo) ───────────────────────────────────────
1094
+ let _packagesCache = { version: -1, data: null }
1095
+ function buildPackagesCached() {
1096
+ const v = scanner.snapshotVersion || 0
1097
+ if (_packagesCache.version === v && _packagesCache.data) return _packagesCache.data
1098
+ const m = scanner.monorepo
1099
+ if (!m || m.kind === 'none' || !m.packages.length) {
1100
+ const empty = { kind: m?.kind || 'none', packages: [], pkgEdges: [], rootIsPackage: !!m?.rootIsPackage }
1101
+ _packagesCache = { version: v, data: empty }; return empty
1102
+ }
1103
+ const filesByPkg = new Map()
1104
+ for (const f of scanner.files.values()) {
1105
+ if (!f.pkg) continue
1106
+ const arr = filesByPkg.get(f.pkg) || []
1107
+ arr.push(f); filesByPkg.set(f.pkg, arr)
1108
+ }
1109
+ const edgesIn = new Map(), edgesOut = new Map()
1110
+ for (const e of scanner.edges) {
1111
+ const sf = scanner.files.get(e.s), tf = scanner.files.get(e.t)
1112
+ if (!sf || !tf) continue
1113
+ if (sf.pkg && sf.pkg !== tf.pkg) edgesOut.set(sf.pkg, (edgesOut.get(sf.pkg) || 0) + 1)
1114
+ if (tf.pkg && sf.pkg !== tf.pkg) edgesIn.set(tf.pkg, (edgesIn.get(tf.pkg) || 0) + 1)
1115
+ }
1116
+ const packages = m.packages.map((p) => {
1117
+ const files = filesByPkg.get(p.name) || []
1118
+ const loc = files.reduce((s, f) => s + (f.loc || 0), 0)
1119
+ const size = files.reduce((s, f) => s + (f.size || 0), 0)
1120
+ return {
1121
+ name: p.name, relRoot: p.relRoot, manifest: p.manifest,
1122
+ language: p.language, kind: p.kind,
1123
+ fileCount: files.length, loc, size,
1124
+ crossPackageImports: edgesOut.get(p.name) || 0,
1125
+ crossPackageDependents: edgesIn.get(p.name) || 0,
1126
+ }
1127
+ })
1128
+ const data = { kind: m.kind, rootIsPackage: m.rootIsPackage, packages, pkgEdges: scanner.pkgEdges || [] }
1129
+ if (scanner.snapshotVersion === v) _packagesCache = { version: v, data }
1130
+ return data
1131
+ }
1132
+ function buildPackageDetail(name) {
1133
+ const m = scanner.monorepo
1134
+ const pkg = m?.packages?.find((p) => p.name === name)
1135
+ if (!pkg) return null
1136
+ const files = []
1137
+ const incoming = new Map()
1138
+ for (const e of scanner.edges) incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
1139
+ for (const f of scanner.files.values()) {
1140
+ if (f.pkg !== name) continue
1141
+ files.push({ id: f.id, ext: f.ext, loc: f.loc, size: f.size, mass: incoming.get(f.id) || 0 })
1142
+ }
1143
+ files.sort((a, b) => b.mass - a.mass)
1144
+ const outgoingEdges = [], incomingEdges = []
1145
+ for (const e of scanner.edges) {
1146
+ const sf = scanner.files.get(e.s), tf = scanner.files.get(e.t)
1147
+ if (!sf || !tf || !sf.pkg || !tf.pkg || sf.pkg === tf.pkg) continue
1148
+ if (sf.pkg === name) outgoingEdges.push({ s: e.s, t: e.t, k: e.k, toPkg: tf.pkg })
1149
+ if (tf.pkg === name) incomingEdges.push({ s: e.s, t: e.t, k: e.k, fromPkg: sf.pkg })
1150
+ }
1151
+ let declared = []
1152
+ try {
1153
+ if (pkg.manifest === 'package.json') {
1154
+ const j = JSON.parse(fs.readFileSync(path.join(pkg.root, 'package.json'), 'utf8'))
1155
+ const collect = (field) => {
1156
+ if (!j[field]) return
1157
+ for (const [k, v] of Object.entries(j[field])) declared.push({ name: k, spec: v, kind: field })
1158
+ }
1159
+ collect('dependencies'); collect('devDependencies'); collect('peerDependencies')
1160
+ }
1161
+ } catch {}
1162
+ return {
1163
+ name, relRoot: pkg.relRoot, manifest: pkg.manifest,
1164
+ language: pkg.language, kind: pkg.kind,
1165
+ fileCount: files.length, files,
1166
+ outgoingEdges, incomingEdges, declared,
1167
+ }
1168
+ }
1169
+
1170
+ // ── Write/edit (optional — only enabled if writeEnabled=true) ─
1171
+ function writeFile(id, content) {
1172
+ const root = getCurrentRoot()
1173
+ const full = path.join(root, id)
1174
+ if (!isInsideRoot(root, full)) return { ok: false, error: 'outside root' }
1175
+ try {
1176
+ fs.mkdirSync(path.dirname(full), { recursive: true })
1177
+ fs.writeFileSync(full, content, 'utf8')
1178
+ return { ok: true, path: full, bytes: Buffer.byteLength(content, 'utf8') }
1179
+ } catch (e) {
1180
+ return { ok: false, error: e.message }
1181
+ }
1182
+ }
1183
+
1184
+ // ── Audit log ─────────────────────────────────────────────────
1185
+ // Append every request line to ~/.codesynapt/audit/YYYY-MM-DD.jsonl
1186
+ // (or whatever auditLogDir was passed). Failure to write must not
1187
+ // block the response — wrap in try/catch.
1188
+ function auditWrite(entry) {
1189
+ if (!auditLogDir) return
1190
+ try {
1191
+ const day = new Date().toISOString().slice(0, 10)
1192
+ const file = path.join(auditLogDir, `${day}.jsonl`)
1193
+ fs.mkdirSync(auditLogDir, { recursive: true })
1194
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8')
1195
+ } catch { /* silent */ }
1196
+ }
1197
+
1198
+ // ── Main router ───────────────────────────────────────────────
1199
+ function handleControlRequest(req, res) {
1200
+ const startTs = Date.now()
1201
+ // ─── DNS-rebinding defense: validate Host header ──────────
1202
+ // Browsers can be tricked into resolving attacker.com → 127.0.0.1
1203
+ // and firing requests at our localhost port. Rejecting Host headers
1204
+ // that aren't loopback closes this attack class.
1205
+ const hostHeader = (req.headers.host || '').split(':')[0].toLowerCase()
1206
+ if (hostHeader !== '127.0.0.1' && hostHeader !== 'localhost' && hostHeader !== '[::1]') {
1207
+ writeJson(res, 403, { error: 'forbidden host: ' + hostHeader })
1208
+ return
1209
+ }
1210
+ // Audit on response finish (status code captured by then)
1211
+ if (auditLogDir) {
1212
+ res.on('finish', () => {
1213
+ auditWrite({
1214
+ ts: startTs,
1215
+ durMs: Date.now() - startTs,
1216
+ method: req.method,
1217
+ path: req.url,
1218
+ status: res.statusCode,
1219
+ principal: req._principal || 'anonymous',
1220
+ })
1221
+ })
1222
+ }
1223
+ if (req.method === 'OPTIONS') {
1224
+ res.writeHead(204, {
1225
+ // CORS is intentionally restrictive — only same-origin loopback.
1226
+ // CLI/MCP don't use CORS (no Origin header), so they're unaffected.
1227
+ 'Access-Control-Allow-Origin': 'null',
1228
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1229
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1230
+ })
1231
+ return res.end()
1232
+ }
1233
+ // Bearer token validation (only if authToken is configured)
1234
+ if (authToken) {
1235
+ const hdr = req.headers.authorization || ''
1236
+ if (!hdr.startsWith('Bearer ') || hdr.slice(7) !== authToken) {
1237
+ req._principal = 'invalid'
1238
+ return writeJson(res, 401, { error: 'unauthorized — Authorization: Bearer <token> required' })
1239
+ }
1240
+ req._principal = 'authenticated'
1241
+ }
1242
+ const url = new URL(req.url, `http://${req.headers.host}`)
1243
+ const parts = url.pathname.split('/').filter(Boolean)
1244
+ const [seg0, ...rest] = parts
1245
+ const idFromRest = () => decodeURIComponent(rest.join('/'))
1246
+
1247
+ try {
1248
+ if (req.method === 'GET' && parts.length === 0) {
1249
+ return writeJson(res, 200, {
1250
+ name: 'codesynapt',
1251
+ mode: 'headless',
1252
+ endpoints: [
1253
+ 'GET /health', 'GET /summary', 'GET /graph', 'GET /node/:id',
1254
+ 'GET /file/:id', 'GET /deps/:id', 'GET /users/:id', 'GET /find?q=',
1255
+ 'GET /external', 'GET /blast/:id', 'GET /packages',
1256
+ 'GET /package/:name', 'GET /package-graph',
1257
+ 'GET /safety/:id', 'GET /bundle/:id', 'GET /env [/?var=NAME]',
1258
+ 'GET /suggest [?top=N]', 'GET /feature/:keyword', 'GET /preflight',
1259
+ 'GET /schema [?model=Name]', 'GET /url [?path=/...]', 'GET /secrets',
1260
+ 'GET /vendors',
1261
+ 'POST /write/:id', 'POST /edit/:id',
1262
+ ],
1263
+ })
1264
+ }
1265
+ if (req.method === 'GET' && seg0 === 'health') {
1266
+ return writeJson(res, 200, {
1267
+ ok: true, mode: 'headless',
1268
+ root: getCurrentRoot(),
1269
+ fileCount: scanner.files.size,
1270
+ edgeCount: scanner.edges.length,
1271
+ })
1272
+ }
1273
+ if (req.method === 'GET' && seg0 === 'summary') {
1274
+ return writeJson(res, 200, withMeta(buildSummaryCached()))
1275
+ }
1276
+ if (req.method === 'GET' && seg0 === 'graph') {
1277
+ const data = getGraphState()
1278
+ const limit = parseInt(url.searchParams.get('limit') || '0', 10)
1279
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10)
1280
+ const extFilter = url.searchParams.get('ext')
1281
+ const minMass = parseInt(url.searchParams.get('minMass') || '0', 10)
1282
+ const sort = url.searchParams.get('sort') || 'mass:desc'
1283
+ let inc = null
1284
+ const needsInc = sort.startsWith('mass') || minMass > 0
1285
+ if (needsInc) {
1286
+ inc = new Map()
1287
+ for (const e of scanner.edges) inc.set(e.t, (inc.get(e.t) || 0) + 1)
1288
+ }
1289
+ let files = data.files.slice()
1290
+ if (extFilter) files = files.filter((f) => f.ext === extFilter)
1291
+ if (minMass > 0) files = files.filter((f) => (inc.get(f.id) || 0) >= minMass)
1292
+ if (sort !== 'insertion') {
1293
+ const [key, dirRaw] = sort.split(':')
1294
+ const dir = dirRaw === 'asc' ? 1 : -1
1295
+ const getter = key === 'mass' ? ((f) => inc.get(f.id) || 0)
1296
+ : key === 'size' ? ((f) => f.size)
1297
+ : key === 'loc' ? ((f) => f.loc)
1298
+ : key === 'id' ? null : null
1299
+ if (getter) files.sort((a, b) => dir * (getter(a) - getter(b)))
1300
+ else if (key === 'id') files.sort((a, b) => dir * a.id.localeCompare(b.id))
1301
+ }
1302
+ const totalAvailable = files.length
1303
+ const sliced = limit > 0 ? files.slice(offset, offset + limit) : files
1304
+ return writeJson(res, 200, withMeta(
1305
+ { root: data.root, files: sliced, edges: data.edges },
1306
+ { totalAvailable, returned: sliced.length, offset, limit: limit || sliced.length,
1307
+ sort, truncated: limit > 0 && (offset + limit) < totalAvailable }
1308
+ ))
1309
+ }
1310
+ if (req.method === 'GET' && seg0 === 'node' && rest.length > 0) {
1311
+ const id = idFromRest()
1312
+ const node = findNode(id)
1313
+ if (!node) return writeJson(res, 404, { error: 'not found' })
1314
+ return writeJson(res, 200, withMeta({
1315
+ ...node, imports: getDeps(id), importedBy: getUsers(id),
1316
+ }))
1317
+ }
1318
+ if (req.method === 'GET' && seg0 === 'file' && rest.length > 0) {
1319
+ const id = idFromRest()
1320
+ const root = getCurrentRoot()
1321
+ const full = path.join(root, id)
1322
+ if (!isInsideRoot(root, full)) return writeJson(res, 400, { error: 'outside root' })
1323
+ try {
1324
+ const stat = fs.statSync(full)
1325
+ if (!stat.isFile()) return writeJson(res, 404, { error: 'not a file' })
1326
+ if (stat.size > 2_000_000) return writeJson(res, 413, { error: 'file too large', size: stat.size })
1327
+ const buf = fs.readFileSync(full)
1328
+ const contentHash = crypto.createHash('sha256').update(buf).digest('hex')
1329
+ return writeJson(res, 200, {
1330
+ id, content: buf.toString('utf8'),
1331
+ contentHash,
1332
+ size: stat.size,
1333
+ })
1334
+ } catch (e) {
1335
+ // Missing path is a client error (bad id), not a server fault.
1336
+ if (e.code === 'ENOENT') return writeJson(res, 404, { error: 'not found', id })
1337
+ return writeJson(res, 500, { error: e.message })
1338
+ }
1339
+ }
1340
+ if (req.method === 'GET' && seg0 === 'deps' && rest.length > 0) {
1341
+ const id = idFromRest()
1342
+ if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
1343
+ return writeJson(res, 200, getDeps(id))
1344
+ }
1345
+ if (req.method === 'GET' && seg0 === 'users' && rest.length > 0) {
1346
+ const id = idFromRest()
1347
+ if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
1348
+ return writeJson(res, 200, getUsers(id))
1349
+ }
1350
+ if (req.method === 'GET' && seg0 === 'find') {
1351
+ return writeJson(res, 200, searchFiles(url.searchParams.get('q') || ''))
1352
+ }
1353
+ if (req.method === 'GET' && seg0 === 'external') {
1354
+ return writeJson(res, 200, getExternalUrls())
1355
+ }
1356
+ if (req.method === 'GET' && seg0 === 'env' && rest.length === 0) {
1357
+ const v = url.searchParams.get('var')
1358
+ return writeJson(res, 200, withMeta(buildEnv(v)))
1359
+ }
1360
+ if (req.method === 'GET' && seg0 === 'secrets' && rest.length === 0) {
1361
+ return writeJson(res, 200, withMeta(buildSecretLeak()))
1362
+ }
1363
+ if (req.method === 'GET' && seg0 === 'vendors' && rest.length === 0) {
1364
+ return writeJson(res, 200, withMeta(buildVendors()))
1365
+ }
1366
+ if (req.method === 'GET' && seg0 === 'url' && rest.length === 0) {
1367
+ const p = url.searchParams.get('path')
1368
+ return writeJson(res, 200, withMeta(buildUrlIndex(p)))
1369
+ }
1370
+ if (req.method === 'GET' && seg0 === 'schema' && rest.length === 0) {
1371
+ const model = url.searchParams.get('model')
1372
+ const r = buildSchemas(model)
1373
+ if (model && !r) return writeJson(res, 404, { error: 'model not found', model })
1374
+ return writeJson(res, 200, withMeta(r))
1375
+ }
1376
+ if (req.method === 'GET' && seg0 === 'preflight' && rest.length === 0) {
1377
+ const locale = url.searchParams.get('locale')
1378
+ return writeJson(res, 200, withMeta(buildPreflight({ locale })))
1379
+ }
1380
+ if (req.method === 'GET' && seg0 === 'feature' && rest.length > 0) {
1381
+ const r = buildFeature(idFromRest())
1382
+ if (!r) return writeJson(res, 400, { error: 'usage: GET /feature/<keyword>' })
1383
+ return writeJson(res, 200, withMeta(r))
1384
+ }
1385
+ if (req.method === 'GET' && seg0 === 'suggest' && rest.length === 0) {
1386
+ const top = parseInt(url.searchParams.get('top') || '10', 10)
1387
+ const locale = url.searchParams.get('locale')
1388
+ return writeJson(res, 200, withMeta(buildSuggestions(top, { locale })))
1389
+ }
1390
+ if (req.method === 'GET' && seg0 === 'safety' && rest.length > 0) {
1391
+ const id = idFromRest()
1392
+ const deep = url.searchParams.get('deep') === '1' || url.searchParams.get('deep') === 'true'
1393
+ const locale = url.searchParams.get('locale')
1394
+ const r = buildSafety(id, { deep, locale })
1395
+ if (!r) return writeJson(res, 404, { error: 'not found' })
1396
+ return writeJson(res, 200, withMeta(r))
1397
+ }
1398
+ if (req.method === 'GET' && seg0 === 'bundle' && rest.length > 0) {
1399
+ const id = idFromRest()
1400
+ const budget = parseInt(url.searchParams.get('budget') || '8000', 10)
1401
+ const depth = Math.max(1, Math.min(10, parseInt(url.searchParams.get('depth') || '3', 10)))
1402
+ const r = buildBundle(id, budget, depth)
1403
+ if (!r) return writeJson(res, 404, { error: 'not found' })
1404
+ return writeJson(res, 200, withMeta(r))
1405
+ }
1406
+ if (req.method === 'GET' && seg0 === 'blast' && rest.length > 0) {
1407
+ const id = idFromRest()
1408
+ const depth = Math.max(1, Math.min(10, parseInt(url.searchParams.get('depth') || '3', 10)))
1409
+ const dir = url.searchParams.get('dir') === 'deps' ? 'deps' : 'users'
1410
+ const r = computeBlastRadius(id, depth, dir)
1411
+ if (!r) return writeJson(res, 404, { error: 'not found' })
1412
+ // IPC highlight always uses the full file set (desktop UI unaffected).
1413
+ if (onBlast) { try { onBlast({ seed: id, ids: r.files.map((f) => f.id) }) } catch {} }
1414
+ // Explicit truthiness: `compact=0` / `compact=false` must mean OFF,
1415
+ // and `full=1` forces the complete view (matches the MCP contract).
1416
+ const cp = url.searchParams.get('compact')
1417
+ const wantCompact = cp != null && cp !== '0' && cp !== 'false' && url.searchParams.get('full') !== '1'
1418
+ if (wantCompact) {
1419
+ const lim = Math.max(1, Math.min(200, parseInt(url.searchParams.get('limit') || '25', 10)))
1420
+ return writeJson(res, 200, compactBlast(r, lim))
1421
+ }
1422
+ return writeJson(res, 200, r)
1423
+ }
1424
+ if (req.method === 'GET' && seg0 === 'packages' && rest.length === 0) {
1425
+ return writeJson(res, 200, withMeta(buildPackagesCached()))
1426
+ }
1427
+ if (req.method === 'GET' && seg0 === 'package' && rest.length > 0) {
1428
+ const d = buildPackageDetail(idFromRest())
1429
+ if (!d) return writeJson(res, 404, { error: 'package not found' })
1430
+ return writeJson(res, 200, withMeta(d))
1431
+ }
1432
+ if (req.method === 'GET' && seg0 === 'package-graph') {
1433
+ const data = buildPackagesCached()
1434
+ return writeJson(res, 200, withMeta({
1435
+ kind: data.kind,
1436
+ packages: data.packages.map((p) => ({ name: p.name, fileCount: p.fileCount })),
1437
+ edges: data.pkgEdges,
1438
+ }))
1439
+ }
1440
+ if (req.method === 'POST' && seg0 === 'focus' && rest.length > 0) {
1441
+ const id = idFromRest()
1442
+ if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
1443
+ if (onFocus) { try { onFocus(id) } catch {} }
1444
+ return writeJson(res, 200, { ok: true, id, dispatched: !!onFocus })
1445
+ }
1446
+ if (req.method === 'POST' && seg0 === 'open' && rest.length > 0) {
1447
+ const id = idFromRest()
1448
+ if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
1449
+ if (onOpen) { try { onOpen(id) } catch {} }
1450
+ return writeJson(res, 200, { ok: true, id, dispatched: !!onOpen })
1451
+ }
1452
+ if (req.method === 'POST' && (seg0 === 'write' || seg0 === 'edit') && rest.length > 0) {
1453
+ // Mutating endpoint — never allow UNauthenticated writes. If the server
1454
+ // was started without a token, edits are disabled entirely; if a token
1455
+ // is set, require it here too (independent of read-side policy).
1456
+ const auth = String(req.headers['authorization'] || '')
1457
+ if (!authToken) return writeJson(res, 403, { error: 'write disabled: start the server with CS_AUTH_TOKEN to enable edits' })
1458
+ if (!auth.startsWith('Bearer ') || auth.slice(7) !== authToken) {
1459
+ return writeJson(res, 401, { error: 'write requires Authorization: Bearer <token>' })
1460
+ }
1461
+ const id = idFromRest()
1462
+ const root = getCurrentRoot()
1463
+ const full = path.join(root, id)
1464
+ if (!isInsideRoot(root, full)) return writeJson(res, 400, { error: 'outside root' })
1465
+ let bodyChunks = [], bodyLen = 0, tooBig = false
1466
+ req.on('data', (c) => {
1467
+ bodyLen += c.length
1468
+ if (bodyLen > 10 * 1024 * 1024) { tooBig = true; req.destroy(); return } // 10 MB cap
1469
+ bodyChunks.push(c)
1470
+ })
1471
+ req.on('error', () => { try { writeJson(res, 400, { error: 'request stream error' }) } catch {} })
1472
+ req.on('end', () => {
1473
+ if (tooBig) return writeJson(res, 413, { error: 'request body too large (max 10MB)' })
1474
+ let body
1475
+ try { body = JSON.parse(Buffer.concat(bodyChunks).toString('utf8')) }
1476
+ catch { return writeJson(res, 400, { error: 'invalid JSON body' }) }
1477
+ if (seg0 === 'write') {
1478
+ if (typeof body.content !== 'string') return writeJson(res, 400, { error: 'usage: { "content": "..." }' })
1479
+ const r = writeFile(id, body.content)
1480
+ if (!r.ok) return writeJson(res, 500, r)
1481
+ return writeJson(res, 200, withMeta({ ...r, id }))
1482
+ }
1483
+ if (typeof body.find !== 'string' || typeof body.replace !== 'string') {
1484
+ return writeJson(res, 400, { error: 'usage: { "find": "...", "replace": "...", "replaceAll": false }' })
1485
+ }
1486
+ let content
1487
+ try { content = fs.readFileSync(full, 'utf8') }
1488
+ catch (e) { return writeJson(res, 500, { error: 'read failed: ' + e.message }) }
1489
+ const findStr = body.find
1490
+ if (!findStr) return writeJson(res, 400, { error: 'find string cannot be empty' })
1491
+ let count = 0, idx = 0
1492
+ while ((idx = content.indexOf(findStr, idx)) !== -1) { count++; idx += findStr.length }
1493
+ if (count === 0) return writeJson(res, 404, { error: 'find string not found' })
1494
+ const replaceAll = body.replaceAll === true
1495
+ if (!replaceAll && count > 1) {
1496
+ return writeJson(res, 409, {
1497
+ error: `find string is not unique (${count} occurrences). Pass replaceAll:true.`,
1498
+ occurrences: count,
1499
+ })
1500
+ }
1501
+ const next = replaceAll
1502
+ ? content.split(findStr).join(body.replace)
1503
+ : content.replace(findStr, body.replace)
1504
+ const r = writeFile(id, next)
1505
+ if (!r.ok) return writeJson(res, 500, r)
1506
+ return writeJson(res, 200, withMeta({ ...r, id, replacements: replaceAll ? count : 1 }))
1507
+ })
1508
+ return
1509
+ }
1510
+ return writeJson(res, 404, { error: 'unknown endpoint', path: url.pathname })
1511
+ } catch (e) {
1512
+ return writeJson(res, 500, { error: e.message })
1513
+ }
1514
+ }
1515
+
1516
+ // ── Server lifecycle ──────────────────────────────────────────
1517
+ let server = null
1518
+ function startControlServer(port, host = '127.0.0.1') {
1519
+ return new Promise((resolve, reject) => {
1520
+ if (server) return resolve({ port, alreadyRunning: true })
1521
+ server = http.createServer(handleControlRequest)
1522
+ server.on('error', (err) => {
1523
+ server = null
1524
+ reject(err)
1525
+ })
1526
+ server.listen(port, host, () => resolve({ port, host }))
1527
+ })
1528
+ }
1529
+ function stopControlServer() {
1530
+ return new Promise((resolve) => {
1531
+ if (!server) return resolve()
1532
+ server.close(() => { server = null; resolve() })
1533
+ })
1534
+ }
1535
+
1536
+ return { handleControlRequest, startControlServer, stopControlServer }
1537
+ }
1538
+
1539
+ module.exports = { createControlServer }