feat: AOT try/catch,以及从它牵出来的 210 个提交 - #32
Open
lollipopkit wants to merge 530 commits into
Open
Conversation
|
Important Review skippedToo many files! This PR contains 457 files, which is 307 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to Pro+ to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (4)
📒 Files selected for processing (457)
You can disable this status message by setting the Comment |
Deploying lk-lang with
|
| Latest commit: |
60cc18e
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://54c0ce01.lk-d8q.pages.dev |
| Branch Preview URL: | https://feat-aot-try-catch.lk-d8q.pages.dev |
CI failure root-cause analysisroot cause undetermined Incremental value: root cause; confidence 0%. Passing CI ≠ absence of defects (§29.4). |
`let fact = |n| … fact(n - 1) …;` —— 绑定在自己的初始化式里不可见,所以 lambda 调不到自己(和 Rust 一致:Rust 的闭包也不能递归,递归走顶层 fn)。规矩没问题, 报错有问题:"Compiler undefined callable `fact`" 是一句关于操作数的话,读者拿它 没有任何可做的事。 现在报 "`fact` is not in scope inside its own initializer, so this closure cannot call itself; write a recursive function as a top-level `fn fact(…)`"。判据是"被调 的名字正是当前正在初始化的那个绑定"(lower_let 打标记,闭包体的子编译器继承), 所以拼错的名字仍然读作拼错;外层同名绑定是另一个函数,调它不受影响。 顺带记下:手写 Lua 那套 `let fact = nil; fact = |n| …;` 也不通 —— fact 是 Nil, "Cannot call non-function type"。裁决写进 docs/semantics.md。 门禁:workspace 全绿、clippy、no_std、覆盖 58/58、examples 全过、裸机 QEMU、 perf geomean 1.023x(本会话同一测量在 0.979–1.023 间摆动)。
1) 上一轮说"一个 helper 回答所有位置",核实下来只覆盖了 let 和结构体字段。剩下
五处各自静默拒绝为它写的 lambda:fn 形参报 "got ('T1) -> Int"、命名实参、声明
的返回类型报 "Return type mismatch"、`List<(Int) -> Int>` 的元素、Map 的值。
现在 check_expr_against 真的是唯一那处,并且会往聚合字面量里分发期望 —— 但只
在那个位置真坐着一个 lambda 时才走,否则普通通路的推断原样保留(混类型列表
字面量是 Tuple,那条规矩不是它该推翻的)。return 也走它,所以 return frame 现在
带着声明的返回类型。
期望流进去只是一半:无条件返回声明的类型是断言不是描述,它一度让
`let fs: List<(Int) -> String> = [|x| { return x + 1; }];` 通过。现在每个元素
都回查。
2) `fn pick(){…} let pick = ||…;` —— 两行调换顺序也一样是 let 赢。fn 和类型声明是
被 hoist 的(相互递归能写,说明 fn 在它那一行之前就可见),源码顺序对它们不适用,
"let 遮蔽了它"没有连贯含义。两个同名 fn 早就报错,fn + let 却静默。现在拒绝并
说清为什么;两个 let 仍是正常遮蔽,可调用体里的 let 也是。
这条不是假想:它正是 closure.lk 里一个死掉的 `fn apply` 挨着一个活的
`let apply` 的来由 —— VM 跑得通(断言碰巧被 lambda 那版满足),native 拒绝,
而没有任何东西说过一句话。
闭包放进容器还不能原生降低(NewMap / 列表里的闭包),例子里因此不放,记了 todo。
门禁:workspace 全绿、clippy、no_std、覆盖 58/58、差分 39+13、examples 全过、
裸机 QEMU、perf geomean 0.999x。
`match n { _ => "any", 1 => "one" }` 静默接受。你写了一个你认为会发生的情况,而它
不会。核心检查器没有 warning 通道,而 LK 对同类错误的做法是响亮拒绝(步长 0 的范
围、占用声明名字的 let),所以这里也拒绝。
"catch-all" 的判据与落空检测(match 无匹配给 nil、类型是 T?)**共用同一个谓词** ——
一个模式一旦算"匹配一切",就不能同时"对类型是全的、对可达性不是"。顺手把落空检测
里那份局部的 is_catch_all 删掉,换成共享的:带守卫的 catch-all 是有条件的、不遮蔽
任何东西;or-pattern 里有一个全的分支就算全(这条以前漏了)。
不是假想:examples/syntax/unsupported.lk 里 `match 99 { n => n, _ => 0 }` 的 `_`
就是死的 —— 绑定模式已经匹配一切了。例子跟着改了。
另外核实了 match 的一批既有裁决都还在:落空给 nil、类型是 T?、
`let r: String = match x { 1 => "one" }` 是类型错误、Bool 两个字面量都在才算全、
臂之间类型必须 unify。这些文档挣到了。
门禁:workspace 全绿、clippy、no_std、覆盖 58/58、examples 全过、裸机 QEMU、
perf geomean 0.996x。
三种撞名:两个 `impl Show for P` 各定义 show、两个 `impl P` 各定义 get、两个不同
trait 各声明同名方法 —— 全都静默取最后一个,前面那个永不可达。
字段那一种更糟,拿到哪个取决于**实参个数**:`struct P { get: Int }` 加
`fn get(self)`,`p.get()` 给的是字段(方法永不可达);而 `struct Q { f: (Int) -> Int }`
加 `fn f(self)`,`q.f(3)` 走的是方法(字段闭包永不可达)。`p.get(…)` 说不出它指哪个,
所以在声明处拒绝。两个同名顶层 fn 早就报错,这是同一条规矩;不同 trait 同名也拒绝
—— LK 没有 `Trait::method(x)` 消歧写法,`p.run()` 会没有答案。
判据是程序级的一遍,不是有序遍历累积的 —— 理由和 collect_function_names 一样:问的
是声明的集合,而检查器注册表对重复注册的 impl 是替换的(REPL 上下文跨次复用),分不
出"这里声明了两次"和"又见到一次"。type_check_collecting 也收这条,LSP 照样报。
顺带:`TypeRegistry::validate_trait_impl` 一直只在 VM 注册 impl 时跑,也就是运行时,
于是 `lk check` 放过一个缺 trait 方法、跑不起来的程序,一句话不说。检查器的 Impl 分支
现在自己查(trait 默认实现在这之前已经拷进去了,所以"在不在"就是全部问题)。
门禁:workspace 全绿、clippy、no_std、覆盖 58/58、examples 全过、裸机 QEMU、
perf geomean 1.007x。
关键字以前在所有位置都被保留,这比语法需要的多。一个成员总是经 `.` 到达,或者声明在
struct / impl / trait 的体里,而这些位置都不能起一条语句 —— 所以
`db.select()`、`parser.match(x)`、`struct Row { type: String }`、
`trait Runner { fn go(self) -> Int; }` 以前都是语法错误,没有任何读者据以行动的理由。
放开四处:`.` 之后的成员读取、结构体字段声明、结构体字面量的字段名、impl/trait 体里
的方法名。值字面量(true/false/nil)故意不在里面 —— 它们是值不是关键字,`p.nil` 读
不出意思。"这个 token 能不能当名字"只有一份判据(token::keyword_as_name),四处共用。
顶层 fn 保留限制:调用它是表达式位置上的裸名字,`select(1)` 和 `select { … }` 得靠
上下文区分。报错跟着说清了 —— 以前是 "Expected function name (found Select)",一句
关于 token 的话;现在说它是关键字、为什么这里不行、哪里可以。
顺手把 "Invalid field name: Select" 里的 `{:?}` 换成 token 的字面写法。
门禁:workspace 全绿、clippy、no_std、覆盖 58/58、examples 全过、裸机 QEMU、
perf geomean 1.013x。
1) `<` / `<=` / `>` / `>=` 排数字和字符串,两边要同类。规矩没变,报错说的是别的:
- `1 < "a"` 报 "the left operand must be numeric types" —— 一句话怪错两次:这里
的左操作数就是数字,换成字符串本来合法。
- `[1,2] < [1,3]` 的期望集合写成 `Int | Float | Box<Any>`,漏了 String(字符串
早就可排序),而且答错了问题:列表的问题是它根本没有序。
根因是排序借用了算术那条判据。算术里"必须是数值"是对的(字符串走拼接那条臂),
排序里不对 —— 所以排序现在有自己的 ensure_orderable_operand,借来的那个包装
随之无人使用,删掉。一条规矩变了而复用它的第二处没跟上,是本会话反复出现的形状。
2) docs/semantics.md 的 AOT 那节表里写着"整数除零 → 失败"、"浮点除零是响亮失败,
不是 IEEE inf",而顶部实测锁定的数值表写的是 `1 / 0` = inf。`/` 早就是浮点除法。
两条退休的裁决删掉,并复核了当前行为两端逐字一致(inf / -inf / NaN / 溢出回绕 /
截断取余)。留在原地的退休裁决不是无害注释 —— lkrt 的通道容量就是照着一条写的。
3) `std` 是 `io` 的子模块,裸 `std` 根本不解析,而 AOT 的 MODULE_TABLE 把它登记成
bare_global(那张表被文档称为"模块名如何绑定的单一真源")。改成
submodule_of: Some("io"),文档也标了正确拼写。
门禁:workspace 全绿、clippy、no_std、覆盖 58/58、examples 全过、裸机 QEMU、
perf geomean 0.995x。
`encoding.json.parse(s)` 和 `use { json } from encoding; json.parse(s)` 是同一个成员
的两种拼法。后者一直原生降低,前者让整个程序回落 —— 答案一样,慢三倍,差分门禁看不
见,是探针撞上的(和模板串里的容器、chan.new 同一类)。
缺两件事:
1. 从父模块读一个子模块,给出的是父模块的一个"函数"(ModuleFn),而不是另一个模块
对象,链子停在第一个点上。is_submodule 谓词早就有 —— 选择性导入那条路一直在用 ——
只是 GetIndex 那侧没用。
2. `encoding.json.parse(s)` 编译成 CallMethodK,接收者是模块对象,而那条路上没有模块
分支,于是去 ssa.read 一个只存在于降低期的 ref,报 "register r7 is read before any
definition"。
顺带补齐 MODULE_TABLE:父模块(encoding / net / io)此前根本没有行,名字都绑不上;
子模块补了 base64 / hex / url / udp / file。名字绑得上和成员降得下是两件事 —— 前者归
这张表,后者归 MODULE_ABI;encoding.base64.encode 现在是后者缺(lkrt 无符号),记了
todo。
`std` 那行同时从 bare_global 改成 submodule_of: Some("io") —— 裸 std 根本不解析。
门禁:workspace 全绿、clippy、no_std、覆盖 58/58、差分 40+13、examples 全过、
裸机 QEMU、perf geomean 1.004x。
`url.encode_component("a b")` 给 "a+b",而 `decode_component` 只撤 `%XX`,于是
`decode(encode(s)) != s`。编码用的是 form 编码(空格→`+`),解码是百分号解码 —— 一对
的两个方向得先互相同意,再谈和别的东西同意。和 datetime 的 format/parse 不往返同形。
裁决:component 就按 component 编码 —— 空格是 %20,`+` 是字面的 `+`,也就是
encodeURIComponent / decodeURIComponent 的规矩,未保留集 `A-Za-z0-9-_.!~*'()`。
form 编码是 query body 要的,而 query_stringify / query_parse 本来就是那一对(两端都
走 form_urlencoded),不受影响。编码器因此改成手写的,和本来就手写的解码器并排:一对
的两个方向应该是一份实现的两个方向,不是两个 crate 的两种约定。
同时补上原生实现:base64.encode、hex.encode、url.encode_component、
url.decode_component。lkrt 用与 stdlib 同一个 crate(base64、hex),所以文本逐字节
相同 —— 和 datetime 用 chrono、json 用 serde_json 是同一个理由;decode_component 的
三条报错文本也逐字照抄,raise 两端一致且可捕获。此前这四个成员里任意一个出现,整个
程序回落。
base64.decode / hex.decode 给 Bytes,原生还没有那个承载类型,继续回落 —— 表里没有行
的成员是普通回落,不是错答案。
门禁:workspace 全绿、clippy、no_std(core + lkrt)、覆盖 58/58、差分 41+13、
examples 全过、裸机 QEMU、perf geomean 1.007x。
Bytes 在原生一侧没有承载类型,于是 `"hi".bytes()`、bytes 模块的每个成员、 base64.decode / hex.decode 里出现任意一个,整个程序回落。 Ty::Bytes 是不透明指针句柄,和 List/Map/Set 同形,十个接点。它必须是独立类型而不是裸 句柄整数,因为显示和相等都要知道它是字节:println(b) 是 Bytes([104,105]) 不是指针, `==` 比内容。覆盖:from_string / len / is_empty / get / slice(2 参与 3 参)/ concat / to_string_utf8 / to_string_lossy、`.bytes()` 方法、`b[i]` 下标(`b.get(i)` 就编译成 这个)、两个 decode、以及 fs.read —— 后者一直有 ABI 项而没有降低行,因为没有类型可给。 **lkrt 里曾经有两个 Bytes。** 另一个是 tcp/fs 用的一次性 host 句柄(take_bytes 读走就 没了)。对"读一次 socket 解一次码"是对的,对值是错的:bytes.len(b) 之后再 to_string_utf8(b),第二次找不到句柄。tcp.read / fs.read 现在都给 arena 句柄,一次性那 套(资源变体、两个访问器、两条 ABI 项)整套删掉 —— 让它们分开的理由消失了,留着是陷阱。 no_std 下 Resource 因此变成空枚举,整个 host-resource 家族跟着 gate 到 std。 差分用例逼出了第三件事:bytes.get(b, -1) 在 VM 里 raise。#62 的裁决说负数从末尾数, slice_position 的注释还写着"List 和 Bytes 都曾 raise"(声称已修),但 bytes 模块在 stdlib crate 里够不到 core 那个 pub(super) 辅助函数,自己写了 usize_arg。可观测的是 同一个操作两个答案:`b.slice(1, -1)` 给 Bytes([98,99,100]),`bytes.slice(b, 1, -1)` 报错。判据因此提成 core::val::position 的公开 API,VM 侧现在真的只有一份; element_position 与 read_position 的区别正是"元素没有可编的答案,窗口有"。 门禁:workspace 全绿、clippy、no_std(core + lkrt)、覆盖 59/59、差分 42+13、 examples 全过、裸机 QEMU、perf geomean 1.018x。
CI 的 lint 是 `clippy --workspace --all-targets --all-features`,而 `--all-features` 从不编译 no_std 那一侧。于是只存在于那里的代码 —— io_bare、中断桩、每个 `#[cfg(not(feature = "std"))]` 分支、以及那个 profile 下的**全部测试代码** —— 一次都 没被 lint 过。加上这一步之后它不干净: - io_bare.rs:一个在**安全** `extern "C" fn` 里解引用裸指针的导出(其余同类导出都是 `unsafe`,文档注释里也早写着 `# Safety`)。 - isr.rs:`global_asm!` 上挂了个 `///` —— 宏调用上的文档注释什么也不记录。 - 四个测试文件在 no_std 下**从未编译过**(缺 alloc 导入)。其中一个是 abi_conformance_test —— 保证 ABI schema 指的符号 lkrt 真的导出的那一份。它现在 gate 到 std:schema 描述的是**宿主**运行时,no_std 是有意的子集,"每个符号都在吗" 在那里不是个有答案的问题。 下面那些裸机构建步骤抓不到这些:它们只 build lib,不是 --all-targets,而且不带 lint。 新增的门禁两条:`clippy -p lk-core --no-default-features --all-targets` 与同样的 lkrt。 另外:`cargo fmt --all -- --check` 在上一个提交处就已经是红的 —— 我这一路跑了 test / clippy / no_std / 覆盖 / 差分 / examples / 裸机 / perf,唯独漏了 fmt。28 处、 14 个文件,现在清了。 门禁:workspace 全绿、clippy(std + no_std 两套)、fmt、no_std 构建、覆盖 59/59、 examples 全过、裸机 QEMU、perf geomean 1.015x。
顺着上一条"门禁本身的洞"往下查,又找到两处。 1) bench 的 `RUN_AOT=1` 用一句普通 `lk compile` 编译语料。普通 compile 对降不下来的 形状**会回落**到 VM bundle —— 那一趟仍然产出二进制、仍然被报成 "AOT",而实际跑的是 解释器。一个错的数字,没有任何东西说一句话。现在带 LK_AOT_NO_FALLBACK=1:降不下来 就编译失败并说明原因。同一份语料也进了 aot_coverage.sh 的扫描(60/60),两半各管 一头 —— 门禁保证它一直能全原生降低,严格编译保证即使跳过门禁,测量本身也不说谎。 (核实过:今天它确实是全原生的,所以现有数字没问题。) 2) `make zed-ext-check` 存在,但从没接进 CI。zed-ext 不在 workspace 里(它编到 wasm32-wasip1),所以 `cargo test --workspace` 也看不见它 —— 扩展可以悄悄编不过。 现在是 check.yml 的一步。(核实过:今天是好的。) 顺带清一处退休残留:bench 报告里的 "AOT: … (unknown)"。它在刮编译日志里的 "backend X," 行,而那行随字符串 IR 的 llvm 后端一起退休了;现在只有一个原生后端, 直接写出来。 另外探了三个从没构建过的配置 —— `lk-cli --no-default-features`、`lk-wasm` 的测试、 `lk-wasm` 在 wasm 目标下的 clippy —— 都是干净的,没有动。`lk-core --no-default-features` 的**测试二进制**在宿主上链接不了(要裸机侧提供的符号),所以那个 profile 只能停在 编译检查,这是上一条已经加的门禁能给的最诚实的东西。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 zed-ext、examples 全过、裸机 QEMU、perf geomean 1.012x。
做了一次广谱回落扫描(40 条常见/少见形状),撞出三件事,这次修两件。 1) `m["a"]![0]` 报 "a macro invocation reached the parser"。后缀 `!` 是解包, `name![…]` 是宏调用,而判据只看后面那个开括号。宏名是**标识符**,`m["a"]` 不可能是 宏名 —— 这些拼写里根本没有歧义,却要靠加括号或拆成两行绕过去。判据改成"`!` 前面 是不是一个裸 Expr::Var"。真正有歧义的只有裸名字一种(`f![0]`),归宏,要解包写 `(f!)[0]` —— 报错里本来就说了这句,现在那条分支也成了唯一分支(泛化的那句文案随之 不可达,删掉)。 2) `xs.slice(1)`(省略 end)和 `string.slice(s, …)`(模块拼写)都回落。前者:Ty::Str 有一参和两参两种,列表只有两参 —— 同一个操作,长度不同就慢三倍。后者:方法拼写一直 降得下来,模块拼写没有行。两种都补上了,顺带 bytes 的一参形式一起。六种 slice 拼写 现在两端逐字一致。 扫描剩下的一条(闭包调另一个闭包不能降低)记成 todo,它需要闭包 ref 能进捕获,不是补 一行表。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 差分 43+13、examples 全过、裸机 QEMU、perf geomean 1.017x(同轮三次采样 1.079/1.017,机器有负载波动;本轮只动 parser 与 AOT 降低,RUN_AOT=0 测的是纯解释器)。
`let f = |x| x + 1; let g = |x| f(x) * 2;` —— 组合两个 lambda 是"有 lambda"这件事本身 最主要的用途。f 被捕获,所以编译器把它放进一个 cell,而进那个 cell 的是一个降低期的 **引用**,不是值:StoreCellVal 去读 SSA 值,读不到。 两半: - Ssa::cell_refs —— 整个内容就是一个可调用引用的 cell:存进去时记下引用、不写槽,读出来 时把引用还回去。一个 cell 只能有一个引用,同时又被赋别的东西就拒绝(回落),而不是猜 后面那次读想要哪个意思。 - SigInfer::ref_captures —— 被调方那侧。引用没有运行时表示,所以捕获仍占着 ABI 槽位(一个 死的 0),意思走这张表。由调用方发现并请求重试,和 cell_captures 同一个回路。 **只收无捕获的可调用**(Lambda / UserFn)。Closure(fidx, caps) 里的 ValueId 属于建造它的 那个函数,在读 cell 的人那里什么也不指 —— 记下来就等于把不存在的操作数递给读者。第一版 我把它一起收了,扫描时发现它靠"读不到寄存器"碰巧拒绝;现在是故意拒绝。 覆盖:两个 lambda 组合、调命名 fn、同一体内调两次、三层链、调用的同时写捕获、lambda 别名。还没通的两种(lambda 实参体内调被捕获的 lambda;被调的那个自己带捕获)记在 docs/aot/aot-gaps-and-lkrt.md §16。 顺带:往 closure.lk 加例子时,我自己撞上了本会话加的 #94 检查 —— 文件里已经有 `let triple`,而我又写了 `fn triple`。它逮住了我。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 差分 44+13、examples 全过、裸机 QEMU、perf geomean 1.011x。
上一条让闭包能调闭包,但 `[1,2,3].map(|x| f(x))` 还差一步:那个 lambda 实参**有**一个 捕获(被捕获的 f),而列表 HOF 的类型化快路(i64_map_fn 等)只认无捕获的可调用 —— 它调 回调时只递一个元素,再没有别的。 关键观察:一个捕获环境全是静态引用的闭包,运行时什么都不需要传 —— 它就等价于一个裸函数 引用。所以 captures_all_static 为真时,lower_function 干脆不声明那些参数,MakeClosure 直接给 GlobalRef::Lambda,各处(包括 HOF 快路、lambda_at)都把它当普通函数引用看。 全有或全无,这是有意的:混合环境需要在某一个下标上留个洞,而每个调用点都得同意洞在哪。 死槽位那种形式本来就把混合情形处理对了,只是白费一个寄存器。 八种组合形状里现在七种原生;剩的一种(被调的自己带捕获)是上一条故意拒绝的那条,记在 docs/aot §16.1。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 差分 44+13(整套跑过)、examples 全过、裸机 QEMU、perf geomean 1.006x。
扫了错误处理与 defer 的交互(10 种形状)。多数是明写且成立的裁决;两件事值得记。
1) task.join_all 原生降低(句柄形式)。它是变参,所以没有哪一行能描述它 —— 一行只有一
个 arity。`join_all(a, b)` 和 `join_all(a)` 是同一个循环(逐个 rt.task_await,推进
一个 dyn 列表);`join_all([a, b])` 传的是列表句柄,长度运行时才知道,需要 lkrt 里的
循环而不是在这里展开,仍然回落。验收点是元素显示:dyn 列表对字符串的引号必须和 VM
的类型化列表逐字一样(`["x","y z"]`),混合类型也一样 —— 差分语料里两种都在。
2) `defer` 在 raise 路径上不跑,这是 defer.rs 里明写的裁决,理由是"raise 走 longjmp,
AST 重写看不见"。但它没考虑第三条路:把函数体包进 try、在 catch 里跑释放、再重新
raise —— 那仍然是纯 AST 重写,而 try/catch 和"从 handler 重新 raise"两端都已经能用
(今天实测过)。挡住它的是可测量的东西而不是哲学:**try 体里的 return 现在不能外联**
(记为 #111 / docs/aot §17),而包裹函数体会把每个 return 都放进 try —— 于是每个带
defer 的函数都会回落,包括那个编译运行的裸机演示,也就是这个特性存在的理由。
两处文档都补上了这条因果,免得下次从零再论一遍。
顺带 #111 本身也是一条独立回落:`try { return n*2; } catch e { return -1; }` 回落,而
`let v = try { n*2 } catch e { -1 }; return v;` 原生 —— 同一个意思两种写法。协议缺口和
实现路线量过了(输出 cell 加一对:标志 + 返回值),风险在父侧漏检查会**静默返回错值**,
所以它值得单独一轮,不搭车。
门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、
差分 45+13、examples 全过、裸机 QEMU、perf geomean 1.013x。
List<Int> 在 lkrt 里就是 Vec<i64> 的 arena 句柄,和 Bytes 同形,所以两个方向都只是一次 转换。不是字节的值 raise,和 stdlib 模块一样 —— 一个不是字节的"字节"是个错误,不是要 静默截断的东西;空列表、空 Bytes、越界与负数四条边都在差分语料里。 这轮本来要做 #111(try 体里的 return 不能外联)。设计上一轮已经写好,这轮把父侧的块结构 量了:条件返回需要拆块,而新块会成为 fallthrough / handler 的新前驱,phi 的每条操作数都 得跟着重排。风险正是"漏一条边就静默返回错值"而不是回落 —— 半途开工比不开工更糟,所以 没动,设计留在 docs/aot §17。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 差分 45+13、examples 全过、裸机 QEMU、perf geomean 0.993x。
`fn f(n) { let v = try { if (n > 0) { return 10; } 0 } catch e { -1 }; return v; }`
以前回落:body 被外联成一个函数,里面的 return 会变成"从 body 返回"。
body 本来就有两条通道(返回值走 LkDyn,raise 走 trampoline 的 outcome),加的是第三个,
用的是现成的输出 cell 机制:一对 cell(标志 + 返回值),只给体里真的有 return 的 region
加,别的 region 传的东西一个不变。body 侧把 Return 改写成写这两个 cell 再正常返回;父侧
的 ok 边先去一个检查块,读标志,真就去返回块,假就转发到原来的 fallthrough。
上一轮我担心的"新块会成为 fallthrough/handler 的新前驱、phi 要重排"**不需要发生**:检查
块转发时用的就是 `args_to(区域块, fallthrough)`,即区域块本来要传的那一份实参 —— 目标的
phi 操作数仍然记在区域块名下,而那里正是读它的地方。插入因此是局部的,这也是我上一轮
停手、这一轮先量清楚再动的原因。
退化情形仍然拒绝并说明理由:体里每条路都 return 时,编译器不发跳过 handler 的 Jmp,这个
region 根本没有 ok 边,TryEnd 那个块没有后继可记 —— 那要的是"没有 ok 边的 region",不是
多一个 cell。
覆盖 Int/String/Bool 返回、return 与 raise 同体、一个体里两个 return 加一条落空路径。
过程里两个自己的错:python 补丁脚本在第二处断言失败时整份不写盘,前一处也跟着丢了(靠
"改动没生效"才发现);以及 Return 那条臂用 continue 跳过了循环末尾的
`block_insts[bi] = insts`,指令被丢掉 —— 改成条件分支。
门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、
差分 46+13(整套跑过)、examples 全过、裸机 QEMU、perf geomean 0.997/1.008x
(首次采样 1.116 是噪声:本轮只动 AOT 降低,而 RUN_AOT=0 测的是解释器,不走那条路)。
1) 上一轮我给"体里每条路都 return"的 region 加了一条拒绝,理由写成"它没有 ok 边"。 **那是错的判断。** ok 边照样在,只是恒定走返回那一支;我当时看到的崩溃来自两个自己的 bug(一份 python 补丁在第二处断言失败时整份没写盘;Return 那条臂用 continue 跳过了 循环末尾存指令的那行)。拒绝去掉后,六种"每条路都 return"的形状全部原生,差分语料加了 三条(含 handler 落空而 body 返回的那种)。 教训写进文档了:**一个自造的 bug 会长得很像一条语言性质**。当"这里需要一条新规矩"这个 念头是从崩溃里冒出来的,先把崩溃归零再判断。 2) 于是 defer 在 raise 路径上跑的前置条件齐了,我把它做了:函数体包进 try、catch 里跑同 一组释放、再重新 raise —— 仍是纯 AST 重写,两端都通过,e01 从"defer 不跑"变成跑。 然后**撤回**,因为一个只有做出来才看得见的代价:包整个函数体,会让体里赋值的每个寄存器 都变成区域的输出 cell,而寄存器复用意味着那是大多数;cell 往返对标量有定义,对容器句柄 **故意没有**(读回成错的类型化句柄是错答案,不是拒绝)。`examples/syntax/defer.lk` 当场 掉出原生(覆盖 59/60)。 这个交换方向不对:把一条已记录的语义缺口,换成正是这个特性存在理由的那类代码(内核, 是编译运行的)**静默慢三倍**。真正的前置条件因此是**容器句柄的 cell 往返**,不是 §17。 两处文档都从"手感判断"改成了这次实测。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 差分 46+13、examples 全过、裸机 QEMU、perf geomean 1.004x。
上一轮撤回 defer-on-raise 时判断"真正的前置条件是容器句柄的 cell 往返"。查下去发现它是
两件事,不是一件:
- **本来就装箱的容器按指针往返**:`dyn.from_list` / `from_map` 只给句柄打 tag,
`as_list` / `as_map` 查 tag 后还回同一个指针 —— 身份和它捎带的修改都在。这个安全,加了:
`List<Any>` 和 `Map<String, Any>` 现在能跨区域,`try { xs = […]; } catch e { }` 不再让
整个程序回落。
- **类型化容器不能**,而且不是缺一行表。它的装箱是**逐元素转换**(`list_h::i64_to_dyn`
会建第二个列表),往返拿回的是副本,体里对原句柄的写就丢了。把类型化句柄直接打上
`DYN_LIST` 比"错"更糟:`Vec<i64>` 被当成 `Vec<LkDyn>` 读是内存安全 bug。它们要的是
**保身份的 cell**(裸句柄槽而不是装箱槽),那是一件真活。
顺带核实了一条让这件事成立的前提:类型检查器**拒绝**把 `List<Int>` 变量改赋成
`List<String>`,所以"同为 list、元素类型不同"这种读回错类型的危险从源码上不可达 ——
`dyn.as_list` 的 tag 检查负责剩下的。
差分加了三条,含"体在赋值前就 raise"(cell 里应当还是调用方 seed 的那个值)。
defer.rs 和 docs/aot 两处都从"前置条件是容器往返"改成了"一半已经做了,剩下的是保身份的
cell",附上为什么。
门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、
差分 47+13、examples 全过、裸机 QEMU、perf geomean 1.005x。
上一轮说"类型化容器要的是保身份的 cell,那是一件真活"。这轮做了:句柄原样停在自己的 tag(DYN_RAW)下,不装箱,拿回来的是同一个指针。 **这条设计敢做的前提是两端都查 tag**:`cell_get` 和 `cell_get_raw` 各自校验,所以"把裸的 当装箱的读"是响亮失败,而不是一个 `Vec<i64>` 被当成 `Vec<LkDyn>` 走 —— 后者是内存安全 bug,不是错答案。判据也只有一处(`function::cell_is_raw`),调用方(seed 和读回)和体 (每次赋值时写)读同一个函数,两边不可能各说各话。 覆盖:类型化 list、类型化 map、Set、Bytes。 **defer-on-raise 第二次尝试,第二次撤回。** 容器这道坎过了,卡住的换成返回值管路:所有 return 都在被包裹的体里时,外层函数没有自己的 Exit::Ret,返回类型就丢了;把 park 的类型 带给调用方能修好这种形状,却弄坏"既有真 return 又有 park return"的形状;而落空那条路又会 对着有类型的签名返回 void。每一条都答得上来,合起来是它自己的一件活 —— 半成品正是"函数 静默返回错东西"的来源。三次测量都记在 docs/aot §17 和 defer.rs 里。 顺带查清一条**先于本轮**就存在的回落:同一函数里"容器区域 + 第二个区域"不降低(两个 dyn 容器区域一样,所以不是裸 cell 带来的)。差分语料因此一个用例一个区域,并把这条记下来。 门禁:workspace 全绿、clippy(std + no_std)、fmt、no_std 构建、覆盖 60/60、 差分 47+13、lkrt 55、examples 全过、裸机 QEMU、perf geomean 1.016x。
体在自己帧里写了又没捎回来的寄存器,父帧那份被 poison;之后谁读到它, `UndefinedOperand` 正是定点用来发现"哪些寄存器需要 cell"的线索。这一步是对的: 它把"区域之后还有人读吗"这个活性问题变成向 SSA 提问,而不是给每个 opcode 写一张读操作数表。 坏在线索只说了寄存器号,没说是谁毒的。消费端于是把 cell 派给函数里每一个区域。 而寄存器号会被复用:第一个体拿 r2 当过草稿(存 `[2]` 这个字面量),第二个变量 `b` 也正好分到 r2。第一个区域收到一个 r2 的 cell —— 可它在自己区域开始处从来 没定义过 r2,做种就要在 pc 0 之前读它,整个函数回落。也就是说:给函数加第二个 `try`,会让第一个 `try` 丢掉降低,而两个分开写时各自都好。 poison 现在记住 body id(`Vec<Vec<Option<u32>>>`),错误带着它走,消费端不用猜。 没有归因的 `UndefinedOperand` 是普通的未定义读,cell 修不了它 —— 那条瞎猜的兜底 一并删掉,覆盖率一个没掉,说明它从来没干过活。 `several_try_regions_share_a_function` 钉五种形状:容器区域接标量区域、三种类型 三个区域、区域进循环、两个都 raise、函数里两个区域串起来。 门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 / 差分 48+13 / examples / 裸机 QEMU。改动只在 aot/lower,perf 门禁量的是纯解释器, 两者不共代码。
两个洞在同一处碰头,而且互相遮掩。
一、`NewMap` 根本没有降低。值不全是常量的字面量 —— `{"k": a}`、`{"a": f(3)}`
—— 走的是它,于是"从算出来的东西拼一条记录"这种再普通不过的程序整个掉回 VM。
对应的 list 拼写 `[a, a + 1]` 一直是原生的,这正是它没被发现的原因:两种字面量
读起来一样,只有一种能编译。修法是走和常量 map 完全同一条路(`lit_new` /
`lit_set` / `lit_finish_<形状>`),所以形状判据只是照抄那几条臂,不长第二套。
二、显示类型化 map 照着一条退休的裁决拒绝。原话是"map 的顺序两个运行时不共享",
而它先于 `lkrt/src/vm_mirror.rs` —— 那个模块存在的全部意义就是让两边共享它,
`lit_protocol_matches_vm_iteration_order` 直接拿 lk-core 比对。`MapStrDyn` 早就
放行了:裁决对一个类型解除、对其余的留着,于是 `println({"a": 1})` 让程序丢掉
降低,`println({"a": 1, "b": "x"})` 不会。
三、顺带撞出来一个错答案:`println([m])` 打的是 `{"a":1}`,VM 打 `[{"a":1}]`。
`NewList` 没有哪条臂装得下类型化 map,于是什么都没物化,目标寄存器只剩 ArgList
那一半视图(它同时也是方法调用的实参窗口),读到它的调用就把元素当成了列表本身。
补装箱之外还加了兜底:非空却没物化出句柄是回落,不是静默拆包。覆盖率一个没掉。
四、整数键仍然拒绝,理由是新查的、不是老裁决:VM 对非字符串键不做第二阶段
(`typed_map_from_entries` 直接返回 `Mixed`),lkrt 的 `lit_finish_i64_*` 却又
rehash 一遍,`{1: 1.5, 2: 2.5}` VM 迭代 2,1 而 native 迭代 1,2。今天没人看得见
(它的 display 和 `.keys()` 都不降低),所以是潜伏项 —— 而给它发一个 display
正是让它变成活的那一步。写进 `to_display_str` 和 docs,单列一条待办。
`a_computed_map_literal_lowers_and_a_typed_map_displays` 钉十种形状。
docs/semantics.md 里那条退休的裁决一并更正。
门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 /
差分 49+13 / lkrt 55 / examples / 裸机 QEMU。VM 未改,perf 门禁量的是纯解释器。
上一条把类型化 map 的显示放进原生子集时,发现整数键对不上:
`{1: 1.5, 2: 2.5}` VM 迭代 2,1,native 迭代 1,2。
根因是镜像少读了 `typed_map_from_entries` 的一行:非字符串键**直接返回
`Mixed`**,而 `Mixed` 就是 stage-1 那张表 —— VM 对它根本不做第二阶段。
`lit_finish_i64_i64` / `i64_f64` 却又 rehash 进 `FxMap<i64, _>`:哈希不是一回事
(`i64` 对 `RtKey::Int(i64)`),插入序也不是(表的迭代序 对 字面量序)。
当时没有哪条路看得见它 —— 整数键 map 的 display 和 `.keys()` 都不降低。但"潜伏"
的意思是:下一个给整数键 map 降低迭代的人会拿到一个错答案,而且没有任何东西会
告诉他。所以修载体,不绕开:
- `vm_mirror::IntKey` 按 `RtKey::Int` 哈希。判别式写成常量而不是现构一个带
`String` 变体的 32 字节枚举,`int_key_hashes_like_the_mirror_enum` 负责说这两个
是同一个(顺带钉住"无 repr 的枚举判别式按 isize 哈希"这个假设)。
- `LitBuilder` 除了 stage-1 表还记一条字面量序。这不是冗余:字符串键有 stage 2,
finisher 迭代表就对;非字符串键没有,finisher 必须重放字面量的插入序列 ——
在那里迭代表就等于多跑了一个 VM 没跑过的阶段。
- `int_lit_protocol_matches_vm_iteration_order` 拿 lk-core 新加的
`typed_map_iteration_int_keys` 逐条比对,五组键(含 64 个、逼出多次扩容)。
于是整数键的 display 也进了子集,差分里字面量和逐个赋值两条路都钉住。
门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 /
差分 49+13 / lkrt 57 / examples / 裸机 QEMU。VM 未改。
容器 × 基本操作摆成矩阵扫了一遍(13 种值 × 9 种操作),撞出这一组:每一种
*list* 配对都能原生比较,而**没有一种 map 配对能** —— 连 `{"a": 1} == {"a": 1}`
都回落。`Map<str, Bool>.len()` 也是,它在 `Len` 那张表里就是漏了一行,而它跑的是
和 `Map<str, Int>` 同一个载体。
接 `dyn.eq` 是对的路(顺序无关、逐键、带 VM 的数值提升),但这条路当时**不能直接
用**:结构体实例就是一张打了标记的 map,而 `dyn_eq_inner` 只比条目 —— 于是
`P{x:1} == Q{x:1}` 和 `P{x:1} == {"x":1}` 都会答 `true`,VM 答 `false`。
标记一次回答这三种情况:每个声明的 struct 都有 id(`trait_env_prescan` 里就写着
"不只是有 impl 的"),普通 map 没有。所以先比标记。`lkrt_dyn_obj_type_id` 的注释
"或者没有 trait impl 的类型"是条过期的话,一并改掉 —— 相等要靠"没标记 = 不是
结构体"这个前提才能分开两个同形状的结构体。
空的判断放在 null 守卫**之前**,否则一个空结构体会等于 `{}`。
整数键 map 仍然回落:没有整数键的 Dyn map 可以归一化过去,要的是它自己的一组
helper,不是这一行。回落,不是错答案。
`maps_and_structs_compare_natively` 钉六组:类型化/装箱 map、数值提升与 bool、
嵌套值、结构体身份、容器里的结构体身份、bool map 的 len。
门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 /
差分 50+13 / lkrt 57 / examples / 裸机 QEMU。
`Set([1, 2, 10, 20, 3])` 打出 `Set([1,10,2,20,3])`,`Set([-1, -2, 5])` 打出 `-1,-2,5`。排序这件事本身是对的 —— 集合的哈希迭代序不该拿出来给人看,显示必须 强加一个序 —— 但它强加在**渲染后的文本**上,而不是成员上。得到的序既不是插入序, 也不是值序,也不是任何读的人能用的东西。 判据放进 `RuntimeMapKey::display_order`:nil → Bool → Int(按数值)→ String (按内容)。**不是 `derive(Ord)`**,这正是它得是个函数的原因:derive 比的是变体, 于是 8 字节的 `String` 会排在所有 7 字节的 `ShortStr` 后面 —— `Set(["ab", "aaaaaaaaaa"])` 会把 `"ab"` 排前面。字符串的表示在语言里其它任何地方都不是它的 值的一部分,这里也不是。 native 侧因此进了子集,而且这是唯一一种**不需要镜像纪律**的容器显示:序是强加的、 比的是内容,两边不可能因为 hasher 或表布局漂移而分开。同时补上: - `Set == Set`(顺序无关,同大小 + 逐个成员在场); - `Set(list)` 的 `List<Dyn>` 拼写 —— 常量列表只要元素类型不齐就是 `List<Dyn>`, 而字符串按长度分家,所以 `Set(["ab", "aaaaaaaaaa"])` 之前没有臂可走, `Set(["ab", "z"])` 有。 `sets_display_sorted_and_compare_natively` 钉六组。docs/semantics.md 补了显示序 那一行,并且写清**迭代序不是显示序** —— `for x in s` 仍不降低,那要的是哈希序的 镜像,和显示是两件事。 门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 / 差分 51+13 / lkrt 57 / examples / 裸机 QEMU / perf 1.005x(VM 的显示路径改了, 所以这次跑了)。
矩阵扫出的 9 处剩余回落里,5 处是同一条根因:`LkDyn` 的标签集**不覆盖语言里的
每一种值**。`Set` 和 `Bytes` 没有标签,于是它们根本无法**装箱** —— 而装箱正是一个
值进入混合容器、结构体字段、返回位置的方式。所以 `[s]`、`{"k": b}` 没有降低,
原因跟集合和字节缓冲都没关系。
补 `DYN_SET` / `DYN_BYTES`,两者都是**原地打标签**,不重建 —— 身份和之后的修改都
跟着走(`boxing_keeps_identity` 钉这条:装箱之后再 `s.add(2)`,从箱子里看得见)。
跟着补齐它们在 dyn 世界里该有的行为,每一条都调**未装箱那一侧同一个函数**,所以
不可能两边漂移:显示走 `set_text` / `bytes_text`(为此把两个 display 拆成 text
helper + extern 包装),相等走 `set.eq` / `bytes.eq`,`len_of` 走各自的 len。
`cast_to_i64` 的两句措辞也补上,不再落到"cannot cast Nil"。
`sets_and_bytes_are_boxable` 钉五组。
还差的 4 处各是一件独立的活:`for x in s`(哈希序,而且 lkset 自己那份 RtKey 只有
4 个变体、不是真镜像)、`for b in bytes`、整数键 map 进容器(没有整数键的 Dyn map)。
门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 /
差分 52+13 / lkrt 57 / examples / 裸机 QEMU。VM 未改。
`vm_mirror::RtKey` 是 `RuntimeMapKey` 的五变体镜像(`ShortStr` / `String` 分家, 哈希与 VM 一致);`lkset.rs` 另有一份四变体的,把两种字符串折成一个 `Str(String)`,理由是"VM 的分家按长度,所以相等不受影响"。 对相等成立,对**哈希**不成立 —— 而集合的迭代序就是哈希做的。于是这两份定义 在成员资格上一致、在顺序上不一致。它表现出来的样子不是错答案,是 **`for x in s` 干脆没有降低**:这个模块自己的头注释写着"iteration 不暴露(hash order)"。一条能力缺口,底下压着一条实现重复。 统一到一份之后: - 顺带把 VM 那条"字符串键按长度分家"的规矩抽成 `str_key`,原本在两处各写一遍; - `for x in s` 放出来,`set_iteration_order_matches_the_vm` 拿 lk-core 新加的 `set_iteration_order` 逐条比对(64 个整数成员 / 48 个长短混合字符串成员, 都逼出多次扩容); - `for b in bytes` 一起放出来 —— 它按字节序,哪儿都没有哈希,不需要镜像。 注意这和上一条 Set display 是**两件事**:显示的序是强加的(比内容),所以不需要 镜像纪律;迭代的序是哈希的,所以需要。docs 里把这条写清楚了。 形状矩阵(13 种值 × 9 种操作,172 个程序)现在只剩 4 处回落,全是整数键 map —— 它缺一个整数键的 Dyn map 可归一化过去。 门禁:workspace / clippy(std+no_std)/ fmt / no_std build / 覆盖 60/60 / 差分 53+13 / lkrt 58 / examples / 裸机 QEMU。VM 只加了测试支持函数。
此前 item 导入一个类型直接报错,消息还说"an imported type cannot be named or
constructed directly" —— 而 `m.P { … }` 一直做得到。
`stmt::struct_ctors` 已经给每个 `struct S` 生成 `fn S$new({…}) -> S`,`m.S { … }`
是解析期糖 `m.S$new(…)`,调用跑在定义方模块里,所以 scope、字段序、trait 分派
全对。缺的只是把裸名字接到这条糖上:
- 导入解析找不到同名导出时改找 `P$new`,把它绑到 `P`(`vm/resolver.rs`)。
`trait` 没有构造函数可绑,所以仍然拒绝,报的是这条理由。
- 编译器按已知的两件事选路:本地 `struct S` 一定带来本地 `S$new`,所以"没有
本地 `P$new`、却有一个叫 `P` 的全局"恰好是导入这一种情形,降到对那个全局的
具名调用;否则照旧发 `NewObject`。本地构造这条热路径一条指令没动。
- 检查器记 `constructible_imports`(绑定名 → 声明名),字面量按声明方的 schema
校验,结果类型也是声明名 —— `use { P as Q }` 因此是 `Q { … }` 建一个 `P`,
报错说 `struct 'P'`,`typeof` 答 `P`。本地同名声明把这个标记清掉,与
`imported_structs` 同规矩,否则本地 `struct Q` 会被按 `P` 的 schema 校验。
- AOT 的 item 导入解析同样回退到 `P$new`,否则这条绑定在原生侧解析不到任何
东西,整个程序回落。
只通过命名空间看见的类型仍然拒绝裸字面量:那里 `P` 没有绑定任何东西,
`NewObject` 会盖上当前模块的 scope,建出一个同名但没有方法的类型。报错点名
两条出路。
geomean 1.005x。
`type_info` 有 traits / impls / structs 三张表,bundler 现在并 impls 与 structs,不并 traits。于是合出来的模块声明了 `impl Area for Sq` 而没有 `trait Area`,`VmContext::register_module_types` 遇到这种模块直接报 "Trait 'Area' not found" —— 只要有哪个消费者带着类型检查器跑这个 artifact。 当前没有消费者踩到:AOT 的分派是从 impls 去虚化的,不读 traits;`lk bundle` 拒绝多文件;hybrid 桥里的 VM 没挂检查器。所以这条改的是产物本身的自洽性, 不是一个可观察的错答案 —— 但一个内部不自洽的 artifact 是留给下一个消费者的坑。 同名而方法不同则拒绝编译,与另外两张表同规矩。 顺带:`TypeInfo::is_empty` 的文档说"没有 trait 或 impl",而它同时看 structs。
`compiling_many_functions_stays_linear` 不设预算,比的是"输入翻倍、耗时不能 翻三倍",本来正是为了躲开墙钟。但它是**两个**测量的比值:两边各取五次最小 值,仍然会被竞争打穿 —— 一边碰上快样本、另一边没碰上,比值就炸,而两边的 最小值各自都是干净的。 它在 2026-08-05 已经因此红过一次并加了 min-of-5;今天在 `cargo test --workspace --all-features` 与一个 `cargo clippy` 抢核时又红了 (那次 0.68s,单独跑 0.17s),之后单独连过八次,其中四次还并行着三个构建。 按 #190 给 LSP 延迟预算立下的先例处理:`#[ignore]`,由 check.yml 的 `Compiler scaling budget` 单独单线程跑。docs/testing.md 补第四条,判据收成 一句:只要断言里出现墙钟,不管是预算还是比值,就不进正确性套。 同时: - `LK_VM_PROFILE=1` 只打 `calls=` 总数,而同一份 metrics 里按目标分的五个 计数器都在,`lk coverage --runtime` 也一直在打。一份测量两个界面,一个有 损。补 `call_kinds=`,并把未分类的余数也打出来,让分项加得回总数 —— 否则读者分不清"不属于这几种"和"这个构建没测"。 - stdlib/src/host_parity_test.rs 里一条消息的续行符被写丢,留下大段空格。 这个分项正是判断 val ↔ vm 依赖环该怎么修所需的数据:算术基准里经过 `CallableValue` 的调用是 62/228186,而 stdlib 调用密集的例子里是 34/34。 结论与量到的数一起记进 docs/module-cycles.md —— 不走类型擦除。
两条,查一条撞出另一条。 **一、Int / Float / Bool / Nil 的内建方法集是空的,而检查器不知道。** `let v = 1; v.nope();` 过 `lk check`,运行时才报 "Int has no method 'nope'" —— 同一个错误在 String / List 上是检查期错误,带位置。逐个探过:abs、sqrt、 round、len、to_string,这四个类型一个方法都没有。所以走到"没有内建签名"这一步 的名字,只可能由用户 `impl` 解析,而那条路在前面已经试过了。 原因是 `BuiltinReceiverKind` 只有 List / Bytes / Slice / Map / Set / Str 六个, `receiver_kind` 对标量答 None,那条"已知接收者上没有这个方法就是错"的规矩够不到 它们;后面的容器兜底对标量也一律不答,最后落到 `Any`。 Map 仍然豁免,而且是对的:map 的条目就是它的字段,`m.score(1)` 可以是普通的 属性调用,map 的类型里没有它有哪些键这件事。 **二、`fn` 和 `struct` 都被提升,唯独 `impl` 按语句顺序读。** 上面那条把它暴露出来了 —— 一个方法是靠"被类型检查"才为检查器所知的,而那个遍历 有序。于是 impl 写在调用之下就"没有这个方法",往上挪两行就过。而**导入**的 impl 早有预扫(`typ::imports::seed_impl_methods`),所以一个 `use` 之外的 impl 能用、 三行之下的不能用。补 `predeclare_impl_method_signatures`,与另外两个预扫并列。 签名从声明读,未标注的参数是 `Any`,所以预扫只让方法更早**可见**,不收紧任何 东西;有序遍历走到定义处再用推断的签名覆盖。元数校验因此从上方也照样生效。 `signature_of_stmt` 两边共用,从 `typ::imports`(`#[cfg(feature = "std")]`,它 读文件)移到新的 `typ::declared_signature` —— 只读 AST,no_std 构建也要编译本模块 那条预扫。 门禁:workspace / 三档 clippy / no_std / 无 aot 的 CLI 特性组合 / 语料(含裸机) / 覆盖 60/60 / 扫描 identical=61 / fuzz 种子 44、777 全绿。
```lk
fn f() -> Int { return LATER; }
println(f()); // 改前 nil,改后类型错误
const LATER = 7;
```
`typeof(f())` 答 `Nil`,而 `f` 声明的返回类型是 `Int`。碰到 nil 之后的操作报
的是它自己的事("Add expected numbers, got Nil and Int"、"`len()` works on a
String, List, …"),从不指向顺序。对照:Python 抛 NameError,JS 从 TDZ 抛
ReferenceError,Lua 给 nil —— 这门语言有检查器,给 nil 是三者里最差的。
三种情形现在齐了:直接读早就拒;函数体读后面的绑定但不在上面调用是**允许**的
(体在整个顶层之后才跑,裸机程序到处这么写);第三种,顶层语句**调用**一个
传递地读到未初始化绑定的函数,现在拒。
分析在新的 `core/src/stmt/init_order.rs`,刻意单向 —— 每个近似都只漏报不误报:
遮蔽整体相减、间接调用看不见、闭包与嵌套 fn 的体不算此刻执行、环上回边不贡献。
唯一会误报的是"读在一条永不执行的分支上",而把声明上移一行永远可行。
两个遍历对 AST 枚举穷尽匹配、没有兜底臂:新增变体会在这里编译失败,而不是静默
掉出分析。传递闭包用显式工作表而不是递归,理由与 `HeapStore::collect` 相同 ——
深度是程序的调用深度,生成的文件可以要多深有多深。
写这个时踩到的:`f(x)` 到达解析器时是 `CallExpr(Var("f"), …)` 而不是
`Call("f", …)`,所以第一版把每个调用都记成了对自己名字的**读**,一个调用都没
记下,整套分析对 `println(f())` 什么也不答。
实测:4000 个函数 `lk check` 0.26s,3000 层调用链 0.12s,5 万层不崩。语料
(examples / bench / 三个裸机目录)全绿 —— 这条保守规则在真实代码上一次都没响。
门禁:workspace / clippy(含 no_std)/ no_std 测试 / fmt / 覆盖 60/60 /
扫描 identical=61 / fuzz 种子 44、1234。
`docs/concurrency.md` 写着 "Every operation has both spellings — the bare global and `chan.…`"。实测:裸全局只有 `chan(n)` / `send` / `recv`(加 `go` / `spawn`);`close` / `is_closed` / `len` / `capacity` / `try_send` / `try_recv` 六个只有模块拼写,裸写一律 "undefined function"。 这条分法本身是对的,所以改文档而不是加六个全局:`close`、`len`、`capacity` 是程序自己很可能要用的名字,占成全局换不来什么 —— 模块拼写已经覆盖全部九个 操作。阻塞的那对是例外,因为它曾经是**缺的那一半**(#83):在它存在之前, `use chan;` 遮蔽了 `chan` 全局,于是根本没有不回退到裸名字就能发送的写法。 加 `the_bare_channel_globals_are_the_go_shaped_core_and_nothing_else` 把两侧 都钉住 —— 该有的五个和不该有的六个。这条断言在文档里活了下来,正是因为没有 任何东西钉它:一句文档不是门禁。 同时把这份文档其余的行为断言逐条探过,全部成立:`chan.new(0)` 可建且 capacity 0、负数 raise、close 后 send raise、close 后缓冲值仍可收、 close+drain 后 recv raise、try_send 满时给 false、try_recv 空时给 nil、 capacity 是要的那个数、`go` 与 `spawn` + `task.await` 都在、 `task.try_await` 未完成给 nil。
`examples/syntax/closure` 和 `examples/syntax/struct_trait` 是 ELF 可执行文件, 各约 20MB,躺在同名 `.lk` 旁边。它们分别进在 f11ccd3(讲 `lk check`)和 508d8e6(讲 impl 方法降低)里 —— 都是在讲别的事情的提交里被 `git add -A` 顺手 带进去的。 机制:`lk compile foo.lk` 把可执行文件写成 `foo`,**没有扩展名**,所以 `.gitignore` 里任何后缀模式都够不到它;`.gitignore` 只有一条 `main`,是同一个 问题打的一个补丁。我这轮扫语料时又把这两个覆盖了一遍(20MB 的 diff),才发现。 三步: - 删产物(`git rm --cached` + 删文件)。 - `.gitignore` 排除 examples/ 与 bench/ 下**无扩展名**的文件。这两棵树里被跟踪 的文件全都有扩展名,所以"没有点"就是产物的形状。目录要先重新纳入 —— git 不能重新纳入一个父目录已被排除的文件。 - check.yml 加一步 `no build artifacts in the example trees`,挡 `git add -f`。 正反两向验过:当前绿,塞一个进去就红。 同时(只改文档): - `docs/semantics.md` 里类型化列表装箱那条的复现**已经过期**。它当时答 `[1,2] []`(原生看不见被调方的写),现在回落 —— 形参格不再 join 成 `Dyn` 而是 冲突;显式 `List<Any>` 形参那条也被 #184 的不变性关上了。 - 换成现在真正露头的形状,记进 `docs/aot/aot-gaps-and-lkrt.md` §23:两种列表 载体流进同一个 `Any` 形参再 push,VM 就地拓宽答 `["q",7]`,原生 `runtime type error`。逐类探出 VM 是对的那一边(单载体加动态元素两端都拓宽)。 四条便宜的修法全部实测否掉,其中"降低期一律拒绝 Dyn 接收者的 push"覆盖率与 扫描都不掉,但打掉了 `writes_cross_the_box_both_ways` —— 那是一个已被钉住的 正确行为。修法唯一:让类型化列表的载荷能改 kind 而句柄保持有效。
**一、路径穿越。** `cache_dir_for_source` 把 git source 按 `/` 切段逐个 `push`
到 `~/.lk/git/` 之下,只过滤空段,不过滤 `..`。实测:
[dependencies.evil]
git = "https://example.com/../../../../../../tmp/x"
git 报 `Cloning into '/home/…/.lk/git/example.com/../../../../../../tmp/x'`
—— 已经在缓存根之外。远端只要可克隆(本地路径或 `file://` 远端)就落地。
source 字符串来自 `Lk.toml`,更糟的是也可能来自 `Lk.lock`。
`..` **拒绝**而不是丢弃:丢弃会让两个不同的 source 塌到同一个缓存目录上。空段
和 `.` 照旧丢弃 —— 那两个本来就是同一个路径。函数改成返回 `Result`,三个调用
点都在能 `?` 的上下文里。
**二、`[package]` 的三个字段。** `lk pkg check` 之前对它们一句话都不说:
| 写法 | 改前 | 改后 |
| --- | --- | --- |
| `edition = "1999"` / `"banana"` | package check ok | 拒绝 |
| `version = "not-a-version"` | package check ok | 拒绝 |
| `name = "../evil"` / `""` / `"9pk"` | package check ok | 拒绝 |
`edition` 由 `lk pkg init` 写出来而**没有任何代码读它**;`version` 同样没有读者
(`Lk.lock` 记的是 name/source/rev/checksum)。名字是 `use <name>;` 要拼出来的
东西,所以必须是标识符。
校验放在 `pkg check` 而不是加载时:这条命令的职责就是回答"这个包是否规整",
而一个没人读的装饰字段写错了,不该拦住一个不读它的程序运行。版本按
`major.minor.patch` 判、允许 `-pre` 与 `+build` 尾巴,不引 semver 依赖 ——
这里要分辨的只是"写了个版本"还是"写了句话"。
仓库自己的四个包(examples、workspace 及两个成员)照常 ok。裁决记进
docs/packages.md。
`[macros] trusted_dependencies` 是整个宏系统的安全边界:一个 provider 是外部 进程,在 `lk check` / `lk macro expand` 期间跑,早于程序的任何一行。守卫本身是 对的(`trusted.is_empty()` 直接返回、`!trusted.contains(&module.name)` 跳过), 但只有"列进去的依赖会展开"这一半有测试。把 `trusted.contains(&module.name)` 那个条件删掉,全套测试照旧绿。 补 `an_untrusted_dependency_provider_is_never_spawned`。断言的是**有没有被 spawn**,不是输出:跑完再把 provider 的答案丢掉,同样满足"宏没有展开",却已经 执行过了。所以 provider 会 `touch` 一个哨兵文件,测试查这个文件不存在。 同一条测试里再把 `[macros] trusted_dependencies` 加回去跑一遍,断言哨兵这次 **存在** —— 否则在一个 provider 根本跑不起来的构建上,前半段也会通过。 反向验过:删掉守卫,这条测试红,消息就是它该说的那句。
`sanitize_path` 拒绝任何含 `..` 的路径参数,而**放行绝对路径** —— 它自己的测试
就断言 `/etc/passwd` 通过。两半合起来说明这条守卫什么也没挡住:`..` 够得到的
地方,绝对路径一样够得到;而每一个调用点都是运行命令的人自己敲的参数。
它真正挡住的是从子目录里最常见的一次调用:
lk ../script.lk error: invalid value '../script.lk' for '[FILE]'
lk check ../x.lk 同上
lk fmt --check ../x.lk 同上
lk compile -o ../out 同上
穿越守卫该待的地方是"路径不由用户选"的位置 —— `package::cache_dir_for_source`
用依赖的 URL 拼目录,那条**确实**拒绝 `..`(上一个提交)。
删掉守卫,`parse_path_arg` 只做 `PathBuf::from`。三条钉住旧行为的测试翻过来:
`a_path_argument_is_taken_as_written`(含 `../` 与 `/etc/passwd`)、
`test_cli_args_accepts_parent_dir_in_compile`、
`compile_takes_a_parent_directory_argument_as_a_path` —— 最后一条还断言真的
不存在的 `../nope.lk` 失败的理由是"读不到文件",而不是它的形状。
README 里"command-line argument paths must be sanitized relative paths"一并删除
—— 那句话描述的是一个不存在的性质(绝对路径一直是允许的)。
> fn g() -> Int { return LATER; }
Error: undefined name `LATER`
> g()
Error: undefined function `g` ← 看起来是另一个问题
第二条是第一条的后果:输入整条生效或整条不生效,会话状态只在
`execute_program` 返回之后才更新。两条错误之间没有任何东西说明这一点。
现在失败的输入会补一句 "nothing from this input was defined",并且只在这次
输入**确实会声明东西**时才打 —— `g()` 或 `v.nope()` 失败本来就什么也不定义,
那句话是噪声。
带**体**的声明(`fn` / `impl`)再多一句,说明它为什么看不见那个名字:体是在你
敲下这行时编译的,只能读会话已有的名字。文件里整个程序一次编译,所以函数体可以
读下方声明的绑定 —— 让 REPL 跟上就得编译一次"可能永远不会绑定"的读取并为它
答 nil,那正是 `stmt::init_order` 存在的理由。
区分按结构而不是按错误文本。写测试时撞到:`struct Q { … }` 会带来一个生成的
`fn Q$new`(`stmt::struct_ctors`),于是它被判成"带体的声明" —— 而那个体只读
自己的参数,不可能因为会话缺名字而失败。所以判据看的是**源码里写的**声明,
生成的构造函数按"只声明了名字"算。
`only_a_declaring_input_reports_that_nothing_was_defined` 钉住九种输入形状,
包括这条 `struct` 与生成构造函数的区别。
130 个 struct、各一个方法、`main` 里逐个 `Sk { x: k }.mk()`,`lk compile` 报
"the call at pc 1293 is not natively lowerable"。128 个可以,130 个不行。
反汇编 pc 1287 是 `LoadString r8 #257`,pc 1293 是通用 `Call` 而不是
`CallMethodK`:后者是 abc 形式(7+8+1+8+8=32 位,已占满),`b` 装方法名的常量
下标只有 8 位,超了就退回 `__lk_call_method` 的通用调用 —— AOT 降低不了,
整个程序回落。编译器注释把这称作 "(pathological)"。
它不是病态输入:常量池是**按函数**的,结构体名、字段名、方法名共用一个,
130 个 struct 字面量先占掉 260 个格子。逐类分离过,单独哪一维都不封顶 ——
同一方法调 400 次、普通函数调 1000 次、一个类型 200 个方法、200 个类型同一
方法名,全都降低;要同时"不同类型 × 不同方法名"才会撞上。
修法不动编码、不动 artifact 版本(池子内容不变,只是顺序变了):降低函数体
以及顶层入口之前,先把这个体里调用到的方法名压进它的常量池。收集复用
`stmt::init_order` 那个穷尽遍历器(它已经为 #205 存在),按走查顺序去重 ——
用 Vec 不用 HashSet,因为池子的顺序进了 artifact。
实测悬崖从 129 移到 250~260,两端答案逐字节一致。
`a_function_may_call_two_hundred_distinct_methods` 断言的是**指令**而不是能否
编译:退回那条路照样产出可运行的程序,只有 opcode 说得清走的是哪条。反向验过。
门禁:workspace / clippy / no_std / fmt / 覆盖 60/60 / 扫描 identical=61 /
fuzz 种子 44 / perf geomean 0.987x。
解析器给**表达式**嵌套设了界(`ast::parser::MAX_EXPR_DEPTH`),语句嵌套没有。
于是 170 层嵌套 `if` 解析得好好的,而**类型检查器**按同一棵树一层一个 Rust 帧
地走下去,栈溢出:
fatal runtime error: stack overflow, aborting exit 134
没有行号、没有消息;裸机上更没有守护页把它拦成一次干净的 abort。
守卫放在 `parse_statement` —— 每一层嵌套的唯一必经点(块在这里解析它的语句,
`if`/`while`/`for`/`try` 把体当块解析)。放在这里,后面所有走这棵树的消费者
都继承这个界,而先撑不住的正是它们中的类型检查器,不是解析本身。
取值按现有那条守卫的方法量出来,不靠感觉:debug `lk check`(8MiB 主栈)在
160~170 **源码层**之间 abort,即一层约 50KiB(解析加检查);libtest 线程 2MiB,
天花板约 41 层;取 24。仓库自己的 `.lk` 语料最深的花括号嵌套 —— 连 `fn`/
`impl`/`struct` 那几层一起算 —— 是 **6**。
常量按它**实际计数的东西**命名:一层源码嵌套是**两个**解析帧(构造本身,以及
它当体用的那个块也是一条语句),所以 `MAX_STMT_DEPTH = 48` 表示 24 层源码。
第一版写成 24 时有效上限成了 12 —— 先量后改。消息里除以二,说的是读者写的层数。
实测:23 层通过,24 层给干净的语法错误,190 层同样干净,exit 1。语料全过。
**留了一条**:同样的输入在 2MiB 线程上到 64 层仍会 abort ——
守卫拦住了正常解析,错误恢复那条路上还有一处不受它约束的递归。埋计数器测过,
那次的最大语句深度是 1,所以递归不在语句解析器里;**原因未查明**,记为任务
#208(含已排除项)。CLI 走主线程,守卫已经够;2MiB 只出现在测试线程上。
…either bounded anything
`if c { … }` alternates between the statement parser and the expression
parser, and each crossing built a sub-parser starting back at depth zero.
400 levels of nesting overflowed a 2MiB thread with the statement counter
never exceeding 1.
One budget now, MAX_PARSE_DEPTH, seeded across every crossing in both
directions. Both refusal messages are reachable and covered: a construct
carrying an expression is refused by the expression parser, a bare block
or a nested `fn` by the statement parser.
Also fixes the exponential this exposed. The speculative tail-expression
parse swallowed every failure as "not this shape" and let the statement
path retry the same tokens, which speculates again one level down --
T(k) = 2*T(k+1) whenever the speculative parse fails. 32 nested `if`s did
not finish in five minutes. The retry is load-bearing (`try { … } catch e
{ }` is refused as an expression and accepted as a statement), so budget
exhaustion is now a downcastable marker type that a speculative parse
propagates instead of swallowing.
…the VM
fn widen(xs: Any) -> Int { xs.push("z"); return xs.len(); }
let a = [1, 2]; let b = [1.5, 2.5];
println(widen(a)); println(widen(b)); println(a); println(b);
VM -> 3 / 3 / [1,2,"z"] / [1.5,2.5,"z"]
native -> compiled fully, then "Error: runtime type error"
The VM widens the carrier in place. Native cannot: the callee holds a
tagged view of the caller's `Vec<i64>`, and the caller's other aliases
read that allocation by its static type. So the carrier has to be decided
at the literal -- the same rule #183 and #196 already apply within a
function, now across a call, through the same retry channel.
Two discovery paths, because either alone covers half the shapes: a
parameter two call sites disagree about is erased to Dyn and the caller
has to be pessimistic; a parameter a single call site pins keeps its
typed carrier, and the callee reports the push (`ParamCarrierContradicted`).
The callee's report needs one extra round trip. Parameter observations
are wiped once at the start of pass 2, and a callee is lowered before its
caller, so throughout the fixpoint it sees the unobserved `I64` default
and only the *final* pass ever sees the real carrier. That pass already
took `DynLoopPhi` back for one more round; it now takes this too.
Coverage 60/60, sweep identical=61, perf 1.034x.
…are one helper
A callee that stores a value its map parameter's carrier cannot hold now
reports it the same way a list push does, so the caller builds the map
literal with a Dyn carrier:
fn widen(m: Any) -> Int { m["k"] = "z"; return m.len(); }
let a = {"x": 1};
println(widen(a)); println(a);
before -> fallback (an operand at pc 2 is a str where a i64 is required)
after -> fully native, 2 / {"x":1,"k":"z"}
The four stores that can contradict a carrier -- list push, list index
store, and both map store arms -- now go through one helper instead of
repeating the rule, since "blame the literal here, or the caller's" is
the same question for all of them.
Half the map family only: a map parameter two call sites disagree about
is erased to `Ty::Dyn`, and a store through a `Dyn` receiver has no
lowering yet, so that shape still falls back. Recorded, not a wrong
answer.
Coverage 60/60, sweep identical=61, perf 1.024x.
…arrier rule fell back
fn widen(m: Any) -> Int { m["k"] = "z"; return m.len(); }
let a = {"x": 1}; let b = {"y": 1.5};
before -> fallback (an operand at pc 2 is a str where a i64 is required)
after -> fully native, matching the VM including iteration order
A parameter two call sites disagree about is erased to `Ty::Dyn`, and
`SetIndex` had no arm for that receiver: it fell through to the list path
and read a string key as a position. The caller side was already fixed
(5936ccd builds the literal with a Dyn carrier); this is the other half.
`dyn.index_set` is one entry point for both containers because the key
rule is one rule -- an integer key is a *key* on a map and a *position*
on a list, the same adjudication `lkrt_dyn_index` states for reads. The
key travels boxed so the callee, which is the side that knows the
carrier, applies it. Negative-from-end and the out-of-range halt are the
unboxed rules, shared through `store_index_or_raise`.
A value the carrier cannot hold raises rather than widening it, matching
`dyn.list_push`: the allocation belongs to whoever built the container.
New in lkrt: `typed_list_set` and `typed_map_set` (the by-kind stores the
dispatcher needs; a `bool` carrier takes a boxed bool and not an `Int`,
which merely shares its machine representation).
Coverage 60/60, sweep identical=61, perf 1.044x.
The adjudication document is what someone consults when a behaviour is surprising and both backends agree, which is exactly this case: `defer` runs on a return and not on a raise, deliberately, with two measured reverts behind that choice. It was written down in `core/src/stmt/defer.rs` and in the example's header, and nowhere a reader of the semantics document would find it.
…n` on a string never lowered
fn has(h: Any, n: Any) -> Bool { return n in h; }
before -> Type Error: 'in' operator requires container type
after -> answered, and lowered fully native
Indexing, index stores, `len`, iteration, method dispatch and `push` all
took an `Any` receiver. The two binary container operators did not, and
both executors had always answered them at run time -- the refusal was
the checker being stricter than the language. That arm had already been
widened for `Tuple`, `String`, `Bytes` and `Slice<T>` for the same
reason; `Any` is the last one.
Found while probing that: `"b" in "abc"` has no native lowering in any
spelling, so a program containing one falls back to the VM entirely --
about 3x slower, with no message. The method spelling
`s.contains("b")` lowered the whole time. Both now share `str.contains`,
and `dyn.contains` gained the `DYN_STR` carrier it was missing (it
covered map, list, set, slice and bytes).
Coverage 60/60, sweep identical=61, perf 1.071x (the bench runner pins
`LK_FORCE_VM=1`, which none of this touches).
fn rm(xs: Any) -> Int { println(xs - [1]); return 0; }
fn mg(m: Any) -> Int { println(m + {"b": 2}); return 0; }
before -> Type Error: list removal / map merge requires both operands to be …
after -> answered, and lowered fully native
Four siblings, one rule: 60e926d fixed `in` and list concatenation, and a
sweep of the erased-receiver surface found list removal and map merge
behind the same guard. Everything else on an `Any` receiver -- equality,
ordering, indexing, `slice`, `sort`, `join`, `delete`, method dispatch --
already took one.
Probed and left alone: `Set` has no `+`/`-` at all, for either operand
type. The container operators come from LEARN.md's expression table,
which lists them for lists and maps only, and #187 gave `Set` its
operations as methods deliberately.
Coverage 60/60, sweep identical=61.
let a = 1;
if a = 2 { println(1); }
before -> checked clean, printed 1, and compiled to `if a { println(1); }`
with the constant 2 nowhere in the bytecode
after -> Syntax error naming `==`
Assignment is a statement in LK, not an expression. Two paths parse a
header expression and only one checked its leftovers: the statement side
uses `Parser::parse`, which rejects a tail it did not consume, while
`parse_header_expr_before_brace` -- reached by a *tail* `if` or `match` --
used `parse_expr` and dropped whatever was left. So the same line was
accepted as the last statement of a file and a syntax error one
statement earlier.
Both now follow one rule, and the leftover message names `=`
specifically, for the reason #201 named `export fn`: a slip everyone
makes should be called by its name.
A statement-side twin of the check was written first and removed --
`parse` already covers that path, so it was unreachable.
…e in it
println("abc);
before -> Error: Syntax error:
String not closed (at end, near 'bc);
')
Line 2: at 2:1-1
after -> Error: Syntax error: String not closed — the quote at 1:9 has
no partner (at end, near 'bc);\\n') at 2:1-1
Three defects in one formatter. It built its own multi-line layout while
the caller already renders the offending line with a caret; it copied
the near context raw, so a newline inside it broke the message again;
and `Line {n}` named the line the *scan* reached, which at end of input
is one past the file, so the field printed empty.
The position is the useful one now: an unterminated string is discovered
at end of input, nowhere near the quote that has no partner, so the
message names where it opened.
`Tokenizer::input` existed only to feed the line-context lookup and is
gone with it, and the struct's lifetime parameter with that.
function f() { return 1; }
before -> Syntax error: Unexpected tokens at end (found Id("f")) at 1:1-9
after -> `function` does not declare a function in LK — the keyword is
`fn`, as in `fn f(x: Int) -> Int { … }`
`function` / `func` / `def` / `fun`, `elif`, and `=>` each reported a
stray token at a position that named neither the mistake nor the
spelling that works — for the words that are the first thing anybody
types. Same reason `export fn` (#201) and `let mut` are named.
All of these lex as ordinary identifiers or operators, so the negative
half is asserted too: a variable or function actually called `def`,
`func` or `function` still parses. `sub` and `procedure` were dropped
from the list for that reason — plausible variable names, and the shape
would have hinted at something the writer never meant.
fn f(a: Int, a: Int) -> Int { return a; } -> returned 2, first argument unreadable
match t { [a, a] => … } -> matched any two elements, bound the second
struct P { x: Int, x: Int } -> accepted; one literal field satisfied both
A binder that declares one name twice can never read the first binding:
the second shadows it before anything runs. `[a, a]` is the dangerous
one — it reads as "two equal elements" to anyone arriving from a
language whose patterns are non-linear, and here it matches any two.
Seven positions, one rule: `fn` parameters, method parameters, lambda
parameters, struct fields, `let` destructuring, `for` patterns, and the
`match`/`if let`/`while let` pattern. Two walkers (one per pattern type)
and one list helper, so the rule is written once per shape rather than
once per position.
The negative half is part of the rule: this is per *binder*, not per
scope, so re-binding in a later statement stays ordinary and an `Or`
pattern still binds the same name in each alternative — which is the only
way an `Or` binds anything.
Also fixes what hid half of it: `if let` and `while let` called
`add_bindings_for_pattern(...).ok()`, discarding the very rejection their
comment claimed. A type the pattern cannot produce is what those
constructs test at run time and must not be refused statically; a
repeated name is not that, so it moved to its own method.
…n for the trait's
trait T { fn m(self, a: Int) -> Int; }
impl T for S { fn m(self) -> Int { return 1; } }
before -> lk check clean, then "Method 'm' arity mismatch" the moment it ran
after -> lk check says the same thing
The rule existed and ran only when the VM registered the impl. #99 moved
the *presence* half into the checker and recorded that presence was
"the whole question"; arity, parameter types and the return type are
decided by the same rule and were all still let through.
One rule, two callers now: `trait_method_conformance` is called from
registration and from the `impl` statement's check. Parameters
contravariant, return covariant — the ordinary rule for a signature that
has to stand in for another.
Also probed and recorded, not fixed here: named arguments are refused
for user functions (`f(b: 1, a: 5)` says "expects 2 positional
arguments") while `Stmt::Function` carries a `named_params` field and
stdlib members take them. That is a separate question about the
declaration syntax, not this rule.
fn f(a: Int, b: Int) -> Int { return a - b; }
println(f(b: 1, a: 5));
before -> Function 'f' expects 2 positional args, got 0
after -> Function 'f' has no named parameter `b`, `a` — a positional
parameter is passed by position, and a named one is declared in
a trailing `{ … }` block, as in `fn f(a: Int, { b: Int? = 1 })`
LK does have named parameters, declared in a trailing block and called
as `f(1, 5, step: 2)`; positional ones are positional. So the call is an
error either way — but reported as a count it read as "you passed no
arguments", which is true and no help, for the shape somebody arrives
with from Python, Swift or Kotlin.
The count message stays for the shape it was written for: a call with
the wrong number of positional arguments and no named ones.
…nce check
impl Nope for S { … } -> accepted; "Trait 'Nope' not found" at run time
trait T { … } trait T { … } -> accepted; the second replaced the first in silence
trait T { fn m; fn m; } -> accepted; no impl was ever measured against one
The impl target *type* is checked for existence; the trait half of the
same line sat behind a `let Some(…)` that skipped the whole conformance
check when the lookup missed, so the program failed only when the VM
registered it. Two top-level `fn`s of one name are refused, and two
`struct`s; `trait` had nothing.
The two set-shaped questions go where that kind of question already
lives — the program-level pass beside `check_method_name_collisions`,
which documents why the registry cannot answer them: by the time the
ordered walk runs, the pre-pass has registered the first declaration, so
a duplicate is indistinguishable from a declaration meeting itself.
…atively
use mathlib;
println(mathlib.double(7));
before -> Tier 0 VM bundle, about 3x slower, with nothing said
after -> fully native, identical output
The workspace example was the VM/native sweep's one fallback. A package
dependency is a `.lk` file and the binding it produces is the same shape
a file import produces, but the bundler only queued file imports, so the
call fell to `lower_module` — which knows stdlib only.
Two halves, because the first alone built a bundle nothing consulted:
the CLI resolves `use dep;` and `use dep as n;` through `PackageGraph`
and queues them beside the file imports, and `ImportEnv::build` looks a
module name up among the bundles before falling back to the stdlib
module-object reading.
Sweep: identical=61 diverged=1 fallback=1 -> identical=62 diverged=1
fallback=0. The gate's expected string and `docs/testing.md` follow.
`file_import_paths` lost its `#[cfg(feature = "aot")]` to the insertion
and took the no-aot build down with it — the same shape the local notes
already record: a helper only an aot function calls needs the gate too.
use { double } from mathlib; before -> fallback
use * as m from mathlib; before -> fallback
after -> both fully native, matching the VM
6a70f56 covered the two whole-module spellings and left the item and
namespace forms on the path they had — the same "one rule, four
spellings, two covered" shape this repository keeps finding, written
into the comment as a limit rather than closed.
An item or namespace import has no module-object binding of its own, so
the bundle is keyed by the module's name, which is what the lowering
looks it up by for those two forms. `Items` binds each item to the
merged function index the way the file branch does, `S$new` fallback
included; `Namespace` binds a file namespace.
Sweep still identical=62 diverged=1 fallback=0, coverage 60/60.
…ep sees
The sweep covers `use dep;` through the workspace example. The other
three — `use dep as n;`, `use { item } from dep;`, `use * as ns from
dep;` — are separate arms of the binding table with no corpus program,
and two of them were the gap 5cc2caf closed.
The test builds a package with a path dependency, compiles each spelling
with fallback and the hybrid bridge both off (so "compiles" means
"lowered"), and requires the native binary and the VM to print the same
thing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
分支从
feat/aot-try-catch起步(把try/catch语料从 AOT 覆盖 allow list 上摘掉,现在那张表是空的),之后一路顺着源码往下查,累积到 210 个提交。分支名早已不能概括内容。门禁
cargo test --workspace --all-features全绿AOT_COVERAGE_ALLOW为空examples/全部可运行bench/run_workload_bench.sh交错 A/B geomean 0.9946 ~ 1.004主要几类
脚本不该能让进程 abort。 GC 标记、相等、渲染都按值的形状递归,一个循环造出的深链就打穿 Rust 栈。GC 改成显式工作表(回收不允许失败,所以那里没有深度上限);相等和渲染在
MAX_VALUE_DEPTH处 raise 可捕获错误。跨堆回收改try_lock,拿不到锁就跳过,消除重入死锁。一个概念多份实现,而它们不一致。 这是本分支反复撞到的同一个形状:
Nil,[["abcdefghij"]].contains(["zzzzzzzzzz"])答 truem[[1,2]]报错而s.add([1,2])静默按 handle 接受HeapValue → 类型名,同一个值一处报Callable一处报Function两端(VM / native)不一致。 字符串
substring/find原生降到按字节的 helper 而 VM 按字符;负字符串下标两端一致地从字节长度回绕("中文abc"[-1]给 nil);a % 0两端错误文本不同。差分语料补了多字节和算术失败的用例。运行时会而前端不让。
"a" < "z"是类型错误,可sort()、常量折叠、执行器三层里两层早就实现了。字符串的正名读取面(slice/index_of/take/skip/first)一个都不能原生降低,只有已弃用的别名能。用户可见文本说实现的话。 opcode 名(
ModInt)和内部表示名(ShortStr—— 语言里没这个类型)漏进错误消息。裁决逐条记在
docs/semantics.md/docs/stdlib.md。🤖 Generated with Claude Code
Summary
Changes