@bushizhanghan/dsh-publish-gate 0.4.0 → 0.6.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/CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ - New rule `tool-undeclared-param`: a tool whose `execute` reads `args.x` that
6
+ `parameters.properties` never declares. The host renders `parameters` into the
7
+ prompt and passes the model nothing else, so that value is undefined on every
8
+ real call — the code path exists and never runs, and nothing throws.
9
+ Covers `args.x`, `args['x']` and `const { x } = args`.
10
+
11
+ ## 0.5.0
12
+
13
+ - New layer: traceability. `git-uncommitted` (files that ship in the tarball are
14
+ not committed), `git-tag-missing` (no `v<version>` tag on HEAD),
15
+ `git-tag-elsewhere` (the tag exists but points at another commit).
16
+ - The incident behind it: a 1.2.7 went to npm while the GitHub release still
17
+ said 1.2.6 and the market entry still pointed at the old tarball.
18
+
19
+ ## 0.4.0
20
+
21
+ - New layer: dependency truth. `undeclared-import` (lib/ imports a package
22
+ package.json never declares), `dep-in-dev-only` (only devDependencies satisfy
23
+ it — npm never installs those for the consumer), `unused-dependency` (info).
24
+ - New rule `version-behind-latest`: publishing under the live `latest` is
25
+ accepted by npm and reaches nobody.
26
+
27
+ ## 0.3.4
28
+
29
+ - `tool-call-slow` split from `tool-call-throws`: a timeout is not a defect.
30
+
31
+ ## 0.3.3
32
+
33
+ - `publish_gate_tools` accepts `call: ["name"]` to really invoke a tool, opt-in
34
+ because some tools open applications or write files.
35
+
36
+ ## 0.3.2
37
+
38
+ - New rule `node-too-old`: `engines.node` says one thing, `lib/` uses an API from
39
+ a newer Node. npm only warns on `engines`, so the plugin installs and then
40
+ throws at import.
41
+
42
+ ## 0.3.1
43
+
44
+ - `publish_gate_commands` emits commands that actually run on this machine
45
+ (`cmd /c "npm publish --access public"` on Windows) and pairs the publish step
46
+ with a `publish_gate_verify` shasum check.
47
+
48
+ ## 0.3.0
49
+
50
+ - New layer: the browser half. `checkClientHalf` / `checkClientRuntime` load the
51
+ bundle against a fake `window.__ModuleLoader__` and report a bundle that never
52
+ loads, never returns `apply`, throws, registers nothing, or uses a service its
53
+ `inject` array does not declare.
54
+ - New layer: market readiness — the repo is already listed, another entry
55
+ publishes the same npm name, or the repo is under 24 hours old (the gate fails
56
+ and reruns itself).
57
+ - New rules: `client-without-platform`, `pack-omits-client`, `pack-ships-secret`,
58
+ `private-field-set`, `publish-config-registry`, `tool-bad-parameter-type`,
59
+ `entry-path-missing`, `bundle-patch-not-declared`, `inject-unused-service`.
60
+ - New tool `publish_gate_client` (9 tools).
61
+
62
+ ## 0.2.0
63
+
64
+ - New layers: artifact (`npm pack --dry-run` — what npm will really ship) and
65
+ runtime (import the entry in an isolated child process, call `apply`, report a
66
+ throwing import, a throwing apply, missing execute, duplicate tool names, a
67
+ throwing `presentCall`, and card kinds the host does not render).
68
+ - New tools `publish_gate_tools` and `publish_gate_verify`.
69
+ - Fixed: `bad-repo-url` never fired for the object form of `repository`.
70
+
71
+ ## 0.1.0
72
+
73
+ - First release: manifest rules, `cordis.patch.yml` insert checks, README and
74
+ test presence, npm name and registry state.
package/README.md CHANGED
@@ -102,6 +102,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
102
102
  | `tool-present-call-throws` | `presentCall` 抛——调用记录渲染时炸 |
103
103
  | `tool-bad-card-kind` | `card: 'generic'` 的 `kind` 不在宿主认识的那一组里(`other` / `execute` / `search` / `read` / `edit` / `delete` / `fetch`)。注册不报错,卡片渲染坏掉——我五个插件一起踩过 |
104
104
  | `tool-bad-parameter-type` | `parameters` 里某个 `type` 写错(`"strin"`)。宿主把 `parameters` 渲染进 prompt、但**从不校验**调用值,所以这个 typo 只是悄悄把类型提示弄丢 |
105
+ | `tool-undeclared-param` | `execute` 读了 `args.x`,但 `parameters.properties` 里没有 `x`。模型只能传 schema 声明过的参数,所以这个值在真实调用里永远是 `undefined`——代码在,永远不跑,也不报错。`args.x`、`args['x']`、`const { x } = args` 三种写法都认 |
105
106
 
106
107
  ### 浏览器半边:侧栏面板到底会不会出现
107
108
 
@@ -161,6 +162,20 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
161
162
 
162
163
  两条都只是 warn:有可能你就是要发手改的产物(我干过)。但发之前你得知道自己在发什么。
163
164
 
165
+ ### 可追溯性:一周后你还能指出线上那包是哪份代码吗
166
+
167
+ 发布是字节唯一一次离开你机器的时刻。如果这些字节既不在任何 commit 里、也没有 tag,那么一周后——包括你自己在内——没人说得清 registry 上那包是从哪份源码来的。
168
+
169
+ 这不是假想:我发过一版 `1.2.7` 到 npm,仓库的 GitHub release 还停在 `1.2.6`,市场条目指向旧 tarball,维护者直接打回。
170
+
171
+ | 规则 | 抓什么 |
172
+ |---|---|
173
+ | `git-uncommitted` | 会进 tarball 的文件有未提交改动。发出去的字节不在任何 commit 里,以后复现不出来、也没法 bisect |
174
+ | `git-tag-missing` | HEAD 上没有 `v<version>` tag。发完之后这些字节的唯一记录就是 registry,GitHub release、changelog、市场条目都没法指向一个从没打过 tag 的 commit |
175
+ | `git-tag-elsewhere` | tag 存在但不在 HEAD 上:tag 和 tarball 是两份不同的字节,从那个 tag 构建出来的东西不是你刚发的东西 |
176
+
177
+ 都是 warn:本地试发、私有包、发完再补 tag 都合理。但「发完之后指不出是哪份代码」这件事,事后补不回来。
178
+
164
179
  ## 一个真实的例子
165
180
 
166
181
  对一个刚从别人那儿接手、准备发布的插件跑 `publish_gate`:
@@ -196,7 +211,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
196
211
  ## 开发
197
212
 
198
213
  ```sh
199
- node --test # 75 个引擎单测
214
+ node --test # 83 个引擎单测
200
215
  node test/integration.test.mjs # 26 条集成断言(9 个真实插件作 fixture)
201
216
  ```
202
217
 
package/lib/engine.js CHANGED
@@ -1057,6 +1057,31 @@ const walkParams = (schema, path) => {
1057
1057
  }
1058
1058
  if (schema.items) walkParams(schema.items, path + '[]')
1059
1059
  }
1060
+ // The model only ever sends the arguments \`parameters\` declares — the host
1061
+ // renders that schema into the prompt and passes nothing else. So an execute
1062
+ // that reads args.dir while the schema never mentions dir is unreachable code:
1063
+ // every call sees undefined, and the tool looks broken with no error anywhere.
1064
+ const ARG_DOT = /\\bargs\\s*\\??\\.\\s*([A-Za-z_$][\\w$]*)/g
1065
+ const ARG_INDEX = /\\bargs\\s*\\[\\s*['"]([^'"]+)['"]\\s*\\]/g
1066
+ const ARG_DESTRUCTURE = /\\{\\s*([^{}]+?)\\s*\\}\\s*=\\s*args\\b/g
1067
+ const undeclaredArgs = (def) => {
1068
+ const props = def?.parameters?.properties
1069
+ if (!props || typeof props !== 'object') return []
1070
+ const declared = Object.keys(props)
1071
+ if (typeof def.execute !== 'function') return []
1072
+ let src = ''
1073
+ try { src = def.execute.toString() } catch { return [] }
1074
+ const reads = []
1075
+ for (const m of src.matchAll(ARG_DOT)) reads.push(m[1])
1076
+ for (const m of src.matchAll(ARG_INDEX)) reads.push(m[1])
1077
+ for (const m of src.matchAll(ARG_DESTRUCTURE)) {
1078
+ for (const part of m[1].split(',')) {
1079
+ const key = part.split(':')[0].split('=')[0].replace(/\\.\\.\\./, '').trim()
1080
+ if (/^[A-Za-z_$][\\w$]*$/.test(key)) reads.push(key)
1081
+ }
1082
+ }
1083
+ return [...new Set(reads)].filter((r) => !declared.includes(r))
1084
+ }
1060
1085
  const noopService = () => new Proxy(function () {}, {
1061
1086
  get: (target, prop) => {
1062
1087
  // Own properties win, or ctx.tools would resolve to another no-op proxy
@@ -1086,6 +1111,7 @@ ctx.tools = {
1086
1111
  hasExecute: typeof def?.execute === 'function',
1087
1112
  hasOutputSchema: Boolean(def && def.output && def.output.schema),
1088
1113
  cardKind: card,
1114
+ undeclaredParams: undeclaredArgs(def),
1089
1115
  run: typeof def?.execute === 'function' ? def.execute : null,
1090
1116
  })
1091
1117
  walkParams(def?.parameters, typeof def?.name === 'string' ? def.name : '(unnamed)')
@@ -1229,6 +1255,21 @@ export async function checkToolRegistrations(dir, opts = {}) {
1229
1255
  ),
1230
1256
  )
1231
1257
  }
1258
+
1259
+ // The model only sends what `parameters` declares, so a read of anything
1260
+ // else is unreachable: args.dir is undefined on every real call, and the
1261
+ // tool looks broken while nothing throws.
1262
+ for (const name of Array.isArray(t.undeclaredParams) ? t.undeclaredParams : []) {
1263
+ out.push(
1264
+ F(
1265
+ 'tool-undeclared-param',
1266
+ 'warn',
1267
+ `tool "${label}" reads args.${name}, which parameters never declares`,
1268
+ 'The host renders parameters into the prompt and passes the model nothing else, so this value is always undefined on a real call. The code path exists and never runs.',
1269
+ `Add "${name}" to parameters.properties, or stop reading it.`,
1270
+ ),
1271
+ )
1272
+ }
1232
1273
  }
1233
1274
  const badParams = Array.isArray(payload.badParamTypes) ? payload.badParamTypes : []
1234
1275
  for (const where of badParams) {
@@ -1678,8 +1719,12 @@ export async function runArtifactChecks(dir, opts = {}) {
1678
1719
  const toolResult = opts.skipTools ? { findings: [], tools: null } : await checkToolRegistrations(dir, opts)
1679
1720
  const clientResult = opts.skipClient ? { findings: [], client: null } : await checkClientRuntime(dir, opts)
1680
1721
  const names = toolResult.tools ? toolResult.tools.map((t) => t.name).filter(Boolean) : null
1722
+ const trace = await checkTraceability(dir, {
1723
+ packFiles: packResult.pack?.files?.map((f) => f.path) || [],
1724
+ ...opts,
1725
+ })
1681
1726
  return {
1682
- findings: [...packResult.findings, ...toolResult.findings, ...clientResult.findings, ...checkReadmeTruth(dir, names)],
1727
+ findings: [...packResult.findings, ...toolResult.findings, ...clientResult.findings, ...checkReadmeTruth(dir, names), ...trace],
1683
1728
  pack: packResult.pack,
1684
1729
  tools: toolResult.tools,
1685
1730
  client: clientResult.client,
@@ -1797,6 +1842,89 @@ export function checkDependencies(dir) {
1797
1842
  return out
1798
1843
  }
1799
1844
 
1845
+ // =====================================================================
1846
+ // Traceability: can you point at the exact bytes you are publishing?
1847
+ // =====================================================================
1848
+
1849
+ const gitOut = async (dir, args) => {
1850
+ try {
1851
+ const { stdout } = await runFile('git', ['-C', dir, ...args], { timeout: 8000, windowsHide: true })
1852
+ return String(stdout).trim()
1853
+ } catch {
1854
+ return null
1855
+ }
1856
+ }
1857
+
1858
+ /**
1859
+ * Publishing is the one moment where the bytes leave your machine. If they are
1860
+ * not in a commit and not under a tag, then a week later nobody — including you —
1861
+ * can say which source produced the live tarball. This really happened: a 1.2.7
1862
+ * went to npm while the repo's release still said 1.2.6, and the marketplace
1863
+ * entry pointed at the old tarball.
1864
+ *
1865
+ * `status` / `atHead` / `tagExists` inject the git answers so the rules are
1866
+ * testable without a repository.
1867
+ */
1868
+ export async function checkTraceability(dir, opts = {}) {
1869
+ const out = []
1870
+ const json = readPkgJson(dir)
1871
+ const version = typeof opts.version === 'string' ? opts.version : json?.version
1872
+ if (!version) return out
1873
+
1874
+ const inside = opts.inside ?? (opts.status === undefined ? await gitOut(dir, ['rev-parse', '--is-inside-work-tree']) : 'true')
1875
+ if (inside !== 'true') return out // checkGitState already reported no-git
1876
+
1877
+ // 1. Dirty tree: the tarball carries bytes that exist in no commit.
1878
+ const status = opts.status === undefined ? await gitOut(dir, ['status', '--porcelain']) : opts.status
1879
+ if (typeof status === 'string') {
1880
+ const packed = new Set((opts.packFiles || []).map((p) => String(p).replace(/\\/g, '/')))
1881
+ const dirty = status
1882
+ .split('\n')
1883
+ .map((line) => line.slice(3).trim())
1884
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ').pop().trim() : p))
1885
+ .filter(Boolean)
1886
+ // Without a tarball listing, lib/ is the honest proxy: that is the code.
1887
+ const relevant = packed.size ? dirty.filter((p) => packed.has(p)) : dirty.filter((p) => p.startsWith('lib/'))
1888
+ if (relevant.length) {
1889
+ out.push(
1890
+ F(
1891
+ 'git-uncommitted',
1892
+ 'warn',
1893
+ `${relevant.length} file(s) going into the tarball are not committed`,
1894
+ `The published bytes exist in no commit, so they can never be reproduced or bisected later. First: ${relevant.slice(0, 3).join(', ')}.`,
1895
+ 'Commit (or stash) them before publishing.',
1896
+ ),
1897
+ )
1898
+ }
1899
+ }
1900
+
1901
+ // 2. No tag on HEAD: nothing points at these bytes afterwards.
1902
+ const atHead = opts.atHead === undefined ? await gitOut(dir, ['tag', '--points-at', 'HEAD']) : opts.atHead
1903
+ const tags = typeof atHead === 'string' ? atHead.split('\n').map((t) => t.trim()).filter(Boolean) : []
1904
+ const wanted = [`v${version}`, version]
1905
+ if (typeof atHead === 'string' && !wanted.some((t) => tags.includes(t))) {
1906
+ const exists = opts.tagExists === undefined ? await gitOut(dir, ['rev-parse', '--verify', '--quiet', `refs/tags/v${version}`]) : opts.tagExists ? 'sha' : null
1907
+ out.push(
1908
+ exists
1909
+ ? F(
1910
+ 'git-tag-elsewhere',
1911
+ 'warn',
1912
+ `tag v${version} exists but does not point at HEAD`,
1913
+ 'The tag and the tarball are different bytes. A release built from that tag would not be what you just published.',
1914
+ `Move it: git tag -f v${version} && git push -f origin v${version}`,
1915
+ )
1916
+ : F(
1917
+ 'git-tag-missing',
1918
+ 'warn',
1919
+ `no git tag v${version} on HEAD`,
1920
+ '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.',
1921
+ `git tag v${version} && git push origin v${version}`,
1922
+ ),
1923
+ )
1924
+ }
1925
+ return out
1926
+ }
1927
+
1800
1928
  // =====================================================================
1801
1929
  // Orchestration
1802
1930
  // =====================================================================
@@ -1831,7 +1959,7 @@ const RULE_ORDER = [
1831
1959
  'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
1832
1960
  'inject-unused-service',
1833
1961
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
1834
- 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-call-throws', 'tool-call-slow',
1962
+ 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-call-throws', 'tool-call-slow',
1835
1963
  'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
1836
1964
  'undeclared-import', 'dep-in-dev-only', 'unused-dependency',
1837
1965
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
@@ -1840,6 +1968,7 @@ const RULE_ORDER = [
1840
1968
  'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
1841
1969
  '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',
1842
1970
  'market-no-repo', 'market-unavailable', 'market-duplicate-entry', 'market-npm-name-collision', 'market-repo-too-new',
1971
+ 'git-uncommitted', 'git-tag-missing', 'git-tag-elsewhere',
1843
1972
  'readme-tool-undocumented', 'readme-test-count',
1844
1973
  'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
1845
1974
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bushizhanghan/dsh-publish-gate",
3
- "version": "0.4.0",
4
- "description": "Pre-publish gate for DSH plugins, in five layers: manifest, dependencies, artifact, runtime and the browser half. 发布前的五层门禁:清单、依赖、产物、运行时、浏览器半边。",
3
+ "version": "0.6.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": {
@@ -13,7 +13,8 @@
13
13
  "lib",
14
14
  "cordis.patch.yml",
15
15
  "README.md",
16
- "LICENSE"
16
+ "LICENSE",
17
+ "CHANGELOG.md"
17
18
  ],
18
19
  "keywords": [
19
20
  "dsh",