dsh-arch-doc 0.1.2

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.
@@ -0,0 +1,893 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * arch-profile.mjs — 零依赖代码库架构探查脚本(纯 Node 内建,不 spawn 子进程)。
4
+ *
5
+ * 用途:对一个本地代码库做确定性扫描,产出「硬事实」JSON:
6
+ * project(语言/仓库类型/技术栈)、modules(模块划分)、dependencies(内部/外部依赖)、
7
+ * entry_points(入口点)、run_methods(安装/构建/测试/运行/部署命令)、directory_tree。
8
+ * LLM 只在此基础上补充语义字段(模块职责、项目描述、关键流程、风险等)。
9
+ *
10
+ * 用法:
11
+ * node arch-profile.mjs <repo_path> --probe
12
+ * node arch-profile.mjs <repo_path> --scan [--max-depth 3]
13
+ * node arch-profile.mjs <repo_path> --deps
14
+ * node arch-profile.mjs <repo_path> --entry
15
+ * node arch-profile.mjs <repo_path> --all
16
+ *
17
+ * 公共参数:
18
+ * --max-depth <N> 目录扫描深度,默认 3(1–10)
19
+ * --include-dirs <a,b> 只分析这些目录(相对 repo_path)
20
+ * --exclude-dirs <a,b> 额外排除目录(与默认排除目录合并)
21
+ * --language <lang> 语言提示:python/javascript/typescript/go/java/generic
22
+ */
23
+
24
+ import {
25
+ readFileSync,
26
+ readdirSync,
27
+ statSync,
28
+ existsSync,
29
+ } from "node:fs";
30
+ import { join, relative, basename, resolve, sep } from "node:path";
31
+
32
+ // ── 常量 ────────────────────────────────────────────────────────────────
33
+ const DEFAULT_EXCLUDE_DIRS = [
34
+ ".git", "node_modules", "dist", "build", "__pycache__",
35
+ ".venv", "venv", "target", ".idea", ".vscode", ".pytest_cache",
36
+ ".mypy_cache", "coverage",
37
+ ];
38
+
39
+ const SOURCE_EXTS = new Set([
40
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".go", ".java", ".kt",
41
+ ".kts", ".rs", ".c", ".h", ".cpp", ".hpp", ".cc", ".rb", ".php", ".vue",
42
+ ".svelte", ".md", ".json", ".yaml", ".yml", ".toml", ".xml", ".gradle",
43
+ ".mod", ".sum", ".lock", ".txt", ".cfg", ".ini", ".sh", ".ps1",
44
+ ]);
45
+
46
+ const SOURCE_SPECIAL = new Set(["Makefile", "Dockerfile", "Rakefile", "Gemfile"]);
47
+
48
+ const MAX_FILE_BYTES = 256 * 1000;
49
+
50
+ // ── 参数解析 ────────────────────────────────────────────────────────────
51
+ const args = process.argv.slice(2);
52
+ const repoArg = args.find((a) => !a.startsWith("--"));
53
+ const getArg = (name, def) => {
54
+ const i = args.indexOf(name);
55
+ return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def;
56
+ };
57
+
58
+ function clamp(n, lo, hi) {
59
+ return Math.min(hi, Math.max(lo, n));
60
+ }
61
+
62
+ const maxDepth = clamp(Number(getArg("--max-depth", "3")) || 3, 1, 10);
63
+ const languageHint = getArg("--language", null);
64
+ const includeDirs = (getArg("--include-dirs", "") || "")
65
+ .split(",").map((s) => s.trim()).filter(Boolean);
66
+ const extraExclude = (getArg("--exclude-dirs", "") || "")
67
+ .split(",").map((s) => s.trim()).filter(Boolean);
68
+ const excludeDirs = new Set([...DEFAULT_EXCLUDE_DIRS, ...extraExclude]);
69
+
70
+ const mode = args.includes("--all") ? "all"
71
+ : args.includes("--probe") ? "probe"
72
+ : args.includes("--scan") ? "scan"
73
+ : args.includes("--deps") ? "deps"
74
+ : args.includes("--entry") ? "entry"
75
+ : "all";
76
+
77
+ if (!repoArg) {
78
+ console.error("用法: node arch-profile.mjs <repo_path> [--probe|--scan|--deps|--entry|--all] [--max-depth N] [--include-dirs a,b] [--exclude-dirs a,b] [--language L]");
79
+ process.exit(2);
80
+ }
81
+
82
+ let root;
83
+ try {
84
+ root = resolve(repoArg);
85
+ } catch {
86
+ console.error("错误: repo_path 无效");
87
+ process.exit(2);
88
+ }
89
+
90
+ // ── 工具函数 ────────────────────────────────────────────────────────────
91
+ function isDir(p) {
92
+ try { return statSync(p).isDirectory(); } catch { return false; }
93
+ }
94
+ function isFile(p) {
95
+ try { return statSync(p).isFile(); } catch { return false; }
96
+ }
97
+ function isExcluded(name) {
98
+ return excludeDirs.has(name) || name.startsWith(".git");
99
+ }
100
+ function readText(p) {
101
+ try {
102
+ if (!isFile(p)) return "";
103
+ if (statSync(p).size > MAX_FILE_BYTES) return "";
104
+ return readFileSync(p).toString("utf8");
105
+ } catch {
106
+ return "";
107
+ }
108
+ }
109
+ function normalizePath(p) {
110
+ return p.split(sep).join("/");
111
+ }
112
+ function relp(f) {
113
+ return normalizePath(relative(root, f));
114
+ }
115
+ function posixDirname(rel) {
116
+ const i = rel.lastIndexOf("/");
117
+ return i <= 0 ? "" : rel.slice(0, i);
118
+ }
119
+ function basenamePosix(rel) {
120
+ const i = rel.lastIndexOf("/");
121
+ return i < 0 ? rel : rel.slice(i + 1);
122
+ }
123
+
124
+ // ── 遍历 ────────────────────────────────────────────────────────────────
125
+ function walk() {
126
+ const files = [];
127
+ const inc = includeDirs.length ? new Set(includeDirs) : null;
128
+ function recur(dir, depth) {
129
+ if (depth > maxDepth) return;
130
+ let entries;
131
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
132
+ for (const e of entries) {
133
+ if (e.isDirectory()) {
134
+ if (isExcluded(e.name)) continue;
135
+ if (inc && depth === 0 && !inc.has(e.name)) continue;
136
+ recur(join(dir, e.name), depth + 1);
137
+ } else if (e.isFile()) {
138
+ files.push(join(dir, e.name));
139
+ }
140
+ }
141
+ }
142
+ recur(root, 0);
143
+ return files;
144
+ }
145
+
146
+ function isSourceFile(rel) {
147
+ const b = basenamePosix(rel);
148
+ if (SOURCE_SPECIAL.has(b)) return true;
149
+ const dot = b.lastIndexOf(".");
150
+ if (dot < 0) return false;
151
+ return SOURCE_EXTS.has(b.slice(dot).toLowerCase());
152
+ }
153
+
154
+ // ── 语言探测 ────────────────────────────────────────────────────────────
155
+ const LANG_MARKERS = [
156
+ ["python", ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]],
157
+ ["javascript", ["package.json"]],
158
+ ["go", ["go.mod"]],
159
+ ["java", ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"]],
160
+ ["kotlin", ["build.gradle.kts"]],
161
+ ["rust", ["Cargo.toml"]],
162
+ ["c", ["CMakeLists.txt", "Makefile", "meson.build"]],
163
+ ["cpp", ["CMakeLists.txt"]],
164
+ ["ruby", ["Gemfile", "Rakefile"]],
165
+ ["php", ["composer.json"]],
166
+ ];
167
+
168
+ function detectLanguage(files) {
169
+ if (languageHint) return languageHint.toLowerCase();
170
+ const has = (n) => existsSync(join(root, n));
171
+ if (has("tsconfig.json") || has("pnpm-workspace.yaml")) return "typescript";
172
+ for (const [lang, ms] of LANG_MARKERS) {
173
+ for (const m of ms) {
174
+ if (has(m)) return lang;
175
+ }
176
+ }
177
+ for (const d of ["cmd", "internal", "pkg"]) {
178
+ if (isDir(join(root, d))) return "go";
179
+ }
180
+ for (const d of ["src", "app", "lib"]) {
181
+ if (!isDir(join(root, d))) continue;
182
+ let py = 0;
183
+ let js = 0;
184
+ for (const f of files) {
185
+ const rel = relp(f);
186
+ if (!rel.startsWith(d + "/")) continue;
187
+ const b = basenamePosix(rel).toLowerCase();
188
+ if (b.endsWith(".py")) py++;
189
+ else if (b.endsWith(".js") || b.endsWith(".ts")) js++;
190
+ }
191
+ if (py > js) return "python";
192
+ if (js > 0) return "typescript";
193
+ }
194
+ return "generic";
195
+ }
196
+
197
+ // ── 项目信息 ────────────────────────────────────────────────────────────
198
+ function projectName() {
199
+ const pkg = join(root, "package.json");
200
+ if (existsSync(pkg)) {
201
+ try {
202
+ const data = JSON.parse(readText(pkg));
203
+ if (typeof data.name === "string" && data.name) return data.name;
204
+ } catch { /* ignore */ }
205
+ }
206
+ const py = join(root, "pyproject.toml");
207
+ if (existsSync(py)) {
208
+ for (const line of readText(py).split(/\r?\n/)) {
209
+ const s = line.trim();
210
+ if (s.startsWith("name") && s.includes("=")) {
211
+ const v = s.split("=", 2)[1].trim().replace(/^["']/, "").replace(/["']$/, "");
212
+ if (v) return v;
213
+ }
214
+ }
215
+ }
216
+ return basename(root) || "unknown";
217
+ }
218
+
219
+ function projectDescription() {
220
+ const pkg = join(root, "package.json");
221
+ if (existsSync(pkg)) {
222
+ try {
223
+ const data = JSON.parse(readText(pkg));
224
+ if (typeof data.description === "string" && data.description) return data.description;
225
+ } catch { /* ignore */ }
226
+ }
227
+ const py = join(root, "pyproject.toml");
228
+ if (existsSync(py)) {
229
+ let inProject = false;
230
+ for (const line of readText(py).split(/\r?\n/)) {
231
+ const s = line.trim();
232
+ if (s === "[project]") { inProject = true; continue; }
233
+ if (inProject && s.startsWith("[") && s !== "[project]") break;
234
+ if (inProject && s.startsWith("description")) {
235
+ const v = s.split("=", 2)[1].trim().replace(/^["']/, "").replace(/["']$/, "");
236
+ if (v) return v;
237
+ }
238
+ }
239
+ }
240
+ for (const n of ["README.md", "README.rst", "README.txt", "README"]) {
241
+ const f = join(root, n);
242
+ if (existsSync(f)) {
243
+ for (const line of readText(f).split(/\r?\n/)) {
244
+ const s = line.trim();
245
+ if (s.startsWith("# ")) return s.replace(/^#\s+/, "");
246
+ if (s && !s.startsWith("![") && !s.startsWith("<!--")) return s.slice(0, 200);
247
+ }
248
+ }
249
+ }
250
+ return "";
251
+ }
252
+
253
+ function techStack(language) {
254
+ const stack = [];
255
+ if (language !== "generic") stack.push(language);
256
+ if (
257
+ existsSync(join(root, "Dockerfile")) ||
258
+ existsSync(join(root, "docker-compose.yml")) ||
259
+ existsSync(join(root, "docker-compose.yaml"))
260
+ ) stack.push("docker");
261
+ if (existsSync(join(root, "Makefile"))) stack.push("make");
262
+ const py = join(root, "pyproject.toml");
263
+ if (existsSync(py)) {
264
+ const text = readText(py);
265
+ for (const dep of ["fastapi", "flask", "django", "pytest", "celery", "uvicorn"]) {
266
+ if (text.includes(dep)) stack.push(dep);
267
+ }
268
+ }
269
+ const pkg = join(root, "package.json");
270
+ if (existsSync(pkg)) {
271
+ try {
272
+ const data = JSON.parse(readText(pkg));
273
+ const deps = { ...(data.dependencies || {}), ...(data.devDependencies || {}) };
274
+ for (const dep of ["react", "vue", "angular", "express", "next", "nuxt", "typescript", "webpack", "vite"]) {
275
+ if (deps[dep]) stack.push(dep);
276
+ }
277
+ } catch { /* ignore */ }
278
+ }
279
+ const gm = join(root, "go.mod");
280
+ if (existsSync(gm)) {
281
+ const text = readText(gm);
282
+ for (const dep of ["gin", "echo", "fiber", "grpc"]) {
283
+ if (text.includes(dep)) stack.push(dep);
284
+ }
285
+ }
286
+ const pom = join(root, "pom.xml");
287
+ if (existsSync(pom)) {
288
+ const text = readText(pom);
289
+ if (text.includes("spring-boot")) stack.push("spring-boot");
290
+ }
291
+ return [...new Set(stack)];
292
+ }
293
+
294
+ // ── 仓库类型 ────────────────────────────────────────────────────────────
295
+ function hasRunnableEntry(files) {
296
+ for (const f of files) {
297
+ const rel = relp(f);
298
+ const b = basenamePosix(rel).toLowerCase();
299
+ if (/^(main|app|server|worker|consumer|cli|index)\.(py|js|ts|jsx|tsx|mjs|cjs|go|java|rb|php|rs|kt)$/.test(b)) return true;
300
+ if (rel.startsWith("bin/") || rel.startsWith("cmd/")) return true;
301
+ if (b === "dockerfile" || b === "docker-compose.yml" || b === "docker-compose.yaml") return true;
302
+ }
303
+ return false;
304
+ }
305
+
306
+ function detectRepoType(files, hasEntry) {
307
+ const top = new Set();
308
+ for (const f of files) {
309
+ const seg = relp(f).split("/")[0];
310
+ if (seg) top.add(seg);
311
+ }
312
+ for (const m of ["packages", "apps", "services", "microservices"]) {
313
+ if (top.has(m)) return "monorepo";
314
+ }
315
+ let serviceLike = 0;
316
+ for (const t of top) {
317
+ const p = join(root, t);
318
+ if (!isDir(p)) continue;
319
+ if (["Dockerfile", "main.go", "main.py", "package.json", "go.mod", "app.py", "server.js", "server.ts"].some((f) => existsSync(join(p, f)))) {
320
+ serviceLike++;
321
+ }
322
+ }
323
+ if (serviceLike > 1) return "microservices";
324
+ const srcLike = ["src", "lib", "include"].some((d) => top.has(d));
325
+ if (srcLike && !hasEntry) return "library";
326
+ return "monolith";
327
+ }
328
+
329
+ // ── 模块扫描 ────────────────────────────────────────────────────────────
330
+ function listTopDirs() {
331
+ const out = [];
332
+ try {
333
+ for (const e of readdirSync(root, { withFileTypes: true })) {
334
+ if (e.isDirectory() && !isExcluded(e.name)) out.push(e.name);
335
+ }
336
+ } catch { /* ignore */ }
337
+ return out;
338
+ }
339
+
340
+ function scanModules(files, language) {
341
+ const srcFiles = files.map(relp).filter(isSourceFile).sort();
342
+ const containers = [];
343
+ const addContainer = (relDir) => {
344
+ if (isDir(join(root, relDir))) containers.push(normalizePath(relDir));
345
+ };
346
+ if (language === "python") {
347
+ for (const d of ["src", "app", "lib"]) addContainer(d);
348
+ for (const top of listTopDirs()) {
349
+ if (existsSync(join(root, top, "__init__.py"))) containers.push(top);
350
+ }
351
+ } else if (language === "javascript" || language === "typescript") {
352
+ for (const d of ["src", "lib", "packages", "apps"]) addContainer(d);
353
+ } else if (language === "go") {
354
+ for (const d of ["cmd", "internal", "pkg"]) addContainer(d);
355
+ } else if (language === "java" || language === "kotlin") {
356
+ addContainer("src/main/java");
357
+ } else {
358
+ for (const d of ["src", "lib", "app", "packages"]) addContainer(d);
359
+ }
360
+
361
+ const modules = [];
362
+ const seen = new Set();
363
+
364
+ const addModule = (modRel) => {
365
+ if (seen.has(modRel)) return;
366
+ seen.add(modRel);
367
+ const prefix = modRel + "/";
368
+ const modFiles = srcFiles.filter((rel) => rel.startsWith(prefix));
369
+ if (modFiles.length === 0) return;
370
+ modules.push({
371
+ name: basenamePosix(modRel),
372
+ path: modRel,
373
+ language,
374
+ key_files: pickKeyFiles(modFiles),
375
+ file_count: modFiles.length,
376
+ });
377
+ };
378
+
379
+ for (const container of containers) {
380
+ const abs = join(root, container);
381
+ let subdirs = [];
382
+ try {
383
+ subdirs = readdirSync(abs, { withFileTypes: true })
384
+ .filter((e) => e.isDirectory() && !isExcluded(e.name))
385
+ .map((e) => e.name);
386
+ } catch { /* ignore */ }
387
+ const direct = srcFiles.some((rel) => posixDirname(rel) === container);
388
+ const subsWithSource = subdirs.filter((sd) =>
389
+ srcFiles.some((rel) => rel.startsWith(container + "/" + sd + "/"))
390
+ );
391
+ if (subsWithSource.length > 0) {
392
+ for (const sd of subsWithSource.sort()) addModule(container + "/" + sd);
393
+ } else if (direct) {
394
+ addModule(container);
395
+ }
396
+ }
397
+
398
+ if (modules.length === 0) {
399
+ for (const top of listTopDirs().sort()) {
400
+ if (srcFiles.some((rel) => rel.startsWith(top + "/"))) addModule(top);
401
+ }
402
+ }
403
+
404
+ return modules;
405
+ }
406
+
407
+ function pickKeyFiles(modFiles) {
408
+ const scored = modFiles.map((rel) => {
409
+ const b = basenamePosix(rel);
410
+ let score = 0;
411
+ if (/^readme/i.test(b)) score += 100;
412
+ if (/^(main|app|server|index|cli|worker|__init__)\./.test(b)) score += 50;
413
+ if (b === "router.py" || b === "service.py") score += 10;
414
+ let size = 0;
415
+ try { size = statSync(join(root, rel.split("/").join(sep))).size; } catch { /* ignore */ }
416
+ score += Math.min(size / 1000, 20);
417
+ return { rel, score };
418
+ });
419
+ return scored
420
+ .sort((a, b) => b.score - a.score)
421
+ .slice(0, 5)
422
+ .map((s) => s.rel);
423
+ }
424
+
425
+ // ── 依赖分析 ────────────────────────────────────────────────────────────
426
+ const PY_STDLIB = new Set([
427
+ "os", "sys", "json", "re", "io", "typing", "collections", "pathlib", "datetime",
428
+ "time", "math", "random", "functools", "itertools", "abc", "argparse", "logging",
429
+ "subprocess", "threading", "asyncio", "copy", "enum", "hashlib", "base64",
430
+ "string", "textwrap", "warnings", "uuid", "dataclasses", "contextlib", "unittest",
431
+ "shutil", "glob", "tempfile", "types", "http",
432
+ ]);
433
+
434
+ const NODE_BUILTINS = new Set([
435
+ "fs", "path", "os", "http", "https", "url", "crypto", "util", "stream", "events",
436
+ "buffer", "process", "assert", "child_process", "zlib", "querystring", "net",
437
+ "tls", "dns", "readline", "timers", "module", "vm", "worker_threads", "cluster",
438
+ "perf_hooks", "fs/promises", "path/posix", "path/win32",
439
+ ]);
440
+
441
+ const GO_STDLIB_PREFIX = new Set([
442
+ "fmt", "net", "io", "os", "strings", "strconv", "errors", "context", "log",
443
+ "time", "sync", "encoding", "math", "sort", "bytes", "bufio", "path", "reflect",
444
+ "regexp", "runtime", "unicode", "crypto", "database", "flag", "html", "mime",
445
+ "testing", "text", "container", "hash", "image", "index", "unsafe",
446
+ ]);
447
+
448
+ function isStdlib(imp, language) {
449
+ if (language === "python") return PY_STDLIB.has(imp);
450
+ if (language === "javascript" || language === "typescript") {
451
+ return imp.startsWith("node:") || NODE_BUILTINS.has(imp);
452
+ }
453
+ if (language === "go") return GO_STDLIB_PREFIX.has(imp.split("/")[0]);
454
+ return false;
455
+ }
456
+
457
+ function categorize(imp) {
458
+ const n = imp.toLowerCase();
459
+ const web = ["fastapi", "flask", "django", "express", "next", "nuxt", "gin", "echo", "fiber", "spring-boot", "react", "vue", "angular", "vite", "webpack", "uvicorn", "gunicorn", "koa", "hapi", "svelte", "nestjs"];
460
+ const db = ["sqlalchemy", "psycopg2", "psycopg", "pymysql", "mysql", "postgres", "redis", "prisma", "mongoose", "typeorm", "sequelize", "knex", "asyncpg", "aiosqlite", "sqlite3"];
461
+ const test = ["pytest", "jest", "vitest", "mocha", "chai", "unittest", "cypress", "playwright"];
462
+ const queue = ["celery", "bullmq", "bull", "kafka", "kafkajs", "rabbitmq", "amqp", "pika", "dramatiq", "rq"];
463
+ if (web.some((w) => n.includes(w))) return "web";
464
+ if (db.some((w) => n.includes(w))) return "database";
465
+ if (test.some((w) => n.includes(w))) return "test";
466
+ if (queue.some((w) => n.includes(w))) return "queue";
467
+ return "library";
468
+ }
469
+
470
+ function extractImports(text, language) {
471
+ const out = [];
472
+ if (language === "python") {
473
+ const re = /^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gm;
474
+ let m;
475
+ while ((m = re.exec(text))) {
476
+ const name = m[1] || m[2];
477
+ if (name && !name.startsWith(".")) out.push(name);
478
+ }
479
+ } else if (language === "javascript" || language === "typescript") {
480
+ const re = /import\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g;
481
+ let m;
482
+ while ((m = re.exec(text))) out.push(m[1]);
483
+ const re2 = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
484
+ while ((m = re2.exec(text))) out.push(m[1]);
485
+ const re3 = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
486
+ while ((m = re3.exec(text))) out.push(m[1]);
487
+ } else if (language === "go") {
488
+ const blockRe = /import\s*\(\s*([\s\S]*?)\)/g;
489
+ let m;
490
+ while ((m = blockRe.exec(text))) {
491
+ const q = /["\`]([^"\`]+)["\`]/g;
492
+ let qm;
493
+ while ((qm = q.exec(m[1]))) out.push(qm[1]);
494
+ }
495
+ const noBlock = text.replace(/import\s*\(\s*[\s\S]*?\)/g, "");
496
+ const single = /import\s+["\`]([^"\`]+)["\`]/g;
497
+ while ((m = single.exec(noBlock))) out.push(m[1]);
498
+ } else if (language === "java" || language === "kotlin") {
499
+ const re = /^\s*import\s+(?:static\s+)?([\w.]+)\s*;/gm;
500
+ let m;
501
+ while ((m = re.exec(text))) out.push(m[1]);
502
+ }
503
+ return [...new Set(out)];
504
+ }
505
+
506
+ function findModuleForFile(rel, modulesSorted) {
507
+ for (const m of modulesSorted) {
508
+ if (rel === m.path || rel.startsWith(m.path + "/")) return m;
509
+ }
510
+ return null;
511
+ }
512
+
513
+ function matchModule(imp, modulesSorted) {
514
+ let clean = imp.replace(/^\.\.?\//, "").replace(/^\./, "");
515
+ if (!clean) return null;
516
+ const firstSeg = clean.split(/[./]/)[0];
517
+ for (const m of modulesSorted) {
518
+ if (m.name === firstSeg) return m;
519
+ }
520
+ const dotted = clean.split("/").join(".");
521
+ for (const m of modulesSorted) {
522
+ const mp = m.path.split("/").join(".");
523
+ if (dotted === mp || dotted.startsWith(mp + ".")) return m;
524
+ }
525
+ for (const m of modulesSorted) {
526
+ if (clean === m.path || clean.endsWith("/" + m.path) || clean.startsWith(m.path + "/") || clean.includes("/" + m.path + "/")) return m;
527
+ }
528
+ return null;
529
+ }
530
+
531
+ function isSourceCode(rel, language) {
532
+ const b = basenamePosix(rel).toLowerCase();
533
+ if (language === "python") return b.endsWith(".py");
534
+ if (language === "javascript" || language === "typescript") return /\.(js|ts|jsx|tsx|mjs|cjs)$/.test(b);
535
+ if (language === "go") return b.endsWith(".go");
536
+ if (language === "java" || language === "kotlin") return /\.(java|kt|kts)$/.test(b);
537
+ if (language === "rust") return b.endsWith(".rs");
538
+ if (language === "ruby") return b.endsWith(".rb");
539
+ if (language === "php") return b.endsWith(".php");
540
+ return /\.(py|js|ts|jsx|tsx|mjs|cjs|go|java|kt|kts|rs|rb|php)$/.test(b);
541
+ }
542
+
543
+ function cleanVersion(dep, name) {
544
+ let v = dep.replace(name, "").trim();
545
+ v = v.replace(/^[<>=~!^]+/, "").trim();
546
+ return v || "";
547
+ }
548
+
549
+ function manifestVersions(language) {
550
+ const map = {};
551
+ const pkg = join(root, "package.json");
552
+ if (existsSync(pkg)) {
553
+ try {
554
+ const d = JSON.parse(readText(pkg));
555
+ for (const [k, v] of Object.entries({ ...(d.dependencies || {}), ...(d.devDependencies || {}) })) {
556
+ map[k] = cleanVersion(typeof v === "string" ? v : "", k);
557
+ }
558
+ } catch { /* ignore */ }
559
+ }
560
+ const py = join(root, "pyproject.toml");
561
+ if (existsSync(py)) {
562
+ const text = readText(py);
563
+ let inDeps = false;
564
+ for (const line of text.split(/\r?\n/)) {
565
+ const s = line.trim();
566
+ if (/^\[project\]/.test(s)) { inDeps = false; continue; }
567
+ if (/^\[/.test(s)) { inDeps = false; continue; }
568
+ if (/^dependencies\s*=\s*\[/.test(s)) { inDeps = true; continue; }
569
+ if (inDeps) {
570
+ if (/\]/.test(s)) inDeps = false;
571
+ const m = s.match(/^["']([^"']+)["']\s*,?\s*$/);
572
+ if (m) {
573
+ const dep = m[1];
574
+ const name = dep.split(/[<>=!~\[]/)[0].trim();
575
+ if (name) map[name] = cleanVersion(dep, name);
576
+ }
577
+ }
578
+ }
579
+ }
580
+ const gm = join(root, "go.mod");
581
+ if (existsSync(gm)) {
582
+ const text = readText(gm);
583
+ for (const m of text.matchAll(/^\s*require\s+([\w./-]+)\s+([\w.+-]+)/gm)) {
584
+ map[m[1]] = m[2];
585
+ }
586
+ const block = text.match(/require\s*\(([\s\S]*?)\)/);
587
+ if (block) {
588
+ for (const m of block[1].matchAll(/^\s*([\w./-]+)\s+([\w.+-]+)/gm)) {
589
+ map[m[1]] = m[2];
590
+ }
591
+ }
592
+ }
593
+ return map;
594
+ }
595
+
596
+ function analyzeDeps(files, modules, language) {
597
+ const internal = [];
598
+ const external = [];
599
+ const externalSeen = new Set();
600
+ const internalSeen = new Set();
601
+ const modulesSorted = [...modules].sort((a, b) => b.path.length - a.path.length);
602
+
603
+ for (const f of files) {
604
+ const rel = relp(f);
605
+ if (!isSourceCode(rel, language)) continue;
606
+ const text = readText(f);
607
+ if (!text) continue;
608
+ const sourceModule = findModuleForFile(rel, modulesSorted);
609
+ for (const imp of extractImports(text, language)) {
610
+ const target = matchModule(imp, modulesSorted);
611
+ if (target) {
612
+ if (sourceModule && target.name !== sourceModule.name) {
613
+ const key = sourceModule.name + "->" + target.name;
614
+ if (!internalSeen.has(key)) {
615
+ internalSeen.add(key);
616
+ internal.push({ source: sourceModule.name, target: target.name, kind: "import", path: rel });
617
+ }
618
+ }
619
+ } else if (!imp.startsWith(".") && !imp.startsWith("/") && !isStdlib(imp, language)) {
620
+ if (!externalSeen.has(imp)) {
621
+ externalSeen.add(imp);
622
+ external.push({ name: imp, version: "", category: categorize(imp) });
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ const versions = manifestVersions(language);
629
+ for (const e of external) {
630
+ if (versions[e.name] !== undefined) e.version = versions[e.name];
631
+ }
632
+
633
+ return { internal, external };
634
+ }
635
+
636
+ // ── 入口点 ──────────────────────────────────────────────────────────────
637
+ function entryCandidates(files) {
638
+ const out = [];
639
+ for (const f of files) {
640
+ const rel = relp(f);
641
+ const b = basenamePosix(rel).toLowerCase();
642
+ if (/^(main|app|server|worker|consumer|cli|index)\.(py|js|ts|jsx|tsx|mjs|cjs|go|java|rb|php|rs|kt)$/.test(b)) { out.push(f); continue; }
643
+ if (b === "application.java" || b === "application.kt") { out.push(f); continue; }
644
+ if (rel.startsWith("bin/") || rel.startsWith("cmd/")) { out.push(f); continue; }
645
+ }
646
+ return out;
647
+ }
648
+
649
+ function classifyEntry(b, rel, text) {
650
+ const t = text.toLowerCase();
651
+ const inBinCmd = rel.startsWith("bin/") || rel.startsWith("cmd/");
652
+ if (/(uvicorn|fastapi|flask|django|express|app\.listen|\.listen\(|http\.createserver|http\.listenandserve|gin\.new|gin\.default|echo\.new|@springbootapplication)/.test(t)) return "web";
653
+ if (/^(server|app)\.(js|ts|mjs|cjs|jsx|tsx)$/.test(b) || b === "index.js" || b === "index.ts") return "web";
654
+ if (/(worker|consumer|celery|bullmq|queue)/.test(b) || /(celery|bullmq|kafka)/.test(t)) return "worker";
655
+ if (/(cron|scheduler|schedule)/.test(b)) return "scheduler";
656
+ if (inBinCmd || /(argparse|commander|cobra|click|yargs|process\.argv|urfave\/cli)/.test(t) || /^cli\.(py|js|ts|go|rb|php)$/.test(b)) return "cli";
657
+ if (b === "main.go" || b === "application.java" || b === "application.kt") return "web";
658
+ if (/^main\.(py|js|ts)$/.test(b)) {
659
+ if (/(argparse|click|commander|yargs|process\.argv|cobra)/.test(t)) return "cli";
660
+ return "web";
661
+ }
662
+ return null;
663
+ }
664
+
665
+ function inferCommand(rel, type, language) {
666
+ if (language === "python") {
667
+ if (type === "web") return "uvicorn " + rel.replace(/\.py$/, "").split("/").join(".") + ":app --reload";
668
+ return "python " + rel;
669
+ }
670
+ if (language === "javascript" || language === "typescript") return "node " + rel;
671
+ if (language === "go") return "go run ./" + posixDirname(rel);
672
+ if (language === "java" || language === "kotlin") return "mvn -q spring-boot:run";
673
+ return rel;
674
+ }
675
+
676
+ function findLibraryFile(files, language) {
677
+ const rels = files.map(relp);
678
+ if (language === "rust") {
679
+ const l = rels.find((r) => basenamePosix(r) === "lib.rs");
680
+ if (l) return l;
681
+ }
682
+ if (language === "javascript" || language === "typescript") {
683
+ const l = rels.find((r) => basenamePosix(r) === "index.ts" || basenamePosix(r) === "index.js");
684
+ if (l) return l;
685
+ }
686
+ if (language === "python") {
687
+ const l = rels.find((r) => basenamePosix(r) === "__init__.py");
688
+ if (l) return l;
689
+ }
690
+ return null;
691
+ }
692
+
693
+ function detectEntryPoints(files, language) {
694
+ const entries = [];
695
+ const seen = new Set();
696
+ for (const f of entryCandidates(files)) {
697
+ const rel = relp(f);
698
+ const b = basenamePosix(rel).toLowerCase();
699
+ const text = readText(f);
700
+ const type = classifyEntry(b, rel, text);
701
+ if (!type) continue;
702
+ const key = type + "|" + rel;
703
+ if (seen.has(key)) continue;
704
+ seen.add(key);
705
+ entries.push({ type, path: rel, command: inferCommand(rel, type, language), description: "" });
706
+ }
707
+ if (entries.length === 0) {
708
+ const lib = findLibraryFile(files, language);
709
+ if (lib) entries.push({ type: "library", path: lib, command: "", description: "" });
710
+ }
711
+ return entries;
712
+ }
713
+
714
+ // ── 运行方式 ────────────────────────────────────────────────────────────
715
+ function mapScriptAction(name) {
716
+ const m = {
717
+ install: "install", dev: "dev", develop: "dev", start: "run", serve: "run",
718
+ build: "build", test: "test", deploy: "deploy", lint: "other",
719
+ format: "other", typecheck: "other", check: "test", ci: "test",
720
+ };
721
+ return m[name] || "other";
722
+ }
723
+
724
+ function parsePyprojectScripts(text) {
725
+ const out = [];
726
+ let inScripts = false;
727
+ for (const line of text.split(/\r?\n/)) {
728
+ const s = line.trim();
729
+ if (/^\[project\.scripts\]/.test(s)) { inScripts = true; continue; }
730
+ if (inScripts && /^\s*\[/.test(s)) { inScripts = false; continue; }
731
+ if (inScripts) {
732
+ const m = s.match(/^([\w-]+)\s*=\s*["']?([^"'\s]+)["']?\s*$/);
733
+ if (m) out.push({ name: m[1], target: m[2] });
734
+ }
735
+ }
736
+ return out;
737
+ }
738
+
739
+ function extractRunMethods(language) {
740
+ const methods = [];
741
+ const seen = new Set();
742
+ const add = (action, command, workspace) => {
743
+ if (!command) return;
744
+ const key = action + "|" + command;
745
+ if (seen.has(key)) return;
746
+ seen.add(key);
747
+ methods.push({ action, command, workspace: workspace || "." });
748
+ };
749
+
750
+ const pkgPath = join(root, "package.json");
751
+ if (existsSync(pkgPath)) {
752
+ let pkg = {};
753
+ try { pkg = JSON.parse(readText(pkgPath)); } catch { /* ignore */ }
754
+ add("install", "npm install", ".");
755
+ const scripts = pkg.scripts || {};
756
+ for (const name of Object.keys(scripts)) {
757
+ const action = mapScriptAction(name);
758
+ add(action, action === "run" ? "npm start" : "npm run " + name, ".");
759
+ }
760
+ }
761
+
762
+ const pyPath = join(root, "pyproject.toml");
763
+ if (existsSync(pyPath)) {
764
+ const text = readText(pyPath);
765
+ add("install", "pip install -e .", ".");
766
+ for (const s of parsePyprojectScripts(text)) add("run", s.name, ".");
767
+ if (/uvicorn/.test(text)) add("dev", "uvicorn src.main:app --reload", ".");
768
+ }
769
+
770
+ const mk = join(root, "Makefile");
771
+ if (existsSync(mk)) {
772
+ const text = readText(mk);
773
+ for (const m of text.matchAll(/^([a-zA-Z0-9_.-]+)\s*:/gm)) {
774
+ add(mapScriptAction(m[1]), "make " + m[1], ".");
775
+ }
776
+ }
777
+
778
+ const df = join(root, "Dockerfile");
779
+ if (existsSync(df)) {
780
+ const name = basename(root);
781
+ add("build", "docker build -t " + name + " .", ".");
782
+ add("run", "docker run " + name, ".");
783
+ }
784
+
785
+ const dc = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]
786
+ .map((n) => join(root, n)).find((p) => existsSync(p));
787
+ if (dc) {
788
+ add("deploy", "docker compose up -d", ".");
789
+ add("other", "docker compose down", ".");
790
+ }
791
+
792
+ return methods;
793
+ }
794
+
795
+ // ── 目录树 ──────────────────────────────────────────────────────────────
796
+ function renderTree(depth) {
797
+ const lines = [];
798
+ lines.push((basename(root) || ".") + "/");
799
+ function walkDir(dir, prefix, d) {
800
+ if (d >= depth) return;
801
+ let entries = [];
802
+ try {
803
+ entries = readdirSync(dir, { withFileTypes: true })
804
+ .filter((e) => !(e.isDirectory() && isExcluded(e.name)));
805
+ } catch { return; }
806
+ entries.sort((a, b) => {
807
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
808
+ return a.name.localeCompare(b.name);
809
+ });
810
+ entries.forEach((e, i) => {
811
+ const last = i === entries.length - 1;
812
+ const branch = last ? "└── " : "├── ";
813
+ if (e.isDirectory()) {
814
+ lines.push(prefix + branch + e.name + "/");
815
+ walkDir(join(dir, e.name), prefix + (last ? " " : "│ "), d + 1);
816
+ } else {
817
+ lines.push(prefix + branch + e.name);
818
+ }
819
+ });
820
+ }
821
+ walkDir(root, "", 0);
822
+ return lines.join("\n");
823
+ }
824
+
825
+ // ── 风险(动态导入) ────────────────────────────────────────────────────
826
+ function detectRisks(files, language) {
827
+ const risks = [];
828
+ for (const f of files) {
829
+ const rel = relp(f);
830
+ if (!isSourceCode(rel, language)) continue;
831
+ const text = readText(f);
832
+ if (!text) continue;
833
+ if (language === "python" && /importlib|__import__/.test(text)) {
834
+ risks.push(rel + " 使用 importlib/__import__ 动态导入,依赖关系可能不完整");
835
+ }
836
+ if ((language === "javascript" || language === "typescript") && /require\(\s*[a-zA-Z_$]/.test(text)) {
837
+ risks.push(rel + " 使用变量 require,依赖关系可能不完整");
838
+ }
839
+ }
840
+ return risks;
841
+ }
842
+
843
+ // ── 主流程 ──────────────────────────────────────────────────────────────
844
+ if (!isDir(root)) {
845
+ console.error("错误: repo_path 不存在或不是目录: " + repoArg);
846
+ process.exit(2);
847
+ }
848
+
849
+ const files = walk();
850
+ const language = detectLanguage(files);
851
+ const modules = scanModules(files, language);
852
+ const dependencies = analyzeDeps(files, modules, language);
853
+ const entry_points = detectEntryPoints(files, language);
854
+ const run_methods = extractRunMethods(language);
855
+ const repo_type = detectRepoType(files, hasRunnableEntry(files));
856
+ const project = {
857
+ name: projectName(),
858
+ root: normalizePath(root),
859
+ language,
860
+ repo_type,
861
+ description: projectDescription(),
862
+ tech_stack: techStack(language),
863
+ };
864
+
865
+ const print = (obj) => console.log(JSON.stringify(obj, null, 2));
866
+
867
+ if (mode === "probe") {
868
+ print({ project });
869
+ } else if (mode === "scan") {
870
+ print({ modules });
871
+ } else if (mode === "deps") {
872
+ print({ dependencies });
873
+ } else if (mode === "entry") {
874
+ print({ entry_points, run_methods });
875
+ } else {
876
+ print({
877
+ project,
878
+ modules: modules.map((m) => ({
879
+ name: m.name,
880
+ path: m.path,
881
+ responsibility: "",
882
+ language: m.language,
883
+ key_files: m.key_files,
884
+ file_count: m.file_count,
885
+ })),
886
+ dependencies,
887
+ entry_points,
888
+ run_methods,
889
+ key_flows: [],
890
+ directory_tree: renderTree(Math.min(maxDepth, 5)),
891
+ risks: detectRisks(files, language),
892
+ });
893
+ }