@perrylink/dsh-plugin-doctor 0.2.2 → 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 -69
- package/LICENSE +201 -201
- package/README.md +266 -243
- 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 -50
- package/plugin.mjs +119 -0
package/lib/framework.mjs
CHANGED
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
// 检测框架:零依赖,检查注册 / 运行 / 判定 / 渲染
|
|
2
|
-
//
|
|
3
|
-
// 不可变契约(R-fix 0B 冻结;家族 37 仓的 CI 依赖它们,任何改动都会造成硬红):
|
|
4
|
-
// 1. 结果项 res.name 保留 `^R[0-8] / ^K[1-9] / ^D\d / ^CC\d ` 前缀 —— ID 只新增字段,绝不从 name 里拆走
|
|
5
|
-
// 2. 结果项仍是扁平数组(JSON 的 .results),按注册顺序稳定输出
|
|
6
|
-
// 3. 渲染行的形态不变(`[STATUS] <name>`),stdout 必须仍含 "R0 " 与 "K1 "
|
|
7
|
-
// 4. skip 不算 fail(verdict.ok 只看 fail/error)
|
|
8
|
-
export const GROUP_IDS = {
|
|
9
|
-
'静态·包结构': 'R',
|
|
10
|
-
'静态·cordis 契约扫描': 'K',
|
|
11
|
-
'动态·沙箱冒烟': 'D',
|
|
12
|
-
'生态·集合站清单': 'CC',
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class Doctor {
|
|
16
|
-
constructor() {
|
|
17
|
-
this.checks = []
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
add(group, name, fn, opts = {}) {
|
|
21
|
-
this.checks.push({ group, name, fn, opts })
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async run(ctx, { groups } = {}) {
|
|
25
|
-
const results = []
|
|
26
|
-
for (const c of this.checks) {
|
|
27
|
-
if (groups && !groups.includes(c.group)) continue
|
|
28
|
-
let res
|
|
29
|
-
try {
|
|
30
|
-
res = await c.fn(ctx)
|
|
31
|
-
if (typeof res === 'string') res = { status: 'pass', message: res }
|
|
32
|
-
if (!res || typeof res !== 'object') res = { status: 'pass', message: String(res) }
|
|
33
|
-
} catch (err) {
|
|
34
|
-
res = { status: 'error', message: err && err.stack ? err.stack : String(err), category: 'doctor-internal' }
|
|
35
|
-
}
|
|
36
|
-
res.group = c.group
|
|
37
|
-
res.name = c.name
|
|
38
|
-
res.critical = !!c.opts.critical
|
|
39
|
-
// 稳定 ID 从 name 前缀派生(只读,不改写 name)
|
|
40
|
-
const m = /^([A-Z]{1,3})(\d+)\s/.exec(c.name)
|
|
41
|
-
res.id = m ? `${m[1]}${m[2]}` : null
|
|
42
|
-
res.groupId = GROUP_IDS[c.group] ?? null
|
|
43
|
-
if (!res.category) res.category = res.status === 'skip' ? 'not-applicable' : 'plugin-defect'
|
|
44
|
-
results.push(res)
|
|
45
|
-
}
|
|
46
|
-
return results
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** 按组统计:total / ran(非 skip)/ skipped / 各状态计数 / 累计 filesInspected */
|
|
51
|
-
export function summarizeGroups(results) {
|
|
52
|
-
const out = {}
|
|
53
|
-
for (const r of results) {
|
|
54
|
-
const g = r.groupId ?? '?'
|
|
55
|
-
const s = (out[g] ??= { total: 0, ran: 0, skipped: 0, pass: 0, warn: 0, fail: 0, error: 0, skip: 0, filesInspected: 0 })
|
|
56
|
-
s.total += 1
|
|
57
|
-
s[r.status] = (s[r.status] ?? 0) + 1
|
|
58
|
-
if (r.status === 'skip') s.skipped += 1
|
|
59
|
-
else s.ran += 1
|
|
60
|
-
if (typeof r.filesInspected === 'number') s.filesInspected += r.filesInspected
|
|
61
|
-
}
|
|
62
|
-
return out
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 被请求、但一条都没真跑的组(全部 skip)→ 该次运行「降级」,不得裸报成功 */
|
|
66
|
-
export function degradedGroups(groups) {
|
|
67
|
-
return Object.entries(groups).filter(([, v]) => v.total > 0 && v.ran === 0).map(([k]) => k)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 最坏状态排序:error/fail > warn > skip > pass
|
|
71
|
-
export function verdict(results, { degraded = [] } = {}) {
|
|
72
|
-
let worst = 'pass'
|
|
73
|
-
for (const r of results) {
|
|
74
|
-
if (r.status === 'error' && worst !== 'error') worst = 'error'
|
|
75
|
-
else if (r.status === 'fail' && worst !== 'error') worst = 'fail'
|
|
76
|
-
else if (r.status === 'warn' && worst === 'pass') worst = 'warn'
|
|
77
|
-
else if (r.status === 'skip' && worst === 'pass') worst = 'skip'
|
|
78
|
-
}
|
|
79
|
-
const criticalFail = results.some((r) => r.critical && (r.status === 'fail' || r.status === 'error'))
|
|
80
|
-
// 退出码契约:0 = 无 fail/error(可含 warn/skip);1 = 存在 fail/error
|
|
81
|
-
return { worst, criticalFail, ok: worst !== 'fail' && worst !== 'error', degraded }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const ICONS = { pass: '[PASS]', warn: '[WARN]', fail: '[FAIL]', error: '[ERROR]', skip: '[SKIP]' }
|
|
85
|
-
|
|
86
|
-
export function render(results) {
|
|
87
|
-
const lines = []
|
|
88
|
-
let lastGroup = null
|
|
89
|
-
for (const r of results) {
|
|
90
|
-
if (r.group !== lastGroup) {
|
|
91
|
-
lines.push('', `== ${r.group} ==`)
|
|
92
|
-
lastGroup = r.group
|
|
93
|
-
}
|
|
94
|
-
const tag = r.critical ? ' [关键]' : ''
|
|
95
|
-
lines.push(`${ICONS[r.status] ?? '[?]'} ${r.name}${tag}`)
|
|
96
|
-
const msg = String(r.message ?? '').trim()
|
|
97
|
-
if (msg) {
|
|
98
|
-
for (const line of msg.split('\n')) lines.push(` ${line}`)
|
|
99
|
-
}
|
|
100
|
-
if (r.evidence) lines.push(` 证据: ${r.evidence}`)
|
|
101
|
-
}
|
|
102
|
-
const v = verdict(results)
|
|
103
|
-
const counts = {}
|
|
104
|
-
for (const r of results) counts[r.status] = (counts[r.status] ?? 0) + 1
|
|
105
|
-
lines.push(
|
|
106
|
-
'',
|
|
107
|
-
`=== 汇总: ${Object.entries(counts).map(([k, n]) => `${k}=${n}`).join(' ')} | 总判定: ${v.worst.toUpperCase()}${v.criticalFail ? '(含关键项失败)' : ''} ===`,
|
|
108
|
-
)
|
|
109
|
-
return lines.join('\n')
|
|
110
|
-
}
|
|
1
|
+
// 检测框架:零依赖,检查注册 / 运行 / 判定 / 渲染
|
|
2
|
+
//
|
|
3
|
+
// 不可变契约(R-fix 0B 冻结;家族 37 仓的 CI 依赖它们,任何改动都会造成硬红):
|
|
4
|
+
// 1. 结果项 res.name 保留 `^R[0-8] / ^K[1-9] / ^D\d / ^CC\d ` 前缀 —— ID 只新增字段,绝不从 name 里拆走
|
|
5
|
+
// 2. 结果项仍是扁平数组(JSON 的 .results),按注册顺序稳定输出
|
|
6
|
+
// 3. 渲染行的形态不变(`[STATUS] <name>`),stdout 必须仍含 "R0 " 与 "K1 "
|
|
7
|
+
// 4. skip 不算 fail(verdict.ok 只看 fail/error)
|
|
8
|
+
export const GROUP_IDS = {
|
|
9
|
+
'静态·包结构': 'R',
|
|
10
|
+
'静态·cordis 契约扫描': 'K',
|
|
11
|
+
'动态·沙箱冒烟': 'D',
|
|
12
|
+
'生态·集合站清单': 'CC',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Doctor {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.checks = []
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
add(group, name, fn, opts = {}) {
|
|
21
|
+
this.checks.push({ group, name, fn, opts })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run(ctx, { groups } = {}) {
|
|
25
|
+
const results = []
|
|
26
|
+
for (const c of this.checks) {
|
|
27
|
+
if (groups && !groups.includes(c.group)) continue
|
|
28
|
+
let res
|
|
29
|
+
try {
|
|
30
|
+
res = await c.fn(ctx)
|
|
31
|
+
if (typeof res === 'string') res = { status: 'pass', message: res }
|
|
32
|
+
if (!res || typeof res !== 'object') res = { status: 'pass', message: String(res) }
|
|
33
|
+
} catch (err) {
|
|
34
|
+
res = { status: 'error', message: err && err.stack ? err.stack : String(err), category: 'doctor-internal' }
|
|
35
|
+
}
|
|
36
|
+
res.group = c.group
|
|
37
|
+
res.name = c.name
|
|
38
|
+
res.critical = !!c.opts.critical
|
|
39
|
+
// 稳定 ID 从 name 前缀派生(只读,不改写 name)
|
|
40
|
+
const m = /^([A-Z]{1,3})(\d+)\s/.exec(c.name)
|
|
41
|
+
res.id = m ? `${m[1]}${m[2]}` : null
|
|
42
|
+
res.groupId = GROUP_IDS[c.group] ?? null
|
|
43
|
+
if (!res.category) res.category = res.status === 'skip' ? 'not-applicable' : 'plugin-defect'
|
|
44
|
+
results.push(res)
|
|
45
|
+
}
|
|
46
|
+
return results
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 按组统计:total / ran(非 skip)/ skipped / 各状态计数 / 累计 filesInspected */
|
|
51
|
+
export function summarizeGroups(results) {
|
|
52
|
+
const out = {}
|
|
53
|
+
for (const r of results) {
|
|
54
|
+
const g = r.groupId ?? '?'
|
|
55
|
+
const s = (out[g] ??= { total: 0, ran: 0, skipped: 0, pass: 0, warn: 0, fail: 0, error: 0, skip: 0, filesInspected: 0 })
|
|
56
|
+
s.total += 1
|
|
57
|
+
s[r.status] = (s[r.status] ?? 0) + 1
|
|
58
|
+
if (r.status === 'skip') s.skipped += 1
|
|
59
|
+
else s.ran += 1
|
|
60
|
+
if (typeof r.filesInspected === 'number') s.filesInspected += r.filesInspected
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 被请求、但一条都没真跑的组(全部 skip)→ 该次运行「降级」,不得裸报成功 */
|
|
66
|
+
export function degradedGroups(groups) {
|
|
67
|
+
return Object.entries(groups).filter(([, v]) => v.total > 0 && v.ran === 0).map(([k]) => k)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 最坏状态排序:error/fail > warn > skip > pass
|
|
71
|
+
export function verdict(results, { degraded = [] } = {}) {
|
|
72
|
+
let worst = 'pass'
|
|
73
|
+
for (const r of results) {
|
|
74
|
+
if (r.status === 'error' && worst !== 'error') worst = 'error'
|
|
75
|
+
else if (r.status === 'fail' && worst !== 'error') worst = 'fail'
|
|
76
|
+
else if (r.status === 'warn' && worst === 'pass') worst = 'warn'
|
|
77
|
+
else if (r.status === 'skip' && worst === 'pass') worst = 'skip'
|
|
78
|
+
}
|
|
79
|
+
const criticalFail = results.some((r) => r.critical && (r.status === 'fail' || r.status === 'error'))
|
|
80
|
+
// 退出码契约:0 = 无 fail/error(可含 warn/skip);1 = 存在 fail/error
|
|
81
|
+
return { worst, criticalFail, ok: worst !== 'fail' && worst !== 'error', degraded }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ICONS = { pass: '[PASS]', warn: '[WARN]', fail: '[FAIL]', error: '[ERROR]', skip: '[SKIP]' }
|
|
85
|
+
|
|
86
|
+
export function render(results) {
|
|
87
|
+
const lines = []
|
|
88
|
+
let lastGroup = null
|
|
89
|
+
for (const r of results) {
|
|
90
|
+
if (r.group !== lastGroup) {
|
|
91
|
+
lines.push('', `== ${r.group} ==`)
|
|
92
|
+
lastGroup = r.group
|
|
93
|
+
}
|
|
94
|
+
const tag = r.critical ? ' [关键]' : ''
|
|
95
|
+
lines.push(`${ICONS[r.status] ?? '[?]'} ${r.name}${tag}`)
|
|
96
|
+
const msg = String(r.message ?? '').trim()
|
|
97
|
+
if (msg) {
|
|
98
|
+
for (const line of msg.split('\n')) lines.push(` ${line}`)
|
|
99
|
+
}
|
|
100
|
+
if (r.evidence) lines.push(` 证据: ${r.evidence}`)
|
|
101
|
+
}
|
|
102
|
+
const v = verdict(results)
|
|
103
|
+
const counts = {}
|
|
104
|
+
for (const r of results) counts[r.status] = (counts[r.status] ?? 0) + 1
|
|
105
|
+
lines.push(
|
|
106
|
+
'',
|
|
107
|
+
`=== 汇总: ${Object.entries(counts).map(([k, n]) => `${k}=${n}`).join(' ')} | 总判定: ${v.worst.toUpperCase()}${v.criticalFail ? '(含关键项失败)' : ''} ===`,
|
|
108
|
+
)
|
|
109
|
+
return lines.join('\n')
|
|
110
|
+
}
|
package/lib/util.mjs
CHANGED
|
@@ -1,125 +1,125 @@
|
|
|
1
|
-
// 工具:临时沙箱 + 子进程执行(输出落盘,规避管道捕获限制)
|
|
2
|
-
import { spawnSync } from 'node:child_process'
|
|
3
|
-
import {
|
|
4
|
-
mkdtempSync, mkdirSync, renameSync, openSync, closeSync, readFileSync, writeFileSync, existsSync, readdirSync,
|
|
5
|
-
} from 'node:fs'
|
|
6
|
-
import { tmpdir } from 'node:os'
|
|
7
|
-
import path from 'node:path'
|
|
8
|
-
|
|
9
|
-
// 沙箱目录一律建在 %TEMP%,绝不触碰真实 ~/.dsh(红线 3)。
|
|
10
|
-
// 前缀用 `doctor-` 而非 `dsh-`:宿主运行目录的保护模板是 %TEMP%\dsh-*(红线 1),
|
|
11
|
-
// 用 `dsh-doctor-*` 会与该模板相撞;改名后本工具的自建临时目录不再落在保护模板内。
|
|
12
|
-
const SANDBOX_PREFIX = 'doctor-sbx-'
|
|
13
|
-
const QUARANTINE_PREFIX = 'doctor-quarantine-'
|
|
14
|
-
|
|
15
|
-
export function makeSandbox(label) {
|
|
16
|
-
const root = mkdtempSync(path.join(tmpdir(), `${SANDBOX_PREFIX}${label}-`))
|
|
17
|
-
const home = path.join(root, 'home')
|
|
18
|
-
const logs = path.join(root, 'logs')
|
|
19
|
-
mkdirSync(home, { recursive: true })
|
|
20
|
-
mkdirSync(logs, { recursive: true })
|
|
21
|
-
return { root, home, logs }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// 清理改为「隔离不删除」(红线 4 三段式的前两段:dry-run 打印 → rename 到隔离目录)。
|
|
25
|
-
// 返回隔离后的绝对路径;调用方必须打印它,人工确认后再自行清理。
|
|
26
|
-
// 本工具不再有任何 rmSync/rm -rf 路径 —— 旧的 cleanSandbox 已移除。
|
|
27
|
-
export function quarantineSandbox(sb) {
|
|
28
|
-
if (!sb || !sb.root || !existsSync(sb.root)) return null
|
|
29
|
-
const dest = path.join(tmpdir(), `${QUARANTINE_PREFIX}${Date.now()}-${path.basename(sb.root)}`)
|
|
30
|
-
try {
|
|
31
|
-
renameSync(sb.root, dest)
|
|
32
|
-
return dest
|
|
33
|
-
} catch {
|
|
34
|
-
return null
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 把运行期绝对路径替换为占位符,使 message 可对外复用(R-fix 0C / L1)。
|
|
39
|
-
// 只处理"本次运行自己注入的路径",不做通用脱敏。
|
|
40
|
-
export function redact(text, paths = []) {
|
|
41
|
-
let s = String(text ?? '')
|
|
42
|
-
const variants = []
|
|
43
|
-
for (const p of paths) {
|
|
44
|
-
if (!p || typeof p !== 'string') continue
|
|
45
|
-
variants.push(p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\'))
|
|
46
|
-
}
|
|
47
|
-
const tmp = tmpdir()
|
|
48
|
-
variants.push(tmp, tmp.replace(/\\/g, '/'), tmp.replace(/\//g, '\\'))
|
|
49
|
-
for (const v of [...new Set(variants)].sort((a, b) => b.length - a.length)) {
|
|
50
|
-
if (v.length < 4) continue
|
|
51
|
-
s = s.split(v).join('<path>')
|
|
52
|
-
}
|
|
53
|
-
return s
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// 执行命令:stdout/stderr 分别写入日志文件(不建管道),返回退出码与全文
|
|
57
|
-
export function runStep(label, cmd, args, { env = {}, timeout = 120_000, cwd, logDir, shell } = {}) {
|
|
58
|
-
const dir = logDir ?? process.cwd()
|
|
59
|
-
const outPath = path.join(dir, `${label}.out.log`)
|
|
60
|
-
const errPath = path.join(dir, `${label}.err.log`)
|
|
61
|
-
const fdOut = openSync(outPath, 'w')
|
|
62
|
-
const fdErr = openSync(errPath, 'w')
|
|
63
|
-
const res = spawnSync(cmd, args, {
|
|
64
|
-
cwd,
|
|
65
|
-
env: { ...process.env, ...env },
|
|
66
|
-
stdio: ['ignore', fdOut, fdErr],
|
|
67
|
-
timeout,
|
|
68
|
-
windowsHide: true,
|
|
69
|
-
// Windows 下解析 npm.cmd/pnpm.cmd 等 shim 需要 shell;直接调用 node.exe 等真实可执行文件时禁用
|
|
70
|
-
shell: shell ?? process.platform === 'win32',
|
|
71
|
-
})
|
|
72
|
-
closeSync(fdOut)
|
|
73
|
-
closeSync(fdErr)
|
|
74
|
-
const read = (p) => { try { return readFileSync(p, 'utf8') } catch { return '' } }
|
|
75
|
-
return {
|
|
76
|
-
code: res.status,
|
|
77
|
-
signal: res.signal,
|
|
78
|
-
spawnError: res.error ? String(res.error) : null,
|
|
79
|
-
out: read(outPath),
|
|
80
|
-
err: read(errPath),
|
|
81
|
-
outPath,
|
|
82
|
-
errPath,
|
|
83
|
-
ok: res.status === 0 && !res.error,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function tail(text, n = 8) {
|
|
88
|
-
if (!text) return '(无输出)'
|
|
89
|
-
const lines = text.trim().split('\n')
|
|
90
|
-
return lines.slice(-n).join('\n')
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const pass = (message) => ({ status: 'pass', message })
|
|
94
|
-
export const fail = (message) => ({ status: 'fail', message })
|
|
95
|
-
export const warn = (message) => ({ status: 'warn', message })
|
|
96
|
-
export const skip = (reason) => ({ status: 'skip', message: reason })
|
|
97
|
-
/** 显式不可判:与 skip 同判定,但带 category 以便上游区分「环境不适用」与「确实没跑」 */
|
|
98
|
-
export const na = (reason, category = 'not-applicable') => ({ status: 'skip', message: reason, category })
|
|
99
|
-
/** 环境类降级:不计 pass、不计插件缺陷,走 category=environment */
|
|
100
|
-
export const envskip = (reason) => ({ status: 'skip', message: reason, category: 'environment' })
|
|
101
|
-
|
|
102
|
-
export function readJson(p) {
|
|
103
|
-
return JSON.parse(readFileSync(p, 'utf8'))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function exists(p) {
|
|
107
|
-
return existsSync(p)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function writeJson(p, obj) {
|
|
111
|
-
writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 递归收集目录下匹配文件(跳过 node_modules/lib/dist 等构建产物)
|
|
115
|
-
export function findFiles(dir, sub, re, out = []) {
|
|
116
|
-
const base = path.join(dir, sub)
|
|
117
|
-
if (!existsSync(base)) return out
|
|
118
|
-
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
119
|
-
if (['node_modules', 'lib', 'dist', '.git'].includes(entry.name)) continue
|
|
120
|
-
const p = path.join(base, entry.name)
|
|
121
|
-
if (entry.isDirectory()) findFiles(dir, path.join(sub, entry.name), re, out)
|
|
122
|
-
else if (re.test(entry.name)) out.push(p)
|
|
123
|
-
}
|
|
124
|
-
return out
|
|
125
|
-
}
|
|
1
|
+
// 工具:临时沙箱 + 子进程执行(输出落盘,规避管道捕获限制)
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync, mkdirSync, renameSync, openSync, closeSync, readFileSync, writeFileSync, existsSync, readdirSync,
|
|
5
|
+
} from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
// 沙箱目录一律建在 %TEMP%,绝不触碰真实 ~/.dsh(红线 3)。
|
|
10
|
+
// 前缀用 `doctor-` 而非 `dsh-`:宿主运行目录的保护模板是 %TEMP%\dsh-*(红线 1),
|
|
11
|
+
// 用 `dsh-doctor-*` 会与该模板相撞;改名后本工具的自建临时目录不再落在保护模板内。
|
|
12
|
+
const SANDBOX_PREFIX = 'doctor-sbx-'
|
|
13
|
+
const QUARANTINE_PREFIX = 'doctor-quarantine-'
|
|
14
|
+
|
|
15
|
+
export function makeSandbox(label) {
|
|
16
|
+
const root = mkdtempSync(path.join(tmpdir(), `${SANDBOX_PREFIX}${label}-`))
|
|
17
|
+
const home = path.join(root, 'home')
|
|
18
|
+
const logs = path.join(root, 'logs')
|
|
19
|
+
mkdirSync(home, { recursive: true })
|
|
20
|
+
mkdirSync(logs, { recursive: true })
|
|
21
|
+
return { root, home, logs }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 清理改为「隔离不删除」(红线 4 三段式的前两段:dry-run 打印 → rename 到隔离目录)。
|
|
25
|
+
// 返回隔离后的绝对路径;调用方必须打印它,人工确认后再自行清理。
|
|
26
|
+
// 本工具不再有任何 rmSync/rm -rf 路径 —— 旧的 cleanSandbox 已移除。
|
|
27
|
+
export function quarantineSandbox(sb) {
|
|
28
|
+
if (!sb || !sb.root || !existsSync(sb.root)) return null
|
|
29
|
+
const dest = path.join(tmpdir(), `${QUARANTINE_PREFIX}${Date.now()}-${path.basename(sb.root)}`)
|
|
30
|
+
try {
|
|
31
|
+
renameSync(sb.root, dest)
|
|
32
|
+
return dest
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 把运行期绝对路径替换为占位符,使 message 可对外复用(R-fix 0C / L1)。
|
|
39
|
+
// 只处理"本次运行自己注入的路径",不做通用脱敏。
|
|
40
|
+
export function redact(text, paths = []) {
|
|
41
|
+
let s = String(text ?? '')
|
|
42
|
+
const variants = []
|
|
43
|
+
for (const p of paths) {
|
|
44
|
+
if (!p || typeof p !== 'string') continue
|
|
45
|
+
variants.push(p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\'))
|
|
46
|
+
}
|
|
47
|
+
const tmp = tmpdir()
|
|
48
|
+
variants.push(tmp, tmp.replace(/\\/g, '/'), tmp.replace(/\//g, '\\'))
|
|
49
|
+
for (const v of [...new Set(variants)].sort((a, b) => b.length - a.length)) {
|
|
50
|
+
if (v.length < 4) continue
|
|
51
|
+
s = s.split(v).join('<path>')
|
|
52
|
+
}
|
|
53
|
+
return s
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 执行命令:stdout/stderr 分别写入日志文件(不建管道),返回退出码与全文
|
|
57
|
+
export function runStep(label, cmd, args, { env = {}, timeout = 120_000, cwd, logDir, shell } = {}) {
|
|
58
|
+
const dir = logDir ?? process.cwd()
|
|
59
|
+
const outPath = path.join(dir, `${label}.out.log`)
|
|
60
|
+
const errPath = path.join(dir, `${label}.err.log`)
|
|
61
|
+
const fdOut = openSync(outPath, 'w')
|
|
62
|
+
const fdErr = openSync(errPath, 'w')
|
|
63
|
+
const res = spawnSync(cmd, args, {
|
|
64
|
+
cwd,
|
|
65
|
+
env: { ...process.env, ...env },
|
|
66
|
+
stdio: ['ignore', fdOut, fdErr],
|
|
67
|
+
timeout,
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
// Windows 下解析 npm.cmd/pnpm.cmd 等 shim 需要 shell;直接调用 node.exe 等真实可执行文件时禁用
|
|
70
|
+
shell: shell ?? process.platform === 'win32',
|
|
71
|
+
})
|
|
72
|
+
closeSync(fdOut)
|
|
73
|
+
closeSync(fdErr)
|
|
74
|
+
const read = (p) => { try { return readFileSync(p, 'utf8') } catch { return '' } }
|
|
75
|
+
return {
|
|
76
|
+
code: res.status,
|
|
77
|
+
signal: res.signal,
|
|
78
|
+
spawnError: res.error ? String(res.error) : null,
|
|
79
|
+
out: read(outPath),
|
|
80
|
+
err: read(errPath),
|
|
81
|
+
outPath,
|
|
82
|
+
errPath,
|
|
83
|
+
ok: res.status === 0 && !res.error,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function tail(text, n = 8) {
|
|
88
|
+
if (!text) return '(无输出)'
|
|
89
|
+
const lines = text.trim().split('\n')
|
|
90
|
+
return lines.slice(-n).join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const pass = (message) => ({ status: 'pass', message })
|
|
94
|
+
export const fail = (message) => ({ status: 'fail', message })
|
|
95
|
+
export const warn = (message) => ({ status: 'warn', message })
|
|
96
|
+
export const skip = (reason) => ({ status: 'skip', message: reason })
|
|
97
|
+
/** 显式不可判:与 skip 同判定,但带 category 以便上游区分「环境不适用」与「确实没跑」 */
|
|
98
|
+
export const na = (reason, category = 'not-applicable') => ({ status: 'skip', message: reason, category })
|
|
99
|
+
/** 环境类降级:不计 pass、不计插件缺陷,走 category=environment */
|
|
100
|
+
export const envskip = (reason) => ({ status: 'skip', message: reason, category: 'environment' })
|
|
101
|
+
|
|
102
|
+
export function readJson(p) {
|
|
103
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function exists(p) {
|
|
107
|
+
return existsSync(p)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function writeJson(p, obj) {
|
|
111
|
+
writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 递归收集目录下匹配文件(跳过 node_modules/lib/dist 等构建产物)
|
|
115
|
+
export function findFiles(dir, sub, re, out = []) {
|
|
116
|
+
const base = path.join(dir, sub)
|
|
117
|
+
if (!existsSync(base)) return out
|
|
118
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
119
|
+
if (['node_modules', 'lib', 'dist', '.git'].includes(entry.name)) continue
|
|
120
|
+
const p = path.join(base, entry.name)
|
|
121
|
+
if (entry.isDirectory()) findFiles(dir, path.join(sub, entry.name), re, out)
|
|
122
|
+
else if (re.test(entry.name)) out.push(p)
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,50 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@perrylink/dsh-plugin-doctor",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Zero-dependency static + sandbox smoke detector for DeepSeek Harness (dsh) plugins: package-structure gates (R), cordis contract scans (K), keyless-headless sandbox smoke (D), and ecosystem-listing checks (CC).",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
}
|
|
50
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@perrylink/dsh-plugin-doctor",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Zero-dependency static + sandbox smoke detector for DeepSeek Harness (dsh) plugins: package-structure gates (R), cordis contract scans (K), keyless-headless sandbox smoke (D), and ecosystem-listing checks (CC).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./plugin.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./plugin.mjs",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"dsh-plugin-doctor": "./doctor.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"doctor.mjs",
|
|
16
|
+
"plugin.mjs",
|
|
17
|
+
"lib/",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"SURVEY.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"deepseek-harness",
|
|
30
|
+
"cordis",
|
|
31
|
+
"plugin",
|
|
32
|
+
"doctor",
|
|
33
|
+
"lint",
|
|
34
|
+
"smoke-test",
|
|
35
|
+
"verification"
|
|
36
|
+
],
|
|
37
|
+
"license": "Apache-2.0",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/PerryLink/dsh-plugin-doctor.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://www.npmjs.com/package/@perrylink/dsh-plugin-doctor",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/PerryLink/dsh-plugin-doctor/issues"
|
|
45
|
+
},
|
|
46
|
+
"funding": {
|
|
47
|
+
"type": "github",
|
|
48
|
+
"url": "https://github.com/sponsors/PerryLink"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --check doctor.mjs && node --check lib/framework.mjs && node --check lib/util.mjs && node --check lib/checks-package.mjs && node --check lib/checks-cordis.mjs && node --check lib/checks-smoke.mjs && node --check lib/checks-collections.mjs && node --check scripts/badge.mjs && node --check scripts/verify.mjs && node --check tests/selftest.mjs && node --check tests/contract.mjs && node tests/selftest.mjs && node tests/contract.mjs",
|
|
52
|
+
"prepublishOnly": "npm test",
|
|
53
|
+
"verify:registry": "node scripts/verify.mjs",
|
|
54
|
+
"badge": "node scripts/badge.mjs"
|
|
55
|
+
},
|
|
56
|
+
"dsh": {
|
|
57
|
+
"bundle": {
|
|
58
|
+
"patch": "./cordis.patch.yml"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|