@bushizhanghan/dsh-publish-gate 0.3.4 → 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
@@ -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 # 67 个引擎单测
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
  }
@@ -1662,6 +1686,117 @@ export async function runArtifactChecks(dir, opts = {}) {
1662
1686
  }
1663
1687
  }
1664
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
+
1665
1800
  // =====================================================================
1666
1801
  // Orchestration
1667
1802
  // =====================================================================
@@ -1675,6 +1810,7 @@ export function runStaticChecks(dir) {
1675
1810
  ...checkLibHygiene(dir),
1676
1811
  ...checkNodeVersion(dir),
1677
1812
  ...checkBuildFreshness(dir),
1813
+ ...checkDependencies(dir),
1678
1814
  ...checkDocs(dir),
1679
1815
  ...checkTests(dir),
1680
1816
  ...checkNpmState(dir),
@@ -1697,9 +1833,10 @@ const RULE_ORDER = [
1697
1833
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
1698
1834
  'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-call-throws', 'tool-call-slow',
1699
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',
1700
1837
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
1701
1838
  'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
1702
- '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',
1703
1840
  'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
1704
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',
1705
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.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 + 市场一键三连。",
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": {