DSH / Atlas
2026-07-06implementedprocess

Export JSDoc gate

导出 JSDoc 门禁

The [cordis JSDoc completeness gate](../../archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md) made undocumented parameters and results impossible on the cordis surface — `interface Events` members and `ctx.<key>` service classes — but that surface is a fraction of what a plugin author imports. The AGENTS.md rule "every export (and non-obvious method) has a JSDoc explaining semantics" stayed prose-checkabl

English

Problem

The cordis JSDoc completeness gate made undocumented parameters and results impossible on the cordis surface — interface Events members and ctx.<key> service classes — but that surface is a fraction of what a plugin author imports. The AGENTS.md rule "every export (and non-obvious method) has a JSDoc explaining semantics" stayed prose-checkable only by review everywhere else, and nothing at all asked for @param/@returns on ordinary exported functions. A survey at adoption found 203 under-documented module-level exports across 34 packages: seam-adjacent helpers (runBash, readForEdit, htmlToMarkdown), format codecs, whole undocumented interfaces and type aliases — exactly the names an IDE consumer hovers.

Decision

A new gate, scripts/verify-export-jsdoc.ts (pnpm run verify-export-jsdoc, wired into doc-sync beside verify-cordis-catalog), walks every module-level exported name under each packages/<group>/<pkg>/src/ tree. The parsing and check helpers moved from gen-cordis-catalog.ts into a shared scripts/jsdoc.ts, so "documented" means the same thing on both surfaces: description prose ends at the first block tag, every checkable parameter needs a non-empty @param, a non-void ANNOTATED return needs a non-empty @returns, a stale @param errors, and violations aggregate into one report.

The contract by declaration kind:

  • Every exported name needs JSDoc with non-empty description prose.
  • Function-like exports (function declarations; consts with function initializers or an INLINE callable annotation; non-identifier function default exports) follow the full function contract, with wrapper expressions (parentheses, as/satisfies casts, non-null assertions) peeled before classifying. A const whose declarator is annotated with a NAMED type (export const f: Handler = …) defers the signature contract to that type's own declaration and @returns stays optional; an inline (x: T) => U annotation or single-call-signature literal is the exported signature itself and gets the full contract, and a literal mixing call/construct signatures with anything else is refused outright (no single signature to hold the tags against — extract a named type).
  • Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs.
  • Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate).
  • Exported namespaces recurse (inside an ambient declare namespace every member exports implicitly); the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once).
  • declare module / declare global bodies and export … from re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An export import X = N.member alias documents ITSELF — its target may be a non-exported namespace member no walk visits — and only prose-only target kinds are gate-supported: a callable, class, or namespace target carries signature/member contracts the alias prose cannot hold, so the gate refuses it and demands the declaration be exported directly.
  • Everything else fails CLOSED: export = is refused outright, parameters the base never names keep their @param duty even as binding patterns, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission.

Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's this/next exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked):

  • Heritage members. Overrides inherit documentation from their base declaration. New public API still requires docs: added parameters, a public override of a protected member, or a concrete return over a void base. Heritage lookup and inferred return classification are the gate's only type-checker work; other checks use the AST.
  • Plugin-protocol slots. Top-level name / inject / reusable / Config consts and the apply entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the interface Config carry the plugin's real semantics.
  • Constructors, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story.

collectExportJsdocViolations() returns the violation list (the CLI exits 1 on non-empty) so the negative-path tests in packages/core/agent/tests/verify-export-jsdoc.spec.ts assert on findings directly, driving fixture packages through every rejection and every exemption.

Alternatives considered

  • eslint-plugin-jsdoc (require-jsdoc/require-param/require-returns) — covers the mechanical core but cannot express the repo's contract: the heritage-member exemption needs cross-package type resolution, the protocol-slot and namespace-merge idioms are cordis-specific, and the completeness semantics (prose-above-tags, stale-tag errors, aggregate reporting) already have one home in scripts/jsdoc.ts shared with the catalog generator. Two subtly different definitions of "documented" is the failure mode this repo's one-home rule exists to prevent.
  • Extending gen-cordis-catalog.ts — the catalog generator renders a curated API and gates its freshness; a repo-wide walk has no catalog to render. Sharing the helpers while keeping the walks separate keeps each gate's scope legible.
  • Enforcing interface/type-alias member docs — deferred: it would multiply the checked scope for members that are largely self-describing fields, while the seam classes carrying the load-bearing member contracts are already gated. Revisit if member-doc drift shows up in review.

Consequences

  • A new export cannot land undocumented: verify-export-jsdoc fails doc-sync and CI. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
  • Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where @param must name them.
  • Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements.
  • The gate builds a ts.Program (~6s) — the one doc gate that pays for type resolution; acceptable inside doc-sync, which already compiles doc snippets.
  • The protocol-slot names are reserved by convention at module top level; a non-protocol export coincidentally named apply or Config would go unchecked — accepted, documented here.

中文

问题

Cordis JSDoc 完整性门禁使得 Cordis 接口上的参数和返回值不可能缺少文档——interface Events 成员和 ctx.<key> 服务类——但这只覆盖插件作者可导入接口的一小部分。AGENTS.md 中的规则「每个导出(以及非显而易见的方法)都必须有解释语义的 JSDoc」在其他地方仍只是由评审检查的文字约定,而且没有任何机制要求普通导出函数带 @param/@returns。采纳时的一次调查发现 34 个包中有 203 个文档不完整的模块级导出:seam 相关辅助函数(runBashreadForEdithtmlToMarkdown)、格式编解码器、完全无文档的接口和类型别名——恰恰是 IDE 消费方悬停查看的那些名称。

决策

新增门禁 scripts/verify-export-jsdoc.tspnpm 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 豁免一致(为已豁免的名称编写文档是允许的;只有缺失才不被检查):

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

后果

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