mcpField
R
reviewing-motoko

reviewing-motoko

github.com/dfinity测试与质量3965 次安装今天更新
代码仓库

关于 reviewing-motoko

面向 Motoko 项目代码审查的 Skill,供评估设计质量的审查者使用,而非用于学习语法。它按类型约束、不稳定与持久状态、文件结构和 Motoko 惯例组织审查流程,并提供问题级别定义。

主要功能

  1. 01检查类型是否编码不变量
  2. 02审查稳定与临时状态
  3. 03评估文件布局和依赖方向
  4. 04定义问题严重级别
  5. 05审查前运行 mops check --fix

适用场景

  1. 01审查 Motoko Actor 的持久化
  2. 02发现类型约束不足的状态和错误
  3. 03检查模块边界与导入关系
  4. 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。

审查由四个问题决定,按以下顺序进行——前三个问题优先,是因为每个问题都可能使下一个问题失去意义;第四个问题放在最后,因为它影响最小:

  1. 代码所依赖的每项不变量是否都编码在类型中? 如果类型系统能够排除某种错误状态,却把这件事交给运行时检查或注释处理,那就是缺陷,而非风格偏好。
  2. 状态是否声明了正确的持久化方式? 在增强迁移机制下,稳定状态与临时状态是两个独立的世界。混淆二者会导致状态悄无声息地停止更新。
  3. 代码是否放在了正确的文件中? types.mo / lib/ / mixins/ / main.mo 约定了哪些内容可以依赖哪些内容,而不是一种文件夹规范。
  4. 代码读起来是否像 Motoko? 使用上下文点表示法、推断隐式参数、不添加调用中已提供的注解、不使用类。mops check --fix 会处理其中大部分问题;它无法发现的部分才需要审查者处理。

流程

先运行机械检查——不要把审查精力花在编译器已经报告的问题上。

mops check --fix

只有在 [moc] args 为相关选项启用了 -W 时,样式自动修复才会生效(M0236 点表示法、M0237 冗余隐式参数、M0223 冗余实例化);设置方法见 writing-motoko 的 references/project-setup.md。如果这些选项未启用,下面 约定 部分所列的问题都需要你手动修复。

然后按以下顺序阅读——从外向内,因为文件放错位置会让其中的所有类型都变得可疑:

  1. mops.toml——是否存在 [canisters.*.migrations] 链?如果没有,下面的发现 B1 优先级最高。
  2. 结构——先检查文件布局,再检查每个文件的导入。
  3. types.mo——检查每个类型声明。大多数发现都在这里。
  4. 状态——检查 main.mo 中的 Actor 字段,以及每个 include 传递的内容。参见 references/state-and-persistence.md。
  5. API 边界——检查每个 public shared / public query 函数:参数类型、返回类型和授权方式。
  6. 函数体——最后检查,并且只检查下面 可维护性 部分所述的问题。

严重程度

使用以下三个级别之一报告发现的问题。不要夸大严重性:把所有问题都称为阻断项的审查者会被忽略。

级别含义
阻断项可能破坏或悄悄丢失状态,或允许调用者执行未经授权的操作。
警告类型系统本可强制执行的不变量,却留给运行时、注释或约定处理。
提示可读性、重复或命名问题。在处理相关文件时修复;不要以此作为阻断条件。

描述具体会造成的失败,而不是复述规则。“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 字段访问到的类型需要迁移,而迁移链是单向的。提出类型更改前,先确认该类型是否属于稳定状态。

  1. 先运行 mops check --fix,消除机械性问题。
  2. 批量处理稳定状态类型更改——每次构建只保留一个待处理迁移,并让这些更改一起提交。
  3. 编写迁移(migrating-motoko-actors)。对于 Text → variant,应明确决定无法识别的字符串要变成什么,而不是凭空添加 #unknown 标签。
  4. 先移动代码,再修改代码——先调整位置,再编辑,可以让每个差异都便于审查。
  5. 运行 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:core API、Result、点表示法
  • mops 工具:加载 mops-cli,了解 mops.toml 配置以及 mops check/mops build 的详情
  • migrating-motoko-actors — 编写类型变更所需的迁移
  • troubleshooting-motoko-migrations — 与源代码不匹配的兼容性诊断

评论

支持 Markdown