@tokensapi/dsh-plugin-check 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -95
- package/bin/dsh-plugin-check.mjs +26 -12
- package/docs/CHECKS.md +335 -0
- package/lib/client-check.mjs +98 -0
- package/lib/contract.mjs +210 -0
- package/lib/cowork-plugin.mjs +118 -113
- package/lib/manifest-check.mjs +105 -69
- package/lib/patch-rows.mjs +126 -31
- package/lib/registry-check.mjs +19 -146
- package/lib/semver-rules.mjs +129 -0
- package/lib/smoke-check.mjs +13 -1
- package/lib/tarball.mjs +5 -1
- package/package.json +45 -44
package/lib/manifest-check.mjs
CHANGED
|
@@ -4,26 +4,112 @@
|
|
|
4
4
|
* 每一条规则都对应一种已经真实发生过的"把宿主搞崩/搞坏"路径,
|
|
5
5
|
* 规则说明写在 README;error 挡上架,warning 提示即将收紧或
|
|
6
6
|
* 潜在错配。判定为纯函数,便于单测与市场门禁复用。
|
|
7
|
+
*
|
|
8
|
+
* 与会话内体检(registry-check.mjs)的关系:凡是只看清单就能判的
|
|
9
|
+
* 规则,两边**必须**调同一份实现(semver-rules / client-check /
|
|
10
|
+
* contract 里的谓词),否则同一个包在两条路径上会得出不同结论。
|
|
11
|
+
* tests/cross-path.test.mjs 是这条约束的闸门。
|
|
7
12
|
* ============================================================ */
|
|
8
13
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
9
14
|
import { join, resolve } from 'node:path'
|
|
10
15
|
import semver from 'semver'
|
|
11
16
|
import { checkPatchContent } from './patch-rows.mjs'
|
|
17
|
+
import { checkVersionRules } from './semver-rules.mjs'
|
|
18
|
+
import { checkClientDeclaration } from './client-check.mjs'
|
|
19
|
+
import {
|
|
20
|
+
BLOCKED_PACKAGE_NAMES,
|
|
21
|
+
HOST_NODE_ENGINES,
|
|
22
|
+
LIFECYCLE_SCRIPTS,
|
|
23
|
+
bundlePatchProblem,
|
|
24
|
+
isIdentityCore,
|
|
25
|
+
lifecycleMessage,
|
|
26
|
+
safePackageName,
|
|
27
|
+
} from './contract.mjs'
|
|
12
28
|
|
|
13
|
-
|
|
14
|
-
const FORBIDDEN_LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare']
|
|
29
|
+
const SUSPICIOUS_FILE = /(\.env(\.|$)|id_rsa|\.pem$|\.p12$|\.key$)/u
|
|
15
30
|
|
|
16
31
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
32
|
+
* 只看清单就能判的规则,两条执行路径共用这一份。
|
|
33
|
+
* @param {object} manifest - package.json / registry 版本文档。
|
|
34
|
+
* @param {string | undefined} runtime - 目标 DSH 运行时版本。
|
|
35
|
+
* @returns {Array<{level: string, rule: string, message: string}>}
|
|
20
36
|
*/
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
export function checkManifestFields(manifest, runtime) {
|
|
38
|
+
const findings = []
|
|
39
|
+
const error = (rule, message) => findings.push({ level: 'error', rule, message })
|
|
40
|
+
const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
|
|
25
41
|
|
|
26
|
-
|
|
42
|
+
// M1 / M6 / M7
|
|
43
|
+
findings.push(...checkVersionRules(manifest, runtime))
|
|
44
|
+
|
|
45
|
+
// M1:包名形状。上游市场验证器在拿到清单之前就用这个谓词拒绝,
|
|
46
|
+
// 名字不合规的包无论内容多干净都装不进去。
|
|
47
|
+
if (typeof manifest.name === 'string' && manifest.name !== '') {
|
|
48
|
+
if (!safePackageName(manifest.name)) {
|
|
49
|
+
error('M1-manifest', `name "${manifest.name}" 不满足市场的包名约束`
|
|
50
|
+
+ `(小写字母/数字开头,只含 a-z 0-9 . _ -,可带一层 @scope,总长 ≤214);`
|
|
51
|
+
+ `不满足时受控安装会在验证阶段直接拒绝`)
|
|
52
|
+
}
|
|
53
|
+
if (BLOCKED_PACKAGE_NAMES.has(manifest.name)) {
|
|
54
|
+
error('M1-manifest', `name "${manifest.name}" 是宿主/市场自身的包名,被上游列入黑名单,永远装不进市场`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// M2
|
|
59
|
+
const scripts = manifest.scripts ?? {}
|
|
60
|
+
for (const script of LIFECYCLE_SCRIPTS) {
|
|
61
|
+
if (typeof scripts[script] === 'string') warning('M2-lifecycle', lifecycleMessage(script))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// M3
|
|
65
|
+
for (const name of Object.keys(manifest.dependencies ?? {})) {
|
|
66
|
+
if (isIdentityCore(name)) {
|
|
67
|
+
error('M3-core-peer', `${name} 出现在 dependencies;核心运行时必须声明为 peerDependencies,`
|
|
68
|
+
+ `否则宿主与插件各持一份实例,服务注册无法对上,典型症状是整个 Cowork 启动失败`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// M4:声明本身(补丁文件内容由调用方按各自的取文件方式补上)
|
|
73
|
+
const patch = manifest.dsh?.bundle?.patch
|
|
74
|
+
if (typeof patch !== 'string' || patch === '') {
|
|
75
|
+
error('M4-bundle', 'dsh.bundle.patch 缺失;宿主靠它把插件挂进加载树')
|
|
76
|
+
} else {
|
|
77
|
+
const problem = bundlePatchProblem(patch)
|
|
78
|
+
if (problem !== undefined) {
|
|
79
|
+
error('M4-bundle', `dsh.bundle.patch "${patch}" ${problem};`
|
|
80
|
+
+ `市场受控安装的验证器(上游 safeBundlePatch)会直接拒绝这个包,`
|
|
81
|
+
+ `用户端永远看不到一键安装按钮,只会看到手动安装命令`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// M8
|
|
86
|
+
const nodeEngine = manifest.engines?.node
|
|
87
|
+
if (nodeEngine === undefined) {
|
|
88
|
+
warning('M8-node-engines', 'engines.node 未声明;宿主运行在 Node 22+')
|
|
89
|
+
} else if (typeof nodeEngine !== 'string' || semver.validRange(nodeEngine) === null) {
|
|
90
|
+
warning('M8-node-engines', `engines.node "${nodeEngine}" 不是合法 SemVer 范围`)
|
|
91
|
+
} else if (semver.validRange(HOST_NODE_ENGINES) !== null
|
|
92
|
+
&& !semver.intersects(nodeEngine, HOST_NODE_ENGINES, { includePrerelease: true })) {
|
|
93
|
+
warning('M8-node-engines', `engines.node "${nodeEngine}" 与宿主的 "${HOST_NODE_ENGINES}" 无交集;`
|
|
94
|
+
+ `包管理器会在安装阶段报 unsupported engine`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// M9
|
|
98
|
+
if (manifest.license === undefined) warning('M9-license', 'license 未声明')
|
|
99
|
+
|
|
100
|
+
// M12
|
|
101
|
+
findings.push(...checkClientDeclaration(manifest))
|
|
102
|
+
|
|
103
|
+
// 宿主/Profile 侧字段被插件误抄
|
|
104
|
+
for (const field of ['profile', 'moduleFallback']) {
|
|
105
|
+
if (manifest.dsh?.[field] !== undefined) {
|
|
106
|
+
warning('M1-manifest', `dsh.${field} 是宿主/Profile 侧字段,插件包声明它没有作用,`
|
|
107
|
+
+ `多半是从 Profile 或宿主的 package.json 误抄过来的`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return findings
|
|
112
|
+
}
|
|
27
113
|
|
|
28
114
|
/**
|
|
29
115
|
* 对一个插件目录执行全部清单规则。
|
|
@@ -50,40 +136,17 @@ export function checkManifest(pluginDir, options = {}) {
|
|
|
50
136
|
return { findings }
|
|
51
137
|
}
|
|
52
138
|
|
|
53
|
-
|
|
54
|
-
error('M1-manifest', 'name 缺失')
|
|
55
|
-
}
|
|
56
|
-
if (typeof manifest.version !== 'string' || semver.valid(manifest.version) === null) {
|
|
57
|
-
error('M1-manifest', 'version 不是合法 SemVer')
|
|
58
|
-
} else if (semver.prerelease(manifest.version) !== null) {
|
|
59
|
-
warning('M1-manifest', `version ${manifest.version} 是预发布版;市场上架要求精确稳定版`)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const scripts = manifest.scripts ?? {}
|
|
63
|
-
for (const script of FORBIDDEN_LIFECYCLE) {
|
|
64
|
-
if (typeof scripts[script] === 'string') {
|
|
65
|
-
error('M2-lifecycle', `禁止 ${script} 生命周期脚本(安装即执行任意代码,市场受控安装会拒绝);构建请改用 prepack`)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const dependencies = manifest.dependencies ?? {}
|
|
70
|
-
for (const name of Object.keys(dependencies)) {
|
|
71
|
-
if (isIdentityCore(name)) {
|
|
72
|
-
error('M3-core-peer', `${name} 出现在 dependencies;核心运行时必须声明为 peerDependencies,否则宿主与插件各持一份实例,服务注册无法对上,典型症状是整个 Cowork 启动失败`)
|
|
73
|
-
}
|
|
74
|
-
}
|
|
139
|
+
findings.push(...checkManifestFields(manifest, options.runtime))
|
|
75
140
|
|
|
76
|
-
|
|
77
|
-
const
|
|
141
|
+
// M4/M5/M11 的补丁内容部分:目录路径直接读盘。
|
|
142
|
+
const patch = manifest.dsh?.bundle?.patch
|
|
78
143
|
let rows
|
|
79
|
-
if (typeof
|
|
80
|
-
|
|
81
|
-
} else {
|
|
82
|
-
const patchPath = resolve(pluginDir, bundle.patch)
|
|
144
|
+
if (typeof patch === 'string' && patch !== '') {
|
|
145
|
+
const patchPath = resolve(pluginDir, patch)
|
|
83
146
|
if (!existsSync(patchPath)) {
|
|
84
|
-
error('M4-bundle', `dsh.bundle.patch 指向的文件不存在: ${
|
|
147
|
+
error('M4-bundle', `dsh.bundle.patch 指向的文件不存在: ${patch}`)
|
|
85
148
|
} else {
|
|
86
|
-
//
|
|
149
|
+
// 与会话内体检共用同一份实现,避免两条路径判定漂移。
|
|
87
150
|
const checked = checkPatchContent(
|
|
88
151
|
typeof manifest.name === 'string' ? manifest.name : '',
|
|
89
152
|
readFileSync(patchPath, 'utf8'),
|
|
@@ -93,35 +156,8 @@ export function checkManifest(pluginDir, options = {}) {
|
|
|
93
156
|
}
|
|
94
157
|
}
|
|
95
158
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
warning('M6-engine', 'dsh.engine 未声明(目标 DSH 运行时 SemVer 范围,如 ">=0.1.3-alpha.1 <0.2");该字段将成为上架必填')
|
|
99
|
-
} else if (semver.validRange(engine, { includePrerelease: true }) === null) {
|
|
100
|
-
error('M6-engine', `dsh.engine 不是合法 SemVer 范围: ${engine}`)
|
|
101
|
-
} else if (options.runtime !== undefined
|
|
102
|
-
&& !semver.satisfies(options.runtime, engine, { includePrerelease: true })) {
|
|
103
|
-
error('M6-engine', `dsh.engine "${engine}" 不覆盖目标运行时 ${options.runtime}`)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const peers = manifest.peerDependencies ?? {}
|
|
107
|
-
if (options.runtime !== undefined) {
|
|
108
|
-
for (const [name, range] of Object.entries(peers)) {
|
|
109
|
-
if (!name.startsWith(`${CORE_SCOPE}dsh`)) continue
|
|
110
|
-
if (semver.validRange(range, { includePrerelease: true }) === null) {
|
|
111
|
-
error('M7-peer-range', `peer ${name} 的范围不合法: ${range}`)
|
|
112
|
-
} else if (!semver.satisfies(options.runtime, range, { includePrerelease: true })) {
|
|
113
|
-
warning('M7-peer-range', `peer ${name}@"${range}" 不含目标运行时 ${options.runtime};宿主升级后此插件可能在运行时断裂(接口错配类故障)`)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (manifest.engines?.node === undefined) {
|
|
119
|
-
warning('M8-node-engines', 'engines.node 未声明;宿主运行在 Node 22+')
|
|
120
|
-
}
|
|
121
|
-
if (manifest.license === undefined) {
|
|
122
|
-
warning('M9-license', 'license 未声明')
|
|
123
|
-
}
|
|
124
|
-
|
|
159
|
+
// M10:依赖文件系统,只在目录路径可判(registry 路径没有工作区,
|
|
160
|
+
// 这是有意的差异,不是漏 —— 见 docs/CHECKS.md §1)。
|
|
125
161
|
const filesList = Array.isArray(manifest.files) ? manifest.files : []
|
|
126
162
|
for (const entry of filesList) {
|
|
127
163
|
if (typeof entry === 'string' && SUSPICIOUS_FILE.test(entry)) {
|
package/lib/patch-rows.mjs
CHANGED
|
@@ -7,23 +7,51 @@
|
|
|
7
7
|
* 不可静态求值(冒烟时跳过该行的 config)。
|
|
8
8
|
* ============================================================ */
|
|
9
9
|
import yaml from 'js-yaml'
|
|
10
|
+
import { RESERVED_ROW_IDS, RESERVED_ROW_NAMES } from './contract.mjs'
|
|
10
11
|
|
|
11
12
|
const JS_EXPR_PLACEHOLDER = Symbol('dsh-plugin-check:js-expr')
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 宽容标签前缀。
|
|
16
|
+
* js-yaml 的 multi type 查找是**对解析后的完整标签串做前缀匹配**,
|
|
17
|
+
* 所以这里必须写解析后的形式,而不是源码里的写法:
|
|
18
|
+
* - `!foo` → `!foo` (前缀 '!')
|
|
19
|
+
* - `!!js` → `tag:yaml.org,2002:js` (前缀 'tag:yaml.org,2002:js')
|
|
20
|
+
* - `!!js/function` → `tag:yaml.org,2002:js/function`(同一前缀覆盖)
|
|
21
|
+
*
|
|
22
|
+
* 只注册 '!' 曾经漏掉整个 `!!js` 家族 —— 而 `!!js` 恰恰是宿主唯一的
|
|
23
|
+
* 动态配置写法(标签定义见 deepseek-harness/scripts/cordis-yaml.ts:
|
|
24
|
+
* `new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar' })`),宿主
|
|
25
|
+
* 自带的 6 份 cordis.patch.yml 全部在用。于是"照抄宿主惯用法的插件"
|
|
26
|
+
* 被判成补丁不是合法 YAML → M4-bundle error。
|
|
27
|
+
*/
|
|
28
|
+
const TOLERANT_TAG_PREFIXES = ['!', 'tag:yaml.org,2002:js']
|
|
29
|
+
|
|
13
30
|
const tolerantSchema = yaml.DEFAULT_SCHEMA.extend(
|
|
14
|
-
|
|
15
|
-
kind,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
TOLERANT_TAG_PREFIXES.flatMap(prefix =>
|
|
32
|
+
['scalar', 'sequence', 'mapping'].map(kind => new yaml.Type(prefix, {
|
|
33
|
+
kind,
|
|
34
|
+
multi: true,
|
|
35
|
+
construct: () => ({ [JS_EXPR_PLACEHOLDER]: true }),
|
|
36
|
+
})),
|
|
37
|
+
),
|
|
19
38
|
)
|
|
20
39
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 配置里是否携带无法静态求值的表达式(!!js 等自定义标签)。
|
|
42
|
+
* YAML 锚点可以自引用,递归必须带环保护,否则一份合法补丁能把体检
|
|
43
|
+
* 工具自己搞成栈溢出。
|
|
44
|
+
* @param {unknown} value
|
|
45
|
+
* @param {WeakSet<object>} [seen]
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function hasDynamicConfig(value, seen = new WeakSet()) {
|
|
23
49
|
if (value === null || typeof value !== 'object') return false
|
|
24
50
|
if (value[JS_EXPR_PLACEHOLDER] === true) return true
|
|
51
|
+
if (seen.has(value)) return false
|
|
52
|
+
seen.add(value)
|
|
25
53
|
const children = Array.isArray(value) ? value : Object.values(value)
|
|
26
|
-
return children.some(child => hasDynamicConfig(child))
|
|
54
|
+
return children.some(child => hasDynamicConfig(child, seen))
|
|
27
55
|
}
|
|
28
56
|
|
|
29
57
|
/**
|
|
@@ -82,27 +110,94 @@ export function extractPatchRows(content) {
|
|
|
82
110
|
})
|
|
83
111
|
}
|
|
84
112
|
candidates.forEach((entry, index) => visit(entry, `第 ${index + 1} 行`))
|
|
85
|
-
return { rows, problems }
|
|
113
|
+
return { rows, problems, entries: candidates }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* M11:补丁行的身份约束。
|
|
118
|
+
*
|
|
119
|
+
* 这一条查的不是"插件自己能不能跑",而是"装上之后用户的 Desktop
|
|
120
|
+
* 还起不起得来"——三种写法各自对应一个宿主硬失败:
|
|
121
|
+
*
|
|
122
|
+
* - 同层 id 重复:vendor/loader/src/config/group.ts:64 抛
|
|
123
|
+
* `TypeError: duplicate loader entry id: <id>`;dsh-plugin-desktop
|
|
124
|
+
* 还有一道前置检查 assertUniqueEntryIds(profile.ts:633-646,在
|
|
125
|
+
* :949 对合成后的整份行集调用)先抛 `duplicate loader entry id
|
|
126
|
+
* "<id>" in the composed profile`。
|
|
127
|
+
* - id 含 ':':EntryTree.sep === ':'(vendor/loader/src/config/tree.ts:8),
|
|
128
|
+
* 嵌套路径解析被破坏。
|
|
129
|
+
* - id/name 撞宿主保留身份:见 contract.mjs 的 RESERVED_ROW_IDS。
|
|
130
|
+
*
|
|
131
|
+
* 而市场安装路径装完**从不重新解析** cordis.patch.yml(service.ts:626-665
|
|
132
|
+
* 只比对版本号,且没有回滚),所以这类包一路绿灯装上、下次开机才炸。
|
|
133
|
+
*
|
|
134
|
+
* 唯一性按"层"判,与 assertUniqueEntryIds 同构:它每递归一层新建一个
|
|
135
|
+
* Set,所以父层与子层的同名 id 不算冲突。
|
|
136
|
+
*
|
|
137
|
+
* 查不到的部分(如实说明):跨插件、以及与宿主自身行集的 id 冲突需要
|
|
138
|
+
* 完整的宿主行清单,那要求本工具跟宿主同步演进,这里只查已知保留 id。
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown[]} entries - extractPatchRows 返回的原始行(含 group 行)。
|
|
141
|
+
* @returns {Array<{level: 'error', rule: string, message: string}>}
|
|
142
|
+
*/
|
|
143
|
+
export function checkRowIdentity(entries) {
|
|
144
|
+
const findings = []
|
|
145
|
+
const error = (message) => findings.push({ level: 'error', rule: 'M11-row-identity', message })
|
|
146
|
+
|
|
147
|
+
const visitLevel = (level, at) => {
|
|
148
|
+
const seen = new Set()
|
|
149
|
+
for (const [index, entry] of level.entries()) {
|
|
150
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
151
|
+
const where = `${at}第 ${index + 1} 行`
|
|
152
|
+
const id = entry.id
|
|
153
|
+
if (typeof id === 'string') {
|
|
154
|
+
if (seen.has(id)) {
|
|
155
|
+
error(`${where} 的 id "${id}" 与同层前面的行重复;`
|
|
156
|
+
+ `宿主合成加载树时会直接抛 duplicate loader entry id,`
|
|
157
|
+
+ `装上这个插件的用户下次启动整个 Desktop 都起不来(不只是本插件失效)`)
|
|
158
|
+
}
|
|
159
|
+
seen.add(id)
|
|
160
|
+
if (id.includes(':')) {
|
|
161
|
+
error(`${where} 的 id "${id}" 含 ":";`
|
|
162
|
+
+ `":" 是加载树的层级分隔符(EntryTree.sep),用在 id 里会破坏嵌套行的寻址`)
|
|
163
|
+
}
|
|
164
|
+
if (RESERVED_ROW_IDS.has(id)) {
|
|
165
|
+
error(`${where} 的 id "${id}" 是宿主保留行 id;`
|
|
166
|
+
+ `占用它会让宿主启动时抛错或让市场 provider 整体失效,请改用带包名前缀的 id`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (typeof entry.name === 'string' && RESERVED_ROW_NAMES.has(entry.name)) {
|
|
170
|
+
error(`${where} 的 name "${entry.name}" 是宿主保留包名;该行会被静默剥离并让市场 provider 失效`)
|
|
171
|
+
}
|
|
172
|
+
if (entry.group === true && Array.isArray(entry.config)) {
|
|
173
|
+
visitLevel(entry.config, `${where} group 内 `)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
visitLevel(Array.isArray(entries) ? entries : [], '')
|
|
179
|
+
return findings
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 对补丁内容执行 M4/M5 判定。
|
|
184
|
+
* 目录体检与会话内体检共用这一份实现:两边曾经各写各的,结果
|
|
185
|
+
* 会话内看不到 M5,把装上即崩的插件判成合格。
|
|
186
|
+
* @param {string} packageName - 本包 npm 包名。
|
|
187
|
+
* @param {string} content - cordis.patch.yml 全文。
|
|
188
|
+
* @returns {{ findings: Array<{level: 'error', rule: string, message: string}>, rows: object[] }}
|
|
189
|
+
*/
|
|
190
|
+
export function checkPatchContent(packageName, content) {
|
|
191
|
+
const findings = []
|
|
192
|
+
const error = (rule, message) => findings.push({ level: 'error', rule, message })
|
|
193
|
+
const { rows, problems, entries } = extractPatchRows(content)
|
|
194
|
+
for (const problem of problems) error('M4-bundle', `补丁行不合法: ${problem}`)
|
|
195
|
+
if (rows.length === 0 && problems.length === 0) error('M4-bundle', '补丁未声明任何插件行')
|
|
196
|
+
findings.push(...checkRowIdentity(entries))
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (packageName !== '' && row.name !== packageName && !row.name.startsWith(`${packageName}/`)) {
|
|
199
|
+
error('M5-row-scope', `补丁行 "${row.id ?? row.name}" 指向 ${row.name},超出本包命名空间(${packageName});插件只能挂载自己的入口`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { findings, rows }
|
|
86
203
|
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 对补丁内容执行 M4/M5 判定。
|
|
90
|
-
* 目录体检与会话内体检共用这一份实现:两边曾经各写各的,结果
|
|
91
|
-
* 会话内看不到 M5,把装上即崩的插件判成合格。
|
|
92
|
-
* @param {string} packageName - 本包 npm 包名。
|
|
93
|
-
* @param {string} content - cordis.patch.yml 全文。
|
|
94
|
-
* @returns {{ findings: Array<{level: 'error', rule: string, message: string}>, rows: object[] }}
|
|
95
|
-
*/
|
|
96
|
-
export function checkPatchContent(packageName, content) {
|
|
97
|
-
const findings = []
|
|
98
|
-
const error = (rule, message) => findings.push({ level: 'error', rule, message })
|
|
99
|
-
const { rows, problems } = extractPatchRows(content)
|
|
100
|
-
for (const problem of problems) error('M4-bundle', `补丁行不合法: ${problem}`)
|
|
101
|
-
if (rows.length === 0 && problems.length === 0) error('M4-bundle', '补丁未声明任何插件行')
|
|
102
|
-
for (const row of rows) {
|
|
103
|
-
if (packageName !== '' && row.name !== packageName && !row.name.startsWith(`${packageName}/`)) {
|
|
104
|
-
error('M5-row-scope', `补丁行 "${row.id ?? row.name}" 指向 ${row.name},超出本包命名空间(${packageName});插件只能挂载自己的入口`)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return { findings, rows }
|
|
108
|
-
}
|
package/lib/registry-check.mjs
CHANGED
|
@@ -1,168 +1,41 @@
|
|
|
1
1
|
/* ============================================================
|
|
2
|
-
* 已发布包体检(
|
|
2
|
+
* 已发布包体检(注册表版)
|
|
3
3
|
* ============================================================
|
|
4
4
|
* 对 npm registry 的版本文档跑清单规则,与目录体检(manifest-check)
|
|
5
|
-
*
|
|
5
|
+
* **同一份实现** —— 不是"同一套语义"而已:清单侧规则全部来自
|
|
6
|
+
* checkManifestFields,两条路径不可能再对同一个包给出不同结论。
|
|
7
|
+
*
|
|
8
|
+
* 这里曾经自带一套手写的迷你 SemVer,理由是"会话内要零依赖";
|
|
9
|
+
* 那个前提本来就不成立(semver 是本包的 dependencies,宿主进程里
|
|
10
|
+
* 早就通过 cowork-plugin → manifest-check 加载了),代价却是把
|
|
11
|
+
* ">=0.1 <0.2"、"1.x"、"1.2.0 - 2.0.0" 这些完全合法的 npm 范围判成
|
|
12
|
+
* "不可识别" → error,合格插件被判不合格。已删除。
|
|
6
13
|
*
|
|
7
14
|
* 两个入口:
|
|
8
|
-
* - checkPublishedManifest
|
|
15
|
+
* - checkPublishedManifest 只看清单字段;
|
|
9
16
|
* - checkPublishedPackage 额外取 tarball 读出 cordis.patch.yml,
|
|
10
|
-
* 补上 M4/M5 —— 只看清单会漏掉"补丁行 name 写成插件名"
|
|
11
|
-
*
|
|
17
|
+
* 补上 M4/M5/M11 —— 只看清单会漏掉"补丁行 name 写成插件名"
|
|
18
|
+
* 这类装上即让整棵插件树崩溃的缺陷。
|
|
12
19
|
* 冒烟阶段无法在宿主进程执行,完整体检走 CLI 或 Plugin Check
|
|
13
20
|
* workflow。
|
|
14
21
|
* ============================================================ */
|
|
15
22
|
import { checkPatchContent } from './patch-rows.mjs'
|
|
16
23
|
import { fetchTarEntry } from './tarball.mjs'
|
|
24
|
+
import { checkManifestFields } from './manifest-check.mjs'
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
const isIdentityCore = (name) =>
|
|
20
|
-
name === '@deepseek-ai/cordis' || name.startsWith('@deepseek-ai/cordis-') || name.startsWith('@deepseek-ai/dsh')
|
|
21
|
-
|
|
22
|
-
/* ------------------------- 迷你 SemVer ------------------------- */
|
|
23
|
-
// 只实现体检需要的子集:比较 + 范围判定(空格=且、||=或、^、~、
|
|
24
|
-
// >=、>、<=、<、=、裸版本),预发布参与比较;识别不了的范围返回
|
|
25
|
-
// null 由调用方按"范围不合法"处理。
|
|
26
|
-
|
|
27
|
-
function parseVersion(input) {
|
|
28
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(String(input).trim())
|
|
29
|
-
if (!match) return null
|
|
30
|
-
return {
|
|
31
|
-
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
32
|
-
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function compareVersions(a, b) {
|
|
37
|
-
for (let index = 0; index < 3; index += 1) {
|
|
38
|
-
if (a.parts[index] !== b.parts[index]) return a.parts[index] < b.parts[index] ? -1 : 1
|
|
39
|
-
}
|
|
40
|
-
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
|
|
41
|
-
if (a.prerelease.length === 0) return 1
|
|
42
|
-
if (b.prerelease.length === 0) return -1
|
|
43
|
-
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
|
44
|
-
for (let index = 0; index < length; index += 1) {
|
|
45
|
-
const left = a.prerelease[index]
|
|
46
|
-
const right = b.prerelease[index]
|
|
47
|
-
if (left === undefined) return -1
|
|
48
|
-
if (right === undefined) return 1
|
|
49
|
-
const leftNumeric = /^\d+$/u.test(left)
|
|
50
|
-
const rightNumeric = /^\d+$/u.test(right)
|
|
51
|
-
if (leftNumeric && rightNumeric) {
|
|
52
|
-
if (Number(left) !== Number(right)) return Number(left) < Number(right) ? -1 : 1
|
|
53
|
-
} else if (leftNumeric !== rightNumeric) {
|
|
54
|
-
return leftNumeric ? -1 : 1
|
|
55
|
-
} else if (left !== right) {
|
|
56
|
-
return left < right ? -1 : 1
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return 0
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function comparatorHolds(version, operator, boundary) {
|
|
63
|
-
const cmp = compareVersions(version, boundary)
|
|
64
|
-
if (operator === '>') return cmp > 0
|
|
65
|
-
if (operator === '>=') return cmp >= 0
|
|
66
|
-
if (operator === '<') return cmp < 0
|
|
67
|
-
if (operator === '<=') return cmp <= 0
|
|
68
|
-
return cmp === 0
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function versionSatisfies(versionInput, rangeInput) {
|
|
72
|
-
const version = parseVersion(versionInput)
|
|
73
|
-
if (version === null) return null
|
|
74
|
-
const alternatives = String(rangeInput).split('||')
|
|
75
|
-
let recognized = false
|
|
76
|
-
for (const alternative of alternatives) {
|
|
77
|
-
const comparators = alternative.trim().split(/\s+/u).filter(Boolean)
|
|
78
|
-
if (comparators.length === 0) continue
|
|
79
|
-
let holds = true
|
|
80
|
-
let valid = true
|
|
81
|
-
for (const comparator of comparators) {
|
|
82
|
-
if (comparator === '*' || comparator === 'x') continue
|
|
83
|
-
const match = /^(>=|<=|>|<|=|\^|~)?(.+)$/u.exec(comparator)
|
|
84
|
-
const boundary = parseVersion(match[2])
|
|
85
|
-
if (boundary === null) { valid = false; break }
|
|
86
|
-
const operator = match[1] ?? '='
|
|
87
|
-
if (operator === '^') {
|
|
88
|
-
const upper = boundary.parts[0] > 0
|
|
89
|
-
? { parts: [boundary.parts[0] + 1, 0, 0], prerelease: ['0'] }
|
|
90
|
-
: boundary.parts[1] > 0
|
|
91
|
-
? { parts: [0, boundary.parts[1] + 1, 0], prerelease: ['0'] }
|
|
92
|
-
: { parts: [0, 0, boundary.parts[2] + 1], prerelease: ['0'] }
|
|
93
|
-
holds = holds && comparatorHolds(version, '>=', boundary) && comparatorHolds(version, '<', upper)
|
|
94
|
-
} else if (operator === '~') {
|
|
95
|
-
const upper = { parts: [boundary.parts[0], boundary.parts[1] + 1, 0], prerelease: ['0'] }
|
|
96
|
-
holds = holds && comparatorHolds(version, '>=', boundary) && comparatorHolds(version, '<', upper)
|
|
97
|
-
} else {
|
|
98
|
-
holds = holds && comparatorHolds(version, operator, boundary)
|
|
99
|
-
}
|
|
100
|
-
if (!holds) break
|
|
101
|
-
}
|
|
102
|
-
if (!valid) continue
|
|
103
|
-
recognized = true
|
|
104
|
-
if (holds) return true
|
|
105
|
-
}
|
|
106
|
-
return recognized ? false : null
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/* --------------------------- 规则 --------------------------- */
|
|
26
|
+
export { versionSatisfies } from './semver-rules.mjs'
|
|
110
27
|
|
|
111
28
|
/**
|
|
112
29
|
* 对一份 npm 版本清单跑体检规则。
|
|
113
30
|
* @param {object} manifest - registry 版本文档(scripts/dependencies/
|
|
114
31
|
* peerDependencies/dsh/engines/license/version)。
|
|
115
|
-
* @param {string} runtime - 目标 DSH
|
|
32
|
+
* @param {string} [runtime] - 目标 DSH 运行时版本;省略时只判范围合法性,
|
|
33
|
+
* 不判是否覆盖(以前这是必填位置参数且无 guard,传 undefined 会产生
|
|
34
|
+
* 一批凭空的 M6/M7 error)。
|
|
116
35
|
* @returns {Array<{level: 'error'|'warning', rule: string, message: string}>}
|
|
117
36
|
*/
|
|
118
37
|
export function checkPublishedManifest(manifest, runtime) {
|
|
119
|
-
|
|
120
|
-
const error = (rule, message) => findings.push({ level: 'error', rule, message })
|
|
121
|
-
const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
|
|
122
|
-
|
|
123
|
-
const scripts = manifest.scripts ?? {}
|
|
124
|
-
for (const script of FORBIDDEN_LIFECYCLE) {
|
|
125
|
-
if (typeof scripts[script] === 'string') {
|
|
126
|
-
error('M2-lifecycle', `禁止 ${script} 生命周期脚本(安装即执行任意代码);构建请改用 prepack`)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
for (const name of Object.keys(manifest.dependencies ?? {})) {
|
|
131
|
-
if (isIdentityCore(name)) {
|
|
132
|
-
error('M3-core-peer', `${name} 出现在 dependencies;核心运行时必须是 peerDependencies,否则双内核实例会让 Cowork 启动失败`)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const bundle = manifest.dsh?.bundle ?? {}
|
|
137
|
-
if (typeof bundle.patch !== 'string' || bundle.patch === '') {
|
|
138
|
-
error('M4-bundle', 'dsh.bundle.patch 缺失;宿主靠它把插件挂进加载树')
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const engine = typeof manifest.dsh?.engine === 'string' ? manifest.dsh.engine : undefined
|
|
142
|
-
if (engine === undefined) {
|
|
143
|
-
warning('M6-engine', 'dsh.engine 未声明(目标运行时 SemVer 范围);该字段将成为上架必填')
|
|
144
|
-
} else {
|
|
145
|
-
const satisfied = versionSatisfies(runtime, engine)
|
|
146
|
-
if (satisfied === null) error('M6-engine', `dsh.engine 不是可识别的 SemVer 范围: ${engine}`)
|
|
147
|
-
else if (!satisfied) error('M6-engine', `dsh.engine "${engine}" 不覆盖当前运行时 ${runtime}`)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) {
|
|
151
|
-
if (!name.startsWith('@deepseek-ai/dsh')) continue
|
|
152
|
-
const satisfied = versionSatisfies(runtime, String(range))
|
|
153
|
-
if (satisfied === null) error('M7-peer-range', `peer ${name} 的范围不可识别: ${range}`)
|
|
154
|
-
else if (!satisfied) warning('M7-peer-range', `peer ${name}@"${range}" 不含当前运行时 ${runtime};宿主升级后可能运行时断裂`)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (manifest.engines?.node === undefined) warning('M8-node-engines', 'engines.node 未声明;宿主运行在 Node 22+')
|
|
158
|
-
if (manifest.license === undefined) warning('M9-license', 'license 未声明')
|
|
159
|
-
|
|
160
|
-
const parsed = parseVersion(typeof manifest.version === 'string' ? manifest.version : '')
|
|
161
|
-
if (parsed !== null && parsed.prerelease.length > 0) {
|
|
162
|
-
warning('M1-manifest', `version ${manifest.version} 是预发布版;市场上架要求精确稳定版`)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return findings
|
|
38
|
+
return checkManifestFields(manifest, runtime)
|
|
166
39
|
}
|
|
167
40
|
|
|
168
41
|
/**
|
|
@@ -170,7 +43,7 @@ export function checkPublishedManifest(manifest, runtime) {
|
|
|
170
43
|
* 取不到 tarball(离线、超时、体积过大)时降级为 warning 并如实
|
|
171
44
|
* 标注未检查,绝不因网络问题把插件判成不合格。
|
|
172
45
|
* @param {object} manifest - registry 版本文档(需含 dist.tarball)。
|
|
173
|
-
* @param {string} runtime - 目标 DSH 运行时版本。
|
|
46
|
+
* @param {string} [runtime] - 目标 DSH 运行时版本。
|
|
174
47
|
* @param {{ maxBytes?: number, timeoutMs?: number }} [options]
|
|
175
48
|
* @returns {Promise<{ findings: object[], inspectedPatch: boolean }>}
|
|
176
49
|
*/
|