# DEVELOPMENT — dsh-plugin-hub 开发规范
> 覆盖**宿主端 / 客户端**两类代码的写法与构建契约,以及我们统一后的客户端形态
> (干净模块 + 独立 CSS + `src/client/` 目录 + 第三方内联)。适用于本公开仓库
> `packages/dsh-*` 的开发与维护。构建/契约/发布脚本见 `scripts/`;仓库级硬性规则见根
> [AGENTS.md](../AGENTS.md),发版执行规程见 [.dsh/skills/dsh-plugin-release/SKILL.md](../.dsh/skills/dsh-plugin-release/SKILL.md)。
## 0. 构建总览
每个插件包 = 独立 npm 包(`@wingsky-1/dsh-*`),发布物自包含(第三方依赖构建期内联)。
> **安装依赖**:本仓库是 pnpm workspace(`pnpm-workspace.yaml` + 包间 `workspace:*`
> 协议 + pnpm 严格 node_modules)。动手前必须 `pnpm install`(在仓库根执行)。
> **不要用 `npm install`**——它会因 `workspace:*` 协议与 pnpm 布局而失败,且无法
> 复现 CI 的依赖解析结果。
```sh
pnpm install # 仓库根,pnpm workspace 依赖安装(必须先于一切构建)
pnpm build # 全仓构建 = pnpm -r build(各包:clean-lib → tsc → bundle-host)
pnpm contract # 客户端契约(node scripts/gate/contract-check.ts)
pnpm test # 全量 smoke(Node ≥23.6 原生 type stripping 直跑)
pnpm cov # 覆盖率采集 + 阈值判分(vitest coverage / istanbul provider,只跑 unit + integration)
pnpm crap # 单函数 CRAP 检查(阈值唯一事实源 scripts/data/gauntlet.config.json 的 crap.threshold / crap.strict)
# src 口径(#722 阶段五重建);crap.strict=false 为观察期语义,见下方引用块
pnpm pack:check # tarball 完整性(含聚合包)
pnpm typecheck # 全仓类型检查
```
> Node 版本:本地直跑 TS 需 **≥23.6**(type stripping 门槛);CI 固定 Node 24。
> 阈值事实源分处两处,按维度划分:**覆盖率**在 `scripts/data/coverage.config.json`
> 的 `thresholds`(#733 计划项 3.4 起;`vitest.config.ts` 只 import 它,不得再内联
> `include`/`exclude`/`thresholds`——降线由 `scripts/gate/threshold-monotonic.mjs` 对比
> `origin/main` 守护,面完整性由 `pnpm verify:coverage-scope` 守);
> **变异与 CRAP**在 `scripts/data/gauntlet.config.json`。
>
> **覆盖率口径(#722 阶段三 / #769 收窄)**:`pnpm cov` = vitest 的 istanbul provider,
> 跑 unit + integration + client-unit + client-dom(四层都**直连 `src/`**)。分母是
> `scripts/data/coverage.config.json` 的
> `include`:`packages/*/src/**/*.{ts,tsx}` + `packages/*/src/**/*.mjs`(#733 3.4 补入的 3 个
> 适配器实现,1756 行)+ `shared/**/*.js` 的**源文件**——零 vendor、零 lib 产物,且未加载的
> 源文件按 0% 计入分母(分母不随「加载了什么」变化)。排除项是**结构化条目**(pattern +
> reason + kind),`verify:coverage-scope` 保证 src 下没有任何文件既不在 include 也不在
> exclude 里(静默逃逸即判红)。e2e(不可复现)与 contract(测 lib
> 产物)不进覆盖率,仍由 `pnpm test` 全量执行。阈值判分就是 `pnpm cov` 的退出码,
> 不再有独立判分步骤;PR 的 `gate:full` 标签与 `observe.yml` 夜间班次共用这一执行点。
>
> **client 面按包按面收窄(#769)**:此前是一条 `**/client/**` 整体排除,理由是「这些文件
> 没有直连 src 的判据,计入分母只会稀释阈值」。那条理由对**一部分**文件成立、对另一部分不成立:
> notifier 的 15 个纯 `.ts` 客户端模块里 12 个有直连判据(另有 2 个 DOM 面判据),它们计入分母
> 后实测全局 lines 82.48 → 81.76、functions 83.17 → 81.99,四项仍在阈值之上。故拆成 5 条
> `pending-project` 条目:notifier 只排除 `.tsx` 渲染面(等组件级渲染判据),另外 3 个包
> 各自的整个 client 面仍排除(尚未重写、没有直连判据),`shared/client/**` 排除(其测试在
> `scripts/test` 下、不属于任何 vitest project);#840 退役 dsh-web-file-preview 后它那条随之删除。
> 全部带 `reviewBy` 与 `exitCriteria`,进 `collect-exemptions` 的到期台账;条目腐烂由
> `verify:coverage-scope` 判红。
> **某个包的客户端有了直连判据就删掉它自己那一条——不要等「全部重写完」再一次性解绑。**
>
> **`pnpm crap` 现状(#722 阶段五已重建为 src 口径)**:圈复杂度取 ESLint 内置
> `complexity` 规则,覆盖率取同一份 src 口径产物(`coverage/coverage-final.json`),
> 两者同源——此前「复杂度取自 lib 产物、与 src 口径行号不可比 → 入口自检 exit 2」的
> 停用态**已不成立**。`crap.strict=false` 是**观察期**语义:超阈热点只落盘
> `coverage/crap-report.json` 并 exit 0,置 true 才判红;数据源缺失或解析失败仍
> fail-closed `exit 2`。执行点:夜间 `observe.yml` 与 `gate:full --with-coverage`。
### 变异测试与增量链路(#178 / #187)
变异配置集中在 `stryker.conf.d/dsh-.json`(未拆分包)与 `stryker.conf.d/dsh--<段名>.json`
(拆分包,段名=功能域,如 `dsh-notifier-server.json`;数字段号已弃用)。mutate 区间、
变异面测试清单、阈值口径与 `gauntlet.config.json` 三方一致,由 workflow-assert 自测锚定。增量链路:
**全量分工总述**:PR 门管变更切片;夜间 observe 门管主干全量;发版前
release.yml tag 管线跑全量门禁——全量只在这三处语义中的后两处真实执行。
- **observe 调度(#433 / #572 / #718)**:每日全量班(observe.yml,北京次日 04:00 = UTC
20:00,cron `0 20 * * *`)刷新基线——刻意不 restore 任何缓存——无 incremental
基线即天然全量。基线存**孤立分支** `refs/heads/baseline/mutation`(单 commit 纯文本树,
#572:彻底剥离 main 分支代码树与自动 PR 噪音;旧 #204 方案 A 的「收进仓库目录
`scripts/gate/baseline/` + 自动开 PR」已废除)。班次结构为三段式(#718 S1.1/S1.5):
`mutation-plan` 派生段清单与逐段超时 → `quality`(cov/契约/打包闸)∥ `mutation-shards`
(逐段矩阵,`max-parallel: 8`、`fail-fast: false`、单段超时按实测校准)→
`mutation-collect`(判分 + **单点并集入档** + 报告 + 工单,`if: always()` 收口)。
逐包容错记账:单段失败不连坐,结尾统一非零退出。
入档是**并集语义**(#718 S1.2):先取回远端再叠加本次产物,本次未产出的段沿用远端文件,
日志逐项记账「新算/沿用/缺/退役」——段被失败实例吃掉因而在物理上不可能再发生。
**增量班(observe-incremental.yml)已于 #718 S2.2 退役**:其唯一独有价值是修复整树替换
丢掉的段,而并集入档后该职责消失;基线新鲜度由「PR 合入即 overlay
(baseline-overlay.yml,秒级复用该 PR CI 产出的 incremental 产物,不重跑变异)」+
「夜间并集入档」两条路径承担。
**基线陈旧可被观测(#718 验收判据)**:health-report.yml 周报(独立班次)在数据采集前读基线分支
**最后提交时间**这一事实,超 48 h(连续两夜未入档)即输出 `::error::` 注解并按稳定标题幂等
建/追工单,未超阈则在周报正文留一行基线龄;判定与阈值见 `scripts/release/baseline-staleness.mjs`。
**发现与失败分开判**:stale(检查做成了)由工单承接、run 保持绿;unknown(gh api 失败 / 分支被改名
或删除 / 响应缺字段,含状态文件缺失或状态文件缺 `status` 字段)由本 job **最末**的 verdict 步骤
判红(白名单:只有 fresh|stale 绿,其余落兜底)——「环境失败不得静默降级」,放最末才不会连坐吞掉
周报与陈旧工单两份留痕。
**判据口径(勿说大)**:基线有两条写入路径——observe.yml 夜班并集入档
(`scripts/gate/orphan-baseline.mjs`)、baseline-overlay.yml 在 push main 时对增量基线做秒级
overlay(`scripts/gate/overlay-baseline.mjs`,两者共用 `scripts/gate/baseline-push.mjs` 写路径)。
故本判据的真实语义是「**两条入档路径都停了**」,不是「observe 单点停了」:**绿 != observe 健康**
——夜班停摆但仍有触及变异切片的 PR 合入时,基线会被 overlay 持续刷新而恒 fresh;要单点观测
observe 需另看它自己最近一次 run,本判据不做这个代理。**落点依据**是「监控者不得是被监控者」:
本 workflow 独立于那两条写入路径,检查放进去会在它们停摆时一起沉默。
- **PR 门禁分层**(ci.yml,#722):默认走**增量**,只有给 PR 打 `gate:full` 标签才跑全量链路
(`pull_request.types` 含 `labeled`/`unlabeled`,打标签即触发重跑)。策略由 `changes`
job 一处计算为 `fullGate` 输出,判定表与三个全量 job 的 `if` 共用同源布尔。
1. `build-test` 矩阵(矩阵 = paths-filter 命中的包;空切片时补 1 个哨兵实例,防 GHA
零实例矩阵回报 failure):按命中包切片构建 + test + typecheck,artifact 只上传命中包;
2. `repo-gate` 分两组——
组 A(廉价全仓闸,恒跑):判定脚本 `repo-gate-assert.mjs`、`threshold-monotonic`、
`aggregate:check`、`stryker:check`、`test:scripts`、`forbid-src-tests`、
`forbid-homedir-src`、`forbid-module-state-src`、`verify-scripts-index`、
`verify-coverage-scope`、`verify:vendored-binaries`、`verify-dir-imports`(4 包硬判
+ provider-usage `--soft`)、`export-surface-snapshot`(dsh-notifier + dsh-lan-proxy)、
`verify-shared-fanin`、`docs:check`、`lint`、`format:check`
(本清单是导读,**事实源是 ci.yml 的 repo-gate 步骤本身**。接线由
`scripts/test/gate-wiring.test.ts` 两族断言守护,缺一不可:**一致性**——「本地档位计划 ↔
CI 恒跑段」两侧各自现场派生「被执行的脚本身份」后双向比对,改任一侧漏改另一侧即判红;
**覆盖性**——一致性只是相对不变量,两侧**同时**删掉同一执行点后集合仍然相等,故还要拿
`scripts/gate` 下的判据全集比「全部 workflow × 全部 job ∪ 本地 pr/full 档 ∪ lefthook」的
执行点全集,既无执行点、又不被非测试源码 import 的判据必须显式登记
(`scripts/data/gate-wiring-exceptions.json`,受悬空、class 方向、脚本存在、总量上限、
indirect 的 `via` 可达(`via: package.json` 还要求该别名在仓库里确有出处)且真的没有执行点
等守卫)。另外几族拦的是「执行点在、判据也在跑,但退出码到不了步骤」,扫描面是**全部
workflow 的全部 job**:① 判据步骤只允许**一条直接的判据命令**(`exit "0"` 前置、
`if [ ]; then` 包装、`X=1 set +e` 这类写法列举不完,故闭合形态而非继续补枚举),设计如此的
例外(产物闸的 if/else 双形态、变异判分的循环与聚合等)逐条登记在 `structuredSteps`,登记项
再用**文本摘要** `digest` 钉死(否则往循环里插一行 `break` / `continue` 就能让剩下的判据不再
执行,而步骤键、执行点、两侧身份全不变);② 步骤级 `if`(`stepIfs`)与含判据的 job 的 job 级
`if`(`jobIfs`)必须逐字登记——它们是「改一处即静默停闸」的开关,而「这个条件会不会成立」
静态判不出(等于停机问题),登记制是唯一能把静默开关变成 diff 里显眼一行的手段;③ workflow 与
lefthook 的 YAML **交给成熟的 `yaml` 包解析**(devDependency;引号键 / 空格冒号 / 块标量与折叠
标量 / flow 写法 / 重复键都由它按 YAML 语义处理,文件级解析错误、白名单之外的步骤键、非字符串
`run` / 非映射的 `env` 都由 `parseIssues` / `yamlErrors` 报出来判红;工作流**文件集合**本身也是
硬编码契约,防文件被删或改名时断言整体空转全绿),判据步骤另不得覆盖 `shell`(内建关键字放行,
自定义模板必须取 basename 后属 bash 家族、把 `{0}` 交给解释器且自带 errexit)、不得带
`continue-on-error`、不得注入能改变执行环境的变量(`BASH_ENV` / `SHELLOPTS` / `NODE_OPTIONS` / `PATH` / …)、命令引号
必须配对(都不设登记出口),判据步骤的**有效 env 键**(workflow ∪ job ∪ 步骤三层)逐键登记在新增的
`stepEnvs`;判据 job 的**环境面**按位置整条登记(不按「谁写了 `$GITHUB_ENV`」判——字样总能被绕开):判据步骤
之前的每个非判据 run 步骤进 `priorRunSteps`(整步摘要 + env 键),该 job 的**执行面**(前序步骤有序
序列 + 全部 `uses:` 步骤整步文本 + job 级 `container` / `defaults`)进 `jobFaces`——action 里是任意
代码,同样能写 `$GITHUB_ENV`;一处 `if: false` 也能让产物上传静默不跑;`container.env` 与
`defaults.run.working-directory` 是换掉整 job 执行环境的 job 级键,判据步骤自己声明 `working-directory`
则直接判红(不设登记出口);已登记条件的操作数来源、其输入步骤(同 job 的 `uses` 整步面)与 artifact 产出端登记在
`conditionInputs`——这些都是「条件 / 环境成立与否的输入面」,只钉条件原文挡不住改产出;④ 判据别名的展开结果必须干净,且**指向必须逐条登记在
`judgmentAliases`**——期望身份与别名指向同源派生,改名改参会让两侧一起移动、比对仍然相等,
故需要这条外部锚点;⑤ 本地 pr 档必须覆盖 full 档的全部判据端点(差额只能登记 `tier-only`),
判据不得内嵌进另一个判据的执行点。产物闸 `contract` / `pack:check` / `verify:npmlayout` 还必须
同时存在切片与全仓两种调用形态,删掉任一侧即判红。
覆盖性只要求「至少一处执行点」,不要求该点在 PR 面——某判据只在 `observe` / `release` 跑时,
它在 PR 上的回归不会被拦住;故执行点全部落在非 PR 面(CI 侧 = ci.yml 里**未**按 `gate:full`
标签收口的 job,本地侧 = **pr** 档)的判据必须登记 `nightly-only` 或 `tier-only`,这份清单就是
「PR 上拦不住哪些判据」的答案;恒假(`if: false`)的 job / 步骤不算执行点,否则一个 decoy
步骤就能顶替被删掉的判据)
(`format:check` 是形态的唯一执行点:面由 `.prettierignore` 显式圈定,只格式化代码面,
文档 / `.github/` / 生成器写入的数据与派生物被排除并各带理由;
**纯格式化提交必须登记到仓库根的 `.git-blame-ignore-revs`**:否则一次全仓重排会把数百个
文件的 blame 全部算到格式化提交上,真实作者与改动动机一起被淹没。GitHub 的 blame 视图
原生读该文件,本地需 `git config blame.ignoreRevsFile .git-blame-ignore-revs` 开一次;
混入语义改动的提交不得登记(会被整条跳过);
`test:scripts` 的编译面前置包清单见
`scripts/test/script-test-prereqs.mjs`,CI 与本地门禁同源读取;
`verify-scripts-index` 的判据见 `scripts/README.md` 顶部说明——**索引边界是「仓库会调用
什么」**:索引项必须存在,被调用点引用的脚本必须登记);
组 B(产物闸,按 `fullGate` 切口径):`contract` / `pack:check` / `verify:npmlayout`
—— 默认只验命中包(`--packages <命中清单>`),`gate:full` 时验全仓;
3. `coverage`(全量链路):只在打了 `gate:full` 标签的 PR 上实例化;`mutation-gate` /
`mutation-verdict` 自 #742 阶段 1 起**与标签解耦**——PR 上一律按命中切片强制跑。coverage 全局单次采集(`pnpm cov` = vitest coverage,只跑 unit +
integration,阈值判分即其退出码;摘要产物经 artifact 留档——cov 从变异矩阵剥离后,
矩阵多实例各自全仓 smoke 导致的端口竞争 flake 随之消除);mutation 按命中包切片跑
Stryker(incremental 跳过未变 mutant);verdict 汇合变异报告逐包判分(变异率 covered
≥ per-package threshold,受 `mutation.strict` 约束;覆盖率维度已上移到 coverage job,
改由 repo-gate 判定表按 `fullGate` 裁决——verdict 不再以 coverage 结果为 `if` 前提,
故 cov 失败/skip 不会吞掉变异判分)。矩阵实例级 failure 时 verdict
仍聚合判分(缺报告包 exit 2 fail-closed)。
判定表为**六维**「事件 × fullGate × 切片(hasMutations) × coverage × 变异矩阵 ×
verdict」,由单测全组合锁死。默认增量路径下三段必须**全部 skipped**——对称 fail-closed:
没打标签却跑了全量同样判红。不新增分支保护 required check 名。
- **为什么分层**(#722):覆盖率是「全仓分母」口径,变异单段最坏约 20 分钟(#720),而
两者夜间 observe.yml(每日全量班)已完整覆盖,PR 上属重复执行且拖长
反馈回路。高风险改动(重构、依赖跃迁、发版前)在 PR 上加 `gate:full` 标签按需补跑;
全仓产物闸在 `gate:full` PR 与 observe 全量班两处落地,默认 PR 只验命中包。
- **触发面收敛**(#187 / #217 扩展):全量三段 job 仅限 pull_request 触发——push 到 main
时 diff 基准取 `github.event.before`(只有 force push / 新分支首推这类全零 SHA 才走
fail-closed 全量 fallback),变异在非 PR 事件下整体 skipped、与当晚夜间全量不重复;主干覆盖与变异覆盖由 observe.yml 夜间全量承接、发版前
由 release.yml tag 管线承接,非 PR 事件下三段 job 整体 skipped 且 `fullGate` 恒为 false
(repo-gate 判定表显式放行)。
- 本地手动入口:`npx stryker run stryker.conf.d/dsh-.json`(临时强制全量用
官方 `--force` 参数,勿改配置文件)。
- **测试单份维护(#423 方案 A)**:变异测试复用 `packages/*/test/*.test.ts`,
测试单份维护、变异自动覆盖。**#722 起变异面(`unit/`、`integration/` 两层)内的
`*.test.ts` 直接 `import "../src/**"`**,变异与覆盖率都跑在源码上,不再需要解析期
重定向;`#423` 时代的 `scripts/test/mutation-lib-to-src-{hook,loader}.mjs` 已随
#722 阶段五退役(连同其最后的消费者 `scripts/gate/cov.mjs`)。`e2e/` 与 `client/`
层仍读 `lib/` 产物(前者跑真实 IO、后者测客户端契约),二者不在变异面内。
仓库出现任何遗留 src 副本测试文件(含未跟踪)即 `scripts/gate/forbid-src-tests.mjs`
判红(ci.yml repo-gate 步骤「Forbid legacy src tests」)。
目录结构:
```text
packages/dsh-*/ # 每个插件 = 独立 npm 包(@wingsky-1/dsh-*)
src/index.ts # 宿主端入口(cordis service,export ROUTES 作客户端路由单一来源)
src/client/index.ts # 客户端干净模块入口(只 export apply/inject,无 load/IIFE 外壳)
src/client/style.css # 客户端样式(独立文件,构建期 text-loader 内联进 client.js)
src/client/*.ts # 客户端辅助模块(宿主用不到、仅浏览器侧)
src/*.ts # 其余为宿主模块;宿主导出的 profile 依赖另见 cordis.patch.yml
packages/dsh-plugins-all/ # 聚合包(dependencies 引用全部子包,发布用 pnpm publish 替换版本号)
shared/ # 宿主端共享层(loopback/host-utils/frontmatter),构建期内联进各包,不发布
scripts/ # 仓库维护脚本(*.ts,Node 直跑;按职能分 build/ gate/ lib/ release/ test/ data/)
```
`scripts/build/bundle-host.ts` 编排单包构建:
1. esbuild 内联 `shared/*` 进 `lib/index.js`(宿主端自包含单文件)。
2. 客户端经 `scripts/build/build-client.ts`(唯一契约外壳/注入点)构建 `lib/client.js`。
3. d.ts X1:shared 声明随包机制(见下小节)。
4. 拷贝资源(非代码文件,递归且保持相对路径)+ LICENSE。
5. 第三方 license 归集:扫描产物中 esbuild 的 node_modules 模块注释,把真实被内联
的第三方库(含传递依赖)license 文本写入 `lib/THIRD-PARTY-LICENSES`
(`scripts/build/collect-licenses.ts`)。**运行时依赖 = 构建期内联**——内联在法律上
等于分发该库副本,必须随发布物附其 license 文本与版权声明;`pack:check` 断言
「有内联 ⇒ 清单存在、非空、含 MIT/BSD/Apache 字样且覆盖每个被内联的包名」。
### d.ts X1:shared 声明随包机制(#478)
宿主端共享层(shared/)是 **js + d.ts 双写**(tsc `rootDir` 硬约束,shared 不可
TS 化):`.js` 实现经 esbuild 内联进各包运行时,`.d.ts` 声明则经 X1 随包发布。
X1 在 bundle-host 构建宿主产物时对 **tsc 声明产物**做两件事(纯类型层,运行时无关):
- **2a 路径改写**(`scripts/lib/rewrite-dts-paths.ts`,`rewriteDtsPaths`):改写
`lib/**/*.d.ts` 中所有指向**仓库外 shared/** 的相对引用——tsc 从 src/ 原样写入
声明的 `../../shared/` 等(实际形态 `(?:\\.\\.\\/)+shared/`)→ 指向**包内副本**。
前缀按当前文件在 lib/ 下的目录深度归一为 `'../'.repeat(depth + 1)`:整体吞掉任意
深度 `../` 前缀后按文件深度重算——顶层 `lib/x.d.ts`(depth 0)→ `../shared/`,
子目录 `lib/client/x.d.ts`(depth 1)→ `../../shared/`。引用原深度与文件深度无
对应关系(归一化语义:一律按**文件**深度)。顺带把相对 `.ts` 后缀 import 回写
`.js`(rewriteRelativeImportExtensions 的 d.ts 不回写缺口,issue #276;未启用该
flag 的包无匹配,天然无操作)。发布后 d.ts 不再引用包外路径,类型解析全部落在
包内副本。
- **2b 声明副本进包**:把仓库根 `shared/` 下全部 `.d.ts`(递归,含子目录如 `client/`)复制进 `packages//shared/`(保留相对目录结构),随包发布。
复制谓词与遍历实现 = `scripts/lib/walk-files.ts` 的 `walkFiles`(单一事实源)。
**副本清单来源**:不是包内静态清单——每次构建**实时枚举仓库 shared/**(`walkFiles`
谓词 `.d.ts`)。包根 `shared/` 不入 git、属构建产物(.gitignore
`packages/*/shared/`;clean-lib 只清 lib/ 不清包根 shared/),经各包 package.json
`files` 白名单 `shared/**/*.d.ts` 发布。
**与 shared 准入规则的关系**:X1 是 shared 契约的**发布面强制器**——准入规则
(shared/README.md 准入 1-7:≥2 稳定消费者、无包级常量依赖、跨 apply 状态语义明确、
无泄漏、登记消费方与行为契约、独立测试、显式废弃两步走)约束**哪些模块有资格进
shared**;X1 保证**已准入的模块随每个消费包完整发布**(机制保证)。「准入审核 →
进 shared → 自动随包」,使 shared 单点维护而各包发布物自包含不断链。
**断言链**(`scripts/lib/shared-dts-lib.ts` + `pack:check`):
- `listSharedDts(ROOT)` 用与 2b **同一 walkFiles 谓词**枚举仓库 shared/ 全部 .d.ts
相对路径;pack:check 打包每个插件后逐包比对 tarball:
- `assertSharedDtsPresent` 查缺:新增 shared 子目录/文件漏随包 → fail-loud;
- `assertSharedDtsNoExtras` 查多(#478):**retired 残留**——shared 模块退休
(DEPRECATED 两步走 → 移除)后,旧声明副本残留在包内 shared/(bundle-host 每次
构建覆盖写入新副本但从不清理已移除者,files 白名单仍会把它带进 tarball,过期
声明随包发布 = 陈旧类型面)→ 报「shared 副本残留」fail-loud。
枚举与复制同源,杜绝两处漂移;双向(缺/多)断言把「机制保证」升级为「断言保证」。
宿主端类型一律用官方类型层(pnpm-workspace catalog 锁版:`@deepseek-ai/cordis`
的 `Context` + `@deepseek-ai/dsh-host-webserver` 的 `WebRoute`/ctx.webServer 增强 +
`@deepseek-ai/dsh-tools` 的 `ToolDefinition`/ctx.tools 增强等;仅 import type,
contract-check 禁止运行时值导入)。原自建类型层 `types/dsh.d.ts` 已删除(issue #48)。
**版本适配策略(只适配 rc)**:官方类型层 catalog 升级以 **dsh rc 版本**为锚定
基线(当前 `0.1.5-rc.1`),peer 与 catalog 锁步;**不对 alpha 版本适配**,除非
维护者明确决策。升级 catalog 须跑全量门禁并核验受影响的结构(如
SessionHeader.origin / Agent.session),并同步根 README「版本适配」与 release notes
锚定声明。
## 1. 宿主端(`src/index.ts`)规范
- **单入口**:`src/index.ts` export 一个 cordis service;需要给客户端传路由时
`export const ROUTES` 作为**单一事实源**——`bundle-host` 经 `__DSH_ROUTES__`
define 注入给客户端(客户端不引用则零影响)。
- **依赖纪律**:只 import `../../shared/*`(loopback / host-utils / frontmatter,构建期
内联)与 Node 内置模块;**任何第三方运行时依赖一律由 esbuild `--bundle` 内联**
(如 mcp-manager 宿主用的 `fast-glob`),发布物不以运行时 npm 依赖形式发布。
- **安全**:全部路由强制 loopback 围栏(非回环 403、方法错 405),`/health` 必项;
RPC/端点做参数校验;密钥/凭据不入包。
- **挂载**:`cordis.patch.yml`,patch **id 用 `ui-`**;声明 `dsh.client` 时必须有
`exports["./client"]`(`contract-check` 联动断言,缺则整包拒载)。**独立包与聚合包
禁双装**(同 id 双装 loader 报 duplicate);改独立包 patch 后必须
`node scripts/gate/aggregate.ts` 重新生成聚合 patch。
- **测试**:`pnpm test` 直跑(包内实现为 `node ../../scripts/test/run-vitest.mjs --min `)。
运行器由 vitest 承载:根 `vitest.config.ts` 从 `scripts/data/mutation-topology.json` 的
`$testLayers.layers` 派生六个 project(`test/unit` → `unit`、
`test/integration` → `integration`、`test/client-unit` → `client-unit`(直连 src 的客户端
纯逻辑判据)、`test/client-dom` → `client-dom`(happy-dom 环境,直连 src 的 DOM 单测)、
`test/client` → `contract`、`test/e2e` → `e2e`;
层 glob 与 `--min` 口径因此同源,不再三处声明),
每个测试文件独立环境(per-file 隔离),包级调用按 cwd 自动收窄到本包;
乱序验证用 `--sequence.shuffle` 透传。
测试文件直跑 TS 源码,但部分文件断言 `lib/` 产物
(如客户端产物契约),故跑前仍需 `pnpm build`;必含 403/405 围栏用例 +
客户端契约断言(`assertClientSourceContract` / `assertClientProductContract`)。
`--min` 是**测试文件数**下限(vitest json reporter 的 `testResults` 计数),用于封堵
include 配置漂移导致部分文件漏收集的假绿向量;各包 `--min` 与实际文件数由
`node scripts/gate/gen-stryker-conf.mjs --check` 强制同步(判据 ③)。
### 测试分层与变异面登记(#690 S2b / #713 T1–T3)
测试文件按**机制**分层,目录即分类源(不按文件名判断):
| 层 | 判据 | 进变异面 |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `test/unit/**` | 单模块 / 纯逻辑 / fake 驱动、只做临时目录 I/O(允许为覆盖分支而短暂 bind 一个端口,如 lan-proxy 的 EADDRINUSE 用例) | 是 |
| `test/integration/**` | 以真实 socket/真实组合根为被测对象:起真实 http server(内核临时端口)走完整转发链、真实 cordis Context、真实配置迁移 | 是 |
| `test/client-unit/**` | 直连 `src/client/**` 的**纯逻辑**判据(判定、映射表、状态机),不需要 DOM;环境 `node` | 是 |
| `test/client-dom/**` | 直连 `src/client/**` 但被测模块在**加载期或运行期真的读写 DOM**(`document.title`、横幅挂载),必须 `happy-dom`;文件头用 `@vitest-environment happy-dom` 声明(派生配置是单 project `node`,不吃根配置的层环境) | 是 |
| `test/client/**` | 断言对象是客户端**构建产物**形态(`lib/client.js`、或 in-place esbuild 后执行已构建副本)——产物外壳无法用 perTest 覆盖分析归因到任何 `src/**` 模块,登记进变异面只增加每个段的 dry run 成本、杀灭贡献为零;直连 src 的判据在 `client-unit` / `client-dom` | 否 |
| `test/e2e/**` | 真实监听端口 / spawn 子进程 / 真机系统调用的大 smoke | 否 |
支撑模块不入任何层:`test/helpers.ts`、`test/client-helpers.ts`(客户端判据共用的替身,只服务
一个域故不上提包级夹具)、`test/smoke-lib.ts`、`test/smoke-pure.ts`、`test/*.worker.mjs`
(它们不是测试条目)。**判层按机制而非文件名**:notifier 的
`e2e-*.test.ts` 用的是 in-process cordis Context + fake 驱动(不 listen、不 spawn),
故归集成层并保留在变异面;反之 `smoke.test.ts`(真实端口/子进程)归 e2e 层。
登记链路(唯一事实源 = `scripts/data/mutation-topology.json` 的 `$testLayers` 与各包 `testLayers`):
- 变异面测试清单由 `scripts/gate/gen-stryker-conf.mjs` **从层 glob 展开为真实文件清单**,落在
每包一份的 `vitest.stryker.d/.config.ts` 的 `include` 上(#722 方案 A 路径一)。
为什么不由 Stryker 的 `testFiles` 承载:该字段非空会让 core 把 static mutant 判成 runtime
激活(上游 #6144 未修),模块级变异体在模块加载后永久漏判(实测 80.49 → 0.00)。
为什么不把 `**` 通配直接交给 Stryker:#712 已 CI 实证沙箱语义失败(`smoke.test.ts` 的
provide 方法面断言)+ mcp 每个段各付一次 dry run、合计撞 5 分钟预算(#712 实测时该包
5 段,此后段数只由域拆分决定,不改变本条理由);
- `pnpm stryker:check` 是登记完整性门禁(实现见 `scripts/gate/test-surface.mjs`,纯函数、import 无副作用):
① 磁盘上有测试的**每个包**都必须在拓扑登记(漏登即在 `$noMutationPackages` 写明理由),且该包
`test/` 下每个 `*.test.ts` 都要有层归属——新增测试必须显式决定层归属,不能靠「没写进清单」逃逸;
② 每条登记与每条豁免在磁盘上真实存在;③ 每个有测试的包(含未登记变异面的包)`--min` == runner glob
实际文件数;④ **充分性下限**:`mutationLayers` 必须包含 `test-surface.mjs` 里的 `REQUIRED_MUTATION_LAYERS`
(unit + integration)且每包变异面非空——防「两行拓扑改动把变异面削掉」;
⑤ 每条派生 `mutate` 条目(正向与 `!` 排除同等)必须**锚定在本包(或 `shared/`)**、在**源码世界内
命中 ≥1 文件**、且命中面不越出本包或 `shared/`。字面前缀不足以证明锚定:`..` 会被 glob 归一化、
brace 会展开,两者都能让前缀看着在本包而命中他包文件(独立复核各实测出绕过形态);
⑥ **有效面非空**:一份 conf 的正向命中被 `!` 条目剔除后必须仍有剩余——⑤ 只判**单条**条目,
一条包根级整包通配能在条数不变、⑤ 全绿的前提下把整包变异面清空,而 Stryker 对 0 mutant 不报错
(判分与门禁都静默)。此外段级 `excludes` 的**条目形状**(非空字符串 + `!` 前缀,缺 `!` 会极性
反转)与包登记(空 `segments` 指向 `$noMutationPackages`)在派生前先判;
- 新增测试文件后的固定动作:放进对应层目录 → `node scripts/gate/gen-stryker-conf.mjs --sync-test-min`
→ `pnpm stryker:gen` → 提交。单元层与集成层**零手工登记**;`testMutationExemptions`(按层分组)只用于
「刻意不进变异面」的逐条裁决,必须写明理由,模型样例两条:
mcp 的 `unit/unit-shared.test.ts`(测的是 shared 层,不在本包 mutate 面内)、
notifier 的 `integration/real-context.test.ts`(Stryker 沙箱内 dry run 失败,属 #712 记录的沙箱语义族);
- 变异面扩缩**在 PR 门禁里看不出来**(`incremental: true` 复用基线状态)。真信号来自 observe.yml
班次全量重建;PR 内的自证方式是「派生测试面 ↔ 基线的集合对比 + 单段真跑 stryker 报告的
mutant 状态分布与基线一致」。
### 落盘路径必须感知 DSH_HOME(#510)
凡插件自行落盘或读取持久化文件(配置、历史、状态、缓存等),路径 base 一律
`process.env.DSH_HOME ?? join(homedir(), ".dsh")`,**禁止直拼 `join(homedir(), ".dsh", ...)`**:
- **为什么**:官方 dsh 在隔离环境(多实例 / 测试沙箱 / dsh-verify-isolated 临时
home)下运行时,settings 存储等宿主数据已随 `DSH_HOME` 隔离;插件若仍硬拼
`~/.dsh`,读写两面都会串到真实 home——#510 即 dsh-notifier 通知历史/投递状态
落真实 `~/.dsh`,隔离实例的通知记录 tab 读出用户真实数据。
- **写法先例**:`packages/dsh-provider-usage/src/path-resolve.ts`(`pluginHome()`);
收敛方向为 `shared/dsh-home.js` 单一事实源(#517 C10 接缝),现阶段各包内聚
helper 亦可,但不得绕过 env 读取。
- **豁免口径**:读取**非 dsh 生态**的外部凭据/配置(如 provider-usage 读 opencode
自家目录)不跟随 `DSH_HOME`,属合法例外——豁免须在代码注释说明「为什么不跟随」。
- **测试义务**:新增/改动落盘路径时,路径契约断言必须双锁定——默认形态
(无 `DSH_HOME`)路径逐字节不变 + 设 `DSH_HOME` 后路径随隔离 home(finally
恢复 env,防污染同进程其他用例);写面用 e2e 落盘断言锁定(读函数返回值不足
以证明写面)。
- **门禁(已落地)**:`scripts/gate/forbid-homedir-src.mjs`(B5,#517)以 AST 扫描
禁止插件 src 直连 HOME 来源 API(`os.homedir` / `os.userInfo` / `process.env.HOME` /
`untildify`,含 import 别名、`os["homedir"]` 中括号混淆形态与动态 import
命名空间形态;`.ts/.tsx/.mts/.mjs` 全覆盖,解析失败一律 fail-closed 判红)。本闸
**没有豁免通道**(#765):该面收口到零豁免后,台账条目与调用点注释词法一并删除,命中即
违规——需要 home 路径就走 `shared/dsh-home.js` 的 `dshHome()`。留一个零命中的豁免入口
只会让下一处命中默认「先开豁免」而不是「先看接缝」;确有域外合法场景时先在 #765 讨论,
不要在闸内复活豁免常量或注释词法。
本地运行 `pnpm gate:homedir`;CI 在 repo-gate 段执行。解析器说明:typescript 7 已移除
经典 JS AST API,扫描链为 esbuild 剥类型 + acorn estree 解析 + node:module
SourceMap 行映射回 TS 原文(行号以原文为准,transform 会剥离注释)。
### 事件订阅与 scope 语义(cordis dispatch 过滤)
cordis `EventsService.dispatch` 对已注册监听器的过滤条件为
`hook.global || !filter || filter.call(thisArg, hook.ctx)`(`hook` = 监听记录,
`thisArg` = 派发载体):
- **`hook.global` 无条件放行**:注册第三参数 `{ global: true }`(cordis
`EventOptions.global`,官方语义 = "Receive the event regardless of context
filter checks")使该监听器跳过一切 filter 检查;
- **`!filter` 放行**:裸 `ctx.emit(name, ...)` 派发(`thisArg` 无
`[Context.filter]` 标签)对所有监听器放行;
- **untagged listener ctx 放行**:宿主 dsh-scope 的 `scopeTarget(agent, agent)`
carrier 派发带 filter(`scopeOf(ctx) === undefined → true`)——**无 scope 标签
的 listener ctx 直接放行**。第三方 bundle 插件经 `cordis.patch.yml` 平铺
insert 挂载、ctx 无 `kScope` 标签时,agent 作用域事件默认可达,**无需**
`{global:true}` 即可收到(该假设已由真实 cordis Context 契约用例固化——
见 dsh-notifier `test/integration/real-context.test.ts`,宿主若收紧 untagged 放行语义,
用例先红而非静默漏检)。
**第三方 bundle 插件接收 agent 作用域事件的推荐做法**:
- 默认挂载形态(untagged 平铺)下不加 `{ global: true }` 也能收到事件——它是
**消费端防御而非必需**:对「事件必须到达」的关键监听(如通知类插件的完成/
错误事件)建议加 `{ global: true }`,把事件到达与宿主 scope 分发语义解耦,
即使未来以 private-scoped 形态挂载(listener ctx 带 scope 标签且与事件
carrier 的 scope 不一致)仍全收(dsh-notifier 全部 `ctx.on` 均如此);
- **代价**:`global` 会收到**跨 scope** 的事件——消费端必须按「payload 自校验
- 事件内容过滤」处理(事件载荷跨宿主边界不受信,逐字段运行时校验),仅想靠
scope 过滤防串扰的监听不应加 `global`。
## 2. 客户端(`src/client/index.ts`)规范 — 干净模块
**核心:源码只写干净模块,不写任何 loader 痕迹。**
```ts
import STYLE from "./style.css"; // 样式走独立 CSS(见 §3)
// ... 顶部模块体(函数、常量、DOM 渲染)...
export function apply(ctx: any): void {
// 挂载入口
// ctx.get("connection"/"sessions"/"workspaces"/"slots") ...
// 卸载清理必须写在 ctx.effect(() => () => { ... }) 返回的 disposer 里
ctx.effect(
() => () => {
cleanup();
},
"dsh-: ui",
);
}
export const inject: string[] = []; // 声明 apply 用到的 ctx 服务(如 ["slots"])
```
**禁止在源码里**:`window.__ModuleLoader__.load`、手拼 `__DSH_PLUGIN_ID__`、`require(`、
外层 IIFE `(function(){})()`、`declare var module` / `interface Window.__ModuleLoader__`。
这些(load 注册、IIFE 闭包工厂、`Symbol.toStringTag` 装配、`exports.apply/inject`、
**load id === 包名**)全部由 `scripts/build/build-client.ts` 构建期统一生成——是唯一事实源,
内建「load id === 包名」硬校验(构建即失败)。
### 2.1 三种客户端路径(build-client 自动选择,作者不用配置)
| 路径 | 触发 | 说明 |
| ------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 纯净 wrapper | 干净模块、无 bare import | esbuild iife + 生成契约外壳;`apply/inject` 直出 |
| wrapper + externals | 干净模块 `import * as React from "react"` | 干净模块 cjs 内联进 `factory(require)`,React 由 loader 的 `require("react")` 注入(dsh web **无全局 React**)。**类型**由仓库根 devDep `@types/react` 解析(#834 起各包不再自备 `react-shim.d.ts`,那份 ambient 声明已删),运行时仍是 loader 注入,故只声明 `peerDependencies.react` + `optional` |
| 第三方内联 | `dsh.client.inlineBareImports: true` | 干净模块的 bare import(dompurify/diff2html/marked/highlight…)由 esbuild **内联进 client.js**,产物仍自包含。用于纯浏览器第三方库、无宿主注入 JS 模块的场景 |
> ⚠️ **互斥**:默认「bare import = 宿主注入 external(React)」;`inlineBareImports: true`
> 则全部内联。按包二选一,不要混用。
### 2.2 目录约定
- 客户端入口统一 `src/client/index.ts`(`src/client.ts` 已停用)。
- **拆 CSS 或带多模块的包**:客户端专属模块(`md/code/renderer` 等)、
`style.css`、`css.d.ts`、`locales.ts` 都归位 `src/client/`;宿主模块留 `src/` 根
(React 类型由根 devDep `@types/react` 提供,不再需要包内 shim)。
- **宿主 & 客户端共享**的模块(如双端共用的后缀表 / 契约常量)留 `src/` 根,
客户端经 `../grouping.js` 引用——不要为"客户端专用"而把共享模块搬走。
- **例外:包内 `src/shared/**`(#769 起)**。双端共享且要求**零 import**(或只做同目录
`.ts` 相对 import)才能两端各自 inline 的模块(典型是契约常量表与种类表)归位
`src/shared/`,两端都经 `src/shared/interface.ts` 这一处门面引用(目录头写明约束,
见 `packages/dsh-notifier/src/shared/interface.ts`)。放进这个目录的意义不是分类而是
**可审**:`scripts/test/shared-leaf-imports.test.ts` 按「客户端是否经门面消费」推导扫描面,
对门面转出链上的每个叶子模块机械判红(值引 `node:*` 会构建失败、值引 bare 包会**静默内联**
进浏览器产物)。该目录的最终形态(包内 `src/shared/` 还是独立 shard 目录)由 #792 的三档
共享规范裁定。
### 2.3 客户端其它要点
- `inject` 语义:声明 `apply` 运行时用到的 ctx 服务;不需要则 `[]`。**这是运行时的
服务注入声明**,与宿主的 cordis `inject`(插槽)是两码事,别混。
- 生命周期:所有卸载清理写进 `ctx.effect(() => () => {})` 的 disposer。
- 样式:带插件前缀隔离 + `CSS_VERSION`/`dataset.version` 失效(热更新重建 `