# Agent Note: 导出表面 JSDoc 门禁

Status: implemented

[English](2026-07-06-export-surface-jsdoc-gate.md) | 中文

## 问题

[Cordis JSDoc 完整性门禁](../../archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md)使得 Cordis 表面上的参数和返回值不可能缺少文档——`interface Events` 成员和 `ctx.<key>` 服务类——但这只是插件作者所导入内容的一小部分。AGENTS.md 中的规则「每个导出（以及非显而易见的方法）都必须有解释语义的 JSDoc」在其他地方只能靠评审以行文方式检查，而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包（package）中有 203 个文档不完整的模块级导出：seam 相关辅助函数（`runBash`、`readForEdit`、`htmlToMarkdown`）、格式编解码器、完全无文档的接口和类型别名——恰恰是 IDE 消费方悬停查看的那些名称。

## 决策

新增门禁 `scripts/verify-export-jsdoc.ts`（`pnpm run verify-export-jsdoc`，接入 `doc-sync`（文档同步门禁），与 `verify-cordis-catalog` 并列），遍历每个 `packages/<group>/<pkg>/src/` 目录树下的所有模块级导出名称。解析与检查辅助函数从 `gen-cordis-catalog.ts` 移入共享的 `scripts/jsdoc.ts`，使得「已文档化」在两个表面上含义一致：描述性文字在第一个块标签处截止、每个可检查参数需要非空 `@param`、非 void 且有显式标注的返回值需要非空 `@returns`、过时的 `@param` 报错，违规项汇总为一份报告。

按声明类型划分的契约：

- 每个导出名称都需要带有非空描述文字的 JSDoc。
- 函数类导出（函数声明；初始化器为函数或带有内联可调用标注的 const；非标识符的函数默认导出）遵循完整的函数契约，分类前会剥离包装表达式（括号、`as`/`satisfies` 类型断言、非空断言）。如果 const 声明器标注了一个具名类型（`export const f: Handler = …`），签名契约推迟到该类型自身的声明处，`@returns` 保持可选；内联的 `(x: T) => U` 标注或单调用签名字面量本身就是表面签名，适用完整契约；而混合了调用/构造签名与其他成员的字面量则直接拒绝（没有单一签名可供标签对照——请提取具名类型）。
- 导出类需要类级别的描述文字；公开方法（包括静态方法——可通过导出名称访问）遵循函数契约；公开属性和访问器需要描述文字（get/set 对由 getter 覆盖）。重载实现体免检——签名承载文档。
- 导出接口、类型别名和枚举需要声明级别的描述文字；成员级别的强制有意推迟（承载关键成员契约的 seam 服务类已在 Cordis 门禁之下）。
- 导出命名空间递归检查（在 ambient `declare` 命名空间内，每个成员隐式导出）；命名空间本身仅在不与同名的已文档化声明合并时才需要描述文字（Config-namespace 惯用法只需文档化插件一次）。
- `declare module`/`declare global` 体和 `export … from` 重导出语句被跳过：augmentation 不是包的导出，重导出的定义在其定义处检查。`export import X = N.member` 别名需要文档化自身——其目标可能是遍历不会访问的非导出命名空间成员——且门禁仅支持纯描述文字的目标类型：可调用、类或命名空间目标携带别名描述文字无法承载的签名/成员契约，门禁会拒绝并要求直接导出该声明。
- 其余情况按封闭原则失败：`export =` 直接拒绝；基类从未命名的参数即使作为绑定模式仍需 `@param`；dispatch 不识别的导出语句类型本身就是违规——没有任何导出形式能因遗漏而免检。

三类豁免避免门禁要求样板代码，精神与 Cordis 门禁的 `this`/`next` 豁免一致（为已豁免的名称编写文档是允许的；只有缺失才不被检查）：

- **继承成员。** 重写从其基类声明继承文档。新增的公开表面仍需文档：新增参数、将 protected 成员公开重写、或在 void 基类之上返回具体类型。继承查找和推断返回值分类是门禁唯一需要类型检查器的工作；其他检查使用 AST。
- **插件协议槽位。** 顶层的 `name`/`inject`/`reusable`/`Config` 常量和 `apply` 入口，以及插件类上的同名静态成员，属于框架协议：其形状由 Cordis 固定，模块文档注释加 `interface Config` 承载插件的真实语义。
- **构造函数**，与 Cordis 门禁一致：插件类由框架构造，类文档承载全部说明。

`collectExportJsdocViolations()` 返回违规列表（CLI（命令行界面）在非空时以 1 退出），因此 `packages/core/agent/tests/verify-export-jsdoc.spec.ts` 中的负路径测试直接断言发现项，通过 fixture（测试前置数据）包驱动每一种拒绝和每一种豁免。

## 曾考虑的替代方案

- **eslint-plugin-jsdoc**（`require-jsdoc`/`require-param`/`require-returns`）：覆盖了机械核心，但无法表达本仓库的契约。继承成员豁免需要跨包的类型解析，协议槽位和命名空间合并惯用法是 Cordis 特有的，而完整性语义（标签前描述文字、过时标签报错、汇总报告）已在 `scripts/jsdoc.ts` 中与 catalog 生成器共享。两套微妙不同的「已文档化」定义，正是本仓库「单一归属」规则所要防止的失败模式。
- **扩展 `gen-cordis-catalog.ts`**：catalog 生成器渲染一个精选表面并守卫其新鲜度；仓库级遍历没有 catalog 可渲染。共享辅助函数、保持遍历独立，使每个门禁的职责清晰可读。
- **强制接口/类型别名的成员文档**：推迟。这会使检查表面成倍增长，而这些成员大多是自描述的字段；承载关键成员契约的 seam 服务类已有门禁。如果评审中出现成员文档漂移再重新考虑。

## 后果

- 新导出不能在缺少文档的情况下落地：`verify-export-jsdoc` 会使 `doc-sync` 和 CI 失败。采纳时发现的 203 处缺口已在同一变更中补齐，因此门禁以绿色状态落地。
- 导出函数必须标注返回类型（采纳时已全面满足，现在成为门禁依赖），并在 `@param` 需要命名参数时使用标识符参数。
- seam 文档是权威的：实现从其继承链继承文档，值得保留在实现上的行为说明是补充，而非必需。
- 门禁构建一个 `ts.Program`（约 6 秒）——唯一需要类型解析的文档门禁；在已编译文档片段的 `doc-sync` 内可以接受。
- 协议槽位名称按约定保留在模块顶层；一个恰好命名为 `apply` 或 `Config` 的非协议导出将不被检查——已接受，记录于此。
