@perrylink/dsh-plugin-doctor 0.1.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.
@@ -0,0 +1,129 @@
1
+ // 生态·集合站清单校验(CC1–CC5)
2
+ // 依据:工作区存量盘点(2026-09-07)——dsh-plugin-certification spec v1 注册表、
3
+ // adp-list 收录条目 schema、dsh-catalog 市场目录、omdsh Workshop v2、dsh-plugin-kit 三门。
4
+ import path from 'node:path'
5
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
6
+ import { runStep, pass, fail, warn, skip, tail, readJson, findFiles } from './util.mjs'
7
+
8
+ export const GROUP = '生态·集合站清单'
9
+
10
+ const CAT_IDS = ['agi', 'ui', 'usage', 'theme', 'model', 'identity', 'session', 'memory', 'tools', 'wsl', 'browser', 'vision', 'voice', 'docs', 'skill', 'workflow', 'git', 'notify', 'dev', 'security', 'remote', 'market', 'fun']
11
+ const OMSDSH_ACTIVATION = ['immediate', 'hot-reload', 'restart-plugin', 'restart-profile', 'restart-host']
12
+
13
+ function githubSlug(pkg) {
14
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url ?? ''
15
+ const m = String(repo).match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
16
+ return m ? { owner: m[1], repo: m[2], slug: `${m[1]}__${m[2]}` } : null
17
+ }
18
+
19
+ export function addChecks(doctor, ctx) {
20
+ const ws = ctx.workspaceRoot
21
+ const { pkg, pkgName, repoPath } = ctx
22
+ const g = githubSlug(pkg)
23
+
24
+ doctor.add(GROUP, 'CC1 认证注册表(dsh-plugin-certification spec v1)', () => {
25
+ const p = path.join(ws, 'dsh-plugin-certification', 'data', 'certified.json')
26
+ if (!existsSync(p)) return skip('认证仓本地不存在(--workspace 外运行无法核对)')
27
+ if (!g) return skip('无法从 repository 字段解析 GitHub owner/repo')
28
+ const data = readJson(p)
29
+ const hit = (data.entries ?? []).find((e) => String(e.repo).toLowerCase() === `${g.owner}/${g.repo}`.toLowerCase())
30
+ if (!hit) return skip('未收录于认证注册表(可选渠道)')
31
+ const problems = []
32
+ if (!hit.grade) problems.push('缺 grade')
33
+ if (!hit.snapshot) problems.push('缺 snapshot')
34
+ for (const d of ['manifest', 'buildHygiene', 'supplyChain', 'releaseIntegrity', 'installSmoke']) {
35
+ const dim = hit.dimensions?.[d]
36
+ if (!dim || !dim.evidence) problems.push(`维度 ${d} 缺 evidence`)
37
+ if (dim && d === 'installSmoke' && dim.result === 'install-fail') problems.push('installSmoke=install-fail(认证硬门失败级)')
38
+ }
39
+ if (hit.veto) problems.push(`存在 veto: ${JSON.stringify(hit.veto)}`)
40
+ if (problems.length) return warn(`已收录(grade ${hit.grade ?? '?'})但有缺口:\n${problems.join('\n')}`)
41
+ const smoke = hit.dimensions.installSmoke.result
42
+ return pass(`grade ${hit.grade} · snapshot ${hit.snapshot} · installSmoke=${smoke} · 五维 evidence 齐全、无 veto`)
43
+ })
44
+
45
+ doctor.add(GROUP, 'CC2 收录条目(adp-list awesome-dsh-plugin)', () => {
46
+ if (!g) return skip('无法解析 GitHub slug')
47
+ const dir = path.join(ws, 'adp-list', 'data', 'plugins')
48
+ if (!existsSync(dir)) return skip('adp-list 本地不存在')
49
+ const hits = readdirSync(dir).filter((f) => f === `${g.slug}.yml` || f.startsWith(`${g.slug}--`))
50
+ if (!hits.length) return skip('未收录于 adp-list(可选渠道)')
51
+ const problems = []
52
+ for (const f of hits) {
53
+ const text = readFileSync(path.join(dir, f), 'utf8')
54
+ const kv = {}
55
+ // 注意:本环境 `$` 不匹配行尾孤立 \r 之前,行必须按 /\r?\n/ 切分并先剥 \r
56
+ for (const line0 of text.split(/\r?\n/)) {
57
+ const line = line0.replace(/\r$/, '')
58
+ const m = line.match(/^\s*([A-Za-z0-9_.-]+):\s*([\s\S]*)$/)
59
+ if (m) kv[m[1]] = m[2].trim()
60
+ }
61
+ for (const k of ['url', 'name', 'category']) if (!(k in kv)) problems.push(`${f}: 缺 ${k}`)
62
+ if (kv.category && !CAT_IDS.includes(kv.category)) problems.push(`${f}: category=${kv.category} 不在 23 值枚举`)
63
+ if (kv.url && !kv.url.includes(`${g.owner}/${g.repo}`)) problems.push(`${f}: url 与 repository 不符(${kv.url})`)
64
+ if (!/description:/.test(text) || !/^\s*en:\s*\S+/m.test(text)) problems.push(`${f}: description.en 必填单行`)
65
+ if (kv.tarball) {
66
+ if (!/^https:\/\/(github\.com|objects\.githubusercontent\.com|release-assets\.githubusercontent\.com)\/.*\/releases\/.+\.tgz$/.test(kv.tarball)) problems.push(`${f}: tarball 必须 GitHub Release 托管 https .tgz`)
67
+ }
68
+ }
69
+ if (problems.length) return fail(problems.join('\n'))
70
+ return pass(`已收录 ${hits.length} 条(${hits.join(', ')}),字段/枚举/描述校验通过`)
71
+ })
72
+
73
+ doctor.add(GROUP, 'CC3 市场目录条目(dsh-catalog)', () => {
74
+ const p = path.join(ws, 'dsh-catalog', 'data', 'packages.json')
75
+ if (!existsSync(p)) return skip('dsh-catalog 本地不存在')
76
+ const list = readJson(p)
77
+ const hit = (Array.isArray(list) ? list : []).find((e) => e.npm === pkgName)
78
+ if (!hit) return skip('未收录于 DSH Desktop Market 目录(可选渠道)')
79
+ const problems = []
80
+ for (const k of ['npm', 'repo', 'displayName', 'categories', 'summary']) if (!(k in hit)) problems.push(`缺 ${k}`)
81
+ if (hit.repo && g && !String(hit.repo).includes(`${g.owner}/${g.repo}`)) problems.push('repo 字段与 repository 不符')
82
+ if (!Array.isArray(hit.categories) || !hit.categories.length) problems.push('categories 必须为非空数组')
83
+ if (typeof hit.summary === 'string' && hit.summary.length > 1000) problems.push('summary 超 v1 schema 1000 字符上限')
84
+ if (typeof hit.summary === 'string' && /dsh\s+plugin\s+--profile|pnpm\s+add|npm\s+i(nstall)?\s/.test(hit.summary)) problems.push('summary 含安装命令文本(v1 schema 禁止)')
85
+ if (typeof hit.summary === 'string' && !/[.!?)—–"」』]$/.test(hit.summary.trim())) problems.push('summary 疑被截断(未以句读符结尾)')
86
+ if (problems.length) return fail(problems.join('\n'))
87
+ return pass(`目录第 ${list.indexOf(hit) + 1} 条(displayName: ${hit.displayName})`)
88
+ })
89
+
90
+ doctor.add(GROUP, 'CC4 omdsh Workshop 清单(dshWorkshop)', () => {
91
+ const w = pkg.dshWorkshop
92
+ if (!w) return skip('未声明 dshWorkshop(omdsh 可选渠道;harness 不读此字段,激活只看 dsh.bundle)')
93
+ const problems = []
94
+ const act = w.lifecycle?.activation
95
+ if (!act) problems.push('缺 lifecycle.activation')
96
+ else if (!OMSDSH_ACTIVATION.includes(act)) problems.push(`activation=${act} 不在 5 值枚举(build-omdsh-submission.mjs 会 throw)`)
97
+ if (problems.length) return fail(problems.join('\n'))
98
+ return pass(`activation=${act} · restartRequired=${/^restart-/.test(act)}(hub intake 推导规则一致)`)
99
+ })
100
+
101
+ doctor.add(GROUP, 'CC5 插件三门(license/五语 README/seam 三角色)', () => {
102
+ const kitCli = path.join(ws, 'dsh-plugin-kit', 'lib', 'verify', 'cli.js')
103
+ if (existsSync(kitCli)) {
104
+ const r = runStep('cc5-kit', process.execPath, [kitCli, 'all', repoPath], { cwd: ws, logDir: ctx.logDir, timeout: 120_000, shell: false })
105
+ if (r.ok) return pass('dsh-plugin-kit 三门 CLI 全过')
106
+ const out = `${r.out}\n${r.err}`
107
+ const note = /no source files found under src/.test(out)
108
+ ? '\n注:纯 JS 仓无 src/ 目录,kit verify-seam 结构不适用(建议 kit 扩展或人工豁免)' : ''
109
+ if (r.code === 1) return fail(`dsh-plugin-kit 三门失败:\n${tail(out, 12)}${note}`)
110
+ return fail(`dsh-plugin-kit 三门执行异常(exit ${r.code}):\n${tail(r.err)}${note}`)
111
+ }
112
+ // 本地降级等价检查(kit lib 未构建时)
113
+ const problems = []
114
+ const hasLicenseFile = ['LICENSE', 'LICENSE.md', 'LICENSE.txt'].some((f) => existsSync(path.join(repoPath, f)))
115
+ if (!hasLicenseFile || !pkg.license) problems.push('缺 LICENSE 文件或 license 字段(kit 三门: error)')
116
+ else if (pkg.license !== 'Apache-2.0') problems.push(`license=${pkg.license}(kit 三门: warning,期望 Apache-2.0)`)
117
+ for (const lang of ['', '.zh', '.es', '.pt', '.hi']) {
118
+ const p = path.join(repoPath, `README${lang}.md`)
119
+ if (!existsSync(p)) problems.push(`缺 README${lang}.md`)
120
+ }
121
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|tsx|mjs|js)$/)
122
+ const all = srcFiles.map((f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }).join('\n')
123
+ for (const marker of ['Service Definition', 'Service Provider', 'Consumer']) {
124
+ if (!all.includes(marker)) problems.push(`src 缺 seam 三角色 marker: ${marker}`)
125
+ }
126
+ if (problems.length) return fail(`三门(本地降级检查)未过:\n${problems.join('\n')}`)
127
+ return pass('三门本地降级检查通过(kit CLI 未构建,跳过官方实现)')
128
+ })
129
+ }
@@ -0,0 +1,196 @@
1
+ // 静态·cordis 契约扫描(K1–K9,启发式)
2
+ // 依据:cordiverse/cordis v4 源码契约 + DSH 官方 cordis 文档(2026-09-07 调研):
3
+ // - apply 可同步,亦可 async 返回 Promise<disposer>;返回非法形状抛 TypeError('Invalid effect')
4
+ // - inject 硬依赖 = (keyof M)[] | { name?: interceptConfig };可选依赖走 ctx.get(name)
5
+ // - v4 删除 v3 的 fork/reusable/using/scope/runtime/lifecycle/config/collect/accept/decline/alias/off
6
+ // - ctx 活数据不可 JSON.stringify/structuredClone/展开
7
+ import path from 'node:path'
8
+ import { readFileSync, readdirSync } from 'node:fs'
9
+ import { pass, fail, warn, skip, findFiles } from './util.mjs'
10
+
11
+ export const GROUP = '静态·cordis 契约扫描'
12
+
13
+ // v4 Context 固有成员(访问无需 inject)。v3 已删除成员(scope/config/fork/...)故意不列入,
14
+ // 由 K7 精确追责。
15
+ const CTX_INTRINSIC = new Set([
16
+ 'get', 'on', 'once', 'effect', 'set', 'root', 'plugin', 'emit',
17
+ 'parallel', 'waterfall', 'bail', 'serial', 'logger', 'name', 'deps',
18
+ 'isolate', 'intercept', 'extend', 'accessor', 'mixin', 'provide',
19
+ 'reflect', 'registry', 'inject', 'fiber', 'filter', 'select',
20
+ ])
21
+ const KNOWN_SEAMS = new Set([
22
+ 'tools', 'llm', 'shell', 'jobs', 'commands', 'web', 'logger', 'config', 'storage',
23
+ 'session', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope',
24
+ 'approval', 'credentials', 'subagents', 'settings', 'systemPrompt',
25
+ ])
26
+
27
+ // 剥注释后再扫描(JSDoc 里的 ctx.xxx 引用是假阳性大户);(^|[^:]) 守卫避免伤及字符串内 ://
28
+ function stripComments(text) {
29
+ return text
30
+ .replace(/\/\*[\s\S]*?\*\//g, '')
31
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
32
+ }
33
+
34
+ function readAll(files) {
35
+ return files.map((f) => { try { return { f, text: stripComments(readFileSync(f, 'utf8')) } } catch { return { f, text: '' } } })
36
+ }
37
+
38
+ const short = (f) => f.split(/[\\/]/).pop()
39
+
40
+ // 源文件发现策略:src/** + 根目录一层 JS/TS;纯 JS 仓(无 build 脚本且 main 指向 lib/)的 lib 即源码
41
+ function collectSourceFiles(ctx) {
42
+ const { repoPath, pkg } = ctx
43
+ const files = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
44
+ for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
45
+ if (!entry.isFile()) continue
46
+ if (/\.(mjs|js|cjs|ts|mts|cts)$/.test(entry.name)) files.push(path.join(repoPath, entry.name))
47
+ }
48
+ if (!pkg.scripts?.build && String(pkg.main ?? '').startsWith('lib/')) {
49
+ files.push(...findFiles(repoPath, 'lib', /\.(mjs|js|cjs)$/))
50
+ }
51
+ return [...new Set(files)]
52
+ }
53
+
54
+ export function addChecks(doctor, ctx) {
55
+ const srcFiles = collectSourceFiles(ctx)
56
+
57
+ doctor.add(GROUP, 'K1 服务访问与 inject 声明一致', () => {
58
+ if (!srcFiles.length) return skip('无源文件可扫描')
59
+ const all = readAll(srcFiles)
60
+ // 全仓 inject 并集(多文件插件架构:inject 常集中声明在入口文件)
61
+ const injectList = new Set()
62
+ for (const { text } of all) {
63
+ const injectDecl = text.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
64
+ for (const s of injectDecl?.[1]?.match(/['"][^'"]*['"]/g) ?? []) injectList.add(s.slice(1, -1))
65
+ }
66
+ const candidates = new Map()
67
+ for (const { f, text } of all) {
68
+ for (const m of text.matchAll(/ctx\.([A-Za-z_$][\w$]*)/g)) {
69
+ const name = m[1]
70
+ if (CTX_INTRINSIC.has(name) || injectList.has(name)) continue
71
+ if (!candidates.has(name)) candidates.set(name, [])
72
+ candidates.get(name).push(f)
73
+ }
74
+ }
75
+ if (!candidates.size) return pass('未发现未声明的 ctx.<服务> 访问')
76
+ const lines = [...candidates.entries()].slice(0, 10)
77
+ .map(([n, fs]) => `ctx.${n}(${fs.length} 处,如 ${short(fs[0])})`)
78
+ return warn(
79
+ `以下服务被直接访问但未在 inject 声明(硬依赖必须 inject,否则运行时抛 "cannot get property ... without inject";可选依赖应改用 ctx.get()):\n${lines.join('\n')}`,
80
+ )
81
+ })
82
+
83
+ doctor.add(GROUP, 'K2 活数据序列化红线', () => {
84
+ if (!srcFiles.length) return skip('无源文件可扫描')
85
+ const hits = []
86
+ for (const { f, text } of readAll(srcFiles)) {
87
+ for (const m of text.matchAll(/JSON\.stringify\(\s*ctx\b|structuredClone\(\s*ctx\b|\{\s*\.\.\.ctx\b/g)) {
88
+ hits.push(`${short(f)}: ${m[0]}`)
89
+ }
90
+ }
91
+ if (hits.length) return fail(`检测到对 ctx 活数据的序列化/展开(Context 是 Proxy,序列化会丢 def/use-site 追踪或触发代理陷阱):\n${hits.slice(0, 5).join('\n')}`)
92
+ return pass('未发现 ctx 序列化/展开')
93
+ })
94
+
95
+ doctor.add(GROUP, 'K3 定时器/全局监听生命周期', () => {
96
+ if (!srcFiles.length) return skip('无源文件可扫描')
97
+ const hits = []
98
+ for (const { f, text } of readAll(srcFiles)) {
99
+ const lines = text.split('\n')
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i]
102
+ if (!/(setInterval|setTimeout|setImmediate|process\.on|addEventListener\()/.test(line)) continue
103
+ const before = lines.slice(Math.max(0, i - 3), i).join('\n')
104
+ if (!/ctx\.effect|ctx\.scope|ctx\.on\b/.test(before) && !/return\s*\(\)\s*=>/.test(before + line)) {
105
+ hits.push(`${short(f)}:${i + 1} ${line.trim()}`)
106
+ }
107
+ }
108
+ }
109
+ if (hits.length) return warn(`定时器/全局监听疑似未包装进 ctx.effect("Cordis API 之外的资源必须包在 ctx.effect 中"——教程 02;卸载后不回收,HMR 泄漏):\n${hits.slice(0, 5).join('\n')}`)
110
+ return pass('未发现裸定时器/全局监听')
111
+ })
112
+
113
+ doctor.add(GROUP, 'K4 Schema 含函数', () => {
114
+ if (!srcFiles.length) return skip('无源文件可扫描')
115
+ const hits = []
116
+ for (const { f, text } of readAll(srcFiles)) {
117
+ if (/Schema\./.test(text) && /=>/.test(text)) hits.push(short(f))
118
+ }
119
+ if (hits.length) return warn(`以下文件同时出现 Schema 与箭头函数(函数值进不了 schema:不可校验、不可持久化):\n${hits.slice(0, 5).join('\n')}`)
120
+ return pass('未发现 Schema 定义旁箭头函数')
121
+ })
122
+
123
+ doctor.add(GROUP, 'K5 apply 返回形状(v4 Effect 契约)', () => {
124
+ if (!srcFiles.length) return skip('无源文件可扫描')
125
+ const notes = []
126
+ for (const { f, text } of readAll(srcFiles)) {
127
+ if (/export\s+async\s+function\s+apply|apply\s*:\s*async\s*\(/.test(text)) {
128
+ notes.push(`${short(f)}: async apply(v4 允许,但必须返回 Promise<disposer>;settle 前 fiber 停留 LOADING、其服务对依赖方不可见)`)
129
+ }
130
+ }
131
+ if (notes.length) return warn(notes.join('\n') + '\nv4 合法返回值:函数 disposer / null / undefined / Promise<disposer> / (async) iterable;其余形状抛 TypeError("Invalid effect")')
132
+ return pass('apply 为同步声明')
133
+ })
134
+
135
+ doctor.add(GROUP, 'K6 inject 服务名属于已知 seam', () => {
136
+ if (!srcFiles.length) return skip('无源文件可扫描')
137
+ const unknown = new Set()
138
+ const provided = new Set()
139
+ for (const { text } of readAll(srcFiles)) {
140
+ const injectDecl = text.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
141
+ for (const s of injectDecl?.[1]?.match(/['"][^'"]*['"]/g) ?? []) {
142
+ const name = s.slice(1, -1)
143
+ if (!KNOWN_SEAMS.has(name) && !name.includes('.')) unknown.add(name)
144
+ }
145
+ // 插件自 provide 的服务不算"未知"(自建 capability seam)
146
+ for (const m of text.matchAll(/(?:ctx\.)?provide\s*\(\s*['"]([^'"]+)['"]/g)) provided.add(m[1])
147
+ }
148
+ for (const p of provided) unknown.delete(p)
149
+ if (unknown.size) return warn(`inject 引用未知服务名(请确认由宿主/其它插件提供,否则 fiber 永久 PENDING、apply 永不执行): ${[...unknown].join(', ')}`)
150
+ return pass('inject 服务名均在已知 seam 内或为自 provide 能力')
151
+ })
152
+
153
+ doctor.add(GROUP, 'K7 v3 遗留 API(3.x→4.x 已删除)', () => {
154
+ if (!srcFiles.length) return skip('无源文件可扫描')
155
+ const hard = []
156
+ const soft = []
157
+ for (const { f, text } of readAll(srcFiles)) {
158
+ for (const m of text.matchAll(/ctx\.(using|scope|runtime|lifecycle|collect|accept|decline|alias|off|fork)\b/g)) {
159
+ hard.push(`${short(f)}: ctx.${m[1]}(v3 API,v4 已删除)`)
160
+ }
161
+ if (/ctx\.config\b/.test(text)) hard.push(`${short(f)}: ctx.config(v4 改为 ctx.fiber.config)`)
162
+ if (/ctx\.(start|stop)\(/.test(text)) hard.push(`${short(f)}: ctx.start()/stop()(v4 改为 fiber.await()/dispose()/update())`)
163
+ if (/inject\s*:\s*\{[^}]*\b(required|optional)\b|\binject\s*\.\s*(required|optional)/.test(text)) hard.push(`${short(f)}: inject {required,optional} 形状(v3;v4 为 string[] 或 {name: interceptConfig},可选依赖用 ctx.get)`)
164
+ if (/\b(reusable|reactive)\s*:/.test(text)) soft.push(`${short(f)}: reusable/reactive 元数据(v4 已删除 fork 机制,该字段被忽略)`)
165
+ if (/\busing\s*:/.test(text)) soft.push(`${short(f)}: using 元数据(v3 的 inject 别名)`)
166
+ if (/static\s+immediate|protected\s+(start|stop|fork)\s*\(/.test(text)) soft.push(`${short(f)}: v3 Service 写法(static immediate/protected start/stop)`)
167
+ if (/return\s*\{\s*dispose\s*[:(]/.test(text)) soft.push(`${short(f)}: 返回 {dispose} 对象(v3 DisposableLike,v4 抛 Invalid effect,须返回函数 disposer)`)
168
+ }
169
+ if (hard.length) return fail([...new Set(hard)].slice(0, 8).join('\n'))
170
+ if (soft.length) return warn([...new Set(soft)].slice(0, 8).join('\n'))
171
+ return pass('未发现 v3 遗留 API')
172
+ })
173
+
174
+ doctor.add(GROUP, 'K8 Config 必须是 Standard Schema 校验器', () => {
175
+ if (!srcFiles.length) return skip('无源文件可扫描')
176
+ const hits = []
177
+ for (const { f, text } of readAll(srcFiles)) {
178
+ const m = text.match(/export\s+const\s+Config\s*=\s*([^\n]*)/)
179
+ if (m && !/Schema\./.test(m[1]) && !/interface|type\s/.test(text.match(/export\s+(?:interface|type)\s+Config/)?.[0] ?? '')) {
180
+ if (m[1].trim().startsWith('{')) hits.push(`${short(f)}: export const Config = {普通对象}("将普通对象导出为 Config 无法工作"——教程 05;应导出 schemastery Schema)`)
181
+ }
182
+ }
183
+ if (hits.length) return fail(hits.join('\n'))
184
+ return pass('Config 声明为 Schema 校验器或纯类型')
185
+ })
186
+
187
+ doctor.add(GROUP, 'K9 插件 name 特例', () => {
188
+ if (!srcFiles.length) return skip('无源文件可扫描')
189
+ const hits = []
190
+ for (const { f, text } of readAll(srcFiles)) {
191
+ if (/name\s*:\s*['"]apply['"]/.test(text)) hits.push(short(f))
192
+ }
193
+ if (hits.length) return warn(`对象插件 name === 'apply' 会被框架重置为 undefined(registry.ts 特例),诊断名丢失:\n${hits.join('\n')}`)
194
+ return pass('无 name="apply" 特例')
195
+ })
196
+ }
@@ -0,0 +1,175 @@
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
+ const problems = []
71
+ if (!/-\s+insert\s*:/.test(text)) problems.push('未找到 "- insert:" 结构')
72
+ const ids = [...text.matchAll(/(?:^|\n)\s*-?\s*id:\s*["']?([^"'\s]+)/g)].map((m) => m[1])
73
+ if (!ids.length) problems.push('未找到 id 行(行 id 必须存在,供上层整行替换定位)')
74
+ const names = [...text.matchAll(/(?:^|\n)\s*name:\s*["']?([^"'\s,]+)|[,\s]name:\s*["']?([^"'\s,]+)/g)]
75
+ .map((m) => m[1] ?? m[2]).filter(Boolean)
76
+ const badNames = names.filter((n) => n !== pkgName && !n.startsWith(`${pkgName}/`))
77
+ if (names.length && badNames.length) problems.push(`行 name 与包名不一致: ${[...new Set(badNames)].join(', ')}(name 必须经 profile node_modules 解析,应为包名)`)
78
+ if (problems.length) return fail(problems.join('\n'))
79
+ return pass(`解析到 insert 结构、${ids.length} 个 id、name 与包名一致(启发式;以 D2 --dump-config 为准)`)
80
+ })
81
+
82
+ doctor.add(GROUP, 'R4 入口契约(name/apply 导出 + inject 字面量)', () => {
83
+ const entry = normalizeEntry(pkg)
84
+ const entryAbs = path.resolve(repoPath, entry)
85
+ if (!existsSync(entryAbs)) return fail(`入口文件 ${entry} 不存在(需先 pnpm run build)`)
86
+ const src = readFileSync(entryAbs, 'utf8')
87
+ const hasApply = /exports\s*\.\s*apply|module\.exports\s*=\s*\{[\s\S]{0,400}\bapply\b|export\s+(?:const|function)\s+apply/.test(src)
88
+ const hasName = /exports\s*\.\s*name|module\.exports\s*=\s*\{[\s\S]{0,400}\bname\b|export\s+const\s+name/.test(src)
89
+ const problems = []
90
+ if (!hasApply) problems.push('入口未检出 apply 导出')
91
+ if (!hasName) problems.push('入口未检出 name 导出')
92
+ // TS 源兜底(入口是编译产物时)
93
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
94
+ const srcText = srcFiles.map((f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }).join('\n')
95
+ if (!hasApply && /export\s+(?:const|function)\s+apply|apply\s*\(ctx/.test(srcText)) {
96
+ problems.splice(problems.indexOf('入口未检出 apply 导出'), 1)
97
+ }
98
+ if (!hasName && /export\s+const\s+name\b/.test(srcText)) {
99
+ problems.splice(problems.indexOf('入口未检出 name 导出'), 1)
100
+ }
101
+ const injectDecl = srcText.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
102
+ if (injectDecl) {
103
+ const inner = injectDecl[1].trim()
104
+ const junk = inner.replace(/['"][^'"]*['"]/g, '').replace(/[\s,]/g, '')
105
+ if (junk) problems.push(`inject 数组含非字符串字面量: [${inner}]`)
106
+ }
107
+ if (problems.length) return warn(problems.join('\n') + '\n(启发式扫描;若为 default 对象/class 形式请人工确认)')
108
+ return pass(`入口 ${entry} 检出 name/apply 导出${injectDecl ? ',inject 为字符串数组' : ''}`)
109
+ })
110
+
111
+ doctor.add(GROUP, 'R5 依赖口径(rescope cordis / 禁裸上游名)', () => {
112
+ const problems = []
113
+ for (const field of ['dependencies', 'peerDependencies', 'devDependencies']) {
114
+ const deps = pkg[field] ?? {}
115
+ if ('cordis' in deps) problems.push(`${field} 出现裸 cordis@${deps.cordis}(应使用 rescope @deepseek-ai/cordis)`)
116
+ if ('schemastery' in deps) problems.push(`${field} 出现裸 schemastery@${deps.schemastery}(应使用 @deepseek-ai/schemastery)`)
117
+ }
118
+ const peer = pkg.peerDependencies?.['@deepseek-ai/cordis']
119
+ const dep = pkg.dependencies?.['@deepseek-ai/cordis']
120
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
121
+ const importsCordis = srcFiles.some((f) => { try { return /from\s+['"]@deepseek-ai\/cordis['"]|require\(['"]@deepseek-ai\/cordis['"]\)/.test(readFileSync(f, 'utf8')) } catch { return false } })
122
+ if (!peer && !dep) {
123
+ if (importsCordis) problems.push('源码 import @deepseek-ai/cordis 但未声明任何依赖')
124
+ else problems.push('未声明 @deepseek-ai/cordis(若源码不 import 可忽略)')
125
+ } else if (!peer) {
126
+ problems.push('@deepseek-ai/cordis 只在 dependencies(官方口径:peerDependencies + devDependencies 同时声明)')
127
+ }
128
+ if (pkg.dependencies?.['@deepseek-ai/dsh']) problems.push('dependencies 直接依赖 @deepseek-ai/dsh(宿主已提供,应走 peer/dev 口径)')
129
+ if (problems.length) return fail(problems.join('\n'))
130
+ return pass(`cordis 口径正确(${peer ?? dep})`)
131
+ })
132
+
133
+ doctor.add(GROUP, 'R6 Node 引擎声明', () => {
134
+ const e = pkg.engines?.node
135
+ if (!e) return warn('未声明 engines.node(建议 "^22.19.0 || >=24.0.0";npm 线宿主不强制,属建议门)')
136
+ const only23 = /23/.test(e) && !/22/.test(e) && !/24/.test(e) && !/>=\s*2[5-9]/.test(e)
137
+ if (only23) return fail(`engines.node=${e} 宣称 Node 23(官方整线排除 23,要求 ^22.19.0 || >=24.0.0)`)
138
+ if (/22\.19/.test(e) && /24/.test(e)) return pass(`engines.node=${e} 与官方 ^22.19.0 || >=24.0.0 一致`)
139
+ return warn(`engines.node=${e} 与官方 ^22.19.0 || >=24.0.0 的相交性请人工确认`)
140
+ })
141
+
142
+ doctor.add(GROUP, 'R7 预构建与 files 覆盖', () => {
143
+ const entry = normalizeEntry(pkg)
144
+ const problems = []
145
+ if (entry.includes('src/')) problems.push(`main 指向源码 ${entry}(npm 发布必须预构建,main 应指向 lib/ 产物)`)
146
+ const filesField = pkg.files
147
+ if (Array.isArray(filesField) && filesField.length) {
148
+ const covered = (f) => filesField.some((item) => {
149
+ const base = item.replace(/\/$/, '')
150
+ return f === item || f.startsWith(`${base}/`)
151
+ })
152
+ if (!covered(entry)) problems.push(`files 白名单未覆盖入口 ${entry}`)
153
+ const patch = stripDot(pkg.dsh?.bundle?.patch ?? '')
154
+ if (patch && !covered(patch)) problems.push('files 白名单未覆盖 cordis.patch.yml')
155
+ } else {
156
+ problems.push('未声明 files 白名单')
157
+ }
158
+ const note = pkg.scripts?.prepare ? '(含 prepare 脚本,支持 git 直装自构建)' : '(无 prepare,git 直装不可用,npm/tarball 不受影响)'
159
+ if (problems.length) return fail(problems.join('\n') + '\n' + note)
160
+ return pass(`main 指向构建产物且 files 覆盖入口与 patch ${note}`)
161
+ })
162
+
163
+ doctor.add(GROUP, 'R8 peer 范围旧 rc 残留(双基线教训)', () => {
164
+ const peers = Object.entries(pkg.peerDependencies ?? {})
165
+ .filter(([k]) => k.startsWith('@deepseek-ai/dsh') || k === 'cordis' || k.startsWith('@deepseek-ai/cordis'))
166
+ if (!peers.length) return skip('无 dsh 相关 peer 声明')
167
+ const problems = []
168
+ for (const [k, v] of peers) {
169
+ if (/(0\.1\.0-rc\.8|0\.1\.1-rc\.2)/.test(String(v))) problems.push(`${k}: ${v} 含旧 rc 版本(2026-09-05 起全仓统一为 >=0.1.2-rc.1 <0.2.0)`)
170
+ if (/^>=\s*0\.1\.0-rc\.\d+\s*<\s*0\.2\.0/.test(String(v))) problems.push(`${k}: ${v} 是旧 prerelease-tuple 区间(裸解析只匹配 0.1.0-rc.8 单行)`)
171
+ }
172
+ if (problems.length) return fail(problems.join('\n'))
173
+ return pass(peers.map(([k, v]) => `${k} ${v}`).join(';'))
174
+ })
175
+ }
@@ -0,0 +1,93 @@
1
+ // 动态·沙箱冒烟(D0–D3 + 清场)
2
+ // 依据:dsh compat.yml 正典配方(MISSING_CREDENTIAL 判据)+ 2026-09-07 harness 调研 +
3
+ // 工作区红线 3(一切测试全沙箱:%TEMP% mkdtemp DSH_HOME)
4
+ import path from 'node:path'
5
+ import { makeSandbox, cleanSandbox, runStep, pass, fail, skip, tail, readJson, writeJson, exists } from './util.mjs'
6
+
7
+ export const GROUP = '动态·沙箱冒烟'
8
+
9
+ export function addSmokeChecks(doctor, ctx, opts = {}) {
10
+ const { repoPath, pkgName, logDir } = ctx
11
+ const dshVersion = opts.dshVersion ?? '0.1.2-rc.1'
12
+ const state = {}
13
+
14
+ doctor.add(GROUP, 'D0 打包 tarball(npm pack)', () => {
15
+ state.sb = makeSandbox('smoke')
16
+ const r = runStep('d0-pack', 'npm', ['pack', '--json', '--ignore-scripts'], { cwd: repoPath, logDir, timeout: 300_000 })
17
+ if (!r.ok) return fail(`npm pack 失败(exit ${r.code}):\n${tail(r.err)}`)
18
+ let pack
19
+ try {
20
+ const json = r.out.replace(/^\uFEFF/, '').trim()
21
+ pack = JSON.parse(json.slice(json.indexOf('['), json.lastIndexOf(']') + 1))[0]
22
+ } catch {
23
+ return fail('npm pack --json 解析失败')
24
+ }
25
+ state.tgz = path.join(repoPath, pack.filename)
26
+ if (!exists(state.tgz)) return fail(`tarball 未生成: ${state.tgz}`)
27
+ return pass(`已打包 ${pack.filename}(${pack.files?.length ?? '?'} 文件)`)
28
+ })
29
+
30
+ doctor.add(GROUP, 'D1 安装冒烟(plugin add + bundles 断言)', () => {
31
+ if (!state.tgz) return skip('D0 未通过')
32
+ writeJson(path.join(state.sb.root, 'package.json'), { name: 'dsh-doctor-runtime', version: '0.0.0', private: true })
33
+ const i = runStep('d1-install', 'npm', ['install', '--no-audit', '--no-fund', '--loglevel=error', `@deepseek-ai/dsh@${dshVersion}`], {
34
+ cwd: state.sb.root, logDir, timeout: 900_000,
35
+ })
36
+ if (!i.ok) return fail(`安装 @deepseek-ai/dsh@${dshVersion} 失败(exit ${i.code}):\n${tail(i.err)}`)
37
+ const hostPkgPath = path.join(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
38
+ if (!exists(hostPkgPath)) return fail('@deepseek-ai/dsh 未安装成功')
39
+ const host = readJson(hostPkgPath)
40
+ const rel = host.bin?.dsh
41
+ const bin = rel ? path.resolve(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', rel) : null
42
+ if (!bin || !exists(bin)) return fail(`未找到 dsh bin(package.json bin=${JSON.stringify(host.bin)})`)
43
+ state.bin = bin
44
+ const dsh = (label, args, o = {}) => runStep(label, process.execPath, [bin, ...args], {
45
+ env: { DSH_HOME: state.sb.home, DSH_AGENTS_HOME: path.join(state.sb.home, '.agents'), ...(o.env ?? {}) },
46
+ cwd: state.sb.root, logDir, timeout: o.timeout ?? 120_000, shell: false,
47
+ })
48
+ state.dsh = dsh
49
+ const a = dsh('d1-add', ['plugin', '--profile', 'headless', 'add', state.tgz], { timeout: 900_000 })
50
+ const envBlock = /ERR_PNPM_IGNORED_BUILDS|approve-builds|ignored builds/i.test(a.err)
51
+ if (!a.ok) {
52
+ if (envBlock) {
53
+ return warn(`pnpm approve-builds/ignored-builds 环境门阻断(属环境配方问题,非插件缺陷,参照 compat.yml allowBuilds 配方):\n${tail(a.err)}`)
54
+ }
55
+ return fail(`plugin add 失败(exit ${a.code}):\n${tail(a.err)}`)
56
+ }
57
+ if (/declares no dsh\.bundle|not activated/i.test(a.err)) return fail('宿主 stderr 出现未激活警告(dsh.bundle 声明未被识别)')
58
+ const profilePkgPath = path.join(state.sb.home, 'profiles', 'headless', 'package.json')
59
+ if (!exists(profilePkgPath)) return fail('profile package.json 未生成')
60
+ const profilePkg = readJson(profilePkgPath)
61
+ const bundles = profilePkg?.dsh?.profile?.bundles ?? []
62
+ if (!bundles.includes(pkgName)) return fail(`dsh.profile.bundles 不含 ${pkgName}(实际: ${JSON.stringify(bundles)})`)
63
+ return pass(`已加入 dsh.profile.bundles: ${bundles.join(', ')}`)
64
+ })
65
+
66
+ doctor.add(GROUP, 'D2 层验证(--dump-config,不 boot)', () => {
67
+ if (!state.dsh) return skip('D1 未通过')
68
+ const r = state.dsh('d2-dump', ['--profile', 'headless', '--dump-config'], { timeout: 120_000 })
69
+ if (!r.ok) return fail(`--dump-config 失败(exit ${r.code}):\n${tail(r.err)}`)
70
+ if (!r.out.includes(`# == ${pkgName}`)) return fail(`dump-config 未见层标记 "# == ${pkgName}"`)
71
+ return pass(`层标记 "# == ${pkgName}" 出现(patch 已进入组合层)`)
72
+ })
73
+
74
+ doctor.add(GROUP, 'D3 keyless headless 冒烟(MISSING_CREDENTIAL 判据)', () => {
75
+ if (!state.dsh) return skip('D1 未通过')
76
+ const r = state.dsh('d3-run', ['--profile', 'headless', 'Reply with exactly: ok'], { timeout: 90_000 })
77
+ const combined = `${r.out}\n${r.err}`
78
+ if (r.signal || r.spawnError) return fail(`进程异常终止(signal=${r.signal}${r.spawnError ? ' ' + r.spawnError : ''})`)
79
+ const bad = combined.match(/.*(NO_ADAPTER|ERR_MODULE_NOT_FOUND|SyntaxError|TypeError|ReferenceError|Cannot find module).*/g)
80
+ if (bad) return fail(`组合未 boot 到请求阶段,出现致命错误:\n${bad.slice(0, 5).join('\n')}`)
81
+ if (r.code === 1 && /dsh:\s*MISSING_CREDENTIAL/.test(combined)) {
82
+ return pass('exit 1 + dsh: MISSING_CREDENTIAL —— bundle 层生效、插件 apply 成功、组合到达模型请求阶段(严格匹配 code,防正则假阳性)')
83
+ }
84
+ if (r.code === 0) return pass('exit 0(环境中存在凭据,完整跑通)')
85
+ return fail(`期望 exit 1 + MISSING_CREDENTIAL,实际 exit ${r.code}:\n${tail(combined)}`)
86
+ })
87
+
88
+ doctor.add(GROUP, 'D9 沙箱清理', () => {
89
+ const wasRun = !!state.sb
90
+ cleanSandbox(state.sb)
91
+ return pass(wasRun ? '临时 DSH_HOME 沙箱已删除(%TEMP% mkdtemp)' : '未创建沙箱')
92
+ })
93
+ }