diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml new file mode 100644 index 0000000000..4c98962ba9 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md new file mode 100644 index 0000000000..0605225a70 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md @@ -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. diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md new file mode 100644 index 0000000000..5caed34222 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md @@ -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 face:Loader 行是具名它的插件而不是 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/*` 整体不在判据范围内。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 1928f30a3c..14965fd495 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -35,6 +35,15 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile. 3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. +## Dependency declaration + +A browser artifact resolves nothing on the user's machine: tsdown inlines every non-platform specifier into `lib/client.js`, the shell `dist` answers `PLATFORM_MODULES` from its frozen module table, and Vite inlines the shell's own imports into the published `dist`. + +- **An external package only browser code reaches belongs in `devDependencies`.** npm installs `dependencies` and non-optional `peerDependencies` for every consumer, so react, shiki, katex, or clsx declared there ships to users who never load it. +- **A workspace name stays where it is.** Such a declaration also states which package supplies an injected service or a mounted Remote contribution — [verify-runtime-closure](../../scripts/verify-runtime-closure.ts) and the Loader read it, and the app installs the package regardless. +- **The node half decides.** Anything its published entries reach — an erased type import, a `require.resolve`, and a Loader row in the package's own `cordis*.yml` included — stays declared as it is. +- `pnpm run verify-client-runtime-deps` (inside `hygiene`) names each offending entry; knip owns whether the surviving declaration is used at all. + ## ctx discipline (components never see ctx) `ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook. @@ -91,7 +100,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list, and every browser-side external package under `devDependencies` per the [declaration rules](#dependency-declaration)), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.