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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 점진 공개 — 화면은 지도가 아니라 **내가 읽은 흔적**이다.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 왜 만드는가.
|
|
5
|
+
*
|
|
6
|
+
* 벤치마크 여섯 회차가 전부 같은 자리에서 막혔는데, 원인이 정보 부족이
|
|
7
|
+
* 아니었다. **한꺼번에 다 보여준 것**이었다. 700개가 엉킨 그림 앞에서
|
|
8
|
+
* 사람이 느끼는 것은 구조가 아니라 벽이다.
|
|
9
|
+
*
|
|
10
|
+
* 지금 화면은 "이 저장소는 무엇인가" 에 답한다. 그런데 읽는 사람의 실제
|
|
11
|
+
* 질문은 **"다음에 뭘 알아야 하나"** 다. 그 둘은 다른 질문이고, 뒤엣것은
|
|
12
|
+
* 한 번에 답할 수 없다 — 답이 읽는 사람이 어디까지 왔느냐에 달렸기 때문이다.
|
|
13
|
+
*
|
|
14
|
+
* 그래서 진입점 하나로 시작한다. 왼쪽에서 개념을 누르면 그만큼만 자란다.
|
|
15
|
+
* 열지 않은 것은 화면에 없다.
|
|
16
|
+
*
|
|
17
|
+
* ── 🔴 지켜야 하는 불변식 하나 ────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* **한 번 자리를 잡은 노드는 다시는 움직이지 않는다.**
|
|
20
|
+
*
|
|
21
|
+
* 움직이면 방금 쌓은 지도가 매번 무너진다. 그건 정적인 헤어볼보다 나쁘다 —
|
|
22
|
+
* 헤어볼은 적어도 어제와 같은 자리에 있다. 그래서 힘-지향도, 방사형 트리도
|
|
23
|
+
* 쓸 수 없다(둘 다 노드가 늘면 전부 다시 배치된다).
|
|
24
|
+
*
|
|
25
|
+
* 층별 배치에서 **줄 끝에 덧붙이면** 앞엣것이 밀리지 않는다. 아래·오른쪽으로
|
|
26
|
+
* 자라기만 하고 재배치가 없다. 사다리는 구조가 맞았고 용량이 틀렸을 뿐이다.
|
|
27
|
+
*
|
|
28
|
+
* 순수 함수만 둔다 — fs 도 git 도 시계도 없다 (CLAUDE.md).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} Reveal
|
|
33
|
+
* @property {string[]} order 드러난 순서. **이 순서가 곧 자리**다.
|
|
34
|
+
* @property {Record<string, {depth: number, col: number, from: string|null, why: string}>} at
|
|
35
|
+
* @property {string[]} opened 이미 "펼친" 노드 — 두 번 펼치지 않기 위해
|
|
36
|
+
* @property {Record<string, number>} width 층마다 지금까지 몇 개 놓였나
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** 아무것도 안 드러난 상태. */
|
|
40
|
+
export function emptyReveal() {
|
|
41
|
+
return { order: [], at: {}, opened: [], width: {} }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 이 노드가 놓일 층.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 **이미 드러난 것만 본다.** 아직 안 드러난 것을 근거로 층을 정하면,
|
|
48
|
+
* 나중에 그것이 드러났을 때 층이 바뀌어야 하고 그건 불변식 위반이다.
|
|
49
|
+
*
|
|
50
|
+
* ① 드러난 것 중 이 파일을 부르는 것이 있으면 → 그중 가장 얕은 층 + 1
|
|
51
|
+
* ② 누가 열었는지 알면 → 그 층 + 1
|
|
52
|
+
* ③ 둘 다 없으면 → 0 (새 뿌리)
|
|
53
|
+
*/
|
|
54
|
+
function depthOf(state, id, parent, importers) {
|
|
55
|
+
const from = importers.get(id)
|
|
56
|
+
let best = null
|
|
57
|
+
for (const p of from ?? []) {
|
|
58
|
+
const a = state.at[p]
|
|
59
|
+
if (a && (best === null || a.depth < best)) best = a.depth
|
|
60
|
+
}
|
|
61
|
+
if (best !== null) return best + 1
|
|
62
|
+
if (parent && state.at[parent]) return state.at[parent].depth + 1
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** target → [source...] — 누가 이 파일을 부르나. 허브·공변경은 빼고 import 만. */
|
|
67
|
+
export function importersOf(edges) {
|
|
68
|
+
const m = new Map()
|
|
69
|
+
for (const e of edges) {
|
|
70
|
+
if (e.hub || e.origin === 'cochange') continue
|
|
71
|
+
if (!m.has(e.target)) m.set(e.target, [])
|
|
72
|
+
m.get(e.target).push(e.source)
|
|
73
|
+
// 방향을 모르는 연결은 양쪽으로 둔다. 모르는 것을 없는 것으로 치면
|
|
74
|
+
// 조용히 빠뜨린다 (ladder·flow 의 같은 판단).
|
|
75
|
+
if (e.directed === false) {
|
|
76
|
+
if (!m.has(e.source)) m.set(e.source, [])
|
|
77
|
+
m.get(e.source).push(e.target)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return m
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 노드 몇 개를 드러낸다. 이미 드러난 것은 **건드리지 않는다.**
|
|
85
|
+
*
|
|
86
|
+
* @param ids 드러낼 것들. 주어진 순서가 그 층 안의 순서가 된다.
|
|
87
|
+
* @param why 화면에 적을 이유. "무엇을 눌러서 나왔나" 를 사람이 알아야 한다.
|
|
88
|
+
* @param parent 눌러서 연 경우 그 노드. 사이드바에서 바로 열었으면 null.
|
|
89
|
+
*/
|
|
90
|
+
export function reveal(state, ids, { why, parent = null, importers }) {
|
|
91
|
+
const next = {
|
|
92
|
+
order: [...state.order],
|
|
93
|
+
at: { ...state.at },
|
|
94
|
+
opened: [...state.opened],
|
|
95
|
+
width: { ...state.width },
|
|
96
|
+
}
|
|
97
|
+
for (const id of ids) {
|
|
98
|
+
if (next.at[id]) continue // 이미 있다 — 자리를 바꾸지 않는다
|
|
99
|
+
const depth = depthOf(next, id, parent, importers)
|
|
100
|
+
const col = next.width[depth] ?? 0
|
|
101
|
+
next.width[depth] = col + 1
|
|
102
|
+
next.at[id] = { depth, col, from: parent ?? null, why }
|
|
103
|
+
next.order.push(id)
|
|
104
|
+
}
|
|
105
|
+
if (parent && !next.opened.includes(parent)) next.opened.push(parent)
|
|
106
|
+
return next
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 이 노드를 펼치면 무엇이 새로 나오나 — **누르기 전에** 알려주기 위한 것.
|
|
111
|
+
*
|
|
112
|
+
* 🔴 "몇 개가 나올지 모르고 누르는 것" 이 곧 벽이다. 3개면 눌러도 되고
|
|
113
|
+
* 200개면 마음의 준비가 필요하다. 그 판단을 사람에게 돌려준다.
|
|
114
|
+
*/
|
|
115
|
+
export function preview(state, id, calls, { limit = 6 } = {}) {
|
|
116
|
+
const out = (calls.get(id) ?? []).filter((x) => !state.at[x])
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 🔴 **첫 클릭이 벽이었다.**
|
|
120
|
+
*
|
|
121
|
+
* 예고만으로는 모자랐다. axMap 자신에서 진입점을 한 번 누르면 17개가
|
|
122
|
+
* 한꺼번에 쏟아진다. "+17개" 라고 미리 알려주지만 **알고도 할 수 있는
|
|
123
|
+
* 것이 없다** — 누르거나 안 누르거나 둘뿐이다. 그건 선택이 아니다.
|
|
124
|
+
*
|
|
125
|
+
* 그래서 한 번에 여는 수를 자르고 **무엇을 먼저 열지 정한다.**
|
|
126
|
+
*
|
|
127
|
+
* 순서는 "이 가지가 코드베이스를 얼마나 여는가" 다 — 그 파일이 부르는
|
|
128
|
+
* 것의 수. 큰 파일이 중요한 파일은 아니라는 것을 `importTree` 에서
|
|
129
|
+
* 이미 배웠다. 테스트·예제는 많이 불러도 뒤로 보낸다.
|
|
130
|
+
*
|
|
131
|
+
* 자른 것은 사라지지 않는다. `more` 로 몇 개가 남았는지 내고, 같은
|
|
132
|
+
* 노드를 다시 누르면 이어서 열린다(이미 열린 것은 걸러지므로).
|
|
133
|
+
*/
|
|
134
|
+
const isAux = (p) => AUX.test(p) || AUX_FILE.test(p)
|
|
135
|
+
const opens = (p) => (calls.get(p) ?? []).filter((x) => !state.at[x]).length
|
|
136
|
+
const ranked = [...out].sort((a, b) =>
|
|
137
|
+
(isAux(a) ? 1 : 0) - (isAux(b) ? 1 : 0)
|
|
138
|
+
|| opens(b) - opens(a)
|
|
139
|
+
|| (a < b ? -1 : 1))
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 어느 영역이 열리는지도 함께 낸다.
|
|
143
|
+
*
|
|
144
|
+
* 이름 17개를 늘어놓는 것과 "app/lib 15개 · src 1개" 라고 말하는 것은
|
|
145
|
+
* 다르다. 벽이라고 느끼게 하는 것은 개수보다 **구별이 안 되는 이름들**이다.
|
|
146
|
+
*/
|
|
147
|
+
const dirs = new Map()
|
|
148
|
+
for (const p of out) {
|
|
149
|
+
const i = p.lastIndexOf('/')
|
|
150
|
+
const d = i < 0 ? '(루트)' : p.slice(0, i)
|
|
151
|
+
dirs.set(d, (dirs.get(d) ?? 0) + 1)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
count: out.length,
|
|
156
|
+
ids: ranked.slice(0, limit),
|
|
157
|
+
more: Math.max(0, out.length - limit),
|
|
158
|
+
dirs: [...dirs.entries()].sort((a, b) => b[1] - a[1]).map(([dir, n]) => ({ dir, n })),
|
|
159
|
+
why: '부르는 것이 많은 순 (테스트·예제는 뒤로)',
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** source → [target...] — 이 파일이 부르는 것. */
|
|
164
|
+
export function callsOf(edges) {
|
|
165
|
+
const m = new Map()
|
|
166
|
+
for (const e of edges) {
|
|
167
|
+
if (e.hub || e.origin === 'cochange') continue
|
|
168
|
+
if (!m.has(e.source)) m.set(e.source, [])
|
|
169
|
+
if (!m.get(e.source).includes(e.target)) m.get(e.source).push(e.target)
|
|
170
|
+
if (e.directed === false) {
|
|
171
|
+
if (!m.has(e.target)) m.set(e.target, [])
|
|
172
|
+
if (!m.get(e.target).includes(e.source)) m.get(e.target).push(e.source)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return m
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 지금 드러난 것을 층으로 낸다. 화면은 이것만 그린다.
|
|
180
|
+
*
|
|
181
|
+
* 층 안의 순서는 `col` 이고, `col` 은 도착 순서다 — 그래서 새것이 들어와도
|
|
182
|
+
* 앞엣것의 자리가 안 바뀐다.
|
|
183
|
+
*/
|
|
184
|
+
export function layers(state, byId = new Map()) {
|
|
185
|
+
const rows = new Map()
|
|
186
|
+
for (const id of state.order) {
|
|
187
|
+
const a = state.at[id]
|
|
188
|
+
if (!rows.has(a.depth)) rows.set(a.depth, [])
|
|
189
|
+
rows.get(a.depth).push({ id, ...a, lines: byId.get(id)?.lines ?? 0 })
|
|
190
|
+
}
|
|
191
|
+
return [...rows.entries()]
|
|
192
|
+
.sort((x, y) => x[0] - y[0])
|
|
193
|
+
.map(([depth, cells]) => ({ depth, cells: cells.sort((x, y) => x.col - y.col) }))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 여기서 몇 개나 더 닿나 — 화면 가장자리에 적는 수.
|
|
198
|
+
*
|
|
199
|
+
* 🔴 "드러난 것만" 보여주기로 했으므로 규모 감각을 잃는다. 그것을 숫자로
|
|
200
|
+
* 돌려준다. 안 그러면 5개를 보고 "이 저장소는 5개짜리" 로 읽는다.
|
|
201
|
+
* 관측한 것만 센다 — import 로 실제로 닿는 것.
|
|
202
|
+
*/
|
|
203
|
+
export function reachable(state, calls) {
|
|
204
|
+
const seen = new Set(state.order)
|
|
205
|
+
let frontierN = 0
|
|
206
|
+
const q = [...state.order]
|
|
207
|
+
const counted = new Set(state.order)
|
|
208
|
+
while (q.length) {
|
|
209
|
+
const a = q.shift()
|
|
210
|
+
for (const b of calls.get(a) ?? []) {
|
|
211
|
+
if (counted.has(b)) continue
|
|
212
|
+
counted.add(b)
|
|
213
|
+
if (!seen.has(b)) frontierN++
|
|
214
|
+
q.push(b)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { shown: state.order.length, more: frontierN }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ── 개념 축 ─────────────────────────────────────────────────────────────
|
|
221
|
+
*
|
|
222
|
+
* 🔴 import 사슬만으로는 **저장소의 대부분에 영영 못 간다.**
|
|
223
|
+
*
|
|
224
|
+
* 실측(syft): `cmd/syft/main.go` 에서 import 로 닿는 것이 1,244개 중
|
|
225
|
+
* 19개(1.5%)뿐이다. Go 가 패키지 수준 등록으로 배선되기 때문이다.
|
|
226
|
+
* 네 번 누르면 마르고, 그 뒤로는 갈 곳이 없다.
|
|
227
|
+
*
|
|
228
|
+
* 그래서 두 번째 축이 필요하다 — README 가 이름 붙인 묶음을 여는 길.
|
|
229
|
+
* "README 읽어나가듯이" 라는 말이 이 축이다.
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/** 테스트·예제·생성물. 개념의 현관문이 될 수 없다. */
|
|
233
|
+
const AUX = /(^|\/)(testdata|examples?|tests?|spec|fixtures?|benchmarks?|mocks?|vendor)(\/|$)/i
|
|
234
|
+
const AUX_FILE = /(^|\/)(test_[^/]+|[^/]+_test|[^/]+\.test|[^/]+_spec|[^/]+\.spec)\.[^/.]+$/i
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 이 묶음의 **현관문** — 바깥에서 가장 많이 불리는 파일들.
|
|
238
|
+
*
|
|
239
|
+
* 🔴 묶음을 통째로 열면 벽이 그대로 돌아온다. syft 의 `syft/pkg` 는
|
|
240
|
+
* 595개다. 한 번 눌러서 595개가 쏟아지면 우리가 없애려던 것을 다시
|
|
241
|
+
* 만드는 것이다.
|
|
242
|
+
*
|
|
243
|
+
* 그럼 무엇을 먼저 보여주나. **다른 코드가 이 묶음으로 들어올 때 통과하는
|
|
244
|
+
* 문**이다 — 바깥에서 들어오는 import 가 많은 파일. 그게 그 개념의 공개된
|
|
245
|
+
* 얼굴이고, 안쪽 구현은 거기서부터 파면 된다.
|
|
246
|
+
*
|
|
247
|
+
* 아무도 바깥에서 안 부르면(내부 전용 묶음) 크기로 줄 세운다. 그때는
|
|
248
|
+
* 근거가 약하다는 것을 `why` 에 적는다 — 지어내지 않는다.
|
|
249
|
+
*/
|
|
250
|
+
export function frontDoor(paths, edges, byId = new Map(), { limit = 6, skip = null } = {}) {
|
|
251
|
+
const inside = new Set(paths)
|
|
252
|
+
const fromOutside = new Map()
|
|
253
|
+
for (const e of edges) {
|
|
254
|
+
if (e.hub || e.origin === 'cochange') continue
|
|
255
|
+
if (!inside.has(e.target) || inside.has(e.source)) continue
|
|
256
|
+
fromOutside.set(e.target, (fromOutside.get(e.target) ?? 0) + 1)
|
|
257
|
+
}
|
|
258
|
+
const isAux = (p) => AUX.test(p) || AUX_FILE.test(p)
|
|
259
|
+
/**
|
|
260
|
+
* 🔴 이미 드러난 것은 후보에서 뺀다.
|
|
261
|
+
*
|
|
262
|
+
* 실측으로 잡았다. click 에서 `click` 묶음을 누르면 현관문 6개를 고르는데
|
|
263
|
+
* 그중 5개가 이미 화면에 있던 것이라 **실제로 는 것은 1개**였다. 우리
|
|
264
|
+
* 저장소에서는 6개 중 6개가 이미 열려 있어서 **눌렀는데 아무 일도 안 났다.**
|
|
265
|
+
*
|
|
266
|
+
* 현관문은 원래 "많이 불리는 파일" 이고 그건 import 사슬로도 먼저 닿는
|
|
267
|
+
* 파일이다 — 그래서 두 축이 같은 것을 고르는 게 정상이고, 안 빼면 개념 축이
|
|
268
|
+
* 있으나 마나가 된다. import 로 못 가는 곳을 열려고 만든 축이기 때문이다.
|
|
269
|
+
*
|
|
270
|
+
* 안 뺀 채로 "6개 열었다" 고 적으면 그건 화면이 거짓말을 하는 것이다.
|
|
271
|
+
*/
|
|
272
|
+
const seen = skip ?? new Set()
|
|
273
|
+
const fresh = paths.filter((p) => !seen.has(p))
|
|
274
|
+
const cand = fresh.filter((p) => !isAux(p))
|
|
275
|
+
const pool = cand.length ? cand : fresh // 전부 테스트뿐이면 그거라도 낸다
|
|
276
|
+
const ranked = [...pool].sort((a, b) =>
|
|
277
|
+
(fromOutside.get(b) ?? 0) - (fromOutside.get(a) ?? 0)
|
|
278
|
+
|| (byId.get(b)?.lines ?? 0) - (byId.get(a)?.lines ?? 0)
|
|
279
|
+
|| (a < b ? -1 : 1))
|
|
280
|
+
const ids = ranked.slice(0, limit)
|
|
281
|
+
/**
|
|
282
|
+
* 🔴 근거를 **낸 것에 대해서만** 말한다.
|
|
283
|
+
*
|
|
284
|
+
* 처음에는 "묶음 안에 바깥에서 불리는 파일이 하나라도 있으면" 근거를
|
|
285
|
+
* `바깥에서 부르는 수` 라고 적었다. 실측(syft)에서 5개 중 2개만 불리고
|
|
286
|
+
* 나머지 3개는 크기로 뽑혔는데 화면은 다섯 줄 전부에 같은 근거를 붙였다.
|
|
287
|
+
* 반은 맞고 반은 틀린 설명은 전부 틀린 설명이다 — 읽는 사람이 어느 줄이
|
|
288
|
+
* 어느 근거인지 가릴 수 없기 때문이다.
|
|
289
|
+
*/
|
|
290
|
+
const withCallers = ids.filter((p) => (fromOutside.get(p) ?? 0) > 0).length
|
|
291
|
+
const why = !ids.length ? '이 묶음은 이미 다 열려 있다'
|
|
292
|
+
: withCallers === ids.length ? '바깥에서 부르는 수'
|
|
293
|
+
: withCallers === 0 ? '바깥에서 부르는 곳이 없어 크기 순'
|
|
294
|
+
: `${withCallers}개는 바깥에서 부르는 수, 나머지는 크기 순`
|
|
295
|
+
return {
|
|
296
|
+
ids,
|
|
297
|
+
total: paths.length,
|
|
298
|
+
/** 이미 열려 있어서 후보에서 빠진 수. 화면이 "새로 3개 (12개는 이미 열림)" 라고 적을 근거다. */
|
|
299
|
+
already: paths.length - fresh.length,
|
|
300
|
+
why,
|
|
301
|
+
calledFrom: Object.fromEntries(ids.map((p) => [p, fromOutside.get(p) ?? 0])),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 묶음 하나를 연다 — 현관문만.
|
|
307
|
+
*
|
|
308
|
+
* `why` 에 묶음 이름을 넣는다. 화면에서 "왜 이게 나왔나" 를 읽을 수 있어야
|
|
309
|
+
* 개념이 쌓인다. 파일 이름만 늘어놓으면 그건 다시 목록일 뿐이다.
|
|
310
|
+
*/
|
|
311
|
+
export function revealFeature(state, feature, { edges, importers, byId = new Map(), limit = 6 }) {
|
|
312
|
+
// 이미 드러난 것을 넘긴다 — 안 넘기면 고른 수와 실제로 는 수가 어긋난다.
|
|
313
|
+
const door = frontDoor(feature.paths ?? [], edges, byId, { limit, skip: new Set(state.order) })
|
|
314
|
+
const next = reveal(state, door.ids, { why: feature.name ?? feature.id, importers })
|
|
315
|
+
return { state: next, door }
|
|
316
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 저장소 안의 **하위 프로젝트**를 찾는다.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 왜 필요한가 — 실측으로 드러난 것.
|
|
5
|
+
*
|
|
6
|
+
* SSAFY 팀 저장소(`S15P11E101`, 파일 531개)를 열었더니 이렇게 나왔다:
|
|
7
|
+
*
|
|
8
|
+
* 14 / 549 개가 진입점에서 닿습니다 · 최대 1겹 · 안 닿는 파일 535개
|
|
9
|
+
*
|
|
10
|
+
* 그래프가 빈 것이 아니었다. 엣지는 1,267개나 있었다. 문제는 **진입점**이었다.
|
|
11
|
+
* 그 저장소는 한 프로젝트가 아니라 넷이다:
|
|
12
|
+
*
|
|
13
|
+
* BE_system java 208 build.gradle
|
|
14
|
+
* BE_robot python 128 (매니페스트 없음 — ROS 작업공간)
|
|
15
|
+
* FE ts/js 149 FE/bbiyong-react/package.json
|
|
16
|
+
* AI python 39 requirements.txt
|
|
17
|
+
*
|
|
18
|
+
* 그런데 `entryCandidates` 는 **저장소 최상위에서만** 매니페스트를 읽는다
|
|
19
|
+
* (`path.join(root, 'package.json')`). 최상위에는 아무것도 없으니 1등급을 못
|
|
20
|
+
* 찾고 하위 근거로 떨어졌고, `if __name__ == '__main__'` 이 있는 **JIRA 자동화
|
|
21
|
+
* 스크립트 8개**를 진입점으로 골랐다. 거기서 Java 208개와 React 149개로 가는
|
|
22
|
+
* 길은 당연히 없다. 그래서 14개만 닿았다.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 이건 군집화로 못 고친다. 531개를 아무리 잘 묶어도 출발점이 틀린 채로
|
|
25
|
+
* 묶이는 것뿐이다. **먼저 "여기 프로젝트가 몇 개인가" 를 답해야 한다.**
|
|
26
|
+
* 주니어가 모노레포 앞에서 하는 첫 질문이 정확히 그것이기도 하다.
|
|
27
|
+
*
|
|
28
|
+
* ── 무엇을 근거로 가르나 ────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* **매니페스트가 있는 디렉터리.** 저자가 "여기가 프로젝트다" 라고 이미 선언해
|
|
31
|
+
* 둔 것이므로 추측할 이유가 없다 (`flow.mjs` 가 1등급을 다루는 것과 같은 원칙 —
|
|
32
|
+
* 선언이 곧 저자의 답이다).
|
|
33
|
+
*
|
|
34
|
+
* 매니페스트가 없는 것도 있다. 위의 `BE_robot` 이 그렇다. 그래서 **소스가 충분히
|
|
35
|
+
* 많은 최상위 디렉터리**를 뒤에 덧붙인다. 다만 그건 추측이므로 `kind: 'dir'` 로
|
|
36
|
+
* 표시해서 화면이 근거를 구분해 보여줄 수 있게 한다.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fs from 'node:fs'
|
|
40
|
+
import path from 'node:path'
|
|
41
|
+
|
|
42
|
+
/** 이 파일이 있으면 그 디렉터리는 프로젝트다. 저자의 선언이다. */
|
|
43
|
+
export const MANIFESTS = [
|
|
44
|
+
['package.json', 'node'],
|
|
45
|
+
['pom.xml', 'java'],
|
|
46
|
+
['build.gradle', 'java'],
|
|
47
|
+
['build.gradle.kts', 'java'],
|
|
48
|
+
['pyproject.toml', 'python'],
|
|
49
|
+
['setup.py', 'python'],
|
|
50
|
+
['requirements.txt', 'python'],
|
|
51
|
+
['go.mod', 'go'],
|
|
52
|
+
['Cargo.toml', 'rust'],
|
|
53
|
+
['package.xml', 'ros'],
|
|
54
|
+
['CMakeLists.txt', 'cmake'],
|
|
55
|
+
['Gemfile', 'ruby'],
|
|
56
|
+
['composer.json', 'php'],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** 들어가지 않는 곳. 남의 코드이거나 산출물이다. */
|
|
60
|
+
const SKIP = new Set([
|
|
61
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor',
|
|
62
|
+
'.venv', 'venv', '__pycache__', '.next', '.nuxt', 'coverage', '.gradle',
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 매니페스트가 없어도 프로젝트로 쳐 주는 최소 소스 파일 수.
|
|
67
|
+
*
|
|
68
|
+
* 눈으로 고른 값이다. 너무 낮으면 `scripts/` 같은 잡동사니가 프로젝트가 되고,
|
|
69
|
+
* 너무 높으면 진짜 하위 프로젝트를 놓친다. 실측에서 `BE_robot`(128개)은 잡고
|
|
70
|
+
* `scripts`(1개) · `ref`(2개)는 안 잡는 자리를 골랐다.
|
|
71
|
+
*/
|
|
72
|
+
export const MIN_FILES = 12
|
|
73
|
+
|
|
74
|
+
/** 매니페스트를 찾아 내려간다. 깊이를 제한하는 이유는 아래 주석. */
|
|
75
|
+
function manifestDirs(repoRoot, maxDepth) {
|
|
76
|
+
const out = []
|
|
77
|
+
const walk = (rel, depth) => {
|
|
78
|
+
if (depth > maxDepth) return
|
|
79
|
+
let ents
|
|
80
|
+
try { ents = fs.readdirSync(path.join(repoRoot, rel), { withFileTypes: true }) } catch { return }
|
|
81
|
+
|
|
82
|
+
for (const [file, kind] of MANIFESTS) {
|
|
83
|
+
if (ents.some((e) => e.isFile() && e.name === file)) { out.push({ dir: rel, kind, manifest: file }); break }
|
|
84
|
+
}
|
|
85
|
+
for (const e of ents) {
|
|
86
|
+
if (!e.isDirectory() || SKIP.has(e.name) || e.name.startsWith('.')) continue
|
|
87
|
+
walk(rel ? `${rel}/${e.name}` : e.name, depth + 1)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
walk('', 0)
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const under = (id, dir) => (dir === '' ? true : id === dir || id.startsWith(`${dir}/`))
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 하위 프로젝트 목록.
|
|
98
|
+
*
|
|
99
|
+
* @param repoRoot 저장소 절대 경로 (매니페스트를 읽는 데 쓴다)
|
|
100
|
+
* @param nodes 그래프 노드. `id` 는 저장소 기준 상대 경로다
|
|
101
|
+
* @returns [{ dir, kind, manifest, files, langs }] — 파일 많은 순
|
|
102
|
+
*/
|
|
103
|
+
export function findRoots(repoRoot, nodes, { maxDepth = 4, minFiles = MIN_FILES } = {}) {
|
|
104
|
+
const code = (nodes ?? []).filter((n) => n.lang && n.lang !== 'data')
|
|
105
|
+
|
|
106
|
+
const roots = manifestDirs(repoRoot, maxDepth).map((d) => ({ ...d, files: 0 }))
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 매니페스트가 없는 덩어리. 추측이므로 `kind: 'dir'` 로 표시한다 —
|
|
110
|
+
* 화면이 "선언된 것" 과 "우리가 짐작한 것" 을 구분해 보여줄 수 있어야 한다
|
|
111
|
+
* (D5: 도구는 자기 확신 수준을 속이지 않는다).
|
|
112
|
+
*
|
|
113
|
+
* 🔴 최상위 한 칸으로 묶지 않는다.
|
|
114
|
+
*
|
|
115
|
+
* 처음에 그렇게 했더니 `BE_robot` 이 그 아래의 `BE_robot/ros2_ws/src/*`
|
|
116
|
+
* (각각 `setup.py` 를 가진 진짜 패키지들)와 **겹쳤다.** 같은 파일이 두 곳에
|
|
117
|
+
* 세어져서 "몇 개인가" 가 거짓말이 됐다. 두 칸까지 내려가면 실제로 남는 덩어리
|
|
118
|
+
* (`BE_robot/orin_dashboard`)를 집으면서 아래의 패키지들과 안 겹친다.
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* 🔴 저장소 루트 자체가 프로젝트면(루트에 매니페스트가 있으면) **덧붙일 것이
|
|
122
|
+
* 없다.** 모든 파일이 이미 그 프로젝트 소속이다.
|
|
123
|
+
*
|
|
124
|
+
* 처음에 루트(`''`)를 이 검사에서 빼놨다가 axMap 자신이 깨졌다 —
|
|
125
|
+
* `test/` · `app/lib/` · `tools/` 가 각각 "프로젝트" 로 올라왔고, 가장 큰 것이
|
|
126
|
+
* `test`(25개)라 거기서 진입점을 찾다가 못 찾고 화면이 통째로 비었다.
|
|
127
|
+
* 그것들은 프로젝트가 아니라 **한 프로젝트 안의 디렉터리**다.
|
|
128
|
+
*/
|
|
129
|
+
const covered = (id) => roots.some((r) => under(id, r.dir))
|
|
130
|
+
const loose = new Map()
|
|
131
|
+
for (const n of code) {
|
|
132
|
+
if (covered(n.id)) continue
|
|
133
|
+
const seg = n.id.split('/').slice(0, -1) // 파일 이름을 뺀 디렉터리
|
|
134
|
+
if (!seg.length) continue // 최상위에 흩어진 파일은 프로젝트가 아니다
|
|
135
|
+
const key = seg.slice(0, 2).join('/')
|
|
136
|
+
loose.set(key, (loose.get(key) ?? 0) + 1)
|
|
137
|
+
}
|
|
138
|
+
for (const [dir, n] of loose) {
|
|
139
|
+
if (n < minFiles) continue
|
|
140
|
+
roots.push({ dir, kind: 'dir', manifest: null, files: 0 })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 🔴 파일은 **자기를 담는 가장 안쪽 프로젝트**에 속한다.
|
|
145
|
+
*
|
|
146
|
+
* 처음에는 "매니페스트를 품은 조상은 뺀다" 로 했다. 그랬더니 axMap 자신이
|
|
147
|
+
* 깨졌다 — 저장소 루트에 `package.json` 이 있고 `desktop/package.json` 도
|
|
148
|
+
* 있는데, 루트가 조상이라는 이유로 통째로 빠져서 **본체 70여 개가 어느
|
|
149
|
+
* 프로젝트에도 안 속했다.** 화면에는 카드가 하나도 안 그려졌다.
|
|
150
|
+
*
|
|
151
|
+
* 조상이라고 지우는 것이 아니라 **안쪽에 넘겨준 만큼만 빼면** 된다.
|
|
152
|
+
* 그러면 겹치지도 않고 빠지지도 않는다 — 합계가 정확히 맞는다.
|
|
153
|
+
*/
|
|
154
|
+
const deepest = [...roots].sort((a, b) => b.dir.length - a.dir.length)
|
|
155
|
+
const mine = new Map(roots.map((r) => [r.dir, []]))
|
|
156
|
+
for (const n of code) {
|
|
157
|
+
const owner = deepest.find((r) => under(n.id, r.dir))
|
|
158
|
+
if (owner) mine.get(owner.dir).push(n)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 개수와 언어는 **같은 목록**으로 센다. 따로 세면 화면의 두 숫자가 어긋난다.
|
|
162
|
+
for (const r of roots) {
|
|
163
|
+
const list = mine.get(r.dir)
|
|
164
|
+
r.files = list.length
|
|
165
|
+
const tally = new Map()
|
|
166
|
+
for (const n of list) tally.set(n.lang, (tally.get(n.lang) ?? 0) + 1)
|
|
167
|
+
r.langs = [...tally].sort((a, b) => b[1] - a[1]).map(([lang, k]) => ({ lang, n: k }))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return roots.filter((r) => r.files > 0).sort((a, b) => b.files - a.files)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 프로젝트가 정말 여럿인가.
|
|
175
|
+
*
|
|
176
|
+
* 🔴 하나짜리 저장소에서 "프로젝트를 고르세요" 를 띄우면 걸음이 하나 늘 뿐이다.
|
|
177
|
+
* 둘 이상일 때만 물어본다. 그리고 **덮이지 않은 파일이 많으면** 그것도 알린다 —
|
|
178
|
+
* 조용히 빼놓으면 사용자는 그 코드가 없는 줄 안다.
|
|
179
|
+
*/
|
|
180
|
+
export function splitNeeded(roots, nodes) {
|
|
181
|
+
const code = (nodes ?? []).filter((n) => n.lang && n.lang !== 'data')
|
|
182
|
+
const inside = code.filter((n) => roots.some((r) => under(n.id, r.dir))).length
|
|
183
|
+
return { multi: roots.length > 1, covered: inside, total: code.length, outside: code.length - inside }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { under }
|