@bushizhanghan/dsh-publish-gate 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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` 用了宿主不认识的值 | 传 `call: ["工具名"]` 可以**真的调一次**(空参数):注册永远不会执行 `execute`,所以「一调就抛 ReferenceError」这类 bug 只有这一条能看见。默认不调——有些工具会开程序或写文件,你点名才算同意。 |
33
+ | `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 | 传 `call: ["工具名"]` 可以**真的调一次**(空参数):注册永远不会执行 `execute`,所以「一调就抛 ReferenceError」这类 bug 只有这一条能看见。默认不调——有些工具会开程序或写文件,你点名才算同意。超时单独算 `tool-call-slow`(warn),不跟「真的抛了」混在一起:`of_env` 这种要起 WSL 的工具本来就慢。 |
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`——「插件装了但工具没出现」的第一个怀疑对象 |
@@ -66,6 +66,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
66
66
  | `require-in-esm` | ESM 里 `require()` → `ERR_AMBIGUOUS_MODULE_SYNTAX`,报错不带行号 |
67
67
  | `no-license` | 缺 LICENSE |
68
68
  | `version-taken` | 这个版本已经在 registry 上,再发是 403 |
69
+ | `version-behind-latest` | 本地版本比 registry 上的 `latest` **旧**。npm 会照收,但 `latest` 继续指向新版本——包上传了,按名字装的人永远拿不到,且没有任何报错 |
69
70
  | `registry-unreachable` | registry 回了 500/429 —— 版本检查**没跑**,所以别把它当干净 |
70
71
 
71
72
  ### 值得在发布前知道的(warn / info)
@@ -137,6 +138,18 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
137
138
 
138
139
  `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
140
 
141
+ ### 依赖真实性:`lib/` 里 import 的东西,消费者那边真的会装上吗
142
+
143
+ `npm install` 装一个包时只装它的 `dependencies`,**永远不装 `devDependencies`**。所以在你自己仓库里 `import 'x'` 能跑(依赖树是完整的),到了用户那边 `ERR_MODULE_NOT_FOUND`——插件装上了,然后加载失败,用户看到的是「这个插件坏了」。
144
+
145
+ | 规则 | 抓什么 |
146
+ |---|---|
147
+ | `undeclared-import` | `lib/` 里 import 了某个包,`package.json` 三个依赖字段里都没有。它在你机器上能解析,只是因为别的包把它提升(hoist)到了 `node_modules` |
148
+ | `dep-in-dev-only` | 只在 `devDependencies` 里声明。这是最常见的一种:写代码时 `npm i -D` 装上了,测试也全绿 |
149
+ | `unused-dependency` | `dependencies` 里声明了,`lib/` 里从没 import。每个装你的人都白下一份(info,因为有些包是运行时动态加载的) |
150
+
151
+ 内置模块(`node:fs`、`fs/promises`)、相对路径、以及浏览器半边从宿主 `require` 拿到的 `react` 都不算。工具描述里那句 `require("react")` 是散文,也不是依赖——这条规则最早就是被它自己误报出来的。
152
+
140
153
  ### 构建产物:`src/` 和 `lib/` 谁更新
141
154
 
142
155
  有 `src/` 的插件(TypeScript 源码 + 构建出来的 `lib/`)最容易在这件事上翻车,而且两种翻车的表现一模一样——**发出去的字节没人跑过**:
@@ -183,7 +196,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
183
196
  ## 开发
184
197
 
185
198
  ```sh
186
- node --test # 66 个引擎单测
199
+ node --test # 75 个引擎单测
187
200
  node test/integration.test.mjs # 26 条集成断言(9 个真实插件作 fixture)
188
201
  ```
189
202
 
package/lib/engine.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // you a publish, so they are all checkable before you spend it.
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
10
10
  import { join, relative } from 'node:path'
11
+ import { builtinModules } from 'node:module'
11
12
  import { execFile } from 'node:child_process'
12
13
  import { promisify } from 'node:util'
13
14
 
@@ -555,30 +556,36 @@ export function checkNpmState(dir) {
555
556
  return out
556
557
  }
557
558
 
558
- /** Version-collision check: the same version already on the registry. */
559
- export async function checkVersionTaken(name, version, { registry = 'https://registry.npmjs.org', timeoutMs = 8000 } = {}) {
559
+ /**
560
+ * Version-collision check: the same version already on the registry.
561
+ * `doc` injects a registry document so the rule is testable without the network.
562
+ */
563
+ export async function checkVersionTaken(name, version, { registry = 'https://registry.npmjs.org', timeoutMs = 8000, doc } = {}) {
560
564
  const out = []
561
565
  try {
562
- const res = await fetch(`${registry.replace(/\/$/, '')}/${name.replace('/', '%2f')}`, {
563
- headers: { accept: 'application/vnd.npm.install-v1+json' },
564
- signal: AbortSignal.timeout(timeoutMs),
565
- })
566
- if (res.status === 404) return out // never published: fine
567
- if (!res.ok) {
568
- // A 500 or a rate limit is not proof the version is free. Saying nothing
569
- // here made the gate report "clean" for a check that never ran.
570
- out.push(
571
- F(
572
- 'registry-unreachable',
573
- 'warn',
574
- `could not read ${name} from the registry (HTTP ${res.status})`,
575
- 'The version-taken check did not run, so this is NOT a clean version check. npm itself will still refuse a duplicate on publish.',
576
- 'Re-run the gate later, or check https://www.npmjs.com/package/' + name + '?activeTab=versions by hand.',
577
- ),
578
- )
579
- return out
566
+ let json = doc ?? null
567
+ if (!json) {
568
+ const res = await fetch(`${registry.replace(/\/$/, '')}/${name.replace('/', '%2f')}`, {
569
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
570
+ signal: AbortSignal.timeout(timeoutMs),
571
+ })
572
+ if (res.status === 404) return out // never published: fine
573
+ if (!res.ok) {
574
+ // A 500 or a rate limit is not proof the version is free. Saying nothing
575
+ // here made the gate report "clean" for a check that never ran.
576
+ out.push(
577
+ F(
578
+ 'registry-unreachable',
579
+ 'warn',
580
+ `could not read ${name} from the registry (HTTP ${res.status})`,
581
+ 'The version-taken check did not run, so this is NOT a clean version check. npm itself will still refuse a duplicate on publish.',
582
+ 'Re-run the gate later, or check https://www.npmjs.com/package/' + name + '?activeTab=versions by hand.',
583
+ ),
584
+ )
585
+ return out
586
+ }
587
+ json = await res.json()
580
588
  }
581
- const json = await res.json()
582
589
  if (json?.versions?.[version]) {
583
590
  out.push(
584
591
  F(
@@ -590,6 +597,23 @@ export async function checkVersionTaken(name, version, { registry = 'https://reg
590
597
  ),
591
598
  )
592
599
  }
600
+
601
+ // npm accepts a publish of an OLDER version without a word. It lands in
602
+ // `versions`, but `dist-tags.latest` keeps pointing at the newer one, so
603
+ // anyone installing by name never receives it. The tarball is uploaded and
604
+ // invisible — which reads exactly like "my update did not reach anyone".
605
+ const latest = json?.['dist-tags']?.latest
606
+ if (latest && compareVersions(version, latest) < 0) {
607
+ out.push(
608
+ F(
609
+ 'version-behind-latest',
610
+ 'error',
611
+ `${name}@${version} is older than the published latest ${latest}`,
612
+ 'npm will accept this publish and leave `latest` on the newer version, so install-by-name keeps resolving to the old code. Nothing errors; the release simply never reaches anyone.',
613
+ `Publish ${bumpPatch(latest)} or higher instead.`,
614
+ ),
615
+ )
616
+ }
593
617
  } catch {}
594
618
  return out
595
619
  }
@@ -1079,17 +1103,21 @@ try {
1079
1103
  // bug the author cannot see any other way - registration never runs execute.
1080
1104
  stage = 'call'
1081
1105
  const wanted = new Set(JSON.parse(process.env.PG_CALLS || '[]'))
1106
+ const budget = Number(process.env.PG_CALL_MS || 8000)
1082
1107
  const calls = []
1083
1108
  for (const entry of registered) {
1084
1109
  if (!wanted.has(entry.name) || !entry.run) continue
1085
1110
  try {
1086
1111
  await Promise.race([
1087
1112
  Promise.resolve(entry.run({}, { signal: undefined })),
1088
- new Promise((_, reject) => setTimeout(() => reject(new Error('timed out after 8s')), 8000)),
1113
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), budget)),
1089
1114
  ])
1090
1115
  calls.push({ name: entry.name, ok: true })
1091
1116
  } catch (error) {
1092
- calls.push({ name: entry.name, ok: false, error: String((error && error.message) || error) })
1117
+ const message = String((error && error.message) || error)
1118
+ // A slow tool is not a broken tool: of_env shells out to WSL and takes
1119
+ // seconds by design. Only a real throw is a defect.
1120
+ calls.push({ name: entry.name, ok: false, timedOut: message === 'timed out', error: message })
1093
1121
  }
1094
1122
  }
1095
1123
  emit({ ok: true, tools: registered.map(({ run, ...rest }) => rest), badParamTypes, calls })
@@ -1107,7 +1135,11 @@ export async function checkToolRegistrations(dir, opts = {}) {
1107
1135
  timeout: opts.timeoutMs ?? 20_000,
1108
1136
  maxBuffer: 4 * 1024 * 1024,
1109
1137
  windowsHide: true,
1110
- env: { ...process.env, PG_CALLS: JSON.stringify(Array.isArray(opts.calls) ? opts.calls : []) },
1138
+ env: {
1139
+ ...process.env,
1140
+ PG_CALLS: JSON.stringify(Array.isArray(opts.calls) ? opts.calls : []),
1141
+ PG_CALL_MS: String(opts.callTimeoutMs ?? 8000),
1142
+ },
1111
1143
  })
1112
1144
  const line = String(stdout).split('\n').filter((l) => l.startsWith('@@PG@@')).pop()
1113
1145
  if (!line) throw new Error('the child printed no result')
@@ -1154,6 +1186,18 @@ export async function checkToolRegistrations(dir, opts = {}) {
1154
1186
  const seen = new Set()
1155
1187
  for (const c of Array.isArray(payload.calls) ? payload.calls : []) {
1156
1188
  if (c.ok) continue
1189
+ if (c.timedOut) {
1190
+ out.push(
1191
+ F(
1192
+ 'tool-call-slow',
1193
+ 'warn',
1194
+ `tool "${c.name}" did not answer within the call budget`,
1195
+ 'A slow tool is not a broken one — this may shell out or wait on a service. Reported so a hang is not mistaken for a pass, and NOT counted as a defect.',
1196
+ 'Raise callTimeoutMs if the tool is legitimately slow, or check that it returns at all.',
1197
+ ),
1198
+ )
1199
+ continue
1200
+ }
1157
1201
  out.push(
1158
1202
  F(
1159
1203
  'tool-call-throws',
@@ -1642,6 +1686,117 @@ export async function runArtifactChecks(dir, opts = {}) {
1642
1686
  }
1643
1687
  }
1644
1688
 
1689
+ // =====================================================================
1690
+ // Dependency truth
1691
+ // =====================================================================
1692
+
1693
+ // A package specifier never contains whitespace, so the capture is whitespace-
1694
+ // excluding on purpose: a greedy [^'"]+ matched across a whole template block
1695
+ // and reported the runner's own source as a dependency name.
1696
+ const SPECIFIER_RE =
1697
+ /\b(?:import|export)\s[^'"]*?from\s*['"]([^'"\s]+)['"]|\bimport\s*\(\s*['"]([^'"\s]+)['"]\s*\)|\bimport\s*['"]([^'"\s]+)['"]|\brequire\s*\(\s*['"]([^'"\s]+)['"]\s*\)/g
1698
+
1699
+ const BUILTINS = new Set(builtinModules)
1700
+
1701
+ // The client half runs inside the host's module loader, which resolves these
1702
+ // itself (CLIENT_RUNNER's fakeRequire mirrors it). They are provided, not
1703
+ // installed by the plugin, so importing them is not a missing dependency.
1704
+ const HOST_PROVIDED = new Set(['react', 'react-dom'])
1705
+
1706
+ /** '@scope/pkg/sub' -> '@scope/pkg', 'pkg/sub' -> 'pkg', relative/builtin -> null. */
1707
+ export const packageOf = (spec) => {
1708
+ if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) return null
1709
+ const parts = spec.split('/')
1710
+ const name = spec.startsWith('@') ? (parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null) : parts[0]
1711
+ return name && !BUILTINS.has(name) ? name : null
1712
+ }
1713
+
1714
+ // A tool description that says `require("react")` is prose, not an import — this
1715
+ // rule fired on this plugin's own description until it was dropped. Only quoted
1716
+ // strings that CONTAIN a call are removed, so a real specifier survives.
1717
+ const dropProse = (code) =>
1718
+ code
1719
+ .replace(/'(?:[^'\\\n]|\\.)*'/g, (s) => (/\b(?:require|import)\s*\(/.test(s) ? "''" : s))
1720
+ .replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => (/\b(?:require|import)\s*\(/.test(s) ? '""' : s))
1721
+
1722
+ /**
1723
+ * Every bare specifier lib/ imports, checked against what package.json declares.
1724
+ *
1725
+ * A published package installs its own `dependencies` — devDependencies are never
1726
+ * fetched by whoever installs the tarball. So an import that only devDependencies
1727
+ * satisfies works in your repo (where everything is installed) and throws
1728
+ * ERR_MODULE_NOT_FOUND in theirs, after they have already installed you.
1729
+ */
1730
+ export function checkDependencies(dir) {
1731
+ const out = []
1732
+ const json = readPkgJson(dir)
1733
+ if (!json) return out
1734
+ const deps = new Set(Object.keys(json.dependencies || {}))
1735
+ const dev = new Set(Object.keys(json.devDependencies || {}))
1736
+ const provided = new Set([
1737
+ ...Object.keys(json.peerDependencies || {}),
1738
+ ...Object.keys(json.optionalDependencies || {}),
1739
+ ])
1740
+
1741
+ const used = new Map() // package name -> the first lib/ file that imports it
1742
+ const clientRel = clientTarget(json)
1743
+ const isClientFile = (file) =>
1744
+ Boolean(clientRel) && relative(dir, file).replace(/\\/g, '/') === String(clientRel).replace(/^\.\//, '')
1745
+ for (const file of walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)) {
1746
+ const text = safeText(file)
1747
+ if (text === null) continue
1748
+ // Backtick strings go first: a runner template literal embeds code that
1749
+ // contains the word `import`, and a greedy specifier match used to swallow
1750
+ // a whole block of it. A real specifier is always a plain quoted string.
1751
+ const code = dropProse(text.split('\n').map(stripComment).join('\n').replace(/`(?:[^`\\]|\\.)*`/g, '``'))
1752
+ for (const m of code.matchAll(SPECIFIER_RE)) {
1753
+ const name = packageOf(m[1] ?? m[2] ?? m[3] ?? m[4])
1754
+ if (!name) continue
1755
+ if (isClientFile(file) && HOST_PROVIDED.has(name)) continue
1756
+ if (!used.has(name)) used.set(name, relative(dir, file))
1757
+ }
1758
+ }
1759
+
1760
+ for (const [name, file] of used) {
1761
+ if (deps.has(name) || provided.has(name)) continue
1762
+ if (dev.has(name)) {
1763
+ out.push(
1764
+ F(
1765
+ 'dep-in-dev-only',
1766
+ 'error',
1767
+ `${file} imports "${name}", which is only in devDependencies`,
1768
+ 'npm installs a package\'s dependencies for the consumer, never its devDependencies. The import resolves on your machine and fails with ERR_MODULE_NOT_FOUND on theirs — the plugin installs fine and then does not load.',
1769
+ `Move "${name}" into dependencies.`,
1770
+ ),
1771
+ )
1772
+ continue
1773
+ }
1774
+ out.push(
1775
+ F(
1776
+ 'undeclared-import',
1777
+ 'error',
1778
+ `${file} imports "${name}", which package.json does not declare`,
1779
+ 'It resolves locally only because some other package happens to hoist it into node_modules. The consumer has no such package, so the import fails at load time.',
1780
+ `Add "${name}" to dependencies (or drop the import).`,
1781
+ ),
1782
+ )
1783
+ }
1784
+
1785
+ for (const name of deps) {
1786
+ if (used.has(name)) continue
1787
+ out.push(
1788
+ F(
1789
+ 'unused-dependency',
1790
+ 'info',
1791
+ `dependencies lists "${name}" but lib/ never imports it`,
1792
+ 'Every consumer downloads and installs it for nothing. Sometimes that is deliberate (a peer you only load dynamically), which is why this is a note rather than a defect.',
1793
+ `Remove "${name}", or ignore this line if it is loaded at runtime only.`,
1794
+ ),
1795
+ )
1796
+ }
1797
+ return out
1798
+ }
1799
+
1645
1800
  // =====================================================================
1646
1801
  // Orchestration
1647
1802
  // =====================================================================
@@ -1655,6 +1810,7 @@ export function runStaticChecks(dir) {
1655
1810
  ...checkLibHygiene(dir),
1656
1811
  ...checkNodeVersion(dir),
1657
1812
  ...checkBuildFreshness(dir),
1813
+ ...checkDependencies(dir),
1658
1814
  ...checkDocs(dir),
1659
1815
  ...checkTests(dir),
1660
1816
  ...checkNpmState(dir),
@@ -1675,11 +1831,12 @@ const RULE_ORDER = [
1675
1831
  'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
1676
1832
  'inject-unused-service',
1677
1833
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
1678
- 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type',
1834
+ 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-call-throws', 'tool-call-slow',
1679
1835
  'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
1836
+ 'undeclared-import', 'dep-in-dev-only', 'unused-dependency',
1680
1837
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
1681
1838
  'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
1682
- 'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'publish-not-served',
1839
+ 'no-license', 'license-mismatch', 'version-taken', 'version-behind-latest', 'registry-unreachable', 'publish-not-served',
1683
1840
  'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
1684
1841
  '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',
1685
1842
  'market-no-repo', 'market-unavailable', 'market-duplicate-entry', 'market-npm-name-collision', 'market-repo-too-new',
package/lib/index.js CHANGED
@@ -91,7 +91,7 @@ export function apply(ctx, config = {}) {
91
91
  {
92
92
  name: 'publish_gate',
93
93
  description:
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}.',
94
+ 'Run the full pre-publish gate over a DSH plugin directory. Four 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) Dependencies: every bare import in lib/ resolved against package.json — an import nothing declares, an import only devDependencies satisfy (npm never installs those for whoever installs you), an unused dependency. (3) 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. (4) 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}.',
95
95
  parameters: {
96
96
  type: 'object',
97
97
  properties: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bushizhanghan/dsh-publish-gate",
3
- "version": "0.3.3",
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 + 市场一键三连。",
3
+ "version": "0.4.0",
4
+ "description": "Pre-publish gate for DSH plugins, in five layers: manifest, dependencies, artifact, runtime and the browser half. 发布前的五层门禁:清单、依赖、产物、运行时、浏览器半边。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {