@bushizhanghan/dsh-publish-gate 0.3.4 → 0.5.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/`)最容易在这件事上翻车,而且两种翻车的表现一模一样——**发出去的字节没人跑过**:
@@ -148,6 +161,20 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
148
161
 
149
162
  两条都只是 warn:有可能你就是要发手改的产物(我干过)。但发之前你得知道自己在发什么。
150
163
 
164
+ ### 可追溯性:一周后你还能指出线上那包是哪份代码吗
165
+
166
+ 发布是字节唯一一次离开你机器的时刻。如果这些字节既不在任何 commit 里、也没有 tag,那么一周后——包括你自己在内——没人说得清 registry 上那包是从哪份源码来的。
167
+
168
+ 这不是假想:我发过一版 `1.2.7` 到 npm,仓库的 GitHub release 还停在 `1.2.6`,市场条目指向旧 tarball,维护者直接打回。
169
+
170
+ | 规则 | 抓什么 |
171
+ |---|---|
172
+ | `git-uncommitted` | 会进 tarball 的文件有未提交改动。发出去的字节不在任何 commit 里,以后复现不出来、也没法 bisect |
173
+ | `git-tag-missing` | HEAD 上没有 `v<version>` tag。发完之后这些字节的唯一记录就是 registry,GitHub release、changelog、市场条目都没法指向一个从没打过 tag 的 commit |
174
+ | `git-tag-elsewhere` | tag 存在但不在 HEAD 上:tag 和 tarball 是两份不同的字节,从那个 tag 构建出来的东西不是你刚发的东西 |
175
+
176
+ 都是 warn:本地试发、私有包、发完再补 tag 都合理。但「发完之后指不出是哪份代码」这件事,事后补不回来。
177
+
151
178
  ## 一个真实的例子
152
179
 
153
180
  对一个刚从别人那儿接手、准备发布的插件跑 `publish_gate`:
@@ -183,7 +210,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
183
210
  ## 开发
184
211
 
185
212
  ```sh
186
- node --test # 67 个引擎单测
213
+ node --test # 80 个引擎单测
187
214
  node test/integration.test.mjs # 26 条集成断言(9 个真实插件作 fixture)
188
215
  ```
189
216
 
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
  }
@@ -1654,14 +1678,212 @@ export async function runArtifactChecks(dir, opts = {}) {
1654
1678
  const toolResult = opts.skipTools ? { findings: [], tools: null } : await checkToolRegistrations(dir, opts)
1655
1679
  const clientResult = opts.skipClient ? { findings: [], client: null } : await checkClientRuntime(dir, opts)
1656
1680
  const names = toolResult.tools ? toolResult.tools.map((t) => t.name).filter(Boolean) : null
1681
+ const trace = await checkTraceability(dir, {
1682
+ packFiles: packResult.pack?.files?.map((f) => f.path) || [],
1683
+ ...opts,
1684
+ })
1657
1685
  return {
1658
- findings: [...packResult.findings, ...toolResult.findings, ...clientResult.findings, ...checkReadmeTruth(dir, names)],
1686
+ findings: [...packResult.findings, ...toolResult.findings, ...clientResult.findings, ...checkReadmeTruth(dir, names), ...trace],
1659
1687
  pack: packResult.pack,
1660
1688
  tools: toolResult.tools,
1661
1689
  client: clientResult.client,
1662
1690
  }
1663
1691
  }
1664
1692
 
1693
+ // =====================================================================
1694
+ // Dependency truth
1695
+ // =====================================================================
1696
+
1697
+ // A package specifier never contains whitespace, so the capture is whitespace-
1698
+ // excluding on purpose: a greedy [^'"]+ matched across a whole template block
1699
+ // and reported the runner's own source as a dependency name.
1700
+ const SPECIFIER_RE =
1701
+ /\b(?:import|export)\s[^'"]*?from\s*['"]([^'"\s]+)['"]|\bimport\s*\(\s*['"]([^'"\s]+)['"]\s*\)|\bimport\s*['"]([^'"\s]+)['"]|\brequire\s*\(\s*['"]([^'"\s]+)['"]\s*\)/g
1702
+
1703
+ const BUILTINS = new Set(builtinModules)
1704
+
1705
+ // The client half runs inside the host's module loader, which resolves these
1706
+ // itself (CLIENT_RUNNER's fakeRequire mirrors it). They are provided, not
1707
+ // installed by the plugin, so importing them is not a missing dependency.
1708
+ const HOST_PROVIDED = new Set(['react', 'react-dom'])
1709
+
1710
+ /** '@scope/pkg/sub' -> '@scope/pkg', 'pkg/sub' -> 'pkg', relative/builtin -> null. */
1711
+ export const packageOf = (spec) => {
1712
+ if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) return null
1713
+ const parts = spec.split('/')
1714
+ const name = spec.startsWith('@') ? (parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null) : parts[0]
1715
+ return name && !BUILTINS.has(name) ? name : null
1716
+ }
1717
+
1718
+ // A tool description that says `require("react")` is prose, not an import — this
1719
+ // rule fired on this plugin's own description until it was dropped. Only quoted
1720
+ // strings that CONTAIN a call are removed, so a real specifier survives.
1721
+ const dropProse = (code) =>
1722
+ code
1723
+ .replace(/'(?:[^'\\\n]|\\.)*'/g, (s) => (/\b(?:require|import)\s*\(/.test(s) ? "''" : s))
1724
+ .replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => (/\b(?:require|import)\s*\(/.test(s) ? '""' : s))
1725
+
1726
+ /**
1727
+ * Every bare specifier lib/ imports, checked against what package.json declares.
1728
+ *
1729
+ * A published package installs its own `dependencies` — devDependencies are never
1730
+ * fetched by whoever installs the tarball. So an import that only devDependencies
1731
+ * satisfies works in your repo (where everything is installed) and throws
1732
+ * ERR_MODULE_NOT_FOUND in theirs, after they have already installed you.
1733
+ */
1734
+ export function checkDependencies(dir) {
1735
+ const out = []
1736
+ const json = readPkgJson(dir)
1737
+ if (!json) return out
1738
+ const deps = new Set(Object.keys(json.dependencies || {}))
1739
+ const dev = new Set(Object.keys(json.devDependencies || {}))
1740
+ const provided = new Set([
1741
+ ...Object.keys(json.peerDependencies || {}),
1742
+ ...Object.keys(json.optionalDependencies || {}),
1743
+ ])
1744
+
1745
+ const used = new Map() // package name -> the first lib/ file that imports it
1746
+ const clientRel = clientTarget(json)
1747
+ const isClientFile = (file) =>
1748
+ Boolean(clientRel) && relative(dir, file).replace(/\\/g, '/') === String(clientRel).replace(/^\.\//, '')
1749
+ for (const file of walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)) {
1750
+ const text = safeText(file)
1751
+ if (text === null) continue
1752
+ // Backtick strings go first: a runner template literal embeds code that
1753
+ // contains the word `import`, and a greedy specifier match used to swallow
1754
+ // a whole block of it. A real specifier is always a plain quoted string.
1755
+ const code = dropProse(text.split('\n').map(stripComment).join('\n').replace(/`(?:[^`\\]|\\.)*`/g, '``'))
1756
+ for (const m of code.matchAll(SPECIFIER_RE)) {
1757
+ const name = packageOf(m[1] ?? m[2] ?? m[3] ?? m[4])
1758
+ if (!name) continue
1759
+ if (isClientFile(file) && HOST_PROVIDED.has(name)) continue
1760
+ if (!used.has(name)) used.set(name, relative(dir, file))
1761
+ }
1762
+ }
1763
+
1764
+ for (const [name, file] of used) {
1765
+ if (deps.has(name) || provided.has(name)) continue
1766
+ if (dev.has(name)) {
1767
+ out.push(
1768
+ F(
1769
+ 'dep-in-dev-only',
1770
+ 'error',
1771
+ `${file} imports "${name}", which is only in devDependencies`,
1772
+ '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.',
1773
+ `Move "${name}" into dependencies.`,
1774
+ ),
1775
+ )
1776
+ continue
1777
+ }
1778
+ out.push(
1779
+ F(
1780
+ 'undeclared-import',
1781
+ 'error',
1782
+ `${file} imports "${name}", which package.json does not declare`,
1783
+ '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.',
1784
+ `Add "${name}" to dependencies (or drop the import).`,
1785
+ ),
1786
+ )
1787
+ }
1788
+
1789
+ for (const name of deps) {
1790
+ if (used.has(name)) continue
1791
+ out.push(
1792
+ F(
1793
+ 'unused-dependency',
1794
+ 'info',
1795
+ `dependencies lists "${name}" but lib/ never imports it`,
1796
+ '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.',
1797
+ `Remove "${name}", or ignore this line if it is loaded at runtime only.`,
1798
+ ),
1799
+ )
1800
+ }
1801
+ return out
1802
+ }
1803
+
1804
+ // =====================================================================
1805
+ // Traceability: can you point at the exact bytes you are publishing?
1806
+ // =====================================================================
1807
+
1808
+ const gitOut = async (dir, args) => {
1809
+ try {
1810
+ const { stdout } = await runFile('git', ['-C', dir, ...args], { timeout: 8000, windowsHide: true })
1811
+ return String(stdout).trim()
1812
+ } catch {
1813
+ return null
1814
+ }
1815
+ }
1816
+
1817
+ /**
1818
+ * Publishing is the one moment where the bytes leave your machine. If they are
1819
+ * not in a commit and not under a tag, then a week later nobody — including you —
1820
+ * can say which source produced the live tarball. This really happened: a 1.2.7
1821
+ * went to npm while the repo's release still said 1.2.6, and the marketplace
1822
+ * entry pointed at the old tarball.
1823
+ *
1824
+ * `status` / `atHead` / `tagExists` inject the git answers so the rules are
1825
+ * testable without a repository.
1826
+ */
1827
+ export async function checkTraceability(dir, opts = {}) {
1828
+ const out = []
1829
+ const json = readPkgJson(dir)
1830
+ const version = typeof opts.version === 'string' ? opts.version : json?.version
1831
+ if (!version) return out
1832
+
1833
+ const inside = opts.inside ?? (opts.status === undefined ? await gitOut(dir, ['rev-parse', '--is-inside-work-tree']) : 'true')
1834
+ if (inside !== 'true') return out // checkGitState already reported no-git
1835
+
1836
+ // 1. Dirty tree: the tarball carries bytes that exist in no commit.
1837
+ const status = opts.status === undefined ? await gitOut(dir, ['status', '--porcelain']) : opts.status
1838
+ if (typeof status === 'string') {
1839
+ const packed = new Set((opts.packFiles || []).map((p) => String(p).replace(/\\/g, '/')))
1840
+ const dirty = status
1841
+ .split('\n')
1842
+ .map((line) => line.slice(3).trim())
1843
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ').pop().trim() : p))
1844
+ .filter(Boolean)
1845
+ // Without a tarball listing, lib/ is the honest proxy: that is the code.
1846
+ const relevant = packed.size ? dirty.filter((p) => packed.has(p)) : dirty.filter((p) => p.startsWith('lib/'))
1847
+ if (relevant.length) {
1848
+ out.push(
1849
+ F(
1850
+ 'git-uncommitted',
1851
+ 'warn',
1852
+ `${relevant.length} file(s) going into the tarball are not committed`,
1853
+ `The published bytes exist in no commit, so they can never be reproduced or bisected later. First: ${relevant.slice(0, 3).join(', ')}.`,
1854
+ 'Commit (or stash) them before publishing.',
1855
+ ),
1856
+ )
1857
+ }
1858
+ }
1859
+
1860
+ // 2. No tag on HEAD: nothing points at these bytes afterwards.
1861
+ const atHead = opts.atHead === undefined ? await gitOut(dir, ['tag', '--points-at', 'HEAD']) : opts.atHead
1862
+ const tags = typeof atHead === 'string' ? atHead.split('\n').map((t) => t.trim()).filter(Boolean) : []
1863
+ const wanted = [`v${version}`, version]
1864
+ if (typeof atHead === 'string' && !wanted.some((t) => tags.includes(t))) {
1865
+ const exists = opts.tagExists === undefined ? await gitOut(dir, ['rev-parse', '--verify', '--quiet', `refs/tags/v${version}`]) : opts.tagExists ? 'sha' : null
1866
+ out.push(
1867
+ exists
1868
+ ? F(
1869
+ 'git-tag-elsewhere',
1870
+ 'warn',
1871
+ `tag v${version} exists but does not point at HEAD`,
1872
+ 'The tag and the tarball are different bytes. A release built from that tag would not be what you just published.',
1873
+ `Move it: git tag -f v${version} && git push -f origin v${version}`,
1874
+ )
1875
+ : F(
1876
+ 'git-tag-missing',
1877
+ 'warn',
1878
+ `no git tag v${version} on HEAD`,
1879
+ 'After publishing, the only record of these bytes is the registry. A GitHub release, a changelog entry or a market listing cannot point at a commit that was never tagged.',
1880
+ `git tag v${version} && git push origin v${version}`,
1881
+ ),
1882
+ )
1883
+ }
1884
+ return out
1885
+ }
1886
+
1665
1887
  // =====================================================================
1666
1888
  // Orchestration
1667
1889
  // =====================================================================
@@ -1675,6 +1897,7 @@ export function runStaticChecks(dir) {
1675
1897
  ...checkLibHygiene(dir),
1676
1898
  ...checkNodeVersion(dir),
1677
1899
  ...checkBuildFreshness(dir),
1900
+ ...checkDependencies(dir),
1678
1901
  ...checkDocs(dir),
1679
1902
  ...checkTests(dir),
1680
1903
  ...checkNpmState(dir),
@@ -1697,12 +1920,14 @@ const RULE_ORDER = [
1697
1920
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
1698
1921
  'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-call-throws', 'tool-call-slow',
1699
1922
  'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
1923
+ 'undeclared-import', 'dep-in-dev-only', 'unused-dependency',
1700
1924
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
1701
1925
  'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
1702
- 'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'publish-not-served',
1926
+ 'no-license', 'license-mismatch', 'version-taken', 'version-behind-latest', 'registry-unreachable', 'publish-not-served',
1703
1927
  'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
1704
1928
  '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
1929
  'market-no-repo', 'market-unavailable', 'market-duplicate-entry', 'market-npm-name-collision', 'market-repo-too-new',
1930
+ 'git-uncommitted', 'git-tag-missing', 'git-tag-elsewhere',
1706
1931
  'readme-tool-undocumented', 'readme-test-count',
1707
1932
  'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
1708
1933
  ]
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.5.0",
4
+ "description": "Pre-publish gate for DSH plugins in five layers: manifest, dependencies, artifact, runtime, browser half — plus traceability (can you point at the bytes you shipped?). 发布前门禁:清单、依赖、产物、运行时、浏览器半边,外加可追溯性。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {