axmap-cli 1.3.2 → 1.4.0

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 (67) hide show
  1. package/CLAUDE.md +15 -9
  2. package/README.ko.md +89 -85
  3. package/README.md +90 -86
  4. package/bin/axmap.mjs +9 -3
  5. package/mcp/README.md +8 -8
  6. package/mcp/install.ps1 +6 -3
  7. package/mcp/install.sh +5 -3
  8. package/mcp/server.mjs +81 -32
  9. package/package.json +7 -11
  10. package/src/agentcli.mjs +19 -0
  11. package/src/bridge.mjs +245 -0
  12. package/src/invariants.mjs +1 -1
  13. package/src/managed-session.mjs +178 -0
  14. package/src/protocol.mjs +1 -1
  15. package/src/session-dialogue.mjs +131 -0
  16. package/src/session-usage.mjs +34 -0
  17. package/src/state.mjs +8 -0
  18. package/src/terminal-bridge.mjs +224 -0
  19. package/src/update.mjs +1 -1
  20. package/tools/bridge.mjs +85 -0
  21. package/tools/mcp-register.mjs +22 -3
  22. package/tools/run-tests.mjs +10 -3
  23. package/tools/run.mjs +60 -0
  24. package/tools/setup.mjs +11 -0
  25. package/app/README.md +0 -366
  26. package/app/eval/edges.mjs +0 -242
  27. package/app/lib/adjacent.mjs +0 -125
  28. package/app/lib/agentcli.mjs +0 -153
  29. package/app/lib/analyze.mjs +0 -1159
  30. package/app/lib/cochange.mjs +0 -421
  31. package/app/lib/datanodes.mjs +0 -127
  32. package/app/lib/entry.mjs +0 -192
  33. package/app/lib/featuregraph.mjs +0 -389
  34. package/app/lib/features.mjs +0 -645
  35. package/app/lib/fetchrepo-run.mjs +0 -37
  36. package/app/lib/fetchrepo.mjs +0 -164
  37. package/app/lib/flow.mjs +0 -1089
  38. package/app/lib/ladder.mjs +0 -387
  39. package/app/lib/langs.mjs +0 -630
  40. package/app/lib/live.mjs +0 -346
  41. package/app/lib/llm.mjs +0 -594
  42. package/app/lib/newfile.mjs +0 -126
  43. package/app/lib/prdiff.mjs +0 -651
  44. package/app/lib/reveal.mjs +0 -316
  45. package/app/lib/roots.mjs +0 -186
  46. package/app/lib/scope.mjs +0 -342
  47. package/app/lib/session.mjs +0 -389
  48. package/app/lib/slots.mjs +0 -233
  49. package/app/lib/ssot.mjs +0 -277
  50. package/app/lib/teamview.mjs +0 -975
  51. package/app/lib/terms.ko.mjs +0 -169
  52. package/app/server.mjs +0 -1959
  53. package/app/web/shell.css +0 -538
  54. package/app/web/shell.html +0 -197
  55. package/app/web/shell.js +0 -638
  56. package/app/web/stage.js +0 -347
  57. package/app/web/words.js +0 -85
  58. package/src/repotarget.mjs +0 -84
  59. package/tools/cluster-experiment.mjs +0 -256
  60. package/tools/cluster-sweep.mjs +0 -226
  61. package/tools/make-icon.mjs +0 -108
  62. package/tools/persona-bench.mjs +0 -362
  63. package/tools/pick-repo.mjs +0 -229
  64. package/tools/reveal-demo.mjs +0 -158
  65. package/tools/shortcut.mjs +0 -121
  66. package/tools/smoke.mjs +0 -174
  67. package/tools/topicgraph.py +0 -154
@@ -1,158 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 그래프 → 트리. `app/lib/reveal.mjs` 를 실제 저장소로 돌려 **화면 없이** 본다.
4
- *
5
- * 🔴 왜 따로 도구인가.
6
- *
7
- * 점진 공개의 주장은 "한꺼번에 다 보여주지 않으면 벽이 안 생긴다" 이고,
8
- * 그 주장은 **숫자로만 확인된다** — 몇 번 눌러서 몇 개를 봤나, 한 번에
9
- * 몇 개가 쏟아졌나, 자리가 안 바뀌었나. 브라우저에서는 이걸 셀 수 없다.
10
- *
11
- * 화면(app/web)은 다른 세션이 잡고 있다. 로직은 순수 함수라 화면 없이도
12
- * 전부 돌릴 수 있으므로, 여기서 먼저 재고 화면은 그 뒤에 붙인다.
13
- *
14
- * 사용:
15
- * node tools/reveal-demo.mjs <저장소> [--steps 5] [--width 100]
16
- */
17
-
18
- import fs from 'node:fs'
19
- import path from 'node:path'
20
-
21
- import { build, scan } from '../app/lib/analyze.mjs'
22
- import { entryStarts } from '../app/lib/flow.mjs'
23
- import { featureGraph } from '../app/lib/featuregraph.mjs'
24
- import { commitFiles } from '../app/lib/live.mjs'
25
- import {
26
- callsOf, emptyReveal, importersOf, layers, preview, reachable, reveal, revealFeature,
27
- } from '../app/lib/reveal.mjs'
28
-
29
- const argv = process.argv.slice(2)
30
- const flag = (name, dflt) => {
31
- const i = argv.indexOf(`--${name}`)
32
- return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt
33
- }
34
- const ROOT = path.resolve(argv.find((a) => !a.startsWith('--') && argv[argv.indexOf(a) - 1]?.startsWith('--') !== true) ?? '.')
35
- const STEPS = Number(flag('steps', 5))
36
-
37
- const short = (p) => (p.length <= 26 ? p : `…${p.slice(-25)}`)
38
-
39
- /** 층 하나를 한 줄로. 자리(col)가 곧 순서라 왼쪽부터 도착 순이다. */
40
- function draw(state, opened) {
41
- const rows = layers(state)
42
- const out = []
43
- for (const r of rows) {
44
- const cells = r.cells.map((c) => {
45
- const mark = opened.has(c.id) ? '▣' : '□'
46
- return `${mark} ${short(c.id)}`
47
- })
48
- // 한 줄이 너무 길면 접는다 — 화면에서도 줄바꿈이 일어날 자리다.
49
- const head = `층 ${String(r.depth).padStart(2)} │ `
50
- const pad = ' '.repeat(head.length)
51
- let line = head
52
- const lines = []
53
- for (const c of cells) {
54
- if (line.length + c.length + 3 > 96) { lines.push(line); line = pad }
55
- line += `${c} `
56
- }
57
- lines.push(line)
58
- out.push(...lines.map((l) => l.replace(/\s+$/, '')))
59
- }
60
- return out.join('\n')
61
- }
62
-
63
- // ── 그래프를 만든다 (지금 화면이 그리는 것과 같은 재료) ────────────────────
64
- const g = build(scan(ROOT))
65
- const importers = importersOf(g.edges)
66
- const calls = callsOf(g.edges)
67
- const byId = new Map(g.nodes.map((n) => [n.id, n]))
68
-
69
- console.log(`\n저장소 ${ROOT}`)
70
- console.log(`━`.repeat(78))
71
- console.log(`지금 그래프가 한 번에 그리는 것 : 노드 ${g.nodes.length}개 · 엣지 ${g.edges.length}개`)
72
- console.log(` ← 이것이 "벽" 이다\n`)
73
-
74
- // ── ① 진입점 하나로 시작 ──────────────────────────────────────────────────
75
- const starts = entryStarts(ROOT, g.nodes, g.edges)
76
- if (!starts.length) {
77
- console.log('진입점을 못 찾았다. 이 저장소로는 시연할 수 없다.')
78
- process.exit(1)
79
- }
80
- let st = reveal(emptyReveal(), [starts[0]], { why: '진입점', importers })
81
- const opened = new Set()
82
-
83
- console.log(`┌─ 0번째 ─ 시작`)
84
- console.log(draw(st, opened))
85
- let r = reachable(st, calls)
86
- console.log(`└─ 보이는 것 ${r.shown}개 · 여기서 import 로 더 닿는 것 ${r.more}개\n`)
87
-
88
- // ── ② 누른다. 누르기 **전에** 몇 개가 나올지 알려준다 ─────────────────────
89
- for (let step = 1; step <= STEPS; step++) {
90
- // 아직 안 펼친 것 중 가장 먼저 드러난 것부터 — 사람이 위에서부터 읽는 순서
91
- const target = st.order.find((id) => !opened.has(id) && preview(st, id, calls).count > 0)
92
- if (!target) {
93
- console.log(`더 펼칠 것이 없다 (import 사슬이 ${step - 1}번 만에 말랐다).`)
94
- console.log(`🔴 이래서 개념 축이 필요하다 — 아래 참조.\n`)
95
- break
96
- }
97
- const pv = preview(st, target, calls)
98
- // 🔴 예고는 **실제로 뜨는 수**여야 한다. `count` 는 후보 전체이고 한 번에
99
- // 여는 것은 `ids` 다. 둘이 다른데 후보 수를 적으면 화면이 거짓말을 한다
100
- // (개념 축에서 "고른 수와 는 수가 다르면 거짓말" 이라고 정한 것과 같다).
101
- const say = pv.more ? `+${pv.ids.length}개 (후보 ${pv.count}개 중)` : `+${pv.ids.length}개`
102
- console.log(`┌─ ${step}번째 ─ "${short(target)}" 를 누른다 (누르기 전 예고: ${say})`)
103
- const before = { ...st.at }
104
- st = reveal(st, pv.ids, { why: `${path.basename(target)} 가 부른다`, parent: target, importers })
105
- opened.add(target)
106
-
107
- // 🔴 불변식 확인 — 한 번 잡은 자리는 다시는 안 움직인다.
108
- const moved = Object.keys(before).filter(
109
- (k) => before[k].depth !== st.at[k].depth || before[k].col !== st.at[k].col)
110
- console.log(draw(st, opened))
111
- r = reachable(st, calls)
112
- console.log(`└─ 보이는 것 ${r.shown}개 · 더 닿는 것 ${r.more}개 · 자리가 움직인 노드 ${moved.length}개`
113
- + (moved.length ? ` ❌ 불변식 위반` : ` ✅`))
114
- console.log()
115
- }
116
-
117
- // ── ③ 개념 축 — import 가 마르면 README 가 이름 붙인 묶음을 연다 ──────────
118
- let readme = null
119
- for (const f of ['README.md', 'README.rst', 'readme.md', 'README']) {
120
- try { readme = fs.readFileSync(path.join(ROOT, f), 'utf8'); break } catch { /* 다음 후보 */ }
121
- }
122
- const { available, commits } = commitFiles(ROOT, { since: '10 years ago', maxCommits: 2000 })
123
- const fg = featureGraph({
124
- paths: g.nodes.map((n) => n.id),
125
- commits: available ? commits : [],
126
- readme,
127
- edges: g.edges,
128
- })
129
- const feats = (fg.nodes ?? []).filter((n) => (n.paths ?? []).length > 1)
130
- if (!feats.length) {
131
- console.log('묶음을 못 찾았다 (README 에서 이름을 못 얻었거나 파일이 흩어져 있다).')
132
- } else {
133
- const f = feats.sort((a, b) => b.paths.length - a.paths.length)[0]
134
- console.log(`┌─ 개념 축 ─ 묶음 "${f.name ?? f.id}" 를 누른다 (안에 ${f.paths.length}개)`)
135
- const before = { ...st.at }
136
- const shownBefore = st.order.length
137
- const out = revealFeature(st, f, { edges: g.edges, importers, byId })
138
- st = out.state
139
- const moved = Object.keys(before).filter(
140
- (k) => before[k].depth !== st.at[k].depth || before[k].col !== st.at[k].col)
141
- console.log(draw(st, opened))
142
- const grew = st.order.length - shownBefore
143
- console.log(`└─ ${f.paths.length}개 중 현관문 ${out.door.ids.length}개를 골랐다`)
144
- console.log(` 근거: ${out.door.why}`)
145
- for (const id of out.door.ids) {
146
- const isNew = !before[id]
147
- console.log(` ${isNew ? '새로' : '이미'} ${short(id).padEnd(28)} 바깥에서 부르는 곳 ${out.door.calledFrom[id]}`)
148
- }
149
- // 🔴 고른 수와 실제로 는 수가 다르면 사람은 "눌렀는데 아무 일도 안 났다" 를 본다.
150
- console.log(` ⇒ 화면에 실제로 는 것 ${grew}개`
151
- + (grew === out.door.ids.length ? '' : ` ❌ ${out.door.ids.length}개를 골랐는데 ${out.door.ids.length - grew}개는 이미 열려 있던 것`))
152
- console.log(` 자리가 움직인 노드 ${moved.length}개` + (moved.length ? ' ❌' : ' ✅'))
153
- }
154
-
155
- r = reachable(st, calls)
156
- console.log(`\n${'━'.repeat(78)}`)
157
- console.log(`한꺼번에 그렸다면 ${g.nodes.length}개. ${STEPS}번 눌러서 본 것은 ${r.shown}개.`)
158
- console.log(`읽은 흔적이 곧 화면이고, 안 연 것은 화면에 없다.`)
@@ -1,121 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 바탕화면에 실행 바로가기를 만든다. 빌드할 때마다 **같은 자리를 덮어쓴다.**
4
- *
5
- * $ node tools/shortcut.mjs 빌드 결과(dist)를 가리킨다
6
- * $ node tools/shortcut.mjs --dev 소스에서 바로 띄우는 바로가기
7
- *
8
- * 🔴 왜 이름을 버전으로 안 나누는가.
9
- *
10
- * `axMap 0.0.2.lnk` 처럼 만들면 바탕화면에 낡은 아이콘이 쌓이고, 사람은 그중
11
- * 아무거나 누른다. **어제 고친 버그가 살아 있는 화면**을 보면서 도구를 탓하게
12
- * 된다는 뜻이다. 이름을 고정하고 덮어써서 "바탕화면의 그 아이콘 = 마지막 빌드"
13
- * 가 항상 참이게 한다.
14
- *
15
- * 🔴 바탕화면 경로를 `%USERPROFILE%\Desktop` 으로 짐작하지 않는다.
16
- *
17
- * OneDrive 를 쓰면 바탕화면이 `...\OneDrive\바탕 화면` 으로 옮겨 가 있고, 한국어
18
- * 윈도우는 폴더 이름도 다르다. 짐작하면 아무도 안 보는 자리에 파일을 만들어 놓고
19
- * 성공했다고 말하게 된다. 윈도우에게 직접 묻는다.
20
- */
21
-
22
- import { spawnSync } from 'node:child_process'
23
- import fs from 'node:fs'
24
- import path from 'node:path'
25
- import { fileURLToPath } from 'node:url'
26
-
27
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
28
- const DEV = process.argv.includes('--dev')
29
- const NAME = 'axMap'
30
-
31
- if (process.platform !== 'win32') {
32
- console.log('바로가기 만들기는 윈도우에서만 합니다 — 건너뜁니다.')
33
- process.exit(0)
34
- }
35
-
36
- /** 윈도우가 아는 진짜 바탕화면 경로. 짐작하지 않는다. */
37
- function desktopDir() {
38
- const r = spawnSync('powershell', ['-NoProfile', '-Command', "[Environment]::GetFolderPath('Desktop')"],
39
- { encoding: 'utf8', windowsHide: true })
40
- const p = r.stdout?.trim()
41
- return p && fs.existsSync(p) ? p : null
42
- }
43
-
44
- /**
45
- * 무엇을 가리킬 것인가.
46
- *
47
- * 기본은 빌드 결과다. `--dev` 면 소스에서 띄운다 — 빌드하지 않고도 최신 코드를
48
- * 바로 볼 수 있어야 팀원이 매번 몇 분씩 기다리지 않는다.
49
- */
50
- function target() {
51
- if (DEV) {
52
- const electron = path.join(ROOT, 'desktop', 'node_modules', 'electron', 'dist', 'electron.exe')
53
- if (!fs.existsSync(electron)) {
54
- console.error('electron 이 없습니다. desktop 에서 `npm install` 을 먼저 하세요.')
55
- process.exit(1)
56
- }
57
- return { exe: electron, args: `"${path.join(ROOT, 'desktop')}"`, cwd: path.join(ROOT, 'desktop') }
58
- }
59
-
60
- const dist = path.join(ROOT, 'desktop', 'dist', 'win-unpacked', `${NAME}.exe`)
61
- if (!fs.existsSync(dist)) {
62
- console.error(`빌드 결과가 없습니다: ${dist}`)
63
- console.error('`npm run dist` 를 먼저 돌리거나, 소스로 띄우려면 --dev 를 주세요.')
64
- process.exit(1)
65
- }
66
- return { exe: dist, args: '', cwd: path.dirname(dist) }
67
- }
68
-
69
- /**
70
- * 바탕화면에서 쓸 이름 — 언제나 `axMap 실행` 이다.
71
- *
72
- * 🔴 저장소가 바탕화면에 있으면 `axmap` 폴더와 `axmap` 바로가기가 나란히
73
- * 뜬다. 윈도우는 `.lnk` 확장자를 감추므로 **둘 다 "axmap" 으로 보인다.**
74
- * 실제로 그렇게 됐고, 그러면 사람은 아무거나 누른다 — 폴더를 눌러 놓고
75
- * "앱이 안 켜진다" 고 말하게 된다.
76
- *
77
- * 🔴 그런데 **겹칠 때만 비키게** 하면 이름이 기계마다 달라진다.
78
- * 저장소를 바탕화면에 둔 사람은 `axMap 실행`, 다른 데 둔 사람은 `axMap` 이
79
- * 생기고, 저장소를 옮기는 순간 같은 기계에서도 이름이 바뀐다. 그러면
80
- * README 도, 셋업 안내도, 옆자리에 하는 말("바탕화면의 axMap 실행 을 누르세요")도
81
- * 누군가에게는 틀린 말이 된다. 이름은 어디에 무엇이 있든 하나여야 한다.
82
- *
83
- * "실행" 을 붙여 두면 동사로 읽혀서 옆의 폴더와도 저절로 구별된다.
84
- */
85
- function linkPath(dir) {
86
- return path.join(dir, `${NAME} 실행.lnk`)
87
- }
88
-
89
- const dir = desktopDir()
90
- if (!dir) { console.error('바탕화면 경로를 찾지 못했습니다.'); process.exit(1) }
91
-
92
- const { exe, args, cwd } = target()
93
- const link = linkPath(dir)
94
- const icon = path.join(ROOT, 'desktop', 'build', 'icon.ico')
95
-
96
- /**
97
- * `.lnk` 는 COM(WScript.Shell)으로만 만들 수 있다. 순수 Node 로는 못 만든다.
98
- * `$s.Save()` 는 같은 경로가 있으면 **묻지 않고 덮어쓴다** — 우리가 원하는 동작이다.
99
- */
100
- const ps = [
101
- '$ErrorActionPreference = "Stop"',
102
- '$w = New-Object -ComObject WScript.Shell',
103
- `$s = $w.CreateShortcut("${link}")`,
104
- `$s.TargetPath = "${exe}"`,
105
- args ? `$s.Arguments = '${args}'` : '$s.Arguments = ""',
106
- `$s.WorkingDirectory = "${cwd}"`,
107
- fs.existsSync(icon) ? `$s.IconLocation = "${icon}"` : `$s.IconLocation = "${exe},0"`,
108
- '$s.Description = "axMap — 코드의 변화를 사람이 이해하고 통제하는 도구"',
109
- '$s.Save()',
110
- ].join('; ')
111
-
112
- const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { encoding: 'utf8', windowsHide: true })
113
- if (r.status !== 0 || !fs.existsSync(link)) {
114
- console.error('바로가기를 만들지 못했습니다.')
115
- console.error(r.stderr || r.stdout || '(출력 없음)')
116
- process.exit(1)
117
- }
118
-
119
- console.log(`바로가기 ${link}`)
120
- console.log(` → ${exe}${args ? ` ${args}` : ''}`)
121
- console.log(` ${DEV ? '소스에서 실행 (--dev)' : '빌드 결과'} · 다음 빌드가 이 파일을 덮어씁니다`)
package/tools/smoke.mjs DELETED
@@ -1,174 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 화면이 실제로 뜨는지 본다.
4
- *
5
- * 🔴 `npm test` 는 화면이 죽어도 초록이다.
6
- *
7
- * 하룻밤에 두 번 겪었다. 둘 다 문법은 멀쩡했고 테스트는 전부 통과했는데
8
- * 브라우저에서는 아무것도 안 떴다.
9
- *
10
- * 1. 주석에 쓴 `examples/[별]/main.go` 의 `[별]/` 가 블록 주석을 닫아 파일이 깨졌다
11
- * 2. `let urlReady` 를 첫 사용처보다 아래에 선언해 TDZ 참조 에러가 났고,
12
- * 그 예외가 부트스트랩 체인을 끊어 흐름·걸음·탭이 통째로 죽었다
13
- *
14
- * 2번이 특히 나빴다. 나는 `?step=3` 으로만 확인했는데 그 경로는 크래시 지점을
15
- * 우회한다. **되는 길만 보면 안 되는 길은 영원히 안 보인다.**
16
- *
17
- * 그래서 여기서는 사람이 실제로 처음 여는 주소 — **맨 주소** 를 연다.
18
- * 자바스크립트가 다 돌고 난 DOM 에 부트스트랩이 남긴 흔적이 있는지만 본다.
19
- * 픽셀은 안 본다. 스크린샷 비교는 깨지기 쉽고, 우리가 잡으려는 것은
20
- * "화면이 통째로 안 뜨는 것" 이지 "3px 밀린 것" 이 아니다.
21
- *
22
- * 의존성은 늘리지 않는다. 이미 깔린 Chrome 을 `--dump-dom` 으로 부른다.
23
- */
24
-
25
- import { spawn, spawnSync, execFileSync } from 'node:child_process'
26
- import fs from 'node:fs'
27
- import path from 'node:path'
28
- import { fileURLToPath } from 'node:url'
29
- import { detect } from '../app/lib/agentcli.mjs'
30
-
31
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
32
- const PORT = Number(process.env.AXMAP_SMOKE_PORT ?? 7912)
33
- /** 검사 대상은 이 저장소 자신이다. 늘 있고, 크지 않고, 결과가 안정적이다. */
34
- const TARGET = process.argv[2] ?? ROOT
35
-
36
- /** 이미 깔린 Chrome 을 찾는다. 없으면 **건너뛰지 않고 실패한다** — 검사는 돌아야 검사다. */
37
- function findChrome() {
38
- if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME
39
- const cands = process.platform === 'win32'
40
- ? [
41
- 'C:/Program Files/Google/Chrome/Application/chrome.exe',
42
- 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
43
- path.join(process.env.LOCALAPPDATA ?? '', 'Google/Chrome/Application/chrome.exe'),
44
- ]
45
- : ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
46
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
47
- for (const c of cands) { try { if (fs.existsSync(c)) return c } catch { /* 다음 후보 */ } }
48
- return null
49
- }
50
-
51
- /**
52
- * 새 껍데기(`/`)가 남겨야 하는 흔적.
53
- *
54
- * `data-shell="ready"` 는 `init()` 의 맨 끝에서 붙는다. 그 앞에 API 세 번
55
- * (`/api/agents` · `/api/graph` · `/api/sessions`)이 있으므로 이 한 줄이
56
- * **모듈 · 서버 라우트 · 세션 저장소**를 한꺼번에 증명한다.
57
- */
58
- /**
59
- * 처음 오는 사람이 보는 화면(`/`).
60
- *
61
- * `data-shell="ready"` 는 `init()` 의 맨 끝에서 붙는다. 그 앞에 API 세 번
62
- * (`/api/agents` · `/api/version` · `/api/sessions`)이 있으므로 이 한 줄이
63
- * **모듈 · 서버 라우트 · 세션 저장소**를 한꺼번에 증명한다.
64
- */
65
- const FIRST = [
66
- { what: '부트스트랩(shell.js)', re: /data-shell="ready"/ },
67
- { what: '3분할 · 왼쪽 목록', re: /id="chats"/ },
68
- { what: '3분할 · 가운데', re: /id="stage"/ },
69
- { what: '3분할 · 오른쪽 입력', re: /id="composer"/ },
70
- { what: '모드 탭(감시·이해)', re: /data-mode="watch"/ },
71
- { what: '툴바(아이콘)', re: /data-menu="all"/ },
72
- /**
73
- * 🔴 처음 오는 사람에게 보이는 CLI 고르기 카드.
74
- * 정적 HTML 에 없다 — `/api/agents` 를 받아 `drawPicks()` 가 그린다.
75
- * 이 표식이 **온보딩이 실제로 떴다**와 **CLI 감지가 돈다**를 함께 증명한다.
76
- */
77
- { what: '처음 설정 · CLI 고르기', re: /class="pick/ },
78
- ]
79
-
80
- /**
81
- * 고르기를 끝낸 사람이 보는 화면(`/?agent=…`).
82
- *
83
- * 🔴 온보딩이 가운데 칸을 가리므로, 주소로 고른 값을 심지 않으면 렌더러가
84
- * 검사에서 통째로 빠진다. 헤드리스는 클릭을 못 한다.
85
- *
86
- * `data-path` 와 `class="wires"` 는 정적 HTML 에 없다 — `/api/ladder` 를 받아
87
- * `stage.js` 가 만든다. 둘이 **겹 계산 · 라우트 · 카드 · 버스 연결선**을 덮는다.
88
- */
89
- const OPENED = [
90
- { what: '부트스트랩(shell.js)', re: /data-shell="ready"/ },
91
- { what: '가운데 · 번호 카드', re: /data-path="/ },
92
- { what: '가운데 · 버스 연결선', re: /class="wires"/ },
93
- { what: '온보딩이 닫혔다', re: /id="onboard" hidden/ },
94
- ]
95
-
96
- // 검사 PC의 설치된 CLI를 선택한다. 특정 팀의 Claude 설치에 의존하지 않는다.
97
- const agent = detect().find((a) => a.installed)
98
- if (!agent) {
99
- console.error('온보딩 이후 화면 검증에 필요한 AI CLI가 없습니다. Claude Code, agy 또는 Codex CLI를 설치하세요.')
100
- process.exit(1)
101
- }
102
-
103
- const PAGES = [
104
- { path: '/', what: '처음 오는 사람', must: FIRST },
105
- { path: `/?agent=${encodeURIComponent(agent.id)}`, what: '고르기를 끝낸 사람', must: OPENED },
106
- ]
107
-
108
- async function waitUp(ms = 120_000) {
109
- const until = Date.now() + ms
110
- while (Date.now() < until) {
111
- try {
112
- await fetch(`http://127.0.0.1:${PORT}/`, { signal: AbortSignal.timeout(2000) })
113
- return true
114
- } catch { await new Promise((r) => setTimeout(r, 400)) }
115
- }
116
- return false
117
- }
118
-
119
- const chrome = findChrome()
120
- if (!chrome) {
121
- console.error('Chrome 을 찾지 못했습니다. CHROME=<경로> 로 알려주세요.')
122
- console.error('건너뛰지 않습니다 — 안 도는 검사는 검사가 아닙니다.')
123
- process.exit(1)
124
- }
125
-
126
- // 이미 그 포트를 쓰고 있으면 그것을 재사용하지 않는다. 무엇을 검사했는지가 흐려진다.
127
- const log = path.join(ROOT, '.axmap-smoke.log')
128
- const out = fs.openSync(log, 'w')
129
- const server = spawn(process.execPath, [path.join(ROOT, 'app', 'server.mjs'), TARGET, String(PORT)],
130
- { cwd: ROOT, stdio: ['ignore', out, out], windowsHide: true })
131
-
132
- let failed = 0
133
- try {
134
- if (!await waitUp()) {
135
- console.error(`서버가 뜨지 않았습니다. 기록: ${log}`)
136
- try { console.error(fs.readFileSync(log, 'utf8').split('\n').slice(-10).join('\n')) } catch { /* 없으면 그만 */ }
137
- process.exit(1)
138
- }
139
-
140
- console.log(`대상 ${TARGET}`)
141
-
142
- for (const page of PAGES) {
143
- // 🔴 맨 주소로 연다. 사람이 처음 여는 주소가 이것이고, 파라미터를 붙이면
144
- // 부트스트랩의 다른 갈래를 타서 크래시 지점을 우회할 수 있다.
145
- const dom = execFileSync(chrome, [
146
- '--headless=new', '--disable-gpu', '--no-sandbox',
147
- // 자바스크립트가 끝날 시간을 준다. 큰 저장소는 그래프를 만드느라 느리다.
148
- '--virtual-time-budget=20000',
149
- '--dump-dom', `http://127.0.0.1:${PORT}${page.path}`,
150
- ], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true })
151
-
152
- console.log(`\n${page.path} ${page.what} · DOM ${dom.length.toLocaleString()} bytes`)
153
- for (const m of page.must) {
154
- const ok = m.re.test(dom)
155
- console.log(` ${ok ? '통과' : '실패'} ${m.what}`)
156
- if (!ok) failed++
157
- }
158
- }
159
-
160
- if (failed) {
161
- console.error('\n화면이 뜨지 않았습니다 — 부트스트랩이 중간에 죽었을 수 있습니다.')
162
- console.error('브라우저 콘솔을 보세요. 문법은 멀쩡한데 실행이 죽는 경우가 많습니다')
163
- console.error('(선언 순서·TDZ·없는 id 참조).')
164
- }
165
- } finally {
166
- try { server.kill() } catch { /* 이미 죽었으면 그만 */ }
167
- if (process.platform === 'win32') {
168
- try { spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }) } catch { /* 그만 */ }
169
- }
170
- try { fs.closeSync(out) } catch { /* 그만 */ }
171
- }
172
-
173
- if (failed) process.exit(1)
174
- console.log('\n화면 연기 검사 통과')
@@ -1,154 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- 토픽 그래프 측정기 — docs/DECISIONS.md 의 D5·D6·D7·D9·D10 근거를 재현한다.
4
-
5
- ROS2 처럼 배선이 문자열로 표현되는 코드베이스에서
6
- "어떤 파일이 어떤 파일과 이어져 있고, 시작점에서 몇 단계 떨어져 있는가"를 센다.
7
-
8
- python tools/topicgraph.py <소스루트> [시작파일] [--hub-cap N]
9
-
10
- python tools/topicgraph.py ../e101_hj/redesign/.../ros2_ws/src \\
11
- bbiyong_base/bbiyong_base/cmd_mux_node.py --hub-cap 6
12
-
13
- 이것은 제품이 아니라 **측정 도구**다. 정적 문자열만 본다.
14
- 그래서 파라미터를 통한 간접 배선을 놓치며, 실제로 놓쳤다 (DECISIONS.md D5).
15
- 그 한계를 보여주는 것도 이 스크립트의 역할이다.
16
- """
17
-
18
- import argparse
19
- import collections
20
- import os
21
- import re
22
- import sys
23
-
24
- if hasattr(sys.stdout, "reconfigure"):
25
- sys.stdout.reconfigure(encoding="utf-8")
26
-
27
- TOPIC = re.compile(r'["\'](/[A-Za-z0-9_/]{2,})["\']')
28
-
29
-
30
- def collect(root):
31
- """파일별로 등장하는 토픽 문자열을 모은다."""
32
- files = {}
33
- for dirpath, dirnames, filenames in os.walk(root):
34
- dirnames[:] = [d for d in dirnames if d != "__pycache__"]
35
- for name in filenames:
36
- if not name.endswith(".py"):
37
- continue
38
- path = os.path.join(dirpath, name)
39
- try:
40
- src = open(path, encoding="utf-8", errors="ignore").read()
41
- except OSError:
42
- continue
43
- topics = set(TOPIC.findall(src))
44
- if topics:
45
- files[os.path.relpath(path, root).replace("\\", "/")] = topics
46
- return files
47
-
48
-
49
- def layers_from(topic_files, start, hub_cap=None):
50
- """
51
- 시작 파일에서 너비우선으로 뻗어나가며 깊이별 파일 집합을 만든다.
52
-
53
- hub_cap 을 주면 그보다 많은 파일이 공유하는 토픽은 엣지에서 제외한다.
54
- 전역 방송 신호(/estop 등)가 무관한 노드들을 서로 1홉 거리로 만드는 것을 막는다.
55
- """
56
- adj = collections.defaultdict(set)
57
- for topic, group in topic_files.items():
58
- if hub_cap is not None and len(group) > hub_cap:
59
- continue
60
- for a in group:
61
- adj[a] |= group - {a}
62
-
63
- seen, out, frontier = {start}, [], {start}
64
- while frontier:
65
- nxt = set()
66
- for node in frontier:
67
- nxt |= adj[node] - seen
68
- if not nxt:
69
- break
70
- seen |= nxt
71
- out.append(nxt)
72
- frontier = nxt
73
- return out
74
-
75
-
76
- def size_histogram(root):
77
- buckets = [("<100", 0), ("100-300", 0), ("300-600", 0), ("600-1000", 0), ("1000+", 0)]
78
- counts = dict(buckets)
79
- total_lines = 0
80
- biggest = []
81
- for dirpath, dirnames, filenames in os.walk(root):
82
- dirnames[:] = [d for d in dirnames if d != "__pycache__"]
83
- for name in filenames:
84
- if not name.endswith(".py"):
85
- continue
86
- path = os.path.join(dirpath, name)
87
- try:
88
- n = sum(1 for _ in open(path, encoding="utf-8", errors="ignore"))
89
- except OSError:
90
- continue
91
- total_lines += n
92
- biggest.append((n, os.path.relpath(path, root).replace("\\", "/")))
93
- key = ("<100" if n < 100 else "100-300" if n < 300 else
94
- "300-600" if n < 600 else "600-1000" if n < 1000 else "1000+")
95
- counts[key] += 1
96
- biggest.sort(reverse=True)
97
- return counts, total_lines, biggest
98
-
99
-
100
- def main():
101
- ap = argparse.ArgumentParser()
102
- ap.add_argument("root")
103
- ap.add_argument("start", nargs="?")
104
- ap.add_argument("--hub-cap", type=int, default=6)
105
- args = ap.parse_args()
106
-
107
- files = collect(args.root)
108
- topic_files = collections.defaultdict(set)
109
- for f, topics in files.items():
110
- for t in topics:
111
- topic_files[t].add(f)
112
-
113
- counts, total_lines, biggest = size_histogram(args.root)
114
- n_files = sum(counts.values())
115
-
116
- print(f"== 규모 ==")
117
- print(f" {n_files}개 .py, {total_lines:,}줄")
118
- print(f" 토픽 문자열을 가진 파일 {len(files)}개, 토픽 {len(topic_files)}개\n")
119
-
120
- print("== 크기 분포 ==")
121
- for label in ["<100", "100-300", "300-600", "600-1000", "1000+"]:
122
- print(f" {label:>9}줄 {counts[label]:3}개")
123
- tail = sum(n for n, _ in biggest[:5])
124
- if total_lines:
125
- print(f" 상위 5개 파일이 전체의 {tail / total_lines * 100:.0f}% ({tail:,}줄)")
126
- for n, f in biggest[:5]:
127
- print(f" {n:5}줄 {f}")
128
- print()
129
-
130
- print(f"== 허브 토픽 (많은 파일이 공유) ==")
131
- for t, group in sorted(topic_files.items(), key=lambda kv: -len(kv[1]))[:6]:
132
- flag = " ← 허브" if len(group) > args.hub_cap else ""
133
- print(f" {len(group):3}개 파일 {t}{flag}")
134
- print()
135
-
136
- if not args.start:
137
- print("시작 파일을 주면 깊이별 확산을 잽니다.")
138
- return
139
- if args.start not in files:
140
- print(f"시작 파일을 찾을 수 없습니다: {args.start}")
141
- return
142
-
143
- for cap, label in [(None, "허브 제한 없음"), (args.hub_cap, f"{args.hub_cap}개 초과 토픽 제외")]:
144
- print(f"== {args.start} 로부터의 깊이 ({label}) ==")
145
- total = 1
146
- for depth, group in enumerate(layers_from(topic_files, args.start, cap), 1):
147
- total += len(group)
148
- mark = " ← 요약 한계" if depth == 3 else ""
149
- print(f" 깊이 {depth}: +{len(group):3}개 (누적 {total:3}){mark}")
150
- print()
151
-
152
-
153
- if __name__ == "__main__":
154
- main()