docs(client): state where a browser-only dependency is declared

Browser artifacts resolve nothing on a user's machine: tsdown inlines every
non-platform specifier, the shell dist answers the rest from its frozen module
table, and Vite inlines the shell's own imports into the published dist. Record
the resulting declaration rule, and the Agent Note behind it.
This commit is contained in:
imccyu
2026-08-14 20:17:50 +08:00
parent 887c4977db
commit 48d14b4a7c
4 changed files with 216 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-14-client-build-time-deps.md
2026-08-14-client-build-time-deps.md: 0605225a70a16fed9004acfe26adba9b6166f201
2026-08-14-client-build-time-deps.zh.md: 5caed3422288711532c40cd767f72c97693e68b4

View File

@@ -0,0 +1,100 @@
# Agent Note: Client build-time dependencies stay out of the install face
Status: proposed
English | [中文](2026-08-14-client-build-time-deps.zh.md)
## Problem
A browser artifact resolves nothing on the user's machine:
- A `ui-*` plugin package's browser artifact is `lib/client.js`, where tsdown inlines every non-platform specifier (`noExternal` in `packages/client/tsdown.client.ts`). The specifiers that survive are answered by the loader's frozen module table, because `require` inside that bundle is a parameter the loader injects, not Node's.
- Platform modules (`PLATFORM_MODULES`) come from the shell `dist`, never from Node resolution.
- The shell's own imports are inlined by Vite into `@deepseek-ai/dsh-web-frontend`'s published `dist`; that package ships `dist` alone and has no `.` export.
Every browser code path is therefore a build product, served as an asset or baked into `dist`. Yet the packages those artifacts are built from — react, react-dom, shiki, katex, clsx, the micromark and mdast families — sit in `dependencies` and non-optional `peerDependencies`, which npm installs for every consumer of the published package. Across the repository that is 79 such external declarations in 38 packages, downloaded by users who never load them.
## Proposal
### The rule
**An external package only a browser artifact reaches belongs in `devDependencies`.** Two deliberate omissions are as much part of the rule:
- **External packages only.** A `@deepseek-ai/*` name stays where its manifest puts it. Such a declaration also states which package supplies an injected service, which Remote contribution an assembly mounts, or which Loader row must resolve; [verify-runtime-closure](../../../../scripts/verify-runtime-closure.ts) and the Loader read it, and the app installs the package regardless — so moving one removes meaning without removing a download.
- **Anything the node half reaches stays**, an erased type import included.
Faces are walked from the entries a manifest publishes, not by a directory rule, so a module under `src/` that only the browser entry reaches counts as browser source:
| kind | test | host face entries |
| --- | --- | --- |
| `bundle-half` | has a `./client` export | every export target except `./client` |
| `browser-library` | under `packages/client/` with no `./client` export | `src/invariant.ts` alone — the companion the host mounts; `.` is browser code |
| `prebuilt-dist` | no `.` export, ships a `dist` | none: the package offers Node no entry |
### The gate: `scripts/verify-client-runtime-deps.ts`
Wired into `pnpm run hygiene`, about 35 seconds — the cost of two bound Programs, the same order as `verify-optional-dependency-imports` in that lane. It reuses the repository's tooling rather than growing its own: `TypeScriptProject` (`scripts/ts-project.ts`) binds the host and client compiler faces separately (that file states why the two cannot share one program — the cordis Context merges collide), `ts.resolveModuleName` resolves relative specifiers, and the walk stops at the package boundary.
Three findings decided the mechanism, after a first pass that scanned string literals:
1. A package name must match as a name: the `react` substring inside `'@deepseek-ai/dsh-client-web-react'` silently swallowed react.
2. Whether `./client` is the tsdown browser bundle is keyed on the **artifact path** (`./lib/client.js`), not the subpath name — `dsh-goal` publishes `./client` as `./lib/types/client.js`, a plain tsc-emitted browser-shared module.
3. `require`, `require.resolve`, and dynamic `import()` on a literal each reach a package; `require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` is a real host resolution path.
Two classes, both reported per entry:
| class | count | test |
| --- | --- | --- |
| `browser` | 74 | only a browser artifact reaches it |
| `nothing` | 5 | no reference names it: `client-runtime`'s `react` (which contradicts its own React-free layering red line), the peer `react` of `ui-settings` and `ui-theme`, `ui-trajectory`'s peer `react-dom`, and `ui-primitives`' `@types/mdast` |
Each conservative rule below answers a false report or a semantic loss observed while building it:
- **A type reference from the node half keeps its declaration.** `import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'` in `src/invariant.ts` is erased at run time, yet it states which package supplies the service that companion registers — `verify-runtime-closure`'s relation.
- **A package publishing a Node entry with no source counterpart is skipped whole, and named in the output.** `dsh-goal`'s `./typert -> ./lib/typert.host.js` is emitted by the typert generator and carries its own `import { z } from 'zod'`, which no source states. Getting this test right removed four false reports, among them `api-gateway`'s `typert-registry`.
- A `cordis*.yml` the package owns counts as host face: a Loader row names its plugin instead of importing it.
- `@deepseek-ai/cordis` is exempt — check-workspace-constraints requires it as both peer and dev everywhere.
`--json` output feeds the bulk edit and the install measurement.
### What leaves an install
Measured against a real `npm install` of the published CLI, with tarball bytes read from an isolated cache: 103 external tarballs stop being downloaded, 6.05 MB in total.
| group | packages | saved |
| --- | --- | --- |
| syntax highlighting and math (shiki family, oniguruma family, katex) | 16 | 3.93 MB |
| react and view libraries (react, react-dom, scheduler, immer, zustand, `@tanstack/*`, clsx, use-sync-external-store) | 11 | 1.47 MB |
| markdown and ansi pipeline plus odds and ends (micromark, mdast, hast families, anser, a few `@types/*`) | 76 | 0.65 MB |
Our own six browser-library packages (ui-primitives, ui-slots, web-react, ui-attachment, schema-form, client-web — 0.20 MB together) stay installed: code names them, and the rule above leaves those declarations alone.
### How it lands, split by nature
1. **Documentation first**: a declaration section in `packages/client/AGENTS.md`, and one clause in the new-plugin-package checklist.
2. **The gate**: `scripts/verify-client-runtime-deps.ts`, its `package.json` script, its place in `hygiene`, and a counterexample spec.
3. **The manifests**: 79 entries in 38 packages. 50 need a new `devDependencies` entry; the rest already carry one, so the change is a deleted line.
4. **Re-measure after the next release** with the same method, confirming the 103 tarballs stay gone.
## Alternatives considered
- **Scanning string literals**: the first implementation, rejected by the three findings above — the react-inside-web-react substring had already produced a silent miss.
- **Reading built artifacts (`lib/**/*.js`) instead of source**: that is Node's own view, but the gate would then depend on `pnpm run build`, and it still cannot judge a browser-library's `lib/index.js` (node platform, browser content), so the face test stays either way.
- **Asking the checker whether a binding is used in a value position** (what `verify-optional-dependency-imports` does): tried, and it also judged 83 node-face type-only declarations movable — no download saved for a real loss of meaning, 53 of them `dsh-invariants`. This gate needs to know whether a reference exists, not whether it is a value.
- **Also clearing our own six browser-library packages from the install face**, on the test that no install loads one: another 0.20 MB, at the price of deleting 74 workspace declarations that code genuinely names. Ruled out (2026-08-14): keep what the code names. The cleaner end state is to stop publishing those six packages, which is its own proposal.
- **`peerDependenciesMeta.optional` instead of `devDependencies`**: npm does skip an optional peer, but the meaning is "a consumer may supply this", and there is no run-time consumer at all. The repository must install it to build, which is what `devDependencies` says.
- **Leaving it to knip**: out of scope for knip, which reports a declared package nothing imports. These specifiers are imported; a bundler inlines them. The evidence is that they persisted on master with knip green. Only the five `nothing` entries overlap.
- **`optionalDependencies`**: wrong meaning — it says "skip this if it cannot be installed".
## Acceptance criteria
- `pnpm run hygiene` includes `verify-client-runtime-deps` and passes; a counterexample spec proves one `dependencies.react` is rejected.
- `pnpm run build`, `pnpm run test:gui`, and `DSH_SNAPSHOT=replay pnpm run test:web` pass — the move changes no build input, so artifacts stay byte-identical.
- A real install after the next release no longer downloads the 103 tarballs above.
## Risks
- **The six browser-library packages that stay installed carry bare imports nothing resolves**: `ui-primitives/lib/index.js` is a rolldown artifact and still reads `from "anser"`, while anser is now dev-only. It is inert — only our Vite build reads that file, and no loader exists for it on a user's machine (verified: only browser code imports those packages, never the host). Retiring their publication is the way to erase it; see Alternatives.
- **`@types/*` go unreported**: source never names them, so the rule cannot see them. `@types/mdast` was caught only because nothing referenced it either. They belong in dev regardless, and closing that gap is follow-up work.
- **A skipped package is unprotected**: `dsh-goal` is skipped whole for its generated entry, so its browser-side declarations are now nobody's business. Reading a generated artifact's own run-time imports is what would let the exemption be withdrawn.
- **A false report would delete a declaration something needs at run time**: three defenses hold that line — literal arguments to `require`, `require.resolve`, and dynamic `import()` count as references; a package's own `cordis*.yml` counts as host face; and no `@deepseek-ai/*` name is subject at all.

View File

@@ -0,0 +1,100 @@
# Agent Note: 客户端构建期依赖不进安装面
Status: proposed
[English](2026-08-14-client-build-time-deps.md) | 中文
## Problem
浏览器产物不在用户机上解析任何 specifier
- `ui-*` 插件包的浏览器产物是 `lib/client.js`tsdown 把每个非平台 specifier 直接内联(`packages/client/tsdown.client.ts``noExternal`)。留下来的 specifier 由 loader 的冻结模块表应答——那个 bundle 里的 `require` 是 loader 注入的形参,不是 Node 的。
- 平台模块(`PLATFORM_MODULES`)由 shell `dist` 提供,不走 Node 解析。
- shell 自身的 import 由 Vite 内联进 `@deepseek-ai/dsh-web-frontend` 已发布的 `dist`;该包只发 `dist`,连 `.` 导出都没有。
所以浏览器的每条代码路径都是构建产物,或作为静态资源下发,或烤进 `dist`。但这些产物的构建输入——react、react-dom、shiki、katex、clsx、micromark 与 mdast 全族——现在写在 `dependencies` 和非 optional `peerDependencies` 里,而 npm 对每个消费者都会安装这两个区段。全仓 38 个包共 79 处这样的外部依赖声明,装给了永远不会加载它们的用户。
## Proposal
### 规则
**只被浏览器产物触及的外部包落 `devDependencies`。** 两条留白同样是规则的一部分:
- **只管外部依赖**。`@deepseek-ai/*` 一律留在原处:那些声明还表达「谁提供我注入的服务」「这个 assembly 挂载了谁的 Remote」「哪个 Loader 行必须能解析」,[verify-runtime-closure](../../../../scripts/verify-runtime-closure.ts) 与 Loader 都读它,而 app 无论如何都会装那个包——移走只是删掉语义,并没有减少下载。
- **node 面触及的一律不动**,包括被擦除的类型引用。
face 从 manifest 真正发布的入口走图,不用目录规则,所以 `src/` 下只被浏览器入口触及的模块就算浏览器代码:
| kind | 判据 | host face 入口 |
| --- | --- | --- |
| `bundle-half` | 有 `./client` 导出 | 除 `./client` 外的每个导出目标 |
| `browser-library` | `packages/client/` 下且无 `./client` 导出 | 只有 `src/invariant.ts`——宿主唯一能挂载的伴生模块;`.` 面是浏览器代码 |
| `prebuilt-dist` | 无 `.` 导出、发布 `dist` | 没有:这个包不给 Node 提供任何入口 |
### 门禁:`scripts/verify-client-runtime-deps.ts`
接入 `pnpm run hygiene`,约 35 秒——两个绑定 Program 的开销,与同 lane 的 `verify-optional-dependency-imports` 同量级。复用仓内既有工具而不自造一套:`TypeScriptProject``scripts/ts-project.ts`)分别绑定 host 与 client 两个编译面(该文件写明两者不能合进一个 program——cordis Context merge 会撞),相对 specifier 交给 `ts.resolveModuleName` 解析,遍历到包边界即停。
判据要害有三条,都是起手那版扫字符串字面量踩出来的:
1. 包名必须按名匹配:`'@deepseek-ai/dsh-client-web-react'` 里的 `react` 子串会静默吞掉 react。
2. `./client` 是不是 tsdown 浏览器 bundle看的是**产物路径**`./lib/client.js`)而不是子路径名——`dsh-goal``./client``./lib/types/client.js`,一个 tsc 直出的浏览器共享模块。
3. `require``require.resolve`、动态 `import()` 的字面量实参都能触及一个包;`require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` 就是真实存在的宿主解析路径。
两类判定,逐条报告:
| 类 | 数量 | 判据 |
| --- | --- | --- |
| `browser` | 74 | 只有浏览器产物触及 |
| `nothing` | 5 | 没有任何引用具名它:`client-runtime``react`(与它自己「零 React 引用」的分层红线矛盾)、`ui-settings``ui-theme` 的 peer `react``ui-trajectory` 的 peer `react-dom``ui-primitives``@types/mdast` |
下面每条保守规则都对应一次实测到的误报或语义损失:
- **node 面的类型引用保留声明。** `src/invariant.ts` 里的 `import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'` 运行期被擦除,但它声明了谁提供这个伴生插件要注册的服务——归 `verify-runtime-closure` 管的关系。
- **发布了没有源码对应文件的 Node 入口的包整包跳过,并在输出里点名。** `dsh-goal``./typert -> ./lib/typert.host.js` 由 typert 生成器直出,自带 `import { z } from 'zod'`,没有任何源码陈述这件事。把这条判据修对,消掉了四处误报,其中包括 `api-gateway``typert-registry`
- 包自带的 `cordis*.yml` 算 host faceLoader 行是具名它的插件而不是 import 它。
- `@deepseek-ai/cordis` 豁免——check-workspace-constraints 要求它在每个包里同时是 peer 和 dev。
`--json` 输出供批量改写与安装体积实测复用。
### 安装面少掉什么
对已发布 CLI 真装一遍实测tarball 字节从独立 cache 读出103 个外部 tarball 不再下载,合计 6.05 MB。
| 组 | 包数 | 省 |
| --- | --- | --- |
| 语法高亮与数学shiki 族、oniguruma 族、katex | 16 | 3.93 MB |
| react 与视图库react、react-dom、scheduler、immer、zustand、`@tanstack/*`、clsx、use-sync-external-store | 11 | 1.47 MB |
| markdown 与 ansi 管线及零碎micromark、mdast、hast 全族、anser、若干 `@types/*` | 76 | 0.65 MB |
我们自己的 6 个浏览器库包ui-primitives、ui-slots、web-react、ui-attachment、schema-form、client-web合计 0.20 MB仍留在安装面代码确实具名它们上面的规则不动那些声明。
### 分刀落地
1. **文档住顶刀**`packages/client/AGENTS.md` 的依赖声明节,加新插件包 checklist 里的一句。
2. **门禁**`scripts/verify-client-runtime-deps.ts`、它的 `package.json` 脚本、它在 `hygiene` 里的位置,以及一条反例 spec。
3. **manifest**38 个包 79 处。其中 50 处需要新增 `devDependencies` 条目,其余包已有同名条目,改动就是删掉一行。
4. **发版后按同一方法复测**,确认这 103 个 tarball 没有回来。
## Alternatives considered
- **扫字符串字面量**起手就是这么实现的被上面三条要害否掉——react-in-web-react 的子串已经造成过一次静默漏报。
- **读构建产物(`lib/**/*.js`)而不是源码**:那是 Node 自己的视角,但门禁从此依赖 `pnpm run build`,而且它照样判不了浏览器库包的 `lib/index.js`platform 是 node、内容是浏览器代码face 判据两种走法都得有。
- **用 checker 判绑定是否用在值位置**`verify-optional-dependency-imports` 就是这么做的):试过,它把 83 处 node 面纯类型声明也判成可移出——没省下任何下载,却实打实损失语义,其中 53 处是 `dsh-invariants`。本门禁要知道的是引用是否存在,而不是它是值还是类型。
- **顺带把我们自己的 6 个浏览器库包也清出安装面**,判据是任何安装都不加载它们:再省 0.20 MB代价是删掉 74 处代码确实具名的 workspace 声明。已否2026-08-14代码用到的就保留。更干净的终态是这 6 个包不再发布,那是另一个提案。
- **用 `peerDependenciesMeta.optional` 而不是 `devDependencies`**npm 确实会跳过 optional peer但那个语义是「消费者可以自行提供」而这里根本没有运行期消费者。仓内必须装一份才能构建这正是 `devDependencies` 的意思。
- **交给 knip**:不属于 knip 的范畴,它报的是「声明了但没人 import」。这些 specifier 确实被 import只是被打包器内联了。实证就是它们在 master 上长期存在而 knip 全绿。只有 `nothing` 那 5 条与它重叠。
- **用 `optionalDependencies`**:语义错,它说的是「装不上就跳过」。
## Acceptance criteria
- `pnpm run hygiene` 包含 `verify-client-runtime-deps` 并通过;一条反例 spec 证明一处 `dependencies.react` 会被拒。
- `pnpm run build``pnpm run test:gui``DSH_SNAPSHOT=replay pnpm run test:web` 通过——这次迁移不改任何构建输入,产物应逐字节等价。
- 发版后真装一遍,上面那 103 个 tarball 不再被下载。
## Risks
- **留在安装面的 6 个浏览器库包会带着解析不了的 bare import**`ui-primitives/lib/index.js` 是 rolldown 产物,仍写着 `from "anser"`,而 anser 已经只在 dev。它是惰性的——只有我们的 Vite 构建会读这个文件,用户机上没有任何加载者(已实证:只有浏览器代码 import 它们,宿主从不)。要彻底消掉就让这些包不再发布,见 Alternatives。
- **`@types/*` 报不出来**:源码从不具名它们,规则看不见。`@types/mdast` 被抓到只是因为恰好也没有任何引用。它们本来就该在 dev补这个缺口是后续的事。
- **被跳过的包没人管**`dsh-goal` 因生成入口整包跳过,它浏览器侧的声明现在没有门禁看着。能读到生成产物自身的运行期 import这条豁免才能收回。
- **误报会删掉运行期真需要的声明**:三层兜底守住这条线——`require``require.resolve`、动态 `import()` 的字面量实参都算引用;包自带的 `cordis*.yml` 算 host face`@deepseek-ai/*` 整体不在判据范围内。