# Lean Mode

> 节制工程——什么时候该写防御性代码、什么时候不该写；怎么把一轮的构建测试耗时从几十分钟压到几十秒。改代码时如果正准备加校验、加 try/catch、加抽象层、加兜底、加"安全"子系统，或者一轮里已经跑了很多次构建测试，读这份。

- Skill: `timefiles404/lean-mode` (Agent Skill)
- Install (CLI): `npx skillmds@latest add timefiles404/lean-mode`
- Raw SKILL.md: https://api.skillmd.com/api/skills/timefiles404/lean-mode/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: Timefiles404 (https://skillmd.com/u/timefiles404)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/timefiles404/lean-mode

---


# 节制工程

这份技能回答两个问题：**这段"安全"代码值不值得写**，以及**这一轮为什么这么慢**。

它不是"少写代码"的口号。下面每一条都有明确的判据和明确的例外。

> **怎么用这份技能**：它是判据表，不是待办清单。你不需要从头读到尾——按你正要做的事
> 跳到对应那一节，用那一节的判据做一次决定，然后继续。判据给出"不写"的结论时，就真的
> 不写，并且**在回复里用一句话说明为什么不写**（这一句是给用户的，不是给自己的）。

> **本文引用的实测数字**都来自**同一份**真实对话记录：一个约 20k 行的 Java / Gradle
> 工程，模型是 GPT-5.6，一次长会话。它说明这类行为真实存在且量级很大，**不是**跨模型
> 或跨工程的基准统计，别当基准线引用。

---

## 一、校验只在信任边界做一次

**信任边界只有四处**：

1. 对外公开的 API（别人的代码会调用它）
2. 用户命令入口 / 事件回调（人或外部系统直接触发）
3. 配置文件、数据库、网络返回值的解析出口
4. 跨进程 / 跨机器边界

**越过这条线，数据就是可信的。** 私有方法、同模块内部调用、构造函数里已经校验过的
字段——不再校验。

### 反例（真实代码，那份记录里的工程有 585 条这样的）

```java
private static List<MainEntry> immutableMainEntries(List<MainEntry> source) {
    Objects.requireNonNull(source, "mainEntries");   // ← 私有静态方法
    ...
}

private ConfigSnapshot currentSnapshot() {
    return Objects.requireNonNull(snapshotSupplier.get(), "config snapshot");
}
```

这两个方法都是 `private`，调用方全在同一个类里。这条校验唯一能捕获的是"我自己刚写的
代码有 bug"——而那种 bug 本来就会在下一行抛 NPE，抛的位置还更接近现场。

### 同一个错误在别的语言里长什么样

```go
// 反例：未导出函数，调用方全在包内
func (s *store) merge(items []Item) []Item {
    if items == nil {                       // ← 这个 nil 是从哪来的？
        return nil
    }
    ...
}
// Go 里更常见的变体：对自己刚构造的东西做 nil 检查，或者给 range 加 len() > 0 守卫
// （range 一个 nil slice 本来就是零次迭代，这个守卫什么也没做）
```

```ts
// 反例：内部函数入口重复校验一个上游已经用类型收窄过的值
function formatRow(row: Row): string {
  if (!row) throw new Error("row is required");        // ← Row 不可空，这行永远不成立
  if (!row.id) throw new Error("row.id is required");  // ← 上游 parseRows 已经校验过
  ...
}
// 真正该校验的地方是 parseRows —— 也就是 JSON 进来的那一处（边界 3）
```

```python
# 反例：私有方法里的断言式校验
def _apply(self, entries: list[Entry]) -> None:
    assert entries is not None, "entries"    # ← 调用方全在类内
    ...
# 注意 assert 在 python -O 下会被整个去掉，用它做真正的边界校验本身就是错的
```

### 判据

问一句：**这个 null / nil / undefined 是从哪来的？**

- 从外部来（用户输入、配置文件、网络、反序列化） → 校验，而且要给出人话错误。
- 从我自己的代码来 → 不校验。真的担心，用**类型**表达（不可空类型 / 值对象 / 构造期
  校验 / TypeScript 的 `strictNullChecks`），别用运行时检查。

### 例外

- 公开库的 API 边界：校验是**契约文档的一部分**，写。
- 反射 / 序列化框架构造出来的对象：绕过了构造函数，写。
- 跨语言 / FFI / 手工内存边界：写。

---

## 二、不要用 catch 把根因盖住

```java
try {
    return codec.decode(item);
} catch (Exception e) {
    return false;              // ← 这里发生了什么？永远不会有人知道
}
```

`catch → return null / false / Optional.empty()`、或者"记一条 warn 然后继续"——这些都
不是容错。它们把**一次清晰的崩溃**换成**一个很难查的错误值**，代价由后面每一次调试支付。
一个星期后有人报"某个属性偶尔没生效"，你会在十七个返回 `false` 的地方之间来回找。

### 各语言里的同一个反模式

```go
// 反例：吞掉 error，只留一个零值
data, err := loadConfig(path)
if err != nil {
    return Config{}, nil       // ← 调用方拿到一份"空但成功"的配置
}
// 也别用 _ = json.Unmarshal(...) 把错误显式丢掉——那和 catch 忽略是同一件事
```

```ts
// 反例：catch 里 return 一个假的成功值
try {
  return await res.json();
} catch {
  return {};                   // ← 上游 500 和上游返回空对象，从此无法区分
}
```

```python
# 反例：宽异常 + pass
try:
    return parse(raw)
except Exception:
    pass                       # ← 之后一切正常，只是结果是 None
```

### 判据

只有当你能对调用方**说清楚**「失败是一条正常的业务路径，它应该这样处理」时才捕获：

```java
// 好：失败是正常路径，而且调用方知道该怎么办
try {
    return Optional.of(parseAmount(raw));
} catch (NumberFormatException e) {
    return Optional.empty();   // 契约：非数字输入 → 空，调用方会提示用户重填
}
```

其他情况一律让它抛，抛到能**真正处理**它的那一层：命令入口、事件回调、请求边界。
在那一层做一次统一转换（转成用户看得懂的话 + 一条带堆栈的日志），全工程只需要一处。

Go 没有异常，对应的做法是**把 error 一路带上去**（`fmt.Errorf("...: %w", err)` 包上
上下文再返回），而不是在中间某一层把它变成零值。

### 反模式：捕获一个你还没见过的异常

如果你说不出这个异常在什么情况下会抛，那你也一定写不出正确的处理——最后必然写成
"记日志然后继续"。**不要 catch。**

### 反模式：捕获范围过宽

`catch (Exception)` / `except Exception` / 空 `catch {}` 会连**编程错误**（空指针、
下标越界、类型错误）一起吞掉。要捕就捕你说得出名字的那一个。

---

## 三、只有一个实现的抽象不是抽象

接口、抽象类、工厂、策略、Builder、适配层——这些只有在**已经有第二个实现**时才有意义。

```java
public interface StrengthenTargetGateway { ... }          // 唯一实现：BukkitStrengthenTargetGateway
public final class StrengthenMutationPlanner { ... }      // 唯一调用点：AccessoryStrengthenService
```

为「将来可能要换」而抽的缝，将来八成不在那个位置。真要换的时候再抽——那时你才知道
该沿哪条缝抽，而且改起来比现在便宜（IDE 的 Extract Interface 是一次按键）。

同样的东西在别处的样子：TypeScript 里给一个只有一种实现的东西写 `interface Foo` +
`class FooImpl`；Python 里为一个类写一个 `ABC` 基类；Go 里在**定义方**先写好接口再写
唯一的实现结构体。

> Go 特别说明：Go 的惯例是「**在消费方**按需要声明小接口」，不是在实现方预先定义。
> 如果只有一个消费者、而且它只是直接调用具体类型，那个接口不用写。

### 例外

- **为了测试**而抽的接口：有理由，但先看看能不能用构造注入一个函数（`Supplier<T>` /
  `Function<A,B>` / 一个函数类型字段）代替一整个接口 + 一个假实现类。
- 已经**明确知道**第二个实现马上要来（下一个迭代就写）：可以抽。
- 语言/框架强制要求（依赖注入容器、插件 SPI、`dyn Trait`）：按框架来。

---

## 四、修 bug 先改根因

**加兜底等于承认没找到根因。**

如果一个问题你修了两次还没好，不要加第三层保护。回去把完整调用路径读一遍——从入口
到出错那一行，每一步的数据长什么样。这一步通常比再写一层保护快得多，而且是一次性的。

唯一允许兜底的场景：**根因在我们改不动的第三方代码里**。这时兜底必须带注释写明
**谁家的问题 + 什么条件下能删**：

```java
// 兜底：上游库 <名字> <版本> 在<具体条件>下会返回一个 amount=0 的对象（见其 issue #NNN）。
// 升到 <修复版本> 之后这段可以删。
if (stack.getAmount() <= 0) { return MaterialCheck.unavailable(); }
```

两样都要有，而且**必须是你真的核实过的**——版本号、条件、issue 号要么写真的，要么
就别写。**编造一条上游缺陷比不写注释更糟**：它会误导后来的人去读一个不存在的 issue，
也会让那个第三方项目背上一个它没有的锅。核实不了就照实写：

```go
// 兜底：<库名> 在某些情况下会返回 0 值，尚未定位到确切触发条件，也没有对应的上游
// issue。删除条件：定位到根因之后。
```

没有这两句的兜底会永远留在代码里。

---

## 五、改动量要与行为变化成比例

一个 bug 的修复通常是几行。如果你正准备提交一个**新增几百行而行为只变了一点**的改动，
那是重写不是修复。

### 动手前的一句话

每次打算新增 50 行以上时，先报：

> 预计新增 N 行，其中 M 行是校验和兜底；不写它的代价是 ______。

这句话本身就是过滤器：`M/N` 超过一半时，你多半在写一个自己也不确定需要的东西。

### 「安全子系统」的特别警告

超时调度、重试、熔断、租约、审计追踪、风险事实、单终态句柄——这类东西成本是**永久的**
（每个新功能都要接一遍，每次调试都要穿过一层），而收益取决于那次失败是否真的会发生。

那份记录里的真实案例：一个给饰品升级用的 Minecraft 插件，`operation/` 包写了 **1786 行**
这样的设施——租约、追踪、风险事实、单终态句柄、超时调度、不确定终止通知格式化器。而这个
插件的核心业务逻辑（强化 + 升星 + 重铸 + 祝福）加起来才 2500 行左右。

**判据：指出一次真实发生过的故障（有日志、有复现步骤），否则不写。**

### 同类但更隐蔽的几种

- **为不存在的并发写锁**：确认它真的会被多个线程/协程碰到，再加。
- **为不存在的规模写缓存**：先测量，别猜。
- **配置化一个从来只有一个取值的参数**：等第二个取值真的出现。
- **提前泛型化**：`<T>` 只有一个实参时，它只是让签名更难读。

---

## 六、写"安全代码"之前的三问

1. 这个失败**真的会发生**吗？——有日志？有复现步骤？还是只是想象？
2. 发生了**用户会怎样**？——数据错乱不可恢复，还是看到一条报错？
3. **不写**它的代价是什么？——一句话说出来。

三问里有一问答不上，就不要写。

答得上、决定要写的时候，把这三句写进提交说明或回复里——它同时也是给评审者的理由。

---

## 七、一轮的耗时是你的责任

这一节是纯粹的工程成本，和代码质量无关，但它决定了用户等你多久。

### 实测

那份记录里：**570 次 Gradle 调用 = 247 分钟**（4.1 小时）。其中：

- **235 次带 `--no-daemon`**（41%）——每次都付一遍 JVM 冷启动 + 全量配置阶段
- **55 次带 `clean`**——每次丢掉全部增量编译产物
- **12 条命令跑到 600 秒超时被杀掉**——那 120 分钟是纯粹的浪费

### 规则

| | 做 | 不做 |
|---|---|---|
| **进程复用** | 让守护进程活着 | Gradle `--no-daemon`；Maven 每次联网解析；重建 venv / node_modules |
| **增量** | 保留构建缓存 | `clean`、`--rerun-tasks`、`go clean -cache`、`cargo clean` |
| **范围** | 只跑受影响的用例 | 改一行跑全量测试套件 |
| **顺序** | 先编译，再测试 | 用测试套件去查编译错误（几十秒的事变成几分钟） |
| **频率** | 全量验证一轮**最多一次**，放最后 | 每改一处跑一次 |
| **超时** | 换个更小的目标 | 把 timeout 调大再跑一遍 |

### 各构建系统的"只跑这一个"

```bash
# Gradle
./gradlew :core:compileJava                              # 先编译
./gradlew :core:test --tests com.example.FooTest         # 再单测

# Maven
mvn -o -q -DskipTests compile
mvn -o -q -Dtest=FooTest#shouldBar test

# Go
go build ./internal/foo/...
go test ./internal/foo/ -run '^TestBar$' -count=1

# Node / TypeScript
npx tsc --noEmit -p tsconfig.json
npx vitest run src/foo.test.ts -t 'bar'

# Rust
cargo check -p mycrate
cargo test -p mycrate bar -- --nocapture

# Python
pytest tests/test_foo.py::test_bar -x -q

# .NET
dotnet build --no-restore
dotnet test --no-build --filter FullyQualifiedName~FooTests.ShouldBar
```

模块名 / 测试名当然要换成这个工程真实的。**不确定这个工程的最小回路是什么，就先问
一句或者读一眼构建脚本**，别默认跑根目录的全量任务。

### 不要做的两件验证

- **不要用哈希/校验和证明产物变了。** 对一个 jar 跑 `Get-FileHash` / `sha256sum`
  不产生任何新信息——构建输出和时间戳已经说明了。那份记录里有 20 条这样的命令。
- **不要为了确认而重读刚写过的文件。** 编辑工具已经回了 diff。那份记录里
  读:改 = **8381:1746**（每改一次读 4.8 次），单个文件被重复读了 **659 次**。
  除非有具体理由怀疑写错了（比如写入报了错、或者要看它和别处的交互），否则直接往下走。

---

## 八、可以说"不用改"

如果最好的答案是删掉一段代码、或者什么都不做，就直接这么说。**产出代码不是目标，
把问题解决掉才是。**

同理，如果一个需求会让代码显著变复杂而收益很小，说出来让用户决定——而不是默默地
把复杂度吃下去。

反过来也一样：**不要在用户没问的地方顺手"加固"**。用户让你修 A，你顺带给 B 加了三层
校验，那三层校验没有人评审过，也没有人要求过。

---

## 附：审计一份已经长歪的代码

按这个顺序做，**先量再删**：

**第一步 · 量**（一次搜索做完，不要逐个文件读）。按语言挑对应的几条：

```bash
# Java / Kotlin
rg -c 'Objects\.requireNonNull|Preconditions\.|checkNotNull|^\s*require\(' --glob '!**/test/**'
rg -c 'throw new IllegalArgumentException'                                  --glob '!**/test/**'
rg -c -U 'catch \([^)]*\) \{[^}]*return (null|false|Optional\.empty\(\))'
rg -c 'List\.copyOf|Collections\.unmodifiable|new ArrayList<>\('

# Go
rg -c -U 'if err != nil \{[^}]*return [^}]*(nil|false|""|\{\})\s*$' --glob '!**/*_test.go'
rg -c '_ = ' --glob '!**/*_test.go'          # 显式丢弃的返回值（多数是被吞的 error）

# TypeScript / JavaScript
rg -c -U 'catch \([^)]*\)?\s*\{[^}]*return (null|undefined|\{\}|\[\]|false)' --glob '!**/*.test.*'
rg -c 'if \(!\w+\) (throw|return)'                                          --glob '!**/*.test.*'

# Python
rg -c -U 'except [\w.]*( as \w+)?:\s*(pass|return None)'
rg -c '^\s*assert '                          --glob '!**/test_*'
```

这些是**起点**，不是定式——按这个工程的实际写法调整。算出**每千行的密度**，列出密度
最高的 5 个文件。参考值：健康的业务代码大约是每 200–500 行一条运行时校验；每 20–40 行
一条就是明显偏高。

**第二步 · 判**（只看那 5 个文件，逐条归档）：

- **删**——调用方全在模块内部的校验；对内部调用方的防御性拷贝；防"还没发生过的失败"的设施
- **上移**——同一个校验在多层重复出现，只保留最外层那一次
- **留**——真正在信任边界上的，说明它守的是哪条边界

**第三步 · 报**——给一张表：文件 / 行号 / 现状 / 判定 / 预计减少行数，末尾给总计。
**先出表，让用户挑**，不要直接开删。

**第四步 · 改**——用户挑完之后再动手，一次一个文件，每个文件改完就编译一次（不是跑
全量测试）。删除校验最常见的翻车是删掉了**唯一**在守边界的那一条，编译期发现不了，
所以边界上的那几条要在第二步就标清楚。

---

## 配套（可选）

Vantaloom 有一个同名插件 `lean-mode`，把这里的守则做成**每一轮都生效**的压力：一份
压缩到 718 字节的守则贴在每轮上下文末端、工具结果尾注（本轮第一条失败的命令 / 命令数
超阈值 / 同一文件反复改），以及一个把「值不值得」变成确定性裁决的 `lean_review` 工具。

**没有那个插件，这份技能也完整可用**——它覆盖了全部守则和判据，而且比插件里的守则
详细。两者的分工是：插件负责「每轮都提醒」，技能负责「需要时给出完整判据」。

