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
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 기능 클러스터 — 파일이 아니라 "무엇을 함께 이루는가"로 코드를 묶는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 git 동시변경인가 ───────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* import 그래프는 "무엇에 기대는가"를 말하지 "무엇을 함께 이루는가"를 말하지
|
|
7
|
+
* 않는다. `qos.py` 를 8개 파일이 import 하지만 그 8개는 한 기능이 아니라
|
|
8
|
+
* 공용 상수를 쓰는 남남이다. 기능 경계에는 약한 신호다.
|
|
9
|
+
*
|
|
10
|
+
* 반면 **같은 커밋에서 함께 바뀐 파일**은 사람이 한 가지 일로 묶어 만진
|
|
11
|
+
* 것이다. e101 저장소 실측:
|
|
12
|
+
*
|
|
13
|
+
* BE_system [5] User · SignupRequest · AuthService · AuthController · LoginRequest
|
|
14
|
+
* BE_system [5] VideoController · VideoService · VideoResponses · VideoArchiveTests …
|
|
15
|
+
* FE [5] AuthContext · authStore · AuthScreen · authApi · signupRules
|
|
16
|
+
*
|
|
17
|
+
* 그리고 결정적으로 **언어에 의존하지 않는다.** axMap 의 정적 파서는
|
|
18
|
+
* `BE_system` 의 Java 18,279줄을 노드 0개로 본다. 동시변경은 같은 코드에서
|
|
19
|
+
* 위 클러스터들을 그대로 찾아낸다. 최소 설치로 아무 저장소나 붙는다는
|
|
20
|
+
* 목표와 같은 방향이다.
|
|
21
|
+
*
|
|
22
|
+
* ── 한계 (숨기지 않는다) ──────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* · 커버리지가 30~35% 다. 커밋이 적은 파일은 신호가 없다.
|
|
25
|
+
* · 히스토리가 짧은 저장소에서는 아무것도 안 나온다.
|
|
26
|
+
* · 테스트 파일끼리 묶이는 오탐이 있다 (함께 깨져서 함께 고쳐진 것).
|
|
27
|
+
* · 마이그레이션 중이면 같은 기능이 `.jsx` 것과 `.tsx` 것으로 갈린다.
|
|
28
|
+
*
|
|
29
|
+
* 그래서 이것은 **초안**이다. 이름은 파일명·디렉터리에서 뽑고(provisionalName,
|
|
30
|
+
* disambiguate), 경계는 사람이 고친다(applyEdits).
|
|
31
|
+
*
|
|
32
|
+
* LLM 에게 이름을 시키지 않기로 한 이유는 disambiguate 주석에 있다 — 요약하면
|
|
33
|
+
* 자동 이름이 이미 대부분 맞고, LLM 은 오탐에도 그럴듯한 이름을 붙여 준다.
|
|
34
|
+
*
|
|
35
|
+
* 사람의 수정은 결과가 아니라 **편집**으로 쌓는다. 그래야 코드가 바뀌어 초안을
|
|
36
|
+
* 다시 계산해도 수정이 살아남는다 (applyEdits 주석).
|
|
37
|
+
*
|
|
38
|
+
* 순수 함수만 둔다. git 호출은 live.mjs 가 한다.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** 이보다 많은 파일을 건드린 커밋은 버린다. */
|
|
42
|
+
const BIG_COMMIT = 12
|
|
43
|
+
|
|
44
|
+
/** 이보다 약한 관계는 엣지로 치지 않는다. */
|
|
45
|
+
const MIN_TOGETHER = 2
|
|
46
|
+
const MIN_JACCARD = 0.35
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 파일 하나가 유지하는 최대 파트너 수. 왜 이 값인지는 mutualTop 주석과 아래 실측.
|
|
50
|
+
*
|
|
51
|
+
* 커버리지와 **응집도**를 함께 재서 골랐다. 응집도는
|
|
52
|
+
* "클러스터 안에서 실제로 함께 바뀐 쌍 / 가능한 모든 쌍" 이고,
|
|
53
|
+
* 체인으로 뭉친 덩어리는 가장자리끼리 만난 적이 없어 이 값이 급락한다.
|
|
54
|
+
*
|
|
55
|
+
* 자카드 0.35 기준 (e101 BE_system, 파일 180)
|
|
56
|
+
* topK 묶임 최대 클러스터 최저 응집도
|
|
57
|
+
* 3 64% 7 0.67
|
|
58
|
+
* 4 68% 11 0.68 ← 이 값
|
|
59
|
+
* 5 69% 24 0.31 ← 24개 뭉텅이 부활
|
|
60
|
+
*
|
|
61
|
+
* 5 에서 무너지는 지점이 예전에 실제로 겪은 그 실패다. 4 는 커버리지를
|
|
62
|
+
* 4%p 더 얻으면서 응집도가 그대로다 — 공짜라 올렸다.
|
|
63
|
+
*
|
|
64
|
+
* 자카드는 0.2~0.45 를 훑어도 0.35 보다 나은 곳이 없었다. 낮추면 커버리지가
|
|
65
|
+
* 조금 늘지만 최저 응집도가 0.24 까지 떨어져 뭉침만 늘어난다. 그대로 둔다.
|
|
66
|
+
*/
|
|
67
|
+
const TOP_K = 4
|
|
68
|
+
|
|
69
|
+
/** 이 개수 미만이면 기능이라 부르지 않는다. */
|
|
70
|
+
const MIN_CLUSTER = 2
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 커밋을 `{subject, files}` 로 통일한다.
|
|
74
|
+
*
|
|
75
|
+
* 테스트와 옛 호출부는 `string[][]` 를 준다. 두 모양을 다 받아 주는 편이
|
|
76
|
+
* 호출부마다 변환 코드를 흩뿌리는 것보다 낫다.
|
|
77
|
+
*/
|
|
78
|
+
const asCommits = (list) =>
|
|
79
|
+
(list ?? []).map((c) => (Array.isArray(c) ? { subject: '', files: c } : { subject: c.subject ?? '', files: c.files ?? [] }))
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 커밋 제목에서 기능 이름을 뽑는다. 못 쓰겠으면 null.
|
|
83
|
+
*
|
|
84
|
+
* 🔴 이 함수가 실패해도 **명단은 그대로다.** 이름만 파일명 추출로 떨어진다.
|
|
85
|
+
* 커밋 메시지 습관은 저장소마다 천차만별이라 여기 기대면 안 된다.
|
|
86
|
+
*
|
|
87
|
+
* 검증은 포기했다. "제목에 쓰인 말이 파일명에도 있는지" 대조해 보려 했는데
|
|
88
|
+
* 실측에서 17개 중 9개를 잘못 걸렀다 — `실시간 맵 전송 경량화` ↔ `nav_bridge.py`
|
|
89
|
+
* 처럼 **제목은 한국어, 파일명은 영어**면 겹칠 수가 없다. 좋은 이름을 버리는
|
|
90
|
+
* 필터는 없느니만 못하다.
|
|
91
|
+
*
|
|
92
|
+
* 그래서 명백한 쓰레기만 거른다. 애매한 것은 통과시키고, 대신 화면이 출처를
|
|
93
|
+
* `커밋` 으로 표시해 사람이 판단하게 한다. 나쁜 이름은 나쁘다는 게 드러나서
|
|
94
|
+
* 오히려 안전하다 — 그럴듯하게 지어낸 이름이 훨씬 위험하다.
|
|
95
|
+
*/
|
|
96
|
+
const USELESS_SUBJECT =
|
|
97
|
+
/^(wip|tmp|temp|test|fix|update[sd]?|change[sd]?|edit|minor|misc|cleanup|refactor|commit|save|init(ial)?( commit)?|초안|수정|반영|정리|추가|변경|작업|커밋|[.\d\s-]+)$/i
|
|
98
|
+
|
|
99
|
+
export function nameFromSubject(subject) {
|
|
100
|
+
if (!subject) return null
|
|
101
|
+
const cleaned = subject
|
|
102
|
+
.replace(/^\[[A-Za-z0-9-]+\]\s*/, '') // [S15P11E101-509]
|
|
103
|
+
.replace(/^(feat|fix|refactor|docs|chore|test|perf|style|build|ci)(\([^)]*\))?[:\s]\s*/i, '')
|
|
104
|
+
.replace(/^\[[^\]]+\]\s*/, '') // [BE]
|
|
105
|
+
// 뒤에 붙은 부제만 뗀다. ` + ` 로 좁힌 이유는 `·` 로 자르면
|
|
106
|
+
// `저장·조회` 같은 접속 표현이 잘려 나가기 때문이다 (실측에서 나온 버그).
|
|
107
|
+
.replace(/\s+\+\s.*$/, '')
|
|
108
|
+
.trim()
|
|
109
|
+
if (cleaned.length < 4 || USELESS_SUBJECT.test(cleaned)) return null
|
|
110
|
+
return cleaned.length > 60 ? `${cleaned.slice(0, 58)}…` : cleaned
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 경로 B — 한 커밋이 **함께 만든** 파일들도 기능으로 본다.
|
|
115
|
+
*
|
|
116
|
+
* 🔴 왜 필요한가. 경로 A(반복 동시변경)는 "두 번 이상 함께 고쳐진 것"만 본다.
|
|
117
|
+
* 뒤집으면 **자주 고쳐진 코드 = 문제가 많았던 코드**만 남는다는 뜻이고,
|
|
118
|
+
* 한 번에 잘 만들어지고 안 고쳐진 코드는 존재하지 않게 된다.
|
|
119
|
+
*
|
|
120
|
+
* 실측이 명확했다. e101 `BE_system` 에서 기능에 안 묶인 파일 133개 중
|
|
121
|
+
* **101개(76%)가 커밋 1회**였고, 그것들이 19개 커밋에 92개가 모여 있었다.
|
|
122
|
+
* 그리고 그 커밋들은 전부 명백한 기능이었다 —
|
|
123
|
+
* `순찰 지점(waypoint) 저장·조회 API`, `매핑 완료 시 2D 도면 자동 생성`.
|
|
124
|
+
*
|
|
125
|
+
* 4주짜리 프로젝트에서는 대부분의 코드가 한 번 쓰이고 안 고쳐지므로,
|
|
126
|
+
* 신생 저장소일수록 이 편향이 크다. "아무 저장소나 붙는다" 와 정면으로 어긋난다.
|
|
127
|
+
*
|
|
128
|
+
* ⚠️ 근거는 A 보다 약하다 (한 번 vs 여러 번). `evidence:'once'` 로 구분한다.
|
|
129
|
+
*
|
|
130
|
+
* @param {Set<string>} taken 경로 A 가 이미 가져간 파일. 강한 근거가 이긴다.
|
|
131
|
+
*/
|
|
132
|
+
export function onceClusters(commits, taken, known = null, { bigCommit = BIG_COMMIT, minCluster = MIN_CLUSTER } = {}) {
|
|
133
|
+
const list = asCommits(commits)
|
|
134
|
+
|
|
135
|
+
// 이 파일이 몇 개의 커밋에 나오는가
|
|
136
|
+
const seen = new Map()
|
|
137
|
+
for (const c of list) for (const p of new Set(c.files)) seen.set(p, (seen.get(p) ?? 0) + 1)
|
|
138
|
+
|
|
139
|
+
const out = []
|
|
140
|
+
let droppedBig = 0
|
|
141
|
+
for (const c of list) {
|
|
142
|
+
const files = [...new Set(c.files)]
|
|
143
|
+
if (files.length > bigCommit) {
|
|
144
|
+
// 큰 커밋은 여러 일이 섞여 있다. 안에서 다시 쪼개 보려 했지만 안전한
|
|
145
|
+
// 규칙이 없었다 — ROS2 는 디렉터리가 관심사라 잘 갈리는데, Spring 은
|
|
146
|
+
// 디렉터리가 계층이라 한 기능이 9개 디렉터리에 1개씩 흩어진다.
|
|
147
|
+
// 게다가 실측에서 큰 커밋 4개 중 3개가 revert·제거·리팩터였다.
|
|
148
|
+
// 버리되 **몇 개를 버렸는지 화면에 알린다.**
|
|
149
|
+
if (files.some((p) => (seen.get(p) ?? 0) === 1)) droppedBig++
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
const fresh = files
|
|
153
|
+
.filter((p) => seen.get(p) === 1) // 이 커밋에서만 등장
|
|
154
|
+
.filter((p) => !taken.has(p)) // 경로 A 가 이미 가져갔으면 그쪽이 이긴다
|
|
155
|
+
.filter((p) => !known || known.has(p)) // 지워진 파일 제외
|
|
156
|
+
.sort()
|
|
157
|
+
if (fresh.length < minCluster) continue
|
|
158
|
+
|
|
159
|
+
out.push({
|
|
160
|
+
id: featureId(fresh),
|
|
161
|
+
name: nameFromSubject(c.subject) ?? provisionalName(fresh),
|
|
162
|
+
nameSource: nameFromSubject(c.subject) ? 'commit' : 'auto',
|
|
163
|
+
paths: fresh,
|
|
164
|
+
commits: 1,
|
|
165
|
+
source: 'commit',
|
|
166
|
+
evidence: 'once',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
return { features: out, droppedBig }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 커밋 목록에서 파일 쌍의 동시변경 강도를 잰다.
|
|
174
|
+
*
|
|
175
|
+
* 횟수를 그대로 쓰지 않고 자카드로 정규화한다. 자주 바뀌는 파일은 무엇과도
|
|
176
|
+
* 함께 바뀌므로, 횟수만 세면 그런 파일이 저장소 전체와 짝이 된다.
|
|
177
|
+
*
|
|
178
|
+
* @param {string[][]} commits 커밋마다 바뀐 경로 목록
|
|
179
|
+
*/
|
|
180
|
+
export function cochange(commits, { bigCommit = BIG_COMMIT, minTogether = MIN_TOGETHER, minJaccard = MIN_JACCARD } = {}) {
|
|
181
|
+
const pair = new Map()
|
|
182
|
+
const solo = new Map()
|
|
183
|
+
|
|
184
|
+
for (const c of asCommits(commits)) {
|
|
185
|
+
// 대량 커밋(일괄 포맷팅·대규모 이동)은 신호가 아니라 잡음이다.
|
|
186
|
+
// 파일 수의 제곱으로 쌍이 늘어 클러스터를 통째로 뭉갠다.
|
|
187
|
+
const files = [...new Set(c.files)]
|
|
188
|
+
if (files.length < 2 || files.length > bigCommit) continue
|
|
189
|
+
|
|
190
|
+
for (const p of files) solo.set(p, (solo.get(p) ?? 0) + 1)
|
|
191
|
+
for (let i = 0; i < files.length; i++) {
|
|
192
|
+
for (let j = i + 1; j < files.length; j++) {
|
|
193
|
+
const [a, b] = files[i] < files[j] ? [files[i], files[j]] : [files[j], files[i]]
|
|
194
|
+
const k = `${a}\u0000${b}`
|
|
195
|
+
pair.set(k, (pair.get(k) ?? 0) + 1)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const links = []
|
|
201
|
+
for (const [k, n] of pair) {
|
|
202
|
+
const [a, b] = k.split('\u0000')
|
|
203
|
+
const j = n / (solo.get(a) + solo.get(b) - n)
|
|
204
|
+
if (n >= minTogether && j >= minJaccard) links.push({ a, b, n, j })
|
|
205
|
+
}
|
|
206
|
+
return { links, solo }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 서로가 서로의 상위 파트너인 관계만 남긴다.
|
|
211
|
+
*
|
|
212
|
+
* 🔴 이것이 없으면 클러스터가 체인으로 뭉친다. A-B 가 강하고 B-C 가 강하면
|
|
213
|
+
* A 와 C 가 아무 상관 없어도 연결 요소로는 한 덩어리다. 실측에서 `BE_system`
|
|
214
|
+
* 이 정확히 그렇게 됐다 — 서로 다른 기능들이 24개짜리 덩어리 하나로 붙어
|
|
215
|
+
* "기능별로 보기"라는 목적 자체가 사라졌다.
|
|
216
|
+
*
|
|
217
|
+
* 상호 top-K 를 걸면 24 → 7 로 줄고 회원가입·영상·맵이 각각 분리된다.
|
|
218
|
+
* K 를 6 으로 올리면 다시 24가 된다. 3 이 실측에서 가장 깨끗했다.
|
|
219
|
+
*/
|
|
220
|
+
export function mutualTop(links, { topK = TOP_K } = {}) {
|
|
221
|
+
const adj = new Map()
|
|
222
|
+
for (const l of links) {
|
|
223
|
+
for (const [x, y] of [[l.a, l.b], [l.b, l.a]]) {
|
|
224
|
+
if (!adj.has(x)) adj.set(x, [])
|
|
225
|
+
adj.get(x).push({ y, j: l.j })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const v of adj.values()) v.sort((p, q) => q.j - p.j || (p.y < q.y ? -1 : 1))
|
|
229
|
+
const top = new Map([...adj].map(([x, v]) => [x, new Set(v.slice(0, topK).map((o) => o.y))]))
|
|
230
|
+
return links.filter((l) => top.get(l.a)?.has(l.b) && top.get(l.b)?.has(l.a))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 연결 요소. 상호 top-K 를 통과한 뒤라 체인이 이미 끊겨 있다. */
|
|
234
|
+
function components(links) {
|
|
235
|
+
const par = new Map()
|
|
236
|
+
const find = (x) => (par.get(x) === x ? x : (par.set(x, find(par.get(x))), par.get(x)))
|
|
237
|
+
for (const l of links) for (const x of [l.a, l.b]) if (!par.has(x)) par.set(x, x)
|
|
238
|
+
for (const l of links) {
|
|
239
|
+
const ra = find(l.a)
|
|
240
|
+
const rb = find(l.b)
|
|
241
|
+
if (ra !== rb) par.set(ra, rb)
|
|
242
|
+
}
|
|
243
|
+
const out = new Map()
|
|
244
|
+
for (const x of par.keys()) {
|
|
245
|
+
const r = find(x)
|
|
246
|
+
if (!out.has(r)) out.set(r, [])
|
|
247
|
+
out.get(r).push(x)
|
|
248
|
+
}
|
|
249
|
+
return [...out.values()]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 이름을 짓지 못했을 때 쓸 임시 이름.
|
|
254
|
+
*
|
|
255
|
+
* LLM 이 붙이기 전에도 목록이 읽혀야 한다. 공통 디렉터리와 가장 흔한
|
|
256
|
+
* 파일명 어간을 쓴다 — `MapService`·`MapController` → `map`.
|
|
257
|
+
* 이건 이름이 아니라 자리표시자이므로 화면에서 출처를 `auto` 로 표시한다.
|
|
258
|
+
*/
|
|
259
|
+
export function provisionalName(paths) {
|
|
260
|
+
const stems = paths.map((p) => p.split('/').pop().replace(/\.[^.]+$/, ''))
|
|
261
|
+
const freq = new Map()
|
|
262
|
+
for (const s of stems) {
|
|
263
|
+
// CamelCase 와 snake_case 를 같은 방식으로 쪼갠다
|
|
264
|
+
for (const w of s.split(/(?=[A-Z])|[_\-.]/)) {
|
|
265
|
+
const k = w.toLowerCase()
|
|
266
|
+
if (k.length < 3 || k === 'test' || k === 'tests') continue
|
|
267
|
+
freq.set(k, (freq.get(k) ?? 0) + 1)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const best = [...freq].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0]
|
|
271
|
+
if (best && best[1] >= 2) return best[0]
|
|
272
|
+
const dirs = paths.map((p) => p.split('/').slice(0, -1).join('/'))
|
|
273
|
+
const common = dirs.reduce((a, b) => {
|
|
274
|
+
const x = a.split('/')
|
|
275
|
+
const y = b.split('/')
|
|
276
|
+
let i = 0
|
|
277
|
+
while (i < x.length && i < y.length && x[i] === y[i]) i++
|
|
278
|
+
return x.slice(0, i).join('/')
|
|
279
|
+
})
|
|
280
|
+
return common.split('/').pop() || '(이름 없음)'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 이름 후보에서 뺄 말. 어느 묶음에나 있어서 아무것도 구별해 주지 못한다. */
|
|
284
|
+
const STOP = new Set([
|
|
285
|
+
'test', 'tests', 'spec', 'src', 'main', 'java', 'com', 'org', 'lib', 'app',
|
|
286
|
+
'index', 'util', 'utils', 'common', 'core', 'impl', 'base', 'node', 'py',
|
|
287
|
+
])
|
|
288
|
+
|
|
289
|
+
/** 파일 경로에서 이름 후보 토큰을 뽑는다. 파일명과 디렉터리 마디를 함께 본다. */
|
|
290
|
+
function tokensOf(p) {
|
|
291
|
+
const out = []
|
|
292
|
+
const parts = p.split('/')
|
|
293
|
+
const file = parts.pop().replace(/\.[^.]+$/, '')
|
|
294
|
+
for (const seg of [...parts, file]) {
|
|
295
|
+
for (const w of seg.split(/(?=[A-Z])|[_\-.]/)) {
|
|
296
|
+
const k = w.toLowerCase()
|
|
297
|
+
if (k.length >= 3 && !STOP.has(k)) out.push(k)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 이 비율을 넘는 파일에 나오는 말은 이름 후보에서 뺀다.
|
|
305
|
+
*
|
|
306
|
+
* `server` · `com` · 회사명 · 프로젝트명처럼 **패키지 뿌리에 있는 말**은
|
|
307
|
+
* 모든 경로에 나오므로 아무것도 구별해 주지 못한다. 실측에서 실제로
|
|
308
|
+
* `robot·server` 라는 이름이 나왔다 — 저장소의 모든 Java 경로가
|
|
309
|
+
* `.../server/...` 였기 때문이다.
|
|
310
|
+
*
|
|
311
|
+
* STOP 목록에 프로젝트별 단어를 계속 더하는 대신 빈도로 거른다.
|
|
312
|
+
* 그래야 다른 저장소에 붙여도 같은 규칙이 작동한다.
|
|
313
|
+
*/
|
|
314
|
+
const TOO_COMMON = 0.5
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 이름이 겹치는 기능들을 구별한다.
|
|
318
|
+
*
|
|
319
|
+
* 실측에서 `BE_system` 의 두 묶음이 똑같이 `robot` 이 됐다.
|
|
320
|
+
*
|
|
321
|
+
* robot [5] stomp/RobotEventListener · stomp/StompWebSocketConfig · wss/RobotWebSocketHandler …
|
|
322
|
+
* robot [4] robot/domain/RobotState · robot/dto/RobotResponse · robot/service/RobotService …
|
|
323
|
+
*
|
|
324
|
+
* 목록에 같은 이름이 둘 있으면 무엇을 누르는지 알 수 없다. **이름의 목적은
|
|
325
|
+
* 아름다움이 아니라 구별과 정직함**이므로 여기서 필요한 것은 좋은 작명이
|
|
326
|
+
* 아니라 겹치지 않게 만드는 것이다.
|
|
327
|
+
*
|
|
328
|
+
* 그래서 **그 묶음에만 있는 말**을 붙인다 — 묶음 안 빈도를 저장소 전체 빈도로
|
|
329
|
+
* 나눠 가장 높은 토큰을 고른다. `service` 처럼 어디에나 있는 말은 낮은 점수를
|
|
330
|
+
* 받고, `stomp` · `cache` 처럼 그 묶음에 몰려 있는 말이 뽑힌다.
|
|
331
|
+
*
|
|
332
|
+
* LLM 에게 이름을 시키지 않는 이유도 여기 있다. LLM 은 `controller [7]` 같은
|
|
333
|
+
* **오탐에도 그럴듯한 이름**을 붙여 준다. 어색한 이름은 사람이 열어보게
|
|
334
|
+
* 만들지만, 그럴듯한 이름은 그냥 통과시키게 만든다 (D5).
|
|
335
|
+
*/
|
|
336
|
+
|
|
337
|
+
export function disambiguate(features) {
|
|
338
|
+
const global = new Map()
|
|
339
|
+
const docs = new Set()
|
|
340
|
+
for (const f of features) {
|
|
341
|
+
for (const p of f.paths) {
|
|
342
|
+
docs.add(p)
|
|
343
|
+
for (const t of new Set(tokensOf(p))) global.set(t, (global.get(t) ?? 0) + 1)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const tooCommon = (t) => global.get(t) / docs.size > TOO_COMMON
|
|
347
|
+
|
|
348
|
+
const byName = new Map()
|
|
349
|
+
for (const f of features) {
|
|
350
|
+
if (!byName.has(f.name)) byName.set(f.name, [])
|
|
351
|
+
byName.get(f.name).push(f)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const [name, group] of byName) {
|
|
355
|
+
if (group.length < 2) continue
|
|
356
|
+
const taken = new Set()
|
|
357
|
+
for (const f of group) {
|
|
358
|
+
const local = new Map()
|
|
359
|
+
for (const p of f.paths) {
|
|
360
|
+
for (const t of new Set(tokensOf(p))) local.set(t, (local.get(t) ?? 0) + 1)
|
|
361
|
+
}
|
|
362
|
+
// 점수 = 묶음 안 빈도² / 저장소 전체 빈도.
|
|
363
|
+
// 분자를 제곱하는 이유: **묶음을 넓게 덮는 말**이 그 묶음을 잘 설명한다.
|
|
364
|
+
// 처음에 빈도/전체(구별력)만 봤더니 파일 하나에만 있는 `config` 가
|
|
365
|
+
// 두 파일에 걸친 `stomp` 를 사전순 동점으로 이겼다. 둘 다 그 묶음에만
|
|
366
|
+
// 있어 구별력은 같지만, 덮는 범위가 다르다.
|
|
367
|
+
// 분모는 흔한 말(`service` 처럼 어디에나 있는 것)을 눌러 준다.
|
|
368
|
+
const score = (t, n) => (n * n) / global.get(t)
|
|
369
|
+
const best = [...local]
|
|
370
|
+
.filter(([t]) => t !== name && !taken.has(t) && !tooCommon(t))
|
|
371
|
+
// 점수가 같으면 사전순으로 못박는다. 같은 입력에 같은 이름이 나와야
|
|
372
|
+
// 편집 기록의 anchor 가 흔들리지 않는다.
|
|
373
|
+
.sort((a, b) => score(b[0], b[1]) - score(a[0], a[1]) || (a[0] < b[0] ? -1 : 1))[0]
|
|
374
|
+
if (best) {
|
|
375
|
+
taken.add(best[0])
|
|
376
|
+
f.name = `${name}·${best[0]}`
|
|
377
|
+
} else {
|
|
378
|
+
// 구별할 말이 하나도 없으면 번호를 붙인다. 예쁘지 않지만 정직하다.
|
|
379
|
+
f.name = `${name} ${group.indexOf(f) + 1}`
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return features
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 기능 사이의 관계 — 같은 커밋에서 두 기능이 함께 바뀐 횟수.
|
|
388
|
+
*
|
|
389
|
+
* 자동으로 만든 기능들은 서로 **겹치는 파일이 없다** (연결 요소로 잘랐으므로).
|
|
390
|
+
* 그래서 "공유 파일"로는 엣지가 하나도 안 생긴다. 대신 커밋을 본다 —
|
|
391
|
+
* 로그인을 고치면서 이벤트 로그도 함께 고쳤다면 두 기능은 이어져 있다.
|
|
392
|
+
*
|
|
393
|
+
* import 를 쓰지 않는 이유는 파서가 읽는 언어가 6종뿐이기 때문이다.
|
|
394
|
+
* 동시변경은 Java 든 Go 든 똑같이 잡힌다.
|
|
395
|
+
*/
|
|
396
|
+
export function featureEdges(features, commits, { minTogether = 1 } = {}) {
|
|
397
|
+
const owner = new Map()
|
|
398
|
+
features.forEach((f, i) => {
|
|
399
|
+
for (const p of f.paths) {
|
|
400
|
+
if (!owner.has(p)) owner.set(p, [])
|
|
401
|
+
owner.get(p).push(i)
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const pair = new Map()
|
|
406
|
+
for (const c of asCommits(commits)) {
|
|
407
|
+
const hit = [...new Set(c.files.flatMap((p) => owner.get(p) ?? []))]
|
|
408
|
+
if (hit.length < 2) continue
|
|
409
|
+
for (let i = 0; i < hit.length; i++) {
|
|
410
|
+
for (let j = i + 1; j < hit.length; j++) {
|
|
411
|
+
const [a, b] = hit[i] < hit[j] ? [hit[i], hit[j]] : [hit[j], hit[i]]
|
|
412
|
+
const k = `${a} ${b}`
|
|
413
|
+
pair.set(k, (pair.get(k) ?? 0) + 1)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const edges = []
|
|
419
|
+
for (const [k, n] of pair) {
|
|
420
|
+
if (n < minTogether) continue
|
|
421
|
+
const [a, b] = k.split(' ').map(Number)
|
|
422
|
+
edges.push({
|
|
423
|
+
source: features[a].id,
|
|
424
|
+
target: features[b].id,
|
|
425
|
+
n,
|
|
426
|
+
// 방향은 모른다. 함께 바뀌었다는 사실에 앞뒤가 없다 (D5 — 아는 척하지 않는다).
|
|
427
|
+
directed: false,
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
return edges.sort((x, y) => y.n - x.n)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 안정적인 식별자.
|
|
435
|
+
*
|
|
436
|
+
* 이름이 아니라 **구성 파일**에서 뽑는다. 사람이 이름을 바꿔도 같은 기능을
|
|
437
|
+
* 계속 가리켜야 하고, 그래야 편집 기록이 이름 변경에도 살아남는다.
|
|
438
|
+
* 파일이 하나 늘고 주는 정도로는 안 흔들리도록 가장 안정적인 축 —
|
|
439
|
+
* 정렬된 경로 중 첫 번째 — 을 쓴다.
|
|
440
|
+
*/
|
|
441
|
+
export function featureId(paths) {
|
|
442
|
+
const sorted = [...paths].sort()
|
|
443
|
+
return sorted[0].replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 커밋 목록 → 기능 초안.
|
|
448
|
+
*
|
|
449
|
+
* @param {string[][]} commits
|
|
450
|
+
* @param {Set<string>|null} known 지금 저장소에 실제로 있는 경로. 지워진 파일을 걸러낸다.
|
|
451
|
+
*/
|
|
452
|
+
export function draft(commits, known = null, opts = {}) {
|
|
453
|
+
const list = asCommits(commits)
|
|
454
|
+
const { links, solo } = cochange(list, opts)
|
|
455
|
+
const kept = mutualTop(links, opts)
|
|
456
|
+
|
|
457
|
+
const groups = components(kept)
|
|
458
|
+
.map((g) => (known ? g.filter((p) => known.has(p)) : g))
|
|
459
|
+
.filter((g) => g.length >= (opts.minCluster ?? MIN_CLUSTER))
|
|
460
|
+
|
|
461
|
+
const features = groups
|
|
462
|
+
.map((paths) => {
|
|
463
|
+
const sorted = [...paths].sort()
|
|
464
|
+
return {
|
|
465
|
+
id: featureId(sorted),
|
|
466
|
+
name: provisionalName(sorted),
|
|
467
|
+
nameSource: 'auto', // 'auto' | 'llm' | 'human' — 화면이 출처를 숨기지 않는다
|
|
468
|
+
paths: sorted,
|
|
469
|
+
// 이 기능이 얼마나 자주 손대지는가. 목록 정렬과 D10 추세에 쓴다
|
|
470
|
+
commits: Math.max(...sorted.map((p) => solo.get(p) ?? 0)),
|
|
471
|
+
source: 'cochange',
|
|
472
|
+
evidence: 'repeat', // 여러 번 함께 고쳐졌다 — 강한 근거
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
.sort((a, b) => b.paths.length - a.paths.length || (a.id < b.id ? -1 : 1))
|
|
476
|
+
|
|
477
|
+
// 경로 B — 반복 신호가 없는 것들을 커밋 단위로 건진다.
|
|
478
|
+
// 경로 A 가 가져간 파일은 넘기지 않는다 (강한 근거가 이긴다).
|
|
479
|
+
const taken = new Set(features.flatMap((f) => f.paths))
|
|
480
|
+
const once = onceClusters(list, taken, known, opts)
|
|
481
|
+
features.push(...once.features.sort((a, b) => b.paths.length - a.paths.length || (a.id < b.id ? -1 : 1)))
|
|
482
|
+
|
|
483
|
+
// 이름이 겹치면 목록에서 무엇을 누르는지 알 수 없다. 초안 단계에서 갈라 둔다.
|
|
484
|
+
disambiguate(features)
|
|
485
|
+
|
|
486
|
+
const covered = new Set(features.flatMap((f) => f.paths))
|
|
487
|
+
return {
|
|
488
|
+
features,
|
|
489
|
+
stats: {
|
|
490
|
+
commitsSeen: list.length,
|
|
491
|
+
commitsUsed: list.filter((c) => {
|
|
492
|
+
const n = new Set(c.files).size
|
|
493
|
+
return n >= 2 && n <= (opts.bigCommit ?? BIG_COMMIT)
|
|
494
|
+
}).length,
|
|
495
|
+
links: links.length,
|
|
496
|
+
linksKept: kept.length,
|
|
497
|
+
files: new Set(list.flatMap((c) => c.files)).size,
|
|
498
|
+
covered: covered.size,
|
|
499
|
+
// 근거별로 몇 개인지 숨기지 않는다
|
|
500
|
+
repeatFeatures: features.filter((f) => f.evidence === 'repeat').length,
|
|
501
|
+
onceFeatures: features.filter((f) => f.evidence === 'once').length,
|
|
502
|
+
// 조용히 버리지 않는다 — 저장소마다 커밋 습관이 달라 이 판단이 틀릴 수 있다
|
|
503
|
+
droppedBigCommits: once.droppedBig,
|
|
504
|
+
},
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// 사람의 수정 — 결과가 아니라 "편집"을 저장한다
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 🔴 왜 결과가 아니라 편집을 쌓는가.
|
|
514
|
+
*
|
|
515
|
+
* 합쳐진 결과 하나만 저장하면 다음 두 가지 중 하나가 반드시 일어난다.
|
|
516
|
+
* · 코드가 바뀌어 초안을 다시 계산하면 **사람의 수정이 지워진다**
|
|
517
|
+
* · 지워지지 않게 하려면 다시 계산을 포기해야 하고, 지도가 코드와 어긋난다
|
|
518
|
+
*
|
|
519
|
+
* 편집만 따로 쌓으면 초안은 언제든 다시 계산되고 그 위에 수정이 다시 얹힌다.
|
|
520
|
+
* 장부(ledger)가 claim 을 append-only 로 쌓고 `audit` 이 재생해 증명하는 것과
|
|
521
|
+
* 같은 구조다. 여기서도 재생 가능성이 곧 신뢰다.
|
|
522
|
+
*
|
|
523
|
+
* 그리고 append-only 이므로 **머지 충돌이 거의 안 난다.** 두 사람이 각자
|
|
524
|
+
* 다른 기능을 고치면 서로 다른 줄이 추가될 뿐이다. 같은 줄을 고치는 것이
|
|
525
|
+
* 아니라 줄을 더하는 것이라 git 이 알아서 합친다.
|
|
526
|
+
*
|
|
527
|
+
* 편집 종류:
|
|
528
|
+
* rename 이름 바꾸기
|
|
529
|
+
* include 이 파일도 이 기능이다
|
|
530
|
+
* exclude 이 파일은 이 기능이 아니다
|
|
531
|
+
* create 초안에 없던 기능을 사람이 만든다
|
|
532
|
+
* hide 이 기능은 목록에서 빼라 (테스트 정비 같은 오탐)
|
|
533
|
+
* merge 두 기능은 사실 하나다
|
|
534
|
+
*/
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 편집이 가리키는 기능을 찾는다.
|
|
538
|
+
*
|
|
539
|
+
* id 만으로 찾으면 안 된다 — id 는 구성 파일에서 나오므로 파일이 하나
|
|
540
|
+
* 빠지는 것만으로도 바뀔 수 있다. 그러면 어제 붙인 이름이 오늘 사라진다.
|
|
541
|
+
*
|
|
542
|
+
* 그래서 편집에 **그때의 파일 목록(anchor)** 을 함께 적어 두고, id 가 안
|
|
543
|
+
* 맞으면 겹침이 가장 큰 기능으로 잇는다. 그마저 약하면 잇지 않고
|
|
544
|
+
* `orphans` 로 돌려준다 — 조용히 버리면 사람이 한 일이 이유 없이 사라진다.
|
|
545
|
+
*/
|
|
546
|
+
const ORPHAN_BELOW = 0.4
|
|
547
|
+
|
|
548
|
+
function resolve(features, edit) {
|
|
549
|
+
const byId = features.find((f) => f.id === edit.id)
|
|
550
|
+
if (byId) return byId
|
|
551
|
+
const anchor = edit.anchor ?? []
|
|
552
|
+
if (!anchor.length) return null
|
|
553
|
+
|
|
554
|
+
let best = null
|
|
555
|
+
let bestJ = 0
|
|
556
|
+
for (const f of features) {
|
|
557
|
+
const set = new Set(f.paths)
|
|
558
|
+
const hit = anchor.filter((p) => set.has(p)).length
|
|
559
|
+
const j = hit / (new Set([...anchor, ...f.paths]).size || 1)
|
|
560
|
+
if (j > bestJ) { bestJ = j; best = f }
|
|
561
|
+
}
|
|
562
|
+
return bestJ >= ORPHAN_BELOW ? best : null
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 초안 위에 사람의 편집을 얹는다. 초안은 건드리지 않는다.
|
|
567
|
+
*
|
|
568
|
+
* @param {object[]} features draft() 가 만든 초안
|
|
569
|
+
* @param {object[]} edits 시간순 편집 기록
|
|
570
|
+
*/
|
|
571
|
+
export function applyEdits(features, edits = []) {
|
|
572
|
+
// 초안을 복사한다. 원본을 고치면 다시 계산할 때 어디까지가 초안인지 알 수 없다.
|
|
573
|
+
let out = features.map((f) => ({ ...f, paths: [...f.paths], edited: false }))
|
|
574
|
+
const orphans = []
|
|
575
|
+
|
|
576
|
+
for (const e of edits) {
|
|
577
|
+
if (e.op === 'create') {
|
|
578
|
+
const paths = [...new Set(e.paths ?? [])].sort()
|
|
579
|
+
if (!paths.length) continue
|
|
580
|
+
out.push({
|
|
581
|
+
id: e.id ?? featureId(paths),
|
|
582
|
+
name: e.name ?? provisionalName(paths),
|
|
583
|
+
nameSource: 'human',
|
|
584
|
+
paths,
|
|
585
|
+
commits: 0,
|
|
586
|
+
source: 'human',
|
|
587
|
+
edited: true,
|
|
588
|
+
})
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const f = resolve(out, e)
|
|
593
|
+
if (!f) { orphans.push(e); continue }
|
|
594
|
+
f.edited = true
|
|
595
|
+
|
|
596
|
+
switch (e.op) {
|
|
597
|
+
case 'rename':
|
|
598
|
+
f.name = e.name
|
|
599
|
+
f.nameSource = e.by === 'llm' ? 'llm' : 'human'
|
|
600
|
+
break
|
|
601
|
+
case 'include':
|
|
602
|
+
if (!f.paths.includes(e.path)) { f.paths.push(e.path); f.paths.sort() }
|
|
603
|
+
break
|
|
604
|
+
case 'exclude':
|
|
605
|
+
f.paths = f.paths.filter((p) => p !== e.path)
|
|
606
|
+
break
|
|
607
|
+
case 'hide':
|
|
608
|
+
f.hidden = true
|
|
609
|
+
break
|
|
610
|
+
case 'merge': {
|
|
611
|
+
const into = resolve(out, { id: e.into, anchor: e.intoAnchor })
|
|
612
|
+
if (!into || into === f) break
|
|
613
|
+
into.paths = [...new Set([...into.paths, ...f.paths])].sort()
|
|
614
|
+
into.edited = true
|
|
615
|
+
f.merged = into.id
|
|
616
|
+
f.hidden = true
|
|
617
|
+
break
|
|
618
|
+
}
|
|
619
|
+
default:
|
|
620
|
+
// 모르는 편집은 버리지 않고 드러낸다. 새 버전이 쓴 것일 수 있다.
|
|
621
|
+
orphans.push(e)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 비어 버린 기능은 목록에서 뺀다 (사람이 파일을 다 빼낸 경우)
|
|
626
|
+
out = out.filter((f) => f.paths.length > 0)
|
|
627
|
+
return { features: out, orphans }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 편집 한 줄을 만든다. anchor 를 반드시 함께 적는다 — 위 resolve 주석 참조. */
|
|
631
|
+
export function editRecord(op, feature, extra = {}) {
|
|
632
|
+
return { op, id: feature.id, anchor: feature.paths, ...extra }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 한 파일이 속한 기능들.
|
|
637
|
+
*
|
|
638
|
+
* 기능은 파일의 **분할이 아니라 겹치는 덮개**다. `cmd_mux_node.py` 는
|
|
639
|
+
* 주행에도 긴급정지에도 수동조종에도 속한다. 그래서 여기는 배열을 돌려준다.
|
|
640
|
+
* 그리고 이 배열의 길이 자체가 D10 이 재려는 결합도다 —
|
|
641
|
+
* "이 파일은 기능 5개에 걸쳐 있다" 는 평균 영향 범위보다 사람 말에 가깝다.
|
|
642
|
+
*/
|
|
643
|
+
export function featuresOf(features, path) {
|
|
644
|
+
return features.filter((f) => f.paths.includes(path))
|
|
645
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `fetchRepo` 를 **자식 프로세스에서** 돌린다.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 왜 따로 띄우는가.
|
|
6
|
+
*
|
|
7
|
+
* `fetchrepo.mjs` 는 `execFileSync` 로 git 을 부른다. 그 자체는 옳다 — 클론은
|
|
8
|
+
* 순서가 있는 일이고 동기 코드가 읽기 쉽다. 그런데 **서버 안에서 그대로 부르면
|
|
9
|
+
* 클론이 끝날 때까지 이벤트 루프가 통째로 멈춘다.** 큰 저장소면 몇 분이다.
|
|
10
|
+
*
|
|
11
|
+
* 그 몇 분 동안 화면도 대화도 전부 죽는다. 그건 이 앱이 세션을 서버에 둔 이유
|
|
12
|
+
* ("앱이 느려져도 작업은 계속 돈다", `app/lib/session.mjs`)와 정면으로 어긋난다.
|
|
13
|
+
* 실제로 처음에 서버에서 직접 불렀다가 요청이 타임아웃으로 끊겼다.
|
|
14
|
+
*
|
|
15
|
+
* 그래서 무거운 일만 자식에게 넘긴다. 부모는 진행 상황만 모은다.
|
|
16
|
+
*
|
|
17
|
+
* $ node app/lib/fetchrepo-run.mjs <git 주소>
|
|
18
|
+
*
|
|
19
|
+
* 진행 로그는 stderr 로, 결과 JSON 은 stdout 으로 낸다. 둘을 섞으면 부모가
|
|
20
|
+
* 결과를 파싱하다 로그를 만나 깨진다.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { fetchRepo } from './fetchrepo.mjs'
|
|
24
|
+
|
|
25
|
+
const url = process.argv[2]
|
|
26
|
+
if (!url) {
|
|
27
|
+
process.stdout.write(JSON.stringify({ error: '주소가 없습니다' }))
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const r = fetchRepo(url, { onLog: (m) => process.stderr.write(`${m}\n`) })
|
|
33
|
+
process.stdout.write(JSON.stringify(r))
|
|
34
|
+
} catch (e) {
|
|
35
|
+
process.stdout.write(JSON.stringify({ error: e?.message ?? String(e) }))
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|