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,421 @@
1
+ /**
2
+ * 공변경(co-change) — git 히스토리에서 "실제로 함께 바뀐" 엣지를 뽑는다.
3
+ *
4
+ * 정적 파싱은 "코드가 무엇을 참조한다고 적혀 있나"를 답한다.
5
+ * 리뷰가 답해야 하는 질문은 "이걸 고치면 무엇을 같이 고쳐야 하나"다.
6
+ * 둘은 겹치지만 같지 않고, **겹치지 않는 부분이 사고가 나는 자리**다.
7
+ *
8
+ * D5 의 세 번째 출처다. 정적 파싱(좁고 확실) · LLM 독해(넓고 검증 필요) 옆에
9
+ * 히스토리(결정론적이고 값싸지만 새 코드에 침묵)를 놓는다.
10
+ * 세 출처의 실패 방식이 서로 달라서, 둘이 합의한 엣지는 훨씬 믿을 만하다.
11
+ *
12
+ * 외부 의존성 0. git 과 node 만 쓴다.
13
+ */
14
+
15
+ import { execFileSync } from 'node:child_process'
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+
19
+ /** 커밋 하나가 이보다 많은 파일을 건드렸으면 버린다. */
20
+ export const MAX_FILES_PER_COMMIT = 50
21
+ /** 이보다 적게 함께 바뀐 쌍은 엣지로 치지 않는다. */
22
+ export const MIN_SUPPORT = 3
23
+
24
+ /**
25
+ * 커밋 크기 필터의 근거.
26
+ *
27
+ * 대량 rename, 일괄 포맷팅, 작업본 통째 반입 커밋은 파일 수백 개를 한 번에 건드린다.
28
+ * 그 안의 파일들이 서로 결합되어 있다는 정보는 **0** 이다. 같은 날 같이 들어왔을 뿐이다.
29
+ * 그런데 쌍은 N(N-1)/2 로 늘어나므로, 그런 커밋 하나가 전체 쌍의 절반을 만들기도 한다.
30
+ * 실측(e101): 454파일 커밋 하나가 102,831쌍 = 전체의 48%.
31
+ *
32
+ * 🔴 이 필터는 결과를 바꾼다. 성능 최적화가 아니라 **의미 판단**이다.
33
+ * 임계값을 옮길 때는 왜 옮기는지 근거를 남긴다.
34
+ */
35
+
36
+ /**
37
+ * 빈도 사전 가지치기의 근거 — 이쪽은 반대로 **무손실**이다.
38
+ *
39
+ * support(A,B) <= min(count(A), count(B))
40
+ *
41
+ * 어떤 파일이 전체에서 2번만 바뀌었다면, 그 파일이 낀 어떤 쌍도 support 3 을 넘을 수 없다.
42
+ * 그러니 쌍을 만들기 *전에* 지워도 최종 결과가 같다. (Apriori 하향 폐쇄성)
43
+ * 파일 빈도가 롱테일이라 실제로 크게 먹는다 — 실측에서 파일 93% 가 사라지고 답은 동일했다.
44
+ */
45
+
46
+ function git(root, args) {
47
+ return execFileSync('git', ['-C', root, '-c', 'core.quotepath=false', ...args], {
48
+ encoding: 'utf8',
49
+ maxBuffer: 1 << 30,
50
+ })
51
+ }
52
+
53
+ /**
54
+ * 커밋별 파일 집합. 머지 커밋은 제외한다 — 머지는 저자가 고른 변경이 아니다.
55
+ *
56
+ * 🔴 `git log --name-only` 는 **저장소 루트 기준** 경로를 준다.
57
+ * 뷰어를 하위 디렉터리로 열면(`node app/server.mjs <repo>/server`) 노드 ID 는
58
+ * 그 디렉터리 기준이라 그대로는 하나도 안 맞는다. 조용히 엣지 0개가 되고
59
+ * "히스토리가 없다"는 틀린 결론이 나온다. 접두사를 떼서 맞춘다.
60
+ */
61
+ /**
62
+ * 이 저장소가 blob 을 지연 로딩하는가 (`git clone --filter=blob:none`).
63
+ *
64
+ * 왜 물어보나. git 은 diff 를 만들 때 rename 을 탐지하는데, 유사도를 재려면
65
+ * **파일 내용(blob)** 이 필요하다. blobless 클론에는 blob 이 없으므로
66
+ * 커밋마다 원격으로 가지러 간다.
67
+ *
68
+ * 실측(expressjs/express, 통제 실험):
69
+ * 기본(rename 탐지) 37,166 ms 팩 1 → 64개, 84KB 페치
70
+ * --no-renames 121 ms 팩 1 → 1개, 페치 0
71
+ * **307배.**
72
+ */
73
+ /**
74
+ * blobless(부분) 클론인가.
75
+ *
76
+ * 🔴 `remote.origin` 만 보면 안 된다. **원격 이름이 바뀌면 판정이 깨진다.**
77
+ *
78
+ * 벤치 2회차에서 물렸다. 평가 저장소의 `origin` 을 장부용 로컬 베어 저장소로
79
+ * 바꿔 달았더니 `remote.origin.promisor` 가 사라졌고, 이 함수가 false 를
80
+ * 돌려줘 rename 탐지가 켜졌다. blobless 클론에서 rename 을 켜면 없는 blob 을
81
+ * 읽으려다 `fatal: unable to read <sha>` 로 죽는다.
82
+ *
83
+ * 그 결과 `readCommitSets` 가 null 이 되고, 화면은 **"히스토리 부족"** 이라고
84
+ * 말했다. 사실은 부족한 게 아니라 **명령이 실패한** 것이다.
85
+ * 같은 서버가 `/api/featuregraph` 에서는 커밋을 멀쩡히 세고 있었으므로,
86
+ * 화면끼리 서로 다른 답을 했다 — 벤치의 신입이 그 불일치를 잡아냈다.
87
+ *
88
+ * `extensions.partialclone` 은 저장소 수준 설정이라 원격 이름과 무관하다.
89
+ * 그것을 먼저 보고, 없으면 원격별 promisor 를 **전부** 훑는다.
90
+ */
91
+ function isBlobless(root) {
92
+ try {
93
+ if (git(root, ['config', '--get', 'extensions.partialclone']).trim()) return true
94
+ } catch { /* 없으면 아래로 */ }
95
+ try {
96
+ return git(root, ['config', '--get-regexp', '^remote\\..*\\.promisor'])
97
+ .split('\n').some((l) => l.trim().endsWith('true'))
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 우리가 노드로 만들 수 있었을 확장자인가.
105
+ *
106
+ * 커버리지를 잴 때 이 구분이 없으면 숫자가 거짓말을 한다.
107
+ * flask 에는 `.rst` 가 79개 있는데 그건 애초에 그래프에 안 들어간다.
108
+ * 그것까지 "못 본 히스토리"로 세면 커버리지가 실제보다 훨씬 나빠 보이고,
109
+ * rename 문제와 "코드 파일이 아님"이 한 숫자에 섞인다.
110
+ */
111
+ const CODE_LIKE = /\.(py|mjs|cjs|js|jsx|ts|tsx|java|kt|go|rs|rb|php|cs|swift)$/i
112
+
113
+ /**
114
+ * 커밋별 파일 목록을 그대로 읽는다. 거르지 않는다.
115
+ *
116
+ * 비코드 파일을 노드로 올릴지 판단하려면(datanodes.mjs) **거르기 전** 목록이
117
+ * 필요하다. 그리고 git 로그를 두 번 읽지 않으려고 결과를 재사용한다.
118
+ */
119
+ /**
120
+ * 마지막 실패 이유. `readCommitSets` 가 null 을 돌려준 뒤 호출부가 읽는다.
121
+ *
122
+ * ⚠️ 모듈 수준 상태다. 한 프로세스에서 저장소 하나만 보는 지금 구조에서는
123
+ * 안전하지만, 여러 저장소를 동시에 다루게 되면 인자로 바꿔야 한다.
124
+ */
125
+ let lastError = null
126
+ export const lastCommitSetsError = () => lastError
127
+
128
+ export function readCommitSets(root) {
129
+ return commitSets(root, () => true)
130
+ }
131
+
132
+ function commitSets(root, keep) {
133
+ let raw, prefix
134
+ const blobless = isBlobless(root)
135
+ try {
136
+ // show-prefix 는 저장소 루트에서 실행하면 빈 문자열, 하위에서는 'server/' 같은 값
137
+ prefix = git(root, ['rev-parse', '--show-prefix']).trim()
138
+ raw = git(root, [
139
+ 'log', '--pretty=format:@', '--name-only', '--no-merges',
140
+ // 🔴 이건 성능 대책이 아니라 **의미 판단**이다.
141
+ //
142
+ // rename 을 탐지하면 파일이 이름을 바꿔도 히스토리가 이어진다 —
143
+ // 공변경 입장에서는 그게 더 정확하다. 그래서 blob 이 있는 보통 클론에서는
144
+ // 켜 둔다(git 기본값).
145
+ //
146
+ // 그런데 blobless 클론에서는 켜 두면 커밋마다 네트워크를 타서 307배가 된다.
147
+ // 그 상태로는 아예 못 돌린다. 그러니 여기서는 "정확도를 조금 잃고 돌아가는 것"과
148
+ // "정확한데 안 돌아가는 것" 중에 앞을 고른다.
149
+ //
150
+ // 잃는 것: rename 된 파일은 add + delete 로 보여서 그 지점에서 히스토리가 끊긴다.
151
+ // 즉 **공변경 엣지가 줄어든다** — fail-closed 쪽이라 조용히 잘못 통과시키지는 않는다.
152
+ ...(blobless ? ['--no-renames'] : []),
153
+ ])
154
+ } catch (e) {
155
+ /**
156
+ * 🔴 **왜** 실패했는지를 남긴다. 화면이 "히스토리 부족" 이라고 말하면 안 된다.
157
+ *
158
+ * 벤치 2회차에서 신입이 잡았다. 같은 서버가 `/api/overlay` 에서는
159
+ * "커밋 0개 · 히스토리 부족" 이라 하고 `/api/featuregraph` 에서는 커밋을
160
+ * 멀쩡히 세고 있었다. 화면끼리 서로 다른 답을 한 것이다.
161
+ *
162
+ * 진짜 원인은 `git log` 가 `fatal: unable to read <sha>` 로 죽은 것이었다.
163
+ * 히스토리는 있었다. **우리가 못 읽은 것을 없는 것으로 말했다.**
164
+ *
165
+ * 원인을 안 남기면 다음 사람이 "이 저장소는 커밋이 적구나" 로 읽고 끝난다.
166
+ */
167
+ lastError = (e?.message ?? String(e)).split(/\r?\n/).slice(0, 2).join(' ').slice(0, 300)
168
+ return null
169
+ }
170
+ const sets = []
171
+ let cur = null
172
+ /**
173
+ * 히스토리에는 있는데 지금 트리에는 없는 경로.
174
+ *
175
+ * 🔴 이 수치를 화면에 내보내지 않으면 도구가 확신 있게 틀린 숫자를 보여준다.
176
+ *
177
+ * flask 에서 실측된 상황이다. 2019년에 `flask/` → `src/flask/` 로 옮겼는데,
178
+ * 노드 id 는 `src/flask/app.py` 이고 5,556 커밋 중 대부분은 `flask/app.py` 로
179
+ * 기록돼 있다. 그래서 여기서 전부 버려진다.
180
+ *
181
+ * git log -- src/flask/app.py 135
182
+ * git log --follow -- src/flask/app.py 487 ← 진짜
183
+ * 화면 표시 136
184
+ *
185
+ * **3.6배 낮은 숫자를 아무 단서 없이 보여줬다.** `--follow` 는 경로 하나에만
186
+ * 쓸 수 있어서 전체 히스토리에는 못 건다. 그러니 최소한 "얼마나 못 봤는지"는
187
+ * 말해야 한다 — 모르는 것을 모른다고 말하는 것이 이 도구의 규칙이다 (D5).
188
+ */
189
+ const unknownPaths = new Set()
190
+ let unknownHits = 0
191
+ let knownHits = 0
192
+ for (const line of raw.split('\n')) {
193
+ if (line.startsWith('@')) { cur = []; sets.push(cur); continue }
194
+ let f = line.trim()
195
+ if (!f || !cur) continue
196
+ if (prefix) {
197
+ if (!f.startsWith(prefix)) continue // 범위 밖 파일
198
+ f = f.slice(prefix.length)
199
+ }
200
+ if (keep(f)) { cur.push(f); knownHits++; continue }
201
+ // 코드 파일인데 트리에 없다 = rename 되었거나 삭제되었다.
202
+ // 그 외(문서·설정·이미지)는 원래 노드가 아니므로 커버리지에서 뺀다.
203
+ if (CODE_LIKE.test(f)) { unknownPaths.add(f); unknownHits++ }
204
+ }
205
+ const out = sets.map((s) => [...new Set(s)]).filter((s) => s.length > 0)
206
+ out.coverage = {
207
+ knownHits,
208
+ unknownHits,
209
+ unknownPaths: unknownPaths.size,
210
+ // **코드 파일 언급 중** 몇 %가 지금 트리에 있는 파일이었나.
211
+ // 낮으면 rename·삭제가 많았다는 뜻이고, 그만큼 공변경이 실제보다 적게 나온다.
212
+ // 문서·설정은 분모에서 뺐다 — 그건 못 본 게 아니라 원래 안 보는 것이다.
213
+ ratio: knownHits + unknownHits > 0 ? +(knownHits / (knownHits + unknownHits)).toFixed(3) : 1,
214
+ }
215
+ return out
216
+ }
217
+
218
+ /**
219
+ * 공변경 엣지를 만든다.
220
+ *
221
+ * @param {string} root 저장소 경로
222
+ * @param {Set<string>} nodeIds 그래프에 실제로 있는 노드만 센다 (지워진 파일은 제외)
223
+ * @returns {{edges:object[], freq:Map<string,number>, stats:object}|null}
224
+ */
225
+ export function coChange(root, nodeIds, {
226
+ maxFiles = MAX_FILES_PER_COMMIT,
227
+ minSupport = MIN_SUPPORT,
228
+ raw = null, // readCommitSets() 결과를 넘기면 git 로그를 다시 안 읽는다
229
+ } = {}) {
230
+ let sets
231
+ if (raw) {
232
+ // 미리 읽은 것을 노드 기준으로 거른다. 커버리지 통계는 그대로 물려받는다.
233
+ const cov = raw.coverage
234
+ sets = raw.map((s) => s.filter((f) => nodeIds.has(f))).filter((s) => s.length)
235
+ sets.coverage = cov
236
+ } else {
237
+ sets = commitSets(root, (f) => nodeIds.has(f))
238
+ }
239
+ if (!sets) return null
240
+
241
+ // 1차 패스 — 파일별 등장 커밋 수. 키가 파일이라 값싸다.
242
+ const freq = new Map()
243
+ for (const s of sets) for (const f of s) freq.set(f, (freq.get(f) ?? 0) + 1)
244
+
245
+ // 2차 패스 — 쌍. 여기서만 데이터가 불어난다.
246
+ const pair = new Map()
247
+ let droppedBig = 0
248
+ let pairsNoPrune = 0
249
+ let pairsEmitted = 0
250
+ for (let s of sets) {
251
+ if (s.length > maxFiles) { droppedBig++; continue }
252
+ pairsNoPrune += (s.length * (s.length - 1)) / 2
253
+ s = s.filter((f) => freq.get(f) >= minSupport).sort()
254
+ for (let i = 0; i < s.length; i++) {
255
+ for (let j = i + 1; j < s.length; j++) {
256
+ // 무순서 쌍이므로 정렬해 표준형으로 만든다. A|B 와 B|A 가 다른 키가 되면
257
+ // 같은 사실이 두 곳에 나뉘어 센다. 구분자는 경로에 절대 못 들어가는 널 바이트.
258
+ const k = `${s[i]}\0${s[j]}`
259
+ pair.set(k, (pair.get(k) ?? 0) + 1)
260
+ pairsEmitted++
261
+ }
262
+ }
263
+ }
264
+
265
+ const N = sets.length || 1
266
+ const edges = []
267
+ for (const [k, sup] of pair) {
268
+ if (sup < minSupport) continue
269
+ const [a, b] = k.split('\0')
270
+ const fa = freq.get(a), fb = freq.get(b)
271
+ edges.push({
272
+ source: a,
273
+ target: b,
274
+ support: sup,
275
+ // 신뢰도는 비대칭이다. 분자는 하나고 분모만 둘이다.
276
+ // "A 를 고치면 B 도 고칠 확률" 과 그 역은 다른 질문이다.
277
+ confA: +(sup / fa).toFixed(3),
278
+ confB: +(sup / fb).toFixed(3),
279
+ // lift 는 "우연히 같이 바뀔 확률 대비 몇 배인가".
280
+ // 자주 바뀌는 파일(README, enum, base 클래스)이 아무거나와 짝지어지는 것을 걸러낸다.
281
+ lift: +((sup / N) / ((fa / N) * (fb / N))).toFixed(1),
282
+ })
283
+ }
284
+ edges.sort((x, y) => y.lift - x.lift || y.support - x.support)
285
+
286
+ return {
287
+ edges,
288
+ freq,
289
+ stats: {
290
+ commits: sets.length,
291
+ // 히스토리 커버리지. 낮으면 rename 등으로 히스토리가 끊긴 것이고,
292
+ // 공변경이 실제보다 적게 잡힌다. 화면이 이 사실을 말해야 한다.
293
+ coverage: sets.coverage ?? null,
294
+ droppedBig,
295
+ pairsNoPrune,
296
+ pairsEmitted,
297
+ filesWithHistory: [...freq.values()].filter((v) => v >= minSupport).length,
298
+ minSupport,
299
+ maxFiles,
300
+ },
301
+ }
302
+ }
303
+
304
+ /**
305
+ * 별칭 import 보강.
306
+ *
307
+ * 🔴 analyze.mjs 의 JS_IMPORT_RE 는 './' 로 시작하는 **상대경로만** 잡는다.
308
+ * 그런데 tsconfig `paths` 를 쓰는 저장소는 절대경로로 쓴다:
309
+ *
310
+ * import { AuthDto } from 'src/dtos/auth.dto' // immich
311
+ * import Foo from '@/components/Foo' // Next.js 관례
312
+ *
313
+ * 실측(immich): 이걸 놓치면 노드 1,132개에 정적 엣지가 331개만 나온다.
314
+ * 그리고 그 누락분이 전부 "히스토리에만 있는 숨은 결합"으로 둔갑한다.
315
+ * **파서 버그가 발견으로 포장되는 것**이 이 도구에서 가장 나쁜 실패다 (D5).
316
+ *
317
+ * 근본 해결은 analyze.mjs 를 고치는 것이지만 그건 모든 소비자의 동작을 바꾼다.
318
+ * 여기서는 보강 엣지로 따로 얹고, 출처를 구분해 둔다.
319
+ */
320
+ export function aliasImportEdges(root, nodeIds) {
321
+ const files = [...nodeIds].filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(f))
322
+ if (files.length === 0) return []
323
+
324
+ // 저장소 안의 패키지 루트를 찾는다. 'src/x' 는 그 패키지의 src 를 가리킨다.
325
+ // (immich 는 server/ web/ 두 패키지가 각자 src 를 갖는다)
326
+ const roots = new Set([''])
327
+ for (const f of files) {
328
+ const i = f.indexOf('/src/')
329
+ if (i > 0) roots.add(f.slice(0, i + 1))
330
+ }
331
+
332
+ const EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '']
333
+ const resolve = (spec, from) => {
334
+ const bases = []
335
+ if (spec.startsWith('.')) bases.push(path.posix.join(path.posix.dirname(from), spec))
336
+ else if (spec.startsWith('src/')) {
337
+ // 같은 패키지 안을 먼저 본다. 아니면 다른 패키지 루트를 시도한다.
338
+ const own = from.slice(0, from.indexOf('/src/') + 1)
339
+ for (const r of [own, ...roots]) bases.push(r + spec)
340
+ } else if (spec.startsWith('@/') || spec.startsWith('~/')) {
341
+ const own = from.slice(0, from.indexOf('/src/') + 1)
342
+ for (const r of [own, ...roots]) bases.push(`${r}src/${spec.slice(2)}`)
343
+ } else return null // 외부 패키지
344
+
345
+ for (const base of bases) {
346
+ for (const e of EXT) {
347
+ if (nodeIds.has(base + e)) return base + e
348
+ if (nodeIds.has(`${base}/index${e}`)) return `${base}/index${e}`
349
+ }
350
+ }
351
+ return null
352
+ }
353
+
354
+ // import 문은 여러 줄에 걸칠 수 있다. from 절까지 넉넉히 잡되 상한을 둬서
355
+ // 파일 전체를 삼키는 폭주를 막는다.
356
+ const RE = /(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s+['"]([^'"]+)['"]/g
357
+
358
+ const seen = new Map()
359
+ for (const f of files) {
360
+ let text
361
+ try { text = fs.readFileSync(path.join(root, f), 'utf8') } catch { continue }
362
+ RE.lastIndex = 0
363
+ let m
364
+ while ((m = RE.exec(text))) {
365
+ const t = resolve(m[1], f)
366
+ if (!t || t === f) continue
367
+ const [a, b] = f < t ? [f, t] : [t, f]
368
+ seen.set(`${a}\0${b}`, { source: f, target: t })
369
+ }
370
+ }
371
+ return [...seen.values()]
372
+ }
373
+
374
+ /**
375
+ * 정적 엣지와 공변경 엣지를 겹쳐 사분면으로 나눈다.
376
+ *
377
+ * import 있음 import 없음
378
+ * 함께 바뀜 both (정상) cochange ← 숨은 결합. 읽어서는 못 찾는다
379
+ * 따로 바뀜 static (안정경계) —
380
+ *
381
+ * 좌하단이 쓸모없는 게 아니다. "호출하지만 같이 안 바뀐다" = 인터페이스가 안정적이라는 뜻이고,
382
+ * 영향 범위를 계산할 때 **가중치를 낮춰야 할 엣지**다. 이게 없으면 그래프가 스파게티가 된다.
383
+ */
384
+ export function overlayEdges(staticEdges, coEdges, freq, minSupport = MIN_SUPPORT) {
385
+ const key = (a, b) => (a < b ? `${a}\0${b}` : `${b}\0${a}`)
386
+
387
+ const co = new Map()
388
+ for (const e of coEdges) co.set(key(e.source, e.target), e)
389
+
390
+ const out = []
391
+ const usedCo = new Set()
392
+
393
+ for (const e of staticEdges) {
394
+ const k = key(e.source, e.target)
395
+ const c = co.get(k)
396
+ if (c) {
397
+ usedCo.add(k)
398
+ out.push({ ...e, origin: 'both', support: c.support, lift: c.lift, confA: c.confA, confB: c.confB })
399
+ } else {
400
+ // 양쪽 다 히스토리가 충분한데 함께 안 바뀐 것만 "안정된 경계"로 부른다.
401
+ // 히스토리가 없는 파일은 판정할 근거가 없으므로 모른다고 둔다.
402
+ const enough = (freq.get(e.source) ?? 0) >= minSupport && (freq.get(e.target) ?? 0) >= minSupport
403
+ out.push({ ...e, origin: enough ? 'static-stable' : 'static' })
404
+ }
405
+ }
406
+ for (const [k, c] of co) {
407
+ if (usedCo.has(k)) continue
408
+ out.push({ source: c.source, target: c.target, origin: 'cochange', directed: false, hub: false, support: c.support, lift: c.lift, confA: c.confA, confB: c.confB })
409
+ }
410
+
411
+ const count = (o) => out.filter((e) => e.origin === o).length
412
+ return {
413
+ edges: out,
414
+ quadrant: {
415
+ both: count('both'),
416
+ cochange: count('cochange'),
417
+ stable: count('static-stable'),
418
+ unknown: count('static'),
419
+ },
420
+ }
421
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 히스토리만 있는 파일을 노드로 만든다.
3
+ *
4
+ * 🔴 이 도구의 존재 이유에 해당하는 결합을 구조적으로 못 잡고 있었다.
5
+ *
6
+ * baritone(Minecraft 봇, Java) 에서 실측된 상황이다. 가장 중요한 숨은 결합은
7
+ * 이것이었다 —
8
+ *
9
+ * src/launch/resources/mixins.baritone.json 을 건드린 커밋 67개
10
+ * 그중 Mixin*.java 도 함께 건드린 커밋 53개 (79%)
11
+ *
12
+ * Mixin 클래스를 추가·삭제하면 **반드시** 이 JSON 의 `client` 배열도 고쳐야 한다.
13
+ * 코드 어디에도 그 규칙이 적혀 있지 않다. 정확히 "코드를 읽어서는 안 보이는 연결"이고,
14
+ * 이 도구가 잡아야 하는 것의 표본이다.
15
+ *
16
+ * 그런데 노드가 `.java` 363개뿐이라 통째로 사라졌다. `scan()` 이 코드 확장자만
17
+ * 노드로 만들기 때문이다. 같은 이유로 —
18
+ * · axMap 자신의 `docs/` 1,306줄이 그래프에 없다 (1회차 관찰의 미해결 항목)
19
+ * · `bin/axmap.mjs → docs/SPEC.md` 같은 규약 결합이 안 잡힌다
20
+ * · `build.gradle` · `fabric.mod.json` · `package.json` 도 전부 없다
21
+ *
22
+ * **정적 파싱이 안 되는 파일이라도 git 공변경은 언어와 무관하게 계산된다.**
23
+ * 노드로만 넣으면 된다.
24
+ *
25
+ * 🔴 다만 전부 넣으면 안 된다.
26
+ *
27
+ * 저장소에는 lock 파일·생성 파일·에셋이 수천 개 있고, 그것들을 다 넣으면
28
+ * 그래프가 노이즈로 덮인다. 그래서 **히스토리가 실제로 있는 것만** 넣는다 —
29
+ * 커밋에 여러 번 등장했다는 것은 사람이 반복해서 손댔다는 뜻이고,
30
+ * 그게 곧 "이 파일은 작업의 일부다" 라는 증거다.
31
+ *
32
+ * 그리고 이 노드들은 **정적 엣지를 절대 갖지 않는다.** 출처를 속이지 않기 위해
33
+ * `confidence: 'history-only'` 로 표시하고 화면이 구분해 그린다.
34
+ */
35
+
36
+ import fs from 'node:fs'
37
+ import path from 'node:path'
38
+
39
+ /**
40
+ * 노드로 만들 수 있는 비코드 파일.
41
+ *
42
+ * 규약·설정·문서만 넣는다. 이미지·폰트·바이너리는 사람이 "읽는" 대상이 아니므로
43
+ * 그래프에 있어도 할 일이 없다.
44
+ */
45
+ const DATA_EXT = /\.(json|ya?ml|toml|ini|cfg|conf|properties|gradle|gradle\.kts|md|rst|adoc|txt|sql|proto|graphql|tf|dockerfile|mod)$/i
46
+ const DATA_NAME = /^(dockerfile|makefile|justfile|procfile|\.gitattributes|\.gitignore|\.env\.example)$/i
47
+
48
+ /** 노이즈. 사람이 손으로 고치는 파일이 아니다. */
49
+ const NOISE = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|Cargo\.lock|go\.sum|composer\.lock)$/i
50
+
51
+ export const isDataFile = (p) => {
52
+ if (NOISE.test(p)) return false
53
+ const base = p.slice(p.lastIndexOf('/') + 1)
54
+ return DATA_EXT.test(base) || DATA_NAME.test(base)
55
+ }
56
+
57
+ /** 코드 노드와 같은 모양이어야 GraphView 가 그대로 쓴다. */
58
+ function makeNode(root, rel, commits) {
59
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
60
+ let lines = 0
61
+ try {
62
+ lines = fs.readFileSync(path.join(root, rel), 'utf8').split('\n').length
63
+ } catch { /* 읽을 수 없으면 0 — 크기는 부차적이다 */ }
64
+ return {
65
+ id: rel,
66
+ name: rel.slice(rel.lastIndexOf('/') + 1),
67
+ dir,
68
+ lang: 'data',
69
+ lines,
70
+ big: false,
71
+ group: rel.split('/')[0],
72
+ kind: 'other',
73
+ role: 'connector',
74
+ deg: { in: 0, out: 0, undirected: 0 },
75
+ badges: [],
76
+ channels: [],
77
+ parsed: false,
78
+ // 🔴 출처를 속이지 않는다. 이 노드는 정적으로 확인된 것이 하나도 없고
79
+ // 오직 "커밋에 함께 나왔다" 만으로 존재한다.
80
+ confidence: 'history-only',
81
+ commits,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 히스토리에서 자주 등장한 비코드 파일을 노드로 만든다.
87
+ *
88
+ * @param {string} root
89
+ * @param {string[][]} commitSets 커밋별 파일 목록 (전체 경로, 아직 안 거른 것)
90
+ * @param {Set<string>} existing 이미 노드인 경로
91
+ * @param {object} opts
92
+ * @returns {{nodes: object[], stats: object}}
93
+ */
94
+ export function historyNodes(root, commitSets, existing, {
95
+ minCommits = 4,
96
+ maxNodes = 400,
97
+ } = {}) {
98
+ const count = new Map()
99
+ for (const files of commitSets) {
100
+ // 대량 커밋은 여기서도 뺀다. 일괄 포맷팅 한 번에 설정 파일 수백 개가
101
+ // 딸려 들어오면 "자주 손댄 파일" 이 아니라 그냥 그 커밋의 부산물이다.
102
+ if (files.length > 50) continue
103
+ for (const f of new Set(files)) {
104
+ if (existing.has(f) || !isDataFile(f)) continue
105
+ count.set(f, (count.get(f) ?? 0) + 1)
106
+ }
107
+ }
108
+
109
+ const frequent = [...count].filter(([, n]) => n >= minCommits)
110
+ // 지워진 파일은 뺀다. 히스토리에는 있지만 지금 트리에 없으면 볼 수 없다.
111
+ const alive = frequent.filter(([f]) => fs.existsSync(path.join(root, f)))
112
+ const picked = alive.sort((a, b) => b[1] - a[1]).slice(0, maxNodes)
113
+
114
+ return {
115
+ nodes: picked.map(([f, n]) => makeNode(root, f, n)),
116
+ stats: {
117
+ candidates: count.size,
118
+ frequent: frequent.length,
119
+ deleted: frequent.length - alive.length, // 히스토리엔 있으나 지금 없는 것
120
+ added: picked.length,
121
+ minCommits,
122
+ // 예산에 걸려 잘린 개수. 위의 deleted 와 다른 것이다 —
123
+ // 하나는 "없어서 못 넣음", 하나는 "많아서 안 넣음" 이다.
124
+ truncated: Math.max(0, alive.length - picked.length),
125
+ },
126
+ }
127
+ }