@perrylink/dsh-plugin-doctor 0.2.1 → 0.2.3
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/CHANGELOG.md +69 -63
- package/LICENSE +201 -201
- package/README.md +266 -190
- package/SURVEY.md +265 -265
- package/cordis.patch.yml +8 -0
- package/doctor.mjs +255 -255
- package/lib/checks-collections.mjs +119 -119
- package/lib/checks-cordis.mjs +230 -230
- package/lib/checks-package.mjs +188 -188
- package/lib/checks-smoke.mjs +117 -117
- package/lib/framework.mjs +110 -110
- package/lib/util.mjs +125 -125
- package/package.json +61 -49
- package/plugin.mjs +119 -0
package/doctor.mjs
CHANGED
|
@@ -1,255 +1,255 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// dsh-plugin-doctor:dsh 插件「完整性 + 运行流畅」一体检测器
|
|
3
|
-
// 分组:静态·包结构(R) / 静态·cordis 契约扫描(K) / 动态·沙箱冒烟(D) / 生态·集合站清单(CC)
|
|
4
|
-
//
|
|
5
|
-
// 不可变契约(R-fix 0B):name 前缀 / results 扁平数组 / stdout 含 "R0 " 与 "K1 " /
|
|
6
|
-
// 退出码 0(通过) 1(插件缺陷) 2(用法错误) 语义不变。新增码 3/4/5/6 只在原本会误判的场景出现。
|
|
7
|
-
import path from 'node:path'
|
|
8
|
-
import { readFileSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
9
|
-
import { tmpdir } from 'node:os'
|
|
10
|
-
import { Doctor, verdict, render, summarizeGroups, degradedGroups } from './lib/framework.mjs'
|
|
11
|
-
import { addChecks as addPackageChecks } from './lib/checks-package.mjs'
|
|
12
|
-
import { addChecks as addCordisChecks } from './lib/checks-cordis.mjs'
|
|
13
|
-
import { addSmokeChecks } from './lib/checks-smoke.mjs'
|
|
14
|
-
import { addChecks as addCollectionChecks } from './lib/checks-collections.mjs'
|
|
15
|
-
import { quarantineSandbox, redact } from './lib/util.mjs'
|
|
16
|
-
|
|
17
|
-
const VERSION = JSON.parse(readFileSync(path.join(import.meta.dirname, 'package.json'), 'utf8')).version
|
|
18
|
-
const SCHEMA_VERSION = '2'
|
|
19
|
-
const CHECKSET = 'R0-R8+K1-K9+D0-D3,D9+CC1-CC5/1'
|
|
20
|
-
const QUARANTINE_PREFIX = 'doctor-quarantine-'
|
|
21
|
-
|
|
22
|
-
const USAGE = `dsh-plugin-doctor ${VERSION} —— dsh 插件完整性 + 运行流畅一体检测器
|
|
23
|
-
|
|
24
|
-
用法: node doctor.mjs --repo <插件仓路径> [选项]
|
|
25
|
-
|
|
26
|
-
选项:
|
|
27
|
-
--repo, -r <路径> 被检插件仓(含 package.json)
|
|
28
|
-
--workspace, -w <路径> 兄弟仓所在工作区根(用于 CC 组核对本地清单;缺省=工具自身父目录)
|
|
29
|
-
--no-smoke 跳过动态沙箱冒烟(默认执行;需网络 + pnpm)
|
|
30
|
-
--dsh <版本> 冒烟宿主版本(默认 ${'0.1.2-rc.1'} = peer 下限:刻意用最老的受支持线做
|
|
31
|
-
向后兼容冒烟,不是 npm latest —— npm @deepseek-ai/dsh 实测 latest 为
|
|
32
|
-
0.1.5-rc.1、next 为 0.1.5-rc.2。38 个下游仓的 plugin-doctor.yml 吃这个
|
|
33
|
-
默认值,改默认值会静默改变那些门禁的含义)
|
|
34
|
-
--only <分组> 只跑指定分组(逗号分隔)。推荐用 ASCII 别名(编码安全):
|
|
35
|
-
R = 静态·包结构
|
|
36
|
-
K = 静态·cordis 契约扫描
|
|
37
|
-
D = 动态·沙箱冒烟
|
|
38
|
-
CC = 生态·集合站清单
|
|
39
|
-
中文全名同样可用;大小写不敏感。
|
|
40
|
-
--allow-degraded 允许「被请求的组整组未真跑」时仍返回 0(默认返回 6)
|
|
41
|
-
--json <路径|-> 另存 JSON 报告;传 "-" 写 stdout(此时抑制人类可读报告)
|
|
42
|
-
--purge <隔离目录> 删除本工具自己产生的隔离目录(仅接受 ${QUARANTINE_PREFIX}* 前缀)
|
|
43
|
-
-h, --help 显示帮助
|
|
44
|
-
-v, --version 显示版本
|
|
45
|
-
|
|
46
|
-
退出码:
|
|
47
|
-
0 = 无 fail/error(可含 warn/skip)
|
|
48
|
-
1 = 存在 fail/error(插件缺陷)
|
|
49
|
-
2 = 用法错误、未知分组或未知选项
|
|
50
|
-
3 = 基础设施错误(如缺 npm/pnpm 等环境不可用)
|
|
51
|
-
4 = 不支持的宿主版本(宿主安装失败,不判插件)
|
|
52
|
-
5 = 结果不稳定(步骤超时)
|
|
53
|
-
6 = 降级:被请求的组整组未真跑(如无源文件可扫描)—— 不得当作通过
|
|
54
|
-
|
|
55
|
-
安全: 冒烟全程使用 %TEMP% 自建沙箱(前缀 doctor-,与宿主 %TEMP%\\dsh-* 保护模板不重叠),绝不触碰真实 ~/.dsh。
|
|
56
|
-
运行结束只做「隔离不删除」(rename 到 ${QUARANTINE_PREFIX}*),需人工确认后用 --purge 清理。
|
|
57
|
-
|
|
58
|
-
防静默通过: --only 里的分组名只要有一个不匹配,本工具立即以退出码 2 失败。
|
|
59
|
-
被请求的分组若整组未真跑(全 skip),以退出码 6 失败——除非显式 --allow-degraded。`
|
|
60
|
-
|
|
61
|
-
const GROUP_ALIASES = {
|
|
62
|
-
R: '静态·包结构',
|
|
63
|
-
K: '静态·cordis 契约扫描',
|
|
64
|
-
D: '动态·沙箱冒烟',
|
|
65
|
-
CC: '生态·集合站清单',
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const FLAGS_WITH_VALUE = new Set(['--repo', '-r', '--workspace', '-w', '--dsh', '--json', '--only', '--purge'])
|
|
69
|
-
|
|
70
|
-
function parseArgs(argv) {
|
|
71
|
-
const opts = {
|
|
72
|
-
repo: null, workspace: null, smoke: true, dshVersion: '0.1.2-rc.1', json: null,
|
|
73
|
-
groups: null, help: false, version: false, allowDegraded: false, purge: null,
|
|
74
|
-
}
|
|
75
|
-
for (let i = 0; i < argv.length; i++) {
|
|
76
|
-
const a = argv[i]
|
|
77
|
-
if (a === '--repo' || a === '-r') opts.repo = argv[++i]
|
|
78
|
-
else if (a === '--workspace' || a === '-w') opts.workspace = argv[++i]
|
|
79
|
-
else if (a === '--no-smoke') opts.smoke = false
|
|
80
|
-
else if (a === '--dsh') opts.dshVersion = argv[++i]
|
|
81
|
-
else if (a === '--json') opts.json = argv[++i]
|
|
82
|
-
else if (a === '--only') opts.groups = String(argv[++i] ?? '').split(',').map((s) => s.trim())
|
|
83
|
-
else if (a === '--purge') opts.purge = argv[++i]
|
|
84
|
-
else if (a === '--allow-degraded') opts.allowDegraded = true
|
|
85
|
-
else if (a === '--help' || a === '-h') opts.help = true
|
|
86
|
-
else if (a === '--version' || a === '-v') opts.version = true
|
|
87
|
-
else if (a.startsWith('-')) {
|
|
88
|
-
// 未知选项必须报错:旧实现会把未识别长选项的值当作位置参数、静默改写 --repo(静默错靶)
|
|
89
|
-
return { ...opts, error: `未知选项: ${a}` }
|
|
90
|
-
} else if (opts.repo === null) {
|
|
91
|
-
opts.repo = a
|
|
92
|
-
} else {
|
|
93
|
-
return { ...opts, error: `多余的位置参数: ${a}(--repo 已由前一个位置参数占用)` }
|
|
94
|
-
}
|
|
95
|
-
if (FLAGS_WITH_VALUE.has(a) && argv[i] === undefined) return { ...opts, error: `选项 ${a} 缺少取值` }
|
|
96
|
-
}
|
|
97
|
-
return opts
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function main() {
|
|
101
|
-
const opts = parseArgs(process.argv.slice(2))
|
|
102
|
-
if (opts.help) { console.log(USAGE); return 0 }
|
|
103
|
-
if (opts.version) { console.log(VERSION); return 0 }
|
|
104
|
-
if (opts.error) { console.error(`${opts.error}\n\n${USAGE}`); return 2 }
|
|
105
|
-
|
|
106
|
-
// --purge:三段式的第三段(人工确认后清理)。只接受本工具自己的隔离目录。
|
|
107
|
-
if (opts.purge) {
|
|
108
|
-
const abs = path.resolve(opts.purge)
|
|
109
|
-
if (!path.basename(abs).startsWith(QUARANTINE_PREFIX)) {
|
|
110
|
-
console.error(`拒绝清理:只接受 ${QUARANTINE_PREFIX}* 前缀的隔离目录(收到 ${path.basename(abs)})`)
|
|
111
|
-
return 2
|
|
112
|
-
}
|
|
113
|
-
try { rmSync(abs, { recursive: true, force: true }) } catch (e) { console.error(`清理失败: ${e.message}`); return 3 }
|
|
114
|
-
console.log(`已清理隔离目录: ${abs}`)
|
|
115
|
-
return 0
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!opts.repo) { console.error('缺少 --repo <路径>\n\n' + USAGE); return 2 }
|
|
119
|
-
const repoPath = path.resolve(opts.repo)
|
|
120
|
-
const pkgPath = path.join(repoPath, 'package.json')
|
|
121
|
-
if (!existsSync(pkgPath)) { console.error(`未找到 ${pkgPath}`); return 2 }
|
|
122
|
-
let pkg
|
|
123
|
-
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch (e) { console.error(`package.json 解析失败: ${e.message}`); return 2 }
|
|
124
|
-
|
|
125
|
-
const runRoot = mkdtempSync(path.join(tmpdir(), 'doctor-run-'))
|
|
126
|
-
const logDir = path.join(runRoot, 'logs')
|
|
127
|
-
mkdirSync(logDir, { recursive: true })
|
|
128
|
-
const workspaceRoot = opts.workspace ? path.resolve(opts.workspace) : path.resolve(import.meta.dirname, '..')
|
|
129
|
-
const ctx = {
|
|
130
|
-
repoPath,
|
|
131
|
-
pkg,
|
|
132
|
-
pkgName: pkg.name,
|
|
133
|
-
logDir,
|
|
134
|
-
workspaceRoot,
|
|
135
|
-
sandboxRoots: [runRoot],
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const doctor = new Doctor()
|
|
139
|
-
addPackageChecks(doctor, ctx)
|
|
140
|
-
addCordisChecks(doctor, ctx)
|
|
141
|
-
addCollectionChecks(doctor, ctx)
|
|
142
|
-
if (opts.smoke) addSmokeChecks(doctor, ctx, { dshVersion: opts.dshVersion })
|
|
143
|
-
|
|
144
|
-
const knownGroups = [...new Set(doctor.checks.map((c) => c.group))]
|
|
145
|
-
if (opts.groups) {
|
|
146
|
-
const resolved = opts.groups.map((t) => GROUP_ALIASES[t.toUpperCase()] ?? t)
|
|
147
|
-
const unknown = resolved.filter((g) => !knownGroups.includes(g))
|
|
148
|
-
if (unknown.length > 0) {
|
|
149
|
-
const smokeHint = !opts.smoke && unknown.some((g) => GROUP_ALIASES.D === g)
|
|
150
|
-
? '\n注:D 组需要动态冒烟,不能与 --no-smoke 同时请求(要么去掉 --no-smoke,要么从 --only 里去掉 D)'
|
|
151
|
-
: ''
|
|
152
|
-
console.error(
|
|
153
|
-
`未知分组: ${unknown.join(', ')}\n` +
|
|
154
|
-
`可用分组: ${knownGroups.map((g) => `${g}(${Object.keys(GROUP_ALIASES).find((a) => GROUP_ALIASES[a] === g) ?? '-'})`).join(', ')}${smokeHint}`,
|
|
155
|
-
)
|
|
156
|
-
return 2
|
|
157
|
-
}
|
|
158
|
-
opts.groups = resolved
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const started = Date.now()
|
|
162
|
-
const raw = doctor.run(ctx, { groups: opts.groups })
|
|
163
|
-
return Promise.resolve(raw).then((results) => finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }))
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }) {
|
|
167
|
-
if (results.length === 0) {
|
|
168
|
-
console.error('没有任何检查被执行(--only 未匹配到任何分组)——拒绝静默通过,退出码 2')
|
|
169
|
-
return 2
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 脱敏:把本次运行注入的绝对路径替换为占位符(R-fix 0C)
|
|
173
|
-
const redactPaths = [repoPath, logDir, runRoot, ...(ctx.sandboxRoots ?? [])]
|
|
174
|
-
for (const r of results) {
|
|
175
|
-
if (typeof r.message === 'string') r.message = redact(r.message, redactPaths)
|
|
176
|
-
if (typeof r.evidence === 'string') r.evidence = redact(r.evidence, redactPaths)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const groups = summarizeGroups(results)
|
|
180
|
-
const degraded = degradedGroups(groups)
|
|
181
|
-
const v = verdict(results, { degraded })
|
|
182
|
-
const durationMs = Date.now() - started
|
|
183
|
-
|
|
184
|
-
const envelope = {
|
|
185
|
-
schemaVersion: SCHEMA_VERSION,
|
|
186
|
-
doctorVersion: VERSION,
|
|
187
|
-
checksetVersion: CHECKSET,
|
|
188
|
-
target: {
|
|
189
|
-
repo: repoPath,
|
|
190
|
-
pkgName: pkg.name,
|
|
191
|
-
pkgVersion: pkg.version ?? null,
|
|
192
|
-
integrity: null, // 取自 registry(npm view <pkg>@<ver> dist.integrity);R/K 离线档不解析
|
|
193
|
-
integritySource: 'unresolved',
|
|
194
|
-
},
|
|
195
|
-
env: {
|
|
196
|
-
node: process.version,
|
|
197
|
-
platform: process.platform,
|
|
198
|
-
arch: process.arch,
|
|
199
|
-
hostDshVersion: opts.smoke ? opts.dshVersion : null,
|
|
200
|
-
smoke: opts.smoke,
|
|
201
|
-
},
|
|
202
|
-
startedAt: new Date(started).toISOString(),
|
|
203
|
-
durationMs,
|
|
204
|
-
quarantine: null,
|
|
205
|
-
coverage: ctx.coverage ?? {},
|
|
206
|
-
groups,
|
|
207
|
-
degraded,
|
|
208
|
-
verdict: { worst: v.worst, ok: v.ok, criticalFail: v.criticalFail },
|
|
209
|
-
results,
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const qPath = quarantineSandbox({ root: runRoot })
|
|
213
|
-
envelope.quarantine = qPath
|
|
214
|
-
|
|
215
|
-
const code = exitCodeFor(results, { degraded, allowDegraded: opts.allowDegraded })
|
|
216
|
-
|
|
217
|
-
if (opts.json === '-') {
|
|
218
|
-
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n')
|
|
219
|
-
} else {
|
|
220
|
-
console.log(`# dsh-plugin-doctor 报告\n目标: ${repoPath}\n包名: ${pkg.name}`)
|
|
221
|
-
console.log(render(results))
|
|
222
|
-
if (degraded.length) {
|
|
223
|
-
console.log(`\n[降级] 以下分组整组未真跑(全 skip),本次结论不可当作通过: ${degraded.join(', ')}`)
|
|
224
|
-
if (!opts.allowDegraded) console.log(' 如需显式接受,请加 --allow-degraded(退出码将降为 0)')
|
|
225
|
-
}
|
|
226
|
-
console.log(`沙箱隔离目录: ${qPath ?? '(未创建)'}(人工确认后用 --purge 清理)`)
|
|
227
|
-
if (opts.json) {
|
|
228
|
-
writeFileSync(opts.json, JSON.stringify(envelope, null, 2) + '\n', 'utf8')
|
|
229
|
-
console.log(`JSON 报告: ${opts.json}`)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return code
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// 退出码映射:fail/error 优先(按 category 细分 3/4/5),其次「整组未真跑」→ 6。
|
|
236
|
-
function exitCodeFor(results, { degraded, allowDegraded }) {
|
|
237
|
-
const bad = results.filter((r) => r.status === 'fail' || r.status === 'error')
|
|
238
|
-
if (bad.length) {
|
|
239
|
-
const cats = [...new Set(bad.map((r) => r.category))]
|
|
240
|
-
if (cats.length === 1) {
|
|
241
|
-
if (cats[0] === 'unsupported-host') return 4
|
|
242
|
-
if (cats[0] === 'infrastructure') return 3
|
|
243
|
-
if (cats[0] === 'unstable') return 5
|
|
244
|
-
}
|
|
245
|
-
return 1
|
|
246
|
-
}
|
|
247
|
-
if (degraded.length && !allowDegraded) return 6
|
|
248
|
-
return 0
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const out = main()
|
|
252
|
-
Promise.resolve(out).then((code) => { process.exitCode = typeof code === 'number' ? code : 0 }).catch((err) => {
|
|
253
|
-
console.error(err)
|
|
254
|
-
process.exitCode = 3
|
|
255
|
-
})
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dsh-plugin-doctor:dsh 插件「完整性 + 运行流畅」一体检测器
|
|
3
|
+
// 分组:静态·包结构(R) / 静态·cordis 契约扫描(K) / 动态·沙箱冒烟(D) / 生态·集合站清单(CC)
|
|
4
|
+
//
|
|
5
|
+
// 不可变契约(R-fix 0B):name 前缀 / results 扁平数组 / stdout 含 "R0 " 与 "K1 " /
|
|
6
|
+
// 退出码 0(通过) 1(插件缺陷) 2(用法错误) 语义不变。新增码 3/4/5/6 只在原本会误判的场景出现。
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { readFileSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { Doctor, verdict, render, summarizeGroups, degradedGroups } from './lib/framework.mjs'
|
|
11
|
+
import { addChecks as addPackageChecks } from './lib/checks-package.mjs'
|
|
12
|
+
import { addChecks as addCordisChecks } from './lib/checks-cordis.mjs'
|
|
13
|
+
import { addSmokeChecks } from './lib/checks-smoke.mjs'
|
|
14
|
+
import { addChecks as addCollectionChecks } from './lib/checks-collections.mjs'
|
|
15
|
+
import { quarantineSandbox, redact } from './lib/util.mjs'
|
|
16
|
+
|
|
17
|
+
const VERSION = JSON.parse(readFileSync(path.join(import.meta.dirname, 'package.json'), 'utf8')).version
|
|
18
|
+
const SCHEMA_VERSION = '2'
|
|
19
|
+
const CHECKSET = 'R0-R8+K1-K9+D0-D3,D9+CC1-CC5/1'
|
|
20
|
+
const QUARANTINE_PREFIX = 'doctor-quarantine-'
|
|
21
|
+
|
|
22
|
+
const USAGE = `dsh-plugin-doctor ${VERSION} —— dsh 插件完整性 + 运行流畅一体检测器
|
|
23
|
+
|
|
24
|
+
用法: node doctor.mjs --repo <插件仓路径> [选项]
|
|
25
|
+
|
|
26
|
+
选项:
|
|
27
|
+
--repo, -r <路径> 被检插件仓(含 package.json)
|
|
28
|
+
--workspace, -w <路径> 兄弟仓所在工作区根(用于 CC 组核对本地清单;缺省=工具自身父目录)
|
|
29
|
+
--no-smoke 跳过动态沙箱冒烟(默认执行;需网络 + pnpm)
|
|
30
|
+
--dsh <版本> 冒烟宿主版本(默认 ${'0.1.2-rc.1'} = peer 下限:刻意用最老的受支持线做
|
|
31
|
+
向后兼容冒烟,不是 npm latest —— npm @deepseek-ai/dsh 实测 latest 为
|
|
32
|
+
0.1.5-rc.1、next 为 0.1.5-rc.2。38 个下游仓的 plugin-doctor.yml 吃这个
|
|
33
|
+
默认值,改默认值会静默改变那些门禁的含义)
|
|
34
|
+
--only <分组> 只跑指定分组(逗号分隔)。推荐用 ASCII 别名(编码安全):
|
|
35
|
+
R = 静态·包结构
|
|
36
|
+
K = 静态·cordis 契约扫描
|
|
37
|
+
D = 动态·沙箱冒烟
|
|
38
|
+
CC = 生态·集合站清单
|
|
39
|
+
中文全名同样可用;大小写不敏感。
|
|
40
|
+
--allow-degraded 允许「被请求的组整组未真跑」时仍返回 0(默认返回 6)
|
|
41
|
+
--json <路径|-> 另存 JSON 报告;传 "-" 写 stdout(此时抑制人类可读报告)
|
|
42
|
+
--purge <隔离目录> 删除本工具自己产生的隔离目录(仅接受 ${QUARANTINE_PREFIX}* 前缀)
|
|
43
|
+
-h, --help 显示帮助
|
|
44
|
+
-v, --version 显示版本
|
|
45
|
+
|
|
46
|
+
退出码:
|
|
47
|
+
0 = 无 fail/error(可含 warn/skip)
|
|
48
|
+
1 = 存在 fail/error(插件缺陷)
|
|
49
|
+
2 = 用法错误、未知分组或未知选项
|
|
50
|
+
3 = 基础设施错误(如缺 npm/pnpm 等环境不可用)
|
|
51
|
+
4 = 不支持的宿主版本(宿主安装失败,不判插件)
|
|
52
|
+
5 = 结果不稳定(步骤超时)
|
|
53
|
+
6 = 降级:被请求的组整组未真跑(如无源文件可扫描)—— 不得当作通过
|
|
54
|
+
|
|
55
|
+
安全: 冒烟全程使用 %TEMP% 自建沙箱(前缀 doctor-,与宿主 %TEMP%\\dsh-* 保护模板不重叠),绝不触碰真实 ~/.dsh。
|
|
56
|
+
运行结束只做「隔离不删除」(rename 到 ${QUARANTINE_PREFIX}*),需人工确认后用 --purge 清理。
|
|
57
|
+
|
|
58
|
+
防静默通过: --only 里的分组名只要有一个不匹配,本工具立即以退出码 2 失败。
|
|
59
|
+
被请求的分组若整组未真跑(全 skip),以退出码 6 失败——除非显式 --allow-degraded。`
|
|
60
|
+
|
|
61
|
+
const GROUP_ALIASES = {
|
|
62
|
+
R: '静态·包结构',
|
|
63
|
+
K: '静态·cordis 契约扫描',
|
|
64
|
+
D: '动态·沙箱冒烟',
|
|
65
|
+
CC: '生态·集合站清单',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const FLAGS_WITH_VALUE = new Set(['--repo', '-r', '--workspace', '-w', '--dsh', '--json', '--only', '--purge'])
|
|
69
|
+
|
|
70
|
+
function parseArgs(argv) {
|
|
71
|
+
const opts = {
|
|
72
|
+
repo: null, workspace: null, smoke: true, dshVersion: '0.1.2-rc.1', json: null,
|
|
73
|
+
groups: null, help: false, version: false, allowDegraded: false, purge: null,
|
|
74
|
+
}
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
const a = argv[i]
|
|
77
|
+
if (a === '--repo' || a === '-r') opts.repo = argv[++i]
|
|
78
|
+
else if (a === '--workspace' || a === '-w') opts.workspace = argv[++i]
|
|
79
|
+
else if (a === '--no-smoke') opts.smoke = false
|
|
80
|
+
else if (a === '--dsh') opts.dshVersion = argv[++i]
|
|
81
|
+
else if (a === '--json') opts.json = argv[++i]
|
|
82
|
+
else if (a === '--only') opts.groups = String(argv[++i] ?? '').split(',').map((s) => s.trim())
|
|
83
|
+
else if (a === '--purge') opts.purge = argv[++i]
|
|
84
|
+
else if (a === '--allow-degraded') opts.allowDegraded = true
|
|
85
|
+
else if (a === '--help' || a === '-h') opts.help = true
|
|
86
|
+
else if (a === '--version' || a === '-v') opts.version = true
|
|
87
|
+
else if (a.startsWith('-')) {
|
|
88
|
+
// 未知选项必须报错:旧实现会把未识别长选项的值当作位置参数、静默改写 --repo(静默错靶)
|
|
89
|
+
return { ...opts, error: `未知选项: ${a}` }
|
|
90
|
+
} else if (opts.repo === null) {
|
|
91
|
+
opts.repo = a
|
|
92
|
+
} else {
|
|
93
|
+
return { ...opts, error: `多余的位置参数: ${a}(--repo 已由前一个位置参数占用)` }
|
|
94
|
+
}
|
|
95
|
+
if (FLAGS_WITH_VALUE.has(a) && argv[i] === undefined) return { ...opts, error: `选项 ${a} 缺少取值` }
|
|
96
|
+
}
|
|
97
|
+
return opts
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function main() {
|
|
101
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
102
|
+
if (opts.help) { console.log(USAGE); return 0 }
|
|
103
|
+
if (opts.version) { console.log(VERSION); return 0 }
|
|
104
|
+
if (opts.error) { console.error(`${opts.error}\n\n${USAGE}`); return 2 }
|
|
105
|
+
|
|
106
|
+
// --purge:三段式的第三段(人工确认后清理)。只接受本工具自己的隔离目录。
|
|
107
|
+
if (opts.purge) {
|
|
108
|
+
const abs = path.resolve(opts.purge)
|
|
109
|
+
if (!path.basename(abs).startsWith(QUARANTINE_PREFIX)) {
|
|
110
|
+
console.error(`拒绝清理:只接受 ${QUARANTINE_PREFIX}* 前缀的隔离目录(收到 ${path.basename(abs)})`)
|
|
111
|
+
return 2
|
|
112
|
+
}
|
|
113
|
+
try { rmSync(abs, { recursive: true, force: true }) } catch (e) { console.error(`清理失败: ${e.message}`); return 3 }
|
|
114
|
+
console.log(`已清理隔离目录: ${abs}`)
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!opts.repo) { console.error('缺少 --repo <路径>\n\n' + USAGE); return 2 }
|
|
119
|
+
const repoPath = path.resolve(opts.repo)
|
|
120
|
+
const pkgPath = path.join(repoPath, 'package.json')
|
|
121
|
+
if (!existsSync(pkgPath)) { console.error(`未找到 ${pkgPath}`); return 2 }
|
|
122
|
+
let pkg
|
|
123
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch (e) { console.error(`package.json 解析失败: ${e.message}`); return 2 }
|
|
124
|
+
|
|
125
|
+
const runRoot = mkdtempSync(path.join(tmpdir(), 'doctor-run-'))
|
|
126
|
+
const logDir = path.join(runRoot, 'logs')
|
|
127
|
+
mkdirSync(logDir, { recursive: true })
|
|
128
|
+
const workspaceRoot = opts.workspace ? path.resolve(opts.workspace) : path.resolve(import.meta.dirname, '..')
|
|
129
|
+
const ctx = {
|
|
130
|
+
repoPath,
|
|
131
|
+
pkg,
|
|
132
|
+
pkgName: pkg.name,
|
|
133
|
+
logDir,
|
|
134
|
+
workspaceRoot,
|
|
135
|
+
sandboxRoots: [runRoot],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const doctor = new Doctor()
|
|
139
|
+
addPackageChecks(doctor, ctx)
|
|
140
|
+
addCordisChecks(doctor, ctx)
|
|
141
|
+
addCollectionChecks(doctor, ctx)
|
|
142
|
+
if (opts.smoke) addSmokeChecks(doctor, ctx, { dshVersion: opts.dshVersion })
|
|
143
|
+
|
|
144
|
+
const knownGroups = [...new Set(doctor.checks.map((c) => c.group))]
|
|
145
|
+
if (opts.groups) {
|
|
146
|
+
const resolved = opts.groups.map((t) => GROUP_ALIASES[t.toUpperCase()] ?? t)
|
|
147
|
+
const unknown = resolved.filter((g) => !knownGroups.includes(g))
|
|
148
|
+
if (unknown.length > 0) {
|
|
149
|
+
const smokeHint = !opts.smoke && unknown.some((g) => GROUP_ALIASES.D === g)
|
|
150
|
+
? '\n注:D 组需要动态冒烟,不能与 --no-smoke 同时请求(要么去掉 --no-smoke,要么从 --only 里去掉 D)'
|
|
151
|
+
: ''
|
|
152
|
+
console.error(
|
|
153
|
+
`未知分组: ${unknown.join(', ')}\n` +
|
|
154
|
+
`可用分组: ${knownGroups.map((g) => `${g}(${Object.keys(GROUP_ALIASES).find((a) => GROUP_ALIASES[a] === g) ?? '-'})`).join(', ')}${smokeHint}`,
|
|
155
|
+
)
|
|
156
|
+
return 2
|
|
157
|
+
}
|
|
158
|
+
opts.groups = resolved
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const started = Date.now()
|
|
162
|
+
const raw = doctor.run(ctx, { groups: opts.groups })
|
|
163
|
+
return Promise.resolve(raw).then((results) => finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }) {
|
|
167
|
+
if (results.length === 0) {
|
|
168
|
+
console.error('没有任何检查被执行(--only 未匹配到任何分组)——拒绝静默通过,退出码 2')
|
|
169
|
+
return 2
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 脱敏:把本次运行注入的绝对路径替换为占位符(R-fix 0C)
|
|
173
|
+
const redactPaths = [repoPath, logDir, runRoot, ...(ctx.sandboxRoots ?? [])]
|
|
174
|
+
for (const r of results) {
|
|
175
|
+
if (typeof r.message === 'string') r.message = redact(r.message, redactPaths)
|
|
176
|
+
if (typeof r.evidence === 'string') r.evidence = redact(r.evidence, redactPaths)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const groups = summarizeGroups(results)
|
|
180
|
+
const degraded = degradedGroups(groups)
|
|
181
|
+
const v = verdict(results, { degraded })
|
|
182
|
+
const durationMs = Date.now() - started
|
|
183
|
+
|
|
184
|
+
const envelope = {
|
|
185
|
+
schemaVersion: SCHEMA_VERSION,
|
|
186
|
+
doctorVersion: VERSION,
|
|
187
|
+
checksetVersion: CHECKSET,
|
|
188
|
+
target: {
|
|
189
|
+
repo: repoPath,
|
|
190
|
+
pkgName: pkg.name,
|
|
191
|
+
pkgVersion: pkg.version ?? null,
|
|
192
|
+
integrity: null, // 取自 registry(npm view <pkg>@<ver> dist.integrity);R/K 离线档不解析
|
|
193
|
+
integritySource: 'unresolved',
|
|
194
|
+
},
|
|
195
|
+
env: {
|
|
196
|
+
node: process.version,
|
|
197
|
+
platform: process.platform,
|
|
198
|
+
arch: process.arch,
|
|
199
|
+
hostDshVersion: opts.smoke ? opts.dshVersion : null,
|
|
200
|
+
smoke: opts.smoke,
|
|
201
|
+
},
|
|
202
|
+
startedAt: new Date(started).toISOString(),
|
|
203
|
+
durationMs,
|
|
204
|
+
quarantine: null,
|
|
205
|
+
coverage: ctx.coverage ?? {},
|
|
206
|
+
groups,
|
|
207
|
+
degraded,
|
|
208
|
+
verdict: { worst: v.worst, ok: v.ok, criticalFail: v.criticalFail },
|
|
209
|
+
results,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const qPath = quarantineSandbox({ root: runRoot })
|
|
213
|
+
envelope.quarantine = qPath
|
|
214
|
+
|
|
215
|
+
const code = exitCodeFor(results, { degraded, allowDegraded: opts.allowDegraded })
|
|
216
|
+
|
|
217
|
+
if (opts.json === '-') {
|
|
218
|
+
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n')
|
|
219
|
+
} else {
|
|
220
|
+
console.log(`# dsh-plugin-doctor 报告\n目标: ${repoPath}\n包名: ${pkg.name}`)
|
|
221
|
+
console.log(render(results))
|
|
222
|
+
if (degraded.length) {
|
|
223
|
+
console.log(`\n[降级] 以下分组整组未真跑(全 skip),本次结论不可当作通过: ${degraded.join(', ')}`)
|
|
224
|
+
if (!opts.allowDegraded) console.log(' 如需显式接受,请加 --allow-degraded(退出码将降为 0)')
|
|
225
|
+
}
|
|
226
|
+
console.log(`沙箱隔离目录: ${qPath ?? '(未创建)'}(人工确认后用 --purge 清理)`)
|
|
227
|
+
if (opts.json) {
|
|
228
|
+
writeFileSync(opts.json, JSON.stringify(envelope, null, 2) + '\n', 'utf8')
|
|
229
|
+
console.log(`JSON 报告: ${opts.json}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return code
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 退出码映射:fail/error 优先(按 category 细分 3/4/5),其次「整组未真跑」→ 6。
|
|
236
|
+
function exitCodeFor(results, { degraded, allowDegraded }) {
|
|
237
|
+
const bad = results.filter((r) => r.status === 'fail' || r.status === 'error')
|
|
238
|
+
if (bad.length) {
|
|
239
|
+
const cats = [...new Set(bad.map((r) => r.category))]
|
|
240
|
+
if (cats.length === 1) {
|
|
241
|
+
if (cats[0] === 'unsupported-host') return 4
|
|
242
|
+
if (cats[0] === 'infrastructure') return 3
|
|
243
|
+
if (cats[0] === 'unstable') return 5
|
|
244
|
+
}
|
|
245
|
+
return 1
|
|
246
|
+
}
|
|
247
|
+
if (degraded.length && !allowDegraded) return 6
|
|
248
|
+
return 0
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const out = main()
|
|
252
|
+
Promise.resolve(out).then((code) => { process.exitCode = typeof code === 'number' ? code : 0 }).catch((err) => {
|
|
253
|
+
console.error(err)
|
|
254
|
+
process.exitCode = 3
|
|
255
|
+
})
|