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.
Files changed (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,651 @@
1
+ /**
2
+ * PR 화면 — "이 작업이 뭘 바꿨고 어디까지 영향 갔나" (D2 의 사후 검증, F2).
3
+ *
4
+ * 🔴 지금 있는 흐름 ①~⑤ 는 작업 **전** 화면이다. 이건 작업 **후** 화면이다.
5
+ *
6
+ * 둘은 같은 데이터를 쓰지만 묻는 것이 다르다.
7
+ *
8
+ * 흐름 "이 문제를 이해하려면 어디를 읽어야 하나" (F1 · 이해)
9
+ * PR "이 변경이 뭘 바꿨고 어디까지 갔나" (F2 · 검증)
10
+ *
11
+ * D2 는 둘을 "같은 일을 하는 두 방법이 아니라 서로 다른 두 문제" 라고 못박았다.
12
+ * 그래서 화면을 나누고 질문도 다시 세운다.
13
+ *
14
+ * Q1 무엇이 바뀌었나
15
+ * Q2 어디까지 번지나 ← 바뀐 것을 import 하는 쪽 (역방향)
16
+ * Q3 같이 바뀌었어야 하는데 안 바뀐 것 ← 🔴 이 화면의 핵심
17
+ * Q4 이번 변경이 평소와 다른가
18
+ *
19
+ * ---
20
+ *
21
+ * 🔴 Q3 하나 때문에 이 화면이 있다.
22
+ *
23
+ * Q1 은 `git diff --stat` 이 이미 준다. Q2 는 IDE 의 "find usages" 가 준다.
24
+ * 아무도 안 주는 것은 **diff 에 없는 파일**에 대한 이야기다. F2("AI 가 뭘
25
+ * 망가뜨렸는지 모른다")의 실체가 정확히 그것이다 — 바뀐 것은 눈에 보이고,
26
+ * 바뀌었어야 하는데 안 바뀐 것은 아무 데도 안 나온다.
27
+ *
28
+ * D12 의 실측이 그 자리를 가리킨다. immich 의 `asset.service.ts` 는
29
+ * `stack`·`trash`·`duplicate` 를 **하나도 import 하지 않는데** 반복해서 함께
30
+ * 바뀌었고, 커밋 `a0c7b8114` 가 정확히 그 자리에서 난 버그다 —
31
+ * *"자산을 지웠는데 스택이 안 풀린다"*, 고친 파일은 `asset.service.ts` 하나뿐.
32
+ * 정적 파싱에도 안 보이고 리뷰어 눈에도 안 보인다. 히스토리에만 보인다.
33
+ *
34
+ * 🔴 그래서 문턱을 보수적으로 잡는다 (아래 Q3_* 상수의 근거 주석).
35
+ *
36
+ * 오탐이 한 번 나오면 사람은 이 목록 전체를 무시하게 된다. 그때부터 이 화면은
37
+ * 없는 것보다 나쁘다 — 확인했다는 착각만 남기기 때문이다.
38
+ * **확신 없는 것은 내지 않는다.** 대신 왜 안 냈는지를 `gaps` 로 말한다.
39
+ *
40
+ * ---
41
+ *
42
+ * flow.mjs 와 같은 규칙을 따른다.
43
+ * · 질문마다 근거(`evidence`)와 못 알아낸 것(`gaps`)을 함께 낸다.
44
+ * · 근거가 없으면 "모른다" 를 답으로 낸다. 추측을 답으로 내지 않는다.
45
+ * · 순수 함수만. git 호출·파일 I/O·시계는 전부 server.mjs 가 한다.
46
+ */
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // 입력 정규화
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\.\//, '')
53
+
54
+ /** git status/diff 의 상태 문자. 화면과 판정이 같은 말을 쓰도록 한 곳에 둔다. */
55
+ const CODE_LABEL = {
56
+ A: '추가', M: '수정', D: '삭제', R: '이름변경', C: '복사', T: '형식변경', U: '충돌', '?': '추적안됨',
57
+ }
58
+
59
+ /**
60
+ * 변경 목록을 표준형으로 만든다.
61
+ *
62
+ * 문자열도 받고 `{path, code}` 도 받는다. server 가 `git diff --name-status`
63
+ * 와 `git status --porcelain` 두 곳에서 만들어 오기 때문이다.
64
+ */
65
+ export function normalizeChanged(changed = []) {
66
+ const out = new Map()
67
+ for (const c of changed) {
68
+ const path = norm(typeof c === 'string' ? c : c?.path ?? '')
69
+ if (!path) continue
70
+ const code = (typeof c === 'object' && c?.code ? String(c.code) : 'M').trim()
71
+ const head = code[0] ?? 'M'
72
+ const prev = out.get(path)
73
+ // 같은 파일이 커밋 diff 와 작업트리 양쪽에 나올 수 있다. 뒤에 오는 것이
74
+ // **작업트리**(더 최신)이므로 상태를 갱신하되, 출처는 둘 다 남긴다.
75
+ out.set(path, {
76
+ path,
77
+ code: head,
78
+ label: CODE_LABEL[head] ?? head,
79
+ where: [...new Set([...(prev?.where ?? []), (typeof c === 'object' && c?.where) || 'diff'])],
80
+ })
81
+ }
82
+ return [...out.values()].sort((a, b) => (a.path < b.path ? -1 : 1))
83
+ }
84
+
85
+ /** freq 는 Map 으로도 평범한 객체로도 온다 (서버 안에서는 Map, JSON 을 거치면 객체). */
86
+ const freqOf = (freq, p) => {
87
+ if (!freq) return 0
88
+ if (typeof freq.get === 'function') return freq.get(p) ?? 0
89
+ return freq[p] ?? 0
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Q1 · 무엇이 바뀌었나
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * 변경 파일을 폴더로 묶는다.
98
+ *
99
+ * 🔴 폴더로 묶는다. 기능(featuregraph)으로 묶지 않는다.
100
+ *
101
+ * 기능 이름은 README 나 커밋 제목에서 온 **추론**이고, 여기서 틀리면 리뷰어가
102
+ * "이 PR 은 인증을 건드렸다" 라는 틀린 요약을 읽게 된다. 폴더는 추론이 아니라
103
+ * 사실이다. 사후 검증 화면에서는 틀릴 수 있는 요약보다 맞는 사실이 낫다.
104
+ */
105
+ export function groupChanges(changed, nodes = []) {
106
+ const lines = new Map(nodes.map((n) => [n.id, n.lines ?? 0]))
107
+ const known = new Set(nodes.map((n) => n.id))
108
+
109
+ const byDir = new Map()
110
+ const offGraph = []
111
+ for (const c of changed) {
112
+ if (!known.has(c.path)) { offGraph.push(c); continue }
113
+ const i = c.path.lastIndexOf('/')
114
+ const dir = i < 0 ? '(최상위)' : c.path.slice(0, i)
115
+ if (!byDir.has(dir)) byDir.set(dir, { dir, files: [], lines: 0 })
116
+ const g = byDir.get(dir)
117
+ g.files.push({ ...c, lines: lines.get(c.path) ?? 0 })
118
+ g.lines += lines.get(c.path) ?? 0
119
+ }
120
+
121
+ return {
122
+ groups: [...byDir.values()].sort((a, b) => b.files.length - a.files.length || (a.dir < b.dir ? -1 : 1)),
123
+ // 🔴 그래프에 없는 파일을 조용히 버리지 않는다.
124
+ //
125
+ // 문서·설정·잠금파일은 노드가 아니라서 Q2·Q3 가 아무 말도 못 한다.
126
+ // 그 사실을 말하지 않으면 "영향 없음" 으로 읽힌다 — 그런데 `package.json`
127
+ // 이나 마이그레이션 파일이 거기 들어 있으면 영향은 오히려 가장 크다.
128
+ offGraph,
129
+ }
130
+ }
131
+
132
+ function q1(changed, nodes) {
133
+ const { groups, offGraph } = groupChanges(changed, nodes)
134
+ const gaps = []
135
+ const inGraph = changed.length - offGraph.length
136
+
137
+ if (!changed.length) gaps.push('바뀐 파일이 하나도 없다 — base 와 지금이 같다')
138
+ else if (!inGraph) {
139
+ gaps.push(
140
+ `바뀐 ${changed.length}개가 전부 그래프 밖 파일(문서·설정 등)이다 —`
141
+ + ' 아래 Q2·Q3 는 이 변경에 대해 아무 말도 할 수 없다',
142
+ )
143
+ } else if (offGraph.length) {
144
+ gaps.push(
145
+ `${offGraph.length}개는 그래프에 없는 파일이라 번짐·누락 판정에서 빠졌다`
146
+ + ` (${offGraph.slice(0, 3).map((c) => c.path).join(', ')}${offGraph.length > 3 ? ' …' : ''})`,
147
+ )
148
+ }
149
+
150
+ const added = changed.filter((c) => c.code === 'A').length
151
+ const removed = changed.filter((c) => c.code === 'D').length
152
+
153
+ return {
154
+ n: 1,
155
+ key: 'what',
156
+ question: '무엇이 바뀌었나',
157
+ why: '먼저 변경 자체를 한눈에 본다. 여기서 놀랄 것이 있으면 나머지는 볼 필요도 없다.',
158
+ headline: `파일 ${changed.length}개 · 폴더 ${groups.length}곳`
159
+ + (added ? ` · 새 파일 ${added}` : '') + (removed ? ` · 지운 파일 ${removed}` : ''),
160
+ answer: {
161
+ total: changed.length,
162
+ inGraph,
163
+ added,
164
+ removed,
165
+ groups,
166
+ offGraph,
167
+ lines: groups.reduce((a, g) => a + g.lines, 0),
168
+ },
169
+ focus: changed.map((c) => c.path),
170
+ focusKind: 'file',
171
+ gaps,
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Q2 · 어디까지 번지나
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * 바뀐 파일을 **import 하는 쪽**으로 역방향 BFS.
181
+ *
182
+ * 🔴 방향이 반대다. 흐름 ③(`layersFrom`)은 "그 다음에 무엇이 불리나" 라서
183
+ * 나가는 방향으로 넓혔다. 여기 질문은 "이걸 고치면 누가 영향을 받나" 이므로
184
+ * **들어오는 방향**이다. `analyze.mjs` 의 엣지는 `source` 가 `target` 을
185
+ * import 한다(쓰는 쪽 → 쓰이는 쪽). 그러니 `target` 이 바뀌면 `source` 가 흔들린다.
186
+ *
187
+ * 🔴 공변경 엣지는 쓰지 않는다. 방향이 없어서 "번진다" 를 말할 수 없다.
188
+ * 공변경으로 이어진 것은 Q3 가 따로 다룬다 — 둘을 섞으면 근거가 뭉개진다.
189
+ *
190
+ * 🔴 허브 엣지도 뺀다 (D9). 전역 신호를 참고할 뿐인 파일까지 "영향 받는 곳" 에
191
+ * 넣으면 깊이 1에서 이미 저장소 절반이 물든다.
192
+ *
193
+ * @param {number} maxDepth 기본 2. D7 은 색을 끝까지 칠하라고 했지만 그건 *이해*
194
+ * 화면의 이야기다. 리뷰는 유한한 시간 안에 끝내야 하고, 3겹 밖까지 "확인해야
195
+ * 할 곳" 으로 내밀면 아무도 확인하지 않는다. 대신 몇 개가 더 있는지는 센다.
196
+ */
197
+ export function impactedBy(changedPaths, edges = [], { maxDepth = 2, nodes = null } = {}) {
198
+ const changed = new Set(changedPaths)
199
+ const known = nodes ? new Set(nodes.map((n) => n.id)) : null
200
+
201
+ // 역방향 인접: 이 파일이 바뀌면 흔들리는 쪽
202
+ const rev = new Map()
203
+ const push = (from, to, e) => {
204
+ if (!rev.has(from)) rev.set(from, new Map())
205
+ if (!rev.get(from).has(to)) rev.get(from).set(to, e)
206
+ }
207
+ for (const e of edges) {
208
+ if (e.hub) continue
209
+ if (e.origin === 'cochange') continue
210
+ if (known && (!known.has(e.source) || !known.has(e.target))) continue
211
+ push(e.target, e.source, e)
212
+ // 방향을 모르는 엣지는 양쪽으로 통과시킨다 — 모른다고 없는 것으로 치면
213
+ // 조용히 빠뜨린다 (analyze.mjs·flow.mjs 의 같은 판단).
214
+ if (e.directed === false) push(e.source, e.target, e)
215
+ }
216
+
217
+ const seen = new Set(changed)
218
+ const layers = []
219
+ let frontier = [...changed]
220
+ let beyond = 0
221
+ for (let d = 1; ; d++) {
222
+ const next = []
223
+ const rows = []
224
+ for (const p of frontier) {
225
+ for (const [q, e] of rev.get(p) ?? []) {
226
+ if (seen.has(q)) continue
227
+ seen.add(q)
228
+ next.push(q)
229
+ rows.push({ path: q, via: p, kind: e.kind ?? 'import', origin: e.origin ?? 'static' })
230
+ }
231
+ }
232
+ if (!rows.length) break
233
+ if (d > maxDepth) { beyond += rows.length; frontier = next; continue }
234
+ layers.push({ depth: d, count: rows.length, files: rows })
235
+ frontier = next
236
+ }
237
+
238
+ return { layers, total: layers.reduce((a, l) => a + l.count, 0), beyond }
239
+ }
240
+
241
+ /** 정적 파싱이 눈이 먼 상태인가. 그러면 Q2 의 "번짐 없음" 은 결과가 아니라 침묵이다. */
242
+ function unparsedRatio(nodes = []) {
243
+ const parseable = nodes.filter((n) => n.confidence !== 'history-only')
244
+ if (!parseable.length) return 0
245
+ return parseable.filter((n) => n.confidence === 'unparsed').length / parseable.length
246
+ }
247
+
248
+ function q2(changed, nodes, edges, { maxDepth = 2 } = {}) {
249
+ const paths = changed.map((c) => c.path)
250
+ const r = impactedBy(paths, edges, { maxDepth, nodes })
251
+ const gaps = []
252
+
253
+ const blind = unparsedRatio(nodes)
254
+ if (blind >= 0.3) {
255
+ // 🔴 syft(Go) 에서 노드의 98.6%가 파싱 실패였는데 화면은 그 상태를
256
+ // 결과처럼 보여줬다. 여기서 침묵하면 "아무 데도 안 번진다" 가 된다.
257
+ gaps.push(
258
+ `파일의 ${Math.round(blind * 100)}%는 import 를 읽지 못했다 —`
259
+ + ' 여기 안 나온다고 영향이 없는 것이 아니다. "연결을 보지 못했다" 이다',
260
+ )
261
+ }
262
+ // 삭제된 파일은 특히 위험하다. 지워진 파일을 import 하던 쪽은 그냥 깨진다.
263
+ const deleted = changed.filter((c) => c.code === 'D').map((c) => c.path)
264
+ const brokenBy = deleted.filter((p) => r.layers[0]?.files.some((f) => f.via === p))
265
+ if (brokenBy.length) {
266
+ gaps.push(`지운 파일 ${brokenBy.length}개를 아직 import 하는 곳이 있다 — ${brokenBy.slice(0, 3).join(', ')}`)
267
+ }
268
+ if (!r.total && !blind) {
269
+ gaps.push('이 파일들을 import 하는 곳이 없다 — 진입점이거나, 아직 아무도 안 쓰는 새 코드다')
270
+ }
271
+
272
+ return {
273
+ n: 2,
274
+ key: 'spread',
275
+ question: '어디까지 번지나',
276
+ why: '바뀐 파일을 쓰는 쪽은 코드를 안 고쳤어도 동작이 바뀐다. 리뷰가 닿아야 하는 범위다.',
277
+ headline: r.total
278
+ ? `${r.total}개가 이 변경을 import 한다 (1겹 ${r.layers[0]?.count ?? 0}${r.layers[1] ? ` · 2겹 ${r.layers[1].count}` : ''})`
279
+ : 'import 로 이어진 곳이 없다',
280
+ answer: { ...r, maxDepth },
281
+ focus: r.layers.flatMap((l) => l.files.map((f) => f.path)),
282
+ focusKind: 'file',
283
+ gaps,
284
+ }
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Q3 · 같이 바뀌었어야 하는데 안 바뀐 것 ← 이 화면의 핵심
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * 문턱 넷. 전부 **오탐을 줄이는 쪽**으로 골랐고, 각각 다른 실패를 막는다.
293
+ *
294
+ * | 상수 | 값 | 막는 것 |
295
+ * |---|---|---|
296
+ * | `Q3_MIN_SUPPORT` | 5 | 우연히 두세 번 겹친 쌍 |
297
+ * | `Q3_MIN_FREQ` | 8 | 분모가 작아 conf 가 1.0 으로 튀는 것 |
298
+ * | `Q3_MIN_CONF` | 0.5 | "평소에도 절반은 따로 바뀌던" 쌍 |
299
+ * | `Q3_MIN_LIFT` | 2 | 아무거나와 함께 바뀌는 파일 (CHANGELOG·버전파일) |
300
+ *
301
+ * **왜 5·8 인가 — 지어낸 숫자가 아니라 화면이 이미 쓰는 숫자다.**
302
+ * `overlay.js` 의 "표본 충분한 것만" 이 `STRONG_SUP=5` · `STRONG_FREQ=8` 이다.
303
+ * 같은 저장소에서 사분면 화면은 "표본이 부족하다" 며 숨긴 쌍을 PR 화면은
304
+ * "빠뜨렸습니다" 라고 단언하면, 두 화면이 서로를 반박한다. 문턱은 같아야 한다.
305
+ *
306
+ * **왜 conf 0.5 인가.** conf = support / (그 파일이 바뀐 커밋 수) 이고
307
+ * "이 파일을 고친 지난 N번 중 M번은 저것도 함께 고쳤다" 는 뜻이다.
308
+ * 절반에 못 미치면 **따로 바뀌는 것이 기본값**이라는 말이므로, 그걸 누락이라고
309
+ * 부르면 그 자체가 틀린 주장이다. 0.5 는 "평소에 더 자주 함께 바뀌었다" 의 경계다.
310
+ *
311
+ * **왜 lift 2 인가.** conf 만으로는 자주 바뀌는 파일을 못 거른다. 전체 커밋의
312
+ * 60% 에 등장하는 파일은 무엇과 짝지어도 conf 0.6 이 나오지만 lift 는 1 근처다.
313
+ * lift 2 = "우연보다 두 배는 자주" 이고, cochange.mjs 가 엣지를 만들 때 쓰는
314
+ * 최소 문턱(support 3)보다 훨씬 위다.
315
+ *
316
+ * 🔴 이 값들을 내리면 통과가 늘어난다. 내릴 때는 **왜 늘려도 되는지** 근거를
317
+ * 여기 남긴다 (CLAUDE.md 의 fail-closed 규칙).
318
+ */
319
+ export const Q3_MIN_SUPPORT = 5
320
+ export const Q3_MIN_FREQ = 8
321
+ export const Q3_MIN_CONF = 0.5
322
+ export const Q3_MIN_LIFT = 2
323
+
324
+ /**
325
+ * 이번 diff 에 없는데, 히스토리가 "보통 같이 바뀐다" 고 말하는 파일.
326
+ *
327
+ * @param {string[]} changedPaths
328
+ * @param {object[]} coEdges cochange.mjs 의 엣지 {source,target,support,confA,confB,lift}
329
+ * @param {Map|object} freq 파일별 등장 커밋 수
330
+ */
331
+ export function missingCoChanges(changedPaths, coEdges = [], freq = null, {
332
+ minSupport = Q3_MIN_SUPPORT,
333
+ minFreq = Q3_MIN_FREQ,
334
+ minConf = Q3_MIN_CONF,
335
+ minLift = Q3_MIN_LIFT,
336
+ limit = 12,
337
+ exclude = null, // 판정에서 뺄 경로 (예: 그래프에 이미 없는 파일)
338
+ } = {}) {
339
+ const changed = new Set(changedPaths)
340
+ const best = new Map()
341
+ const dropped = { support: 0, conf: 0, lift: 0, shortHistory: 0 }
342
+ const shortHistory = new Set()
343
+ let considered = 0
344
+
345
+ for (const e of coEdges) {
346
+ const aIn = changed.has(e.source)
347
+ const bIn = changed.has(e.target)
348
+ // 양쪽 다 바뀌었으면 잘한 것이고, 양쪽 다 안 바뀌었으면 이 PR 과 무관하다.
349
+ if (aIn === bIn) continue
350
+ const from = aIn ? e.source : e.target // 이번에 바뀐 쪽
351
+ const to = aIn ? e.target : e.source // 안 바뀐 쪽 = 후보
352
+ if (exclude?.has(to)) continue
353
+ considered++
354
+
355
+ const n = freqOf(freq, from)
356
+ /**
357
+ * 🔴 히스토리가 짧은 파일에 대해서는 **아무 말도 하지 않는다.**
358
+ *
359
+ * conf 는 support/n 이라 n 이 3이면 3번 중 3번으로 1.0 이 나온다.
360
+ * 그 숫자는 "언제나 함께 바뀐다" 가 아니라 "표본이 3개다" 라는 뜻인데,
361
+ * 화면에는 100% 로 찍힌다. 확신 있게 틀린 답이 가장 해롭다 (D5).
362
+ */
363
+ if (n < minFreq) { dropped.shortHistory++; shortHistory.add(from); continue }
364
+
365
+ const support = e.support ?? 0
366
+ if (support < minSupport) { dropped.support++; continue }
367
+
368
+ /**
369
+ * conf 는 엣지에 실려 온 것을 쓰지 않고 여기서 다시 계산한다.
370
+ * `confA`/`confB` 는 source/target 기준이라 어느 쪽이 바뀐 쪽인지에 따라
371
+ * 분모가 달라진다. 방향을 잘못 집으면 조용히 다른 질문에 답하게 된다.
372
+ */
373
+ const conf = support / n
374
+ if (conf < minConf) { dropped.conf++; continue }
375
+ if ((e.lift ?? 0) < minLift) { dropped.lift++; continue }
376
+
377
+ const row = {
378
+ path: to,
379
+ support,
380
+ of: n,
381
+ conf: +conf.toFixed(3),
382
+ lift: e.lift ?? 0,
383
+ from,
384
+ // 🔴 근거를 문장으로 함께 낸다. 숫자만 주면 관찰자 셋이 전부
385
+ // "이 숫자가 뭐냐" 고 물었다 (overlay.js 의 TERMS 와 같은 이유).
386
+ evidence: `${from} 를 고친 지난 ${n}번 중 ${support}번 함께 바뀌었다`
387
+ + ` (${Math.round(conf * 100)}% · 우연의 ${e.lift ?? 0}배)`,
388
+ alsoFrom: [],
389
+ }
390
+ const prev = best.get(to)
391
+ if (!prev) { best.set(to, row); continue }
392
+ // 같은 후보를 여러 변경 파일이 가리키면 **가장 센 근거**를 대표로 삼고
393
+ // 나머지는 옆에 붙인다. 근거가 여럿이라는 사실 자체가 신호다.
394
+ if (conf > prev.conf || (conf === prev.conf && support > prev.support)) {
395
+ row.alsoFrom = [...prev.alsoFrom, prev.from]
396
+ best.set(to, row)
397
+ } else {
398
+ prev.alsoFrom.push(from)
399
+ }
400
+ }
401
+
402
+ const rows = [...best.values()].sort(
403
+ (a, b) => b.conf - a.conf || b.support - a.support || (a.path < b.path ? -1 : 1),
404
+ )
405
+ return {
406
+ rows: rows.slice(0, limit),
407
+ truncated: Math.max(0, rows.length - limit),
408
+ stats: { considered, dropped, shortHistory: [...shortHistory] },
409
+ thresholds: { minSupport, minFreq, minConf, minLift },
410
+ }
411
+ }
412
+
413
+ function q3(changed, coEdges, freq, opts) {
414
+ const paths = changed.map((c) => c.path)
415
+ // 지운 파일은 짝을 부를 자격이 있다(지웠으면 쓰던 쪽도 고쳐야 한다).
416
+ // 다만 후보로는 나오면 안 된다 — 이번에 지운 파일을 "안 바꿨다" 고 할 수는 없다.
417
+ const r = missingCoChanges(paths, coEdges, freq, opts)
418
+ const gaps = []
419
+
420
+ if (!coEdges.length) {
421
+ gaps.push('공변경 엣지가 하나도 없다 — 히스토리가 짧거나(커밋 부족) 이름이 바뀐 파일이 많다. Q3 는 판정하지 못했다')
422
+ } else if (r.stats.shortHistory.length) {
423
+ /**
424
+ * 🔴 "빠진 것 없음" 과 "판정할 수 없었음" 은 다른 말이다.
425
+ *
426
+ * 화면에서 이 둘이 같아 보이면 사용자는 확인했다고 믿는다.
427
+ * 새로 만든 파일이나 최근에 추가된 파일은 대부분 여기 걸린다.
428
+ */
429
+ gaps.push(
430
+ `${r.stats.shortHistory.length}개 파일은 히스토리가 ${Q3_MIN_FREQ}커밋 미만이라 판정하지 않았다`
431
+ + ` (${r.stats.shortHistory.slice(0, 3).join(', ')}${r.stats.shortHistory.length > 3 ? ' …' : ''})`,
432
+ )
433
+ }
434
+ if (!r.rows.length && r.stats.considered > 0) {
435
+ const d = r.stats.dropped
436
+ gaps.push(
437
+ `문턱을 넘은 후보가 없다 — 검토한 쌍 ${r.stats.considered}개 중`
438
+ + ` 표본 부족 ${d.support + d.shortHistory} · 동반율 미달 ${d.conf} · lift 미달 ${d.lift}`,
439
+ )
440
+ }
441
+
442
+ return {
443
+ n: 3,
444
+ key: 'missing',
445
+ question: '같이 바뀌었어야 하는데 안 바뀐 것',
446
+ why: '바뀐 것은 diff 에 보인다. 안 바뀐 것은 아무 데도 안 나온다 — 그 자리에서 사고가 난다 (D12).',
447
+ headline: r.rows.length
448
+ ? `${r.rows.length}개가 평소 함께 바뀌던 파일인데 이번 diff 에 없다`
449
+ : (coEdges.length ? '문턱을 넘는 누락 후보가 없다' : '판정할 히스토리가 없다'),
450
+ answer: r,
451
+ focus: r.rows.map((x) => x.path),
452
+ focusKind: 'file',
453
+ gaps,
454
+ }
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Q4 · 이번 변경이 평소와 다른가
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /** 비교할 과거 커밋이 이보다 적으면 "평소" 라는 말 자체를 못 한다. */
462
+ export const Q4_MIN_COMMITS = 5
463
+ /** 쌍 분석을 포기하는 크기. 200개 diff 면 쌍이 2만 개라 재미도 뜻도 없다. */
464
+ const Q4_MAX_PAIRS_FILES = 60
465
+
466
+ /**
467
+ * 평소 이 파일들이 함께 바뀌던 범위와 이번 변경을 비교한다.
468
+ *
469
+ * 두 가지를 본다.
470
+ * 크기 평소 이 파일들이 낀 커밋은 몇 개짜리였나 (중앙값)
471
+ * 조합 이번에 함께 바뀐 쌍 중 과거에도 함께 바뀐 적 있는 비율
472
+ *
473
+ * 🔴 조합 쪽이 크기보다 중요하다. 큰 PR 이 나쁜 것은 아니다. 나쁜 것은
474
+ * **평소 아무 상관 없던 파일들이 한 커밋에 들어오는 것**이고, 그건 보통
475
+ * "AI 가 시킨 것 말고 다른 것도 건드렸다" 이거나 관심사가 섞인 것이다.
476
+ * D3 이 말한 "선언 ⊂ 실제" 를 히스토리 쪽에서 재는 방법이다.
477
+ *
478
+ * 🔴 중앙값을 쓴다. 평균은 대량 포맷팅 커밋 하나에 통째로 끌려간다
479
+ * (cochange.mjs 가 454파일 커밋을 버리는 것과 같은 이유).
480
+ */
481
+ export function compareWithUsual(changedPaths, commits = []) {
482
+ const changed = new Set(changedPaths)
483
+ const related = commits.filter((c) => (c.files ?? []).some((f) => changed.has(f)))
484
+
485
+ const sizes = related.map((c) => c.files.length).sort((a, b) => a - b)
486
+ const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : null
487
+ const p90 = sizes.length ? sizes[Math.min(sizes.length - 1, Math.floor(sizes.length * 0.9))] : null
488
+
489
+ // 과거에 함께 바뀐 적 있는 쌍
490
+ const seenPairs = new Set()
491
+ for (const c of related) {
492
+ const inDiff = (c.files ?? []).filter((f) => changed.has(f)).sort()
493
+ for (let i = 0; i < inDiff.length; i++) {
494
+ for (let j = i + 1; j < inDiff.length; j++) seenPairs.add(`${inDiff[i]}\0${inDiff[j]}`)
495
+ }
496
+ }
497
+ const list = [...changed].sort()
498
+ let pairsTotal = 0
499
+ let pairsSeen = 0
500
+ const newPairs = []
501
+ const pairable = list.length <= Q4_MAX_PAIRS_FILES
502
+ if (pairable) {
503
+ for (let i = 0; i < list.length; i++) {
504
+ for (let j = i + 1; j < list.length; j++) {
505
+ pairsTotal++
506
+ if (seenPairs.has(`${list[i]}\0${list[j]}`)) pairsSeen++
507
+ else if (newPairs.length < 8) newPairs.push([list[i], list[j]])
508
+ }
509
+ }
510
+ }
511
+
512
+ const touched = new Set(related.flatMap((c) => c.files ?? []))
513
+ const firstTime = list.filter((p) => !touched.has(p))
514
+
515
+ return {
516
+ relatedCommits: related.length,
517
+ median,
518
+ p90,
519
+ thisSize: list.length,
520
+ pairsTotal,
521
+ pairsSeen,
522
+ // 🔴 pairsTotal 이 0 이면 비율은 "0%" 가 아니라 **없음**이다.
523
+ // 0 을 내놓으면 "한 번도 같이 안 바뀌었다" 로 읽힌다.
524
+ familiarity: pairsTotal ? +(pairsSeen / pairsTotal).toFixed(3) : null,
525
+ newPairs,
526
+ firstTime,
527
+ pairable,
528
+ samples: related.slice(0, 5).map((c) => ({
529
+ subject: c.subject ?? '',
530
+ size: c.files.length,
531
+ hit: (c.files ?? []).filter((f) => changed.has(f)).length,
532
+ })),
533
+ }
534
+ }
535
+
536
+ function q4(changed, commits) {
537
+ const paths = changed.map((c) => c.path)
538
+ const r = compareWithUsual(paths, commits)
539
+ const gaps = []
540
+ const flags = []
541
+
542
+ if (r.relatedCommits < Q4_MIN_COMMITS) {
543
+ // 🔴 "평소" 를 말하려면 평소가 있어야 한다. 커밋 두 개로 평균을 내고
544
+ // "평소보다 넓습니다" 라고 말하는 것은 근거 없는 단정이다.
545
+ gaps.push(
546
+ `이 파일들이 낀 과거 커밋이 ${r.relatedCommits}개뿐이라 "평소" 를 말할 수 없다`
547
+ + ` (${Q4_MIN_COMMITS}개 이상 필요)`,
548
+ )
549
+ } else {
550
+ if (r.median != null && r.thisSize > Math.max(r.p90, r.median + 3)) {
551
+ flags.push({
552
+ key: 'wide',
553
+ text: `평소 이 파일들은 ${r.median}개짜리 커밋에서 바뀌었는데 이번은 ${r.thisSize}개다`
554
+ + ` (상위 10% 커밋도 ${r.p90}개)`,
555
+ })
556
+ }
557
+ if (r.pairable && r.familiarity != null && r.familiarity < 0.2 && r.thisSize >= 3) {
558
+ flags.push({
559
+ key: 'unfamiliar',
560
+ text: `함께 바뀐 쌍 ${r.pairsTotal}개 중 과거에도 함께 바뀐 적 있는 것은 ${r.pairsSeen}개뿐이다`
561
+ + ' — 평소 따로 움직이던 것들이 한 번에 들어왔다. 관심사가 섞였는지 확인할 것',
562
+ })
563
+ }
564
+ if (r.firstTime.length) {
565
+ flags.push({
566
+ key: 'first',
567
+ text: `${r.firstTime.length}개는 이 파일들과 함께 바뀐 적이 한 번도 없다`
568
+ + ` (${r.firstTime.slice(0, 3).join(', ')}${r.firstTime.length > 3 ? ' …' : ''})`,
569
+ })
570
+ }
571
+ }
572
+ if (!r.pairable) {
573
+ gaps.push(`변경 파일이 ${r.thisSize}개라 조합 분석은 건너뛰었다 (${Q4_MAX_PAIRS_FILES}개까지)`)
574
+ }
575
+ if (!commits.length) gaps.push('커밋 히스토리를 못 읽어 비교할 것이 없다')
576
+
577
+ return {
578
+ n: 4,
579
+ key: 'usual',
580
+ question: '이번 변경이 평소와 다른가',
581
+ why: '같은 코드를 고치던 지난 커밋들과 모양이 다르면, 그 다름 자체가 봐야 할 이유다 (D3).',
582
+ headline: r.relatedCommits < Q4_MIN_COMMITS
583
+ ? '비교할 히스토리가 부족하다'
584
+ : flags.length
585
+ ? `평소와 다른 점 ${flags.length}가지`
586
+ : `평소 범위 안이다 (과거 ${r.relatedCommits}개 커밋 기준)`,
587
+ answer: { ...r, flags },
588
+ focus: r.firstTime,
589
+ focusKind: 'file',
590
+ gaps,
591
+ }
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /**
597
+ * 네 질문에 답한다.
598
+ *
599
+ * @param {object} input
600
+ * changed 변경 파일 [{path, code}] 또는 [string]
601
+ * nodes 그래프 노드
602
+ * edges 오버레이 엣지 (origin 포함)
603
+ * coEdges 공변경 엣지
604
+ * freq 파일별 등장 커밋 수 (Map 또는 객체)
605
+ * commits 커밋별 파일 목록 [{subject, files}]
606
+ */
607
+ export function prReview({
608
+ changed = [], nodes = [], edges = [], coEdges = [], freq = null, commits = [],
609
+ } = {}, opts = {}) {
610
+ const list = normalizeChanged(changed)
611
+ const known = new Set(nodes.map((n) => n.id))
612
+
613
+ // 🔴 이번에 지운 파일은 Q3 후보에서 뺀다. "지운 파일을 안 바꿨다" 는 말이
614
+ // 안 되고, 그래프에도 이미 없다.
615
+ const exclude = new Set(list.filter((c) => c.code === 'D').map((c) => c.path))
616
+
617
+ const questions = [
618
+ q1(list, nodes),
619
+ q2(list, nodes, edges, opts),
620
+ q3(list.filter((c) => known.has(c.path) || freqOf(freq, c.path) > 0), coEdges, freq, { ...opts, exclude }),
621
+ q4(list, commits),
622
+ ]
623
+
624
+ /**
625
+ * 🔴 변경이 없으면 Q2~Q4 는 입을 다문다.
626
+ *
627
+ * 그냥 두면 빈 입력에 대고 "이 파일들을 import 하는 곳이 없다 — 진입점이거나
628
+ * 아직 아무도 안 쓰는 새 코드다" 같은 말을 한다. 사실도 아니고(파일이 없다)
629
+ * 읽는 사람에게는 **답처럼 보인다.** 답할 것이 없을 때 답처럼 보이는 문장을
630
+ * 내놓는 것이 이 도구가 가장 조심해야 하는 실패다.
631
+ */
632
+ if (!list.length) {
633
+ for (const q of questions.slice(1)) {
634
+ q.headline = '변경이 없어 답할 것이 없다'
635
+ q.gaps = []
636
+ }
637
+ }
638
+
639
+ return {
640
+ changed: list,
641
+ questions,
642
+ // 흐름과 같은 규칙 — 질문마다 gaps 를 내도 사용자가 넷을 다 읽지는 않는다.
643
+ gaps: questions.flatMap((q) => q.gaps.map((text) => ({ q: q.n, text }))),
644
+ stats: {
645
+ changed: list.length,
646
+ nodes: nodes.length,
647
+ coEdges: coEdges.length,
648
+ commits: commits.length,
649
+ },
650
+ }
651
+ }