---
name: 2026-08-05-issue232-234-address-width-and-gsub-escape
description: >
  定时巡检任务（每天 03:07，前一天刚建）的**第一次实际处理轮**：三个 nightly `FuzzOracleDiff` crasher
  #232、#233、#234，**三个都在当前 HEAD 上如实复现**，1 个 commit `2b1aeb4`（本地在 `master` 上）。
  **#232 与 #233 后来发现是同一个根因家族**（引用值地址的宽度），所以实际是两个修复加一个既有兄弟缺陷。
  **#232：地址逃过了归一化。** seed 是 `io.write(0)print(print)`，`io.write` 不带换行，于是输出是
  `0function: 0x...`；`compare.go` 的 `addrRe` 用 `\b` 锚定类型名，而 `0` 与 `function` 之间**都是 word
  字符、`\b` 不匹配**，于是那个地址整段逃过归一化——凡是这种写法都必然分歧。改成用「不是字母」锚定左边，
  既修好又保留了那个锚点原本的用途（让脚本自己打印的 hex 仍然可比，这是当初 PR review 要求加锚点的理由）。
  **#233：脚本测量地址的长度，归一化够不着。** seed 是 `t={0}print(#tostring(t))`：PUC 是 21（`%p` 在本
  平台给 12 个十六进制位），望舒是 17（`0x%08x` 给 8 位），**长度在归一化之前就已经分歧了**，所以
  `NormalizeOutput` 再怎么改都到不了。修在 **oracle 侧**：prelude 里包一层 `tostring`，把 PUC 自己的地址
  渲染成望舒的宽度——这与仓库已有的 NaN 符号位处理是同一个选择（**在渲染处消除差异**，见
  [[prove-the-path-under-test]] §9.0）。这也解释了 **#233 为什么看起来是间歇的**：#232 那种「原始形式」
  是否分歧，取决于真实地址恰好需要几位十六进制；#233 那种「测量长度」是稳定分歧的。同一个根因、两种
  可见度。**#234：gsub 替换串里的转义（引擎侧）。** seed 是
  `print(string.gsub("aaa","a","%",0000000001))`。PUC 的 `add_s` 有两个我们都没跟上的后果：① **末尾的
  `%`** 会让它做 `i++` 然后**越过长度读 `news[i]`**，读到的是 `lua_tolstring` 保证的 NUL 终止符，于是
  **每次匹配吐一个 NUL 字节**（实测 hexdump 确认 `gsub("aaa","a","x%")` 是 `x\0x\0x\0`），望舒要求
  `i+1 < len(rb)`、把末尾的 `%` 当成了字面量；② **`%` 后面任何非数字**都被原样吐出，所以
  `gsub("a","a","%z")` 是 `"z"` 而**不是报错**——这一半是**既有缺陷**，在 base 上同样分歧（实测确认过），
  只修第一半会留一个已知的洞，所以一起修了。「照抄一个越界读的怪癖」本身不显然正确，但 oracle 比较的是
  字节：不照抄，凡是以 `%` 结尾的替换串就永远不可比。三处修复各自用「还原该修复」实测确认过会变红。
  五条教训：一批 crasher 里同一个根因可能以不同的**可见度**出现，不要按症状分成两件事 / 归一化只能救
  「被打印出来」的差异，救不了「被测量」的差异 / 一个正则锚点的**反例集合**要用它自己的失败输入去检验，
  而不是靠读它的注释 / 修参照实现的语义时「只修被报的那一半」会留下已知的洞 / 照抄参照实现的越界读怪癖
  是可以的，前提是判据来自「比较的是什么」。
metadata:
  type: reflection
  date: 2026-08-05
---

# 一个地址的两种可见度，与一段替换串里没跟上的怪癖（2026-08-05，commit `2b1aeb4`）

> 范围：#232、#233、#234 三个 issue，1 个 commit `2b1aeb4`。**这是定时巡检任务的第一次实际处理轮**
> （每天 03:07，前一天刚建，辅助脚本见 `.claude/patrol/`）。改动落在 `internal/oracle/compare.go`
> （`addrRe` 换锚点 + 归一化改成只重写匹配内部的地址）、`internal/oracle/prelude.go`（包一层
> `tostring` 把 oracle 侧的地址渲染成 8 位）与 `internal/stdlib/stringlib.go`（`st2gsubRepl` 的
> `%` 转义对齐 `add_s`）；测试落在 `internal/oracle/normalize_addr_test.go` 与 `fuzz_234_test.go`；
> 三个 seed 入 `testdata/fuzz/FuzzOracleDiff/`（`61bf45a6f4a854a8` / `65ba4c0a7202462d` /
> `3150cd52d760dc8a`）。
>
> 一处与任务描述不一致的事实：分支不是 `fix/232-234-oracle-diff`，`git branch --show-current` 给的是
> `master`，`2b1aeb4` 是 master 的 HEAD。

## 任务

定时巡检从 2026-08-04 起挂上（每天 03:07），这是它的**第一次实际处理轮**。nightly 的
`nightly-diff-fuzz` 自动开了三个 `FuzzOracleDiff` crasher：#232、#233、#234。

## 本轮做了什么

### 0. 三个都真复现，而「会不会被同一个改动一起解决」这次给出的是一半

按 [[unreproducible-crasher-triage]]「第一步永远是版本核对」核完之后，逐条在当前 HEAD 上重放——
**三个都如实 FAIL**，都是真缺陷。那一节「三档是成本档不是缺陷档」在这一轮又一次成立。

同一篇 guide 的「一批 crasher 先问会不会被同一个改动一起解决」这次的答案是**一半**：#234 与另外两个
无关（引擎侧的 stdlib 语义），而 **#232 与 #233 是同一个根因家族**——都是引用值地址的**宽度**，只是
一个表现为「原始输出不同」、另一个表现为「长度不同」。这一点不是一眼看出来的，见教训 1。

### 1. #232：地址逃过了归一化（`internal/oracle/compare.go`）

seed 是 `io.write(0)print(print)`。`io.write` 不带换行，于是捕获输出是一整行 `0function: 0x...`。

`addrRe` 原先是：

```
\b(table|function|thread|userdata): 0x[0-9a-fA-F]+|\bfile \(0x[0-9a-fA-F]+\)
```

那个 `\b` 是当初 PR review 要求加的，理由正当：不加锚点时脚本自己打印的 hex（`0x1`、
`myfunction: 0x1`）会被一起归一掉，而那是脚本自己的文本、必须保持逐字节可比。问题在**具体写法**：
`\b` 是「word 字符与非 word 字符之间的边界」，而 `0` 与 `function` **都是 word 字符**，中间没有边界，
于是整个地址逃过归一化——**凡是这种写法都必然分歧**，两个引擎的地址值本来就不同。

这一处**改了三版才对**,三版的失败方式都值得记下来。

**第一版**改成用「不是字母」锚定左边:

```
(^|[^A-Za-z])((?:table|function|thread|userdata): 0x[0-9a-fA-F]+|file \(0x[0-9a-fA-F]+\))
```

它只修好了 seed 里那个「数字紧贴前缀」的情形。审计指出两个问题:**字母仍然拦住**
(`io.write("x")` 后接打印引用值,与 `io.write(0)` 一模一样地分歧);而且这个锚点**消耗**了前导那一个字符,
于是两个相邻地址共用一个锚点、第二个逃掉 —— 那是相对 base 的回归。

**最终版**放弃在正则里锚定:`addrRe` 只匹配地址**本体**(`0x[0-9a-fA-F]+`),
前缀由 `NormalizeOutput` 在 Go 侧按 `FindAllStringIndex` 的真实偏移逐个校验。
RE2 没有 lookbehind,前缀无法零宽断言;而 Go 侧校验不消耗字符(相邻与重复地址各自独立判定),
判据也正好是「这是不是 tostring 的四种引用拼写之一」,而不是 `\b` 对它的近似。

还有一个**无法用局部上下文判定**的情形,是明确取舍而非疏漏:`xfunction: 0x...`(`io.write` 拼出来的)
与 `myfunction: 0x1`(脚本自己的文本)形状完全相同,信息不在字符串里。两种失败并不对称:
拒绝归一会让每个「`io.write` 后接打印引用值」的脚本**必然**分歧,而归一化一个同形文本是无害的 ——
脚本自己的 hex 两侧算出来一样、坍缩到同一个 token;真要被掩盖,得是两个引擎对同一个表达式算出**不同**的
hex,而那种分歧我们希望它以自己的值暴露、而不是以渲染形式暴露。取「一律归一」。

### 2. #233：脚本**测量**地址的长度，`NormalizeOutput` 到不了（`internal/oracle/prelude.go`）

seed 是 `t={0}print(#tostring(t))`。它不打印地址，它**量**地址：

| 侧 | 渲染 | `#tostring(t)` |
|---|---|---|
| PUC | `%p`，本平台给 12 个十六进制位 | **21** |
| 望舒 | `0x%08x`（`internal/stdlib/stdlib.go`），给 8 位 | **17** |

关键是**长度在归一化之前就已经分歧了**：`NormalizeOutput` 拿到的字节流里根本没有地址，只有一个
`21` 和一个 `17`。这与 [[2026-07-26-oracle-nan-render-redesign]] 那一轮的 `string.len(0/0)` 是
**完全一样的机制**——一个引擎相关的量被 `#` 变成了数字，输出里连一个可锚定的 token 都不剩。

所以修在 **oracle 侧**：prelude 里包一层 `tostring`，对匹配 `^(%a+: )0x(%x+)$` 的结果保留低 32 位、
补零到 8 位，让 PUC 自己渲染成望舒的宽度。这与仓库已有的 NaN 符号位处理是**同一个选择**——
[[prove-the-path-under-test]] §9.0 的「同一个抽象值的不同书写方式 → 在渲染处消除」，而 oracle 是仓库
自己 vendor 并用 cgo 编译、只为做差分基准而存在的（且已经为确定性 stub 掉 `os.time`/`math.random`/
`pairs` 顺序），供给链可控这一条也满足。

望舒这一侧的宽度于是变成了一份**契约**：`fuzz_234_test.go::TestAddressLengthIsComparable` 断
`#tostring({})` 是 17、`#tostring(print)` 是 20，宽度一改这个测试就红。

**#233 为什么看起来是间歇的**，到这里也有了答案：#232 那种「原始形式」是否分歧，取决于真实地址恰好
需要几位十六进制（正好 8 位时两侧碰巧一样长，nightly 于是有时不报）；而 #233 那种「测量长度」是
**稳定**分歧的。同一个根因、两种可见度。

### 3. #234：gsub 替换串里的 `%` 转义（`internal/stdlib/stringlib.go`）

seed 是 `print(string.gsub("aaa","a","%",0000000001))`。按
[[cross-backend-semantic-fix-sweep]]「PUC 语义由 C 实现定义」读 `_lua515/lstrlib.c` 的 `add_s`：

```c
if (news[i] != L_ESC) luaL_addchar(b, news[i]);
else {
  i++;                                    /* 注意:没有边界检查 */
  if (!isdigit(uchar(news[i]))) luaL_addchar(b, news[i]);
  ...
}
```

两个后果我们都没跟上：

| 后果 | PUC 的行为 | 望舒原先 |
|---|---|---|
| **末尾的 `%`** | `i++` 之后**越过长度读 `news[i]`**，读到 `lua_tolstring` 保证的 NUL 终止符，于是**每次匹配吐一个 NUL 字节**——`gsub("aaa","a","x%")` 是 `x\0x\0x\0`（hexdump 实测确认） | 要求 `i+1 < len(rb)`，于是把末尾的 `%` 当成了字面量 |
| **`%` 后面任何非数字** | 原样吐出，`gsub("a","a","%z")` 是 `"z"` | 抬 `invalid use of '%' in replacement string` |

第二半是**既有缺陷**：在 base 上同样分歧（实测确认过），只是没有 issue 记它。#234 报的只有第一半，
但两半是同一段 `add_s` 逻辑的两个出口，只修被报的那一个会留一个已知的洞（见教训 4）。

修法：`%` 分支不再要求 `i+1 < len(rb)`，越界时 `c` 取零值（那正是 PUC 会读到的 NUL）；`else` 分支
从抬错改成 `out = append(out, c)`。`%%`、`%0`–`%9`、越界的捕获下标仍报 `invalid capture index`——这三条
都不变，有用例钉住。

**「照抄一个越界读」这件事的判据**：这不是可以引以为据的设计，PUC 那一行确实读了一个超出长度的字节。
但 oracle **逐字节**比较，不照抄的话，凡是以 `%` 结尾的替换串就永远不可比（每次匹配差一个字节，而且
那个字节还会流进长度、比较、表键）。注释里写清了这是怪癖而非规则，见教训 5。

### 4. 验证

三处修复各自用「把该修复还原掉」实测确认过会变红：

- `internal/oracle/normalize_addr_test.go::TestNormalizeAddrPrefixValidation` —— 六条必须归一
  （含紧贴数字的 `0function:` / `0table:` / `0file (`）+ 四条必须保持原样（`0x1`、`myfunction: 0x1`、
  `tostring gives 0xff`）。**两侧都有用例**是这一条的关键：只写前半会让「把锚点整个删掉」也通过。
- `fuzz_234_test.go::TestGsubReplacementEscapeMatchesPUC` —— 四条改了行为的（末尾 `%`、文本后的末尾
  `%`、捕获后的 `%`、`%z`）+ 三条必须不变的（`%%`、`%1`、越界下标仍抬错）。
- `fuzz_234_test.go::TestAddressLengthIsComparable` —— 望舒侧的宽度契约。
- 三个 seed 入 `testdata/fuzz/FuzzOracleDiff/`。

## 期望与实际

| 期望 | 实际 |
|---|---|
| 三个 crasher 里大概有一个是过期的 | 三个都在当前 HEAD 上如实复现 |
| 三个互不相关，分头查 | #232 与 #233 是同一个根因（地址宽度）的两种可见度，只有 #234 独立 |
| #233 是间歇的，先看能不能稳定复现 | 它稳定复现；「间歇」的是 #232 那种原始形式，取决于真实地址恰好几位 |
| #233 也在 `NormalizeOutput` 里修 | 归一化在长度分歧之后才跑，物理上到不了；必须回到渲染处 |
| `addrRe` 的 `\b` 有清楚的注释解释它为什么在，应该是对的 | 意图对、写法漏了「数字紧贴类型名」这一类 |
| #234 修末尾 `%` 就收工 | 同一段 `add_s` 还有「非数字原样吐出」这一半在 base 上就是错的 |

## 教训

### 教训 1（一批 crasher 里，「同一个根因」可能以不同的可见度出现，不要按症状把它们分成两件事）

#232 的症状是「原始输出不同」（一段 `0function: 0x...` 逃过归一化），#233 的症状是「长度不同」
（`21` 对 `17`）。看起来一个是渲染问题、一个是语义问题；实际是**同一个地址宽度差异**——只是前者
取决于真实地址恰好几位（所以间歇），后者稳定。

**为什么容易错**：[[unreproducible-crasher-triage]] 已有的分组手法是「按根因分组」，而分组这个动作
的输入是**症状**——nightly 给的是标题、hash 与最小化后的 seed，三者都描述表现。两条表现差得越远，
越不会被放到一起，而「差得远」恰恰可能是同一个量的两种可见度造成的。

**判据**：同一批里若有两条都涉及同一种「引擎相关的值」（地址、指针、时间、随机数），**先假设同源，
再去找那个共同量**。自查办法：把每条 seed 里出现的引擎相关量列出来（#232 是 `tostring(print)` 的
地址，#233 是 `tostring(t)` 的地址），列出来相同就别急着分头查。

这与 [[prove-the-path-under-test]] §9.6 的推论互补：那条讲**修完之后**「一批 crasher 被同一个改动
一起解决，是根因修在正确位置的信号」；本条讲**动手之前**「症状不同不代表根因不同」。

### 教训 2（归一化只能救「被打印出来」的差异，救不了「被测量」的差异）

`#tostring(t)` 的长度在任何 `NormalizeOutput` 跑之前就定了。归一化拿到的字节流里只有一个 `17`，
没有任何可锚定的东西。

**为什么容易错**：归一化这个机制的心智模型是「把不可比的值抹成占位符」，而这个模型隐含一个前提——
**那个值在输出里以它自己的形式出现**。脚本可以不打印它而是测量它，测量结果是一个普通数字，与那个值
再无字面联系。

**判据**：给差分 harness 加归一化时，问一句「脚本能不能**不打印它、而是测量它**」——能的话
（`#`、`string.len`、比较、当表键、算术），归一化就不是完整的修法，必须回到**渲染处**去消除。
自查办法：为你要归一的那个量写一个不打印它的脚本（`print(#tostring(t))` 这种）；写得出来就说明
归一化有洞。

这是 [[prove-the-path-under-test]] §9.0 那张表的**延伸**：那一节把「同一个抽象值的不同书写方式」
判去渲染侧、把「两侧本来就是不同的东西」判去比较侧，而引用值地址一直被列在**后者**（"没有正确值可
对齐 → 归一为 `0xADDR`"）。这一轮说明那个分类还要再切一刀：地址的**值**确实两侧不同、只能归一，
但地址的**宽度**是一个渲染选择，而它会被 `#` 变成一个普通数字流走。所以归一化管值、渲染处管宽度，
两件事都要做。#233 就是「判在比较侧的那一类里，仍然有一个子量必须在渲染侧对齐」。

### 教训 3（一个正则锚点的「反例集合」要用它自己的失败输入去检验，而不是靠读它的注释）

`addrRe` 的 `\b` 有一段很清楚的注释解释它为什么存在（防止把脚本自己的 hex 归一化掉），注释是对的、
锚点的**意图**也是对的，但 `\b` 这个具体写法漏掉了「数字紧贴类型名」这一类。

**为什么容易错**：读一个锚点时得到的是它的**意图**（「不要匹配更长单词的尾部」），而正则的行为由
它的**字符类语义**决定。`\b` 的语义是 word 字符边界，而 word 字符包含数字和下划线——意图里的「单词」
与 `\b` 里的「word」不是同一个集合，差的正好是数字和下划线。注释写的是意图，所以读注释永远发现
不了这个差。

**判据**：锚点类的正则，除了「该匹配的」用例，还要专门构造「**紧贴各类字符**」的用例——数字、
下划线、字母、行首。自查办法：把锚点左右各能出现的字符类列一张表，每一格写一个用例；写不满就说明
还没检验这个锚点。

这与 [[prove-the-path-under-test]] §4.9x「断言两者一致时要用能让两者不同的输入」同源：那条讲测试
输入恰好落在性质成立的一侧，本条讲注释描述的意图恰好落在写法正确的那一侧。

### 教训 4（修参照实现的语义时，「只修被报的那一半」会留下已知的洞）

#234 的 seed 只覆盖末尾 `%`，而同一段 `add_s` 逻辑还有「非数字原样吐出」这一半在 base 上就是错的。

**为什么容易错**：定位到参照实现的某一行之后，注意力会停在「这一行怎么让 seed 走到这个结果」，而
那一行往往是一个**分支的入口**，分支有多个出口。fuzz 报的是其中一个出口，而其余出口的错误只是
「还没有被采样到」。

**判据**：定位到参照实现的某个分支之后，把**那个分支的所有出口**都对一遍，而不是只对 seed 走到的
那个出口。自查办法：把参照实现那段代码的每一个 `if` / `else` / `switch` 分支写成一行「这个出口在
望舒是什么行为」，逐行核对——本轮 `add_s` 的 `%` 分支有三个出口（`%%`、数字、其余非数字）加一个
边界情况（越界读），望舒对了两个。

这是 [[prove-the-path-under-test]] §4.1「一个 reported case 是接受面的一个采样」在**参照实现的
控制流**上的形式：那条枚举「同一段实现有几条到达通道」（输入侧），本条枚举「参照实现的这个分支有
几个出口」（实现侧）。

### 教训 5（照抄参照实现的「越界读」怪癖是可以的，前提是判据来自「比较的是什么」）

PUC 读了一个超出长度的字节；这不是可以引以为据的设计，但 oracle 逐字节比较，所以不照抄就永远不可比。

**为什么容易错**：看到参照实现里一个明显可疑的行为（越界读、依赖 NUL 终止符、依赖未指定的求值顺序）
时，第一反应是「这不该照抄」，而这个反应跳过了一个前提问题——**我们的验证口径是什么**。口径不同，
答案相反：逐字节比较就必须照抄（不然那个差异会流进长度、比较、表键，永远不可比），只比语义就可以
不抄。

**判据**：遇到参照实现的可疑行为，先问「**我们的验证口径是什么**」——逐字节比较就必须照抄，只比
语义就可以不抄；并在注释里写清这是**怪癖而非规则**，否则下一个读它的人会把它当成一条可以推广的
规则。自查办法：照抄之前写下一句「不照抄的后果是什么」；答案是「某类输入永远不可比」就照抄，
答案是「某个边界值上不一样」就先按 [[cross-backend-semantic-fix-sweep]]「有定义 vs UB」那张表分类。

与那张表的关系：越界读**不属于**表里任何一格。它不是 C 的 UB（`lua_tolstring` 保证了那个位置有
一个 NUL，所以读它是有定义的、结果确定），也不是「参照实现自相矛盾」（PUC 内部一致）。它是
**有定义但可疑**的第五种情况：行为确定、跨平台一致、可以照抄，只是它的确定性来自一个实现细节
（NUL 终止）而不是一条语义规则。判据因此不在「有没有定义」这一维，而在「我们比什么」这一维。

## Promotion 决策

- **教训 1 → [[unreproducible-crasher-triage]]**：「一批 crasher 先问会不会被同一个改动一起解决」
  那一节的推论——**同源可能症状不同**，所以分组的输入不能只是症状；同一批里出现同一种引擎相关的值
  时先假设同源。
- **教训 2 / 教训 3 → [[prove-the-path-under-test]]**：§9.0 选址那一族。教训 2 是「判在渲染侧」的
  延伸（判去比较侧的那一类里仍可能有一个子量必须在渲染侧对齐，因为它会被测量而不是被打印）；
  教训 3 归到 §9.1 判据输入那一族（一个锚点的反例集合也是判据的一部分，要用失败输入检验而不是
  读注释）。
- **教训 4 / 教训 5 → [[cross-backend-semantic-fix-sweep]]**：教训 4 是「PUC 语义由 C 实现定义」
  的又一个刻度（**一个分支的所有出口**都要对，不只对 seed 走到的那个）；教训 5 扩「有定义 vs UB」
  那张表——加一类「有定义但可疑」，判据不在「有没有定义」而在「我们的验证口径是逐字节还是语义」。

## 触发场景

- 拿到一批 nightly crasher、准备分头查时 → 先把每条 seed 里出现的**引擎相关的值**列出来，相同就
  假设同源。
- 想给差分 harness 加一条归一化时 → 问「脚本能不能不打印它、而是测量它」；能就说明归一化有洞，
  回到渲染处。
- 改一个带锚点的正则时 → 给锚点左右各能出现的字符类列一张表，每格一个用例；别靠读注释判断意图
  是否被写法正确表达。
- 修完 fuzz 报的那条参照实现语义分歧时 → 把参照实现那个分支的**每一个出口**对一遍。
- 看到参照实现里一个明显可疑的行为时 → 先问「我们的验证口径是逐字节还是语义」，再决定照抄不照抄；
  照抄就在注释里写清这是怪癖。

## 关联

[[unreproducible-crasher-triage]]（第一步永远是版本核对——这一轮三个都真复现；「一批 crasher 先问
会不会被同一个改动一起解决」这次给出的是一半，教训 1 的落点）·
[[prove-the-path-under-test]]（§9.0 选址是 #233 的判据来源，教训 2 的落点；§9.1 判据输入是教训 3 的
落点；§9.6 断 equal 不断绿 + 双向验证是三处修复的验证口径）·
[[cross-backend-semantic-fix-sweep]]（「PUC 语义由 C 实现定义」是 #234 的定位手法，教训 4/5 的落点）·
[[2026-07-26-oracle-nan-render-redesign]]（#233 的长度分歧与 `string.len(0/0)` 是完全一样的机制：一个
引擎相关的量被 `#` 变成数字，输出里没有可锚定的 token；那一轮定下的「在渲染处消除」在本轮第二次被
正向使用）·
[[2026-08-04-issue228-229-lazy-capture-and-nested-tailcall-top]]（也是 gsub、也是「参照实现的哪一段
没照全」——那次是抬错点的位置与粒度，这次是一个分支的出口没对齐）·
[[2026-08-02-issue212-219-fuzz-crasher-batch]]（#216 同样是 gsub 的 repl，那次是校验在控制流里的位置）·
`internal/oracle/compare.go`（`addrRe` / `addrBody` / `NormalizeOutput`）·
`internal/oracle/prelude.go`（`preludeCapture` 里的 `tostring` 包装）·
`internal/stdlib/stringlib.go::st2gsubRepl` · `internal/stdlib/stdlib.go`（`function: 0x%08x` 的宽度）·
`internal/oracle/_lua515/lstrlib.c`（`add_s`）·
`docs/design/p1-interpreter/10-stdlib.md` §6.5.2（替换串的 `%` 转义）·
`docs/design/p1-interpreter/12-testing-difftest.md` §4.3（地址归一的能力边界）·
`docs/design/engineering.md` §3.2（oracle 差分机制）· `.claude/patrol/`（巡检辅助脚本）
