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.
- package/AGENTS.md +67 -0
- package/AUDIT.md +802 -0
- package/BEST-PRACTICES.md +268 -0
- package/CLAUDE.md +1 -0
- package/CONTRIBUTING.md +42 -0
- package/DEVELOPMENT.md +112 -0
- package/HANDOFF.md +124 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +66 -0
- package/README.en.md +71 -0
- package/README.md +71 -0
- package/REPO-AUDIT.md +107 -0
- package/REPO-CLASSIFICATION.md +137 -0
- package/SECURITY.md +19 -0
- package/docs/repo-audit/AGENT-GUIDE.md +474 -0
- package/docs/repo-audit/HUMAN-GUIDE.md +749 -0
- package/package.json +36 -0
- package/repo-audit.mjs +1507 -0
- package/rules/categories/archive.yaml +48 -0
- package/rules/categories/content.yaml +34 -0
- package/rules/categories/dsh-plugin.yaml +79 -0
- package/rules/categories/go-service.yaml +67 -0
- package/rules/categories/product-oss.yaml +75 -0
- package/rules/categories/python-app.yaml +60 -0
- package/rules/categories/sandbox.yaml +33 -0
- package/rules/domains/docs.yaml +105 -0
- package/rules/domains/git.yaml +52 -0
- package/rules/domains/quality.yaml +69 -0
- package/rules/domains/security.yaml +79 -0
- package/shim/repo-audit.cmd +7 -0
- package/shim/repo-audit.ps1 +4 -0
- package/shim/repo-audit.sh +5 -0
- package/templates/AGENT-GUIDE.md +331 -0
- package/templates/README.md +192 -0
- package/templates/UPDATE-MODEL.md +141 -0
- package/templates/UPDATE-PLAN.md +79 -0
- package/templates/categories/content/AGENTS-append.md +6 -0
- package/templates/categories/content/CATEGORY.md +26 -0
- package/templates/categories/content/README.en.md +12 -0
- package/templates/categories/content/README.md +14 -0
- package/templates/categories/content/gitignore-append.md +6 -0
- package/templates/categories/dsh-plugin/AGENTS-append.md +21 -0
- package/templates/categories/dsh-plugin/CATEGORY.md +9 -0
- package/templates/categories/dsh-plugin/CONTRIBUTING-append.md +22 -0
- package/templates/categories/dsh-plugin/DEVELOPMENT-append.md +71 -0
- package/templates/categories/dsh-plugin/PUBLISHING-append.md +53 -0
- package/templates/categories/dsh-plugin/README-append.md +28 -0
- package/templates/categories/dsh-plugin/README.en.md +44 -0
- package/templates/categories/dsh-plugin/gitignore-append.md +15 -0
- package/templates/categories/go-service/.githooks/pre-commit +13 -0
- package/templates/categories/go-service/.github/workflows/ci.yml +31 -0
- package/templates/categories/go-service/.github/workflows/release.yml +58 -0
- package/templates/categories/go-service/.goreleaser.yaml +86 -0
- package/templates/categories/go-service/AGENTS-append.md +7 -0
- package/templates/categories/go-service/CATEGORY.md +33 -0
- package/templates/categories/go-service/CONTRIBUTING-append.md +13 -0
- package/templates/categories/go-service/DEVELOPMENT-append.md +21 -0
- package/templates/categories/go-service/Makefile +21 -0
- package/templates/categories/go-service/PUBLISHING-append.md +48 -0
- package/templates/categories/go-service/PUBLISHING.md +59 -0
- package/templates/categories/go-service/README-append.md +26 -0
- package/templates/categories/go-service/README.en.md +43 -0
- package/templates/categories/go-service/gitignore-append.md +13 -0
- package/templates/categories/product-oss/.github/CODEOWNERS +6 -0
- package/templates/categories/product-oss/.github/ISSUE_TEMPLATE/bug_report.yml +52 -0
- package/templates/categories/product-oss/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/templates/categories/product-oss/.github/dependabot.yml +28 -0
- package/templates/categories/product-oss/.github/workflows/dependency-review.yml +18 -0
- package/templates/categories/product-oss/.github/workflows/release-please.yml +39 -0
- package/templates/categories/product-oss/.release-please-manifest.json +3 -0
- package/templates/categories/product-oss/CATEGORY.md +27 -0
- package/templates/categories/product-oss/CHANGELOG.md +14 -0
- package/templates/categories/product-oss/PULL_REQUEST_TEMPLATE.md +15 -0
- package/templates/categories/product-oss/SUPPORT.md +71 -0
- package/templates/categories/product-oss/release-please-config.json +10 -0
- package/templates/categories/python-app/.githooks/pre-commit +13 -0
- package/templates/categories/python-app/.github/workflows/ci.yml +33 -0
- package/templates/categories/python-app/.github/workflows/publish.yml +112 -0
- package/templates/categories/python-app/AGENTS-append.md +18 -0
- package/templates/categories/python-app/CATEGORY.md +34 -0
- package/templates/categories/python-app/CONTRIBUTING-append.md +13 -0
- package/templates/categories/python-app/DEVELOPMENT-append.md +21 -0
- package/templates/categories/python-app/PUBLISHING-append.md +52 -0
- package/templates/categories/python-app/PUBLISHING.md +61 -0
- package/templates/categories/python-app/README-append.md +27 -0
- package/templates/categories/python-app/README.en.md +44 -0
- package/templates/categories/python-app/gitignore-append.md +19 -0
- package/templates/categories/python-app/pyproject.toml +41 -0
- package/templates/categories/python-app/scripts/verify.py +39 -0
- package/templates/categories/sandbox/AGENTS-append.md +7 -0
- package/templates/categories/sandbox/CATEGORY.md +31 -0
- package/templates/categories/sandbox/README.en.md +24 -0
- package/templates/categories/sandbox/README.md +23 -0
- package/templates/check-single-source.mjs +107 -0
- package/templates/common/AGENTS-core.md +28 -0
- package/templates/common/CONTRIBUTING-core.md +21 -0
- package/templates/common/DEVELOPMENT-core.md +45 -0
- package/templates/common/PUBLISHING-core.md +31 -0
- package/templates/common/README-core.md +60 -0
- package/templates/common/SECURITY-core.md +19 -0
- package/templates/common/copilot-instructions.md +5 -0
- package/templates/common/gitignore-core.md +11 -0
- package/templates/repo-root/.githooks/pre-commit +20 -0
- package/templates/repo-root/.github/workflows/ci.yml +45 -0
- package/templates/repo-root/.github/workflows/publish.yml +77 -0
- package/templates/repo-root/CLAUDE.md +3 -0
- package/templates/repo-root/LICENSE +21 -0
- package/templates/repo-root/package.json +71 -0
- package/templates/repo-root/scripts/build.mjs +24 -0
- package/templates/repo-root/scripts/check-deploy.mjs +176 -0
- package/templates/repo-root/scripts/verify.mjs +52 -0
- package/templates/repo-root/src/config.ts +25 -0
- package/templates/repo-root/src/index.ts +40 -0
- package/templates/repo-root/tsconfig.build.json +22 -0
- package/templates/repo-root/tsconfig.json +23 -0
- package/templates/scaffold.mjs +793 -0
- 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
|
+
})
|