关于 reviewing-motoko
面向 Motoko 项目代码审查的 Skill,供评估设计质量的审查者使用,而非用于学习语法。它按类型约束、不稳定与持久状态、文件结构和 Motoko 惯例组织审查流程,并提供问题级别定义。
主要功能
- 01检查类型是否编码不变量
- 02审查稳定与临时状态
- 03评估文件布局和依赖方向
- 04定义问题严重级别
- 05审查前运行 mops check --fix
适用场景
- 01审查 Motoko Actor 的持久化
- 02发现类型约束不足的状态和错误
- 03检查模块边界与导入关系
- 04评估公开 API 的类型和授权
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill reviewing-motoko安装后在 Claude Code 中输入/reviewing-motoko确认已加载。
附带文件references/state-and-persistence.mdreferences/type-encoding.md
详细介绍
审查 Motoko
此技能用于判断 Motoko 代码的设计是否良好。它不教授这门语言——语法、mo:core API 和机械规则请加载 writing-motoko;更改任何稳定状态之前,请加载 migrating-motoko-actors。
审查由四个问题决定,按以下顺序进行——前三个问题优先,是因为每个问题都可能使下一个问题失去意义;第四个问题放在最后,因为它影响最小:
- 代码所依赖的每项不变量是否都编码在类型中? 如果类型系统能够排除某种错误状态,却把这件事交给运行时检查或注释处理,那就是缺陷,而非风格偏好。
- 状态是否声明了正确的持久化方式? 在增强迁移机制下,稳定状态与临时状态是两个独立的世界。混淆二者会导致状态悄无声息地停止更新。
- 代码是否放在了正确的文件中?
types.mo/lib//mixins//main.mo约定了哪些内容可以依赖哪些内容,而不是一种文件夹规范。 - 代码读起来是否像 Motoko? 使用上下文点表示法、推断隐式参数、不添加调用中已提供的注解、不使用类。
mops check --fix会处理其中大部分问题;它无法发现的部分才需要审查者处理。
流程
先运行机械检查——不要把审查精力花在编译器已经报告的问题上。
mops check --fix
只有在 [moc] args 为相关选项启用了 -W 时,样式自动修复才会生效(M0236 点表示法、M0237 冗余隐式参数、M0223 冗余实例化);设置方法见 writing-motoko 的 references/project-setup.md。如果这些选项未启用,下面 约定 部分所列的问题都需要你手动修复。
然后按以下顺序阅读——从外向内,因为文件放错位置会让其中的所有类型都变得可疑:
mops.toml——是否存在[canisters.*.migrations]链?如果没有,下面的发现 B1 优先级最高。- 结构——先检查文件布局,再检查每个文件的导入。
types.mo——检查每个类型声明。大多数发现都在这里。- 状态——检查
main.mo中的 Actor 字段,以及每个include传递的内容。参见 references/state-and-persistence.md。 - API 边界——检查每个
public shared/public query函数:参数类型、返回类型和授权方式。 - 函数体——最后检查,并且只检查下面 可维护性 部分所述的问题。
严重程度
使用以下三个级别之一报告发现的问题。不要夸大严重性:把所有问题都称为阻断项的审查者会被忽略。
| 级别 | 含义 |
|---|---|
| 阻断项 | 可能破坏或悄悄丢失状态,或允许调用者执行未经授权的操作。 |
| 警告 | 类型系统本可强制执行的不变量,却留给运行时、注释或约定处理。 |
| 提示 | 可读性、重复或命名问题。在处理相关文件时修复;不要以此作为阻断条件。 |
描述具体会造成的失败,而不是复述规则。“status : Text 接受 "complete",但三个调用处都写成了 "completed"”比“应优先使用变体类型而非字符串”更有说服力。
类型编码
规则是:如果一个值有一组固定的形态,类型就必须包含这组形态。 完整的实现方法,包括如何在迁移中落实每项更改,见 references/type-encoding.md。
T1. 用 Text 表示有限取值集合——警告;若用于授权门控,则为阻断
这是生成的 Motoko 代码中最常见的缺陷。所有生产者和使用者只能靠约定保持拼写一致,编译器不会检查任何内容——既不检查是否穷尽,也不会因拼写错误或新增状态后有一半调用点未更新而报错。
public type Task = { id : Nat; var status : Text }; // WRONG
public type TaskStatus = { #open; #claimed : Principal; #done : { at : Time.Time } };
变体是共享的,因此前端也会将其视为带判别标记的联合类型。只要代码、注释或文档列出了允许的取值,就应标记 Text(或 Nat、Bool)——例如 == "..." 比较、对带有 case _ 的 Text 执行 switch,以及以 Text 保存的角色名称。对于真正开放的取值集合,以及由外部系统管理的字符串,继续使用 Text。
T2. 相关字段——警告
只有同时有效的两个字段应放在同一个变体中。一个 Bool 加上一个可选负载,会表示出四种组合,但其中只有两种有意义。
// WRONG — { hasDeadline = true; deadline = null } compiles and means nothing
{ hasDeadline : Bool; deadline : ?Time.Time }
// RIGHT
{ deadline : { #none; #due : Time.Time } } // or simply `deadline : ?Time.Time`
如果 ?T 已经表达了这一点,就去掉 Bool——不要引入一个仅仅重复表达 ?T 的变体。
T3. 基数不匹配——注意;若调用方需要防御性循环,则为警告
使用与实际数量相符的类型。始终为空或仅有一个元素的 [T] 会迫使每个调用方编写循环或 if size == 1;?T 则表明无需遍历。
T4. 将错误表示为 Text——警告
Result<Ok, Text> 迫使每个调用方通过匹配字符串来区分不同失败。应将 Err 定义为一个包含各情况所需数据的变体。完整规则请参阅 writing-motoko 中的 Result 部分,其中还说明了何时应返回 ?T、何时应直接 trap。
T5. var 字段泄漏到 API 中——阻断(无法编译)或警告(可以编译,但暴露过多)
包含 var 字段的记录不是共享类型,因此不能从公共函数返回。如果项目已经采用内部类型与视图类型分离的方式(内部使用 Task,共享使用 TaskView),请检查:
- 没有公共签名提及内部类型;
- 视图应比内部记录范围更窄,而不是机械地逐字段复制。重新导出内部
Principal列表、审计记录或其他用户数据的视图,即使能编译,也会造成信息泄露。
T6. case _ 吞掉变体——警告
兜底分支会把“添加变体标签后,所有相关位置都会产生编译错误”变成“添加变体标签后,代码会静默地走默认分支”。只有当各分支的行为确实一致、且预期取值集合会增长时,才保留 case _(例如,将多个标签映射到同一个标签)。凡是新增标签需要新行为的地方,都应逐一列出标签。
T7. 将透明别名包装成安全保障——注意
type UserId = Principal 是文档别名:任何 Principal 在需要 UserId 的地方仍能通过类型检查。这没有问题,而且通常是合理的权衡。只需记住,这并不是强制区分——如果调用点确实会混淆两种不同的 ID 类型,则应使用单字段记录({ userId : Principal })或变体标签(#user(Principal))来真正强制区分,但这会改变 Candid 形状。
状态与持久化
在增强迁移(EM)模式下,Actor 的稳定字段只声明类型,所有初始值都来自迁移链。这样的分离正是其设计目的:它能确保稳定状态不可能从临时值初始化。详情请参阅 references/state-and-persistence.md。
B1. 未使用增强迁移——阻断
如果 mops.toml 中没有 [canisters.<name>.migrations] 链,就表示使用 EM 之前的持久化方式,此时稳定字段带有内联初始化值——因此稳定字段可以从临时字段初始化:
transient let defaultQuota = 100;
let quota = defaultQuota; // stable; the initializer runs ONCE, on first install
将 defaultQuota 改为 250 并部署后,quota 仍然是 100——永远如此,而且不会有任何警告。请参阅 migrating-motoko-actors 了解转换方法;以下所有内容均假定使用 EM。
S1. 在稳定字段上内联初始化 — 阻断
在 EM 下,这会触发 M0250 / M0014,因此无法编译;但从 EM 之前的示例中复制的代码,以及尚未完成的重构中,经常会出现这种情况。修复方法绝不是添加 transient:应将初始值移入待处理迁移的 NewActor。
S2. 误用 transient — 两种情况均为警告
针对每个 actor 和 mixin 字段都要问:如果升级时丢失这个字段,应用仍然正确吗?
- 缺少
transient:缓存、派生索引、速率限制计数器,以及所有可重建的数据都被持久化了。之后它们就必须永远迁移,而且原本应在升级时清除的陈旧缓存会继续保留。 - 错误地使用了
transient:持久的领域数据会在每次升级时静默重置为其初始值。检查是否有任何用户期望部署后仍保留的数据被标记为transient。
S3. 与 mixin 共享的 actor var 字段 — 阻断
var 参数按值传递,因此 mixin 修改的是副本,actor 字段不会改变。请将其包装在记录中,并传递该记录。另外,也要标记 include 处新建的记录字面量(include Api({ var n = state.n }))——每个 mixin 随后都会获得自己的副本。参见 writing-motoko。
S4. mixin 块中的稳定状态 — 阻断
mixin 顶层的每个裸 let/var 都会隐式成为稳定状态,并在运行时触发 IC0503。状态应作为参数传入;常量应放入模块;只有 transient 可以内联声明。
S5. 迁移规范 — 阻断
- 迁移文件导入了
mo:core/...以外的任何内容——迁移链会永久重放,因此项目导入会在相应类型发生变化时立即导致问题。(caffeineai-lints会检测此问题。) - 一次构建中有多个待处理迁移,或修改已应用的迁移。
main.mo中的稳定字段未由迁移链中的任何迁移提供(M0254 / M0267)。
恒等迁移主体属于警告——该更改与稳定状态兼容,因此应删除该文件。完整表格。
结构
各层的依赖只能单向指向:main.mo → mixins/ → lib/ → types.mo。
| 文件 | 内容 | 不得包含 |
|---|---|---|
types.mo | 类型声明;这些类型上的小型纯辅助函数 | 状态、端点、业务规则 |
lib/*.mo | 无状态模块形式的领域逻辑,状态作为参数传入 | 公共端点、actor 字段 |
mixins/*.mo | 公共端点:授权、委托、映射为视图 | 领域逻辑、稳定状态 |
main.mo | 状态声明和 include | 任何公共方法、任何逻辑 |
migrations/ | 冻结的迁移链,每个文件一个模块 | 项目导入 |
- A1 — 阻断/警告:
main.mo中有公共方法。将其移入 mixin。 - A2 — 警告: 文件过于庞大。一个
.mo文件中同时包含类型、状态、逻辑和端点。 - A3 — 警告: mixin 主体中有领域逻辑。如果端点主体不止于“授权 → 委托 → 映射”,中间的逻辑就应该放在
lib/中。 - A4 — 警告: 依赖方向错误——
lib/导入 mixin,或types.mo导入其中任意一层。 - A5 — 提示: mixin 不是按功能划分的。mixin 应按功能划分(
mixins/Bookings.mo),而不是按动词划分(mixins/Getters.mo)。 - A6 — 警告: 注入的状态范围过宽。mixin 接收了自己从不使用的状态,会扩大更改可能造成影响的范围;只传入它实际使用的状态片段。
可维护性
大多数问题会在最后一轮正文检查中发现。例外是 M1,即授权阻断项——它会在 API 边界检查(步骤 5)中发现,而不是通过下面的正文代码异味检查发现。
- **M1 — 阻断项:**未检查
caller的public shared函数。每次状态变更,以及每次读取其他用户的数据,都需要在后端进行授权。 - **M2 — 警告:**操作时无法证明结果非负的
Nat减法。这会触发陷阱。 - **M3 — 警告:**调用方本可恢复的陷阱。陷阱会回滚消息,并以不透明的拒绝结果返回给客户端;可由调用方修正的失败应放在
Result错误变体中。 - **M4 — 提示:**重复的内部类型到视图的映射。每种类型只保留一个
toView,放在lib/或types.mo中。 - **M5 — 提示:**魔法值——直接写出的限制、配额或时长字面值。在模块中为它们命名。
- **M6 — 提示:**注释断言了本可由类型承载的不变量。每条“必须非空”/“仅在……时设置”的注释,实质上都是类型编码问题。在 Motoko 无法表达该不变量时,保留注释,但在边界处验证一次该值,而不是每次使用时都重新检查。
约定
这是四个方面中影响最小的一项;而在启用 -W 标志时,mops check --fix 会修复其中大部分问题。因此,只有完成上述检查后,且仅在工具无法处理的情况下,才提出这些问题。规则见 writing-motoko;以下是需要检查的内容。
- **C1 — 提示:**调用带有
self的函数的模块函数,例如List.add(list, x)、Principal.toText(p)。M0236,可自动修复。 - **C2 — 提示:**显式传入本可隐式传入的参数(
map.add(Text.compare, k, v)),或已经推断出的类型实例化。M0237/M0223,可自动修复。 - C3 — 警告:作为调用参数传入的内联
func上的类型注解——xs.filter(func(x : Nat) : Bool { x > 1 })。没有诊断能捕获此问题,因此需要审查者检查:调用已确定两种类型,注解只是重复声明,可能导致类型不一致。应写为xs.filter(func x = x > 1)。当 M0098 要求时,应实例化调用(map<In, Out>),而不是 lambda。例外:异步回调上的: async ()会影响实际行为,不能删除。 - **C4 — 阻断项:**将
class或任何持有函数的对象用作 Actor 状态。函数不是稳定值,因此会因 M0131(“声明为 stable,但类型不稳定”)而失败。在状态之外,class 也不是此处惯用的写法——使用接收self的模块并采用点号表示法,可以得到相同的调用形式,同时保持稳定。
重构
在 Motoko 中,设计修复并非没有代价:更改可从稳定 Actor 字段访问到的类型需要迁移,而迁移链是单向的。提出类型更改前,先确认该类型是否属于稳定状态。
- 先运行
mops check --fix,消除机械性问题。 - 批量处理稳定状态类型更改——每次构建只保留一个待处理迁移,并让这些更改一起提交。
- 编写迁移(
migrating-motoko-actors)。对于Text→ variant,应明确决定无法识别的字符串要变成什么,而不是凭空添加#unknown标签。 - 先移动代码,再修改代码——先调整位置,再编辑,可以让每个差异都便于审查。
- 运行
mops check --fix直到检查通过,然后运行一次mops build。
报告
按严重程度分组,阻断项优先。每项需注明:file:line、级别、它可能导致的具体故障,以及相应的更改。编译器已报告的问题不必重复报告。
Blocker src/backend/mixins/Tasks.mo:34 (M1)
`claimTask` never reads `caller` — any principal can claim any task.
Warning src/backend/types.mo:12 (T1)
`Task.status : Text` is a closed set, so the comparison at Tasks.mo:51
spells it "complete" and is never true. Stable state — needs a migration.
如果没有问题,就直说。不要为了填满报告而捏造问题。
其他参考资料
- references/type-encoding.md — 各不变量对应的方案,以及每种方案所需的迁移
- references/state-and-persistence.md — EM 为何重要、稳定状态与临时状态的问题,以及迁移规范
writing-motoko— 语言机制、mo:coreAPI、Result、点表示法- mops 工具:加载
mops-cli,了解mops.toml配置以及mops check/mops build的详情 migrating-motoko-actors— 编写类型变更所需的迁移troubleshooting-motoko-migrations— 与源代码不匹配的兼容性诊断