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/tools/smoke.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 화면이 실제로 뜨는지 본다.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 `npm test` 는 화면이 죽어도 초록이다.
|
|
6
|
+
*
|
|
7
|
+
* 하룻밤에 두 번 겪었다. 둘 다 문법은 멀쩡했고 테스트는 전부 통과했는데
|
|
8
|
+
* 브라우저에서는 아무것도 안 떴다.
|
|
9
|
+
*
|
|
10
|
+
* 1. 주석에 쓴 `examples/[별]/main.go` 의 `[별]/` 가 블록 주석을 닫아 파일이 깨졌다
|
|
11
|
+
* 2. `let urlReady` 를 첫 사용처보다 아래에 선언해 TDZ 참조 에러가 났고,
|
|
12
|
+
* 그 예외가 부트스트랩 체인을 끊어 흐름·걸음·탭이 통째로 죽었다
|
|
13
|
+
*
|
|
14
|
+
* 2번이 특히 나빴다. 나는 `?step=3` 으로만 확인했는데 그 경로는 크래시 지점을
|
|
15
|
+
* 우회한다. **되는 길만 보면 안 되는 길은 영원히 안 보인다.**
|
|
16
|
+
*
|
|
17
|
+
* 그래서 여기서는 사람이 실제로 처음 여는 주소 — **맨 주소** 를 연다.
|
|
18
|
+
* 자바스크립트가 다 돌고 난 DOM 에 부트스트랩이 남긴 흔적이 있는지만 본다.
|
|
19
|
+
* 픽셀은 안 본다. 스크린샷 비교는 깨지기 쉽고, 우리가 잡으려는 것은
|
|
20
|
+
* "화면이 통째로 안 뜨는 것" 이지 "3px 밀린 것" 이 아니다.
|
|
21
|
+
*
|
|
22
|
+
* 의존성은 늘리지 않는다. 이미 깔린 Chrome 을 `--dump-dom` 으로 부른다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn, spawnSync, execFileSync } from 'node:child_process'
|
|
26
|
+
import fs from 'node:fs'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
31
|
+
const PORT = Number(process.env.AXMAP_SMOKE_PORT ?? 7912)
|
|
32
|
+
/** 검사 대상은 이 저장소 자신이다. 늘 있고, 크지 않고, 결과가 안정적이다. */
|
|
33
|
+
const TARGET = process.argv[2] ?? ROOT
|
|
34
|
+
|
|
35
|
+
/** 이미 깔린 Chrome 을 찾는다. 없으면 **건너뛰지 않고 실패한다** — 검사는 돌아야 검사다. */
|
|
36
|
+
function findChrome() {
|
|
37
|
+
if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME
|
|
38
|
+
const cands = process.platform === 'win32'
|
|
39
|
+
? [
|
|
40
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
41
|
+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
42
|
+
path.join(process.env.LOCALAPPDATA ?? '', 'Google/Chrome/Application/chrome.exe'),
|
|
43
|
+
]
|
|
44
|
+
: ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
45
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
|
|
46
|
+
for (const c of cands) { try { if (fs.existsSync(c)) return c } catch { /* 다음 후보 */ } }
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 새 껍데기(`/`)가 남겨야 하는 흔적.
|
|
52
|
+
*
|
|
53
|
+
* `data-shell="ready"` 는 `init()` 의 맨 끝에서 붙는다. 그 앞에 API 세 번
|
|
54
|
+
* (`/api/agents` · `/api/graph` · `/api/sessions`)이 있으므로 이 한 줄이
|
|
55
|
+
* **모듈 · 서버 라우트 · 세션 저장소**를 한꺼번에 증명한다.
|
|
56
|
+
*/
|
|
57
|
+
/**
|
|
58
|
+
* 처음 오는 사람이 보는 화면(`/`).
|
|
59
|
+
*
|
|
60
|
+
* `data-shell="ready"` 는 `init()` 의 맨 끝에서 붙는다. 그 앞에 API 세 번
|
|
61
|
+
* (`/api/agents` · `/api/version` · `/api/sessions`)이 있으므로 이 한 줄이
|
|
62
|
+
* **모듈 · 서버 라우트 · 세션 저장소**를 한꺼번에 증명한다.
|
|
63
|
+
*/
|
|
64
|
+
const FIRST = [
|
|
65
|
+
{ what: '부트스트랩(shell.js)', re: /data-shell="ready"/ },
|
|
66
|
+
{ what: '3분할 · 왼쪽 목록', re: /id="chats"/ },
|
|
67
|
+
{ what: '3분할 · 가운데', re: /id="stage"/ },
|
|
68
|
+
{ what: '3분할 · 오른쪽 입력', re: /id="composer"/ },
|
|
69
|
+
{ what: '모드 탭(감시·이해)', re: /data-mode="watch"/ },
|
|
70
|
+
{ what: '툴바(아이콘)', re: /data-menu="all"/ },
|
|
71
|
+
/**
|
|
72
|
+
* 🔴 처음 오는 사람에게 보이는 CLI 고르기 카드.
|
|
73
|
+
* 정적 HTML 에 없다 — `/api/agents` 를 받아 `drawPicks()` 가 그린다.
|
|
74
|
+
* 이 표식이 **온보딩이 실제로 떴다**와 **CLI 감지가 돈다**를 함께 증명한다.
|
|
75
|
+
*/
|
|
76
|
+
{ what: '처음 설정 · CLI 고르기', re: /class="pick/ },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 고르기를 끝낸 사람이 보는 화면(`/?agent=…`).
|
|
81
|
+
*
|
|
82
|
+
* 🔴 온보딩이 가운데 칸을 가리므로, 주소로 고른 값을 심지 않으면 렌더러가
|
|
83
|
+
* 검사에서 통째로 빠진다. 헤드리스는 클릭을 못 한다.
|
|
84
|
+
*
|
|
85
|
+
* `data-path` 와 `class="wires"` 는 정적 HTML 에 없다 — `/api/ladder` 를 받아
|
|
86
|
+
* `stage.js` 가 만든다. 둘이 **겹 계산 · 라우트 · 카드 · 버스 연결선**을 덮는다.
|
|
87
|
+
*/
|
|
88
|
+
const OPENED = [
|
|
89
|
+
{ what: '부트스트랩(shell.js)', re: /data-shell="ready"/ },
|
|
90
|
+
{ what: '가운데 · 번호 카드', re: /data-path="/ },
|
|
91
|
+
{ what: '가운데 · 버스 연결선', re: /class="wires"/ },
|
|
92
|
+
{ what: '온보딩이 닫혔다', re: /id="onboard" hidden/ },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const PAGES = [
|
|
96
|
+
{ path: '/', what: '처음 오는 사람', must: FIRST },
|
|
97
|
+
{ path: '/?agent=claude', what: '고르기를 끝낸 사람', must: OPENED },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
async function waitUp(ms = 120_000) {
|
|
101
|
+
const until = Date.now() + ms
|
|
102
|
+
while (Date.now() < until) {
|
|
103
|
+
try {
|
|
104
|
+
await fetch(`http://127.0.0.1:${PORT}/`, { signal: AbortSignal.timeout(2000) })
|
|
105
|
+
return true
|
|
106
|
+
} catch { await new Promise((r) => setTimeout(r, 400)) }
|
|
107
|
+
}
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const chrome = findChrome()
|
|
112
|
+
if (!chrome) {
|
|
113
|
+
console.error('Chrome 을 찾지 못했습니다. CHROME=<경로> 로 알려주세요.')
|
|
114
|
+
console.error('건너뛰지 않습니다 — 안 도는 검사는 검사가 아닙니다.')
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 이미 그 포트를 쓰고 있으면 그것을 재사용하지 않는다. 무엇을 검사했는지가 흐려진다.
|
|
119
|
+
const log = path.join(ROOT, '.axmap-smoke.log')
|
|
120
|
+
const out = fs.openSync(log, 'w')
|
|
121
|
+
const server = spawn(process.execPath, [path.join(ROOT, 'app', 'server.mjs'), TARGET, String(PORT)],
|
|
122
|
+
{ cwd: ROOT, stdio: ['ignore', out, out], windowsHide: true })
|
|
123
|
+
|
|
124
|
+
let failed = 0
|
|
125
|
+
try {
|
|
126
|
+
if (!await waitUp()) {
|
|
127
|
+
console.error(`서버가 뜨지 않았습니다. 기록: ${log}`)
|
|
128
|
+
try { console.error(fs.readFileSync(log, 'utf8').split('\n').slice(-10).join('\n')) } catch { /* 없으면 그만 */ }
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`대상 ${TARGET}`)
|
|
133
|
+
|
|
134
|
+
for (const page of PAGES) {
|
|
135
|
+
// 🔴 맨 주소로 연다. 사람이 처음 여는 주소가 이것이고, 파라미터를 붙이면
|
|
136
|
+
// 부트스트랩의 다른 갈래를 타서 크래시 지점을 우회할 수 있다.
|
|
137
|
+
const dom = execFileSync(chrome, [
|
|
138
|
+
'--headless=new', '--disable-gpu', '--no-sandbox',
|
|
139
|
+
// 자바스크립트가 끝날 시간을 준다. 큰 저장소는 그래프를 만드느라 느리다.
|
|
140
|
+
'--virtual-time-budget=20000',
|
|
141
|
+
'--dump-dom', `http://127.0.0.1:${PORT}${page.path}`,
|
|
142
|
+
], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true })
|
|
143
|
+
|
|
144
|
+
console.log(`\n${page.path} ${page.what} · DOM ${dom.length.toLocaleString()} bytes`)
|
|
145
|
+
for (const m of page.must) {
|
|
146
|
+
const ok = m.re.test(dom)
|
|
147
|
+
console.log(` ${ok ? '통과' : '실패'} ${m.what}`)
|
|
148
|
+
if (!ok) failed++
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (failed) {
|
|
153
|
+
console.error('\n화면이 뜨지 않았습니다 — 부트스트랩이 중간에 죽었을 수 있습니다.')
|
|
154
|
+
console.error('브라우저 콘솔을 보세요. 문법은 멀쩡한데 실행이 죽는 경우가 많습니다')
|
|
155
|
+
console.error('(선언 순서·TDZ·없는 id 참조).')
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
try { server.kill() } catch { /* 이미 죽었으면 그만 */ }
|
|
159
|
+
if (process.platform === 'win32') {
|
|
160
|
+
try { spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }) } catch { /* 그만 */ }
|
|
161
|
+
}
|
|
162
|
+
try { fs.closeSync(out) } catch { /* 그만 */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (failed) process.exit(1)
|
|
166
|
+
console.log('\n화면 연기 검사 통과')
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
토픽 그래프 측정기 — docs/DECISIONS.md 의 D5·D6·D7·D9·D10 근거를 재현한다.
|
|
4
|
+
|
|
5
|
+
ROS2 처럼 배선이 문자열로 표현되는 코드베이스에서
|
|
6
|
+
"어떤 파일이 어떤 파일과 이어져 있고, 시작점에서 몇 단계 떨어져 있는가"를 센다.
|
|
7
|
+
|
|
8
|
+
python tools/topicgraph.py <소스루트> [시작파일] [--hub-cap N]
|
|
9
|
+
|
|
10
|
+
python tools/topicgraph.py ../e101_hj/redesign/.../ros2_ws/src \\
|
|
11
|
+
bbiyong_base/bbiyong_base/cmd_mux_node.py --hub-cap 6
|
|
12
|
+
|
|
13
|
+
이것은 제품이 아니라 **측정 도구**다. 정적 문자열만 본다.
|
|
14
|
+
그래서 파라미터를 통한 간접 배선을 놓치며, 실제로 놓쳤다 (DECISIONS.md D5).
|
|
15
|
+
그 한계를 보여주는 것도 이 스크립트의 역할이다.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import collections
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
25
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
TOPIC = re.compile(r'["\'](/[A-Za-z0-9_/]{2,})["\']')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def collect(root):
|
|
31
|
+
"""파일별로 등장하는 토픽 문자열을 모은다."""
|
|
32
|
+
files = {}
|
|
33
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
34
|
+
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
|
35
|
+
for name in filenames:
|
|
36
|
+
if not name.endswith(".py"):
|
|
37
|
+
continue
|
|
38
|
+
path = os.path.join(dirpath, name)
|
|
39
|
+
try:
|
|
40
|
+
src = open(path, encoding="utf-8", errors="ignore").read()
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
|
43
|
+
topics = set(TOPIC.findall(src))
|
|
44
|
+
if topics:
|
|
45
|
+
files[os.path.relpath(path, root).replace("\\", "/")] = topics
|
|
46
|
+
return files
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def layers_from(topic_files, start, hub_cap=None):
|
|
50
|
+
"""
|
|
51
|
+
시작 파일에서 너비우선으로 뻗어나가며 깊이별 파일 집합을 만든다.
|
|
52
|
+
|
|
53
|
+
hub_cap 을 주면 그보다 많은 파일이 공유하는 토픽은 엣지에서 제외한다.
|
|
54
|
+
전역 방송 신호(/estop 등)가 무관한 노드들을 서로 1홉 거리로 만드는 것을 막는다.
|
|
55
|
+
"""
|
|
56
|
+
adj = collections.defaultdict(set)
|
|
57
|
+
for topic, group in topic_files.items():
|
|
58
|
+
if hub_cap is not None and len(group) > hub_cap:
|
|
59
|
+
continue
|
|
60
|
+
for a in group:
|
|
61
|
+
adj[a] |= group - {a}
|
|
62
|
+
|
|
63
|
+
seen, out, frontier = {start}, [], {start}
|
|
64
|
+
while frontier:
|
|
65
|
+
nxt = set()
|
|
66
|
+
for node in frontier:
|
|
67
|
+
nxt |= adj[node] - seen
|
|
68
|
+
if not nxt:
|
|
69
|
+
break
|
|
70
|
+
seen |= nxt
|
|
71
|
+
out.append(nxt)
|
|
72
|
+
frontier = nxt
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def size_histogram(root):
|
|
77
|
+
buckets = [("<100", 0), ("100-300", 0), ("300-600", 0), ("600-1000", 0), ("1000+", 0)]
|
|
78
|
+
counts = dict(buckets)
|
|
79
|
+
total_lines = 0
|
|
80
|
+
biggest = []
|
|
81
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
82
|
+
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
|
83
|
+
for name in filenames:
|
|
84
|
+
if not name.endswith(".py"):
|
|
85
|
+
continue
|
|
86
|
+
path = os.path.join(dirpath, name)
|
|
87
|
+
try:
|
|
88
|
+
n = sum(1 for _ in open(path, encoding="utf-8", errors="ignore"))
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
total_lines += n
|
|
92
|
+
biggest.append((n, os.path.relpath(path, root).replace("\\", "/")))
|
|
93
|
+
key = ("<100" if n < 100 else "100-300" if n < 300 else
|
|
94
|
+
"300-600" if n < 600 else "600-1000" if n < 1000 else "1000+")
|
|
95
|
+
counts[key] += 1
|
|
96
|
+
biggest.sort(reverse=True)
|
|
97
|
+
return counts, total_lines, biggest
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main():
|
|
101
|
+
ap = argparse.ArgumentParser()
|
|
102
|
+
ap.add_argument("root")
|
|
103
|
+
ap.add_argument("start", nargs="?")
|
|
104
|
+
ap.add_argument("--hub-cap", type=int, default=6)
|
|
105
|
+
args = ap.parse_args()
|
|
106
|
+
|
|
107
|
+
files = collect(args.root)
|
|
108
|
+
topic_files = collections.defaultdict(set)
|
|
109
|
+
for f, topics in files.items():
|
|
110
|
+
for t in topics:
|
|
111
|
+
topic_files[t].add(f)
|
|
112
|
+
|
|
113
|
+
counts, total_lines, biggest = size_histogram(args.root)
|
|
114
|
+
n_files = sum(counts.values())
|
|
115
|
+
|
|
116
|
+
print(f"== 규모 ==")
|
|
117
|
+
print(f" {n_files}개 .py, {total_lines:,}줄")
|
|
118
|
+
print(f" 토픽 문자열을 가진 파일 {len(files)}개, 토픽 {len(topic_files)}개\n")
|
|
119
|
+
|
|
120
|
+
print("== 크기 분포 ==")
|
|
121
|
+
for label in ["<100", "100-300", "300-600", "600-1000", "1000+"]:
|
|
122
|
+
print(f" {label:>9}줄 {counts[label]:3}개")
|
|
123
|
+
tail = sum(n for n, _ in biggest[:5])
|
|
124
|
+
if total_lines:
|
|
125
|
+
print(f" 상위 5개 파일이 전체의 {tail / total_lines * 100:.0f}% ({tail:,}줄)")
|
|
126
|
+
for n, f in biggest[:5]:
|
|
127
|
+
print(f" {n:5}줄 {f}")
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
print(f"== 허브 토픽 (많은 파일이 공유) ==")
|
|
131
|
+
for t, group in sorted(topic_files.items(), key=lambda kv: -len(kv[1]))[:6]:
|
|
132
|
+
flag = " ← 허브" if len(group) > args.hub_cap else ""
|
|
133
|
+
print(f" {len(group):3}개 파일 {t}{flag}")
|
|
134
|
+
print()
|
|
135
|
+
|
|
136
|
+
if not args.start:
|
|
137
|
+
print("시작 파일을 주면 깊이별 확산을 잽니다.")
|
|
138
|
+
return
|
|
139
|
+
if args.start not in files:
|
|
140
|
+
print(f"시작 파일을 찾을 수 없습니다: {args.start}")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
for cap, label in [(None, "허브 제한 없음"), (args.hub_cap, f"{args.hub_cap}개 초과 토픽 제외")]:
|
|
144
|
+
print(f"== {args.start} 로부터의 깊이 ({label}) ==")
|
|
145
|
+
total = 1
|
|
146
|
+
for depth, group in enumerate(layers_from(topic_files, args.start, cap), 1):
|
|
147
|
+
total += len(group)
|
|
148
|
+
mark = " ← 요약 한계" if depth == 3 else ""
|
|
149
|
+
print(f" 깊이 {depth}: +{len(group):3}개 (누적 {total:3}){mark}")
|
|
150
|
+
print()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|