DSH / Atlas
2026-07-14implementedprocess

TypeScript Program-backed semantic gates

基于 TypeScript Program 的语义门禁

Repository gates sometimes need facts that TypeScript syntax does not carry by itself: whether a receiver is a Cordis `Context`, which concrete event names reach a forwarding helper, and whether declaration merging changed an event signature. The existing gates use TypeScript's single-file syntax model and maintain these facts through naming conventions, handwritten tables, and JSDoc. The repository needs one semanti

English

Problem

Repository gates sometimes need facts that TypeScript syntax does not carry by itself: whether a receiver is a Cordis Context, which concrete event names reach a forwarding helper, and whether declaration merging changed an event signature.

The existing gates use TypeScript's single-file syntax model and maintain these facts through naming conventions, handwritten tables, and JSDoc.

The repository needs one semantic source of truth without introducing runtime package cycles, broad fallback heuristics, or machine-readable annotations that restate information already available to TypeScript.

Decision

Repository gates can combine project-wide type information through ts.Program and use TypeChecker to extract strongly typed facts, reducing their reliance on naming conventions, handwritten tables, and JSDoc metadata.

The repository applies this model to two gates.

One project model expands the root solution

TypeScriptProject parses the root tsconfig.json, recursively expands every project reference, and combines the referenced source roots into one no-emit semantic program. A normal program created from the solution config can redirect referenced projects to built declarations; explicit expansion keeps the package src files available for AST traversal and symbol identity.

The wrapper owns config diagnostics, semantic compiler options, repository-relative paths, source lookup, and the shared checker. Individual gates do not glob package sources or construct partial programs independently.

A. Event relations follow receiver and value types

gen-doc-graphs classifies calls by assignability to the repository's actual Context, AgentEventDispatch, and Cordis EventsService types. Variable names and property spellings do not determine whether a call is an event operation.

Context and agent-dispatch calls contribute only finite string-literal event sets. Direct EventsService.dispatch() calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value.

Semantic queries run only where a branch can consume them: calls are prefiltered by the closed event-API method-name set before receiver classification, and helper call sites are indexed on demand instead of eagerly resolving every call in every package source. The demand-driven index proves locality per helper — a helper that is non-exported, sits in a real ES module, and whose every same-file reference is a direct callee has all of its calls in that file by module scoping, so only that file is indexed. Any unproven premise (an export modifier, a global script file, an aliasing or otherwise unclassifiable reference) falls back to the original full package-source index, which is the unchanged original semantics; the proof affects cost, never results. A lazy single global index was rejected because the helper-parameter path is reached on the current tree, so it would still pay nearly the whole getResolvedSignature sweep.

Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. internal/dispatch instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships.

B. Scoped-event routing generates one typed resolver map

gen-scoped-events scans real scopeTarget(base, key) calls to establish the routing-key type for each scoped base. It then finds Cordis Events members with this: Scoped<Base> and searches every payload parameter plus one public property level for a type identical to that key after removing null and undefined.

Exactly one match generates a resolver. Multiple matches are ambiguous and fail. Zero matches require @dshScopeScan unsupported, which is reserved for events whose routing key intentionally stays outside the payload, such as owner-keyed session events and parent-keyed subagent lifecycle events. The annotation records an unsupported scan; it does not encode an event name, parameter index, property path, or replacement type.

The committed scoped-events.generated.ts is a runtime-only map in the package that owns scoped dispatch and imports no event-owner package. Semantic completeness lives in the generator: its root Program enumerates every scoped Events declaration and real scopeTarget contract, resolves the unique payload path with the checker, and refuses missing, stale, or ambiguous entries before rendering the unknown[] runtime boundary.

The dsh-scope/invariant companion consumes this map instead of maintaining a handwritten table. Because Program analysis happens in the repository gate rather than through generated type imports, neither dsh-scope nor dsh-invariants acquires dependencies on every event owner.

Semantic gaps fail explicitly

The generators reject missing declarations, config diagnostics, widened or generic event names, inconsistent routing-key types, ambiguous payload matches, unnecessary unsupported annotations, and stale generated output. Recovery through local helper call sites is deliberately narrow: exported or unresolved dataflow requires a new semantic rule rather than a package-specific override.

Verification

verify-doc-graphs freshness-checks semantic producer/listener discovery, and verify-scoped-events reruns the Program analysis while freshness-checking the generated resolver map. The root TypeScript build compiles its runtime adapter; workspace constraints and runtime-closure checks keep event-owner aggregation out of deployment dependencies.

Alternatives considered

  • Keep syntax-only scans with receiver allowlists and manual overrides. This is simple per exception but makes renames and new helper shapes update a second representation. Completeness can detect a missing producer, but it cannot prove that the override still describes the source.

Consequences

  • Event relation generation follows semantic receiver identity and closed event values instead of local naming conventions.
  • Scoped-event membership, subject extraction, and runtime invariant coverage come from event declarations and real dispatch contracts rather than handwritten tables.
  • Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation at the owning contract.
  • Building a flattened Program costs more startup time and memory than parsing isolated files, and semantic gates depend on a valid root project graph.
  • Generated TypeScript remains committed source: changes to event owners or dispatch shapes must regenerate it and the affected documentation.

中文

问题

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

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

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

决策

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

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

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

TypeScriptProject 解析根 tsconfig.json,递归展开每个项目引用,并将各引用项目的源码根合并为一个不输出文件的语义 Program。直接从根项目配置创建普通 Program 时,TypeScript 可能将引用项目重定向到构建后的声明文件;显式展开可以让门禁继续遍历各包的 src 文件,并保留符号同一性。

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

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

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

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

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

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

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

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

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

提交到仓库的 scoped-events.generated.ts 是位于 scoped dispatch 所属包中的纯运行时映射,不导入任何事件声明方包。语义完整性由生成器自身保证:根 Program 枚举所有 scoped Events 声明与真实 scopeTarget 约定,通过 checker 解析唯一的 payload 路径,并在渲染 unknown[] 运行时边界前拒绝缺失、陈旧或含义不明确的条目。

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

语义缺口必须显式失败

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

验证

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

考虑过的替代方案

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

后果

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