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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 진입점 — "어디서부터 읽어야 하나".
|
|
3
|
+
*
|
|
4
|
+
* 왜 필요한가. 신입 온보딩 관찰에서 나온 지적이다.
|
|
5
|
+
* 그래프가 전부 그려지는데 **무엇을 먼저 봐야 하는지는 아무도 말해주지 않는다.**
|
|
6
|
+
* 노드 629개를 다 그리는 것은 "여기 다 있습니다" 이지 "여기부터 보세요" 가 아니다.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 점수 하나로 줄 세우지 않는다.
|
|
9
|
+
*
|
|
10
|
+
* "중요도 = 0.4·빈도 + 0.3·연결도 + 0.3·크기" 같은 합성 점수를 만들고 싶은
|
|
11
|
+
* 유혹이 크지만, 그 가중치를 정당화할 근거가 없다. 근거 없는 숫자로 순위를
|
|
12
|
+
* 매기면 사용자는 그것이 측정값인 줄 안다 — 이 도구에서 가장 나쁜 실패다 (D5).
|
|
13
|
+
*
|
|
14
|
+
* 대신 **각각 다른 질문에 답하는 목록 넷**을 준다. 질문이 다르면 답도 다르고,
|
|
15
|
+
* 사용자는 자기 상황에 맞는 질문을 고르면 된다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} graph build() 결과 {nodes, edges}
|
|
20
|
+
* @param {object[]} overlayEdges origin 이 붙은 엣지
|
|
21
|
+
* @param {Object<string,number>} freq 파일별 등장 커밋 수
|
|
22
|
+
*/
|
|
23
|
+
export function entryPoints(graph, overlayEdges, freq, { limit = 12, isolatedMaxFreq = 5 } = {}) {
|
|
24
|
+
// 🔴 정적 이웃과 공변경 이웃을 따로 센다.
|
|
25
|
+
//
|
|
26
|
+
// 예전에는 합계 하나만 셌다. 그러면 두 가지가 동시에 틀린다 —
|
|
27
|
+
// ① "고치면 파급이 큰 곳" 이 사실상 "같이 자주 바뀌는 곳" 이 된다.
|
|
28
|
+
// flask 에서 전체 781 엣지 중 568이 공변경이었다. 두 축을 나누는 것이
|
|
29
|
+
// 이 도구의 핵심 주장인데 정작 진입 목록에서 합쳐져 있었다.
|
|
30
|
+
// ② "아무와도 안 이어진 곳" 의 설명문은 "정적 엣지 0개" 라고 적어놓고
|
|
31
|
+
// 계산은 합계를 썼다. flask 에서 6개 전부 오탐이었고, 정작 진짜
|
|
32
|
+
// 정적 고립인 `docs/conf.py`(커밋 78회)는 목록에서 빠졌다.
|
|
33
|
+
// 설명과 계산이 다르면 설명이 거짓말이 된다.
|
|
34
|
+
const degStatic = new Map()
|
|
35
|
+
const hidden = new Map()
|
|
36
|
+
for (const e of overlayEdges) {
|
|
37
|
+
const isCo = e.origin === 'cochange'
|
|
38
|
+
const bump = (m, k) => m.set(k, (m.get(k) ?? 0) + 1)
|
|
39
|
+
if (!isCo) { bump(degStatic, e.source); bump(degStatic, e.target) }
|
|
40
|
+
else { bump(hidden, e.source); bump(hidden, e.target) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const info = (n) => ({
|
|
44
|
+
path: n.id,
|
|
45
|
+
lines: n.lines,
|
|
46
|
+
freq: freq[n.id] ?? 0,
|
|
47
|
+
deg: degStatic.get(n.id) ?? 0,
|
|
48
|
+
hidden: hidden.get(n.id) ?? 0,
|
|
49
|
+
})
|
|
50
|
+
const all = graph.nodes.map(info)
|
|
51
|
+
|
|
52
|
+
const top = (rows, key, min = 1) =>
|
|
53
|
+
rows.filter((r) => r[key] >= min).sort((a, b) => b[key] - a[key] || b.lines - a.lines).slice(0, limit)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 고립 노드 — 죽은 코드 후보.
|
|
57
|
+
*
|
|
58
|
+
* 이 목록은 **실제로 성과를 냈다.** 컨텍스트 없는 에이전트가 이 저장소를
|
|
59
|
+
* 처음 볼 때, `ansi.js`·`highlight.js` 가 deg 0 으로 뜬 것을 단서로
|
|
60
|
+
* 죽은 코드 199줄과 낡은 문서를 연쇄로 찾아냈다.
|
|
61
|
+
*
|
|
62
|
+
* 다만 **정적 파싱이 못 보는 연결이 있다**는 것을 함께 말해야 한다.
|
|
63
|
+
* 같은 저장소에서 `mcp/server.mjs` 도 고립으로 나오는데, 그건 오답이다 —
|
|
64
|
+
* 자식 프로세스로 CLI 를 부르므로 import 가 없을 뿐 실제로는 이어져 있다.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* 🔴 커밋 빈도로 한 번 더 거른다.
|
|
68
|
+
*
|
|
69
|
+
* syft 에서 이 목록이 3/3 오탐이었고, 실질적으로 "저장소에서 가장 큰 파일 12개"
|
|
70
|
+
* 였다 — `lines` 내림차순으로만 정렬했기 때문이다. 그중 하나는
|
|
71
|
+
* `binary/classifier_cataloger_test.go` 로 **커밋 76개에 import 14개**였다.
|
|
72
|
+
*
|
|
73
|
+
* `freq` 는 이미 같은 행에 표시돼 있었는데 거르는 데 쓰지 않았다.
|
|
74
|
+
* 활발하게 바뀌는 파일은 죽은 코드가 아니다 — 그건 우리가 못 읽은 파일이다.
|
|
75
|
+
* 그리고 크기가 아니라 **커밋이 적은 순**으로 정렬한다. "안 쓰이는 것"을
|
|
76
|
+
* 찾는 목록에서 크기순 정렬은 질문과 무관하다.
|
|
77
|
+
*/
|
|
78
|
+
const isolated = all
|
|
79
|
+
.filter((r) => r.deg === 0 && r.freq <= isolatedMaxFreq)
|
|
80
|
+
.sort((a, b) => a.freq - b.freq || b.lines - a.lines)
|
|
81
|
+
.slice(0, limit)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
lists: [
|
|
85
|
+
{
|
|
86
|
+
key: 'churn',
|
|
87
|
+
title: '가장 자주 바뀌는 곳',
|
|
88
|
+
question: '지금 개발이 활발한 영역은 어디인가',
|
|
89
|
+
hint: '커밋에 자주 등장한 순서. 팀이 실제로 시간을 쓰는 자리다.',
|
|
90
|
+
metric: 'freq',
|
|
91
|
+
unit: '커밋',
|
|
92
|
+
rows: top(all, 'freq'),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: 'hub',
|
|
96
|
+
title: '가장 많이 연결된 곳',
|
|
97
|
+
question: '고치면 파급이 큰 곳은 어디인가',
|
|
98
|
+
hint: 'import 로 이어진 이웃 수(공변경은 안 센다 — 그건 아래 목록이다). 여기를 건드리면 확인할 것이 많다.',
|
|
99
|
+
metric: 'deg',
|
|
100
|
+
unit: 'import 이웃',
|
|
101
|
+
rows: top(all, 'deg'),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'risk',
|
|
105
|
+
title: '숨은 결합이 많은 곳',
|
|
106
|
+
question: '리뷰에서 사고가 날 확률이 높은 곳은 어디인가',
|
|
107
|
+
hint: 'import 없이 함께 바뀌는 이웃 수. 코드를 읽어서는 안 보이는 연결이다.',
|
|
108
|
+
metric: 'hidden',
|
|
109
|
+
unit: '숨은 이웃',
|
|
110
|
+
rows: top(all, 'hidden'),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: 'isolated',
|
|
114
|
+
title: '아무와도 안 이어진 곳',
|
|
115
|
+
question: '죽은 코드이거나, 파서가 못 보는 연결이다',
|
|
116
|
+
/**
|
|
117
|
+
* 🔴 개발자용 근거를 사용자 문구에 섞지 않는다.
|
|
118
|
+
*
|
|
119
|
+
* 예전에는 여기에 "flask 6/6, syft 3/3 이 오탐이었다" 가 붙어 있었다.
|
|
120
|
+
* 우리에게는 이 목록을 믿지 말라는 강력한 증거지만, 화면에서는 **이
|
|
121
|
+
* 저장소와 아무 상관 없는 남의 프로젝트 이름** 두 개일 뿐이다.
|
|
122
|
+
* 5회차 온보딩 실험에서 에이전트가 "해석 못 한 라벨" 로 꼽았다.
|
|
123
|
+
*
|
|
124
|
+
* 실측 근거는 여기 주석에 남긴다 — flask 6개 전부, syft 3개 전부가
|
|
125
|
+
* 오탐이었다. 그래서 이 목록은 후보이지 판정이 아니다.
|
|
126
|
+
*/
|
|
127
|
+
hint: 'import 엣지 0개이면서 커밋도 5회 이하. 실제로 죽었을 수도 있고, 파서가 못 푼 import 이거나(자식 프로세스·리플렉션·생성 파일) 애초에 import 가 없는 파일(패키지 마커·데이터 테이블)일 수도 있다. 이 목록은 후보이지 판정이 아니다 — 반드시 열어서 확인하라.',
|
|
128
|
+
metric: 'freq',
|
|
129
|
+
unit: '커밋',
|
|
130
|
+
rows: isolated,
|
|
131
|
+
},
|
|
132
|
+
].filter((l) => l.rows.length > 0),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 밀집 완화 — 처음에 무엇을 그릴지 고른다.
|
|
138
|
+
*
|
|
139
|
+
* 노드 629개에 엣지 2,500개를 한꺼번에 그리면 "여기 다 있습니다" 라는 말밖에
|
|
140
|
+
* 못 한다. 사람은 그 화면에서 아무것도 못 읽는다.
|
|
141
|
+
*
|
|
142
|
+
* 🔴 무작위로 자르거나 상위 N개만 남기면 **끊긴 조각들**이 남는다.
|
|
143
|
+
* 이웃 없이 떠 있는 점은 정보가 아니라 노이즈다.
|
|
144
|
+
* 그래서 씨앗을 고른 뒤 **그 이웃까지 함께** 데려온다.
|
|
145
|
+
*
|
|
146
|
+
* 자른 사실은 반드시 알린다. 조용한 절단은 "전부 봤다"로 읽힌다.
|
|
147
|
+
*/
|
|
148
|
+
export function coreSubset(nodes, edges, freq, { budget = 90 } = {}) {
|
|
149
|
+
if (nodes.length <= budget) return { ids: null, seeds: 0, reason: null }
|
|
150
|
+
|
|
151
|
+
const deg = new Map()
|
|
152
|
+
for (const e of edges) {
|
|
153
|
+
deg.set(e.source, (deg.get(e.source) ?? 0) + 1)
|
|
154
|
+
deg.set(e.target, (deg.get(e.target) ?? 0) + 1)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 씨앗은 "자주 바뀌면서 많이 이어진" 노드다. 두 축 각각의 순위를 더해 고른다 —
|
|
158
|
+
// 값 자체를 더하면 단위가 달라 한쪽이 다른 쪽을 삼킨다(커밋 160 vs 이웃 12).
|
|
159
|
+
const byFreq = [...nodes].sort((a, b) => (freq[b.id] ?? 0) - (freq[a.id] ?? 0))
|
|
160
|
+
const byDeg = [...nodes].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0))
|
|
161
|
+
const rank = new Map()
|
|
162
|
+
byFreq.forEach((n, i) => rank.set(n.id, i))
|
|
163
|
+
byDeg.forEach((n, i) => rank.set(n.id, (rank.get(n.id) ?? 0) + i))
|
|
164
|
+
|
|
165
|
+
const seedCount = Math.max(10, Math.floor(budget / 4))
|
|
166
|
+
const seeds = [...nodes].sort((a, b) => rank.get(a.id) - rank.get(b.id)).slice(0, seedCount)
|
|
167
|
+
|
|
168
|
+
const nb = new Map()
|
|
169
|
+
for (const e of edges) {
|
|
170
|
+
if (!nb.has(e.source)) nb.set(e.source, [])
|
|
171
|
+
if (!nb.has(e.target)) nb.set(e.target, [])
|
|
172
|
+
nb.get(e.source).push(e.target)
|
|
173
|
+
nb.get(e.target).push(e.source)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ids = new Set(seeds.map((n) => n.id))
|
|
177
|
+
// 씨앗의 이웃을 예산까지 채운다. 이웃을 많이 가진 씨앗부터 채워야
|
|
178
|
+
// 끊긴 조각 대신 하나의 덩어리가 남는다.
|
|
179
|
+
for (const s of seeds) {
|
|
180
|
+
for (const q of nb.get(s.id) ?? []) {
|
|
181
|
+
if (ids.size >= budget) break
|
|
182
|
+
ids.add(q)
|
|
183
|
+
}
|
|
184
|
+
if (ids.size >= budget) break
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
ids,
|
|
189
|
+
seeds: seeds.length,
|
|
190
|
+
reason: `노드 ${nodes.length}개는 한 화면에서 읽을 수 없습니다. 자주 바뀌면서 많이 이어진 ${seeds.length}개와 그 이웃 ${ids.size - seeds.length}개만 그렸습니다.`,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 기능 단위 그래프 — 파일이 아니라 **사람의 말**로 코드를 그린다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 ────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 지금까지 화면의 노드는 파일이었다. `AuthService.java` · `authStore.ts`.
|
|
7
|
+
* 그 화면은 **읽을 줄 아는 사람에게만** 지도다.
|
|
8
|
+
*
|
|
9
|
+
* AI 가 코드를 쓰는 시대에 사람이 하는 일은 "무엇을 만들지 정하고, 그게
|
|
10
|
+
* 어디까지 영향을 주는지 아는 것" 쪽으로 옮겨간다. 그 일에는 파일 이름이
|
|
11
|
+
* 아니라 기능 이름이 필요하다. 비전공자가 화면을 보고
|
|
12
|
+
* **"웹 수집 쪽을 고치면 자연어 처리도 같이 움직이는구나"** 를 읽을 수 있어야 한다.
|
|
13
|
+
*
|
|
14
|
+
* ── 이름을 어디서 가져오나 ────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* 🔴 지어내지 않는다. **사람이 이미 써 놓은 문장**을 그대로 쓴다.
|
|
17
|
+
*
|
|
18
|
+
* 출처는 셋이고, 위에 있을수록 좋다. 화면은 이 차이를 숨기지 않는다.
|
|
19
|
+
*
|
|
20
|
+
* docs README 가 그 모듈을 부르는 말 "Data Mining" · "Machine Learning"
|
|
21
|
+
* commit 그 안에서 한 일의 커밋 제목 "순찰 지점 저장·조회 API"
|
|
22
|
+
* path 폴더 이름 "web" · "vector"
|
|
23
|
+
*
|
|
24
|
+
* LLM 에게 이름을 시키지 않는 이유는 features.mjs 의 판단과 같다 —
|
|
25
|
+
* 모델은 오탐 묶음에도 그럴듯한 이름을 붙여 준다. 여기서는 더 나쁘다:
|
|
26
|
+
* **비전공자는 그 이름이 맞는지 코드로 확인할 방법이 없다.** 틀린 이름을
|
|
27
|
+
* 그대로 믿게 된다. 그래서 출처가 유일한 검증 수단이고, 반드시 함께 낸다.
|
|
28
|
+
*
|
|
29
|
+
* ── 왜 커밋 제목만으로는 부족한가 (실측) ──────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* 처음에는 커밋 제목만으로 기능을 만들었다. clips/pattern 에서 21개 묶음이
|
|
32
|
+
* 나왔고 **전부 커밋 제목에서 이름을 얻었다.** 그런데 그 이름들이 이랬다 —
|
|
33
|
+
*
|
|
34
|
+
* "Use unicode literals, import from builtins..."
|
|
35
|
+
* "Make print statements Python 3 compatible"
|
|
36
|
+
*
|
|
37
|
+
* 파이썬 2→3 이식 작업이다. 커밋 제목은 "무엇을 **했나**" 이지
|
|
38
|
+
* "무엇**인가**" 가 아니다. 유지보수가 많은 저장소에서는 화면이 통째로
|
|
39
|
+
* 유지보수 기록이 된다.
|
|
40
|
+
*
|
|
41
|
+
* 그래서 **모듈을 주인공으로** 두고, 커밋 제목은 그 안에서 "무슨 일이
|
|
42
|
+
* 있었나" 로 붙인다. 둘 다 사람의 말이지만 답하는 질문이 다르다.
|
|
43
|
+
*
|
|
44
|
+
* ── 한계 (숨기지 않는다) ──────────────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* · README 에 기능 목록이 없으면 폴더 이름으로 떨어진다.
|
|
47
|
+
* · 히스토리가 짧으면 "무슨 일이 있었나" 가 빈다.
|
|
48
|
+
* · 모듈 경계가 폴더와 다른 저장소에서는 이 그림이 안 맞는다.
|
|
49
|
+
*
|
|
50
|
+
* 순수 함수만 둔다. git 호출과 파일 읽기는 server.mjs 가 한다.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { draft, nameFromSubject } from './features.mjs'
|
|
54
|
+
|
|
55
|
+
/** 한 커밋이 이 묶음의 것이라고 볼 만한 겹침 비율. */
|
|
56
|
+
const OVERLAP = 0.5
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 쌍을 Map 키로 만들 때 쓰는 구분자.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 소스에는 **이스케이프 표기**로 적는다. 진짜 NUL 바이트를 파일에 넣으면
|
|
62
|
+
* 도구마다 다르게 취급하고 diff 가 깨진다 (test/features.test.mjs 가 막는다).
|
|
63
|
+
* 경로에는 NUL 이 들어갈 수 없으므로 구분자로는 안전하다.
|
|
64
|
+
*/
|
|
65
|
+
const SEP = '\u0000'
|
|
66
|
+
|
|
67
|
+
/** 모듈 하나에 붙일 "무슨 일이 있었나" 개수. */
|
|
68
|
+
const WORKS_SHOWN = 5
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 라이브러리를 **쓰는** 쪽. 모듈로 치지 않는다.
|
|
72
|
+
* (flow.mjs 의 같은 판단 — 예제·테스트는 코드의 소비자다.)
|
|
73
|
+
*/
|
|
74
|
+
const CONSUMER_RE = /^(tests?|spec|__tests__|testing|examples?|samples?|demos?|tutorials?|docs?|documentation|website|scripts?|tools?|bench(marks?)?|\.github|ci)$/i
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// 모듈 나누기
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 파일들을 모듈로 나눈다.
|
|
82
|
+
*
|
|
83
|
+
* 🔴 소스 뿌리를 먼저 찾는다.
|
|
84
|
+
*
|
|
85
|
+
* `pattern/web` · `pattern/text` 처럼 코드가 한 폴더 아래 모여 있으면,
|
|
86
|
+
* 최상위로 나누면 모듈이 `pattern` 하나가 되어 그래프가 점 하나가 된다.
|
|
87
|
+
* 그래서 **소비자(예제·테스트·문서)를 뺀 코드**가 거의 다 한 폴더 아래 있으면
|
|
88
|
+
* 그 폴더를 뿌리로 보고 한 단계 더 들어간다.
|
|
89
|
+
*
|
|
90
|
+
* `src/` 도 같은 이유로 건너뛴다.
|
|
91
|
+
*/
|
|
92
|
+
export function modulesOf(paths, { dominant = 0.6 } = {}) {
|
|
93
|
+
const code = paths.filter((p) => !CONSUMER_RE.test(p.split('/')[0]))
|
|
94
|
+
if (!code.length) return { mods: new Map(), root: null }
|
|
95
|
+
|
|
96
|
+
const topCount = new Map()
|
|
97
|
+
for (const p of code) {
|
|
98
|
+
const t = p.includes('/') ? p.split('/')[0] : ''
|
|
99
|
+
topCount.set(t, (topCount.get(t) ?? 0) + 1)
|
|
100
|
+
}
|
|
101
|
+
const [topDir, n] = [...topCount].sort((a, b) => b[1] - a[1])[0]
|
|
102
|
+
// 뿌리로 인정하려면 폴더여야 하고(빈 문자열은 최상위 파일), 압도적이어야 한다.
|
|
103
|
+
const root = topDir && n / code.length >= dominant ? topDir : null
|
|
104
|
+
|
|
105
|
+
const mods = new Map()
|
|
106
|
+
for (const p of code) {
|
|
107
|
+
const seg = p.split('/')
|
|
108
|
+
let key
|
|
109
|
+
if (root) key = seg.length > 2 ? `${root}/${seg[1]}` : root
|
|
110
|
+
else key = seg.length > 1 ? seg[0] : '(최상위)'
|
|
111
|
+
if (!mods.has(key)) mods.set(key, [])
|
|
112
|
+
mods.get(key).push(p)
|
|
113
|
+
}
|
|
114
|
+
// 🔴 뿌리는 함께 돌려준다. 뿌리는 기능이 아니라 **담는 그릇**이다.
|
|
115
|
+
//
|
|
116
|
+
// README 의 설치 안내는 프로젝트 이름을 계속 부른다 —
|
|
117
|
+
// "Put the pattern folder in the same folder as your script."
|
|
118
|
+
// 뿌리 모듈에 문서 이름을 붙이면 그 문장이 기능 이름이 된다. 실제로 됐다.
|
|
119
|
+
return { mods, root }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// README 에서 이름 얻기
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** README 에서 "사람이 기능을 부르는 말" 이 될 만한 줄. 목록 항목과 제목. */
|
|
127
|
+
export function docLines(text) {
|
|
128
|
+
if (!text) return []
|
|
129
|
+
const out = []
|
|
130
|
+
for (const raw of text.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)) {
|
|
131
|
+
const t = raw.trim()
|
|
132
|
+
const bullet = t.match(/^[*\-+]\s+(.+)$/)
|
|
133
|
+
const heading = t.match(/^#{2,6}\s+(.+?)\s*#*$/)
|
|
134
|
+
/**
|
|
135
|
+
* 🔴 마크다운 링크는 **글자만** 남긴다.
|
|
136
|
+
*
|
|
137
|
+
* syft 에서 걸음⑤의 모듈 이름이 이렇게 나왔다 —
|
|
138
|
+
* "Works seamlessly with [Grype](https"
|
|
139
|
+
* 그리고 "출처 README.md" 배지까지 달았다. 원인은 아래 matchDocs 의
|
|
140
|
+
* 콜론 분리가 **URL 의 `https:` 를 "이름: 설명" 구분자로 읽은 것**이다.
|
|
141
|
+
*
|
|
142
|
+
* theHarvester 에서는 멀쩡했다. README 문체가 달랐을 뿐이고, 그것이
|
|
143
|
+
* 한 저장소에만 맞춰 놓았다는 증거다. 링크를 먼저 글자로 풀면 이름 짓기와
|
|
144
|
+
* 토큰 추출이 둘 다 정확해진다.
|
|
145
|
+
*/
|
|
146
|
+
let line = (bullet?.[1] ?? heading?.[1] ?? '')
|
|
147
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // 이미지: 통째로 버린다
|
|
148
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // 링크: 보이는 글자만 남긴다
|
|
149
|
+
.replace(/<[^>]+>/g, ' ') // 인라인 HTML
|
|
150
|
+
.replace(/https?:\/\/\S+/g, ' ') // 남은 맨 URL
|
|
151
|
+
.replace(/[*`_]/g, '')
|
|
152
|
+
.replace(/\s+/g, ' ')
|
|
153
|
+
.trim()
|
|
154
|
+
if (line.length < 4 || line.length > 200) continue
|
|
155
|
+
// 배지·링크만 있는 줄은 말이 아니다.
|
|
156
|
+
if (/^\[?!\[/.test(line)) continue
|
|
157
|
+
out.push({ line, kind: bullet ? 'bullet' : 'heading' })
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const STOP = new Set([
|
|
163
|
+
'the', 'and', 'for', 'with', 'from', 'this', 'that', 'has', 'are', 'its',
|
|
164
|
+
'you', 'can', 'all', 'use', 'using', 'used', 'tools', 'module', 'modules',
|
|
165
|
+
'library', 'python', 'javascript', 'java', 'api', 'apis', 'support', 'based',
|
|
166
|
+
'init', 'src', 'lib', 'core', 'common', 'util', 'utils', 'main', 'test', 'tests',
|
|
167
|
+
])
|
|
168
|
+
|
|
169
|
+
const tokens = (s) =>
|
|
170
|
+
s.toLowerCase().split(/[^a-z0-9가-힣]+/).filter((w) => w.length >= 3 && !STOP.has(w))
|
|
171
|
+
|
|
172
|
+
/** 모듈이 가진 말 — 폴더 이름과 그 안 파일·하위폴더 이름. */
|
|
173
|
+
function moduleWords(dir, paths) {
|
|
174
|
+
const w = new Set()
|
|
175
|
+
for (const seg of dir.split('/')) for (const t of tokens(seg)) w.add(t)
|
|
176
|
+
for (const p of paths) {
|
|
177
|
+
for (const seg of p.split('/').slice(0, -1)) for (const t of tokens(seg)) w.add(t)
|
|
178
|
+
const base = p.split('/').pop().replace(/\.[^.]+$/, '')
|
|
179
|
+
for (const t of tokens(base)) w.add(t)
|
|
180
|
+
}
|
|
181
|
+
return w
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* README 의 줄을 모듈에 붙인다.
|
|
186
|
+
*
|
|
187
|
+
* 🔴 애매하면 안 붙인다 (fail-closed).
|
|
188
|
+
*
|
|
189
|
+
* 한 줄이 두 모듈에 비슷하게 맞으면 어느 쪽인지 모르는 것이다. 그때 억지로
|
|
190
|
+
* 붙이면 비전공자에게 **틀린 이름을 확신 있게** 보여주게 된다 — 그 사람은
|
|
191
|
+
* 코드로 확인할 수 없으므로 그대로 믿는다. 이 도구에서 가장 나쁜 실패다.
|
|
192
|
+
*
|
|
193
|
+
* 점수는 "그 줄의 말 중 몇 개가 이 모듈의 이름들에 실제로 나오는가" 다.
|
|
194
|
+
* 1등이 2등보다 확실히 앞설 때만 붙인다.
|
|
195
|
+
*/
|
|
196
|
+
export function matchDocs(mods, lines, { looseHits = 2, margin = 1, skip = null } = {}) {
|
|
197
|
+
const words = new Map()
|
|
198
|
+
const own = new Map() // dir -> 그 모듈 자신의 이름 토큰
|
|
199
|
+
for (const [dir, paths] of mods) {
|
|
200
|
+
if (skip && dir === skip) continue // 뿌리는 기능이 아니다 (modulesOf 주석)
|
|
201
|
+
words.set(dir, moduleWords(dir, paths))
|
|
202
|
+
own.set(dir, new Set(tokens(dir.split('/').pop())))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const chosen = new Map() // dir -> {line, hits, label}
|
|
206
|
+
for (const { line } of lines) {
|
|
207
|
+
const lt = [...new Set(tokens(line))]
|
|
208
|
+
if (!lt.length) continue
|
|
209
|
+
const scored = [...words].map(([dir, w]) => ({
|
|
210
|
+
dir,
|
|
211
|
+
hits: lt.filter((t) => w.has(t)).length,
|
|
212
|
+
// 🔴 그 줄이 **모듈 이름 자체**를 부르는가. 이게 결정적이다.
|
|
213
|
+
//
|
|
214
|
+
// 처음에는 겹치는 단어 하나면 붙였다. axMap 에서 `app/lib` 에
|
|
215
|
+
// "Phase 4 · 시각화와 로컬 LLM" 이라는 개발 단계 제목이 붙었다 —
|
|
216
|
+
// `app/lib/llm.mjs` 때문에 "llm" 하나가 겹쳤을 뿐이다.
|
|
217
|
+
// 한 단어 겹침은 우연이다. 그리고 비전공자는 그 이름이 틀린 줄 모른다.
|
|
218
|
+
dirHit: lt.some((t) => own.get(dir).has(t)),
|
|
219
|
+
})).sort((a, b) => (b.dirHit - a.dirHit) || b.hits - a.hits)
|
|
220
|
+
|
|
221
|
+
const best = scored[0]
|
|
222
|
+
const second = scored[1]
|
|
223
|
+
// 모듈 이름을 직접 부르거나(강함), 아니면 겹치는 말이 충분히 많아야 한다.
|
|
224
|
+
if (!best || (!best.dirHit && best.hits < looseHits)) continue
|
|
225
|
+
if (second && second.dirHit === best.dirHit && best.hits - second.hits < margin) continue
|
|
226
|
+
|
|
227
|
+
// 이미 더 강한 줄이 붙어 있으면 두지 않는다.
|
|
228
|
+
const cur = chosen.get(best.dir)
|
|
229
|
+
if (cur && cur.hits >= best.hits) continue
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `Data Mining: web services, crawler` → 이름은 콜론 앞, 설명은 뒤.
|
|
233
|
+
*
|
|
234
|
+
* 🔴 URL 의 콜론을 구분자로 읽지 않는다. docLines 가 링크를 걷어내지만
|
|
235
|
+
* `mailto:` `note:` 같은 것이 남을 수 있으므로 여기서도 막는다 —
|
|
236
|
+
* 뒤가 `//` 로 시작하면 그건 설명이 아니라 주소다.
|
|
237
|
+
*/
|
|
238
|
+
// 🔴 전방탐색 안에 공백을 넣는다. 밖에 두면 `\s*` 가 빈 문자열로 물러나
|
|
239
|
+
// 검사를 우회한다 — `note: //x` 가 그대로 갈라졌다.
|
|
240
|
+
const m = line.match(/^([^::]{2,40})[::](?!\s*\/\/)\s*(.+)$/)
|
|
241
|
+
chosen.set(best.dir, {
|
|
242
|
+
label: (m?.[1] ?? line).trim(),
|
|
243
|
+
line,
|
|
244
|
+
detail: m?.[2]?.trim() ?? null,
|
|
245
|
+
hits: best.hits,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
return chosen
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// 커밋 제목에서 "무슨 일이 있었나"
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 이 파일 묶음에서 사람이 한 일을, 사람이 쓴 문장 그대로.
|
|
257
|
+
*
|
|
258
|
+
* 🔴 "이 파일을 건드린 모든 커밋" 이 아니라 **이 묶음이 주인공인 커밋**만 본다.
|
|
259
|
+
*
|
|
260
|
+
* 파일 하나만 스친 대형 커밋("의존성 일괄 업데이트")의 제목을 집으면
|
|
261
|
+
* 이름이 "chore: bump deps" 가 된다.
|
|
262
|
+
*/
|
|
263
|
+
export function worksIn(paths, commits, { overlap = OVERLAP, limit = WORKS_SHOWN } = {}) {
|
|
264
|
+
const set = new Set(paths)
|
|
265
|
+
const byText = new Map()
|
|
266
|
+
for (const c of commits) {
|
|
267
|
+
const files = [...new Set(c.files)]
|
|
268
|
+
if (!files.length) continue
|
|
269
|
+
const hit = files.filter((f) => set.has(f)).length
|
|
270
|
+
if (!hit || hit / files.length < overlap) continue
|
|
271
|
+
const name = nameFromSubject(c.subject)
|
|
272
|
+
if (!name) continue
|
|
273
|
+
const e = byText.get(name) ?? { subject: name, times: 0, hit: 0 }
|
|
274
|
+
e.times++
|
|
275
|
+
e.hit = Math.max(e.hit, hit)
|
|
276
|
+
byText.set(name, e)
|
|
277
|
+
}
|
|
278
|
+
return [...byText.values()]
|
|
279
|
+
.sort((a, b) => b.times - a.times || b.hit - a.hit || a.subject.length - b.subject.length)
|
|
280
|
+
.slice(0, limit)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 기능 그래프.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} input
|
|
289
|
+
* paths 그래프에 있는 파일 경로
|
|
290
|
+
* commits [{subject, files}]
|
|
291
|
+
* readme README 원문 (없으면 폴더 이름으로 떨어진다)
|
|
292
|
+
* edges 파일 단위 정적 엣지 (모듈 사이 import 를 세는 데 쓴다)
|
|
293
|
+
*/
|
|
294
|
+
export function featureGraph({ paths, commits = [], readme = null, edges = [] } = {}) {
|
|
295
|
+
const { mods, root } = modulesOf(paths)
|
|
296
|
+
const docs = matchDocs(mods, docLines(readme), { skip: root })
|
|
297
|
+
|
|
298
|
+
const nodes = []
|
|
299
|
+
for (const [dir, files] of mods) {
|
|
300
|
+
const d = docs.get(dir)
|
|
301
|
+
const works = worksIn(files, commits)
|
|
302
|
+
nodes.push({
|
|
303
|
+
id: dir,
|
|
304
|
+
name: d?.label ?? dir.split('/').pop(),
|
|
305
|
+
// 🔴 이름의 출처. 비전공자에게는 이것이 유일한 검증 수단이다.
|
|
306
|
+
nameSource: d ? 'docs' : 'path',
|
|
307
|
+
// README 가 그 모듈을 설명한 문장 그대로. 요약하지 않는다.
|
|
308
|
+
docLine: d?.line ?? null,
|
|
309
|
+
detail: d?.detail ?? null,
|
|
310
|
+
dir,
|
|
311
|
+
paths: files.sort(),
|
|
312
|
+
files: files.length,
|
|
313
|
+
lines: 0, // server 가 노드 정보를 알면 채운다
|
|
314
|
+
// 여기서 사람이 무슨 일을 했나 — 커밋 제목 그대로
|
|
315
|
+
works,
|
|
316
|
+
commits: 0,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const byPath = new Map()
|
|
321
|
+
for (const n of nodes) for (const p of n.paths) byPath.set(p, n.id)
|
|
322
|
+
|
|
323
|
+
// ── 엣지 ① 같은 커밋에서 함께 바뀐 모듈 ───────────────────────────────
|
|
324
|
+
const together = new Map()
|
|
325
|
+
const touched = new Map()
|
|
326
|
+
for (const c of commits) {
|
|
327
|
+
const hit = [...new Set(c.files.map((f) => byPath.get(f)).filter(Boolean))]
|
|
328
|
+
for (const h of hit) touched.set(h, (touched.get(h) ?? 0) + 1)
|
|
329
|
+
if (hit.length < 2) continue
|
|
330
|
+
// 🔴 대형 커밋은 버린다. 일괄 포맷팅 한 번이 모든 모듈을 서로 잇는다.
|
|
331
|
+
if (hit.length > 6) continue
|
|
332
|
+
for (let i = 0; i < hit.length; i++) {
|
|
333
|
+
for (let j = i + 1; j < hit.length; j++) {
|
|
334
|
+
const [a, b] = hit[i] < hit[j] ? [hit[i], hit[j]] : [hit[j], hit[i]]
|
|
335
|
+
together.set((a + SEP + b), (together.get((a + SEP + b)) ?? 0) + 1)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const n of nodes) n.commits = touched.get(n.id) ?? 0
|
|
340
|
+
|
|
341
|
+
// ── 엣지 ② 모듈 사이 import ───────────────────────────────────────────
|
|
342
|
+
const imports = new Map()
|
|
343
|
+
for (const e of edges) {
|
|
344
|
+
if (e.origin === 'cochange') continue
|
|
345
|
+
const a = byPath.get(e.source)
|
|
346
|
+
const b = byPath.get(e.target)
|
|
347
|
+
if (!a || !b || a === b) continue
|
|
348
|
+
const k = a < b ? (a + SEP + b) : (b + SEP + a)
|
|
349
|
+
imports.set(k, (imports.get(k) ?? 0) + 1)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const out = []
|
|
353
|
+
for (const k of new Set([...together.keys(), ...imports.keys()])) {
|
|
354
|
+
const [source, target] = k.split(SEP)
|
|
355
|
+
const n = together.get(k) ?? 0
|
|
356
|
+
const imp = imports.get(k) ?? 0
|
|
357
|
+
out.push({
|
|
358
|
+
source, target, n, imports: imp,
|
|
359
|
+
// D12 와 같은 축. 무엇으로 알아냈는지를 엣지마다 붙인다.
|
|
360
|
+
origin: n && imp ? 'both' : imp ? 'static' : 'cochange',
|
|
361
|
+
directed: false,
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
out.sort((a, b) => (b.n + b.imports) - (a.n + a.imports))
|
|
365
|
+
|
|
366
|
+
// 참고용으로 히스토리 묶음도 함께 낸다 (features.mjs 의 초안).
|
|
367
|
+
// 모듈이 주인공이지만, "이 저장소에서 최근 무슨 일이 있었나" 는 이쪽이 답한다.
|
|
368
|
+
const clusters = commits.length ? draft(commits, new Set(paths)) : { features: [], stats: {} }
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
nodes: nodes.sort((a, b) => b.files - a.files),
|
|
372
|
+
edges: out,
|
|
373
|
+
clusters: clusters.features.slice(0, 20).map((f) => ({
|
|
374
|
+
id: f.id,
|
|
375
|
+
name: worksIn(f.paths, commits)[0]?.subject ?? f.name,
|
|
376
|
+
paths: f.paths,
|
|
377
|
+
evidence: f.evidence,
|
|
378
|
+
})),
|
|
379
|
+
stats: {
|
|
380
|
+
modules: nodes.length,
|
|
381
|
+
namedFromDocs: nodes.filter((n) => n.nameSource === 'docs').length,
|
|
382
|
+
namedFromPath: nodes.filter((n) => n.nameSource === 'path').length,
|
|
383
|
+
withWorks: nodes.filter((n) => n.works.length).length,
|
|
384
|
+
coveredFiles: nodes.reduce((a, n) => a + n.files, 0),
|
|
385
|
+
totalFiles: paths.length,
|
|
386
|
+
docLines: docLines(readme).length,
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
}
|