# RFC: 基于 TypeScript Program 的语义门禁

Status: implemented

[English](2026-07-14-typescript-program-backed-semantic-gates.md) | 中文

## 问题

仓库门禁有时需要判断 TypeScript 语法本身不携带的事实：接收者是否为 Cordis `Context`、哪些具体事件名会进入转发辅助函数、声明合并是否改变了事件签名。

当前的门禁基于 TypeScript 单文件语法解析能力，使用命名约定、手写的表格、JSDoc 等方式来维护这类信息。

仓库需要一个语义真源，同时不能引入运行时包（package）之间的循环依赖、宽泛的兜底启发式逻辑，或重复描述 TypeScript 已有信息的机器可读标注。

## 决策

仓库可以通过项目级类型信息 `ts.Program` 进行跨文件项目类型联合计算，并通过 `TypeChecker` 来提取 **强类型** 信息，用以缓解原有命名约定、手写表格、JSDoc 标注等形式。

当前已完成 A / B 两个门禁的语义化改造。

### 一个项目模型展开根项目配置

[`TypeScriptProject`](../../../../scripts/ts-project.ts) 解析根 `tsconfig.json`，递归展开每个项目引用，并将各引用项目的源码根合并为一个不输出文件的语义 Program。直接从根项目配置创建普通 Program 时，TypeScript 可能将引用项目重定向到构建后的声明文件；显式展开可以让门禁继续遍历各包的 `src` 文件，并使用真实符号标识。

该封装统一负责配置诊断、语义编译选项、仓库相对路径、源码查找和共享 TypeChecker。各门禁不再自行按文件通配模式扫描包源码，也不再分别构建不完整的 Program。

### A. 事件关系由接收者类型和值类型决定

[`gen-doc-graphs`](../../../../scripts/gen-doc-graphs.ts) 根据调用接收者与仓库中真实 `Context`、`AgentEventDispatch` 和 Cordis `EventsService` 类型之间的可赋值关系进行分类。变量名和属性拼写不再决定某次调用是否属于事件操作。

Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径，生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方：事件仍归属于传入封闭事件值的调用点。

每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时，生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败；没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅，因此关系矩阵只记录直接的产品监听方，不再手工补充间接关系。

### B. 带作用域的事件路由生成一份强类型解析函数表

[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) 扫描真实的 `scopeTarget(base, key)` 调用，为每种 scoped 基础对象确定路由键类型。随后，它查找带有 `this: Scoped<Base>` 的 Cordis `Events` 成员，并在每个事件参数及其一层公开属性中搜索类型；移除 `null` 和 `undefined` 后，候选类型必须与路由键类型完全相同。

恰好一个匹配项会生成解析函数。存在多个匹配项时，含义不明确，生成器会失败。没有匹配项时，事件必须标记 `@dshScopeScan unsupported`；该标记只用于路由键有意留在事件参数之外的情况，例如按所属 agent（智能体）路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持，不编码事件名、参数下标、属性路径或替代类型。

仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方，使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters<Events[K]>`，完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此，常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。

不变式插件消费这份生成的运行时表，不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在，不进入对等依赖，因此编译期聚合不会扩大插件的运行时依赖闭包。

### 语义缺口必须显式失败

遇到声明缺失、配置诊断、事件名被拓宽或保持泛型、路由键类型不一致、事件参数匹配不唯一、不必要的 unsupported 标记，或生成产物陈旧时，生成器都会拒绝继续。通过本地辅助函数调用点恢复信息的能力被刻意限制在窄范围内：如果数据流经过导出或无法解析的边界，应新增通用语义规则，而不是添加特定包的覆盖项。

## 验证

`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查，`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译；workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。

## 考虑过的替代方案

- **保留语法扫描、接收者白名单和手写覆盖项。** 每个例外都容易单独处理，但重命名和新增辅助函数形态时还必须更新第二份表示。完整性检查能够发现生产方缺失，却无法证明覆盖项仍与源码一致。

## 后果

- 事件关系生成依据语义接收者身份和封闭事件值，不再依赖局部命名约定；
- 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约，不再来自手写表；
- 修改事件名、参数位置、主体属性或路由键类型时，会在其所属契约处触发生成或编译失败；
- 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存，语义门禁也依赖有效的根项目图；
- 生成的 TypeScript 仍属于提交到仓库的源码：事件声明方或 dispatch 形态发生变化后，必须重新生成该文件和受影响的文档。
