dsh-molbio-tools 0.22.1 → 0.22.2

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
@@ -10,6 +10,54 @@
10
10
  改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
11
11
  下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
12
12
 
13
+ ## [0.22.2] — 2026-10-30(DSH 0.2.1-rc.5 适配;契约守卫兼容桌面版)
14
+
15
+ **工具行为零变化(60 个)。** 0.22.1 的验收基线是 **0.2.1-rc.3**;这一版把基线推到
16
+ **0.2.1-rc.5**,并按 rc.5 的两种安装形态(npm 包 / 桌面 `app.asar`)各跑了一遍。
17
+
18
+ ### 1. 为什么"补丁号在范围内"不等于适配
19
+
20
+ `engines.dsh` 的 `>=0.2.1-rc.2 <0.3.0-0` 本来就覆盖 rc.5,DSH 的 `unmetRequirements`
21
+ (`@deepseek-ai/dsh-app-boot`)用 `includePrerelease` 判定也通过;但按本仓库的规矩
22
+ ([store-evidence.md](docs/store-evidence.md)),范围只是安装粗筛,**逐版本结论要写进
23
+ `dsh.compatibility.dshReleases`**。rc.3 之后上游发过 rc.4 / rc.5,这一版补上。
24
+
25
+ ### 2. rc.5 上真正跑过的证据
26
+
27
+ - 桌面版 0.2.1-rc.5(Electron 44.7.0 / Node 24.21.0)的完整流程:`npm pack`(115 文件)→
28
+ `dsh plugin add` → `--dump-config`(bundle 层与 preset 行都在)→ 启动 HTTP 200 →
29
+ `__DSH_BOOT__` 含 `dsh-molbio-tools/client.js` → `dsh plugin remove`(0 残留)。
30
+ - `agentPresets/list` 的 `broken` 为空,`pluginInventory/list` 里 molbio-lab 的
31
+ `tool-molbio` 是 `fiberPhase: "active"`(rules.md 规则 1 的判据,说明 preset 里 60 个工具
32
+ 真的挂上了,而不是"组合树看着对")。
33
+ - 上游 `dsh-web-app/presets/standard.patch.yml` 在 rc.3 与 rc.5 之间**逐字节一致**
34
+ (6799 B,同一 sha256),因此 preset 行清单不需要重抄。
35
+ - `preset-health` 30/30 行、`drift-probe` 16/16、`smoke` 60 工具、`bench --offline` 61 题
36
+ 全绿;`contract` 17/17(见下)。
37
+
38
+ ### 3. contract:桌面版不再报"假红"
39
+
40
+ `app.asar` 为省体积裁掉了 `lib/types/*.d.ts`(`dsh-fs` 整个 `lib/types` 目录都没有),
41
+ 于是 0.22.1 的 contract 在桌面版上有两条以 ENOENT 失败——那是**打包差异**,不是契约变化
42
+ (npm tarball 里声明仍在,运行时行为也一致)。这一版把声明守卫改成
43
+ `declarationEvidence()`:先读 `lib/types/*.d.ts`,缺失时读同一份声明的内嵌副本
44
+ (`dsh-llm/lib/typert.host.js`、`dsh-fs-local` / `dsh-attachment-local` 的 runtime),
45
+ 两条断言改由同一组谓词驱动;新增第 17 条检查用**突变文本**驱动这些谓词,证明回退
46
+ 不是"永远通过"(rules.md 规则 9)。npm 安装与桌面安装现在都是 17/17。
47
+
48
+ ### 4. Node 下限对齐 DSH 桌面运行时
49
+
50
+ `engines.node` 从 `>=26` 降到 **`>=24`**,CI 矩阵从 26 改到 24:DSH 0.2.1-rc.5 桌面版
51
+ 内嵌 **Node 24.21.0**,而 `engines.node` 是给 npm/pnpm 看的(DSH 自己只读 `engines.dsh`)。
52
+ 全套测试与 61 题离线 benchmark 都在 24.21.0 上跑过。
53
+
54
+ ### 5. 声明与版本
55
+
56
+ - 两个包 `dshReleases`:`0.2.1-rc.5: compatible`、`0.2.1-rc.4: unknown`(未装机验证)。
57
+ - CI(`test.yml` / `release.yml`)钉 `@deepseek-ai/dsh@0.2.1-rc.5`,Node 24。
58
+ - 根包 `0.22.1 → 0.22.2`;面板包 `0.1.6 → 0.1.7`(只改清单声明,`lib/client.js` 未变)。
59
+ - 基线文档(maintainer / roadmap / workflow / store-evidence / history)同步到 rc.5。
60
+
13
61
  ## [0.22.1] — 2026-10-25(PubMed key 改走 DSH 凭据库;环境变量降为后备)
14
62
 
15
63
  **工具数不变(60);没有凭据库的组合,行为与 0.22.0 相同。** 0.22.0 只从 `api_key` 参数与
package/docs/history.md CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 0.22.2 | — | **DSH 0.2.1-rc.5 适配**:基线 rc.3 → rc.5(上游 standard 逐字节未变);contract 声明守卫兼容桌面版裁掉 `lib/types` 的打包形态(+突变自检);engines.node 与 CI 降到 24 | 60 |
19
20
  | 0.22.1 | — | **PubMed key 改走 DSH 凭据库**:解析顺序 `api_key` → 凭据库 → 环境变量;无凭据库时行为同 0.22.0 | 60 |
20
21
  | 0.22.0 | — | **DSH 0.2.1-rc.3 适配 + PubMed 官方 API**:`molbio_pubmed_search`/`_abstract` 支持 NCBI API key(E-utilities),无 key 行为不变 | 60 |
21
22
  | 0.21.1 | — | **DSH STORE 提交前置**:单文件 bundle patch、逐版本兼容声明;根 tarball 只带一个插件清单(工具行为零变化) | 60 |
@@ -32,7 +32,7 @@
32
32
 
33
33
  - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**60 个 `molbio_*` 工具**(其中
34
34
  `molbio_plot` 一个工具承载 8 种图表)。
35
- - **基线**:DSH **0.2.1-rc.3**;`package.json` 版本 0.22.1。
35
+ - **基线**:DSH **0.2.1-rc.5**;`package.json` 版本 0.22.2(DSH 桌面版内嵌 Node 24.21.0)。
36
36
  - **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
37
37
  **两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
38
38
  - **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
package/docs/roadmap.md CHANGED
@@ -10,12 +10,12 @@ L = 需要新机制或外部资源)。
10
10
 
11
11
  ---
12
12
 
13
- ## 1. 当前状态(0.22.1)
13
+ ## 1. 当前状态(0.22.2)
14
14
 
15
15
  - 60 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
16
16
  - 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
17
17
  (见 [benchmark/README.md](../benchmark/README.md))。
18
- - 基线:DSH **0.2.1-rc.3**。
18
+ - 基线:DSH **0.2.1-rc.5**;DSH 桌面版内嵌 Node 24.21.0,`engines.node` 与 CI 矩阵都按这个下限。
19
19
  - **CI 已在位**(`.github/workflows/test.yml`,0.18.2 起):push / PR / 手动都跑全套;harness 装
20
20
  **钉死版本**,并用 `npm prefix -g` 显式导出 `DSH_HARNESS_ROOT`。0.18.4 起**同时跑
21
21
  `ubuntu-latest` 与 `windows-latest`**——3.6 已修,套件不再依赖宿主分隔符。
@@ -45,6 +45,12 @@ L = 需要新机制或外部资源)。
45
45
  的 key 解析顺序变为 **`api_key` 参数 → DSH 凭据库 → 环境变量**(环境变量会被 agent 自己的 shell
46
46
  子进程继承,凭据库不会);同时插件详情页出现 **PubMed API key** 表单,用户不必改 YAML。
47
47
  没有凭据库的组合行为与 0.22.0 相同。
48
+ - **0.22.2 是 rc.5 纯适配版(工具行为零变化)**:基线 rc.3 → rc.5。上游 `standard` 行清单
49
+ **逐字节未变**;真正的变化在**打包形态**——桌面版 `app.asar` 裁掉 `lib/types`,于是
50
+ `contract` 的声明守卫改为"npm 包读 `.d.ts`,桌面版读 `typert.host.js` / runtime 内嵌副本",
51
+ 并加了"突变必须失败"的自检(17 条)。两份 `dshReleases` 增加 rc.5 `compatible`、rc.4
52
+ `unknown`;`engines.node` 与 CI 降到 DSH 桌面实际内嵌的 **24**。面板包 `0.1.6 → 0.1.7`
53
+ (只改声明,产物未变)。
48
54
 
49
55
  ---
50
56
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  ```jsonc
12
12
  "engines": {
13
- "node": ">=26",
13
+ "node": ">=24",
14
14
  "dsh": ">=0.2.1-rc.2 <0.3.0-0"
15
15
  },
16
16
  "dsh": {
@@ -18,7 +18,9 @@
18
18
  "dshReleases": {
19
19
  "0.2.1-rc.1": "unknown",
20
20
  "0.2.1-rc.2": "compatible",
21
- "0.2.1-rc.3": "compatible"
21
+ "0.2.1-rc.3": "compatible",
22
+ "0.2.1-rc.4": "unknown",
23
+ "0.2.1-rc.5": "compatible"
22
24
  }
23
25
  }
24
26
  }
@@ -29,21 +31,23 @@
29
31
  `@deepseek-ai/dsh-app-boot` 的 `unmetRequirements`):带 prerelease 选项时 `<0.3.0`
30
32
  会放行 `0.3.0-alpha.1`,而 `-0` 不会。
31
33
 
32
- ### 为什么 rc.2 / rc.3 是 compatible、rc.1 是 unknown
34
+ ### 为什么 rc.2 / rc.3 / rc.5 是 compatible、rc.1 / rc.4 是 unknown
33
35
 
34
36
  | 版本 | 记录 | 依据 |
35
37
  | --- | --- | --- |
36
38
  | 0.2.1-rc.1 | unknown | 早于本包基线(0.20.1 钉 `0.2.0-rc.2`),未在此版本上运行过;同时也不在 `engines.dsh` 范围内 |
37
39
  | 0.2.1-rc.2 | compatible | 0.21.0 / 0.21.1 的发布基线;本文件第 3 节的一次性 Profile 流程在 rc.2 上跑过;`contract` / `preset-health` / `smoke` 均通过 |
38
- | 0.2.1-rc.3 | compatible | 0.22.0 的发布基线(CI 钉该版本);同一套一次性 Profile 流程在 rc.3 上重跑通过(安装 → 启动 HTTP 200 → 卸载无残留);`contract` / `preset-health` / `drift-probe` / `benchmark-profile` / `smoke` 在 rc.3 上全绿。上游 `standard` 行清单与 rc.2 **逐字节一致** |
40
+ | 0.2.1-rc.3 | compatible | 0.22.0 的发布基线;同一套一次性 Profile 流程在 rc.3 上重跑通过(安装 → 启动 HTTP 200 → 卸载无残留);`contract` / `preset-health` / `drift-probe` / `benchmark-profile` / `smoke` 在 rc.3 上全绿。上游 `standard` 行清单与 rc.2 **逐字节一致** |
41
+ | 0.2.1-rc.4 | unknown | 没有在真实安装上跑过;不能因为"补丁号在 `engines.dsh` 范围内"就写成 compatible |
42
+ | 0.2.1-rc.5 | compatible | 0.22.2 的发布基线(CI 钉该版本)。在本机 DSH 桌面版 0.2.1-rc.5 上重跑:安装 → 启动 HTTP 200 → `agentPresets/list` 的 `broken` 为空且 molbio-lab 的 `tool-molbio` 为 `fiberPhase: "active"` → 卸载无残留;`contract` 17 / `preset-health` 30 行 / `drift-probe` 16 / `smoke` 60 工具 / `bench:offline` 61 题全绿。上游 `standard` 与 rc.3 **逐字节一致**(6799 B,同一 sha256) |
39
43
 
40
44
  ## 2. 环境
41
45
 
42
46
  | 项 | 值 |
43
47
  | --- | --- |
44
- | DSH | `0.2.1-rc.3` |
45
- | Node.js | `v26.12.1` |
46
- | pnpm | `11.22.0` |
48
+ | DSH | `0.2.1-rc.5`(桌面版 `app.asar`,同一份代码也是 CI 的 npm 基线) |
49
+ | Node.js | `v24.21.0`(DSH 桌面版内嵌的 Node,Electron 44.7.0) |
50
+ | pnpm | `11.28.5`(DSH bundle 内) |
47
51
  | 平台 | Windows(win32 x64) |
48
52
 
49
53
  ## 3. 一次性 Profile 的完整流程
@@ -52,9 +56,9 @@
52
56
 
53
57
  ```bash
54
58
  export DSH_HOME=<workspace>/.tmp/dsh-store-check
55
- npm pack --pack-destination "$DSH_HOME" # 得到 dsh-molbio-tools-0.22.0.tgz
59
+ npm pack --pack-destination "$DSH_HOME" # 得到 dsh-molbio-tools-0.22.2.tgz
56
60
  dsh --profile molbio-tgz-check --from-default-profile web --dump-config
57
- dsh plugin --profile molbio-tgz-check add "$DSH_HOME/dsh-molbio-tools-0.22.0.tgz"
61
+ dsh plugin --profile molbio-tgz-check add "$DSH_HOME/dsh-molbio-tools-0.22.2.tgz"
58
62
  dsh --profile molbio-tgz-check --dump-config # 安装后的组合树
59
63
  dsh --profile molbio-tgz-check --no-open --port 0 # 启动,打印 URL
60
64
  curl "http://127.0.0.1:<port>/?token=<token>" # 读回页面
@@ -65,18 +69,18 @@ dsh --profile molbio-tgz-check --dump-config # 卸载后的组合树
65
69
  安装用 **tarball** 而不是目录,因为 tarball 就是发布物本身:它能同时证明 `files`
66
70
  白名单带全了运行文件(`host.mjs`、`lib/client.js`、单个 `cordis.patch.yml` 的两个 insert、preset)。
67
71
 
68
- tarball 是 **115 个文件 / 约 1.0 MB**(`npm pack` 实测 1,004,017 B,解包约 3.2 MB;再改文档会让字节数
72
+ tarball 是 **115 个文件 / 约 1.02 MB**(`npm pack` 实测约 1,024,000 B,解包约 3.3 MB;再改文档会让字节数
69
73
  小幅浮动,文件数不变),且 `package/packages/**` 为 **0**:根包不再内嵌
70
74
  `packages/molbio-panel`,一个 tarball 里只有一个插件清单(面板包按自己的地址单独发布)。
71
75
  面板本身没有因此消失——根包的 `lib/client.js` 就带着它,第 3.2 节的回读可以作证。
72
76
 
73
- 下面这次是在 **0.22.0 的 tarball** 上、用 **dsh 0.2.1-rc.3** 的 CLI 重跑的(2026-10-25),
77
+ 下面这次是在 **0.22.2 的 tarball** 上、用 **dsh 0.2.1-rc.5** 的 CLI 重跑的(2026-10-30),
74
78
  文件数与发布物一致;更早的记录只差版本号与文件数,流程未变。
75
79
 
76
80
  ### 3.1 安装
77
81
 
78
82
  ```
79
- + dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.0.tgz
83
+ + dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.2.tgz
80
84
  Packages: +1
81
85
  Done in 571ms
82
86
  ```
@@ -102,14 +106,19 @@ dsh web: http://127.0.0.1:50071/?token=...
102
106
  ```
103
107
 
104
108
  读回结果:`HTTP 200`、38180 字节;页面里的 `window.__DSH_BOOT__` 图包含
105
- `dsh-molbio-tools/client.js&rev=a25c3919676b`,即浏览器半被宿主 Loader 发现并进了客户端图——
109
+ `dsh-molbio-tools/client.js&rev=…`(rev 是内容哈希,随产物变化),即浏览器半被宿主 Loader 发现并进了客户端图——
106
110
  这是"启动成功"的可观察证据,而不只是进程没退出。(先用 token 换 cookie,再带 cookie 读 `/`,
107
111
  否则第一跳是 303。)
108
112
 
113
+ 再用同一 cookie POST `/api/agentPresets/list` 与 `/api/pluginInventory/list`(envelope 见
114
+ [rules.md](rules.md) 规则 1):`broken` 为空(空数组时服务端直接省略该键),molbio-lab 的
115
+ `tool-molbio` 为 `fiberPhase: "active"`——preset 里的 60 个工具**真的挂上了**,
116
+ 而不只是组合树正确。
117
+
109
118
  ### 3.3 卸载
110
119
 
111
120
  ```
112
- - dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.0.tgz
121
+ - dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.2.tgz
113
122
  Done in 421ms
114
123
  ```
115
124
 
@@ -132,13 +141,15 @@ pnpm 的 hoisted linker 会在 `node_modules/` 留下一个指向仓库目录的
132
141
  ## 5. 仓库内验证(同一基线)
133
142
 
134
143
  ```
135
- node test/contract.mjs → 16 checks passed(host 契约、client 产物、tarball 覆盖)
136
- node test/preset-health.mjs → all 30 rows load on dsh 0.2.1-rc.3
144
+ node test/contract.mjs → 17 checks passed(host 契约、client 产物、tarball 覆盖、声明回退的突变自检)
145
+ node test/preset-health.mjs → all 30 rows load on dsh 0.2.1-rc.5
137
146
  node test/smoke.mjs → registered 60 tools, all smoke tests passed
147
+ node test/drift-probe.mjs → 16/16(14 个突变全部被抓到)
148
+ node benchmark/run.mjs --offline → 61 tasks checked against the shipped tools
138
149
  ```
139
150
 
140
- `test/release.mjs` 的 manifest 一致性断言通过;该文件末尾的 npm 版本探测在受限沙箱里
141
- 会拿到 `EPERM`(Node 以管道捕获子进程输出被拒绝),属环境限制,不是插件问题。
151
+ `test/release.mjs` 在完整权限的本地会话里整套通过(包括末尾的 npm 版本探测);
152
+ 在受限沙箱里该探测会拿到 `EPERM`(Node 以管道捕获子进程输出被拒绝),属环境限制,不是插件问题。
142
153
 
143
154
  ## 6. 上架路径
144
155
 
package/docs/workflow.md CHANGED
@@ -456,7 +456,7 @@ git status --short # lib/client.js 与 packages/molbio-panel/lib/clien
456
456
  ### DSH 升级后:preset 组合的维护(必做)
457
457
 
458
458
  `preset/molbio-lab/agent.cordis.yml` 是官方 `standard` 预设的**行清单**副本 + 末尾一行
459
- `tool-molbio`(当前基线:**dsh 0.2.1-rc.3**)。它不会自动跟随 DSH 升级,因此每次升级
459
+ `tool-molbio`(当前基线:**dsh 0.2.1-rc.5**)。它不会自动跟随 DSH 升级,因此每次升级
460
460
  DSH 后:
461
461
 
462
462
  1. 取新版的 shipped `standard`:
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dsh-molbio-tools",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel and hydropathy plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, read-level FASTQ QC, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
7
  "engines": {
8
- "node": ">=26",
8
+ "node": ">=24",
9
9
  "dsh": ">=0.2.1-rc.2 <0.3.0-0"
10
10
  },
11
11
  "exports": {
@@ -96,7 +96,9 @@
96
96
  "dshReleases": {
97
97
  "0.2.1-rc.1": "unknown",
98
98
  "0.2.1-rc.2": "compatible",
99
- "0.2.1-rc.3": "compatible"
99
+ "0.2.1-rc.3": "compatible",
100
+ "0.2.1-rc.4": "unknown",
101
+ "0.2.1-rc.5": "compatible"
100
102
  }
101
103
  },
102
104
  "bundle": {
package/test/contract.mjs CHANGED
@@ -102,6 +102,70 @@ function hostHalf(pkg) {
102
102
  return existsSync(path) ? { path, text: readFileSync(path, 'utf8') } : undefined;
103
103
  }
104
104
 
105
+ /**
106
+ * The declaration evidence for a contract check, from whichever copy this
107
+ * installation carries.
108
+ *
109
+ * The npm packages ship `lib/types/*.d.ts`; the DESKTOP build (app.asar)
110
+ * strips them to save space (`dsh-fs` ships no `lib/types` at all and
111
+ * `dsh-llm` keeps only the declarations `lib/typert.host.js` embeds for its
112
+ * type registry). The contract is the same in both builds, so a desktop
113
+ * verification must not report the packaging difference as a contract change.
114
+ * The fallbacks therefore read the same declarations where the build kept them;
115
+ * `\n`/`\"` escapes in the embedded copy are unescaped so one regex serves
116
+ * both.
117
+ *
118
+ * This is still a guard: when NO candidate exists the check fails loudly
119
+ * instead of skipping, and `the declaration guard still fails when a
120
+ * declaration moves` below drives the same assertions with mutated text.
121
+ *
122
+ * @param pkg the `@deepseek-ai/*` package name.
123
+ * @param candidates package-relative paths, in preference order.
124
+ * @returns the first existing file's text and path.
125
+ */
126
+ function declarationEvidence(pkg, candidates) {
127
+ const dir = packageDir(pkg);
128
+ for (const relative of candidates) {
129
+ const path = join(dir, relative);
130
+ if (!existsSync(path)) continue;
131
+ const text = readFileSync(path, 'utf8').replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\'/g, "'");
132
+ return { text, path };
133
+ }
134
+ assert.fail(`${pkg} ships none of ${candidates.join(', ')} — the declaration guard cannot read its evidence`);
135
+ }
136
+
137
+ /** The ImageBlock half of the picture contract, over a declaration text. */
138
+ function assertImageBlockDeclared(types) {
139
+ const imageBlock = /interface ImageBlock \{[\s\S]*?\n\}/.exec(types)?.[0] ?? '';
140
+ assert.match(imageBlock, /type: 'image'/, 'ImageBlock still declares type "image"');
141
+ assert.match(imageBlock, /attachment: ImageAttachmentRef/, 'and still carries a durable attachment reference');
142
+ }
143
+
144
+ /** The ImageAttachmentRef fields our output value mirrors. */
145
+ function assertImageRefDeclared(types) {
146
+ const ref = /interface ImageAttachmentRef \{[\s\S]*?\n\}/.exec(types)?.[0] ?? '';
147
+ for (const field of ['attachmentId', 'mediaType', 'bytes', 'width', 'height']) {
148
+ assert.ok(ref.includes(`${field}:`), `ImageAttachmentRef still carries ${field}`);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The store method the picture hand-off calls: the TypeScript declaration on a
154
+ * build that ships types, or the runtime method that implements it on the
155
+ * desktop build, which strips `lib/types`.
156
+ */
157
+ function assertSaveImageDeclared(indexTypes) {
158
+ const declared = /saveImage\(input: SaveImageAttachment\): Promise<ImageAttachmentRef>/.test(indexTypes);
159
+ const implemented = /async saveImage\(input\)/.test(indexTypes);
160
+ assert.ok(declared || implemented, 'the store still exposes saveImage');
161
+ }
162
+
163
+ /** The text-only filesystem seam the picture workaround rests on. */
164
+ function assertFsSeamDeclared(fsText, fsIndexText) {
165
+ assert.ok(fsText.includes('FS_NOT_TEXT'), 'the filesystem seam still carries FS_NOT_TEXT');
166
+ assert.ok(!/\bwriteBytes\b/.test(fsIndexText), 'no binary write primitive appeared on the fs seam');
167
+ }
168
+
105
169
  /** Every file under a directory, bounded (the shell bundle ships under dist/). */
106
170
  function filesUnder(dir, limit = 400) {
107
171
  const out = [];
@@ -627,20 +691,13 @@ check('a tool may show the model a picture the way read_image does', () => {
627
691
  // Our output.render builds `{ type: 'image', attachment: {...} }`; that shape
628
692
  // must stay the harness's own ImageBlock, and the reference fields must stay
629
693
  // the ones our output value mirrors.
630
- const llmTypes = readFileSync(join(packageDir('@deepseek-ai/dsh-llm'), 'lib', 'types', 'types.d.ts'), 'utf8');
631
- const imageBlock = /interface ImageBlock \{[\s\S]*?\n\}/.exec(llmTypes)?.[0] ?? '';
632
- assert.match(imageBlock, /type: 'image'/, 'ImageBlock still declares type "image"');
633
- assert.match(imageBlock, /attachment: ImageAttachmentRef/, 'and still carries a durable attachment reference');
634
-
635
- const attachmentTypes = readFileSync(join(packageDir('@deepseek-ai/dsh-attachment'), 'lib', 'types', 'types.d.ts'), 'utf8');
636
- const ref = /interface ImageAttachmentRef \{[\s\S]*?\n\}/.exec(attachmentTypes)?.[0] ?? '';
637
- for (const field of ['attachmentId', 'mediaType', 'bytes', 'width', 'height']) {
638
- assert.ok(ref.includes(`${field}:`), `ImageAttachmentRef still carries ${field}`);
639
- }
694
+ assertImageBlockDeclared(declarationEvidence('@deepseek-ai/dsh-llm', ['lib/types/types.d.ts', 'lib/typert.host.js']).text);
695
+ // The desktop build strips dsh-attachment's own `lib/types`, so the durable
696
+ // reference is read from the copy `dsh-llm/lib/typert.host.js` embeds.
697
+ assertImageRefDeclared(declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/types.d.ts', 'lib/types/index.d.ts', '../dsh-llm/lib/typert.host.js']).text);
640
698
 
641
699
  // The commit call, and that this deployment accepts what we encode.
642
- const attachmentIndex = readFileSync(join(packageDir('@deepseek-ai/dsh-attachment'), 'lib', 'types', 'index.d.ts'), 'utf8');
643
- assert.match(attachmentIndex, /saveImage\(input: SaveImageAttachment\): Promise<ImageAttachmentRef>/, 'the store still exposes saveImage');
700
+ assertSaveImageDeclared(declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/index.d.ts', '../dsh-attachment-local/lib/index.js']).text);
644
701
  const attachmentLocal = readFileSync(join(packageDir('@deepseek-ai/dsh-attachment-local'), 'lib', 'index.js'), 'utf8');
645
702
  assert.ok(attachmentLocal.includes('"image/png"'), 'this deployment still accepts image/png');
646
703
 
@@ -663,12 +720,57 @@ check('the workspace still cannot hold a binary file (the reason the picture rid
663
720
  // the filesystem seam is text-only. If DSH grows a binary write, this check
664
721
  // fails on purpose: revisit the design (png_path becomes possible) rather
665
722
  // than silently keeping the workaround.
666
- const fsTypes = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'lib', 'types', 'types.d.ts'), 'utf8');
667
- assert.ok(fsTypes.includes('FS_NOT_TEXT'), 'the filesystem seam still types FS_NOT_TEXT');
723
+ // `dsh-fs`'s own base class carries no code list; the refusal code lives in
724
+ // the local backend (the base is consulted last, only as a presence fallback).
725
+ const fsTypes = declarationEvidence('@deepseek-ai/dsh-fs', ['lib/types/types.d.ts', '../dsh-fs-local/lib/index.js', 'lib/index.js']).text;
668
726
  const fsReadme = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'README.md'), 'utf8');
669
727
  assert.match(fsReadme, /binary-safe mutations remain deferred/i, 'and still documents binary mutations as deferred');
670
728
  const fsIndex = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'lib', 'index.js'), 'utf8');
671
- assert.ok(!/\bwriteBytes\b/.test(fsIndex), 'no binary write primitive appeared on the fs seam');
729
+ assertFsSeamDeclared(fsTypes, fsIndex);
730
+ });
731
+
732
+ check('the declaration guard still fails when a declaration moves (the build fallback is not vacuous)', () => {
733
+ // 0.22.2 taught the two checks above to read the declarations a DESKTOP build
734
+ // embeds instead of the `lib/types` it strips. A fallback that cannot fail
735
+ // would turn those checks into decoration, so drive every predicate with a
736
+ // mutated copy of the SAME evidence the installation supplied: each one must
737
+ // reject the mutation. (rules.md rule 9.)
738
+ const llm = declarationEvidence('@deepseek-ai/dsh-llm', ['lib/types/types.d.ts', 'lib/typert.host.js']).text;
739
+ const attachment = declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/types.d.ts', 'lib/types/index.d.ts', '../dsh-llm/lib/typert.host.js']).text;
740
+ const attachmentIndex = declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/index.d.ts', '../dsh-attachment-local/lib/index.js']).text;
741
+ const fsText = declarationEvidence('@deepseek-ai/dsh-fs', ['lib/types/types.d.ts', '../dsh-fs-local/lib/index.js', 'lib/index.js']).text;
742
+
743
+ const imageBlock = /interface ImageBlock \{[\s\S]*?\n\}/.exec(llm)?.[0] ?? '';
744
+ assert.notEqual(imageBlock, '', 'the mutation experiment found a real ImageBlock declaration to mutate');
745
+ assert.throws(
746
+ () => assertImageBlockDeclared(llm.replace(imageBlock, imageBlock.replace("type: 'image'", "type: 'picture'"))),
747
+ 'a moved ImageBlock type is caught',
748
+ );
749
+ assert.throws(
750
+ () => assertImageBlockDeclared(llm.replace(imageBlock, imageBlock.replace('ImageAttachmentRef', 'SomeOtherRef'))),
751
+ 'a moved attachment reference is caught',
752
+ );
753
+
754
+ const refBlock = /interface ImageAttachmentRef \{[\s\S]*?\n\}/.exec(attachment)?.[0] ?? '';
755
+ assert.notEqual(refBlock, '', 'the mutation experiment found a real ImageAttachmentRef declaration to mutate');
756
+ assert.throws(
757
+ () => assertImageRefDeclared(attachment.replace(refBlock, refBlock.replace('attachmentId:', 'id:'))),
758
+ 'a renamed attachment field is caught',
759
+ );
760
+
761
+ assert.throws(
762
+ () => assertSaveImageDeclared(attachmentIndex.split('saveImage(').join('putImage(')),
763
+ 'a renamed saveImage is caught',
764
+ );
765
+
766
+ assert.throws(
767
+ () => assertFsSeamDeclared(fsText.split('FS_NOT_TEXT').join('FS_BINARY_REFUSED'), ''),
768
+ 'a renamed FS error code is caught',
769
+ );
770
+ assert.throws(
771
+ () => assertFsSeamDeclared(fsText, 'function writeBytes() {}'),
772
+ 'a binary write primitive is caught',
773
+ );
672
774
  });
673
775
 
674
776
  check('the harness decodes the PNG our rasterizer produces (and refuses a corrupt one)', async () => {