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
package/app/README.md ADDED
@@ -0,0 +1,366 @@
1
+ # axMap 뷰어 (프로토타입)
2
+
3
+ > 🔴 **이 문서는 낡았다. 아래 설명 중 상당수는 지금 화면에 없다.**
4
+ >
5
+ > 커밋 `aa571f6` 에서 `app/web/app.js` (1,065줄) 를 제거하면서
6
+ > **트리 · 요약 패널 · 감시 모드 · 터미널 · 기능/찾기 탭 · LLM 스트리밍 요약**이
7
+ > 화면에서 빠졌다. 코드는 git 히스토리에 남아 있다.
8
+ >
9
+ > 지금 실제로 뜨는 화면은 **정적 파싱 × git 공변경 오버레이** 하나다.
10
+ > 그 설계는 [../docs/DECISIONS.md](../docs/DECISIONS.md) 의 **D12** 에 있다.
11
+ > 이 문서는 방향이 굳으면 다시 쓴다 — 먼저 쓰면 아직 정하지 않은 것을
12
+ > 정한 것처럼 보이게 된다 (루트 README 와 같은 이유).
13
+
14
+ 코드베이스를 그래프로 보고, 지금 AI 가 어디를 건드리는지 지켜보고, 터미널을 쓰는 화면.
15
+
16
+ ```bash
17
+ node app/server.mjs <대상경로> [포트]
18
+ node app/server.mjs ../e101_hj/redesign/S15P11E101/BE_robot 7777
19
+ ```
20
+
21
+ 브라우저로 `http://127.0.0.1:7777` 을 연다.
22
+
23
+ ---
24
+
25
+ ## 화면
26
+
27
+ ```
28
+ ┌──────────┬────────────────────────────────┐
29
+ │ 트리 │ 그래프 │
30
+ │ ↕ │ │
31
+ │ 요약 ├────────────────────────────────┤
32
+ │ │ 터미널 │
33
+ └──────────┴────────────────────────────────┘
34
+ ```
35
+
36
+ 좌측은 한 자리를 둘이 나눠 쓴다. **아무것도 선택 안 하면 디렉터리 트리, 노드를 클릭하면 그 노드의 요약.**
37
+ 빈 곳을 클릭하면 트리로 돌아온다.
38
+
39
+ **노드를 클릭하면 그것이 곧 이해의 시작점**이 된다. 거기서부터 깊이별로 그래프가 옅어진다.
40
+
41
+ ## 두 가지 모드
42
+
43
+ | 모드 | 언제 | 보여주는 것 |
44
+ |---|---|---|
45
+ | **이해** | AI 를 시키기 전 | 시작점에서의 거리. 가까울수록 진하고, 3단계 밖은 옅다 |
46
+ | **감시** | AI 가 작업하는 동안 | 누가 무엇을 선점했고 실제로 뭘 건드리는 중인지 |
47
+
48
+ 한 화면에 다 넣지 않고 나눈 이유는 색이 다섯 가지를 동시에 뜻하면 아무것도 못 읽기 때문이다.
49
+
50
+ ### 감시 모드의 색
51
+
52
+ | | 뜻 |
53
+ |---|---|
54
+ | 🟡 노랑 | 선점했지만 아직 안 건드림 |
55
+ | 🟢 초록 | 선점했고 실제로 작업 중 |
56
+ | 🔴 빨강 | **선언 없이 수정됨** — 그 AI 는 자기가 뭘 할지 몰랐다 |
57
+ | ⚪ 회색 | 관련 없음 |
58
+
59
+ 빨강이 핵심이다. `docs/DECISIONS.md` D3 의 "선언 ⊂ 실제" 를 PR 시점이 아니라 **작업 중에** 잡는다.
60
+
61
+ 감시 모드가 쓸모 있으려면 대상 저장소에 axMap 장부가 있어야 한다.
62
+
63
+ ```bash
64
+ cd <대상저장소>
65
+ AXMAP_AGENT=agent-a node <axmap>/bin/axmap.mjs init
66
+ AXMAP_AGENT=agent-a node <axmap>/bin/axmap.mjs claim src/auth --intent "무엇을 하려는지"
67
+ ```
68
+
69
+ 장부가 없으면 git 이 본 변경만 표시된다.
70
+
71
+ ## 좌측 패널 순서
72
+
73
+ 노드를 누르면 이 순서로 나온다. **핵심이 전체 코드보다 먼저다.**
74
+
75
+ 1. **지금** — 그 파일을 누가 선점/수정 중인지 (감시 정보가 있을 때)
76
+ 2. **전역 신호** — 이 파일이 쓰는 허브 채널
77
+ 3. **요약** — LLM 한 문단, 스트리밍
78
+ 4. **핵심** — LLM 이 고른 "반드시 볼 곳" 최대 3군데 + 왜 그런지
79
+ 5. **코드** — 260줄 이하면 전체, 넘으면 구성 목록만
80
+
81
+ 핵심 구간은 LLM 이 고르되 **줄 번호를 파일 길이로 잘라 검증**한다.
82
+ 없는 줄을 가리키면 버린다.
83
+
84
+ ## 색이 뜻하는 것
85
+
86
+ ### 이해 모드
87
+
88
+ | 채널 | 뜻 |
89
+ |---|---|
90
+ | **색상(hue)** | 어느 갈래인가 — 패키지/모듈 그룹. 황금각으로 배정해 서로 최대한 다르다 |
91
+ | **투명도** | 시작점에서의 그래프 거리 |
92
+ | **반지름(궤도)** | 시작점에서의 **단계**. 1단계·2단계·3단계가 껍질로 보인다 |
93
+ | **크기·가림** | 카메라 거리 (원근) |
94
+ | **흐르는 점** | 데이터가 가는 방향 (발행 → 구독) |
95
+
96
+ ### 감시 모드 — 누가 건드리는가
97
+
98
+ | 색 | 뜻 |
99
+ |---|---|
100
+ | 🟢 초록 | 사람이 작업 중 |
101
+ | 🟣 보라 | 대화형 AI 에이전트 |
102
+ | 🔵 하늘 | **백그라운드 에이전트** |
103
+ | 🩷 분홍 | 다른 팀원 |
104
+ | 🟡 노랑 | 선점만 (아직 안 건드림) |
105
+ | 🔴 빨강 | **선언 없이 수정됨** ⚠ |
106
+
107
+ 행위자 종류는 `axmap claim --actor human|agent|background|team` 으로 정한다.
108
+ 지정하지 않으면 이름이 내 git user.name 과 같으면 `human`, 다르면 `team` 으로 본다.
109
+
110
+ ## 작성자별 강조
111
+
112
+ 좌측 위 작성자 버튼을 누르면 **그 사람이 건드린 파일만 밝고 나머지는 흐려진다.**
113
+ `git log --relative` 로 뷰어가 보고 있는 경로 기준으로 집계한다.
114
+
115
+ ## 코드는 필요한 만큼만
116
+
117
+ 전체를 쏟아내면 "핵심만 읽게 한다"는 목적이 무너진다. D6 와 같은 기준을 쓴다.
118
+
119
+ | 파일 | 보여주는 것 |
120
+ |---|---|
121
+ | 260줄 이하 | 통째로. 어차피 짧다 |
122
+ | 260줄 초과 | **구성(함수 목록)만.** 항목을 누르면 그 부분만 펼친다 |
123
+
124
+ 문법 강조는 직접 구현했다(`web/highlight.js`). 완전한 파서가 아니라 토큰 근사지만
125
+ 눈이 구조를 잡는 데는 충분하다. 줄 번호는 원본 기준이라 큰 파일의 일부만 봐도 찾아갈 수 있다.
126
+
127
+ ## 그래프는 3D 다
128
+
129
+ | 조작 | 동작 |
130
+ |---|---|
131
+ | 클릭 | 그 노드를 선택 + **이해의 시작점**으로 지정 |
132
+ | 왼쪽 드래그 | 빈 곳이면 화면 이동, 노드 위면 그 노드 끌기 |
133
+ | **오른쪽 드래그** | 전체 회전 (yaw / pitch) |
134
+ | 오른쪽 더블클릭 | 정면으로 복귀 |
135
+ | 휠 | 확대 |
136
+
137
+ 노드 끌기는 회전을 역변환해 **카메라 평면 위에서** 움직인다.
138
+ 그래서 어느 각도로 돌려놓아도 마우스를 따라온다.
139
+
140
+ ### 시각 채널을 의미별로 나눴다
141
+
142
+ 3D 로 가면 원근감도 보통 크기·투명도로 표현하는데, 투명도는 이미
143
+ "시작점에서의 그래프 거리"(D8)가 쓰고 있다. **같은 채널을 두 의미가 다투면 둘 다 못 읽는다.**
144
+
145
+ | 채널 | 뜻 |
146
+ |---|---|
147
+ | 투명도 | 시작점에서의 **그래프 거리** (의미) |
148
+ | 크기 · 가림 | 카메라에서의 **화면 거리** (원근) |
149
+
150
+ 안개(거리에 따른 흐림)는 최대 25% 만 건다. 더 걸면 "멀리 있는 것"과 "관련 없는 것"이 섞인다.
151
+ 노드는 먼 것부터 그려 앞의 것이 뒤를 가리게 한다 — 가림이 원근의 두 번째 단서다.
152
+
153
+ ## 이해 모드의 표시
154
+
155
+ - **진하기** = 시작점으로부터의 거리 (D8). 검정 바탕이라 색상이 아닌 **밝기**로 표현한다
156
+ - 색은 의미가 있을 때만 쓴다 — 감시 모드의 세 상태, 그리고 문법 강조
157
+
158
+ ## 콘솔에서 만지기
159
+
160
+ 프로토타입이라 상태를 열어뒀다.
161
+
162
+ ```js
163
+ axmap.view.yaw = 0 // 정면으로
164
+ axmap.view.maxDepth = 2 // 요약 깊이 한계 바꾸기
165
+ axmap.S.live.summary // 지금 선점/수정 요약
166
+ ```
167
+ - **빨간 테두리 원** = 300줄 초과. 파일 단위로 보면 안 되고 함수로 펼쳐야 하는 노드 (D6)
168
+ - **점선 회색 엣지** = 허브(전역 신호) 경유. 거리는 1이지만 의미는 멀다 (D9)
169
+ - **허브 배지** = 좌측 요약에 `/bbiyong/estop` 같은 전역 신호 목록
170
+
171
+ ## 로컬 LLM (선택)
172
+
173
+ 없어도 전부 동작한다. 요약만 비활성화된다.
174
+
175
+ ```bash
176
+ winget install --id Ollama.Ollama -e --scope user
177
+ ollama pull qwen2.5-coder:7b
178
+ node app/server.mjs <경로> # 모델을 자동으로 찾아 미리 올린다
179
+ ```
180
+
181
+ RTX 5060 Laptop(VRAM 8GB) 기준 **7~8B 가 적정**이다. 27B 는 VRAM 을 넘겨 RAM 으로 밀려난다.
182
+ 은행 서버 얘기와 노트북 얘기는 분리해야 한다.
183
+ 9.7B(`qwen3.5:9b`, 6.6GB)는 올라가긴 하지만 13% 가 CPU 로 밀리고, **품질도 더 낫지 않다**
184
+ — [아래 실측](#모델을-바꾸면-나아지는가--qwen359b-실측).
185
+
186
+ 환경변수: `AXMAP_MODEL` (기본 `qwen2.5-coder:7b`), `OLLAMA_HOST`, `AXMAP_KEEP_ALIVE` (기본 `30m`).
187
+
188
+ ### 실측 — RTX 5060 Laptop 8GB, qwen2.5-coder:7b (Q4_K_M), 100% GPU
189
+
190
+ | 파일 | 크기 | 모델에 넣은 것 | 첫 글자 | 전체 |
191
+ |---|---|---|---|---|
192
+ | `cmd_mux_node.py` | 130줄 | 전체 | 0.5초 | 2.5초 |
193
+ | `manual_drive_bridge.py` | 222줄 | 전체 | 0.7초 | 3.2초 |
194
+ | `escape_recovery.py` | 801줄 | 앞 130줄 + 구성 | 1.9초 | 5.5초 |
195
+ | 캐시 적중 | — | — | — | **4~32ms** |
196
+
197
+ 생성 속도는 41 tok/s 로 일정하다. **변하는 것은 프롬프트 평가 시간**이므로
198
+ 병목은 "얼마나 쓰느냐"가 아니라 **"얼마나 읽히느냐"**다.
199
+
200
+ ### 그래서 이렇게 했다
201
+
202
+ - **스트리밍** — 다 끝날 때까지 빈 화면을 보여주면 실제 속도와 무관하게 느리게 느껴진다.
203
+ 첫 글자가 0.5~1.9초에 나오는 것이 전체가 5초에 끝나는 것보다 체감에 크게 작용한다.
204
+ - **모델을 미리 올린다** — 콜드 스타트는 **44초**다(4.7GB 적재, 실측).
205
+ 서버가 뜰 때 한 번 부르고 `keep_alive: 30m` 으로 붙잡는다. 기본값 5분이면
206
+ 잠깐 딴짓하고 돌아올 때마다 44초를 다시 기다린다.
207
+ - **파일 크기에 따라 넣는 것을 바꾼다** — 고정 줄 수로 자르면 정확도를 잃는다.
208
+ 222줄 파일을 160줄로 자르자 뒤쪽 62줄에 있던 채널 두 개(`/bbiyong/manual_drive_status`,
209
+ `/tmp/orincar_drive.json`)를 통째로 놓쳤다. 260줄 이하는 전체를, 그 이상은
210
+ 앞 130줄 + 함수 목록을 준다. D6(적응형 노드)와 같은 원리다.
211
+ - **content hash 캐시** — `app/.cache/`. 파일이 안 바뀌면 다시 돌리지 않는다.
212
+
213
+ ### prefetch
214
+
215
+ 노드를 클릭하면 그 요약이 끝난 뒤 **깊이 1~3 을 미리 만든다.** 깊이 순서대로 하므로
216
+ 가장 클릭할 만한 것부터 준비된다. 다른 노드를 선택하면 즉시 취소한다.
217
+
218
+ e101 의 `cmd_mux_node.py` 기준 깊이 1~3 은 **40개**(7 + 15 + 18)라
219
+ 전부 준비되는 데 약 2분 걸린다. 상단에 `주변 요약 4/40` 으로 진행이 보인다.
220
+
221
+ ### 검증 가능한 출력
222
+
223
+ LLM 이 말한 채널 이름은 **파일에 그 문자열이 실제로 있는지 대조해서** `verified` 를 붙인다.
224
+ 작은 모델을 쓸 수 있는 이유가 이것이다 — 환각이 기계적으로 걸러진다.
225
+
226
+ 다만 정적 파싱과의 역할 분담은 처음 생각보다 미묘하다.
227
+ 따옴표 안의 `/경로` 를 전부 긁는 정규식은 파라미터 기본값도 잡으므로 **연결 자체는 놓치지 않는다.**
228
+ LLM 이 더해주는 것은 **방향**(발행인지 구독인지)과 **문서에만 적힌 흐름**이다.
229
+ 데이터 흐름을 이해하려면 방향이 있어야 하므로 여전히 필요하지만,
230
+ "정적 파싱은 못 보고 LLM 만 본다" 는 과장이다.
231
+
232
+ ### 출력 길이는 문법으로 묶는다
233
+
234
+ `format:'json'` 은 문법만 강제하고 **길이는 강제하지 못한다.** 채널 10개짜리 파일에
235
+ 같은 채널을 방향만 바꿔가며 97줄 반복하다 `num_predict` 에 잘려 죽는 일이
236
+ 40파일 중 1~2건씩 났다. 잘린 JSON 은 파싱에 실패하고 **그 파일의 엣지가 통째로 사라진다.**
237
+ 하필 채널이 가장 많은 파일에서 터지므로 손실이 가장 큰 곳에서 조용히 진다.
238
+
239
+ temperature 를 0.4 까지 올려도 재현된다 — 확률로는 못 막는다.
240
+ `format` 에 JSON Schema 를 주고 `maxItems` 를 걸면 문법이 배열을 닫도록 강제하므로
241
+ 이 실패가 원천적으로 사라진다. 최악 비용도 34초에서 8초로 묶인다.
242
+ `verified` 가 환각을 문자열 대조로 막는 것과 같은 수법을 길이에 적용한 것이다.
243
+
244
+ 문법이 배열 길이는 묶어도 **같은 채널을 두 번 쓰는 것은 막지 못하므로** 중복은 따로 제거한다.
245
+ 제거하지 않으면 화면에 같은 선을 겹쳐 그리고, 세는 쪽에서는 개수를 부풀린다.
246
+
247
+ ### 모델을 바꾸면 나아지는가 — qwen3.5:9b 실측
248
+
249
+ **결론: 안 나아진다.** `qwen2.5-coder:7b` 를 유지한다. (2026-08-16 측정)
250
+
251
+ `app/eval/edges.mjs` 로 40파일 × 5회를 설정별로 재고 정확 순열검정(252조합 전수)으로 판정했다.
252
+
253
+ | 설정 | 부가가치 | 커버리지 | 정밀도 | 방향오류율 | ms중앙 |
254
+ |---|---|---|---|---|---|
255
+ | **qwen2.5-coder:7b** (temp 0.1) | **109.0±5.8** | **82.8%** | 72.7% | **3.00%** | 1977 |
256
+ | qwen3.5:9b (temp 1.0 기본) | 86.2±2.2 | 76.1% | **90.0%** | 5.50% | 1774 |
257
+ | qwen3.5:9b (temp 0.1 통제) | 83.4±1.7 | 75.6% | 83.6% | 4.08% | 1645 |
258
+
259
+ 부가가치 p=0.008 — 252조합에서 나올 수 있는 최솟값이고 두 집단의 범위가 겹치지 않는다.
260
+ 속도는 차이 없다(p=0.238~0.365).
261
+
262
+ **왜 더 큰 모델이 지는가**
263
+
264
+ - **3.5 는 사고를 켤 수 없다.** 문법을 걸면 첫 토큰부터 JSON 만 허용되어 사고할 자리가 없고
265
+ 답이 통째로 `thinking` 필드로 가서 `response` 가 빈 문자열이 된다. 문법을 풀면 사고가
266
+ 끝나지 않는다 — 컨텍스트 16384 · 예산 8192 로도 31,540자를 쓰고 287초 뒤 답 없이 잘렸다.
267
+ 둘 사이에 안전지대가 없다. `think:false` 는 3.5 를 불리하게 만든 조치가 아니라
268
+ **답이 나오는 유일한 설정**이다.
269
+ - **온도 탓이 아니다.** 0.1 로 맞추자 오히려 내려갔다(86.2 → 83.4). 다만 방향오류율은
270
+ 5.50% → 4.08% 로 내려가 두 모델이 대등해진다(충돌 p=0.556).
271
+ - **애초에 동급이 아니다.** `qwen3.5:9b` 는 9.7B 중 **27블록이 vision tower** 다.
272
+ 파이썬 채널 문자열을 읽는 데 한 글자도 안 쓰인다. `qwen2.5-coder:7b` 는 7.6B 전부가
273
+ 코드 사전학습이다.
274
+ - **`verified` 필터가 정밀도의 값을 깎는다.** 3.5 는 확실할 때만 말한다(정밀도 90% vs 72.7%).
275
+ 좋은 성질이지만 여기서는 틀린 주장의 비용이 0이라, 신중함이 커버리지 손실로만 나타난다.
276
+
277
+ 일반 벤치마크에서 3.5 가 앞선다는 것과 모순되지 않는다. 여기서 재는 것은 추론력이 아니라
278
+ "ROS 파이썬에서 토픽 문자열과 pub/sub 방향 뽑기" 이고, 3.5 의 두 주무기가
279
+ 각각 봉쇄되거나 무가치화된다.
280
+
281
+ **미검증 변수 하나** — 프롬프트는 소형 모델에 맞춰 코드를 먼저 주고 지시를 뒤에 붙인 구조다
282
+ (`llm.mjs` 의 `summaryPrompt` 주석). 3.5 에 맞춰 다시 쓰면 결과가 달라질 수 있다.
283
+
284
+ ### 모델 비교 하네스
285
+
286
+ ```bash
287
+ node app/eval/edges.mjs <대상저장소> [--files N] # 기본 20파일
288
+ AXMAP_MODEL=<다른모델> node app/eval/edges.mjs <같은저장소>
289
+ node app/eval/edges.mjs --compare
290
+ ```
291
+
292
+ 정답 세트가 없어도 판정이 된다. 서로 독립된 기준이 이미 두 개 있기 때문이다 —
293
+ `verified`(문자열 대조)와 `channelsOf`(정적 추출). 재는 것은 "모델이 똑똑한가"가 아니라
294
+ **정적 파싱 위에 모델이 무엇을 더 얹는가**다. 대상은 채널이 실제로 배선된 저장소여야 한다.
295
+
296
+ 읽는 순서는 **`형식위반`이 0인지 먼저**다. 0이 아니면 품질이 아니라 배관 문제를 보고 있는 것이다.
297
+
298
+ 그리고 **확률적 모델은 1회 측정이 무의미하다.** 실제로 20파일 × 2회에서는 3.5 가 21%
299
+ 앞서는 것처럼 보였고, 40파일 × 5회로 늘리자 부호가 반대로 나왔다.
300
+
301
+ ---
302
+
303
+ ## 지금 되는 것 / 안 되는 것
304
+
305
+ | | 상태 |
306
+ |---|---|
307
+ | 디렉터리 트리 · 필터 | ✅ |
308
+ | 토픽/import 그래프, 허브 분리 | ✅ |
309
+ | 시작점 기준 깊이 명암 | ✅ |
310
+ | 300줄 초과 파일의 함수 목록 | ✅ (정규식 추출 — 진짜 파서 아님) |
311
+ | 실시간 감시 (선점 · 수정 · 선언 밖) | ✅ |
312
+ | 터미널 | ✅ (pty 아님 — 아래 참조) |
313
+ | LLM 요약 (스트리밍) · 캐시 · 워밍업 | ✅ (Ollama 있을 때) |
314
+ | 깊이 1~3 prefetch · 취소 | ✅ |
315
+ | LLM 엣지 독해 + 문자열 검증 | ✅ API 만, 화면 미연결 |
316
+ | 결합도 추세 경고 | ⬜ API(`/api/coupling`)만 있음 |
317
+
318
+ ### 터미널로 실제로 되는 것 / 안 되는 것
319
+
320
+ **된다.** 대상 저장소를 작업 디렉터리로 PowerShell 이 진짜로 돈다. 확인한 것:
321
+
322
+ ```
323
+ $ git status --short 진짜 변경 목록 (빨간 D 까지 색으로)
324
+ $ python -c "print('한글')" 한글 정상 출력
325
+ $ Get-ChildItem ros2_ws/src 실제 디렉터리 목록
326
+ $ Get-Location C:\...\BE_robot
327
+ ```
328
+
329
+ ANSI 색은 직접 파싱해 그린다. 진행 막대(`\r`)도 줄을 다시 그리는 방식으로 처리한다.
330
+ 윈도우 콘솔이 기본 CP949 라 한글이 깨지므로 `[Console]::OutputEncoding` 과
331
+ `PYTHONIOENCODING` 을 UTF-8 로 잡아 넣는다.
332
+
333
+ **안 된다.** `spawn` 이지 pty 가 아니다.
334
+
335
+ - 대화형 TUI (`vim`, `htop`, `less`) — 화면 제어 시퀀스를 흉내내지 않는다
336
+ - 입력이 필요한 명령 (`ssh` 비밀번호, `git rebase -i`) — stdin 이 연결돼 있지 않다
337
+ - `Ctrl+C` 로 중단 — 브라우저 탭을 닫으면 프로세스는 죽는다
338
+ - 명령 사이에 상태가 남지 않는다 (`cd` 후 다음 명령은 다시 원래 위치)
339
+
340
+ 필요해지면 `node-pty` + `xterm.js` 로 교체하면 되고 화면 구조는 그대로다.
341
+
342
+ ### 그 밖의 한계
343
+ - **함수 목록은 정규식이다.** `def`/`class` 를 줄 단위로 찾는다. 데코레이터·중첩·멀티라인
344
+ 시그니처에서 틀린다. 화면에 "정규식 추출"이라고 적어 확신을 속이지 않는다.
345
+ - **백업 디렉터리를 자동 제외한다.** `backup|snapshot|.bak|-old|archive` 가 경로에 있으면 건너뛴다.
346
+ e101 의 `orin-live-backup/` 처럼 같은 파일이 수십 벌 있으면 그래프가 복제본으로 뒤덮인다.
347
+ - **`.gitignore` 가 "선언 밖 수정"으로 잡힌다.** `axmap init` 자신이 고치기 때문이다. 오탐이다.
348
+
349
+ ## 왜 이 스택인가
350
+
351
+ - **웹 UI** — 그래프도 터미널도 웹 라이브러리가 압도적이다. 네이티브로 가면 둘 다 직접 만들어야 한다.
352
+ - **Electron 셸** — 배포는 `desktop/` 이 맡는다 (D16). 이 웹 UI 를 그대로 싣기 때문에
353
+ 프로토타입에서 버려지는 것이 없다. 브라우저로 여는 길도 그대로 산다 —
354
+ `node app/server.mjs` 는 죽지 않는다.
355
+ - **번들러 없음** — 브라우저 ES 모듈만 쓴다. 빌드 단계가 없어 저장하고 새로고침하면 끝이다.
356
+ - **코어 외부 의존성 0** — 힘 기반 그래프도 직접 그린다. 이유는 **폐쇄망에서 npm 이 안 되기
357
+ 때문이 아니다.** 앱은 번들되어 나가므로 설치본에는 의존성이 몇 개든 상관없다. 진짜 이유는 둘이다.
358
+ - **감사 표면.** 이 도구는 누가 어떤 코드를 커밋할 수 있는지를 판정하고 git 자격증명을 들고
359
+ 실행된다. `src/protocol.mjs` 가 도는 프로세스에 검토 안 된 코드가 같이 올라가면
360
+ 도구가 지키려는 성질을 도구 자신이 깬다.
361
+ - **승인 절차.** 폐쇄망 조직은 서드파티 패키지마다 검토·화이트리스트를 요구한다.
362
+ 막히는 것은 네트워크가 아니라 결재다.
363
+
364
+ 여기에 더해, 시각 인코딩이 전부 이 프로젝트 고유라 범용 라이브러리를 써도 대부분을 덮어써야 한다.
365
+ 의존성을 쓰는 자리는 `desktop/` 이고, 경계는 **import 방향 하나**로 지킨다 —
366
+ `desktop/` → 코어는 되고 반대는 안 된다.
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 모델 비교 하네스 — 엣지 독해만 잰다.
4
+ *
5
+ * "모델을 바꾸면 이득이 있나" 에 감상 대신 숫자로 답하기 위한 것이다.
6
+ * 요약문 품질은 기계로 잴 수 없다. 엣지 독해는 잴 수 있는데, 정답 세트가 없어도
7
+ * 서로 독립된 판정 기준이 이미 두 개 있기 때문이다.
8
+ *
9
+ * · verified — 모델이 말한 채널이 파일에 문자열로 실재하는가 (llm.mjs, D5)
10
+ * · 정적 추출 — analyze.mjs 의 channelsOf 가 같은 채널을 찾았는가, 방향은 뭐라 했는가
11
+ *
12
+ * 그래서 이 하네스가 재는 것은 "모델이 똑똑한가" 가 아니라
13
+ * **정적 파싱 위에 모델이 무엇을 더 얹어주는가** 다. README 의 표현대로
14
+ * 모델이 더해주는 것은 방향과 문서에만 적힌 흐름이고, 그 둘이 여기서 숫자가 된다.
15
+ *
16
+ * 사용:
17
+ * AXMAP_MODEL=qwen2.5-coder:7b node app/eval/edges.mjs <대상저장소>
18
+ * AXMAP_MODEL=<다른모델> node app/eval/edges.mjs <대상저장소>
19
+ * node app/eval/edges.mjs --compare
20
+ *
21
+ * 캐시는 모델별로 갈리므로(llm.mjs 의 key) 같은 모델을 다시 돌리면 즉시 끝난다.
22
+ * 모델이 다르면 캐시가 겹치지 않는다 — 비교가 오염되지 않는 이유다.
23
+ */
24
+
25
+ import fs from 'node:fs'
26
+ import path from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+ import { scan, channelsOf } from '../lib/analyze.mjs'
29
+ import { status, warmup, readEdges } from '../lib/llm.mjs'
30
+
31
+ const OUT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'out')
32
+
33
+ const slug = (model) => model.replace(/[^A-Za-z0-9._-]/g, '_')
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // 한 파일의 측정
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * @param st Map<channel, 'pub'|'sub'|'unknown'|'both'> 정적 추출 결과
41
+ * @param res readEdges 의 반환
42
+ */
43
+ function measure(file, st, res, ms) {
44
+ const edges = res.edges ?? []
45
+ const verified = edges.filter((e) => e.verified)
46
+ const seen = new Set(verified.map((e) => e.channel))
47
+
48
+ let dirResolved = 0 // 정적이 방향을 확정 못 한 채널에 모델이 방향을 준 것 ← 부가가치
49
+ let dirAgree = 0 // 정적도 확신했고 모델도 같은 방향
50
+ let dirConflict = 0 // 정적은 확신했는데 모델이 반대 ← 둘 중 하나는 틀렸다
51
+ let beyondStatic = 0 // 정적이 아예 못 본 채널인데 파일에 실재 ← 부가가치
52
+
53
+ for (const e of verified) {
54
+ if (!st.has(e.channel)) {
55
+ beyondStatic++
56
+ continue
57
+ }
58
+ const sd = st.get(e.channel)
59
+ // 'both' 은 정적이 한 방향으로 확정하지 못한 상태이므로 unknown 과 같이 센다
60
+ if (sd === 'unknown' || sd === 'both') dirResolved++
61
+ else if (sd === e.direction) dirAgree++
62
+ else dirConflict++
63
+ }
64
+
65
+ return {
66
+ path: file.path,
67
+ lines: file.lines,
68
+ staticCount: st.size,
69
+ claimed: edges.length,
70
+ verified: verified.length,
71
+ hallucinated: edges.length - verified.length,
72
+ dirResolved,
73
+ dirAgree,
74
+ dirConflict,
75
+ beyondStatic,
76
+ staticMissed: [...st.keys()].filter((ch) => !seen.has(ch)).length,
77
+ parseFailed: res.parseFailed === true,
78
+ cached: res.cached === true,
79
+ ms,
80
+ }
81
+ }
82
+
83
+ const SUM_KEYS = [
84
+ 'staticCount', 'claimed', 'verified', 'hallucinated',
85
+ 'dirResolved', 'dirAgree', 'dirConflict', 'beyondStatic', 'staticMissed',
86
+ ]
87
+
88
+ function totalsOf(rows) {
89
+ const t = Object.fromEntries(SUM_KEYS.map((k) => [k, 0]))
90
+ for (const r of rows) for (const k of SUM_KEYS) t[k] += r[k]
91
+ t.files = rows.length
92
+ t.parseFailedFiles = rows.filter((r) => r.parseFailed).length
93
+ const fresh = rows.filter((r) => !r.cached)
94
+ t.freshFiles = fresh.length
95
+ t.msMedian = fresh.length
96
+ ? fresh.map((r) => r.ms).sort((a, b) => a - b)[Math.floor(fresh.length / 2)]
97
+ : null
98
+ return t
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // 실행
103
+ // ---------------------------------------------------------------------------
104
+
105
+ async function run(root, nFiles) {
106
+ const s = await status()
107
+ // 여기서 통과시키면 "0개 찾음" 이 모델 탓인지 Ollama 탓인지 영영 구분되지 않는다.
108
+ if (!s.available) {
109
+ console.error(`Ollama 에 연결할 수 없습니다 (${s.host}). 켜고 다시 실행하세요.`)
110
+ process.exit(1)
111
+ }
112
+ if (!s.modelReady) {
113
+ console.error(`모델 ${s.model} 이 없습니다. 받은 것: ${s.models.join(', ') || '(없음)'}`)
114
+ console.error(` ollama pull ${s.model}`)
115
+ process.exit(1)
116
+ }
117
+
118
+ const all = scan(root).filter((f) => !f.backup)
119
+ if (!all.length) {
120
+ console.error(`${root} 에서 분석할 파일을 찾지 못했습니다.`)
121
+ process.exit(1)
122
+ }
123
+
124
+ // 채널이 많은 파일부터 본다. 재는 대상이 엣지 독해이므로 채널이 0인 파일을
125
+ // 아무리 돌려도 판정에 기여하지 않는다. 대신 몇 개를 안 봤는지 반드시 찍는다.
126
+ const ranked = all
127
+ .map((f) => ({ f, st: channelsOf(f) }))
128
+ .sort((a, b) => b.st.size - a.st.size || a.f.path.localeCompare(b.f.path))
129
+ const picked = ranked.slice(0, nFiles)
130
+
131
+ console.log(`모델 ${s.model}`)
132
+ console.log(`대상 ${root}`)
133
+ console.log(`파일 ${picked.length}개 (전체 ${ranked.length}개 중 채널 많은 순, ${ranked.length - picked.length}개 안 봄)\n`)
134
+
135
+ await warmup() // 콜드 스타트 44초를 첫 파일의 측정치에 섞지 않는다
136
+
137
+ const rows = []
138
+ for (const { f, st } of picked) {
139
+ const t0 = performance.now()
140
+ const res = await readEdges(f.path, f.text)
141
+ rows.push(measure(f, st, res, Math.round(performance.now() - t0)))
142
+ process.stderr.write(`\r ${rows.length}/${picked.length} ${f.path}`.padEnd(78).slice(0, 78))
143
+ }
144
+ process.stderr.write('\r'.padEnd(79) + '\r')
145
+
146
+ report(rows, s.model, root)
147
+ }
148
+
149
+ function report(rows, model, root) {
150
+ const t = totalsOf(rows)
151
+
152
+ const head = ['파일', '정적', '주장', '실재', '환각', '방향+', '일치', '충돌', '정적밖', '누락', 'ms']
153
+ const w = [38, 4, 4, 4, 4, 5, 4, 4, 6, 4, 6]
154
+ const line = (cells) => cells.map((c, i) => (i === 0 ? String(c).padEnd(w[i]) : String(c).padStart(w[i]))).join(' ')
155
+
156
+ console.log(line(head))
157
+ console.log('-'.repeat(w.reduce((a, b) => a + b + 1, -1)))
158
+ for (const r of rows) {
159
+ const name = r.path.length > w[0] ? '…' + r.path.slice(-(w[0] - 1)) : r.path
160
+ console.log(line([
161
+ name, r.staticCount, r.claimed, r.verified, r.hallucinated,
162
+ r.dirResolved, r.dirAgree, r.dirConflict, r.beyondStatic, r.staticMissed,
163
+ r.cached ? 'cache' : r.ms,
164
+ ]) + (r.parseFailed ? ' ← 형식위반' : ''))
165
+ }
166
+
167
+ const pct = (n, d) => (d ? `${((n / d) * 100).toFixed(1)}%` : '—')
168
+ console.log(`\n합계 ${t.files}개 파일`)
169
+ console.log(` 주장 ${t.claimed} · 실재 ${t.verified} · 환각 ${t.hallucinated} (${pct(t.hallucinated, t.claimed)})`)
170
+ console.log(` 정적 위에 더한 것 : 방향 채움 ${t.dirResolved} + 정적 밖 채널 ${t.beyondStatic} = ${t.dirResolved + t.beyondStatic}`)
171
+ console.log(` 정적과 충돌 : ${t.dirConflict}`)
172
+ console.log(` 정적이 찾은 걸 놓침: ${t.staticMissed} / ${t.staticCount} (${pct(t.staticMissed, t.staticCount)})`)
173
+ console.log(` 새로 추론한 파일 ${t.freshFiles}개, 중앙값 ${t.msMedian ?? '—'}ms`)
174
+
175
+ if (t.parseFailedFiles) {
176
+ console.log(`\n⚠ JSON 형식 위반 ${t.parseFailedFiles}개 파일.`)
177
+ console.log(` 위 숫자는 믿을 수 없다. 모델이 사고 토큰을 뱉거나 num_predict 한도에 잘렸을 가능성이 크다.`)
178
+ console.log(` 프롬프트/num_predict 를 그 모델에 맞추기 전까지는 품질 비교가 아니라 배관 문제다.`)
179
+ }
180
+
181
+ fs.mkdirSync(OUT_DIR, { recursive: true })
182
+ const file = path.join(OUT_DIR, `${slug(model)}.json`)
183
+ fs.writeFileSync(file, JSON.stringify({ model, root, at: new Date().toISOString(), totals: t, rows }, null, 2))
184
+ console.log(`\n기록 ${path.relative(process.cwd(), file)}`)
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // 비교
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function compare() {
192
+ let names = []
193
+ try {
194
+ names = fs.readdirSync(OUT_DIR).filter((n) => n.endsWith('.json'))
195
+ } catch {
196
+ /* 아래에서 안내한다 */
197
+ }
198
+ if (names.length < 2) {
199
+ console.error(`비교하려면 리포트가 2개 이상 필요합니다 (지금 ${names.length}개).`)
200
+ console.error(` AXMAP_MODEL=<모델> node app/eval/edges.mjs <대상저장소> 를 모델별로 돌리세요.`)
201
+ process.exit(1)
202
+ }
203
+
204
+ const reps = names.map((n) => JSON.parse(fs.readFileSync(path.join(OUT_DIR, n), 'utf8')))
205
+ const roots = new Set(reps.map((r) => r.root))
206
+ if (roots.size > 1) {
207
+ // 대상이 다르면 숫자를 나란히 놓는 것 자체가 거짓말이다
208
+ console.error(`대상 저장소가 서로 다릅니다: ${[...roots].join(' / ')}`)
209
+ console.error(`같은 대상으로 다시 돌리거나 app/eval/out/ 을 비우세요.`)
210
+ process.exit(1)
211
+ }
212
+
213
+ const rows = reps.map((r) => ({
214
+ model: r.model,
215
+ 환각률: r.totals.claimed ? `${((r.totals.hallucinated / r.totals.claimed) * 100).toFixed(1)}%` : '—',
216
+ 부가가치: r.totals.dirResolved + r.totals.beyondStatic,
217
+ 충돌: r.totals.dirConflict,
218
+ 누락: r.totals.staticMissed,
219
+ 형식위반: r.totals.parseFailedFiles,
220
+ 'ms(중앙)': r.totals.msMedian ?? '—',
221
+ }))
222
+ console.log(`대상 ${reps[0].root} · 파일 ${reps[0].totals.files}개\n`)
223
+ console.table(rows)
224
+ console.log(`부가가치 = 정적이 방향을 확정 못 한 채널에 방향을 준 수 + 정적이 못 본 실재 채널 수`)
225
+ console.log(`형식위반이 0이 아닌 모델의 숫자는 품질이 아니라 배관 문제를 보고 있는 것이다.`)
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+
230
+ const args = process.argv.slice(2)
231
+ if (args.includes('--compare')) {
232
+ compare()
233
+ } else {
234
+ const root = args.find((a) => !a.startsWith('--'))
235
+ if (!root) {
236
+ console.error('사용: AXMAP_MODEL=<모델> node app/eval/edges.mjs <대상저장소> [--files N]')
237
+ console.error(' node app/eval/edges.mjs --compare')
238
+ process.exit(1)
239
+ }
240
+ const i = args.indexOf('--files')
241
+ await run(root, i >= 0 ? Number(args[i + 1]) : 20)
242
+ }