axmap-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,387 @@
1
+ /**
2
+ * 사다리 — 힘 그래프를 대신하는 배치.
3
+ *
4
+ * 🔴 왜 만드는가.
5
+ *
6
+ * 온보딩 실험에서 여섯 회차 연속 같은 말이 나왔다. 오른쪽 3D 힘 그래프는
7
+ * **장식**이라는 것이다. 그대로 옮기면:
8
+ *
9
+ * - "노드가 계속 움직여서 클릭이 빗나간다"
10
+ * - "점이 700개 엉켜 있는데 무엇이 무엇인지 알 수 없다"
11
+ * - "위치가 무엇을 뜻하는지 모르겠다. 가까우면 뭔데?"
12
+ * - "결국 왼쪽 안내록만 읽고 그래프는 한 번도 안 봤다"
13
+ *
14
+ * 마지막 줄이 핵심이다. 성공한 회차들은 전부 **왼쪽 글**로 이해에 도달했다.
15
+ * 변형 ①(호출 사슬)이 M3 도달을 31호출 → 9~10호출로 줄인 이유도 같다 —
16
+ * 그림이 아니라 **읽을 수 있는 순서**를 줬기 때문이다.
17
+ *
18
+ * 그렇다면 오른쪽 넓은 자리를 힘 그래프가 계속 차지할 이유가 없다.
19
+ * 같은 데이터를 **아무것도 움직이지 않는 배치**로 그린다:
20
+ *
21
+ * - 세로 = 진입점에서 몇 겹 안쪽인가 (위치에 뜻이 있다)
22
+ * - 가로 = 같은 겹 안에서 디렉터리별 묶음
23
+ * - 모든 칸에 이름이 적혀 있고, 좌표가 고정이라 클릭이 빗나가지 않는다
24
+ *
25
+ * 힘 그래프를 지우지는 않는다. 둘 다 두고 어느 쪽이 빠른지 **재는 것**이
26
+ * 이번 회차의 목적이다.
27
+ *
28
+ * 순수 함수만 둔다 — git 도 fs 도 시계도 없다 (CLAUDE.md).
29
+ */
30
+
31
+ import { classifyUnreached } from './flow.mjs'
32
+
33
+ /**
34
+ * 묶음 이름 — 위에서 두 마디까지.
35
+ *
36
+ * 🔴 최상위 한 마디로 묶었더니 syft 에서 `syft/` 하나가 한 겹의 50개를
37
+ * 삼켰다. 묶음이 "그 파일이 어느 영역인가" 를 말해야 하는데 저장소
38
+ * 이름을 반복할 뿐이었다. 반대로 부모 디렉터리 전체로 묶으면
39
+ * `syft/pkg/cataloger/golang` 같은 1개짜리 묶음이 수십 개 생긴다.
40
+ *
41
+ * 두 마디가 실측에서 맞았다 — `syft/pkg`, `syft/internal`, `cmd/syft`.
42
+ * 저장소마다 다르겠지만, 한 마디는 늘 너무 굵고 전체는 늘 너무 가늘다.
43
+ */
44
+ export function groupDir(p) {
45
+ const seg = p.split('/')
46
+ if (seg.length <= 1) return '(루트)'
47
+ return seg.slice(0, Math.min(2, seg.length - 1)).join('/')
48
+ }
49
+
50
+ /**
51
+ * 테스트·예제·생성물인가.
52
+ *
53
+ * 🔴 정렬에서 뒤로 보내려고 판정한다. **테스트는 많은 것을 import 하므로
54
+ * "여는 파일 수" 정렬에서 1등을 한다.** syft 실측에서 2겹 1위가
55
+ * `cataloger_test.go`(31개 엶)였다. 신입이 두 번째로 읽을 파일이
56
+ * 테스트일 리가 없는데, 순위는 그렇게 말하고 있었다.
57
+ *
58
+ * 지우지는 않는다 — 테스트는 그 코드가 무엇을 하는지 보여주는 가장
59
+ * 정직한 문서이기도 하다. 순서만 뒤로 보내고 화면에 그렇다고 적는다.
60
+ */
61
+ export function isAux(p) {
62
+ return /(^|\/)(testdata|examples?|tests?|spec|fixtures?|benchmarks?|mocks?)(\/|$)/i.test(p)
63
+ || /(^|\/)(test_[^/]+|[^/]+_test|[^/]+\.test|[^/]+_spec|[^/]+\.spec)\.[^/.]+$/i.test(p)
64
+ || /\.(pb|gen|generated)\.[^/.]+$/i.test(p)
65
+ }
66
+
67
+ /**
68
+ * 진입점에서 내려가는 층을 만든다.
69
+ *
70
+ * `layersFrom` 과 **같은 도달 규칙**을 쓴다(허브 제외·공변경 제외·방향 모르면
71
+ * 양쪽). 두 함수가 서로 다른 답을 내면 같은 화면 두 곳이 다른 말을 하게 된다.
72
+ * 그래서 `test/ladder.test.mjs` 가 둘의 도달 수가 같은지 못박는다.
73
+ *
74
+ * `layersFrom` 을 그대로 부르지 않는 이유는 여기서는 **누가 불렀는가(from)**와
75
+ * **그 가지가 여는 파일 수(opens)**가 더 필요하기 때문이다. 앞의 것은 화면에
76
+ * 적어야 하고(변형 ①에서 이게 결정적이었다), 뒤의 것은 정렬 근거다.
77
+ */
78
+ export function ladder(nodes, edges, starts, { perGroup = 8, maxGroups = 6 } = {}) {
79
+ const have = new Set(nodes.map((n) => n.id))
80
+ const byId = new Map(nodes.map((n) => [n.id, n]))
81
+ const seeds = starts.filter((s) => have.has(s))
82
+
83
+ const out = new Map()
84
+ const push = (a, b) => {
85
+ if (!out.has(a)) out.set(a, new Set())
86
+ out.get(a).add(b)
87
+ }
88
+ for (const e of edges) {
89
+ if (e.hub) continue
90
+ if (e.origin === 'cochange') continue
91
+ if (!have.has(e.source) || !have.has(e.target)) continue
92
+ push(e.source, e.target)
93
+ if (e.directed === false) push(e.target, e.source)
94
+ }
95
+
96
+ const depth = new Map()
97
+ const parent = new Map()
98
+ for (const s of seeds) { depth.set(s, 0); parent.set(s, null) }
99
+ let frontier = [...seeds]
100
+ let d = 0
101
+ while (frontier.length) {
102
+ d++
103
+ const next = []
104
+ for (const a of frontier) {
105
+ for (const b of out.get(a) ?? []) {
106
+ if (depth.has(b)) continue
107
+ depth.set(b, d); parent.set(b, a); next.push(b)
108
+ }
109
+ }
110
+ frontier = next
111
+ }
112
+
113
+ // 이 가지가 여는 파일 수. 큰 파일이 아니라 **많이 여는 파일**을 위에 둔다
114
+ // (importTree 가 자식 정렬에서 내린 것과 같은 판단).
115
+ const kids = new Map()
116
+ for (const [c, p] of parent) { if (p !== null) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(c) } }
117
+ const sub = new Map()
118
+ const opensOf = (id) => {
119
+ if (sub.has(id)) return sub.get(id)
120
+ sub.set(id, 1)
121
+ let n = 1
122
+ for (const c of kids.get(id) ?? []) n += opensOf(c)
123
+ sub.set(id, n)
124
+ return n
125
+ }
126
+ for (const id of parent.keys()) opensOf(id)
127
+
128
+ const byDepth = new Map()
129
+ for (const [id, dd] of depth) {
130
+ if (!byDepth.has(dd)) byDepth.set(dd, [])
131
+ byDepth.get(dd).push(id)
132
+ }
133
+
134
+ const cell = (id) => ({
135
+ path: id,
136
+ lines: byId.get(id)?.lines ?? 0,
137
+ opens: sub.get(id) ?? 1,
138
+ from: parent.get(id) ?? null,
139
+ // 화면이 "왜 이게 아래에 있나" 를 말할 수 있어야 한다
140
+ aux: isAux(id),
141
+ })
142
+
143
+ const layers = [...byDepth.entries()].sort((a, b) => a[0] - b[0]).map(([dd, ids]) => {
144
+ const g = new Map()
145
+ for (const id of ids) {
146
+ const k = groupDir(id)
147
+ if (!g.has(k)) g.set(k, [])
148
+ g.get(k).push(id)
149
+ }
150
+ const groups = [...g.entries()]
151
+ .map(([dir, list]) => {
152
+ // 0겹만은 ②가 정한 순서를 그대로 쓴다 — layersFrom 이 같은 이유로
153
+ // 같은 예외를 둔다. 같은 질문에 두 화면이 다른 순서를 주면 안 된다.
154
+ const sorted = dd === 0
155
+ ? seeds.filter((p) => list.includes(p))
156
+ : [...list].sort((a, b) =>
157
+ // 테스트·예제가 먼저다 → 아니다. 많이 열어도 뒤로 보낸다.
158
+ (isAux(a) ? 1 : 0) - (isAux(b) ? 1 : 0)
159
+ || (sub.get(b) ?? 1) - (sub.get(a) ?? 1)
160
+ || (byId.get(b)?.lines ?? 0) - (byId.get(a)?.lines ?? 0) || (a < b ? -1 : 1))
161
+ return {
162
+ dir,
163
+ count: sorted.length,
164
+ files: sorted.slice(0, perGroup).map(cell),
165
+ truncated: Math.max(0, sorted.length - perGroup),
166
+ }
167
+ })
168
+ /**
169
+ * 🔴 0겹의 묶음 순서도 ②가 정한다.
170
+ *
171
+ * 개수로 정렬했더니 syft 에서 `syft/pkg`(코드 생성기 4개)가
172
+ * `cmd/syft`(진짜 진입점 1개) 위에 앉았다. 파일 순서는 ②를 지키게
173
+ * 해놨는데 묶음 순서가 그걸 도로 뒤집은 것이다. 신입은 묶음을 먼저 본다.
174
+ *
175
+ * 아래 겹은 개수가 맞다 — 거기서는 "어느 영역이 두꺼운가" 가 읽는 순서다.
176
+ */
177
+ .sort((a, b) => (dd === 0
178
+ ? seeds.indexOf(a.files[0]?.path) - seeds.indexOf(b.files[0]?.path)
179
+ : b.count - a.count) || (a.dir < b.dir ? -1 : 1))
180
+ return {
181
+ depth: dd,
182
+ count: ids.length,
183
+ groups: groups.slice(0, maxGroups),
184
+ moreGroups: Math.max(0, groups.length - maxGroups),
185
+ }
186
+ })
187
+
188
+ const reachedSet = new Set(depth.keys())
189
+ const unreachedIds = nodes.map((n) => n.id).filter((id) => !reachedSet.has(id))
190
+
191
+ return {
192
+ starts: seeds,
193
+ layers,
194
+ reached: depth.size,
195
+ total: nodes.length,
196
+ maxDepth: Math.max(0, d - 1),
197
+ unreached: { count: unreachedIds.length, by: classifyUnreached(unreachedIds) },
198
+ // 조용히 빈 결과를 결과로 내지 않는다 (fail-closed).
199
+ why: seeds.length ? null : '시작점이 그래프에 없다 — 진입점부터 정해야 한다',
200
+ }
201
+ }
202
+
203
+ /* ── 변형 ③ — 두 파일 사이 경로 ────────────────────────────────────── */
204
+
205
+ /**
206
+ * A 에서 B 까지 어떻게 이어지나.
207
+ *
208
+ * 🔴 변형 ① 벤치마크가 정확히 여기서 멈췄다. 관찰자의 말을 그대로 옮기면:
209
+ *
210
+ * "정작 '진입점에서 기능까지'의 핵심 연결(scan→create_sbom)은 스스로
211
+ * 안 보여주고 사용자가 파일명을 추측해 검색해야 하는 구멍이 있다."
212
+ *
213
+ * 사슬 뷰(①)는 **위에서 아래로 펴는** 것이라 목적지를 정해두고 묻지
214
+ * 못한다. 사다리(②)도 마찬가지다 — 어디에 무엇이 있는지는 보여주지만
215
+ * "그래서 여기서 저기까지 어떻게 가나" 는 여전히 사람이 이어야 한다.
216
+ *
217
+ * 🔴 그리고 여기가 이 저장소의 논지가 실제로 값을 내는 자리다.
218
+ *
219
+ * `scan.go` → `create_sbom.go` 는 **정적 import 그래프에 없다.**
220
+ * `syft.CreateSBOM(...)` 은 패키지 수준 호출이라 파일 단위 import 로는
221
+ * 안 잡힌다. 정적 분석만 하는 도구는 여기서 "경로 없음" 을 내고 끝이다.
222
+ * 그건 사실이지만 쓸모가 없고, 신입에게는 거짓말처럼 느껴진다.
223
+ *
224
+ * 우리에게는 두 번째 자료가 있다 — **함께 바뀐 기록**. import 로 못 이으면
225
+ * 히스토리로 이어보고, **그 경로가 어느 근거로 이어졌는지 hop 마다 적는다.**
226
+ * 섞어놓고 뭉뚱그리면 그게 곧 거짓말이 된다.
227
+ */
228
+ /**
229
+ * @param maxBridges 공변경 hop 을 몇 개까지 허용하나.
230
+ *
231
+ * 🔴 **공변경은 전이적이지 않다.** A·B 가 함께 바뀌고 B·C 가 함께 바뀐다고
232
+ * A 와 C 가 관계있는 것이 아니다. import 는 전이적이지만("A가 B를 부르고
233
+ * B가 C를 부르면 A는 C에 닿는다") 공변경은 그런 성질이 없다.
234
+ *
235
+ * 그래서 다리는 **하나까지**만 놓는다. 정적 파싱이 끊긴 자리 한 곳을
236
+ * 히스토리로 잇는 것은 근거가 있지만, 두 번 이으면 그건 추론이 아니라
237
+ * 연상이다. 실제로 두 번 허용했을 때 나온 경로가 앞의 go.mod 사례다.
238
+ */
239
+ function bfsPath(adj, from, to, maxHops, maxBridges = 0) {
240
+ if (from === to) return []
241
+ // 상태는 (파일, 지금까지 쓴 다리 수) 다. 같은 파일이라도 다리를 덜 쓰고
242
+ // 도착했으면 다시 볼 값이 있다.
243
+ const key = (id, b) => `${b}\u0000${id}`
244
+ const prev = new Map([[key(from, 0), null]])
245
+ let frontier = [{ id: from, b: 0 }]
246
+ for (let d = 0; d < maxHops && frontier.length; d++) {
247
+ const next = []
248
+ for (const { id: a, b } of frontier) {
249
+ for (const e of adj.get(a) ?? []) {
250
+ const nb = b + (e.kind === 'cochange' ? 1 : 0)
251
+ if (nb > maxBridges) continue
252
+ if (prev.has(key(e.to, nb))) continue
253
+ prev.set(key(e.to, nb), { from: a, fb: b, e })
254
+ if (e.to === to) {
255
+ const hops = []
256
+ let cur = e.to
257
+ let cb = nb
258
+ while (prev.get(key(cur, cb))) {
259
+ const { from: f, fb, e: ed } = prev.get(key(cur, cb))
260
+ hops.unshift({ path: cur, from: f, kind: ed.kind, directed: ed.directed, support: ed.support, lift: ed.lift })
261
+ cur = f; cb = fb
262
+ }
263
+ return hops
264
+ }
265
+ next.push({ id: e.to, b: nb })
266
+ }
267
+ }
268
+ frontier = next
269
+ }
270
+ return null
271
+ }
272
+
273
+ /**
274
+ * 공변경 다리의 최소 조건.
275
+ *
276
+ * 🔴 lift 는 "우연보다 몇 배 자주 함께 바뀌나" 다. 1 이면 무관하다는 뜻이다.
277
+ * 실측(syft): `go.mod` 은 91개 파일과 함께 바뀌는데 lift 가 0.1~2.9 다 —
278
+ * **모든 것과 함께 바뀌므로 어느 것과도 관계없다.** 그런데 처음에는 이걸
279
+ * 안 걸러서 `main.go → go.mod → licenses.go → create_sbom.go` 라는 경로가
280
+ * 나왔다. 사실이 아니면서 그럴듯한, 가장 나쁜 종류의 답이다.
281
+ *
282
+ * 5배는 통계적 진술이지 손으로 고른 숫자가 아니다 — "우연의 다섯 배"다.
283
+ * 같은 저장소의 공변경 lift 중앙값이 30이므로 넉넉히 낮은 문턱이고,
284
+ * go.mod 의 최대치(2.9)보다는 확실히 위다.
285
+ */
286
+ const BRIDGE_MIN_LIFT = 5
287
+ const BRIDGE_MIN_SUPPORT = 3
288
+
289
+ function pathAdj(nodes, edges, { cochange }) {
290
+ const have = new Set(nodes.map((n) => n.id))
291
+ const adj = new Map()
292
+ const add = (a, b, e, kind) => {
293
+ if (!adj.has(a)) adj.set(a, [])
294
+ adj.get(a).push({ to: b, kind, directed: e.directed !== false, support: e.support, lift: e.lift })
295
+ }
296
+ for (const e of edges) {
297
+ // 허브를 타면 아무 두 파일이나 2단계로 이어진다. 경로가 아니라 소음이다.
298
+ if (e.hub) continue
299
+ if (!have.has(e.source) || !have.has(e.target)) continue
300
+ const isCo = e.origin === 'cochange'
301
+ if (isCo && !cochange) continue
302
+ // 약한 공변경은 다리가 되지 못한다. 위 주석의 go.mod 사례.
303
+ if (isCo && !((e.lift ?? 0) >= BRIDGE_MIN_LIFT && (e.support ?? 0) >= BRIDGE_MIN_SUPPORT)) continue
304
+ const kind = isCo ? 'cochange' : 'import'
305
+ add(e.source, e.target, e, kind)
306
+ // 공변경은 방향이 없다. import 도 방향을 모르면 양쪽으로 두되
307
+ // 화면이 "모른다" 고 말할 수 있게 directed 를 그대로 들고 간다.
308
+ if (isCo || e.directed === false) add(e.target, e.source, e, kind)
309
+ }
310
+ return adj
311
+ }
312
+
313
+ export function pathBetween(nodes, edges, from, to, { maxHops = 12 } = {}) {
314
+ const have = new Set(nodes.map((n) => n.id))
315
+ // 애매하면 거부한다. 없는 파일을 조용히 무시하면 "경로 없음" 과 구분이 안 된다.
316
+ if (!have.has(from)) return { from, to, found: false, why: `출발 파일이 그래프에 없습니다: ${from}` }
317
+ if (!have.has(to)) return { from, to, found: false, why: `도착 파일이 그래프에 없습니다: ${to}` }
318
+ if (from === to) return { from, to, found: true, via: 'import', hops: [], why: '같은 파일입니다' }
319
+
320
+ const impAdj = pathAdj(nodes, edges, { cochange: false })
321
+
322
+ // 1) import 만으로 이어지나. 가장 강한 근거이므로 더 길어도 이쪽이 먼저다.
323
+ const imp = bfsPath(impAdj, from, to, maxHops)
324
+ if (imp) {
325
+ return {
326
+ from, to, found: true, via: 'import', hops: imp,
327
+ unknownDir: imp.filter((h) => !h.directed).length,
328
+ why: null,
329
+ }
330
+ }
331
+
332
+ // 2) 안 되면 함께 바뀐 기록까지 섞는다. **어느 hop 이 어느 근거인지 표시한다.**
333
+ const mixed = bfsPath(pathAdj(nodes, edges, { cochange: true }), from, to, maxHops, 1)
334
+ if (mixed) {
335
+ return {
336
+ from, to, found: true, via: 'mixed', hops: mixed,
337
+ cochangeHops: mixed.filter((h) => h.kind === 'cochange').length,
338
+ why: 'import 만으로는 이어지지 않습니다. 끊긴 한 곳을 함께 바뀐 기록으로 이었습니다 —'
339
+ + ' 호출 관계라는 뜻이 아니라 같은 커밋에서 자주 함께 고쳐졌다는 뜻입니다.'
340
+ + ' 패키지 수준 호출·동적 등록처럼 파일 단위 import 로는 안 잡히는 연결이 여기에 걸립니다.'
341
+ + ' 공변경은 전이적이지 않으므로 이런 다리는 한 번만 놓습니다.',
342
+ }
343
+ }
344
+
345
+ // 3) 그래도 없으면 반대 방향을 본다. "없다" 보다 "반대로는 있다" 가 훨씬 쓸모 있다.
346
+ const back = bfsPath(impAdj, to, from, maxHops)
347
+
348
+ /**
349
+ * 4) 그래도 없으면 **목적지 쪽에서 한 걸음**을 준다.
350
+ *
351
+ * 🔴 "경로 없음" 은 정직하지만 쓸모가 없다. 벤치마크 관찰자가 두 회차 연속
352
+ * 같은 자리에서 멈췄고, 두 번 다 "모른다" 로 보고했다. 정직한 실패지만
353
+ * 실패는 실패다. 도착지에 들어오는 연결을 보여주면 거기서 거꾸로 올라갈
354
+ * 수 있다 — 신입이 실제로 하는 일이 그것이다.
355
+ *
356
+ * 지어내지 않는다. 이미 관측한 이웃을 세기 순으로 낼 뿐이다.
357
+ */
358
+ const inbound = []
359
+ for (const e of edges) {
360
+ if (e.hub) continue
361
+ const isCo = e.origin === 'cochange'
362
+ const hitsTo = e.target === to || (isCo || e.directed === false ? e.source === to : false)
363
+ if (!hitsTo) continue
364
+ const other = e.source === to ? e.target : e.source
365
+ if (!have.has(other) || other === to) continue
366
+ inbound.push({ path: other, kind: isCo ? 'cochange' : 'import', support: e.support, lift: e.lift })
367
+ }
368
+ inbound.sort((a, b) =>
369
+ (a.kind === 'import' ? 0 : 1) - (b.kind === 'import' ? 0 : 1)
370
+ // 🔴 발판 목록에도 같은 규칙을 쓴다. 실측에서 testdata 아래 껍데기
371
+ // 카탈로거들이 위로 올라왔다 — 진짜 사용처를 밀어내고서.
372
+ || (isAux(a.path) ? 1 : 0) - (isAux(b.path) ? 1 : 0)
373
+ || (b.lift ?? 0) - (a.lift ?? 0))
374
+
375
+ return {
376
+ from, to, found: false,
377
+ reverse: back ? { hops: back, length: back.length } : null,
378
+ // 여기서부터 거꾸로 올라가면 된다
379
+ inbound: inbound.slice(0, 8),
380
+ why: back
381
+ ? `${from} 에서 ${to} 로는 이어지지 않습니다. 반대 방향으로는 ${back.length}단계로 이어집니다.`
382
+ : `${maxHops}단계 안에서는 어느 방향으로도 이어지지 않습니다.`
383
+ + ' 정적 파싱이 못 보는 연결(동적 로딩·설정 기반 등록·패키지 수준 호출)이거나,'
384
+ + ' 정말로 무관한 두 파일입니다.'
385
+ + (inbound.length ? ' 대신 도착지로 들어오는 연결을 아래에 냅니다 — 거기서 거꾸로 올라가세요.' : ''),
386
+ }
387
+ }