axmap-cli 0.0.1
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/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
package/app/server.mjs
ADDED
|
@@ -0,0 +1,1959 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* axMap 뷰어 — 로컬 웹서버.
|
|
4
|
+
*
|
|
5
|
+
* node app/server.mjs <대상경로> [포트]
|
|
6
|
+
* node app/server.mjs ../e101_hj/redesign/S15P11E101/BE_robot 7777
|
|
7
|
+
*
|
|
8
|
+
* 브라우저로도 열리고 Electron 셸(`desktop/`)로도 열린다. 셸은 이 파일을 자식
|
|
9
|
+
* 프로세스로 띄우고 창을 그리로 가리킬 뿐이라, 여기는 셸의 존재를 모른다 —
|
|
10
|
+
* import 방향은 `desktop/` → 코어 단방향이다 (docs/DECISIONS.md D16).
|
|
11
|
+
*
|
|
12
|
+
* 외부 의존성 0. 이유는 폐쇄망에서 npm 이 안 되기 때문이 **아니다**(설치본에는 번들되어
|
|
13
|
+
* 나간다). 이 프로세스가 `src/protocol.mjs` 의 판정을 들고 돌기 때문에 감사 대상 코드를
|
|
14
|
+
* 최소로 유지하는 것이다. 의존성을 쓰는 자리는 `desktop/` 이다.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import http from 'node:http'
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import {
|
|
23
|
+
askOf, build, coupling, depths, detail, entryOf, referenceEdges, scan,
|
|
24
|
+
} from './lib/analyze.mjs'
|
|
25
|
+
import { resolveThresholds, dominantLang, fetchBaseline, bandOf, cellFor } from './lib/ssot.mjs'
|
|
26
|
+
import { registrationPoints, say as sayNewFile } from './lib/newfile.mjs'
|
|
27
|
+
// 겹침 판정은 프로토콜의 것을 그대로 쓴다 — 화면과 락이 다른 답을 하면 안 된다.
|
|
28
|
+
import { coversPath } from '../src/protocol.mjs'
|
|
29
|
+
import {
|
|
30
|
+
authorMap, commitFiles, modifiedFiles, overlay, readClaims, summarize as liveSummary, watch, whoAmI,
|
|
31
|
+
} from './lib/live.mjs'
|
|
32
|
+
import { applyEdits, draft, featureEdges } from './lib/features.mjs'
|
|
33
|
+
import { aliasImportEdges, coChange, lastCommitSetsError, overlayEdges, readCommitSets } from './lib/cochange.mjs'
|
|
34
|
+
import { historyNodes } from './lib/datanodes.mjs'
|
|
35
|
+
import { featureScope } from './lib/scope.mjs'
|
|
36
|
+
import { findRoots, splitNeeded } from './lib/roots.mjs'
|
|
37
|
+
import { coreSubset, entryPoints } from './lib/entry.mjs'
|
|
38
|
+
import { featureAdjacency } from './lib/adjacent.mjs'
|
|
39
|
+
import { basicFlow, entryStarts } from './lib/flow.mjs'
|
|
40
|
+
import { ladder, pathBetween, isAux } from './lib/ladder.mjs'
|
|
41
|
+
import { featureGraph, modulesOf, docLines, matchDocs } from './lib/featuregraph.mjs'
|
|
42
|
+
import { fetchRepo, isRemote } from './lib/fetchrepo.mjs'
|
|
43
|
+
import { prReview } from './lib/prdiff.mjs'
|
|
44
|
+
import { detect as detectAgents, preferred as preferredAgent } from './lib/agentcli.mjs'
|
|
45
|
+
import * as sess from './lib/session.mjs'
|
|
46
|
+
import * as llm from './lib/llm.mjs'
|
|
47
|
+
|
|
48
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
49
|
+
const WEB = path.join(HERE, 'web')
|
|
50
|
+
/** `app/` 자신. 순수 모듈(`app/lib/*.mjs`)을 화면에 주기 위해 쓴다. */
|
|
51
|
+
const ROOT_DIR_OF_APP = path.dirname(WEB)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 대상은 로컬 경로 **또는 git 주소**다 — 서사 1단계 (D14).
|
|
55
|
+
*
|
|
56
|
+
* node app/server.mjs . 로컬
|
|
57
|
+
* node app/server.mjs https://github.com/pallets/flask 원격
|
|
58
|
+
* node app/server.mjs github.com/pallets/flask 스킴 생략도 받는다
|
|
59
|
+
*
|
|
60
|
+
* 원격이면 받아서 캐시에 두고 그 경로를 쓴다. 두 번째부터는 다시 안 받는다.
|
|
61
|
+
*/
|
|
62
|
+
const TARGET = process.argv[2] ?? process.cwd()
|
|
63
|
+
let CLONED = null
|
|
64
|
+
if (isRemote(TARGET)) {
|
|
65
|
+
try {
|
|
66
|
+
CLONED = fetchRepo(TARGET, { onLog: (m) => console.log(` ${m}`) })
|
|
67
|
+
console.log(` 커밋 ${CLONED.commits.toLocaleString()}개${CLONED.cached ? ' (캐시)' : ''}\n`)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// 실패를 삼키고 빈 폴더로 진행하면 "저장소가 비었다" 는 틀린 결론이 화면에 뜬다.
|
|
70
|
+
console.error(`\n ${e.message}\n`)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const ROOT = path.resolve(CLONED?.dir ?? TARGET)
|
|
75
|
+
const PORT = Number(process.argv[3] ?? 7777)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 읽기전용 모드 — 밖으로 열 때 쓴다.
|
|
79
|
+
*
|
|
80
|
+
* 🔴 이 뷰어는 localhost 전용으로 설계됐다. `127.0.0.1` 에만 바인딩하는 것이
|
|
81
|
+
* 유일한 방어선이었고, 그 전제 위에서 파일을 쓰고 LLM 키를 저장하는
|
|
82
|
+
* 엔드포인트를 열어뒀다.
|
|
83
|
+
*
|
|
84
|
+
* 터널·리버스프록시로 밖에 내보내는 순간 그 전제가 깨진다. 그래서 상태를
|
|
85
|
+
* 바꾸는 모든 경로를 한 곳에서 막는다. **막는 쪽을 기본으로 두지 않은 것은
|
|
86
|
+
* 로컬 개발을 위해서지만, 노출할 때는 반드시 켜야 한다.**
|
|
87
|
+
*
|
|
88
|
+
* node app/server.mjs <경로> <포트> --readonly
|
|
89
|
+
*/
|
|
90
|
+
const READONLY = process.argv.includes('--readonly')
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Basic 인증 — `--auth 사용자:비밀번호` 또는 환경변수 `AXMAP_AUTH`.
|
|
94
|
+
*
|
|
95
|
+
* 리버스 프록시가 인증을 걸어주지 못하는 자리가 있다. 그럴 때 앱이 스스로 건다.
|
|
96
|
+
* 읽기전용이어도 **소스 코드가 그대로 보이므로** 밖으로 열 때는 인증이 필수다.
|
|
97
|
+
*
|
|
98
|
+
* 🔴 상시 서비스로 돌릴 때는 `--auth` 를 쓰지 않는다.
|
|
99
|
+
*
|
|
100
|
+
* argv 는 `/proc/<pid>/cmdline` 에 그대로 있고 그 파일은 **누구나 읽는다.**
|
|
101
|
+
* 그래서 `ps aux` 한 줄이면 비밀번호가 나온다 — AWS 에 systemd 로 올리면서
|
|
102
|
+
* 실제로 그렇게 새고 있었다. 환경변수는 `/proc/<pid>/environ` 에 있지만
|
|
103
|
+
* 그쪽은 프로세스 소유자만 읽는다.
|
|
104
|
+
*
|
|
105
|
+
* `--auth` 를 남겨두는 이유는 손으로 잠깐 띄울 때 편하기 때문이고, 그때는
|
|
106
|
+
* 셸 히스토리가 이미 같은 값을 알고 있으므로 잃을 것이 없다.
|
|
107
|
+
* 둘 다 있으면 `--auth` 가 이긴다 — 손으로 준 것이 더 구체적인 의도다.
|
|
108
|
+
*/
|
|
109
|
+
/**
|
|
110
|
+
* 🔴 공개로 여는데 자격증명이 없으면 **뜨지 않는다.**
|
|
111
|
+
*
|
|
112
|
+
* 한때 여기에 옛 이름 환경변수를 받아주는 분기가 있었다. 서버의 env 파일이
|
|
113
|
+
* 아직 옛 이름이었기 때문인데, 그 호환이 가리고 있던 진짜 문제는 따로 있었다 —
|
|
114
|
+
* **자격증명이 비면 이 함수가 조용히 `null` 을 돌려주고, 공개 뷰어가 인증 없이
|
|
115
|
+
* 소스를 통째로 내보낸다.** 아무 오류도 안 난다.
|
|
116
|
+
*
|
|
117
|
+
* 그래서 호환을 늘리는 대신 실패 방향을 뒤집었다. `--readonly`(공개 배포)인데
|
|
118
|
+
* 자격증명이 없으면 **거부하고 죽는다.** env 파일 이름이 틀리면 서버가 안 뜨고,
|
|
119
|
+
* 안 뜨는 것은 사람이 즉시 안다. 반대는 아무도 모른다.
|
|
120
|
+
*
|
|
121
|
+
* 손으로 로컬에서 띄울 때(`--readonly` 없이)는 그대로 인증 없이 돈다.
|
|
122
|
+
* 그때는 127.0.0.1 에만 열리고 잃을 것이 없다.
|
|
123
|
+
*/
|
|
124
|
+
const AUTH = (() => {
|
|
125
|
+
const i = process.argv.indexOf('--auth')
|
|
126
|
+
if (i >= 0 && process.argv[i + 1]) {
|
|
127
|
+
return `Basic ${Buffer.from(process.argv[i + 1]).toString('base64')}`
|
|
128
|
+
}
|
|
129
|
+
const cred = process.env.AXMAP_AUTH
|
|
130
|
+
if (!cred) {
|
|
131
|
+
if (READONLY) {
|
|
132
|
+
console.error(
|
|
133
|
+
'공개(--readonly)로 열려는데 자격증명이 없습니다.\n' +
|
|
134
|
+
' AXMAP_AUTH=사용자:비밀번호 또는 --auth 사용자:비밀번호\n' +
|
|
135
|
+
'인증 없이 공개하면 소스가 통째로 나갑니다. 그래서 뜨지 않고 멈춥니다.',
|
|
136
|
+
)
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
return `Basic ${Buffer.from(cred).toString('base64')}`
|
|
142
|
+
})()
|
|
143
|
+
|
|
144
|
+
/** 길이에 무관한 시간으로 비교한다. 빨리 끝나는 비교는 문자 단위로 정답을 알려준다. */
|
|
145
|
+
function sameSecret(a, b) {
|
|
146
|
+
const A = Buffer.from(a), B = Buffer.from(b)
|
|
147
|
+
if (A.length !== B.length) return false
|
|
148
|
+
let d = 0
|
|
149
|
+
for (let k = 0; k < A.length; k++) d |= A[k] ^ B[k]
|
|
150
|
+
return d === 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 🔴 API 접두사를 바꿀 수 있게 한다.
|
|
155
|
+
*
|
|
156
|
+
* 리버스 프록시 아래에 붙일 때 `/api/` 가 남의 것과 부딪힌다. 이 서버를
|
|
157
|
+
* `/dev/` 아래에 붙이려 했더니 nginx 에 `location /dev/api/` 가 따로 있어서
|
|
158
|
+
* 우리 요청이 **다른 백엔드로 흘러갔다.** 그쪽 응답을 우리 데이터인 줄 알고
|
|
159
|
+
* 파싱하게 된다 — 조용히 틀리는 최악의 방식이다.
|
|
160
|
+
*
|
|
161
|
+
* 그래서 `/_bm/` 을 별칭으로 받는다. 화면은 이쪽을 쓰고, 로컬에서는 `/api/` 도
|
|
162
|
+
* 그대로 동작한다.
|
|
163
|
+
*/
|
|
164
|
+
const API_ALIAS = '/_bm/'
|
|
165
|
+
|
|
166
|
+
if (!fs.existsSync(ROOT)) {
|
|
167
|
+
console.error(`대상 경로가 없습니다: ${ROOT}`)
|
|
168
|
+
process.exit(1)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// 그래프 캐시 — 파일이 바뀌면 무효화
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 코퍼스 기준. 서버가 뜨기 전에 한 번 받는다.
|
|
177
|
+
*
|
|
178
|
+
* 🔴 첫 요청 뒤에 받으면 안 된다. 그래프는 지연 생성되고 문턱값은 **생성 시점에**
|
|
179
|
+
* 박히므로, 늦게 도착한 기준은 이미 만들어진 그래프에 반영되지 않는다.
|
|
180
|
+
* 그러면 화면은 "코퍼스 기준 적용" 이라고 말하면서 기본값으로 그린 그래프를
|
|
181
|
+
* 보여주게 된다 — 도구가 거짓말하는 종류의 실패다.
|
|
182
|
+
*/
|
|
183
|
+
const { baseline: BASELINE, from: BASELINE_FROM, error: BASELINE_ERR } = await fetchBaseline()
|
|
184
|
+
|
|
185
|
+
/** 저장소 커밋 수. 코퍼스의 규모 칸(small/mid/large)을 고르는 데 쓴다. */
|
|
186
|
+
function commitCount() {
|
|
187
|
+
const r = git(['rev-list', '--count', 'HEAD'])
|
|
188
|
+
const n = Number((r.stdout ?? '').trim())
|
|
189
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 저장소 받아오기 (자식 프로세스) ────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/** 진행 중이거나 끝난 클론 작업. id → {state, root, error, log[]} */
|
|
195
|
+
const fetchJobs = new Map()
|
|
196
|
+
let fetchSeq = 0
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 클론을 자식에게 맡기고 **작업 id 만 돌려준다.** 이 프로세스는 막히지 않는다.
|
|
200
|
+
* 왜 자식에게 넘기는지는 `app/lib/fetchrepo-run.mjs` 머리말에 있다.
|
|
201
|
+
*/
|
|
202
|
+
function startFetch(url) {
|
|
203
|
+
const id = `f${++fetchSeq}`
|
|
204
|
+
const job = { id, state: 'running', url, root: null, error: null, log: [] }
|
|
205
|
+
fetchJobs.set(id, job)
|
|
206
|
+
|
|
207
|
+
const child = spawn(process.execPath, [path.join(HERE, 'lib', 'fetchrepo-run.mjs'), url], {
|
|
208
|
+
cwd: HERE,
|
|
209
|
+
windowsHide: true,
|
|
210
|
+
/**
|
|
211
|
+
* 🔴 stdin 을 막고 git 이 **묻지 못하게** 한다.
|
|
212
|
+
*
|
|
213
|
+
* 자격증명이 필요한 주소(사설 저장소, 만료된 토큰)에서 git 은 아이디를
|
|
214
|
+
* 물어보며 stdin 을 기다린다. 창이 없으니 아무도 대답하지 않고, 작업은
|
|
215
|
+
* **영원히 "받아오는 중"** 에 머문다. 실제로 그렇게 멈췄다 — 오류도 없고
|
|
216
|
+
* 타임아웃도 없어서 사용자는 저장소가 큰가 보다 하고 기다리게 된다.
|
|
217
|
+
*
|
|
218
|
+
* 물어보지 못하게 하면 git 이 그 자리에서 실패하고, 우리는 그 이유를
|
|
219
|
+
* 그대로 화면에 올린다. 기다리게 하는 것보다 거절하는 것이 낫다.
|
|
220
|
+
*/
|
|
221
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
222
|
+
env: {
|
|
223
|
+
...process.env,
|
|
224
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
225
|
+
/**
|
|
226
|
+
* 윈도우의 Git Credential Manager. `never` 는 **캐시된 자격증명은 그대로
|
|
227
|
+
* 쓰되 창을 띄우지 않는다** — 권한 있는 사설 저장소는 계속 열리고,
|
|
228
|
+
* 권한 없는 곳에서만 즉시 실패한다.
|
|
229
|
+
*/
|
|
230
|
+
GCM_INTERACTIVE: 'never',
|
|
231
|
+
/**
|
|
232
|
+
* ssh 주소(`git@…`)도 같은 함정이 있다. 암호구절이나 호스트 키를 물으면
|
|
233
|
+
* 역시 영원히 멈춘다. `BatchMode` 가 그 자리에서 실패하게 만든다.
|
|
234
|
+
*/
|
|
235
|
+
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
let out = ''
|
|
240
|
+
child.stdout.setEncoding('utf8')
|
|
241
|
+
child.stdout.on('data', (c) => { out += c })
|
|
242
|
+
// 진행 로그는 stderr 로 온다. 화면이 "받아오는 중" 대신 진짜 상황을 보여줄 수 있다.
|
|
243
|
+
child.stderr.setEncoding('utf8')
|
|
244
|
+
child.stderr.on('data', (c) => {
|
|
245
|
+
for (const line of String(c).split('\n')) if (line.trim()) job.log.push(line.trim())
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
child.on('error', (e) => { job.state = 'error'; job.error = e.message })
|
|
249
|
+
child.on('close', (code) => {
|
|
250
|
+
if (job.state === 'error') return
|
|
251
|
+
let r = null
|
|
252
|
+
try { r = JSON.parse(out) } catch { /* 아래에서 실패로 떨어진다 */ }
|
|
253
|
+
if (code !== 0 || !r || r.error) {
|
|
254
|
+
job.state = 'error'
|
|
255
|
+
/**
|
|
256
|
+
* 🔴 자식이 왜 죽었는지 삼키지 않는다.
|
|
257
|
+
* "실패했습니다" 만 뜨면 사용자는 주소가 틀린 건지 네트워크가 막힌 건지
|
|
258
|
+
* 사설 저장소라 권한이 없는 건지 알 수 없다. git 이 한 말을 그대로 올린다.
|
|
259
|
+
*/
|
|
260
|
+
job.error = r?.error || job.log.slice(-3).join(' / ') || `종료 코드 ${code}`
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
Object.assign(job, { state: 'done', root: r.dir, commits: r.commits, cached: r.cached })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
return id
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let graphCache = null
|
|
270
|
+
function graph() {
|
|
271
|
+
if (!graphCache) {
|
|
272
|
+
const t0 = Date.now()
|
|
273
|
+
const files = scan(ROOT)
|
|
274
|
+
// 문턱값은 이 저장소의 성격(언어·규모)에 따라 달라진다. 파일을 봐야 정해지므로
|
|
275
|
+
// build 보다 먼저, 그러나 같은 scan 결과 위에서 정한다.
|
|
276
|
+
const t = resolveThresholds(BASELINE, {
|
|
277
|
+
lang: dominantLang(files.map((f) => f.path)),
|
|
278
|
+
commits: commitCount(),
|
|
279
|
+
})
|
|
280
|
+
graphCache = build(files, { splitOver: t.splitOver, hubCap: t.hubCap })
|
|
281
|
+
graphCache.stats.buildMs = Date.now() - t0
|
|
282
|
+
// 근거를 그래프에 붙여 화면까지 내보낸다. 숫자만 주고 출처를 안 주면
|
|
283
|
+
// 사람은 그 숫자를 검증할 방법이 없다 (SSOT 원칙 2 — 적용 조건을 붙인다).
|
|
284
|
+
graphCache.ssot = { ...t, fetchedFrom: BASELINE_FROM, fetchError: BASELINE_ERR ?? null }
|
|
285
|
+
}
|
|
286
|
+
return graphCache
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 오버레이 — 정적 파싱 × git 공변경
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 히스토리는 커밋이 쌓여야 바뀌므로 파일 감시로 무효화하지 않는다.
|
|
295
|
+
* 저장소 전체 로그를 한 번 갈아야 해서 비싸고(immich 10,746 커밋 기준 수 초),
|
|
296
|
+
* 그 사이 답이 달라질 일이 없다.
|
|
297
|
+
*/
|
|
298
|
+
let overlayCache = null
|
|
299
|
+
function overlayGraph() {
|
|
300
|
+
if (overlayCache) return overlayCache
|
|
301
|
+
const t0 = Date.now()
|
|
302
|
+
const g = graph()
|
|
303
|
+
const ids = new Set(g.nodes.map((n) => n.id))
|
|
304
|
+
|
|
305
|
+
// 정적 엣지 = analyze.mjs 의 것 + 별칭 import 보강.
|
|
306
|
+
// 보강을 안 하면 tsconfig paths 를 쓰는 저장소에서 정적 엣지가 대량 누락되고,
|
|
307
|
+
// 그 누락이 전부 "숨은 결합"으로 잘못 표시된다 (cochange.mjs 주석 참조).
|
|
308
|
+
const seen = new Set(g.edges.map((e) => (e.source < e.target ? `${e.source}\0${e.target}` : `${e.target}\0${e.source}`)))
|
|
309
|
+
const alias = aliasImportEdges(ROOT, ids)
|
|
310
|
+
.filter((e) => !seen.has(e.source < e.target ? `${e.source}\0${e.target}` : `${e.target}\0${e.source}`))
|
|
311
|
+
.map((e) => ({ ...e, kind: 'import', hub: false, directed: true, via: ['alias'] }))
|
|
312
|
+
const staticEdges = [...g.edges, ...alias]
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 🔴 히스토리만 있는 파일을 노드로 올린다 (datanodes.mjs).
|
|
316
|
+
*
|
|
317
|
+
* baritone 에서 가장 중요한 숨은 결합이
|
|
318
|
+
* `mixins.baritone.json` ↔ `Mixin*.java` (67커밋 중 53커밋 동반) 였는데
|
|
319
|
+
* `.json` 이 노드가 아니라 통째로 사라졌다. 정적 파싱이 안 되는 파일이라도
|
|
320
|
+
* git 공변경은 언어와 무관하게 계산된다 — 노드로만 넣으면 된다.
|
|
321
|
+
*/
|
|
322
|
+
const rawSets = readCommitSets(ROOT)
|
|
323
|
+
const extra = rawSets ? historyNodes(ROOT, rawSets, ids) : { nodes: [], stats: null }
|
|
324
|
+
for (const n of extra.nodes) ids.add(n.id)
|
|
325
|
+
const allNodes = [...g.nodes, ...extra.nodes]
|
|
326
|
+
|
|
327
|
+
const co = coChange(ROOT, ids, { raw: rawSets })
|
|
328
|
+
if (!co) {
|
|
329
|
+
overlayCache = {
|
|
330
|
+
nodes: allNodes, edges: staticEdges.map((e) => ({ ...e, origin: 'static' })),
|
|
331
|
+
quadrant: { both: 0, cochange: 0, stable: 0, unknown: staticEdges.length },
|
|
332
|
+
/**
|
|
333
|
+
* 🔴 `noGit: true` 만으로는 화면이 "히스토리 부족" 이라고 말한다.
|
|
334
|
+
* 그런데 부족한 것과 **못 읽은 것**은 다르다.
|
|
335
|
+
*
|
|
336
|
+
* 벤치 2회차에서 신입이 잡았다 — 같은 서버가 여기서는 "커밋 0개" 라 하고
|
|
337
|
+
* /api/featuregraph 에서는 커밋을 멀쩡히 세고 있었다. 진짜 원인은
|
|
338
|
+
* `git log` 가 `fatal: unable to read <sha>` 로 죽은 것이었다.
|
|
339
|
+
* 이유를 함께 실어야 다음 사람이 "이 저장소는 커밋이 적구나" 로 오독하지 않는다.
|
|
340
|
+
*/
|
|
341
|
+
stats: {
|
|
342
|
+
...g.stats,
|
|
343
|
+
noGit: true,
|
|
344
|
+
gitError: lastCommitSetsError(),
|
|
345
|
+
aliasEdges: alias.length,
|
|
346
|
+
buildMs: Date.now() - t0,
|
|
347
|
+
},
|
|
348
|
+
freq: {},
|
|
349
|
+
}
|
|
350
|
+
return overlayCache
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { edges, quadrant } = overlayEdges(staticEdges, co.edges, co.freq)
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 🔴 파서가 못 읽은 언어를 화면에 반드시 알린다.
|
|
357
|
+
*
|
|
358
|
+
* syft(Go, 파일 1,244개)에서 실측된 상황이다.
|
|
359
|
+
* 노드의 98.6%가 `parsed:false` 였고 정적 엣지가 **1개**였다. 그런데 화면은
|
|
360
|
+
* 그 상태를 결과처럼 보여줬다 —
|
|
361
|
+
*
|
|
362
|
+
* "숨은 결합 1,082 · import 없는데 함께 바뀜"
|
|
363
|
+
* → 사실은 import 를 **안 본** 것이다. syft 에는 내부 import 가 2,702개 있다.
|
|
364
|
+
* "가장 많이 연결된 곳: .github/scripts/labeler.py"
|
|
365
|
+
* → 저장소에서 가장 안 중요한 CI 스크립트를 "파급 1위" 로 내놨다.
|
|
366
|
+
* "아무와도 안 이어진 곳"
|
|
367
|
+
* → 사실상 "가장 큰 파일 12개".
|
|
368
|
+
*
|
|
369
|
+
* 데이터에는 이미 `confidence: 'unparsed'` 가 있었다. **읽는 코드가 없었을 뿐이다.**
|
|
370
|
+
* 침묵으로 실패하고 그 실패를 답으로 내놓는 것이 이 도구의 최악의 실패다 (D5).
|
|
371
|
+
*/
|
|
372
|
+
// 히스토리 전용 노드는 애초에 파싱 대상이 아니므로 분모에서 뺀다.
|
|
373
|
+
const parseable = allNodes.filter((n) => n.confidence !== 'history-only')
|
|
374
|
+
const unparsed = parseable.filter((n) => n.confidence === 'unparsed')
|
|
375
|
+
const langCount = new Map()
|
|
376
|
+
for (const n of unparsed) {
|
|
377
|
+
const ext = n.id.slice(n.id.lastIndexOf('.') + 1).toLowerCase()
|
|
378
|
+
if (ext && ext !== n.id) langCount.set(ext, (langCount.get(ext) ?? 0) + 1)
|
|
379
|
+
}
|
|
380
|
+
const parsing = {
|
|
381
|
+
total: parseable.length,
|
|
382
|
+
unparsed: unparsed.length,
|
|
383
|
+
ratio: parseable.length ? +(unparsed.length / parseable.length).toFixed(3) : 0,
|
|
384
|
+
// 히스토리만으로 올린 노드가 몇 개인지도 알린다. 정적 엣지가 없는 것이 정상이다.
|
|
385
|
+
historyOnly: extra.nodes.length,
|
|
386
|
+
historyStats: extra.stats,
|
|
387
|
+
// 무엇을 못 읽었는지 확장자로 말한다. "Go 를 못 읽는다" 가
|
|
388
|
+
// "엣지가 없다" 보다 훨씬 쓸모 있는 정보다.
|
|
389
|
+
tops: [...langCount].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([ext, n]) => ({ ext, n })),
|
|
390
|
+
staticEdges: staticEdges.length,
|
|
391
|
+
/**
|
|
392
|
+
* 🔴 **커서 안 그린 모듈.** '못 읽었다' 의 형제이고, 화면에서 같은 자리에 선다.
|
|
393
|
+
*
|
|
394
|
+
* 스위프트 모듈은 파일 수십 개가 보통이라 상한(D9)을 넘는 것이 예외가 아니라
|
|
395
|
+
* 기본값이다. 실측(apple/swift-argument-parser): 본체 ArgumentParser 가 52개
|
|
396
|
+
* 파일이고 87개 파일이 그것을 부르는데, 그리면 4,524개 선이 된다.
|
|
397
|
+
*
|
|
398
|
+
* 안 그리는 것까지는 맞다. 문제는 **안 그렸다는 말을 안 하면** 화면이
|
|
399
|
+
* '이 저장소는 결합이 없다' 로 읽힌다는 것이다. syft 에서 파싱 실패를
|
|
400
|
+
* 결과처럼 보여줬던 것과 같은 실패다 — 그때도 데이터에는 이미 있었고
|
|
401
|
+
* 읽는 코드가 없었을 뿐이다.
|
|
402
|
+
*/
|
|
403
|
+
wideModules: g.wideModules ?? [],
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
overlayCache = {
|
|
407
|
+
parsing,
|
|
408
|
+
nodes: allNodes,
|
|
409
|
+
edges,
|
|
410
|
+
quadrant,
|
|
411
|
+
hubs: g.hubs,
|
|
412
|
+
// 기능 범위(/api/scope)가 원본 공변경 결과를 그대로 필요로 한다.
|
|
413
|
+
// JSON 응답에는 안 나간다 — freq 가 Map 이라 직렬화되지 않기 때문이고,
|
|
414
|
+
// 화면에는 이미 평평하게 편 freq 를 따로 주고 있다.
|
|
415
|
+
co,
|
|
416
|
+
freq: Object.fromEntries(co.freq),
|
|
417
|
+
stats: {
|
|
418
|
+
...g.stats, ...co.stats,
|
|
419
|
+
staticEdges: staticEdges.length,
|
|
420
|
+
analyzeEdges: g.edges.length,
|
|
421
|
+
aliasEdges: alias.length,
|
|
422
|
+
coEdges: co.edges.length,
|
|
423
|
+
buildMs: Date.now() - t0,
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
return overlayCache
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// 실시간 — 선언(claim) + 실제(git status)
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
const ME = whoAmI(ROOT)
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// 기능 클러스터 — 초안(git 동시변경) + 사람의 편집
|
|
437
|
+
//
|
|
438
|
+
// 🔴 공개 API 는 제거했다 (2026-08-19). 지금은 taxonomy/index 층의 내부 재료로만 쓴다.
|
|
439
|
+
//
|
|
440
|
+
// `/api/features`, `/api/features/edit`, `unit=feature` 그래프를 없앴다.
|
|
441
|
+
// 화면에서 이 층을 부르는 코드가 하나도 없었다 — app.js 를 지울 때 함께 끊겼고
|
|
442
|
+
// 다시 붙이지 않았다. 계산은 하는데 아무도 안 보는 상태였다.
|
|
443
|
+
//
|
|
444
|
+
// 그리고 그대로 붙이면 안 되는 상태였다. syft(커밋 3,486) 실측:
|
|
445
|
+
// · 54개 묶음 중 28개의 이름이 커밋 제목 그대로 (dependabot 버전 올림 포함)
|
|
446
|
+
// · commitFiles 의 기본 창이 1년이라 커밋의 6%만 보고, 그 사실을 안 알림
|
|
447
|
+
// · 같은 저장소에 파일 수가 두 개 (그래프 1,244 / 기능 842)
|
|
448
|
+
// 붙였으면 또 하나의 "확신 있게 틀린 답" 이 됐다.
|
|
449
|
+
//
|
|
450
|
+
// 기능 단위 질문은 /api/scope 가 답한다 — 커밋 메시지와 공변경으로 범위를 만들고
|
|
451
|
+
// 근거가 된 커밋 제목을 함께 보여준다. 그쪽은 출처를 말할 수 있다.
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 편집 기록은 **대상 저장소에** 둔다. 팀이 커밋해서 함께 보는 것이기 때문이다.
|
|
456
|
+
* 초안은 여기 없다 — 코드에서 언제든 다시 계산되는 파생물이라 커밋하면
|
|
457
|
+
* 저장소만 부풀고 머지 충돌만 늘어난다 (app/.cache 와 같은 기준).
|
|
458
|
+
*/
|
|
459
|
+
const EDITS = path.join(ROOT, '.axmap', 'features.jsonl')
|
|
460
|
+
|
|
461
|
+
function readEdits() {
|
|
462
|
+
try {
|
|
463
|
+
return fs.readFileSync(EDITS, 'utf8')
|
|
464
|
+
.split('\n')
|
|
465
|
+
.filter((l) => l.trim())
|
|
466
|
+
// 한 줄이 깨져도 나머지는 살린다. append-only 파일이라 마지막 줄이
|
|
467
|
+
// 쓰다 만 상태일 수 있고, 그것 때문에 팀 전체의 이름이 사라지면 안 된다.
|
|
468
|
+
.map((l) => { try { return JSON.parse(l) } catch { return null } })
|
|
469
|
+
.filter(Boolean)
|
|
470
|
+
} catch {
|
|
471
|
+
return []
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let featureCache = null
|
|
476
|
+
function features() {
|
|
477
|
+
if (!featureCache) {
|
|
478
|
+
const t0 = Date.now()
|
|
479
|
+
const { available, commits } = commitFiles(ROOT)
|
|
480
|
+
// 🔴 그래프 노드로 거르면 안 된다. 정적 파서가 읽는 언어는 6종뿐이라
|
|
481
|
+
// Java 저장소에서는 노드가 0개이고, 그러면 기능 목록도 통째로 비어 버린다.
|
|
482
|
+
// 기능 클러스터의 존재 이유가 정확히 "파서가 못 읽는 곳에서도 되는 것"이므로
|
|
483
|
+
// 여기서는 **디스크에 실제로 있는가**만 본다.
|
|
484
|
+
const known = new Set(
|
|
485
|
+
[...new Set(commits.flatMap((c) => c.files))].filter((p) => {
|
|
486
|
+
const abs = safeJoin(ROOT, p)
|
|
487
|
+
return abs && fs.existsSync(abs)
|
|
488
|
+
}),
|
|
489
|
+
)
|
|
490
|
+
const d = draft(commits, known)
|
|
491
|
+
const { features: list, orphans } = applyEdits(d.features, readEdits())
|
|
492
|
+
featureCache = {
|
|
493
|
+
gitAvailable: available,
|
|
494
|
+
features: list,
|
|
495
|
+
orphans,
|
|
496
|
+
// 분모는 **지금 디스크에 있는 파일**이다. 이력에 나온 경로를 다 세면
|
|
497
|
+
// 지워진 파일까지 분모에 들어가 커버리지가 실제보다 낮게 나온다
|
|
498
|
+
// (실측에서 194 vs 180 — 8%p 차이).
|
|
499
|
+
liveFiles: known.size,
|
|
500
|
+
stats: { ...d.stats, ms: Date.now() - t0 },
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return featureCache
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// 대분류 · 파일 색인 — 자연어 단위로 노드를 세우기 위한 층
|
|
511
|
+
//
|
|
512
|
+
// 큰 모델이 대분류를 한 번 만들고(authored, 커밋), 작은 모델이 파일마다
|
|
513
|
+
// 그 목록에서 고른다(derived, 캐시). 자세한 근거는 llm.mjs 의 classify 주석.
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
/** 팀이 함께 보는 것이라 대상 저장소에 커밋한다. 초안은 큰 모델이 만들어 준다. */
|
|
517
|
+
const TAXONOMY = path.join(ROOT, '.axmap', 'taxonomy.json')
|
|
518
|
+
|
|
519
|
+
function readTaxonomy() {
|
|
520
|
+
try {
|
|
521
|
+
const j = JSON.parse(fs.readFileSync(TAXONOMY, 'utf8'))
|
|
522
|
+
return Array.isArray(j?.categories) ? j.categories.filter((c) => typeof c === 'string') : []
|
|
523
|
+
} catch {
|
|
524
|
+
return []
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 큰 모델에게 대분류를 물을 때 붙여 줄 재료.
|
|
530
|
+
*
|
|
531
|
+
* axMap 이 Claude API 키를 들고 있지 않아도 되게, **재료만 만들어 주고**
|
|
532
|
+
* 사람이 자기 큰 모델에 붙여넣게 한다. 최소 설치라는 목표에 맞고,
|
|
533
|
+
* 결과를 사람이 읽고 커밋하므로 검토 단계도 자연스럽게 생긴다.
|
|
534
|
+
*/
|
|
535
|
+
function taxonomyPrompt() {
|
|
536
|
+
const f = features().features.filter((x) => !x.hidden)
|
|
537
|
+
const dirs = [...new Set(graph().nodes.map((n) => n.dir.split('/').slice(0, 3).join('/')))]
|
|
538
|
+
return [
|
|
539
|
+
'아래는 한 저장소의 기능 묶음과 디렉터리 구조다.',
|
|
540
|
+
'이 저장소를 사람이 이해하기 좋은 **대분류 6~10개**로 나눠라.',
|
|
541
|
+
'분류 이름은 자연어로, 코드 용어가 아니라 하는 일로 적어라.',
|
|
542
|
+
'JSON 으로만 답하라: {"categories":["...","..."]}',
|
|
543
|
+
'',
|
|
544
|
+
'## 기능 묶음',
|
|
545
|
+
...f.map((x) => `- ${x.name} (${x.paths.length}개): ${x.paths.slice(0, 4).map((p) => p.split('/').pop()).join(', ')}`),
|
|
546
|
+
'',
|
|
547
|
+
'## 디렉터리',
|
|
548
|
+
...dirs.slice(0, 40).map((d) => `- ${d}`),
|
|
549
|
+
].join('\n')
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 파일별 분류. 개별 오류는 **기능 클러스터 다수결**로 흡수한다.
|
|
554
|
+
*
|
|
555
|
+
* 실측에서 40개 중 1개가 틀렸다 (`StompWebSocketConfig.java` → 영상·인식).
|
|
556
|
+
* STOMP 설정이 영상 중계에도 쓰이니 헷갈릴 만하다. 그런데 그 파일이 속한
|
|
557
|
+
* 기능은 11개 중 10개가 `로봇 상태·통신` 이었다 — 다수결이 고쳐 준다.
|
|
558
|
+
*
|
|
559
|
+
* 즉 **개별 분류가 완벽할 필요가 없다.** 결정론적 묶음이 투표 블록이 되어
|
|
560
|
+
* 준다. 큰 모델 없이 작은 모델로 버틸 수 있는 이유가 이것이다.
|
|
561
|
+
*/
|
|
562
|
+
let indexCache = null
|
|
563
|
+
let indexing = null
|
|
564
|
+
|
|
565
|
+
function indexState() {
|
|
566
|
+
const cats = readTaxonomy()
|
|
567
|
+
const files = graph().nodes
|
|
568
|
+
return {
|
|
569
|
+
categories: cats,
|
|
570
|
+
ready: !!indexCache && indexCache.cats.join('') === cats.join(''),
|
|
571
|
+
running: !!indexing,
|
|
572
|
+
done: indexCache?.done ?? 0,
|
|
573
|
+
total: files.length,
|
|
574
|
+
byPath: indexCache?.byPath ?? {},
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function buildIndex() {
|
|
579
|
+
const cats = readTaxonomy()
|
|
580
|
+
if (!cats.length) return { error: '대분류가 없습니다' }
|
|
581
|
+
if (indexing) return { running: true }
|
|
582
|
+
|
|
583
|
+
const nodes = graph().nodes
|
|
584
|
+
indexCache = { cats, byPath: {}, done: 0 }
|
|
585
|
+
|
|
586
|
+
indexing = (async () => {
|
|
587
|
+
for (const n of nodes) {
|
|
588
|
+
let text = ''
|
|
589
|
+
try {
|
|
590
|
+
const abs = safeJoin(ROOT, n.id)
|
|
591
|
+
text = abs ? fs.readFileSync(abs, 'utf8') : ''
|
|
592
|
+
} catch { /* 지워진 파일 */ }
|
|
593
|
+
if (text) {
|
|
594
|
+
const r = await llm.classify(n.id, text, cats)
|
|
595
|
+
if (r.category) indexCache.byPath[n.id] = { category: r.category, role: r.role, raw: r.category }
|
|
596
|
+
}
|
|
597
|
+
indexCache.done++
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 기능 클러스터 다수결로 개별 오류를 흡수한다
|
|
601
|
+
for (const f of features().features) {
|
|
602
|
+
if (f.hidden || f.paths.length < 3) continue
|
|
603
|
+
const votes = new Map()
|
|
604
|
+
for (const p of f.paths) {
|
|
605
|
+
const c = indexCache.byPath[p]?.category
|
|
606
|
+
if (c) votes.set(c, (votes.get(c) ?? 0) + 1)
|
|
607
|
+
}
|
|
608
|
+
const [top, n2] = [...votes].sort((a, b) => b[1] - a[1])[0] ?? []
|
|
609
|
+
// 과반이 아니면 고치지 않는다. 근거가 약한 보정은 원래 답보다 나쁠 수 있다.
|
|
610
|
+
if (!top || n2 <= f.paths.length / 2) continue
|
|
611
|
+
for (const p of f.paths) {
|
|
612
|
+
const e = indexCache.byPath[p]
|
|
613
|
+
if (e && e.category !== top) { e.category = top; e.corrected = true }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
indexing = null
|
|
617
|
+
})()
|
|
618
|
+
|
|
619
|
+
return { running: true }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// PR — 올리기 전에 "뭘 바꿨고 어디까지 갔나" (D2)
|
|
624
|
+
//
|
|
625
|
+
// 판정은 전부 app/lib/prdiff.mjs 가 한다. 여기는 git 을 부르고 경로를 맞추는
|
|
626
|
+
// 일만 한다 (CLAUDE.md — 순수 로직과 부수효과를 섞지 않는다).
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
function git(args) {
|
|
630
|
+
return spawnSync('git', args, {
|
|
631
|
+
cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true,
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* "여기에 새로 만들려면 어디를 고치나" 의 재료를 git 에서 캔다.
|
|
637
|
+
*
|
|
638
|
+
* 두 번 읽는다. 한 번에 못 하는 이유가 있다 —
|
|
639
|
+
* 1) `-- <범위>` 를 걸면 그 범위 **안의** 파일만 보인다. 우리가 알고 싶은 것은
|
|
640
|
+
* 그 커밋이 범위 **밖에서** 무엇을 고쳤나 이므로 그것만으로는 답이 안 나온다.
|
|
641
|
+
* 2) 그렇다고 전체 히스토리를 상태까지 읽으면 큰 저장소에서 비싸다.
|
|
642
|
+
* 그래서 먼저 "새 파일을 추가한 커밋" 만 좁혀 찾고(1), 그 커밋들만 다시 펼친다(2).
|
|
643
|
+
*
|
|
644
|
+
* 판정은 하지 않는다. newfile.mjs 가 한다 (CLAUDE.md — 순수 로직과 부수효과 분리).
|
|
645
|
+
*/
|
|
646
|
+
const MAX_ADD_COMMITS = 150
|
|
647
|
+
|
|
648
|
+
function newFileCommits(scope) {
|
|
649
|
+
// ROOT 가 저장소 하위일 수 있다. git 은 늘 저장소 루트 기준 경로를 주므로 맞춰준다.
|
|
650
|
+
const prefix = (git(['rev-parse', '--show-prefix']).stdout ?? '').trim()
|
|
651
|
+
const full = scope ? `${prefix}${scope}` : prefix || null
|
|
652
|
+
|
|
653
|
+
const args = ['log', '--no-merges', '--diff-filter=A', '--format=@%H', '--name-only', `-n${MAX_ADD_COMMITS}`]
|
|
654
|
+
if (full) args.push('--', full)
|
|
655
|
+
const a = git(args)
|
|
656
|
+
if (a.status !== 0) return null
|
|
657
|
+
|
|
658
|
+
const added = new Map() // sha -> 이 커밋이 범위 안에 새로 만든 파일
|
|
659
|
+
let cur = null
|
|
660
|
+
for (const line of (a.stdout ?? '').split('\n')) {
|
|
661
|
+
const t = line.trim()
|
|
662
|
+
if (!t) continue
|
|
663
|
+
if (t.startsWith('@')) { cur = t.slice(1); added.set(cur, []); continue }
|
|
664
|
+
if (cur) added.get(cur).push(t)
|
|
665
|
+
}
|
|
666
|
+
const shas = [...added.keys()].filter((s) => added.get(s).length)
|
|
667
|
+
if (!shas.length) return []
|
|
668
|
+
|
|
669
|
+
// 그 커밋들이 건드린 **전부**. 범위를 걸지 않는다.
|
|
670
|
+
const b = git(['log', '--no-walk', '--no-merges', '--format=@%H', '--name-only', ...shas])
|
|
671
|
+
if (b.status !== 0) return null
|
|
672
|
+
|
|
673
|
+
const touched = new Map()
|
|
674
|
+
cur = null
|
|
675
|
+
for (const line of (b.stdout ?? '').split('\n')) {
|
|
676
|
+
const t = line.trim()
|
|
677
|
+
if (!t) continue
|
|
678
|
+
if (t.startsWith('@')) { cur = t.slice(1); touched.set(cur, []); continue }
|
|
679
|
+
if (cur) touched.get(cur).push(t)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 화면의 노드 id 는 ROOT 기준이므로 prefix 를 벗긴다. 벗겨지지 않는 것은
|
|
683
|
+
// ROOT 밖의 파일이라 화면에 없다 — 그래도 등록 지점일 수 있으므로 버리지 않고 그대로 둔다.
|
|
684
|
+
const strip = (p) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p)
|
|
685
|
+
|
|
686
|
+
return shas.map((sha) => ({
|
|
687
|
+
sha,
|
|
688
|
+
added: added.get(sha).map(strip),
|
|
689
|
+
touched: (touched.get(sha) ?? []).map(strip),
|
|
690
|
+
}))
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* ⑤가 기본으로 물어볼 범위 — "새로 만든다면 아마 여기" 인 폴더.
|
|
695
|
+
*
|
|
696
|
+
* 파일이 가장 많은 폴더를 고른다. 신입에게 실제로 시키는 일이 대개
|
|
697
|
+
* "이 목록에 하나 더 추가해줘" 이기 때문이다 (theHarvester 라면 `discovery/`).
|
|
698
|
+
*
|
|
699
|
+
* 테스트·문서 폴더는 뺀다. 거기에 새로 만드는 것은 결과물이 아니라 부산물이다.
|
|
700
|
+
* 못 고르면 null 을 내고 저장소 전체를 본다 — 추측해서 엉뚱한 폴더를 답으로
|
|
701
|
+
* 내놓는 것보다 범위가 넓은 편이 낫다.
|
|
702
|
+
*/
|
|
703
|
+
const AUX_DIR = /(^|\/)(tests?|__tests__|spec|specs|docs?|examples?|samples?|fixtures?|vendor|node_modules)(\/|$)/i
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* ⑤가 물어볼 범위를 고르고, **그곳이 무엇인지도 함께** 낸다.
|
|
707
|
+
*
|
|
708
|
+
* 🔴 이름 없이 폴더만 주면 절반만 답한 것이다.
|
|
709
|
+
*
|
|
710
|
+
* 2회차 온보딩 실험에서 에이전트가 정확히 이 틈을 짚었다 —
|
|
711
|
+
* ⑤가 `theHarvester/discovery` 의 등록 지점을 정확히 알려줬는데,
|
|
712
|
+
*
|
|
713
|
+
* > "discovery 폴더 = 새 데이터 소스가 들어가는 곳" 이라는 연결 자체는
|
|
714
|
+
* > 도구가 문장으로 말해준 게 아니라 내가 파일명을 보고 추론한 것이다.
|
|
715
|
+
*
|
|
716
|
+
* 즉 "어디를 고치나" 는 답했는데 "여기가 어디냐" 를 안 말했다. 신입은 그 둘이
|
|
717
|
+
* 다 있어야 움직인다. README 가 이미 그 폴더에 이름을 붙여뒀다 —
|
|
718
|
+
* "Discovery routes and enrichment". 그것을 가져와 같이 낸다.
|
|
719
|
+
*
|
|
720
|
+
* 커밋은 읽지 않는다. modulesOf·docLines·matchDocs 는 순수 함수라
|
|
721
|
+
* 경로와 README 만으로 이름을 짓는다 (featuregraph.mjs 가 쓰는 것과 같은 것).
|
|
722
|
+
*/
|
|
723
|
+
function pickScope(nodes) {
|
|
724
|
+
const paths = nodes.map((n) => String(n.id ?? ''))
|
|
725
|
+
const { mods, root } = modulesOf(paths)
|
|
726
|
+
|
|
727
|
+
let readme = null
|
|
728
|
+
for (const f of ['README.md', 'README.rst', 'readme.md', 'README']) {
|
|
729
|
+
try { readme = fs.readFileSync(path.join(ROOT, f), 'utf8'); break } catch { /* 다음 후보 */ }
|
|
730
|
+
}
|
|
731
|
+
const docs = matchDocs(mods, docLines(readme), { skip: root })
|
|
732
|
+
|
|
733
|
+
// 후보: 테스트·문서가 아닌 묶음. README 가 이름 붙인 것을 먼저 본다 —
|
|
734
|
+
// 저자가 "여기는 이런 곳" 이라고 이미 말해둔 곳이 신입이 갈 곳이다.
|
|
735
|
+
const cand = [...mods]
|
|
736
|
+
.filter(([dir]) => dir && !AUX_DIR.test(dir))
|
|
737
|
+
.map(([dir, files]) => ({ dir, files: files.length, doc: docs.get(dir) ?? null }))
|
|
738
|
+
if (!cand.length) return null
|
|
739
|
+
|
|
740
|
+
cand.sort((a, b) =>
|
|
741
|
+
(b.doc ? 1 : 0) - (a.doc ? 1 : 0) // README 가 이름 붙인 것 우선
|
|
742
|
+
|| b.files - a.files // 그 다음 파일이 많은 것
|
|
743
|
+
|| (a.dir < b.dir ? -1 : 1)) // 동률은 이름으로 — 답을 결정론적으로
|
|
744
|
+
|
|
745
|
+
const w = cand[0]
|
|
746
|
+
return {
|
|
747
|
+
dir: w.dir,
|
|
748
|
+
files: w.files,
|
|
749
|
+
name: w.doc?.label ?? null,
|
|
750
|
+
// 🔴 이름의 출처를 반드시 같이 낸다. 비전공자에게는 이것이 유일한 검증 수단이다.
|
|
751
|
+
nameSource: w.doc ? 'docs' : 'path',
|
|
752
|
+
docLine: w.doc?.line ?? null,
|
|
753
|
+
why: w.doc
|
|
754
|
+
? 'README 가 이름을 붙인 묶음 중 파일이 가장 많다'
|
|
755
|
+
: '테스트·문서를 뺀 폴더 중 파일이 가장 많다 (README 가 이름 붙인 곳은 없었다)',
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** git 을 두 번 읽으므로 캐시한다. 커밋이 늘 때만 달라진다 — 감시자가 비운다. */
|
|
760
|
+
let newFileCache = null
|
|
761
|
+
function newFileForFlow(nodes) {
|
|
762
|
+
if (newFileCache) return newFileCache
|
|
763
|
+
const place = pickScope(nodes)
|
|
764
|
+
const scope = place?.dir ?? null
|
|
765
|
+
const commits = newFileCommits(scope)
|
|
766
|
+
if (commits === null) {
|
|
767
|
+
return (newFileCache = { answered: false, why: 'git 히스토리를 읽지 못했습니다', scope, place, points: [] })
|
|
768
|
+
}
|
|
769
|
+
const r = registrationPoints(commits)
|
|
770
|
+
return (newFileCache = {
|
|
771
|
+
...r,
|
|
772
|
+
scope,
|
|
773
|
+
// 어디를 고치나 + 여기가 어디냐. 둘 다 있어야 신입이 움직인다.
|
|
774
|
+
place,
|
|
775
|
+
sentence: sayNewFile(r, scope),
|
|
776
|
+
capped: commits.length >= MAX_ADD_COMMITS ? MAX_ADD_COMMITS : null,
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* 이 저장소의 기본 브랜치.
|
|
782
|
+
*
|
|
783
|
+
* 🔴 `main` 을 하드코딩하고 있었다. theHarvester 는 `master` 라서 PR 화면이
|
|
784
|
+
* 첫 진입에 바로 에러를 냈다 — 온보딩 실험에서 에이전트가 "PR 비교 기능이
|
|
785
|
+
* 고장났다고 오해하고 포기할 수도 있다" 고 적었다.
|
|
786
|
+
*
|
|
787
|
+
* `origin/HEAD` 가 원격이 스스로 선언한 기본 브랜치라 가장 믿을 만하다.
|
|
788
|
+
* 클론이 그것을 안 받았을 수 있으므로(--single-branch 등) 흔한 이름을 차례로 본다.
|
|
789
|
+
* 그래도 없으면 **추측하지 않고 null** 을 낸다. 호출자가 있는 브랜치 목록을 보여준다.
|
|
790
|
+
*/
|
|
791
|
+
let defaultBaseCache
|
|
792
|
+
function defaultBase() {
|
|
793
|
+
if (defaultBaseCache !== undefined) return defaultBaseCache
|
|
794
|
+
const head = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
795
|
+
if (head.status === 0) {
|
|
796
|
+
const name = head.stdout.trim().replace(/^origin\//, '')
|
|
797
|
+
if (name) return (defaultBaseCache = name)
|
|
798
|
+
}
|
|
799
|
+
for (const cand of ['main', 'master', 'trunk', 'develop']) {
|
|
800
|
+
for (const ref of [cand, `origin/${cand}`]) {
|
|
801
|
+
if (git(['rev-parse', '--verify', `${ref}^{commit}`]).status === 0) return (defaultBaseCache = cand)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return (defaultBaseCache = null)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* base 로 받아도 되는 문자열인가.
|
|
809
|
+
*
|
|
810
|
+
* 🔴 `-` 로 시작하는 값을 그대로 넘기면 git 이 그것을 **옵션으로 읽는다.**
|
|
811
|
+
* `?base=--upload-pack=...` 같은 값이 인자 자리에 들어가는 순간 우리가 의도한
|
|
812
|
+
* 명령이 아니게 된다. spawnSync 는 셸을 안 거치므로 셸 주입은 없지만,
|
|
813
|
+
* git 자신의 옵션 파싱은 그대로 살아 있다.
|
|
814
|
+
*
|
|
815
|
+
* 이 뷰어는 `--auth` 로 밖에 열릴 수 있다(server 상단 주석). 그러니 입력은
|
|
816
|
+
* 로컬이라고 가정하지 않는다. 애매하면 거부한다.
|
|
817
|
+
*/
|
|
818
|
+
const REF_OK = /^[A-Za-z0-9._/~^@{}-]{1,200}$/
|
|
819
|
+
const validRef = (s) => REF_OK.test(s) && !s.startsWith('-') && !s.includes('..')
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* 저장소 루트 기준 경로를 **노드 ID 기준**으로 맞춘다.
|
|
823
|
+
*
|
|
824
|
+
* 🔴 `git diff --name-status` 는 저장소 루트 기준 경로를 준다. 뷰어를 하위
|
|
825
|
+
* 디렉터리로 열면(`node app/server.mjs <repo>/server`) 노드 ID 는 그
|
|
826
|
+
* 디렉터리 기준이라 하나도 안 맞는다. 그대로 두면 "바뀐 파일이 그래프에
|
|
827
|
+
* 하나도 없다" 는 틀린 결론이 나온다 — cochange.mjs 가 같은 자리에서
|
|
828
|
+
* 같은 처리를 한다.
|
|
829
|
+
*
|
|
830
|
+
* `--relative` 를 쓰면 git 이 대신 해주지만, 그러면 **범위 밖 파일이 몇 개
|
|
831
|
+
* 잘렸는지**를 알 수 없다. 그 수를 화면에 말해야 하므로 직접 자른다.
|
|
832
|
+
*/
|
|
833
|
+
function scopeToRoot(files) {
|
|
834
|
+
const prefix = git(['rev-parse', '--show-prefix']).stdout?.trim() ?? ''
|
|
835
|
+
if (!prefix) return { files, outside: 0 }
|
|
836
|
+
const kept = []
|
|
837
|
+
let outside = 0
|
|
838
|
+
for (const f of files) {
|
|
839
|
+
if (f.path.startsWith(prefix)) kept.push({ ...f, path: f.path.slice(prefix.length) })
|
|
840
|
+
else outside++
|
|
841
|
+
}
|
|
842
|
+
return { files: kept, outside }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** `git diff --name-status` 한 줄을 {path, code} 로. 이름변경은 새 이름을 쓴다. */
|
|
846
|
+
function parseNameStatus(out) {
|
|
847
|
+
const files = []
|
|
848
|
+
for (const line of (out ?? '').split('\n')) {
|
|
849
|
+
if (!line.trim()) continue
|
|
850
|
+
const parts = line.split('\t')
|
|
851
|
+
const code = parts[0].trim()
|
|
852
|
+
// R100 old new / C75 old new — 새 경로가 마지막이다
|
|
853
|
+
const p = parts[parts.length - 1].trim()
|
|
854
|
+
if (!p) continue
|
|
855
|
+
files.push({ path: p, code: code[0], where: 'diff' })
|
|
856
|
+
}
|
|
857
|
+
return files
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* diff 대상을 정한다.
|
|
862
|
+
*
|
|
863
|
+
* 🔴 git 이 없거나 base 가 없으면 **조용히 빈 결과를 내지 않는다.**
|
|
864
|
+
*
|
|
865
|
+
* 이 화면의 답이 "빠진 것 없음" 인지 "판정하지 못함" 인지가 전부다.
|
|
866
|
+
* 빈 목록을 결과처럼 내놓으면 사용자는 확인했다고 믿는다 — 그 착각을
|
|
867
|
+
* 만드는 것이 이 도구가 할 수 있는 가장 나쁜 실패다 (D5).
|
|
868
|
+
*/
|
|
869
|
+
function prChanges(baseArg) {
|
|
870
|
+
if (!fs.existsSync(path.join(ROOT, '.git')) && git(['rev-parse', '--git-dir']).status !== 0) {
|
|
871
|
+
return { error: `git 저장소가 아닙니다 (${ROOT}) — PR 화면은 diff 로 답하므로 git 없이는 아무 말도 할 수 없습니다.` }
|
|
872
|
+
}
|
|
873
|
+
if (git(['rev-parse', '--verify', 'HEAD']).status !== 0) {
|
|
874
|
+
return { error: '커밋이 하나도 없습니다 — 비교할 기준(HEAD)이 없습니다. 첫 커밋 뒤에 다시 열어주세요.' }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 작업트리 변경은 언제나 본다. "올리기 전" 화면이므로 아직 커밋 안 한 것이
|
|
878
|
+
// 오히려 주인공이다. ?worktree=0 으로 끌 수 있다.
|
|
879
|
+
const st = git(['status', '--porcelain=v1', '-uall'])
|
|
880
|
+
// 상태를 못 읽으면 "작업트리에 변경 없음" 으로 보이게 된다. 그건 조용한 거짓말이다.
|
|
881
|
+
if (st.status !== 0) {
|
|
882
|
+
return { error: `git status 실패: ${(st.stderr ?? '').trim().split('\n')[0] || '알 수 없는 오류'}` }
|
|
883
|
+
}
|
|
884
|
+
const work = []
|
|
885
|
+
for (const line of (st.stdout ?? '').split('\n')) {
|
|
886
|
+
if (line.length < 4) continue
|
|
887
|
+
const code = line.slice(0, 2).trim()
|
|
888
|
+
const raw = line.slice(3).split(' -> ').pop().replace(/^"|"$/g, '')
|
|
889
|
+
work.push({ path: raw, code: code[0] === '?' ? 'A' : code[0], where: 'worktree' })
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// 'default' 는 "이 저장소의 기본 브랜치" 라는 뜻이다. 화면이 main 을 박아 보내면
|
|
893
|
+
// master 를 쓰는 저장소에서 매번 에러가 난다.
|
|
894
|
+
const asked = String(baseArg ?? 'default')
|
|
895
|
+
const wantBase = asked === 'default' ? defaultBase() : asked
|
|
896
|
+
// 작업트리만 보고 싶을 때. 커밋 전 마지막 확인이 이 모드다.
|
|
897
|
+
if (['working', 'worktree', 'none', ''].includes(String(wantBase))) {
|
|
898
|
+
return { base: null, mode: 'worktree', diff: [], worktree: work }
|
|
899
|
+
}
|
|
900
|
+
if (!wantBase) {
|
|
901
|
+
const brs = (git(['for-each-ref', '--format=%(refname:short)', '--count=12', 'refs/heads', 'refs/remotes']).stdout ?? '')
|
|
902
|
+
.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
903
|
+
return {
|
|
904
|
+
error: '기본 브랜치를 정하지 못했습니다 (origin/HEAD 도 main·master·trunk·develop 도 없습니다).',
|
|
905
|
+
hint: brs.length ? `이 저장소에 있는 것: ${brs.join(', ')}` : 'branch 가 하나도 없습니다.',
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!validRef(wantBase)) {
|
|
909
|
+
return { error: `base 로 쓸 수 없는 값입니다: ${wantBase}` }
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// 로컬에 없으면 origin/ 을 한 번 더 본다. 클론한 저장소는 `main` 이 로컬
|
|
913
|
+
// 브랜치로 없고 `origin/main` 만 있는 경우가 흔하다 (fetchrepo.mjs 로 받은 것 포함).
|
|
914
|
+
let ref = null
|
|
915
|
+
for (const cand of [wantBase, `origin/${wantBase}`]) {
|
|
916
|
+
if (git(['rev-parse', '--verify', `${cand}^{commit}`]).status === 0) { ref = cand; break }
|
|
917
|
+
}
|
|
918
|
+
if (!ref) {
|
|
919
|
+
const brs = (git(['for-each-ref', '--format=%(refname:short)', '--count=12', 'refs/heads', 'refs/remotes']).stdout ?? '')
|
|
920
|
+
.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
921
|
+
return {
|
|
922
|
+
error: `'${wantBase}' 라는 ref 가 없습니다 (origin/${wantBase} 도 없습니다).`,
|
|
923
|
+
hint: brs.length ? `이 저장소에 있는 것: ${brs.join(', ')}` : 'branch 가 하나도 없습니다.',
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// PR 이 묻는 것은 "이 브랜치가 base 에서 갈라진 뒤 무엇을 했나" 다.
|
|
928
|
+
// 그래서 두 점(base..HEAD)이 아니라 공통 조상 기준이다 — base 쪽에서 그 뒤에
|
|
929
|
+
// 일어난 일은 이 PR 이 한 일이 아니다.
|
|
930
|
+
const mb = git(['merge-base', ref, 'HEAD'])
|
|
931
|
+
if (mb.status !== 0) {
|
|
932
|
+
return { error: `'${ref}' 와 HEAD 의 공통 조상을 찾지 못했습니다 — 관계 없는 히스토리일 수 있습니다.` }
|
|
933
|
+
}
|
|
934
|
+
const base = mb.stdout.trim()
|
|
935
|
+
const d = git(['diff', '--name-status', '-M', base, 'HEAD'])
|
|
936
|
+
if (d.status !== 0) {
|
|
937
|
+
return { error: `git diff 실패: ${(d.stderr ?? '').trim().split('\n')[0] || '알 수 없는 오류'}` }
|
|
938
|
+
}
|
|
939
|
+
const ahead = Number((git(['rev-list', '--count', `${base}..HEAD`]).stdout ?? '0').trim()) || 0
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
base, ref, ahead, mode: 'branch',
|
|
943
|
+
diff: parseNameStatus(d.stdout),
|
|
944
|
+
worktree: work,
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// 작성자 이력은 git log 를 훑어야 해서 비싸다. 파일이 바뀔 때만 다시 계산한다.
|
|
949
|
+
let authorCache = null
|
|
950
|
+
function authors() {
|
|
951
|
+
if (!authorCache) authorCache = authorMap(ROOT)
|
|
952
|
+
return authorCache
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* 장부를 원격에서 당겨온다.
|
|
957
|
+
*
|
|
958
|
+
* 🔴 서버는 지금까지 **로컬 장부만 읽었다.** CLI 는 claim/status 때마다
|
|
959
|
+
* fetch 하는데(bin/axmap.mjs syncLedger) 서버는 안 했다. 그래서 다른
|
|
960
|
+
* 컴퓨터에서 일하는 사람이 잡은 것이 **감시 화면에 영영 안 뜬다.**
|
|
961
|
+
* 두 대로 협업을 시작하자마자 드러난 구멍이다.
|
|
962
|
+
*
|
|
963
|
+
* 🔴 `reset --hard` 를 조건 없이 하지 않는다.
|
|
964
|
+
*
|
|
965
|
+
* CLI 는 그렇게 한다 — claim 이 CAS 로 즉시 push 되므로 로컬에 안 밀린
|
|
966
|
+
* 것이 없다고 전제할 수 있기 때문이다. 서버는 그 전제를 세울 수 없다.
|
|
967
|
+
* push 가 실패한 직후일 수도 있고, 그때 reset 하면 **남의 락이 아니라
|
|
968
|
+
* 내 락이 조용히 사라진다.** 그건 두 사람이 같은 코드를 고치게 만드는
|
|
969
|
+
* 바로 그 실패다. 그러니 빨리 감기가 되는 경우에만 옮기고,
|
|
970
|
+
* 갈라졌으면 옮기지 않고 **갈라졌다고 말한다.**
|
|
971
|
+
*
|
|
972
|
+
* 🔴 `fetch` 는 반드시 장부 worktree **안에서** 돌린다. `FETCH_HEAD` 는
|
|
973
|
+
* worktree 별로 따로 보관되므로 메인에서 fetch 하면 장부 쪽에서 못 본다.
|
|
974
|
+
*/
|
|
975
|
+
const LEDGER_DIR = path.join(ROOT, '.axmap', 'ledger')
|
|
976
|
+
const LEDGER_SYNC_MS = 8000
|
|
977
|
+
let ledgerSync = { at: null, ok: null, why: '아직 시도 안 함' }
|
|
978
|
+
let ledgerSyncing = false
|
|
979
|
+
|
|
980
|
+
function syncLedgerSoon() {
|
|
981
|
+
if (ledgerSyncing) return
|
|
982
|
+
if (ledgerSync.at && Date.now() - ledgerSync.at < LEDGER_SYNC_MS) return
|
|
983
|
+
if (!fs.existsSync(LEDGER_DIR)) { ledgerSync = { at: Date.now(), ok: null, why: '장부가 없습니다' }; return }
|
|
984
|
+
ledgerSyncing = true
|
|
985
|
+
// 화면을 막지 않는다. 이번 요청은 디스크에 있는 것으로 답하고, 다음
|
|
986
|
+
// 요청이 새 것을 본다. 8초 안에 두 번 당기지 않는다.
|
|
987
|
+
const p = spawn('git', ['fetch', '--quiet', 'origin', 'axmap/claims'],
|
|
988
|
+
{ cwd: LEDGER_DIR, stdio: 'ignore', windowsHide: true })
|
|
989
|
+
p.on('error', (e) => { ledgerSyncing = false; ledgerSync = { at: Date.now(), ok: false, why: e.message } })
|
|
990
|
+
p.on('exit', (code) => {
|
|
991
|
+
ledgerSyncing = false
|
|
992
|
+
if (code !== 0) { ledgerSync = { at: Date.now(), ok: false, why: '원격 장부에 닿지 못했습니다' }; return }
|
|
993
|
+
const ff = spawnSync('git', ['merge-base', '--is-ancestor', 'HEAD', 'FETCH_HEAD'],
|
|
994
|
+
{ cwd: LEDGER_DIR, windowsHide: true })
|
|
995
|
+
if (ff.status !== 0) {
|
|
996
|
+
// 갈라졌다. 옮기면 내 락이 사라질 수 있으므로 옮기지 않는다.
|
|
997
|
+
ledgerSync = { at: Date.now(), ok: false, why: '로컬 장부가 원격과 갈라졌습니다 — 화면이 오래됐을 수 있습니다' }
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
const r = spawnSync('git', ['reset', '--hard', '--quiet', 'FETCH_HEAD'],
|
|
1001
|
+
{ cwd: LEDGER_DIR, windowsHide: true })
|
|
1002
|
+
ledgerSync = r.status === 0
|
|
1003
|
+
? { at: Date.now(), ok: true, why: null }
|
|
1004
|
+
: { at: Date.now(), ok: false, why: '장부를 옮기지 못했습니다' }
|
|
1005
|
+
})
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function liveState() {
|
|
1009
|
+
syncLedgerSoon()
|
|
1010
|
+
const { available: ledger, claims } = readClaims(ROOT, Date.now(), ME)
|
|
1011
|
+
const { available: git, files } = modifiedFiles(ROOT)
|
|
1012
|
+
// 그래프 노드 목록을 넘겨야 디렉터리 claim 이 그 아래 파일까지 물든다
|
|
1013
|
+
const ov = overlay(claims, files, graph().nodes.map((n) => n.id))
|
|
1014
|
+
return {
|
|
1015
|
+
ledgerAvailable: ledger,
|
|
1016
|
+
gitAvailable: git,
|
|
1017
|
+
me: ME,
|
|
1018
|
+
/**
|
|
1019
|
+
* 🔴 장부를 못 읽었으면 빈 배열이 아니라 null 이다.
|
|
1020
|
+
*
|
|
1021
|
+
* 벤치마크에서 텍스트만 보는 신입이 `claims: []` 를 읽고
|
|
1022
|
+
* "아무도 안 잡고 있다" 고 확신 있게 틀린 답을 냈다.
|
|
1023
|
+
* 빈 배열은 "없다" 이고 null 은 "모른다" 다. 섞으면 안 된다.
|
|
1024
|
+
*/
|
|
1025
|
+
claims: ledger ? claims : null,
|
|
1026
|
+
modified: files,
|
|
1027
|
+
overlay: ledger ? Object.fromEntries(ov) : null,
|
|
1028
|
+
summary: liveSummary(claims, files, ov, ledger),
|
|
1029
|
+
// 🔴 언제 마지막으로 원격을 봤나. 이걸 안 내면 오래된 화면이 최신인 척한다 —
|
|
1030
|
+
// 선점 화면에서 그건 "아무도 안 잡고 있다" 는 거짓말이 된다.
|
|
1031
|
+
sync: ledgerSync,
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const clients = new Set()
|
|
1036
|
+
function broadcast(event, data) {
|
|
1037
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
1038
|
+
for (const res of clients) res.write(payload)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const watcher = watch(ROOT, () => {
|
|
1042
|
+
graphCache = null // 구조가 바뀌었을 수 있다
|
|
1043
|
+
authorCache = null // 커밋이 늘었을 수 있다
|
|
1044
|
+
featureCache = null // 커밋이 늘면 클러스터도 달라진다
|
|
1045
|
+
newFileCache = null // 새 파일이 추가되면 등록 지점도 달라진다
|
|
1046
|
+
broadcast('live', liveState())
|
|
1047
|
+
})
|
|
1048
|
+
if (!watcher.ok) console.error(`파일 감시 실패 (수동 새로고침 필요): ${watcher.error}`)
|
|
1049
|
+
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
// 라우팅
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
const MIME = {
|
|
1055
|
+
'.html': 'text/html; charset=utf-8',
|
|
1056
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1057
|
+
/**
|
|
1058
|
+
* 🔴 `.mjs` 가 빠져 있었다. 그리고 그 결과가 조용하지 않고 **치명적**이다.
|
|
1059
|
+
*
|
|
1060
|
+
* 브라우저는 ES 모듈을 **JS MIME 이 아니면 실행을 거부한다**(strict MIME
|
|
1061
|
+
* checking). `text/plain` 으로 주면 `import` 가 통째로 실패하고, 그 예외가
|
|
1062
|
+
* 부트스트랩 체인을 끊어 화면이 백지가 된다. 파일은 200 으로 잘 내려가므로
|
|
1063
|
+
* 네트워크 탭만 보면 멀쩡해 보인다.
|
|
1064
|
+
*
|
|
1065
|
+
* `app/lib/*.mjs` 를 화면에 주기 시작하면서 처음 드러났다. 연기 검사가
|
|
1066
|
+
* 잡았다 — 그게 없었으면 "코드가 왜 안 도나" 를 한참 팠을 것이다.
|
|
1067
|
+
*/
|
|
1068
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
1069
|
+
'.css': 'text/css; charset=utf-8',
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function json(res, body, code = 200) {
|
|
1073
|
+
const s = JSON.stringify(body)
|
|
1074
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
1075
|
+
res.end(s)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function safeJoin(base, rel) {
|
|
1079
|
+
const p = path.resolve(base, rel)
|
|
1080
|
+
return p.startsWith(path.resolve(base)) ? p : null
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
async function readBody(req) {
|
|
1084
|
+
const chunks = []
|
|
1085
|
+
for await (const c of req) chunks.push(c)
|
|
1086
|
+
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const server = http.createServer(async (req, res) => {
|
|
1090
|
+
const url = new URL(req.url, `http://localhost:${PORT}`)
|
|
1091
|
+
const q = url.searchParams
|
|
1092
|
+
|
|
1093
|
+
try {
|
|
1094
|
+
// ── 인증 ──────────────────────────────────────────────────────────────
|
|
1095
|
+
// 🔴 다른 어떤 분기보다 먼저다. 라우트 사이에 두면 그 위의 라우트는 그냥 뚫린다.
|
|
1096
|
+
//
|
|
1097
|
+
// 실제로 뚫려 있었다. 게이트가 정적 분기와 `/api/graph` **아래** 있어서
|
|
1098
|
+
// 인증 없이 `/` 는 200 을 주고 `/api/graph` 는 파일 경로·줄수를 전부 내줬다.
|
|
1099
|
+
// 주석에는 "모든 요청에 건다" 라고 적혀 있었고 코드는 아니었다 —
|
|
1100
|
+
// 락에서 금지한 fail-open 과 같은 종류다.
|
|
1101
|
+
//
|
|
1102
|
+
// 새 라우트를 이 블록 위에 추가하면 그 라우트만 조용히 열린다.
|
|
1103
|
+
// 그래서 게이트를 try 의 첫 문장으로 고정한다.
|
|
1104
|
+
if (AUTH && !sameSecret(req.headers.authorization ?? '', AUTH)) {
|
|
1105
|
+
res.writeHead(401, {
|
|
1106
|
+
'WWW-Authenticate': 'Basic realm="axmap", charset="UTF-8"',
|
|
1107
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1108
|
+
})
|
|
1109
|
+
return res.end(JSON.stringify({ error: '인증이 필요합니다' }))
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* `/_bm/` 을 `/api/` 로 되돌린다 (위 API_ALIAS 주석 참조).
|
|
1114
|
+
*
|
|
1115
|
+
* 🔴 정적 분기보다 **먼저**다.
|
|
1116
|
+
*
|
|
1117
|
+
* 아래 정적 분기의 조건은 `!pathname.startsWith('/api/')` 다. 별칭을
|
|
1118
|
+
* 나중에 풀면 `/_bm/flow` 는 그 조건을 통과해 **정적 파일로 취급되어
|
|
1119
|
+
* 404** 가 된다. 화면은 언제나 `_bm/` 로 부르므로(overlay.js 의 api()),
|
|
1120
|
+
* 이 순서가 뒤집혀 있던 동안 뷰어는 데이터를 하나도 못 받았다.
|
|
1121
|
+
* 리버스 프록시 뒤에서만 깨지는 줄 알았는데 로컬에서도 깨져 있었다.
|
|
1122
|
+
*
|
|
1123
|
+
* 인증 뒤에 두는 것은 유지한다 — 별칭으로 인증을 우회할 수 있으면 안 된다.
|
|
1124
|
+
*/
|
|
1125
|
+
if (url.pathname.startsWith(API_ALIAS)) {
|
|
1126
|
+
url.pathname = `/api/${url.pathname.slice(API_ALIAS.length)}`
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ── 정적 ──────────────────────────────────────────────────────────────
|
|
1130
|
+
if (req.method === 'GET' && !url.pathname.startsWith('/api/')) {
|
|
1131
|
+
/**
|
|
1132
|
+
* 화면은 하나다 — `shell.html`.
|
|
1133
|
+
*
|
|
1134
|
+
* 🔴 예전 화면(`index.html` · `overlay.js`)은 지웠다. 한동안 `/classic` 으로
|
|
1135
|
+
* 남겨 뒀는데, 남겨 두는 것 자체가 "저기 있으니 나중에 가져다 쓰자" 는
|
|
1136
|
+
* 여지를 만든다. 재활용할 것이 아니면 여지를 남기지 않는다.
|
|
1137
|
+
*
|
|
1138
|
+
* 벤치 3회차의 측정 대상은 **git 에 그대로 있다** — `git show <커밋>:app/web/overlay.js`
|
|
1139
|
+
* 로 언제든 꺼내서 그 시점 그대로 돌릴 수 있다. 작업 트리에서 지운 것이
|
|
1140
|
+
* 기준선을 없앤 것은 아니다.
|
|
1141
|
+
*/
|
|
1142
|
+
const rel = url.pathname === '/' ? 'shell.html' : url.pathname.slice(1)
|
|
1143
|
+
/**
|
|
1144
|
+
* 🔴 `app/lib/` 의 **순수 모듈**은 브라우저에도 그대로 준다.
|
|
1145
|
+
*
|
|
1146
|
+
* `reveal.mjs` 는 fs 도 git 도 안 만지는 순수 함수뿐이다. 그것을 화면
|
|
1147
|
+
* 쪽에 다시 구현하면 두 벌이 되고, 한쪽만 고치면 조용히 갈린다 —
|
|
1148
|
+
* 이 저장소가 `LANG` 표에서 이미 한 번 당한 실패다.
|
|
1149
|
+
*
|
|
1150
|
+
* 같은 파일이 서버에서 테스트되고 브라우저에서 돌아간다. `test/` 가
|
|
1151
|
+
* 검증하는 그 코드가 화면에서도 그대로 도는 것이 요점이다.
|
|
1152
|
+
*/
|
|
1153
|
+
const file = rel.startsWith('lib/')
|
|
1154
|
+
? safeJoin(path.join(ROOT_DIR_OF_APP, 'lib'), rel.slice(4))
|
|
1155
|
+
: safeJoin(WEB, rel)
|
|
1156
|
+
if (!file || !fs.existsSync(file)) {
|
|
1157
|
+
res.writeHead(404).end('not found')
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* 🔴 화면 파일은 캐시하지 않는다.
|
|
1162
|
+
*
|
|
1163
|
+
* 캐시 헤더가 아예 없었다. 그러면 브라우저가 휴리스틱으로 알아서 캐시하고,
|
|
1164
|
+
* axMap 을 고쳐도 **옛 화면이 계속 뜬다.**
|
|
1165
|
+
*
|
|
1166
|
+
* 실제로 이것 때문에 반나절을 잃을 뻔했다 — 물리 정지 코드를 넣고 몇 번을
|
|
1167
|
+
* 고쳐도 화면이 안 변해서 코드를 의심했는데, 서버는 새 파일을 주고 있었고
|
|
1168
|
+
* 브라우저가 옛 `graph.js` 를 쓰고 있었다. HTML 에 `?cb=` 를 붙여도 모듈
|
|
1169
|
+
* URL 은 그대로라 안 바뀐다.
|
|
1170
|
+
*
|
|
1171
|
+
* 이건 로컬 개발 뷰어다. 대역폭보다 **지금 고친 것이 지금 보이는 것**이
|
|
1172
|
+
* 훨씬 중요하다.
|
|
1173
|
+
*/
|
|
1174
|
+
res.writeHead(200, {
|
|
1175
|
+
'content-type': MIME[path.extname(file)] ?? 'text/plain',
|
|
1176
|
+
'cache-control': 'no-store, must-revalidate',
|
|
1177
|
+
})
|
|
1178
|
+
fs.createReadStream(file).pipe(res)
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ── 그래프 ────────────────────────────────────────────────────────────
|
|
1183
|
+
if (url.pathname === '/api/graph') {
|
|
1184
|
+
const g = graph()
|
|
1185
|
+
json(res, { root: ROOT, ...g })
|
|
1186
|
+
return
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// 읽기전용이면 상태를 바꾸는 요청을 전부 막는다. 개별 라우트에 흩어 놓으면
|
|
1190
|
+
// 새 엔드포인트를 추가할 때 빠뜨린다 — 한 곳에서 막아야 빠뜨릴 수 없다.
|
|
1191
|
+
if (READONLY && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1192
|
+
return json(res, { error: '읽기 전용으로 열린 서버입니다 (--readonly)' }, 403)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (url.pathname === '/api/overlay') {
|
|
1196
|
+
const { co: _co, ...o } = overlayGraph() // co 는 서버 내부용 (Map 이라 직렬화 안 된다)
|
|
1197
|
+
json(res, { root: ROOT, ...o })
|
|
1198
|
+
return
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// 기능 범위 — "사진 업로드만 보여줘"
|
|
1202
|
+
// 이름으로는 못 찾는다. 커밋 메시지(사람이 남긴 의도)와 공변경으로 넓힌다.
|
|
1203
|
+
if (url.pathname === '/api/scope') {
|
|
1204
|
+
const query = q.get('q') ?? ''
|
|
1205
|
+
const o = overlayGraph()
|
|
1206
|
+
if (!o.co) { json(res, { error: 'git 히스토리가 없어 기능 범위를 만들 수 없습니다' }, 400); return }
|
|
1207
|
+
const s = featureScope(ROOT, query, {
|
|
1208
|
+
ids: new Set(o.nodes.map((n) => n.id)), freq: o.co.freq, coEdges: o.co.edges,
|
|
1209
|
+
})
|
|
1210
|
+
json(res, s ?? { error: '질의가 비었습니다' }, s ? 200 : 400)
|
|
1211
|
+
return
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// 진입점 — "어디서부터 읽어야 하나".
|
|
1215
|
+
// 점수 하나로 줄 세우지 않고, 서로 다른 질문에 답하는 목록 넷을 준다 (entry.mjs).
|
|
1216
|
+
if (url.pathname === '/api/entry') {
|
|
1217
|
+
const o = overlayGraph()
|
|
1218
|
+
const core = coreSubset(o.nodes, o.edges, o.freq)
|
|
1219
|
+
json(res, {
|
|
1220
|
+
...entryPoints({ nodes: o.nodes }, o.edges, o.freq),
|
|
1221
|
+
core: core.ids ? { ids: [...core.ids], seeds: core.seeds, reason: core.reason } : null,
|
|
1222
|
+
})
|
|
1223
|
+
return
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* 기본 흐름 — 다섯 걸음 (D15).
|
|
1228
|
+
*
|
|
1229
|
+
* 🔴 `/api/entry` 와 다르다. entry 는 **질문 넷에 각각 답하는 병렬 목록**이고,
|
|
1230
|
+
* 여기는 **따라가는 순서**다. 둘 다 있어야 한다 —
|
|
1231
|
+
* 처음 온 사람에게는 순서가, 목적이 있는 사람에게는 목록이 맞다.
|
|
1232
|
+
*
|
|
1233
|
+
* ④는 entry 의 결과를 그대로 재사용한다. 같은 숫자를 두 번 계산하면
|
|
1234
|
+
* 두 화면이 서로 다른 답을 하게 된다.
|
|
1235
|
+
*/
|
|
1236
|
+
/**
|
|
1237
|
+
* 기능 단위 그래프 — 파일이 아니라 사람의 말 (featuregraph.mjs).
|
|
1238
|
+
*
|
|
1239
|
+
* 🔴 파일 그래프를 대신하는 것이 아니라 **다른 축**이다.
|
|
1240
|
+
* 코드를 읽을 줄 아는 사람에게는 파일이, 그렇지 않은 사람에게는
|
|
1241
|
+
* 모듈 이름이 맞다. 화면이 오갈 수 있게 둘 다 준다.
|
|
1242
|
+
*/
|
|
1243
|
+
if (url.pathname === '/api/featuregraph') {
|
|
1244
|
+
const o = overlayGraph()
|
|
1245
|
+
const { available, commits } = commitFiles(ROOT, { since: '10 years ago', maxCommits: 4000 })
|
|
1246
|
+
let readme = null
|
|
1247
|
+
for (const f of ['README.md', 'README.rst', 'readme.md', 'README']) {
|
|
1248
|
+
try { readme = fs.readFileSync(path.join(ROOT, f), 'utf8'); break } catch { /* 다음 후보 */ }
|
|
1249
|
+
}
|
|
1250
|
+
const fg = featureGraph({
|
|
1251
|
+
paths: o.nodes.map((n) => n.id),
|
|
1252
|
+
commits: available ? commits : [],
|
|
1253
|
+
readme,
|
|
1254
|
+
edges: o.edges,
|
|
1255
|
+
})
|
|
1256
|
+
// 파일 줄수를 모듈에 합쳐 넣는다 — 화면이 노드 크기로 쓴다.
|
|
1257
|
+
const lines = new Map(o.nodes.map((n) => [n.id, n.lines ?? 0]))
|
|
1258
|
+
for (const n of fg.nodes) n.lines = n.paths.reduce((a, p) => a + (lines.get(p) ?? 0), 0)
|
|
1259
|
+
json(res, { ...fg, historyAvailable: available })
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (url.pathname === '/api/flow') {
|
|
1264
|
+
const o = overlayGraph()
|
|
1265
|
+
const live = liveState()
|
|
1266
|
+
const f = basicFlow(ROOT, { nodes: o.nodes, edges: o.edges }, {
|
|
1267
|
+
entry: entryPoints({ nodes: o.nodes }, o.edges, o.freq),
|
|
1268
|
+
claims: live.claims,
|
|
1269
|
+
newFile: newFileForFlow(o.nodes),
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* ③걸음이 "중심까지 가는 길" 을 **먼저 답한다.**
|
|
1274
|
+
*
|
|
1275
|
+
* 🔴 세 변형을 재고 나온 결론이다. ①(사슬)만 M3 를 앞당겼고, ②(사다리)와
|
|
1276
|
+
* ③(질의 상자)은 **보고서에 이름조차 안 나왔다.** 기본 배치로 뒀는데도,
|
|
1277
|
+
* 걸음 안에 넣었는데도 그랬다. 신입은 번호 매긴 걸음을 따라갈 뿐
|
|
1278
|
+
* **옆에 놓인 것을 발견하지 않는다.**
|
|
1279
|
+
*
|
|
1280
|
+
* 그런데 ③ 회차가 가장 완전한 답을 냈다 — ②가 "모른다" 로 끝낸
|
|
1281
|
+
* 연결을 끝까지 이었다. 도구가 나빠서가 아니라 **묻게 만들어서**
|
|
1282
|
+
* 느렸던 것이다. 그러니 묻기를 기다리지 말고 먼저 답한다.
|
|
1283
|
+
*
|
|
1284
|
+
* 🔴 "중심" 은 추측이 아니라 관측이다 — **가장 많은 파일이 부르는 파일**.
|
|
1285
|
+
* 테스트·예제는 뺀다. 그것들은 많이 부르지 많이 불리지 않는다.
|
|
1286
|
+
*/
|
|
1287
|
+
const s3 = f.steps.find((s) => s.n === 3)
|
|
1288
|
+
if (s3?.answer?.chain?.start) {
|
|
1289
|
+
const inDeg = new Map()
|
|
1290
|
+
for (const e of o.edges) {
|
|
1291
|
+
if (e.hub || e.origin === 'cochange') continue
|
|
1292
|
+
if (isAux(e.target)) continue
|
|
1293
|
+
inDeg.set(e.target, (inDeg.get(e.target) ?? 0) + 1)
|
|
1294
|
+
}
|
|
1295
|
+
const [center, deg] = [...inDeg.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0] ?? []
|
|
1296
|
+
// 중심이 시작점 자신이거나 부르는 곳이 한 줌이면 말할 것이 없다.
|
|
1297
|
+
if (center && center !== s3.answer.chain.start && deg >= 5) {
|
|
1298
|
+
s3.answer.toCenter = {
|
|
1299
|
+
...pathBetween(o.nodes, o.edges, s3.answer.chain.start, center),
|
|
1300
|
+
calledBy: deg,
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* ④걸음의 날 숫자를 코퍼스 분포에 댄다.
|
|
1307
|
+
*
|
|
1308
|
+
* 🔴 `go.mod 865회` 를 보고 신입이 할 수 있는 판단은 없다. 865가 많은
|
|
1309
|
+
* 건지 원래 그런 건지 알 방법이 없기 때문이다. 같은 언어·규모
|
|
1310
|
+
* 저장소 수천 개의 분포와 대면 그제야 객관적 진술이 된다.
|
|
1311
|
+
*
|
|
1312
|
+
* 🔴 `hot`(커밋 수)만 붙인다. `risk`(숨은 이웃 수)에 대응하는 코퍼스
|
|
1313
|
+
* 지표가 무엇인지 확실하지 않다. 어긋난 지표로 등수를 매기면 그건
|
|
1314
|
+
* 없는 근거를 만들어내는 것이고, 여기서 가장 나쁜 종류의 실패다.
|
|
1315
|
+
*/
|
|
1316
|
+
const cell = cellFor(BASELINE, {
|
|
1317
|
+
lang: dominantLang(o.nodes.map((n) => n.id)),
|
|
1318
|
+
commits: o.stats?.commits ?? null,
|
|
1319
|
+
})
|
|
1320
|
+
const hot = f.steps.find((s) => s.n === 4)?.answer?.hot
|
|
1321
|
+
if (cell?.fileCommits && hot?.rows) {
|
|
1322
|
+
for (const r of hot.rows) r.band = bandOf(cell.fileCommits, r.freq)
|
|
1323
|
+
hot.corpus = {
|
|
1324
|
+
cell: cell.key, repos: cell.repos, at: BASELINE?.at ?? null,
|
|
1325
|
+
metric: '파일당 커밋 수', n: cell.fileCommits.n ?? null,
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
json(res, f)
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* 사다리 — 힘 그래프를 대신하는 배치 (변형 ②).
|
|
1334
|
+
*
|
|
1335
|
+
* 🔴 `/api/flow` 의 ③걸음과 **같은 데이터**를 다르게 자른다. 안내록은
|
|
1336
|
+
* "몇 겹에 몇 개" 를 요약하고, 여기는 오른쪽 넓은 자리에 전부 편다.
|
|
1337
|
+
* 두 곳의 도달 판정이 어긋나면 안 되므로 `test/ladder.test.mjs` 가
|
|
1338
|
+
* `layersFrom` 과 도달 수가 같은지 못박는다.
|
|
1339
|
+
*/
|
|
1340
|
+
/**
|
|
1341
|
+
* ── 하위 프로젝트 ─────────────────────────────────────────────────────
|
|
1342
|
+
*
|
|
1343
|
+
* 모노레포에서 "어디서부터 읽나" 의 **첫 답은 파일이 아니라 프로젝트**다.
|
|
1344
|
+
* 왜 필요한지는 `app/lib/roots.mjs` 머리말 (실측 포함).
|
|
1345
|
+
*/
|
|
1346
|
+
if (url.pathname === '/api/roots') {
|
|
1347
|
+
const g = graph()
|
|
1348
|
+
const roots = findRoots(ROOT, g.nodes)
|
|
1349
|
+
json(res, { roots, ...splitNeeded(roots, g.nodes) })
|
|
1350
|
+
return
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (url.pathname === '/api/ladder') {
|
|
1354
|
+
const o = overlayGraph()
|
|
1355
|
+
/**
|
|
1356
|
+
* `?root=FE/bbiyong-react` — 그 하위 프로젝트 안에서만 겹을 만든다.
|
|
1357
|
+
*
|
|
1358
|
+
* 🔴 진입점도 **그 안에서** 다시 찾는다. 저장소 최상위 기준으로 찾으면
|
|
1359
|
+
* 모노레포에서 엉뚱한 것이 잡힌다 — 실측에서 JIRA 자동화 스크립트가
|
|
1360
|
+
* 잡혀 549개 중 14개만 닿았다.
|
|
1361
|
+
*/
|
|
1362
|
+
const sub = (q.get('root') ?? '').replace(/^\/+|\/+$/g, '')
|
|
1363
|
+
if (sub) {
|
|
1364
|
+
const nodes = o.nodes.filter((n) => n.id === sub || n.id.startsWith(`${sub}/`))
|
|
1365
|
+
const ids = new Set(nodes.map((n) => n.id))
|
|
1366
|
+
const edges = o.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
|
|
1367
|
+
const starts = entryStarts(ROOT, nodes, edges, { prefix: sub })
|
|
1368
|
+
return json(res, {
|
|
1369
|
+
root: sub,
|
|
1370
|
+
...ladder(nodes, edges, starts, {
|
|
1371
|
+
perGroup: Number(q.get('perGroup')) || 8,
|
|
1372
|
+
maxGroups: Number(q.get('maxGroups')) || 6,
|
|
1373
|
+
}),
|
|
1374
|
+
})
|
|
1375
|
+
}
|
|
1376
|
+
// ③걸음과 **같은** 시작점을 쓴다. 따로 뽑으면 두 화면이 다른 말을 한다.
|
|
1377
|
+
const num = (k, d, max) => {
|
|
1378
|
+
const v = Number(q.get(k))
|
|
1379
|
+
// 이상한 입력은 안전한 값으로 치환하지 않고 기본값으로 되돌린다.
|
|
1380
|
+
return Number.isFinite(v) && v > 0 ? Math.min(v, max) : d
|
|
1381
|
+
}
|
|
1382
|
+
json(res, ladder(o.nodes, o.edges, entryStarts(ROOT, o.nodes, o.edges), {
|
|
1383
|
+
perGroup: num('perGroup', 8, 400),
|
|
1384
|
+
maxGroups: num('maxGroups', 6, 60),
|
|
1385
|
+
}))
|
|
1386
|
+
return
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* 두 파일 사이 경로 (변형 ③).
|
|
1391
|
+
*
|
|
1392
|
+
* /api/path?from=a.go&to=b.go
|
|
1393
|
+
* /api/path?to=b.go from 을 비우면 진입점 전부에서 시도한다
|
|
1394
|
+
*
|
|
1395
|
+
* 🔴 `from` 을 생략할 수 있게 한 것이 중요하다. 신입은 출발점을 모른다 —
|
|
1396
|
+
* "이 기능이 어디서 시작되나" 를 묻고 싶은 것이지 "A 에서 B" 를 묻고
|
|
1397
|
+
* 싶은 게 아니다. 진입점은 ②걸음이 이미 알고 있다.
|
|
1398
|
+
*/
|
|
1399
|
+
if (url.pathname === '/api/path') {
|
|
1400
|
+
const o = overlayGraph()
|
|
1401
|
+
const to = q.get('to')
|
|
1402
|
+
if (!to) { json(res, { found: false, why: '도착 파일을 지정해주세요' }); return }
|
|
1403
|
+
const from = q.get('from')
|
|
1404
|
+
if (from) { json(res, pathBetween(o.nodes, o.edges, from, to)); return }
|
|
1405
|
+
// 진입점마다 시도하고 **가장 짧은** 것을 낸다. import 로 이어지는 것이
|
|
1406
|
+
// 있으면 그것이 우선이다 (pathBetween 안의 판단과 같은 이유).
|
|
1407
|
+
const starts = entryStarts(ROOT, o.nodes, o.edges)
|
|
1408
|
+
const tries = starts.map((s) => pathBetween(o.nodes, o.edges, s, to)).filter((r) => r.found)
|
|
1409
|
+
const best = tries.sort((a, b) =>
|
|
1410
|
+
(a.via === 'import' ? 0 : 1) - (b.via === 'import' ? 0 : 1) || a.hops.length - b.hops.length)[0]
|
|
1411
|
+
json(res, best ?? {
|
|
1412
|
+
from: null, to, found: false, triedStarts: starts,
|
|
1413
|
+
why: `진입점 ${starts.length}개 어디서도 ${to} 로 이어지지 않습니다.`
|
|
1414
|
+
+ ' 정적 파싱이 못 보는 연결이거나, 진입점이 더 있습니다.',
|
|
1415
|
+
})
|
|
1416
|
+
return
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* PR — 작업을 올리기 전에 "뭘 바꿨고 어디까지 갔나" (D2 · F2).
|
|
1421
|
+
*
|
|
1422
|
+
* /api/pr base=main (기본)
|
|
1423
|
+
* /api/pr?base=HEAD~1 직전 커밋과 비교
|
|
1424
|
+
* /api/pr?base=working 아직 커밋 안 한 것만
|
|
1425
|
+
* /api/pr?worktree=0 커밋된 것만
|
|
1426
|
+
*
|
|
1427
|
+
* 🔴 `/api/flow` 와 짝이다. flow 는 작업 **전**(이해), 여기는 작업 **후**(검증).
|
|
1428
|
+
* 같은 오버레이 데이터를 쓰지만 묻는 것이 다르다.
|
|
1429
|
+
*/
|
|
1430
|
+
if (url.pathname === '/api/pr') {
|
|
1431
|
+
const c = prChanges(q.get('base'))
|
|
1432
|
+
if (c.error) {
|
|
1433
|
+
// 🔴 200 에 빈 목록을 실어 보내지 않는다. 화면이 "빠진 것 없음" 으로
|
|
1434
|
+
// 그리게 되고, 그 착각이 이 화면을 없는 것보다 나쁘게 만든다.
|
|
1435
|
+
json(res, { error: c.error, hint: c.hint ?? null, base: q.get('base') ?? 'default' }, 400)
|
|
1436
|
+
return
|
|
1437
|
+
}
|
|
1438
|
+
const useWork = q.get('worktree') !== '0'
|
|
1439
|
+
const raw = [...c.diff, ...(useWork ? c.worktree : [])]
|
|
1440
|
+
const { files: changed, outside } = scopeToRoot(raw)
|
|
1441
|
+
|
|
1442
|
+
const o = overlayGraph()
|
|
1443
|
+
/**
|
|
1444
|
+
* Q4 의 "평소" 는 **이번 변경 이전**의 히스토리다.
|
|
1445
|
+
*
|
|
1446
|
+
* 🔴 merge-base 까지만 본다. 이번 커밋들을 표본에 넣으면 이번 커밋과
|
|
1447
|
+
* 이번 커밋을 포함한 평균을 비교하게 되어, 넓은 변경일수록 "평소와
|
|
1448
|
+
* 비슷하다" 는 답이 나온다 — 정확히 거꾸로다.
|
|
1449
|
+
*/
|
|
1450
|
+
/**
|
|
1451
|
+
* 창을 넓게 잡는다 (30년 · 20,000커밋).
|
|
1452
|
+
*
|
|
1453
|
+
* 🔴 featuregraph 는 10년 · 4,000 을 쓰는데, 그건 "요즘 무슨 일이
|
|
1454
|
+
* 있었나" 를 묻기 때문이다. Q4 는 반대로 **전체 분포**를 물어야 한다 —
|
|
1455
|
+
* 창이 좁으면 오래된 저장소에서 "평소" 가 최근 몇 년만 뜻하게 되고,
|
|
1456
|
+
* 그 사실이 화면 어디에도 안 나온다. 상한에 걸리면 아래에서 말한다.
|
|
1457
|
+
*/
|
|
1458
|
+
const HIST_MAX = 20000
|
|
1459
|
+
const hist = commitFiles(ROOT, {
|
|
1460
|
+
since: '30 years ago', maxCommits: HIST_MAX, ref: c.base ?? 'HEAD',
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
const r = prReview({
|
|
1464
|
+
changed,
|
|
1465
|
+
nodes: o.nodes,
|
|
1466
|
+
edges: o.edges,
|
|
1467
|
+
coEdges: o.co?.edges ?? [],
|
|
1468
|
+
freq: o.freq,
|
|
1469
|
+
commits: hist.available ? hist.commits : [],
|
|
1470
|
+
})
|
|
1471
|
+
|
|
1472
|
+
const gaps = [...r.gaps]
|
|
1473
|
+
if (outside) {
|
|
1474
|
+
gaps.push({ q: 1, text: `보고 있는 폴더 밖에서 ${outside}개가 함께 바뀌었습니다 — 이 화면은 그것들을 못 봅니다` })
|
|
1475
|
+
}
|
|
1476
|
+
if (!hist.available) gaps.push({ q: 4, text: 'git log 를 읽지 못해 "평소" 를 비교하지 못했습니다' })
|
|
1477
|
+
else if (hist.commits.length >= HIST_MAX) {
|
|
1478
|
+
gaps.push({ q: 4, text: `히스토리를 ${HIST_MAX.toLocaleString()}커밋까지만 봤습니다 — 그보다 오래된 것은 "평소" 에 안 들어갔습니다` })
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* 🔴 PR 이 "이것도 봐야 한다" 고 지목한 파일을 **지금 누가 잡고 있는지** 붙인다.
|
|
1483
|
+
*
|
|
1484
|
+
* 온보딩 실험에서 에이전트가 짚었다 — PR 이 경고한 누락 파일이 정확히
|
|
1485
|
+
* 어떤 동료의 작업 영역이었는데, *"이 인과 연결은 화면이 한 문장으로
|
|
1486
|
+
* 안 이어줘서 두 화면을 내가 대조해 추론했다"* 고 적었다.
|
|
1487
|
+
*
|
|
1488
|
+
* ⑤(새로 만들 곳)에는 이미 붙였다. 같은 원칙을 여기에도 적용한다 —
|
|
1489
|
+
* 판정하는 화면과 사람을 아는 화면이 따로 있으면 겹치는 일이 사람 몫이 된다.
|
|
1490
|
+
* 거부가 아니라 알림이다. 프로토콜은 건드리지 않는다.
|
|
1491
|
+
*/
|
|
1492
|
+
const nowClaims = liveState().claims
|
|
1493
|
+
const holderOf = (p) => {
|
|
1494
|
+
const owner = nowClaims.find((cl) => coversPath(cl.paths ?? [], p))
|
|
1495
|
+
return owner
|
|
1496
|
+
? { agent: owner.agent ?? null, task: owner.task ?? null, intent: owner.intent ?? null }
|
|
1497
|
+
: null
|
|
1498
|
+
}
|
|
1499
|
+
for (const qq of r.questions ?? []) {
|
|
1500
|
+
for (const row of qq.answer?.rows ?? []) {
|
|
1501
|
+
const h = holderOf(row.path)
|
|
1502
|
+
if (h) row.heldBy = h
|
|
1503
|
+
/**
|
|
1504
|
+
* 🔴 짝이 되는 쪽도 본다.
|
|
1505
|
+
*
|
|
1506
|
+
* Q3("같이 바뀌었어야 하는데 안 바뀐 것")의 한 줄은 두 파일 이야기다 —
|
|
1507
|
+
* 바뀐 `from` 과 안 바뀐 `path`. 에이전트가 실제로 이은 연결은
|
|
1508
|
+
* **바뀐 쪽이 남의 작업 영역**이라는 것이었다. 그쪽을 안 보면
|
|
1509
|
+
* 정작 사람이 필요한 정보를 빠뜨린다.
|
|
1510
|
+
*/
|
|
1511
|
+
if (row.from) {
|
|
1512
|
+
const hf = holderOf(row.from)
|
|
1513
|
+
if (hf) row.heldByFrom = hf
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
json(res, {
|
|
1519
|
+
...r,
|
|
1520
|
+
gaps,
|
|
1521
|
+
source: {
|
|
1522
|
+
mode: c.mode,
|
|
1523
|
+
base: c.base,
|
|
1524
|
+
ref: c.ref ?? null,
|
|
1525
|
+
asked: q.get('base') ?? 'default',
|
|
1526
|
+
ahead: c.ahead ?? 0,
|
|
1527
|
+
diffFiles: c.diff.length,
|
|
1528
|
+
worktreeFiles: useWork ? c.worktree.length : 0,
|
|
1529
|
+
worktreeIncluded: useWork,
|
|
1530
|
+
outside,
|
|
1531
|
+
historyCommits: hist.available ? hist.commits.length : 0,
|
|
1532
|
+
},
|
|
1533
|
+
})
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// 선점 현황 + 기능 인접 경고.
|
|
1538
|
+
// 경로는 안 겹치는데 공변경으로 이어진 claim 쌍을 찾는다 (adjacent.mjs).
|
|
1539
|
+
// 🔴 경고일 뿐이다. 프로토콜의 판정은 건드리지 않는다.
|
|
1540
|
+
if (url.pathname === '/api/watch') {
|
|
1541
|
+
// 실시간 상태는 liveState() 가 이미 만든다. 두 벌로 만들면 두 화면이
|
|
1542
|
+
// 서로 다른 답을 하게 된다 — 여기서는 기능 인접 경고만 얹는다.
|
|
1543
|
+
const live = liveState()
|
|
1544
|
+
const o = overlayGraph()
|
|
1545
|
+
json(res, {
|
|
1546
|
+
...live,
|
|
1547
|
+
available: live.ledgerAvailable,
|
|
1548
|
+
adjacency: featureAdjacency(live.claims, o.co?.edges ?? [], o.nodes.map((n) => n.id)),
|
|
1549
|
+
})
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* "여기에 새로 만들려면 어디를 고치나".
|
|
1555
|
+
*
|
|
1556
|
+
* 온보딩 실험에서 도구가 답하지 못한 유일한 질문이다. 신입이 가장 오래
|
|
1557
|
+
* 막히는 자리이기도 하다 — 무엇을 하는 물건인지는 README 가 말하지만
|
|
1558
|
+
* "내가 뭘 건드려야 하나" 는 코드를 다 읽은 사람만 안다.
|
|
1559
|
+
*/
|
|
1560
|
+
if (url.pathname === '/api/newfile') {
|
|
1561
|
+
const scope = q.get('scope')
|
|
1562
|
+
// 경로는 저장소 안이어야 한다. `..` 로 밖을 보게 두지 않는다.
|
|
1563
|
+
if (scope && (scope.includes('..') || path.isAbsolute(scope))) {
|
|
1564
|
+
return json(res, { error: '범위로 쓸 수 없는 경로입니다' }, 400)
|
|
1565
|
+
}
|
|
1566
|
+
const commits = newFileCommits(scope)
|
|
1567
|
+
if (commits === null) {
|
|
1568
|
+
return json(res, { answered: false, why: 'git 히스토리를 읽지 못했습니다', scope, points: [] })
|
|
1569
|
+
}
|
|
1570
|
+
const r = registrationPoints(commits)
|
|
1571
|
+
return json(res, {
|
|
1572
|
+
...r,
|
|
1573
|
+
scope: scope ?? null,
|
|
1574
|
+
sentence: sayNewFile(r, scope),
|
|
1575
|
+
// 🔴 어디까지 봤는지 같이 낸다. 상한에 걸렸는데 말하지 않으면
|
|
1576
|
+
// "전부 봤다" 로 읽힌다 (SPEC 의 조용한 절단 금지와 같은 이유).
|
|
1577
|
+
capped: commits.length >= MAX_ADD_COMMITS ? MAX_ADD_COMMITS : null,
|
|
1578
|
+
})
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (url.pathname === '/api/depths') {
|
|
1582
|
+
const start = q.get('start')
|
|
1583
|
+
const g = graph()
|
|
1584
|
+
if (!g) return json(res, { depths: {} })
|
|
1585
|
+
const node = g.nodes.find((n) => n.id === start)
|
|
1586
|
+
if (!node) return json(res, { depths: {} })
|
|
1587
|
+
const withHubs = q.get('hubs') === '1'
|
|
1588
|
+
const opts = { includeHubEdges: withHubs }
|
|
1589
|
+
|
|
1590
|
+
// 방향은 화면이 고르지 않는다. 기본값의 근거가 analyze.mjs 의 askOf 에 있고,
|
|
1591
|
+
// 두 곳에서 따로 정하면 화면 설명과 계산이 갈라진다.
|
|
1592
|
+
const asked = q.get('dir')
|
|
1593
|
+
const direction = ['in', 'out', 'both'].includes(asked) ? asked : askOf(node)
|
|
1594
|
+
|
|
1595
|
+
// 양쪽 수를 함께 준다. "내가 쓰는 것 2 · 나를 쓰는 것 23" 을 보여줘야
|
|
1596
|
+
// 사용자가 뒤집을지 판단할 수 있다. 한 방향만 주면 뒤집을 이유를 모른다.
|
|
1597
|
+
json(res, {
|
|
1598
|
+
start,
|
|
1599
|
+
direction,
|
|
1600
|
+
role: node.role,
|
|
1601
|
+
reach: {
|
|
1602
|
+
out: depths(g, start, { ...opts, direction: 'out' }).size - 1,
|
|
1603
|
+
in: depths(g, start, { ...opts, direction: 'in' }).size - 1,
|
|
1604
|
+
},
|
|
1605
|
+
depths: Object.fromEntries(depths(g, start, { ...opts, direction })),
|
|
1606
|
+
})
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
if (url.pathname === '/api/coupling') {
|
|
1611
|
+
json(res, coupling(graph()))
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// ── 기능 ──────────────────────────────────────────────────────────────
|
|
1616
|
+
|
|
1617
|
+
// ── 대분류 · 색인 ─────────────────────────────────────────────────────
|
|
1618
|
+
// POST 를 먼저 본다. 아래 GET 분기에 메서드 조건이 없어서 순서를 바꾸면
|
|
1619
|
+
// 저장 요청이 조회로 처리되고 조용히 아무 일도 안 일어난다.
|
|
1620
|
+
if (url.pathname === '/api/taxonomy' && req.method === 'POST') {
|
|
1621
|
+
const { categories } = await readBody(req)
|
|
1622
|
+
if (!Array.isArray(categories) || !categories.length) {
|
|
1623
|
+
return json(res, { error: '분류 목록이 비었습니다' }, 400)
|
|
1624
|
+
}
|
|
1625
|
+
fs.mkdirSync(path.dirname(TAXONOMY), { recursive: true })
|
|
1626
|
+
fs.writeFileSync(TAXONOMY, `${JSON.stringify({ categories }, null, 2)}\n`, 'utf8')
|
|
1627
|
+
indexCache = null // 목록이 바뀌면 색인은 무효다
|
|
1628
|
+
json(res, indexState())
|
|
1629
|
+
return
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (url.pathname === '/api/taxonomy') {
|
|
1633
|
+
// prompt=1 이면 큰 모델에 붙여넣을 재료를 준다. 도구가 API 키를 들지 않는다.
|
|
1634
|
+
if (q.get('prompt') === '1') return json(res, { prompt: taxonomyPrompt() })
|
|
1635
|
+
json(res, indexState())
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
if (url.pathname === '/api/index' && req.method === 'POST') {
|
|
1640
|
+
json(res, { ...(await buildIndex()), ...indexState() })
|
|
1641
|
+
return
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
// ── 파일 ──────────────────────────────────────────────────────────────
|
|
1646
|
+
if (url.pathname === '/api/detail') {
|
|
1647
|
+
const rel = q.get('path')
|
|
1648
|
+
const abs = rel && safeJoin(ROOT, rel)
|
|
1649
|
+
if (!abs || !fs.existsSync(abs)) return json(res, { error: '없는 파일' }, 404)
|
|
1650
|
+
json(res, detail(ROOT, rel))
|
|
1651
|
+
return
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* ── 대화(= CLI 프로세스) ──────────────────────────────────────────────
|
|
1656
|
+
*
|
|
1657
|
+
* 화면에서는 채팅이지만 여기서는 자식 프로세스다. 세션을 **서버가** 들고
|
|
1658
|
+
* 있으므로 창이 느려지거나 닫혀도 진행 중인 작업은 계속 돈다.
|
|
1659
|
+
* 자세한 이유는 `app/lib/session.mjs` 머리말.
|
|
1660
|
+
*/
|
|
1661
|
+
/**
|
|
1662
|
+
* ── 지금 도는 것이 어느 빌드인가 ──────────────────────────────────────
|
|
1663
|
+
*
|
|
1664
|
+
* 🔴 이 화면이 없어서 한 번 헤맸다.
|
|
1665
|
+
*
|
|
1666
|
+
* 바탕화면 아이콘은 **마지막으로 빌드한 것**을 가리킨다. 그런데 그 뒤로 코드를
|
|
1667
|
+
* 고치고 커밋해도 다시 빌드하지 않으면 아이콘은 옛것을 계속 연다. 실제로
|
|
1668
|
+
* 53분 전 빌드가 열려서 "새로 만든 온보딩이 왜 안 뜨지" 로 시간을 썼다.
|
|
1669
|
+
*
|
|
1670
|
+
* 화면에 적어 두면 그 혼란이 다시 안 생긴다. git 이 있으면 커밋을,
|
|
1671
|
+
* 없으면(패키징된 앱은 `.git` 이 없다) 코드 파일의 시각을 쓴다.
|
|
1672
|
+
*/
|
|
1673
|
+
if (url.pathname === '/api/version') {
|
|
1674
|
+
let label = null
|
|
1675
|
+
try {
|
|
1676
|
+
const r = spawnSync('git', ['-C', HERE, 'rev-parse', '--short', 'HEAD'],
|
|
1677
|
+
{ encoding: 'utf8', windowsHide: true })
|
|
1678
|
+
if (r.status === 0 && r.stdout.trim()) {
|
|
1679
|
+
const d = spawnSync('git', ['-C', HERE, 'status', '--porcelain'],
|
|
1680
|
+
{ encoding: 'utf8', windowsHide: true })
|
|
1681
|
+
label = r.stdout.trim() + (d.stdout?.trim() ? ' (고친 것 있음)' : '')
|
|
1682
|
+
}
|
|
1683
|
+
} catch { /* git 이 없으면 아래로 */ }
|
|
1684
|
+
if (!label) {
|
|
1685
|
+
// 패키징된 앱. 코드가 언제 만들어졌는지가 곧 빌드 시각이다.
|
|
1686
|
+
try {
|
|
1687
|
+
const t = fs.statSync(fileURLToPath(import.meta.url)).mtime
|
|
1688
|
+
label = `빌드 ${t.toISOString().slice(0, 16).replace('T', ' ')}`
|
|
1689
|
+
} catch { label = '알 수 없음' }
|
|
1690
|
+
}
|
|
1691
|
+
json(res, { label })
|
|
1692
|
+
return
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (url.pathname === '/api/open/status') {
|
|
1696
|
+
const j = fetchJobs.get(q.get('job'))
|
|
1697
|
+
if (!j) return json(res, { error: '없는 작업입니다' }, 404)
|
|
1698
|
+
// 로그는 뒤쪽 몇 줄만. 전부 보내면 받는 쪽이 느려지고, 앞줄은 이미 지나갔다.
|
|
1699
|
+
json(res, { ...j, log: j.log.slice(-6) })
|
|
1700
|
+
return
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* ── 저장소 열기 ───────────────────────────────────────────────────────
|
|
1705
|
+
*
|
|
1706
|
+
* git 주소면 받아 오고, 로컬 경로면 확인만 한다. **그리고 여기서 끝난다.**
|
|
1707
|
+
*
|
|
1708
|
+
* 🔴 대상을 이 프로세스 안에서 바꾸지 않는 이유.
|
|
1709
|
+
*
|
|
1710
|
+
* `ROOT` 는 45곳에서 읽히고 그 위에 캐시가 8개(그래프·오버레이·기능·색인·
|
|
1711
|
+
* 새 파일·기준선·작성자…)와 파일 감시자가 얹혀 있다. 하나라도 안 비우면
|
|
1712
|
+
* **A 저장소의 데이터를 B 라고 보여준다** — 에러도 안 나고, 화면은 그럴듯하다.
|
|
1713
|
+
* D5 가 "확신 있게 틀린 답이 가장 해롭다" 고 적은 바로 그 실패다.
|
|
1714
|
+
*
|
|
1715
|
+
* 그래서 여기서는 무거운 일(클론)만 하고, 대상을 바꾸는 것은 **프로세스를
|
|
1716
|
+
* 갈아끼우는 쪽**에 맡긴다. 데스크톱 셸이 그 일을 한다(`desktop/main.mjs`).
|
|
1717
|
+
* 캐시 세대(generation)를 붙여 안전하게 갈아끼우는 것은 별도 작업으로 남긴다.
|
|
1718
|
+
*/
|
|
1719
|
+
if (url.pathname === '/api/open' && req.method === 'POST') {
|
|
1720
|
+
const { target } = await readBody(req)
|
|
1721
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
1722
|
+
return json(res, { error: '주소나 경로를 넣어 주세요' }, 400)
|
|
1723
|
+
}
|
|
1724
|
+
const t = target.trim()
|
|
1725
|
+
try {
|
|
1726
|
+
if (isRemote(t)) {
|
|
1727
|
+
/**
|
|
1728
|
+
* 🔴 클론은 **자식 프로세스**에 넘기고 즉시 답한다.
|
|
1729
|
+
*
|
|
1730
|
+
* `fetchRepo` 는 `execFileSync` 로 git 을 부르므로 여기서 그대로 부르면
|
|
1731
|
+
* 클론이 끝날 때까지 이벤트 루프가 통째로 멈춘다 — 그동안 화면도 대화도
|
|
1732
|
+
* 전부 죽는다. 실제로 처음에 그렇게 만들었다가 요청이 타임아웃으로 끊겼다.
|
|
1733
|
+
* 세션을 서버에 둔 이유와 정면으로 어긋나는 동작이었다.
|
|
1734
|
+
*/
|
|
1735
|
+
return json(res, { job: startFetch(t), current: ROOT })
|
|
1736
|
+
}
|
|
1737
|
+
const p = path.resolve(t)
|
|
1738
|
+
if (!fs.existsSync(p) || !fs.statSync(p).isDirectory()) {
|
|
1739
|
+
return json(res, { error: `그런 폴더가 없습니다: ${p}` }, 400)
|
|
1740
|
+
}
|
|
1741
|
+
// git 저장소가 아니면 공변경도 감시도 못 한다. 미리 말한다.
|
|
1742
|
+
const isGit = fs.existsSync(path.join(p, '.git'))
|
|
1743
|
+
return json(res, { root: p, url: null, cached: true, git: isGit, current: ROOT })
|
|
1744
|
+
} catch (e) {
|
|
1745
|
+
return json(res, { error: e.message }, 400)
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
if (url.pathname === '/api/agents') {
|
|
1750
|
+
const found = detectAgents()
|
|
1751
|
+
json(res, { agents: found, preferred: preferredAgent(found)?.id ?? null })
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (url.pathname === '/api/sessions' && req.method === 'POST') {
|
|
1756
|
+
const { agent, title } = await readBody(req)
|
|
1757
|
+
try {
|
|
1758
|
+
// cwd 는 항상 지금 보고 있는 저장소다. 화면이 고르게 두지 않는다 —
|
|
1759
|
+
// 엉뚱한 디렉터리에서 에이전트가 파일을 고치는 것이 최악의 사고다.
|
|
1760
|
+
json(res, sess.create({ agent, cwd: ROOT, title }))
|
|
1761
|
+
} catch (e) { json(res, { error: e.message }, 400) }
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if (url.pathname === '/api/sessions') {
|
|
1766
|
+
json(res, { sessions: sess.list() })
|
|
1767
|
+
return
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
if (url.pathname.startsWith('/api/session/')) {
|
|
1771
|
+
const [, , , id, verb] = url.pathname.split('/')
|
|
1772
|
+
const s = sess.get(id)
|
|
1773
|
+
if (!s) return json(res, { error: '없는 세션입니다' }, 404)
|
|
1774
|
+
|
|
1775
|
+
if (verb === 'send' && req.method === 'POST') {
|
|
1776
|
+
const { prompt } = await readBody(req)
|
|
1777
|
+
if (typeof prompt !== 'string' || !prompt.trim()) {
|
|
1778
|
+
return json(res, { error: '빈 프롬프트' }, 400)
|
|
1779
|
+
}
|
|
1780
|
+
try { json(res, sess.send(id, prompt)) } catch (e) { json(res, { error: e.message }, 409) }
|
|
1781
|
+
return
|
|
1782
|
+
}
|
|
1783
|
+
if (verb === 'stop' && req.method === 'POST') { sess.stop(id); return json(res, sess.view(s)) }
|
|
1784
|
+
if (verb === 'close' && req.method === 'POST') { sess.remove(id); return json(res, { ok: true }) }
|
|
1785
|
+
|
|
1786
|
+
// 화면은 `?since=` 로 밀린 것만 받아 간다. 매번 전부 보내면 앱이 느려진다.
|
|
1787
|
+
json(res, sess.view(s, Number(q.get('since') ?? -1)))
|
|
1788
|
+
return
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ── 실시간 ────────────────────────────────────────────────────────────
|
|
1792
|
+
if (url.pathname === '/api/live') {
|
|
1793
|
+
json(res, liveState())
|
|
1794
|
+
return
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// ── 작성자 이력 ───────────────────────────────────────────────────────
|
|
1798
|
+
if (url.pathname === '/api/authors') {
|
|
1799
|
+
const a = authors()
|
|
1800
|
+
// 목록만 필요할 때는 파일 목록을 빼서 가볍게 준다
|
|
1801
|
+
if (q.get('files') === '1') return json(res, a)
|
|
1802
|
+
json(res, {
|
|
1803
|
+
available: a.available,
|
|
1804
|
+
me: ME,
|
|
1805
|
+
authors: a.authors.map(({ files, ...rest }) => rest),
|
|
1806
|
+
})
|
|
1807
|
+
return
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
if (url.pathname === '/api/authors/files') {
|
|
1811
|
+
const name = q.get('name')
|
|
1812
|
+
const a = authors().authors.find((x) => x.name === name)
|
|
1813
|
+
if (!a) return json(res, { error: '없는 작성자' }, 404)
|
|
1814
|
+
json(res, { name, paths: a.files.map((f) => f.path), touches: a.files })
|
|
1815
|
+
return
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
if (url.pathname === '/api/events') {
|
|
1819
|
+
res.writeHead(200, {
|
|
1820
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1821
|
+
'cache-control': 'no-cache',
|
|
1822
|
+
connection: 'keep-alive',
|
|
1823
|
+
})
|
|
1824
|
+
res.write(`event: live\ndata: ${JSON.stringify(liveState())}\n\n`)
|
|
1825
|
+
clients.add(res)
|
|
1826
|
+
req.on('close', () => clients.delete(res))
|
|
1827
|
+
return
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// ── LLM ───────────────────────────────────────────────────────────────
|
|
1831
|
+
if (url.pathname === '/api/llm/status') {
|
|
1832
|
+
json(res, await llm.status())
|
|
1833
|
+
return
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// 스트리밍이 기본이다. 첫 글자가 빨리 나오는 것이 체감을 좌우한다.
|
|
1837
|
+
if (url.pathname === '/api/llm/summarize' && req.method === 'POST') {
|
|
1838
|
+
const { path: rel } = await readBody(req)
|
|
1839
|
+
const abs = rel && safeJoin(ROOT, rel)
|
|
1840
|
+
if (!abs || !fs.existsSync(abs)) return json(res, { error: '없는 파일' }, 404)
|
|
1841
|
+
|
|
1842
|
+
const text = fs.readFileSync(abs, 'utf8')
|
|
1843
|
+
let started = false
|
|
1844
|
+
try {
|
|
1845
|
+
const meta = await llm.summarizeStream(rel, text, (chunk) => {
|
|
1846
|
+
if (!started) {
|
|
1847
|
+
started = true
|
|
1848
|
+
res.writeHead(200, {
|
|
1849
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
1850
|
+
'x-accel-buffering': 'no',
|
|
1851
|
+
})
|
|
1852
|
+
}
|
|
1853
|
+
res.write(chunk)
|
|
1854
|
+
})
|
|
1855
|
+
if (!started) {
|
|
1856
|
+
// 한 글자도 안 나온 경우 — LLM 이 없거나 빈 응답
|
|
1857
|
+
return json(res, meta.available ? { available: true, summary: '' } : { available: false })
|
|
1858
|
+
}
|
|
1859
|
+
res.end()
|
|
1860
|
+
} catch (e) {
|
|
1861
|
+
if (started) res.end(`\n\n(중단: ${e.message})`)
|
|
1862
|
+
else json(res, { available: false, error: e.message }, 200)
|
|
1863
|
+
}
|
|
1864
|
+
return
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (url.pathname === '/api/llm/key' && req.method === 'POST') {
|
|
1868
|
+
const { path: rel } = await readBody(req)
|
|
1869
|
+
const abs = rel && safeJoin(ROOT, rel)
|
|
1870
|
+
if (!abs || !fs.existsSync(abs)) return json(res, { error: '없는 파일' }, 404)
|
|
1871
|
+
json(res, await llm.keyLines(rel, fs.readFileSync(abs, 'utf8')))
|
|
1872
|
+
return
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
if (url.pathname === '/api/llm/edges' && req.method === 'POST') {
|
|
1876
|
+
const { path: rel } = await readBody(req)
|
|
1877
|
+
const abs = rel && safeJoin(ROOT, rel)
|
|
1878
|
+
if (!abs || !fs.existsSync(abs)) return json(res, { error: '없는 파일' }, 404)
|
|
1879
|
+
json(res, await llm.readEdges(rel, fs.readFileSync(abs, 'utf8')))
|
|
1880
|
+
return
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// ── 터미널 ────────────────────────────────────────────────────────────
|
|
1884
|
+
// pty 가 아니라 spawn 이다. 대화형 TUI(vim 등)는 안 되지만
|
|
1885
|
+
// git·테스트·ros2 명령을 돌리고 출력을 보는 데는 충분하고 네이티브 의존성이 없다.
|
|
1886
|
+
json(res, { error: 'unknown endpoint' }, 404)
|
|
1887
|
+
} catch (e) {
|
|
1888
|
+
json(res, { error: e.message }, 500)
|
|
1889
|
+
}
|
|
1890
|
+
})
|
|
1891
|
+
|
|
1892
|
+
server.on('error', async (e) => {
|
|
1893
|
+
if (e.code === 'EADDRINUSE') {
|
|
1894
|
+
/**
|
|
1895
|
+
* 🔴 그 포트에 무엇이 떠 있는지 반드시 말한다.
|
|
1896
|
+
*
|
|
1897
|
+
* 관찰자가 이것 때문에 5분을 날렸다. 직전 세션의 다른 저장소가 같은 포트에
|
|
1898
|
+
* 떠 있었고, "이미 켜져 있다면 접속하세요" 를 그대로 따랐더니 **남의 저장소**가
|
|
1899
|
+
* 보였다. 화면에 대상 경로가 있긴 하지만 처음 보는 사람은 그게 자기가 연
|
|
1900
|
+
* 저장소라고 믿는다. 30분을 엉뚱한 코드에 쓸 수 있었다.
|
|
1901
|
+
*/
|
|
1902
|
+
let who = null
|
|
1903
|
+
try {
|
|
1904
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/api/overlay`, { signal: AbortSignal.timeout(3000) })
|
|
1905
|
+
who = (await r.json()).root
|
|
1906
|
+
} catch { /* axMap 이 아닌 다른 서버일 수 있다 */ }
|
|
1907
|
+
|
|
1908
|
+
console.error(`\n 포트 ${PORT} 이 이미 쓰이는 중입니다.`)
|
|
1909
|
+
if (who && path.resolve(who) !== ROOT) {
|
|
1910
|
+
console.error(` 🔴 거기 떠 있는 것은 **다른 저장소**입니다:`)
|
|
1911
|
+
console.error(` 떠 있는 것 : ${who}`)
|
|
1912
|
+
console.error(` 열려던 것 : ${ROOT}`)
|
|
1913
|
+
console.error(` 그대로 접속하면 엉뚱한 저장소를 보게 됩니다.`)
|
|
1914
|
+
} else if (who) {
|
|
1915
|
+
console.error(` 같은 저장소가 이미 떠 있습니다 — http://127.0.0.1:${PORT} 로 접속하세요.`)
|
|
1916
|
+
} else {
|
|
1917
|
+
console.error(` axMap 이 아닌 다른 프로그램일 수 있습니다.`)
|
|
1918
|
+
}
|
|
1919
|
+
console.error(` 다른 포트로 실행: node app/server.mjs "${ROOT}" ${PORT + 1}\n`)
|
|
1920
|
+
process.exit(1)
|
|
1921
|
+
}
|
|
1922
|
+
throw e
|
|
1923
|
+
})
|
|
1924
|
+
|
|
1925
|
+
server.listen(PORT, '127.0.0.1', async () => {
|
|
1926
|
+
const g = graph()
|
|
1927
|
+
const s = await llm.status()
|
|
1928
|
+
console.log(`\n axMap 뷰어`)
|
|
1929
|
+
console.log(` 대상 ${CLONED ? `${CLONED.url}
|
|
1930
|
+
${ROOT}` : ROOT}`)
|
|
1931
|
+
console.log(` 파일 ${g.stats.files}개 (${g.stats.totalLines.toLocaleString()}줄), 백업 ${g.stats.skippedBackups}개 제외`)
|
|
1932
|
+
console.log(` 그래프 노드 ${g.nodes.length} · 엣지 ${g.edges.length} · 허브 ${g.hubs.length} · ${g.stats.buildMs}ms`)
|
|
1933
|
+
// 문턱값의 출처를 시작할 때 말한다. 어떤 기준으로 그린 그림인지 모르면
|
|
1934
|
+
// 화면의 숫자는 해석할 수가 없다.
|
|
1935
|
+
const basis = g.ssot
|
|
1936
|
+
if (basis.cell) {
|
|
1937
|
+
console.log(` 기준 ${basis.cell} · 저장소 ${basis.repos}개 · 긴 파일 ${basis.splitOver}줄 · 허브 ${basis.hubCap}`
|
|
1938
|
+
+ `${basis.fetchedFrom === 'cache' ? ' (캐시 — 원격에 못 닿음)' : ''}`)
|
|
1939
|
+
} else {
|
|
1940
|
+
console.log(` 기준 코퍼스 미적용 — ${basis.why} · 눈으로 고른 값(${basis.splitOver}줄 / ${basis.hubCap}) 사용`)
|
|
1941
|
+
}
|
|
1942
|
+
if (basis.why && basis.cell) console.log(` 보류: ${basis.why}`)
|
|
1943
|
+
console.log(` LLM ${s.available ? `${s.model} (${s.host})` : '없음 — 요약은 비활성, 나머지는 정상'}`)
|
|
1944
|
+
console.log(`\n http://127.0.0.1:${PORT}\n`)
|
|
1945
|
+
|
|
1946
|
+
// 모델을 미리 올려 첫 클릭이 콜드 스타트를 만나지 않게 한다
|
|
1947
|
+
if (s.available && s.modelReady) {
|
|
1948
|
+
const t0 = Date.now()
|
|
1949
|
+
llm.warmup().then(() => console.log(` 모델 준비 완료 (${((Date.now() - t0) / 1000).toFixed(0)}초)`))
|
|
1950
|
+
}
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1953
|
+
process.on('SIGINT', () => {
|
|
1954
|
+
watcher.close()
|
|
1955
|
+
// 대화 세션의 자식 프로세스를 남기지 않는다. 남으면 다음 실행에서
|
|
1956
|
+
// 누가 띄운 건지 모르는 CLI 가 백그라운드에 떠 있게 된다.
|
|
1957
|
+
sess.shutdown()
|
|
1958
|
+
process.exit(0)
|
|
1959
|
+
})
|