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
package/app/lib/flow.mjs
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 기본 흐름 — "따라가다 보면 어느샌가 이해하게 되는" 순서 (D14 2단계, D15).
|
|
3
|
+
*
|
|
4
|
+
* 🔴 이 파일이 있기 전까지 이 도구는 **병렬 목록만** 줬다.
|
|
5
|
+
*
|
|
6
|
+
* `entryPoints()` 는 질문 넷에 각각 답하는 목록을 준다. 그건 좋은 답이지만
|
|
7
|
+
* **순서가 아니다.** "여기서부터 읽으세요" 라고 적어두고 정작 *그 다음에
|
|
8
|
+
* 무엇을* 은 말하지 않았다. 관찰자 4명 중 4명이 같은 자리에서 멈췄다.
|
|
9
|
+
*
|
|
10
|
+
* 흐름은 여섯 걸음이다.
|
|
11
|
+
*
|
|
12
|
+
* ① 이 저장소는 무엇을 하는 물건인가 ← 도구가 한 번도 답한 적 없다
|
|
13
|
+
* ② 실행은 어디서 시작하나
|
|
14
|
+
* ③ 그 다음에 무엇이 불리나 ← 정적 파싱이 살아 있어야 가능
|
|
15
|
+
* ④ 어디가 활발하고 위험한가 ← 이미 있던 것
|
|
16
|
+
* ⑤ 여기에 새로 만들려면 어디를 고치나 ← 첫 번째 '쓰는' 걸음
|
|
17
|
+
* ⑥ 이제 당신 차례 ← 3단계(함께 짜기)로 넘기는 다리
|
|
18
|
+
*
|
|
19
|
+
* ①②③ 이 비어 있었다. 이 파일이 그 셋을 채운다.
|
|
20
|
+
*
|
|
21
|
+
* ---
|
|
22
|
+
*
|
|
23
|
+
* 🔴 걸음마다 `gaps` 를 함께 낸다. 못 알아낸 것을 반드시 말한다.
|
|
24
|
+
*
|
|
25
|
+
* 이 프로젝트에서 실제로 발견된 가장 나쁜 버그는 전부 같은 모양이었다 —
|
|
26
|
+
* 정적 파싱이 Go·Java 에서 조용히 0개를 내놓고, 화면이 **그 침묵을 결과로**
|
|
27
|
+
* 제시했다. 흐름은 그보다 더 위험하다. 순서를 주면 사용자는 그것이
|
|
28
|
+
* 검증된 경로라고 믿고 따라가기 때문이다.
|
|
29
|
+
*
|
|
30
|
+
* 그래서 각 걸음은 근거(`evidence`)와 못 알아낸 것(`gaps`)을 함께 낸다.
|
|
31
|
+
* 근거가 없으면 그 걸음은 "모른다" 를 답으로 낸다. 추측을 답으로 내지 않는다.
|
|
32
|
+
*
|
|
33
|
+
* 🔴 한 걸음이 끝났다는 판정은 도구가 하지 않는다 (Q8 에 대한 답).
|
|
34
|
+
*
|
|
35
|
+
* 체크박스도, 읽은 시간도, 진도율도 두지 않는다. 이해했는지는 사람만 안다.
|
|
36
|
+
* 대신 걸음마다 `done` 에 **스스로 확인할 수 있는 문장**을 적는다 —
|
|
37
|
+
* "이 저장소를 한 문장으로 말할 수 있다" 처럼. 도구가 판정하는 순간
|
|
38
|
+
* 사용자는 이해가 아니라 진도율을 좇게 된다.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from 'node:fs'
|
|
42
|
+
import path from 'node:path'
|
|
43
|
+
// 겹침 판정은 프로토콜의 것을 그대로 쓴다. 여기서 다시 정의하면 화면과
|
|
44
|
+
// 락이 서로 다른 답을 하게 된다 (CLAUDE.md — 판정은 한 곳에서).
|
|
45
|
+
import { coversPath } from '../../src/protocol.mjs'
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// ① 이 저장소는 무엇을 하는 물건인가
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** README 로 인정할 파일. 위에 있을수록 우선. */
|
|
52
|
+
const README_NAMES = [
|
|
53
|
+
'README.md', 'README.rst', 'README.txt', 'README',
|
|
54
|
+
'readme.md', 'Readme.md', 'docs/README.md', 'docs/index.md',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* README 첫 문단에서 "무엇을 하는 물건인지" 를 뽑는다.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 배지·이미지·HTML 정렬 태그를 건너뛴다. 요즘 README 는 첫 10줄이
|
|
61
|
+
* 전부 배지라서, 그냥 첫 줄을 집으면 `[](...)` 가
|
|
62
|
+
* 프로젝트 설명으로 화면에 뜬다. 실제로 그렇게 뜬 적이 있다.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* 공지 블록인가 — 보관·중단·이관 안내.
|
|
66
|
+
*
|
|
67
|
+
* 🔴 실측(clips/pattern): README 가 이렇게 시작한다.
|
|
68
|
+
*
|
|
69
|
+
* # WARNING: This repository is no longer maintained.
|
|
70
|
+
*
|
|
71
|
+
* This project is archived and will not receive further updates...
|
|
72
|
+
*
|
|
73
|
+
* 첫 제목과 첫 문단을 그대로 집으면 ①의 답이
|
|
74
|
+
* **"이 프로젝트는 보관되었습니다"** 가 된다. 사실이지만 "무엇을 하는
|
|
75
|
+
* 물건인가" 에 대한 답이 아니다. 진짜 설명은 배너 아래에 있다 —
|
|
76
|
+
* "Pattern is a web mining module for Python."
|
|
77
|
+
*
|
|
78
|
+
* 그렇다고 버리지도 않는다. 보관됐다는 사실은 이 코드를 읽으려는 사람에게
|
|
79
|
+
* 중요한 정보다. 따로 `notice` 로 빼서 둘 다 보여준다.
|
|
80
|
+
*/
|
|
81
|
+
const NOTICE_RE = /^(?:[⚠🚨❗️\s*_-]*)?(?:\*{0,2})(?:warning|note|notice|caution|important|deprecated|archived|unmaintained|주의|경고|안내)\b/i
|
|
82
|
+
const NOTICE_BODY_RE = /\b(no longer maintained|not maintained|is archived|has been archived|deprecated|end of life|read[- ]only|moved to|superseded by)\b/i
|
|
83
|
+
|
|
84
|
+
const isNotice = (s) => !!s && (NOTICE_RE.test(s) || NOTICE_BODY_RE.test(s))
|
|
85
|
+
|
|
86
|
+
export function readmeSummary(text) {
|
|
87
|
+
if (!text) return null
|
|
88
|
+
const src = text.replace(/<!--[\s\S]*?-->/g, '') // 주석 제거
|
|
89
|
+
const lines = src.split(/\r?\n/)
|
|
90
|
+
|
|
91
|
+
let title = null
|
|
92
|
+
let notice = null
|
|
93
|
+
const para = []
|
|
94
|
+
|
|
95
|
+
const skippable = (l) => {
|
|
96
|
+
const t = l.trim()
|
|
97
|
+
if (!t) return true
|
|
98
|
+
if (/^[-=_*]{3,}$/.test(t)) return true // 구분선
|
|
99
|
+
if (/^<\/?(p|div|img|br|h\d|a|picture|source|center)\b/i.test(t)) return true
|
|
100
|
+
if (/^\[!\[/.test(t) || /^!\[/.test(t)) return true // 배지·이미지
|
|
101
|
+
if (/^\[\!\[.*\]\(.*\)\]\(.*\)$/.test(t)) return true
|
|
102
|
+
// 배지만 여러 개 이어 붙인 줄
|
|
103
|
+
if (/^(\s*\[?!\[[^\]]*\]\([^)]*\)\]?\([^)]*\)\s*)+$/.test(t)) return true
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* rST/Setext 제목인가 — 다음 줄이 `=====` 나 `-----` 인 짧은 줄.
|
|
109
|
+
*
|
|
110
|
+
* 🔴 앞줄만 보고는 판단할 수 없어서 다음 줄을 본다.
|
|
111
|
+
*
|
|
112
|
+
* 예전에는 "짧고 마침표로 안 끝나면 제목" 이라고 봤다. 그건 공지 배너가
|
|
113
|
+
* 있는 README 에서 무너진다 — clips/pattern 의 진짜 제목은
|
|
114
|
+
* `Pattern` / `=======` 인데, 마크다운 제목이 아니라서 못 잡고
|
|
115
|
+
* 그 앞의 아무 짧은 줄이나 제목이 될 수 있었다.
|
|
116
|
+
*/
|
|
117
|
+
const isSetext = (i) => /^[=\-~^]{3,}\s*$/.test(lines[i + 1]?.trim() ?? '')
|
|
118
|
+
|
|
119
|
+
for (let li = 0; li < lines.length; li++) {
|
|
120
|
+
const raw = lines[li]
|
|
121
|
+
const t = raw.trim()
|
|
122
|
+
const heading = t.match(/^#{1,6}\s+(.+?)\s*#*$/)
|
|
123
|
+
|
|
124
|
+
// 🔴 공지 블록은 제목이든 문단이든 통째로 건너뛰고 **처음부터 다시 찾는다.**
|
|
125
|
+
// 보관 안내를 답으로 내놓으면 ①이 "무엇을 하는 물건인가" 에 답을 못 한다.
|
|
126
|
+
if (isNotice(t)) {
|
|
127
|
+
if (!notice) notice = t.replace(/^#+\s*/, '').trim()
|
|
128
|
+
title = null
|
|
129
|
+
para.length = 0
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (title === null) {
|
|
134
|
+
if (heading) { title = heading[1].trim(); continue }
|
|
135
|
+
// rST/Setext 제목. 다음 줄의 `=====` 는 구분선 규칙에 걸려 건너뛴다.
|
|
136
|
+
if (t && t.length < 90 && isSetext(li)) { title = t; continue }
|
|
137
|
+
if (skippable(t)) continue
|
|
138
|
+
// 🔴 공지 블록의 꼬리를 설명으로 집지 않는다.
|
|
139
|
+
//
|
|
140
|
+
// 공지는 여러 문단이다. `no longer maintained` 는 걸렀는데 그 다음
|
|
141
|
+
// "Use this software at your own risk." 가 설명으로 올라왔다.
|
|
142
|
+
// 배너 뒤에는 문서가 **제목부터 다시 시작한다** — 그러니 공지를 본 뒤에는
|
|
143
|
+
// 새 제목을 만나기 전까지 본문을 모으지 않는다.
|
|
144
|
+
if (notice) continue
|
|
145
|
+
// 제목이 아예 없는 README. 마침표로 끝나면 이미 설명문이므로 흘려보낸다.
|
|
146
|
+
if (t.length > 0 && t.length < 90 && !/[.。]$/.test(t)) { title = t; continue }
|
|
147
|
+
} else if (heading) {
|
|
148
|
+
// 🔴 제목을 지난 뒤 만나는 섹션 제목에서 무조건 끊는다.
|
|
149
|
+
//
|
|
150
|
+
// 전에는 `para` 가 비어 있으면 계속 읽었다. 그러면 첫 문단이 배지뿐인
|
|
151
|
+
// README 에서 **`## 설치` 아래 본문을 프로젝트 설명으로 집었다.**
|
|
152
|
+
// "이 저장소는 무엇을 하는 물건인가" 자리에 설치 안내가 뜬다.
|
|
153
|
+
//
|
|
154
|
+
// 여기서 끊으면 답이 null 이 되고, ①이 "README 첫 문단이 배지뿐이라
|
|
155
|
+
// 설명을 못 찾았다" 고 말한다. 모른다고 말하는 편이 낫다.
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (skippable(t)) { if (para.length) break; continue }
|
|
160
|
+
para.push(t)
|
|
161
|
+
if (para.join(' ').length > 400) break
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const summary = para.join(' ').replace(/\s+/g, ' ').trim()
|
|
165
|
+
if (!title && !summary && !notice) return null
|
|
166
|
+
return { title, summary: summary || null, notice }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 매니페스트에서 이름·설명을 읽는다.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ package.json 만 진짜로 파싱한다. 나머지(pyproject·go.mod·gradle)는
|
|
173
|
+
* 정규식으로 한 줄씩 집는다. **파서가 아니다.** 형식이 조금만 달라도
|
|
174
|
+
* 못 읽는데, 그건 못 읽었다고 말하면 되는 일이라 허용한다.
|
|
175
|
+
* 반대로 어설픈 파서를 넣으면 틀린 값을 자신 있게 내놓게 된다.
|
|
176
|
+
*/
|
|
177
|
+
export function manifestInfo(root) {
|
|
178
|
+
const read = (rel) => {
|
|
179
|
+
try { return fs.readFileSync(path.join(root, rel), 'utf8') } catch { return null }
|
|
180
|
+
}
|
|
181
|
+
const out = []
|
|
182
|
+
|
|
183
|
+
const pkg = read('package.json')
|
|
184
|
+
if (pkg) {
|
|
185
|
+
try {
|
|
186
|
+
const j = JSON.parse(pkg)
|
|
187
|
+
out.push({
|
|
188
|
+
file: 'package.json', kind: 'node',
|
|
189
|
+
name: j.name ?? null, description: j.description ?? null,
|
|
190
|
+
})
|
|
191
|
+
} catch { out.push({ file: 'package.json', kind: 'node', unparsable: true }) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const py = read('pyproject.toml')
|
|
195
|
+
if (py) {
|
|
196
|
+
out.push({
|
|
197
|
+
file: 'pyproject.toml', kind: 'python',
|
|
198
|
+
name: py.match(/^\s*name\s*=\s*["']([^"']+)/m)?.[1] ?? null,
|
|
199
|
+
description: py.match(/^\s*description\s*=\s*["']([^"']+)/m)?.[1] ?? null,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
const setup = read('setup.py')
|
|
203
|
+
if (setup && !py) {
|
|
204
|
+
out.push({
|
|
205
|
+
file: 'setup.py', kind: 'python',
|
|
206
|
+
name: setup.match(/name\s*=\s*["']([^"']+)/)?.[1] ?? null,
|
|
207
|
+
description: setup.match(/description\s*=\s*["']([^"']+)/)?.[1] ?? null,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const gomod = read('go.mod')
|
|
212
|
+
if (gomod) {
|
|
213
|
+
out.push({
|
|
214
|
+
file: 'go.mod', kind: 'go',
|
|
215
|
+
name: gomod.match(/^\s*module\s+(\S+)/m)?.[1] ?? null, description: null,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const cargo = read('Cargo.toml')
|
|
220
|
+
if (cargo) {
|
|
221
|
+
out.push({
|
|
222
|
+
file: 'Cargo.toml', kind: 'rust',
|
|
223
|
+
name: cargo.match(/^\s*name\s*=\s*["']([^"']+)/m)?.[1] ?? null,
|
|
224
|
+
description: cargo.match(/^\s*description\s*=\s*["']([^"']+)/m)?.[1] ?? null,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const g of ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'pom.xml']) {
|
|
229
|
+
const t = read(g)
|
|
230
|
+
if (!t) continue
|
|
231
|
+
const name = g === 'pom.xml'
|
|
232
|
+
? t.match(/<artifactId>([^<]+)<\/artifactId>/)?.[1] ?? null
|
|
233
|
+
: t.match(/rootProject\.name\s*=\s*["']([^"']+)/)?.[1] ?? null
|
|
234
|
+
out.push({ file: g, kind: 'jvm', name, description: null })
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return out
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 최상위 폴더별 파일 수·줄수. "이 저장소가 무엇으로 이뤄졌나" 의 뼈대다. */
|
|
242
|
+
export function topLevelShape(nodes, { limit = 10 } = {}) {
|
|
243
|
+
const byDir = new Map()
|
|
244
|
+
for (const n of nodes) {
|
|
245
|
+
const top = n.id.includes('/') ? n.id.slice(0, n.id.indexOf('/')) : '(최상위)'
|
|
246
|
+
const e = byDir.get(top) ?? { dir: top, files: 0, lines: 0, langs: new Map() }
|
|
247
|
+
e.files++
|
|
248
|
+
e.lines += n.lines ?? 0
|
|
249
|
+
e.langs.set(n.lang, (e.langs.get(n.lang) ?? 0) + 1)
|
|
250
|
+
byDir.set(top, e)
|
|
251
|
+
}
|
|
252
|
+
return [...byDir.values()]
|
|
253
|
+
.map((e) => ({
|
|
254
|
+
...e,
|
|
255
|
+
langs: [...e.langs].sort((a, b) => b[1] - a[1]).map(([l, c]) => ({ lang: l, files: c })),
|
|
256
|
+
}))
|
|
257
|
+
.sort((a, b) => b.files - a.files)
|
|
258
|
+
.slice(0, limit)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function stepWhat(root, nodes) {
|
|
262
|
+
const gaps = []
|
|
263
|
+
let readme = null
|
|
264
|
+
let readmeFile = null
|
|
265
|
+
for (const rel of README_NAMES) {
|
|
266
|
+
try {
|
|
267
|
+
const t = fs.readFileSync(path.join(root, rel), 'utf8')
|
|
268
|
+
const s = readmeSummary(t)
|
|
269
|
+
if (s) { readme = s; readmeFile = rel; break }
|
|
270
|
+
} catch { /* 다음 후보 */ }
|
|
271
|
+
}
|
|
272
|
+
if (!readme) gaps.push('README 를 찾지 못했거나 첫 문단이 배지뿐이다 — 설명을 사람이 직접 확인해야 한다')
|
|
273
|
+
|
|
274
|
+
const manifests = manifestInfo(root)
|
|
275
|
+
if (!manifests.length) gaps.push('매니페스트(package.json·pyproject.toml·go.mod 등)가 없다 — 프로젝트 이름을 알 수 없다')
|
|
276
|
+
|
|
277
|
+
const shape = topLevelShape(nodes)
|
|
278
|
+
const langs = new Map()
|
|
279
|
+
for (const n of nodes) langs.set(n.lang, (langs.get(n.lang) ?? 0) + 1)
|
|
280
|
+
|
|
281
|
+
const described = readme?.summary
|
|
282
|
+
?? manifests.find((m) => m.description)?.description
|
|
283
|
+
?? null
|
|
284
|
+
if (!described) gaps.push('한 문장 설명을 어디서도 못 찾았다')
|
|
285
|
+
// 보관·중단 안내는 버리지 않는다. 이 코드를 읽으려는 사람에게 중요한 정보다.
|
|
286
|
+
if (readme?.notice) gaps.push(`README 에 안내가 붙어 있다 — "${readme.notice}"`)
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
n: 1,
|
|
290
|
+
key: 'what',
|
|
291
|
+
question: '이 저장소는 무엇을 하는 물건인가',
|
|
292
|
+
why: '무엇을 하는 물건인지 모르는 채로 구조를 보면, 보이는 것이 전부 그냥 파일이다.',
|
|
293
|
+
answer: {
|
|
294
|
+
name: manifests.find((m) => m.name)?.name ?? readme?.title ?? null,
|
|
295
|
+
summary: described,
|
|
296
|
+
source: readme?.summary ? readmeFile : manifests.find((m) => m.description)?.file ?? null,
|
|
297
|
+
manifests,
|
|
298
|
+
shape,
|
|
299
|
+
langs: [...langs].sort((a, b) => b[1] - a[1]).map(([lang, files]) => ({ lang, files })),
|
|
300
|
+
},
|
|
301
|
+
/**
|
|
302
|
+
* 무엇을 강조할지 — **가장 큰 폴더 하나**.
|
|
303
|
+
*
|
|
304
|
+
* 🔴 처음에는 상위 3개를 강조했다. 그랬더니 화면의 거의 모든 점이 물들어
|
|
305
|
+
* 강조가 아무 말도 하지 않게 됐다(스크린샷으로 확인). 전부를 강조하는
|
|
306
|
+
* 것은 아무것도 강조하지 않는 것이다.
|
|
307
|
+
*/
|
|
308
|
+
focus: shape.slice(0, 1).map((s) => s.dir),
|
|
309
|
+
focusKind: 'dir',
|
|
310
|
+
done: '이 저장소가 무엇을 하는지 한 문장으로 말할 수 있다.',
|
|
311
|
+
next: '무엇을 하는지 알았으면, 그 일이 어디서 시작되는지를 본다.',
|
|
312
|
+
gaps,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// ② 실행은 어디서 시작하나
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** 이름만으로 진입점 후보가 되는 파일. 약한 근거이므로 등급을 낮게 준다. */
|
|
321
|
+
const CONVENTIONAL = /(^|\/)(main|index|cli|app|server|__main__|manage|program)\.(mjs|cjs|js|jsx|ts|tsx|py|go|java|kt|rs|rb|php|cs)$/i
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 진입점을 **근거 등급과 함께** 낸다.
|
|
325
|
+
*
|
|
326
|
+
* 🔴 합성 점수로 줄 세우지 않는다 (entry.mjs 와 같은 이유).
|
|
327
|
+
* 대신 근거의 종류를 그대로 보여준다 — 선언된 것, main 이 있는 것,
|
|
328
|
+
* 이름이 그런 것, 그래프에서 아무도 안 부르는 것. 넷은 신뢰도가 다르다.
|
|
329
|
+
*
|
|
330
|
+
* `declared` 는 사람이 직접 적은 것이라 가장 세고,
|
|
331
|
+
* `convention` 은 이름만 보고 찍은 것이라 가장 약하다.
|
|
332
|
+
* 그 차이를 숫자 하나로 뭉개면 사용자가 무엇을 믿을지 알 수 없게 된다.
|
|
333
|
+
*/
|
|
334
|
+
export function entryCandidates(root, nodes, edges, { prefix = '' } = {}) {
|
|
335
|
+
/**
|
|
336
|
+
* 🔴 매니페스트를 **저장소 최상위에서만** 읽고 있었다.
|
|
337
|
+
*
|
|
338
|
+
* 모노레포에서는 그것이 아무것도 못 찾는다는 뜻이다. 실측(SSAFY S15P11E101):
|
|
339
|
+
* 매니페스트가 `FE/bbiyong-react/package.json` · `BE_system/build.gradle` ·
|
|
340
|
+
* `AI/requirements.txt` 에 있는데 최상위에는 없어서 1등급을 하나도 못 찾았고,
|
|
341
|
+
* 하위 근거로 떨어져 `if __name__ == '__main__'` 이 있는 **JIRA 자동화 스크립트**
|
|
342
|
+
* 8개를 진입점으로 골랐다. 거기서 Java 208개와 React 149개로 가는 길은 없다.
|
|
343
|
+
* 결과는 "549개 중 14개만 닿음" 이었다.
|
|
344
|
+
*
|
|
345
|
+
* `prefix` 를 받으면 그 하위 프로젝트 안에서 찾는다. 하위 프로젝트를 어떻게
|
|
346
|
+
* 나누는지는 `app/lib/roots.mjs`.
|
|
347
|
+
*/
|
|
348
|
+
const base = prefix ? path.join(root, prefix) : root
|
|
349
|
+
/** 매니페스트가 준 경로는 그 프로젝트 기준이다. 노드 id 는 저장소 기준이라 앞을 붙인다. */
|
|
350
|
+
const at = (p) => (prefix && p ? `${prefix}/${p}` : p)
|
|
351
|
+
// 🔴 코드가 아닌 노드는 진입점이 될 수 없다.
|
|
352
|
+
//
|
|
353
|
+
// `datanodes.mjs` 가 `.md`·`.json`·`.gradle` 을 노드로 올린다. 그건 숨은
|
|
354
|
+
// 결합을 잡기 위한 것이고 옳지만, 진입점 목록에는 들어오면 안 된다.
|
|
355
|
+
// 실제로 `CLAUDE.md` 가 "아무도 부르지 않는데 남을 부른다" 로 4등급에
|
|
356
|
+
// 올라왔다. 문서는 실행되지 않는다.
|
|
357
|
+
const runnable = nodes.filter((n) => n.lang !== 'data' && n.confidence !== 'history-only')
|
|
358
|
+
const have = new Set(runnable.map((n) => n.id))
|
|
359
|
+
nodes = runnable
|
|
360
|
+
const found = new Map() // path -> {path, evidence:[], rank}
|
|
361
|
+
|
|
362
|
+
const add = (p, evidence, rank) => {
|
|
363
|
+
if (!p || !have.has(p)) return
|
|
364
|
+
const e = found.get(p) ?? { path: p, evidence: [], rank: 9 }
|
|
365
|
+
if (!e.evidence.includes(evidence)) e.evidence.push(evidence)
|
|
366
|
+
e.rank = Math.min(e.rank, rank)
|
|
367
|
+
found.set(p, e)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── 1등급: 매니페스트가 선언한 것 ──────────────────────────────────────
|
|
371
|
+
const norm = (s) => (s ?? '').replace(/^\.\//, '').replace(/\\/g, '/')
|
|
372
|
+
try {
|
|
373
|
+
const j = JSON.parse(fs.readFileSync(path.join(base, 'package.json'), 'utf8'))
|
|
374
|
+
if (typeof j.main === 'string') add(at(norm(j.main)), 'package.json 의 main', 1)
|
|
375
|
+
if (typeof j.module === 'string') add(at(norm(j.module)), 'package.json 의 module', 1)
|
|
376
|
+
if (typeof j.bin === 'string') add(at(norm(j.bin)), 'package.json 의 bin', 1)
|
|
377
|
+
else if (j.bin && typeof j.bin === 'object') {
|
|
378
|
+
for (const [k, v] of Object.entries(j.bin)) add(at(norm(v)), `package.json 의 bin.${k}`, 1)
|
|
379
|
+
}
|
|
380
|
+
for (const [k, v] of Object.entries(j.scripts ?? {})) {
|
|
381
|
+
// `node app/server.mjs .` 같은 스크립트에서 파일 경로만 집는다.
|
|
382
|
+
for (const m of String(v).matchAll(/([\w./-]+\.(?:mjs|cjs|js|ts|py))\b/g)) {
|
|
383
|
+
add(at(norm(m[1])), `package.json 의 scripts.${k}`, 1)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch { /* 없거나 못 읽으면 다음 근거로 */ }
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 🔴 번들러 앱은 `index.html` 이 매니페스트다.
|
|
390
|
+
*
|
|
391
|
+
* Vite·Parcel 로 만든 앱의 `package.json` 에는 `main` 이 없다. 브라우저가
|
|
392
|
+
* 처음 읽는 것이 `index.html` 이고, 거기 `<script type="module" src>` 가
|
|
393
|
+
* 진짜 진입점을 **선언한다.** 그것을 안 읽으면 rank 1 이 비고, 실측에서
|
|
394
|
+
* `FE/bbiyong-react` 의 진입점이 `tools/fake-ws-harness.mjs` 로 떨어졌다 —
|
|
395
|
+
* 테스트 하네스가 앱의 시작점으로 뜬 것이다.
|
|
396
|
+
*/
|
|
397
|
+
try {
|
|
398
|
+
const html = fs.readFileSync(path.join(base, 'index.html'), 'utf8')
|
|
399
|
+
for (const m of html.matchAll(/<script[^>]+type=["']module["'][^>]+src=["']([^"']+)["']/gi)) {
|
|
400
|
+
add(at(norm(m[1].replace(/^\//, ''))), 'index.html 의 module script', 1)
|
|
401
|
+
}
|
|
402
|
+
} catch { /* 없으면 다음 근거로 */ }
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const py = fs.readFileSync(path.join(base, 'pyproject.toml'), 'utf8')
|
|
406
|
+
const sect = py.match(/\[project\.scripts\]([\s\S]*?)(?:\n\[|$)/)?.[1]
|
|
407
|
+
for (const m of (sect ?? '').matchAll(/=\s*["']([\w.]+):/g)) {
|
|
408
|
+
add(at(`${m[1].replace(/\./g, '/')}.py`), 'pyproject.toml 의 project.scripts', 1)
|
|
409
|
+
}
|
|
410
|
+
} catch { /* 없으면 넘어간다 */ }
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 🔴 라이브러리에는 "실행 시작점" 이 없다. **공개 표면**이 시작점이다.
|
|
414
|
+
*
|
|
415
|
+
* 실측(clips/pattern)에서 두 번 틀렸다.
|
|
416
|
+
*
|
|
417
|
+
* ① 처음에는 데모 블록(`if __name__ == '__main__'`)이 든 하위 모듈
|
|
418
|
+
* `pattern/text/en/__init__.py` 등이 시작점이 됐다. 엉뚱한 데서
|
|
419
|
+
* 출발하니 ③의 도달률이 17% 였다.
|
|
420
|
+
* ② 그래서 최상위 패키지 하나(`pattern/__init__.py`)만 쓰게 했더니
|
|
421
|
+
* **1%** 가 됐다. 그 파일은 내부 import 를 하나도 안 한다.
|
|
422
|
+
*
|
|
423
|
+
* 답은 추측이 아니라 매니페스트에 적혀 있었다. `setup.py` 의 `packages=`
|
|
424
|
+
* 가 이 저장소가 배포하는 것을 그대로 나열한다 — pattern, pattern.web,
|
|
425
|
+
* pattern.text, pattern.vector … 라이브러리를 읽는 것은 거기서 시작한다.
|
|
426
|
+
*
|
|
427
|
+
* 점 하나까지만 취한다. `pattern.text.en.wordnet` 까지 넣으면 시작점이
|
|
428
|
+
* 20개를 넘어 "0겹" 이 저장소의 절반이 되고, 그러면 계층이 아니다.
|
|
429
|
+
*/
|
|
430
|
+
try {
|
|
431
|
+
const setup = fs.readFileSync(path.join(base, 'setup.py'), 'utf8')
|
|
432
|
+
const list = setup.match(/packages\s*=\s*\[([\s\S]*?)\]/)?.[1]
|
|
433
|
+
for (const m of (list ?? '').matchAll(/["']([\w.]+)["']/g)) {
|
|
434
|
+
if ((m[1].match(/\./g) ?? []).length > 1) continue
|
|
435
|
+
add(at(`${m[1].replace(/\./g, '/')}/__init__.py`), 'setup.py 가 배포하는 패키지', 1)
|
|
436
|
+
}
|
|
437
|
+
} catch { /* setup.py 가 없으면 아래 규칙으로 */ }
|
|
438
|
+
|
|
439
|
+
// 매니페스트가 아무 말도 안 하면, 저장소 바로 아래(또는 src/ 아래)의
|
|
440
|
+
// 패키지 뿌리를 쓴다. 그보다 깊으면 하위 모듈이라 뿌리가 아니다.
|
|
441
|
+
if (!found.size) {
|
|
442
|
+
for (const n of nodes) {
|
|
443
|
+
const m = n.id.match(/^(?:src\/)?([^/]+)\/__init__\.py$/)
|
|
444
|
+
if (m) add(n.id, `저장소가 배포하는 패키지 (${m[1]})`, 1)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// JS 쪽에는 같은 규칙을 두지 않는다. 공개 표면은 package.json 의
|
|
448
|
+
// `main`/`bin`/`exports` 가 이미 1등급으로 말해주고, `index.*` 는 아래
|
|
449
|
+
// 관례 규칙(3등급)이 잡는다. 여기에 또 넣으면 "이름이 그렇게 생겼다" 를
|
|
450
|
+
// 2등급("파일 안에 실행 시작점이 있다")으로 올려 등급의 뜻을 흐린다.
|
|
451
|
+
|
|
452
|
+
// ── 2등급: 파일 안에 실행 시작점이 실제로 있는 것 ──────────────────────
|
|
453
|
+
// 내용을 읽어야 알 수 있으므로 후보를 좁혀서 본다. 전부 읽으면 느리다.
|
|
454
|
+
//
|
|
455
|
+
// 🔴 `lang` 으로 막고, 줄머리에 고정한다. 둘 다 필요하다.
|
|
456
|
+
//
|
|
457
|
+
// 이 목록을 처음 썼을 때 **이 파일 자신이 진입점으로 잡혔다.** 아래
|
|
458
|
+
// `what` 에 적힌 설명 문자열 `"if __name__ == '__main__'"` 이 바로 위
|
|
459
|
+
// 정규식에 걸렸기 때문이다. 탐지기가 자기 문서를 탐지했다.
|
|
460
|
+
//
|
|
461
|
+
// 같은 실패를 전에도 한 번 했다 — 토픽 정규식이 `@app.route("/add")` 를
|
|
462
|
+
// 채널로 읽어 가짜 엣지 35개를 만들었다. 패턴은 **그 패턴이 의미를 갖는
|
|
463
|
+
// 언어에서만** 돌려야 하고, 문법 요소는 줄 아무 데나가 아니라 제자리에
|
|
464
|
+
// 있어야 한다.
|
|
465
|
+
/**
|
|
466
|
+
* 진짜 진입점이지만 **그** 진입점은 아닌 곳.
|
|
467
|
+
*
|
|
468
|
+
* 예제·테스트·코드 생성기·빌드 도구는 전부 실행 가능하다. 그래서 탐지에는
|
|
469
|
+
* 걸리는데, 신입이 "이 저장소를 실행하면 어디부터 도나" 를 물을 때의 답은
|
|
470
|
+
* 아니다. 지우지 않고 뒤로 민다 — 지우면 "왜 내 스크립트가 없지" 가 된다.
|
|
471
|
+
*/
|
|
472
|
+
// `gotestdata` 처럼 앞뒤에 말이 붙은 것도 잡는다 — syft 에서 실제로
|
|
473
|
+
// `.../internal/gotestdata/go-source/cmd/bin1/main.go` 가 2위로 올라왔다.
|
|
474
|
+
const AUX_ENTRY = /(^|\/)([\w-]*testdata[\w-]*|examples?|tests?|spec|benchmarks?|fixtures?|tools?|scripts?|hack|docs?|generate|gen)(\/|$)/i
|
|
475
|
+
|
|
476
|
+
/** Go 의 사실상 선언. `go install ./cmd/...` 이 이 관례 위에 선다. */
|
|
477
|
+
const GO_CMD = /(^|\/)cmd\/[^/]+\/main\.go$/
|
|
478
|
+
const GO_CMD_WHY = 'Go 관례: cmd/<이름>/main.go'
|
|
479
|
+
|
|
480
|
+
const MAIN_PAT = [
|
|
481
|
+
{ lang: 'go', re: /^\s*func\s+main\s*\(\s*\)/m, need: /^\s*package\s+main\b/m, what: 'func main() (package main)' },
|
|
482
|
+
{ lang: 'java', re: /^\s*(public\s+)?static\s+.*\bmain\s*\(/m, need: null, what: 'static void main' },
|
|
483
|
+
{ lang: 'python', re: /^\s*if\s+__name__\s*==\s*["']__main__["']/m, need: null, what: "if __name__ == '__main__'" },
|
|
484
|
+
{ lang: 'rust', re: /^\s*fn\s+main\s*\(\s*\)/m, need: null, what: 'fn main()' },
|
|
485
|
+
]
|
|
486
|
+
const readable = nodes.filter((n) => (n.lines ?? 0) > 0 && (n.lines ?? 0) < 3000)
|
|
487
|
+
for (const n of readable) {
|
|
488
|
+
let text = null
|
|
489
|
+
try { text = fs.readFileSync(path.join(root, n.id), 'utf8') } catch { continue }
|
|
490
|
+
for (const p of MAIN_PAT) {
|
|
491
|
+
if (p.lang !== n.lang) continue
|
|
492
|
+
if (!p.re.test(text)) continue
|
|
493
|
+
if (p.need && !p.need.test(text)) continue
|
|
494
|
+
add(n.id, p.what, 2)
|
|
495
|
+
break
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 🔴 `@SpringBootApplication` 은 관례가 아니라 **선언**이다.
|
|
499
|
+
*
|
|
500
|
+
* 그래서 rank 2(`static void main`)가 아니라 rank 1 이다. 실측에서
|
|
501
|
+
* BE_system 은 `static void main` 을 가진 Spring 진입점과 `if __name__ ==
|
|
502
|
+
* '__main__'` 을 가진 JIRA 자동화 스크립트가 **같은 rank 2 동률**이 되어,
|
|
503
|
+
* 스크립트 쪽이 목록을 차지했다. 등급이 같으면 순서가 답을 정하는데
|
|
504
|
+
* 그건 근거가 아니다.
|
|
505
|
+
*/
|
|
506
|
+
if (/@SpringBootApplication\b/.test(text)) add(n.id, '@SpringBootApplication', 1)
|
|
507
|
+
// 셔뱅도 실행 파일이라는 증거다.
|
|
508
|
+
if (/^#!.*\b(node|python\d?|bash|sh|ruby)\b/.test(text)) add(n.id, '셔뱅(#!)', 2)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── 3등급: 이름이 그렇게 생긴 것 ───────────────────────────────────────
|
|
512
|
+
for (const n of nodes) if (CONVENTIONAL.test(n.id)) add(n.id, '관례적인 파일명', 3)
|
|
513
|
+
|
|
514
|
+
// ── 4등급: 그래프에서 아무도 부르지 않는데 남을 부르는 것 ──────────────
|
|
515
|
+
const inDeg = new Map()
|
|
516
|
+
const outDeg = new Map()
|
|
517
|
+
for (const e of edges) {
|
|
518
|
+
if (e.hub) continue
|
|
519
|
+
inDeg.set(e.target, (inDeg.get(e.target) ?? 0) + 1)
|
|
520
|
+
outDeg.set(e.source, (outDeg.get(e.source) ?? 0) + 1)
|
|
521
|
+
}
|
|
522
|
+
for (const n of nodes) {
|
|
523
|
+
if ((inDeg.get(n.id) ?? 0) === 0 && (outDeg.get(n.id) ?? 0) > 0) {
|
|
524
|
+
add(n.id, '아무도 import 하지 않는데 남을 import 한다', 4)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 🔴 같은 등급 안의 순서를 out-degree 로만 정하면 안 된다.
|
|
530
|
+
*
|
|
531
|
+
* syft(Go)에서 정면으로 드러났다. `func main()` 을 가진 파일이 **264개**였고
|
|
532
|
+
* 앞의 8개가 전부 `examples/<이름>/main.go` 와 코드 생성기였다. 정작 제품 진입점
|
|
533
|
+
* `cmd/syft/main.go` 는 목록에 없어서 신입이 직접 검색해 찾아야 했다.
|
|
534
|
+
*
|
|
535
|
+
* 예제가 이긴 이유는 단순하다 — 예제는 라이브러리를 많이 부르므로 out-degree 가
|
|
536
|
+
* 높다. 그런데 **"많이 부른다" 는 "진입점이다" 가 아니다.** 오히려 제품 진입점은
|
|
537
|
+
* 얇은 경우가 많다(플래그 파싱하고 한 곳으로 넘긴다).
|
|
538
|
+
*
|
|
539
|
+
* 파이썬 저장소에서는 이런 파일이 적어 우연히 맞았다. 등급(rank)은 근거의
|
|
540
|
+
* 종류라 건드리지 않는다 — 바꾸면 라벨이 거짓말한다. 순서만 고친다.
|
|
541
|
+
*/
|
|
542
|
+
const depthOf = (p) => p.split('/').length
|
|
543
|
+
const rankKey = (e) => [
|
|
544
|
+
e.rank,
|
|
545
|
+
// 예제·테스트·생성기·도구에도 진짜 진입점이 있다. 다만 그것은 *그* 진입점이
|
|
546
|
+
// 아니다. 지우지 않고 뒤로 민다.
|
|
547
|
+
AUX_ENTRY.test(e.path) ? 1 : 0,
|
|
548
|
+
// Go 는 go.mod 가 바이너리를 선언하지 않는다. `cmd/<이름>/main.go` 가
|
|
549
|
+
// 사실상의 선언이고 툴체인(`go install ./cmd/...`)도 그 관례 위에 선다.
|
|
550
|
+
GO_CMD.test(e.path) ? 0 : 1,
|
|
551
|
+
depthOf(e.path), // 얕을수록 뿌리에 가깝다
|
|
552
|
+
-(outDeg.get(e.path) ?? 0), // 그래도 남으면 많이 부르는 쪽
|
|
553
|
+
e.path, // 완전한 결정론
|
|
554
|
+
]
|
|
555
|
+
return [...found.values()]
|
|
556
|
+
.map((e) => ({
|
|
557
|
+
...e,
|
|
558
|
+
out: outDeg.get(e.path) ?? 0,
|
|
559
|
+
in: inDeg.get(e.path) ?? 0,
|
|
560
|
+
evidence: GO_CMD.test(e.path) && !e.evidence.includes(GO_CMD_WHY)
|
|
561
|
+
? [...e.evidence, GO_CMD_WHY] : e.evidence,
|
|
562
|
+
}))
|
|
563
|
+
.sort((a, b) => {
|
|
564
|
+
const ka = rankKey(a), kb = rankKey(b)
|
|
565
|
+
for (let i = 0; i < ka.length; i++) {
|
|
566
|
+
if (ka[i] < kb[i]) return -1
|
|
567
|
+
if (ka[i] > kb[i]) return 1
|
|
568
|
+
}
|
|
569
|
+
return 0
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const RANK_LABEL = {
|
|
574
|
+
1: '매니페스트가 선언했다',
|
|
575
|
+
2: '파일 안에 실행 시작점이 있다',
|
|
576
|
+
3: '이름이 그렇게 생겼다',
|
|
577
|
+
4: '아무도 부르지 않는데 남을 부른다',
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function stepEnter(root, nodes, edges, { limit = 8, prefix = '' } = {}) {
|
|
581
|
+
const all = entryCandidates(root, nodes, edges, { prefix })
|
|
582
|
+
const gaps = []
|
|
583
|
+
if (!all.length) {
|
|
584
|
+
gaps.push('진입점을 하나도 못 찾았다 — 라이브러리이거나, 실행을 코드 밖(설정·컨테이너)에서 시작하는 저장소일 수 있다')
|
|
585
|
+
} else if (all[0].rank >= 3) {
|
|
586
|
+
gaps.push('가장 센 근거가 "이름이 그렇게 생겼다" 뿐이다 — 열어서 확인해야 한다')
|
|
587
|
+
}
|
|
588
|
+
const rows = all.slice(0, limit)
|
|
589
|
+
const groups = []
|
|
590
|
+
for (const r of rows) {
|
|
591
|
+
let g = groups.find((x) => x.rank === r.rank)
|
|
592
|
+
if (!g) { g = { rank: r.rank, label: RANK_LABEL[r.rank], rows: [] }; groups.push(g) }
|
|
593
|
+
g.rows.push(r)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
n: 2,
|
|
598
|
+
key: 'enter',
|
|
599
|
+
question: '실행은 어디서 시작하나',
|
|
600
|
+
why: '읽는 순서를 코드가 실행되는 순서에 맞추면, 파일이 아니라 동작이 보인다.',
|
|
601
|
+
answer: { groups, total: all.length, shown: rows.length },
|
|
602
|
+
focus: rows.map((r) => r.path),
|
|
603
|
+
focusKind: 'file',
|
|
604
|
+
done: '이 저장소를 실행했을 때 가장 먼저 읽히는 파일을 하나 짚을 수 있다.',
|
|
605
|
+
next: '시작점을 알았으면, 거기서 무엇이 차례로 불리는지를 본다.',
|
|
606
|
+
gaps,
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// ③ 그 다음에 무엇이 불리나
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 시작점 여러 개에서 동시에 BFS 를 돌려 계층을 만든다.
|
|
616
|
+
*
|
|
617
|
+
* 🔴 `analyze.mjs` 의 `depths()` 는 시작점이 **하나**다. 흐름에서는 진입점이
|
|
618
|
+
* 여럿이라(bin 여러 개, 서버와 CLI가 따로) 각각 재서 최솟값을 취해야 한다.
|
|
619
|
+
* 하나만 골라서 재면 나머지 진입점에서만 닿는 코드가 통째로 "안 닿음"이 된다.
|
|
620
|
+
*
|
|
621
|
+
* 🔴 정적 엣지만 쓴다. 공변경 엣지는 방향이 없어서 "다음에 불린다" 를
|
|
622
|
+
* 말할 수 없다. 섞으면 계층이 아니라 그냥 이웃 목록이 된다.
|
|
623
|
+
*/
|
|
624
|
+
/**
|
|
625
|
+
* 진입점 하나에서 뻗어나가는 **방향 있는** import 체인.
|
|
626
|
+
*
|
|
627
|
+
* 🔴 계층 목록만으로는 "무엇이 무엇을 부르나" 를 못 읽는다.
|
|
628
|
+
*
|
|
629
|
+
* 벤치마크 기준선에서 신입이 M3(진입점→기능 경로)에 도달하는 데 노드를 다섯 번
|
|
630
|
+
* 눌러 손으로 이어붙였고, 그러고도 방향은 **파일명 의미로 추측**했다. 원문:
|
|
631
|
+
*
|
|
632
|
+
* > "안정된 경계 목록에 방향 표기가 없다. cli.go 패널에 main.go 와
|
|
633
|
+
* > clio_setup_config.go 가 같은 목록에 섞여 나왔다 — 누가 누구를 import
|
|
634
|
+
* > 하는지 구분하는 화살표가 없었다."
|
|
635
|
+
* > "step=3 레이어 목록은 8개 진입점을 전부 섞어서 보여줘 cmd/syft/main.go
|
|
636
|
+
* > 단독 경로를 알려주지 않았다."
|
|
637
|
+
*
|
|
638
|
+
* 그래서 여기서는 **시작점 하나**만 잡고, **부모를 명시**하고, 방향을 아는 것과
|
|
639
|
+
* 모르는 것을 **가른다.** 모르는 것을 아는 척하면 신입은 그것을 사실로 읽는다.
|
|
640
|
+
*
|
|
641
|
+
* 반환은 **미리 순회(pre-order) 순서**다. 그대로 들여쓰면 위에서 아래로 읽는
|
|
642
|
+
* 것이 곧 경로가 된다 — 어느 줄에서든 위로 훑으면 시작점까지 이어진다.
|
|
643
|
+
*
|
|
644
|
+
* @param {object[]} nodes
|
|
645
|
+
* @param {object[]} edges
|
|
646
|
+
* @param {string} start 시작 파일 하나
|
|
647
|
+
* @param {object} opts
|
|
648
|
+
* maxDepth 몇 겹까지 (기본 4)
|
|
649
|
+
* perNode 한 파일이 부르는 것 중 몇 개까지 (기본 5)
|
|
650
|
+
*/
|
|
651
|
+
export function importTree(nodes, edges, start, { maxDepth = 4, perNode = 5 } = {}) {
|
|
652
|
+
const have = new Set(nodes.map((n) => n.id))
|
|
653
|
+
if (!have.has(start)) return { start: null, rows: [], reached: 0, why: '시작점이 그래프에 없다' }
|
|
654
|
+
|
|
655
|
+
const lines = new Map(nodes.map((n) => [n.id, n.lines ?? 0]))
|
|
656
|
+
|
|
657
|
+
/** source → [{to, directed}] — 허브와 공변경은 쓰지 않는다 (layersFrom 과 같은 이유). */
|
|
658
|
+
const out = new Map()
|
|
659
|
+
const push = (a, b, directed) => {
|
|
660
|
+
if (!out.has(a)) out.set(a, [])
|
|
661
|
+
if (!out.get(a).some((x) => x.to === b)) out.get(a).push({ to: b, directed })
|
|
662
|
+
}
|
|
663
|
+
for (const e of edges) {
|
|
664
|
+
if (e.hub) continue
|
|
665
|
+
if (e.origin === 'cochange') continue
|
|
666
|
+
if (!have.has(e.source) || !have.has(e.target)) continue
|
|
667
|
+
push(e.source, e.target, e.directed !== false)
|
|
668
|
+
// 🔴 방향을 모르는 엣지는 양쪽으로 두되 **모른다고 표시**한다.
|
|
669
|
+
// 없는 것으로 치면 조용히 빠뜨리고, 안다고 치면 거짓말이 된다.
|
|
670
|
+
if (e.directed === false) push(e.target, e.source, false)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 최단 거리로 부모를 정한다 — 같은 파일에 여러 경로가 있으면 가장 짧은 것이 읽기 쉽다.
|
|
674
|
+
const parent = new Map([[start, null]])
|
|
675
|
+
const depth = new Map([[start, 0]])
|
|
676
|
+
const dirOf = new Map()
|
|
677
|
+
let frontier = [start]
|
|
678
|
+
for (let d = 1; d <= maxDepth && frontier.length; d++) {
|
|
679
|
+
const next = []
|
|
680
|
+
for (const a of frontier) {
|
|
681
|
+
for (const { to, directed } of out.get(a) ?? []) {
|
|
682
|
+
if (depth.has(to)) continue
|
|
683
|
+
depth.set(to, d); parent.set(to, a); dirOf.set(to, directed)
|
|
684
|
+
next.push(to)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
frontier = next
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const kids = new Map()
|
|
691
|
+
for (const [child, par] of parent) {
|
|
692
|
+
if (par === null) continue
|
|
693
|
+
if (!kids.has(par)) kids.set(par, [])
|
|
694
|
+
kids.get(par).push(child)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 🔴 자식을 **줄 수**로 정렬하면 안 된다.
|
|
699
|
+
*
|
|
700
|
+
* 처음에 큰 것부터 폈다. syft 에서 그 결과가 이랬다 — `main.go` 가 처음 펼치는
|
|
701
|
+
* 가지가 CLI 부팅·UI 이벤트·로그 배선이고, 정작 이 저장소의 본질인 패키지
|
|
702
|
+
* 카탈로징으로 가는 가지는 "7개 더" 에 접혀 있었다. 벤치마크 관찰자가
|
|
703
|
+
* *"이건 이벤트 파싱 기능이지 SBOM 카탈로징이 아니다"* 라고 적었다.
|
|
704
|
+
*
|
|
705
|
+
* 큰 파일이 중요한 파일은 아니다. **그 가지가 코드베이스를 얼마나 여는가**로
|
|
706
|
+
* 정렬한다 — 본류는 많은 것으로 이어지고 곁가지는 금방 끝난다.
|
|
707
|
+
*/
|
|
708
|
+
const subtree = new Map()
|
|
709
|
+
const sizeOf = (id) => {
|
|
710
|
+
if (subtree.has(id)) return subtree.get(id)
|
|
711
|
+
subtree.set(id, 1) // 순환은 없지만 방어적으로 먼저 넣는다
|
|
712
|
+
let n2 = 1
|
|
713
|
+
for (const c of kids.get(id) ?? []) n2 += sizeOf(c)
|
|
714
|
+
subtree.set(id, n2)
|
|
715
|
+
return n2
|
|
716
|
+
}
|
|
717
|
+
for (const id of parent.keys()) sizeOf(id)
|
|
718
|
+
for (const arr of kids.values()) {
|
|
719
|
+
arr.sort((a, b) => sizeOf(b) - sizeOf(a) || (lines.get(b) ?? 0) - (lines.get(a) ?? 0) || (a < b ? -1 : 1))
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const rows = []
|
|
723
|
+
const walk = (id, d) => {
|
|
724
|
+
const all = kids.get(id) ?? []
|
|
725
|
+
for (const c of all.slice(0, perNode)) {
|
|
726
|
+
rows.push({
|
|
727
|
+
path: c,
|
|
728
|
+
depth: d + 1,
|
|
729
|
+
from: id,
|
|
730
|
+
// 이 연결의 방향을 아는가. 모르면 화면이 그렇게 말해야 한다.
|
|
731
|
+
directed: dirOf.get(c) === true,
|
|
732
|
+
lines: lines.get(c) ?? 0,
|
|
733
|
+
// 이 가지가 여는 파일 수. 정렬 근거를 화면에도 낼 수 있어야 한다.
|
|
734
|
+
opens: sizeOf(c),
|
|
735
|
+
})
|
|
736
|
+
if (d + 1 < maxDepth) walk(c, d + 1)
|
|
737
|
+
}
|
|
738
|
+
if (all.length > perNode) {
|
|
739
|
+
rows.push({ more: all.length - perNode, depth: d + 1, from: id })
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
walk(start, 0)
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
start,
|
|
746
|
+
rows,
|
|
747
|
+
reached: depth.size - 1,
|
|
748
|
+
// 방향을 모르는 연결이 섞였으면 그 수를 낸다. 화면이 조용히 넘어가지 않게.
|
|
749
|
+
unknownDir: rows.filter((r) => r.path && !r.directed).length,
|
|
750
|
+
why: rows.length ? null : `${start} 이 import 로 부르는 것을 하나도 못 찾았다`,
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export function layersFrom(nodes, edges, starts) {
|
|
755
|
+
const have = new Set(nodes.map((n) => n.id))
|
|
756
|
+
const adj = new Map()
|
|
757
|
+
for (const e of edges) {
|
|
758
|
+
if (e.hub) continue // 허브 경유는 계층을 뭉갠다 (D9)
|
|
759
|
+
if (e.origin === 'cochange') continue // 방향이 없다
|
|
760
|
+
if (!have.has(e.source) || !have.has(e.target)) continue
|
|
761
|
+
if (!adj.has(e.source)) adj.set(e.source, new Set())
|
|
762
|
+
adj.get(e.source).add(e.target)
|
|
763
|
+
// 방향을 모르는 엣지는 양쪽으로 통과시킨다 — 모른다고 없는 것으로 치면
|
|
764
|
+
// 조용히 빠뜨리게 된다 (depths() 의 같은 판단).
|
|
765
|
+
if (e.directed === false) {
|
|
766
|
+
if (!adj.has(e.target)) adj.set(e.target, new Set())
|
|
767
|
+
adj.get(e.target).add(e.source)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const depth = new Map()
|
|
772
|
+
let frontier = starts.filter((s) => have.has(s))
|
|
773
|
+
for (const s of frontier) depth.set(s, 0)
|
|
774
|
+
let d = 0
|
|
775
|
+
while (frontier.length) {
|
|
776
|
+
d++
|
|
777
|
+
const next = []
|
|
778
|
+
for (const n of frontier) {
|
|
779
|
+
for (const m of adj.get(n) ?? []) {
|
|
780
|
+
if (!depth.has(m)) { depth.set(m, d); next.push(m) }
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
frontier = next
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const byDepth = new Map()
|
|
787
|
+
for (const [id, dd] of depth) {
|
|
788
|
+
if (!byDepth.has(dd)) byDepth.set(dd, [])
|
|
789
|
+
byDepth.get(dd).push(id)
|
|
790
|
+
}
|
|
791
|
+
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
792
|
+
const layers = [...byDepth.entries()]
|
|
793
|
+
.sort((a, b) => a[0] - b[0])
|
|
794
|
+
.map(([depthN, ids]) => ({
|
|
795
|
+
depth: depthN,
|
|
796
|
+
count: ids.length,
|
|
797
|
+
/**
|
|
798
|
+
* 🔴 시작점(0겹)만은 ②가 정한 순서를 그대로 쓴다.
|
|
799
|
+
*
|
|
800
|
+
* 예전에는 여기서도 줄 수로 정렬했다. 그래서 ②가 1위로 지목한
|
|
801
|
+
* `cmd/syft/main.go`(35줄)가 ③에서는 5번째로 내려가고, 그 위에 예제와
|
|
802
|
+
* 생성기가 앉았다. 같은 화면 두 곳이 같은 질문에 다른 순서를 준 것이다.
|
|
803
|
+
* 신입은 어느 쪽을 믿어야 할지 알 수 없다.
|
|
804
|
+
*
|
|
805
|
+
* 1겹 아래는 크기 순이 맞다 — 거기서는 "무엇이 큰가" 가 읽는 순서다.
|
|
806
|
+
*/
|
|
807
|
+
files: (depthN === 0
|
|
808
|
+
? starts.filter((p) => ids.includes(p)).map((id) => byId.get(id)).filter(Boolean)
|
|
809
|
+
: ids.map((id) => byId.get(id)).sort((a, b) => (b.lines ?? 0) - (a.lines ?? 0))
|
|
810
|
+
).map((n) => ({ path: n.id, lines: n.lines })),
|
|
811
|
+
}))
|
|
812
|
+
|
|
813
|
+
return { layers, reached: depth.size, total: nodes.length, maxDepth: Math.max(0, d - 1) }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** 계층이 믿을 만한지. 도달률이 낮으면 정적 파싱이 덜 된 것이다. */
|
|
817
|
+
const COVERAGE_LOW = 0.4
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* 시작점에서 안 닿는 것을 갈래별로 센다.
|
|
821
|
+
*
|
|
822
|
+
* 🔴 "도달 8%" 는 숫자만 보면 고장으로 읽힌다. 실제로는 아니었다.
|
|
823
|
+
*
|
|
824
|
+
* clips/pattern 은 파일 130개 중 예제가 52개, 테스트가 18개다. 그것들은
|
|
825
|
+
* 라이브러리를 **쓰는** 쪽이라 라이브러리에서 출발하면 당연히 안 닿는다.
|
|
826
|
+
* 안 닿는 게 맞는 것과 못 닿는 것을 같은 숫자에 넣으면, 사용자는 도구가
|
|
827
|
+
* 고장 났다고 판단하거나(그럼 안 쓴다) 코드가 죽었다고 판단한다(그럼 틀린다).
|
|
828
|
+
*
|
|
829
|
+
* 그래서 갈래로 가른다. 남는 "그 밖에" 만이 진짜로 설명이 필요한 몫이다.
|
|
830
|
+
*/
|
|
831
|
+
const CONSUMER = [
|
|
832
|
+
{ key: 'test', label: '테스트', re: /(^|\/)(tests?|spec|__tests__|testing)(\/|$)|(^|\/)test_[^/]*$|_test\.[^/]+$/i },
|
|
833
|
+
{ key: 'example', label: '예제', re: /(^|\/)(examples?|samples?|demos?|tutorials?)(\/|$)/i },
|
|
834
|
+
{ key: 'doc', label: '문서', re: /(^|\/)(docs?|documentation|website)(\/|$)/i },
|
|
835
|
+
{ key: 'build', label: '빌드·도구', re: /(^|\/)(scripts?|tools?|bench(marks?)?|\.github|ci)(\/|$)/i },
|
|
836
|
+
]
|
|
837
|
+
|
|
838
|
+
export function classifyUnreached(ids) {
|
|
839
|
+
const out = CONSUMER.map((c) => ({ key: c.key, label: c.label, count: 0 }))
|
|
840
|
+
let rest = 0
|
|
841
|
+
for (const id of ids) {
|
|
842
|
+
const i = CONSUMER.findIndex((c) => c.re.test(id))
|
|
843
|
+
if (i >= 0) out[i].count++
|
|
844
|
+
else rest++
|
|
845
|
+
}
|
|
846
|
+
return { groups: out.filter((g) => g.count > 0), rest }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function stepLayers(nodes, edges, starts, { perLayer = 8 } = {}) {
|
|
850
|
+
const r = layersFrom(nodes, edges, starts)
|
|
851
|
+
const coverage = nodes.length ? r.reached / nodes.length : 0
|
|
852
|
+
const gaps = []
|
|
853
|
+
|
|
854
|
+
const reachedSet = new Set(r.layers.flatMap((l) => l.files.map((f) => f.path)))
|
|
855
|
+
const unreachedIds = nodes.map((n) => n.id).filter((id) => !reachedSet.has(id))
|
|
856
|
+
const un = classifyUnreached(unreachedIds)
|
|
857
|
+
// 라이브러리를 쓰는 쪽(예제·테스트·문서)을 뺀 몫. 이쪽이 진짜 도달률이다.
|
|
858
|
+
const consumers = un.groups.reduce((a, g) => a + g.count, 0)
|
|
859
|
+
const coreTotal = nodes.length - consumers
|
|
860
|
+
const coreCoverage = coreTotal > 0 ? r.reached / coreTotal : 0
|
|
861
|
+
|
|
862
|
+
if (!starts.length) {
|
|
863
|
+
gaps.push('시작점이 없어 계층을 만들 수 없다 — ②가 먼저다')
|
|
864
|
+
} else if (coreCoverage < COVERAGE_LOW) {
|
|
865
|
+
// 🔴 이게 Go·Java 에서 우리를 물었던 실패다. 그때는 아무 말도 없이
|
|
866
|
+
// 빈 결과를 결과로 내놨다. 이번에는 숫자를 붙여서 말한다.
|
|
867
|
+
//
|
|
868
|
+
// 판정은 **예제·테스트를 뺀** 도달률로 한다. 그것들이 안 닿는 것은
|
|
869
|
+
// 고장이 아니라 방향이 반대라서다 (라이브러리를 쓰는 쪽이다).
|
|
870
|
+
gaps.push(
|
|
871
|
+
`예제·테스트를 뺀 코드 ${coreTotal}개 중 시작점에서 닿는 것이 ${r.reached}개`
|
|
872
|
+
+ ` (${Math.round(coreCoverage * 100)}%) 뿐이다. 계층을 전부 믿지 말 것 —`
|
|
873
|
+
+ ' 정적 파싱이 못 푼 연결(동적 로딩·리플렉션·설정 기반 등록)이 있거나,'
|
|
874
|
+
+ ' 진입점이 더 있다.',
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
if (r.layers.length > 1 && r.layers.at(-1).depth >= 12) {
|
|
878
|
+
gaps.push('계층이 12겹을 넘는다 — 순환 import 가 있거나 유틸을 타고 번졌을 수 있다')
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
n: 3,
|
|
883
|
+
key: 'layers',
|
|
884
|
+
question: '그 다음에 무엇이 불리나',
|
|
885
|
+
why: '한 겹씩 내려가면 파일 목록이 호출 순서가 된다. 이 순서가 곧 읽는 순서다.',
|
|
886
|
+
answer: {
|
|
887
|
+
starts,
|
|
888
|
+
/**
|
|
889
|
+
* 🔴 계층 목록 **위에** 방향 있는 체인을 둔다.
|
|
890
|
+
*
|
|
891
|
+
* 벤치마크 기준선에서 신입이 M3 에 도달하는 데 노드를 다섯 번 눌러 손으로
|
|
892
|
+
* 이어붙였고, 방향은 파일명 의미로 추측했다. 계층은 "몇 겹에 몇 개" 를
|
|
893
|
+
* 말할 뿐 "무엇이 무엇을 부르나" 를 말하지 않는다.
|
|
894
|
+
*
|
|
895
|
+
* 시작점은 **하나**만 쓴다 — 여덟 개를 섞으면 그것이 정확히 기준선의
|
|
896
|
+
* 불만이었다("cmd/syft/main.go 단독 경로를 알려주지 않았다").
|
|
897
|
+
*/
|
|
898
|
+
chain: starts.length ? importTree(nodes, edges, starts[0]) : null,
|
|
899
|
+
layers: r.layers.map((l) => ({ ...l, files: l.files.slice(0, perLayer), truncated: Math.max(0, l.count - perLayer) })),
|
|
900
|
+
reached: r.reached,
|
|
901
|
+
total: nodes.length,
|
|
902
|
+
coveragePct: Math.round(coverage * 100),
|
|
903
|
+
// 예제·테스트를 뺀 도달률. 화면은 이쪽을 크게 보여준다.
|
|
904
|
+
coreTotal,
|
|
905
|
+
coreCoveragePct: Math.round(coreCoverage * 100),
|
|
906
|
+
unreached: nodes.length - r.reached,
|
|
907
|
+
unreachedBy: un,
|
|
908
|
+
},
|
|
909
|
+
focus: r.layers.slice(0, 3).flatMap((l) => l.files.slice(0, perLayer).map((f) => f.path)),
|
|
910
|
+
focusKind: 'file',
|
|
911
|
+
done: '1겹과 2겹에 무엇이 있는지 말할 수 있고, 그 둘의 역할 차이를 안다.',
|
|
912
|
+
next: '구조를 알았으면, 그중 어디가 실제로 자주 바뀌고 위험한지를 본다.',
|
|
913
|
+
gaps,
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// ④⑤
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
function stepRisk(entry) {
|
|
922
|
+
// 🔴 키 이름을 지어내지 않는다. entry.mjs 가 쓰는 것은
|
|
923
|
+
// churn · hub · risk · isolated 다. 처음에 'hot' 이라고 썼더니
|
|
924
|
+
// 조용히 null 이 되어 ④의 절반이 빈 채로 화면에 나갔다 —
|
|
925
|
+
// 빈 목록은 "여기는 활발한 곳이 없다" 로 읽힌다.
|
|
926
|
+
const hot = entry?.lists?.find((l) => l.key === 'churn')
|
|
927
|
+
const risk = entry?.lists?.find((l) => l.key === 'risk')
|
|
928
|
+
const gaps = []
|
|
929
|
+
if (!risk?.rows?.length) gaps.push('숨은 결합이 하나도 안 잡혔다 — 히스토리가 짧거나 공변경 문턱에 못 미쳤다')
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
n: 4,
|
|
933
|
+
key: 'risk',
|
|
934
|
+
question: '어디가 활발하고, 어디가 위험한가',
|
|
935
|
+
why: '구조는 어제의 결정이고 히스토리는 오늘의 현실이다. 둘이 다른 곳이 사고가 나는 곳이다.',
|
|
936
|
+
answer: { hot: hot ?? null, risk: risk ?? null },
|
|
937
|
+
focus: [...(hot?.rows ?? []).slice(0, 5), ...(risk?.rows ?? []).slice(0, 5)].map((r) => r.path),
|
|
938
|
+
focusKind: 'file',
|
|
939
|
+
done: '"여기는 건드리면 같이 깨진다" 라고 말할 수 있는 파일이 하나 있다.',
|
|
940
|
+
next: '위험한 곳을 알았다. 이제 무언가를 새로 만들 차례다.',
|
|
941
|
+
gaps,
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* ⑤ 여기에 새로 만들려면 어디를 고치나.
|
|
947
|
+
*
|
|
948
|
+
* 🔴 이 걸음이 없을 때 온보딩 실험이 실패했다.
|
|
949
|
+
*
|
|
950
|
+
* 맥락 없는 에이전트가 ①~④ 를 전부 통과하고도 "새 데이터 소스를 추가하려면
|
|
951
|
+
* 어디를 고치나" 에서 멈췄다. 정확히는 멈추지 않고 **추론했다** —
|
|
952
|
+
* 숨은 결합 점수를 눈으로 조합해서 답을 만들어냈다. 그건 도구가 답한 게 아니다.
|
|
953
|
+
*
|
|
954
|
+
* ④까지는 "읽는" 걸음이고 이것이 첫 번째 "쓰는" 걸음이다. 신입이 실제로
|
|
955
|
+
* 겁내는 자리이기도 하다 — 무엇을 하는 물건인지는 README 가 말해주지만
|
|
956
|
+
* 내가 어디를 건드려야 하는지는 코드를 다 읽은 사람만 안다.
|
|
957
|
+
*
|
|
958
|
+
* 판정은 newfile.mjs 가 한다. 여기는 그 결과를 걸음으로 감싸기만 한다.
|
|
959
|
+
*/
|
|
960
|
+
function stepAdd(newFile, claims) {
|
|
961
|
+
/**
|
|
962
|
+
* 🔴 "여기를 고쳐라" 와 "그건 지금 누가 잡고 있다" 를 한 화면에서 말한다.
|
|
963
|
+
*
|
|
964
|
+
* 5회차 온보딩 실험의 마지막 지적이 이것이었다 —
|
|
965
|
+
* "새 파일을 추가했을 때 기존 claim 과 충돌할지 시뮬레이션해주지 않는다.
|
|
966
|
+
* Q3(동반 수정 파일)와 M7(현재 claim)을 사람이 직접 겹쳐봐야 한다."
|
|
967
|
+
*
|
|
968
|
+
* 두 축이 각자 맞는 답을 내는데 서로를 모르면, 그 둘을 겹치는 일은 결국
|
|
969
|
+
* 사람 몫이 된다. 신입이 가장 못 하는 일이 바로 그것이다.
|
|
970
|
+
*/
|
|
971
|
+
const points = (newFile?.points ?? []).map((pt) => {
|
|
972
|
+
const owner = (claims ?? []).find((c) => coversPath(c.paths ?? [], pt.path))
|
|
973
|
+
if (!owner) return pt
|
|
974
|
+
return {
|
|
975
|
+
...pt,
|
|
976
|
+
heldBy: owner.agent ?? null,
|
|
977
|
+
heldTask: owner.task ?? null,
|
|
978
|
+
heldIntent: owner.intent ?? null,
|
|
979
|
+
// 파일을 직접 잡은 것과 폴더에 딸려온 것은 사람에게 다른 정보다.
|
|
980
|
+
heldVia: (owner.paths ?? []).find((q) => coversPath([q], pt.path)) ?? null,
|
|
981
|
+
}
|
|
982
|
+
})
|
|
983
|
+
const clash = points.filter((p) => p.heldBy)
|
|
984
|
+
|
|
985
|
+
const gaps = []
|
|
986
|
+
if (!newFile) gaps.push('git 히스토리를 읽지 못해 이 걸음은 답할 수 없다')
|
|
987
|
+
else if (!newFile.answered) gaps.push(newFile.why)
|
|
988
|
+
else if (newFile.capped) gaps.push(`최근 ${newFile.capped}건까지만 봤다 — 그 이전은 세지 않았다`)
|
|
989
|
+
// 거부가 아니라 알림이다. 프로토콜 판정은 건드리지 않는다 (adjacent.mjs 와 같은 원칙).
|
|
990
|
+
if (clash.length) gaps.push(`등록 지점 ${clash.length}곳을 지금 다른 사람이 잡고 있다 — 손대기 전에 알려라`)
|
|
991
|
+
|
|
992
|
+
return {
|
|
993
|
+
n: 5,
|
|
994
|
+
key: 'add',
|
|
995
|
+
question: '여기에 새로 만들려면 어디를 고치나',
|
|
996
|
+
why: '누군가 전에 같은 일을 했다. 그때 함께 고친 파일이 등록 지점이다 — 추측이 아니라 관측이다.',
|
|
997
|
+
answer: { ...(newFile ?? { answered: false, points: [], scope: null }), points, clash },
|
|
998
|
+
// 등록 지점을 그래프에서도 비춰준다. 말과 그림이 같은 곳을 가리켜야 믿을 수 있다.
|
|
999
|
+
focus: (newFile?.points ?? []).map((p) => p.path),
|
|
1000
|
+
focusKind: 'file',
|
|
1001
|
+
done: '"새로 하나 만들면 이 파일들도 같이 고쳐야 한다" 를 말할 수 있다.',
|
|
1002
|
+
next: '고칠 곳을 알았다. 손대기 전에 선점한다.',
|
|
1003
|
+
gaps,
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function stepYours(claims) {
|
|
1008
|
+
return {
|
|
1009
|
+
n: 6,
|
|
1010
|
+
key: 'yours',
|
|
1011
|
+
question: '이제 당신 차례 — 어디를 잡을 것인가',
|
|
1012
|
+
why: '이해가 끝나면 코드를 고친다. 고치기 전에 선점하면 남과 겹치지 않는다 (D14 3단계).',
|
|
1013
|
+
answer: {
|
|
1014
|
+
active: claims ?? [],
|
|
1015
|
+
how: 'axmap claim <경로> --task <작업ID> --intent "<한 줄>"',
|
|
1016
|
+
},
|
|
1017
|
+
focus: (claims ?? []).flatMap((c) => c.paths ?? []),
|
|
1018
|
+
focusKind: 'file',
|
|
1019
|
+
done: '내가 고칠 경로를 잡았고, 그것이 남의 것과 겹치지 않는다.',
|
|
1020
|
+
next: null,
|
|
1021
|
+
gaps: [],
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* 다섯 걸음을 만든다.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {string} root
|
|
1031
|
+
* @param {{nodes:object[], edges:object[]}} graph
|
|
1032
|
+
* @param {object} opts
|
|
1033
|
+
* overlay 오버레이 엣지 (④가 쓴다)
|
|
1034
|
+
* entry entryPoints() 결과 (④가 쓴다)
|
|
1035
|
+
* newFile registrationPoints() 결과 (⑤가 쓴다)
|
|
1036
|
+
* claims 현재 선점 (⑥이 쓴다)
|
|
1037
|
+
*/
|
|
1038
|
+
/**
|
|
1039
|
+
* ②걸음의 결과에서 ③이 쓸 시작점을 뽑는다.
|
|
1040
|
+
*
|
|
1041
|
+
* 🔴 사다리 뷰(`/api/ladder`)가 이걸 그대로 부른다. 예전에는 서버가
|
|
1042
|
+
* `entryPoints()` 로 따로 뽑았는데, 그러면 같은 저장소를 두고 안내록 ③과
|
|
1043
|
+
* 오른쪽 사다리가 **서로 다른 시작점**을 쓰게 된다. 신입에게는 그게
|
|
1044
|
+
* "이 도구는 자기 말을 못 지킨다" 로 읽힌다.
|
|
1045
|
+
*
|
|
1046
|
+
* 1등급이 하나라도 있으면 그것만 쓰는 규칙의 근거는 basicFlow 주석에 있다.
|
|
1047
|
+
*/
|
|
1048
|
+
export function startsOf(s2) {
|
|
1049
|
+
const pick = (max) => s2.answer.groups.filter((g) => g.rank <= max).flatMap((g) => g.rows.map((r) => r.path))
|
|
1050
|
+
const strong = pick(1).length ? pick(1) : pick(2)
|
|
1051
|
+
return strong.length ? strong : s2.focus.slice(0, 3)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** 저장소에서 바로 시작점을 얻는다. 사다리처럼 ③ 전체가 필요 없는 쪽을 위해. */
|
|
1055
|
+
export function entryStarts(root, nodes, edges, opt = {}) {
|
|
1056
|
+
return startsOf(stepEnter(root, nodes, edges, opt))
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export function basicFlow(root, graph, { entry = null, claims = [], edges = null, newFile = null } = {}) {
|
|
1060
|
+
const nodes = graph.nodes
|
|
1061
|
+
const useEdges = edges ?? graph.edges
|
|
1062
|
+
|
|
1063
|
+
const s1 = stepWhat(root, nodes)
|
|
1064
|
+
const s2 = stepEnter(root, nodes, useEdges)
|
|
1065
|
+
/**
|
|
1066
|
+
* ③은 ②의 결과에 기댄다. 시작점을 헐겁게 잡으면 계층이 부풀어 아무 데서나
|
|
1067
|
+
* 출발한 것처럼 된다.
|
|
1068
|
+
*
|
|
1069
|
+
* 🔴 1등급이 하나라도 있으면 **그것만** 쓴다.
|
|
1070
|
+
*
|
|
1071
|
+
* 실측(clips/pattern): 2등급까지 썼더니 `test/test_en.py` 와 데모 블록이 든
|
|
1072
|
+
* `pattern/text/de/__init__.py` 가 시작점 8개에 섞였다. 이 저장소가 실제로
|
|
1073
|
+
* 배포하는 것은 `pattern` 하나다. 매니페스트가 선언한 것이 있는데 추측을
|
|
1074
|
+
* 같이 넣을 이유가 없다 — 선언이 곧 저자의 답이다.
|
|
1075
|
+
*/
|
|
1076
|
+
const starts = startsOf(s2)
|
|
1077
|
+
const s3 = stepLayers(nodes, useEdges, starts)
|
|
1078
|
+
const s4 = stepRisk(entry)
|
|
1079
|
+
const s5 = stepAdd(newFile, claims)
|
|
1080
|
+
const s6 = stepYours(claims)
|
|
1081
|
+
|
|
1082
|
+
const steps = [s1, s2, s3, s4, s5, s6]
|
|
1083
|
+
return {
|
|
1084
|
+
steps,
|
|
1085
|
+
// 🔴 흐름 전체가 얼마나 믿을 만한지 한 줄로. 걸음마다 gaps 를 내도
|
|
1086
|
+
// 사용자가 다섯 개를 다 읽지는 않는다.
|
|
1087
|
+
gaps: steps.flatMap((s) => s.gaps.map((g) => ({ step: s.n, text: g }))),
|
|
1088
|
+
}
|
|
1089
|
+
}
|