# Agent Note: 基于 TypeScript Program 的语义门禁

Status: implemented

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

## 问题

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

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

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

## 决策

仓库门禁可以通过 `ts.Program` 汇集项目级类型信息，并使用 `TypeChecker` 提取**强类型**事实，从而减少对命名约定、手写表格和 JSDoc 元数据的依赖。

仓库将这一模型应用于以下两个门禁。

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

[`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()` 的路径，生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方：事件仍归属于传入封闭事件值的调用点。

语义查询只在存在消费分支的位置运行：调用先经过封闭的事件 API 方法名集合预过滤，再做接收者分类；辅助函数调用点索引按需构建，而不是预先对全部包源码的每个调用求解签名。需求式索引对每个辅助函数逐一证明局部性——未导出、位于真正的 ES 模块文件中、且同文件所有引用都是直接调用位的辅助函数，按模块作用域规则其全部调用必在本文件内，此时只索引该文件。任一前提无法证明（带导出修饰符、位于全局 script 文件、存在别名化或无法归类的引用）即回退到原全部包源码索引，回退路径就是原语义本身：证明只影响开销，不影响结果。惰性单一全局索引方案被否决，因为当前源码树确实会走到辅助函数参数路径，该方案仍需支付几乎全额的 `getResolvedSignature` 扫描成本。

每个已声明的 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/core/scope/src/scoped-events.generated.ts) 是位于 scoped dispatch 所属包中的纯运行时映射，不导入任何事件声明方包。语义完整性由生成器自身保证：根 Program 枚举所有 scoped `Events` 声明与真实 `scopeTarget` 约定，通过 checker 解析唯一的 payload 路径，并在渲染 `unknown[]` 运行时边界前拒绝缺失、陈旧或含义不明确的条目。

`dsh-scope/invariant` companion 消费这份映射，不再维护手写事件表。Program 分析发生在仓库门禁内，而不是依赖生成的类型导入，因此 `dsh-scope` 和 `dsh-invariants` 都不需要依赖所有事件声明方。

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

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

## 验证

`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查；`verify-scoped-events` 会重新运行 Program 分析，并检查生成映射的新鲜度。根 TypeScript 构建会编译该运行时适配器；workspace 约束与运行时依赖闭包检查确保事件声明方聚合不会进入部署依赖。

## 考虑过的替代方案

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

## 后果

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