@bushizhanghan/dsh-publish-gate 0.1.5 → 0.2.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 +52 -10
- package/lib/engine.js +497 -49
- package/lib/index.js +76 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
>
|
|
5
5
|
> 发布一个 DSH 插件有七步,每一步都有一个**直到你付出代价才说话的坑**:`files` 忘了 `lib/` 于是发出去一个空包、PowerShell 写下的 BOM 让 `JSON.parse` 报一个不提 BOM 的错、patch 的 insert 行缩进错了于是插件装上了但**永远不加载**、裸包名让 granular token 收到一个既不说 token 也不说 scope 的 403。这个插件把整条链路的每个静默坑变成一条确定性检查。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
八个 agent 工具:全量门禁、运行时注册检查、发后校验、patch 专项、市场条目校验、版本推进、多插件体检、发布命令清单。
|
|
8
|
+
|
|
9
|
+
门禁分三层:**清单层**看 package.json / patch / 入口源码,**产物层**真的跑一遍 `npm pack --dry-run` 看 npm 到底会发出什么,**运行时层**在独立子进程里 `import()` 入口并 `apply(ctx)` 一遍,看它到底注册了什么。
|
|
8
10
|
|
|
9
11
|
## 典型用法(写完插件后)
|
|
10
12
|
|
|
11
13
|
```
|
|
12
14
|
我写好了一个新插件 X,帮我发出去
|
|
13
|
-
→ agent 先调 publish_gate(X)
|
|
15
|
+
→ agent 先调 publish_gate(X) 查所有静默坑(清单 + 产物 + 运行时三层)
|
|
14
16
|
→ 有 blocker 就修,修完再跑
|
|
15
17
|
→ 全绿后调 publish_gate_commands(X, {version}) 拿 GitHub/npm/市场三步的命令,照抄执行
|
|
18
|
+
→ 发完调 publish_gate_verify(X) 确认 registry 上那包的 shasum 和你本地这份一致
|
|
16
19
|
```
|
|
17
20
|
|
|
18
21
|
## 安装
|
|
@@ -26,11 +29,13 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
26
29
|
|
|
27
30
|
| 工具 | 作用 |
|
|
28
31
|
|---|---|
|
|
29
|
-
| `publish_gate` |
|
|
32
|
+
| `publish_gate` | 全量门禁:清单 + 产物 + 运行时 + 网络,返回 `safeToPublish`(`skipPack` / `skipTools` / `skipNetwork` 可逐层跳过) |
|
|
33
|
+
| `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 |
|
|
34
|
+
| `publish_gate_verify` | 发后校验:`npm pack --dry-run` 的 shasum 和 registry 上的 `dist.shasum` 逐字节比,比版本号猜准 |
|
|
30
35
|
| `publish_gate_patch` | 只查 `cordis.patch.yml`——「插件装了但工具没出现」的第一个怀疑对象 |
|
|
31
36
|
| `publish_gate_market` | 校验市场条目 YAML(url/name/category/description 双语) |
|
|
32
37
|
| `publish_gate_next_version` | 当前版本、是否已被 registry 占用、建议 bump 到多少(本地比 npm 旧时从 npm 的 latest 往上加,不会推荐一个已被占用的版本) |
|
|
33
|
-
| `publish_gate_doctor` | 扫整个目录,按 blocker
|
|
38
|
+
| `publish_gate_doctor` | 扫整个目录,按 blocker 数排序,附带每个插件的真实包体积,看哪个还不能发 |
|
|
34
39
|
| `publish_gate_commands` | 按顺序列出发布命令,并标出这台机器已经走到哪一步 |
|
|
35
40
|
|
|
36
41
|
## 检查清单
|
|
@@ -58,7 +63,39 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
58
63
|
| `registry-unreachable` | registry 回了 500/429 —— 版本检查**没跑**,所以别把它当干净 |
|
|
59
64
|
|
|
60
65
|
### 值得在发布前知道的(warn / info)
|
|
61
|
-
`unscoped-name`(granular token 的 403)、`repo-missing`、`patch-name-unresolvable`(junction 放错层级)、`no-readme` / `readme-no-install` / `readme-no-profile`、`license-mismatch`、`no-tests` / `no-test-files` / `no-test-script`、`no-engines`、`bad-repo-url`、`no-git` / `no-gitignore
|
|
66
|
+
`unscoped-name`(granular token 的 403)、`repo-missing`、`patch-name-unresolvable`(junction 放错层级)、`no-readme` / `readme-no-install` / `readme-no-profile`、`license-mismatch`、`no-tests` / `no-test-files` / `no-test-script`、`no-engines`、`bad-repo-url`、`no-git` / `no-gitignore`、`readme-tool-undocumented`(README 没提这个工具)/ `readme-test-count`(README 写的测试数和真实数不一致),以及市场条目的 `market-*` 全系列。
|
|
67
|
+
|
|
68
|
+
### 产物层:npm 到底会发出什么
|
|
69
|
+
|
|
70
|
+
清单层只看 `files` 字段,而 npm 有它自己的规矩(`main` / `exports` 指向的文件强制入包、README/LICENSE/package.json 强制入包)。所以这一层真的跑一遍 `npm pack --dry-run --json --ignore-scripts`,读它返回的文件清单和体积。
|
|
71
|
+
|
|
72
|
+
| 规则 | 抓什么 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `pack-omits-lib` | tarball 里没有 `lib/`——发出去是空包,**npm 不警告** |
|
|
75
|
+
| `pack-omits-patch` | `dsh.bundle.patch` 指的文件没进 tarball,装上不挂载 |
|
|
76
|
+
| `pack-omits-readme` / `pack-omits-license` | README / LICENSE 没进包 |
|
|
77
|
+
| `pack-oversize` | 解包 >5 MB 是 error、>1 MB 是 warn,并点名最大的三个文件。测试 fixture 是常见元凶——我的一个插件曾把 20 MB 的算例目录发上去,装了它的人每次都要下 20 MB |
|
|
78
|
+
| `pack-unexpected-dir` | `fixtures` / `test` / `tests` / `src` 被打进包里 |
|
|
79
|
+
| `pack-script-build` | 有 `prepack` / `prepare`:dry run 用了 `--ignore-scripts`,这次清单**不代表**真正发布时的产物 |
|
|
80
|
+
| `pack-unavailable` | npm 跑不起来(warn)——产物层没跑,别把它当干净 |
|
|
81
|
+
|
|
82
|
+
### 运行时层:`apply` 之后到底注册了什么
|
|
83
|
+
|
|
84
|
+
前两层都是读文件,读不出「装上能不能用」。这一层在独立子进程里 `import()` 入口、用假 ctx 调一次 `apply(ctx)`,把 `ctx.tools.register` 收到的每一条拿回来检查。20 秒超时,你的插件代码不在宿主进程里跑。
|
|
85
|
+
|
|
86
|
+
| 规则 | 抓什么 |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `entry-import-failed` | `import()` 入口就抛——装上必定不加载 |
|
|
89
|
+
| `apply-threw` | `apply(ctx)` 抛 |
|
|
90
|
+
| `tool-none-registered` | 入口能加载,但一个工具都没注册 |
|
|
91
|
+
| `tool-missing-execute` / `tool-missing-output-schema` | 注册项缺 `execute` / `output.schema`(宿主用 schema 校验返回值,缺了等于没校验) |
|
|
92
|
+
| `tool-duplicate-name` | 两个工具同名,后者静默覆盖前者 |
|
|
93
|
+
| `tool-present-call-throws` | `presentCall` 抛——调用记录渲染时炸 |
|
|
94
|
+
| `tool-bad-card-kind` | `card: 'generic'` 的 `kind` 不在宿主认识的那一组里(`other` / `execute` / `search` / `read` / `edit` / `delete` / `fetch`)。注册不报错,卡片渲染坏掉——我五个插件一起踩过 |
|
|
95
|
+
|
|
96
|
+
### 代码卫生
|
|
97
|
+
|
|
98
|
+
`lib-debugger`(error)、`lib-console-log`、`lib-hardcoded-path`(`C:\Users\...` 硬编码)、`lib-url-pathname`(`new URL(...).pathname` 在中文路径上会百分号编码,得用 `fileURLToPath`)、`inject-missing-<service>`(用了 `ctx.tools` / `ctx.web` / `ctx.webServer` / `ctx.setInterval` 却没写进 `inject`,服务永远注入不进来)。
|
|
62
99
|
|
|
63
100
|
## 一个真实的例子
|
|
64
101
|
|
|
@@ -82,15 +119,20 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
82
119
|
|
|
83
120
|
## 设计取舍
|
|
84
121
|
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
122
|
+
- **三层,按代价排序**:清单层最便宜、先跑;产物层要起一个 npm(约 1–2 秒);运行时层要起一个 node 子进程。前一层没过的错误通常值得先修,但三层都会跑完——一次给全,比来回问好。
|
|
123
|
+
- **产物层用 `npm pack --dry-run`,不用自己推 `files` 规则**:npm 的入包规则(`main` / `exports` 强制入包、README/LICENSE 强制入包、`.npmignore` 优先级)比自己实现的准。唯一的代价是它需要这台机器上有 npm,所以跑不起来时降级成 `pack-unavailable` 而不是报错。
|
|
124
|
+
- **运行时层真的执行插件代码**:这是唯一能回答「装上能不能用」的办法。代价是 `apply()` 会跑,所以:假 ctx 是 Proxy 不落盘、20 秒硬超时、`skipTools` 一键跳过。
|
|
125
|
+
- **`--ignore-scripts`**:dry run 不跑 `prepack`,否则检查会触发一次构建。代价是清单可能和真实产物不同,所以有 `pack-script-build` 把这件事说出来。
|
|
126
|
+
- **`publish_gate_verify` 比 shasum,不比版本号**:版本号相同而字节不同(发了又改、或本地根本没发)是最常见的自欺。`npm pack --dry-run` 的 shasum 和 registry 的 `dist.shasum` 是同一套算法,所以这个比较是精确的。
|
|
127
|
+
- **只读**:修不修由你定,每条 finding 都带 `fix` 文案。门禁的职责是把话说清楚,不是替你做决定——所以没有 `--fix`。
|
|
128
|
+
- **确定性**:全部规则是文件内容、子进程或 registry/GitHub API 检查,没有 LLM 调用——一条规则答错,你可以直接看规则。
|
|
129
|
+
- **自举**:这个插件的测试把九个已发布插件当 fixture,断言它们在门禁下零 blocker;规则如果有任何误报倾向,测试先炸。
|
|
89
130
|
|
|
90
131
|
## 开发
|
|
91
132
|
|
|
92
133
|
```sh
|
|
93
|
-
node --test
|
|
134
|
+
node --test # 44 个引擎单测
|
|
135
|
+
node test/integration.test.mjs # 21 条集成断言(9 个真实插件作 fixture)
|
|
94
136
|
```
|
|
95
137
|
|
|
96
138
|
## License
|
package/lib/engine.js
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// unscoped npm name that returned E403, a git push that failed on the network
|
|
7
7
|
// while the REST API worked. All of them are silent until the moment they cost
|
|
8
8
|
// you a publish, so they are all checkable before you spend it.
|
|
9
|
-
import { existsSync, readFileSync, readdirSync
|
|
10
|
-
import { join,
|
|
11
|
-
import {
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
10
|
+
import { join, relative } from 'node:path'
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
12
|
+
import { promisify } from 'node:util'
|
|
13
|
+
|
|
14
|
+
const runFile = promisify(execFile)
|
|
12
15
|
|
|
13
16
|
const safeRead = (p) => {
|
|
14
17
|
try {
|
|
@@ -25,6 +28,42 @@ const safeText = (p) => {
|
|
|
25
28
|
|
|
26
29
|
export const F = (rule, severity, title, detail, fix) => ({ rule, severity, title, detail, fix })
|
|
27
30
|
|
|
31
|
+
/** package.json without the BOM trap. Null when it is missing or unparseable. */
|
|
32
|
+
export function readPkgJson(dir) {
|
|
33
|
+
const text = safeText(join(dir, 'package.json'))
|
|
34
|
+
if (text === null) return null
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text.replace(/^\uFEFF/, ''))
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every file under root matching filter, skipping node_modules. */
|
|
43
|
+
function walkFiles(root, filter, acc = []) {
|
|
44
|
+
let entries
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
47
|
+
} catch {
|
|
48
|
+
return acc
|
|
49
|
+
}
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const p = join(root, e.name)
|
|
52
|
+
if (e.isDirectory()) {
|
|
53
|
+
if (e.name !== 'node_modules') walkFiles(p, filter, acc)
|
|
54
|
+
} else if (filter.test(e.name)) acc.push(p)
|
|
55
|
+
}
|
|
56
|
+
return acc
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Drop a trailing // comment so a commented-out line is not reported as live code. */
|
|
60
|
+
const stripComment = (line) => line.replace(/\/\/.*$/, '')
|
|
61
|
+
|
|
62
|
+
const bytesHuman = (n) => {
|
|
63
|
+
const v = Number(n) || 0
|
|
64
|
+
return v >= 1_048_576 ? `${(v / 1_048_576).toFixed(1)} MB` : v >= 1024 ? `${Math.round(v / 1024)} kB` : `${v} B`
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
// =====================================================================
|
|
29
68
|
// package.json
|
|
30
69
|
// =====================================================================
|
|
@@ -163,9 +202,13 @@ export function checkPackageJson(dir) {
|
|
|
163
202
|
)
|
|
164
203
|
}
|
|
165
204
|
|
|
166
|
-
// repository.url — GitHub Actions and the market link use it
|
|
167
|
-
|
|
168
|
-
|
|
205
|
+
// repository.url — GitHub Actions and the market link use it. Both shapes
|
|
206
|
+
// occur: a bare string, and the {type,url} object npm documents. The object
|
|
207
|
+
// form used to slip through, because the check tested `typeof === 'string'`
|
|
208
|
+
// and every plugin in this workspace writes the object form.
|
|
209
|
+
const repoRaw = typeof json.repository === 'string' ? json.repository : json.repository?.url
|
|
210
|
+
if (typeof repoRaw === 'string' && !/github\.com/.test(repoRaw)) {
|
|
211
|
+
out.push(F('bad-repo-url', 'warn', `repository "${repoRaw}" is not a github.com URL`, 'The market listing and issue links will not resolve.', 'Use "git+https://github.com/<owner>/<repo>.git".'))
|
|
169
212
|
}
|
|
170
213
|
|
|
171
214
|
// engines — a too-low floor lets the install succeed and then crash
|
|
@@ -197,16 +240,7 @@ export function checkPatchYml(dir) {
|
|
|
197
240
|
}
|
|
198
241
|
|
|
199
242
|
// find insert rows and the package name they mount
|
|
200
|
-
const
|
|
201
|
-
const t = safeText(join(dir, 'package.json'))
|
|
202
|
-
if (!t) return null
|
|
203
|
-
try {
|
|
204
|
-
return JSON.parse(t.replace(/^\uFEFF/, ''))
|
|
205
|
-
} catch {
|
|
206
|
-
return null
|
|
207
|
-
}
|
|
208
|
-
})()
|
|
209
|
-
const name = pkgJson?.name
|
|
243
|
+
const name = readPkgJson(dir)?.name
|
|
210
244
|
|
|
211
245
|
const lines = text.split('\n')
|
|
212
246
|
const insertRows = []
|
|
@@ -337,14 +371,7 @@ export function checkDocs(dir) {
|
|
|
337
371
|
if (!existsSync(join(dir, 'LICENSE'))) {
|
|
338
372
|
out.push(F('no-license', 'error', 'LICENSE is missing', 'npm warns on publish and the market listing shows an unknown license.', 'Add a LICENSE file (MIT is the ecosystem norm).'))
|
|
339
373
|
} else {
|
|
340
|
-
const json = (
|
|
341
|
-
const t = safeText(join(dir, 'package.json'))
|
|
342
|
-
try {
|
|
343
|
-
return t ? JSON.parse(t.replace(/^\uFEFF/, '')) : null
|
|
344
|
-
} catch {
|
|
345
|
-
return null
|
|
346
|
-
}
|
|
347
|
-
})()
|
|
374
|
+
const json = readPkgJson(dir)
|
|
348
375
|
if (json?.license && json.license !== 'SEE LICENSE IN LICENSE' && !safeText(join(dir, 'LICENSE'))?.includes(json.license)) {
|
|
349
376
|
out.push(F('license-mismatch', 'warn', `package.json says "${json.license}" but the LICENSE text does not mention it`, 'A reviewer reads the mismatch as sloppiness at best.', 'Align them.'))
|
|
350
377
|
}
|
|
@@ -356,16 +383,9 @@ export function checkDocs(dir) {
|
|
|
356
383
|
// npm / GitHub / market state
|
|
357
384
|
// =====================================================================
|
|
358
385
|
|
|
359
|
-
export function checkNpmState(dir
|
|
386
|
+
export function checkNpmState(dir) {
|
|
360
387
|
const out = []
|
|
361
|
-
const json = (
|
|
362
|
-
const t = safeText(join(dir, 'package.json'))
|
|
363
|
-
try {
|
|
364
|
-
return t ? JSON.parse(t.replace(/^\uFEFF/, '')) : null
|
|
365
|
-
} catch {
|
|
366
|
-
return null
|
|
367
|
-
}
|
|
368
|
-
})()
|
|
388
|
+
const json = readPkgJson(dir)
|
|
369
389
|
if (!json?.name) return out
|
|
370
390
|
|
|
371
391
|
// unscoped + granular token = E403 on the PUT, discovered the hard way
|
|
@@ -381,11 +401,8 @@ export function checkNpmState(dir, { token, registry = 'https://registry.npmjs.o
|
|
|
381
401
|
)
|
|
382
402
|
}
|
|
383
403
|
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
const url = `${registry.replace(/\/$/, '')}/${json.name.replace('/', '%2f')}`
|
|
387
|
-
out.push(...[]) // filled asynchronously by the caller
|
|
388
|
-
}
|
|
404
|
+
// Whether the version is already live needs the network: that is
|
|
405
|
+
// checkVersionTaken / latestPublished. Nothing synchronous belongs here.
|
|
389
406
|
return out
|
|
390
407
|
}
|
|
391
408
|
|
|
@@ -519,7 +536,10 @@ export function checkEntrySource(dir) {
|
|
|
519
536
|
if (!/export\s+(?:const|function)\s+apply/.test(text)) out.push(F('no-apply-export', 'error', 'lib/index.js does not export `apply`', 'cordis calls apply(ctx, config); without it nothing happens.', 'Add `export function apply(ctx, config) {}`.'))
|
|
520
537
|
if (!/export\s+const\s+inject/.test(text)) out.push(F('no-inject', 'warn', 'lib/index.js does not export `inject`', 'Without an inject list the service is resolved late and `ctx.tools` may be undefined when apply runs.', 'Add `export const inject = [\'tools\']`.'))
|
|
521
538
|
// require() in ESM blows up at load
|
|
522
|
-
|
|
539
|
+
// Any call form, including destructuring and a member access: `const {a} = require(…)`,
|
|
540
|
+
// `x = require(…)`, `require('node:fs').readFileSync`. A bare `require(` inside a
|
|
541
|
+
// string is not matched because the regex requires a call with a quote argument.
|
|
542
|
+
if (/\brequire\s*\(\s*['"`]/.test(text)) {
|
|
523
543
|
out.push(
|
|
524
544
|
F(
|
|
525
545
|
'require-in-esm',
|
|
@@ -554,14 +574,7 @@ export function checkTests(dir) {
|
|
|
554
574
|
})()
|
|
555
575
|
const hasTest = testFiles.length > 0
|
|
556
576
|
if (!hasTest) out.push(F('no-test-files', 'warn', 'test/ has no *.test.mjs or *.spec.mjs', 'Add at least an engine test so the rules are exercised.', 'Add test/engine.test.mjs.'))
|
|
557
|
-
const pkgJson = (
|
|
558
|
-
const t = safeText(join(dir, 'package.json'))
|
|
559
|
-
try {
|
|
560
|
-
return t ? JSON.parse(t.replace(/^\uFEFF/, '')) : null
|
|
561
|
-
} catch {
|
|
562
|
-
return null
|
|
563
|
-
}
|
|
564
|
-
})()
|
|
577
|
+
const pkgJson = readPkgJson(dir)
|
|
565
578
|
if (!pkgJson?.scripts?.test) out.push(F('no-test-script', 'info', 'package.json has no "test" script', 'npm test does nothing, so CI and reviewers cannot run your checks.', 'Add "scripts": { "test": "node --test" }.'))
|
|
566
579
|
// Node 22+ loads a positional argument to --test as a module path instead of
|
|
567
580
|
// globbing it, so `node --test test/` dies with MODULE_NOT_FOUND on every run
|
|
@@ -572,6 +585,430 @@ export function checkTests(dir) {
|
|
|
572
585
|
return out
|
|
573
586
|
}
|
|
574
587
|
|
|
588
|
+
// =====================================================================
|
|
589
|
+
// Artifact truth: what npm will actually put in the tarball
|
|
590
|
+
// =====================================================================
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* `npm pack --dry-run` is the only honest answer to "what ships?". Reading
|
|
594
|
+
* `files` from package.json is a guess: npm merges it with .npmignore, adds
|
|
595
|
+
* package.json/README/LICENSE whether or not you listed them, and honours
|
|
596
|
+
* nested .gitignore files. Every number below comes from npm itself.
|
|
597
|
+
*
|
|
598
|
+
* --ignore-scripts is deliberate. A `prepack` hook (tsdown, tsc, rollup) would
|
|
599
|
+
* run a real build here and overwrite hand-edited output; that case is
|
|
600
|
+
* reported as `pack-script-build` instead of being silently executed.
|
|
601
|
+
*/
|
|
602
|
+
export async function checkPack(dir, opts = {}) {
|
|
603
|
+
const out = []
|
|
604
|
+
const warnBytes = opts.warnUnpackedBytes ?? 1_000_000
|
|
605
|
+
const maxBytes = opts.maxUnpackedBytes ?? 5_000_000
|
|
606
|
+
const json = readPkgJson(dir)
|
|
607
|
+
|
|
608
|
+
let pack = null
|
|
609
|
+
try {
|
|
610
|
+
// On Windows npm is npm.cmd, which Node refuses to exec without a shell;
|
|
611
|
+
// the command string is a fixed literal, so cmd.exe adds no injection
|
|
612
|
+
// surface. Everywhere else npm is an ordinary executable.
|
|
613
|
+
const isWin = process.platform === 'win32'
|
|
614
|
+
const cmd = isWin ? process.env.ComSpec || 'cmd.exe' : 'npm'
|
|
615
|
+
const args = isWin ? ['/d', '/s', '/c', 'npm pack --dry-run --json --ignore-scripts'] : ['pack', '--dry-run', '--json', '--ignore-scripts']
|
|
616
|
+
const { stdout } = await runFile(cmd, args, {
|
|
617
|
+
cwd: dir,
|
|
618
|
+
timeout: opts.timeoutMs ?? 60_000,
|
|
619
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
620
|
+
windowsHide: true,
|
|
621
|
+
})
|
|
622
|
+
const parsed = JSON.parse(stdout)
|
|
623
|
+
pack = Array.isArray(parsed) ? parsed[0] : parsed
|
|
624
|
+
} catch (error) {
|
|
625
|
+
out.push(
|
|
626
|
+
F(
|
|
627
|
+
'pack-unavailable',
|
|
628
|
+
'warn',
|
|
629
|
+
'npm pack --dry-run did not run',
|
|
630
|
+
`The tarball check did not run, so this is NOT a clean artifact report. ${String(error?.message || error).slice(0, 140)}`,
|
|
631
|
+
'Run `npm pack --dry-run` by hand in the plugin directory.',
|
|
632
|
+
),
|
|
633
|
+
)
|
|
634
|
+
return { findings: out, pack: null }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const files = Array.isArray(pack.files) ? pack.files : []
|
|
638
|
+
const paths = files.map((f) => String(f.path))
|
|
639
|
+
const has = (prefix) => paths.some((p) => p === prefix || p.startsWith(`${prefix}/`))
|
|
640
|
+
|
|
641
|
+
if (!has('lib')) {
|
|
642
|
+
out.push(
|
|
643
|
+
F(
|
|
644
|
+
'pack-omits-lib',
|
|
645
|
+
'error',
|
|
646
|
+
'the tarball contains no lib/ files',
|
|
647
|
+
`npm packed ${files.length} file(s) and none are under lib/: the published plugin has no code at all.`,
|
|
648
|
+
'Add "lib" to files.',
|
|
649
|
+
),
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
if (json?.dsh?.bundle?.patch && !paths.includes(String(json.dsh.bundle.patch).replace(/^\.\//, ''))) {
|
|
653
|
+
out.push(
|
|
654
|
+
F(
|
|
655
|
+
'pack-omits-patch',
|
|
656
|
+
'error',
|
|
657
|
+
`the tarball does not contain ${json.dsh.bundle.patch}`,
|
|
658
|
+
'dsh.bundle.patch names a file npm did not pack, so the installed plugin has nothing to mount.',
|
|
659
|
+
'Add it to files.',
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
if (!paths.includes('README.md')) out.push(F('pack-omits-readme', 'warn', 'the tarball has no README.md', 'npm renders it as the package page and the market reviewer reads it.', 'Add "README.md" to files.'))
|
|
664
|
+
if (!paths.includes('LICENSE')) out.push(F('pack-omits-license', 'warn', 'the tarball has no LICENSE', 'npm warns on publish and consumers get an unlicensed package.', 'Add "LICENSE" to files.'))
|
|
665
|
+
|
|
666
|
+
const unpacked = Number(pack.unpackedSize ?? 0)
|
|
667
|
+
if (unpacked > warnBytes) {
|
|
668
|
+
const biggest = [...files]
|
|
669
|
+
.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
|
|
670
|
+
.slice(0, 3)
|
|
671
|
+
.map((f) => `${f.path} ${bytesHuman(f.size)}`)
|
|
672
|
+
.join(', ')
|
|
673
|
+
out.push(
|
|
674
|
+
F(
|
|
675
|
+
'pack-oversize',
|
|
676
|
+
unpacked > maxBytes ? 'error' : 'warn',
|
|
677
|
+
`the tarball unpacks to ${bytesHuman(unpacked)}`,
|
|
678
|
+
`Every install downloads that. Largest: ${biggest}. Test fixtures and case data are the usual cause — they belong in the repo, not the package.`,
|
|
679
|
+
'Move the data out of the package, or narrow "files".',
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
for (const d of ['fixtures', 'test', 'tests', 'src']) {
|
|
685
|
+
if (has(d)) out.push(F('pack-unexpected-dir', 'warn', `the tarball includes ${d}/`, 'A development directory is being published: it bloats the install and ships code nobody runs.', `Add "!${d}" to files if it is not meant to ship.`))
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const scripts = json?.scripts || {}
|
|
689
|
+
const buildHook = scripts.prepack ? 'prepack' : scripts.prepare ? 'prepare' : null
|
|
690
|
+
if (buildHook) {
|
|
691
|
+
out.push(
|
|
692
|
+
F(
|
|
693
|
+
'pack-script-build',
|
|
694
|
+
'warn',
|
|
695
|
+
`package.json runs "${buildHook}" on publish`,
|
|
696
|
+
'This dry run used --ignore-scripts, so it describes the directory as it is now, not what a real `npm publish` would build. If the hook rewrites lib/, the published files differ from the ones you just checked.',
|
|
697
|
+
'Run the build, then re-run this gate, then publish.',
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return { findings: out, pack }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// =====================================================================
|
|
706
|
+
// Runtime truth: does the entry load, and what does it register?
|
|
707
|
+
// =====================================================================
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The card kinds the host actually renders. Read out of the built-in tools in
|
|
711
|
+
* the DSH bundle, every one of which returns { card: "generic", title, kind }
|
|
712
|
+
* with kind in this set. A plugin that writes kind: "tool" still registers and
|
|
713
|
+
* still runs — only the transcript card renders wrong, which is why nobody
|
|
714
|
+
* notices until a user mentions it.
|
|
715
|
+
*/
|
|
716
|
+
export const CARD_KINDS = new Set(['other', 'execute', 'search', 'read', 'edit', 'delete', 'fetch'])
|
|
717
|
+
|
|
718
|
+
// Runs in a child process: importing a plugin executes its module body, and a
|
|
719
|
+
// top-level throw or an infinite loop must not take the gate down with it.
|
|
720
|
+
const RUNNER = `
|
|
721
|
+
import { pathToFileURL } from 'node:url'
|
|
722
|
+
import { join } from 'node:path'
|
|
723
|
+
const dir = process.argv[1]
|
|
724
|
+
const registered = []
|
|
725
|
+
const noopService = () => new Proxy(function () {}, {
|
|
726
|
+
get: (target, prop) => {
|
|
727
|
+
// Own properties win, or ctx.tools would resolve to another no-op proxy
|
|
728
|
+
// and every registration would vanish.
|
|
729
|
+
if (Reflect.has(target, prop)) return Reflect.get(target, prop)
|
|
730
|
+
if (prop === 'then') return undefined
|
|
731
|
+
if (prop === Symbol.toPrimitive) return () => ''
|
|
732
|
+
if (prop === 'toString') return () => '[ctx]'
|
|
733
|
+
return noopService()
|
|
734
|
+
},
|
|
735
|
+
apply: () => noopService(),
|
|
736
|
+
})
|
|
737
|
+
const ctx = noopService()
|
|
738
|
+
ctx.tools = {
|
|
739
|
+
register: (def) => {
|
|
740
|
+
let card = null
|
|
741
|
+
if (def && typeof def.presentCall === 'function') {
|
|
742
|
+
try {
|
|
743
|
+
const c = def.presentCall({})
|
|
744
|
+
card = c && typeof c.kind === 'string' ? c.kind : null
|
|
745
|
+
} catch (error) {
|
|
746
|
+
card = { threw: String((error && error.message) || error) }
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
registered.push({
|
|
750
|
+
name: typeof def?.name === 'string' ? def.name : null,
|
|
751
|
+
hasExecute: typeof def?.execute === 'function',
|
|
752
|
+
hasOutputSchema: Boolean(def && def.output && def.output.schema),
|
|
753
|
+
cardKind: card,
|
|
754
|
+
})
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
const emit = (payload) => process.stdout.write('\\n@@PG@@' + JSON.stringify(payload) + '\\n')
|
|
758
|
+
let stage = 'import'
|
|
759
|
+
try {
|
|
760
|
+
const mod = await import(pathToFileURL(join(dir, 'lib', 'index.js')).href)
|
|
761
|
+
stage = 'apply'
|
|
762
|
+
if (typeof mod.apply !== 'function') throw new Error('lib/index.js does not export apply')
|
|
763
|
+
await mod.apply(ctx, {})
|
|
764
|
+
emit({ ok: true, tools: registered })
|
|
765
|
+
} catch (error) {
|
|
766
|
+
emit({ ok: false, stage, error: String((error && error.message) || error) })
|
|
767
|
+
}
|
|
768
|
+
process.exit(0)
|
|
769
|
+
`
|
|
770
|
+
|
|
771
|
+
export async function checkToolRegistrations(dir, opts = {}) {
|
|
772
|
+
const out = []
|
|
773
|
+
let payload = null
|
|
774
|
+
try {
|
|
775
|
+
const { stdout } = await runFile(process.execPath, ['--input-type=module', '-e', RUNNER, dir], {
|
|
776
|
+
timeout: opts.timeoutMs ?? 20_000,
|
|
777
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
778
|
+
windowsHide: true,
|
|
779
|
+
})
|
|
780
|
+
const line = String(stdout).split('\n').filter((l) => l.startsWith('@@PG@@')).pop()
|
|
781
|
+
if (!line) throw new Error('the child printed no result')
|
|
782
|
+
payload = JSON.parse(line.slice(6))
|
|
783
|
+
} catch (error) {
|
|
784
|
+
out.push(
|
|
785
|
+
F(
|
|
786
|
+
'tools-unavailable',
|
|
787
|
+
'warn',
|
|
788
|
+
'the isolated import did not finish',
|
|
789
|
+
`The runtime check did not run, so this is NOT a clean registration report. ${String(error?.message || error).slice(0, 140)}`,
|
|
790
|
+
'Load the plugin once in a profile and watch for a bundle error.',
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
return { findings: out, tools: null }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (!payload.ok) {
|
|
797
|
+
out.push(
|
|
798
|
+
F(
|
|
799
|
+
payload.stage === 'import' ? 'entry-import-failed' : 'apply-threw',
|
|
800
|
+
'error',
|
|
801
|
+
payload.stage === 'import' ? 'lib/index.js throws when imported' : 'apply(ctx) throws',
|
|
802
|
+
`Stage "${payload.stage}": ${payload.error}. The plugin installs and then fails to load; the host reports it as a bundle error with none of your own diagnostics.`,
|
|
803
|
+
'Fix the throw — this gate reproduces it in isolation.',
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
return { findings: out, tools: null }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const tools = Array.isArray(payload.tools) ? payload.tools : []
|
|
810
|
+
if (tools.length === 0) {
|
|
811
|
+
out.push(
|
|
812
|
+
F(
|
|
813
|
+
'tool-none-registered',
|
|
814
|
+
'warn',
|
|
815
|
+
'apply() registered no tools',
|
|
816
|
+
'A plugin that registers nothing is only useful if it adds a client panel or a service. If you expected tools, ctx.tools was undefined when apply ran.',
|
|
817
|
+
"Check `export const inject = ['tools']`.",
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const seen = new Set()
|
|
823
|
+
for (const t of tools) {
|
|
824
|
+
const label = t.name || '(unnamed)'
|
|
825
|
+
if (!t.name) out.push(F('tool-missing-name', 'error', 'a registered tool has no name', 'The host cannot address a tool without a name.', 'Give it a name.'))
|
|
826
|
+
else if (seen.has(t.name)) out.push(F('tool-duplicate-name', 'error', `two tools are registered as "${t.name}"`, 'One registration is dropped or shadows the other, and which one wins is not something you control.', 'Rename one.'))
|
|
827
|
+
else seen.add(t.name)
|
|
828
|
+
|
|
829
|
+
if (!t.hasExecute) out.push(F('tool-missing-execute', 'error', `tool "${label}" has no execute function`, "It appears in the model's tool list and throws the moment it is called.", 'Add execute(args, exec).'))
|
|
830
|
+
if (!t.hasOutputSchema) out.push(F('tool-missing-output-schema', 'warn', `tool "${label}" declares no output.schema`, 'The host validates tool output against output.schema; without one the contract is implicit and a malformed result reaches the caller.', "Add output: { schema: { type: 'json' }, render }."))
|
|
831
|
+
if (t.cardKind && typeof t.cardKind === 'object') {
|
|
832
|
+
out.push(F('tool-present-call-throws', 'error', `tool "${label}" presentCall() throws`, `presentCall renders the transcript card and must be total: ${t.cardKind.threw}`, 'Do not read args fields that may be absent.'))
|
|
833
|
+
} else if (typeof t.cardKind === 'string' && !CARD_KINDS.has(t.cardKind)) {
|
|
834
|
+
out.push(
|
|
835
|
+
F(
|
|
836
|
+
'tool-bad-card-kind',
|
|
837
|
+
'error',
|
|
838
|
+
`tool "${label}" renders card kind "${t.cardKind}", which the host does not know`,
|
|
839
|
+
`The host only understands ${[...CARD_KINDS].join(', ')}. An unknown kind does not throw at registration — the card just renders wrong, which is why it survives to release.`,
|
|
840
|
+
"Use kind: 'read' for a read-only tool.",
|
|
841
|
+
),
|
|
842
|
+
)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { findings: out, tools }
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// =====================================================================
|
|
849
|
+
// Source hygiene and service wiring
|
|
850
|
+
// =====================================================================
|
|
851
|
+
|
|
852
|
+
export function checkLibHygiene(dir) {
|
|
853
|
+
const out = []
|
|
854
|
+
for (const file of walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)) {
|
|
855
|
+
const text = safeText(file)
|
|
856
|
+
if (text === null) continue
|
|
857
|
+
const rel = relative(dir, file).replace(/\\/g, '/')
|
|
858
|
+
const lines = text.split('\n')
|
|
859
|
+
for (let i = 0; i < lines.length; i++) {
|
|
860
|
+
const code = stripComment(lines[i])
|
|
861
|
+
const at = `${rel}:${i + 1}`
|
|
862
|
+
if (/\bconsole\.log\s*\(/.test(code)) out.push(F('lib-console-log', 'warn', `${at} calls console.log`, 'Tool output is the channel; a stray console.log lands in the host log and never reaches the model. Harmless, but it is debug residue in a published file.', 'Delete it, or use console.error if it is a real warning.'))
|
|
863
|
+
if (/(?:^|[;{}\s])debugger\s*;?\s*$/.test(code)) out.push(F('lib-debugger', 'error', `${at} has a debugger statement`, 'In a published package a debugger statement stops every consumer who reaches that path.', 'Remove it.'))
|
|
864
|
+
if (/(?:[A-Za-z]:[\\/]{1,2}Users[\\/]|\/Users\/)/.test(code)) out.push(F('lib-hardcoded-path', 'warn', `${at} contains an absolute path from this machine`, "It does not exist on the consumer's machine, so that code path silently fails there.", 'Resolve it from homedir() or ctx instead.'))
|
|
865
|
+
}
|
|
866
|
+
if (/new\s+URL\s*\([\s\S]{0,240}?\)\s*\.pathname/.test(text)) {
|
|
867
|
+
out.push(
|
|
868
|
+
F(
|
|
869
|
+
'lib-url-pathname',
|
|
870
|
+
'warn',
|
|
871
|
+
`${rel} reads .pathname from a URL`,
|
|
872
|
+
'pathname percent-encodes non-ASCII, so a plugin directory under a Chinese path becomes %E6%8F%92... and every path derived from it is wrong. This cost a test suite a day.',
|
|
873
|
+
'Use fileURLToPath(new URL(...)) instead.',
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return out
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const INJECT_HINTS = [
|
|
882
|
+
['tools', /ctx\.tools\b/],
|
|
883
|
+
['web', /ctx\.web\b/],
|
|
884
|
+
['webServer', /ctx\.webServer\b/],
|
|
885
|
+
['timer', /ctx\.(?:setInterval|setTimeout|clearInterval|clearTimeout)\b/],
|
|
886
|
+
]
|
|
887
|
+
|
|
888
|
+
export function checkInjectCoverage(dir) {
|
|
889
|
+
const out = []
|
|
890
|
+
const text = safeText(join(dir, 'lib', 'index.js'))
|
|
891
|
+
if (text === null) return out
|
|
892
|
+
const code = text.split('\n').map(stripComment).join('\n')
|
|
893
|
+
const declared = /export\s+const\s+inject\s*=\s*\[([^\]]*)\]/.exec(code)
|
|
894
|
+
if (!declared) return out // checkEntrySource already reports a missing inject
|
|
895
|
+
const names = new Set([...declared[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]))
|
|
896
|
+
for (const [service, re] of INJECT_HINTS) {
|
|
897
|
+
if (!re.test(code) || names.has(service)) continue
|
|
898
|
+
out.push(
|
|
899
|
+
F(
|
|
900
|
+
`inject-missing-${service}`,
|
|
901
|
+
'error',
|
|
902
|
+
`lib/index.js uses ctx.${service} but does not inject "${service}"`,
|
|
903
|
+
'cordis resolves injected services before apply runs. Without the inject entry the property may still be undefined at that moment, so the plugin throws or silently registers nothing — and it works on your machine only if another plugin happened to pull the service in first.',
|
|
904
|
+
`Add "${service}" to export const inject.`,
|
|
905
|
+
),
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
return out
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export function checkReadmeTruth(dir, toolNames = null) {
|
|
912
|
+
const out = []
|
|
913
|
+
const text = safeText(join(dir, 'README.md'))
|
|
914
|
+
if (text === null) return out
|
|
915
|
+
|
|
916
|
+
if (Array.isArray(toolNames) && toolNames.length > 0) {
|
|
917
|
+
const missing = toolNames.filter((n) => n && !text.includes(n))
|
|
918
|
+
if (missing.length > 0) {
|
|
919
|
+
out.push(
|
|
920
|
+
F(
|
|
921
|
+
'readme-tool-undocumented',
|
|
922
|
+
'warn',
|
|
923
|
+
`README does not name ${missing.length} registered tool(s)`,
|
|
924
|
+
`Registered but never mentioned: ${missing.join(', ')}. A reader cannot discover them, and the market reviewer reads the README, not the source.`,
|
|
925
|
+
'Add them to the tool table.',
|
|
926
|
+
),
|
|
927
|
+
)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// The boundary belongs to the English form only: a Chinese character is not
|
|
932
|
+
// a word character, so `单测\b` never matches and "27 个单测。" was invisible.
|
|
933
|
+
const claimed = /(\d+)\s*(?:个单测|unit tests|tests\b)/.exec(text)
|
|
934
|
+
if (claimed) {
|
|
935
|
+
const actual = countTests(dir)
|
|
936
|
+
if (actual > 0 && Number(claimed[1]) !== actual) {
|
|
937
|
+
out.push(F('readme-test-count', 'info', `README claims ${claimed[1]} tests but test/ defines ${actual}`, 'A stale count is the first thing a careful reader checks, and being wrong there makes them distrust everything else.', `Update the README to ${actual}.`))
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function countTests(dir) {
|
|
944
|
+
let n = 0
|
|
945
|
+
for (const file of walkFiles(join(dir, 'test'), /\.(?:test|spec)\.(?:mjs|js|cjs)$/)) {
|
|
946
|
+
const text = safeText(file)
|
|
947
|
+
if (!text) continue
|
|
948
|
+
n += (text.match(/^\s*(?:test|it)\s*\(/gm) || []).length
|
|
949
|
+
}
|
|
950
|
+
return n
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// =====================================================================
|
|
954
|
+
// Post-publish: is the tarball on the registry the one you gated?
|
|
955
|
+
// =====================================================================
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* `npm pack --dry-run` and the registry's dist.shasum are directly
|
|
959
|
+
* comparable — verified against a real publish. So "did the thing I checked
|
|
960
|
+
* actually ship?" is an exact comparison, not a version-number guess.
|
|
961
|
+
*/
|
|
962
|
+
export async function checkPublishedMatches(name, version, localPack, { registry = 'https://registry.npmjs.org', timeoutMs = 8000 } = {}) {
|
|
963
|
+
const out = []
|
|
964
|
+
if (!name || !version) return out
|
|
965
|
+
|
|
966
|
+
let json = null
|
|
967
|
+
try {
|
|
968
|
+
const res = await fetch(`${registry.replace(/\/$/, '')}/${name.replace('/', '%2f')}`, {
|
|
969
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
970
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
971
|
+
})
|
|
972
|
+
if (res.status === 404) return [F('publish-not-served', 'warn', `${name} is not on the registry`, 'Nothing has been published under this name yet.', 'npm publish --access public')]
|
|
973
|
+
if (!res.ok) return out // unreachable is unknown, not a failure
|
|
974
|
+
json = await res.json()
|
|
975
|
+
} catch {
|
|
976
|
+
return out
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const dist = json?.versions?.[version]?.dist
|
|
980
|
+
if (!dist) {
|
|
981
|
+
return [F('publish-not-served', 'warn', `${name}@${version} is not on the registry`, `The live latest is ${json?.['dist-tags']?.latest ?? 'unknown'}. Either it was never published, or the registry has not finished processing it (a 202 then a 404 for a minute or two is normal).`, 'npm publish --access public')]
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (localPack?.shasum && dist.shasum && localPack.shasum !== dist.shasum) {
|
|
985
|
+
out.push(
|
|
986
|
+
F(
|
|
987
|
+
'publish-served-mismatch',
|
|
988
|
+
'error',
|
|
989
|
+
`${name}@${version} is published, but its tarball is not this directory`,
|
|
990
|
+
`registry shasum ${dist.shasum} (${bytesHuman(dist.unpackedSize)}, ${dist.fileCount} files) vs local ${localPack.shasum} (${bytesHuman(localPack.unpackedSize)}, ${(localPack.files || []).length} files). The version number is spent: npm will not let you overwrite it, and every install gets the older bytes.`,
|
|
991
|
+
`Bump to ${bumpPatch(version)} and publish again.`,
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
} else {
|
|
995
|
+
out.push(F('publish-served', 'info', `${name}@${version} on the registry matches this directory`, `shasum ${dist.shasum}, ${bytesHuman(dist.unpackedSize)}, ${dist.fileCount} files — the tarball you gated is the one that shipped.`, null))
|
|
996
|
+
}
|
|
997
|
+
return out
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** The async half of the gate: everything that needs a subprocess or the network. */
|
|
1001
|
+
export async function runArtifactChecks(dir, opts = {}) {
|
|
1002
|
+
const packResult = opts.skipPack ? { findings: [], pack: null } : await checkPack(dir, opts)
|
|
1003
|
+
const toolResult = opts.skipTools ? { findings: [], tools: null } : await checkToolRegistrations(dir, opts)
|
|
1004
|
+
const names = toolResult.tools ? toolResult.tools.map((t) => t.name).filter(Boolean) : null
|
|
1005
|
+
return {
|
|
1006
|
+
findings: [...packResult.findings, ...toolResult.findings, ...checkReadmeTruth(dir, names)],
|
|
1007
|
+
pack: packResult.pack,
|
|
1008
|
+
tools: toolResult.tools,
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
575
1012
|
// =====================================================================
|
|
576
1013
|
// Orchestration
|
|
577
1014
|
// =====================================================================
|
|
@@ -580,6 +1017,8 @@ export function runStaticChecks(dir) {
|
|
|
580
1017
|
...checkPackageJson(dir),
|
|
581
1018
|
...checkPatchYml(dir),
|
|
582
1019
|
...checkEntrySource(dir),
|
|
1020
|
+
...checkInjectCoverage(dir),
|
|
1021
|
+
...checkLibHygiene(dir),
|
|
583
1022
|
...checkDocs(dir),
|
|
584
1023
|
...checkTests(dir),
|
|
585
1024
|
...checkNpmState(dir),
|
|
@@ -590,11 +1029,20 @@ export function runStaticChecks(dir) {
|
|
|
590
1029
|
const RULE_ORDER = [
|
|
591
1030
|
'missing-file', 'unreadable', 'utf8-bom', 'json-parse', 'yaml-bom', 'invalid-name', 'invalid-version',
|
|
592
1031
|
'missing-field', 'not-esm', 'files-omits-lib', 'files-omits-patch', 'no-exports', 'client-without-export',
|
|
593
|
-
'no-patch-file', 'no-insert-row', 'insert-
|
|
1032
|
+
'no-patch-file', 'no-insert-row', 'insert-missing-name', 'insert-name-mismatch',
|
|
594
1033
|
'patch-name-unresolvable', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
|
|
595
|
-
'
|
|
1034
|
+
'entry-import-failed', 'apply-threw', 'tools-unavailable',
|
|
1035
|
+
'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
|
|
1036
|
+
'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
|
|
1037
|
+
'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered',
|
|
1038
|
+
'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname',
|
|
1039
|
+
'pack-omits-lib', 'pack-omits-patch', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license',
|
|
1040
|
+
'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
|
|
1041
|
+
'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'publish-not-served',
|
|
1042
|
+
'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
|
|
596
1043
|
'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc',
|
|
597
1044
|
'market-no-en', 'market-no-zh', 'unscoped-name', 'no-git', 'no-readme', 'readme-no-install', 'readme-no-profile',
|
|
1045
|
+
'readme-tool-undocumented', 'readme-test-count',
|
|
598
1046
|
'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
|
|
599
1047
|
]
|
|
600
1048
|
|
package/lib/index.js
CHANGED
|
@@ -15,12 +15,16 @@ import {
|
|
|
15
15
|
checkMarketEntry,
|
|
16
16
|
checkNpmState,
|
|
17
17
|
checkPackageJson,
|
|
18
|
+
checkPack,
|
|
18
19
|
checkPatchYml,
|
|
20
|
+
checkPublishedMatches,
|
|
19
21
|
checkTests,
|
|
22
|
+
checkToolRegistrations,
|
|
20
23
|
checkVersionTaken,
|
|
21
24
|
compareVersions,
|
|
22
25
|
latestPublished,
|
|
23
26
|
renderReport,
|
|
27
|
+
runArtifactChecks,
|
|
24
28
|
runStaticChecks,
|
|
25
29
|
} from './engine.js'
|
|
26
30
|
|
|
@@ -85,18 +89,22 @@ export function apply(ctx, config = {}) {
|
|
|
85
89
|
{
|
|
86
90
|
name: 'publish_gate',
|
|
87
91
|
description:
|
|
88
|
-
'Run the full pre-publish gate over a DSH plugin directory
|
|
92
|
+
'Run the full pre-publish gate over a DSH plugin directory. Three layers. (1) Manifest: missing exports["."], files omitting lib/, no "type": "module", UTF-8 BOM, JSON/YAML parse errors, cordis.patch.yml insert rows at the wrong indent (the bug that makes a plugin install and never mount), insert name mismatches, missing entry exports, missing LICENSE or README install line, missing tests, unscoped npm names (the 403 you cannot diagnose). (2) Artifact: runs `npm pack --dry-run` and reads what npm will actually ship — lib/ missing from the tarball, a dsh.bundle.patch that never gets packed, oversize tarballs (test fixtures are the usual cause), dev directories shipping, and a prepack hook that means the dry run did not build. (3) Runtime: imports the entry in an isolated child process, calls apply(ctx) with a fake ctx, and reports a throwing import, a throwing apply, tools with no execute, duplicate tool names, a presentCall that throws, and card kinds the host does not render. Deterministic: every rule is a file, subprocess or API check, no LLM. Returns {ok, report, findings:[{rule,severity,title,detail,fix}], counts}.',
|
|
89
93
|
parameters: {
|
|
90
94
|
type: 'object',
|
|
91
95
|
properties: {
|
|
92
96
|
dir: { type: 'string', description: 'Plugin directory to gate. Defaults to the current working directory.' },
|
|
93
97
|
skipNetwork: { type: 'boolean', description: 'Skip the npm registry and GitHub checks (default false).' },
|
|
98
|
+
skipPack: { type: 'boolean', description: 'Skip `npm pack --dry-run` (default false). Faster, but says nothing about the real tarball.' },
|
|
99
|
+
skipTools: { type: 'boolean', description: 'Skip the isolated import of the plugin entry (default false).' },
|
|
94
100
|
},
|
|
95
101
|
},
|
|
96
102
|
async execute(args) {
|
|
97
103
|
const dir = resolveDir(args.dir)
|
|
98
104
|
if (!dir) return { ok: false, error: 'no plugin directory given and cwd has no package.json' }
|
|
99
105
|
const findings = runStaticChecks(dir)
|
|
106
|
+
const artifact = await runArtifactChecks(dir, { skipPack: args.skipPack, skipTools: args.skipTools })
|
|
107
|
+
findings.push(...artifact.findings)
|
|
100
108
|
if (!args.skipNetwork) findings.push(...(await networkGate(dir)))
|
|
101
109
|
const counts = {
|
|
102
110
|
error: findings.filter((f) => f.severity === 'error').length,
|
|
@@ -108,11 +116,66 @@ export function apply(ctx, config = {}) {
|
|
|
108
116
|
blockers: counts.error,
|
|
109
117
|
counts,
|
|
110
118
|
findings,
|
|
119
|
+
tools: artifact.tools,
|
|
120
|
+
unpackedSize: artifact.pack?.unpackedSize ?? null,
|
|
111
121
|
report: renderReport(findings, { title: `publish gate · ${dir}` }),
|
|
112
122
|
safeToPublish: counts.error === 0,
|
|
113
123
|
}
|
|
114
124
|
},
|
|
115
125
|
},
|
|
126
|
+
{
|
|
127
|
+
name: 'publish_gate_tools',
|
|
128
|
+
description:
|
|
129
|
+
'Import a plugin entry in an isolated child process, call apply(ctx) with a no-op context, and report what it actually registered. Catches the failures the manifest cannot show: the entry throws when imported, apply throws, a tool has no execute function, two tools share a name, presentCall throws, and a card kind the host does not render (the host only knows other/execute/search/read/edit/delete/fetch — anything else registers fine and renders a broken card). 20 s timeout, plugin code never runs in the host process. Returns {ok, tools:[{name,hasExecute,hasOutputSchema,cardKind}], findings, report}.',
|
|
130
|
+
parameters: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
dir: { type: 'string', description: 'Plugin directory.' },
|
|
134
|
+
timeoutMs: { type: 'number', description: 'Kill the child after this long (default 20000).' },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
async execute(args) {
|
|
138
|
+
const dir = resolveDir(args.dir)
|
|
139
|
+
if (!dir) return { ok: false, error: 'no plugin directory given' }
|
|
140
|
+
const { findings, tools } = await checkToolRegistrations(dir, { timeoutMs: args.timeoutMs })
|
|
141
|
+
return { ok: true, tools, findings, report: renderReport(findings, { title: `runtime gate · ${dir}` }) }
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'publish_gate_verify',
|
|
146
|
+
description:
|
|
147
|
+
'Check that the tarball on the npm registry is the one in this directory. Runs `npm pack --dry-run` and compares its shasum with the registry dist.shasum — an exact comparison, not a version-number guess, so it catches "I bumped and forgot to publish" and "I published, then edited lib/". Reports publish-served (match), publish-served-mismatch (the version is spent and the registry holds different bytes), or publish-not-served (not published, or still inside the registry\'s one-minute 202-then-404 window). Run it after `npm publish`. Returns {ok, name, version, published, shasumMatch, localShasum, unpackedSize, files, findings, report}.',
|
|
148
|
+
parameters: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
dir: { type: 'string', description: 'Plugin directory.' },
|
|
152
|
+
version: { type: 'string', description: 'Version to verify (defaults to package.json version).' },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
async execute(args) {
|
|
156
|
+
const dir = resolveDir(args.dir)
|
|
157
|
+
if (!dir) return { ok: false, error: 'no plugin directory given' }
|
|
158
|
+
const json = readJson(join(dir, 'package.json'))
|
|
159
|
+
if (!json?.name) return { ok: false, error: 'no package.json' }
|
|
160
|
+
const version = args.version || json.version
|
|
161
|
+
const { pack, findings: packFindings } = await checkPack(dir)
|
|
162
|
+
if (!pack) return { ok: false, error: `npm pack --dry-run did not run, so there is nothing to compare: ${packFindings.map((f) => f.title).join('; ')}` }
|
|
163
|
+
const findings = await checkPublishedMatches(json.name, version, pack, { registry: cfg.registry, timeoutMs: cfg.networkTimeoutMs })
|
|
164
|
+
const served = findings.some((f) => f.rule === 'publish-served')
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
name: json.name,
|
|
168
|
+
version,
|
|
169
|
+
published: served || findings.some((f) => f.rule === 'publish-served-mismatch'),
|
|
170
|
+
shasumMatch: served,
|
|
171
|
+
localShasum: pack.shasum,
|
|
172
|
+
unpackedSize: pack.unpackedSize,
|
|
173
|
+
files: (pack.files || []).length,
|
|
174
|
+
findings,
|
|
175
|
+
report: renderReport(findings, { title: `publish verify · ${json.name}@${version}` }),
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
},
|
|
116
179
|
{
|
|
117
180
|
name: 'publish_gate_patch',
|
|
118
181
|
description:
|
|
@@ -195,11 +258,12 @@ export function apply(ctx, config = {}) {
|
|
|
195
258
|
{
|
|
196
259
|
name: 'publish_gate_doctor',
|
|
197
260
|
description:
|
|
198
|
-
'Run the gate over every plugin in a directory tree (e.g. a workspace holding several plugin checkouts) and rank them by blocker count, so you can see which one is not ready to ship before you start a publish session.
|
|
261
|
+
'Run the gate over every plugin in a directory tree (e.g. a workspace holding several plugin checkouts) and rank them by blocker count, so you can see which one is not ready to ship before you start a publish session. No registry or GitHub calls, so it is fast and works offline; each row also carries the real tarball size from `npm pack --dry-run` (pass skipPack to skip that and stay purely static). Returns {ok, plugins:[{dir,name,version,blockers,warns,notes,unpackedSize,files,top}], totals}.',
|
|
199
262
|
parameters: {
|
|
200
263
|
type: 'object',
|
|
201
264
|
properties: {
|
|
202
265
|
root: { type: 'string', description: 'Directory containing plugin checkouts.' },
|
|
266
|
+
skipPack: { type: 'boolean', description: 'Skip `npm pack --dry-run` per plugin (default false).' },
|
|
203
267
|
},
|
|
204
268
|
},
|
|
205
269
|
async execute(args) {
|
|
@@ -219,6 +283,7 @@ export function apply(ctx, config = {}) {
|
|
|
219
283
|
if (!json?.name || !existsSync(join(dir, 'lib'))) continue
|
|
220
284
|
const findings = runStaticChecks(dir)
|
|
221
285
|
const top = findings.find((f) => f.severity === 'error')
|
|
286
|
+
const pack = args.skipPack ? null : (await checkPack(dir)).pack
|
|
222
287
|
plugins.push({
|
|
223
288
|
dir,
|
|
224
289
|
name: json.name,
|
|
@@ -226,13 +291,20 @@ export function apply(ctx, config = {}) {
|
|
|
226
291
|
blockers: findings.filter((f) => f.severity === 'error').length,
|
|
227
292
|
warns: findings.filter((f) => f.severity === 'warn').length,
|
|
228
293
|
notes: findings.filter((f) => f.severity === 'info').length,
|
|
294
|
+
unpackedSize: pack?.unpackedSize ?? null,
|
|
295
|
+
files: pack?.files?.length ?? null,
|
|
229
296
|
top: top ? `${top.rule}: ${top.title}` : null,
|
|
230
297
|
})
|
|
231
298
|
}
|
|
232
299
|
plugins.sort((a, b) => b.blockers - a.blockers || b.warns - a.warns)
|
|
233
300
|
const totals = plugins.reduce(
|
|
234
|
-
(acc, p) => ({
|
|
235
|
-
|
|
301
|
+
(acc, p) => ({
|
|
302
|
+
blockers: acc.blockers + p.blockers,
|
|
303
|
+
warns: acc.warns + p.warns,
|
|
304
|
+
notes: acc.notes + p.notes,
|
|
305
|
+
unpackedBytes: acc.unpackedBytes + (p.unpackedSize || 0),
|
|
306
|
+
}),
|
|
307
|
+
{ blockers: 0, warns: 0, notes: 0, unpackedBytes: 0 },
|
|
236
308
|
)
|
|
237
309
|
return { ok: true, plugins, totals }
|
|
238
310
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bushizhanghan/dsh-publish-gate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Helps DSH plugin AUTHORS publish their plugins: a
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Helps DSH plugin AUTHORS publish their plugins: a three-layer gate that turns every silent failure in the publish path into a deterministic check. Manifest layer — package.json completeness, BOM, YAML/JSON parseability, patch insert correctness, npm scope permissions, GitHub repo and token state, market entry format, LICENSE/README, tests. Artifact layer — runs `npm pack --dry-run` and reads what npm will really ship: code missing from the tarball, an unpacked patch, oversize packages, dev directories. Runtime layer — imports the entry in an isolated child process, calls apply(ctx), and reports tools that never register, missing execute, duplicate names, throwing presentCall and card kinds the host does not render. Then GitHub + npm + market, and a post-publish shasum check that proves the registry holds these exact bytes. 帮助插件作者一键发布:写完插件后跑一遍三层门禁(清单 / 产物 / 运行时),把发布链路上的每个静默坑变成确定性检查,然后 GitHub + npm + 市场一键三连。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|