助手挑的简洁写法都能跑通,代价在出错时才出现——错误被吞掉、进程整个退出。
合并请求被挡回、指出用了禁用写法,直接看禁用清单。
把这段代码里所有失败路径列一遍,每一条是不是都有人负责?
答「否」,说明有失败被丢掉或被打断。丢掉失败的写法在测试里也能全绿,只是因为没触发那条路径。
下面这几条有确定判据,写进配置,不需要评审。它们的共同点是:修法只有一种,所以能自动化。
| 拦什么 | 规则 | 命令 |
|---|---|---|
未处理的 error | Go errcheck | golangci-lint run |
裸 except:、可变默认参数、eval 与 exec | Python E722、B006、S307、S102 | ruff check --select E722,B006,S307,S102 . |
var、空的 catch、被重新赋值却声明为 const 的反面 | ESLint no-var、prefer-const、no-empty,都在 eslint:recommended 里 | npm --prefix frontend run lint |
init() 里做 IO、goroutine 泄漏、未取消的 context | golangci-lint 的 gochecknoinits、contextcheck,以及 govet 的 lostcancel | golangci-lint run |
库代码里的 unwrap() 与 expect() | clippy::unwrap_used,默认关闭,需要显式打开 | cargo clippy -- -D warnings -W clippy::unwrap_used |
这几条与命名那页的 revive 写在同一个文件里,不要另建一份。
version: "2"
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- revive
- gochecknoinits
- contextcheck
settings:
revive:
rules:
- name: var-naming
severity: error
同样在基线之上追加。E722(裸 except)已经在基线的 E 组里,不用重复写;这一页只加 B(bugbear)与 S(bandit)。加上之后先跑一次数现有代码有多少命中,一次改不完就先只开 B006 与 E722。
[tool.ruff.lint] # 基线:select = ["E", "F", "I", "B", "S", "C901"] extend-select = ["B", "S"]
工具查不到的边界:S307(eval)在测试与数据生成的脚本目录里会误报,可以在 pyproject.toml 里对 scripts/ 单独关掉,但 src/ 下不关。
「禁用」的含义是:评审的人看到就挡回,不用讨论。替代写法都是同一件事——把失败交还给调用方。
| 特性 | 理由 | 替代写法 | 规则 |
|---|---|---|---|
Go:f, _ := os.Open(path) | f 为 nil 时下一条语句才报错,栈里看不出根因 | 写 f, err := os.Open(path),判断后 fmt.Errorf("open notes: %w", err) | errcheck |
Go:用 panic 处理输入错误 | 打掉整个进程或整个请求,其他用户的请求一起失败 | 返回 error,由路由层统一转成状态码 | 人工,见接口 |
Go:time.Sleep 当同步用 | 掩盖竞态,测试结果随机 | 用 sync.WaitGroup 或 channel 等待,要超时就配 context.WithTimeout | 人工 |
Go:init() 里连数据库、读配置 | 启动顺序不可控,测试里没法替换依赖 | 写成显式构造函数,在 main 里装配 | gochecknoinits |
Python:裸 except: | 连 KeyboardInterrupt 一起吞掉,失败变成静默的错误结果 | 写 except ValueError as err:,只捕具体异常 | E722 |
Python:def f(items=[]) | 默认值只在定义时求值一次,多次调用共用同一个列表 | def f(items=None),函数内 items = [] if items is None else items | B006 |
Python:eval()、exec() | 把输入当代码执行 | 解析用 ast.literal_eval,配置用 json.loads 或 tomllib | S307、S102 |
JS/TS:var | 函数作用域、可重复声明,容易写出意料外的覆盖 | 默认写 const,要重新赋值时写 let | no-var、prefer-const |
JS/TS:空 catch {} | 失败被静默 | 记录后重新抛出,或返回一个能判断的失败值 | no-empty |
Rust:库代码里 unwrap() | 输入不合法时 panic | 返回 Result,只在测试里用 unwrap | clippy::unwrap_used |
Java:只用 try 不写 catch 也不写 finally,把异常一路抛到最外层 | 调用方分不清是哪一步失败的 | 在能补充上下文的那一层包一层并重抛,或在最外层统一转成响应 | 人工 |
%w 保留原始错误,Python 用 raise ... from err,Java 保留 cause。这样顶层还能用 errors.Is 或 __cause__ 判断。
recover() 兜住之后返回零值(失败看起来像成功);把 err 写进日志就当作处理完(调用方仍不知道)。
工具查不到的边界:进程启动阶段(读配置、连数据库、开监听端口)失败时,可以直接 log.Fatal 退出,这时没有调用方可交还。这个例外只覆盖 main 里装配的那几十行,不覆盖 internal/ 下的任何一行。
rows, _ := db.Query("SELECT id, title FROM notes") ← 查询失败被丢掉
defer resp.Body.Close() ← 关闭失败被丢掉
go func() { report(ctx, note) }() ← 没人等它,出错没人知道
认它的办法:把这段代码里所有失败路径列一遍,每一条是不是都有人负责。三个下划线各吞掉一条失败路径:查询失败时 rows 是空的、关闭失败无人知晓、那个 goroutine 出错时没有任何人接。跑起来不报错、测试也能过,代价只在你试图查一次线上问题的时候才出现。
下面这些不属于「禁用」,但它们有个共同副作用:助手模仿现有写法时会把它们扩散到不该用的地方。用的时候,在改动说明里写一句为什么这里需要它。
| 特性 | 代价 | 什么时候可以用 |
|---|---|---|
| 反射与动态字段访问 | 改名后编译不报错,只在运行时出问题 | 只在序列化的边界层用,且该层有测试覆盖字段名 |
| 泛型与抽象接口 | 调用点少于两个时,读代码要先跳一层 | 同一组约束在三个以上调用点出现时再抽 |
并发(goroutine、async) | 竞态难复现,日志顺序与实际顺序不同 | 有明确的等待或独立任务,且每条路径都配了取消或超时 |
| 装饰器与宏改变函数签名 | 跳转到定义看到的形参不是真的形参 | 只用于日志、计时这类横切,不用于改参数语义 |
any、dynamic、interface{} | 类型检查失效,字段名拼错也能过 | 只在解析外部输入那一层用,解析完立刻转成具体类型 |
| 嵌套三元与链式一行表达式 | 调试时断点落不到中间结果 | 不超过一层;超了就拆成两句 |
工具查不到的边界:并发那一行在课程项目里最常见的误用是「为了让接口看起来快一点」而开 goroutine,实际没有等待。测试通过只是因为本地执行时间短,没真正走到并发路径。
同一个函数里要区分「预期内的失败」和「不该发生的失败」:前者记 Info(例如查询没命中),后者记 Error。级别的完整判据与字段清单在日志那一页,本节只给写法。
用 errors.Is 判断哨兵错误,不要比较错误字符串——字符串一比,包装过的错误就判断不出来了。
if errors.Is(err, notes.ErrNotFound) {
slog.Info("note not found", "note_id", id)
return nil
}
slog.Error("load note failed", "note_id", id, "err", err)
return err
工具查不到的边界:只有一种失败路径的函数不必区分,直接返回错误、由上层记一行就够。为每个 if err != nil 各记一条日志,一次请求会打出十几行,检索时被稀释。
golangci-lint run 与 npm run lint,退出码为 0;internal/ 与 src/ 下搜不到 panic(、unwrap()、裸 except:。违反后果:一次输入错误让整进程退出,同机其他请求一起失败。fmt.Errorf 的每一处都带 %w;Python 里重新抛出时带 from err。违反后果:顶层没法用 errors.Is 判断,只能比字符串,接口状态码跟着错。if err != nil 却新增业务逻辑的,评审时一律挡回。违反后果:与质量门禁里「不许改判据」是同一类问题,只是发生在代码里而不是配置里。工具查不到的边界:第一条里的「搜不到 panic(」在 main 包与测试文件里放宽。测试里用 t.Fatal 或 panic 提前结束都是正常的。