@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 +28 -1
- package/lib/engine.js +248 -23
- package/lib/index.js +1 -1
- package/package.json +2 -2
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 #
|
|
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
|
-
/**
|
|
559
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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": {
|