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,362 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 3인 페르소나 벤치마크 — 시각화가 이해 속도를 **실제로** 올리는가.
4
+ *
5
+ * node tools/persona-bench.mjs --repo <경로|git주소> [--port 7850]
6
+ * node tools/persona-bench.mjs --report 지금까지의 결과
7
+ *
8
+ * ── 왜 이걸 만드나 ────────────────────────────────────────────────────────
9
+ *
10
+ * 관찰 여섯 회차가 모두 "그래프를 꺼도 이 도구는 거의 그대로 쓸모 있었을
11
+ * 것" 이라고 했다. 그런데 그건 **인상**이지 측정이 아니다. 그리고 관찰자는
12
+ * 전부 AI 였다 — AI 는 텍스트를 잘 읽는다. 사람에게도 같은 말인지는 모른다.
13
+ *
14
+ * 그러니 재야 한다. 같은 저장소, 같은 질문지, **보는 것만 다른** 셋을 붙인다.
15
+ *
16
+ * A 시각화만 화면 스크린샷만 본다. API 도 소스도 못 본다.
17
+ * B 텍스트만 API 응답만 본다. 화면을 못 본다.
18
+ * C 둘 다 화면과 API 를 다 본다.
19
+ *
20
+ * 🔴 이 셋의 차이가 곧 시각화의 값이다.
21
+ *
22
+ * A ≥ B 이면 시각화가 혼자서도 일한다.
23
+ * A < B 인데 C > B 이면 시각화는 보조로만 값이 있다.
24
+ * C ≈ B 이면 **시각화가 보태는 것이 없다.** 그때는 형식을 바꾼다 —
25
+ * 힘-지향 그래프가 아니어도 된다. 2차원 트리든 mermaid 든.
26
+ *
27
+ * ── 무엇을 재나 ──────────────────────────────────────────────────────────
28
+ *
29
+ * 호출 수 같은 답에 도달하는 데 든 도구 호출. 적을수록 빠르다.
30
+ * 정답 docs/BENCH.md 의 질문지. 채점은 사람이 아니라 **검증 가능한 것**만.
31
+ * 막힌 곳 답을 못 낸 질문. 이게 다음에 고칠 자리다.
32
+ *
33
+ * ⚠️ 이 하네스는 **에이전트를 띄우지 않는다.** 페르소나별 프롬프트와 접근
34
+ * 제한을 만들어 파일로 내놓고, 결과를 받아 표로 만든다. 에이전트를 띄우는
35
+ * 것은 부르는 쪽(사람 또는 상위 세션)이 한다 — 그래야 어떤 모델로 돌렸는지가
36
+ * 기록에 남고, 하네스가 모델에 묶이지 않는다.
37
+ */
38
+
39
+ import { execFileSync, spawn } from 'node:child_process'
40
+ import fs from 'node:fs'
41
+ import path from 'node:path'
42
+ import { fileURLToPath } from 'node:url'
43
+
44
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
45
+ const OUT = path.join(ROOT, 'docs', 'bench-runs')
46
+
47
+ const args = process.argv.slice(2)
48
+ const flag = (n, d = null) => { const i = args.indexOf(n); return i < 0 ? d : args[i + 1] }
49
+
50
+ /**
51
+ * 페르소나.
52
+ *
53
+ * 🔴 제한이 곧 실험 조건이다. "보지 마라" 로는 안 된다 — 에이전트는 본다.
54
+ * 그래서 **줄 것만 준다.** A 에게는 스크린샷 파일만, B 에게는 API 주소만.
55
+ */
56
+ export const PERSONAS = {
57
+ viz: {
58
+ key: 'viz',
59
+ label: 'A · 시각화만',
60
+ sees: '화면 스크린샷 (PNG)',
61
+ forbids: ['API 응답', '저장소 소스', '파일 목록'],
62
+ why: '시각화가 혼자서 얼마나 말하는가',
63
+ },
64
+ text: {
65
+ key: 'text',
66
+ label: 'B · 텍스트만',
67
+ sees: 'API 응답 (JSON)',
68
+ forbids: ['화면 스크린샷'],
69
+ why: '텍스트만으로 어디까지 되는가 — 이것이 기준선이다',
70
+ },
71
+ both: {
72
+ key: 'both',
73
+ label: 'C · 둘 다',
74
+ sees: '화면 스크린샷 + API 응답',
75
+ forbids: [],
76
+ why: '시각화가 텍스트 위에 무엇을 보태는가',
77
+ },
78
+ }
79
+
80
+ /** 질문지. docs/BENCH.md 가 규격이고 여기는 그 기계 표현이다. */
81
+ export const QUESTIONS = [
82
+ { id: 'M1', q: '이 저장소는 무엇을 하는 물건인가', check: 'README·매니페스트의 설명과 맞는가' },
83
+ { id: 'M2', q: '실행은 어디서 시작하나 — 파일 하나를 짚어라', check: '매니페스트가 선언한 진입점과 맞는가' },
84
+ { id: 'M3', q: '이 저장소의 큰 덩어리 셋을 이름으로 대라', check: '모듈 경계와 맞는가' },
85
+ { id: 'M4', q: '<기능 X> 를 고치려면 어느 파일을 열어야 하나', check: '그 기능의 파일 집합에 들어 있는가' },
86
+ { id: 'M5', q: '건드리면 파급이 큰 파일 하나와 그 근거', check: '근거가 화면·API 에 실제로 있는 수인가' },
87
+ { id: 'M6', q: '지금 다른 사람이 잡고 있는 곳이 있나 — 있으면 누가 무엇을', check: '장부와 맞는가' },
88
+ { id: 'M7', q: '내가 지금 <파일 Y> 를 고치면 누구와 부딪히나', check: '경고와 맞는가' },
89
+ ]
90
+
91
+ const now = () => new Date().toISOString()
92
+ const stamp = () => now().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
93
+
94
+ /** 화면 스크린샷을 찍는다 — A·C 페르소나가 볼 것. */
95
+ function shoot(url, file, { width = 1440, height = 900, budget = 30000 } = {}) {
96
+ const chrome = [
97
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
98
+ '/usr/bin/google-chrome', '/usr/bin/chromium',
99
+ ].find((p) => { try { return fs.existsSync(p) } catch { return false } })
100
+ if (!chrome) throw new Error('크롬을 못 찾았습니다 — 시각화 페르소나를 돌릴 수 없습니다')
101
+ execFileSync(chrome, [
102
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
103
+ `--window-size=${width},${height}`, `--virtual-time-budget=${budget}`,
104
+ `--screenshot=${file}`, url,
105
+ ], { stdio: 'ignore', timeout: budget + 30000, windowsHide: true })
106
+ if (!fs.existsSync(file)) throw new Error(`스크린샷이 안 나왔습니다: ${url}`)
107
+ return file
108
+ }
109
+
110
+ /**
111
+ * 한 판을 준비한다.
112
+ *
113
+ * 서버를 띄우고, 시각화 페르소나가 볼 스크린샷을 미리 찍어두고,
114
+ * 페르소나별 지시문을 파일로 낸다.
115
+ */
116
+ /**
117
+ * 🔴 성공한 저장소는 두 번 쓰지 않는다.
118
+ *
119
+ * 도구가 그 저장소를 이해시키는 데 성공했다면, 같은 것으로 또 재는 것은
120
+ * 아무것도 재지 않는 것이다 — 우리가 이미 그 저장소에 맞춰 고쳤기 때문이다.
121
+ * 과적합은 조용히 온다. 실제로 겪었다: 파이썬에서 합격한 뒤 Go 로 옮기자마자
122
+ * 과적합 둘이 드러났다 (밤샘 로그).
123
+ *
124
+ * 기록은 온보딩 평가와 **같은 파일**을 쓴다. 두 목록을 두면 한쪽에서 본 것을
125
+ * 다른 쪽이 또 뽑는다.
126
+ */
127
+ const SEEN = path.join(ROOT, 'docs', 'evaluated-repos.txt')
128
+
129
+ function alreadySeen(name) {
130
+ try {
131
+ return fs.readFileSync(SEEN, 'utf8').split('\n')
132
+ .map((l) => l.split('#')[0].trim()).filter(Boolean)
133
+ .some((l) => l === name || l.endsWith(`/${name}`) || name.endsWith(`/${l}`))
134
+ } catch { return false }
135
+ }
136
+
137
+ function markSeen(name, note) {
138
+ try { fs.appendFileSync(SEEN, `${name} # 페르소나 벤치 · ${note}\n`) } catch { /* 못 적어도 계속 */ }
139
+ }
140
+
141
+ /** 새 저장소를 뽑는다 — 추첨기가 이미 본 것을 빼고 고른다. */
142
+ function drawRepo() {
143
+ console.log('새 저장소를 뽑습니다 (본 적 없는 인기 저장소)...')
144
+ const out = execFileSync(process.execPath, [path.join(ROOT, 'tools', 'pick-repo.mjs'), '--no-serve'], {
145
+ encoding: 'utf8', timeout: 900_000, windowsHide: true,
146
+ })
147
+ console.log(out.split('\n').filter((l) => l.trim() && !/Updating files/.test(l)).slice(-5).join('\n'))
148
+ // 추첨기가 찍는 두 형태 중 하나에서 경로를 집는다. 형태가 바뀌면 여기서 던진다 —
149
+ // 조용히 빈 경로로 진행하면 "저장소가 비었다" 는 틀린 결론이 벤치에 들어간다.
150
+ const m = out.match(/클론 중\.\.\.\s*(.+?)\s*$/m) ?? out.match(/serve-public\.mjs\s+"([^"]+)"/)
151
+ if (!m) throw new Error('추첨기가 클론 경로를 안 알려줬습니다 (출력 형식이 바뀐 듯)')
152
+ return m[1].trim()
153
+ }
154
+
155
+ async function prepare({ repo, port, drawn = false }) {
156
+ const name = path.basename(repo)
157
+ // 🔴 방금 뽑은 것은 검사하지 않는다.
158
+ //
159
+ // 추첨기(pick-repo)가 뽑는 순간 같은 목록에 기록한다. 그래서 --draw 로 막
160
+ // 받아온 저장소를 그대로 검사하면 **자기가 방금 적은 줄에 자기가 걸린다.**
161
+ // 목록 하나를 두 도구가 쓰기로 한 대가다 — 그 편이 "두 곳에서 본 것을
162
+ // 또 뽑는" 것보다 낫다.
163
+ if (!drawn && alreadySeen(name) && !args.includes('--again')) {
164
+ throw new Error(`이미 평가한 저장소입니다: ${name}\n`
165
+ + ' 성공한 저장소로 또 재면 과적합이 안 보입니다. 새로 뽑으세요:\n'
166
+ + ' node tools/persona-bench.mjs --draw\n'
167
+ + ' 그래도 다시 재려면 --again')
168
+ }
169
+ const runId = `${stamp()}-${path.basename(repo).replace(/[^\w.-]/g, '_')}`
170
+ const dir = path.join(OUT, runId)
171
+ fs.mkdirSync(path.join(dir, 'shots'), { recursive: true })
172
+
173
+ console.log(`판 ${runId}`)
174
+ console.log(` 대상 ${repo}`)
175
+
176
+ const server = spawn(process.execPath, [path.join(ROOT, 'app', 'server.mjs'), repo, String(port), '--readonly'], {
177
+ cwd: ROOT, detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
178
+ })
179
+ let log = ''
180
+ server.stdout.on('data', (d) => { log += d })
181
+ server.stderr.on('data', (d) => { log += d })
182
+
183
+ const base = `http://127.0.0.1:${port}`
184
+ const up = await (async () => {
185
+ for (let i = 0; i < 90; i++) {
186
+ try { await fetch(base, { signal: AbortSignal.timeout(1500) }); return true } catch { /* 아직 */ }
187
+ await new Promise((r) => setTimeout(r, 500))
188
+ }
189
+ return false
190
+ })()
191
+ if (!up) { server.kill(); throw new Error(`서버가 안 떴습니다:\n${log.slice(-600)}`) }
192
+ console.log(` 서버 ${base}`)
193
+
194
+ /**
195
+ * 🔴 시각화 페르소나가 볼 화면은 **흐름의 각 걸음**이다.
196
+ *
197
+ * 첫 화면 하나만 주면 "시각화가 못 한다" 가 아니라 "우리가 안 보여줬다" 가
198
+ * 된다. 걸음마다 찍어서 사람이 실제로 눌러 볼 만한 것을 다 준다.
199
+ */
200
+ const views = [
201
+ { name: '01-첫화면', url: `${base}/` },
202
+ { name: '02-흐름1-무엇을하는물건', url: `${base}/?step=1` },
203
+ { name: '03-흐름2-진입점', url: `${base}/?step=2` },
204
+ { name: '04-흐름3-계층', url: `${base}/?step=3` },
205
+ { name: '05-흐름4-활발하고위험한곳', url: `${base}/?step=4` },
206
+ { name: '06-기능축', url: `${base}/?unit=feature` },
207
+ { name: '07-감시-누가무엇을', url: `${base}/?watch=1` },
208
+
209
+ /**
210
+ * 🔴 그림만 — 왼쪽 패널을 잘라낸 것. A′ 페르소나가 볼 것.
211
+ *
212
+ * 1회차의 설계 결함을 고치는 자리다. "시각화만 보는 신입" 에게 준
213
+ * 스크린샷에 **사이드바의 문장이 통째로 들어 있었다.** 그래서 그 신입은
214
+ * 픽셀에서 글자를 읽었고, 우리가 잰 것은 "그림 대 글" 이 아니라
215
+ * "화면 대 API" 였다.
216
+ *
217
+ * `view=graph` 를 함께 준다 — 안 주면 오른쪽이 사다리 뷰(텍스트)라
218
+ * 패널만 없앤 또 다른 글 화면이 된다. 실제로 한 번 그렇게 찍혔다.
219
+ */
220
+ { name: 'g1-그림만-계층', url: `${base}/?panel=off&view=graph&step=3`, viz: true },
221
+ { name: 'g2-그림만-기능축', url: `${base}/?panel=off&view=graph&unit=feature`, viz: true },
222
+ { name: 'g3-그림만-감시', url: `${base}/?panel=off&view=graph&watch=1`, viz: true },
223
+ ]
224
+ const shots = []
225
+ for (const v of views) {
226
+ const f = path.join(dir, 'shots', `${v.name}.png`)
227
+ try { shoot(v.url, f); shots.push({ ...v, file: path.relative(dir, f) }); process.stdout.write('.') } catch (e) {
228
+ shots.push({ ...v, error: e.message.slice(0, 120) }); process.stdout.write('x')
229
+ }
230
+ }
231
+ console.log(`\n 스크린샷 ${shots.filter((s) => !s.error).length}/${views.length}`)
232
+
233
+ for (const p of Object.values(PERSONAS)) {
234
+ fs.writeFileSync(path.join(dir, `persona-${p.key}.md`), personaPrompt(p, { base, shots, repo }))
235
+ }
236
+ fs.writeFileSync(path.join(dir, 'run.json'), JSON.stringify({
237
+ runId, repo, port, at: now(), views: shots, personas: Object.keys(PERSONAS),
238
+ }, null, 1))
239
+
240
+ markSeen(name, runId)
241
+ console.log(`\n 지시문 ${dir}`)
242
+ console.log(` 서버는 계속 돕니다 (PID ${server.pid}). 끝나면: taskkill /PID ${server.pid} /F`)
243
+ server.unref()
244
+ return dir
245
+ }
246
+
247
+ /**
248
+ * 페르소나에게 줄 프롬프트를 만든다.
249
+ *
250
+ * 🔴 내보내는 이유는 재사용이 아니라 **검사**다. 이 프롬프트는 실제로 판을 열어야만
251
+ * 찍히고, 판을 열려면 크롬과 저장소가 필요하다. 그래서 여기가 깨져도 아무 테스트도
252
+ * 빨개지지 않는다 — app/web 이 죽어도 npm test 가 초록이던 것과 같은 사각지대다.
253
+ * 내보내 두면 렌더링해서 눈으로 볼 수 있다.
254
+ */
255
+ export function personaPrompt(p, { base, shots, repo }) {
256
+ const shotList = shots.filter((s) => !s.error)
257
+ .map((s) => ` · ${s.name} → shots/${path.basename(s.file)}`).join('\n')
258
+ const seeVis = p.key !== 'text'
259
+ const seeApi = p.key !== 'viz'
260
+
261
+ return `# ${p.label}
262
+
263
+ 너는 이 저장소를 **처음 본다.** 아무 사전 지식도 없다.
264
+ 아래 질문에 답하고, 답할 때마다 **무엇을 보고 그렇게 판단했는지** 적어라.
265
+
266
+ ## 🔴 볼 수 있는 것 — 이것만 본다
267
+
268
+ ${seeVis ? `**화면 스크린샷** (Read 도구로 PNG 를 읽어라)\n${shotList}\n` : ''}
269
+ ${seeApi ? `**API** (curl 또는 fetch)\n ${base}/api/flow · /api/overlay · /api/entry · /api/featuregraph · /api/watch · /api/graph\n` : ''}
270
+ ## 🔴 보면 안 되는 것
271
+
272
+ ${p.forbids.length ? p.forbids.map((f) => ` · ${f}`).join('\n') : ' (제한 없음)'}
273
+ · 저장소 소스 코드를 직접 열지 마라 (${repo})
274
+ · axMap 자신의 코드를 읽지 마라 — 도구를 쓰는 사람이지 만든 사람이 아니다
275
+
276
+ 이 제한이 곧 실험 조건이다. 어기면 이 판이 무의미해진다.
277
+ **볼 수 없는 것 때문에 답을 못 하겠으면 "못 함" 이라고 적어라.** 그게 데이터다.
278
+
279
+ ## 🔴 낱말 기록 — 답과 나란히, 답을 바꾸지 않고
280
+
281
+ 질문에는 **평소대로 답해라.** 이 기록은 답에 영향을 주지 않는다. 따로 적는 것이다.
282
+
283
+ 화면과 API 에 나오는 낱말 중 **화면이 그 뜻을 알려주지 않은 것**을 전부 적어라.
284
+
285
+ · 낱말 · 어디서 봤나 · 화면이 준 설명(없으면 "없음") · 그 뜻을 몰라도 답할 수 있었나
286
+
287
+ 🔴 **네가 이미 뜻을 아는 낱말도 적는다.** 재는 것은 네 지식이 아니라 **화면이 자기
288
+ 낱말을 스스로 가르치는가**이다. "당연히 아는 말이라 뺐다" 는 판단이 정확히 우리가
289
+ 재려는 그것이므로, 그 판단을 하지 마라.
290
+
291
+ 이 도구를 쓸 사람 중에는 프로그래밍을 배운 적 없는 사람이 있다. 그 사람에게는
292
+ 네가 당연하다고 여긴 낱말이 벽이다. 네가 그 벽을 대신 볼 수는 없지만, **벽이
293
+ 있었을 자리**는 적어줄 수 있다.
294
+
295
+ ## 질문
296
+
297
+ ${QUESTIONS.map((q) => `### ${q.id}. ${q.q}\n\n- 답:\n- 무엇을 보고:\n- 도구 호출 수:\n`).join('\n')}
298
+
299
+ ## 마지막에 적을 것
300
+
301
+ 1. **호출 수 합계**
302
+ 2. **못 한 질문**과 그 이유
303
+ 3. 🔴 **막혔던 자리** — 무엇을 보고 싶었는데 없었나. 이게 우리가 고칠 자리다.
304
+ 4. ${seeVis ? '화면이 도움이 된 순간과 방해가 된 순간을 각각 하나씩' : '화면이 있었다면 어디서 도움이 됐을 것 같나'}
305
+ 5. 🔴 **낱말 기록** — 위 규칙대로 표로. 화면의 말을 고칠 유일한 근거다.
306
+ 하나도 없으면 "없음" 이라고 적어라. 빈 것과 안 적은 것은 다른 말이다.
307
+
308
+ 답은 \`answers-${p.key}.md\` 로 저장해라.
309
+ `
310
+ }
311
+
312
+ function report() {
313
+ let runs = []
314
+ try { runs = fs.readdirSync(OUT).filter((d) => fs.existsSync(path.join(OUT, d, 'run.json'))) } catch { /* 없음 */ }
315
+ if (!runs.length) return console.log('아직 판이 없습니다.')
316
+ console.log(`판 ${runs.length}개\n`)
317
+ for (const r of runs.sort()) {
318
+ const meta = JSON.parse(fs.readFileSync(path.join(OUT, r, 'run.json'), 'utf8'))
319
+ const answers = Object.keys(PERSONAS).map((k) => {
320
+ const f = path.join(OUT, r, `answers-${k}.md`)
321
+ if (!fs.existsSync(f)) return { k, done: false }
322
+ const t = fs.readFileSync(f, 'utf8')
323
+ // 🔴 채점을 지어내지 않는다. 파일에 적힌 것만 읽는다.
324
+ const calls = [...t.matchAll(/도구\s*호출\s*수\s*[::]\s*(\d+)/g)].map((m) => Number(m[1]))
325
+ const cant = (t.match(/못\s*함/g) ?? []).length
326
+ return { k, done: true, calls: calls.reduce((a, b) => a + b, 0), answered: calls.length, cant }
327
+ })
328
+ console.log(`── ${r} ${path.basename(meta.repo)}`)
329
+ for (const a of answers) {
330
+ const p = PERSONAS[a.k]
331
+ console.log(a.done
332
+ ? ` ${p.label.padEnd(14)} 호출 ${String(a.calls).padStart(3)} · 답한 질문 ${a.answered}/${QUESTIONS.length} · "못 함" ${a.cant}`
333
+ : ` ${p.label.padEnd(14)} (아직 안 돌림)`)
334
+ }
335
+ console.log('')
336
+ }
337
+ console.log(`🔴 읽는 법
338
+ A(시각화만) ≥ B(텍스트만) → 시각화가 혼자서도 일한다
339
+ A < B 인데 C > B → 시각화는 보조로만 값이 있다
340
+ C ≈ B → 시각화가 보태는 것이 없다. 형식을 바꿔라.`)
341
+ }
342
+
343
+ if (args.includes('--report')) report()
344
+ else {
345
+ let repo = flag('--repo')
346
+ let drawn = false
347
+ if (!repo && args.includes('--draw')) {
348
+ try { repo = drawRepo(); drawn = true } catch (e) { console.error(e.message); process.exit(1) }
349
+ }
350
+ if (!repo) {
351
+ console.error(`사용법:
352
+ node tools/persona-bench.mjs --draw 새 저장소를 뽑아서 준비 (권장)
353
+ node tools/persona-bench.mjs --repo <경로> [--again]
354
+ node tools/persona-bench.mjs --report
355
+
356
+ 🔴 성공한 저장소는 두 번 쓰지 않습니다. 같은 것으로 또 재면 아무것도 재지 않는 것입니다.`)
357
+ process.exit(1)
358
+ }
359
+ prepare({ repo, port: Number(flag('--port') ?? 7850), drawn })
360
+ .then((d) => console.log(`\n준비 끝. 페르소나 셋에게 각각 persona-*.md 를 주고 돌려라.\n ${d}`))
361
+ .catch((e) => { console.error(e.message); process.exit(1) })
362
+ }
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 온보딩 평가용 저장소 추첨기.
4
+ *
5
+ * 🔴 **우리 저장소로 평가하면 안 된다. 같은 저장소를 두 번 써도 안 된다.**
6
+ *
7
+ * 이 도구의 목표는 "처음 보는 코드베이스를 빨리 이해하게 하는 것" 이다.
8
+ * 그런데 그 목표를 우리가 이미 아는 코드로 재면 아무것도 못 잰다.
9
+ * 실제로 한동안 axMap 자신과 immich 만 반복해서 봤는데, 그건
10
+ * 없애려던 편향을 다른 형태로 되살린 것이었다.
11
+ *
12
+ * 그래서 매번 **본 적 없는 인기 저장소**를 뽑는다.
13
+ * 뽑은 이력은 파일로 남겨 다시는 같은 것이 나오지 않게 한다.
14
+ *
15
+ * 외부 의존성 0 — Node 의 fetch 와 git 만 쓴다.
16
+ *
17
+ * node tools/pick-repo.mjs 한 개 뽑아서 클론
18
+ * node tools/pick-repo.mjs --lang python 언어 지정
19
+ * node tools/pick-repo.mjs --min-commits 300 히스토리 하한 (기본 400)
20
+ * node tools/pick-repo.mjs --list 지금까지 본 것
21
+ * node tools/pick-repo.mjs --dry 뽑기만 하고 클론 안 함
22
+ */
23
+
24
+ import { execFileSync, spawnSync } from 'node:child_process'
25
+ import fs from 'node:fs'
26
+ import path from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
30
+ const SEEN = path.join(HERE, '..', 'docs', 'evaluated-repos.txt')
31
+
32
+ /**
33
+ * 파서가 실제로 읽는 언어만 고른다.
34
+ * 읽지 못하는 언어를 뽑으면 "도구가 못 읽는다" 만 반복 확인하게 된다 —
35
+ * 그것도 사실이지만 매번 확인할 필요는 없다.
36
+ */
37
+ const LANGS = ['typescript', 'python', 'javascript', 'go', 'java']
38
+
39
+ /**
40
+ * 별 구간을 나눠 뽑는다.
41
+ *
42
+ * 상위만 보면 매번 리액트·리눅스 같은 초대형 저장소가 나온다. 그런 저장소는
43
+ * 클론만 몇 십 분이고, 대부분의 사람이 실제로 온보딩하는 규모와도 다르다.
44
+ */
45
+ const STAR_BANDS = ['1000..2000', '2000..5000', '5000..10000', '10000..20000', '20000..50000']
46
+
47
+ /**
48
+ * 히스토리 하한.
49
+ *
50
+ * 🔴 별 수는 커밋 수를 뜻하지 않는다.
51
+ *
52
+ * 첫 추첨에서 별 1,959개짜리가 뽑혔는데 커밋이 **37개**였다. 인기 있는
53
+ * 단일 목적 라이브러리는 흔히 그렇다. 그런 저장소로는 공변경이 아예 안 나오고,
54
+ * 평가가 "히스토리가 없어서 못 했다" 만 반복 확인하게 된다.
55
+ *
56
+ * 커밋 수는 GitHub 검색 API 가 안 주므로 클론해야 알 수 있다.
57
+ * 뽑고 → 클론하고 → 미달이면 버리고 다시 뽑는다.
58
+ * **버린 것도 기록한다** — 다음에 또 뽑아서 또 클론하지 않기 위해서다.
59
+ */
60
+ const MIN_COMMITS = 400
61
+ const MAX_TRIES = 6
62
+
63
+ const args = process.argv.slice(2)
64
+ const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? (args[i + 1] ?? true) : null }
65
+
66
+ function seen() {
67
+ try {
68
+ return new Set(
69
+ fs.readFileSync(SEEN, 'utf8').split('\n')
70
+ .map((l) => l.split('#')[0].trim().toLowerCase())
71
+ .filter(Boolean),
72
+ )
73
+ } catch {
74
+ return new Set()
75
+ }
76
+ }
77
+
78
+ function remember(full, note) {
79
+ fs.appendFileSync(SEEN, `${full} # ${note}\n`)
80
+ }
81
+
82
+ /**
83
+ * 결정론적이지 않아도 되는 유일한 자리다 — 추첨은 무작위여야 한다.
84
+ * 다만 **무엇이 뽑혔는지는 반드시 기록**해서 나중에 재현할 수 있게 한다.
85
+ */
86
+ const pickOne = (arr) => arr[Math.floor(Math.random() * arr.length)]
87
+
88
+ async function search(lang, band, page) {
89
+ const q = `stars:${band} language:${lang} archived:false`
90
+ const url = 'https://api.github.com/search/repositories'
91
+ + `?q=${encodeURIComponent(q)}&sort=stars&order=desc&per_page=100&page=${page}`
92
+ const r = await fetch(url, {
93
+ headers: {
94
+ accept: 'application/vnd.github+json',
95
+ // 토큰이 있으면 쓴다. 없어도 시간당 10회는 되므로 추첨에는 충분하다.
96
+ ...(process.env.GITHUB_TOKEN ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
97
+ 'user-agent': 'axmap-onboarding-eval',
98
+ },
99
+ })
100
+ if (!r.ok) throw new Error(`GitHub API ${r.status} ${r.statusText}`)
101
+ return (await r.json()).items ?? []
102
+ }
103
+
104
+ /** @returns {{done:true}|{retry:true, why:string}} */
105
+ async function attempt(minCommits) {
106
+ const already = seen() // 매 회차 다시 읽는다 — 직전 시도가 기록을 남겼다
107
+ const lang = flag('--lang') ?? pickOne(LANGS)
108
+ const band = flag('--stars') ?? pickOne(STAR_BANDS)
109
+
110
+ let items = []
111
+ for (const page of [1, 2, 3]) {
112
+ try {
113
+ items = items.concat(await search(lang, band, page))
114
+ } catch (e) {
115
+ if (items.length) break
116
+ console.error(`GitHub 검색 실패: ${e.message}`)
117
+ console.error('토큰을 주면 한도가 올라갑니다: GITHUB_TOKEN=<토큰>')
118
+ process.exit(1)
119
+ }
120
+ }
121
+
122
+ const fresh = items.filter((x) => !already.has(x.full_name.toLowerCase()))
123
+ if (!fresh.length) return { retry: true, why: `${lang} / ${band} 에 새 저장소가 없습니다` }
124
+
125
+ const pick = pickOne(fresh)
126
+ const dir = path.join(process.env.TEMP ?? '/tmp', 'axmap-eval', pick.name.replace(/[^\w.-]/g, '_'))
127
+
128
+ console.log(`추첨 ${pick.full_name}`)
129
+ console.log(` 별 ${pick.stargazers_count.toLocaleString()} · ${pick.language} · ${(pick.size / 1024).toFixed(0)}MB`)
130
+ console.log(` ${pick.description ?? '(설명 없음)'}`)
131
+ console.log(` ${pick.html_url}`)
132
+ console.log(` (${lang} / ${band} 후보 ${fresh.length}개 중 추첨 · 이미 본 것 ${already.size}개 제외)`)
133
+
134
+ if (args.includes('--dry')) return { done: true }
135
+
136
+ fs.mkdirSync(path.dirname(dir), { recursive: true })
137
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true })
138
+
139
+ console.log(`\n클론 중... ${dir}`)
140
+ /**
141
+ * 🔴 **통째로 받는다.** 전에는 `--filter=blob:none` (blobless) 였다.
142
+ *
143
+ * 공변경에는 커밋과 트리만 있으면 되므로 blobless 가 이론적으로는 맞다.
144
+ * 그런데 벤치 2회차에서 그것이 화면을 거짓말하게 만들었다 —
145
+ *
146
+ * 평가 클론의 장부를 남의 GitHub 에 push 하지 않으려고 `origin` 을
147
+ * 로컬 베어 저장소로 바꿔 달았는데, 그때 `extensions.partialclone` 설정이
148
+ * 통째로 지워졌다. blobless 인데 blobless 가 아니라고 적힌 저장소가 됐다.
149
+ * 그 상태에서 rename 탐지가 켜지면 없는 blob 을 읽으려다 git 이 죽는다.
150
+ *
151
+ * readCommitSets 가 `fatal: unable to read <sha>` 로 죽고 null 을 조용히
152
+ * 돌려줬고, 화면은 그것을 **"커밋 0개, 히스토리 부족"** 이라고 번역했다.
153
+ * 히스토리는 있었다. 못 읽은 것을 없는 것으로 말한 것이다.
154
+ *
155
+ * 느리지만 이 함정이 사라진다. 벤치는 하루에 한 번 도는 것이고,
156
+ * 화면이 거짓말하면 그 판 전체가 무의미해진다.
157
+ */
158
+ execFileSync('git', ['clone', '--single-branch', pick.clone_url, dir], { stdio: 'inherit' })
159
+
160
+ const n = Number(
161
+ execFileSync('git', ['-C', dir, 'rev-list', '--count', '--no-merges', 'HEAD'], { encoding: 'utf8' }).trim(),
162
+ )
163
+ const stamp = new Date().toISOString().slice(0, 10)
164
+
165
+ if (n < minCommits) {
166
+ remember(pick.full_name, `건너뜀 — 커밋 ${n}개 (하한 ${minCommits}) · ${stamp}`)
167
+ fs.rmSync(dir, { recursive: true, force: true })
168
+ return { retry: true, why: `커밋이 ${n}개뿐이라 건너뜁니다 (하한 ${minCommits})` }
169
+ }
170
+
171
+ remember(pick.full_name, `${pick.language} · 별 ${pick.stargazers_count} · 커밋 ${n} · ${stamp}`)
172
+ console.log(`\n커밋 ${n.toLocaleString()}개`)
173
+ return { done: true, dir }
174
+ }
175
+
176
+ /**
177
+ * 뽑은 저장소를 공개 주소에 올린다.
178
+ *
179
+ * 🔴 추첨과 게시를 붙여둔다.
180
+ *
181
+ * 떼어놓으면 두 가지가 어긋난다 — 새 저장소를 보고 있는데 **밖에서는 지난
182
+ * 저장소가 계속 보이고**, 손으로 띄우다 인증을 빠뜨린다.
183
+ * 평가의 목적은 남이 보고 판단하는 것이라, 밖이 최신이 아니면 평가가 아니다.
184
+ *
185
+ * 자격증명은 여기서 만들지 않는다. 없으면 serve-public.mjs 가 거부한다 —
186
+ * 기본 비밀번호를 심어두면 그건 비밀번호가 아니다.
187
+ */
188
+ function serve(dir) {
189
+ if (args.includes('--no-serve')) {
190
+ console.log(`
191
+ 다음:
192
+ node tools/serve-public.mjs "${dir}" --auth 아이디:비밀번호`)
193
+ return
194
+ }
195
+ const pass = []
196
+ const i = args.indexOf('--auth')
197
+ if (i >= 0 && args[i + 1]) pass.push('--auth', args[i + 1])
198
+ const r = spawnSync(process.execPath, [path.join(HERE, 'serve-public.mjs'), dir, ...pass], {
199
+ stdio: 'inherit', windowsHide: true,
200
+ })
201
+ if (r.status !== 0) {
202
+ console.log(`
203
+ 게시는 못 했지만 클론은 끝났습니다:
204
+ node tools/serve-public.mjs "${dir}" --auth 아이디:비밀번호`)
205
+ }
206
+ }
207
+
208
+ async function main() {
209
+ if (args.includes('--list')) {
210
+ const s = [...seen()]
211
+ console.log(s.length ? s.join('\n') : '(아직 없음)')
212
+ console.log(`\n총 ${s.length}개`)
213
+ return
214
+ }
215
+
216
+ const minCommits = Number(flag('--min-commits') ?? MIN_COMMITS)
217
+ for (let i = 1; i <= MAX_TRIES; i++) {
218
+ const r = await attempt(minCommits)
219
+ if (r.done) return serve(r.dir)
220
+ console.log(` ↻ ${r.why} — 다시 뽑습니다 (${i}/${MAX_TRIES})\n`)
221
+ }
222
+ console.error(`${MAX_TRIES}회 시도했지만 조건에 맞는 저장소를 못 찾았습니다.`)
223
+ process.exit(1)
224
+ }
225
+
226
+ main().catch((e) => {
227
+ console.error(e.message)
228
+ process.exit(1)
229
+ })