Sign in

    moonbit_static_analysis

    Three-inspection (structural / type / behavior) static analysis for MoonBit programs, every toolchain file kind (.mbt / .mbtx / .mbti / .mbt.md / .mbtp) and state-machine tables

    static-analysis
    linter
    state-machine
    abstract-interpretation
    file-kinds
    mbti
    literate-markdown
    formal-verification
    Download zip
    Author
    Version
    0.4.23
    License
    MIT
    Last updated
    6 hours ago
    Downloads
    151

    Dependencies

    #moonbit_static_analysis

    中文 | English

    riantr/moonbit_static_analysis — 三鉴(结构/类型/行为)静态分析流水线,一个基础设施,两种用途:

    1. 程序代码修订:分析快速进化中的 MoonBit 语言的程序(未定义名、未用绑定、类型错配、死分支、不可达代码);
    2. 静态状态修订:为多层状态机提供通用机器表审计(src/statecheck)——被测对象调用本模块,把机器表作为纯数据喂进来。参考消费方是 riantr/pyroduct(主体/群体/社会/进化层状态机族),其 audit 包用真实机器表调用本模块做黑盒测试。

    一条流水线贯穿两者:结构走查 → 类型/符号 → 抽象解释 → 统一报告。

    #0.4.7 要点

    .mbti 接口审计学会了跨文件解析类型。 生成的接口文件大量引用声明在同包其它文件 (或 extern 目录)里的类型——&Show 参数、raise SnapshotError 返回、impl Debug forBenchError 的目标——此前 @moonfiles.iface 只看单文件,core 上 13 个真实 .mbti 因此被误报「未知类型」。本版为接口审计建立模块级接口符号表:对扫描目标的每个 .mbti 逐文件收集声明与 pub using 再导出后合并,解析顺序为本文件声明 → 内建名 → 类型参数 → 任一已扫接口(仅类型)。core 上 .mbti 缺陷 13 → 0,actionable 49 → 36。

    顺带修掉:pub using @pkg {type T} 形状的再导出此前一条也没被收集(24 个 type + 20 个 trait 的 prelude 段因此不可见),现在进表。

    实测(moonbitlang/core,1029 文件,同一台机器):

    文件解析actionable未定义名
    0.4.610293204917*
    0.4.710293203617

    * 勘误:0.4.6 行的未定义名发布时误记为 0,与该版自己的扫描产物不符——0.4.6 的 扫描里同样有这 17 条,本次核对原始产物后按实测更正;.mbti 修复没有触碰它们。

    剩余 36 条的构成(下一层的靶子):17 条未定义名 = 11 条跨文件函数引用(hex / base64 的 v128 bench 文件调用同包其它文件定义的 sample_bytes / encode_scalar)+ 3 条 引用子集外声明的名字(E_MAX / b / N)+ 3 条其余形态(is / e);其余 19 条 是未用绑定等其它缺陷。

    自检(扫本仓库自身源码)保持 0。测试 231 → 233。

    #0.4.11 —— 第一次扫外部代码:四个修复,全部来自实测

    把扫描器指向一个它从没见过的包:

    moonx riantr/moonbit_static_analysis@latest riantr/pyroduct@latest

    119 个文件,解析 87,actionable 43。逐条分类而不是只数一遍,发现其中 30 条是 分析器自己的缺陷。四个修复,每一条都能追到具体的发现:

    修复是什么条数
    Repr 进内建表core/debug 里 Repr 枚举的构造器,隐式可见——而不在 core/builtin 接口里(表里其它名字全是从那儿抄的)16
    插值里的 import 别名@ 不是标识符字符,所以 \{@sm.all_states()} 记录别名时丢了 @,walk 里 !has_prefix("@") 那道闸根本看不见。这是 0.4.8 自己引入的回归6
    extend 是声明当成语句读时,extend 变成了一个名字8
    C 风格三段式 forMoonBit 的另一种 for;把它的头部当绑定变量读,就会要求 in覆盖率

    Repr 是用工具链定的,不是推断的:同一个插值里同时写 Repr(1) 和 ZzNoSuchName123(2),moon check 只报了第二个。

    覆盖率 73.1% → 86.5%(87 → 103 / 119)。真正推动覆盖率的是 for 修复,而且 是在修复自身的第二个 bug 之后:用来区分两种 for 的前瞻,最早那版扫描 深度 0 的 ; 时没有在函数体的 { 处停下,于是文件后面只要出现一个 ;, 绑定形式的 for 就会被误判成 C 风格,然后死在 expected ';' 上。它把本该 恢复 12 个文件的结果变成 4 个,把错误搬走了而不是消掉——15 条 expected 'in' 变成 14 条 expected ';'。现在钉住这一点的测试,就是照着 这个失败写的。

    for 被降解成 SBlock[SAssign, SWhile],而不是新增 AST 节点——因为每个 lens 都对 SKind 做穷尽匹配。它唯一的不精确之处(update 子句在循环后跑一次,而不是每轮 一次)是刻意的,且偏向安静那一侧:update 被保留,因为 i = i + 1 正是 i 被 读的地方,丢掉它会让每个循环计数器看起来都没被用过。这一点有测试钉住。

    四个修复合计:actionable 43 → 31,其中 26 条属于同一个已知限制——调用同包 另一个文件里的包私有辅助函数,.mbti 解析看不见它。这已经是剩余问题里 占比最大的一类,0.4.12 关掉了它(见下节)。

    测试:js 215 / wasm 285 / wasm-gc 215。自检保持 46/46、actionable=0。 五道新防护全部做过突变验证。

    #0.4.12 —— 包私有跨文件解析:剩余最大的一类被关掉

    0.4.11 剩下 31 条 actionable 里的 26 条属于同一件事:调用同包另一个文件里的 包私有顶层名字。这不是"限制",是分析器的缺口——MoonBit 的一个目录就是一个包, 同目录任何 .mbt 声明的顶层名字在兄弟文件里都可见,而分析器只经 .mbti 解析跨文件 名字,生成的接口里只有 pub。

    先把结论量出来,再动手:

    • 26 条涉及的 11 个名字(sieve、ridge_fit、ridge_predict、ols_core、 normal_cdf、fmt、claim_code、claim_of、phase_code、contains_state、 has_state)全部是同目录兄弟文件里的包私有顶层 fn——26/26,没有例外;
    • 工具链确认语言确实允许:两文件包里一个文件调用另一个文件的私有 fn, moon check 与 moon test 都通过,跨 _test.mbt 也一样(has_state 正是这一种)。所以这 26 条全是对分析器的误报。

    做法是每个目录一张表:把该目录下所有 .mbt 的顶层声明收进来,与跨文件表求并, 只给这一目录的文件用。@parser.collect_declared_names 是只读 token 的扫描 (和 harvest_ctors 同一手法:fn / struct / enum / trait / type / const / let / extern / suberror),不是解析——调用方只要名字,而词法是解析的 便宜那一半。

    三条边界,都是刻意钉住的:

    1. 只取深度 0。 函数体、test、extend、impl 里的绑定都在至少一层花括号 之下,永远不会被收进来。否则一个局部 let 就能让自己全包可见。
    2. 名字必须是关键字后紧邻的那个 token。 let (a, b) = ... 后面跟的是 (, 什么都收不到。
    3. fn Foo::new 按整体收成 Foo::new,不是 Foo——Foo 未必存在。

    被分析的文件自己也在自己那张表里:它本来就是自己包的一部分。副作用是同文件 顶层 const 也不再被误报(走查本身不注册 const 声明,这一直是个坑)——这是测出来 的,不是顺手带出来的。

    IfaceSymTab::merge 是原地改接收者的,所以这里用的是新的 IfaceSymTab::union:合并进共享表会把一个包的私有名字发给另一个包,而符号表里每个 名字都是一次抑制,跨包泄漏只会让另一个包里真正未定义的名字不再上报。

    pyroduct actionable 31 → 5,总发现数 658 → 632,正好 −26:既没有新发现冒出来, 也没有误伤被移走的条目。剩下 5 条:paths.mbt 两条 OperatorMismatch、 algebra.mbt 的 _classes、slot.mbt 的 self、audit/mbti.mbt 的 _f。

    自检 46/46、actionable=0。输出多一行 PRIVTAB packages=N names=M——没查过的表和 查过是空的,看起来是一样的,所以它得被说出来。

    测试 js 220 / wasm 292 / wasm-gc 220。七道防护做了突变验证,其中一道是突变找出 来的测试漏洞:把 union 的 types 半边改成写进 self,整个套件仍然全绿——因为原 测试只查了值那一半。补上类型侧之后才变红。

    #0.4.23 —— raise 效应标注,以及一个被它照出来的假阳性

    先更正我自己的一个数。 这一版开工时我说「moui 608 个文件里 149 个(24.5%)带 -> T raise E,全部完全不可解析」。那个 grep 把 vendored 的 .mooncakes 一起数了, 而扫描器根本不扫 vendored 目录。排除之后 moui 只有 2 个文件。同一把尺子量六个语料 (排除 .mooncakes):

    语料文件签名位 -> T raise类型位 () -> T raise
    moonbitlang/async 0.22.44097210
    moonbit-community/proton 0.3.41367033
    riantr/moonbit_image 0.3.717140
    proton_contract 0.3.4320
    riantr/moonbit_labeler 0.2.133521
    wzzc-dev/moui 0.1.1260720

    修复是真的(moonbit_image/bmp.mbt:26 的 -> Image raise Failure 现在干净解析, decoded_bmp 之前死在 expected '{'),但规模我之前报大了一个数量级。可读文件总数 787 → 825,不是 149 个文件的量级。

    #一条子句,两个位置

    parse_fn_signature 读完 -> T 之后直接期待 {,不认 raise。settle 过了,四种形式 (-> T raise / -> T raise E / -> T! / -> Unit raise E)moon check 0 错误。 裸形出自 moonbitlang/core/error/error_test.mbt,所以它不是这里发明的形状。

    写完签名位才发现漏了类型位。 函数类型自己也可以带效应标注——fn f(g : () -> Int raise) 在 settle 里同样是 0 错误,但解析器报 expected ')' 并且丢掉文件后半段。只修签名位会留下 一个「签名位置认、类型位置不认」的半截修复:proton 33 个文件、async 10 个正好是这个形状, 每一个都会死。两个调用点现在共用 Parser::parse_raise_effect。

    裸形「必须停在这里」的判据写成了**「下一个 token 能不能开一个类型」**(TName / TLParen / TAmp),不是「哪些 token 终结」。因为两个方向不对称:多读一个 token 是静默误解析 (函数体被吞进类型标注,什么都不报);少读一个 token 是响亮的 expected '{'。

    #顺带照出来的假阳性:guard!

    parse_guard 读完 guard 就去读条件,于是 guard! cond 的 ! 掉进表达式读取器,变成对条件 取反。报出来是 operand of '!' must be bool, got '[int]'——一个关于能编译的代码的、语气 笃定的假阳性:

    // moonbitlang/async 0.22.4 src/internal/fd_util/pipe.mbt:31
    fds is [r, w]

    这个缺陷一直在,只是被「文件压根没解析」挡着。raise 修复只是不再替它遮丑。

    于是 TBang 这一个 token 在同一个文件里有三个主人:raise 短式、guard!、以及真正的布尔 非。三道对照测试各钉一个——! 短式、裸 guard、! 仍然是非运算符。任何一处改成扫 token 而不是按位置判定,这三条里必有一条会红。

    #代价:actionable 涨了,而且涨的全是 UndefinedName

    可读文件变多,每多一个可读文件就多一批 finding,其中一部分是假的。这必须说清楚:

    语料0.4.22 parsed / actionable0.4.23 parsed / actionable发现级
    wzzc-dev/moui 0.1.12parsed 481 / actionable 1parsed 483 / actionable 10 条新增
    moonbitlang/async 0.22.4parsed 206 / actionable 17parsed 216 / actionable 22+5 UndefinedName
    moonbit-community/proton 0.3.4parsed 72 / actionable 43parsed 95 / actionable 103+60 UndefinedName
    riantr/moonbit_image 0.3.7parsed 1 / actionable 0parsed 2 / actionable 3+3 UndefinedName
    proton_contract 0.3.4parsed 3 / actionable 1parsed 4 / actionable 3+2 UndefinedName
    riantr/moonbit_labeler 0.2.13parsed 24 / actionable 0parsed 25 / actionable 00 条新增

    按 family 数过:新增的 actionable 一条 UndefinedName 都不例外,UnusedParam 不变, OperatorMismatch 因为 guard! 修好反而减少(2 → 0)。抽查确认是假阳性:

    // proton facade_error.mbt:6
    OperationFailed(action~, status=..., detail=...)
    // 报: no visible binding for 'OperationFailed'

    OperationFailed 是 WindowSessionError 的构造子,全语料有四处真的这么用。它的声明 (enum / suberror / derive)在分析子集之外,body 没被分析,名字就永远进不了符号表。 根因是既有的子集边界,不是这一版的解析改动——这一版只是让这些文件第一次能被读,于是 第一次能看见这个洞。它现在是 actionable 噪声的第一大来源,比 raise 本身更值得下一版处理。

    #突变门禁:9 道,9 杀 0 存活(_verify/mutate_0423.ps1)

    比 0.4.22 的 6 杀 2 存活干净。几道值得记:

    • M7 / M8 是同一个守卫的两个方向。 守卫放行一切 → 裸 raise 吞掉函数体的 { 或闭合的 ),静默误解析,4 条测试变红;守卫什么都不放行 → 每个具名效应都不再被消费,6 条变红。 只测一个方向,会把「过严」误当成「正确」。
    • M5 杀掉的不是 skip_indexed(),而是 raise 关键字自己。 第一版我把 skip_indexed() 去掉当 M5,结果 371/371 全绿——它是个等价突变:type_annotation 自己 就调 indexed_suffix(),raise E[Int] 不靠那行也能整块吃掉。这行死代码删掉了,理由写在 调用点:留着会让下一个人以为它在管一个它其实没管的用例,而删掉它的人没有任何证据说明有东西 在查。
    • M2 第一次报的是 COMPILE-ERR,那是门禁自己的 bug 不是结果。 我的 Find 串以换行开头, 替换后代码被并进了上一行注释里,突变根本没运行。判据只有一个:Total tests: 行不存在 才算 COMPILE-ERR,绝不 grep。

    #两个新记下来的缺口(这一版量到,没修)

    • 返回类型在尾表达式函数体上渲染成 None。 fn f() -> Int { 1 } → f() -> None, 而 fn f() -> Int { return 1 } → f() -> int。跟 raise 无关:四种 raise 形式配上/不配上 显式 return 全部一致。真实 MoonBit 大量用尾表达式,所以这个的语料影响大概率远大于 raise 本身。 0.4.23 的测试因此刻意用显式 return——不然这条断言会因为一个和效应标注毫无关系的理由变红。
    • 返回位置的带括号函数类型被当成元组。 fn f() -> (() -> Int) { 报 expected '{', 没有 raise 也一样(return_annotation 的元组分支停在第一个 ) 而非配对的那个)。 真实的 -> (Int, String) 正常。语料影响小:moui 6 个文件,其余语料 0。下一版处理。

    测试 js 374 / wasm 446 / wasm-gc 374(新增 14 道)。

    #0.4.22 —— 具名实参按名字匹配,以及 Int32 读作 Int

    可读文件数不变(481),但 moui 的 actionable 8 → 1。消失的 7 条全是同一个形状 argument 4 ... has incompatible type 'str'; expected 'bool',从 core/gesture.mbt:754 开始:

    // core/pkg.generated.mbti:92 的声明顺序
    ActionCommand::new(intent~, label?, shortcut?, enabled?, group?, description?)
    // core/gesture.mbt:754 的调用顺序
    ActionCommand::new(intent=..., label=..., group=..., description=..., shortcut=...)

    MoonBit 允许具名实参任意重排(moon check 确认),所以实参的下标不等于形参的下标。 旧代码比较 args[3] 和 params[3],于是 description(String)落到 enabled(Bool) 上。现在每个具名实参按标签找形参,只有没有标签、或标签谁也不匹配时才退回按位置。

    ~ 简写(f(x~))同样是一个标签,从 token 流里把名字读回来(Parser::name_at), 所以它也能重排。

    形状结果
    A具名实参重排(moui core/gesture.mbt:754)actionable 8 → 1
    Bann_to_ty 少一个 "Int32" 分支修掉格式化器给自己源码造的 87 个静音点

    反向对照是这一版最要紧的东西。 A 的断言是 0 条,而"把检查整个关掉"同样会得到 0 条 ——所以必须有一条断言 1 条 的测试才让它有意义。九条测试成对写:具名错类型、位置错 类型、标签谁也不匹配退回位置、~ 简写重排对与错、位置元数、? 可选参数省略、 Int32 同义与不同义。

    突变门禁抓到了三条当时看不见的东西(_verify/mutate_0422.ps1,8 道 6 杀 2 存活):

    • 夹具里多一行 let r = build() 会让三条突变全部存活。 实参类型不符时行为透镜会把 同一个调用报成 MethodError,和类型透镜的 ArgTypeMismatch 合并(merge_findings 按 (merge family, 行号) 分组),于是 count_family("ArgTypeMismatch") 恒为 0。 把 param_index 改成永不匹配,432 个测试照样全绿。 零计数断言只有在它数的那个 finding 真的以那个 family 到达时才可能失败。
    • 两行同线会塌成一条。 同上,merge_key 是 (merge_group, line);两个错实参写在 一行只回来一条,所以期望 2 的用例必须拆行。
    • parse_call_args 有四个 push 点。 「不以名字开头的实参」那一个不是「以名字开头」 那一个;少 push 一次不会报错,只会让 labels 比 args 短,缺口之后的每个实参读到的 是后面某个实参的标签。补的夹具是 g(1, b=true)——顺带settle出一条语义: ? 可选形参根本不能按位置传(moon check 报 "requires 0 positional arguments"), ~ 必填标签的也不能,只有裸的 a : Int 才行。这也正是具名实参在真实 MoonBit 里 到处都是、而按位置匹配误报那么多的原因。

    两道存活的突变,如实记账而不是补测试把它做绿:

    • M7 all_positional 门(具名调用不查元数)。 没有任何测试能看见它,因为没有任何 合法 MoonBit 会用具名实参传超过形参个数——settle 过了:标签重复是 4084,标签匹配不 到形参是 4085,都是 moon check 已经报出来的编译错误。它的代价是量过的:六个语料 12,428 个文件里,它只影响 1 条 finding(moui backend/web/web_host.mbt,且在该文件 的可靠区边界之后,落进 unreliable 桶、从不作为结果显示),而那条无法确认是真阳性。 写一条钉住「无合法程序会表现出的行为」的测试会让门禁变绿、让套件变弱。
    • M8 方法调用标签。 EMethodCall 现在带标签,但 infer 直接丢弃 (EMethodCall(base, _, args, _)),因为本模型里方法实参根本没有类型检查——没有 接收者签名可查。这是真缺口,不是设计上的宽容:要杀掉它得先有方法接收者签名,比这一版大。

    Int64 / UInt 故意不并到 TInt。 moon check 确认 UInt 传给 Int 形参是 Expr Type Mismatch,反过来也是,Int64 对两者都是。并进去不是无害的加宽,而是精准 静音 moon check 恰好会报的那些不匹配——正是 0.4.5 的失败模式(前置条件永不满足的 检查)符号反转。它们读作 Unknown,等各自的 Ty 变体和可赋值规则。给后续的人一个规模: proton + async + moui 一共 122 处 : Int64、23 处 : UInt。

    测试 js 360 / wasm 432 / wasm-gc 360(新增 17 道)。自检 59/59、actionable=0、 unreliable=0、withheld=0。

    语料0.4.210.4.22发现级
    wzzc-dev/moui 0.1.12parsed 481 / actionable 8parsed 481 / actionable 17 条假阳性消失,0 条新增
    riantr/moonbit_labeler 0.2.13parsed 24 / actionable 0逐字节一致—
    moonbitlang/async 0.22.4parsed 206 / actionable 17逐字节一致—
    moonbit-community/proton 0.3.4parsed 72 / actionable 43逐字节一致—
    riantr/moonbit_image 0.3.7parsed 1 / actionable 0逐字节一致—
    proton_contract 0.3.4parsed 3 / actionable 1逐字节一致—

    #0.4.21 —— 436 → 481:guard、具名实参的 lambda、跨行的 is,和一个只绑第一个名字的 is

    可读文件 436 → 481(71.7% → 79.1%),withheld 1078 → 242,unreliable 3025 → 1711,unexpected token 163 → 113,moui 的 actionable 10 → 8。 embedded_runtime_backend.mbt 上那 7 条(3× image_state、4× host_bridge) 全部消失。

    四个独立形状,都是先量后断言:

    形状语料处
    Aguard COND else { ... } 被当成标识符550 处 / 126 文件
    B具名实参的值是 lambda,参数列表在那里断掉解析 436 → 481
    Cis 不跨行续接,文件照样解析成功embedded_runtime_backend.mbt:227
    Dis 只绑第一个名字,而且只在 depth 17 条 + 全语料 13 处

    #C 是这个项目里最难查的一种坏:解析成功,树是错的

    guard (self.frame, self.image, self.host_bridge)
    is (Some(frame_state), Some(image_state), Some(host_bridge)) &&
    !self.disposed else {

    换行结束表达式,is 被读成普通标识符,然后文件解析成功,只是那个点之后 每个名字都是错的——15 条发现,而且因为什么都没失败,一条都没被标成 unreliable。所以这道门禁不断言 n_parse == 0(它两种情况都绿),断言的是 续接之后的那些名字能解析。

    续接只跳 TNewline,不能用 skip_seps——它也吃 ;,而分号在 C 风格 for 里是结构性的。写成 skip_seps 时是既有测试把它抓红的,这条以 0421: a C-style for body is still analyzed (control) 钉住。

    #D:先 settle 再动手,结果和我的判断不一样

    我以为 Some(Os(error)) 里的 error 本来就绑着,只是没人用。先跑 moon check:

    fn f(x : Int) -> Int { guard try_os(x) is Some(Os(error)) else { return 0 } error + 1 }

    moon check 接受,error 确实是绑定。原来的读取器只读 depth 1,于是 Os 成了绑定名、error 掉了下去,它的每一次使用都报 undefined variable。depth 3 的 Some(DragLeft(Some(event_position))) 同理。

    规则收敛成一个判定,两个分支共用同一个读取器(0.4.18 和 0.4.20 各栽在 "第二个名字读取器会和第一个漂移"上一次,这次写成带下标的谓词,让它没法漂):

    • 不是 _,不是关键字
    • 后面不是 (——那是嵌套构造器,不是绑定
    • 左右邻居都不是 .——@core.Role::Checkbox 是限定路径,语料里 18 处
    • 第一个 : 收尾——后面是类型标注,Int 不是绑定;:: 正好走到这条

    #差分抓出了我自己引入的回归

    proton internal/bridge_admission_test/main.mbt:93:

    runtime.prepare_bridge_request(request, window) is (Gone | PageUnavailable)

    那个 ( 是分组括号,不是构造器载荷,于是 Gone 和 PageUnavailable 被当成局部绑定,报出两个并不存在的未使用局部。其中一个是我这次新引入的 ——旧读取器只取第一个名字,所以从来只绑过 Gone。元组分支因此改成 depth >= 2(真正的元组绑定在 Some( 里,深一层)。这条不是推理出来的,是 六语料逐条发现级差分跑出来的,加了 M8 钉住。

    #差分

    语料0.4.200.4.21发现级
    wzzc-dev/moui 0.1.12parsed 436 / actionable 10parsed 481 / actionable 8多 45 个文件变可读
    riantr/moonbit_labeler 0.2.13parsed 23 / actionable 3parsed 24 / actionable 03 条 undefined function '@core.X::new' 消失
    moonbitlang/async 0.22.4parsed 206 / actionable 29parsed 206 / actionable 1712 条 undefined name 'guard' 消失,0 条新增
    moonbit-community/proton 0.3.4parsed 71 / actionable 44parsed 72 / actionable 432 条 guard 消失,1 条新增见下
    riantr/moonbit_image 0.3.7parsed 1 / actionable 0逐字节一致—
    proton_contract 0.3.4parsed 3 / actionable 1逐字节一致—

    proton 唯一那条新增是 internal/bridge_admission_test/main.mbt 里的 undefined name 'nobreak'——一个刚变可读的新文件,nobreak 是本前端 还没实现的循环修饰符。不是这次改动造出来的假阳性,是新文件暴露出的真实缺口。

    #突变 8 道全杀,两个方向都有

    M1 guard 不解析、M2 丢掉 guard 的 else 块(控制测试才抓得住:条件里的 名字照样解析,不丢 else 的话两边一模一样)、M3 具名实参 lambda 头关掉、M4 跨行 续接去掉、M5 只绑第一个名字、M6 限定路径规则去掉、M7 嵌套构造器规则去掉、 M8 or-pattern 深度规则去掉。8/8,每一处还原都按 SHA256 验证过逐字节一致。

    #没修的,如实记账

    moui 剩 8 条 actionable,全部落在两个已知项上:navigation_routing.mbt 1 条 和 gesture.mbt 6 条是具名实参按位置比对(TFunc 不带形参名,要改整条签名 流水线);view_tree.mbt 1 条是 expr as Type,AST 里没有 ECast。

    另外两条量出来但没动的已知限制,因为它们不在这一轮的形状家族里:

    • 调用函数类型的参数会报 undefined function:let f = inc 然后 f(1) 就报,文件里根本没有 lambda。这是 0.4.19 加函数类型注解带出来的既有假阳性。 我的门禁夹具一开始踩到了它,才写进测试文件头的告诫里。
    • is Some(x : Int) 的类型标注:全语料 0 处(18 处 is X(...) 里带冒号的 全是 @core.Role::Checked 这种限定路径)。规则里留了 : 收尾,但没有语料 验证过,不据此宣称已覆盖。

    #工具链在这次发布当天动了,CI 一开始是红的

    v0.4.21 的 tag 一推上去,gate 就红在 moon fmt --check,而 moon check 是绿的。两个 workflow 都从 curl .../install/unix.sh | bash 装 moon——不钉版本,装的是 latest。

    先量,再决定怎么修:

    1. 本地 moon fmt --check 是绿的,所以不是我这棵树的问题。
    2. 把已发布的 v0.4.20 tag 检出,用同一个新 moon 跑 moon fmt --check—— 也红。所以这是早就存在的环境问题,跟任何一次改动无关:上游一改 格式化器,不钉版本的 gate 就会在任何 commit(包括 revert)上变红。
    3. 新 moon 是 0.1.20261012(当天发布),本地是 0.1.20260920。差异不是 空白:它把 src/core/moon.pkg 的 options("proof-enabled": true,) 改成 proof_enabled = true,全树 19 个文件要重排,其中还有 Int → Int32 的类型改写。
    4. 先试钉版本——最省事。MOONBIT_INSTALL_VERSION=0.1.20260920 装到 CI 上,Install moon 这一步直接失败:CDN 上已经没有这个版本的 Linux 二进制。钉版本这条路是死的,因为旧的会被删。
    5. 所以只剩一条:接受新工具链的格式化。

    接受之前先量它到底改不改行为:新 moon 跑未重排的 0.4.21 源码, moon check 退出 0、moon test 415/415 全过——说明 Int → Int32 只是 格式化器的规范形式,不是编译要求。重排之后再量一遍:check 0、三目标 343/415/343 全过、自检 59/59 actionable=0、六个语料的 SUMMARY 逐字节 一致。所以这 19 个文件的改动一个可测量的数都没动,Int → Int32 也 不是新要求。

    #顺带发现:突变脚本会静默失配

    重排把 .mbt 从 LF 变成 CRLF,而 mutate_0421.ps1 自己还是 LF(它不是 MoonBit 源码,格式化器不管它)。于是每一道跨行 pattern 都匹配不上,八道 突变里有六道报 NO-OP。

    门禁是响亮地失败的(NO-OP 计作失败,这正是它该有的行为),但一个会静默 失配的门禁离「装饰品」只差一次编辑。所以改成两边归一化换行再比对,写回时 沿用文件原有的风格——改完之后八道全杀,而且每次还原的 SHA256 仍然逐字节一致, 这反过来也证明了归一化的往返是对的。

    #测试

    415 道(js 343 / wasm 415 / wasm-gc 343)。自检 59/59,actionable=0、 unreliable=0、withheld=0。

    #0.4.20 —— 一个方法被当成了顶层函数

    impl T for U with fn m(...) { ... }。可读文件 432 → 436,withheld 176 → 172, unreliable 3193 → 3025,moui 的 actionable 14 → 10:四条 undefined name 'self' 消失。

    #我第一版的测试是空的,突变抓出来了

    第一版只断言「self 没被报未定义」。把修复回退掉,整个套件照样全绿。 原因不在直觉里,在 Parser::skip:它只记一条 note,一个 token 都不消费。 所以修复前 cursor 停在 fn 上,顶层循环把 with fn identity(self) { ... } 当成一个普通顶层函数重新读了一遍——self 一直都绑定着,语料那条告警 从来就不是「缺绑定」。

    真正变掉的是收割(harvest):collect_declared_names 看到的是 depth 0 的 fn,于是把方法名 identity 记成了包级 value,包里任何地方调用 identity 都会解析成「已定义」。

    #改了两个地方,因为有两个独立的读取器

    1. skip_decl_braced:fn 哨兵是给 pub impl Eq for T(无体,形参在另一个 块里)准备的,with 只出现在有体的那种写法里,所以拿 with 当区分。
    2. collect_declared_names:完全独立的第二个名字读取器,根本不经过 skip_decl_braced。不给它自己加护栏,幽灵方法名原样回来。

    (这已经是 0.4.18 的同一个教训第二次出现了:泛型函数名进不了 sibling 表, 也是因为这个。)

    语料上量过:1224 个 impl ... with 头(221 个文件),其中 1047 个写 with fn,另外 177 个省略 fn(pub impl ToJson for Affine withto_json(self : Affine))。那 177 个从来没碰到哨兵、一直是对的;1047 个全错。 1047 个头里没有一个带多于一个 fn,所以停在第一个 body 之后不会留下悬空。

    #trait 自己的名字必须留着

    第一版把整个 holder 挡掉了,于是 trait Marker { ... } 的 Marker 也收割不到, 把 pyroduct 那个既有测试弄红了。trait 声明的类型是包级的,要留; impl Eq for T 和 extend 声明任何东西都不声明——Eq 是被实现的 trait, 声明在别处——所以只让 trait 贡献名字。这条单独用 M4 钉住。

    #收割那道修复在 moui 上看不到收益,这是预期

    语料数字和只改 parser 时一模一样(436 / 10)。因为它修的是造出来的绑定 ——一个假的阴性,不是少报一条可见的发现。它防的是「包里调用一个方法名, 被解析成已定义」。moui 里没有这种调用,所以量不出来。它的门是 0420: an impl method is not harvested as a top-level function, 只测收割器本身。

    #差分

    语料0.4.190.4.20
    riantr/moonbit_image 0.3.7parsed 1 / actionable 0六项指标逐字节一致
    riantr/moonbit_labeler 0.2.13parsed 23 / actionable 3parsed 23 / actionable 3
    moonbitlang/async 0.22.4parsed 196 / actionable 30parsed 206 / actionable 29
    wzzc-dev/moui 0.1.12parsed 432 / actionable 14parsed 436 / actionable 10

    自检 56/56,actionable=0、unreliable=0、withheld=0。

    #突变 4 道全杀,而且是两个方向

    M1 拿掉 with 例外(修复消失)、M2 把哨兵整个关掉(控制测试才抓得住: 无体 impl 会一路吞掉后面整个文件)、M3 拿掉收割护栏、M4 不收 trait 名字。 M2 是这个门禁存在的理由——只测「修复没了」是不够的。

    #0.4.19 —— 389 → 432:一个字面量被当成绑定名,和一个把文件读废的类型注解

    接着用 wzzc-dev/moui 0.1.12。读得懂的文件 389 → 432(71.1%),withheld 219 → 176,unreliable 4388 → 3193。

    #if x is Some(true) 把 true 当成了绑定名

    core/view_node_test.mbt 一带报了 local variable 'true' assigned but may not beused。parse_is_pattern 把 payload 里任何名字都当成绑定——而绑定的前提是 「这个名字引入一个变量」,关键字不引入。

    判据用 @lexer.is_moonbit_keyword,不在 parser 里另抄一份。那张表本来就含 true/false,而且里面有一段长注释专门说明为什么 self 不在表里;另抄一份 正是两边漂移的开始。self 因此照样能绑定(if x is Some(self) 就是这个意思), 这条也进了测试当反向对照。

    #函数类型注解:845 处 / 171 个文件

    on_input~ : ((String) -> Msg)?、key : (T) -> String、emit : (Msg) -> Unit。 ( 原来直接撞上 expect_name,报 "expected type name",文件到此为止。

    注解的元素由 type_annotation 自己读,所以嵌套自然成立——((String) -> Msg)? 是「返回函数类型的函数类型」。渲染是如实渲染的,不是拍平,所以交给类型镜头的 字符串还是原来那句话;ann_to_ty 对 ( 开头的注解没有分支,返回 TUnknown, 这是诚实的答案:镜头不建模函数类型,而 unknown 才能让上面任何操作保持安静。

    返回位置多一个坑:return_annotation 的元组分支接受任何 (。第一版用 「三个 token (, ), ->」去认函数类型——那只匹配 () -> T,因为 (Int) -> String 的括号中间有个 Int。是那个夹具(4 个 parse error)把 这条抓出来的。改成扫描到配对的 ) 再看后面是不是 ->。

    #诚实记账:这一版 actionable 从 4 涨到 14

    多读 43 个文件,必然多出 9 条新发现。其中 7 条是假阳性,来自三个这轮只是 「变得可见」的类别,都已量过、已写进 SKILL:

    类别条数语料规模根因
    具名实参按位置比对5ActionCommand::new(...) ×5调用省略了有默认值的参数并按名字传剩下的,镜头丢标签按位置比,于是 description(String) 对上了第 4 个形参 enabled(Bool)
    impl ... with fn name(self) 的 self41047 处 / 204 文件skip_decl_braced 的哨兵放弃 impl 体,形参没有绑定
    expr as Type 转换129 处 / 21 文件as 没有处理,当成裸名字

    前两类都需要改 AST(ECall 的实参要带标签;Param 要记有没有默认值), 不是一处几行的改动。这轮不做,写下来当下一轮的账。

    行首 is matcher 那个(f(x) 换行后接 is Some(_))这轮没有 settle 掉: 最小探针复现不出合法形态,所以没有据此写修复,仍然是已知缺口。

    #差分

    语料0.4.180.4.19
    riantr/moonbit_labeler 0.2.13parsed 22 / actionable 3parsed 23 / actionable 3
    riantr/moonbit_image 0.3.7parsed 1 / actionable 0六项指标逐字节一致
    wzzc-dev/moui 0.1.12parsed 389 / actionable 4parsed 432 / actionable 14

    labeler 的 unreliable 360 → 352,actionable 一条没动。自检 55/55, actionable=0、unreliable=0、withheld=0。

    #突变:一道杀不掉,而且应该杀不掉

    4 道突变 3 杀。第 4 道把「每个 ( 开头的返回都当函数类型」,活了下来—— 原因是诚实的:对镜头读得懂的任何注解,元组分支和函数类型分支消耗的 token 完全相同,没有 -> 时 fn_type_annotation 渲染完就返回,ann_to_ty 两边都得到 unknown。所以 n_parse 分辨不了它们,这道突变本质上不可分辨。 元组那条测试仍然留着——它不是给这道突变当击杀的,是防将来把元组解析改坏的。

    还有一处工具选错了:is_moonbit_keyword 的 .mbti 导出没有突变覆盖—— moon test 从源码编译,而只有自检读被跟踪的 .mbti 接口文件;把那行 删掉,391 个测试照样全绿。这个洞是真的(0.4.18 已经踩过一次 TTilde), 所以发布检查里跑自检并要求 actionable=0。

    #0.4.18 —— 换一个语料:608 个文件里 193 → 389,actionable 588 → 6

    前十七版的语料都是本项目熟悉的东西。这轮换了一个从未扫过的包: wzzc-dev/moui 0.1.12(608 文件)。选它的理由只有一条——moon check 能跑通, 所以凡是它报出来的问题,要么是解析器读不懂的合法 MoonBit,要么就是真缺陷。 没有第三种可能。

    结果:193/608(31.7%)读得懂,六处修复后 389/608(63.9%);actionable 从 588 降到 6。

    #先 settle,再动手

    六处里有一处的根因是错的,而且是我自己以为对了的那种。data : &RuntimeViewNode 里的 & 我一直当「引用类型」。moon check 说不是:

    Error [4100] x : &Node ╰── The type Node is not a trait Error [4091] x.n ╰── @moonbitlang/core/ref.Ref[Node] has no field n

    &T 是 Ref[T],而且 T 必须是 trait——moui 只在 trait 上写它 (&RuntimeViewNode),那才是能过编译的形状。修法因此不是「加个引用类型」, 而是在 type_annotation 里把 & 收掉、折进类型名,让 ann_to_ty 老老实实 返回 TUnknown(和 Map[K,V] 同一个答案)。

    ~ 也是先 settle 才知道有两个面:编译器原话是 The labels f~ are required by this function(声明侧)与 This function has no parameter with label x~(调用侧)。

    #六处修复,各自的账

    修复形状语料里的量
    &TRef[T] 注解31 处 / 13 文件
    ~label~ : T / x~594 文件
    fn[T]类型参数1563 处 / 266 文件
    b"..."字节串字面量878 处 / 134 文件
    { ..base }struct update370 处 / 88 文件
    .m(...)行首点号续链1124 行 / 131 文件

    可读文件数逐条:193 →(&)195 →(~)313 →(fn[T])342 →({..})373 →(.)389。

    #三条「读到了但丢掉」,不是三条规则

    b"..."、{ ..base }、行首 .m(...) 链,这三处如果只把 token 吃掉, 会各自造出一个新的假阳性:链和 spread 里的实参是真实表达式,丢掉就等于 把「只在链里用过的参数」报成 unused。所以三处都保留了读:

    • spread 的 base 以空名压进字段表——walk/infer/interp 三家都写成 let (_, v) = f,只看值不看名,所以空名对它们不可见,而表达式活着, self 就还是被读到的;
    • 行首链复用真正的 postfix 构造器(把 parse_postfix 拆出一个 parse_postfix_from),链段于是成为 EMethodCall,实参自然在树里。

    #一处如实记账:b"..." 没有换来可读文件

    改了之后 moui 的 parsed 数一个没动(342 → 342)。因为 b" 在 moui 里从来 不是任何文件的第一个错误,前面总有别的构造先挡住。这是个真的解析器缺陷 (突变验证证明:不修就会 undefined variable 'b' + expected ')'),只是 这一版语料上测不出收益。留着,账照实记。

    #突变验证里,脚本自己抓到了自己的错

    12 道突变,11 道被杀。第 12 道(链的 rewind)故意标成 SURVIVED:把 self.p = save 去掉之后,cursor 停在分隔符之后——但外层语句循环本来就会再调 skip_seps,所以结果完全一样。那行是防御性的,没有任何测试或语料能区分它。 留着它(让循环自洽,而不是靠调用方兜底),但不假装它被覆盖了。

    更值得记的是脚本自己出的错:第一版用 Parse error 判断 COMPILE-ERR, 结果它匹配上了我自己那条名字里带 "parse error" 的测试,把一道杀了 2 条 的突变报成「build 坏了,不算证据」。改成用 Total tests: 行是否存在来判断, 脆弱的探测器会把一个真修复挡在发布外面,这比没有探测器更糟。

    Buffer 那道也先写了错版本:只让它出现在类型注解里,而 .mbt 前端对 未知类型名根本不开火——测试恒绿,突变 M12 活了。语料里真正的形状是 Buffer(size_hint=12),构造函数调用。按语料改完才杀掉。

    #差分

    语料0.4.170.4.18
    riantr/moonbit_labeler 0.2.13parsed 18 / actionable 3parsed 22 / actionable 3
    riantr/moonbit_image 0.3.7parsed 1 / actionable 0parsed 1 / actionable 0(逐字节一致)
    moonbit-community/proton 0.3.4—parsed 70 / actionable 45
    moonbit-community/proton_contract 0.3.4—parsed 3 / actionable 1
    moonbitlang/async 0.22.4—parsed 196 / actionable 30
    wzzc-dev/moui 0.1.12parsed 193 / actionable 588parsed 389 / actionable 6

    labeler 的 unreliable 从 612 降到 360,withheld 从 179 降到 146,actionable 一条没动——只读得更多,没有新增判定。moonbit_image 六项指标全部相同。

    自检:54/54 文件读懂,actionable=0、unreliable=0、withheld=0。

    (中途这里也出过一次真问题:加了 TTilde 之后自检冒出 2 条 undefined name 'TTilde'。不是代码错,是 src/lexer/pkg.generated.mbti 这个被 git 跟踪的生成文件没跟着更新——moon update / moon doc 都不会 重新写它。补进去之后自检回到 0。)

    #还没修的(都是 parser,行为上有意不改)

    • is 打头的表达式(f(x) 换行后接 is Some(_))会被读成裸名字 is, 报 undefined name 'is';if x is Some(true) 会把字面量 true 当成绑定名, 报 local variable 'true'。两处共 3 条 actionable,都在 core/view_node_test.mbt 一带。
    • impl ... with fn layout(self, context) 里未标注类型的 self 报未定义, 1 条。
    • 三处 parser 补完之后 219 个文件仍读不到全文,主因还是 unexpected token。

    #0.4.17 —— 11 → 9:一条的根因是「上一版把探针写坏了」,另一条是「读到了却丢掉」

    0.4.16 剩 11 条。这轮回头查了 0.4.16 判定为不可观测、因而撤回的那条 Array::make。结论是:不可观测的原因是探针有缺陷,不是规则有问题。

    #Array::make:探针从来没调用过那个函数

    behavior 镜头只分析被到达的函数体——顶层语句调用得到才算。 rasterizer.mbt 在 1378 行调用了 PixelBuffer::new,而 0.4.16 的每一个 探针都只抄了定义、没抄那个调用。于是规则「改了没变化」是必然的:它压根没跑。

    补上调用后复现了,然后发现第二件事,而且它推翻了当初的读法:

    探针结果
    顶层可达,真实那行报 Byte::default
    去掉顶层调用完全静默
    Array::make 单独调用被报
    Byte::default 单独调用被报
    外层也不存在 + 内层 Byte::default只报内层

    最后一行是重点:一个调用一旦报错就返回 ABottom,外层调用不再被访问。 所以嵌套链只报最内层那个名字。当初把「加了 Byte::default 就露出 Array::make」读成「打地鼠、没有终点」是错的——那是一层掩盖被揭开, 不是一张无穷长的清单;调用者都认识之后,家族就终止了。

    #为什么是表,不是规则

    规则版本是「限定在内建类型上的调用一律是内建方法,不报」。反向对照判了它:

    写法结果
    Array::make(4, 0)静默(名字在表里)
    Array::mkae(4, 0)被报(名字不在表里)

    规则版本会把两个都静音。所以 src/core/builtin_methods.mbt 是生成的表: 656 个名字,642 个读自工具链自己的 moonbitlang/core/**/pkg.generated.mbti, 剩下 14 个编译器内建(Array::make、Map::new、StringBuilder::new …) 没有任何接口声明,逐个用 moon check settle——一个探针文件把它们全部 裸写调用,旁边放一个不可能存在的控制名,工具链只标了控制名。

    生成脚本随仓库发布在 tools/gen_builtin_methods.ps1,它自己会跑 moon fmt。 它必须在仓库里而不是临时目录里:builtin_methods.mbt 的头写着「不要手改」, 一张生成器没进仓库的表会静默腐烂——下一个 MoonBit 加了方法,没人生成, 分析器就又开始把真实的内建调用报成未定义。 这个表是 builtins.mbt 那张 ambient 表的一部分,而那张表的头注释早就写明: 每次使用都只会漏报,绝不会凭空造出误报——这就是为什么它可以是数据。

    #顺带证伪了 0.4.16 的一条结论

    0.4.16 的测试写着「限定名调用不上报,是过度报告的镜像,一个已知缺口」。 假的。 那个 fixture 是 fn f() -> Int { Nope::also_missing() },而 f 从来没被调用过——和 Array::make 那个探针是同一个缺陷。把 let top = f() 加上去,同一个名字立刻被报出来。

    真实原因有两条,叠在一起看着像一条:behavior 镜头需要函数体被到达,而 type 镜头根本不把 :: 名当类型引用。测试已按实际行为重写。

    #Select:同包兄弟文件里的枚举变体

    canvas_view_test.mbt:4917:

    fn every_mode() -> Array[Mode] {
    [Select, Rect, Polygon, Keypoint, EditBinding]
    }

    Mode 和五个变体都在同一个包目录的 app_moui/app.mbt:23。而 collect_declared_names 只收顶层关键字后面的名字——枚举变体在花括号里, 被 depth-0 规则跳过了。这是 0.4.15 用 path 尾部兜底解决的同文件问题的跨文件 孪生体:带路径的 Mode::Select 走得通,裸写的 [Select, ...] 走不通。

    修法是进 enum 体收割变体名,但载荷类型不算变体,否则 Conv2d(Params) 会让 Params 变成包可见,静默掉别的文件里一条真发现。 所以规则是:行首(或紧跟 { / , / |)且不在括号内。

    (顺带一个实测:enum E { A B } 这种空格分隔的写法是语法错误, moon check 报 expect ; or }。测试最初按合法写了它,跑出来是红的—— 于是去问工具链,而不是去猜语法。)

    #差分:933 个文件里只有 2 个文件的判定变了

    语料0.4.160.4.17
    riantr/snn_mbt 0.155.066(全真阳性,未动)
    riantr/moonbit_doubleML 0.139.000
    riantr/moonbit_labeler 0.2.1353
    riantr/moonbit_image 0.3.700
    riantr/pyroduct 0.1.2900
    合计119

    逐文件比对:只有 rasterizer.mbt 与 canvas_view_test.mbt 的判定变了,各减 恰好 1 条 actionable,两者的 notice 数(1 / 175)一条未动。其余 931 个 文件逐字节一致。两处改动都是外科手术级的。

    labeler 剩下的 3 条是 @core.SurfaceMetrics::new ×1 与 @core.Size::new ×2, 属于 0.4.16 已经定过的「调用方式」——加 --extern-iface-dir .repos 就没有。

    测试 js 286 / wasm 358 / wasm-gc 286。七道防护做了突变验证,全部杀死。其中 两道专门用来测形状而不是测数值:M3 往表里塞一个不存在的名字(Array::mkae), 反向对照必须变红;M4 把 0.4.16 那个规则装回去替代表,8 个测试变红—— 「表优于规则」是实测出来的,不是风格选择。

    #0.4.16 —— 19 → 8,其中 6 条是真阳性:先承认「对」,再修剩下的错

    0.4.15 剩 19 条。这轮先做了一件上一轮没做的事:逐条判定哪些是真的。

    结果是 6 条真阳性——分析器是对的,代码确实有问题:

    位置发现判据
    neuron_identity.mbt参数 dt 从未被读读源码确认函数体里没有 dt
    dsac.mbt ×2 + 3 个 examples/*/main.mbtfor a in 0..<n 的绑定变量 a/s 从未被读同上;探针确认 moon check 对未读的 for 绑定变量也告警

    外加 3 条不是缺陷:labeler 里的 @core.SurfaceMetrics::new 等 3 条, 分析器自己就打印了 FETCH ... pass --extern-iface-dir .repos to index theirinterface names。加上这个参数重扫,labeler 从 5 条降到 2 条。那 3 条 本来就不是 bug,是调用方式。

    真正修掉的 9 条,成因只有一个共同点:信息已经读到了,却被丢掉或没用上。

    成因条数位置
    跨包 const 被当成函数4IfaceSymTab.values 的 Bool 一直写 true、没人读
    带花括号的 suberror 体被当成表达式4一律走「跳到行尾」的 skip

    (第三条 Byte::default / Array::make 没有修,原因见下面「改完又撤回」。)

    #跨包 const:3.0F * hz 被报成 'float' 和 'fn hz'

    // snn_mbt/istdp.mbt
    r: 3.0F * hz, // hz 是 @snn_kw 的 const hz : Float

    .mbti 读取器已经分得清 const 和 fn——它对两者匹配的前缀不同—— 然后把区别扔了,因为两条分支都只产出一个裸名字。IfaceSymTab.values 的 Map[String, Bool] 里那个 Bool 一直在写 true,从来没被读过。现在它是 is_const,merge / union 也跟着透传。

    反例对照(必须有):跨包的函数仍然返回 TUnknownFn,不能一起降级成 TUnknown——那会让调用处理器跑去检查一个凭空编出来的参数个数。

    #带花括号的 suberror

    suberror 有两种写法:suberror Timeout of String(一行)和 pub suberror DecodeError { ... }(带体)。原来一律走「跳到行尾」, 于是带体的那种跳到换行就停了,剩下的 { ... } 被当作表达式块解析, 里面的载荷类型一个个变成 undefined name——moonbit_image/types.mbt 里 String 报了 4 次。

    现在按关键字后面实际跟的是什么分流,扫描边界固定在 8 个 token (suberror X of T 的头部不会更长),避免把后面某个声明的花括号 误认成自己的。反例对照:不带体的 suberror 必须照样解析,且它之后的 函数仍然存在。

    #一个反向的发现:这个分析器漏报

    Nope::also_missing() 这种限定名调用在被调用方解析不出来时,完全不报。 这不是本轮引入的,是上一轮修掉的过度报告留下的镜像:

    限定名一度被过度报告(Layer::ReLU、fn hz),修完之后它们变成了 报告不足。

    我把这条写成了断言当前行为的测试,并标注 KNOWN GAP——不是断言一个 还不存在的发现(那会让每个版本都卡在一个没人评估过的问题上)。

    要往正确的方向走,得先把「接收者类型」和「包别名」区分开,这轮没做。

    #改完又撤回:Byte::default / Array::make

    labeler 的 rasterizer.mbt:74 是一行:

    let pixels : Array[Byte] = Array::make(total, Byte::default())

    分析器把其中一个被调用方报成 undefined function。我按探针结论 (Byte::default() 编译 0 错误 0 警告)把 Byte::default 加进内建表—— 结果只是把底下的 Array::make 露了出来,同一行。

    这说明「一个名字一个名字补」是打地鼠,没有终点。于是改成按家族判定: 限定在内建类型上的调用(Array::、Map::、Int::)一律不当未定义—— 分析器本来就没有内建类型的方法表。

    这条改完又被撤回了。 五道突变里它占两道,两道都存活(关掉它、让它永不 匹配,整个套件照样全绿);在一个手搭的探针上——完整文件和故意做成 partial 的文件各一次——开关它输出逐字节相同。

    而语料里那条发现的形状我复现不出来:undefined function 这个串在本文件 里只被构造一次,而我改的那处根本走不到。在证明不了的前提下静音一整族调用, 和当初制造这批误报是同一类错误。 理由写在 interp.mbt 那条上报旁边。

    #结果

    语料0.4.130.4.140.4.150.4.16
    snn_mbt331996(全真)
    moonbit_doubleML28310
    moonbit_labeler6555(加 --extern-iface-dir 为 2)
    moonbit_image4440
    pyroduct0000
    合计71311911

    剩下 11 条里 6 条是真阳性,3 条是调用方式,另外 2 条在 labeler (1 条已修的 Byte::default 同类,1 条被 stdout 截断看不到)。

    测试 js 270 / wasm 342 / wasm-gc 270(新增 8)。四道突变全部杀死, restore 后 SHA256 逐字节一致;自检 52/52、actionable=0。

    #0.4.15 —— 剩下 30 条里的 12 条:三个成因,其中一个差点把整条规则静音

    0.4.14 把四个语料的 actionable 从 71 降到 31。其中 1 条是真阳性 (neuron_identity.mbt 的 dt 参数确实没被读,编译器也会警告)——第一次在 这套语料里找到真的。其余 30 条继续归因,三条成因占了 12 条。

    成因条数判据
    extern "C" 的参数被判「未使用」6探针:extern 不告警,同文件的普通 fn 告警
    Bool == Bool 被判「不支持的操作数」2探针:0 error 0 warning
    单元(无载荷)枚举变体读不到4探针:0 error;同 enum 的带载荷变体本来就正常

    #单元变体:同一文件里,带载荷的能读、不能带载荷的读不到

    enum Layer {
    Conv2d(Conv2dParam) // 能读
    ReLU // 报 undefined name
    }

    不是「enum 没被采集」。采集器确实把两个名字都收进了模块帧,但存的是裸名 (ReLU),而引用写的是整条路径(Layer::ReLU)。Layer::Conv2d(p) 是 调用,调用路径另有兜底;Layer::ReLU 是裸引用,没有。

    修法是给整条路径一次尾部兜底(@core.path_tail),只在整条路径已经解析失败 之后才走,所以不可能把能解析的名字变成解析不了的——只可能救回尾部真有东西的 路径,那正是单元变体。

    配套的反向对照:Colour::Purple(从未声明)仍然必须报错。否则每个语料里拼错的 枚举路径都会被静音。

    #「函数体是空的」不能当 extern 的代理

    直觉是:extern 函数没有函数体,所以「体空 ⇒ 不审计参数」就够了。探针否掉了它:

    写法moon check
    extern "C" fn expf(x : Float) -> Float = "expf"静默
    fn p7(z : Float) -> Float { }告警
    fn p6(y : Float) -> Float { 1.0 }告警

    所以必须由 parser 显式记录 extern 函数名(ParseResult.extern_fns), 跟 enum_ctors 同一个套路。

    #结果

    语料0.4.130.4.140.4.15
    snn_mbt33199
    moonbit_doubleML2831
    moonbit_labeler655
    moonbit_image444
    pyroduct000
    合计713119

    snn_mbt 少了 10 条、doubleML 少了 2 条,正好等于两条成因各自的计数; labeler 和 moonbit_image 没动,因为它们的成因是另外两条(@core.X::new 这类跨包构造、Byte::default 这类内建缺失,以及 suberror 体被二次扫描)。

    测试 js 263 / wasm 335 / wasm-gc 263(新增 7)。五道突变全部被杀死,restore 后 SHA256 逐字节校验通过;自检 51/51、actionable=0。

    #突变脚本自己踩的第三个坑

    替换文本写成了 PowerShell 的 $true,而 MoonBit 里没有这个语法。两道突变因此 编译失败,脚本把它们报成 COMPILE-ERR——和「套件抓到」长得一样,其实 什么都没抓到。一种不能编译的突变,和一种成功杀死的突变,在报告里必须分开。

    #0.4.14 —— pyroduct 的 0 不是「对」:换四个语料,一次挖出三个真缺陷 + 一个静默错误

    0.4.13 把 pyroduct 的 actionable 清到 0,但那是 pyroduct 恰好没踩到,不是分析器对。 这次把同一分析器指向另外四个已发布模块(snn_mbt / moonbit_doubleML / moonbit_labeler / moonbit_image,共 933 个文件),立刻出现 71 条 actionable。

    成因条数判据
    F 后缀浮点字面量 0.0F 未建模 → undefined name 'F'28moon run 确认合法;7F 才是语法错
    / 被当成浮点除法 → cannot assign 'float' into '[int]'147 / 2 = 3,不是 3.5
    指数记法 1.0e-12 未词法化 → undefined name 'e'14moon run 确认合法
    suberror 体被二次扫描 → undefined variable 'String'4同一行已报 ParseError

    68 条逐条复核,0 条是真阳性。

    #还有一个不产生任何发现的缺陷

    改的过程中,探针发现一个比上面四条都更糟的东西:

    1.5 -> 1 | 0.25 -> 0 | 3.14159 -> 3

    每个浮点字面量都丢掉了小数部分。 lexer.mbt 里 int_part 在消费小数之前 就取好了值,之后 slice(start, int_part) 于是只切到整数部分。

    它一直没被发现,是因为既有测试只断言 token 的种类是 float——被截断的值同样 满足。一条不产生任何发现的缺陷,会让分析器对读到的每一个浮点都静默地错。 新测试因此改为读值和token 数。

    #实测过的修法,以及一条实测后撤回的修法

    walk.mbt / interp.mbt 里 / 的常量折叠与结果类型同样与 MoonBit 事实不符 (OpDiv => None、AType("float"))。三处都改对之后:

    • 五道突变里,这三道全部存活(改回去套件照样全绿);
    • 在 933 个文件上做差分扫描,两次的报告流逐字节相同。

    也就是说常量求值层今天根本到不了任何一条输出的发现。按本项目「不可观测的分支 一律不提交」的口径,这三处改动已撤回,理由写在了 walk.mbt 和 interp.mbt 的注释里。声称一个无法证明的修复,比留着一处已知但暂时无害的写法更糟。

    #结果

    语料文件可解析actionable
    riantr/snn_mbt 0.155.0673234 → 60333 → 19
    riantr/moonbit_doubleML 0.139.0205145 → 16528 → 3
    riantr/moonbit_labeler 0.2.133716 → 186 → 5
    riantr/moonbit_image 0.3.71814
    riantr/pyroduct 0.1.29(不回退)119103 → 1040
    合计 actionable71 → 31

    测试 js 256 / wasm 328 / wasm-gc 256(新增 16)。五道突变全部被杀死,restore 后逐字节校验与运行前一致。

    #顺带修掉的两个自伤

    验证脚本本身在这次迭代里咬了两次:

    1. Get-Content -Raw 在中文 Windows 上按 GBK 解码 UTF-8,把注释里的 — 写成 鈥?,并且吞掉了后面的换行,把两行注释并成一行。现已改为按字节读、并用 SHA256 证明 restore 与运行前逐字节一致。
    2. 差分脚本里 "$v_" 被 PowerShell 解析成名为 v_ 的变量(空),两次扫描 写进同一个文件、后者覆盖前者,于是「变体 A 和变体 B」变成了自己和自己比, 报出「完全一致」。现在用 ${v_},并在比较前断言文件存在——两次读取都失败 曾经被当成「相同」。

    判据:比较两个结果之前,先证明两个结果都存在。

    #0.4.13 —— 最后 5 条:三种成因,其中一种是「把检查调窄」的陷阱

    0.4.12 剩 5 条。全部是分析器的缺陷,pyroduct 的代码没错——三条都是 moon check 零错误通过的干净文件。

    成因条数位置
    color[u]:把 Map 当成 list 报下标类型错2src/paths.mbt 135/152
    self 只在插值里被读 → 报参数未用1src/slot.mbt 127
    _ 前缀的未用绑定/参数(分析器与编译器规则相反)2src/algebra.mbt 118、audit/mbti.mbt 69

    #第一条差点被我用一个更糟的 bug 换掉

    Map[String, Int] 用 String 下标是对的——Map 按 key 索引。而类型镜从来没问 过「这个下标的基到底是不是 list」,只要下标不是 int 就报 list indices must be int,在干净文件上给出 error: 级发现。

    最直接的修法是加个前提:基是 TList 才报。这一改立刻吞掉了两条真实错误—— 我拿探针量出来的:xs["k"] 在 Array[String] 和 Array[Int] 上,moon check 报 "Expr Type Mismatch",0.4.12 能抓到,加了前提之后两条都没了。

    原因是 ann_to_ty 不认识 Array[T](只认 [T],而 [T] 在 MoonBit 里根本不是 合法写法),parser 还用 skip_indexed 把 [...] 整个丢掉——Array[String] 到达类型镜时只剩 "Array"。所以那个 TList 前提永远不成立,检查等于静音。

    真正的修法是补上类型模型,而不是关掉检查:三处—— ann_to_ty 认 Array[T];parser 的 type_annotation 保留头部的 [...] (它自己上面的注释还写着「类型镜不建模容器,所以丢掉不损失」——那句话正是这个 bug 的说明书);ty_assignable 递归进 TList,让 TList(TUnknown) 能赋给 TList(TStr](否则「Unknown 可赋给任何东西」这条规则被下一层悄悄推翻)。

    最终对照(探针里 5 个下标,moon check 报 3 个真错误):

    真错误误报
    0.4.123 / 31(Map)
    0.4.133 / 30

    ⇒ 教训一:给检查加前提之前,先问「它依赖的类型读得到吗」。 前提写对了, 但如果依赖的那个类型是上游丢掉的,加前提就等于静音——而且它静默得很有道理: 检查确实"只在基是 list 时才报"。

    ⇒ 配套:parser 丢信息这件事,类型镜那边的注释替它辩护了。 一句写对的注释 (「不建模容器所以丢掉不损失」)会在前提收紧的那一刻变成谎言。注释里 "这不是妥协"这种话,要跟着它依赖的前提一起复查。

    #_ 前缀:分析器和编译器规则相反

    _ 前缀在 MoonBit 里是丢弃约定。探针(同一个文件里同时放 _f / _classes / _ok3 和一个非下划线对照 plain):moon check 只对 plain 报警。 分析器此前只豁免裸 _,并在注释里断言「名字不是丢弃堆」——那是论证,不是实测。

    ⇒ 教训二:豁免规则先探针,再写进注释。 这条规则和它那句理由都是 0.4.8 加的, 从没被工具链验证过;一改注释里的断言,编译器一句话就把它否掉了。

    #self 是约定参数名,不是关键字

    词法器把 self 列进了「插值里不是绑定」的关键字表,于是 \{self.designation()} 不记录这次读,只在插值里被读的参数就被报成未使用。它是 0.4.8 加插值追踪时 顺手猜进去的,没量过。

    ⇒ 教训三:同一个读法在不同代码路径上答案必须一致。 这里的 bug 就是 「插值里算读、普通表达式里不算」,两条路径互相矛盾。写测试时要两个方向都钉, 否则只钉一个方向会以为已经覆盖了。

    #被突变验证挖出来的两件事

    1. 突变脚本自己的过滤器匹配了 0 个测试。 两个 guard 报成 GREEN,其实 --filter 的 glob 根本没选中任何用例——一次「全绿」什么也没证明。脚本现在 断言 ran > 0,ran = 0 直接判 INVALID。
    2. 一个修复没有任何测试能杀死它。 我一度让空列表字面量「采纳注解类型」, 把它静音后两个语料的发现流逐字节相同(496 / 528 条),也没有测试能杀它。 真正起作用的是 ty_assignability 那一条。测不出差别的分支不是保险,是没被测过 却长得像决策的代码——已删,并在两处注释里写明为什么删。

    pyroduct actionable 5 → 0,总发现数 632 → 627,正好 −5:没有新发现冒出来。 自检 48/48、actionable=0。测试 js 240 / wasm 312 / wasm-gc 240(新增 24 个, 其中 6 个是新的 types_test.mbt——这个文件此前不存在,也正是它缺失让上面那个 回归活过了整整一个版本)。八道防护,全部突变验证通过。

    #0.4.10 —— 发布 0.4.9 才暴露出自检一直在遮蔽的两件事

    把 provenance 的修复发出去,意味着要再用它自己扫一遍已发布的包——就是当初找出 unknown 那个 bug 的检查。这一次回来是 actionable=2,而 0.4.8 是干净的, 两条都在 provenance.mbt:

    no visible binding for 'is_coordinate' no visible binding for 'split_coord'

    两条都不是代码缺陷。它们是这一版在读自己的源码时,发现了两件关于工具本身的事。

    if 表达式会藏起未定义名。 同样这两个名字在同一个文件里出现了两次,只报了 一次:

    let stem = if is_coordinate(p.target) { // 226 行——if 表达式
    ...
    if is_coordinate(target) { // 411 行——if 语句

    let x = if c { … } else { … } 会被解析、两个分支会被读,然后整块丢掉—— 它属于「不分析的子集」,而且有告知说明。所以写在这种分支里的不存在名字, 永远不会被报。0.4.8 自检之所以干净,部分原因正是这两个调用躺在一个被丢掉的 if 表达式里。把它们挪进普通函数后 if 变成语句,被完整解析, 两个潜伏的漏报就变成了两条发现。

    另一个文件里的包私有辅助函数是看不见的。 跨文件解析走 .mbti, 而包私有的 fn 不出现在任何 .mbti 里——所以一个文件调用同包另一个文件的私有 辅助函数,读起来就像未绑定。那两条发现的真相就是这个:真实存在的绑定, 而分析器没有任何途径看到。is_coordinate 和 split_coord 现在是 pub, 因此进了接口文件,理由和 prov_or_unknown、stamp_utc、scan_artifact_name 当初一样。每个函数上的注释都写明了原因,免得有人日后"整理"回私有、 悄悄把这条发现放回来。

    这两类本次都没修:它们都是已声明的子集 / 可见性限制,而且都偏向沉默而不是 噪音。扫一个多文件包却什么都没返回时,值得知道有这两回事。

    #0.4.9 —— @latest 扫描把自己的产物命名成 unknown

    用文档里那条命令、通过它自己扫已发布的包:

    moonx riantr/moonbit_static_analysis@latest riantr/moonbit_static_analysis@latest --mmd-auto

    结果是 46/46 parsed, actionable=0——已发布的产物扫自己是干净的,这是工作树 给不了的那个检查。但产物名是 riantr-moonbit_static_analysis_unknown_0.1.20260920_…mmd,头部写着 version=unknown,而这次扫的正是在读 .repos/riantr/moonbit_static_analysis/0.4.8/,它的 moon.mod 里明明白白是 version = "0.4.8"。

    provenance_for 在目标是坐标时只从坐标取版本,从不回退到那棵树。@latest 本身不带版本:split_coord 故意把它抹成空,好让 moon fetch 收到一个裸模块名。 于是工具自己头部举的那条例子命令,永远写出 unknown——而版本正是用来把两次 扫描区分开的那个字段。

    钉了版本的坐标仍然优先于树(调用方要的就是那一个),只有空的情况才回退。 修复后重跑同一条坐标扫描实测:

    MMD riantr-moonbit_static_analysis_0.4.8_0.1.20260920_20261010T235302Z.mmd %% version=0.4.8

    测试:js 207 / wasm 277 / wasm-gc 207。

    #0.4.8 —— 以 mermaid 工件为证据的三轮自检

    把扫描器对准自己跑了三轮,每一轮以上一轮的输出作为本轮的靶子。证据取 mermaid 工件(--mmd '@');但 stdout 文本报告才是权威——图天生有损 (findings_shown_per_file = 40),只用来定位。

    轮已解析actionable那一轮修了什么
    起点(0.4.7)46/46104——
    第二轮后46/468被跳过的表达式、walk_target 穿透写、过期 .mbti
    第三轮后46/460字符串插值里的名字不可见

    覆盖率全程没有回退。测试:js 208 / wasm 274 / wasm-gc 208,--deny-warn 干净。

    被跳过的子表达式也是帧上的一个洞。 match / try / 函数面量是「声明的子集 边界」——它不移动错误行、不扣覆盖率,但它跳过的那块区域恰恰可能是某个绑定唯一的 赋值处,因此含它的函数现在整函数退出 UnusedLocal / UnusedParam / ParamChanged 审计。这要求函数有完整区间 (从头部到右花括号),SFunc / SLocalFunc 现在带的就是它。104 → 8。

    穿透参数写是读,不是重绑定。 MoonBit 的结构体是引用,self.field = v、 v[0] = x、pair.0 = x 改的是参数所指的对象,参数本身没变。这三种以前都算 ParamChanged,于是报出一条它自己的解释(「不再持有调用方传入的值」)为假的结论。 ParamChanged 现在只针对裸名重绑定。

    经 \{...} 读到的名字就是读到了。 插值体被原样抄进字符串 payload,所以它从来 不是 token——只经插值读的名字看起来没被读,而插值里不存在的名字则根本不报。 TStr 现在携带其插值引用的名字,walk 逐个解析。上一轮活下来的 8 条全是 这一个缺陷:mermaid.mbt anchor、pipeline.mbt i/s/r、 parser.mbt what ×2、interp.mbt name、cli/main.mbt live。8 → 0。

    这两个数字是怎么拿到的,值得单列,因为两条都容易被当成规矩重复下去:

    • 第三轮的起点就是错的。 那 8 条本来准备当死代码清理。它们不是死代码——照着清理 就是删掉能跑的代码去迎合一条误报。那一轮找到的是分析器自己的缺陷。
    • 插值那个修复立刻又造出两条误报:no visible binding for '1' 和 '10', 出在 \{i + 1} 与 \{pct / 10}——标识符被允许以数字开头。只有拿真实代码再扫一遍 才会浮出来。修误报和制造误报是同一类动作,区别只在于有没有再跑一次扫描。

    #安装 / 快速上手

    moon add riantr/moonbit_static_analysis@0.4.23

    // 用途一:修订一段 MoonBit(当前子集)程序
    let result : @pipeline.PipelineResult = @pipeline.run(source, "main.mbt")
    println(@pipeline.render_result(result))

    // 用途二:审计一台状态机(机器表以纯数据进来)
    let spec : @statecheck.MachineSpec = { name: "主体", states: [...], ... }
    println(@statecheck.render(spec))

    #三鉴(结构/类型/行为)(流水线核心)

    三鉴按"后者消费前者的表"组装:

    鉴包职责产出
    结构src/walk赋值即绑定、未用/未定义/参数被改、常量折叠剪枝绑定表 fn_bindings
    类型src/types注解即契约、推断、赋值/实参/条件检查(声明取自绑定表)签名表 sigs
    行为src/interp格上抽象解释、按签名调度、虚栈(方法表取自签名表)行为发现
    合并src/pipeline同一缺陷的多鉴回声 → 一条报告,lens 并集render_result

    四个组装点:绑定表→符号表(组装点1)、签名表→方法表(组装点2)、常量折叠的格化(组装点3)、合并去重(组装点4)。

    #用途一:程序代码修订(快速进化中的 MoonBit 语言)

    moon run src/cli # demo:6 个样例 × 三鉴

    === undefined.mbt === 2:10 - error: undefined variable 'missing' (UndefinedName) [structural+type+behavior] in main() at undefined.mbt:4 in g at undefined.mbt:1 Summary: 1 finding(s) (before merge: structural 1, type 2, behavior 1)

    一条缺陷三鉴都看见 → 一条报告,标签是并集;合并前后计数都保留(合并无损)。死分支被剪两次(结构层 const_eval + 行为层 ABool 格值),不存在的 typo_fn 零报告。

    #用途二:静态状态修订(机器表 → 三鉴)

    src/statecheck 接受任意状态机的纯数据规格 MachineSpec(状态 / 初始 / 终点 / 迁移 / 触发-槽位 / Block 理由 / 修习历程),三鉴读机器表就像读程序:

    鉴机器侧语义检查内容
    结构状态即绑定("赋值即绑定"推广到机器表)任何状态都必须被至少一条迁移绑定(出或入);只出不进 → never entered;从初始位置的可达性闭包;终点必须可达(机器必须能完成)
    类型驱动槽契约("注解即契约")每个触发必须归位已知槽、无空槽;Block 必须带理由——无路必须说出口,不能沉默
    行为轨迹抽象执行(虚栈)修习历程每一步都必须有触发承载;断链的轨迹步带入口帧→出错帧的虚栈

    let spec : @statecheck.MachineSpec = { name: "主体", states: [...], ... }
    let findings : Array[@report.Report] = @statecheck.audit(spec)
    let text : String = @statecheck.render(spec) // 合并后的文本报告

    pyroduct 是被测对象,不是依赖:本模块自身不依赖 pyroduct;方向是 pyroduct(其 audit 包)用真实机器表构造 MachineSpec 调用本模块。依赖单向:机器 → 分析器。

    pyroduct 侧的真实自审计(moon run cmd/main -- audit)。它 pin 的是 0.2.0,所以 下面输出里出现的 0.2.0 指那个 pin,不是上面 moon add 的安装版本:

    规格 | 计数 ---|--- 状态 | 034 迁移 | 053 触发→槽 | 049 → 8 无路组合(带理由) | 228 修习历程 | 031 站 已知设计(4 条,出处层:设计使然——0.2.0 审计已验证): - state '无忆' is never entered: it appears only as a transition source - state '无筹' is never entered: it appears only as a transition source - state '无忆' is unreachable from the initial position '立位' - state '无筹' is unreachable from the initial position '立位' 未预期发现:**0 条**

    无忆/无筹(原文:失忆/浑噩)是主体机器(34 位·53 迁·8 槽)里真实存在的两个"只出不进且不可达" 位置——pyroduct 自己的 192 个测试之外,用另一套三鉴语言独立复核出机器的论文边界 (「记不起来的过去」与「尚未到来的未来」)。"未预期发现 0 条"是回归绊线:三鉴在真实表上 任何新增报警都会让 pyroduct 的测试失败,强制复审。

    moon run src/cli 末尾那台 pyroduct 形状示例机器 是本模块内嵌的 8 状态玩具机 (statecheck.pyroduct_flavored()),不是 pyroduct 的真表;它带 182:12 这类 行:列,那是 state_span() 按状态名哈希出来的稳定伪 span(同一名字永远同一坐标), 不是任何源文件的位置——别拿它去 pyroduct 里对行号。

    #把扫描结果渲染成状态图(public API)

    @sa.mermaid_states(verdicts, summary, prov, per_file, max_files) 把一次扫描渲染成 mermaid stateDiagram-v2——和 CLI 打印的是同一批发现,只是形状从"给人扫一眼"换成 "给程序读"。

    每个文件的框的主干就是那些计数所依赖的信任链:lexed → structural → type → behavioral。 前端没读完的文件停在那条线上,越界之后的发现计数但不画。每条发现是自己的状态, 挂在报告它的那一鉴下面,带一条 note:WHY:(哪条规则、依据什么证据触发)与 HOW: (该改什么),取自 @core.Family::advice。

    有两个 family 带着读者动手之前必须知道的警告,而且都写在 note 正文里: .mbti / .mbtp / .mbt.md 上的 ParseError 通常是前端规则而不是解析边界; UndefinedName 有两个已知盲区(#doc(hidden) 的 API 不出现在任何 .mbti 里; @pkg.fn(1, 2) 这种顶层跨包引用不会被折叠成可调用名)。

    per_file 与 max_files 限制图的规模,0 表示不限制。被截掉的部分一定会在图里写出来 ——写在头部和该文件的 note 上,所以被截断的图不会被读成"干净的图"。summary 永远描述 整个扫描,而画出来的可能只是子集。文件按可行动数降序取,不是按原始发现数:在 moonbitlang/core 上,原始发现最多的那个文件是一个 402 条发现的测试文件,而那 402 条全是 notice、零缺陷——按原始数排,它会排在这张以"先看该修什么"为职责的图的第一位。

    let root = /* 已解析到的源码根目录 */
    let prov = @sa.provenance_for("my/module@1.2.3", root)
    let text = @sa.mermaid_states(verdicts, @sa.aggregate(verdicts), prov, 8, 25)
    println(scan_artifact_name(prov, root))

    输出是合法 mermaid:已用 mermaid@10.9.8 对 moonbitlang/core 的真实扫描(1029 文件)做过 解析 + 渲染双重验证。渲染器围绕两条实测出来的文法约束写成:

    • 迁移标签是第一个 : 之后的全部内容,所以标签里不能再有 :——行号和消息因此放在 带引号的 state 标签里,绝不放箭头上
    • MoonBit 没有运算符续行,源码里以 + 开头的行是语法错误——所以文档是一段一段 write_string 拼出来的,原因就是这个

    src/sa/mermaid_test.mbt 每次运行都把生成的文本按这套文法重读一遍——每一行都必须是 mermaid 接受的形态、每个节点 id 必须是标识符、每条迁移的端点必须是已声明的状态——并且 它自己被证明能抓住五个它本该抓住的文档。

    #给产物命名:版本与扫描时间写进文件名

    一次扫描结果是对某一棵树、由某套工具链、在某一时刻的断言。三件事都放进文件名—— @sa.provenance_for(target, root) 负责收集,@sa.scan_artifact_name(prov, root) 负责排布:

    moonbit_static_analysis_0.4.7_0.1.20260920_20261009T155222Z.mmd moonbitlang-core_0.1.20260920+7d59c7ec9_0.1.20260920_20261009T051925Z.mmd tree_unknown_0.1.20260920_20261009T051511Z.mmd # 版本没找到

    格式是 <包名>_<版本号>_<moonbit版本>_<时间戳>.mmd。四个字段各回答一个问题:

    字段回答来源
    package读的是什么坐标,或解析后的目录名
    version它的哪个修订目标自己的 moon.mod(moon.mod.json 也读)
    moonbit是哪套工具链读的子进程跑 moon version
    stamp什么时候@async.now(),UTC

    moonbit 是唯一没法从树里读回来的字段,也是决定两次扫描能不能比较的那个。moon.mod 只记 模块自己的版本和依赖,从不记编译它的工具链;也没有 MOONBITS_* 环境变量可问(实测:env 里 过滤 MOON 是空的)。所以只能去问工具链本身。这一点是实测的而不是照着文档念: moon version 打出

    moon 0.1.20260920 (914d7da 2026-09-20)

    取第二个字段。它是一次子进程调用,所以只在真的要写图时跑一次;失败就写 unknown, 而不是写错。

    对本工具尤其重要:仅仅因为把方法调用建进 AST,这个 .mbt 子集在同一份没变过的 core 检出上就从 158 个文件涨到 173 个。结论随工具链走,所以不写明工具链的扫描结果没法跟后来的 结果比较。

    包名是坐标的模块名;路径则是解析后的目录名——扫 . 是最常见的用法,照着调用方写的 字符串命名会得到 _.mmd。

    版本优先取坐标里的 @version,没有就读目标自己的 moon.mod(moon.mod.json 也读—— 两种写法在现场都存在)。时间戳是 UTC,来自 @async.now(),这一点是实测的而不是照着 文档注释念:wasm 跑出 1791522911114,而约 1.1 秒前读到的墙上时钟是 1791522910008, 差值正好是两次调用之间的延迟。这里没有时区数据库,所以时间戳取 UTC 并用结尾的 Z 说明, 而不是一年错两次小时。

    本轮更正。 上一版这一节写着「CLI 根本没法打时间戳,因为唯一的时钟在 internal 包后面」。那是错的:moonbitlang/async 把它公开重导出了 (pub using @event_loop {now}),而且 provenance_for 从 0.4.5 起就在调 @async.now()。CLI 之所以写 unknown,只是因为它被塞了一个手写的空时间戳,而没有去调 那个本来就能用的函数。

    这件事刻意不做的三件事:

    • 不省略它没能确定的东西。 版本、工具链或时钟缺失时写字面量 unknown。省掉那一段会 得到一个"看起来完整"的名字,而且四个字段时更糟:会错位——读 core_0.1.2_20261009 的人会把时间戳当成工具链版本。
    • 不产出 dotfile。 以 . 开头的名字在 Linux/macOS 上 ls 根本列不出来,而没人 列得出来的产物等于没人找得回来。
    • 不把文件名当记录。 同样的三个字段会以 %% 注释重复写在文档正文里,因为文件 是会被改名的,改完名之后它的名字就不再是证据了。

    #从 CLI 直接写出一张图:--mmd / --mmd-auto

    渲染器本来只是库函数,现在不写代码也能用上:

    moonx riantr/moonbit_static_analysis@latest <target> --mmd out/scan.mmd moonx riantr/moonbit_static_analysis@latest <target> --mmd-auto

    两者都额外写出图,并在最后一行打印 MMD<TAB><路径>;stdout 上的文本报告一字未改, 仍然是权威输出。--mmd-auto 用上面那套命名,写进工作目录。

    这里有三个决定,都不是随手定的:

    • 要显式开,永远不是默认。 这张图天生有损:mermaid_states 按 actionable 数排序、 到自己的上限就停,并在正文里写明 %% THIS DRAWING IS A SUBSET。把图当唯一输出等于把 这句话藏起来,而且会悄悄弄坏所有 grep SUMMARY 的脚本。
    • --mmd 必须带路径,自动命名另给一个 --mmd-auto。 可选值在这里是歧义的: <target> --mmd <path> 里 --mmd 后面那个裸词同样可能是第二个目标,两种读法无法区分。 与其猜、然后以 "unexpected extra argument" 失败,不如让这个值必填。
    • 产物绝不落进被扫描的树里。 本模块写明的约束是只读目标的文件;往别人的源码仓库 里丢文件既违背这个承诺,也会弄脏一棵用户未必拥有的树。

    四个字段全是真的。在 moonbitlang/core 完整 1029 文件的扫描上实测:

    MMD _mbcore_0.1.20260920+7d59c7ec9_0.1.20260920_20261009T132547Z.mmd

    _mbcore 是解析后的目录名,0.1.20260920+7d59c7ec9 是目标 moon.mod 里的自己的版本, 0.1.20260920 是 moon version 报的工具链,20261009T132547Z 是真实的 UTC 时间戳—— 对照本地墙上时钟 20261009T212552,正好差预期的 +8。若 moon version 那个子进程失败, 该字段写 unknown,其余字段不受影响。

    写失败会打印 ERROR --mmd: cannot write <path>,stdout 其余部分不受影响——write_file 不会创建父目录,所以 --mmd no/such/dir/x.mmd 是干净失败,而不是留下半个文件。

    @sa.stamp_utc(ms) -> String 是纯函数、可单独使用:把 epoch 毫秒转成 YYYYMMDDTHHMMSSZ,民用日期用 Hinnant 的 civil_from_days 算,测试把答案钉在 "不会有争议"的日期上——epoch 本身、闰日、年界月界、一次真实运行报出的那个确切时刻, 以及 400 年世纪规则的两侧(2000 是闰年,2100 不是)。

    #包结构

    src/core Span/Severity/Lens/Family(merge_group 契约)/Frame + Family::advice src/report Report 结构与渲染(lens 并集标签、vst 框架) src/sa 扫描入口 + aggregate() + mermaid_states()(对外 library) src/lexer MoonBit 词法前端 src/parser MoonBit 语法 → ast(含 .mbtx 脚本的 import 块) src/ast MoonBit 抽象语法 src/walk 结构鉴:绑定表 + 结构发现 src/types 类型鉴:Ty/Ty?/Sig 推断 src/interp 行为鉴:AbsVal 格 + 调度 + 虚栈 src/pipeline 组装:跑三鉴 + merge_group 合并 src/moonfiles 其余文件种类:.mbt.md 逐块三鉴 / .mbti 接口审计 / .mbtp 证明 lint src/statecheck 用途二:通用机器表审计(MachineSpec 纯数据桥) src/samples demo 样例(内嵌 MoonBit 源) src/cli 可执行入口(程序 demo + 文件种类 demo + 示例机器审计)

    #文件种类(MoonBit 工具链的其它后缀)

    完整分类表见 EXTENSIONS.md,语义依据 docs.moonbitlang.com/en/latest(最终依据), 并与 mooncakes 的 moonbitlang/parser@0.4.3 / moonbitlang/lexer@0.4.2 对照过:

    后缀本项目静态分析
    .mbt三鉴程序分析
    .mbtx三鉴程序分析 + 导入块审计(条目文法 "path" [@alias] [*];重复路径报 FParse;导入清单随报告回显)
    .mbti接口审计:畸形行 / 重复签名 / 未知类型引用。行文法与 moon info 实际输出一致,生成文件不会报畸形行——但它的未知类型检查完全没有跨文件感知,在生成文件上仍会误报(实测 moonbitlang/core 上 93 条,而那棵树 moon check --target all 是 0 错误通过的)。这类发现按「未验证」对待,不要当缺陷报给用户
    .mbt.mdliterate:只分析工具链确实编译的围栏 —— mbt check / mbt test 及其 moonbit 写法;第二个词是 check 或 test 才使块成为活代码。行号对齐 .md 真实行。裸 mbt、裸 moonbit 与 nocheck 是展示块,跳过——按工具链实测,见 EXTENSIONS.md
    .mbtp证明文件逻辑侧 lint(体内字符串常量、!/↔ 禁形、跨包调用、lemma 缺 proof_ensure)——不替代 moon prove
    moon.mod / moon.pkg / workspace记录在案,不做静态分析(配置不是代码——官方也把两者放在 parser 的 moon_config 子包里,与 syntax / mbti_parser 并列而独立;理由见 EXTENSIONS.md「配置文件的边界」)

    .mbti 审计的行文法是照着真实生成物对齐的:moon info 实际会输出 import {} 块、 #deprecated / #alias(...) / #callsite(...) 属性行、const、impl … for T、 suberror、带 pub / async / extern / 类型参数 / 具名参数(input_offset? : Int)/ raise / String? 的 fn 签名。早期版本会把其中每一种都当成"畸形行"报出来, 也就是对每一个真实生成的接口文件都误报。

    #验证

    moon check --target all --deny-warn # 0 错 0 警(js / native / wasm / wasm-gc) moon test --deny-warn # wasm 192/192;js 与 wasm-gc 139/139 moon run src/cli # 程序 demo + 文件种类 demo + pyroduct 形状示例机器审计

    各 target 的数量不同是刻意的:src/sa 与 src/jsoncli 声明了 supported_targets = "+wasm+native",js 与 wasm-gc 会跳过这两个包而不是失败。 因此"四个 target 各 N"这种写法本身就是错的;只有 wasm 的数字覆盖渲染器与扫描器。

    --deny-warn 是有意的:官方包配置页要求「In CI, add --deny-warn to moon check, moon test, or the equivalent command to treat enabled warnings as fatal errors」。 少了它,「0 警」这句话没有任何东西强制 —— 冒出警告 CI 照样绿。.github/workflows/gate.yml 里的 CI 门禁因此带 --deny-warn;代价是工具链日后新增警告会让 CI 变红,这正是它的用途。

    CI 只跑 --target js;上面「四个 target」是本地跑的结论,CI 不覆盖 native/wasm。

    #形式验证(moon prove)

    src/core 是纯内核(无 Array 携带的结构体、无字符串),以 "proof-enabled": true 开启 MoonBit 2026 实验形式验证;src/core/core_proof.mbtp 是逻辑侧:谓词(is_error / type_error_family / lower_irreflexive_ok / lower_transitive_ok / lower_antisymmetric_ok / rank_order_ok)+ 19 条引理(rank 顺序三常数、lower 三律、rank_order_agrees、 severity 分类:5 个 Error 族 + 7 个 Warning 族逐条 + 全量 taxonomy)。

    moon prove src/core --why3-config .why3.conf # 生成 19 个 VC 并交 cvc5/alt-ergo

    验证状态(why3 降级产物 _build/verif/src/core/*.smt2 逐目标 cvc5 机检):

    • 21/21 目标全部 VALID(unsat):19 条引理 + span_at/zero_span 两个自动安全 VC。
    • 已知工具链限制(均为上游问题,非本项目代码):
      1. #proof_pure 函数体为结构体字面量时(pos/span_at/zero_span)降级为不透明逻辑符号 —— 函数体被丢弃,span 形状律在逻辑侧不可证(已由测试钉住,见 core_proof.mbtp 注释)。
      2. 捆绑 why3server(Windows 构建)丢失求解器 stdout —— why3 报 "High failure"、输出仅 .;裸 why3 ... prove CLI 同样复现。逐目标机检绕过: why3 -o <dir> -P cvc5 <mlw> 导出 SMT 后对每个文件跑 cvc5。

    本地复现所需 shim(不进发布 zip):.why3.conf(datadir/libdir 指向 .moon/share/why3 与 .moon/lib/why3,显式 [prover] 节)与 cvc5wrap.ps1(滤掉 why3 文件尾部的 get-info :reason-unknown —— cvc5 1.0.9 对确定性回答会报错,why3 1.7.2 解析失败)。

    #发布

    本模块通过以下渠道发布(同一份源码,三处同步):

    • mooncakes.io:moon publish(发布后 riantr/moonbit_static_analysis@0.4.23 可被任何 MoonBit 模块以 import 依赖;src/cli 附带 SKILL.md,上架 skills.mooncakes.io)。
    • Gitee / GitHub:git push 双推;tag 与 moon.mod 版本号保持一致。

    #分析其他项目(不引入本项目)

    本工具可以扫任意 MoonBit 仓库,而那个仓库既不依赖本模块、也不会被改动 —— 源码只在扫描时被读取:

    moonx riantr/moonbit_static_analysis@latest riantr/moonbit_doubleML@latest

    第一个参数是要扫的目标:registry 坐标(author/module,可带 @version / @latest)或一个本地路径;省略则扫当前目录。坐标会经 moon fetch 落到 .repos/ 再遍历。--exclude <目录> 可重复,用来跳过以普通名字存放的 vendored 源码(点目录规则抓不到它们)。

    #输出说的是「我读懂了什么」,不是「我数出多少条」

    SCANNED 107 files under ...\ML\CI CLEAN .mbti ...\CI/pof/pkg.generated.mbti PARTIAL .mbt defects=2 notices=1 read up to line 36, 57 after it not shown ...\CI/pof/aggregator.mbt 12:3 - error: ... (UndefinedName) [behavior] PARSED 32/107 files understood (29.9%); 75 more read only up to their first parse error SUMMARY files=107 parsed=32 actionable=57 notices=211 unreliable=15759 withheld=6769 total=16027 (actionable+notices+unreliable = total; withheld ⊆ unreliable)

    • PARSED 是头号指标。 工具链的前端只覆盖 MoonBit 的一个子集,读不懂的部分 报出来的「发现」量的是工具的盲区,不是你的代码。
    • 半读懂的文件仍然值得读。 解析器记录它真正读不下去的第一行;那之前的 发现照常报(PARTIAL),之后的计入 unreliable 且从不展示——它们来自一棵 已经错了的树,摆出来等于把这个工具最初的毛病又犯一遍。
    • 跳过声明不算读不懂。 「不分析 test 块」不会移动这条边界,因为块之后的 代码照样读得通。
    • actionable 不含告知。 「这种声明不分析」不是你的代码有缺陷——不然自检 会报「发现 92 个问题」而真实缺陷是 0。
    • 未解析文件按原因分组而不是一行一个,带示例路径。
    • actionable + notices + unreliable = total,这一行打印里直接断言成立。withheld 也印在旁边,但是 unreliable 的子集(无前置发现的 PARTIAL 文件的尾部发现),不计入等式。

    跨文件类型符号解析(0.4.3 新增):单个 .mbt 用到别的包里的类型(如 @argparse 的 Command)原本会报 undefined name 'Command'——单文件分析 看不到外部声明。--extern-iface-dir <dir>(可重复)把指定目录里所有 .mbti 的声明类型 + 值签名灌进跨文件符号表;结构走查和类型镜在报 FUndefinedName 之前先查这张表。默认还会读目标的 moon.mod 自动 moon fetch 直接依赖(--no-auto-fetch-deps 关掉)。类型命中返回 TUnknown、值命中 返回 TUnknownFn(新增的 Ty 变体:确定是个值、但签名没读到,所以 调用方 Command(...) 既不误报 FOpMismatch,也不会拿一个编出来的 arity 去判「参数过多」——.mbti 只给名字、从不给参数表,编出来的 arity 是猜测,检查会把它当事实报出去)。表里没有的名字照常报错—— 表只抑制、不发明。查表前会先剥掉 @pkg. 限定:.mbti 里声明是裸名 pub fn T::m,使用处必须写成 @pkg.T::m,两侧拼写不一致时精确匹配 永远打不中,而那正是唯一常见的跨包写法。

    已实测(改可信区间前后,同一批树):

    目标文件解析actionable(旧 → 新)
    riantr/moonbit_doubleML@latest → 0.113.018073(40.5%)0 → 123
    本仓库自身(.repos/0.3.5)3922(56.4%)0 → 7
    moonbit_linalg_gpu84(50.0%)1 → 5
    ML/CI(--exclude _qa_verify)10732(29.9%)11 → 57

    与 moon check 的关系要说清楚:在 .mbt 上,本工具不与工具链的编译器 竞争——moon check 有真正的解析器和约 90 条内建告警,覆盖率恒为 100%, 实测 2.3 秒 / 128 条,且每条都带 file:line:col、源码片段与修复建议。本工具的 .mbt 前端只读一个子集:在 moonbitlang/core 上,882 个 .mbt 解析了 173 个(19.6%),而 .mbti 与 .mbt.md 是 100%。原先最大的单一原因 方法调用在 AST 里根本没有位置——ECall 的被调名是 String,所以 m.set(k, v) 无处安放,解析就停在那里——本轮已修(新增 EMethodCall 节点, 三个 lens 都接上),该阻塞从 34 降到 0。这才是诚实的头条数字,也是 PARSED 要印在第一行的原因。本工具能说「编译器做不到」的地方是 .mbti / .mbt.md / .mbtp 三类文件与跨包语义。

    早先版本在同一份 ML/CI 代码上报过 529 条「未使用局部」,而编译器报 8 条。 那不是两种意见,而是没解析的 struct 函数体里那些字段被当成了没被读的绑定。 现在它报不出来了——被前端截断的函数会整函数退出未使用局部 / 未使用参数 / 参数被改写这三族审计,因为「从未被读」是关于整个函数的结论,半个函数 支撑不了这个结论。仅此一项修复就把 moonbitlang/core 从 270 条 actionable 降到 147 条,把本仓库从 11 条降到 0 条。

    再往下读,同一条原则还适用于模块帧,也适用于一类解析器从没学过怎么解析的 名字:前端没读完的文件,模块帧是可证不完整的,所以那里「找不到可见绑定」 什么也不能证明;而 Type::member 路径(@debug.Repr::opaque_(v))是成员引用 不是绑定,跟字段名、方法名本来的处理一致。再把 extern "c" fn 当成声明建模 (原先整条跳过,名字就丢了,于是每个 extern 函数的调用点都报未绑定),补上数值 字面量与 enum 构造器,moonbitlang/core 从 270 条降到 49 条,undefined name 从 64 条降到 17 条(发布 0.4.6 时误记为 0,与该版扫描产物不符,后按实测勘正), 本仓库仍是 0;0.4.7 再把 .mbti 审计的未知类型检查接到模块级接口符号表上, 其余 13 条误报清零,降到 36。

    moonbitlang/core文件解析actionable未定义名
    0.4.5102930527064
    0.4.610293204917
    0.4.710293203617

    根包是薄转发层,真正的实现在 src/sa(library)。设成 library 是因为「main 包 import 另一个 main 包」已被工具链标记为将来会报错;src/sa 与根包都声明 supported_targets = "+wasm+native",因为文件 IO 与子进程来自 moonbitlang/async,只有 wasm / native 后端有可用的 async 运行时(js 没有,wasm-gc 缺 run_async_main),其余后端会跳过这两个包而不是失败。

    #DeepSeek Harness 插件

    src/jsoncli 是 JSON 桥接入口(Node 下运行,一条 JSON 请求进、一条 JSON 应答出): {"kind":"program","source":...} 走程序三鉴,{"kind":"file","filename":...} 按扩展名 分派到对应文件种类的分析(.mbt / .mbtx / .mbt.md / .mbti / .mbtp), {"kind":"machine","spec":...} 走机器表审计。 它被打包为 DeepSeek Harness 插件 @riantr/moonbit-static-analysis-dsh(工作区目录 dsh-plugin-moonbit-static-analysis/),向 agent 暴露 moonbit_analyze / moonbit_analyze_file / moonbit_audit / moonbit_gates 四个工具——插件只是生成器与 格式化器,分析语义全部留在 MoonBit 侧、随模块一起版本化与跑门禁。 安装方式见该目录 README(plugin_manager 的 install_bundle)。

    Source Files