@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.
@@ -1,188 +1,188 @@
1
- // 静态·包结构检查(R0–R8)
2
- // 依据:deepseek-harness publish.md / apps/cli/src/plugin.ts(2026-09-07 调研)+
3
- // PerryLink 工作区双基线约定(AGENTS.md)
4
- import path from 'node:path'
5
- import { readFileSync, existsSync } from 'node:fs'
6
- import { runStep, pass, fail, warn, skip, tail, findFiles } from './util.mjs'
7
-
8
- export const GROUP = '静态·包结构'
9
-
10
- const stripDot = (p) => String(p ?? '').replace(/^\.\//, '')
11
- const normalizeEntry = (pkg) => stripDot(pkg.main ?? (typeof pkg.exports === 'string' ? pkg.exports : pkg.exports?.default?.default ?? pkg.exports?.['.']?.default) ?? 'index.js')
12
-
13
- export function addChecks(doctor, ctx) {
14
- const { repoPath, pkg, pkgName } = ctx
15
-
16
- doctor.add(GROUP, 'R0 基础字段(name/version/license/README/type)', () => {
17
- const problems = []
18
- if (!pkg.name) problems.push('缺 name')
19
- if (!pkg.version) problems.push('缺 version')
20
- if (!pkg.license) problems.push('缺 license(SPDX)')
21
- if (!existsSync(path.join(repoPath, 'README.md'))) problems.push('缺 README.md')
22
- const hasLicenseFile = ['LICENSE', 'LICENSE.md', 'LICENSE.txt'].some((f) => existsSync(path.join(repoPath, f)))
23
- if (!hasLicenseFile) problems.push('缺 LICENSE 文件')
24
- if (!pkg.type) problems.push('未声明 type(官方范式 "module")')
25
- if (problems.length) return warn(problems.join(';'))
26
- return pass('基础字段齐全')
27
- })
28
-
29
- doctor.add(GROUP, 'R1 激活门 dsh.bundle.patch', () => {
30
- const patch = pkg.dsh?.bundle?.patch
31
- if (typeof patch === 'string' && patch.length > 0) return pass(`dsh.bundle.patch = ${patch}`)
32
- return fail(
33
- 'package.json 缺少 dsh.bundle.patch —— 该包安装后只会作为普通依赖,宿主永远不会把它加入 dsh.profile.bundles(最静默的失败模式)',
34
- )
35
- }, { critical: true })
36
-
37
- doctor.add(GROUP, 'R2 tarball 完整性(npm pack --dry-run)', () => {
38
- const r = runStep('r2-pack', 'npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { cwd: repoPath, logDir: ctx.logDir, timeout: 300_000 })
39
- if (!r.ok) return fail(`npm pack 失败(exit ${r.code}):\n${tail(r.err)}`)
40
- let pack
41
- try {
42
- const json = r.out.replace(/^\uFEFF/, '').trim()
43
- pack = JSON.parse(json.slice(json.indexOf('['), json.lastIndexOf(']') + 1))[0]
44
- } catch {
45
- return fail('npm pack --json 输出解析失败')
46
- }
47
- const files = pack.files ?? []
48
- const paths = files.map((f) => f.path)
49
- const problems = []
50
- const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
51
- const entry = normalizeEntry(pkg)
52
- if (patch && !paths.includes(patch)) problems.push(`patch 文件 ${patch} 不在 tarball(files 白名单未覆盖?)`)
53
- if (entry && !paths.some((f) => f === entry)) problems.push(`入口 ${entry} 不在 tarball`)
54
- for (const need of [patch, entry]) {
55
- const hit = files.find((f) => f.path === need)
56
- if (hit && Number(hit.size ?? 0) === 0) problems.push(`${need} 为空文件`)
57
- }
58
- const expected = `${pkgName.replace(/^@/, '').replace('/', '-')}-${pkg.version}.tgz`
59
- if (pack.filename && pack.filename !== expected) problems.push(`tarball 名 ${pack.filename} ≠ 期望 ${expected}`)
60
- if (problems.length) return fail(problems.join('\n'))
61
- return pass(`tarball ${pack.filename} 含入口与 patch(共 ${files.length} 个文件)`)
62
- })
63
-
64
- doctor.add(GROUP, 'R3 cordis.patch.yml 结构(启发式)', () => {
65
- const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
66
- if (!patch) return skip('无 dsh.bundle.patch')
67
- const p = path.resolve(repoPath, patch)
68
- if (!existsSync(p)) return fail(`patch 文件不存在: ${patch}`)
69
- const text = readFileSync(p, 'utf8')
70
- // YAML 注释行剔除后再做启发式(示例配置注释里常出现 name: xxx)
71
- const active = text.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n')
72
- const problems = []
73
- if (!/-\s+insert\s*:/.test(active)) problems.push('未找到 "- insert:" 结构')
74
- const ids = [...active.matchAll(/(?:^|\n)\s*-?\s*id:\s*["']?([^"'\s]+)/g)].map((m) => m[1])
75
- if (!ids.length) problems.push('未找到 id 行(行 id 必须存在,供上层整行替换定位)')
76
- const names = [...active.matchAll(/(?:^|\n)\s*name:\s*["']?([^"'\s,]+)|[,\s]name:\s*["']?([^"'\s,]+)/g)]
77
- .map((m) => m[1] ?? m[2]).filter(Boolean)
78
- const badNames = names.filter((n) => n !== pkgName && !n.startsWith(`${pkgName}/`))
79
- if (names.length && badNames.length) problems.push(`行 name 与包名不一致: ${[...new Set(badNames)].join(', ')}(name 必须经 profile node_modules 解析,应为包名)`)
80
- if (problems.length) return fail(problems.join('\n'))
81
- return pass(`解析到 insert 结构、${ids.length} 个 id、name 与包名一致(启发式;以 D2 --dump-config 为准)`)
82
- })
83
-
84
- doctor.add(GROUP, 'R4 入口契约(name/apply 导出 + inject 字面量)', () => {
85
- const entry = normalizeEntry(pkg)
86
- const entryAbs = path.resolve(repoPath, entry)
87
- if (!existsSync(entryAbs)) return fail(`入口文件 ${entry} 不存在(需先 pnpm run build)`)
88
- const src = readFileSync(entryAbs, 'utf8')
89
- const hasApply = /exports\s*\.\s*apply|module\.exports\s*=\s*\{[\s\S]{0,400}\bapply\b|export\s+(?:const|function)\s+apply/.test(src)
90
- const hasName = /exports\s*\.\s*name|module\.exports\s*=\s*\{[\s\S]{0,400}\bname\b|export\s+const\s+name/.test(src)
91
- const problems = []
92
- if (!hasApply) problems.push('入口未检出 apply 导出')
93
- if (!hasName) problems.push('入口未检出 name 导出')
94
- // TS 源兜底(入口是编译产物时)
95
- const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
96
- const srcText = srcFiles.map((f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }).join('\n')
97
- if (!hasApply && /export\s+(?:const|function)\s+apply|apply\s*\(ctx/.test(srcText)) {
98
- problems.splice(problems.indexOf('入口未检出 apply 导出'), 1)
99
- }
100
- if (!hasName && /export\s+const\s+name\b/.test(srcText)) {
101
- problems.splice(problems.indexOf('入口未检出 name 导出'), 1)
102
- }
103
- const injectDecl = srcText.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
104
- if (injectDecl) {
105
- const inner = injectDecl[1].trim()
106
- const junk = inner.replace(/['"][^'"]*['"]/g, '').replace(/[\s,]/g, '')
107
- if (junk) problems.push(`inject 数组含非字符串字面量: [${inner}]`)
108
- }
109
- if (problems.length) return warn(problems.join('\n') + '\n(启发式扫描;若为 default 对象/class 形式请人工确认)')
110
- return pass(`入口 ${entry} 检出 name/apply 导出${injectDecl ? ',inject 为字符串数组' : ''}`)
111
- })
112
-
113
- doctor.add(GROUP, 'R5 依赖口径(rescope cordis / 禁裸上游名)', () => {
114
- const problems = []
115
- for (const field of ['dependencies', 'peerDependencies', 'devDependencies']) {
116
- const deps = pkg[field] ?? {}
117
- if ('cordis' in deps) problems.push(`${field} 出现裸 cordis@${deps.cordis}(应使用 rescope @deepseek-ai/cordis)`)
118
- if ('schemastery' in deps) problems.push(`${field} 出现裸 schemastery@${deps.schemastery}(应使用 @deepseek-ai/schemastery)`)
119
- }
120
- const peer = pkg.peerDependencies?.['@deepseek-ai/cordis']
121
- const dep = pkg.dependencies?.['@deepseek-ai/cordis']
122
- const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
123
- const importsCordis = srcFiles.some((f) => { try { return /from\s+['"]@deepseek-ai\/cordis['"]|require\(['"]@deepseek-ai\/cordis['"]\)/.test(readFileSync(f, 'utf8')) } catch { return false } })
124
- if (!peer && !dep) {
125
- if (importsCordis) problems.push('源码 import @deepseek-ai/cordis 但未声明任何依赖')
126
- // R-fix 0A / P7:无源文件时旧实现直接 pass(真·静默通过,连 skip 痕迹都不留),而 R5 属被门禁的 16 项之一。
127
- else if (srcFiles.length) return pass('未声明 @deepseek-ai/cordis 且源码无 import(纯 JS 仓可无 cordis 运行时依赖)')
128
- else return skip('无源文件可判(无 src/ 且未声明 @deepseek-ai/cordis)——不计 pass')
129
- } else if (!peer) {
130
- problems.push('@deepseek-ai/cordis 只在 dependencies(官方口径:peerDependencies + devDependencies 同时声明)')
131
- }
132
- if (pkg.dependencies?.['@deepseek-ai/dsh']) problems.push('dependencies 直接依赖 @deepseek-ai/dsh(宿主已提供,应走 peer/dev 口径)')
133
- if (problems.length) return fail(problems.join('\n'))
134
- return pass(`cordis 口径正确(${peer ?? dep})`)
135
- })
136
-
137
- doctor.add(GROUP, 'R6 Node 引擎声明', () => {
138
- const e = pkg.engines?.node
139
- if (!e) return warn('未声明 engines.node(建议 "^22.19.0 || >=24.0.0";npm 线宿主不强制,属建议门)')
140
- const only23 = /23/.test(e) && !/22/.test(e) && !/24/.test(e) && !/>=\s*2[5-9]/.test(e)
141
- if (only23) return fail(`engines.node=${e} 宣称 Node 23(官方整线排除 23,要求 ^22.19.0 || >=24.0.0)`)
142
- if (/22\.19/.test(e) && /24/.test(e)) return pass(`engines.node=${e} 与官方 ^22.19.0 || >=24.0.0 一致`)
143
- return warn(`engines.node=${e} 与官方 ^22.19.0 || >=24.0.0 的相交性请人工确认`)
144
- })
145
-
146
- doctor.add(GROUP, 'R7 预构建与 files 覆盖', () => {
147
- const entry = normalizeEntry(pkg)
148
- const problems = []
149
- if (entry.includes('src/')) problems.push(`main 指向源码 ${entry}(npm 发布必须预构建,main 应指向 lib/ 产物)`)
150
- const filesField = pkg.files
151
- if (Array.isArray(filesField) && filesField.length) {
152
- const covered = (f) => filesField.some((item) => {
153
- const base = item.replace(/\/$/, '')
154
- return f === item || f.startsWith(`${base}/`)
155
- })
156
- if (!covered(entry)) problems.push(`files 白名单未覆盖入口 ${entry}`)
157
- const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
158
- if (patch && !covered(patch)) problems.push('files 白名单未覆盖 cordis.patch.yml')
159
- } else {
160
- problems.push('未声明 files 白名单')
161
- }
162
- const note = pkg.scripts?.prepare ? '(含 prepare 脚本,支持 git 直装自构建)' : '(无 prepare,git 直装不可用,npm/tarball 不受影响)'
163
- if (problems.length) return fail(problems.join('\n') + '\n' + note)
164
- return pass(`main 指向构建产物且 files 覆盖入口与 patch ${note}`)
165
- })
166
-
167
- doctor.add(GROUP, 'R8 peer 范围旧 rc 残留(双基线教训)', () => {
168
- const peers = Object.entries(pkg.peerDependencies ?? {})
169
- .filter(([k]) => k.startsWith('@deepseek-ai/dsh') || k === 'cordis' || k.startsWith('@deepseek-ai/cordis'))
170
- if (!peers.length) return skip('无 dsh 相关 peer 声明')
171
- const problems = []
172
- for (const [k, v] of peers) {
173
- const s = String(v)
174
- // Enumerating two literals (0.1.0-rc.8 / 0.1.1-rc.2) only caught the shapes
175
- // that happened to exist on 2026-09-05; every other old line slipped through.
176
- // Match the whole old-line family instead.
177
- if (/(0\.1\.0-rc\.|0\.1\.1-rc\.|0\.1\.2-alpha\.|0\.1\.3-alpha\.)/.test(s)) problems.push(`${k}: ${s} 含旧 rc/alpha 版本(2026-09-05 起全仓统一为 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0)`)
178
- // A single-arm prerelease-tuple range admits only the one prerelease it
179
- // names: `>=0.1.2-rc.1 <0.2.0` (no `||`) rejects 0.1.5-rc.1 — exactly the
180
- // false green the OR form exists to fix. Flag any bare arm.
181
- if (!s.includes('||') && /^>=\s*0\.1\.\d+-[0-9A-Za-z.]+/.test(s) && /<\s*0\.2\.0/.test(s)) {
182
- problems.push(`${k}: ${s} 是单臂 prerelease-tuple 区间,不会放行更新的宿主线(须写成 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0)`)
183
- }
184
- }
185
- if (problems.length) return fail(problems.join('\n'))
186
- return pass(peers.map(([k, v]) => `${k} ${v}`).join(';'))
187
- })
188
- }
1
+ // 静态·包结构检查(R0–R8)
2
+ // 依据:deepseek-harness publish.md / apps/cli/src/plugin.ts(2026-09-07 调研)+
3
+ // PerryLink 工作区双基线约定(AGENTS.md)
4
+ import path from 'node:path'
5
+ import { readFileSync, existsSync } from 'node:fs'
6
+ import { runStep, pass, fail, warn, skip, tail, findFiles } from './util.mjs'
7
+
8
+ export const GROUP = '静态·包结构'
9
+
10
+ const stripDot = (p) => String(p ?? '').replace(/^\.\//, '')
11
+ const normalizeEntry = (pkg) => stripDot(pkg.main ?? (typeof pkg.exports === 'string' ? pkg.exports : pkg.exports?.default?.default ?? pkg.exports?.['.']?.default) ?? 'index.js')
12
+
13
+ export function addChecks(doctor, ctx) {
14
+ const { repoPath, pkg, pkgName } = ctx
15
+
16
+ doctor.add(GROUP, 'R0 基础字段(name/version/license/README/type)', () => {
17
+ const problems = []
18
+ if (!pkg.name) problems.push('缺 name')
19
+ if (!pkg.version) problems.push('缺 version')
20
+ if (!pkg.license) problems.push('缺 license(SPDX)')
21
+ if (!existsSync(path.join(repoPath, 'README.md'))) problems.push('缺 README.md')
22
+ const hasLicenseFile = ['LICENSE', 'LICENSE.md', 'LICENSE.txt'].some((f) => existsSync(path.join(repoPath, f)))
23
+ if (!hasLicenseFile) problems.push('缺 LICENSE 文件')
24
+ if (!pkg.type) problems.push('未声明 type(官方范式 "module")')
25
+ if (problems.length) return warn(problems.join(';'))
26
+ return pass('基础字段齐全')
27
+ })
28
+
29
+ doctor.add(GROUP, 'R1 激活门 dsh.bundle.patch', () => {
30
+ const patch = pkg.dsh?.bundle?.patch
31
+ if (typeof patch === 'string' && patch.length > 0) return pass(`dsh.bundle.patch = ${patch}`)
32
+ return fail(
33
+ 'package.json 缺少 dsh.bundle.patch —— 该包安装后只会作为普通依赖,宿主永远不会把它加入 dsh.profile.bundles(最静默的失败模式)',
34
+ )
35
+ }, { critical: true })
36
+
37
+ doctor.add(GROUP, 'R2 tarball 完整性(npm pack --dry-run)', () => {
38
+ const r = runStep('r2-pack', 'npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { cwd: repoPath, logDir: ctx.logDir, timeout: 300_000 })
39
+ if (!r.ok) return fail(`npm pack 失败(exit ${r.code}):\n${tail(r.err)}`)
40
+ let pack
41
+ try {
42
+ const json = r.out.replace(/^\uFEFF/, '').trim()
43
+ pack = JSON.parse(json.slice(json.indexOf('['), json.lastIndexOf(']') + 1))[0]
44
+ } catch {
45
+ return fail('npm pack --json 输出解析失败')
46
+ }
47
+ const files = pack.files ?? []
48
+ const paths = files.map((f) => f.path)
49
+ const problems = []
50
+ const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
51
+ const entry = normalizeEntry(pkg)
52
+ if (patch && !paths.includes(patch)) problems.push(`patch 文件 ${patch} 不在 tarball(files 白名单未覆盖?)`)
53
+ if (entry && !paths.some((f) => f === entry)) problems.push(`入口 ${entry} 不在 tarball`)
54
+ for (const need of [patch, entry]) {
55
+ const hit = files.find((f) => f.path === need)
56
+ if (hit && Number(hit.size ?? 0) === 0) problems.push(`${need} 为空文件`)
57
+ }
58
+ const expected = `${pkgName.replace(/^@/, '').replace('/', '-')}-${pkg.version}.tgz`
59
+ if (pack.filename && pack.filename !== expected) problems.push(`tarball 名 ${pack.filename} ≠ 期望 ${expected}`)
60
+ if (problems.length) return fail(problems.join('\n'))
61
+ return pass(`tarball ${pack.filename} 含入口与 patch(共 ${files.length} 个文件)`)
62
+ })
63
+
64
+ doctor.add(GROUP, 'R3 cordis.patch.yml 结构(启发式)', () => {
65
+ const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
66
+ if (!patch) return skip('无 dsh.bundle.patch')
67
+ const p = path.resolve(repoPath, patch)
68
+ if (!existsSync(p)) return fail(`patch 文件不存在: ${patch}`)
69
+ const text = readFileSync(p, 'utf8')
70
+ // YAML 注释行剔除后再做启发式(示例配置注释里常出现 name: xxx)
71
+ const active = text.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n')
72
+ const problems = []
73
+ if (!/-\s+insert\s*:/.test(active)) problems.push('未找到 "- insert:" 结构')
74
+ const ids = [...active.matchAll(/(?:^|\n)\s*-?\s*id:\s*["']?([^"'\s]+)/g)].map((m) => m[1])
75
+ if (!ids.length) problems.push('未找到 id 行(行 id 必须存在,供上层整行替换定位)')
76
+ const names = [...active.matchAll(/(?:^|\n)\s*name:\s*["']?([^"'\s,]+)|[,\s]name:\s*["']?([^"'\s,]+)/g)]
77
+ .map((m) => m[1] ?? m[2]).filter(Boolean)
78
+ const badNames = names.filter((n) => n !== pkgName && !n.startsWith(`${pkgName}/`))
79
+ if (names.length && badNames.length) problems.push(`行 name 与包名不一致: ${[...new Set(badNames)].join(', ')}(name 必须经 profile node_modules 解析,应为包名)`)
80
+ if (problems.length) return fail(problems.join('\n'))
81
+ return pass(`解析到 insert 结构、${ids.length} 个 id、name 与包名一致(启发式;以 D2 --dump-config 为准)`)
82
+ })
83
+
84
+ doctor.add(GROUP, 'R4 入口契约(name/apply 导出 + inject 字面量)', () => {
85
+ const entry = normalizeEntry(pkg)
86
+ const entryAbs = path.resolve(repoPath, entry)
87
+ if (!existsSync(entryAbs)) return fail(`入口文件 ${entry} 不存在(需先 pnpm run build)`)
88
+ const src = readFileSync(entryAbs, 'utf8')
89
+ const hasApply = /exports\s*\.\s*apply|module\.exports\s*=\s*\{[\s\S]{0,400}\bapply\b|export\s+(?:const|function)\s+apply/.test(src)
90
+ const hasName = /exports\s*\.\s*name|module\.exports\s*=\s*\{[\s\S]{0,400}\bname\b|export\s+const\s+name/.test(src)
91
+ const problems = []
92
+ if (!hasApply) problems.push('入口未检出 apply 导出')
93
+ if (!hasName) problems.push('入口未检出 name 导出')
94
+ // TS 源兜底(入口是编译产物时)
95
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
96
+ const srcText = srcFiles.map((f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }).join('\n')
97
+ if (!hasApply && /export\s+(?:const|function)\s+apply|apply\s*\(ctx/.test(srcText)) {
98
+ problems.splice(problems.indexOf('入口未检出 apply 导出'), 1)
99
+ }
100
+ if (!hasName && /export\s+const\s+name\b/.test(srcText)) {
101
+ problems.splice(problems.indexOf('入口未检出 name 导出'), 1)
102
+ }
103
+ const injectDecl = srcText.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
104
+ if (injectDecl) {
105
+ const inner = injectDecl[1].trim()
106
+ const junk = inner.replace(/['"][^'"]*['"]/g, '').replace(/[\s,]/g, '')
107
+ if (junk) problems.push(`inject 数组含非字符串字面量: [${inner}]`)
108
+ }
109
+ if (problems.length) return warn(problems.join('\n') + '\n(启发式扫描;若为 default 对象/class 形式请人工确认)')
110
+ return pass(`入口 ${entry} 检出 name/apply 导出${injectDecl ? ',inject 为字符串数组' : ''}`)
111
+ })
112
+
113
+ doctor.add(GROUP, 'R5 依赖口径(rescope cordis / 禁裸上游名)', () => {
114
+ const problems = []
115
+ for (const field of ['dependencies', 'peerDependencies', 'devDependencies']) {
116
+ const deps = pkg[field] ?? {}
117
+ if ('cordis' in deps) problems.push(`${field} 出现裸 cordis@${deps.cordis}(应使用 rescope @deepseek-ai/cordis)`)
118
+ if ('schemastery' in deps) problems.push(`${field} 出现裸 schemastery@${deps.schemastery}(应使用 @deepseek-ai/schemastery)`)
119
+ }
120
+ const peer = pkg.peerDependencies?.['@deepseek-ai/cordis']
121
+ const dep = pkg.dependencies?.['@deepseek-ai/cordis']
122
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
123
+ const importsCordis = srcFiles.some((f) => { try { return /from\s+['"]@deepseek-ai\/cordis['"]|require\(['"]@deepseek-ai\/cordis['"]\)/.test(readFileSync(f, 'utf8')) } catch { return false } })
124
+ if (!peer && !dep) {
125
+ if (importsCordis) problems.push('源码 import @deepseek-ai/cordis 但未声明任何依赖')
126
+ // R-fix 0A / P7:无源文件时旧实现直接 pass(真·静默通过,连 skip 痕迹都不留),而 R5 属被门禁的 16 项之一。
127
+ else if (srcFiles.length) return pass('未声明 @deepseek-ai/cordis 且源码无 import(纯 JS 仓可无 cordis 运行时依赖)')
128
+ else return skip('无源文件可判(无 src/ 且未声明 @deepseek-ai/cordis)——不计 pass')
129
+ } else if (!peer) {
130
+ problems.push('@deepseek-ai/cordis 只在 dependencies(官方口径:peerDependencies + devDependencies 同时声明)')
131
+ }
132
+ if (pkg.dependencies?.['@deepseek-ai/dsh']) problems.push('dependencies 直接依赖 @deepseek-ai/dsh(宿主已提供,应走 peer/dev 口径)')
133
+ if (problems.length) return fail(problems.join('\n'))
134
+ return pass(`cordis 口径正确(${peer ?? dep})`)
135
+ })
136
+
137
+ doctor.add(GROUP, 'R6 Node 引擎声明', () => {
138
+ const e = pkg.engines?.node
139
+ if (!e) return warn('未声明 engines.node(建议 "^22.19.0 || >=24.0.0";npm 线宿主不强制,属建议门)')
140
+ const only23 = /23/.test(e) && !/22/.test(e) && !/24/.test(e) && !/>=\s*2[5-9]/.test(e)
141
+ if (only23) return fail(`engines.node=${e} 宣称 Node 23(官方整线排除 23,要求 ^22.19.0 || >=24.0.0)`)
142
+ if (/22\.19/.test(e) && /24/.test(e)) return pass(`engines.node=${e} 与官方 ^22.19.0 || >=24.0.0 一致`)
143
+ return warn(`engines.node=${e} 与官方 ^22.19.0 || >=24.0.0 的相交性请人工确认`)
144
+ })
145
+
146
+ doctor.add(GROUP, 'R7 预构建与 files 覆盖', () => {
147
+ const entry = normalizeEntry(pkg)
148
+ const problems = []
149
+ if (entry.includes('src/')) problems.push(`main 指向源码 ${entry}(npm 发布必须预构建,main 应指向 lib/ 产物)`)
150
+ const filesField = pkg.files
151
+ if (Array.isArray(filesField) && filesField.length) {
152
+ const covered = (f) => filesField.some((item) => {
153
+ const base = item.replace(/\/$/, '')
154
+ return f === item || f.startsWith(`${base}/`)
155
+ })
156
+ if (!covered(entry)) problems.push(`files 白名单未覆盖入口 ${entry}`)
157
+ const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
158
+ if (patch && !covered(patch)) problems.push('files 白名单未覆盖 cordis.patch.yml')
159
+ } else {
160
+ problems.push('未声明 files 白名单')
161
+ }
162
+ const note = pkg.scripts?.prepare ? '(含 prepare 脚本,支持 git 直装自构建)' : '(无 prepare,git 直装不可用,npm/tarball 不受影响)'
163
+ if (problems.length) return fail(problems.join('\n') + '\n' + note)
164
+ return pass(`main 指向构建产物且 files 覆盖入口与 patch ${note}`)
165
+ })
166
+
167
+ doctor.add(GROUP, 'R8 peer 范围旧 rc 残留(双基线教训)', () => {
168
+ const peers = Object.entries(pkg.peerDependencies ?? {})
169
+ .filter(([k]) => k.startsWith('@deepseek-ai/dsh') || k === 'cordis' || k.startsWith('@deepseek-ai/cordis'))
170
+ if (!peers.length) return skip('无 dsh 相关 peer 声明')
171
+ const problems = []
172
+ for (const [k, v] of peers) {
173
+ const s = String(v)
174
+ // Enumerating two literals (0.1.0-rc.8 / 0.1.1-rc.2) only caught the shapes
175
+ // that happened to exist on 2026-09-05; every other old line slipped through.
176
+ // Match the whole old-line family instead.
177
+ if (/(0\.1\.0-rc\.|0\.1\.1-rc\.|0\.1\.2-alpha\.|0\.1\.3-alpha\.)/.test(s)) problems.push(`${k}: ${s} 含旧 rc/alpha 版本(2026-09-05 起全仓统一为 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0)`)
178
+ // A single-arm prerelease-tuple range admits only the one prerelease it
179
+ // names: `>=0.1.2-rc.1 <0.2.0` (no `||`) rejects 0.1.5-rc.1 — exactly the
180
+ // false green the OR form exists to fix. Flag any bare arm.
181
+ if (!s.includes('||') && /^>=\s*0\.1\.\d+-[0-9A-Za-z.]+/.test(s) && /<\s*0\.2\.0/.test(s)) {
182
+ problems.push(`${k}: ${s} 是单臂 prerelease-tuple 区间,不会放行更新的宿主线(须写成 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0)`)
183
+ }
184
+ }
185
+ if (problems.length) return fail(problems.join('\n'))
186
+ return pass(peers.map(([k, v]) => `${k} ${v}`).join(';'))
187
+ })
188
+ }
@@ -1,117 +1,117 @@
1
- // 动态·沙箱冒烟(D0–D3 + D9)
2
- // 依据:dsh compat.yml 正典配方(MISSING_CREDENTIAL 判据)+ 2026-09-07 harness 调研 +
3
- // 工作区红线 3(一切测试全沙箱)
4
- //
5
- // 本轮(R-fix 6/7)两处硬化:
6
- // 1. `dsh plugin add` 显式传 --ignore-scripts —— 被测包的 install/prepare 脚本不得在宿主执行;
7
- // pnpm 的 ignored-builds 阻断归类为 environment(不计 pass、不计插件缺陷)。
8
- // 2. D9 由「直接 rmSync」改为「隔离不删除」(红线 4);D0 的 tarball 落进沙箱而非被检仓。
9
- import path from 'node:path'
10
- import { makeSandbox, quarantineSandbox, runStep, pass, fail, skip, envskip, tail, readJson, writeJson, exists } from './util.mjs'
11
-
12
- export const GROUP = '动态·沙箱冒烟'
13
-
14
- export function addSmokeChecks(doctor, ctx, opts = {}) {
15
- const { repoPath, pkgName, logDir } = ctx
16
- const dshVersion = opts.dshVersion ?? '0.1.2-rc.1'
17
- const state = {}
18
-
19
- doctor.add(GROUP, 'D0 打包 tarball(npm pack,禁止生命周期脚本)', () => {
20
- state.sb = makeSandbox('smoke')
21
- ctx.sandboxRoots.push(state.sb.root)
22
- // --pack-destination 指向沙箱:旧实现把 .tgz 直接落在被检仓里且从不清理(实测 8 仓遗留 17 个 stray .tgz)
23
- const r = runStep('d0-pack', 'npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', state.sb.root], { cwd: repoPath, logDir, timeout: 300_000 })
24
- if (!r.ok) return fail(`npm pack 失败(exit ${r.code}):\n${tail(r.err)}`)
25
- let pack
26
- try {
27
- const json = r.out.replace(/^\uFEFF/, '').trim()
28
- pack = JSON.parse(json.slice(json.indexOf('['), json.lastIndexOf(']') + 1))[0]
29
- } catch {
30
- return fail('npm pack --json 解析失败')
31
- }
32
- state.tgz = path.join(state.sb.root, pack.filename)
33
- if (!exists(state.tgz)) return fail(`tarball 未生成: ${pack.filename}`)
34
- return pass(`已打包 ${pack.filename}(${pack.files?.length ?? '?'} 文件,落沙箱)`)
35
- })
36
-
37
- doctor.add(GROUP, 'D1 安装冒烟(plugin add + bundles 断言)', () => {
38
- if (!state.tgz) return skip('D0 未通过')
39
- writeJson(path.join(state.sb.root, 'package.json'), { name: 'dsh-doctor-runtime', version: '0.0.0', private: true })
40
- const i = runStep('d1-host', 'npm', ['install', '--no-audit', '--no-fund', '--loglevel=error', `@deepseek-ai/dsh@${dshVersion}`], {
41
- cwd: state.sb.root, logDir, timeout: 900_000,
42
- })
43
- if (i.spawnError || i.code === 127) {
44
- return { status: 'fail', message: `环境不可用:找不到 npm/node(${i.spawnError ?? 'exit 127'}):\n${tail(i.err)}`, category: 'infrastructure' }
45
- }
46
- if (!i.ok) {
47
- // 宿主安装失败 ≠ 插件缺陷:归类 unsupported-host(退出码 4)
48
- return { status: 'fail', message: `安装 @deepseek-ai/dsh@${dshVersion} 失败(exit ${i.code})—— 宿主版本不可用,不判插件:\n${tail(i.err)}`, category: 'unsupported-host' }
49
- }
50
- const hostPkgPath = path.join(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
51
- if (!exists(hostPkgPath)) return { status: 'fail', message: '@deepseek-ai/dsh 未安装成功', category: 'unsupported-host' }
52
- const host = readJson(hostPkgPath)
53
- const rel = host.bin?.dsh
54
- const bin = rel ? path.resolve(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', rel) : null
55
- if (!bin || !exists(bin)) return { status: 'fail', message: `未找到 dsh bin(package.json bin=${JSON.stringify(host.bin)})`, category: 'infrastructure' }
56
- state.bin = bin
57
- const dsh = (label, args, o = {}) => runStep(label, process.execPath, [bin, ...args], {
58
- env: { DSH_HOME: state.sb.home, DSH_AGENTS_HOME: path.join(state.sb.home, '.agents'), ...(o.env ?? {}) },
59
- cwd: state.sb.root, logDir, timeout: o.timeout ?? 120_000, shell: false,
60
- })
61
- state.dsh = dsh
62
- // --ignore-scripts 透传给 pnpm(dsh plugin 是 pnpm 的透明转发器)
63
- const a = dsh('d1-add', ['plugin', '--profile', 'headless', 'add', state.tgz, '--ignore-scripts'], { timeout: 900_000 })
64
- const envBlock = /ERR_PNPM_IGNORED_BUILDS|approve-builds|ignored builds/i.test(a.err)
65
- const pnpmMissing = /pnpm not found on PATH|ENOENT/i.test(a.err) || a.code === 127
66
- if (pnpmMissing) {
67
- return { status: 'fail', message: `环境不可用:PATH 上找不到 pnpm(dsh plugin 是 pnpm 转发器):\n${tail(a.err)}`, category: 'infrastructure' }
68
- }
69
- if (!a.ok) {
70
- if (envBlock) {
71
- // 环境配方问题(pnpm 阻断 build scripts)→ environment,不计 pass、不计插件缺陷
72
- return envskip(`pnpm approve-builds/ignored-builds 环境门阻断(属环境配方问题,非插件缺陷,参照 compat.yml allowBuilds 配方):\n${tail(a.err)}`)
73
- }
74
- return fail(`plugin add 失败(exit ${a.code}):\n${tail(a.err)}`)
75
- }
76
- if (/declares no dsh\.bundle|not activated/i.test(a.err)) return fail('宿主 stderr 出现未激活警告(dsh.bundle 声明未被识别)')
77
- const profilePkgPath = path.join(state.sb.home, 'profiles', 'headless', 'package.json')
78
- if (!exists(profilePkgPath)) return fail('profile package.json 未生成')
79
- const profilePkg = readJson(profilePkgPath)
80
- const bundles = profilePkg?.dsh?.profile?.bundles ?? []
81
- if (!bundles.includes(pkgName)) return fail(`dsh.profile.bundles 不含 ${pkgName}(实际: ${JSON.stringify(bundles)})`)
82
- return pass(`已加入 dsh.profile.bundles: ${bundles.join(', ')}`)
83
- })
84
-
85
- doctor.add(GROUP, 'D2 层验证(--dump-config,不 boot)', () => {
86
- if (!state.dsh) return skip('D1 未通过')
87
- const r = state.dsh('d2-dump', ['--profile', 'headless', '--dump-config'], { timeout: 120_000 })
88
- if (r.signal) return { status: 'fail', message: `--dump-config 被信号终止(${r.signal})—— 结果不稳定`, category: 'unstable' }
89
- if (!r.ok) return fail(`--dump-config 失败(exit ${r.code}):\n${tail(r.err)}`)
90
- if (!r.out.includes(`# == ${pkgName}`)) return fail(`dump-config 未见层标记 "# == ${pkgName}"`)
91
- return pass(`层标记 "# == ${pkgName}" 出现(patch 已进入组合层)`)
92
- })
93
-
94
- doctor.add(GROUP, 'D3 keyless headless 冒烟(MISSING_CREDENTIAL 判据)', () => {
95
- if (!state.dsh) return skip('D1 未通过')
96
- const r = state.dsh('d3-run', ['--profile', 'headless', 'Reply with exactly: ok'], { timeout: 90_000 })
97
- const combined = `${r.out}\n${r.err}`
98
- if (r.signal) return { status: 'fail', message: `进程异常终止(signal=${r.signal})—— 结果不稳定`, category: 'unstable' }
99
- if (r.spawnError) return { status: 'fail', message: `子进程未启动(${r.spawnError})—— 环境不可用`, category: 'infrastructure' }
100
- const bad = combined.match(/.*(NO_ADAPTER|ERR_MODULE_NOT_FOUND|SyntaxError|TypeError|ReferenceError|Cannot find module).*/g)
101
- if (bad) return fail(`组合未 boot 到请求阶段,出现致命错误:\n${bad.slice(0, 5).join('\n')}`)
102
- if (r.code === 1 && /dsh:\s*MISSING_CREDENTIAL/.test(combined)) {
103
- return pass('exit 1 + dsh: MISSING_CREDENTIAL —— bundle 层生效、插件 apply 成功、组合到达模型请求阶段(严格匹配 code,防正则假阳性)')
104
- }
105
- if (r.code === 0) return pass('exit 0(环境中存在凭据,完整跑通)')
106
- return fail(`期望 exit 1 + MISSING_CREDENTIAL,实际 exit ${r.code}:\n${tail(combined)}`)
107
- })
108
-
109
- doctor.add(GROUP, 'D9 沙箱隔离(三段式:只隔离不删除)', () => {
110
- const before = state.sb?.root ?? null
111
- const moved = quarantineSandbox(state.sb)
112
- state.sb = null
113
- if (!before) return pass('未创建沙箱(无需隔离)')
114
- if (!moved) return fail(`沙箱隔离失败:${before}(请人工检查后清理)`)
115
- return pass(`沙箱已隔离到 ${moved}(未删除;人工确认后用 --purge 清理)`)
116
- })
117
- }
1
+ // 动态·沙箱冒烟(D0–D3 + D9)
2
+ // 依据:dsh compat.yml 正典配方(MISSING_CREDENTIAL 判据)+ 2026-09-07 harness 调研 +
3
+ // 工作区红线 3(一切测试全沙箱)
4
+ //
5
+ // 本轮(R-fix 6/7)两处硬化:
6
+ // 1. `dsh plugin add` 显式传 --ignore-scripts —— 被测包的 install/prepare 脚本不得在宿主执行;
7
+ // pnpm 的 ignored-builds 阻断归类为 environment(不计 pass、不计插件缺陷)。
8
+ // 2. D9 由「直接 rmSync」改为「隔离不删除」(红线 4);D0 的 tarball 落进沙箱而非被检仓。
9
+ import path from 'node:path'
10
+ import { makeSandbox, quarantineSandbox, runStep, pass, fail, skip, envskip, tail, readJson, writeJson, exists } from './util.mjs'
11
+
12
+ export const GROUP = '动态·沙箱冒烟'
13
+
14
+ export function addSmokeChecks(doctor, ctx, opts = {}) {
15
+ const { repoPath, pkgName, logDir } = ctx
16
+ const dshVersion = opts.dshVersion ?? '0.1.2-rc.1'
17
+ const state = {}
18
+
19
+ doctor.add(GROUP, 'D0 打包 tarball(npm pack,禁止生命周期脚本)', () => {
20
+ state.sb = makeSandbox('smoke')
21
+ ctx.sandboxRoots.push(state.sb.root)
22
+ // --pack-destination 指向沙箱:旧实现把 .tgz 直接落在被检仓里且从不清理(实测 8 仓遗留 17 个 stray .tgz)
23
+ const r = runStep('d0-pack', 'npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', state.sb.root], { cwd: repoPath, logDir, timeout: 300_000 })
24
+ if (!r.ok) return fail(`npm pack 失败(exit ${r.code}):\n${tail(r.err)}`)
25
+ let pack
26
+ try {
27
+ const json = r.out.replace(/^\uFEFF/, '').trim()
28
+ pack = JSON.parse(json.slice(json.indexOf('['), json.lastIndexOf(']') + 1))[0]
29
+ } catch {
30
+ return fail('npm pack --json 解析失败')
31
+ }
32
+ state.tgz = path.join(state.sb.root, pack.filename)
33
+ if (!exists(state.tgz)) return fail(`tarball 未生成: ${pack.filename}`)
34
+ return pass(`已打包 ${pack.filename}(${pack.files?.length ?? '?'} 文件,落沙箱)`)
35
+ })
36
+
37
+ doctor.add(GROUP, 'D1 安装冒烟(plugin add + bundles 断言)', () => {
38
+ if (!state.tgz) return skip('D0 未通过')
39
+ writeJson(path.join(state.sb.root, 'package.json'), { name: 'dsh-doctor-runtime', version: '0.0.0', private: true })
40
+ const i = runStep('d1-host', 'npm', ['install', '--no-audit', '--no-fund', '--loglevel=error', `@deepseek-ai/dsh@${dshVersion}`], {
41
+ cwd: state.sb.root, logDir, timeout: 900_000,
42
+ })
43
+ if (i.spawnError || i.code === 127) {
44
+ return { status: 'fail', message: `环境不可用:找不到 npm/node(${i.spawnError ?? 'exit 127'}):\n${tail(i.err)}`, category: 'infrastructure' }
45
+ }
46
+ if (!i.ok) {
47
+ // 宿主安装失败 ≠ 插件缺陷:归类 unsupported-host(退出码 4)
48
+ return { status: 'fail', message: `安装 @deepseek-ai/dsh@${dshVersion} 失败(exit ${i.code})—— 宿主版本不可用,不判插件:\n${tail(i.err)}`, category: 'unsupported-host' }
49
+ }
50
+ const hostPkgPath = path.join(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
51
+ if (!exists(hostPkgPath)) return { status: 'fail', message: '@deepseek-ai/dsh 未安装成功', category: 'unsupported-host' }
52
+ const host = readJson(hostPkgPath)
53
+ const rel = host.bin?.dsh
54
+ const bin = rel ? path.resolve(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', rel) : null
55
+ if (!bin || !exists(bin)) return { status: 'fail', message: `未找到 dsh bin(package.json bin=${JSON.stringify(host.bin)})`, category: 'infrastructure' }
56
+ state.bin = bin
57
+ const dsh = (label, args, o = {}) => runStep(label, process.execPath, [bin, ...args], {
58
+ env: { DSH_HOME: state.sb.home, DSH_AGENTS_HOME: path.join(state.sb.home, '.agents'), ...(o.env ?? {}) },
59
+ cwd: state.sb.root, logDir, timeout: o.timeout ?? 120_000, shell: false,
60
+ })
61
+ state.dsh = dsh
62
+ // --ignore-scripts 透传给 pnpm(dsh plugin 是 pnpm 的透明转发器)
63
+ const a = dsh('d1-add', ['plugin', '--profile', 'headless', 'add', state.tgz, '--ignore-scripts'], { timeout: 900_000 })
64
+ const envBlock = /ERR_PNPM_IGNORED_BUILDS|approve-builds|ignored builds/i.test(a.err)
65
+ const pnpmMissing = /pnpm not found on PATH|ENOENT/i.test(a.err) || a.code === 127
66
+ if (pnpmMissing) {
67
+ return { status: 'fail', message: `环境不可用:PATH 上找不到 pnpm(dsh plugin 是 pnpm 转发器):\n${tail(a.err)}`, category: 'infrastructure' }
68
+ }
69
+ if (!a.ok) {
70
+ if (envBlock) {
71
+ // 环境配方问题(pnpm 阻断 build scripts)→ environment,不计 pass、不计插件缺陷
72
+ return envskip(`pnpm approve-builds/ignored-builds 环境门阻断(属环境配方问题,非插件缺陷,参照 compat.yml allowBuilds 配方):\n${tail(a.err)}`)
73
+ }
74
+ return fail(`plugin add 失败(exit ${a.code}):\n${tail(a.err)}`)
75
+ }
76
+ if (/declares no dsh\.bundle|not activated/i.test(a.err)) return fail('宿主 stderr 出现未激活警告(dsh.bundle 声明未被识别)')
77
+ const profilePkgPath = path.join(state.sb.home, 'profiles', 'headless', 'package.json')
78
+ if (!exists(profilePkgPath)) return fail('profile package.json 未生成')
79
+ const profilePkg = readJson(profilePkgPath)
80
+ const bundles = profilePkg?.dsh?.profile?.bundles ?? []
81
+ if (!bundles.includes(pkgName)) return fail(`dsh.profile.bundles 不含 ${pkgName}(实际: ${JSON.stringify(bundles)})`)
82
+ return pass(`已加入 dsh.profile.bundles: ${bundles.join(', ')}`)
83
+ })
84
+
85
+ doctor.add(GROUP, 'D2 层验证(--dump-config,不 boot)', () => {
86
+ if (!state.dsh) return skip('D1 未通过')
87
+ const r = state.dsh('d2-dump', ['--profile', 'headless', '--dump-config'], { timeout: 120_000 })
88
+ if (r.signal) return { status: 'fail', message: `--dump-config 被信号终止(${r.signal})—— 结果不稳定`, category: 'unstable' }
89
+ if (!r.ok) return fail(`--dump-config 失败(exit ${r.code}):\n${tail(r.err)}`)
90
+ if (!r.out.includes(`# == ${pkgName}`)) return fail(`dump-config 未见层标记 "# == ${pkgName}"`)
91
+ return pass(`层标记 "# == ${pkgName}" 出现(patch 已进入组合层)`)
92
+ })
93
+
94
+ doctor.add(GROUP, 'D3 keyless headless 冒烟(MISSING_CREDENTIAL 判据)', () => {
95
+ if (!state.dsh) return skip('D1 未通过')
96
+ const r = state.dsh('d3-run', ['--profile', 'headless', 'Reply with exactly: ok'], { timeout: 90_000 })
97
+ const combined = `${r.out}\n${r.err}`
98
+ if (r.signal) return { status: 'fail', message: `进程异常终止(signal=${r.signal})—— 结果不稳定`, category: 'unstable' }
99
+ if (r.spawnError) return { status: 'fail', message: `子进程未启动(${r.spawnError})—— 环境不可用`, category: 'infrastructure' }
100
+ const bad = combined.match(/.*(NO_ADAPTER|ERR_MODULE_NOT_FOUND|SyntaxError|TypeError|ReferenceError|Cannot find module).*/g)
101
+ if (bad) return fail(`组合未 boot 到请求阶段,出现致命错误:\n${bad.slice(0, 5).join('\n')}`)
102
+ if (r.code === 1 && /dsh:\s*MISSING_CREDENTIAL/.test(combined)) {
103
+ return pass('exit 1 + dsh: MISSING_CREDENTIAL —— bundle 层生效、插件 apply 成功、组合到达模型请求阶段(严格匹配 code,防正则假阳性)')
104
+ }
105
+ if (r.code === 0) return pass('exit 0(环境中存在凭据,完整跑通)')
106
+ return fail(`期望 exit 1 + MISSING_CREDENTIAL,实际 exit ${r.code}:\n${tail(combined)}`)
107
+ })
108
+
109
+ doctor.add(GROUP, 'D9 沙箱隔离(三段式:只隔离不删除)', () => {
110
+ const before = state.sb?.root ?? null
111
+ const moved = quarantineSandbox(state.sb)
112
+ state.sb = null
113
+ if (!before) return pass('未创建沙箱(无需隔离)')
114
+ if (!moved) return fail(`沙箱隔离失败:${before}(请人工检查后清理)`)
115
+ return pass(`沙箱已隔离到 ${moved}(未删除;人工确认后用 --purge 清理)`)
116
+ })
117
+ }