@bushizhanghan/dsh-publish-gate 0.20.0 → 0.22.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 CHANGED
@@ -1,15 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0
4
+
5
+ - New tool `publish_gate_link` (the tenth): the local-development layer. It answers "is the code the
6
+ host loads the code I just edited?" from the profile's own files — `<profile>/node_modules`,
7
+ `<profile>/node_modules/.modules.yaml`, `<profile>/cordis.patch.yml` — with no network and no
8
+ package manager. Every failure on this path is silent: the plugin simply never appears.
9
+ - `link-not-installed` (info): the profile has no entry for this package. Normal before the first
10
+ local install, so it does not raise the blocker count.
11
+ - `link-target-missing` (error): the entry is a link whose target is gone, or whose target has no
12
+ parseable `package.json`. A dead junction makes the host resolve the package to nothing — this is
13
+ what broke this machine's Web UI once, with no error anywhere.
14
+ - `link-stale-copy` (warn): the entry is a plain directory, not a link, and its version differs from
15
+ the checkout. Edits to `lib/` never reach the profile while the checkout's own tests stay green.
16
+ - `pnpm-store-conflict` (warn): the profile's pnpm store major differs from the bundled pnpm's (11).
17
+ `dsh plugin add link:` then fails with `ERR_PNPM_UNEXPECTED_STORE` before installing anything, and
18
+ the message names neither the profile nor the major to match. Read from `storeDir` in
19
+ `.modules.yaml`; measured on this profile: `...\AppData\Local\pnpm\store\v11`, so v11 vs v11 is
20
+ clean and only a v10 store is flagged.
21
+ - `patch-entry-top-level` (error): the package's row in the profile patch is a top-level `- id:`
22
+ entry. A top-level row overrides an entry that already exists; it does not add one, so the plugin
23
+ installs, loads, and never appears. This profile currently has **zero** `insert:` blocks and every
24
+ row is top-level — exactly the shape the rule exists to catch.
25
+ - The link kind is reported as `junction` / `symlink` / `copy` / `missing`: Node reports a junction
26
+ and a directory symlink identically (`lstat().isSymbolicLink()`), so the junction is identified by
27
+ `realpathSync(p) !== p`, the check that distinguishes the form `mklink /J` creates without admin
28
+ rights from the one that needs them.
29
+ - Tests: 131 engine tests (six new, one per rule, each against a temp profile — the real profile is
30
+ never read or written) and 32 integration assertions.
31
+
32
+ ## 0.21.0
33
+
34
+ - The runtime layer no longer assumes the entry is `lib/index.js`. It resolves `exports["."]` →
35
+ `main` → `lib/index.js`. Measured against the thirty-one plugins installed in this profile, the
36
+ old guess produced **twenty false "entry throws when imported" errors**; two of those plugins ship
37
+ `index.mjs` with no `lib/` at all.
38
+ - `entry-missing` (error): the manifest names no file that exists, so there is nothing to import.
39
+ - `entry-import-unresolved` (warn): the entry imports a bare package that is not installed beside
40
+ it and is a host-provided peer (`@deepseek-ai/*` or a declared `peerDependencies` entry). That is
41
+ not a defect — but a bare import of something the host does *not* provide fails identically.
42
+ - `lib-console-log` reports **once per file with a count**, and never for the client bundle. One
43
+ bundled plugin carried 459 `console.log` calls and produced 459 warnings, which is how a gate gets
44
+ ignored; in a browser bundle `console.log` is the debug channel, not host-log residue.
45
+ - `unused-dependency` is skipped for packages that ship a client bundle: the bundler inlines those
46
+ dependencies, so the code is in the bundle and "lib/ never imports it" is true but not actionable.
47
+
3
48
  ## 0.20.0
4
-
49
+
5
50
  - `publish_gate_commands` now agrees with the rules: a prerelease version gets `npm publish --tag next`
6
51
  instead of a bare publish that would move `latest`.
7
52
  - New step `tag the shipped commit` (`git tag v<version> && git push origin v<version>`). The gate has
8
53
  warned about a missing tag since 0.5.0 and the command list never mentioned tagging — I wrote a
9
54
  separate script to do it because the instructions never said to.
10
-
55
+
11
56
  ## 0.19.0
12
-
57
+
13
58
  - `prerelease-published-as-latest` (warn): a `-beta` / `-rc` version with no `publishConfig.tag`.
14
59
  npm publishes to `latest` whatever the suffix says, so `npm i <name>` starts handing everyone an
15
60
  unfinished build and nothing warns.
@@ -17,17 +62,17 @@
17
62
  older one. That is what `--tag next` does when you meant to release: the release looks shipped and
18
63
  is not what anyone installs.
19
64
  - README: dropped a made-up `test/…` example path — `readme-path-missing` caught my own prose.
20
-
65
+
21
66
  ## 0.18.0
22
-
67
+
23
68
  - `readme-path-missing` (warn): a README that names `test/…`, `lib/…`, `scripts/…` files that no longer
24
69
  exist. Rename a file and the README keeps telling people to run something that returns ENOENT;
25
70
  nothing else in the repo notices. Only repo-relative paths under directories a plugin owns, so a
26
71
  tutorial example is not a finding.
27
72
  - Verified over all nine plugins in this workspace: no false positives, all nine still `e0 w0`.
28
-
73
+
29
74
  ## 0.17.0
30
-
75
+
31
76
  - `changelog-not-shipped` (warn): a CHANGELOG.md that exists on disk but is not in the tarball. npm
32
77
  force-includes README and LICENSE and nothing else, so the file you keep updating never reaches the
33
78
  registry page. Hit it the first time I added a changelog and had to add it to `files` by hand.
@@ -37,14 +82,14 @@
37
82
  was pushed through the API and local HEAD is not the shipped commit.
38
83
  - First end-to-end run of the gate with the test layer over all nine plugins in this workspace: every
39
84
  suite passed, and the tag rule was the only false positive.
40
-
85
+
41
86
  - `changelog-not-shipped` (warn): a CHANGELOG.md that exists on disk but is not in the tarball. npm
42
87
  force-includes README and LICENSE and nothing else, so the file you keep updating never reaches the
43
88
  registry page. Hit it the first time I added a changelog and had to add it to `files` by hand.
44
89
  - First end-to-end run of the gate with the test layer over all nine plugins in this workspace.
45
-
90
+
46
91
  ## 0.16.0
47
-
92
+
48
93
  - `market-release-behind` (warn): the newest GitHub release is older (or newer) than package.json. A
49
94
  catalogue entry may install `releases/latest/download/<asset>`, so a lagging release serves code the
50
95
  description no longer describes — a reviewer blocked a submission for exactly that.
@@ -54,9 +99,9 @@
54
99
  - The source scan added in 0.15.0 caught the same mistake again while building this version: a string
55
100
  replace aimed at RULE_ORDER matched an earlier `F(...)` call and wrote the new rule name into a severity
56
101
  slot. Fixed, and the fix now goes through the RULE_ORDER slice instead of a global replace.
57
-
102
+
58
103
  ## 0.15.0
59
-
104
+
60
105
  - Three rules shipped with a *rule name* in their severity slot: `market-repo-too-new`,
61
106
  `test-script-dir-arg` and `tool-missing-output-schema` were built as
62
107
  `F(rule, "some-other-rule", "a-third-rule", "warn", title, ...)`, because a string replace aimed at
@@ -65,45 +110,45 @@
65
110
  reaches the report, so the rules looked like they simply never fired. Fixed all three, added the six
66
111
  rule names that never made it into RULE_ORDER, and added a source scan that fails when any `F(...)`
67
112
  call passes something other than an error/warn/info literal (or a ternary of two of them).
68
-
113
+
69
114
  ## 0.14.0
70
-
115
+
71
116
  - New test layer: `checkTestRun` runs `node --test` in the plugin directory and reports `tests-fail`
72
117
  (error), `tests-timeout` (warn) and `tests-not-run` (warn). Every other layer reads the paperwork —
73
118
  the manifest, the tarball, the registry — while nine of my own plugins shipped
74
119
  `"test": "node --test test/"`, which throws MODULE_NOT_FOUND on Node 22+ and would have been caught
75
120
  by simply running it. The child drops the inherited NODE_TEST_CONTEXT, because a gate that runs
76
121
  inside `node --test` would otherwise start the suite in test-child mode and see no summary at all.
77
-
122
+
78
123
  ## 0.13.1
79
-
124
+
80
125
  - The tag check now asks `git ls-remote origin` instead of `git tag --points-at HEAD`. When the commit is pushed
81
126
  through the GitHub API the local HEAD is not the remote one, so the local form reported `git-tag-missing` on
82
127
  seven of my nine plugins whose tag existed all along. It now compares the tag against the shipped branch
83
128
  head and stays silent when the remote cannot be reached — a check that cannot see the truth must not claim
84
129
  a defect.
85
130
  ## 0.13.0
86
-
131
+
87
132
  - `market-fork-of-listed` / `market-fork-undisclosed` (warn): the repo is a GitHub fork whose parent is
88
133
  already in the catalogue, and nothing says so. A submission was blocked for exactly this: the entry
89
134
  described the fork as if it were original work, and both plugins claim the same provider id, so installing
90
135
  both silently shadows one of them. The check uses the `fork` and `parent.full_name` fields of the repo
91
136
  metadata the market layer already fetches.
92
137
  ## 0.12.0
93
-
138
+
94
139
  - `pack-ships-junk` (warn): the tarball carries `.bak-*`, `.orig`, `.swp`, `.log`, `~` or `.DS_Store` debris.
95
140
  Naming a directory in `files` takes everything inside it, so a `lib/client.js.bak-20261026` left behind
96
141
  during a fix ships to every consumer and stays on npm permanently. Both weapp-loop and page-monitor had
97
142
  exactly that file during this session; both were deleted by hand before this rule existed.
98
143
  ## 0.11.0
99
-
144
+
100
145
  - `tool-no-description` (error), `tool-description-thin` (warn), `tool-no-parameters` (error): the host
101
146
  renders name + description + parameters into the prompt and nothing else, so those three fields are the
102
147
  tool's entire specification. A tool with no description is a name in a list; a tool that reads args with no
103
148
  schema never receives them. All nine of my plugins were clean, so this guards the shape rather than a
104
149
  specific crash — the first two fixtures that had `description: 'd'` did fail, which is the point.
105
150
  ## 0.10.0
106
-
151
+
107
152
  - `client-double-load` (warn): the client probe now evaluates the bundle a second time against the same
108
153
  `window`, with a loader that throws on a repeated registration. A bundle inserted by two patches (its own,
109
154
  plus any sidebar plugin that injects `dsh-client-modules`) aborted web boot this way. A window-level guard
package/README.md CHANGED
@@ -4,18 +4,18 @@
4
4
  >
5
5
  > 发布一个 DSH 插件有七步,每一步都有一个**直到你付出代价才说话的坑**:`files` 忘了 `lib/` 于是发出去一个空包、PowerShell 写下的 BOM 让 `JSON.parse` 报一个不提 BOM 的错、patch 的 insert 行缩进错了于是插件装上了但**永远不加载**、裸包名让 granular token 收到一个既不说 token 也不说 scope 的 403。这个插件把整条链路的每个静默坑变成一条确定性检查。
6
6
 
7
- 八个 agent 工具:全量门禁、运行时注册检查、发后校验、patch 专项、市场条目校验、版本推进、多插件体检、发布命令清单。
7
+ 十个 agent 工具:全量门禁、运行时注册检查、发后校验、patch 专项、市场条目校验、版本推进、多插件体检、发布命令清单、本地开发链路诊断。
8
8
 
9
9
  门禁分三层:**清单层**看 package.json / patch / 入口源码,**产物层**真的跑一遍 `npm pack --dry-run` 看 npm 到底会发出什么,**运行时层**在独立子进程里 `import()` 入口并 `apply(ctx)` 一遍,看它到底注册了什么。
10
10
 
11
11
  ### 自检:看不见的 finding
12
-
12
+
13
13
  `severity` 不是 `error`/`warn`/`info` 的 finding 会被所有计数器忽略,等于规则从没触发过。`publish_gate` 现在会把这类 finding 提升成一条 `internal-bad-severity` 的 error,测试里还有一条源码扫描盯着 `F(...)` 的第二个参数。这条网加上当天就抓到了我自己的同一个错误。
14
-
14
+
15
15
  ### 测试层:门禁真的会跑一遍你的测试
16
-
16
+
17
17
  `tests-fail` / `tests-timeout` / `tests-not-run` 三条规则会以 `node --test` 在插件目录里真跑一次(不经过 `npm test`,因为脚本内容由作者决定、可能 shell 出去)。子进程会去掉继承来的 `NODE_TEST_CONTEXT`——否则当门禁本身跑在 `node --test` 里时,子进程进入 test-child 模式、一个摘要都不打印,看起来像"没有测试"。
18
-
18
+
19
19
  ## 典型用法(写完插件后)
20
20
 
21
21
  ```
@@ -45,6 +45,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
45
45
  | `publish_gate_market` | 校验市场条目 YAML(url/name/category/description 双语);传 `dir` 则改查只有线上才能回答的三件事:这个仓库**已经在目录里**(再开 PR 就是重复条目)、另一个条目也在发同一个 npm 包、仓库还**不满 24 小时**(提交门禁会失败并自己重跑) |
46
46
  | `publish_gate_next_version` | 当前版本、是否已被 registry 占用、建议 bump 到多少(本地比 npm 旧时从 npm 的 latest 往上加,不会推荐一个已被占用的版本) |
47
47
  | `publish_gate_doctor` | 扫整个目录,按 blocker 数排序,附带每个插件的真实包体积,看哪个还不能发 |
48
+ | `publish_gate_link` | 本地开发链路:不联网、不调包管理器,只读 profile 自己的 `node_modules` / `.modules.yaml` / `cordis.patch.yml`,回答「宿主现在跑的到底是不是我刚改的这份代码」——链接的形态与目标、pnpm store 的 major、patch 行挂不挂载,一次报全。传 `profile` 可看别的 profile(默认 `~/.dsh/profiles/desktop`) |
48
49
  | `publish_gate_commands` | 按顺序列出发布命令,并标出这台机器已经走到哪一步。命令是**这台机器上真能跑的形式**:Windows 下 `npm publish` 走 `cmd /c`(PowerShell 的 `npm.ps1` 常被执行策略拦掉),`npm publish` 后面紧跟一条 `publish_gate_verify`;版本是预发版时命令自带 `--tag next`;最后一步是给发出去的 commit 打 tag |
49
50
 
50
51
  ## 检查清单
@@ -65,7 +66,8 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
65
66
  | `not-esm` | 没 `"type": "module"`,`import` 在加载时炸 |
66
67
  | `no-insert-row` | patch 里只有 override,永远不挂载(页面报的仍然是这条——顶层 `- id:` 是 patch 不是挂载) |
67
68
  | `bundle-patch-not-declared` | 有 `cordis.patch.yml` 但清单里没有 `dsh.bundle.patch`——patch 是死的,装了不挂载 |
68
- | `entry-path-missing` | `main` / `exports["."]` / `exports["./client"]` 指的文件不存在,宿主解析到空 |
69
+ | `entry-path-missing` | `main` / `exports["."]` / `exports["./client"]` 指的文件不存在,宿主解析到空 |
70
+ | `entry-missing` | 清单里没有任何一个存在的入口文件——连 `import` 的机会都没有 |
69
71
  | `inject-missing-*` | 用了 `ctx.tools` / `ctx.web` / `ctx.webServer` / `ctx.setInterval` 却没在 `inject` 里声明,cordis 永远不激活 |
70
72
  | `patch-not-mount` | info:这些顶层 `- id:` 是在覆盖已有条目,合法,但**不是**挂载 |
71
73
  | `insert-missing-name` / `insert-name-mismatch` | 挂载名缺失或与 package.json 不一致 |
@@ -103,11 +105,14 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
103
105
 
104
106
  ### 运行时层:`apply` 之后到底注册了什么
105
107
 
106
- 前两层都是读文件,读不出「装上能不能用」。这一层在独立子进程里 `import()` 入口、用假 ctx 调一次 `apply(ctx)`,把 `ctx.tools.register` 收到的每一条拿回来检查。20 秒超时,你的插件代码不在宿主进程里跑。
108
+ 前两层都是读文件,读不出「装上能不能用」。这一层在独立子进程里 `import()` 入口、用假 ctx 调一次 `apply(ctx)`,把 `ctx.tools.register` 收到的每一条拿回来检查。20 秒超时,你的插件代码不在宿主进程里跑。
109
+
110
+ 入口按 `exports["."]` → `main` → `lib/index.js` 的顺序解析。**早先写死 `lib/index.js`,拿三十一个真实已装插件一测,二十个报"入口抛异常"——其中两个的入口是 `index.mjs`,根本没有 `lib/`。**误报比漏报更贵:一个满屏假错误的门禁,第二次就没人跑了。
107
111
 
108
112
  | 规则 | 抓什么 |
109
113
  |---|---|
110
- | `entry-import-failed` | `import()` 入口就抛——装上必定不加载 |
114
+ | `entry-import-failed` | `import()` 入口就抛——装上必定不加载 |
115
+ | `entry-import-unresolved` | 入口 import 了一个没装在旁边的包(`@deepseek-ai/*` 这类宿主提供的 peer)——**warn**,不是缺陷 |
111
116
  | `apply-threw` | `apply(ctx)` 抛 |
112
117
  | `tool-none-registered` | 入口能加载,但一个工具都没注册 |
113
118
  | `tool-missing-execute` / `tool-missing-output-schema` | 注册项缺 `execute` / `output.schema`(宿主用 schema 校验返回值,缺了等于没校验) |
@@ -159,7 +164,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
159
164
 
160
165
  ### 代码卫生
161
166
 
162
- `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 那一刻抛——用户看到的是「插件坏了」,不是版本不匹配)。字符串和注释里的同名文字不算。
167
+ `lib-debugger`(error)、`lib-console-log`(**每个文件一条,带次数**;浏览器半边豁免——那里 `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 那一刻抛——用户看到的是「插件坏了」,不是版本不匹配)。字符串和注释里的同名文字不算。
163
168
 
164
169
  ### 依赖真实性:`lib/` 里 import 的东西,消费者那边真的会装上吗
165
170
 
@@ -169,7 +174,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
169
174
  |---|---|
170
175
  | `undeclared-import` | `lib/` 里 import 了某个包,`package.json` 三个依赖字段里都没有。它在你机器上能解析,只是因为别的包把它提升(hoist)到了 `node_modules` |
171
176
  | `dep-in-dev-only` | 只在 `devDependencies` 里声明。这是最常见的一种:写代码时 `npm i -D` 装上了,测试也全绿 |
172
- | `unused-dependency` | `dependencies` 里声明了,`lib/` 里从没 import。每个装你的人都白下一份(info,因为有些包是运行时动态加载的) |
177
+ | `unused-dependency` | `dependencies` 里声明了,`lib/` 里从没 import。每个装你的人都白下一份(info,因为有些包是运行时动态加载的)。带浏览器半边的包**不查这条**:打包器已经把依赖内联进 bundle,代码就在里面,不是没用到 |
173
178
 
174
179
  内置模块(`node:fs`、`fs/promises`)、相对路径、以及浏览器半边从宿主 `require` 拿到的 `react` 都不算。工具描述里那句 `require("react")` 是散文,也不是依赖——这条规则最早就是被它自己误报出来的。
175
180
 
@@ -199,6 +204,37 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
199
204
 
200
205
  都是 warn:本地试发、私有包、发完再补 tag 都合理。但「发完之后指不出是哪份代码」这件事,事后补不回来。
201
206
 
207
+ ## 本地开发:宿主跑的到底是不是你刚改的代码
208
+
209
+ 发布之前总有一段时间,插件只在你自己机器上跑。这段路比发布更容易踩空:宿主只加载 `<profile>/node_modules` 下的包,所以你改的是 `E:\...\dsh-x`,跑的是 profile 里那个入口——它可能是一个 junction、一份早就不再同步的拷贝,或者根本没有。这三种情况在宿主那边长得一模一样:插件不出现,没有报错,日志里也没有一行。
210
+
211
+ `publish_gate_link` 只读 profile 自己的三个文件回答这件事,不联网、不调包管理器、不写任何东西。
212
+
213
+ | 规则 | 严重度 | 抓什么 |
214
+ |---|---|---|
215
+ | `link-not-installed` | info | profile 的 `node_modules` 里没有这个包。新插件第一次接进宿主之前,这是正常状态 |
216
+ | `link-target-missing` | error | 链接在、目标没了(或目标里没有能解析的 `package.json`)。死掉的 junction 让宿主把包解析成空,插件静默消失——这台机器的 Web UI 就这样坏过一次 |
217
+ | `link-stale-copy` | warn | profile 里是**一份拷贝**而不是链接,且版本和工作区不同:改 `lib/` 永远不会到那边,而你在这边跑测试全绿 |
218
+ | `pnpm-store-conflict` | warn | profile 的 pnpm store 是 v10、随包的 pnpm 是 v11(或反过来)。`dsh plugin add link:` 会在装任何东西之前以 `ERR_PNPM_UNEXPECTED_STORE` 失败,而那条错误既不说 profile、也不说要匹配哪个 major |
219
+ | `patch-entry-top-level` | error | 这个包在 profile 的 `cordis.patch.yml` 里是一条顶层 `- id:` 行。顶层行覆盖树里已有的条目、不新增一条:装上了、加载了、永远不出现。这个坑藏了我两个插件两周 |
220
+
221
+ 链接的三种形态是分开报的:Node 对 junction 和目录符号链接都只说 `isSymbolicLink() === true`,只有 `realpathSync(p) !== p` 才说明它是 junction;普通目录则是拷贝。`mklink /J` 建 junction 不需要管理员权限,符号链接需要。
222
+
223
+ 工作流:
224
+
225
+ ```
226
+ 1. 改完 lib/,跑 publish_gate_link(dir)
227
+ 2. link-not-installed → 建 junction(finding 的 fix 里就是能直接粘的命令)
228
+ 3. link-target-missing → 删掉旧入口,重建 junction
229
+ link-stale-copy → 同上;拷贝不会跟着工作区变
230
+ 4. patch-entry-top-level → 把那一行挪进 "- insert:" 块
231
+ 5. pnpm-store-conflict → 别用 pnpm,用 mklink
232
+ ```
233
+
234
+ `profile` 参数可以看别的 profile(默认 `~/.dsh/profiles/desktop`)。测试永远用临时 profile,不碰真的那个。
235
+
236
+ 没有做的:它不替你建链接、不改 profile 的 patch——和门禁其余部分一样只读,每条 finding 都带 `fix` 文案,动手是你的决定。
237
+
202
238
  ## 一个真实的例子
203
239
 
204
240
  对一个刚从别人那儿接手、准备发布的插件跑 `publish_gate`:
@@ -235,8 +271,8 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
235
271
  ## 开发
236
272
 
237
273
  ```sh
238
- node --test # 120 个引擎单测
239
- node test/integration.test.mjs # 28 条集成断言(9 个真实插件作 fixture)
274
+ node --test # 131 个引擎单测
275
+ node test/integration.test.mjs # 32 条集成断言(9 个真实插件作 fixture)
240
276
  ```
241
277
 
242
278
  ## License
package/lib/engine.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // unscoped npm name that returned E403, a git push that failed on the network
7
7
  // while the REST API worked. All of them are silent until the moment they cost
8
8
  // you a publish, so they are all checkable before you spend it.
9
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
9
+ import { existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, realpathSync, statSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
10
11
  import { join, relative } from 'node:path'
11
12
  import { builtinModules } from 'node:module'
12
13
  import { execFile } from 'node:child_process'
@@ -27,16 +28,16 @@ const safeText = (p) => {
27
28
  return b === null ? null : b.toString('utf8')
28
29
  }
29
30
 
30
- export const F = (rule, severity, title, detail, fix) => ({ rule, severity, title, detail, fix })
31
-
32
- /**
33
- * A finding whose severity is not error/warn/info is invisible: the counters
34
- * only count those three, so the rule silently never reports. Three of them
35
- * shipped that way before this existed. The source scan in the test suite
36
- * catches the literal forms; this catches whatever a future refactor builds.
37
- */
38
- export const SEVERITIES = new Set(['error', 'warn', 'info'])
39
- export const straySeverities = (findings) =>
31
+ export const F = (rule, severity, title, detail, fix) => ({ rule, severity, title, detail, fix })
32
+
33
+ /**
34
+ * A finding whose severity is not error/warn/info is invisible: the counters
35
+ * only count those three, so the rule silently never reports. Three of them
36
+ * shipped that way before this existed. The source scan in the test suite
37
+ * catches the literal forms; this catches whatever a future refactor builds.
38
+ */
39
+ export const SEVERITIES = new Set(['error', 'warn', 'info'])
40
+ export const straySeverities = (findings) =>
40
41
  [...new Set((findings || []).filter((f) => !SEVERITIES.has(f?.severity)).map((f) => String(f?.rule || '<no rule>')))]
41
42
 
42
43
  /** package.json without the BOM trap. Null when it is missing or unparseable. */
@@ -191,20 +192,20 @@ export function checkPackageJson(dir) {
191
192
  out.push(F('invalid-version', 'error', `version "${json.version}" is not semver`, 'npm publish rejects it.', 'Use MAJOR.MINOR.PATCH.'))
192
193
  }
193
194
 
194
-
195
- // npm publishes to `latest` unless told otherwise, and a prerelease version
196
- // does not change that. `npm i <name>` then resolves to a beta and every user
197
- // is on an unfinished build; nothing warns, and the author sees a green publish.
198
- if (json.version && /^\d+\.\d+\.\d+-/.test(json.version) && (!json.publishConfig?.tag || json.publishConfig.tag === 'latest')) {
199
- out.push(
200
- F(
201
- 'prerelease-published-as-latest',
202
- 'warn',
203
- `version "${json.version}" is a prerelease but it will be published to \`latest\``,
204
- 'npm publishes to `latest` by default and a `-beta`/`-rc` suffix does not change that, so `npm i <name>` starts handing everyone an unfinished build.',
205
- 'Publish with `npm publish --tag next`, or set publishConfig.tag to next.',
206
- ),
207
- )
195
+
196
+ // npm publishes to `latest` unless told otherwise, and a prerelease version
197
+ // does not change that. `npm i <name>` then resolves to a beta and every user
198
+ // is on an unfinished build; nothing warns, and the author sees a green publish.
199
+ if (json.version && /^\d+\.\d+\.\d+-/.test(json.version) && (!json.publishConfig?.tag || json.publishConfig.tag === 'latest')) {
200
+ out.push(
201
+ F(
202
+ 'prerelease-published-as-latest',
203
+ 'warn',
204
+ `version "${json.version}" is a prerelease but it will be published to \`latest\``,
205
+ 'npm publishes to `latest` by default and a `-beta`/`-rc` suffix does not change that, so `npm i <name>` starts handing everyone an unfinished build.',
206
+ 'Publish with `npm publish --tag next`, or set publishConfig.tag to next.',
207
+ ),
208
+ )
208
209
  }
209
210
  // type: module — the whole plugin surface is ESM
210
211
  if (json.type !== 'module') {
@@ -569,29 +570,29 @@ export function checkDocs(dir) {
569
570
  )
570
571
  }
571
572
  }
572
- // A README that names files by path rots the moment one is renamed: the
573
- // reader copies the command, gets ENOENT, and nothing in the repo notices.
574
- // Only repo-relative paths in the directories a plugin actually owns, so a
575
- // tutorial example does not turn into a finding.
576
- if (text) {
577
- const named = new Set()
578
- for (const m of text.matchAll(/(?:^|[\s`'"(])((?:test|lib|scripts|bin|src)\/[\w./@-]+\.(?:mjs|cjs|js|json|yml|yaml|md))/g)) {
579
- if (!existsSync(join(dir, m[1]))) named.add(m[1])
580
- }
581
- if (named.size) {
582
- const list = [...named]
583
- out.push(
584
- F(
585
- 'readme-path-missing',
586
- 'warn',
587
- `README names ${list.length} path(s) that do not exist`,
588
- `Whoever copies them gets ENOENT: ${list.slice(0, 4).join(", ")}.`,
589
- 'Rename them in the README, or restore the files.',
590
- ),
591
- )
592
- }
593
- }
594
- }
573
+ // A README that names files by path rots the moment one is renamed: the
574
+ // reader copies the command, gets ENOENT, and nothing in the repo notices.
575
+ // Only repo-relative paths in the directories a plugin actually owns, so a
576
+ // tutorial example does not turn into a finding.
577
+ if (text) {
578
+ const named = new Set()
579
+ for (const m of text.matchAll(/(?:^|[\s`'"(])((?:test|lib|scripts|bin|src)\/[\w./@-]+\.(?:mjs|cjs|js|json|yml|yaml|md))/g)) {
580
+ if (!existsSync(join(dir, m[1]))) named.add(m[1])
581
+ }
582
+ if (named.size) {
583
+ const list = [...named]
584
+ out.push(
585
+ F(
586
+ 'readme-path-missing',
587
+ 'warn',
588
+ `README names ${list.length} path(s) that do not exist`,
589
+ `Whoever copies them gets ENOENT: ${list.slice(0, 4).join(", ")}.`,
590
+ 'Rename them in the README, or restore the files.',
591
+ ),
592
+ )
593
+ }
594
+ }
595
+ }
595
596
  if (!existsSync(join(dir, 'LICENSE'))) {
596
597
  out.push(F('no-license', 'error', 'LICENSE is missing', 'npm warns on publish and the market listing shows an unknown license.', 'Add a LICENSE file (MIT is the ecosystem norm).'))
597
598
  } else {
@@ -782,21 +783,21 @@ export async function checkVersionTaken(name, version, { registry = 'https://reg
782
783
  )
783
784
  }
784
785
 
785
-
786
- // A publish with `--tag next` leaves `latest` where it was: the new version
787
- // is on the registry, `npm i <name>` still gets the old one, and nothing
788
- // fails. The release looks shipped and is not what anyone installs.
789
- const latestTag = json?.['dist-tags']?.latest
790
- if (json?.versions?.[version] && latestTag && latestTag !== version) {
791
- out.push(
792
- F(
793
- 'publish-tag-not-latest',
794
- 'warn',
795
- `${name}@${version} is published but \`latest\` still points at ${latestTag}`,
796
- 'Installing by name keeps getting the old version. This is what `--tag next` (or publishConfig.tag) does when you meant to release.',
797
- `Move it: npm dist-tag add ${name}@${version} latest`,
798
- ),
799
- )
786
+
787
+ // A publish with `--tag next` leaves `latest` where it was: the new version
788
+ // is on the registry, `npm i <name>` still gets the old one, and nothing
789
+ // fails. The release looks shipped and is not what anyone installs.
790
+ const latestTag = json?.['dist-tags']?.latest
791
+ if (json?.versions?.[version] && latestTag && latestTag !== version) {
792
+ out.push(
793
+ F(
794
+ 'publish-tag-not-latest',
795
+ 'warn',
796
+ `${name}@${version} is published but \`latest\` still points at ${latestTag}`,
797
+ 'Installing by name keeps getting the old version. This is what `--tag next` (or publishConfig.tag) does when you meant to release.',
798
+ `Move it: npm dist-tag add ${name}@${version} latest`,
799
+ ),
800
+ )
800
801
  }
801
802
  // npm accepts a publish of an OLDER version without a word. It lands in
802
803
  // `versions`, but `dist-tags.latest` keeps pointing at the newer one, so
@@ -920,7 +921,7 @@ export async function checkMarketReadiness(dir, opts = {}) {
920
921
  return out
921
922
  }
922
923
  const json = readPkgJson(dir)
923
- const pkgName = typeof json?.name === 'string' ? json.name : null
924
+ const pkgName = typeof json?.name === 'string' ? json.name : null
924
925
  const pkgVersion = typeof json?.version === 'string' ? json.version : null
925
926
 
926
927
  let entries = Array.isArray(opts.entries) ? opts.entries : null
@@ -1036,37 +1037,37 @@ export async function checkMarketReadiness(dir, opts = {}) {
1036
1037
  ),
1037
1038
  )
1038
1039
  }
1039
- }
1040
- // A catalogue entry can point at a GitHub release asset instead of npm. When
1041
- // the newest release is older than this directory, the entry serves code the
1042
- // description no longer matches — a reviewer blocked a submission for exactly
1043
- // that (the entry said 1.2.7, the asset was still 1.2.6).
1044
- let releaseInfo = opts.releaseInfo ?? null
1045
- if (releaseInfo === null) {
1046
- try {
1047
- const res = await fetch(`https://api.github.com/repos/${slug}/releases/latest`, {
1048
- signal: AbortSignal.timeout(timeoutMs),
1049
- headers: { 'user-agent': 'dsh-publish-gate', accept: 'application/vnd.github+json' },
1050
- })
1051
- if (res.ok) releaseInfo = await res.json()
1052
- } catch {
1053
- // no network, no releases, or a rate limit — the rule stays quiet
1054
- }
1055
- }
1056
- const releaseTag = typeof releaseInfo?.tag_name === 'string' ? releaseInfo.tag_name.replace(/^v/, '') : null
1057
- if (releaseTag && pkgVersion && releaseTag !== pkgVersion) {
1058
- const older = compareVersions(releaseTag, pkgVersion) < 0
1059
- out.push(
1060
- F(
1061
- 'market-release-behind',
1062
- 'warn',
1063
- `the newest GitHub release is ${releaseTag}, this directory says ${pkgVersion}`,
1064
- older
1065
- ? 'An entry that installs releases/latest/download/<asset> serves the older code while the description is written for the newer version — a reviewer reads that as a false claim.'
1066
- : `The release is ahead of package.json, so what the release ships is not what this directory builds.`,
1067
- `Cut a ${pkgVersion} release (asset name unchanged) or point the entry at npm.`,
1068
- ),
1069
- )
1040
+ }
1041
+ // A catalogue entry can point at a GitHub release asset instead of npm. When
1042
+ // the newest release is older than this directory, the entry serves code the
1043
+ // description no longer matches — a reviewer blocked a submission for exactly
1044
+ // that (the entry said 1.2.7, the asset was still 1.2.6).
1045
+ let releaseInfo = opts.releaseInfo ?? null
1046
+ if (releaseInfo === null) {
1047
+ try {
1048
+ const res = await fetch(`https://api.github.com/repos/${slug}/releases/latest`, {
1049
+ signal: AbortSignal.timeout(timeoutMs),
1050
+ headers: { 'user-agent': 'dsh-publish-gate', accept: 'application/vnd.github+json' },
1051
+ })
1052
+ if (res.ok) releaseInfo = await res.json()
1053
+ } catch {
1054
+ // no network, no releases, or a rate limit — the rule stays quiet
1055
+ }
1056
+ }
1057
+ const releaseTag = typeof releaseInfo?.tag_name === 'string' ? releaseInfo.tag_name.replace(/^v/, '') : null
1058
+ if (releaseTag && pkgVersion && releaseTag !== pkgVersion) {
1059
+ const older = compareVersions(releaseTag, pkgVersion) < 0
1060
+ out.push(
1061
+ F(
1062
+ 'market-release-behind',
1063
+ 'warn',
1064
+ `the newest GitHub release is ${releaseTag}, this directory says ${pkgVersion}`,
1065
+ older
1066
+ ? 'An entry that installs releases/latest/download/<asset> serves the older code while the description is written for the newer version — a reviewer reads that as a false claim.'
1067
+ : `The release is ahead of package.json, so what the release ships is not what this directory builds.`,
1068
+ `Cut a ${pkgVersion} release (asset name unchanged) or point the entry at npm.`,
1069
+ ),
1070
+ )
1070
1071
  }
1071
1072
  return out
1072
1073
  }
@@ -1266,22 +1267,22 @@ export async function checkPack(dir, opts = {}) {
1266
1267
  'Add "lib" to files.',
1267
1268
  ),
1268
1269
  )
1269
- }
1270
- // npm does not force-include a changelog the way it does README and LICENSE,
1271
- // so a CHANGELOG.md that is not listed in `files` never reaches the registry
1272
- // page — the file exists, the author updates it, and nobody installing the
1273
- // package can see it. I hit this the first time I added one.
1274
- const changelogOnDisk = ['CHANGELOG.md', 'CHANGELOG', 'HISTORY.md'].find((f) => existsSync(join(dir, f)))
1275
- if (changelogOnDisk && !paths.includes(changelogOnDisk)) {
1276
- out.push(
1277
- F(
1278
- 'changelog-not-shipped',
1279
- 'warn',
1280
- `${changelogOnDisk} is not in the tarball`,
1281
- 'npm force-includes README and LICENSE but not the changelog, so the file you keep updating is invisible on the package page.',
1282
- `Add "${changelogOnDisk}" to files.`,
1283
- ),
1284
- )
1270
+ }
1271
+ // npm does not force-include a changelog the way it does README and LICENSE,
1272
+ // so a CHANGELOG.md that is not listed in `files` never reaches the registry
1273
+ // page — the file exists, the author updates it, and nobody installing the
1274
+ // package can see it. I hit this the first time I added one.
1275
+ const changelogOnDisk = ['CHANGELOG.md', 'CHANGELOG', 'HISTORY.md'].find((f) => existsSync(join(dir, f)))
1276
+ if (changelogOnDisk && !paths.includes(changelogOnDisk)) {
1277
+ out.push(
1278
+ F(
1279
+ 'changelog-not-shipped',
1280
+ 'warn',
1281
+ `${changelogOnDisk} is not in the tarball`,
1282
+ 'npm force-includes README and LICENSE but not the changelog, so the file you keep updating is invisible on the package page.',
1283
+ `Add "${changelogOnDisk}" to files.`,
1284
+ ),
1285
+ )
1285
1286
  }
1286
1287
  if (json?.dsh?.bundle?.patch && !paths.includes(String(json.dsh.bundle.patch).replace(/^\.\//, ''))) {
1287
1288
  out.push(
@@ -1401,6 +1402,10 @@ const RUNNER = `
1401
1402
  import { pathToFileURL } from 'node:url'
1402
1403
  import { join } from 'node:path'
1403
1404
  const dir = process.argv[1]
1405
+ // The entry is whatever package.json says it is. Assuming lib/index.js
1406
+ // reported a hard load failure for every plugin that ships index.mjs or a
1407
+ // dsh/ folder instead - two of the thirty-one installed plugins.
1408
+ const entryPath = process.argv[2] || join(dir, 'lib', 'index.js')
1404
1409
  const registered = []
1405
1410
  // The host renders \`parameters\` into the prompt and never validates the value
1406
1411
  // against it, so a typo like "strin" silently removes the type hint.
@@ -1528,7 +1533,7 @@ const validateValue = (schema, value, path) => {
1528
1533
  const emit = (payload) => process.stdout.write('\\n@@PG@@' + JSON.stringify(payload) + '\\n')
1529
1534
  let stage = 'import'
1530
1535
  try {
1531
- const mod = await import(pathToFileURL(join(dir, 'lib', 'index.js')).href)
1536
+ const mod = await import(pathToFileURL(entryPath).href)
1532
1537
  stage = 'apply'
1533
1538
  if (typeof mod.apply !== 'function') throw new Error('lib/index.js does not export apply')
1534
1539
  await mod.apply(ctx, {})
@@ -1594,16 +1599,51 @@ try {
1594
1599
  }
1595
1600
  emit({ ok: true, tools: registered.map(({ run, ...rest }) => rest), badParamTypes, calls, fuzzIssues })
1596
1601
  } catch (error) {
1597
- emit({ ok: false, stage, error: String((error && error.message) || error) })
1602
+ emit({ ok: false, stage, error: String((error && error.message) || error), code: String((error && error.code) || '') })
1598
1603
  }
1599
1604
  process.exit(0)
1600
1605
  `
1601
1606
 
1607
+ export function entryFile(dir) {
1608
+ const json = readPkgJson(dir)
1609
+ if (!json) return null
1610
+ const pick = (value) => {
1611
+ if (typeof value === 'string') return value
1612
+ if (value && typeof value === 'object') {
1613
+ for (const key of ['import', 'node', 'default', 'require']) {
1614
+ const got = pick(value[key])
1615
+ if (got) return got
1616
+ }
1617
+ }
1618
+ return null
1619
+ }
1620
+ const candidates = [pick(json.exports?.['.']), pick(json.exports), pick(json.main), 'lib/index.js', 'index.js']
1621
+ for (const rel of candidates) {
1622
+ if (typeof rel !== 'string' || !rel || rel.includes('*')) continue
1623
+ const abs = join(dir, rel.replace(/^\.\//, ''))
1624
+ if (existsSync(abs)) return abs
1625
+ }
1626
+ return null
1627
+ }
1628
+
1602
1629
  export async function checkToolRegistrations(dir, opts = {}) {
1603
1630
  const out = []
1631
+ const entry = entryFile(dir)
1632
+ if (!entry) {
1633
+ out.push(
1634
+ F(
1635
+ 'entry-missing',
1636
+ 'error',
1637
+ 'no entry file could be resolved',
1638
+ 'package.json names no file that exists, so the host cannot import anything.',
1639
+ 'Point main or exports["."] at the file you ship.',
1640
+ ),
1641
+ )
1642
+ return { findings: out, tools: null }
1643
+ }
1604
1644
  let payload = null
1605
1645
  try {
1606
- const { stdout } = await runFile(process.execPath, ['--input-type=module', '-e', RUNNER, dir], {
1646
+ const { stdout } = await runFile(process.execPath, ['--input-type=module', '-e', RUNNER, dir, entry], {
1607
1647
  timeout: opts.timeoutMs ?? 20_000,
1608
1648
  maxBuffer: 4 * 1024 * 1024,
1609
1649
  windowsHide: true,
@@ -1632,6 +1672,26 @@ export async function checkToolRegistrations(dir, opts = {}) {
1632
1672
  }
1633
1673
 
1634
1674
  if (!payload.ok) {
1675
+ // A bare specifier that is not installed beside the plugin may still be
1676
+ // provided by the host: every plugin importing @deepseek-ai/* peers failed
1677
+ // this way, and calling that a defect makes the whole runtime layer noise.
1678
+ const missing = /Cannot find package '([^']+)'/.exec(String(payload.error || ''))
1679
+ if (payload.stage === 'import' && payload.code === 'ERR_MODULE_NOT_FOUND' && missing) {
1680
+ const spec = missing[1]
1681
+ const peers = readPkgJson(dir)?.peerDependencies || {}
1682
+ if (spec.startsWith('@deepseek-ai/') || Object.prototype.hasOwnProperty.call(peers, spec)) {
1683
+ out.push(
1684
+ F(
1685
+ 'entry-import-unresolved',
1686
+ 'warn',
1687
+ `imports "${spec}", which is not installed next to the plugin`,
1688
+ 'That package is a peer the host provides, so this is not a defect — but a bare import of something the host does not provide fails at load time with the same message.',
1689
+ 'Confirm the host really supplies it, or declare it in dependencies.',
1690
+ ),
1691
+ )
1692
+ return { findings: out, tools: null }
1693
+ }
1694
+ }
1635
1695
  out.push(
1636
1696
  F(
1637
1697
  payload.stage === 'import' ? 'entry-import-failed' : 'apply-threw',
@@ -1810,18 +1870,40 @@ export async function checkToolRegistrations(dir, opts = {}) {
1810
1870
 
1811
1871
  export function checkLibHygiene(dir) {
1812
1872
  const out = []
1873
+ const clientRel = String(clientTarget(readPkgJson(dir)) || '').replace(/^\.\//, '')
1813
1874
  for (const file of walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)) {
1814
1875
  const text = safeText(file)
1815
1876
  if (text === null) continue
1816
1877
  const rel = relative(dir, file).replace(/\\/g, '/')
1817
1878
  const lines = text.split('\n')
1879
+ let logCount = 0
1880
+ let logFirst = 0
1818
1881
  for (let i = 0; i < lines.length; i++) {
1819
1882
  const code = stripComment(lines[i])
1820
1883
  const at = `${rel}:${i + 1}`
1821
- if (/\bconsole\.log\s*\(/.test(code)) out.push(F('lib-console-log', 'warn', `${at} calls console.log`, 'Tool output is the channel; a stray console.log lands in the host log and never reaches the model. Harmless, but it is debug residue in a published file.', 'Delete it, or use console.error if it is a real warning.'))
1884
+ const logSeen = code.match(/\bconsole\.log\s*\(/g)
1885
+ if (logSeen) {
1886
+ if (!logFirst) logFirst = i + 1
1887
+ logCount += logSeen.length
1888
+ }
1822
1889
  if (/(?:^|[;{}\s])debugger\s*;?\s*$/.test(code)) out.push(F('lib-debugger', 'error', `${at} has a debugger statement`, 'In a published package a debugger statement stops every consumer who reaches that path.', 'Remove it.'))
1823
1890
  if (/(?:[A-Za-z]:[\\/]{1,2}Users[\\/]|\/Users\/)/.test(code)) out.push(F('lib-hardcoded-path', 'warn', `${at} contains an absolute path from this machine`, "It does not exist on the consumer's machine, so that code path silently fails there.", 'Resolve it from homedir() or ctx instead.'))
1824
1891
  }
1892
+ // One line per file, not per call. A bundled client carried 459
1893
+ // console.log calls and reported 459 warnings - that is how a gate gets
1894
+ // ignored. The client bundle is exempt: there, console.log is the debug
1895
+ // channel, not residue in the host log.
1896
+ if (logCount && rel !== clientRel) {
1897
+ out.push(
1898
+ F(
1899
+ 'lib-console-log',
1900
+ 'warn',
1901
+ `${rel} calls console.log ${logCount} time(s), first at line ${logFirst}`,
1902
+ 'Tool output is the channel; a stray console.log lands in the host log and never reaches the model. Harmless, but it is debug residue in a published file.',
1903
+ 'Delete it, or use console.error if it is a real warning.',
1904
+ ),
1905
+ )
1906
+ }
1825
1907
  if (/new\s+URL\s*\([\s\S]{0,240}?\)\s*\.pathname/.test(text)) {
1826
1908
  out.push(
1827
1909
  F(
@@ -2403,6 +2485,11 @@ export function checkDependencies(dir) {
2403
2485
  )
2404
2486
  }
2405
2487
 
2488
+ // A package that ships a client bundle inlines its dependencies at build
2489
+ // time, so "lib/ never imports it" is true and not actionable: the code is
2490
+ // already inside the bundle. Six of the thirty-one installed plugins are
2491
+ // built this way and produced fifty-nine of these notes between them.
2492
+ if (clientRel) return out
2406
2493
  for (const name of deps) {
2407
2494
  if (used.has(name)) continue
2408
2495
  out.push(
@@ -2510,29 +2597,29 @@ export async function checkTraceability(dir, opts = {}) {
2510
2597
  return out
2511
2598
  }
2512
2599
 
2513
- // No answer from the remote. Two very different situations hide here: a repo
2514
- // with no `origin` at all, where the local tag is the only truth and a missing
2515
- // one is real; and a repo whose `origin` simply could not be reached, where
2516
- // local HEAD is not the shipped commit (the API pusher builds its own commit)
2517
- // and the tag usually exists. A check that cannot see the truth must not claim
2518
- // a defect — this reported six false positives on a day GitHub was down.
2519
- const remotes = opts.remotes === undefined ? await gitOut(dir, ['remote']) : opts.remotes
2520
- const hasOrigin = typeof remotes === 'string' && remotes.split('\n').map((r) => r.trim()).includes('origin')
2521
- if (hasOrigin) {
2522
- if (!localHasIt) {
2523
- out.push(
2524
- F(
2525
- 'git-tag-unverified',
2526
- 'info',
2527
- `could not check whether v${version} is tagged on the remote`,
2528
- 'origin could not be read, and with an API-pushed commit the local HEAD is not the commit that shipped, so the local tag list proves nothing either way.',
2529
- 'Re-run when the network is back — the tag may well exist.',
2530
- ),
2531
- )
2532
- }
2533
- return out
2534
- }
2535
-
2600
+ // No answer from the remote. Two very different situations hide here: a repo
2601
+ // with no `origin` at all, where the local tag is the only truth and a missing
2602
+ // one is real; and a repo whose `origin` simply could not be reached, where
2603
+ // local HEAD is not the shipped commit (the API pusher builds its own commit)
2604
+ // and the tag usually exists. A check that cannot see the truth must not claim
2605
+ // a defect — this reported six false positives on a day GitHub was down.
2606
+ const remotes = opts.remotes === undefined ? await gitOut(dir, ['remote']) : opts.remotes
2607
+ const hasOrigin = typeof remotes === 'string' && remotes.split('\n').map((r) => r.trim()).includes('origin')
2608
+ if (hasOrigin) {
2609
+ if (!localHasIt) {
2610
+ out.push(
2611
+ F(
2612
+ 'git-tag-unverified',
2613
+ 'info',
2614
+ `could not check whether v${version} is tagged on the remote`,
2615
+ 'origin could not be read, and with an API-pushed commit the local HEAD is not the commit that shipped, so the local tag list proves nothing either way.',
2616
+ 'Re-run when the network is back — the tag may well exist.',
2617
+ ),
2618
+ )
2619
+ }
2620
+ return out
2621
+ }
2622
+
2536
2623
  if (typeof atHead === 'string' && !localHasIt) {
2537
2624
  const exists = opts.tagExists === undefined ? await gitOut(dir, ['rev-parse', '--verify', '--quiet', `refs/tags/v${version}`]) : opts.tagExists ? 'sha' : null
2538
2625
  out.push(
@@ -2575,6 +2662,213 @@ async function readRemoteRefs(dir) {
2575
2662
  // =====================================================================
2576
2663
  // Orchestration
2577
2664
  // =====================================================================
2665
+ // =====================================================================
2666
+ // Local development: is this checkout the code the host actually loads?
2667
+ // =====================================================================
2668
+ // An unpublished plugin reaches the host only through a link in the profile's
2669
+ // node_modules, and every failure looks the same from the outside: not
2670
+ // installed, linked to a target that moved, or a copy that stopped tracking
2671
+ // the checkout. `pnpm add link:` cannot help — the bundled pnpm and the
2672
+ // profile store are different majors. These checks read the profile's own
2673
+ // files, so they answer it with no network and no package manager.
2674
+
2675
+ /** The desktop profile a plugin is installed into, unless the caller names one. */
2676
+ const profileDirOf = (opts = {}) => opts.profileDir || join(homedir(), '.dsh', 'profiles', 'desktop')
2677
+
2678
+ export function checkLocalLink(dir, opts = {}) {
2679
+ const findings = []
2680
+ const name = readPkgJson(dir)?.name
2681
+ const profileDir = profileDirOf(opts)
2682
+ const path = name ? join(profileDir, 'node_modules', name) : null
2683
+ const link = { kind: 'missing', target: null, resolves: false, path }
2684
+ if (!name) return { ...link, findings } // a nameless package is checkPackageJson's finding
2685
+
2686
+ let entry = null
2687
+ try {
2688
+ entry = lstatSync(path)
2689
+ } catch {
2690
+ entry = null
2691
+ }
2692
+ if (!entry) {
2693
+ findings.push(
2694
+ F(
2695
+ 'link-not-installed',
2696
+ 'info',
2697
+ `${name} is not installed in ${profileDir}`,
2698
+ "The host only loads packages under the profile's node_modules, so this checkout is not what is running. Expected for a plugin nobody is testing in the host yet.",
2699
+ `Link it: cmd /c mklink /J "${path}" "${dir}"`,
2700
+ ),
2701
+ )
2702
+ return { ...link, findings }
2703
+ }
2704
+
2705
+ if (!entry.isSymbolicLink()) {
2706
+ // A plain directory is a copy, not a link: nothing written to lib/ here
2707
+ // reaches the profile, so the host keeps running whatever was copied.
2708
+ link.kind = 'copy'
2709
+ link.target = path
2710
+ link.resolves = true
2711
+ const copied = readPkgJson(path)?.version ?? null
2712
+ const local = readPkgJson(dir)?.version ?? null
2713
+ if (copied !== local) {
2714
+ findings.push(
2715
+ F(
2716
+ 'link-stale-copy',
2717
+ 'warn',
2718
+ `the profile holds a copy of ${name}@${copied ?? '?'} while this checkout is ${local ?? '?'}`,
2719
+ 'A copy is not a link: edits to lib/ never reach the profile, so the host runs the copied code while every test you run here passes against the checkout.',
2720
+ `Delete "${path}", then: cmd /c mklink /J "${path}" "${dir}"`,
2721
+ ),
2722
+ )
2723
+ }
2724
+ return { ...link, findings }
2725
+ }
2726
+
2727
+ // Node reports a junction and a directory symlink identically: both are
2728
+ // symbolic links. The junction `mklink /J` creates without admin rights is
2729
+ // the one whose realpath leaves the link path. A dead link no longer
2730
+ // resolves, so it reads as 'symlink' here — `resolves: false` and the error
2731
+ // finding below are the parts that matter. readlink still names the target.
2732
+ let real = null
2733
+ try {
2734
+ real = realpathSync(path)
2735
+ } catch {
2736
+ real = null
2737
+ }
2738
+ let target = real
2739
+ if (!target) {
2740
+ try {
2741
+ target = readlinkSync(path)
2742
+ } catch {
2743
+ target = null
2744
+ }
2745
+ }
2746
+ link.kind = real && real !== path ? 'junction' : 'symlink'
2747
+ link.target = target
2748
+
2749
+ const targetPkg = target ? safeText(join(target, 'package.json')) : null
2750
+ let parsed = null
2751
+ if (targetPkg !== null) {
2752
+ try {
2753
+ parsed = JSON.parse(targetPkg.replace(/^\uFEFF/, ''))
2754
+ } catch {
2755
+ parsed = null
2756
+ }
2757
+ }
2758
+ link.resolves = real !== null && parsed !== null
2759
+ if (!link.resolves) {
2760
+ findings.push(
2761
+ F(
2762
+ 'link-target-missing',
2763
+ 'error',
2764
+ `the profile links ${name} to a target that is not a package`,
2765
+ `The link at ${path} points at ${target ?? '(unreadable)'}, which ${real ? 'has no parseable package.json' : 'does not exist'}. A dead junction fails quietly: the host resolves the package to nothing and the plugin disappears with no error. This is what broke this machine's Web UI once.`,
2766
+ `Delete the link, then: cmd /c mklink /J "${path}" "${dir}"`,
2767
+ ),
2768
+ )
2769
+ }
2770
+ return { ...link, findings }
2771
+ }
2772
+
2773
+ export function checkStoreConflict(dir, opts = {}) {
2774
+ const findings = []
2775
+ const profileDir = profileDirOf(opts)
2776
+ const bundledMajor = Number(opts.bundledPnpmMajor ?? 11)
2777
+ const state = { profileMajor: null, bundledMajor, conflict: false, storeDir: null }
2778
+ const text = safeText(join(profileDir, 'node_modules', '.modules.yaml'))
2779
+ if (text === null) return { ...state, findings }
2780
+
2781
+ // pnpm writes this file as JSON despite the .yaml extension. The YAML shape
2782
+ // is accepted too, so an older profile still gets an answer instead of silence.
2783
+ let storeDir = null
2784
+ try {
2785
+ storeDir = JSON.parse(text.replace(/^\uFEFF/, ''))?.storeDir ?? null
2786
+ } catch {
2787
+ storeDir = null
2788
+ }
2789
+ if (!storeDir) {
2790
+ const m = /^\s*"?storeDir"?\s*:\s*["']?([^"'\r\n]+?)["']?\s*$/m.exec(text)
2791
+ storeDir = m ? m[1].trim() : null
2792
+ }
2793
+ state.storeDir = storeDir
2794
+ if (!storeDir) return { ...state, findings }
2795
+
2796
+ const majors = [...String(storeDir).matchAll(/(?:^|[\\/])v(\d+)(?=[\\/]|$)/g)].map((m) => Number(m[1]))
2797
+ const profileMajor = majors.length > 0 ? majors[majors.length - 1] : null
2798
+ state.profileMajor = profileMajor
2799
+ if (profileMajor === null) return { ...state, findings }
2800
+
2801
+ if (profileMajor !== bundledMajor) {
2802
+ state.conflict = true
2803
+ findings.push(
2804
+ F(
2805
+ 'pnpm-store-conflict',
2806
+ 'warn',
2807
+ `the profile store is pnpm v${profileMajor}, the bundled pnpm is v${bundledMajor}`,
2808
+ `pnpm refuses a store written by another major, so \`dsh plugin add link:\` fails with ERR_PNPM_UNEXPECTED_STORE before it installs anything — and the message names neither the profile nor the version to match. The store is ${storeDir}.`,
2809
+ `Skip pnpm: cmd /c mklink /J "${join(profileDir, 'node_modules', '<name>')}" "<checkout>"`,
2810
+ ),
2811
+ )
2812
+ }
2813
+ return { ...state, findings }
2814
+ }
2815
+
2816
+ export function checkMountEntry(dir, opts = {}) {
2817
+ const findings = []
2818
+ const name = readPkgJson(dir)?.name
2819
+ const profileDir = profileDirOf(opts)
2820
+ const mount = { entryFound: false, shape: 'absent', line: null, id: null }
2821
+ if (!name) return { ...mount, findings }
2822
+
2823
+ const patchPath = join(profileDir, 'cordis.patch.yml')
2824
+ const text = safeText(patchPath)
2825
+ // No profile patch at all: nothing to mount, and the plugin's own bundle
2826
+ // patch is the supported channel, so absence is not a finding.
2827
+ if (text === null) return { ...mount, findings }
2828
+
2829
+ const bare = name.replace(/^@[^/]+\//, '')
2830
+ const lines = text.split('\n')
2831
+ let inInsert = false
2832
+ let insertIndent = 0
2833
+ for (let i = 0; i < lines.length; i++) {
2834
+ const line = lines[i]
2835
+ const insert = /^(\s*)-?\s*insert:\s*$/.exec(line)
2836
+ if (insert) {
2837
+ inInsert = true
2838
+ insertIndent = insert[1].length
2839
+ continue
2840
+ }
2841
+ const row = /^(\s*)-\s*id:\s*(.+?)\s*$/.exec(line)
2842
+ if (row) {
2843
+ const indent = row[1].length
2844
+ const id = row[2].replace(/^['"]|['"]$/g, '')
2845
+ const mounted = (/^\s*name:\s*(.+?)\s*$/.exec(lines[i + 1] || '')?.[1] || '').replace(/^['"]|['"]$/g, '')
2846
+ if (id === name || id === bare || mounted === name) {
2847
+ mount.entryFound = true
2848
+ mount.id = id
2849
+ mount.line = i + 1
2850
+ mount.shape = inInsert && indent > insertIndent ? 'insert' : 'top-level'
2851
+ if (mount.shape === 'top-level') {
2852
+ findings.push(
2853
+ F(
2854
+ 'patch-entry-top-level',
2855
+ 'error',
2856
+ `the profile patch row for ${name} is a top-level "- id:" entry, so it never mounts`,
2857
+ `Line ${i + 1} of ${patchPath} sits outside any "- insert:" block. A top-level row overrides an entry that already exists in the tree; it does not add one, so the plugin installs, loads, and never appears. This hid two plugins for two weeks.`,
2858
+ `Move it under "- insert:":\n- insert:\n - id: ${bare}\n name: "${name}"`,
2859
+ ),
2860
+ )
2861
+ }
2862
+ return { ...mount, findings }
2863
+ }
2864
+ continue
2865
+ }
2866
+ // a top-level key closes the insert sequence
2867
+ if (/^\S/.test(line)) inInsert = false
2868
+ }
2869
+ return { ...mount, findings }
2870
+ }
2871
+
2578
2872
  export function runStaticChecks(dir) {
2579
2873
  return [
2580
2874
  ...checkPackageJson(dir),
@@ -2603,7 +2897,7 @@ const RULE_ORDER = [
2603
2897
  'client-inject-missing-slots', 'client-inject-missing-sidebarRightTabs', 'client-unavailable',
2604
2898
  'no-patch-file', 'no-insert-row', 'insert-missing-name', 'insert-name-mismatch',
2605
2899
  'patch-name-unresolvable', 'tests-fail', 'tests-timeout', 'tests-not-run', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
2606
- 'entry-import-failed', 'apply-threw', 'tools-unavailable',
2900
+ 'entry-missing', 'entry-import-failed', 'entry-import-unresolved', 'apply-threw', 'tools-unavailable',
2607
2901
  'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
2608
2902
  'inject-unused-service',
2609
2903
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
@@ -2620,6 +2914,7 @@ const RULE_ORDER = [
2620
2914
  'git-uncommitted', 'git-tag-missing', 'git-tag-unverified', 'git-tag-elsewhere',
2621
2915
  'readme-tool-undocumented', 'readme-test-count',
2622
2916
  'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
2917
+ 'link-not-installed', 'link-target-missing', 'link-stale-copy', 'pnpm-store-conflict', 'patch-entry-top-level',
2623
2918
  ]
2624
2919
 
2625
2920
  const MARK = { error: '✗', warn: '!', info: '·' }
package/lib/index.js CHANGED
@@ -12,13 +12,16 @@ import {
12
12
  checkDocs,
13
13
  checkEntrySource,
14
14
  checkGitState,
15
+ checkLocalLink,
15
16
  checkMarketEntry,
16
17
  checkMarketReadiness,
18
+ checkMountEntry,
17
19
  checkNpmState,
18
20
  checkPackageJson,
19
21
  checkPack,
20
22
  checkPatchYml,
21
23
  checkPublishedMatches,
24
+ checkStoreConflict,
22
25
  checkTests,
23
26
  checkToolRegistrations,
24
27
  checkClientRuntime,
@@ -477,6 +480,36 @@ export function apply(ctx, config = {}) {
477
480
  },
478
481
  ]
479
482
 
483
+ tools.push({
484
+ name: 'publish_gate_link',
485
+ description:
486
+ 'Answer "why is my local plugin not running?" from the profile\'s own files, with no network and no package manager. An unpublished plugin reaches the host only through a link in <profile>/node_modules plus a mount row in the profile\'s cordis.patch.yml, and every failure looks identical from the outside. Reports: link-not-installed (info) when the profile has no entry for this package; the link kind and its target — Node reports a junction and a directory symlink the same way, while a plain directory is a copy; link-target-missing (error) when the link points at a path with no parseable package.json, because a dead junction resolves the package to nothing and the plugin silently disappears; link-stale-copy (warn) when the profile holds a copy whose version differs from this checkout, so edits to lib/ never reach it; pnpm-store-conflict (warn) when the profile\'s pnpm store major differs from the bundled pnpm (v11) — that is the ERR_PNPM_UNEXPECTED_STORE that makes `dsh plugin add link:` fail before it installs; patch-entry-top-level (error) when this package\'s row in the profile patch sits outside an "- insert:" block, which installs and never mounts. Pass profile to inspect a temp profile; the default is ~/.dsh/profiles/desktop. Returns {ok, link:{kind,target,resolves}, store:{profileMajor,bundledMajor,conflict}, mount:{entryFound,shape}, findings, report}.',
487
+ parameters: {
488
+ type: 'object',
489
+ properties: {
490
+ dir: { type: 'string', description: 'Plugin checkout directory.' },
491
+ profile: { type: 'string', description: 'Profile directory to inspect (default ~/.dsh/profiles/desktop). Tests pass a temp profile instead of the real one.' },
492
+ },
493
+ },
494
+ async execute(args) {
495
+ const dir = resolveDir(args.dir)
496
+ if (!dir) return { ok: false, error: 'no plugin directory given (dir must be a string path)' }
497
+ const profileDir = typeof args.profile === 'string' && args.profile.trim() ? args.profile.trim() : undefined
498
+ const link = checkLocalLink(dir, { profileDir })
499
+ const store = checkStoreConflict(dir, { profileDir })
500
+ const mount = checkMountEntry(dir, { profileDir })
501
+ const findings = [...link.findings, ...store.findings, ...mount.findings]
502
+ return {
503
+ ok: true,
504
+ link: { kind: link.kind, target: link.target, resolves: link.resolves },
505
+ store: { profileMajor: store.profileMajor, bundledMajor: store.bundledMajor, conflict: store.conflict },
506
+ mount: { entryFound: mount.entryFound, shape: mount.shape },
507
+ findings,
508
+ report: renderReport(findings, { title: `local link · ${dir}` }),
509
+ }
510
+ },
511
+ })
512
+
480
513
  for (const t of tools) {
481
514
  try {
482
515
  ctx.tools.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bushizhanghan/dsh-publish-gate",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Pre-publish gate for DSH plugins: manifest, dependencies, artifact, runtime, browser half, traceability and market readiness — plus an input fuzzer that drives every declared parameter with the wrong type. 发布前的七层门禁 + 入参模糊测试。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",