docs: address review comments on the rules directory

- package-surface: split the two plugin forms (a function plugin named-exports
  the Loader four, a service plugin default-exports its class), keep Host
  Service Definition exports and the Client `/client` export discipline, replace
  the wrong example that quoted live production code, state that the static
  client libraries and `packages/util/*` are closed to new members, and drop
  census numbers that rot on the next package.
- client-components: use the real Slot API — the inject face with the reserved
  `hooks` compartment, the parent's derived `renderSlot`, `registerFactory`
  scope declarations with local components at `renderFactorySlot` — drop the
  string seat, pass the injected `useResource` explicitly with a protocol key,
  and describe persistence as the spec's framework-run `persist` key.
- conversation-and-session: rewrite the reasoning example on the real
  `conversation.chat.reasoning.body` seat and the published `reasoning.content`
  Factory, and drop status annotations and future-tense spec-speak.
- remote-and-api: scope the JSON requirement to the wire payload so
  `@Remote({ mode: 'stream' })`, lookup arguments, and a trailing `AbortSignal`
  stay valid; drop a leaked Chinese parenthetical from the English page.
- general: measure through `benchmarks/` (`test:bench`, `check:ci:bench`,
  `test:bench:memory`) instead of an invented command, state the baseline
  without presuming a hook, and drop a leaked exception line.
- api-gateway: link the Remote and API rules instead of restating them.
- development.zh and AGENTS: drop an orphan anchor and widen the standing
  order's trigger to the directory's scope.

The earlier "retired routing skill" claim was wrong and is dropped: that skill
was never committed, so the diff deletes nothing under `.agents/skills/`.
This commit is contained in:
imccyu committed 2026-10-09 23:03:57 +08:00
1 parent 73dbbb751c
commit 32aba5ea37
21 files changed
+245 -208

No files matched your search

+1 -1
View File
@@ -163,7 +163,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
## Defensive patterns
Read [docs/defensive-patterns/README.md](docs/defensive-patterns/README.md) before lifecycle, concurrency, subprocess, or teardown work. Its [review principles](docs/defensive-patterns/README.md#why-these-rules) apply per artifact across the repository.
Read [docs/defensive-patterns/README.md](docs/defensive-patterns/README.md) before changing a package surface, remote service, client component or state, conversation or Session behavior, or lifecycle, concurrency, subprocess, and teardown code. Its [review principles](docs/defensive-patterns/README.md#why-these-rules) apply per artifact.
## Type safety and documentation
+2 -2
View File
@@ -27,5 +27,5 @@
en: e6befc5f8c90f97b
zh: 4ace4a493fd2b41f
/api-gateway/boundaries/business-ownership:
en: 02c78afe5e2efcc5
zh: 06cbd0d81293bc29
en: 980ef04e501a3896
zh: d2f6db14e5135b68
+1 -3
View File
@@ -166,6 +166,4 @@ Lookup policy is configured per key, so all `agent` or `session` parameters shar
### Business ownership
`packages/api` owns transport, call shape, and session lifecycle. A business rule — a state-dependent decision, a feature-specific handling path, or a per-feature filter — belongs to an independent business package and reaches this layer through a registration surface or projected data. A new business branch here needs an independent-package review.
A new remote capability declares its own service instead of growing an existing one, marks the capability method with `@Remote`, and leaves client-facing types to inference. Remote payloads stay JSON with no output-side decode, and existing method and field names are additive.
`packages/api` owns transport, call shape, and session lifecycle; a business rule belongs to an independent business package and reaches this layer through a registration surface or projected data. The requirements a change here must satisfy — the four `@Remote` disciplines, the additive-names rule, and the boundary this layer keeps — are owned by [Remote and API rules](defensive-patterns/remote-and-api.md), which is where a review checks them.
+1 -3
View File
@@ -172,6 +172,4 @@ lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷
### 业务所有权
`packages/api` 负责传输、调用形态与会话生命周期。业务规则——依赖状态的判断、与具体功能绑定的处理路径、逐功能的过滤——归独立业务包所有,经注册面或投影数据接入本层;在此新增业务分支须走独立包评审。
新的远程能力声明自己的 service,而不是继续扩大既有 service;能力方法用 `@Remote` 标注,面向 Client 的类型交给推断。远程载荷保持 JSON、不做输出侧 decode;既有的方法与字段名只增不改。
`packages/api` 负责传输、调用形态与会话生命周期;业务规则归独立的业务包,经注册面或投影数据接入本层。这里改动必须满足的要求——`@Remote` 的四条纪律、存量名只增不改、以及本层保留的边界——正家是[远程与 API 规则](defensive-patterns/remote-and-api.zh.md),评审就在那里核对。
@@ -6,14 +6,14 @@
en: a6e8a87976040c04
zh: 08b59ad3c14fe250
/client-component-rules/state-arrives-through-injection:
en: f8abfaf951119c64
zh: be91a98d3a53b082
en: 112232a703ce472d
zh: cf42bda22d8e4da4
/client-component-rules/props-carry-data-and-callbacks-not-react-nodes:
en: 253f05568dd7e26f
zh: fa12565ff5afb8bd
/client-component-rules/a-slot-registration-is-a-stable-function-component:
en: 36fa33b28a889aa6
zh: 93ecb1d415d47ad1
en: 784d3a4bba02f8a4
zh: 0ccfcc1a504b135c
/client-component-rules/components-never-receive-a-cordis-context:
en: 8bb102b178615c03
zh: 93cb177a578a1373
@@ -21,11 +21,11 @@
en: 652ec94d13ee51c9
zh: b47c39e96c53cfac
/client-component-rules/resource-consumers-go-through-useresource:
en: edb2be9bbfa1a53b
zh: 414bf824c80286ed
en: 6650c772df96f935
zh: 57fc0a564caf62a8
/client-component-rules/store-usage-is-narrow-and-explicit:
en: 9dc1bad38c435976
zh: effaa4ae9ba0e4e7
en: 697b2e81a862e3bf
zh: 13cf31bef332a601
/client-component-rules/identity-and-initial-state-are-deliberate:
en: a34c30b8f7c415d4
zh: cbd0c0cf0c0db57c
+60 -47
View File
@@ -6,26 +6,34 @@ Rules for client components, their state, and their effects. [packages/client/AG
## State arrives through injection
A Slot's component receives everything it renders through the Injection face bound at its render site — owner props, hook seats, and actions — and never reads one large global state at the top of the tree to hand values down. Keeping every live value on that face is what makes a prop that did not change keep its identity, and what lets a component subscribe only to the sub-state it reads. Without it any unrelated field change re-renders the whole subtree, which is how a long session under streaming events loses frames while the user scrolls or types.
A Slot's component receives everything it renders through the inject face of its own registration — plain data, action callbacks, and the reserved `hooks` compartment the renderer binds to `use<Name>` hooks — while the parent's render site passes owner data through the derived `renderSlot`. It never reads one large global state at the top of the tree to hand values down. Keeping every live value on that face is what makes a prop that did not change keep its identity, and what lets a component subscribe only to the sub-state it reads. Without it any unrelated field change re-renders the whole subtree, which is how a long session under streaming events loses frames while the user scrolls or types.
- Judged by: at every `slots.render` site the author must be able to name which sub-states the rendered component subscribes to and which of its incoming props keep a stable identity across renders. Watch the mutability of every prop: a value rebuilt each render — a fresh object, array, or inline callback — propagates re-renders even when nothing in it changed.
- Judged by: at the registration and at its render site, the author must be able to name which sub-states the rendered component subscribes to and which of its incoming props keep a stable identity across renders. Watch the mutability of every prop: a value rebuilt each render — a fresh object, array, or inline callback — propagates re-renders even when nothing in it changed.
- Not covered: the component's own local state (`useState` for a fold, a hover, a draft) is not injection content, and a component that subscribes to nothing outside React needs no new seat.
- Exception: a new injection face or hook seat changes the Slot contract, so that change states its impact — which components subscribe to what — in review; using an existing face needs no per-component approval.
- Exception: a new hook seat changes the Slot contract, so that change states its impact — which components subscribe to what — in review; using an existing face needs no per-component approval.
- Fix: narrow the injection to what the view renders, expose one field per read through a hook seat, and pass a narrow action set instead of a store or service handle. [Component inputs](../subsystems/slots.md#component-inputs) owns the inject factory, the standard hook seats, and how each becomes a prop.
The render site injects one hook seat and a narrow action set, and the component reads only the field it renders:
The registration carries plain data, a narrow action set, and one hook seat; the render site passes owner data, and the component reads only the field it renders:
```tsx
// Render site: plain owner data, one hook seat, a narrow action set.
slots.render('sidebar.right', {
props: { id },
useHooks: SidebarHooks,
action: { rename, archive },
})
// The inject factory returns plain data and callbacks; hooks is the reserved compartment.
ctx.slots.register(
{
name: 'sidebar.right',
inject: () => ({
rename,
hooks: { info: infoSource }, // bound by the renderer as useInfo()
}),
},
SidebarRight,
)
function SidebarRight({ id, useInfo, action }: Props) {
// The parent's render site passes owner data through the derived renderSlot.
{renderSlot('sidebar.right', { id })}
function SidebarRight({ id, useInfo, rename }: Props) {
const { title } = useInfo() // subscribes to title only
return <button onClick={() => action.rename(id)}>{title}</button>
return <button onClick={() => rename(id)}>{title}</button>
}
```
@@ -86,39 +94,40 @@ The card's Slot contract stops describing what it renders, the call site keeps r
## A slot registration is a stable function component
The value registered into a slot is a stable function component declared at module scope, or a plain string when the seat renders fixed text. A component function created during render is a new component type on every pass, and React treats a changed type as a different component: it unmounts the subtree and mounts a fresh one, which resets expanded rows, input focus, and scroll position. Registration-time parameters reach the component through props rather than by closing over render-time variables.
The value registered into a slot is a stable function component declared at module scope. A component function created during render is a new component type on every pass, and React treats a changed type as a different component: it unmounts the subtree and mounts a fresh one, which resets expanded rows, input focus, and scroll position. Registration-time parameters reach the component through props rather than by closing over render-time variables.
- Judged by: where the registered value is created. An inline arrow in the registration call, a component defined inside a render body, and a `useCallback` wrapper around an inline component all fail; a module-scope `function` declaration or a string passes.
- Not covered: the rule constrains the identity of the registered value, not its complexity. A stable component may still read injected props, hooks, and callbacks, and a fixed-text seat may stay a string.
- Judged by: where the registered value is created. An inline arrow in a registration or render call, a component defined inside a render body, and a `useCallback` wrapper around an inline component all fail; a module-scope `function` declaration passes.
- Not covered: the rule constrains the identity of the registered value, not its complexity. A stable component may still read injected props, hooks, and callbacks.
- Exception: creating a component during render is correct only when the caller wants the remount, and an ordinary change needs no separate approval; a change to the form of the Slot contract follows the injection-face review described under State arrives through injection.
- Fix: move the component to module scope, type its props from the registration's shares, and pass the varying value through the inject face instead of the closure.
The registration holds one stable reference to a module-scope component, and the varying value arrives injected:
The registration holds one stable reference to a module-scope component, and the varying value arrives injected; the render site supplies the local component:
```tsx
function PanelTitle({ title, count }: Props) {
return <span>{title} ({count})</span>
}
// The registration holds one stable reference to the module-scope component.
// Registration: the local slot's scope is declared, never a component.
ctx.slots.registerFactory(
{
name: 'panel',
slots: { title: PanelTitle },
scope: 'root',
slots: { title: { scope: 'root' } },
inject: () => ({ title: 'Sessions', count: 3 }),
},
Panel,
)
// The render site passes the local component, keeping its identity stable.
renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } })
```
An inline arrow inside the registration call creates a new component type whenever the registration runs:
An inline arrow at the render site creates a new component type whenever that render runs:
```tsx
// A new component type each time this registration runs.
ctx.slots.registerFactory(
{ name: 'panel', slots: { title: (p) => <span>{p.title}</span> } },
Panel,
)
// A new component type each time this render runs.
renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => <span>{p.title}</span> } })
```
React sees a different type than the previous registration, remounts the seat, and resets its local state — expanded, focused, or scrolled position — on an otherwise unrelated update.
@@ -185,18 +194,19 @@ An element that shows a resource reads it through the injected `useResource` hoo
- Exception: reshaping the snapshot is free, but a wrapper that hides protocol differences behind conditional branches states its ownership in review, and a new protocol's hook comes from that protocol's owner.
- Fix: call `useResource` inside the domain hook, map its result onto what the component renders, and delete the bespoke cache.
The domain hook wraps `useResource` and only reshapes its result, so the bottom layer stays single:
The domain wrapper takes the injected hook as an argument and only reshapes its result, so the bottom layer stays single:
```tsx
// The bottom layer is the standard hook; the wrapper only reshapes it.
export function useChatResource(id: string) {
const { status, value } = useResource<ChatData>(`dsh-resource://chat/${id}`)
if (status !== 'live' || value === undefined) return { messages: [], loading: true }
return { messages: value.messages, loading: false }
// The bottom layer is the standard hook, reached through the component's inject face.
function useChatMessages(useResource: UseResource, id: string) {
const resource = useResource<'chat'>(`dsh-resource://chat/${id}`)
return resource.status === 'live' && resource.value !== undefined
? { messages: resource.value.messages, loading: false }
: { messages: [], loading: true }
}
function ChatBody({ id, useResource }: Props) {
const { messages } = useChatResource(id)
const { messages } = useChatMessages(useResource, id)
return <ol>{messages.map((message) => <li key={message.id}>{message.text}</li>)}</ol>
}
```
@@ -218,44 +228,47 @@ Every protocol grows its own fetch and invalidation logic, cache entries and ref
## Store usage is narrow and explicit
A front-end store carries shared viewing or interaction state, and a single-instance store is expected to support persistence through its own `persist()` and `hydrate()`. A multi-instance store is marked as not persisted, with that design still open. The store's attributes and the actions that mutate them are encapsulated and injected as a narrow interface, so a component receives those and the selectors it needs and never the whole view store. Business data lives in the object layer rather than in a store, and a setting change goes through the settings interface, not through store state.
A front-end store carries shared viewing or interaction state. Its spec declares the initial state, the actions that mutate it, and — when the value should survive a reload — a persistence key the framework runs mechanically, with a session-scope instance suffixing that key so per-session instances persist independently. The store's attributes and the actions that mutate them are encapsulated and injected as a narrow interface, so a component receives those and the selectors it needs and never the whole view store. Business data lives in the object layer rather than in a store, and a setting change goes through the settings interface, not through store state.
- Judged by: whether the injected face is a narrow interface or the store object itself, and whether the store has one instance or several.
- Persistence: one instance means `persist()`/`hydrate()`; several mean an explicit "not persisted" note. Rollback consumers are few, so multi-instance persistence carries no hard requirement yet, and forcing a persistence key onto a multi-instance store is the violation.
- Judged by: whether the injected face is a narrow interface or the store object itself, and whether the handle is built inside `apply` or at module level.
- Persistence: the spec's `persist` key is the whole mechanism, run by the framework; the engine instance exposes `getSnapshot`, `subscribe`, the baked actions, and `clearPersisted`, and nothing hydrates it by hand.
- Not covered: render state in a store is fine while it does not change a setting, and component-local state is not store content. [Store contracts](../subsystems/slots.md#component-inputs) own how a declared store becomes props, and [Web Client architecture](../subsystems/web-client.md) owns where business data lives.
- Exception: the multi-instance persistence design is unconfirmed, so a change there needs the owning domain expert's sign-off; restructuring an injection face into a narrow one needs no separate approval.
- Exception: a store handle exported at module level is forbidden — module-cache identity is a disguised singleton across plugin reloads; restructuring an injection face into a narrow one needs no separate approval.
- Fix: replace the store handle in the inject return with the fields and action callbacks the component renders, and keep the handle inside `apply`.
One handle inside `apply` persists itself, and the inject face carries only the fields and actions the view renders:
One handle is built inside `apply` and passed to the registration, and the inject face carries only the fields and actions the view renders:
```tsx
// Inside apply: one handle, persistence through the store's own methods.
const store = createSidebarStore().create()
store.hydrate(savedSidebar)
// The handle is built in apply; persistence is the spec's own key.
const sidebarStore = createSidebarStore() // defineStore({ init, actions, persist: 'sidebar' })
// The inject face carries only what the view renders.
const inject = () => ({ title: store.title, onSelect: store.select })
ctx.slots.register(
{ name: 'sidebar.header', store: sidebarStore, inject: () => ({ onSelect: selectPanel }) },
SidebarHeader,
)
function SidebarHeader({ title, onSelect }: Props) {
// The component reads through the framework-bound seat and writes through the actions.
function SidebarHeader({ useStore, actions, onSelect }: Props) {
const { title } = useStore()
return <button onClick={onSelect}>{title}</button>
}
```
The whole view store is injected for the component to pick fields from, and a multi-instance store is given one shared persistence key:
The whole view store is injected for the component to pick fields from, and a module-level handle takes instance lifecycle away from the framework:
```tsx
// The component receives the store and picks its own fields.
// The component receives the store itself and picks its own fields.
const inject = () => ({ viewStore })
function SidebarHeader({ viewStore }: Props) {
return <button onClick={viewStore.select}>{viewStore.activePanel.title}</button>
}
// One key for several pane instances.
createPaneStore({ persistKey: 'pane' })
// Module-level handle: one identity shared by every plugin load.
export const paneStore = createPaneStore()
```
The component's dependency face stops being auditable, and the shared key makes two pane instances overwrite each other's state on hydrate.
The component's dependency face stops being auditable, and the shared module identity outlives every scope teardown: two panes end up on one instance instead of one each.
## Identity and initial state are deliberate
+61 -48
View File
@@ -6,26 +6,34 @@
## 状态经 Injection 到达
Slot 的组件经其渲染点绑定的 Injection 面获得它渲染所需的一切——owner props、hook 席位与 action——绝不先在顶层读一棵大全局状态再逐层手工传下去。把每个活值都放在这个面上,props 才能在没变时保持引用不变,组件也才能只订阅自己读取的那部分子状态。缺了这条,任何一个无关字段变化都会重渲整棵子树——长会话在流式事件下的掉帧、滚动与输入卡顿就是这么来的。
Slot 的组件经**自己注册项**的 inject 面获得它渲染所需的一切——普通数据、action 回调,以及渲染器绑定成 `use<Name>` hook 的保留 `hooks` 隔间——而父组件的渲染点经派生的 `renderSlot` 传 owner 数据。它绝不先在顶层读一棵大全局状态再逐层手工传下去。把每个活值都放在那个面上,props 才能在没变时保持引用不变,组件也才能只订阅自己读取的那部分子状态。缺了这条,任何一个无关字段变化都会重渲整棵子树——长会话在流式事件下的掉帧、滚动与输入卡顿就是这么来的。
- 判定方式:每个 `slots.render` 点上的作者都必须能回答两件事——被渲染组件订阅了哪些子状态,以及它收到的哪些 props 在多次渲染之间保持引用稳定。要盯住每个 prop 的可变性:每次渲染都重建的值(新对象、新数组、内联回调)即使内容没变也会向下传播重渲。
- 判定方式:在注册项与其渲染点上,作者都必须能回答两件事——被渲染组件订阅了哪些子状态,以及它收到的哪些 props 在多次渲染之间保持引用稳定。要盯住每个 prop 的可变性:每次渲染都重建的值(新对象、新数组、内联回调)即使内容没变也会向下传播重渲。
- 不在范围内:组件自己的局部状态(折叠、悬停、草稿用的 `useState`)不属于注入内容;不订阅 React 之外任何东西的组件也不需要新席位。
- 例外:新增注入面或 hook 席位会改动 Slot 契约,因此该改动要在 review 中说明影响面——哪些组件订阅什么;使用既有注入面不需要逐组件批准。
- 例外:新增 hook 席位会改动 Slot 契约,因此该改动要在 review 中说明影响面——哪些组件订阅什么;使用既有注入面不需要逐组件批准。
- 修法:把注入收窄到该视图真正渲染的内容,每个读取项经一个 hook 席位暴露一个字段,用窄 action 集合取代 store 或 service 句柄。[组件输入](../subsystems/slots.zh.md#component-inputs) 正家负责 inject 工厂、标准 hook 席位,以及它们各自如何成为 prop。
渲染点只注入一个 hook 席位与一组窄 action,组件只读它渲染的那个字段:
注册项承载普通数据、一组窄 action 与一个 hook 席位;渲染点只传 owner 数据,组件只读它渲染的那个字段:
```tsx
// Render site: plain owner data, one hook seat, a narrow action set.
slots.render('sidebar.right', {
props: { id },
useHooks: SidebarHooks,
action: { rename, archive },
})
// The inject factory returns plain data and callbacks; hooks is the reserved compartment.
ctx.slots.register(
{
name: 'sidebar.right',
inject: () => ({
rename,
hooks: { info: infoSource }, // bound by the renderer as useInfo()
}),
},
SidebarRight,
)
function SidebarRight({ id, useInfo, action }: Props) {
// The parent's render site passes owner data through the derived renderSlot.
{renderSlot('sidebar.right', { id })}
function SidebarRight({ id, useInfo, rename }: Props) {
const { title } = useInfo() // subscribes to title only
return <button onClick={() => action.rename(id)}>{title}</button>
return <button onClick={() => rename(id)}>{title}</button>
}
```
@@ -86,39 +94,40 @@ type ToolCardProps = {
## slot 注册物是稳定的 function component
注册进 slot 的值是在模块作用域声明的稳定 function component;席位渲染固定文本时也可以是纯字符串。渲染期创建的组件函数每一轮都是新的组件类型,而 React 把变化的类型当作另一个组件:它卸载这棵子树并挂载一棵新的,展开的行、输入焦点与滚动位置随之重置。注册期参数经 props 到达组件,而不是靠闭包捕获渲染期变量。
注册进 slot 的值是在模块作用域声明的稳定 function component。渲染期创建的组件函数每一轮都是新的组件类型,而 React 把变化的类型当作另一个组件:它卸载这棵子树并挂载一棵新的,展开的行、输入焦点与滚动位置随之重置。注册期参数经 props 到达组件,而不是靠闭包捕获渲染期变量。
- 判定方式:注册物在哪里创建。注册调用里的内联箭头、渲染函数体内定义的组件、以及用 `useCallback` 包住内联组件的写法都不合格;模块作用域的 `function` 声明或字符串合格。
- 不在范围内:本条约束的是注册物的身份,不是它的复杂度。稳定组件照样可以读取注入的 props、hooks 与回调,固定文本席位也可以保持为字符串。
- 判定方式:注册物在哪里创建。注册调用或渲染调用里的内联箭头、渲染函数体内定义的组件、以及用 `useCallback` 包住内联组件的写法都不合格;模块作用域的 `function` 声明合格。
- 不在范围内:本条约束的是注册物的身份,不是它的复杂度。稳定组件照样可以读取注入的 props、hooks 与回调。
- 例外:只有调用方确实想要这次重挂载时,渲染期创建组件才是对的,普通改动无需单独批准;Slot 契约形态的变更按「状态经 Injection 到达」一节描述的注入面 review 走。
- 修法:把组件移到模块作用域,用注册面的各个 share 给它的 props 定型,让变化的值经 inject 面传入而不是走闭包。
注册物持有对模块作用域组件的稳定引用,变化的值经注入到达:
注册项持有对模块作用域组件的稳定引用,变化的值经注入到达;局部组件由渲染点传入:
```tsx
function PanelTitle({ title, count }: Props) {
return <span>{title} ({count})</span>
}
// The registration holds one stable reference to the module-scope component.
// Registration: the local slot's scope is declared, never a component.
ctx.slots.registerFactory(
{
name: 'panel',
slots: { title: PanelTitle },
scope: 'root',
slots: { title: { scope: 'root' } },
inject: () => ({ title: 'Sessions', count: 3 }),
},
Panel,
)
// The render site passes the local component, keeping its identity stable.
renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } })
```
注册调用里的内联箭头会在注册每次运行时产生新的组件类型:
渲染调用里的内联箭头会在该次渲染运行时产生新的组件类型:
```tsx
// A new component type each time this registration runs.
ctx.slots.registerFactory(
{ name: 'panel', slots: { title: (p) => <span>{p.title}</span> } },
Panel,
)
// A new component type each time this render runs.
renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => <span>{p.title}</span> } })
```
React 看到的类型与上一次注册不同,于是重挂载该席位,并在一次无关更新中重置它的局部状态——展开态、焦点或滚动位置。
@@ -187,18 +196,19 @@ const activeTabId = globalThis.__activeTabId // every tab reads the same value
- 例外:重塑快照自由,但把协议差异藏进条件分支的包装要在 review 中说明归属;新协议的 hook 由该协议的拥有者提供。
- 修法:在领域 hook 内调用 `useResource`,把结果映射成组件要渲染的内容,删掉自建缓存。
领域 hook 包装 `useResource` 且只重塑其结果,底层因此保持唯一:
领域包装接收注入的 hook 作为参数,并且只重塑其结果,底层因此保持唯一:
```tsx
// The bottom layer is the standard hook; the wrapper only reshapes it.
export function useChatResource(id: string) {
const { status, value } = useResource<ChatData>(`dsh-resource://chat/${id}`)
if (status !== 'live' || value === undefined) return { messages: [], loading: true }
return { messages: value.messages, loading: false }
// The bottom layer is the standard hook, reached through the component's inject face.
function useChatMessages(useResource: UseResource, id: string) {
const resource = useResource<'chat'>(`dsh-resource://chat/${id}`)
return resource.status === 'live' && resource.value !== undefined
? { messages: resource.value.messages, loading: false }
: { messages: [], loading: true }
}
function ChatBody({ id, useResource }: Props) {
const { messages } = useChatResource(id)
const { messages } = useChatMessages(useResource, id)
return <ol>{messages.map((message) => <li key={message.id}>{message.text}</li>)}</ol>
}
```
@@ -220,44 +230,47 @@ export function useTabStream(id: string) {
## Store 用法窄而明确
前端 store 承载共享的查看或交互状态,单实例 store 原则上经自己的 `persist()` 与 `hydrate()` 支持持久化。多实例 store 标注为不持久化,该设计仍待定。store 的属性与改动这些属性的 action 被封装成窄接口注入,因此组件拿到的是这些以及它需要的选择器,绝不拿整个 view store。业务数据住在对象层而不是 store 里,设置变更走设置接口,不走 store 状态。
前端 store 承载共享的查看或交互状态。它的 spec 声明初始状态、改动这些状态的 action,以及在值需要跨重载存活时由框架机械执行的持久化键——session 作用域的实例会在该键上追加 scope key,因此每个会话的实例各自独立持久化。store 的属性与改动这些属性的 action 被封装成窄接口注入,组件只拿到这些以及它需要的选择器,绝不拿整个 view store。业务数据住在对象层而不是 store 里,设置变更走设置接口,不走 store 状态。
- 判定方式:注入面是窄接口还是 store 对象本身,以及该 store 是一个实例还是多个实例。
- 持久化:一个实例就用 `persist()`/`hydrate()`;多个实例就明确标注「不持久化」。需要 rollback 的使用者不多,多实例持久化目前没有硬性要求,给多实例 store 强加持久化键才是违规形态。
- 不在范围内:不改设置时,渲染状态放 store 没问题;组件局部状态也不是 store 内容。[Store 契约](../subsystems/slots.zh.md#component-inputs) 正家负责声明的 store 如何变成 props,[Web Client 架构](../subsystems/web-client.zh.md) 正家负责业务数据住在哪里。
- 例外:多实例持久化方案尚未确认,那里的改动需要对应领域专家确认;把注入面改成窄接口不需要单独批准。
- 判定方式:注入面是窄接口还是 store 对象本身,以及句柄是在 `apply` 内构建还是在模块级构建。
- 持久化:spec 的 `persist` 键就是全部机制,由框架执行;引擎实例暴露 `getSnapshot`、`subscribe`、烘焙好的 actions 与 `clearPersisted`,没有任何一方手工 hydrate。
- 不在范围内:不改设置时,渲染状态放 store 没问题;组件局部状态也不是 store 内容。[Store 契约](../subsystems/slots.zh.md#component-inputs) 正家负责声明的 store 如何变成 props,[Web 客户端架构](../subsystems/web-client.zh.md) 正家负责业务数据住在哪里。
- 例外:在模块级导出 store 句柄一律禁止——模块缓存的身份在插件重载之间是一个伪装的单例;把注入面收窄成窄接口不需要单独批准。
- 修法:把 inject 返回值里的 store 句柄换成组件渲染所需的字段与 action 回调,句柄留在 `apply` 内。
`apply` 内的一个句柄自行持久化,inject 面只带视图渲染所需的字段与 action:
一个句柄在 `apply` 内构建并传给注册项,inject 面只带视图渲染所需的字段与 action:
```tsx
// Inside apply: one handle, persistence through the store's own methods.
const store = createSidebarStore().create()
store.hydrate(savedSidebar)
// The handle is built in apply; persistence is the spec's own key.
const sidebarStore = createSidebarStore() // defineStore({ init, actions, persist: 'sidebar' })
// The inject face carries only what the view renders.
const inject = () => ({ title: store.title, onSelect: store.select })
ctx.slots.register(
{ name: 'sidebar.header', store: sidebarStore, inject: () => ({ onSelect: selectPanel }) },
SidebarHeader,
)
function SidebarHeader({ title, onSelect }: Props) {
// The component reads through the framework-bound seat and writes through the actions.
function SidebarHeader({ useStore, actions, onSelect }: Props) {
const { title } = useStore()
return <button onClick={onSelect}>{title}</button>
}
```
整个 view store 被注入给组件自行挑选字段,多实例 store 又被赋了一个共享的持久化键:
整个 view store 被注入给组件自行挑选字段,模块级句柄则把实例生命周期从框架手里拿走:
```tsx
// The component receives the store and picks its own fields.
// The component receives the store itself and picks its own fields.
const inject = () => ({ viewStore })
function SidebarHeader({ viewStore }: Props) {
return <button onClick={viewStore.select}>{viewStore.activePanel.title}</button>
}
// One key for several pane instances.
createPaneStore({ persistKey: 'pane' })
// Module-level handle: one identity shared by every plugin load.
export const paneStore = createPaneStore()
```
组件的依赖面不再可审计,而共享的键会让两个 pane 实例在 hydrate 时互相覆盖状态。
组件的依赖面不再可审计,而共享的模块身份活得比任何一次 scope 清理都久:两个 pane 会落到同一个实例上,而不是各自一个。
## 身份与初始状态都是刻意的
@@ -12,20 +12,20 @@
en: 32991da738e47f7f
zh: b138910f8c16d103
/conversation-and-session-rules/retention-follows-one-lifecycle-rule:
en: 580a3a11e97a6c49
zh: d19fed1195d08ee6
en: fcb40a31d0881e84
zh: 779bc49cce81651e
/conversation-and-session-rules/node-computation-is-incremental-and-streaming-publication-is-batched:
en: edce17b7d760152b
zh: 3f6caec6e6c27c30
en: 315c7ac2186d0721
zh: 06bb4e072b242fe1
/conversation-and-session-rules/the-node-build-and-grouping-engine-carries-no-business-rule:
en: a588f4abbbc27166
zh: 7601dd70d9845c59
en: ebfa96408f32c3c1
zh: fb854b2b3e37aa9b
/conversation-and-session-rules/the-node-list-and-seat-components-carry-no-business-branch:
en: f69c4c16f472adcc
zh: 4570a62aa3d934fb
/conversation-and-session-rules/the-markdown-pipeline-carries-no-business-branch:
en: bc01d9cf17d3273c
zh: 0400c59c648ccfda
en: b124a7515404ed9e
zh: 4b544c2d1bbb6e76
/conversation-and-session-rules/the-user-message-model-and-rendering-carry-no-business-field:
en: 8f97bbeaba14be0d
zh: cfad05711395cbd2
@@ -87,9 +87,7 @@ Every miss now reads the whole log in the foreground, so a slow Session is slow
A client holds a Session through an explicit reference (`SessionReference`), and the reference count reaching zero releases it. Idle reclamation follows the one lifecycle rule — no references and nothing in flight — and never enumerates which Sessions may be reclaimed from business state, because that list has to be maintained by every feature that adds one. An implicit holder is invisible, so reclamation cannot tell who still uses the Session.
- Judged condition: the predicate is `refCount == 0 && inflight == 0`. Does the code hold the Session across its acquire → use window through a reference that retain and release can be audited against? A module-level Session object or a global current id is the violation.
- Current design, not a hard requirement: reference count zero releases the Session today, and that is the rule in force, but the exact release trigger is checked by hand and is not by itself a rejection reason. The enforced parts are the unified predicate and the absence of business-state enumeration.
- Backend status: Session references are not implemented on the backend yet; once they exist they use the same explicit references and lifecycle management. Code that assumes an implicit backend hold is premature.
- Evidence: the predicate is recorded as secondary evidence, so re-read the retention contract before citing it. Do not weaken the two parts it does establish — an explicit reference, and no business-state enumeration.
- Boundary: the enforced parts are the unified predicate and the absence of business-state enumeration; the release trigger itself is checked by hand, so hand-checking alone is not a rejection reason.
- Ownership: `retain`, `release`, `ready`, and teardown ordering belong to [the Session Controller's client references](../../packages/api/session-controller/README.md#client-references). A source-session mention carried in a message is a different type, owned by [session-reference.md](../subsystems/session-reference.md).
A view that retains through an explicit reference and releases it on teardown complies:
@@ -132,7 +130,7 @@ Nobody can count who still uses the Session, and the next state added to the Ses
A new node computation states its complexity, and high-frequency streaming chunks never publish immediately and never recompute in full. A per-chunk full rebuild published at once puts the whole node collection on the frame budget, and the transcript stalls behind the stream. The append-path cost — with `D` registered Definitions, `D` matches and one constant-time Context-key lookup — and the publication vocabulary (`none`, `animation-frame`, `immediate`) are owned by [conversation.md](../subsystems/conversation.md#definition-and-typed-chat-payload).
- Judged condition: how much one chunk computes — the increment on the affected subtree, not the event window — and when its result is published — a batching policy, not one publication per chunk.
- Development requirement, not a review rejection: the implementation must follow this rule; a reviewer does not reject a change for its cadence alone.
- Boundary: this rule binds the implementation; cadence alone is not a rejection reason, so a change is judged on the cadence it states and the published path it uses.
- Forbidden on the high-frequency path: `immediate` publication, and a full recomputation per chunk.
- Boundary: a structural or terminal change may publish `immediate`, because the cadence is chosen per publication. Only the high-frequency delta path is bound to batching.
@@ -166,7 +164,7 @@ Each chunk costs a full-window rebuild inside the frame budget, so grouping and
Business rules never enter the grouping engine, and a business requirement never reshapes the built payload. `ProcessState`, the group product hierarchy, and the set of group semantics in `conversation-nodes/process-groups.ts` are infrastructure: the engine understands enum placement values such as grouped, standalone, and reply, and the caller computes which one applies. A special case inside the engine changes the produced structure for every consumer and puts a business condition on the group computation path.
- Judged condition: does the engine contain a business condition? A diff that adds a `ProcessState` field, adds or removes a group level, or changes group semantics for one business fails; the placement decision belongs to the caller that computes the enum value.
- Design status: the build and grouping design cannot be substantially reworked. The layering, its cost, and existing business expectations are the reason.
- Boundary: the layering, its cost, and existing business expectations are the reason a business does not rework the grouping design.
- Boundary: a new enum value carrying a new placement meaning is the supported extension, wherever the caller computes it.
- Exception and approver: none by default. A change to this infrastructure needs the corresponding domain expert's confirmation. [conversation.md](../subsystems/conversation.md#group-definitions) owns the group Definition types.
@@ -244,18 +242,24 @@ One feature's condition now decides how every node renders, and the shared seat
- Boundary: generic Markdown capability — syntax support, performance — is not a business modification.
- Exception and approver: none by default; the corresponding domain expert confirms any exception.
A contribution that registers on the existing chain, and a host that keeps the official fallback:
A contribution that registers into the existing single seat, and reuses the published Factory instead of re-rendering the standard content:
```tsx
// The business registers a renderer; AssistantMarkdown itself stays generic.
ctx.slots.inject('conversation.chat.reasoning-body', () => ctx.slots.register(
{ name: 'conversation.chat.reasoning-body', locale: NS, select: owner => owner },
({ text, running }) => <TranslatedReasoning text={text} running={running} />,
// The business contributes its own body into the seat the host already renders.
ctx.slots.inject('conversation.chat.reasoning.body', () => ctx.slots.register(
{ name: 'conversation.chat.reasoning.body', scope: 'session', locale: NS },
TranslatedReasoningBody,
))
// The host invokes the chain at the position it owns and keeps the official fallback.
{renderReasoningBody('conversation.chat.reasoning-body', { text, running, setHeaderAction },
{ fallback: <MarkdownText text={text} variant="compact" /> })}
// The contribution keeps the standard rendering by calling the published Factory.
function TranslatedReasoningBody({ text, running, renderFactorySlot, t }: ReasoningBodyProps) {
return (
<>
<p>{t('reasoning.translated')}</p>
{renderFactorySlot('conversation.chat.reasoning.content', { text, running })}
</>
)
}
```
A business branch inside the shared Markdown pipeline:
@@ -87,9 +87,7 @@ function readListing(header: SessionHeader, session: Session): ListingView {
客户端经显式引用(`SessionReference`)持有 Session,引用计数归零即释放。闲置回收只看那一条生命周期判据——零引用且无在途——绝不按业务状态枚举「哪些 Session 可以回收」,因为那份清单要靠每个新增状态的业务功能自己去维护。隐式持有者不可见,回收便判断不了谁还在使用该 Session。
- 判定条件:判据 = `refCount == 0 && inflight == 0`。代码是否在「获取 → 使用」的时间窗口内通过一份可审计 retain / release 的引用来持有 Session?模块级 Session 对象或全局 current id 都属违规。
- 现行设计,非硬性要求:引用计数归零即释放 Session,这是当前生效的规则,但确切释放时机靠人工检查,本身不作为打回理由。被强制执行的是统一判据与「不按业务状态枚举」这两点。
- 后端状态:后端 session reference 尚未实现;实现后一律走同样的显式 reference 与生命周期管理。假定后端存在隐式持有的代码为时过早。
- 证据强度:该判据属次要证据,引用前须回读 retainer 契约。它确实确立的两点不得弱化——显式引用,以及不按业务状态枚举。
- 边界:强制的是统一判据与不做业务状态枚举这两点;释放触发本身靠人工核对,因此仅凭人工核对不构成打回理由。
- 归口:`retain`、`release`、`ready` 与 teardown 顺序归 [Session Controller 的 client references](../../packages/api/session-controller/README.zh.md#client-references)。消息里携带的 source session mention 是另一种类型,归 [session-reference.zh.md](../subsystems/session-reference.zh.md)。
经显式引用持有并在 teardown 释放的视图合规:
@@ -132,7 +130,7 @@ export function reclaimIdle(): void {
新增节点计算要说明其复杂度;高频流式 chunk 绝不立即发布、也不做全量计算。每个 chunk 全量重算并立即发布,等于把整个节点集合压进帧预算,转写区便会堵在流后面。追加速路径的开销——在 `D` 个已注册 Definition 下执行 `D` 次匹配与一次常量级 Context key 查找——以及发布节拍取值(`none`、`animation-frame`、`immediate`)归 [conversation.zh.md](../subsystems/conversation.zh.md#definition-and-typed-chat-payload)。
- 判定条件:单个 chunk 计算了多少——受影响子树的增量,而不是事件窗口——以及结果何时发布——合批策略,而不是每个 chunk 发布一次。
- 属开发要求,不作 review 打回依据:实现必须遵守本条;评审不单凭节拍选择打回改动。
- 边界:本条约束实现;节拍本身不构成打回理由,改动按它声明的节拍与所用的已发布路径评判。
- 高频路径上禁止:`immediate` 发布,以及每个 chunk 做一次全量重算。
- 边界:结构性或终态变化可以 `immediate` 发布,因为节拍是按单次发布选择的。只有高频 delta 路径受合批约束。
@@ -166,7 +164,7 @@ function onChunk(chunk: AssistantLiveChunk): void {
业务规则绝不进入分组引擎,业务诉求也绝不改变构建产物的形态。`ProcessState`、分组产物层级,以及 `conversation-nodes/process-groups.ts` 里的分组语义集合都属基础设施:引擎只认枚举化的落位语义(如成组 / 独立 / 回复),由调用方算出该用哪一个。引擎内的业务特判会改变所有使用方共享的产物结构,并把业务条件带上分组计算路径。
- 判定条件:引擎内部是否存在业务条件?新增 `ProcessState` 字段、增删分组产物层级、为某个业务改变分组语义,都属违规;落位判定归算出枚举值的调用方。
- 设计状态:当前构建与分组设计不能大改。架构分层、其开销与既有业务预期就是原因。
- 边界:架构分层、其开销与既有业务预期,就是业务不得重做分组设计的原因。
- 边界:携带新落位含义的新枚举值才是受支持的扩展,无论调用方在哪里算出它。
- 例外与批准人:无默认例外。改动该基础设施须经对应领域专家确认。分组 Definition 类型归 [conversation.zh.md](../subsystems/conversation.zh.md#group-definitions)。
@@ -244,18 +242,24 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ node, handoffFlag }: Ch
- 边界:通用 Markdown 能力——语法支持、性能优化——不属于为业务诉求修改。
- 例外与批准人:无默认例外;任何例外须经对应领域专家确认。
在既有链上注册的 contribution,与保留官方 fallback 的宿主:
注册进既有单席位、并复用已发布 Factory(而不是自己重渲标准内容)的 contribution:
```tsx
// The business registers a renderer; AssistantMarkdown itself stays generic.
ctx.slots.inject('conversation.chat.reasoning-body', () => ctx.slots.register(
{ name: 'conversation.chat.reasoning-body', locale: NS, select: owner => owner },
({ text, running }) => <TranslatedReasoning text={text} running={running} />,
// The business contributes its own body into the seat the host already renders.
ctx.slots.inject('conversation.chat.reasoning.body', () => ctx.slots.register(
{ name: 'conversation.chat.reasoning.body', scope: 'session', locale: NS },
TranslatedReasoningBody,
))
// The host invokes the chain at the position it owns and keeps the official fallback.
{renderReasoningBody('conversation.chat.reasoning-body', { text, running, setHeaderAction },
{ fallback: <MarkdownText text={text} variant="compact" /> })}
// The contribution keeps the standard rendering by calling the published Factory.
function TranslatedReasoningBody({ text, running, renderFactorySlot, t }: ReasoningBodyProps) {
return (
<>
<p>{t('reasoning.translated')}</p>
{renderFactorySlot('conversation.chat.reasoning.content', { text, running })}
</>
)
}
```
业务分支写进共享 Markdown 管线:
+4 -4
View File
@@ -21,8 +21,8 @@
en: 243e275fee74f54e
zh: a58e04b6e3ac0e7a
/defensive-patterns/one-value-states-one-fact:
en: a3b0f6585a381a1d
zh: 6fd57b2181e3009a
en: 598d9041ccfaba7c
zh: 2ac9f98c97015f66
/defensive-patterns/a-new-active-current-singleton-is-refused:
en: 8bf36acf96fa4c33
zh: bb6db31a63bcf383
@@ -33,8 +33,8 @@
en: 7588b8dba9279dac
zh: 2760468ede60220c
/defensive-patterns/changes-carry-performance-evidence:
en: 8ec0e26abfb13e8f
zh: ad5cfda4ffe2ec9a
en: 0d6855d1ae0ef4ae
zh: 0f771999398ce052
/defensive-patterns/migrations-preserve-behavior:
en: 6213e051f51437b8
zh: 853580b3a4fec100
+11 -7
View File
@@ -72,7 +72,7 @@ The wrong form keeps the contract vague and swallows the error, so the next call
An unknown value, an empty collection or string, and a genuinely absent field are three different facts and take three representations. One `undefined` or `null` never stands for several of them, and a consumer that must act differently on each discriminates explicitly instead of collapsing them at the boundary.
The judged condition is whether a single "no value" carries more than one semantic: not yet queried, queried and empty, and cannot be determined. A discriminated union or an explicit enum value is the ordinary implementation, documented where the type is defined in [core.md](../subsystems/core.md#repo-wide-type-patterns). One named exception: `catalog` membership reporting is a specific development requirement, not a general pattern to copy.
The judged condition is whether a single "no value" carries more than one semantic: not yet queried, queried and empty, and cannot be determined. A discriminated union or an explicit enum value is the ordinary implementation, documented where the type is defined in [core.md](../subsystems/core.md#repo-wide-type-patterns).
```ts
type ValueState<T> = { kind: 'unknown' } | { kind: 'empty' } | { kind: 'value'; value: T }
@@ -150,17 +150,21 @@ The wrong form makes every new capability edit the center layer, which becomes t
A change that can move the cost of a hot path measures the code it touched before and after and reports the numbers with their conditions. The three metrics are runtime, peak memory, and steady-state memory, measured in one environment on one input; the granularity is the changed path, not a whole-machine score. Reporting that "nothing got slower overall" is not evidence, and a path that improved elsewhere does not excuse a regression here — an optimised first paint still has to answer for `append`.
The baseline is the native implementation with no hook attached, not the previous implementation. Comparing old against new with the same hook attached on both sides dilutes the difference until it disappears: both sides pay the same overhead, so the measurement reports "no change" while both are slow. CI benchmark output is an alert, not a pass — a regression there is located by profiling segments locally to find which part slowed and which change introduced it.
Boundaries: edits that touch no runtime path — comments, documentation, test-only code — need no measurement, and production performance code is not benchmark instrumentation to be deleted opportunistically (`performance.now()` and `scheduler.yield()` are part of production decode and encode chunk scheduling). An architectural change that genuinely costs performance states the measured cost and the reason, and the domain owner approves it; "the layering needed it" is not a self-service reason. Record the command, the numbers, the environment, and the input size so the baseline can be rebuilt.
The judged condition: under one environment and one input, any of the three metrics is significantly worse on the changed path.
The baseline is the same path without the change under measurement — normally the native implementation that carries no measurement hook, not the previous implementation. Comparing old against new with the same hook attached on both sides dilutes the difference until it disappears: both sides pay the same overhead, so the measurement reports "no change" while both are slow.
Measurement is owned by [benchmarks/](../../benchmarks/AGENTS.md), one directory per measured user path, and its scenarios are the entry point:
```sh
# Before and after, same environment and input; the numbers go into the PR.
pnpm run bench --filter <pkg> -- --scenario <name>
# Run the owning scenario before and after the change; the numbers go into the PR.
pnpm run test:bench
```
Package-local diagnostics stay beside their owner with the `.perf.ts` suffix and do not join `test:bench`. The CI gate is `pnpm run check:ci:bench` (job `node 24 / benchmarks`), which publishes a scaled report — that gate is an alert, not a pass: a regression there is located by re-running the owning scenario and profiling segments locally to find which part slowed and which change introduced it. Retained-memory claims use `pnpm run test:bench:memory 4g|8g`, which reruns its listed files under an unchanged budget in a swapless cgroup.
Boundaries: edits that touch no runtime path — comments, documentation, test-only code — need no measurement, and production performance code is not benchmark instrumentation to be deleted opportunistically (`performance.now()` and `scheduler.yield()` are part of production decode and encode chunk scheduling). An architectural change that genuinely costs performance states the measured cost and the reason, and the domain owner approves it; "the layering needed it" is not a self-service reason. Record the command, the numbers, the environment, and the input size so the baseline can be rebuilt.
```text
CI benchmark reported green, so the change is accepted as performance-neutral.
```
+11 -7
View File
@@ -84,7 +84,7 @@ const value = a?.b?.c?.d ?? fallback // four layers of fallback hide whether d
未知值、空集合或空字符串、以及字段真的不存在,是三件不同的事实,用三种不同的表示。一个 `undefined` 或 `null` 绝不代表其中多个;需要在三者上分别处理的消费方显式判别,而不是在边界处把它们压平。
判定条件是:同一个「无值」是否承载了多种语义——**未查询**、**查到为空**、**无法判定**。通常的实现是可辨识联合或显式枚举值,并在类型定义处记录(见 [core.zh.md](../subsystems/core.zh.md#repo-wide-type-patterns))。一条具名例外:`catalog` membership 的上报属特定开发要求,不作为可照搬的通则。
判定条件是:同一个「无值」是否承载了多种语义——**未查询**、**查到为空**、**无法判定**。通常的实现是可辨识联合或显式枚举值,并在类型定义处记录(见 [core.zh.md](../subsystems/core.zh.md#repo-wide-type-patterns))。
```ts
type ValueState<T> = { kind: 'unknown' } | { kind: 'empty' } | { kind: 'value'; value: T }
@@ -170,17 +170,21 @@ if (pluginName === 'ui-attachment') { /* business branch inside the manager */ }
可能改变热点路径成本的改动,要测量它所触及的代码在改动前后的表现,并把数字与测量条件一并给出。三项指标是运行耗时、内存峰值与稳态内存,在同一环境、同一输入下测量;粒度是**被改动的那条路径**,不是整机跑分。只报「整体没变慢」不算证据,别处变快也不能抵消这里的倒退——优化了首屏,仍然要回答 `append` 是否倒退。
基线是**完全不挂 hook 的原生实现**,不是旧实现。新旧两侧挂着同一个 hook、共同开销把真实差值稀释掉,测量就会报告「没变化」,而实际上两边一起慢。CI benchmark 输出是报警而不是通过依据——CI 出现退化时,靠本地分段 profile 定位是哪一段变慢、哪次变更引入。
边界:不触碰运行路径的改动——注释、文档、仅测试代码——不需要测量;生产性能代码不是可以顺手删掉的 benchmark instrumentation(`performance.now()`、`scheduler.yield()` 属于生产 decode/encode 分片调度的一部分)。架构调整确实要付出性能代价时,写明实测代价与理由,并由对应领域专家确认;「分层需要」不是自助理由。命令、数字、环境与输入规模都要落账,以便返工时重建同一基线。
判定条件:同一环境、同一输入下,改动后那条路径的三项指标中任一项显著变差。
基线是被测路径**不带本次改动**的形态——通常是不挂测量 hook 的原生实现,而不是旧实现。新旧两侧挂着同一个 hook、共同开销把真实差值稀释掉,测量就会报告「没变化」,而实际上两边一起慢。
测量的正家是 [benchmarks/](../../benchmarks/AGENTS.md),按被测用户路径分目录,它的场景就是入口:
```sh
# Before and after, same environment and input; the numbers go into the PR.
pnpm run bench --filter <pkg> -- --scenario <name>
# Run the owning scenario before and after the change; the numbers go into the PR.
pnpm run test:bench
```
包内诊断留在包旁边、用 `.perf.ts` 后缀,不进 `test:bench`。CI 门禁是 `pnpm run check:ci:bench`(作业 `node 24 / benchmarks`),产出 scaled report——该门禁只是**报警**,不是通过依据:出现退化时重跑对应场景并本地分段 profile,定位是哪一段变慢、哪次变更引入。内存留存类结论用 `pnpm run test:bench:memory 4g|8g`,它会在无 swap 的 cgroup 里以不变预算重跑其列出的文件。
边界:不触碰运行路径的改动——注释、文档、仅测试代码——不需要测量;生产性能代码不是可以顺手删掉的 benchmark instrumentation(`performance.now()`、`scheduler.yield()` 属于生产 decode/encode 分片调度的一部分)。架构调整确实要付出性能代价时,写明实测代价与理由,并由对应领域专家确认;「分层需要」不是自助理由。命令、数字、环境与输入规模都要落账,以便返工时重建同一基线。
```text
CI benchmark reported green, so the change is accepted as performance-neutral.
```
@@ -5,21 +5,21 @@
/package-surface-rules:
en: 28d4c2d7da24269f
zh: d723b807815401d7
/package-surface-rules/a-plugin-package-exports-the-loader-four:
en: 34e91aa4d00d75b0
zh: e1fc49160591fda2
/package-surface-rules/a-plugin-package-exports-what-the-loader-loads:
en: 44a06f50e751f06b
zh: 339dea7be403fd0b
/package-surface-rules/two-package-shapes-and-both-are-dynamic-plugin-packages:
en: eb8289ca36b97f3b
zh: 3370e3bc05783dcd
en: bf79c6acf35c90a5
zh: 9f2ce6c7f6493b17
/package-surface-rules/package-json-exports-uses-the-standard-key-set:
en: eb2734c4b6df6772
zh: adb8d592e12d3030
en: 3604bf435be1c430
zh: 169621c8a0c2dc4c
/package-surface-rules/dependency-declarations-and-version-consistency-are-enforced-by-the-dependency-scan:
en: e68bfcde70f845b4
zh: 73e5c4fb067c4fa1
/package-surface-rules/internal-dependency-syntax-follows-the-package-family:
en: d0cbaf0a92d4b601
zh: 9e6c7239fd87e730
en: d6bb855545b7b05b
zh: 6c489ed5a2ed0ae5
/package-surface-rules/a-new-resolve-hook-is-refused-outright:
en: b420ae3e228432d5
zh: 0c49c4a9c3a5262c
+10 -10
View File
@@ -4,13 +4,13 @@ English | [中文](package-surface.zh.md)
Rules for what a package exports, how it is built, and how it declares dependencies. [development.md](../development.md#typescript-project-layout) describes the layout itself; [packages/AGENTS.md](../../packages/AGENTS.md) carries the standing orders.
## A plugin package exports the Loader four
## A plugin package exports what the Loader loads
A plugin package's entry point exports `name`, `inject`, `Config`, and `apply`, and no other value; `name`, `inject`, and `apply` are lower-case, while `Config` is upper-case and appears both as an `interface` and as its Schemastery schema value. Cross-package references carry types only, so a new import of a value from another package is not allowed. A same-package test reaches internals directly through a relative `../src/...` path or the `./src/*` subpath, so a test-only need never becomes a permanent export. The rule stops a test convenience from freezing into published API and stops value references from spreading across package boundaries, where every later refactor has to follow them.
A plugin package's entry point takes one of two forms, and they are not mixed. A **function plugin** named-exports `name`, `inject`, `Config`, and `apply`: `name`, `inject`, and `apply` are lower-case, while `Config` is upper-case and appears both as an `interface` and as its Schemastery schema value. A **service plugin** default-exports its service class, which is how the Loader loads it; a default export beside the four makes the Loader discard the function plugin's namespace. Beyond those, a Host entry may export the Service Definition classes and error types that other packages extend or throw — the capability seam consumes exactly those — and never a test-convenience value. The `/client` entry of a UI plugin package is stricter, and [packages/client/AGENTS.md](../../packages/client/AGENTS.md) owns that discipline: a same-package test reaches internals through a relative `../src/...` path or the `./src/*` subpath, so a test-only need never becomes a permanent export. The rule stops a test convenience from freezing into published API.
- The Loader four and its exact form are defined in [cordis-primer.md](../cordis-primer.md#loader-configuration).
- Judged condition: the plugin entry (`src/index.ts`) exports an entity beyond the four; a cross-package import names a value instead of a type; a fixture reaches another package by relative path.
- Boundary: only Loader plugin entry points are constrained. Internal modules, helper functions, and hooks keep exporting values, and a test fixture stays inside its own package — a relative path into another package's `src` bypasses the `exports` map and the package-boundary checks.
- Judged condition: a function plugin's entry (`src/index.ts`) exports an entity beyond the four; a default export is added beside the four, or the two forms are mixed; a `/client` entry exports an implementation value or lets another feature plugin import one; a fixture reaches another package by relative path.
- Boundary: only Loader plugin entry points are constrained. Internal modules, helper functions, and hooks keep exporting values; a Host entry's Service Definition classes and error types are expected exports; and a test fixture stays inside its own package — a relative path into another package's `src` bypasses the `exports` map and the package-boundary checks.
- Exception: none by default; an exception that is genuinely needed requires the domain owner's confirmation.
The compliant entry — the four exports, with the same-package test path recorded as a comment rather than as a fifth export.
@@ -35,8 +35,8 @@ Three ways the entry gains an export it must not have; all three are visible in
// The entry widened so a test could reach one helper
export { isHeaderLine } from './internal/header.ts'
// A value imported across packages
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
// Both plugin forms on one entry: the Loader discards the four
export default class WebApp {}
// A fixture reaching into another package by relative path
import { isSurfaceEligibleType } from '../../core/session/src/surface.ts'
@@ -44,10 +44,10 @@ import { isSurfaceEligibleType } from '../../core/session/src/surface.ts'
## Two package shapes, and both are dynamic plugin packages
A package is either a Host package or a Host + Client package, and both are dynamic plugin packages; a new package takes one of those two forms, with no third form and no exception. Only a genuine both-face package splits its compiler faces, build output goes to `lib`, and the `include`, `external`, and roster lists are derived by scanning the workspace rather than hand-written. A hand-written list or an invented compiler face makes build output and the manifest disagree, and every later package would have to maintain its own list.
A new package is either a Host package or a Host + Client package, and both are dynamic plugin packages; no third form is available and there is no exception. The static client libraries the shell seeds through `PLATFORM_MODULES` in [packages/client/web/src/platform.ts](../../packages/client/web/src/platform.ts) — `client-store`, `ui-slots`, `ui-primitives`, `ui-dockkit` — and the zero-dependency libraries under [packages/util/](../../packages/util/) predate this rule and are closed: they keep working as they are, and a new package never joins them. Only a genuine both-face package splits its compiler faces, build output goes to `lib`, and the `include`, `external`, and roster lists are derived by scanning the workspace rather than hand-written. A hand-written list or an invented compiler face makes build output and the manifest disagree, and every later package would have to maintain its own list.
- Judged condition: a new package built as anything but a dynamic plugin package (static-link bundling, a hand-written all-external list); a third `tsconfig` variant; a hand-written `include`, `external`, or roster list.
- Census over `packages/*/*`: 330 packages carry a `tsconfig.json`, 24 are both-face (`tsconfig.json` plus `tsconfig.client.json` and `tsconfig.host.json`), 75 declare `dsh.client`, 87 export `./client`, and 330 point `main` at `lib/`.
- Recompute the shapes instead of trusting a number here: list the `tsconfig.json` files under `packages/*/*`, then the packages that also carry `tsconfig.client.json` and `tsconfig.host.json`, then those declaring `dsh.client`.
- Boundary: a both-face package may appear in `include` or `exclude`.
- Exception: none; there is no approval path. A change to a package's shape or compiler faces starts with the owning documents and needs the domain owner's confirmation.
- Owners: [development.md](../development.md#typescript-project-layout) for the compiler faces, [packages/README.md](../../packages/README.md) for the package groups, and [adding-a-package.md](../cookbook/adding-a-package.md) for the steps.
@@ -76,7 +76,7 @@ A hand-written `external` list — and a `tsconfig.foo.json` invented beside it
A `package.json` `exports` map uses only the standard key set, in which every key already has one meaning across the repository, and a package never opens a key for its own local purpose. A key that only a handful of packages use without a documenting owner is not a precedent: formalizing a key, including one of the undocumented keys below, means changing the owning document and the check in the same change. The small shared key set is what lets a consumer infer a key's purpose from its name and lets a scan judge the export map at all.
- Standard keys, with the number of packages using each across `packages/*/*/package.json` and `apps/*/package.json`: `.` (330, the package entry), `./package.json` (332, toolchain access, not a semantic export), `./src/*` (315, source passthrough for tests and tools, whose documented status is still open), `./client` (87, the browser entry, paired with the `dsh.client` declaration), `./types` (64, the type entry), `./typert` (28, the typert type runtime), `./remote` (28, the `@Remote` call surface), `./locale/*.json` (26, locale resources), `./cordis.patch.yml` (19, the bundle patch layer declared by `dsh.bundle`).
- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them.
- Keys outside that set (`./brand` in 5 packages, `./worker` in 4, `./startup`, `./runner`, and `./protocol` in 2 each, plus many one-off keys) have no documented meaning; they are pending review, not a pattern to copy.
- Judged condition: does a change add an export key outside the standard set?
- Exception: none by default; a new key changes the owning document and the check first, with the domain owner's confirmation.
@@ -151,7 +151,7 @@ Internal dependency syntax follows the package family: `@deepseek-ai/dsh` and `@
- Judged by `workspaceRange()` in [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts): the `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` families get `workspace:*`, every other internal package gets `workspace:~`; the scan walks the managed manifests and compares every declaration of the same dependency name.
- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `^0.1.5` in `apps/desktop-host/package.json` (dependencies), `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies).
- Distribution (the command below prints the current numbers): `workspace:*` 4216, `workspace:~` 1034, `workspace:^` 0, plus the four `^0.1.5` declarations above.
- Distribution is not restated here; the recompute command below prints the current counts of `workspace:*`, `workspace:~`, `workspace:^`, and any exact-range declaration outside the family rule.
- Exception: the scan decides mechanically; a genuine exception means changing the scan and the policy file with the domain owner's confirmation and the reason on the change.
- Recompute:
+10 -10
View File
@@ -4,13 +4,13 @@
关于包导出什么、怎么构建、如何声明依赖的规则。布局本身见 [development.zh.md](../development.zh.md#typescript-project-layout);常设条款见 [packages/AGENTS.md](../../packages/AGENTS.md)。
## 插件包导出 Loader 四件套
## 插件包导出 Loader 所需的东西
插件包的入口导出 `name`、`inject`、`Config`、`apply`,没有第五个值;`name`、`inject`、`apply` 全小写,`Config` 首字母大写,且同时以 `interface` 与 Schemastery schema 值两种形态出现。跨包引用只携带类型,因此不允许从其他包新增实体值的 import。同包测试通过相对 `../src/...` 路径或 `./src/*` 子路径直接触达内部,测试专用的需要因此永远不会变成常驻导出。这条规则既阻止测试便利被固化成对外 API,也阻止实体值引用跨包扩散——后者会让此后每一次重构都必须跟着它们走。
插件包的入口取两种形态之一,两者不得混用。**函数插件**具名导出 `name`、`inject`、`Config`、`apply`:`name`、`inject`、`apply` 全小写,`Config` 首字母大写,且同时以 `interface` 与 Schemastery schema 值两种形态出现。**服务插件**默认导出它的服务类,Loader 就是这么加载它的;在四件套旁边再加一个默认导出,会让 Loader 丢弃函数插件的命名空间。除这两者之外,Host 入口可以导出供其他包 `extends` 或 `throw` 的 Service Definition 类与错误类型——能力接缝消费的正是这些——但绝不导出测试便利值。UI 插件包的 `/client` 入口更严,[packages/client/AGENTS.md](../../packages/client/AGENTS.md) 是那条纪律的正家:同包测试用相对 `../src/...` 路径或 `./src/*` 子路径触达内部,测试专用的需要因此永远不会变成常驻导出。
- Loader 四件套及其准确形态见 [cordis-primer.zh.md](../cordis-primer.zh.md#loader-configuration)。
- 判定条件:插件入口(`src/index.ts`)导出了四件套以外的实体;跨包 import 引用的是实体值而不是类型;fixture 用相对路径引用其他包。
- 边界:只约束 Loader 插件入口。内部模块、工具函数与 hooks 照常导出值;测试 fixture 也留在本包内——用相对路径伸进别的包的 `src` 会绕过 `exports` 映射与包边界检查。
- 判定条件:函数插件的入口(`src/index.ts`)导出了四件套以外的实体;在四件套旁边加了默认导出,或两种形态被混用;`/client` 入口导出了实现值、或让另一个功能插件导入了实现值;fixture 用相对路径引用其他包。
- 边界:只约束 Loader 插件入口。内部模块、工具函数与 hooks 照常导出值;Host 入口的 Service Definition 类与错误类型是应有的导出;测试 fixture 也留在本包内——用相对路径伸进别的包的 `src` 会绕过 `exports` 映射与包边界检查。
- 例外:无默认例外;确需破例须经对应领域专家确认。
合规的入口——四件套,同包测试路径以注释记录,而不是加出第五个导出。
@@ -35,8 +35,8 @@ export function apply(ctx: Context, config: Config): void { /* … */ }
// The entry widened so a test could reach one helper
export { isHeaderLine } from './internal/header.ts'
// A value imported across packages
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
// Both plugin forms on one entry: the Loader discards the four
export default class WebApp {}
// A fixture reaching into another package by relative path
import { isSurfaceEligibleType } from '../../core/session/src/surface.ts'
@@ -44,10 +44,10 @@ import { isSurfaceEligibleType } from '../../core/session/src/surface.ts'
## 包只有两类,且两类都是动态插件包
包要么是 Host 包,要么是 Host + Client 包,两类都是动态插件包;新增包只能取这两种形态之一,没有第三种形态,也没有例外。只有真正的双面包才拆编译面,生成物进 `lib`,`include`、`external` 与 roster 清单由扫描 workspace 得出,不手写。手写清单或新造编译面会让构建产物与清单不再一致,此后每个新增包都得各自维护清单。
新增包要么是 Host 包,要么是 Host + Client 包,两类都是动态插件包;没有第三种形态可选,也没有例外。shell 经 [packages/client/web/src/platform.ts](../../packages/client/web/src/platform.ts) 的 `PLATFORM_MODULES` 播种的静态客户端库——`client-store`、`ui-slots`、`ui-primitives`、`ui-dockkit`——以及 [packages/util/](../../packages/util/) 下的零依赖库先于本条规则存在,且**已关闭**:它们照原样继续工作,新增包永不加入其中。只有真正的双面包才拆编译面,生成物进 `lib`,`include`、`external` 与 roster 清单由扫描 workspace 得出,不手写。手写清单或新造编译面会让构建产物与清单不再一致,此后每个新增包都得各自维护清单。
- 判定条件:新增包未按动态插件包形态构建(static link 打包、手写的全 external 清单);出现第三种 `tsconfig` 变体;手写 `include`、`external` 或 roster 清单。
- 对 `packages/*/*` 的实查:330 个包带 `tsconfig.json`,24 个是双面包(`tsconfig.json` 加 `tsconfig.client.json` 与 `tsconfig.host.json`),75 个声明 `dsh.client`,87 个导出 `./client`,330 个把 `main` 指向 `lib/`。
- 形态不要照抄数字,就地复算:先列 `packages/*/*` 下的 `tsconfig.json`,再列同时带 `tsconfig.client.json` 与 `tsconfig.host.json` 的包,最后列声明 `dsh.client` 的包。
- 边界:双面包允许被 `include` 或 `exclude`。
- 例外:无;没有审批通道。包的形态或编译面变更须先改正家文档,并须经对应领域专家确认。
- 正家:[development.zh.md](../development.zh.md#typescript-project-layout) 管编译面,[packages/README.zh.md](../../packages/README.zh.md) 管包分组,[adding-a-package.zh.md](../cookbook/adding-a-package.zh.md) 管步骤。
@@ -76,7 +76,7 @@ import { isSurfaceEligibleType } from '../../core/session/src/surface.ts'
`package.json` 的 `exports` 只使用标准键集合,集合中每个键在全仓已经只有一个含义,包不得为自己的局部用途开键。只有少数几个包在用、又没有文档正家的键不构成先例:把某个键正式化(包括下面那些未记录在案的键)意味着在同一次改动里改正家文档与检查。键集合小且共享,消费者才能从键名推断用途,扫描也才有可能判定导出映射。
- 标准键及各键在 `packages/*/*/package.json` 与 `apps/*/package.json` 中的包数:`.`(330,包主入口)、`./package.json`(332,工具链读取清单,非语义 export)、`./src/*`(315,测试与工具用的源码直出,其文档地位仍待定)、`./client`(87,浏览器入口,与 `dsh.client` 声明配对)、`./types`(64,类型入口)、`./typert`(28,typert 类型运行时)、`./remote`(28,`@Remote` 调用面)、`./locale/*.json`(26,多语言资源)、`./cordis.patch.yml`(19,`dsh.bundle` 声明的组合包 patch 层)。
- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。
- 集合之外的键(`./brand` 5 个包、`./worker` 4 个、`./startup`、`./runner`、`./protocol` 各 2 个,另有大量一次性键)没有文档含义,属待核项,不是可照抄的先例。
- 判定条件:改动是否新增了标准集合以外的 export 键?
- 例外:无默认例外;新增键须先改正家文档与检查,并取得对应领域专家确认。
@@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page
- 判定在 [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts) 的 `workspaceRange()`:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 包族取 `workspace:*`,其余内部包取 `workspace:~`;扫描遍历受管清单,比对同一依赖名的每一处声明。
- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `apps/desktop-host/package.json`(dependencies)、`packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `^0.1.5`。
- 分布(下面的命令打印当前数字):`workspace:*` 4216 处、`workspace:~` 1034 处、`workspace:^` 0 处,另有上面四处 `^0.1.5` 声明。
- 分布不在此复述;下面的复算命令会打印 `workspace:*`、`workspace:~`、`workspace:^` 的当前数量,以及族规则之外的精确版本声明。
- 例外:门禁机械判定;确需例外意味着修改扫描与策略文件,并须经对应领域专家确认、在改动上说明理由。
- 复算:
@@ -6,11 +6,11 @@
en: 26fb6e92d16925b0
zh: 72d13c0481fc61e9
/remote-and-api-rules/a-new-remote-capability-gets-its-own-service:
en: 12b1bb62563bd0f8
en: 5759a7b0f96ea4bc
zh: 62465fbe91a4d2af
/remote-and-api-rules/remote-marks-capability-methods:
en: ad35b0223f7e3ea9
zh: e04d59e75730758a
en: e5613ec07023429d
zh: 9272c9ae5718060a
/remote-and-api-rules/packages-api-carries-generic-orchestration-only:
en: 12a964b9c04fccf9
zh: cf804f8044535727
+6 -6
View File
@@ -10,7 +10,7 @@ A new remote business capability is a new service — its own package or its own
- Judged by: which object holds the new remote method. A method for a new business line added to a service that already owns another line is the defect; the fix is a service named for the new capability, with `@Remote` on its concrete methods. A migration is judged the same way — the moved method appears on its new owner, as `SubagentRuntime` does in `packages/subagent/subagent`, not on the object it left.
- Not covered: a service whose methods all serve one business boundary stays whole, and a new private helper of that capability belongs on it. Data that needs its own protocol does not become a Remote method on the new service either; session event streams, pagination, incremental reduce, projection, and entity substreams keep their separate registration model ([gateway boundaries](../api-gateway.md#boundaries)).
- Exception: none recorded; a single caller is not a reason to merge. Where the business boundary itself is contested, the domain owner (对应领域专家) settles it as an architecture question before the change lands.
- Exception: none recorded; a single caller is not a reason to merge. Where the business boundary itself is contested, the domain owner settles it as an architecture question before the change lands.
- Fix: create the capability's own service object or package, put `@Remote` on its concrete methods, and let callers reach it through `ctx.remote.<namespace>`.
The subagent capability owns its own service, and every remote method is a concrete method of that service:
@@ -48,10 +48,10 @@ One object then answers for both domains, so a change to either capability's act
- **Capability methods.** The annotation goes on the concrete method that performs the operation (`list`, `prompt`, `interruptByParent`), never on a wrapper that forwards to the real implementation. Judged by the decorated method's body: a body that only delegates means the annotation is on the wrong method.
- **Types from inference.** Client-facing types are aliased from what the generator produces — `type SessionRemote = TypertClientRemote['session']` — instead of being written a second time. Judged by a hand-written declaration whose fields repeat a generated Client type; [typert.md](../subsystems/typert.md) owns the generated maps and their declaration merges.
- **JSON only, no output-side decode.** A Remote parameter and result are JSON, with binary content expressed field-level. `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value on a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method.
- **JSON on the wire, no output-side decode.** The **wire payload** of a Remote method is JSON, with binary content expressed field-level. The Host signature keeps what the Gateway already supports — lookup arguments resolved to Host objects, a trailing `AbortSignal`, a stream declared with `@Remote({ mode: 'stream' })` — and `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value in the payload of a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method.
- **Existing names are additive.** A migration does not rename a remote method or field that a Client already calls; a new semantic gets a new entry, and a rename is its own change with its own review. Judged by a diff that renames or removes a name already exposed through `ctx.remote.<namespace>`.
- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the four disciplines govern what becomes a Remote method, not what a service may implement.
- **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation (对应领域专家); the author never self-approves.
- **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation; the author never self-approves.
The generated map is the only source for Client-facing types, the decorator sits on the concrete method, and the payload stays JSON:
@@ -62,9 +62,9 @@ import { Remote, TypertRemoteService, type TypertClientRemote } from '@deepseek-
type SessionRemote = TypertClientRemote['session']
class SessionController extends TypertRemoteService {
@Remote('rename')
rename(id: string, title: string): SessionRenameValue {
return this.applyRename(id, title)
@Remote('search')
search(query: string): SessionSearchValue {
return this.runSearch(query)
}
}
```
+4 -4
View File
@@ -48,7 +48,7 @@ class ApiProxy {
- **标能力方法。** 标注落在执行该操作的具体方法上(`list`、`prompt`、`interruptByParent`),绝不落在转发给真正实现的壳方法上。判定看被标注方法的函数体:函数体只做转发,标注就标错了方法。
- **类型靠推断。** 面向 Client 的类型从生成结果取别名——`type SessionRemote = TypertClientRemote['session']`——而不是重写一份。判定看是否出现字段与生成的 Client 类型重复的手写声明;生成映射及其声明合并的正家是 [typert.zh.md](../subsystems/typert.zh.md)。
- **只 JSON、不做输出侧 decode。** Remote 的参数与结果都是 JSON,二进制在字段级表达。`@Remote` 不做输出侧 decode;结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。
- **线上载荷只承载 JSON、不做输出侧 decode。** Remote 方法的**线上载荷**是 JSON,二进制在字段级表达。Host 签名保留 Gateway 已支持的形式——经解析变成 Host 对象的 lookup 参数、末尾的 `AbortSignal`、以 `@Remote({ mode: 'stream' })` 声明的流式返回;`@Remote` 不做输出侧 decode,结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。
- **存量名只增不改。** 迁移不改 Client 已在调用的远程方法名或字段名;新语义新增条目,改名是独立的一刀、单独评审。判定看 diff 是否改名或删除了已经经 `ctx.remote.<namespace>` 暴露的名字。
- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这四项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。
- **例外。** 确需改名或新增 decode 路径,须先说明收益与对既有调用方的影响,并取得对应领域专家的确认;作者不得自行批准。
@@ -62,9 +62,9 @@ import { Remote, TypertRemoteService, type TypertClientRemote } from '@deepseek-
type SessionRemote = TypertClientRemote['session']
class SessionController extends TypertRemoteService {
@Remote('rename')
rename(id: string, title: string): SessionRenameValue {
return this.applyRename(id, title)
@Remote('search')
search(query: string): SessionSearchValue {
return this.runSearch(query)
}
}
```
+1 -1
View File
@@ -22,7 +22,7 @@
zh: 29f166be6a5c04d9
/development-guide/contributor-reference/typescript-project-layout:
en: e3327c5492a593cb
zh: 9de38778c76a5f0d
zh: d43a5edcb11523ae
/development-guide/contributor-reference/environment-variables:
en: dba2c72ee6ffa930
zh: b65c27fbb9129ec8
-1
View File
@@ -109,7 +109,6 @@ pnpm run build
`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。
<a id="refactoring-and-performance-evidence"></a>
### 环境变量