docs(spec): clean up project spec hierarchy and fix external tool concept leakage

This commit is contained in:
YBF
2026-09-15 10:24:01 +08:00
parent e9874ba69e
commit 0a133b8151
2 changed files with 5 additions and 272 deletions
@@ -38,7 +38,7 @@ Source → Transform → Store → Retrieve → Transform → Display
1. **隐式格式假设**:不检查就假设日期或字段格式;在边界处显式转换。
2. **分散校验**:同一件事在多层各校验一遍,规则逐渐漂移;在入口校验一次。
3. **泄漏的抽象**:组件知道数据库 schema;每层只认识相邻层。
4. **每个消费者自己解析 payload**命令代码对 JSONL 事件逐字段 inline cast
4. **每个消费者自己解析 payload**消费代码对原始事件/消息逐字段 inline cast
```typescript
const thread = (ev as { thread?: string }).thread;
@@ -82,7 +82,7 @@ Source → Transform → Store → Retrieve → Transform → Display
append-only 日志是跨层契约。一条事件经过:
```
CLI input → event writer → events.jsonl → reader → filter → reducer → display
页面观测 / 外部输入 → event writer → durable store (IndexedDB / PostgreSQL) → reader / router → filter → reducer / projection → display
```
新增 event kind 或字段之后:
+3 -270
View File
@@ -47,7 +47,7 @@ package/context/
### 3.2–3.5 共享层级、原语与领域语义
先判断参数、返回值和行为是否含领域语义,再找真实/已确定消费者,选择覆盖它们的最近共同所有者,最后检查反向或循环依赖。语言级基础原语放包内共享基础目录;上下文共享领域概念放上下文根 `model.ts`;单功能契约放功能 `model.ts`;纯无副作用辅助才放最近公共 `utils.ts`。不得因可能复用就提升到包级或跨包引用内部实现。
先判断参数、返回值和行为是否含领域语义,再找真实/已确定消费者,选择覆盖它们的最近共同所有者,最后检查反向或循环依赖。语言级基础原语放包内共享基础目录;上下文共享领域概念放上下文根 `model.ts`;单功能契约放功能 `model.ts`;纯无副作用辅助才放最近公共 `utils.ts`。不得因可能复用就提升到包级或跨包引用内部实现。
| 概念范围 | 归属 | 示例 |
| --- | --- | --- |
@@ -110,273 +110,6 @@ if (!isSameContext(host, contextKey)) return fail(featureErrorCodes.contextChang
满足任一条件应拆文件/建立功能目录:不同需求独立修改的分支;入口开始读取分支内部字段;两个分支共享领域契约/转换;重要概念藏在 `utils.ts`/入口;功能文件挤占父目录使调用链和归属不可见。不要仅因函数长、文件少或预期未来扩展而拆分。
### 3.9 有状态同步协调器的职责拆分
#### 1. Scope / Trigger
当一个工厂函数或 class 同时拥有多个异步流程、多个可变状态集合和明确生命周期时,按职责拆成组件。典型触发信号包括:同一实现同时处理连接状态、门禁、事件订阅、队列、持久化状态机、重试和 ACK;一个事件需要更新三类以上互相独立的状态;或入口文件已经无法从字段名直接看出状态的唯一 owner。
这不是“看到文件很长就拆文件”,也不是把同一批函数机械搬到多个文件。拆分依据是变化原因和状态所有权:会因连接生命周期变化的状态放生命周期组件,会因候选投递变化的状态放 ACK 组件,始终一起变化的状态保留在同一组件。
#### 2. Signatures
公开接口保持稳定,由 facade 负责组合内部组件:
```ts
type SyncEngine = {
ingestObservedBatch(input: ObservationInput): Promise<ObservationResult>;
startSync(input: StartInput): Promise<StartResult>;
resume(conversationId?: string): Promise<StartResult[]>;
getStatus(): SyncStatus;
dispose(): void;
};
class Lifecycle {
isBusinessGateOpen(): boolean;
currentEpoch(): string;
getStatus(): SyncStatus;
dispose(): void;
}
class ConversationQueue {
enqueue<T>(key: ConversationKey, operation: () => Promise<T>): Promise<T>;
}
class ObservationPipeline {
ingest(input: ObservationInput): Promise<ObservationResult>;
}
class CheckpointCoordinator {
applyPageProgress(progress: PageProgress): Promise<CheckpointUpdate>;
}
class BootstrapCoordinator {
start(input: StartInput): Promise<StartResult>;
resume(conversationId?: string): Promise<StartResult[]>;
}
class AckCompletionCoordinator {
handleAcknowledgement(frame: AckFrame): Promise<void>;
tryComplete(key: ConversationKey): Promise<void>;
}
const createSyncEngine = (options: SyncEngineOptions): SyncEngine =>
new SyncEngineImpl(options);
```
#### 3. Contracts
组件边界按以下规则落地:
```text
SyncEngine facade
├── Lifecycle 生命周期、gate、epoch、状态、外部订阅
├── ConversationQueue 按会话串行和 active key
├── ObservationPipeline 页面观测归一化、先落库、进度串行化
├── CheckpointCoordinator checkpoint 读写和状态转换
├── BootstrapCoordinator 页面扫描、bootstrap、start/resume
└── AckCompletionCoordinator 候选投递、ACK、completion、重试
```
- `SyncEngine` 只组合依赖、分发外部事件并代理公开 API;不得直接实现 payload 解析、队列细节或 checkpoint 转换。
- `Lifecycle` 是连接状态、`disposed`、epoch、业务 gate、status/diagnostic/error sink 和 Bright/page subscription 的 owner。权威的当前 anchor snapshot 也应由生命周期或明确的 snapshot owner 保存,不能由多个协调器各自复制。
- `ConversationQueue` 只保证同一 `channelAccountId + conversationId` 的串行执行并投影活动数量;不得读取协议字段、调用 storage 或决定重试策略。
- `ObservationPipeline` 负责 page observation 到领域 observation 的转换、按会话串行 durable write,以及把“已持久化”结果通知协调器;不得拥有 ACK request map 或页面扫描流程。
- `CheckpointCoordinator` 是 checkpoint 状态转换的唯一 owner;其它组件只能通过具名方法读取或申请转换,不得自行拼接 `phase``anchorState``syncResult` 的第二套规则。
- `BootstrapCoordinator` 负责页面 command、历史/增量扫描、bootstrap 和恢复;候选上传、ACK 和 completion 必须委托给 `AckCompletionCoordinator`
- `AckCompletionCoordinator` 负责 request-to-candidate 关联、discovery、逐条发送、ACK 状态落库、completion 声明和 completion 重试;不得自行解释页面分页协议。
- 组件之间只依赖更低层的窄接口或回调,不反向 import facade,不形成循环依赖。需要跨组件通知时传递 `onAnchorFound``onConversationReady` 这类具名回调,而不是暴露整个引擎实例。
- 重构只改变职责归属时,facade 的公开类型、错误码、事件时序和持久化字段必须保持不变;协议行为变化必须另立契约和测试。
#### 4. Validation & Error Matrix
| 发现 | 处理 |
| --- | --- |
| facade 直接读取页面 payload 或解析 ACK 字段 | 移到对应 pipeline/ACK 组件,facade 只分发 |
| 两个组件都保存同一份 anchor、mode 或 disposed 状态 | 指定唯一 owner,通过 getter/回调访问 |
| queue 组件开始调用 Bright、storage 或决定业务错误 | 删除业务依赖,保留纯串行能力 |
| Bootstrap 直接更新 candidate 状态或发送 completion | 委托 `AckCompletionCoordinator` |
| Observation 直接推进 checkpoint phase | 委托 `CheckpointCoordinator`,只传递观察结果 |
| 组件 import facade 或出现组件循环依赖 | 提取窄接口、回调或无副作用领域函数 |
| 为了“变成 class”只移动函数、公开全部字段或保留一个 1000+ 行 class | 停止机械转换,按状态 owner 重新切分 |
| 公开工厂/接口被内部组件直接替代 | 保留 facade 和稳定的 factory,避免调用方与实现结构耦合 |
#### 5. Good / Base / Bad Cases
- Good`sync-engine.ts` 只有组件构造、Bright frame/status 分发和公开方法代理;六个组件分别拥有自己的 Map/Set、异步流程和错误边界。
- Goodanchor snapshot 只由 lifecycle 保存,bootstrap 通过 `getAnchors()` 读取;ACK 组件通过 checkpoint coordinator 更新候选和完成状态。
- Base:流程很小、只有一个异步状态且没有跨事件生命周期时,闭包工厂可以继续保留,不为形式引入 class 或目录。
- Bad:一个 `createSyncEngine` 同时声明 `queues``requestCandidates``anchors``bootstrapPromise`,并在同一函数中处理 page、Bright、IndexedDB、ACK 和重试。
- Bad:把所有函数拆到 `utils.ts`,但状态仍由 facade 隐式共享;文件数量增加了,职责和 owner 没有变清晰。
#### 6. Tests Required
- facade 测试:公开方法、错误码、状态投影和 dispose 行为与重构前一致。
- queue 测试:同一复合会话键串行,不同会话可并行,active count 在入队/完成时符合契约。
- observation/checkpoint 测试:先 durable write 后继续流程;每种 page progress 只由 checkpoint coordinator 产生合法状态转换。
- bootstrap 测试:full/incremental start、bootstrap single-flight、失败重试和 resume 委托到正确组件。
- ACK/completion 测试:requestId 关联、逐条 ACK、`completionSent`、匹配 anchor snapshot 后完成,以及断线重试。
- 组合测试:anchor snapshot、页面 ready、Bright authenticated 的事件顺序不会导致重复 bootstrap、提前上传或错误推进锚点。
- 结构检查:组件不 import facade,不出现循环依赖;共享状态只有一个 owner,公开 factory 使用稳定的 `SyncEngine` 接口。
#### 7. Wrong vs Correct
```ts
// Wrong:facade 同时拥有所有状态和业务流程。
const createSyncEngine = (options: Options): SyncEngine => {
const queues = new Map<string, Promise<unknown>>();
const requestCandidates = new Map<string, string>();
const anchors = new Map<string, string | null>();
const onPageObservation = async (message: PageMessage) => {
// 解析页面消息、写 checkpoint、发送 Bright、处理 ACK……
};
return { onPageObservation, /* 其它几十个方法 */ };
};
```
```ts
// Correctfacade 只组合和分发,状态由职责组件拥有。
class SyncEngineImpl implements SyncEngine {
private readonly lifecycle: Lifecycle;
private readonly observations: ObservationPipeline;
private readonly bootstrap: BootstrapCoordinator;
private readonly ack: AckCompletionCoordinator;
public constructor(
lifecycle: Lifecycle,
observations: ObservationPipeline,
bootstrap: BootstrapCoordinator,
ack: AckCompletionCoordinator,
) {
this.lifecycle = lifecycle;
this.observations = observations;
this.bootstrap = bootstrap;
this.ack = ack;
}
public ingestObservedBatch(input: ObservationInput): Promise<ObservationResult> {
return this.observations.ingest(input);
}
public startSync(input: StartInput): Promise<StartResult> {
return this.bootstrap.start(input);
}
}
```
### 3.10 边界内核、静态路由与业务 Flow
#### 1. Scope / Trigger
当一条输入链同时包含原始传输数据、协议解码、会话/端点门禁、多个业务分支和跨模块状态时,使用本节确定边界和目录。典型场景包括 WebSocket、Port、消息队列、RPC 或浏览器事件;适用于新增 frame family、拆分大 handler、引入 route table、移动 transport 或替换业务发送接口。
这类重构必须按变化原因拆分,而不是把同一套逻辑机械复制到多个文件。目标是让传输边界、路由所有权、业务 Flow 和共享状态各自只有一个事实源。
#### 2. Signatures
```ts
type ProtocolFrame = {
type: string;
requestId: string;
scope: Scope;
payload: unknown;
};
type AuthenticatedFrame = Exclude<ProtocolFrame, { type: "handshake" }>;
type RouteDefinition<
TContext,
TFrame extends AuthenticatedFrame = AuthenticatedFrame,
> = {
type: TFrame["type"];
owner: "endpoint_a" | "endpoint_b" | readonly ("endpoint_a" | "endpoint_b")[];
authorization: "session" | "operation";
handle: (context: TContext, frame: TFrame) => Promise<void>;
};
type FrameRouter<TContext> = {
dispatch: (context: TContext, frame: AuthenticatedFrame) => Promise<void>;
};
type EndpointHandler = {
handleAuthenticated: (frame: AuthenticatedFrame) => Promise<void>;
dispose: () => void;
};
const createProtocolKernel = (options: ProtocolKernelOptions): ProtocolKernel => {};
const createAuthenticatedRouter = <TContext>(
routes: readonly RouteDefinition<TContext>[],
): FrameRouter<TContext> => {};
```
#### 3. Contracts
- 共享 contract/schema 是 frame union、payload、方向和 decoder 的唯一所有者;transport 不复制 schema 或业务字段校验。
- `ProtocolKernel` 只负责原始数据大小限制、解析/解码、连接/版本准入、未认证门禁、单连接 FIFO 和通用生命周期;不得选择业务 Flow 或保存业务状态。
- 静态 route table 必须从已认证 frame 集合推导或显式覆盖该集合:握手帧不进入普通路由;每个非共享业务帧恰有一个 endpoint owner;明确允许共享的控制帧才可有多个 owner。
- route metadata 一旦被定义为 canonical,生产 dispatch 必须通过同一 router 调用 route handler。不能同时保留一套未被生产调用的 router 和另一套手写 `if`/`switch` 分支;如果只需要启动期完整性检查,应使用名称明确的 validator,不要把它包装成运行时 router。
- Endpoint handler 只拥有本端的握手、授权适配和业务 Flow 组合;跨端 registry、连接代际、pending 状态、发布器或资源生命周期由最近共同 owner 管理。
- 业务 Flow 接收已收窄的 frame 和窄接口;不得再次解析 raw transport data、绕过 guarded send、创建第二份 route table 或复制共享状态。
- composition root 只负责注入依赖、注册唯一入口和对称 dispose;模块初始化不得通过副作用自动注册隐藏路由。
```text
raw input
-> ProtocolKernel
decode / admission / FIFO / session gate
-> EndpointHandler
endpoint ownership / authorization / composition
-> FrameRouter
one canonical route owner
-> BusinessFlow
domain order / persistence / acknowledgement / publish
```
#### 4. Validation & Error Matrix
| 发现 | 处理 |
| --- | --- |
| route table 缺失、重复、未知或包含握手帧 | 在构造或注册阶段失败;不得启动不完整的路由 |
| route table 存在但生产入口绕过它 | 视为结构性缺陷;接入同一 router,或删除未使用的运行时抽象 |
| endpoint 收到非本端 frame | 返回稳定的 scope/ownership 错误,不调用业务 collaborator |
| raw frame 解码失败或超限 | 在进入业务层前按协议错误结束;不执行写入、发送、ACK 或发布 |
| Flow 解析 raw data 或重新定义 payload 校验 | 移到 protocol/contract 边界,Flow 只消费已收窄输入 |
| 两个模块各自保存连接、代际、pending、timer 或 terminal 状态 | 指定一个 state owner,其余模块只使用窄读写接口 |
| composition root 替换配置或关闭 | 先使旧代际失效,再取消 subscription/timer,最后释放连接和 Flow |
#### 5. Good / Base / Bad Cases
- Good`ProtocolKernel` 完成解码和 FIFO`EndpointHandler` 注入 `FrameRouter`,业务 Flow 只处理自己的已收窄 frame;生产测试从真实 composition root 触发 `router.dispatch`
- Good:新增 frame type 时,contract list、route metadata、endpoint owner、业务 handler 和 route integration test 在同一变更中更新。
- Base:只有一个小型同步分支、没有跨端状态且没有独立生命周期时,可以保留局部 `switch`;一旦引入 route table,就必须让它成为实际 dispatch 的唯一映射。
- Bad:创建了完整 router 但生产代码继续使用另一套 `if` 分支,导致 route metadata、授权策略和实际 handler 逐渐漂移。
- Badtransport 同时构造业务 frame、执行页面命令、维护 pending map;或把所有 frame 广播给多个 Flow 以“避免漏处理”。
#### 6. Tests Required
- contract-derived route test:覆盖完整 frame 集合、握手排除、missing、duplicate、unknown、共享控制帧和单 owner 约束。
- production composition test:通过真实入口验证 frame 确实经由 canonical router 到达唯一 Flow;不能只实例化一个未被生产使用的 router 做孤立单测。
- endpoint boundary test:错 endpoint、错 scope、错 connection type 和未授权 frame 均不调用业务 collaborator,并返回稳定错误。
- ordering test:解码、FIFO、授权、业务 Flow 和最终动作顺序保持不变;结构重构前后使用同一组行为特征测试。
- ownership/import audit:确认 transport、router、Flow 和 shared state owner 的依赖方向,无反向依赖、循环依赖或第二份状态。
- lifecycle test:配置替换、socket close、dispose 和迟到结果不会让旧 Flow、timer 或 subscription 继续产生副作用。
#### 7. Wrong vs Correct
```ts
// Wrong:声明了 router,但生产路径另有一套路由事实源。
const router = createAuthenticatedRouter(routes);
if (frame.type === "data_a") return flowA.handle(frame);
if (frame.type === "data_b") return flowB.handle(frame);
```
```ts
// Correct:生产入口使用唯一的 canonical dispatch。
const router = createAuthenticatedRouter(routes);
return router.dispatch(context, frame);
```
## 4. Validation & Error Matrix
| 发现 | 处理 |
@@ -402,9 +135,9 @@ return router.dispatch(context, frame);
## 6. Tests Required
- 基础原语如果只代理语言内行为,只需由使用方错误路径覆盖;不要为一行代理复制无价值测试。
- 基础原语如果只代理语言内行为,只需由使用方错误路径覆盖;不要为一行代理复制无价值测试。
- 领域守卫覆盖接受值、边界值和拒绝值,并断言类型收窄所依赖的运行时条件。
- 外部上下文适配覆盖有效、格式异常和缺失;一致性覆盖未变化/变化,并断言功能错误映射不泄到共享能力。
- 外部上下文适配覆盖有效、格式异常和缺失;一致性覆盖未变化/变化,并断言功能错误映射不泄到共享能力。
- 入口、分发和分支重构保留原有行为测试,证明只改变职责归属和输出未变。
- 移动共享概念后检查导入方向、跨包内部引用、反向依赖和循环依赖。
- 修改补偿逻辑前后搜索同类 `||``??`、三元和 mode env 读取,相关边界规则见 [missing-values.md](./missing-values.md)。