repo-audit-tool 1.2.0

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 (117) hide show
  1. package/AGENTS.md +67 -0
  2. package/AUDIT.md +802 -0
  3. package/BEST-PRACTICES.md +268 -0
  4. package/CLAUDE.md +1 -0
  5. package/CONTRIBUTING.md +42 -0
  6. package/DEVELOPMENT.md +112 -0
  7. package/HANDOFF.md +124 -0
  8. package/LICENSE +21 -0
  9. package/PUBLISHING.md +66 -0
  10. package/README.en.md +71 -0
  11. package/README.md +71 -0
  12. package/REPO-AUDIT.md +107 -0
  13. package/REPO-CLASSIFICATION.md +137 -0
  14. package/SECURITY.md +19 -0
  15. package/docs/repo-audit/AGENT-GUIDE.md +474 -0
  16. package/docs/repo-audit/HUMAN-GUIDE.md +749 -0
  17. package/package.json +36 -0
  18. package/repo-audit.mjs +1507 -0
  19. package/rules/categories/archive.yaml +48 -0
  20. package/rules/categories/content.yaml +34 -0
  21. package/rules/categories/dsh-plugin.yaml +79 -0
  22. package/rules/categories/go-service.yaml +67 -0
  23. package/rules/categories/product-oss.yaml +75 -0
  24. package/rules/categories/python-app.yaml +60 -0
  25. package/rules/categories/sandbox.yaml +33 -0
  26. package/rules/domains/docs.yaml +105 -0
  27. package/rules/domains/git.yaml +52 -0
  28. package/rules/domains/quality.yaml +69 -0
  29. package/rules/domains/security.yaml +79 -0
  30. package/shim/repo-audit.cmd +7 -0
  31. package/shim/repo-audit.ps1 +4 -0
  32. package/shim/repo-audit.sh +5 -0
  33. package/templates/AGENT-GUIDE.md +331 -0
  34. package/templates/README.md +192 -0
  35. package/templates/UPDATE-MODEL.md +141 -0
  36. package/templates/UPDATE-PLAN.md +79 -0
  37. package/templates/categories/content/AGENTS-append.md +6 -0
  38. package/templates/categories/content/CATEGORY.md +26 -0
  39. package/templates/categories/content/README.en.md +12 -0
  40. package/templates/categories/content/README.md +14 -0
  41. package/templates/categories/content/gitignore-append.md +6 -0
  42. package/templates/categories/dsh-plugin/AGENTS-append.md +21 -0
  43. package/templates/categories/dsh-plugin/CATEGORY.md +9 -0
  44. package/templates/categories/dsh-plugin/CONTRIBUTING-append.md +22 -0
  45. package/templates/categories/dsh-plugin/DEVELOPMENT-append.md +71 -0
  46. package/templates/categories/dsh-plugin/PUBLISHING-append.md +53 -0
  47. package/templates/categories/dsh-plugin/README-append.md +28 -0
  48. package/templates/categories/dsh-plugin/README.en.md +44 -0
  49. package/templates/categories/dsh-plugin/gitignore-append.md +15 -0
  50. package/templates/categories/go-service/.githooks/pre-commit +13 -0
  51. package/templates/categories/go-service/.github/workflows/ci.yml +31 -0
  52. package/templates/categories/go-service/.github/workflows/release.yml +58 -0
  53. package/templates/categories/go-service/.goreleaser.yaml +86 -0
  54. package/templates/categories/go-service/AGENTS-append.md +7 -0
  55. package/templates/categories/go-service/CATEGORY.md +33 -0
  56. package/templates/categories/go-service/CONTRIBUTING-append.md +13 -0
  57. package/templates/categories/go-service/DEVELOPMENT-append.md +21 -0
  58. package/templates/categories/go-service/Makefile +21 -0
  59. package/templates/categories/go-service/PUBLISHING-append.md +48 -0
  60. package/templates/categories/go-service/PUBLISHING.md +59 -0
  61. package/templates/categories/go-service/README-append.md +26 -0
  62. package/templates/categories/go-service/README.en.md +43 -0
  63. package/templates/categories/go-service/gitignore-append.md +13 -0
  64. package/templates/categories/product-oss/.github/CODEOWNERS +6 -0
  65. package/templates/categories/product-oss/.github/ISSUE_TEMPLATE/bug_report.yml +52 -0
  66. package/templates/categories/product-oss/.github/ISSUE_TEMPLATE/config.yml +8 -0
  67. package/templates/categories/product-oss/.github/dependabot.yml +28 -0
  68. package/templates/categories/product-oss/.github/workflows/dependency-review.yml +18 -0
  69. package/templates/categories/product-oss/.github/workflows/release-please.yml +39 -0
  70. package/templates/categories/product-oss/.release-please-manifest.json +3 -0
  71. package/templates/categories/product-oss/CATEGORY.md +27 -0
  72. package/templates/categories/product-oss/CHANGELOG.md +14 -0
  73. package/templates/categories/product-oss/PULL_REQUEST_TEMPLATE.md +15 -0
  74. package/templates/categories/product-oss/SUPPORT.md +71 -0
  75. package/templates/categories/product-oss/release-please-config.json +10 -0
  76. package/templates/categories/python-app/.githooks/pre-commit +13 -0
  77. package/templates/categories/python-app/.github/workflows/ci.yml +33 -0
  78. package/templates/categories/python-app/.github/workflows/publish.yml +112 -0
  79. package/templates/categories/python-app/AGENTS-append.md +18 -0
  80. package/templates/categories/python-app/CATEGORY.md +34 -0
  81. package/templates/categories/python-app/CONTRIBUTING-append.md +13 -0
  82. package/templates/categories/python-app/DEVELOPMENT-append.md +21 -0
  83. package/templates/categories/python-app/PUBLISHING-append.md +52 -0
  84. package/templates/categories/python-app/PUBLISHING.md +61 -0
  85. package/templates/categories/python-app/README-append.md +27 -0
  86. package/templates/categories/python-app/README.en.md +44 -0
  87. package/templates/categories/python-app/gitignore-append.md +19 -0
  88. package/templates/categories/python-app/pyproject.toml +41 -0
  89. package/templates/categories/python-app/scripts/verify.py +39 -0
  90. package/templates/categories/sandbox/AGENTS-append.md +7 -0
  91. package/templates/categories/sandbox/CATEGORY.md +31 -0
  92. package/templates/categories/sandbox/README.en.md +24 -0
  93. package/templates/categories/sandbox/README.md +23 -0
  94. package/templates/check-single-source.mjs +107 -0
  95. package/templates/common/AGENTS-core.md +28 -0
  96. package/templates/common/CONTRIBUTING-core.md +21 -0
  97. package/templates/common/DEVELOPMENT-core.md +45 -0
  98. package/templates/common/PUBLISHING-core.md +31 -0
  99. package/templates/common/README-core.md +60 -0
  100. package/templates/common/SECURITY-core.md +19 -0
  101. package/templates/common/copilot-instructions.md +5 -0
  102. package/templates/common/gitignore-core.md +11 -0
  103. package/templates/repo-root/.githooks/pre-commit +20 -0
  104. package/templates/repo-root/.github/workflows/ci.yml +45 -0
  105. package/templates/repo-root/.github/workflows/publish.yml +77 -0
  106. package/templates/repo-root/CLAUDE.md +3 -0
  107. package/templates/repo-root/LICENSE +21 -0
  108. package/templates/repo-root/package.json +71 -0
  109. package/templates/repo-root/scripts/build.mjs +24 -0
  110. package/templates/repo-root/scripts/check-deploy.mjs +176 -0
  111. package/templates/repo-root/scripts/verify.mjs +52 -0
  112. package/templates/repo-root/src/config.ts +25 -0
  113. package/templates/repo-root/src/index.ts +40 -0
  114. package/templates/repo-root/tsconfig.build.json +22 -0
  115. package/templates/repo-root/tsconfig.json +23 -0
  116. package/templates/scaffold.mjs +793 -0
  117. package/templates/skill/SKILL.md +47 -0
package/repo-audit.mjs ADDED
@@ -0,0 +1,1507 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * repo-audit — 仓库标准化审计工具
4
+ *
5
+ * 审计 scaffold 生成的仓库是否符合工业最佳实践标准。
6
+ * 审计基准 = templates/ 目录下的模板文件。
7
+ *
8
+ * 用法:
9
+ * node repo-audit.mjs [options]
10
+ *
11
+ * 跨平台 shim:shim/repo-audit.sh | .cmd | .ps1
12
+ *
13
+ * 环境变量:
14
+ * LLM_PROVIDER - openai / anthropic / deepseek / none(默认 none)
15
+ * LLM_API_KEY - API Key(必须手动指定)
16
+ * LLM_MODEL - 模型名称(默认 auto)
17
+ * LLM_BASE_URL - 自定义 API 端点
18
+ * REPO_AUDIT_TYPES - 自定义分类列表(JSON 数组)
19
+ */
20
+
21
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'
22
+ import { join, resolve, dirname, relative } from 'node:path'
23
+ import { spawnSync } from 'node:child_process'
24
+ import { createHash } from 'node:crypto'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { platform } from 'node:os'
27
+
28
+ const ROOT = dirname(fileURLToPath(import.meta.url))
29
+ const RULES_DIR = join(ROOT, 'rules')
30
+ const TEMPLATES_DIR = join(ROOT, 'templates')
31
+ const DEFAULT_OUTPUT_DIR = 'audit-report'
32
+
33
+ // ============================================================
34
+ // 参数解析
35
+ // ============================================================
36
+
37
+ const KNOWN_FLAGS = new Set([
38
+ '--repo', '--type', '--format', '--output',
39
+ '--llm-provider', '--llm-model', '--llm-base-url', '--strict',
40
+ '--rules', '--dim', '--help', '-h'
41
+ ])
42
+ const VALUE_FLAGS = new Set([
43
+ '--repo', '--type', '--format', '--output',
44
+ '--llm-provider', '--llm-model', '--llm-base-url', '--rules', '--dim'
45
+ ])
46
+
47
+ function parseArgs(argv) {
48
+ const args = { _positional: [], flags: {} }
49
+ let expectValue = false
50
+ const orphans = []
51
+
52
+ for (const a of argv) {
53
+ if (expectValue) { args._positional.push(a); expectValue = false; continue }
54
+ const m = a.match(/^(--[a-z]+)=(.*)$/)
55
+ if (m && KNOWN_FLAGS.has(m[1])) { args.flags[m[1]] = m[2]; continue }
56
+ if (a.startsWith('--') && KNOWN_FLAGS.has(a) && !['--help', '-h', '--strict'].includes(a)) {
57
+ args.flags[a] = true; expectValue = true; continue
58
+ }
59
+ if (a.startsWith('--') && !KNOWN_FLAGS.has(a.split('=')[0])) {
60
+ orphans.push(a); args._positional.push(a)
61
+ }
62
+ if (a.startsWith('--')) { args.flags[a] = true; expectValue = true; continue }
63
+ args._positional.push(a)
64
+ }
65
+
66
+ // 收集值
67
+ const flagVals = {}
68
+ const flat = []
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const a = argv[i]
71
+ if (a.startsWith('--') && VALUE_FLAGS.has(a) && i + 1 < argv.length) {
72
+ flat.push([a, argv[i + 1]])
73
+ i++
74
+ } else if (!a.startsWith('--') || !KNOWN_FLAGS.has(a)) {
75
+ flat.push([a, null])
76
+ }
77
+ }
78
+ for (const [k, v] of flat) {
79
+ if (v !== null) {
80
+ if (flagVals[k] === undefined) flagVals[k] = v
81
+ else if (Array.isArray(flagVals[k])) flagVals[k].push(v)
82
+ else flagVals[k] = [flagVals[k], v]
83
+ }
84
+ }
85
+ args.flags = { ...args.flags, ...flagVals }
86
+
87
+ for (const o of orphans) {
88
+ console.error(`⚠ 未知参数 "${o}"`)
89
+ }
90
+ return args
91
+ }
92
+
93
+ function usage() {
94
+ return `repo-audit — 仓库标准化审计工具
95
+
96
+ 用法:
97
+ node repo-audit.mjs [选项]
98
+
99
+ 选项:
100
+ --repo <path> 目标仓库路径(默认:当前目录)
101
+ --type <type> 强制指定分类(跳过自动检测)
102
+ --format <fmt> 输出格式:md / json / both(默认:both)
103
+ --output <path> 输出目录(默认:<repo>/audit-report/)
104
+ --llm-provider <p> LLM 协议:openai / anthropic(DeepSeek/通义/智谱/硅基流动等都用 openai)
105
+ --llm-model <m> 模型名称(默认:自动选择)
106
+ --strict 严格模式:Critical/Major 发现即 exit 1
107
+ --rules <file> 自定义规则文件(可多次)
108
+ --dim <domain> 只审计指定维度(可多次,如 --dim security --dim docs)
109
+ --help, -h 显示此帮助
110
+
111
+ 环境变量:
112
+ LLM_PROVIDER LLM 协议(openai/anthropic/none);也可用厂商别名如 deepseek/qwen/glm
113
+ LLM_API_KEY API Key(必须手动指定)
114
+ LLM_MODEL 模型名称
115
+ LLM_BASE_URL 自定义 API 端点(OpenAI 兼容厂商必填,如 https://api.deepseek.com)
116
+ REPO_AUDIT_TYPES 自定义分类列表(JSON 数组)
117
+
118
+ 退出码:
119
+ 0 审计通过(无 Critical/Major 或 --strict 未启用)
120
+ 1 有 Critical/Major 发现且 --strict 启用
121
+ 2 参数错误
122
+ 3 目标不是 git 仓库
123
+
124
+ 示例:
125
+ node repo-audit.mjs
126
+ node repo-audit.mjs --repo /path/to/repo --type python-app
127
+ node repo-audit.mjs --strict --format json --output ./reports
128
+ LLM_PROVIDER=deepseek LLM_API_KEY=sk-xxx node repo-audit.mjs --dim security
129
+ `
130
+ }
131
+
132
+ // ============================================================
133
+ // 文件系统工具
134
+ // ============================================================
135
+
136
+ function hasFile(dir, name) {
137
+ return existsSync(join(dir, name))
138
+ }
139
+
140
+ function hasDir(dir, name) {
141
+ if (!existsSync(join(dir, name))) return false
142
+ try { return readdirSync(join(dir, name)).length > 0 } catch { return false }
143
+ }
144
+
145
+ function readJsonSafe(path) {
146
+ try { return JSON.parse(readFileSync(path, 'utf-8')) } catch { return null }
147
+ }
148
+
149
+
150
+ // P6: monorepo 感知 — 递归查找 package.json(根优先,fallback workspace 目录)
151
+ function findPackageJson(repoPath) {
152
+ // 根优先
153
+ const rootPkg = readJsonSafe(join(repoPath, 'package.json'))
154
+ if (rootPkg) return { path: join(repoPath, 'package.json'), pkg: rootPkg, dir: repoPath }
155
+ // 常见 monorepo 布局:apps/*/package.json, packages/*/package.json, workspaces/*/package.json
156
+ const patterns = ['apps', 'packages', 'workspaces', 'components', 'modules', 'libs', 'projects']
157
+ for (const pattern of patterns) {
158
+ const patternDir = join(repoPath, pattern)
159
+ if (!existsSync(patternDir)) continue
160
+ try {
161
+ for (const entry of readdirSync(patternDir, { withFileTypes: true })) {
162
+ if (!entry.isDirectory()) continue
163
+ const pkgPath = join(patternDir, entry.name, 'package.json')
164
+ const pkg = readJsonSafe(pkgPath)
165
+ if (pkg) return { path: pkgPath, pkg, dir: join(patternDir, entry.name) }
166
+ }
167
+ } catch {}
168
+ }
169
+ return null
170
+ }
171
+
172
+ function readFileSafe(path) {
173
+
174
+ try { return readFileSync(path, 'utf-8') } catch { return null }
175
+ }
176
+
177
+ function walkDir(dir, predicate = () => true) {
178
+ const result = []
179
+ function walk(d) {
180
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
181
+ if (entry.name.startsWith('.')) continue
182
+ const full = join(d, entry.name)
183
+ if (predicate(entry, full)) result.push(full)
184
+ if (entry.isDirectory()) walk(full)
185
+ }
186
+ }
187
+ try { walk(dir) } catch {}
188
+ return result
189
+ }
190
+
191
+ function countNonHiddenFiles(path) {
192
+ let count = 0
193
+ function walk(dir) {
194
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
195
+ if (entry.name.startsWith('.')) continue
196
+ if (entry.isDirectory()) walk(join(dir, entry.name))
197
+ else count++
198
+ }
199
+ }
200
+ try { walk(path) } catch {}
201
+ return count
202
+ }
203
+
204
+ // P0-3 fix: 嵌套 YAML 路径下钻查找(替代行级正则)
205
+ function findNestedYaml(lines, startIdx, baseIndent, segments) {
206
+ let currentIndent = baseIndent
207
+ for (let i = startIdx + 1; i < lines.length; i++) {
208
+ const l = lines[i]
209
+ if (!l.trim() || l.trim().startsWith('#')) continue
210
+ const ind = l.length - l.trimStart().length
211
+ if (ind <= currentIndent) break // 退出当前层级
212
+ const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
213
+ if (m) {
214
+ const key = m[1]
215
+ const valStr = m[2].trim()
216
+ const seg = segments[0]
217
+ if (key === seg) {
218
+ if (valStr && valStr !== '' && valStr !== '{}') {
219
+ // 标量值
220
+ return { found: true, value: valStr }
221
+ }
222
+ // 继续递归
223
+ const sub = findNestedYaml(lines, i, ind, segments.slice(1))
224
+ if (sub.found) return sub
225
+ }
226
+ }
227
+ }
228
+ return { found: false }
229
+ }
230
+
231
+
232
+ // P3: 项目定位识别 —— 扫描 README 首段检测个人/自用项目
233
+
234
+ // P5: 加载仓库级 .auditrc.yaml 豁免配置
235
+ function loadAuditrc(repoPath) {
236
+ const rcPath = join(repoPath, '.auditrc.yaml')
237
+ if (!existsSync(rcPath)) return { waive: [] }
238
+ const content = readFileSafe(rcPath)
239
+ if (!content) return { waive: [] }
240
+ try {
241
+ // 简单解析:waive: 下的列表项
242
+ const lines = content.split('\n')
243
+ const waive = []
244
+ let cur = null
245
+ for (const raw of lines) {
246
+ const line = raw.replace(/\r$/, '')
247
+ const trimmed = line.trim()
248
+ if (!trimmed || trimmed.startsWith('#')) continue
249
+ if (trimmed === 'waive:') continue
250
+ if (trimmed.startsWith('- id:')) {
251
+ if (cur) waive.push(cur)
252
+ cur = { id: trimmed.replace('- id:', '').trim() }
253
+ } else if (cur && trimmed.startsWith('reason:')) {
254
+ cur.reason = trimmed.replace('reason:', '').replace(/['"]/g, '').trim()
255
+ } else if (cur && trimmed.startsWith('since:')) {
256
+ cur.since = trimmed.replace('since:', '').trim()
257
+ }
258
+ }
259
+ if (cur) waive.push(cur)
260
+ return { waive }
261
+ } catch { return { waive: [] } }
262
+ }
263
+ function detectProjectMode(repoPath) {
264
+ const README_PATHS = ['README.md', 'README.en.md', 'readme.md']
265
+ let content = null
266
+ for (const p of README_PATHS) {
267
+ const c = readFileSafe(join(repoPath, p))
268
+ if (c) { content = c; break }
269
+ }
270
+ if (!content) return 'generic'
271
+ // 取前 10 行
272
+ const lines = content.split('\n').slice(0, 10).join(' ')
273
+ const PERSONAL_KEYWORDS = /自用|solo|personal|个人|单机|private.?use|internal.?use|private-use/i
274
+ if (PERSONAL_KEYWORDS.test(lines)) return 'personal'
275
+ return 'generic'
276
+ }
277
+
278
+ function countMdFiles(path) {
279
+ let count = 0
280
+ function walk(dir) {
281
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
282
+ if (entry.name.startsWith('.')) continue
283
+ const full = join(dir, entry.name)
284
+ if (entry.isDirectory()) walk(full)
285
+ else if (entry.name.endsWith('.md')) count++
286
+ }
287
+ }
288
+ try { walk(path) } catch {}
289
+ return count
290
+ }
291
+
292
+ // ============================================================
293
+ // Git 工具(跨平台)
294
+ // ============================================================
295
+
296
+ function git(args, cwd) {
297
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
298
+ return r.status === 0 ? r.stdout.trim() : null
299
+ }
300
+
301
+ function isGitRepo(path) {
302
+ const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: path, encoding: 'utf-8' })
303
+ return r.status === 0 && r.stdout.trim() === 'true'
304
+ }
305
+
306
+ function getRepoMetadata(path) {
307
+ return {
308
+ remote: git(['remote', 'get-url', 'origin'], path),
309
+ branch: git(['branch', '--show-current'], path) || '(detached)',
310
+ commitCount: parseInt(git(['rev-list', '--count', 'HEAD'], path) || '0'),
311
+ fileCount: countNonHiddenFiles(path),
312
+ lastCommit: git(['log', '-1', '--format=%ci'], path) || '未知',
313
+ path
314
+ }
315
+ }
316
+
317
+ // ============================================================
318
+ // 分类检测(通用化评分制)
319
+ // ============================================================
320
+
321
+ const CUSTOM_TYPES = process.env.REPO_AUDIT_TYPES
322
+ ? JSON.parse(process.env.REPO_AUDIT_TYPES)
323
+ : ['dsh-plugin', 'python-app', 'go-service', 'sandbox', 'content', 'product-oss',
324
+ 'javascript', 'npm-package', 'rust', 'ruby', 'java-kotlin', 'java-maven', 'agent-collab', 'changelog-driven', 'multi-maintainer', 'oss-like', 'automated-release', 'unknown', 'archive']
325
+
326
+ function detectType(repoPath) {
327
+ const scores = {}
328
+
329
+ // 语言 / 运行时检测
330
+ const mpj = findPackageJson(repoPath)
331
+ if (mpj) {
332
+ scores['javascript'] = 5
333
+ const pkg = mpj.pkg
334
+ if (pkg?.peerDependencies?.['@deepseek-ai/dsh-session']) scores['dsh-plugin'] = 12
335
+ else if (pkg?.devDependencies?.['@deepseek-ai/dsh-session']) scores['dsh-plugin'] = 10
336
+ else scores['npm-package'] = 5
337
+ if (hasFile(repoPath, 'cordis.patch.yml')) scores['dsh-plugin'] += 5
338
+ if (hasFile(repoPath, 'tsconfig.json')) scores['javascript'] += 1
339
+ }
340
+
341
+ if (hasFile(repoPath, 'pyproject.toml')) scores['python-app'] = 8
342
+ if (hasFile(repoPath, 'go.mod')) scores['go-service'] = 9
343
+ if (hasFile(repoPath, 'Cargo.toml')) scores['rust'] = 8
344
+ if (hasFile(repoPath, 'Gemfile')) scores['ruby'] = 7
345
+ if (hasFile(repoPath, 'build.gradle') || hasFile(repoPath, 'build.gradle.kts')) scores['java-kotlin'] = 7
346
+ if (hasFile(repoPath, 'pom.xml')) scores['java-maven'] = 7
347
+
348
+ // 协作模式
349
+ if (hasFile(repoPath, 'CHANGELOG.md')) scores['changelog-driven'] = 3
350
+ if (hasFile(repoPath, '.github/CODEOWNERS')) scores['multi-maintainer'] = 4
351
+ if (hasFile(repoPath, '.github/dependabot.yml')) scores['oss-like'] = 3
352
+ if (hasFile(repoPath, 'release-please-config.json')) scores['automated-release'] = 5
353
+ if (hasFile(repoPath, '.github/workflows/release-please.yml')) scores['automated-release'] += 2
354
+
355
+ // AGENTS / 内容特征
356
+ if (hasFile(repoPath, 'AGENTS.md')) scores['agent-collab'] = 3
357
+ const mdCount = countMdFiles(repoPath)
358
+ if (mdCount > 3 && !mpj && !hasFile(repoPath, 'go.mod') && !hasFile(repoPath, 'pyproject.toml')) {
359
+ scores['content'] = Math.min(mdCount, 10)
360
+ }
361
+
362
+ // 沙盒特征
363
+ if (!mpj && !hasFile(repoPath, 'go.mod') &&
364
+ !hasFile(repoPath, 'pyproject.toml') && !hasFile(repoPath, 'Cargo.toml')) {
365
+ if (hasFile(repoPath, 'AGENTS.md') && !hasFile(repoPath, 'SECURITY.md')) {
366
+ scores['sandbox'] = 5
367
+ }
368
+ }
369
+
370
+ // 归档检测
371
+ if (isGitRepo(repoPath)) {
372
+ const log = spawnSync('git', ['log', '--oneline', '-1'], { cwd: repoPath, encoding: 'utf-8' })
373
+ if (log.stdout?.match(/\[archived\]|\[封版\]|archived/i)) scores['archive'] = 11
374
+ }
375
+
376
+ // Python tests 补充
377
+ if (hasFile(repoPath, 'pytest.ini') || hasFile(repoPath, 'setup.cfg') || hasFile(repoPath, 'tox.ini')) {
378
+ scores['python-app'] += 1
379
+ }
380
+ if (hasDir(repoPath, 'src')) {
381
+ try {
382
+ const srcFiles = readdirSync(join(repoPath, 'src'))
383
+ if (srcFiles.some(f => f.endsWith('.py'))) scores['python-app'] += 2
384
+ if (srcFiles.some(f => f.endsWith('.ts') || f.endsWith('.tsx'))) scores['javascript'] += 2
385
+ } catch {}
386
+ }
387
+ if (hasDir(repoPath, 'tests') || hasDir(repoPath, 'test')) scores['python-app'] += 1
388
+
389
+ // Go 补充
390
+ if (hasDir(repoPath, 'cmd')) scores['go-service'] += 2
391
+ if (hasFile(repoPath, 'Makefile')) {
392
+ if (scores['go-service'] >= 9) scores['go-service'] += 1
393
+ else scores['javascript'] += 1
394
+ }
395
+
396
+ // 选最高分
397
+ let bestType = 'unknown'
398
+ let bestScore = 0
399
+ for (const [type, score] of Object.entries(scores)) {
400
+ if (score > bestScore) { bestScore = score; bestType = type }
401
+ }
402
+
403
+ // 过滤到自定义类型列表
404
+ const validType = CUSTOM_TYPES.includes(bestType) ? bestType :
405
+ bestScore >= 5 ? bestType : 'unknown'
406
+
407
+ return {
408
+ type: validType,
409
+ confidence: bestScore >= 8 ? 'high' : bestScore >= 5 ? 'medium' : 'low',
410
+ reasons: Object.entries(scores)
411
+ .filter(([, s]) => s >= 3)
412
+ .sort((a, b) => b[1] - a[1])
413
+ .slice(0, 5)
414
+ .map(([t, s]) => `${t}: ${s}分`),
415
+ scores
416
+ }
417
+ }
418
+
419
+ // ============================================================
420
+ // 规则加载(YAML 轻量解析)
421
+ // ============================================================
422
+
423
+ function parseSimpleYaml(content) {
424
+ const lines = content.split('\n')
425
+ const result = { rules: [] }
426
+ let currentRule = null
427
+ // 深度跟踪栈:每个条目 { indent, obj, key } 表示 obj[key] 是缩进 indent 的对象
428
+ // 用 depth 计数而不是 indent 比较来避免同层歧义
429
+ const pathStack = [] // [{indent, obj, key}]
430
+
431
+ function resolveTarget(indent) {
432
+ // 从栈顶向下找第一个 indent 严格小于当前的条目
433
+ for (let i = pathStack.length - 1; i >= 0; i--) {
434
+ if (pathStack[i].indent < indent) return pathStack[i].obj
435
+ }
436
+ return currentRule
437
+ }
438
+
439
+ function setVal(obj, key, val, quoteType) {
440
+ if (val === '' || val === '{}') { obj[key] = {}; return }
441
+ // 去引号 + 转义处理
442
+ let clean
443
+ if (quoteType === "'") {
444
+ // 单引号:仅去首尾引号,内部 \ 不做转义(YAML 1.2 规范)
445
+ clean = val
446
+ } else {
447
+ // 双引号:去首尾引号 + 标准转义还原
448
+ clean = val.replace(/^"|"$/g, '').trim()
449
+ clean = clean
450
+ .replace(/\\n/g, '\n')
451
+ .replace(/\\t/g, '\t')
452
+ .replace(/\\r/g, '\r')
453
+ .replace(/\\"/g, '"')
454
+ .replace(/\\\\/g, '\\')
455
+ .replace(/\\\//g, '/')
456
+ }
457
+ if (clean.startsWith('[') && clean.endsWith(']')) {
458
+ try { obj[key] = JSON.parse(clean) } catch { obj[key] = clean }
459
+ } else if (!isNaN(clean) && clean !== '') {
460
+ obj[key] = Number(clean)
461
+ } else if (clean === 'true') { obj[key] = true }
462
+ else if (clean === 'false') { obj[key] = false }
463
+ else { obj[key] = clean }
464
+ }
465
+
466
+ for (const rawLine of lines) {
467
+ const line = rawLine.replace(/\r$/, '')
468
+ const trimmed = line.trim()
469
+ if (!trimmed || trimmed.startsWith('#')) continue
470
+ const indent = line.length - line.trimStart().length
471
+
472
+ // 顶层 rules: 数组项
473
+ if (indent === 2 && trimmed.startsWith('- id:')) {
474
+ if (currentRule) result.rules.push(currentRule)
475
+ currentRule = {}
476
+ pathStack.length = 0
477
+ const val = trimmed.replace('- id:', '').trim().replace(/^["']|["']$/g, '')
478
+ currentRule.id = val
479
+ continue
480
+ }
481
+
482
+ if (!currentRule) continue
483
+
484
+ // 数组项(indent >= 4, starts with "- ")
485
+ if (trimmed.startsWith('- ') && indent >= 4) {
486
+ const val = trimmed.slice(2).trim().replace(/^["']|["']$/g, '')
487
+ // 数组项:弹出严格深于当前行的条目,保留同层的作为父对象
488
+ while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent > indent) {
489
+ pathStack.pop()
490
+ }
491
+ let parent = resolveTarget(indent)
492
+ // 如果父对象本身是空的(例如 patterns: 刚被创建),向上找一级
493
+ if (Object.keys(parent).length === 0) {
494
+ // 找到上一层的条目,取其 obj 作为父对象
495
+ for (let i = pathStack.length - 1; i >= 0; i--) {
496
+ const candidate = pathStack[i].obj
497
+ if (Object.keys(candidate).length > 0) { parent = candidate; break }
498
+ }
499
+ }
500
+ // 找到父对象中最后一个数组或空对象键
501
+ const lastKey = Object.keys(parent).reverse().find(k =>
502
+ Array.isArray(parent[k]) || (typeof parent[k] === 'object' && parent[k] !== null && Object.keys(parent[k]).length === 0)
503
+ )
504
+ if (lastKey) {
505
+ if (typeof parent[lastKey] === 'object' && parent[lastKey] !== null && Object.keys(parent[lastKey]).length === 0) {
506
+ parent[lastKey] = []
507
+ }
508
+ parent[lastKey].push(val)
509
+ }
510
+ continue
511
+ }
512
+
513
+ // 键值对
514
+ const kvMatch = trimmed.match(/^(\w[\w\-]*)\s*:\s*(.*)$/)
515
+ if (!kvMatch) continue
516
+ const [, key, val] = kvMatch
517
+ const cleanVal = val.trim()
518
+ const target = resolveTarget(indent)
519
+
520
+ if (cleanVal === '' || cleanVal === '{}') {
521
+ // 嵌套对象:记录到路径栈
522
+ // 注意:栈中存的是嵌套值本身(target[key]),不是 target
523
+ target[key] = {}
524
+ // 弹出所有 indent >= 当前的条目(这些已关闭)
525
+ while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent) {
526
+ pathStack.pop()
527
+ }
528
+ pathStack.push({ indent, obj: target[key], key })
529
+ } else {
530
+ // 普通值:检测引号类型以正确处理转义
531
+ const rawVal = val.trim()
532
+ const quoteType = rawVal.startsWith("'") ? "'" : '"'
533
+ setVal(target, key, cleanVal, quoteType)
534
+ // 弹出所有 indent >= 当前的条目
535
+ while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent) {
536
+ pathStack.pop()
537
+ }
538
+ }
539
+ }
540
+
541
+ if (currentRule) result.rules.push(currentRule)
542
+ return result
543
+ }
544
+
545
+ function loadRules(type, customPaths = [], repoPath = null) {
546
+ // P3: 项目定位识别
547
+ const projectMode = repoPath ? detectProjectMode(repoPath) : 'generic'
548
+ // 个人/自用项目豁免列表(按规则 ID)
549
+ const personalSkipIds = new Set([
550
+ 'DOC-002', // 双语 README
551
+ 'DOC-005', // CONTRIBUTING.md
552
+ 'DOC-007', // PUBLISHING.md
553
+ 'QUA-002', // CI workflow
554
+ 'QUA-005', // CLAUDE.md
555
+ 'GO-004', // CI workflow go(与 QUA-002 重复)
556
+ 'GO-005', // release workflow
557
+ ])
558
+
559
+ const allRules = []
560
+
561
+ // 加载 domains/*.yaml(与 categories 同构过滤)
562
+ const domainsDir = join(RULES_DIR, 'domains')
563
+ if (existsSync(domainsDir)) {
564
+ for (const file of readdirSync(domainsDir)) {
565
+ if (!file.endsWith('.yaml')) continue
566
+ const content = readFileSafe(join(domainsDir, file))
567
+ if (content) {
568
+ const parsed = parseSimpleYaml(content)
569
+ const filtered = (parsed.rules || []).filter(r =>
570
+ !r.applies_to || r.applies_to.includes('*') || r.applies_to.includes(type)
571
+ )
572
+ allRules.push(...filtered)
573
+ }
574
+ }
575
+ }
576
+
577
+ // 加载 categories/<type>.yaml
578
+ const catFile = join(RULES_DIR, 'categories', `${type}.yaml`)
579
+ if (existsSync(catFile)) {
580
+ const content = readFileSafe(catFile)
581
+ if (content) {
582
+ const parsed = parseSimpleYaml(content)
583
+ allRules.push(...(parsed.rules || []))
584
+ }
585
+ }
586
+
587
+ // 加载 categories/*.yaml(通用分类规则,applies_to 含 '*' 的)
588
+ if (existsSync(join(RULES_DIR, 'categories'))) {
589
+ for (const file of readdirSync(join(RULES_DIR, 'categories'))) {
590
+ if (!file.endsWith('.yaml') || file === `${type}.yaml`) continue
591
+ const content = readFileSafe(join(RULES_DIR, 'categories', file))
592
+ if (content) {
593
+ const parsed = parseSimpleYaml(content)
594
+ const filtered = (parsed.rules || []).filter(r =>
595
+ !r.applies_to || r.applies_to.includes('*') || r.applies_to.includes(type)
596
+ )
597
+ allRules.push(...filtered)
598
+ }
599
+ }
600
+ }
601
+
602
+ // 加载自定义规则
603
+ const paths = Array.isArray(customPaths) ? customPaths : (customPaths ? [customPaths] : [])
604
+ for (const p of paths) {
605
+ if (existsSync(p)) {
606
+ const content = readFileSafe(p)
607
+ if (content) {
608
+ const parsed = parseSimpleYaml(content)
609
+ allRules.push(...(parsed.rules || []))
610
+ }
611
+ }
612
+ }
613
+
614
+ // P3: 个人项目 N/A 过滤 + 等效内容检测
615
+ if (projectMode === 'personal') {
616
+ const filtered = allRules.filter(r => {
617
+ // 直接跳过的规则
618
+ if (personalSkipIds.has(r.id)) return false
619
+ // 等效内容检测
620
+ if (r.id === 'DOC-008' && existsSync(join(repoPath, 'docs', 'security.md'))) return false
621
+ if (r.id === 'DOC-006' && hasEquivalentDocs(repoPath)) return false
622
+ if (r.id === 'QUA-003' && existsSync(join(repoPath, '.githooks')) &&
623
+ readdirSync(join(repoPath, '.githooks')).length > 0) return false
624
+ return true
625
+ })
626
+ return filtered
627
+ }
628
+
629
+ // Archive: 仅保留安全/脱敏规则(SEC-001, SEC-003)
630
+ if (type === 'archive') {
631
+ const archiveIds = new Set(['SEC-001', 'SEC-003'])
632
+ const filtered = allRules.filter(r => archiveIds.has(r.id) || r.domain === 'security')
633
+ // 替换 allRules 引用后的去重
634
+ const seenIds = new Set()
635
+ return filtered.filter(r => {
636
+ if (seenIds.has(r.id)) return false
637
+ seenIds.add(r.id)
638
+ return true
639
+ })
640
+ }
641
+
642
+ // 参数类型校验:YAML 解析后参数类型可能与代码假设不匹配,
643
+ // 在加载阶段提前发现并警告,而非运行时 catch 吞掉
644
+ const PARAM_SCHEMA = {
645
+ file_exists: { paths: ['array', 'string'] },
646
+ file_contains: { path: ['string'], patterns: ['array', 'string'] },
647
+ file_header: { path: ['string'], pattern: ['string'] },
648
+ regex: { path: ['string'], pattern: ['string'] },
649
+ grep: { pattern: ['string'] },
650
+ json_field: { path: ['string'], field: ['string'], fallback_field: ['array', 'string'] },
651
+ toml_field: { path: ['string'], field: ['string'] },
652
+ yaml_field: { path: ['string'], field: ['string'] },
653
+ directory_exists: { paths: ['array', 'string'] },
654
+ not_exists: { paths: ['array', 'string'] },
655
+ glob_count: { pattern: ['string'] },
656
+ command_result: { command: ['string'] },
657
+ }
658
+ const checkType = (val, types) =>
659
+ types.some(t => t === 'array' ? Array.isArray(val) : typeof val === t)
660
+ for (const rule of allRules) {
661
+ const schema = PARAM_SCHEMA[rule.check]
662
+ if (!schema) continue
663
+ const params = rule.params || {}
664
+ for (const [key, expectedTypes] of Object.entries(schema)) {
665
+ if (params[key] === undefined) continue
666
+ if (!checkType(params[key], expectedTypes)) {
667
+ console.error(`⚠ 规则 ${rule.id} (${rule.check}):参数 ${key} 类型不匹配,` +
668
+ `期望 ${expectedTypes.join(' | ')},实际 ${Array.isArray(params[key]) ? 'array' : typeof params[key]} — 该规则将被跳过`)
669
+ rule._invalid = true
670
+ break
671
+ }
672
+ // fallback_field 语义校验:字符串含逗号时应为数组格式
673
+ if (rule.check === 'json_field' && key === 'fallback_field' &&
674
+ typeof params[key] === 'string' && params[key].includes(',')) {
675
+ console.error(`⚠ 规则 ${rule.id}:fallback_field 为逗号分隔字符串,应为 YAML 数组格式 [${JSON.stringify(params[key].split(','))}]`)
676
+ rule._invalid = true
677
+ break
678
+ }
679
+ }
680
+ }
681
+
682
+ // 去重:同 id 只保留第一条
683
+ const seenIds = new Set()
684
+ const deduped = []
685
+ for (const r of allRules) {
686
+ if (r._invalid) continue
687
+ if (!seenIds.has(r.id)) { seenIds.add(r.id); deduped.push(r) }
688
+ }
689
+ return deduped
690
+ }
691
+
692
+ // P3: 等效开发文档检测
693
+ function hasEquivalentDocs(repoPath) {
694
+ const docsDir = join(repoPath, 'docs')
695
+ if (!existsSync(docsDir)) return false
696
+ const docs = readdirSync(docsDir)
697
+ // 检测到开发流程相关文档即视为等效
698
+ return docs.some(f => /systemd|docker|deploy|cli|dev|setup/i.test(f) && f.endsWith('.md'))
699
+ }
700
+
701
+ // ============================================================
702
+ // 规则检查引擎
703
+ // ============================================================
704
+
705
+ function runCheck(rule, repoPath, mpj = null) {
706
+ const { check, params } = rule
707
+ let passed = false
708
+ let evidence = null
709
+
710
+ switch (check) {
711
+ case 'file_exists': {
712
+ const paths = Array.isArray(params?.paths) ? params.paths : [params?.paths].filter(Boolean)
713
+ const anyOf = params?.anyOf !== undefined ? params.anyOf : (paths.length > 1)
714
+ // P23 fix: skip_if_no_parent — 父目录不存在时记 pass(非适用场景)
715
+ if (params?.skip_if_no_parent) {
716
+ const parentExists = existsSync(join(repoPath, params.skip_if_no_parent))
717
+ if (!parentExists) {
718
+ passed = true
719
+ evidence = `✓ ${params.skip_if_no_parent} 不存在(非 scaffold 管理仓,豁免)`
720
+ break
721
+ }
722
+ }
723
+ // P0-2 fix: skip_if_no_file — 目标文件不存在时记 pass(非适用场景)
724
+ if (params?.skip_if_no_file) {
725
+ if (!existsSync(join(repoPath, params.skip_if_no_file))) {
726
+ passed = true
727
+ evidence = `✓ ${params.skip_if_no_file} 不存在(豁免)`
728
+ break
729
+ }
730
+ }
731
+ const found = paths.filter(p => existsSync(join(repoPath, p)))
732
+ passed = anyOf ? found.length > 0 : paths.length > 0 && found.length === paths.length
733
+ evidence = passed
734
+ ? `✓ ${found.join(', ')}`
735
+ : `缺失: ${paths.filter(p => !found.includes(p)).join(', ') || '无匹配'}`
736
+ // P4: fallback_paths + fallback_field — 主路径 fail 时尝试 fallback(如 verify.mjs → package.json scripts.test)
737
+ if (!passed && params?.fallback_paths && params?.fallback_field) {
738
+ const fbPaths = Array.isArray(params.fallback_paths) ? params.fallback_paths : [params.fallback_paths]
739
+ const fbField = params.fallback_field
740
+ for (const fp of fbPaths) {
741
+ if (existsSync(join(repoPath, fp)) || (fp === 'package.json' && mpj)) {
742
+ const actualPath = (fp === 'package.json' && mpj) ? mpj.path : join(repoPath, fp)
743
+ const fbContent = readFileSafe(actualPath)
744
+ if (fbContent) {
745
+ try {
746
+ const fbJson = JSON.parse(fbContent)
747
+ const segs = fbField.split('.')
748
+ let val = fbJson
749
+ for (const seg of segs) {
750
+ if (val === null || val === undefined) break
751
+ val = val[seg]
752
+ }
753
+ if (val !== null && val !== undefined && val !== '') {
754
+ passed = true
755
+ evidence = `✓ ${fp} fallback 命中: ${fbField} = ${String(val).slice(0, 60)}`
756
+ break
757
+ }
758
+ } catch {}
759
+ }
760
+ }
761
+ }
762
+ if (!passed) evidence += ` (fallback 也未命中)`
763
+ }
764
+ // P6: monorepo lock file fallback — 根无锁文件时搜索 workspace
765
+ if (!passed && paths.length > 0 && mpj && mpj.dir !== repoPath) {
766
+ const lockFiles = paths.filter(p => p.includes('lock') || p.includes('.sum'))
767
+ if (lockFiles.length > 0) {
768
+ for (const lf of lockFiles) {
769
+ const wsPath = join(mpj.dir, lf)
770
+ if (existsSync(wsPath)) {
771
+ passed = true
772
+ evidence = `✓ ${lf}(workspace: ${mpj.dir})`
773
+ break
774
+ }
775
+ }
776
+ }
777
+ }
778
+ if (!passed) evidence = `缺失: ${paths.filter(p => !found.includes(p)).join(', ') || '无匹配'}`
779
+ break
780
+ }
781
+
782
+ case 'file_contains': {
783
+ const content = readFileSafe(join(repoPath, params?.path))
784
+ if (!content) { passed = false; evidence = '文件不存在'; break }
785
+ const patterns = Array.isArray(params?.patterns) ? params.patterns : [params?.patterns].filter(Boolean)
786
+ const found = patterns.filter(p => content.includes(p))
787
+ passed = found.length === patterns.length
788
+ evidence = passed
789
+ ? `✓ 全部匹配`
790
+ : `缺失: ${patterns.filter(p => !found.includes(p)).join(', ')}`
791
+ break
792
+ }
793
+
794
+ case 'file_header': {
795
+ const content = readFileSafe(join(repoPath, params?.path))
796
+ if (!content) { passed = false; evidence = '文件不存在'; break }
797
+ const required = Array.isArray(params?.required_strings) ? params.required_strings : [params?.required_strings].filter(Boolean)
798
+ const found = required.filter(s => content.includes(s))
799
+ passed = found.length === required.length
800
+ evidence = passed
801
+ ? `✓ LICENSE 头部完整`
802
+ : `缺失: ${required.filter(s => !found.includes(s)).join(', ')}`
803
+ break
804
+ }
805
+
806
+ case 'regex': {
807
+ if (params?.command) {
808
+ const r = spawnSync('sh', ['-c', params.command], { cwd: repoPath, encoding: 'utf-8' })
809
+ if (r.status !== 0) { passed = false; evidence = `命令失败: ${r.stderr?.trim() || 'exit ' + r.status}`; break }
810
+ const re = new RegExp(params.pattern)
811
+ const lines = r.stdout.split('\n').filter(Boolean)
812
+ const matched = lines.filter(l => re.test(l))
813
+ passed = params?.requireAtLeast
814
+ ? matched.length >= params.requireAtLeast
815
+ : matched.length === lines.length
816
+ evidence = `${matched.length}/${lines.length} 匹配${params?.requireAtLeast ? `(需≥${params.requireAtLeast})` : ''}`
817
+ } else if (params?.path) {
818
+ const content = readFileSafe(join(repoPath, params.path))
819
+ if (!content) { passed = false; evidence = '文件不存在'; break }
820
+ const re = new RegExp(params.pattern)
821
+ const matches = content.match(re)
822
+ passed = !!matches
823
+ evidence = matches ? `✓ 找到 ${matches.length} 处匹配` : '无匹配'
824
+ }
825
+ break
826
+ }
827
+
828
+ case 'grep': {
829
+ const patterns = params?.patterns || []
830
+ const exclude = params?.exclude || ['.git/', 'node_modules/', '.scaffold/', 'audit-report/', 'templates/']
831
+ const results = []
832
+ // P1-1 fix: 优先用 git ls-files 只扫描 tracked 文件,避免 .gitignore 排除的私有文档误报
833
+ let trackedFiles = null
834
+ try {
835
+ const lsOut = spawnSync('git', ['ls-files'], { cwd: repoPath, encoding: 'utf-8' })
836
+ if (lsOut.status === 0) {
837
+ trackedFiles = new Set(lsOut.stdout.trim().split('\n').filter(Boolean))
838
+ }
839
+ } catch {}
840
+ function grepDir(dir) {
841
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
842
+ if (entry.name.startsWith('.')) continue
843
+ const full = join(dir, entry.name)
844
+ const rel = relative(repoPath, full)
845
+ if (exclude.some(e => rel.startsWith(e) || rel.includes('/' + e))) continue
846
+ // tracked-only 过滤
847
+ if (trackedFiles && !trackedFiles.has(rel)) continue
848
+ if (entry.isDirectory()) { grepDir(full); continue }
849
+ if (!entry.isFile()) continue
850
+ try {
851
+ const content = readFileSync(full, 'utf-8')
852
+ for (const pat of patterns) {
853
+ const re = new RegExp(pat, 'i')
854
+ const match = content.match(re)
855
+ if (match) {
856
+ const lineNum = content.split('\n').findIndex(l => re.test(l)) + 1
857
+ results.push({ file: rel, line: lineNum, match: match[0]?.slice(0, 60) })
858
+ if (params?.max_match !== undefined && results.length >= params.max_match) return
859
+ }
860
+ }
861
+ } catch {}
862
+ }
863
+ }
864
+ try { grepDir(repoPath) } catch {}
865
+ passed = results.length === 0
866
+ evidence = passed
867
+ ? '✓ 未发现匹配'
868
+ : `发现 ${results.length} 处: ${results.slice(0, 3).map(r => `${r.file}:${r.line}`).join(', ')}`
869
+ break
870
+ }
871
+
872
+ case 'command_result': {
873
+ const r = spawnSync('sh', ['-c', params?.command], { cwd: repoPath, encoding: 'utf-8' })
874
+ if (r.status !== 0) { passed = false; evidence = `命令失败 (exit ${r.status})`; break }
875
+ const val = parseInt(r.stdout.trim())
876
+ // P3: skip_if_zero — 命令结果为 0 时豁免(如零提交仓、空文件等)
877
+ if (params?.skip_if_zero && val === 0) {
878
+ passed = true
879
+ evidence = `✓ 结果为 0,豁免检查`
880
+ break
881
+ }
882
+ passed = val >= (params?.min ?? 0)
883
+ evidence = `结果: ${val} (需 ≥ ${params?.min ?? 0})`
884
+ break
885
+ }
886
+
887
+ case 'json_field': {
888
+ // P6: monorepo 感知 — package.json 不在根时 fallback 到 workspace 目录
889
+ let actualPath = join(repoPath, params?.path)
890
+ let workspaceHint = ''
891
+ if (!readFileSafe(actualPath) && params?.path === 'package.json' && mpj) {
892
+ actualPath = mpj.path
893
+ workspaceHint = `(workspace: ${mpj.dir})`
894
+ }
895
+ const content = readFileSafe(actualPath)
896
+ if (!content) { passed = false; evidence = '文件不存在'; break }
897
+ try {
898
+ const obj = JSON.parse(content)
899
+ const fields = (params?.field || '').split('.')
900
+ let val = obj
901
+ let missing = false
902
+ for (const f of fields) {
903
+ if (val === undefined || val === null) { passed = false; evidence = `字段 ${f} 不存在`; missing = true; break }
904
+ val = val[f]
905
+ }
906
+ if (!missing) {
907
+ passed = val !== undefined && val !== null
908
+ evidence = `字段 ${params.field} = ${JSON.stringify(val)?.slice(0, 100)}${workspaceHint}`
909
+ }
910
+ // P1-2 fix: 支持 fallback_field(如 peerDeps 不存在时检查 devDeps)
911
+ // 支持两种格式:数组 ["deps", "peerDeps", "devDeps"] 或字符串 "deps"(点路径)
912
+ if (!passed && params?.fallback_field) {
913
+ const fbFields = Array.isArray(params.fallback_field)
914
+ ? params.fallback_field
915
+ : params.fallback_field.split('.')
916
+ // 对每个候选字段,尝试在 obj 中查找
917
+ for (const candidate of fbFields) {
918
+ const segs = Array.isArray(candidate) ? candidate : candidate.split('.')
919
+ let fbVal = obj
920
+ for (const f of segs) {
921
+ if (fbVal === undefined || fbVal === null) break
922
+ fbVal = fbVal[f]
923
+ }
924
+ if (fbVal !== undefined && fbVal !== null) {
925
+ passed = true
926
+ const matchedField = Array.isArray(params.fallback_field)
927
+ ? String(candidate)
928
+ : params.fallback_field
929
+ evidence = `字段 ${params.field} 不存在,但 ${matchedField} = ${JSON.stringify(fbVal)?.slice(0, 100)}${workspaceHint}`
930
+ break
931
+ }
932
+ }
933
+ }
934
+ } catch (e) { passed = false; evidence = `JSON 解析失败: ${e.message}` }
935
+ break
936
+ }
937
+
938
+ case 'toml_field': {
939
+ const content = readFileSafe(join(repoPath, params?.path))
940
+ if (!content) { passed = false; evidence = '文件不存在'; break }
941
+ // P0-1 fix + v2 fix: 支持「表路径」expect_table 模式
942
+ const field = (params?.field || '').trim()
943
+ const expectTable = !!params?.expect_table
944
+ const segs = field.split('.')
945
+ const table = segs.slice(0, -1).join('.') // 父表路径
946
+ const key = segs[segs.length - 1] // 最后一层 key
947
+ const lines = content.split('\n')
948
+ let curTable = '' // 当前表路径("" = 根表)
949
+ let hit = null
950
+ for (const raw of lines) {
951
+ const line = raw.replace(/#.*/, '').trim() // 剥行尾注释
952
+ if (!line) continue
953
+ // 匹配 [table] 或 [[array.table]]
954
+ const t = line.match(/^\[+([\w.\-]+)\]+$/)
955
+ if (t) {
956
+ const tbl = t[1]
957
+ // expect_table 模式:精确匹配完整表路径([tool.pytest.ini_options])
958
+ if (expectTable && tbl === field) { hit = true; break }
959
+ // 非 expect_table 模式:track 当前表
960
+ curTable = tbl
961
+ continue
962
+ }
963
+ // 匹配 key = value(仅非 expect_table 模式)
964
+ if (!expectTable) {
965
+ const k = line.match(/^([\w.\-]+)\s*=\s*(.+)$/)
966
+ if (k && k[1] === key && curTable === table) {
967
+ hit = k[2].trim().replace(/^["']|["']$/g, '')
968
+ }
969
+ }
970
+ }
971
+ passed = !!hit
972
+ if (expectTable) {
973
+ evidence = hit ? `✓ 表 ${field} 存在` : `表 ${field} 不存在`
974
+ } else {
975
+ evidence = hit !== null ? `field = ${hit.slice(0, 80)}` : `字段不存在([${table}]${table ? '.' : ''}${key})`
976
+ }
977
+ break
978
+ }
979
+
980
+ case 'yaml_field': {
981
+ // P0-2 fix: skip_if_no_file — 文件不存在时豁免(非适用场景)
982
+ if (params?.skip_if_no_file) {
983
+ if (!existsSync(join(repoPath, params.skip_if_no_file))) {
984
+ passed = true
985
+ evidence = `✓ ${params.skip_if_no_file} 不存在(豁免)`
986
+ break
987
+ }
988
+ }
989
+ const content = readFileSafe(join(repoPath, params?.path))
990
+ if (!content) { passed = false; evidence = '文件不存在'; break }
991
+ const field = params?.field || ''
992
+ const expected = (params?.expected || '').trim()
993
+ // 嵌套 YAML 路径:逐段下钻缩进层级
994
+ const segments = field.split('.')
995
+ let val = null
996
+ let found = false
997
+ try {
998
+ const lines = content.split('\n')
999
+ let currentIndent = -1
1000
+ let currentObj = null
1001
+ // 第一遍:找到顶层键
1002
+ for (let i = 0; i < lines.length; i++) {
1003
+ const l = lines[i]
1004
+ if (!l.trim() || l.trim().startsWith('#')) continue
1005
+ const ind = l.length - l.trimStart().length
1006
+ const m = l.match(/^\s*(\w[\w\-]*)\s*:\s*(.*)$/)
1007
+ if (m && m[1] === segments[0]) {
1008
+ currentIndent = ind
1009
+ const valStr = m[2].trim()
1010
+ if (valStr && valStr !== '' && valStr !== '{}') {
1011
+ // 标量值(非嵌套)
1012
+ val = valStr
1013
+ found = true
1014
+ } else {
1015
+ // 嵌套对象,递归查找
1016
+ const subResult = findNestedYaml(lines, i, ind, segments.slice(1))
1017
+ if (subResult.found) { val = subResult.value; found = true }
1018
+ }
1019
+ break
1020
+ }
1021
+ }
1022
+ } catch {}
1023
+ if (found) {
1024
+ passed = val !== null && String(val).trim() === expected
1025
+ evidence = passed ? `value = ${String(val).trim()}` : `value = ${String(val)?.trim()}(期望 ${expected})`
1026
+ } else {
1027
+ passed = false
1028
+ evidence = '字段不存在或值不匹配'
1029
+ }
1030
+ break
1031
+ }
1032
+
1033
+ case 'directory_exists': {
1034
+ passed = existsSync(join(repoPath, params?.path))
1035
+ evidence = passed ? '✓ 目录存在' : `✗ 目录不存在: ${params?.path}`
1036
+ break
1037
+ }
1038
+
1039
+ case 'not_exists': {
1040
+ const paths = Array.isArray(params?.paths) ? params.paths : [params?.paths].filter(Boolean)
1041
+ const found = paths.filter(p => existsSync(join(repoPath, p)))
1042
+ passed = found.length === 0
1043
+ evidence = passed ? '✓ 均不存在(符合预期)' : `存在: ${found.join(', ')}`
1044
+ break
1045
+ }
1046
+
1047
+ case 'glob_count': {
1048
+ let count = 0
1049
+ function walk(dir) {
1050
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1051
+ if (entry.name.startsWith('.')) continue
1052
+ const full = join(dir, entry.name)
1053
+ if (entry.isDirectory()) { walk(full); continue }
1054
+ if (entry.name.match(new RegExp((params?.pattern || '*.md').replace('*', '.*') + '$'))) count++
1055
+ }
1056
+ }
1057
+ try { walk(repoPath) } catch {}
1058
+ passed = count >= (params?.min ?? 1)
1059
+ evidence = `匹配文件数: ${count} (需 ≥ ${params?.min ?? 1})`
1060
+ break
1061
+ }
1062
+
1063
+ case 'structure': {
1064
+ const missing = []
1065
+ if (params?.requiredDirs) {
1066
+ for (const d of params.requiredDirs) {
1067
+ if (!existsSync(join(repoPath, d))) missing.push(`dir/${d}`)
1068
+ }
1069
+ }
1070
+ if (params?.requiredFiles) {
1071
+ for (const f of params.requiredFiles) {
1072
+ if (!existsSync(join(repoPath, f))) missing.push(f)
1073
+ }
1074
+ }
1075
+ passed = missing.length === 0
1076
+ evidence = passed ? '✓ 结构完整' : `缺失: ${missing.join(', ')}`
1077
+ break
1078
+ }
1079
+
1080
+ default:
1081
+ passed = false
1082
+ evidence = `未知检查类型: ${check}`
1083
+ }
1084
+
1085
+ return { passed, evidence }
1086
+ }
1087
+
1088
+ // ============================================================
1089
+ // LLM 增强层(可选)
1090
+ // ============================================================
1091
+
1092
+ // 协议类型 = 底层 API 格式,不是具体厂商
1093
+ // 任何支持 OpenAI 兼容协议的厂商都能用 --llm-provider openai + --llm-base-url
1094
+ // 任何支持 Anthropic 兼容协议的厂商都能用 --llm-provider anthropic + --llm-base-url
1095
+ const PROVIDERS = {
1096
+ openai: {
1097
+ // OpenAI 兼容协议
1098
+ // 覆盖范围:官方 API / 国内厂商 / 聚合平台 / 自建代理 / 本地运行(Ollama/LM Studio等)
1099
+ defaultUrl: 'https://api.openai.com/v1/chat/completions',
1100
+ protocol: 'openai',
1101
+ headers: (key) => ({ 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' }),
1102
+ buildBody: (model, prompt) => ({ model, messages: [{ role: 'user', content: prompt }], max_tokens: 2000 }),
1103
+ parse: (resp) => resp?.choices?.[0]?.message?.content
1104
+ },
1105
+ anthropic: {
1106
+ // Anthropic 兼容协议
1107
+ // 覆盖范围:官方 API / 自建代理
1108
+ defaultUrl: 'https://api.anthropic.com/v1/messages',
1109
+ protocol: 'anthropic',
1110
+ headers: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }),
1111
+ buildBody: (model, prompt) => ({ model, max_tokens: 2000, messages: [{ role: 'user', content: prompt }] }),
1112
+ parse: (resp) => resp?.content?.[0]?.text
1113
+ }
1114
+ }
1115
+
1116
+ // 默认模型按协议分类
1117
+ const DEFAULT_MODELS = {
1118
+ openai: 'gpt-4o-mini',
1119
+ anthropic: 'claude-3-5-haiku-20241022'
1120
+ }
1121
+
1122
+ // 厂商/聚合平台别名 → 协议
1123
+ // 新平台接入:加一行 alias → protocol + 在文档中说明端点即可
1124
+ const PROVIDER_ALIASES = {
1125
+ // === OpenAI 兼容协议 ===
1126
+ // 聚合平台
1127
+ 'openrouter': 'openai',
1128
+ 'openrouter.ai': 'openai',
1129
+ // 官方
1130
+ 'openai': 'openai',
1131
+ 'azure': 'openai',
1132
+ 'azure-openai': 'openai',
1133
+ // 国内厂商
1134
+ 'deepseek': 'openai',
1135
+ 'qwen': 'openai',
1136
+ '通义千问': 'openai',
1137
+ 'glm': 'openai',
1138
+ '智谱': 'openai',
1139
+ 'zhipu': 'openai',
1140
+ 'siliconflow': 'openai',
1141
+ '硅基流动': 'openai',
1142
+ 'yunwu': 'openai',
1143
+ '零一万物': 'openai',
1144
+ '01ai': 'openai',
1145
+ 'doubao': 'openai',
1146
+ '豆包': 'openai',
1147
+ '字节火山': 'openai',
1148
+ // Anthropic 兼容
1149
+ 'anthropic': 'anthropic',
1150
+ 'claude': 'anthropic',
1151
+ }
1152
+
1153
+ function getLLMConfig(args) {
1154
+ let rawProvider = args.flags['--llm-provider'] || process.env.LLM_PROVIDER || 'none'
1155
+ const apiKey = args.flags['--llm-api-key'] || process.env.LLM_API_KEY || ''
1156
+ const model = args.flags['--llm-model'] || process.env.LLM_MODEL || 'auto'
1157
+ const baseUrl = process.env.LLM_BASE_URL || args.flags['--llm-base-url']
1158
+
1159
+ // 厂商别名 → 协议映射
1160
+ const protocol = PROVIDER_ALIASES[rawProvider.toLowerCase()] || rawProvider.toLowerCase()
1161
+
1162
+ return { provider: protocol, apiKey, model, baseUrl }
1163
+ }
1164
+
1165
+ function autoSelectModel(protocol) {
1166
+ return DEFAULT_MODELS[protocol] || 'gpt-4o-mini'
1167
+ }
1168
+
1169
+ function buildLLMPrompt(rule, finding, metadata) {
1170
+ return `你是一位资深软件工程师,正在审计一个 scaffold 生成的仓库是否符合工业最佳实践。
1171
+
1172
+ ## 仓库
1173
+ - 分类: ${metadata.type}
1174
+ - 远程: ${metadata.remote || '本地仓库'}
1175
+ - 分支: ${metadata.branch}
1176
+ - 提交数: ${metadata.commitCount}
1177
+ - 文件数: ${metadata.fileCount}
1178
+ - 最近更新: ${metadata.lastCommit}
1179
+
1180
+ ## 待审计项: ${rule.id} — ${rule.title}
1181
+ ${rule.description}
1182
+
1183
+ ## 当前规则检查结果
1184
+ 状态: ${finding.status}
1185
+ 证据: ${finding.evidence}
1186
+
1187
+ ## 任务
1188
+ 1. 判断严重度(critical/major/minor/info)
1189
+ 2. 分析根本原因
1190
+ 3. 给出具体修复步骤(命令级,可直接执行)
1191
+ 4. 参考模板: ${rule.template_ref || '无'}
1192
+
1193
+ 回复纯 JSON,不要有其他文字:
1194
+ {"severity": "critical|major|minor|info", "root_cause": "...", "fix_steps": ["步骤1", "步骤2"], "priority": 1-5}`
1195
+ }
1196
+
1197
+ async function enhanceWithLLM(findings, llmConfig, metadata) {
1198
+ if (llmConfig.provider === 'none' || !llmConfig.apiKey) return findings
1199
+
1200
+ const failed = findings.filter(f => f.status === 'fail')
1201
+ const MAX = 5
1202
+ const enhanced = [...findings]
1203
+
1204
+ for (const finding of failed.slice(0, MAX)) {
1205
+ const rule = ALL_RULES_CACHE.find(r => r.id === finding.id)
1206
+ if (!rule) continue
1207
+ try {
1208
+ const config = { ...llmConfig, model: llmConfig.model === 'auto' ? autoSelectModel(llmConfig.provider) : llmConfig.model }
1209
+ const provider = PROVIDERS[config.provider]
1210
+ if (!provider) continue
1211
+
1212
+ const prompt = buildLLMPrompt(rule, finding, metadata)
1213
+ const res = await fetch(config.baseUrl || provider.url, {
1214
+ method: 'POST',
1215
+ headers: provider.headers(config.apiKey),
1216
+ body: JSON.stringify(provider.buildBody(config.model, prompt))
1217
+ })
1218
+ if (!res.ok) { console.error(`⚠ LLM API ${res.status}`); continue }
1219
+ const text = provider.parse(await res.json())
1220
+ if (text) {
1221
+ finding.llm_analysis = safeParseJSON(text)
1222
+ finding.llm_raw = text
1223
+ finding.status = 'llm'
1224
+ }
1225
+ } catch {
1226
+ // LLM 失败静默跳过
1227
+ }
1228
+ }
1229
+ return enhanced
1230
+ }
1231
+
1232
+ function safeParseJSON(str) {
1233
+ try { return JSON.parse(str) } catch { return { raw: str?.slice(0, 500) } }
1234
+ }
1235
+
1236
+ let ALL_RULES_CACHE = []
1237
+
1238
+ // ============================================================
1239
+ // 报告生成
1240
+ // ============================================================
1241
+
1242
+ function calcSummary(findings, llmUsed) {
1243
+ // P5: 跳过 waived 项
1244
+ const active = findings.filter(f => f.status !== 'waived')
1245
+ const s = { critical: 0, major: 0, minor: 0, info: 0, pass: 0, total: active.length, waived: findings.filter(f => f.status === 'waived').length, llmUsed }
1246
+ for (const f of active) {
1247
+ if (f.status === 'pass') s.pass++
1248
+ else s[f.severity] = (s[f.severity] || 0) + 1
1249
+ }
1250
+ return s
1251
+ }
1252
+
1253
+ function generateMarkdown(findings, llmUsed, summary, metadata, type) {
1254
+ const lines = []
1255
+
1256
+ lines.push('# 仓库标准化审计报告')
1257
+ lines.push('')
1258
+ lines.push(`> 生成时间: ${new Date().toISOString()}`)
1259
+ lines.push(`> 审计分类: **${type}**`)
1260
+ lines.push(`> LLM 增强: ${llmUsed ? '已启用' : '未启用(纯规则模式)'}`)
1261
+ lines.push('')
1262
+
1263
+ // 仓库信息
1264
+ lines.push('## 仓库信息')
1265
+ lines.push('')
1266
+ lines.push('| 字段 | 值 |')
1267
+ lines.push('|---|---|')
1268
+ lines.push(`| 远程 | ${metadata.remote || '本地仓库'} |`)
1269
+ lines.push(`| 分支 | ${metadata.branch} |`)
1270
+ lines.push(`| 提交数 | ${metadata.commitCount} |`)
1271
+ lines.push(`| 文件数 | ${metadata.fileCount} |`)
1272
+ lines.push(`| 最近更新 | ${metadata.lastCommit} |`)
1273
+ lines.push('')
1274
+
1275
+ // 审计摘要
1276
+ lines.push('## 审计摘要')
1277
+ lines.push('')
1278
+ lines.push('| 级别 | 数量 |')
1279
+ lines.push('|---|---|')
1280
+ lines.push(`| 🔴 Critical | ${summary.critical || 0} |`)
1281
+ lines.push(`| 🟠 Major | ${summary.major || 0} |`)
1282
+ lines.push(`| 🟡 Minor | ${summary.minor || 0} |`)
1283
+ lines.push(`| 🔵 Info | ${summary.info || 0} |`)
1284
+ lines.push(`| ✅ 通过 | ${summary.pass || 0} |`)
1285
+ lines.push(`| **总计** | **${summary.total}** |`)
1286
+ lines.push('')
1287
+
1288
+ // 健康评分
1289
+ const score = Math.max(0, 100 - (summary.critical || 0) * 25 - (summary.major || 0) * 10 - (summary.minor || 0) * 3)
1290
+ const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F'
1291
+ lines.push(`**综合评分: ${score}/100(等级 ${grade})**`)
1292
+ lines.push('')
1293
+
1294
+ // 详细发现
1295
+ lines.push('## 详细发现')
1296
+ lines.push('')
1297
+
1298
+ const severityOrder = { critical: 0, major: 1, minor: 2, info: 3 }
1299
+ const sorted = [...findings].sort((a, b) => {
1300
+ if (a.status === 'pass' && b.status !== 'pass') return 1
1301
+ if (b.status === 'pass' && a.status !== 'pass') return -1
1302
+ return (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9)
1303
+ })
1304
+
1305
+ for (const f of sorted) {
1306
+ if (f.status === 'pass') continue
1307
+
1308
+ const icon = f.severity === 'critical' ? '🔴' : f.severity === 'major' ? '🟠' : f.severity === 'minor' ? '🟡' : '🔵'
1309
+ const statusIcon = f.status === 'llm' ? '🤖' : '❌'
1310
+
1311
+ lines.push(`### ${icon} ${f.id}: ${f.title} ${statusIcon}`)
1312
+ lines.push('')
1313
+ lines.push(`**领域**: ${f.domain} | **级别**: ${f.severity}`)
1314
+ lines.push('')
1315
+ lines.push(f.message)
1316
+ lines.push('')
1317
+ lines.push(`**证据**: \`${f.evidence || '无'}\``)
1318
+ lines.push('')
1319
+
1320
+ if (f.hint) {
1321
+ lines.push(`**修复建议**: ${f.hint}`)
1322
+ lines.push('')
1323
+ }
1324
+
1325
+ if (f.template_ref) {
1326
+ lines.push(`**参考模板**: \`${f.template_ref}\``)
1327
+ lines.push('')
1328
+ }
1329
+
1330
+ if (f.llm_analysis?.root_cause) {
1331
+ lines.push('#### 🤖 LLM 分析')
1332
+ lines.push('')
1333
+ lines.push(`- **严重度**: ${f.llm_analysis.severity || f.severity}`)
1334
+ lines.push(`- **根本原因**: ${f.llm_analysis.root_cause}`)
1335
+ lines.push('')
1336
+ if (f.llm_analysis.fix_steps?.length) {
1337
+ lines.push('- **修复步骤**:')
1338
+ for (const step of f.llm_analysis.fix_steps) {
1339
+ lines.push(` 1. ${step}`)
1340
+ }
1341
+ lines.push('')
1342
+ }
1343
+ }
1344
+
1345
+ lines.push('---')
1346
+ lines.push('')
1347
+ }
1348
+
1349
+ // 通过项
1350
+ const passed = findings.filter(f => f.status === 'pass')
1351
+ if (passed.length > 0) {
1352
+ lines.push('## ✅ 通过项(共 ' + passed.length + ' 项)')
1353
+ lines.push('')
1354
+ for (const f of passed.slice(0, 15)) {
1355
+ lines.push(`- ${f.id}: ${f.title}`)
1356
+ }
1357
+ if (passed.length > 15) lines.push(`... 以及 ${passed.length - 15} 项其他通过`)
1358
+ lines.push('')
1359
+ }
1360
+
1361
+ return lines.join('\n')
1362
+ }
1363
+
1364
+ function generateJSON(findings, llmUsed, summary, metadata, type) {
1365
+ return {
1366
+ generated_at: new Date().toISOString(),
1367
+ audit_type: type,
1368
+ llm_used: llmUsed,
1369
+ metadata,
1370
+ summary,
1371
+ findings: findings.map(f => ({
1372
+ id: f.id, title: f.title, severity: f.severity,
1373
+ domain: f.domain, applies_to: f.applies_to,
1374
+ status: f.status, message: f.message,
1375
+ evidence: f.evidence, hint: f.hint,
1376
+ template_ref: f.template_ref,
1377
+ waived_reason: f.waived_reason || null,
1378
+ waived_since: f.waived_since || null,
1379
+ llm_analysis: f.llm_analysis || null,
1380
+ llm_raw: f.llm_raw || null
1381
+ }))
1382
+ }
1383
+ }
1384
+
1385
+ function generateSummaryText(summary, metadata, type, outputDir) {
1386
+ const score = Math.max(0, 100 - (summary.critical || 0) * 25 - (summary.major || 0) * 10 - (summary.minor || 0) * 3)
1387
+ const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F'
1388
+ const reportPath = outputDir ? `${outputDir}/report.md + report.json` : 'audit-report/report.md + report.json'
1389
+ return [
1390
+ `分类: ${type} | 评分: ${score}/100 (${grade})`,
1391
+ `发现: 🔴${summary.critical || 0} 🟠${summary.major || 0} 🟡${summary.minor || 0} 🔵${summary.info || 0} ✅${summary.pass || 0}`,
1392
+ `LLM 增强: ${summary.llmUsed ? '已启用' : '未启用'}`,
1393
+ `报告: ${reportPath}`
1394
+ ].join(' | ')
1395
+ }
1396
+
1397
+ // ============================================================
1398
+ // 主流程
1399
+ // ============================================================
1400
+
1401
+ async function main() {
1402
+ const args = parseArgs(process.argv.slice(2))
1403
+
1404
+ if (args.flags['--help'] || args.flags['-h']) {
1405
+ console.log(usage())
1406
+ process.exit(0)
1407
+ }
1408
+
1409
+ const repoPath = resolve(args.flags['--repo'] || '.')
1410
+ const forceType = args.flags['--type']
1411
+ const format = args.flags['--format'] || 'both'
1412
+ const outputDir = args.flags['--output'] ? resolve(args.flags['--output']) : join(repoPath, DEFAULT_OUTPUT_DIR)
1413
+ const strict = !!args.flags['--strict']
1414
+ const customRules = args.flags['--rules']
1415
+ const dims = args.flags['--dim']
1416
+
1417
+ // 验证 git 仓
1418
+ if (!isGitRepo(repoPath)) {
1419
+ console.error(`✗ 目标不是 git 仓库: ${repoPath}`)
1420
+ process.exit(3)
1421
+ }
1422
+
1423
+ // 分类检测
1424
+ const detected = detectType(repoPath)
1425
+ const type = forceType || detected.type
1426
+ console.error(`📋 分类: ${type}(置信度: ${detected.confidence})${detected.reasons.length ? ' — ' + detected.reasons.join(', ') : ''}`)
1427
+
1428
+ // 加载规则
1429
+ const rulesPaths = Array.isArray(customRules) ? customRules : (customRules ? [customRules] : [])
1430
+ ALL_RULES_CACHE = loadRules(type, rulesPaths, repoPath)
1431
+ // P5: 加载仓库级豁免配置
1432
+ const auditrc = repoPath ? loadAuditrc(repoPath) : { waive: [] }
1433
+ const waivedIds = new Set(auditrc.waive.map(w => w.id))
1434
+
1435
+ // 按维度过滤
1436
+ const rules = dims
1437
+ ? ALL_RULES_CACHE.filter(r => dims.includes(r.domain))
1438
+ : ALL_RULES_CACHE
1439
+
1440
+ console.error(`📐 规则数: ${rules.length}(维度: ${[...new Set(rules.map(r => r.domain))].join(', ')})`)
1441
+
1442
+ // P6: monorepo 感知 — 查找 package.json(供 file_exists fallback 使用)
1443
+ const mpj = findPackageJson(repoPath)
1444
+
1445
+ // 执行检查
1446
+ const findings = []
1447
+ for (const rule of rules) {
1448
+ const { passed, evidence } = runCheck(rule, repoPath, mpj)
1449
+ findings.push({
1450
+ id: rule.id, title: rule.title, severity: rule.severity,
1451
+ domain: rule.domain, applies_to: rule.applies_to,
1452
+ status: passed ? 'pass' : 'fail',
1453
+ message: passed ? '符合' : rule.description,
1454
+ evidence, hint: rule.fix_hint || null,
1455
+ template_ref: rule.template_ref || null
1456
+ })
1457
+ }
1458
+
1459
+ // P5: 应用仓库级豁免
1460
+ for (const f of findings) {
1461
+ if (waivedIds.has(f.id)) {
1462
+ f.status = 'waived'
1463
+ const wa = auditrc.waive.find(w => w.id === f.id)
1464
+ f.waived_reason = wa?.reason || null
1465
+ f.waived_since = wa?.since || null
1466
+ }
1467
+ }
1468
+
1469
+ // LLM 增强
1470
+ const llmConfig = getLLMConfig(args)
1471
+ const metadata = getRepoMetadata(repoPath)
1472
+ let finalFindings = findings
1473
+ if (llmConfig.provider !== 'none' && llmConfig.apiKey) {
1474
+ console.error('🤖 启动 LLM 增强...')
1475
+ finalFindings = await enhanceWithLLM(findings, llmConfig, metadata)
1476
+ }
1477
+
1478
+ const summary = calcSummary(finalFindings, llmConfig.provider !== 'none' && llmConfig.apiKey)
1479
+
1480
+ // 生成报告
1481
+ const markdown = generateMarkdown(finalFindings, summary.llmUsed, summary, metadata, type)
1482
+ const json = generateJSON(finalFindings, summary.llmUsed, summary, metadata, type)
1483
+ const summaryText = generateSummaryText(summary, metadata, type, outputDir)
1484
+
1485
+ // 写入文件
1486
+ mkdirSync(outputDir, { recursive: true })
1487
+ writeFileSync(join(outputDir, 'report.md'), markdown, 'utf-8')
1488
+ writeFileSync(join(outputDir, 'report.json'), JSON.stringify(json, null, 2), 'utf-8')
1489
+
1490
+ // stdout
1491
+ console.error(summaryText)
1492
+ if (format === 'json' || format === 'both') {
1493
+ console.log(JSON.stringify(json, null, 2))
1494
+ }
1495
+ if (format === 'md' || format === 'both') {
1496
+ // markdown 已写入文件,stdout 只打印摘要
1497
+ }
1498
+
1499
+ // 退出码
1500
+ const hasCritical = findings.some(f => f.severity === 'critical' || f.severity === 'major')
1501
+ process.exit(hasCritical && strict ? 1 : 0)
1502
+ }
1503
+
1504
+ main().catch(err => {
1505
+ console.error(`✗ 运行时错误: ${err.message}`)
1506
+ process.exit(2)
1507
+ })