☰
Unison 的 `upgrade` 依赖后缀化修复:从歧义的 `c = y + 1` 到精确的 `c = b.y + 1`
2026/10/9 10:02:47 网站建设 项目流程
  • 编程语言
  • 编译器
  • 语言运行时
  • 开发工具

【免费下载链接】unison

A friendly programming language from the future

项目地址:https://gitcode.com/gh_mirrors/un/unison
点击查看免费下载

导读:本文以 Unison 仓库中的回归测试 fix-5323.md 为线索,剖析 UCM(Unison Codebase Manager)upgrade命令在重写库依赖时如何对"被升级库的依赖者"(dependents)进行名称后缀化(suffixify)渲染,并深入到 Upgrade.hs 与 Name.hs 的源码实现。读完本文,你将掌握 upgrade 的"渲染 → 回填 → 解析"替换流水线、suffixifyByName/suffixifyByHash系列算法的语义与消歧规则,并能亲手复现与验证该 Bug 修复。

一、问题背景:upgrade 依赖者渲染为何会歧义

fix-5323 的回归测试文档开篇即点明了这个 Bug 的本质:

This transcript demonstrates that dependents of an upgrade are suffixified properly. Previously,c = b.y + 1would render asc = y + 1(ambiguous).

在 Unison 中,upgrade会把代码库里的lib.old依赖整体替换为lib.new,凡是(直接或传递)依赖lib.old中定义的 term 都会被自动重写。重写的实现方式并不是对 AST 做引用替换,而是先把这些依赖者定义用"去掉旧库名称"的 pretty-print 环境渲染成 Unison 源码文本,再用"只包含新库名称"的解析环境重新解析并类型检查(见 Upgrade.hs 中handleUpgrade1的注释)。

问题就出在渲染这一步:如果渲染时没有对名称做正确的后缀化(suffixify),一个本应写为c = b.y + 1的定义可能会被渲染成c = y + 1。当命名空间中同时存在a.y和b.y(甚至还有lib.*.y)时,裸的y就是歧义名称——重新解析时它无法唯一确定指代哪一个引用,导致升级失败或产生错误结果。fix-5323 正是把这一场景固化为 transcript,防止该 Bug 回归。

二、回归测试的载体:UCM Transcript 的运行机制

该文档位于 unison-src/transcripts/idempotent/ 目录下。Unison 的 transcript 是一种"命令脚本 + 期望输出"合一的可执行文档:

  • 以```ucm代码块书写要在 UCM 中执行的命令;
  • 以```unison代码块书写要载入 scratch 的 Unison 源码;
  • 紧随其后的```ucm :added-by-ucm代码块由 transcript 运行器在首次执行时回填真实输出,并在后续运行时比对校验,任何输出漂移都会让测试失败。

从目录名idempotent可以推断,这套 transcript 还要求命令可重复执行且输出保持稳定。整个 transcript 套件由仓库中的 scripts/transcripts.sh 驱动(其证明脚本位于 scripts/proofs/transcripts.sh,运行入口可参考 unison-cli/transcripts/Transcripts.hs)。因此,fix-5323.md 不仅是一份文档,更是一个"可执行、可回归"的测试用例。

三、操作序列逐步复现:fix-5323 的完整流程

3.1 合并内置库

transcript 的第一步是把内置库(builtins)以lib.builtin的名义合并进当前命名空间,为后续定义提供Nat等基础类型:

> builtins.merge lib.builtin Done.

builtins.merge是 Unison 初始化新代码库的标准步骤,它把编译器内置的引用集合挂到lib.builtin下,作为所有用户代码的间接依赖。

3.2 编写"旧库 / 新库 / 依赖者"三组定义

接着在 scratch 文件中写入待演练的 Unison 代码:

old.x = 17 new.x = 100 a.y = 18 b.y = old.x + 1 c = b.y + 1

这里构造了一个典型的升级依赖链:

  • old.x/new.x:同一逻辑实体在旧库与新库中的两份实现(17 与 100),之后会被分别移动到lib.old.x与lib.new.x;
  • b.y:直接依赖old.x,因此是 upgrade 的直接依赖者;
  • c:依赖b.y,是 upgrade 的传递依赖者;
  • a.y:与b.y同名段(同为y),是制造名称歧义的关键——正因为有a.y存在,裸的y无法唯一指代b.y,这正是 Bug 得以暴露的环境。

3.3 update 入库

把上述定义写入代码库分支。transcript 的:added-by-ucm块展示了此时 UCM 检测到的变更(注意这里使用了:added-by-ucm标记,表示输出由运行器回填校验):

Loading changes detected in scratch.u. + a.y : Nat + b.y : Nat + c : Nat + new.x : Nat + old.x : Nat Run `update` to apply these changes to your codebase.

随后执行update,将 5 个新定义提交进分支:

> update Okay, I'm searching the branch for code that needs to be updated... Done.

update的处理逻辑在 Update2.hs 的handleUpdate2中,它同样涉及"渲染后回填依赖者"与后缀化处理,与upgrade共享Cli.UpdateUtils中的nameHydratedRefIds、parseAndTypecheck等工具函数,是理解 upgrade 前置知识的另一半(参见 Update2.hs 中关于"by name / by hash 两种后缀化"的注释)。

3.4 将新旧定义移动进 lib 命名空间

升级的对象是lib.old/lib.new两个库依赖,所以先把old.x、new.x分别移动到lib.old.x、lib.new.x:

> move old.x lib.old.x Done. > move new.x lib.new.x Done.

move命令(底层模式名为moveAll)的语义是"重命名 term、type 与命名空间",在 InputPatterns.hs 中定义为move foo barrenames the term, type, and namespace foo to bar。这里lib.old.x、lib.new.x这种lib.<name>.<def>的路径正是 UCM 识别库依赖(libdep)的标准位置——handleUpgrade会先构造lib.<old>与lib.<new>两个绝对路径并读取其分支内容(Upgrade.hs)。

3.5 执行升级

最后执行关键命令:

> upgrade old new I upgraded old to new.

upgrade命令的完整定义在 InputPatterns.hs:

  • 模式名lib.upgrade,别名upgrade.lib与upgrade;
  • 参数形式为upgrade old new [old2 new2...],即成对出现:lib.old升级到lib.new、lib.old2升级到lib.new2,可一次升级多个库。

这一整段 transcript 的可验证结论是:升级后b.y与c被正确重写,且c的渲染结果保留了b.y这个最短无歧义后缀,而不是退化为歧义的y。

四、upgrade 的替换流水线:渲染 → 回填 → 解析

handleUpgrade的入口在 Upgrade.hs,它先做参数合法性检查:参数必须是偶数个(否则报 "takes an even number of arguments"),且old不能等于new(否则报 "I can't upgrade ... to itself!")。随后handleUpgrade1执行真正的替换流程(Upgrade.hs):

  1. 状态守卫:若当前分支正处于update/upgrade/merge过程中,则提前返回CantDoThatDuring(对应 Output.hs),避免在中间态上再次叠加操作。
  2. 收集升级信息:对每个(old, new)对,读取lib.old的完整深定义(oldDeepDefns)、剥离 libdep 后的本地定义(oldLocalDefns)以及lib.new的本地定义(newLocalDefns)。
  3. 计算依赖者集合:通过getNamespaceDependentsOf找出当前命名空间中所有依赖旧库定义的 term/type(dependents),再把它们从代码库中水合出来(hydrateRefs),并用nameHydratedRefIds把引用与名称关联(Upgrade.hs)。
  4. 渲染:用精心构造的 pretty-print 环境(见第五节)把依赖者定义渲染成 Unison 源码文本,由renderDefnsForUnisonFile完成(Upgrade.hs)。
  5. 解析:makeParsingEnv基于"去掉全部旧库名称后的当前命名空间"构造解析环境,随后parseAndTypecheck把渲染出的源码重新解析并类型检查(Upgrade.hs)。
  6. 提交:类型检查通过后,typecheckedUnisonFileToBranchUpdates生成分支更新并提交(Upgrade.hs),最终以UpgradeSuccess应答(Output.hs),即 transcript 中的 "I upgraded old to new."。

源码中的算法注释给出了一个形象的最小示例(Upgrade.hs):

lib.old.foo#oldfoo = 17 lib.new.foo#newfoo = 18 mything#mything = #oldfoo + 10 -- 依赖旧库 -- upgrade old new 时,先渲染为(foo 是"去掉 old 后"的最短无歧义后缀): mything = foo + 10 -- 再在只含 lib.new.foo 的解析环境中解析,得到: mything#mything2 = #newfoo + 10 -- 引用已切换到新库

fix-5323 的场景正是这个示例的"依赖者名称也需要后缀化"版本:c依赖的b.y必须渲染为b.y而非y,否则重解析时y在a.y/b.y之间无法定夺。

五、后缀化的核心算法:suffixify 系列函数

"后缀化"的语义与实现位于 unison-core/src/Unison/Name.hs:

Tries to shortenfqnto the smallest suffix that still unambiguously refers to the same name.

即:把全限定名缩短到"仍然无歧义地指向同一名称"的最短后缀。仓库提供了三个变体:

  • suffixifyByName(Name.hs):以名称(name)为判定单位,通过searchDomG统计匹配该后缀的名称数量(利用NamePriority加权,优先级不同的名称也会计入歧义),只有当matchingNameCount == 1时才接受该后缀。
  • suffixifyByHash(Name.hs):以引用集合(refs)为判定单位,要求后缀命中的引用集合matchingRefs == allRefs(与全名指代完全一致),适合"按哈希确定性消歧"的渲染场景。
  • suffixifyByHashName(Name.hs):在suffixifyByHash基础上"继续缩短"——若当前后缀可能指向lib之外的本地定义,则继续增加片段,因为本地定义可能随后在 scratch 文件里被编辑,按哈希消歧在此处不生效。

一个重要的消歧规则(见 Name.hs 与第 623 行的注释):间接依赖名称不会造成歧义——只要存在一个非间接依赖名称,例如同时存在lib.base.List.map与lib.something.lib.base.Set.map时,裸的map会无歧义地指向lib.base.List.map。这一规则正是 upgrade 渲染环境"去掉旧库名称后求最短后缀"的理论基础。

六、修复落点:makeOldDepPPE 的 PPE 拼接策略

fix-5323 修复的关键在于handleUpgrade1中三种 pretty-print 环境的优先级拼接(Upgrade.hs):

PPED.leftBiased [ makeOldDepPPE upgradeInfos currentDeepDefnsSansOlds, -- ① 旧库依赖专用环境 PPED.makePPED (PPE.namer (Names.fromUnconflictedReferenceIds dependents)) (PPE.suffixifyByName (Names.fromRelations currentDeepDefnsSansOlds)), -- ② 按名后缀化 PPED.makePPED (PPE.hqNamer 10 (Names.fromRelations currentDeepDefnsSansOlds)) (PPE.suffixifyByHash (Names.fromRelations currentDeepDefnsSansOlds)) -- ③ 按哈希后缀化 ]

PPED.leftBiased的含义是:对于某个引用,最左侧提供名称的环境优先命中;只有左侧环境不提供名称时,才回退到右侧环境。三种环境的职责分别是:

  • ①makeOldDepPPE(Upgrade.hs):专门处理"旧库中存在的名称"。对于同时出现在旧库与新建库的引用(inOldAndNewNamespaces),直接返回空名称列表——即这类名称不应该在依赖者的渲染文本里出现(因为它将被新库名称取代);对于只存在于旧库的引用(onlyInOldNamespace),则用带lib.old.前缀的完整旧名且不做后缀化(PPE.dontSuffixify),保证解析时仍能指向旧引用。
  • ② 按名后缀化:对dependents中的引用,用suffixifyByName在"去掉旧库后的当前深名称"里求最短无歧义后缀。这正是c被渲染为c = b.y + 1而非c = y + 1的保证——在a.y、b.y同时存在时,y的matchingNameCount大于 1,不满足suffixifyByName的isOk条件,于是继续加长到b.y。
  • ③ 按哈希后缀化:兜底环境,用suffixifyByHash保证即使按名称无法唯一判定,也能以引用哈希集合为准确定性地渲染,避免歧义。

值得一提的是,makePrettyUnisonFile(Upgrade.hs)在渲染出的文件头部会写两行注释:

-- The definitions below no longer typecheck after upgrading. -- Please fix the errors, then run `update`.

这表明 upgrade 的最终产物是"可直接编辑的源码文本"——若渲染后无法通过类型检查,该文件会被写入 scratch 文件供用户手工修复,随后通过update重新并入分支,形成"upgrade 自动重写 + update 手工收尾"的完整工作流。

七、失败保护与边界处理

除了后缀化,fix-5323 所覆盖的 upgrade 机制还包含多项工程化保护:

  • 类型检查失败路径:若渲染出的依赖者无法通过parseAndTypecheck,handleUpgrade1会创建名为upgrade-<old>-to-<new>(如upgrade-unison_base_3_0_0-to-unison_base_4_0_0)的临时分支、把待修复源码写入 scratch 文件,并应答UpgradeFailure(Output.hs),同时允许通过upgrade.commit把临时分支合并回父分支(见 InputPatterns.hs 中upgrade.commit的说明)。
  • 重名去后缀(unmangle):maybeUnmangle(Upgrade.hs)会检测新库名称中形如foo__2的后缀(__<数字>,可能是多次安装未发布依赖时由名称冲突机制生成的),若去除后缀后的foo未被占用,则自动把 libdep 名从foo__2还原为foo;其辅助函数unsnocUnderscoreUnderscoreNumber还附带了 doctest 示例(Upgrade.hs)。
  • 临时分支命名:findTemporaryBranchName(Upgrade.hs)在单库升级时生成upgrade-<old>-to-<new>形式的分支名,若因符号等导致命名失败则退化为纯字母数字的 scrubbed 版本;多库同时升级时则使用通用的upgrade前缀名,避免分支名过长。
  • 名称一致性守卫:升级前会通过Branch.asUnconflicted断言命名空间无冲突定义、通过getBranchDeclNameLookup断言声明结构一致(不一致时以IncoherentDeclDuringUpgrade回滚,Upgrade.hs),保证替换操作建立在干净的基础上。

八、小结:如何验证与使用这份修复

fix-5323.md 用一条极简的依赖链(old.x → b.y → c,外加干扰项a.y)把"upgrade 依赖者后缀化"这个易被忽略的细节固化成了可执行回归测试:先builtins.merge lib.builtin初始化,再定义old.x/new.x/a.y/b.y/c,经update入库、两次move归位到lib.old.x/lib.new.x,最后upgrade old new。整个过程在 transcript 运行器下每次重放都必须产出完全一致的输出。

如果你想在实际代码库中复现这一行为,只需按第三节的序列在 UCM 中逐步执行,并观察升级完成后view c的渲染结果是否保留了b.y后缀。若要对修复本身做更深入的代码级追踪,建议按以下路径阅读:

  • 升级主流程:Unison/Codebase/Editor/HandleInput/Upgrade.hs
  • 后缀化算法:Unison/Name.hs
  • 命令定义与帮助文本:Unison/CommandLine/InputPatterns.hs
  • 输出消息类型(UpgradeSuccess/UpgradeFailure/CantDoThatDuring):Unison/Codebase/Editor/Output.hs
  • 姊妹命令update的后缀化策略:Unison/Codebase/Editor/HandleInput/Update2.hs

对于希望运行整套回归测试的开发者,仓库提供了 scripts/transcripts.sh 作为 transcript 测试入口,fix-5323.md即与idempotent目录下其他用例一起被纳入该套件,持续守护upgrade命令的渲染正确性。

  • 编程语言
  • 编译器
  • 语言运行时
  • 开发工具

【免费下载链接】unison

A friendly programming language from the future

项目地址:https://gitcode.com/gh_mirrors/un/unison
点击查看免费下载

相关推荐

上一篇:Firecrawl PHP SDK 实战指南:从 Scrape、Crawl 到 Laravel AI 工具的完整接入方案
下一篇:中国车牌生成器终极指南:快速生成合规车牌图片的完整教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询