@bushizhanghan/dsh-publish-gate 0.2.0 → 0.3.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
@@ -29,11 +29,12 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
29
29
 
30
30
  | 工具 | 作用 |
31
31
  |---|---|
32
- | `publish_gate` | 全量门禁:清单 + 产物 + 运行时 + 网络,返回 `safeToPublish`(`skipPack` / `skipTools` / `skipNetwork` 可逐层跳过) |
32
+ | `publish_gate` | 全量门禁:清单 + 产物 + 运行时 + 浏览器半边 + 网络,返回 `safeToPublish`(`skipPack` / `skipTools` / `skipClient` / `skipNetwork` 可逐层跳过) |
33
33
  | `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 |
34
+ | `publish_gate_client` | 只跑浏览器半边:在子进程里假造 `window.__ModuleLoader__`,看 bundle 会不会加载、`apply` 会不会抛、到底往哪个服务注册了东西、`inject` 有没有漏 |
34
35
  | `publish_gate_verify` | 发后校验:`npm pack --dry-run` 的 shasum 和 registry 上的 `dist.shasum` 逐字节比,比版本号猜准 |
35
36
  | `publish_gate_patch` | 只查 `cordis.patch.yml`——「插件装了但工具没出现」的第一个怀疑对象 |
36
- | `publish_gate_market` | 校验市场条目 YAML(url/name/category/description 双语) |
37
+ | `publish_gate_market` | 校验市场条目 YAML(url/name/category/description 双语);传 `dir` 则改查只有线上才能回答的三件事:这个仓库**已经在目录里**(再开 PR 就是重复条目)、另一个条目也在发同一个 npm 包、仓库还**不满 24 小时**(提交门禁会失败并自己重跑) |
37
38
  | `publish_gate_next_version` | 当前版本、是否已被 registry 占用、建议 bump 到多少(本地比 npm 旧时从 npm 的 latest 往上加,不会推荐一个已被占用的版本) |
38
39
  | `publish_gate_doctor` | 扫整个目录,按 blocker 数排序,附带每个插件的真实包体积,看哪个还不能发 |
39
40
  | `publish_gate_commands` | 按顺序列出发布命令,并标出这台机器已经走到哪一步 |
@@ -50,9 +51,14 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
50
51
  | `files-omits-lib` | 发出去是空包——**npm 不警告** |
51
52
  | `files-omits-patch` | `dsh.bundle.patch` 指的文件不在 files 里,装上不挂载 |
52
53
  | `no-exports` | 缺 `exports["."]`,宿主解析不到入口 |
54
+ | `private-field-set` | 还留着脚手架模板的 `"private": true`,npm 直接 EPRIVATE 拒发 |
53
55
  | `client-without-export` | 声明了 client 平台却没导出 `./client`,宿主半边在、侧栏永不出现 |
56
+ | `client-without-platform` | 反过来:导出了 `./client` 却没写 `dsh.client.platform`。文件发出去了,宿主不知道要加载它——装上后一个字节都不执行,而且没有任何报错 |
54
57
  | `not-esm` | 没 `"type": "module"`,`import` 在加载时炸 |
55
58
  | `no-insert-row` | patch 里只有 override,永远不挂载(页面报的仍然是这条——顶层 `- id:` 是 patch 不是挂载) |
59
+ | `bundle-patch-not-declared` | 有 `cordis.patch.yml` 但清单里没有 `dsh.bundle.patch`——patch 是死的,装了不挂载 |
60
+ | `entry-path-missing` | `main` / `exports["."]` / `exports["./client"]` 指的文件不存在,宿主解析到空 |
61
+ | `inject-missing-*` | 用了 `ctx.tools` / `ctx.web` / `ctx.webServer` / `ctx.setInterval` 却没在 `inject` 里声明,cordis 永远不激活 |
56
62
  | `patch-not-mount` | info:这些顶层 `- id:` 是在覆盖已有条目,合法,但**不是**挂载 |
57
63
  | `insert-missing-name` / `insert-name-mismatch` | 挂载名缺失或与 package.json 不一致 |
58
64
  | `no-patch-file` | 有 `lib/index.js` 但没有 `cordis.patch.yml` |
@@ -63,7 +69,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
63
69
  | `registry-unreachable` | registry 回了 500/429 —— 版本检查**没跑**,所以别把它当干净 |
64
70
 
65
71
  ### 值得在发布前知道的(warn / info)
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-*` 全系列。
72
+ `unscoped-name`(granular token 的 403)、`repo-missing`、`patch-name-unresolvable`(junction 放错层级)、`inject-unused-service`(`inject` 里写了个拼错的服务名,cordis 一直等它,插件静默不激活)、`no-publish-config-access`(scoped 包没写 `publishConfig.access`,只是提醒,因为命令里本来就带 `--access public`)、`publish-config-registry`(`publishConfig.registry` 指到内网 registry,版本发到别人装不到的地方)、`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
73
 
68
74
  ### 产物层:npm 到底会发出什么
69
75
 
@@ -73,8 +79,10 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
73
79
  |---|---|
74
80
  | `pack-omits-lib` | tarball 里没有 `lib/`——发出去是空包,**npm 不警告** |
75
81
  | `pack-omits-patch` | `dsh.bundle.patch` 指的文件没进 tarball,装上不挂载 |
82
+ | `pack-omits-client` | `exports["./client"]` 指的文件没进 tarball。npm **不会**因为它在 `exports` 里就强制入包(只强制 `main`),所以 `files` 里没写它就会静默丢掉——本地能跑、装了没侧栏 |
76
83
  | `pack-omits-readme` / `pack-omits-license` | README / LICENSE 没进包 |
77
84
  | `pack-oversize` | 解包 >5 MB 是 error、>1 MB 是 warn,并点名最大的三个文件。测试 fixture 是常见元凶——我的一个插件曾把 20 MB 的算例目录发上去,装了它的人每次都要下 20 MB |
85
+ | `pack-ships-secret` | tarball 里有 `.env` / `.npmrc` / `*.pem` / `*.key` / `id_rsa`。没写 `files` 字段时 npm 会把 `.gitignore` 没排除的一切都打包,而包一发布就是公开的、npm 不会帮你撤回 |
78
86
  | `pack-unexpected-dir` | `fixtures` / `test` / `tests` / `src` 被打进包里 |
79
87
  | `pack-script-build` | 有 `prepack` / `prepare`:dry run 用了 `--ignore-scripts`,这次清单**不代表**真正发布时的产物 |
80
88
  | `pack-unavailable` | npm 跑不起来(warn)——产物层没跑,别把它当干净 |
@@ -92,10 +100,53 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
92
100
  | `tool-duplicate-name` | 两个工具同名,后者静默覆盖前者 |
93
101
  | `tool-present-call-throws` | `presentCall` 抛——调用记录渲染时炸 |
94
102
  | `tool-bad-card-kind` | `card: 'generic'` 的 `kind` 不在宿主认识的那一组里(`other` / `execute` / `search` / `read` / `edit` / `delete` / `fetch`)。注册不报错,卡片渲染坏掉——我五个插件一起踩过 |
103
+ | `tool-bad-parameter-type` | `parameters` 里某个 `type` 写错(`"strin"`)。宿主把 `parameters` 渲染进 prompt、但**从不校验**调用值,所以这个 typo 只是悄悄把类型提示弄丢 |
104
+
105
+ ### 浏览器半边:侧栏面板到底会不会出现
106
+
107
+ 清单层只检查「声明了 client 平台就得有 `exports["./client"]`」,可这一步过了之后还能坏很多次。而且它坏得很安静:宿主半边照样跑、工具照样能用,只是侧栏里什么都没有——看起来像 UI bug,其实是打包/注册 bug。
108
+
109
+ 所以这一层真的把那个 bundle 在一个子进程里跑一遍:造一个假的 `window.__ModuleLoader__`、假的 `require('react')`,再给一个 `ctx`——`effect` / `inject` 会立刻执行回调,其余服务(`sidebarRightTabs`、`slots`…)每被调用一次就记一条。
110
+
111
+ | 规则 | 抓什么 |
112
+ |---|---|
113
+ | `client-file-missing` | `exports["./client"]` 指的文件不存在 |
114
+ | `client-no-module-loader` | bundle 里没有 `window.__ModuleLoader__.load`——宿主根本不会执行它 |
115
+ | `client-load-not-called` | 文件能解析,但跑完没调用 `load`(常见:包在 `if (typeof window !== 'undefined')` 里,判断写错了) |
116
+ | `client-factory-throws` | `factory(require)` 抛 |
117
+ | `client-no-apply` | `factory` 返回的对象里没有 `apply` |
118
+ | `client-apply-throws` | `apply(ctx)` 抛 |
119
+ | `client-no-registration` | `apply` 跑完一次服务都没调用——面板不会出现 |
120
+ | `client-inject-missing-<service>` | `apply` 用了 `ctx.sidebarRightTabs` 但 `inject` 数组里没写它。cordis 不会把没声明的服务给你,于是 `ctx.sidebarRightTabs` 是 `undefined`,`apply` 一碰就抛(或静默什么都没注册) |
121
+ | `client-parse-failed` | 文件有语法错误。**单独判**:`SyntaxError` 说明 bundle 本身是坏的,别的报错说明是沙箱不够真,两者别混为一谈 |
122
+ | `client-unavailable` | 子进程没跑起来(warn)——这一层没跑,别把它当干净 |
123
+
124
+ ### 市场层:提交之前就能知道会不会被打回
125
+
126
+ 本地全绿之后,发布还会在提交门禁那儿停一次。停的原因通常只有三种,而且三种都能提前查:
127
+
128
+ | 规则 | 抓什么 |
129
+ |---|---|
130
+ | `market-duplicate-entry` | 这个仓库**已经在目录里**。再提一个 PR 就是同一仓库的第二条条目,reviewer 会拒。版本升级根本不需要 PR——目录条目只存 `url` |
131
+ | `market-npm-name-collision` | 另一个条目也在发同一个 npm 包,安装路径会互相盖 |
132
+ | `market-repo-too-new` | 仓库创建不满 24 小时。门禁会报 `0.0 days old` 然后**自己重跑**,PR 不会丢,但在那之前什么都不会合 |
133
+ | `market-no-repo` | `package.json` 里没有 GitHub 地址,没东西可提交 |
134
+ | `market-unavailable` | 目录拉不到(warn)——这几条没跑,别把它当干净 |
95
135
 
96
136
  ### 代码卫生
97
137
 
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`,服务永远注入不进来)。
138
+ `lib-debugger`(error)、`lib-console-log`、`lib-hardcoded-path`(`C:\Users\...` 硬编码)、`lib-url-pathname`(`new URL(...).pathname` 在中文路径上会百分号编码,得用 `fileURLToPath`)。
139
+
140
+ ### 构建产物:`src/` 和 `lib/` 谁更新
141
+
142
+ 有 `src/` 的插件(TypeScript 源码 + 构建出来的 `lib/`)最容易在这件事上翻车,而且两种翻车的表现一模一样——**发出去的字节没人跑过**:
143
+
144
+ | 规则 | 抓什么 |
145
+ |---|---|
146
+ | `stale-build` | `src/` 比 `lib/` 新:构建没跑就发布了,tarball 里是上一次的产物 |
147
+ | `hand-edited-build` | `lib/` 比 `src/` 新:有人直接改了构建产物。下一次干净的构建会**静默丢掉**这些改动,所以发布的字节没法从 `src/` 复现出来 |
148
+
149
+ 两条都只是 warn:有可能你就是要发手改的产物(我干过)。但发之前你得知道自己在发什么。
99
150
 
100
151
  ## 一个真实的例子
101
152
 
@@ -120,6 +171,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
120
171
  ## 设计取舍
121
172
 
122
173
  - **三层,按代价排序**:清单层最便宜、先跑;产物层要起一个 npm(约 1–2 秒);运行时层要起一个 node 子进程。前一层没过的错误通常值得先修,但三层都会跑完——一次给全,比来回问好。
174
+ - **浏览器半边单独一层,不并进运行时层**:宿主半边的失败会抛错、会有日志;浏览器半边的失败是「什么都没发生」。两者的排查路径完全不同,所以各起一个子进程、各有一组规则、各自能单独跳过(`skipTools` / `skipClient`)。
123
175
  - **产物层用 `npm pack --dry-run`,不用自己推 `files` 规则**:npm 的入包规则(`main` / `exports` 强制入包、README/LICENSE 强制入包、`.npmignore` 优先级)比自己实现的准。唯一的代价是它需要这台机器上有 npm,所以跑不起来时降级成 `pack-unavailable` 而不是报错。
124
176
  - **运行时层真的执行插件代码**:这是唯一能回答「装上能不能用」的办法。代价是 `apply()` 会跑,所以:假 ctx 是 Proxy 不落盘、20 秒硬超时、`skipTools` 一键跳过。
125
177
  - **`--ignore-scripts`**:dry run 不跑 `prepack`,否则检查会触发一次构建。代价是清单可能和真实产物不同,所以有 `pack-script-build` 把这件事说出来。
@@ -131,8 +183,8 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
131
183
  ## 开发
132
184
 
133
185
  ```sh
134
- node --test # 44 个引擎单测
135
- node test/integration.test.mjs # 21 条集成断言(9 个真实插件作 fixture)
186
+ node --test # 64 个引擎单测
187
+ node test/integration.test.mjs # 24 条集成断言(9 个真实插件作 fixture)
136
188
  ```
137
189
 
138
190
  ## License
package/lib/engine.js CHANGED
@@ -6,7 +6,7 @@
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 } from 'node:fs'
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
10
10
  import { join, relative } from 'node:path'
11
11
  import { execFile } from 'node:child_process'
12
12
  import { promisify } from 'node:util'
@@ -59,6 +59,57 @@ function walkFiles(root, filter, acc = []) {
59
59
  /** Drop a trailing // comment so a commented-out line is not reported as live code. */
60
60
  const stripComment = (line) => line.replace(/\/\/.*$/, '')
61
61
 
62
+ const mtimeOf = (file) => {
63
+ try {
64
+ return statSync(file).mtimeMs
65
+ } catch {
66
+ return 0
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Which of `src/` and `lib/` was touched last.
72
+ *
73
+ * The two failure modes look identical from the outside and both ship code the
74
+ * author never ran:
75
+ * - src newer -> lib is stale build output. Publishing with --ignore-scripts
76
+ * (or a missing prepack) ships the old bytes.
77
+ * - lib newer -> lib was hand-edited after the source. The next clean build
78
+ * silently drops those edits, so the published tarball is not reproducible.
79
+ */
80
+ export function checkBuildFreshness(dir) {
81
+ const src = join(dir, 'src')
82
+ if (!existsSync(src)) return []
83
+ const newest = (root) => walkFiles(root, /\.(?:[cm]?[jt]sx?)$/, []).reduce((max, f) => Math.max(max, mtimeOf(f)), 0)
84
+ const srcTime = newest(src)
85
+ const libTime = newest(join(dir, 'lib'))
86
+ if (!srcTime || !libTime) return []
87
+ const stamp = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
88
+ if (srcTime > libTime + 1000) {
89
+ return [
90
+ F(
91
+ 'stale-build',
92
+ 'warn',
93
+ `src/ is newer than lib/ (${stamp(srcTime)} vs ${stamp(libTime)})`,
94
+ 'lib/ is what npm ships. If the build did not run before publish, the tarball carries the old output.',
95
+ 'Run the build (npm run build) and commit the result, or check that prepack runs it.',
96
+ ),
97
+ ]
98
+ }
99
+ if (libTime > srcTime + 1000) {
100
+ return [
101
+ F(
102
+ 'hand-edited-build',
103
+ 'warn',
104
+ `lib/ is newer than src/ (${stamp(libTime)} vs ${stamp(srcTime)})`,
105
+ 'lib/ was edited after the source. A clean build overwrites it, so the published bytes cannot be reproduced from src/.',
106
+ 'Port the change into src/ and rebuild, or accept that the next build drops it.',
107
+ ),
108
+ ]
109
+ }
110
+ return []
111
+ }
112
+
62
113
  const bytesHuman = (n) => {
63
114
  const v = Number(n) || 0
64
115
  return v >= 1_048_576 ? `${(v / 1_048_576).toFixed(1)} MB` : v >= 1024 ? `${Math.round(v / 1024)} kB` : `${v} B`
@@ -201,6 +252,20 @@ export function checkPackageJson(dir) {
201
252
  ),
202
253
  )
203
254
  }
255
+ // The mirror image, and the quieter one: the bundle ships, but nothing tells
256
+ // the host to load it. It installs, the host half works, the file is dead
257
+ // weight — and there is no error anywhere.
258
+ if (hasClient && !wantsClient) {
259
+ out.push(
260
+ F(
261
+ 'client-without-platform',
262
+ 'error',
263
+ 'exports["./client"] is declared but dsh.client.platform is missing',
264
+ 'The host decides whether to fetch a client bundle from dsh.client.platform; without it the file ships and is never loaded.',
265
+ 'Add "dsh": { "client": { "platform": "web" } }.',
266
+ ),
267
+ )
268
+ }
204
269
 
205
270
  // repository.url — GitHub Actions and the market link use it. Both shapes
206
271
  // occur: a bare string, and the {type,url} object npm documents. The object
@@ -211,11 +276,95 @@ export function checkPackageJson(dir) {
211
276
  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".'))
212
277
  }
213
278
 
279
+ // "private": true is the one flag npm refuses outright (EPRIVATE), and it is
280
+ // easy to leave behind from a scaffolding template.
281
+ if (json.private === true) {
282
+ out.push(
283
+ F(
284
+ 'private-field-set',
285
+ 'error',
286
+ '"private": true blocks publishing outright',
287
+ 'npm refuses with EPRIVATE and no partial state, so this is a hard stop rather than a risk.',
288
+ 'Remove "private": true (or set it false) before publishing.',
289
+ ),
290
+ )
291
+ }
292
+
293
+ // publishConfig.registry redirects the publish. A leftover internal registry
294
+ // means the release lands somewhere nobody else can install from.
295
+ const targetRegistry = json.publishConfig?.registry
296
+ if (typeof targetRegistry === 'string' && !/^https?:\/\/registry\.npmjs\.org\/?$/.test(targetRegistry)) {
297
+ out.push(
298
+ F(
299
+ 'publish-config-registry',
300
+ 'warn',
301
+ `publishConfig.registry points at ${targetRegistry}`,
302
+ 'npm publishes there instead of the public registry, so the version you announce is not the version consumers can install.',
303
+ 'Remove publishConfig.registry, or confirm that registry is intentional.',
304
+ ),
305
+ )
306
+ }
307
+
214
308
  // engines — a too-low floor lets the install succeed and then crash
215
309
  if (!json.engines?.node) {
216
310
  out.push(F('no-engines', 'info', 'no engines.node declared', 'Consumers on an older Node get a runtime error instead of an install-time message.', 'Add "engines": { "node": ">=22" }.'))
217
311
  }
218
312
 
313
+ // Scoped packages default to restricted. Publish one without --access public
314
+ // (or publishConfig.access) and it installs for you and 404s for everyone
315
+ // else — the publish output says nothing about it. Info, not a defect:
316
+ // `publish_gate_commands` always emits --access public, so this is a
317
+ // reminder to make that explicit in the manifest rather than to change it.
318
+ if (typeof json.name === 'string' && json.name.startsWith('@') && json.publishConfig?.access !== 'public') {
319
+ out.push(
320
+ F(
321
+ 'no-publish-config-access',
322
+ 'info',
323
+ 'scoped name without publishConfig.access: "public"',
324
+ 'npm publishes a scoped package as restricted unless you pass --access public or set publishConfig.access. A restricted package installs fine on the machine that published it and 404s for every other consumer, with no hint in the publish output. Already-public packages stay public, so this only matters on a first publish.',
325
+ 'Add "publishConfig": { "access": "public" }, or always publish with --access public (publish_gate_commands does).',
326
+ ),
327
+ )
328
+ }
329
+
330
+ // The patch file is inert without the declaration. The host mounts a plugin
331
+ // through dsh.bundle.patch; with the file present but undeclared, the plugin
332
+ // installs, the entry loads, and no tool or panel ever appears.
333
+ if (existsSync(join(dir, 'cordis.patch.yml')) && !json.dsh?.bundle?.patch) {
334
+ out.push(
335
+ F(
336
+ 'bundle-patch-not-declared',
337
+ 'error',
338
+ 'cordis.patch.yml exists but dsh.bundle.patch is not declared',
339
+ 'The host mounts a plugin through dsh.bundle.patch. Without the declaration the patch file is inert: the plugin installs, its entry loads, and no tool or panel ever appears. Nothing reports an error, because from the host point of view nothing was ever asked of it.',
340
+ 'Add "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }.',
341
+ ),
342
+ )
343
+ }
344
+
345
+ // Entry paths pointing at files that are not there. npm does not validate
346
+ // them, so the package publishes and every consumer hits a bare
347
+ // MODULE_NOT_FOUND that never names the manifest field responsible.
348
+ const entryTargets = [
349
+ ['main', typeof json.main === 'string' ? json.main : null],
350
+ ['exports["."]', typeof json.exports === 'object' && json.exports ? json.exports['.'] : null],
351
+ ['exports["./client"]', typeof json.exports === 'object' && json.exports ? json.exports['./client'] : null],
352
+ ]
353
+ for (const [field, target] of entryTargets) {
354
+ if (typeof target !== 'string' || target.includes('*')) continue
355
+ const rel = target.replace(/^\.\//, '')
356
+ if (existsSync(join(dir, rel))) continue
357
+ out.push(
358
+ F(
359
+ 'entry-path-missing',
360
+ 'error',
361
+ `${field} points at "${target}", which does not exist`,
362
+ 'The manifest publishes, the package resolves, and the import then fails at load time with a MODULE_NOT_FOUND that does not name the field responsible.',
363
+ `Create ${target}, or point ${field} at the file you actually ship.`,
364
+ ),
365
+ )
366
+ }
367
+
219
368
  return out
220
369
  }
221
370
 
@@ -522,6 +671,118 @@ export function checkMarketEntry(text) {
522
671
  return out
523
672
  }
524
673
 
674
+ // The submission gate is where a release stalls after all the local checks are
675
+ // green: the catalogue already has this repo (a duplicate PR), the repo is too
676
+ // young (the gate fails with "0.0 days old" and only reruns on its own), or the
677
+ // name is taken. All three are visible before opening the PR — this reads the
678
+ // live catalogue and the GitHub repo metadata instead of guessing.
679
+ const CATALOGUE_URL = 'https://awesome-dsh-plugin.com/plugins.json'
680
+ let catalogueCache = null
681
+
682
+ export function repoSlug(dir) {
683
+ const json = readPkgJson(dir)
684
+ const raw = typeof json?.repository === 'string' ? json.repository : json?.repository?.url
685
+ if (typeof raw !== 'string') return null
686
+ const m = /github\.com[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?$/.exec(raw.replace(/^git\+/, ''))
687
+ return m ? `${m[1]}/${m[2]}` : null
688
+ }
689
+
690
+ export async function checkMarketReadiness(dir, opts = {}) {
691
+ const out = []
692
+ const timeoutMs = opts.timeoutMs ?? 8000
693
+ const slug = repoSlug(dir)
694
+ if (!slug) {
695
+ out.push(F('market-no-repo', 'warn', 'package.json has no GitHub repository', 'The market lists GitHub repos; without one there is nothing to submit.', 'Add repository.url.'))
696
+ return out
697
+ }
698
+ const json = readPkgJson(dir)
699
+ const pkgName = typeof json?.name === 'string' ? json.name : null
700
+
701
+ let entries = Array.isArray(opts.entries) ? opts.entries : null
702
+ try {
703
+ if (!entries) {
704
+ if (!catalogueCache) {
705
+ const res = await fetch(CATALOGUE_URL, { signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': 'dsh-publish-gate' } })
706
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
707
+ const data = await res.json()
708
+ catalogueCache = Array.isArray(data) ? data : Array.isArray(data?.plugins) ? data.plugins : []
709
+ }
710
+ entries = catalogueCache
711
+ }
712
+ } catch (error) {
713
+ out.push(
714
+ F(
715
+ 'market-unavailable',
716
+ 'warn',
717
+ 'the market catalogue could not be read',
718
+ `The duplicate and name-collision checks did not run, so this is NOT a clean market report. ${String(error?.message || error).slice(0, 120)}`,
719
+ 'Retry when the network is available.',
720
+ ),
721
+ )
722
+ return out
723
+ }
724
+
725
+ const sameRepo = entries.filter((e) => typeof e?.url === 'string' && e.url.toLowerCase().includes(slug.toLowerCase()))
726
+ if (sameRepo.length > 0) {
727
+ out.push(
728
+ F(
729
+ 'market-duplicate-entry',
730
+ 'warn',
731
+ `${slug} is already in the market catalogue`,
732
+ `Listed as "${sameRepo[0].name}" (category ${sameRepo[0].category}). A new submission PR adds a second entry for the same repo, which reviewers reject.`,
733
+ 'Update the existing entry instead of adding one — a version bump needs no PR at all.',
734
+ ),
735
+ )
736
+ }
737
+ if (pkgName) {
738
+ const sameName = entries.filter((e) => e?.npm === pkgName && !sameRepo.includes(e))
739
+ if (sameName.length > 0) {
740
+ out.push(
741
+ F(
742
+ 'market-npm-name-collision',
743
+ 'warn',
744
+ `another catalogue entry also publishes ${pkgName}`,
745
+ `Listed as "${sameName[0].name}" — two entries installing the same npm package confuse the install path.`,
746
+ 'Check whether one of them is stale.',
747
+ ),
748
+ )
749
+ }
750
+ }
751
+
752
+ // The gate rejects a repo younger than a day and only reruns by itself, so
753
+ // opening the PR earlier wastes a round trip.
754
+ let repoInfo = opts.repoInfo ?? null
755
+ if (!repoInfo) {
756
+ try {
757
+ const res = await fetch(`https://api.github.com/repos/${slug}`, {
758
+ signal: AbortSignal.timeout(timeoutMs),
759
+ headers: { 'user-agent': 'dsh-publish-gate', accept: 'application/vnd.github+json' },
760
+ })
761
+ if (res.ok) repoInfo = await res.json()
762
+ } catch {
763
+ // a blocked GitHub API only costs the age hint
764
+ }
765
+ }
766
+ if (repoInfo?.created_at) {
767
+ const created = Date.parse(repoInfo.created_at)
768
+ if (Number.isFinite(created)) {
769
+ const hours = (Date.now() - created) / 3_600_000
770
+ if (hours < 24) {
771
+ out.push(
772
+ F(
773
+ 'market-repo-too-new',
774
+ 'warn',
775
+ `the repo is ${hours.toFixed(1)}h old; the submission gate needs 24h`,
776
+ 'The gate fails with a "repo too new" message and reruns on its own, so the PR is not lost — but nothing merges until it passes.',
777
+ 'Wait until the repo is a day old before submitting.',
778
+ ),
779
+ )
780
+ }
781
+ }
782
+ }
783
+ return out
784
+ }
785
+
525
786
  // =====================================================================
526
787
  // Source sanity: the entry protocol
527
788
  // =====================================================================
@@ -537,9 +798,17 @@ export function checkEntrySource(dir) {
537
798
  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\']`.'))
538
799
  // require() in ESM blows up at load
539
800
  // 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)) {
801
+ // `x = require(…)`, `require('node:fs').readFileSync`. Quoted literals are removed
802
+ // first, because a tool description that *mentions* `require("react")` is prose —
803
+ // this rule fired on this plugin's own description until that was fixed.
804
+ const code = text
805
+ .split('\n')
806
+ .map(stripComment)
807
+ .join('\n')
808
+ .replace(/'(?:[^'\\\n]|\\.)*'/g, "''")
809
+ .replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
810
+ .replace(/`(?:[^`\\]|\\.)*`/g, '``')
811
+ if (/\brequire\s*\(\s*['"`]/.test(code)) {
543
812
  out.push(
544
813
  F(
545
814
  'require-in-esm',
@@ -660,9 +929,39 @@ export async function checkPack(dir, opts = {}) {
660
929
  ),
661
930
  )
662
931
  }
932
+ // The browser half can work in dev and be absent from the package: `files`
933
+ // is usually ["lib"], so a bundle that lives anywhere else silently drops out.
934
+ const clientRel = typeof json?.exports === 'object' && json?.exports ? json.exports['./client'] : null
935
+ if (typeof clientRel === 'string' && !clientRel.includes('*') && !paths.includes(clientRel.replace(/^\.\//, ''))) {
936
+ out.push(
937
+ F(
938
+ 'pack-omits-client',
939
+ 'error',
940
+ `the tarball does not contain ${clientRel}`,
941
+ 'exports["./client"] points at a file npm did not pack, so the installed plugin has no browser half even though it works from the checkout.',
942
+ 'Add its directory to files, or move the bundle under lib/.',
943
+ ),
944
+ )
945
+ }
663
946
  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
947
  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
948
 
949
+ // A tarball that carries credentials is worse than any other finding here:
950
+ // npm never expires them and the market mirrors the tarball.
951
+ const SECRET = /(?:^|\/)(?:\.env(?:\..+)?|\.npmrc|\.netrc|id_rsa|id_ed25519)$|\.(?:pem|key|p12|pfx)$/i
952
+ const secrets = paths.filter((p) => SECRET.test(p))
953
+ if (secrets.length > 0) {
954
+ out.push(
955
+ F(
956
+ 'pack-ships-secret',
957
+ 'error',
958
+ `the tarball contains ${secrets.length} credential-shaped file(s)`,
959
+ `${secrets.slice(0, 5).join(', ')} — npm does not expire these and the tarball is public the moment it is published. Without a "files" field npm packs everything .gitignore does not exclude.`,
960
+ 'Delete them from the package and narrow "files".',
961
+ ),
962
+ )
963
+ }
964
+
666
965
  const unpacked = Number(pack.unpackedSize ?? 0)
667
966
  if (unpacked > warnBytes) {
668
967
  const biggest = [...files]
@@ -722,6 +1021,18 @@ import { pathToFileURL } from 'node:url'
722
1021
  import { join } from 'node:path'
723
1022
  const dir = process.argv[1]
724
1023
  const registered = []
1024
+ // The host renders \`parameters\` into the prompt and never validates the value
1025
+ // against it, so a typo like "strin" silently removes the type hint.
1026
+ const JSON_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null'])
1027
+ const badParamTypes = []
1028
+ const walkParams = (schema, path) => {
1029
+ if (!schema || typeof schema !== 'object') return
1030
+ if (typeof schema.type === 'string' && !JSON_TYPES.has(schema.type)) badParamTypes.push(path + '.type=' + schema.type)
1031
+ if (schema.properties && typeof schema.properties === 'object') {
1032
+ for (const [key, sub] of Object.entries(schema.properties)) walkParams(sub, path + '.' + key)
1033
+ }
1034
+ if (schema.items) walkParams(schema.items, path + '[]')
1035
+ }
725
1036
  const noopService = () => new Proxy(function () {}, {
726
1037
  get: (target, prop) => {
727
1038
  // Own properties win, or ctx.tools would resolve to another no-op proxy
@@ -752,6 +1063,7 @@ ctx.tools = {
752
1063
  hasOutputSchema: Boolean(def && def.output && def.output.schema),
753
1064
  cardKind: card,
754
1065
  })
1066
+ walkParams(def?.parameters, typeof def?.name === 'string' ? def.name : '(unnamed)')
755
1067
  },
756
1068
  }
757
1069
  const emit = (payload) => process.stdout.write('\\n@@PG@@' + JSON.stringify(payload) + '\\n')
@@ -761,7 +1073,7 @@ try {
761
1073
  stage = 'apply'
762
1074
  if (typeof mod.apply !== 'function') throw new Error('lib/index.js does not export apply')
763
1075
  await mod.apply(ctx, {})
764
- emit({ ok: true, tools: registered })
1076
+ emit({ ok: true, tools: registered, badParamTypes })
765
1077
  } catch (error) {
766
1078
  emit({ ok: false, stage, error: String((error && error.message) || error) })
767
1079
  }
@@ -842,6 +1154,18 @@ export async function checkToolRegistrations(dir, opts = {}) {
842
1154
  )
843
1155
  }
844
1156
  }
1157
+ const badParams = Array.isArray(payload.badParamTypes) ? payload.badParamTypes : []
1158
+ for (const where of badParams) {
1159
+ out.push(
1160
+ F(
1161
+ 'tool-bad-parameter-type',
1162
+ 'warn',
1163
+ `a parameters schema uses an unknown type at ${where}`,
1164
+ 'The host renders parameters into the prompt and never validates a call against it, so a typo like "strin" removes the type hint with no error anywhere.',
1165
+ 'Use one of string, number, integer, boolean, object, array, null.',
1166
+ ),
1167
+ )
1168
+ }
845
1169
  return { findings: out, tools }
846
1170
  }
847
1171
 
@@ -905,9 +1229,227 @@ export function checkInjectCoverage(dir) {
905
1229
  ),
906
1230
  )
907
1231
  }
1232
+
1233
+ // The other direction. cordis waits for every declared service before apply
1234
+ // runs, so a misspelled entry ('tool' for 'tools') leaves the plugin pending
1235
+ // forever with no error at all — the tools simply never appear. Scan every
1236
+ // lib file, so a service used from a helper still counts as used.
1237
+ const all = walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)
1238
+ .map((f) => safeText(f))
1239
+ .filter((t) => t !== null)
1240
+ .map((t) => t.split('\n').map(stripComment).join('\n'))
1241
+ .join('\n')
1242
+ for (const name of names) {
1243
+ const usage = INJECT_HINTS.find(([service]) => service === name)?.[1] ?? new RegExp(`ctx\\.${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)
1244
+ if (usage.test(all)) continue
1245
+ out.push(
1246
+ F(
1247
+ 'inject-unused-service',
1248
+ 'warn',
1249
+ `inject declares "${name}" but no lib file uses ctx.${name}`,
1250
+ 'cordis resolves every injected service before apply runs. A misspelled or leftover entry keeps the plugin pending forever without an error message — the tools just never appear, and the plugin looks installed.',
1251
+ `Fix the spelling, or remove "${name}" from inject.`,
1252
+ ),
1253
+ )
1254
+ }
1255
+ return out
1256
+ }
1257
+
1258
+ // =====================================================================
1259
+ // The browser half. It is the half that fails silently: the host half keeps
1260
+ // working, so a panel that never registers looks like a UI bug.
1261
+ // =====================================================================
1262
+
1263
+ const clientTarget = (json) => (typeof json?.exports === 'object' && json?.exports ? json.exports['./client'] : null)
1264
+
1265
+ export function checkClientHalf(dir) {
1266
+ const out = []
1267
+ const json = readPkgJson(dir)
1268
+ const target = clientTarget(json)
1269
+ if (typeof target !== 'string') return out // client-without-export covers the other direction
1270
+ const file = join(dir, target.replace(/^\.\//, ''))
1271
+ if (!existsSync(file)) {
1272
+ out.push(
1273
+ F(
1274
+ 'client-file-missing',
1275
+ 'error',
1276
+ `exports["./client"] points at ${target}, which does not exist`,
1277
+ 'The host asks for the client bundle, gets MODULE_NOT_FOUND, and the panel never appears — while the host half keeps working, so nothing looks broken.',
1278
+ `Ship ${target}.`,
1279
+ ),
1280
+ )
1281
+ return out
1282
+ }
1283
+ const text = safeText(file)
1284
+ if (text === null) return out
1285
+ const code = text.split('\n').map(stripComment).join('\n')
1286
+ if (!/__ModuleLoader__/.test(code)) {
1287
+ out.push(
1288
+ F(
1289
+ 'client-no-module-loader',
1290
+ 'error',
1291
+ `${target} never calls window.__ModuleLoader__.load()`,
1292
+ 'The host evaluates the bundle and looks for a factory registered through __ModuleLoader__. A bundle that never registers one is loaded and discarded: no error, no panel.',
1293
+ 'Wrap the module in window.__ModuleLoader__.load({ id, factory }).',
1294
+ ),
1295
+ )
1296
+ }
908
1297
  return out
909
1298
  }
910
1299
 
1300
+ // Evaluates the bundle in a child process: fake window, fake module loader,
1301
+ // fake React, fake ctx. Answers the question the source cannot: does apply()
1302
+ // actually register anything, and does it touch a service it forgot to inject?
1303
+ const CLIENT_RUNNER = `
1304
+ const { readFileSync } = await import('node:fs')
1305
+ // With -e, the extra argument lands at argv[1]; there is no script path.
1306
+ const file = process.argv[2] || process.argv[1]
1307
+ const records = { loaded: 0, id: null, parseError: null, evalError: null, factoryError: null, hasApply: false, inject: null, applyError: null, registrations: [], services: [] }
1308
+ const noop = () => {}
1309
+ const serviceProxy = (name) => new Proxy({}, {
1310
+ get(_t, prop) {
1311
+ if (prop === 'then') return undefined
1312
+ if (prop === 'inject') return (_slot, fn) => { try { return fn() } catch (e) { records.applyError = records.applyError || String(e && e.message || e); return noop } }
1313
+ return () => { records.registrations.push(name + '.' + String(prop)); return noop }
1314
+ },
1315
+ })
1316
+ const fakeCtx = new Proxy({}, {
1317
+ get(_t, prop) {
1318
+ if (prop === 'then') return undefined
1319
+ if (prop === 'effect') return (fn) => { try { return fn() } catch (e) { records.applyError = records.applyError || String(e && e.message || e); return noop } }
1320
+ if (prop === 'on' || prop === 'emit' || prop === 'off') return noop
1321
+ records.services.push(String(prop))
1322
+ return serviceProxy(String(prop))
1323
+ },
1324
+ })
1325
+ globalThis.window = {
1326
+ __ModuleLoader__: {
1327
+ load(spec) {
1328
+ records.loaded += 1
1329
+ records.id = spec && spec.id
1330
+ if (typeof spec?.factory !== 'function') { records.factoryError = 'factory is not a function'; return }
1331
+ const fakeRequire = (name) => {
1332
+ if (name === 'react') {
1333
+ return { createElement: () => null, useState: (v) => [v, noop], useEffect: noop, useMemo: (f) => f(), useCallback: (f) => f, useRef: (v) => ({ current: v }), Fragment: 'fragment' }
1334
+ }
1335
+ return new Proxy({}, { get: () => noop })
1336
+ }
1337
+ let mod
1338
+ try { mod = spec.factory(fakeRequire) } catch (e) { records.factoryError = String(e && e.message || e); return }
1339
+ records.hasApply = typeof mod?.apply === 'function'
1340
+ records.inject = Array.isArray(mod?.inject) ? mod.inject : null
1341
+ if (!records.hasApply) return
1342
+ try { mod.apply(fakeCtx) } catch (e) { records.applyError = String(e && e.message || e) }
1343
+ },
1344
+ },
1345
+ }
1346
+ globalThis.fetch = () => Promise.reject(new Error('stubbed by publish-gate'))
1347
+ const stub = new Proxy(function () {}, { get: (_t, p) => (p === 'then' ? undefined : stub), apply: () => stub, construct: () => stub })
1348
+ // Node already defines some of these as getter-only (navigator), so a plain
1349
+ // assignment throws and would abort the whole probe.
1350
+ for (const name of ['document', 'localStorage', 'sessionStorage', 'navigator', 'location', 'history', 'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame', 'matchMedia']) {
1351
+ try { globalThis[name] = stub } catch { try { Object.defineProperty(globalThis, name, { value: stub, configurable: true, writable: true }) } catch {} }
1352
+ }
1353
+ let source = ''
1354
+ try { source = readFileSync(file, 'utf8') } catch (e) { records.parseError = 'unreadable: ' + String(e && e.message || e) }
1355
+ if (!records.parseError) {
1356
+ try { new Function(source)() } catch (e) {
1357
+ const text = String(e && e.message || e)
1358
+ if (e && e.name === 'SyntaxError') records.parseError = text
1359
+ else records.evalError = text
1360
+ }
1361
+ }
1362
+ process.stdout.write('\\n@@PG@@' + JSON.stringify(records))
1363
+ process.exit(0)
1364
+ `
1365
+
1366
+ export async function checkClientRuntime(dir, opts = {}) {
1367
+ const out = []
1368
+ const json = readPkgJson(dir)
1369
+ const target = clientTarget(json)
1370
+ if (typeof target !== 'string') return { findings: out, client: null }
1371
+ const file = join(dir, target.replace(/^\.\//, ''))
1372
+ if (!existsSync(file)) return { findings: out, client: null } // checkClientHalf names it
1373
+ const timeoutMs = opts.timeoutMs || 20000
1374
+
1375
+ let stdout
1376
+ try {
1377
+ ;({ stdout } = await runFile(process.execPath, ['--input-type=module', '-e', CLIENT_RUNNER, file], { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, windowsHide: true }))
1378
+ } catch (error) {
1379
+ // execFile's own message is the whole command line; stderr has the reason.
1380
+ const why = String(error.stderr || error.message || error).trim().split('\n').filter(Boolean).slice(-3).join(' ').slice(0, 300)
1381
+ out.push(
1382
+ F(
1383
+ 'client-unavailable',
1384
+ 'warn',
1385
+ 'the client bundle could not be evaluated',
1386
+ `${why} — the runtime layer did not run, so treat this bundle as unverified.`,
1387
+ 'Check that the file parses as a classic script, or pass skipClient.',
1388
+ ),
1389
+ )
1390
+ return { findings: out, client: null }
1391
+ }
1392
+ const m = /@@PG@@(.*)$/m.exec(stdout)
1393
+ if (!m) {
1394
+ out.push(F('client-unavailable', 'warn', 'the client probe produced no result', 'The child process exited without reporting — the bundle may call process.exit() or loop forever.', 'Run the bundle by hand, or pass skipClient.'))
1395
+ return { findings: out, client: null }
1396
+ }
1397
+ const r = JSON.parse(m[1])
1398
+
1399
+ if (r.parseError) {
1400
+ out.push(F('client-parse-failed', 'error', `${target} does not parse: ${String(r.parseError).slice(0, 160)}`, 'The host evaluates this file as a classic script. A syntax error there means the whole client half never runs.', 'Fix the syntax error.'))
1401
+ return { findings: out, client: r }
1402
+ }
1403
+ if (r.loaded === 0) {
1404
+ out.push(
1405
+ F(
1406
+ 'client-load-not-called',
1407
+ 'error',
1408
+ `${target} ran without calling window.__ModuleLoader__.load()`,
1409
+ 'Nothing was registered with the module loader, so the host has no factory to call: the bundle is loaded and discarded with no error and no panel.',
1410
+ 'Register through window.__ModuleLoader__.load({ id, factory }) unconditionally.',
1411
+ ),
1412
+ )
1413
+ }
1414
+ if (r.factoryError) {
1415
+ out.push(F('client-factory-throws', 'error', `the factory in ${target} throws: ${String(r.factoryError).slice(0, 160)}`, 'The host calls the factory to get the module. A throw here aborts the panel and can leave the surrounding UI pending.', 'Fix the throw, or move the work into apply().'))
1416
+ }
1417
+ if (r.loaded > 0 && !r.hasApply && !r.factoryError) {
1418
+ out.push(F('client-no-apply', 'error', `the factory in ${target} returns no apply()`, 'The host calls module.apply(ctx) to let the panel register itself. Without it the factory result is inert.', 'Return { apply, inject } from the factory.'))
1419
+ }
1420
+ if (r.applyError) {
1421
+ out.push(F('client-apply-throws', 'error', `apply() in ${target} throws: ${String(r.applyError).slice(0, 160)}`, 'Registration happens inside apply(); a throw there leaves the panel unregistered while the host half keeps working.', 'Wrap the failing registration, or fix the cause.'))
1422
+ }
1423
+ if (r.hasApply && !r.applyError && r.registrations.length === 0) {
1424
+ out.push(
1425
+ F(
1426
+ 'client-no-registration',
1427
+ 'warn',
1428
+ `apply() in ${target} registered nothing`,
1429
+ 'apply() ran and touched no slot or tab registration. The panel is loaded, mounted nowhere, and there is no error to find.',
1430
+ 'Call ctx.slots.inject(...) / ctx.<tab>.register(...) inside apply().',
1431
+ ),
1432
+ )
1433
+ }
1434
+ if (Array.isArray(r.inject)) {
1435
+ const declared = new Set(r.inject)
1436
+ const used = new Set(r.services.filter((s) => s !== 'effect'))
1437
+ for (const service of used) {
1438
+ if (declared.has(service)) continue
1439
+ out.push(
1440
+ F(
1441
+ `client-inject-missing-${service}`,
1442
+ 'warn',
1443
+ `apply() in ${target} uses ctx.${service} but inject does not list it`,
1444
+ 'The client loader resolves injected services before calling apply. Without the entry the property can be undefined at that moment, so the registration throws or silently does nothing.',
1445
+ `Add "${service}" to the inject array the factory returns.`,
1446
+ ),
1447
+ )
1448
+ }
1449
+ }
1450
+ return { findings: out, client: r }
1451
+ }
1452
+
911
1453
  export function checkReadmeTruth(dir, toolNames = null) {
912
1454
  const out = []
913
1455
  const text = safeText(join(dir, 'README.md'))
@@ -1001,11 +1543,13 @@ export async function checkPublishedMatches(name, version, localPack, { registry
1001
1543
  export async function runArtifactChecks(dir, opts = {}) {
1002
1544
  const packResult = opts.skipPack ? { findings: [], pack: null } : await checkPack(dir, opts)
1003
1545
  const toolResult = opts.skipTools ? { findings: [], tools: null } : await checkToolRegistrations(dir, opts)
1546
+ const clientResult = opts.skipClient ? { findings: [], client: null } : await checkClientRuntime(dir, opts)
1004
1547
  const names = toolResult.tools ? toolResult.tools.map((t) => t.name).filter(Boolean) : null
1005
1548
  return {
1006
- findings: [...packResult.findings, ...toolResult.findings, ...checkReadmeTruth(dir, names)],
1549
+ findings: [...packResult.findings, ...toolResult.findings, ...clientResult.findings, ...checkReadmeTruth(dir, names)],
1007
1550
  pack: packResult.pack,
1008
1551
  tools: toolResult.tools,
1552
+ client: clientResult.client,
1009
1553
  }
1010
1554
  }
1011
1555
 
@@ -1018,7 +1562,9 @@ export function runStaticChecks(dir) {
1018
1562
  ...checkPatchYml(dir),
1019
1563
  ...checkEntrySource(dir),
1020
1564
  ...checkInjectCoverage(dir),
1565
+ ...checkClientHalf(dir),
1021
1566
  ...checkLibHygiene(dir),
1567
+ ...checkBuildFreshness(dir),
1022
1568
  ...checkDocs(dir),
1023
1569
  ...checkTests(dir),
1024
1570
  ...checkNpmState(dir),
@@ -1028,20 +1574,25 @@ export function runStaticChecks(dir) {
1028
1574
 
1029
1575
  const RULE_ORDER = [
1030
1576
  'missing-file', 'unreadable', 'utf8-bom', 'json-parse', 'yaml-bom', 'invalid-name', 'invalid-version',
1031
- 'missing-field', 'not-esm', 'files-omits-lib', 'files-omits-patch', 'no-exports', 'client-without-export',
1577
+ 'missing-field', 'not-esm', 'files-omits-lib', 'files-omits-patch', 'no-exports', 'client-without-export', 'client-without-platform',
1578
+ 'entry-path-missing', 'bundle-patch-not-declared', 'no-publish-config-access', 'private-field-set', 'publish-config-registry',
1579
+ 'client-file-missing', 'client-no-module-loader', 'client-parse-failed', 'client-load-not-called',
1580
+ 'client-factory-throws', 'client-no-apply', 'client-apply-throws', 'client-no-registration',
1581
+ 'client-inject-missing-slots', 'client-inject-missing-sidebarRightTabs', 'client-unavailable',
1032
1582
  'no-patch-file', 'no-insert-row', 'insert-missing-name', 'insert-name-mismatch',
1033
1583
  'patch-name-unresolvable', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
1034
1584
  'entry-import-failed', 'apply-threw', 'tools-unavailable',
1035
1585
  'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
1586
+ 'inject-unused-service',
1036
1587
  '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',
1588
+ 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type',
1589
+ 'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'stale-build', 'hand-edited-build',
1590
+ 'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
1040
1591
  'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
1041
1592
  'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'publish-not-served',
1042
1593
  'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
1043
- 'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc',
1044
- 'market-no-en', 'market-no-zh', 'unscoped-name', 'no-git', 'no-readme', 'readme-no-install', 'readme-no-profile',
1594
+ 'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc', 'market-no-en', 'market-no-zh', 'unscoped-name', 'no-git', 'no-readme', 'readme-no-install', 'readme-no-profile',
1595
+ 'market-no-repo', 'market-unavailable', 'market-duplicate-entry', 'market-npm-name-collision', 'market-repo-too-new',
1045
1596
  'readme-tool-undocumented', 'readme-test-count',
1046
1597
  'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
1047
1598
  ]
package/lib/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  checkEntrySource,
14
14
  checkGitState,
15
15
  checkMarketEntry,
16
+ checkMarketReadiness,
16
17
  checkNpmState,
17
18
  checkPackageJson,
18
19
  checkPack,
@@ -20,6 +21,7 @@ import {
20
21
  checkPublishedMatches,
21
22
  checkTests,
22
23
  checkToolRegistrations,
24
+ checkClientRuntime,
23
25
  checkVersionTaken,
24
26
  compareVersions,
25
27
  latestPublished,
@@ -89,7 +91,7 @@ export function apply(ctx, config = {}) {
89
91
  {
90
92
  name: 'publish_gate',
91
93
  description:
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}.',
94
+ '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, an entry path that points at no file, a cordis.patch.yml the manifest never declares (inert), 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; then, if the package declares exports["./client"], loads the browser bundle against a fake window.__ModuleLoader__ and reports a bundle that never loads, never returns apply, throws, or registers nothing. Deterministic: every rule is a file, subprocess or API check, no LLM. Returns {ok, report, findings:[{rule,severity,title,detail,fix}], counts}.',
93
95
  parameters: {
94
96
  type: 'object',
95
97
  properties: {
@@ -97,13 +99,14 @@ export function apply(ctx, config = {}) {
97
99
  skipNetwork: { type: 'boolean', description: 'Skip the npm registry and GitHub checks (default false).' },
98
100
  skipPack: { type: 'boolean', description: 'Skip `npm pack --dry-run` (default false). Faster, but says nothing about the real tarball.' },
99
101
  skipTools: { type: 'boolean', description: 'Skip the isolated import of the plugin entry (default false).' },
102
+ skipClient: { type: 'boolean', description: 'Skip the isolated load of the browser bundle (default false).' },
100
103
  },
101
104
  },
102
105
  async execute(args) {
103
106
  const dir = resolveDir(args.dir)
104
107
  if (!dir) return { ok: false, error: 'no plugin directory given and cwd has no package.json' }
105
108
  const findings = runStaticChecks(dir)
106
- const artifact = await runArtifactChecks(dir, { skipPack: args.skipPack, skipTools: args.skipTools })
109
+ const artifact = await runArtifactChecks(dir, { skipPack: args.skipPack, skipTools: args.skipTools, skipClient: args.skipClient })
107
110
  findings.push(...artifact.findings)
108
111
  if (!args.skipNetwork) findings.push(...(await networkGate(dir)))
109
112
  const counts = {
@@ -117,6 +120,7 @@ export function apply(ctx, config = {}) {
117
120
  counts,
118
121
  findings,
119
122
  tools: artifact.tools,
123
+ client: artifact.client,
120
124
  unpackedSize: artifact.pack?.unpackedSize ?? null,
121
125
  report: renderReport(findings, { title: `publish gate · ${dir}` }),
122
126
  safeToPublish: counts.error === 0,
@@ -141,6 +145,24 @@ export function apply(ctx, config = {}) {
141
145
  return { ok: true, tools, findings, report: renderReport(findings, { title: `runtime gate · ${dir}` }) }
142
146
  },
143
147
  },
148
+ {
149
+ name: 'publish_gate_client',
150
+ description:
151
+ 'Load a plugin\'s browser bundle in an isolated child process and report whether it would actually appear in the UI. A client half fails silently: the host half keeps working, so a panel that never registers looks like a UI bug rather than a packaging one. Reproduces the host contract — a fake window.__ModuleLoader__.load({id, factory}), a fake require("react"), a ctx whose effect/inject run the callback and whose services record every call — then reports: the file declared in exports["./client"] is missing, the bundle never calls load, the factory throws, it returns no apply, apply throws, apply registers nothing, the bundle uses a service its own inject array omits (cordis never hands it over, so the panel is blank), and a SyntaxError anywhere in the file. 20 s timeout, plugin code never runs in the host process. Returns {ok, client:{loaded,id,hasApply,inject,registrations,services,parseError,evalError,factoryError,applyError}, findings, report}.',
152
+ parameters: {
153
+ type: 'object',
154
+ properties: {
155
+ dir: { type: 'string', description: 'Plugin directory.' },
156
+ timeoutMs: { type: 'number', description: 'Kill the child after this long (default 20000).' },
157
+ },
158
+ },
159
+ async execute(args) {
160
+ const dir = resolveDir(args.dir)
161
+ if (!dir) return { ok: false, error: 'no plugin directory given' }
162
+ const { findings, client } = await checkClientRuntime(dir, { timeoutMs: args.timeoutMs })
163
+ return { ok: true, client, findings, report: renderReport(findings, { title: `client gate · ${dir}` }) }
164
+ },
165
+ },
144
166
  {
145
167
  name: 'publish_gate_verify',
146
168
  description:
@@ -194,15 +216,22 @@ export function apply(ctx, config = {}) {
194
216
  {
195
217
  name: 'publish_gate_market',
196
218
  description:
197
- 'Validate a market submission entry (the YAML you put in data/plugins/<owner>__<repo>.yml): url is a GitHub URL, name is owner/repo, category is one the market knows, and description carries both en: and zh:. Catches the shape errors the submission gate rejects. Returns {ok, findings, report, entry}.',
219
+ 'Validate a market submission entry (the YAML you put in data/plugins/<owner>__<repo>.yml): url is a GitHub URL, name is owner/repo, category is one the market knows, and description carries both en: and zh:. Catches the shape errors the submission gate rejects. Pass dir instead to run the pre-submission checks that only the live catalogue can answer: this repo is already listed (a duplicate PR), another entry publishes the same npm package, and the repo is younger than the 24 h the submission gate requires — that last one fails with "0.0 days old" and only reruns on its own, so it is worth knowing before you open the PR. Returns {ok, findings, report, entry}.',
198
220
  parameters: {
199
221
  type: 'object',
200
222
  properties: {
201
223
  text: { type: 'string', description: 'The entry YAML text.' },
202
224
  file: { type: 'string', description: 'Or a path to the entry file.' },
225
+ dir: { type: 'string', description: 'Or a plugin directory: reads the live catalogue and GitHub metadata instead of validating YAML.' },
203
226
  },
204
227
  },
205
228
  async execute(args) {
229
+ if (!args.text && !args.file && args.dir) {
230
+ const dir = resolveDir(args.dir)
231
+ if (!dir) return { ok: false, error: 'no plugin directory given' }
232
+ const findings = await checkMarketReadiness(dir, { timeoutMs: cfg.networkTimeoutMs })
233
+ return { ok: true, findings, report: renderReport(findings, { title: `market readiness · ${dir}` }) }
234
+ }
206
235
  let text = args.text ?? null
207
236
  if (text === null && args.file) {
208
237
  try {
@@ -212,7 +241,7 @@ export function apply(ctx, config = {}) {
212
241
  return { ok: false, error: `cannot read ${args.file}` }
213
242
  }
214
243
  }
215
- if (text === null) return { ok: false, error: 'give either text or file' }
244
+ if (text === null) return { ok: false, error: 'give text, file, or dir' }
216
245
  const findings = checkMarketEntry(text)
217
246
  return { ok: true, findings, report: renderReport(findings, { title: 'market entry' }), entry: text }
218
247
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bushizhanghan/dsh-publish-gate",
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 + 市场一键三连。",
3
+ "version": "0.3.0",
4
+ "description": "Helps DSH plugin AUTHORS publish their plugins: a gate that turns every silent failure in the publish path into a deterministic check. Manifest layer — package.json completeness, BOM, YAML/JSON parseability, entry paths that point at no file, a patch the manifest never declares, patch insert correctness, a build whose lib/ is older than src/, inject services that are missing or misspelled, 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. Browser half — loads the client bundle against a fake window.__ModuleLoader__ and reports a bundle that never loads, returns no apply, throws, registers nothing, or uses a service its own inject array omits, because a panel that never appears looks like a UI bug, not a packaging one. 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": {