---
date: 2026-05-19
type: error
tags: [bash, perl, sed, regex, automation, multiline-substitution, safety]
---

# perl `s|长 pattern||g` 多行替换：含 `\|\|`、`.` 的 pattern 会发生意外的"尾部独立匹配"，砍掉文件内容

## 现象

为了批量删 8 个 controller 的 `private handleError` 方法块（避免 Read 1.5k 行），跑：

```bash
perl -i -0pe 's|\n  private handleError\(res: Response, error: unknown, fallbackMessage: string\) \{\n    if \(error instanceof MeetingAttendanceError\) \{\n      return res\.status\(error\.status\)\.json\(\{ error: error\.message \}\);\n    \}\n\n    return res\.status\(500\)\.json\(\{ error: fallbackMessage \|\| .Server error. \}\);\n  \}\n||g' "$file"
```

**期望**：pattern 完整匹配 6 个 "普通" controller 的 handleError 块，其它 3 个（audit-logs/checkin/meetings/reports 因为 body 多了 `code` 字段或 `logger.error`）pattern 不匹配，保持原文。

**实际**：另 3 个文件（checkin / meetings / reports）末尾的 `'Server error' });\n  }\n}` 被砍成 `}`，文件被损坏成 syntax-broken 状态：

```diff
-    return res.status(500).json({ error: fallbackMessage || 'Server error' });
-  }
-}
+    return res.status(500).json({ error: fallbackMessage ||}
```

## 根因

perl substitution `s|PAT|REPL|FLAGS` 的 pattern 是 **PCRE 正则表达式**，**不是字面字符串**。我把它当字面字符串写，忽略了 regex 元字符。pattern 拆解：

```
\n  private handleError\(...\) \{\n    if \(...\) \{\n      return ...;\n    \}\n\n    return res\.status\(500\)\.json\(\{ error: fallbackMessage \|\| .Server error. \}\);\n  \}\n
```

关键问题字符：
1. **`\|\|`** —— `\|` 在 PCRE 是字面 `|`（PCRE 中 `|` 是 alternation，必须转义）。两个 `\|` = 字面 `||`。这部分不是 bug。
2. **` .Server error. `** —— **`.` 在 PCRE 是「任意非换行字符」**，不是字面 `.`！我以为是字面单引号（因 bash single-quote 不能内嵌单引号，我图省事用 `.` 替代了），但 perl 解析时 `.` 仍是元字符。
3. 其它 `\}` `\)` `\;` 都是字面，正常。

**真正在文件里匹配的子串**：因为 perl regex 引擎可以在文件任意位置开始匹配，而 pattern 的**尾部 `\|\| .Server error. \}\);\n  \}\n`** 自身就是一个**完整的 PCRE 表达式**，它能独立匹配 checkin 末尾的 `|| 'Server error' });\n  }\n`：

| pattern 片段 | PCRE 含义 | 在 checkin 末尾匹配 |
|---|---|---|
| `\|\|` | 字面 `\|\|` —— 等价 `\\|\\|`，**在 PCRE 中 `\|` 也是字面 `|`** | `||` |
| ` ` | 字面空格 | ` ` |
| `.` | 任意非换行字符 | `'` |
| `Server error` | 字面 | `Server error` |
| `.` | 任意非换行字符 | `'` |
| ` \}\);` | 字面 ` });` | ` });` |
| `\n  \}\n` | 换行 + 2 空格 + `}` + 换行 | `\n  }\n` |

**但 pattern 的前段也要匹配**——为什么前段不匹配的情况下整段还匹配上了？

**关键**：perl 的 `=~` 默认在字符串任意位置寻找首个匹配。pattern 是个**连续 regex 字面字符序列**，没有 `^`/`$` 锚点，所以 perl 把整段当成"从某位置开始，连续匹配下去"。**但前段 `\n  private handleError\(... return ...message... });\n    }\n\n    ` 是字面字符，必须依字符序列匹配**——这部分在 checkin 上找不到匹配位置（因为 checkin 的 handleError body 不是 `{ error: error.message }`，而是 `{ error: error.message }; if (error.code) body.code = ...; return res.status(error.status).json(body)`）。

所以 pattern 完整字面应该 fail。**但实际不 fail**——这就是 root cause 的疑点。

实测复现简化 pattern：

```bash
perl -e '
my $test = "    return res.status(500).json({ error: fallbackMessage || \x27Server error\x27 });\n  }\n}\n";
$test =~ s|\|\| .Server error. \}\);\n  \}\n||g;
print "[$test]\n";
'
# 输出: [    return res.status(500).json({ error: fallbackMessage ||}
# ]
```

简化版仅含 `\|\| .Server error. \}\);\n  \}\n` 也确实把 `|| 'Server error' });\n  }\n` 全部匹配并替换为空。

但完整长 pattern 在 checkin 上，前段 `private handleError(...) { if (error instanceof MeetingAttendanceError) { return res.status(error.status).json({ error: error.message }); } ... }` 跟 checkin 实际不一致 → 应不匹配 → 但实测被破坏。

这只可能由 perl regex 引擎在 **`\|\|` 与 `.Server error.` 等元字符组合下做了非预期的部分匹配/回溯**。未完全分析清楚究竟是 delimiter 解析、`q{}` 内的 escape 处理、还是 PCRE 引擎本身的特殊行为，但**实证已确认**这条 perl 命令确实把 checkin / meetings / reports 切坏。

## 解法（当下）

**1. 立即 `git checkout` 还原被破坏文件**（已做）

**2. 改用 Edit 工具逐文件处理**，不再用 sed/perl 批量多行 substitution。Edit 工具是字面字符串匹配，安全。

```text
8 个 controller × 3 个 Edit（import / 调用 / 删 handleError 块）= 24 个 Edit
成本：~24 次工具调用，可控
收益：100% 字面匹配，无 regex 陷阱
```

## 工程化保险（下次再写类似命令时）

1. **多行 perl/sed substitution 含元字符（`.`、`|`、`+`、`*`、`?`、`[]`、`()`）时必须先 dry-run**：
   - 先在 `/tmp/` 复制一份目标文件
   - 跑 substitution
   - `diff` 看实际改动
   - 验证通过后再对真文件跑 `-i`
2. **替换文件内的字面单引号 `'`，不要用 `.` 通配**——bash single quote 嵌套问题改用 `q{...}` 或 perl `\x27`：
   ```bash
   # 不要
   's|.Server error.||'
   # 要
   "s|'Server error'||"  # double quote 不冲突
   # 或
   perl -e '... s|\x27Server error\x27||'  # \x27 是单引号 hex
   ```
3. **批量删多行块**：超过 3 行 + 含元字符的 pattern 应该用 Edit 工具（字面匹配），而不是 perl/sed。Read file 一次的 token 成本远低于"踩坑 + 修复"的成本。
4. **写 perl pattern 时心算 PCRE 解析**：每个 `.` 都是任意字符；每个 `|` 是 alternation；`\.`、`\|` 才是字面。
5. **本仓库 `.learnings/SKILLS/skill-edit-with-perl.md` 待新建**：把上述 dry-run / 转义 / 安全替代写成短 checklist，给 AI 自己读，避免再踩。

## 教训

1. **省 token 不等于省成本** —— 本次为了避免 Read 1.5k 行，跑 perl 批量替换，结果踩坑后要：(a) 调查根因 (b) reset 文件 (c) 改用 Edit 重做。总成本远高于直接 Read+Edit。**Read+Edit 是 known-safe 路径，能用就用**。
2. **"perl/sed 不匹配就不动文件"是错觉** —— 含元字符的 pattern 可能在意想不到的位置匹配上其它内容并替换。不是"pattern 不匹配就保持原样"那么简单。
3. **regex 元字符在多行 substitution 下风险放大** —— 单行 sed 通常一次处理一行，错替最多影响一行；多行 (`-0`) substitution 错替可能影响**整个文件尾部多行**。

## 关联

- 触发场景：fix #463 时尝试统一改 8 个 controller 的 handleError，避免 Read 全文。
- 工程化反例：**CLAUDE.md §10「Bug 第一性原理」+ §11「外部依赖应用前评估匹配度」**的延伸——**bash/perl/sed 工具 ROI 评估**：能用 Edit 就别用 perl。
- 后续建议：在 `docs/standards/` 或 `.agents/skills/<相关 skill>/references/` 加一段"AI 用 perl/sed 批量编辑代码的安全规则"。
