Lompi · 包管理器
Lompi 是库系统的工具,也是它的第一个独立实现:一个用 Loment 写的程序——入口加七个模块编成一个可执行文件,跑起来不需要 Python。它自己读源码里的 use 找出依赖,自己算身份,自己发锁。
一句话说清它和别的包管理器差在哪:pip 里最难的是「挑版本」,Lompi 里最难的是「把实例身份算准」。选版只剩一条规则——版本最大;而身份要对得起「同一份源码绑到不同依赖上就该是两个东西」这件事。
仓库长什么样
<store>/
<名字>/
<版本>/
<名字>.lomt ← 入口。文件名必须与包名一致
*.lomt ← 包内其余模块
pkg.lomp ← 可选。只放两个给人看的标签
pkg.lomp 是可选的,写成普通 Loment 源码。deps/ 里的东西不算本包的源码——否则会凭空多出依赖边,并把 vendored 的副本算进身份。
两种边,只有一种算依赖
| 写法 | 含义 |
|---|---|
use 名字 |
指向另一个包。解析、算身份、进锁,都看它。 |
use "io.lomt" |
包内引用。列出来是为了看得见,但那个文件本来就在本包源码里,所以不参与解析,也不进哈希。 |
use "../other/x.lomt"),Lompi 不跟踪。跨包引用请一律写成 use 名字,并让那个名字落在仓库里。
命令全表
| 命令 | 作用 |
|---|---|
lompi install <名字> [--into DIR] [--apply] [--from-lock F] | 取件 / 校验 / 安装。默认装进全局 store;--apply 才真写盘 |
lompi check <目录> | 这个目录是不是一个合法的 Loment 库 |
lompi fetch [--registry URL] | 打出网址库的下载计划 |
lompi config | 显示全局根与网址库(含命中了哪条规则) |
lompi version | 版本 |
lompi index <store> | 仓库里有哪些包 |
lompi show <store> <名字[@版本]> | 元数据 / use 边 / 身份 |
lompi tree <store> <名字[@版本]> | 依赖树 |
lompi resolve <store> <名字[@版本]> | 打出锁(身份钉死的清单) |
lompi verify <store> <名字[@版本]> <锁文件> | 重算并逐字节比对 |
lompi plan <store> <名字[@版本]> [--into D] | 闭包与物化计划 |
lompi hash <包目录> | 单个包的自身源码哈希 |
<名字[@版本]> 的写法:mathutil 意思是「版本最大的那个」,mathutil@0.1.0 是钉死版本。版本按 . 分段做数值比较,所以 0.10.0 大于 0.9.0。
下面每一条都是在真实仓库上跑出来的,不是示意。
$ lompi index fixture/store 5 package(s) in store geom 1.0.0 06b4cc0d3b1f136f mathutil 0.1.0 53ce217a387ed58a mathutil 0.2.0 c5f88a0519c41888 multi 2.0.0 8326d0125e3b111d util 0.3.0 e0e90317698ff804
每行是「名字 版本 身份前 16 位」。同一个名字出现两次是正常的——那是两个实例(多版本共存),不是重复;第一行那个 5 package(s) 数的是实例数。
$ lompi show fixture/store multi name: multi version: 2.0.0 id: 8326d0125e3b111d51d050e49395400524a18ddc31eb730f7afbc9cb6a4955fb dir: fixture/store/multi/2.0.0 1 use edge(s): name util
id 是实例身份(64 位十六进制)。把它和别的构建里同一个包的 id 比一比,就知道两边是不是同一个东西。
$ lompi tree fixture/store multi multi@2.0.0 8326d0125e3b util@0.3.0 e0e90317698f $ lompi resolve fixture/store multi # lompi lock v1 multi 2.0.0 8326d0125e3b111d51d050e49395400524a18ddc31eb730f7afbc9cb6a4955fb util 0.3.0 e0e90317698ff804db92a01bce6b7a965e9e679eb6bb80b25f02c2d05207b041 $ lompi verify fixture/store multi lompi.lock [OK] lompi: 锁与仓库一致 (169 bytes)
缩进表示层次;菱形依赖(两个包依赖同一个)第二次出现时会标 (already shown),不重复展开——那是同一个实例,展开两次会骗人。锁是身份清单而不是版本清单;验锁会把整棵树重算一遍再逐字节比对,拿错一份锁得到的是 [DIFF] 锁与仓库不一致 并以 1 退出,不是「差不多能用」。
取件那一半:全局根、网址库、install
上面那七条是只读的。下面这一半会动你的磁盘,所以先说清楚它从哪儿拿、往哪儿放。
全局根是「推」出来的,不是读环境变量
因为没法读:实测 /proc/self/environ 在 PE 垫片上打不开(它只合成 cmdline),语言也没有 getenv。但 argv[0] 是执行文件的全路径,所以从它反推是可行的。规则按可靠性排序,命中哪条 lompi config 会打出来——推错了要看得见,不能静默走错目录。
$ lompi config version: 0.1.0 exe: D:\Dev\Lolment-ku\lompi global: D:\Dev\Lolment-ku\.lompi rule: fallback: <exe dir>\.lompi (exe is not under AppData or Users) store: D:\Dev\Lolment-ku\.lompi\store cache: D:\Dev\Lolment-ku\.lompi\cache conf: D:\Dev\Lolment-ku\lompi\lompi.conf (absent) registry: (not set) -- install will use the global store
网址库是一个 git 仓库
布局和 store 完全一样:<名字>/<版本>/*.lomt。它的地址写在执行文件旁边的 lompi.conf 里,内容是 Loment 源码,形状与 pkg.lomp 一样——不新造格式,读它用的就是读清单那套。
module conf
pub fn registry() -> str {
return "https://example.invalid/loment-registry.git";
}
fetch 不联网,install 才动盘
Lompi 自己做不到联网。PE 目标上只有 8 个 syscall,里面没有 socket;垫片也没有 ws2_32 / winhttp。所以 fetch 给出的是计划,由 shell 或 CI 执行——与「plan 不建目录」是同一条边界,只是这次让出去的是 git。
$ lompi fetch --registry https://example.invalid/loment-registry.git # lompi fetch plan (lompi cannot do network on PE -- no socket in the 8 syscalls) # registry: https://example.invalid/loment-registry.git (from --registry) mkdir -p <global>\cache git clone --depth 1 https://example.invalid/loment-registry.git <global>\cache\loment-registry # to update later: git -C <global>\cache\loment-registry pull
克隆下来之后,install 从缓存里的网址库取件;没配网址库,就从全局 store 取。取到之后先校验——不是合法的 Loment 库就拒装,并且说清是哪一条不过。
默认装进全局 store;--into DIR 装到 DIR\<名字>\(项目内)。不带 --apply 就只出计划,要它真写盘得显式加。--from-lock F 则只装 F 钉死的那些实例,对不上就拒。
check:一个目录算不算合法的 Loment 库
装之前校验的就是这一组。它逐条报,过不了的写 [BAD]:
$ lompi check fixture/store/mathutil/0.1.0 [ok] source files: 1 [ok] package name: mathutil [ok] every module name matches its file name [ok] no pkg.lomp (optional) [OK] lompi: this is a valid Loment library $ lompi check fixture/store_broken [ok] source files: 0 [BAD] no *.lomt in this directory [BAD] lompi: 1 check(s) failed
身份怎么算
id(P) = sha256( 自身源码 ⊕ 每条 name 边: 边名 0x00 子实例身份 0x00 )
自身源码 = 包内 *.lomt(排除 pkg.lomp 与 deps/),按文件名排序后逐文件喂「名字 0x00 内容 0x00」;边也按名字排序后才进哈希。所以换个文件顺序写 use 不会改变身份——目录返回顺序不保证,不排序就没有稳定身份。
这条公式有一个必须接受的后果,把它单独放在这里:
geom 的源码逐字节相同, 但落在两个仓库里 仓库 A 有 mathutil 0.1.0 / 0.2.0 → geom id = 06b4cc0d3b1f… (绑 0.2.0) 仓库 B 只有 mathutil 0.1.0 → geom id = 0f4bb3784d09… (绑 0.1.0)
两个 id 不同是对的。同一份源码绑到不同依赖上,语义就不同;把它们合并才是有害的。这正是「只看自身源码是错的」那句话的具体样子。
算的时候用的是不动点迭代,不是递归。递归每一层都要留住自己的边表,而这个程序的内存是一块固定工作区(语言没有动态分配),递归就得给每层切一块缓冲,于是深度上限被写死进布局。换成「反复扫到没有新的包可以定身份为止」就没这个问题——代价是最坏 O(n²),而一个仓库的包数很少(上限 512),这个代价换得起。
环也因此自然暴露:A→B→A,两边都永远「还没准备好」,循环结束后它们就是剩下的那些。身份算不出来是响亮的——环与缺依赖各有独立的状态位与消息,不混成一句「失败」。
依赖从源码读,不从清单读
因为模型是「依赖就是源码里的 use」,包管理器必须自己去读源码。参考实现借用了编译器的前端 AST;Lompi 是一个独立程序,借不到,所以它做的是一个够用的词法扫描——只认顶层 use,不建语法树。
扫描要处理三种假 use,否则会凭空多出依赖边,而那是静默的错:行注释里的 // use foo、块注释里的 /* use foo */、以及字符串里的 "use foo"(含反斜杠转义)。
use(前面只允许空白)。语言里 use 只在顶层,而顶层语句在本项目里一行一条——这条规则够用,也不会把 x.use 之类的误认成边。真有跨行写的 use,这里会漏,所以 Lompi 会把「扫到的边」和「包目录里存在的模块」对一遍,不匹配就报出来,而不是默默少一条边。
两件被工具链逼出来的事
- 为什么函数不返回结构,而是用一块工作区 —— 这些函数的结果是多块的(名字池 + 记录表 + 内容缓冲),而语言没有出参、也没有可变全局,包内的链接器又不支持按值传聚合。所以照仓库工具的成法:调用方给一块内存,本层按命名偏移使用,签名保持标量。这正是库系统里那两张真库的接口形状,不是 Lompi 自己的口味。
- 为什么两层挤进同一个文件 —— 源码扫描(
lex_*)与包 / 仓库模型(pkg_*/sto_*)本该是两层,但打包版编译器的传递导入深度上限是 8,而 Lompi 这条链正好 8 层(lompi → cli → idn → pkg → sha → dir → txt → sys)。再加一层,最底层的sys_*会整片报「未声明」,而且报错指向的层与真正的原因无关。分层没变,只是两层住在一个文件里。
第二条值得单独看一眼:它不是设计,是被工具链的护栏顶回来的一道痕迹。项目的做法是把原因写在文件头上,而不是假装那是自然的。
退出码与输出分家
| 码 | 含义 |
|---|---|
| 0 | 成功(含 lompi help) |
| 1 | 失败:仓库打不开 / 依赖成环 / 依赖找不到 / 没有这个包 / 锁与仓库不一致 |
| 2 | 用法错:缺参数、不认识的命令、锁文件读不了 |
诊断走 stderr,正常输出走 stdout——所以 lompi resolve … > lompi.lock 得到的是干净的一份锁,不用过滤。
报错对照
| 你看到的 | 意思 / 怎么办 |
|---|---|
仓库里没有这个包 | 名字或版本对不上。用 lompi index <store> 看目录名与版本 |
仓库里没有这个依赖: X | 某个包的 use X 在仓库里找不到 X。把 X 放进仓库,或改那个包 |
依赖成环 … | A→B→A。拆环(这门语言在解析期就该拒掉) |
[DIFF] 锁与仓库不一致 | 重算出来的锁与文件不同:源码或依赖变了。确认后重打锁 |
有源码文件读不出来或太大 … | 单文件超过 256 KiB,或读不到。拆文件 |
不认识的命令: X | 命令名打错。lompi help |
和 pip 对照着看
| pip | lompi | 差别 |
|---|---|---|
pip install X | lompi plan <store> X --into deps | Lompi 输出计划,落盘交给上层 |
pip show X | lompi show <store> X | 多给实例身份与 use 边 |
pip freeze | lompi resolve <store> X | 钉的是内容哈希,不是版本号 |
pip check | lompi verify <store> X <锁> | 重算 + 逐字节比对,强得多 |
pipdeptree | lompi tree <store> X | 同一个包的不同版本各占一行 |
requirements.txt | lompi.lock | 没有版本区间可写;哈希就是身份 |
边界(诚实清单)
- 没有注册中心服务端,Lompi 也不自己联网 —— 网址库就是一个 git 仓库(布局与 store 相同),不需要谁去运维一个服务端。Lompi 自己做不到联网:PE 上那 8 个 syscall 里没有 socket,垫片也没有
ws2_32/winhttp;所以网络那一步是fetch打印出来交给 git 的,由 shell 或 CI 执行。 - 不做二进制分发 —— 泛型是类型定向单态化的,使用方必须看得到源码。
-
plan不建目录 —— 工具链的 Windows PE 目标没有mkdir,它只打计划;deps/下的目录要自己先建好。 - 单文件上限 256 KiB —— 超了 Lompi 拒算这个包的身份,而不是截断。静默截断会算出一个错的身份,那比报错坏得多。
- 容量护栏 —— 仓库 512 个实例、单包 use 边 2048 条、目录项 4096 条、名字 / 路径池各 64 KiB 与 256 KiB、依赖树深度 256。超出这些会被静默截断,属于已知上限,不是设计。
- 环单独检测 —— A
useB 且 BuseA 会被拒。Merkle 身份替代不了这条检测:两边哈希都算得出,所以它得单独做。
lompi 就在 PATH 上,不用单独装。同样的处理方式也用在 agent 指南上:那份让 agent 一装上就会写 Loment 的东西,随工具链一起落地,不需要另外去拿。Lompi 覆盖的是解析与身份这一层:读依赖、算身份、发锁、验锁、给落盘计划。它不做编译器那一侧的物化改名——那是 库系统里
materialize 的活。它自己也是用 Loment 写的,所以在源码树里 loment build lompi.lomt -o lompi 就能把它重编出来(必须在那个目录里调用——这条工具链的路径导入按当前目录解析)。