dsh-plugin-comment-spec
GitHub规范DSH插件代码注释的编写、评审与迁移,强调契约式TSDoc、模块头职责说明及工具校验,旨在提升API可用性与代码可维护性。
Trigger Scenarios
Install
npx skills add PC2005-cloud/dsh-pet --skill dsh-plugin-comment-spec -g -y
SKILL.md
Frontmatter
{
"name": "dsh-plugin-comment-spec",
"metadata": {
"category": "development",
"language": "zh"
},
"whenToUse": "编写或修改 DSH 插件(宿主半侧 \/ 浏览器半侧 \/ 独立模式 \/ 桌面 helper)代码、补 TSDoc 与 API 文档、评审注释质量、清理横幅式或叙述式注释、为注释引入 lint 或文档门禁时。",
"description": "为 DSH 插件编写、评审或迁移代码注释时使用:以模块头、声明级 TSDoc 契约和少量 why 注释为核心,统一标签、语言与校验方式。",
"user-invocable": true,
"disable-model-invocation": false
}
DSH 插件注释规范
目标
让调用方不读实现就能正确使用插件 API,让维护者只读必要信息就能安全改动代码,让注释可以被工具校验、并且不会随实现漂移。
三条基线:
- 注释是契约,不是散文。 每条注释只回答类型和命名回答不了的问题:边界、不变量、副作用、为什么这样做。
- 一处事实只写一遍。 能从代码、类型、OpenAPI、schema 生成的信息,不手写第二份。
- 默认单行,复杂才展开。 先改结构,再写注释;注释不能弥补过大的文件或过深的函数。
速查(十行版)
| 场景 | 写什么 | 写在哪 |
|---|---|---|
| 新文件(入口/驱动/带状态 hook) | 职责、非目标、依赖、不变量 | 文件头 4–8 行 |
| 新文件(普通模块) | 一句话职责 | 文件头 1 行 |
| 新文件(纯函数叶子) | 可省略:导出自身有完整文档时省略 | — |
| 导出符号 | 一行摘要,必要时加标签 | 声明上方 |
| 接口字段 | 语义不显然才写(配置项、单位、互斥、缺省) | 字段上方,1 行 |
| 复杂函数 | 摘要 + @param / @returns / @throws |
声明上方,≤ 12 行 |
| 私有成员 / 内部类 | 一行摘要,不承诺兼容 | 声明上方 |
| 非显然实现 | 为什么、权衡、必须的顺序 | 行内 1–4 行 |
| 生成文件 | @generated、生成命令、禁止手改 |
文件头 |
| 凭据相关 | 来源、生命周期、能否进日志 | 使用处一行 |
| 待办 | TODO(#123, @owner): 动作 |
行内 |
| 背景与决策 | 链接 docs/ 或 ADR |
模块头一行 |
适用范围
适用于 DSH 插件仓库里的手写源码:
- 宿主半侧(host):Cordis 插件入口、服务注册、路由、配置合并、状态维护。
- 浏览器半侧(client):
./client入口、React 组件、设置页、渲染与轮询。 - 独立模式(standalone / CLI):参数解析、伪 ctx、本地 server、shim。
- 桌面 helper(Electron / runtime):主进程、preload、renderer、窗口与素材调度。
不适用于:lib/ 构建产物、*.d.ts 生成声明、node_modules、以及 package.json 这类无法写注释的清单文件(这类文件的说明放 README)。
其他语言的项目可以复用分层、禁止项与流程,只把标签换成对应生态(例如 Java 用 Javadoc)。
第一原则:先判断“该不该写”
落笔前对每句话做三次判断:
- 能否从签名或命名直接得到? 能——删掉。
/** 用户名 */ name: string是噪音。 - 描述的是当前事实,还是历史与计划? 历史——删掉或进 CHANGELOG;计划——进 issue;只保留“现在必须成立”的约束。
- 下次改代码时它会不会失效? 会——改成约束、改成链接,或者不写。注释的价值不在于“写了”,而在于“仍然为真”。
一句话检验:未来的读者能否用代码或测试验证这句话? 不能验证的叙述,多半不该留在注释里。
否定式注释
模块头允许且鼓励写一条非目标;函数级与字段级默认不写。
落笔前过三问,三问都否就不写:
- 越界是否低成本? 不新增 import、不跨类型边界就能顺手写进去。
- 读者是否会误会? 名字与上下文没有暗示这条边界。
- 是否出过事故? 有过真实的错放或返工。
反例(写了等于噪音):
// 本文件不是工具函数集合。—— 读者不会这样以为。/** 不负责持久化。*/挂在parseX()上 —— 函数体已经说明了。const ts = Date.now(); // 这里是毫秒而不是秒—— 应该直接改名tsMs。
正例(写了才有价值):
- 配置写入口:不重启桌面 Helper —— 上层曾直接调重启,属跨层顺手错。
- 事件监听模块:不订阅事件(订阅在装配层)—— 名字叫 watch 却不订阅,反直觉。
变量层面的“是 A 而不是 B”:只在两个相似概念并存时写,且优先用命名区分;命名能说清就不写。
注释分层
| 层级 | 位置 | 必须写什么 | 长度上限 | 校验方式 |
|---|---|---|---|---|
| 模块头(入口/驱动/带状态) | 文件顶部 | 插件角色、职责、非目标、依赖关系、关键不变量 | 4–8 行 | review |
| 模块头(普通模块) | 文件顶部 | 一句话职责 | 1 行 | review |
| 模块头(纯函数叶子) | 文件顶部 | 可省略 | 0 行 | review |
| 导出声明 | export 的函数、类、常量、类型 |
一行摘要,按需加标签 | 1–7 行 | tsdoc / api-extractor |
| 接口字段 | 语义不显然的字段 | 一行语义 | 1 行 | review |
| 私有成员 | 内部类字段、私有方法、模块内常量 | 一行摘要,不承诺兼容 | 1 行 | review |
| 复杂函数 | 参数、返回、异常不显然时 | 摘要 + @param / @returns / @throws |
≤ 12 行 | eslint |
| 生命周期 | apply / inject / dispose / ctx.on |
注册了什么、依赖什么、何时清理 | 1–4 行 | review |
| 实现体内 | 非显然分支、魔数、外部怪癖 | 为什么这样做、为什么不能那样做 | 1–4 行 | review |
| 生成文件 | 文件顶部 | @generated、生成命令、禁止手改 |
3 行 | review |
| 工具指令 | eslint-disable / v8 ignore |
指令 + 原因 | 1 行 | lint |
模块头
按文件角色分三档,不要一律写成四段式:
| 角色 | 写什么 | 长度 |
|---|---|---|
| 入口、装配、服务、驱动、带状态的 hook | 负责什么、不负责什么(最多一条)、依赖谁、什么必须始终成立 | 4–8 行 |
| 普通模块(一组相关函数或一个组件) | 一句话职责,必要时补一条非目标 | 1 行 |
| 纯函数叶子(换算、格式化、纯决策) | 省略;文件只有一个导出且该导出已有完整文档时也应省略 | 0 行 |
有 @module 时写在首行或末行,只有入口与驱动类文件值得带它。头不是表格:一句话说得清就不该写成四行。
不要写:路由清单、字段清单、配置项清单、变更历史、ASCII 图、目录结构。这些内容要么能生成,要么属于文档站,写进注释就是第二份真相。
/**
* 宿主半侧:把 <能力> 注册为 DSH 服务与路由,并维护其内存状态。
* 不负责渲染与素材;浏览器半侧经 /<prefix>/state 轮询读取。
* 契约见 openapi.yaml;配置只经 readAllConfig 读取,本模块不碰文件。
* @module dsh-plugin-<name>
*/
超过 8 行通常不是“需要更多说明”,而是“这个文件该拆了”。
导出声明
package.json 的 exports(.、./client)指向的入口,其导出就是插件的公开面:每个符号必须有摘要;字段按下一节的判据写。
摘要用祈使句或名词短语、现在时、句末句号;用中文写,标识符保持原文。不要重复名称和类型。
/** 解析不带前导斜杠的命令名;不合法时返回 undefined。 */
export function parseCommandName(line: string): string | undefined;
/** 轮询状态的唯一数据源;counter 变化才触发渲染。 */
readonly state: PollState;
接口字段
只写语义不显然的字段。以下四类必须写:
- 配置项与开关:它改变什么行为,以及缺省值。
- 数值:单位与量纲(
_MS/_PX/_BYTES),以及取值范围或夹取规则。 - 互斥或二选一:另一侧是什么,由谁保证只出现一个。
- 跨模块契约:谁写、谁读、失败或缺失时怎么降级。
坐标、尺寸、计数、名字这类字段名已经说清的,不写。给几十个字段逐个补一行,读起来更像表格而不像契约:真正需要解释的字段会被同款句式淹没。
私有成员与内部类
内部类、私有字段、模块内常量与导出符号用同一标准,但只写一行摘要,不写完整标签,也不承诺兼容性:
/** 当前实例的注册层级;层级高的定义遮蔽全局定义。 */
private readonly layers: ScopedLayers;
/** 单进程内生成 commandId 的序号,重启后从 0 开始。 */
private commandSeq = 0;
需要明确“不对外”时加 @internal;能被外部直接 import 到的符号一律按导出标准写。
复杂函数
只在“读完签名仍有疑问”时展开。结构固定:摘要一行 → 空行 → 标签区。
@param name - 语义:写含义、可选性、缺省值、边界,不写类型。@returns:写边界值(空、undefined、默认值、部分成功)。@throws:写触发条件与错误类型;不抛错就不写。{@link Symbol}:引用相关符号,不写“见 xxx.ts:123”。@example:只给非常规用法,一个函数最多一个。
/**
* 注册一个插件命令。
*
* @param definition - 发现元数据与处理器;同名重复注册会被拒绝。
* @returns 精确注销该定义的 disposer。
* @throws {TypeError} 名称不符合 `<规则>` 时。
*/
泛型
类型参数不显然时用 @typeParam 写约束与用途,不重复 extends 已经表达的内容:
/**
* 按配置键读取值。
*
* @typeParam K - 配置键;返回类型由该键推导。
* @param key - 已声明的键。
* @returns 对应值;键不存在时返回 `undefined`。
*/
export function readConfigValue<K extends keyof Config>(key: K): Config[K] | undefined;
重载
摘要与标签只写一次,放在实现签名或第一个重载上;各重载只在行为有差异时补一行 @remarks,不要把同一段说明复制三遍。
生命周期与注入
DSH 插件的特有位置,最容易被写成“复述框架”:
name/inject/provide/apply/dispose:各写一行,说明为什么需要这个依赖、注册了什么、清理什么,不要解释 Cordis 本身怎么工作。ctx.on(...)/ctx.effect(...):注明监听器的生命周期与解除时机。- 路由处理函数:契约链接 OpenAPI,不逐字段列响应体。
- 客户端组件:props 每个字段一行;
useEffect依赖只解释“为什么这个依赖不能删”。 - 配置合并:默认值与优先级写进模块头,不逐字段写散文。
实现体内
只写五类内容:非显然的常量来源、反直觉的分支、性能或安全权衡、外部系统的怪癖、必须保持的调用顺序。
不写:逐行翻译、显而易见的判空、getter/setter 说明、变量含义复述。
1–4 行为限。超过 4 行说明需要更好的命名、更小的函数,或者把背景迁到 docs/ 并只留链接。
// 宿主的 dispose 是异步的:先摘监听再关窗,否则回调会打到已销毁的 webContents。
安全与凭据
涉及密钥、token、用户数据的代码,注释只写来源、生命周期、能否进日志,绝不写真实值;示例一律用占位符。
// key 只驻留内存,禁止写日志或落盘;错误信息里只保留后四位。
生成文件
提交进仓库的生成物(bundle、复制出来的 runtime 脚本)必须在文件头三行内声明:谁生成的、生成命令、禁止手改。
/**
* @generated by `pnpm run build:desktop-core`,请勿手改。
* 源文件:src/shared/index.ts。
*/
生成器不支持注入文件头时,在最近的手写入口或 README 里标注。
禁止项
| 反模式 | 为什么不行 | 替代做法 |
|---|---|---|
// ---- 配置 ---- 这类横幅 |
掩盖文件过大,制造合并冲突,代码搬走后常留在原地 | 拆模块,用文件与导出边界代替 |
**加粗**、## 标题、ASCII 图 |
编辑器不渲染,grep 与 diff 里全是噪音 | 纯文本;图进文档站 |
| “改造前 / 旧实现 / 曾经” | 无法验证,必然腐化 | git、CHANGELOG、ADR |
| 注释掉的代码 | 双重维护,读者不知道哪份有效 | 删除,靠版本库找回 |
| 行号或文件路径引用 | 文件一移动就失效 | {@link} 或稳定文档链接 |
@param {string} name 名称 |
类型已由 TS 表达,纯冗余 | 只写语义 |
| 在注释里抄路由表、字段表、配置表 | 与实现漂移,且无人校验 | 链接 OpenAPI、schema 或生成物 |
| 无主 TODO | 永远不会被处理 | TODO(#123, @owner): 具体动作,或只留 issue |
| 同一段注释中英混写 | 读者来回切换,术语容易漂移 | 统一用中文;英文只用于标识符与引用原文 |
| 为覆盖率凑注释 | 增加噪音,掩盖真正缺失的契约 | 宁可缺,不可假 |
// 本文件不是…、函数级“不负责…”、变量“是 A 而不是 B” |
读者本来不会误会,属噪音 | 删;需要区分时改命名 |
标签规范
使用 TSDoc 语义(JSDoc 工具链兼容)。
| 标签 | 何时用 | 写法 |
|---|---|---|
@module |
模块头 | @module <包名> |
@param |
有参数且语义不显然 | @param name - 语义 |
@typeParam |
泛型参数不显然 | @typeParam T - 约束与用途 |
@returns |
有返回值且边界不显然 | @returns 边界与缺省 |
@throws |
会抛出异常 | @throws {Type} 触发条件 |
@deprecated |
计划移除 | @deprecated 替代品与起始版本 |
@internal |
不对外承诺 | @internal |
@remarks |
需要成段说明 | 摘要之后的独立段落 |
@example |
非常规用法 | 代码块,最多一个 |
@see / {@link} |
关联符号或文档 | {@link Symbol} |
规则:
- 顺序固定:摘要 →
@remarks→@typeParam→@param→@returns→@throws→@example。 @param的名字必须与签名完全一致;改参数名时同步改标签。- 标签之间不空行;
@param组与@returns之间不空行;与摘要之间空一行。 - 中文句子用全角标点;代码片段、命令、路径内部的符号保持原样。
- 不写
@param {type},类型交给 TypeScript。
语言与术语
注释一律用中文。代码标识符、类型名、API 名、命令名保持英文原文,不翻译;引用错误消息、日志、UI 文案时保留原文,不改写。
- 语言跟随目标仓库。 本文示例用中文;移植或参考其他代码时不照抄语言,按目标仓库统一,同一个文件里不混写。
- 全仓库统一中文叙述。 不要一句中文一句英文;专有名词直接用英文不算混写。
- 一个概念一个词。 首次出现可括注英文,例如“成品配置(resolved config)”;同义词不要轮换。
- 避免项目黑话。 必须使用时,在模块头定义一次;不要让新人靠猜。
- 标点跟语义走。 中文句子用全角标点;代码片段、命令、路径内部的符号保持原样。
- 标签名保持英文。
@param、@returns、{@link}是工具语法,不翻译。
注释与测试的取舍
能被测试钉住的规则不写进注释。优先级表、边界换算、状态转移、降级分支,都写成断言;注释只留测试表达不了的三类:
- 顺序:为什么必须按这个次序执行,换个次序会坏在哪。
- 权衡:两个都可行的方案里为什么选这个,放弃了什么。
- 外部怪癖:宿主或浏览器某处的行为反直觉,测试无法稳定复现。
同一条事实只出现在一处:测试已经断言了换算公式,模块头就不必再抄一遍,只写测试覆盖不到的约定(例如结果与耗时无关、不受输入顺序影响)。两边都写会漂移,且漂移时没人知道该信哪边。
长度与密度
- 模块头按角色分档(入口 4–8 行 / 普通 1 行 / 纯函数叶子可省);声明摘要 1 行;函数文档 ≤ 12 行;体内 why 1–4 行。
- 文档块中位数应为 1 行,p90 ≤ 7 行。≥ 10 行的块是“该迁文档”的信号,不是“写得更详细”的信号。
- 注释行占比 10%–20% 属于常见区间。高于 25% 先查“是不是在写散文、是不是在掩盖结构”;低于 5% 先查“导出契约是不是缺失”。
- 密度不是 KPI,不按行数考核,也不为了达标补废话。字段注释尤其如此:把显然的字段补满,会让真正需要解释的字段淹没在同款句式里。
写作流程
新增代码
- 新文件先按角色定模块头(入口/驱动 4–8 行;普通模块 1 行;纯函数叶子可省),再写 import。
- 每个导出符号写一行摘要;接口每个字段一行。
- 参数、返回、异常不显然时补标签;不显然的用法才加
@example。 - 只在非显然处写 why;能用命名和类型表达的一律不写。
- 提交前跑类型检查与 lint(若已配置 TSDoc 规则)。
修改代码
- 先判断这次改动让哪些注释失真;只改相关注释,不顺手重写无关注释。
- 删除已不存在的符号引用与失效示例。
- 行为变化同步
@returns/@throws;要删除导出,先标@deprecated保留一个版本。 - 新增分支若引入新约束,补一句 why,而不是补一段流程说明。
评审代码
- 按检查清单逐条过,把问题分成“错误、过期、风格”三类。
- 只对“错误、过期”要求必须改;风格问题给建议,不阻塞。
- 长注释只要求迁移到文档,不要求删掉信息。
- 发现“注释与实现矛盾”时,以代码为准,先改注释,再判断代码是否有 bug。
迁移存量
- 先删装饰:横幅、加粗、标题、ASCII 图。
- 再把 ≥ 10 行的块迁到
docs/,代码里留 2–3 行摘要加链接。 - 再补齐导出符号的标签。
- 最后开门禁:先
warn,稳定后再error。一次只动一个维度,避免大 diff。
检查清单
- 模块头与文件角色匹配(入口/驱动写全,纯函数叶子可以不写),回答了“负责什么、依赖谁、什么必须成立”?
- 每个导出符号有摘要;接口字段只写了语义不显然的那些(配置项、单位、互斥、缺省),明显字段没被凑数?
- 泛型参数不显然时写了
@typeParam?重载说明只写了一遍? - 生成文件是否标了
@generated与生成命令? - 凭据相关注释是否只写来源与脱敏要求,没有真实值?
-
@param名称与签名一致?返回与异常的边界写清楚? - 有没有重复类型、重复命名、重复实现细节?
- 有没有横幅、加粗、图、历史叙述、行号引用?
- ≥ 10 行的注释是否应该迁到文档?
- TODO 是否带 issue 与 owner?
- 同一事实是否只存在一处(没有复制 OpenAPI、schema、配置表)?
- 叙述语言是否统一为中文(标识符与引用原文除外)?
门禁与工具(推荐,不强制)
eslint-plugin-tsdoc:校验 TSDoc 语法,直接error。eslint-plugin-jsdoc:require-jsdoc(仅 exported)、require-param、require-returns、check-param-names、check-tag-names、tag-lines,先warn。@microsoft/api-extractor:生成.api.md并在 CI diff,导出变更必须显式批准。- TypeDoc:
--validation.notDocumented把“导出无文档”变成失败。 - CI 检查 TODO 格式:只接受
TODO(#\d+, @[\w-]+)。 - 存量基线:门禁只对新增文件与 diff 行生效;存量问题用基线文件记录,指标只降不升,避免上千个存量块一次全红。
- 迁移顺序:只读盘点 →
warn→ 修存量 →error。不要一次性全开。
指标(建议值,不作硬指标)
| 指标 | 建议值 |
|---|---|
| 导出符号文档覆盖 | ≥ 95% |
导出函数带 @param |
≥ 90% |
| 接口字段覆盖 | 不作指标:只覆盖非显然字段,写满反而降质 |
| ≥ 10 行注释块 | 0 |
| 横幅、加粗、ASCII 图 | 0 |
| TODO 带 issue 与 owner | 100% |
| 文档块长度 p50 / p90 | 1 行 / ≤ 7 行 |
| 注释行占比 | 10%–20% |
示例:一次改写
// ---- 配置模块 ----
// 改造前配置分散在三个文件,现在统一到这里
// 读取配置
export function readConfig() { /* ... */ }
改成:
/**
* 配置的唯一读取入口:合并内置默认、用户层与环境变量。
* 不负责写盘;写盘只经 saveUserConfig。
* @module dsh-plugin-<name>/config
*/
/** 返回字段已填满的成品配置;解析失败时抛出,不返回半成品。 */
export function readConfig(): Config;
改动只有三件事:横幅换成模块头,历史叙述删掉,函数摘要写契约。没有增加信息量,但每句话都可验证。
守则
- 不为了指标写注释;宁缺毋滥。
- 不用注释弥补结构:文件过大先拆,函数过长先提,不写横幅。
- 有价值的 why 不删,迁移到
docs/或 ADR,代码里留链接。 - 一次只改一个维度:先装饰,再长文,再标签,最后门禁。
- 公开 API 变更必须同步注释与 API 报告。
- 拿不准时,用“未来读者能否验证这句话”决定去留。
Version History
- 38ae846 Current 2026-11-02 08:57


