@tokensapi/dsh-plugin-check 0.3.4 → 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.
@@ -1,138 +1,175 @@
1
- /* ============================================================
2
- * 阶段①:清单体检(纯静态,秒级,离线)
3
- * ============================================================
4
- * 每一条规则都对应一种已经真实发生过的"把宿主搞崩/搞坏"路径,
5
- * 规则说明写在 README;error 挡上架,warning 提示即将收紧或
6
- * 潜在错配。判定为纯函数,便于单测与市场门禁复用。
7
- * ============================================================ */
8
- import { existsSync, readFileSync, statSync } from 'node:fs'
9
- import { join, resolve } from 'node:path'
10
- import semver from 'semver'
11
- import { checkPatchContent } from './patch-rows.mjs'
12
- import { LIFECYCLE_SCRIPTS, lifecycleMessage } from './contract.mjs'
13
-
14
- /**
15
- * 携带身份语义的核心包:cordis 上下文与 dsh 服务在宿主/插件各持一份时
16
- * Symbol 不等,注册对不上。schemastery、cosmokit 等纯工具库不在此列,
17
- * 插件可以正常依赖。
18
- */
19
- const CORE_SCOPE = '@deepseek-ai/'
20
- const isIdentityCore = (name) => name === '@deepseek-ai/cordis'
21
- || name.startsWith('@deepseek-ai/cordis-')
22
- || name.startsWith('@deepseek-ai/dsh')
23
-
24
- const SUSPICIOUS_FILE = /(\.env(\.|$)|id_rsa|\.pem$|\.p12$|\.key$)/u
25
-
26
- /**
27
- * 对一个插件目录执行全部清单规则。
28
- * @param {string} pluginDir - 插件根目录(含 package.json)。
29
- * @param {{ runtime?: string }} [options] - runtime 为目标 DSH 运行时
30
- * 版本(如 0.1.3-alpha.1);给出时才执行 engine/peer 与其的交集判定。
31
- * @returns {{ findings: Array<{level: 'error'|'warning', rule: string, message: string}>, manifest?: object, rows?: object[] }}
32
- */
33
- export function checkManifest(pluginDir, options = {}) {
34
- const findings = []
35
- const error = (rule, message) => findings.push({ level: 'error', rule, message })
36
- const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
37
-
38
- const manifestPath = resolve(pluginDir, 'package.json')
39
- if (!existsSync(manifestPath)) {
40
- error('M1-manifest', 'package.json 不存在')
41
- return { findings }
42
- }
43
- let manifest
44
- try {
45
- manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
46
- } catch (cause) {
47
- error('M1-manifest', `package.json 不是合法 JSON: ${cause instanceof Error ? cause.message : String(cause)}`)
48
- return { findings }
49
- }
50
-
51
- if (typeof manifest.name !== 'string' || manifest.name === '') {
52
- error('M1-manifest', 'name 缺失')
53
- }
54
- if (typeof manifest.version !== 'string' || semver.valid(manifest.version) === null) {
55
- error('M1-manifest', 'version 不是合法 SemVer')
56
- } else if (semver.prerelease(manifest.version) !== null) {
57
- warning('M1-manifest', `version ${manifest.version} 是预发布版;`
58
- + `市场受控安装只认稳定的精确版(上游 stableExactVersion),npm latest 若停在预发布版,`
59
- + `用户端拿不到一键安装按钮,只会看到手动命令。预发布请发在 next 之类的 dist-tag 上`)
60
- }
61
-
62
- const scripts = manifest.scripts ?? {}
63
- for (const script of LIFECYCLE_SCRIPTS) {
64
- if (typeof scripts[script] === 'string') warning('M2-lifecycle', lifecycleMessage(script))
65
- }
66
-
67
- const dependencies = manifest.dependencies ?? {}
68
- for (const name of Object.keys(dependencies)) {
69
- if (isIdentityCore(name)) {
70
- error('M3-core-peer', `${name} 出现在 dependencies;核心运行时必须声明为 peerDependencies,否则宿主与插件各持一份实例,服务注册无法对上,典型症状是整个 Cowork 启动失败`)
71
- }
72
- }
73
-
74
- const dsh = manifest.dsh ?? {}
75
- const bundle = dsh.bundle ?? {}
76
- let rows
77
- if (typeof bundle.patch !== 'string' || bundle.patch === '') {
78
- error('M4-bundle', 'dsh.bundle.patch 缺失;宿主靠它把插件挂进加载树')
79
- } else {
80
- const patchPath = resolve(pluginDir, bundle.patch)
81
- if (!existsSync(patchPath)) {
82
- error('M4-bundle', `dsh.bundle.patch 指向的文件不存在: ${bundle.patch}`)
83
- } else {
84
- // M4/M5 与会话内体检共用同一份实现,避免两条路径判定漂移。
85
- const checked = checkPatchContent(
86
- typeof manifest.name === 'string' ? manifest.name : '',
87
- readFileSync(patchPath, 'utf8'),
88
- )
89
- findings.push(...checked.findings)
90
- rows = checked.rows
91
- }
92
- }
93
-
94
- const engine = typeof dsh.engine === 'string' ? dsh.engine : undefined
95
- if (engine === undefined) {
96
- warning('M6-engine', 'dsh.engine 未声明(目标 DSH 运行时 SemVer 范围,如 ">=0.1.3-alpha.1 <0.2");'
97
- + '上游目前不强制读取该字段,但声明之后体检才能判断你与目标运行时是否相容')
98
- } else if (semver.validRange(engine, { includePrerelease: true }) === null) {
99
- error('M6-engine', `dsh.engine 不是合法 SemVer 范围: ${engine}`)
100
- } else if (options.runtime !== undefined
101
- && !semver.satisfies(options.runtime, engine, { includePrerelease: true })) {
102
- error('M6-engine', `dsh.engine "${engine}" 不覆盖目标运行时 ${options.runtime}`)
103
- }
104
-
105
- const peers = manifest.peerDependencies ?? {}
106
- if (options.runtime !== undefined) {
107
- for (const [name, range] of Object.entries(peers)) {
108
- if (!name.startsWith(`${CORE_SCOPE}dsh`)) continue
109
- if (semver.validRange(range, { includePrerelease: true }) === null) {
110
- error('M7-peer-range', `peer ${name} 的范围不合法: ${range}`)
111
- } else if (!semver.satisfies(options.runtime, range, { includePrerelease: true })) {
112
- warning('M7-peer-range', `peer ${name}@"${range}" 不含目标运行时 ${options.runtime};宿主升级后此插件可能在运行时断裂(接口错配类故障)`)
113
- }
114
- }
115
- }
116
-
117
- if (manifest.engines?.node === undefined) {
118
- warning('M8-node-engines', 'engines.node 未声明;宿主运行在 Node 22+')
119
- }
120
- if (manifest.license === undefined) {
121
- warning('M9-license', 'license 未声明')
122
- }
123
-
124
- const filesList = Array.isArray(manifest.files) ? manifest.files : []
125
- for (const entry of filesList) {
126
- if (typeof entry === 'string' && SUSPICIOUS_FILE.test(entry)) {
127
- error('M10-secrets', `files 白名单包含疑似凭据文件: ${entry}`)
128
- }
129
- }
130
- for (const candidate of ['.env', 'id_rsa']) {
131
- const candidatePath = join(pluginDir, candidate)
132
- if (existsSync(candidatePath) && statSync(candidatePath).isFile() && filesList.length === 0) {
133
- warning('M10-secrets', `目录含 ${candidate} 且未用 files 白名单限定发布内容,npm pack 可能把它带上`)
134
- }
135
- }
136
-
137
- return { findings, manifest, rows }
138
- }
1
+ /* ============================================================
2
+ * 阶段①:清单体检(纯静态,秒级,离线)
3
+ * ============================================================
4
+ * 每一条规则都对应一种已经真实发生过的"把宿主搞崩/搞坏"路径,
5
+ * 规则说明写在 README;error 挡上架,warning 提示即将收紧或
6
+ * 潜在错配。判定为纯函数,便于单测与市场门禁复用。
7
+ *
8
+ * 与会话内体检(registry-check.mjs)的关系:凡是只看清单就能判的
9
+ * 规则,两边**必须**调同一份实现(semver-rules / client-check /
10
+ * contract 里的谓词),否则同一个包在两条路径上会得出不同结论。
11
+ * tests/cross-path.test.mjs 是这条约束的闸门。
12
+ * ============================================================ */
13
+ import { existsSync, readFileSync, statSync } from 'node:fs'
14
+ import { join, resolve } from 'node:path'
15
+ import semver from 'semver'
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'
28
+
29
+ const SUSPICIOUS_FILE = /(\.env(\.|$)|id_rsa|\.pem$|\.p12$|\.key$)/u
30
+
31
+ /**
32
+ * 只看清单就能判的规则,两条执行路径共用这一份。
33
+ * @param {object} manifest - package.json / registry 版本文档。
34
+ * @param {string | undefined} runtime - 目标 DSH 运行时版本。
35
+ * @returns {Array<{level: string, rule: string, message: string}>}
36
+ */
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 })
41
+
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
+ }
113
+
114
+ /**
115
+ * 对一个插件目录执行全部清单规则。
116
+ * @param {string} pluginDir - 插件根目录(含 package.json)。
117
+ * @param {{ runtime?: string }} [options] - runtime 为目标 DSH 运行时
118
+ * 版本( 0.1.3-alpha.1);给出时才执行 engine/peer 与其的交集判定。
119
+ * @returns {{ findings: Array<{level: 'error'|'warning', rule: string, message: string}>, manifest?: object, rows?: object[] }}
120
+ */
121
+ export function checkManifest(pluginDir, options = {}) {
122
+ const findings = []
123
+ const error = (rule, message) => findings.push({ level: 'error', rule, message })
124
+ const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
125
+
126
+ const manifestPath = resolve(pluginDir, 'package.json')
127
+ if (!existsSync(manifestPath)) {
128
+ error('M1-manifest', 'package.json 不存在')
129
+ return { findings }
130
+ }
131
+ let manifest
132
+ try {
133
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
134
+ } catch (cause) {
135
+ error('M1-manifest', `package.json 不是合法 JSON: ${cause instanceof Error ? cause.message : String(cause)}`)
136
+ return { findings }
137
+ }
138
+
139
+ findings.push(...checkManifestFields(manifest, options.runtime))
140
+
141
+ // M4/M5/M11 的补丁内容部分:目录路径直接读盘。
142
+ const patch = manifest.dsh?.bundle?.patch
143
+ let rows
144
+ if (typeof patch === 'string' && patch !== '') {
145
+ const patchPath = resolve(pluginDir, patch)
146
+ if (!existsSync(patchPath)) {
147
+ error('M4-bundle', `dsh.bundle.patch 指向的文件不存在: ${patch}`)
148
+ } else {
149
+ // 与会话内体检共用同一份实现,避免两条路径判定漂移。
150
+ const checked = checkPatchContent(
151
+ typeof manifest.name === 'string' ? manifest.name : '',
152
+ readFileSync(patchPath, 'utf8'),
153
+ )
154
+ findings.push(...checked.findings)
155
+ rows = checked.rows
156
+ }
157
+ }
158
+
159
+ // M10:依赖文件系统,只在目录路径可判(registry 路径没有工作区,
160
+ // 这是有意的差异,不是漏 —— 见 docs/CHECKS.md §1)。
161
+ const filesList = Array.isArray(manifest.files) ? manifest.files : []
162
+ for (const entry of filesList) {
163
+ if (typeof entry === 'string' && SUSPICIOUS_FILE.test(entry)) {
164
+ error('M10-secrets', `files 白名单包含疑似凭据文件: ${entry}`)
165
+ }
166
+ }
167
+ for (const candidate of ['.env', 'id_rsa']) {
168
+ const candidatePath = join(pluginDir, candidate)
169
+ if (existsSync(candidatePath) && statSync(candidatePath).isFile() && filesList.length === 0) {
170
+ warning('M10-secrets', `目录含 ${candidate} 且未用 files 白名单限定发布内容,npm pack 可能把它带上`)
171
+ }
172
+ }
173
+
174
+ return { findings, manifest, rows }
175
+ }
@@ -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
- ['scalar', 'sequence', 'mapping'].map(kind => new yaml.Type('!', {
15
- kind,
16
- multi: true,
17
- construct: () => ({ [JS_EXPR_PLACEHOLDER]: true }),
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
- /** 配置里是否携带无法静态求值的表达式(!!js 等自定义标签)。 */
22
- export function hasDynamicConfig(value) {
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
- }