@bushizhanghan/dsh-publish-gate 0.1.6 → 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 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
- 六个 agent 工具:全量门禁、patch 专项、市场条目校验、版本推进、多插件体检、发布命令清单。
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` | 全量门禁:静态检查 + 网络检查,返回 `safeToPublish` |
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`,以及市场条目的 `market-*` 全系列。
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
- - **静态检查不需要网络**:`publish_gate` 默认带网络检查(版本占用、repo 存在),但 `skipNetwork` 让它在离线和 CI 里同样可用;网络失败一律降级成「未知」,不让门禁本身变成障碍。
86
- - **只读**:修不修由你定,每条 finding 都带 `fix` 文案。门禁的职责是把话说清楚,不是替你做决定。
87
- - **确定性**:全部规则是文件内容或 registry/GitHub API 检查,没有 LLM 调用——一条规则答错,你可以直接看规则。
88
- - **自举**:这个插件的测试把四个已发布插件当 fixture,断言它们在门禁下零 blocker;规则如果有任何误报倾向,测试先炸。
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 # 27 个测试(含 4 个真实插件作 fixture)
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, statSync } from 'node:fs'
10
- import { join, resolve } from 'node:path'
11
- import { homedir } from 'node:os'
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
- if (typeof json.repository === 'string' && /^git\+/.test(json.repository) === false && !/github\.com/.test(json.repository)) {
168
- out.push(F('bad-repo-url', 'warn', `repository "${json.repository}" is not a github.com URL`, 'The market listing and issue links will not resolve.', 'Use "git+https://github.com/<owner>/<repo>.git".'))
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 pkgJson = (() => {
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, { token, registry = 'https://registry.npmjs.org' } = {}) {
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
- // is the version already live? publishing the same version twice is a 403
385
- if (token) {
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
 
@@ -557,14 +574,7 @@ export function checkTests(dir) {
557
574
  })()
558
575
  const hasTest = testFiles.length > 0
559
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.'))
560
- const pkgJson = (() => {
561
- const t = safeText(join(dir, 'package.json'))
562
- try {
563
- return t ? JSON.parse(t.replace(/^\uFEFF/, '')) : null
564
- } catch {
565
- return null
566
- }
567
- })()
577
+ const pkgJson = readPkgJson(dir)
568
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" }.'))
569
579
  // Node 22+ loads a positional argument to --test as a module path instead of
570
580
  // globbing it, so `node --test test/` dies with MODULE_NOT_FOUND on every run
@@ -575,6 +585,430 @@ export function checkTests(dir) {
575
585
  return out
576
586
  }
577
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
+
578
1012
  // =====================================================================
579
1013
  // Orchestration
580
1014
  // =====================================================================
@@ -583,6 +1017,8 @@ export function runStaticChecks(dir) {
583
1017
  ...checkPackageJson(dir),
584
1018
  ...checkPatchYml(dir),
585
1019
  ...checkEntrySource(dir),
1020
+ ...checkInjectCoverage(dir),
1021
+ ...checkLibHygiene(dir),
586
1022
  ...checkDocs(dir),
587
1023
  ...checkTests(dir),
588
1024
  ...checkNpmState(dir),
@@ -593,11 +1029,20 @@ export function runStaticChecks(dir) {
593
1029
  const RULE_ORDER = [
594
1030
  'missing-file', 'unreadable', 'utf8-bom', 'json-parse', 'yaml-bom', 'invalid-name', 'invalid-version',
595
1031
  'missing-field', 'not-esm', 'files-omits-lib', 'files-omits-patch', 'no-exports', 'client-without-export',
596
- 'no-patch-file', 'no-insert-row', 'insert-wrong-indent', 'insert-missing-name', 'insert-name-mismatch',
1032
+ 'no-patch-file', 'no-insert-row', 'insert-missing-name', 'insert-name-mismatch',
597
1033
  'patch-name-unresolvable', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
598
- 'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'market-empty', 'market-no-url', 'market-url-not-github',
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',
599
1043
  'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc',
600
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',
601
1046
  'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
602
1047
  ]
603
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 and report every blocker: package.json completeness (missing exports["."], files omitting lib/, no "type": "module"), UTF-8 BOM, JSON/YAML parse errors, cordis.patch.yml insert rows written 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), and — with network — versions already on the registry and missing GitHub repos. Deterministic: every rule is a file or API check, no LLM. Returns {ok, report, findings:[{rule,severity,title,detail,fix}], counts}.',
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. Static checks only — no registry or GitHub calls, so it is fast and works offline. Returns {ok, plugins:[{dir,blockers,warns,notes,top}], totals}.',
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) => ({ blockers: acc.blockers + p.blockers, warns: acc.warns + p.warns, notes: acc.notes + p.notes }),
235
- { blockers: 0, warns: 0, notes: 0 },
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.1.6",
4
- "description": "Helps DSH plugin AUTHORS publish their plugins: a one-command gate that turns every silent failure in the publish path into a deterministic check — package.json completeness, BOM, YAML/JSON parseability, junction and patch insert correctness, npm scope permissions, GitHub repo and token state, market entry format, LICENSE/README, test suite. 帮助插件作者一键发布:写完插件后跑一遍门禁,把发布链路上的每个静默坑变成确定性检查,然后 GitHub + npm + 市场一键三连。",
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": {