# 发布说明 v0.4.1（代码审查收口）

[← 返回 README](../README.zh.md) ｜ 相关：[v0.4.0 发布说明](v0.4.0.md)

**发布日期**：2026-09-16 ｜ **区间**：`v0.4.0 → v0.4.1`
**规模**：6 commits + 收尾文档；源码 8 文件 +570 / −213（含全量测试与文档 20 文件 +1134 / −237）；
新增源码模块 1 个（`src/dsh-paths.ts`）；测试套件仍为 6 套，新增 13 个场景

v0.4.1 是一次**纯修复与可见性**版本：把 v0.3.1 以来全部 TypeScript 变更的代码审查
（`docs/code-review/ts-review-since-v0.3.1.zh.md`）逐条落地。**配置文件格式没有任何变化**，
唯一可见的输出变化是 CLI 写出的 JSON 条目现在带显式 `type`。

---

## 亮点速览

| 主题 | 一句话 |
|---|---|
| ⚠️ 项目侧压制不再自伤 | 只 deny「本轮真会全局装载」的名字，不再误伤项目自己的工具或宿主 patch |
| `$DSH_HOME` 全链路生效 | 用户层三文件、全局诊断、CLI 读写路径统一走 `src/dsh-paths.ts` |
| 坏条目不再被顺手删掉 | `mcpServers` 里的非对象条目跨读-改-写原样保留，可被指名删除 |
| yml 写入并发安全 | 新增 `updateManagedRows`：读-改-写全在锁内，`EACCES` 不再被吞成「文件缺失」 |
| 遮蔽诊断按层归因 | 纯用户层冲突只写全局诊断，零配置项目不再被动创建 `.mcp-diag.json` |
| 告警按变化门控 | 文件错误、坏条目、`name-taken` 只在集合变化时告警一次 |
| CLI 提示双真相 | `add` 提示新行会不会被遮蔽，`remove` 说明同名定义接管 |
| `DSH_MCP_PROFILE` 校验 | `../../somewhere` 不再被拼进 profiles 目录 |

---

## 修复

### 项目侧压制只 deny 真会全局装载的名字（`src/registry.ts`）

两个静默自伤场景：用户层 `~/.dsh/mcp.yml` 里的 `disabled: true` 占名行，以及被宿主全局
patch 行顶掉（`name-taken`）的用户层行——两者都**不产生全局实例**，却照旧进了该项目的
压制集合。压制按 `mcp__<原名>__` 前缀展开，于是 deny 打到了项目自己的服务器（它的生效名
从未改名）或宿主 patch 实例上，而快照仍报 `fiberPhase: "active"`、没有 `skipReason`。

修复：把用户层合并结果与「宿主已占名」集合前移到项目循环之前，`suppressedGlobals`
只收真正会全局装载的名字；`sweepRestrictions` 再加一道「该名字当前确有全局实例」的守卫。

### `$DSH_HOME` 重定位不再让用户层整体消失（`src/dsh-paths.ts`）

此前用户层路径、全局诊断与 CLI 各处都硬编码 `homedir()/.dsh`，与 README 承诺的
「dshHome 默认 `%USERPROFILE%\.dsh`，设置了 `DSH_HOME` 则用其值」相矛盾。重定位后
用户层三文件全部指向不存在的路径，而「文件缺失」是合法的零配置状态，所以**没有任何告警**。

新模块 `src/dsh-paths.ts` 成为唯一解析口径：`dshHomeDir(home, env)` / `dshHomeFor()` /
`userLayerPathsIn()` / `profileMcpJsonFile()`。注入的路径（registry 的 `userLayerPaths`、
CLI 的 `deps.home`）仍然优先于环境变量，测试才能与本机真实配置隔离。

### 其余修复

- **`remove` 说明同名接管**：yml 与 json 同时有同名行时，仍只删首个命中，但会打印
  接下来由哪个文件的定义接管生效；再执行一次才真正清空。
- **坏条目跨写入保留**：`readJsonServers`/`updateJsonServers` 不再过滤非对象条目，
  手写的 `"legacy": "node x.js"` 不会被无关的 `add`/`remove` 永久删除，判重能看到它占的名，
  `dsh-mcp remove legacy` 可以清掉它。
- **yml 写路径锁内读-改-写**：`updateManagedRows(path, mutate, options)` 取代「锁外预读 +
  锁内整体替换」，并发两个 `add` 不再互相丢行；`createIfMissing` 收紧为只接受 `ENOENT`，
  `EACCES` 与损坏的受管块不再被当成「文件缺失」而被覆盖。
- **`DSH_MCP_PROFILE` 先校验再拼路径**：不匹配 `^[A-Za-z0-9][A-Za-z0-9._-]*$`（含显式的
  `.` / `..`）时按「解析不出 profile」降级并告警一次，不再读到 profiles 目录之外。
- **`serverView` 的用户层作用域**：报 `global` + 层文件路径，与快照分区口径一致
  （原先固定报 `workspace` + 项目根）。

---

## 变更

- **遮蔽诊断按层归因**：`IdentityShadow` 带上失败方与胜出方的来源，新增 `shadowedGlobal`
  桶记录用户层之间的精确同名遮蔽（此前零可见性）。只涉及用户层的冲突在
  `<dshHome>/.mcp-diag.json` 全局报一次；项目侧只保留至少一方是项目层行的条目，
  零配置项目不再因为两个用户层文件互相冲突而被创建 `.dsh/.mcp-diag.json`。
- **告警按变化门控**：文件级错误、坏条目、`name-taken` 集合只在集合本身变化时告警，
  不再每轮对账每项目重刷；`skipReason` 照旧每轮写入，快照仍能查到原因。
- **`name-taken` 文案**改为「宿主全局 patch 行（bundle 层或 profile patch 层）」，
  与 `globalNames()` 实际收集的范围一致。
- **CLI 写出的 JSON 条目带显式 `type`**（`stdio` / `http`），便于要求该键的生态工具消费；
  本插件自己的读取器仍然可以推断。
- **`add` 写入后复核**：新行被更高优先层按名字或按同服务遮蔽时提示「不会装载」，
  同作用域另一方言文件也有定义时提示条目数。
- **JSON 读取失败带 errno**（`读取失败（EACCES）`），错误串不含文件内容。
- **`rowNameOf` 移入 `src/model.ts`** 由装载器与 CLI 共用：受管行 id 优先于
  `config.serverName`。CLI 此前口径相反，id 与 `config.serverName` 不一致的行
  没法用装载器实际使用的名字删除。
- **内部清理**：`reconcileAll` 的项目循环体抽成 `scanProject`；逐项目 Map 按本轮 known keys
  剪枝；`planProjectChanges` 的 O(n²) 查找改 Map；`activeProfile()` 经 `Promise.resolve`
  兜住同步抛错；CLI 消除 `.dsh` / `mcp.yml` 字面量重复，`getMissMessage` 改用实际查过的层。

---

## 文档

- `docs/guide/layers.md` / `.zh.md`：补记 v0.4.0 起遗留 `.mcp.json` 层的三处行为变化——
  条目级显式 `cwd` 现在生效、DSH 透传键（`toolCallTimeoutMs`/`failOnStartupError`/`reconnect`）
  现在生效、`disabled: true` 从「静默跳过不占名」改为「占名不装载」。
- `docs/guide/cli.md` / `.zh.md`：`-f`（`--format`）与 `-p`（`--profile`）自 v0.4.0 起是本 CLI
  的保留短名，会吞掉下一个词元；要传给被 spawn 命令的同名参数写在 `--` 之后。
- `CHANGELOG.md`：补记快照消费方须知——全局层分区的 `project` 字段自 v0.4.0 起是**层文件
  自身所在目录**（`mcp.yml`/`mcp.json` 为 dshHome，profile 文件为 `profiles/<name>`），
  v0.3.1 报的是 `.dsh` 的父目录（家目录）。`dsh-skill-mcp-panel` 等消费方按「层自己的目录」读。

---

## 升级

无需改动任何配置文件，无破坏性变更：

- 设置了 `DSH_HOME` 的机器，用户层会从**它下面**读取（这是 README 一直承诺的行为，
  此前的静默失效是缺陷）。若之前依赖「无论 `DSH_HOME` 如何都读 `%USERPROFILE%\.dsh`」，
  把文件移到 `$DSH_HOME` 下即可。
- CLI 新写出的 JSON 条目多一个 `type` 键；既有文件不会被改写。
- 快照 / 诊断的字段只有新增（`shadowedGlobal`、`IdentityShadow.source` 与 `winnerSource`）。

安全边界不变：`stdio` 行仍在 dsh 宿主进程里 spawn `command`，项目与用户层配置文件都是
可执行代码载体，只在可信项目里启用。
