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.17
    License
    MIT
    Last updated
    5 hours ago
    Downloads
    117

    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.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.17

    // 用途一:修订一段 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.17 可被任何 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