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