@bushizhanghan/dsh-publish-gate 0.3.1 → 0.3.3

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
@@ -30,7 +30,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
30
30
  | 工具 | 作用 |
31
31
  |---|---|
32
32
  | `publish_gate` | 全量门禁:清单 + 产物 + 运行时 + 浏览器半边 + 网络,返回 `safeToPublish`(`skipPack` / `skipTools` / `skipClient` / `skipNetwork` 可逐层跳过) |
33
- | `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 |
33
+ | `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 | 传 `call: ["工具名"]` 可以**真的调一次**(空参数):注册永远不会执行 `execute`,所以「一调就抛 ReferenceError」这类 bug 只有这一条能看见。默认不调——有些工具会开程序或写文件,你点名才算同意。 |
34
34
  | `publish_gate_client` | 只跑浏览器半边:在子进程里假造 `window.__ModuleLoader__`,看 bundle 会不会加载、`apply` 会不会抛、到底往哪个服务注册了东西、`inject` 有没有漏 |
35
35
  | `publish_gate_verify` | 发后校验:`npm pack --dry-run` 的 shasum 和 registry 上的 `dist.shasum` 逐字节比,比版本号猜准 |
36
36
  | `publish_gate_patch` | 只查 `cordis.patch.yml`——「插件装了但工具没出现」的第一个怀疑对象 |
@@ -135,7 +135,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
135
135
 
136
136
  ### 代码卫生
137
137
 
138
- `lib-debugger`(error)、`lib-console-log`、`lib-hardcoded-path`(`C:\Users\...` 硬编码)、`lib-url-pathname`(`new URL(...).pathname` 在中文路径上会百分号编码,得用 `fileURLToPath`)。
138
+ `lib-debugger`(error)、`lib-console-log`、`lib-hardcoded-path`(`C:\Users\...` 硬编码)、`lib-url-pathname`(`new URL(...).pathname` 在中文路径上会百分号编码,得用 `fileURLToPath`)、`node-too-old`(`engines.node` 写着 `>=18`,`lib/` 里却用了 `Object.groupBy`(21)或 `Promise.withResolvers`(22)。npm 对 `engines` 只是警告,照样装上,然后在 import 那一刻抛——用户看到的是「插件坏了」,不是版本不匹配)。字符串和注释里的同名文字不算。
139
139
 
140
140
  ### 构建产物:`src/` 和 `lib/` 谁更新
141
141
 
@@ -183,7 +183,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
183
183
  ## 开发
184
184
 
185
185
  ```sh
186
- node --test # 64 个引擎单测
186
+ node --test # 66 个引擎单测
187
187
  node test/integration.test.mjs # 26 条集成断言(9 个真实插件作 fixture)
188
188
  ```
189
189
 
package/lib/engine.js CHANGED
@@ -1062,6 +1062,7 @@ ctx.tools = {
1062
1062
  hasExecute: typeof def?.execute === 'function',
1063
1063
  hasOutputSchema: Boolean(def && def.output && def.output.schema),
1064
1064
  cardKind: card,
1065
+ run: typeof def?.execute === 'function' ? def.execute : null,
1065
1066
  })
1066
1067
  walkParams(def?.parameters, typeof def?.name === 'string' ? def.name : '(unnamed)')
1067
1068
  },
@@ -1073,7 +1074,25 @@ try {
1073
1074
  stage = 'apply'
1074
1075
  if (typeof mod.apply !== 'function') throw new Error('lib/index.js does not export apply')
1075
1076
  await mod.apply(ctx, {})
1076
- emit({ ok: true, tools: registered, badParamTypes })
1077
+ // Calling a tool is opt-in: some tools launch applications or write files, so
1078
+ // the caller names the ones it considers safe. A tool that throws here is a
1079
+ // bug the author cannot see any other way - registration never runs execute.
1080
+ stage = 'call'
1081
+ const wanted = new Set(JSON.parse(process.env.PG_CALLS || '[]'))
1082
+ const calls = []
1083
+ for (const entry of registered) {
1084
+ if (!wanted.has(entry.name) || !entry.run) continue
1085
+ try {
1086
+ await Promise.race([
1087
+ Promise.resolve(entry.run({}, { signal: undefined })),
1088
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timed out after 8s')), 8000)),
1089
+ ])
1090
+ calls.push({ name: entry.name, ok: true })
1091
+ } catch (error) {
1092
+ calls.push({ name: entry.name, ok: false, error: String((error && error.message) || error) })
1093
+ }
1094
+ }
1095
+ emit({ ok: true, tools: registered.map(({ run, ...rest }) => rest), badParamTypes, calls })
1077
1096
  } catch (error) {
1078
1097
  emit({ ok: false, stage, error: String((error && error.message) || error) })
1079
1098
  }
@@ -1088,6 +1107,7 @@ export async function checkToolRegistrations(dir, opts = {}) {
1088
1107
  timeout: opts.timeoutMs ?? 20_000,
1089
1108
  maxBuffer: 4 * 1024 * 1024,
1090
1109
  windowsHide: true,
1110
+ env: { ...process.env, PG_CALLS: JSON.stringify(Array.isArray(opts.calls) ? opts.calls : []) },
1091
1111
  })
1092
1112
  const line = String(stdout).split('\n').filter((l) => l.startsWith('@@PG@@')).pop()
1093
1113
  if (!line) throw new Error('the child printed no result')
@@ -1132,6 +1152,18 @@ export async function checkToolRegistrations(dir, opts = {}) {
1132
1152
  }
1133
1153
 
1134
1154
  const seen = new Set()
1155
+ for (const c of Array.isArray(payload.calls) ? payload.calls : []) {
1156
+ if (c.ok) continue
1157
+ out.push(
1158
+ F(
1159
+ 'tool-call-throws',
1160
+ 'error',
1161
+ `tool "${c.name}" throws when called with no arguments`,
1162
+ `${c.error}. Registration never runs execute, so nothing else in this gate can see it — the consumer hits it on the first call.`,
1163
+ 'Guard the missing input, or accept that the tool is unusable without arguments and say so in its description.',
1164
+ ),
1165
+ )
1166
+ }
1135
1167
  for (const t of tools) {
1136
1168
  const label = t.name || '(unnamed)'
1137
1169
  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.'))
@@ -1202,6 +1234,63 @@ export function checkLibHygiene(dir) {
1202
1234
  return out
1203
1235
  }
1204
1236
 
1237
+ /**
1238
+ * Features that need a newer Node than the manifest promises.
1239
+ *
1240
+ * `engines.node` is only advisory — npm warns and installs anyway — so a plugin
1241
+ * that says `>=20` and uses `Object.groupBy` (21) breaks on exactly the Node it
1242
+ * advertised. Each entry is [name, regex, minimum major], newest evidence only.
1243
+ */
1244
+ const NODE_FEATURES = [
1245
+ ['Object.groupBy / Map.groupBy', /\b(?:Object|Map)\.groupBy\b/, 21],
1246
+ ['Array.fromAsync', /\bArray\.fromAsync\b/, 22],
1247
+ ['Promise.withResolvers', /\bPromise\.withResolvers\b/, 22],
1248
+ ['import.meta.dirname', /\bimport\.meta\.dirname\b/, 21],
1249
+ ['Array.prototype.toSorted / toReversed / toSpliced / with', /\.(?:toSorted|toReversed|toSpliced)\(|\.with\(/, 20],
1250
+ ['structuredClone', /\bstructuredClone\s*\(/, 17],
1251
+ ]
1252
+
1253
+ /** `>=22.1.0`, `^20`, `>=18 <21` -> the first major it allows. */
1254
+ const minNodeMajor = (range) => {
1255
+ if (typeof range !== 'string') return null
1256
+ const m = /(\d+)/.exec(range)
1257
+ return m ? Number(m[1]) : null
1258
+ }
1259
+
1260
+ export function checkNodeVersion(dir) {
1261
+ const declared = minNodeMajor(readPkgJson(dir)?.engines?.node)
1262
+ if (declared === null) return []
1263
+ const out = []
1264
+ const seen = new Set()
1265
+ for (const file of walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)) {
1266
+ const text = safeText(file)
1267
+ if (text === null) continue
1268
+ // Strings are stripped first: a tool description that names a feature is
1269
+ // prose, not a call (the same false positive require-in-esm had).
1270
+ const code = text
1271
+ .split('\n')
1272
+ .map(stripComment)
1273
+ .join('\n')
1274
+ .replace(/'(?:[^'\\\n]|\\.)*'/g, "''")
1275
+ .replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
1276
+ .replace(/`(?:[^`\\]|\\.)*`/g, '``')
1277
+ for (const [name, re, needs] of NODE_FEATURES) {
1278
+ if (needs <= declared || seen.has(name) || !re.test(code)) continue
1279
+ seen.add(name)
1280
+ out.push(
1281
+ F(
1282
+ 'node-too-old',
1283
+ 'warn',
1284
+ `engines.node says ${JSON.stringify(readPkgJson(dir).engines.node)} but lib/ uses ${name} (needs >=${needs})`,
1285
+ 'npm only warns about engines, so it installs on the older Node and throws at import time — the consumer sees a broken plugin, not a version mismatch.',
1286
+ `Raise engines.node to >=${needs}, or stop using ${name}.`,
1287
+ ),
1288
+ )
1289
+ }
1290
+ }
1291
+ return out
1292
+ }
1293
+
1205
1294
  const INJECT_HINTS = [
1206
1295
  ['tools', /ctx\.tools\b/],
1207
1296
  ['web', /ctx\.web\b/],
@@ -1564,6 +1653,7 @@ export function runStaticChecks(dir) {
1564
1653
  ...checkInjectCoverage(dir),
1565
1654
  ...checkClientHalf(dir),
1566
1655
  ...checkLibHygiene(dir),
1656
+ ...checkNodeVersion(dir),
1567
1657
  ...checkBuildFreshness(dir),
1568
1658
  ...checkDocs(dir),
1569
1659
  ...checkTests(dir),
@@ -1586,7 +1676,7 @@ const RULE_ORDER = [
1586
1676
  'inject-unused-service',
1587
1677
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
1588
1678
  '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',
1679
+ 'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
1590
1680
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
1591
1681
  'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
1592
1682
  'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'publish-not-served',
package/lib/index.js CHANGED
@@ -136,12 +136,18 @@ export function apply(ctx, config = {}) {
136
136
  properties: {
137
137
  dir: { type: 'string', description: 'Plugin directory.' },
138
138
  timeoutMs: { type: 'number', description: 'Kill the child after this long (default 20000).' },
139
+ call: {
140
+ type: 'array',
141
+ items: { type: 'string' },
142
+ description:
143
+ 'Tool names to actually call with empty arguments, e.g. ["page_watch_list"]. Off by default: some tools open applications or write files, so you name the ones you consider safe. A tool that throws on {} is a bug no other check can see, because registration never runs execute.',
144
+ },
139
145
  },
140
146
  },
141
147
  async execute(args) {
142
148
  const dir = resolveDir(args.dir)
143
149
  if (!dir) return { ok: false, error: 'no plugin directory given' }
144
- const { findings, tools } = await checkToolRegistrations(dir, { timeoutMs: args.timeoutMs })
150
+ const { findings, tools } = await checkToolRegistrations(dir, { timeoutMs: args.timeoutMs, calls: args.call })
145
151
  return { ok: true, tools, findings, report: renderReport(findings, { title: `runtime gate · ${dir}` }) }
146
152
  },
147
153
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bushizhanghan/dsh-publish-gate",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
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",