本层判断标准

在一个没跑过排版命令的仓库里敲 make fmt,git status 干不干净?

答「不干净」,说明排版还没交给工具,评审仍要分心在空格和引号上。要补的是配置和一个入口命令,不是让每人自己注意。

排版与检查各归谁

把两件事分开:排版命令会改文件,检查命令只报告、不改文件。提交和持续集成只跑检查,退出码必须是 0。混用两者,就会「本地明明格式化过、线上还是红的」。每种语言只留一个排版工具,命令写进仓库;个人编辑器里的格式化设置不算团队约定。

语言排版,会改文件检查,不改文件工具不改、要自己写对
Pythonruff format .ruff format --check . 与 ruff check .删掉没用的 import、注释内容
Gogolangci-lint fmtgolangci-lint run名字、注释、错误处理
TypeScriptnpm run formatnpm run format:check 与 npm run lint,类型另跑 npx tsc -b未使用的变量
Javamvn spotless:applymvn spotless:check常量全大写、方法名小写开头
C#dotnet formatdotnet format --verify-no-changes类型、方法、常量都用 PascalCase
Rustcargo fmtcargo fmt --check 与 cargo clippy -- -D warnings警告要改掉,不能留着

名字、注释、错误处理和函数复杂度,排版工具都不改。这四样分别去命名、注释、语言特性、复杂度处理。类型标注是相邻但不同的第四项:排版工具不管它,静态检查多数也不管,去类型检查。

写死的六个数字

下面六个值写进配置,之后的每次改动不用再讨论一遍。

Go 缩进
1 个 Tab
gofmt 写死。把编辑器设成 4 个空格,保存时也会被改回 Tab,不必争。
TypeScript / Java 缩进
2 个空格
写进 .editorconfig 的 indent_size。
Python / C# / Rust 缩进
4 个空格
同一份 .editorconfig 按扩展名分组给出。
文件末尾换行
1 个
0 个或多个都会被 make check 判失败。
行尾空格
0 个
由 trim_trailing_whitespace 在保存时清掉,不留给评审发现。
提交前的检查退出码
0
非零就停在本地,不推送。改配置让检查变绿是错的,见质量门禁。

工具查不到的边界:行宽不在这六个数字里,它不是硬判据——gofmt 根本不折长行,Python 的 E501 在本仓库被忽略,因为格式化器管不到注释。把行宽设成阻断项,只会逼出一批为凑长度而写的换行。

从手写到提交的四步

下面用 Python 走一遍,其余语言的命令回第一节的总表找。排版不替人修检查报出来的问题。

第一步:手写(src/notes.py)

手写的是这样:单引号、== 两侧没空格,还有一个用不上的 import。

import json

def visible(notes, scope_id):
    return [n for n in notes if n['scope_id']==scope_id]

第二步:ruff format . 之后

跑完排版:引号变双引号,== 两侧补空格,函数前补到两个空行。json 还在——排版工具不管它有没有被用到。

import json

def visible(notes, scope_id):
    return [n for n in notes if n["scope_id"] == scope_id]

第三步:ruff check . 拦住

输出格式与 Makefile 里的 --output-format=concise 一致:文件、行、列、规则号、修法,照着它改。

$ ruff check --output-format=concise .
src/notes.py:1:8: F401 [*] `json` imported but unused
Found 1 error.
[*] 1 fixable with the `--fix` option.

第四步:改完再提交

删掉那一行 import。要保留它,就让代码里真的用到——不许在同一行加 # noqa: F401 了事。scope_id 来自会话这一点从函数体里看不出来,按注释那页的写法补一句文档字符串。

def visible(notes, scope_id):
    """返回属于这个所属范围的笔记。

    scope_id 来自会话,不从请求参数读取。
    """
    return [n for n in notes if n["scope_id"] == scope_id]

工具查不到的边界:这套四步假定仓库里已经有 pyproject.toml 和入口命令。还没有这两个文件时,先按下一节补上,不要先手工调格式——手工调出来的结果和 ruff format 不一致,下一次保存又会变回去。

评审里不再讨论什么,改看什么

排版交给工具之后,评审在这一项上少花时间,但它换来一个新风险:大段重排会盖住真正的功能改动。

评审时看这些 评审时看这三样:用 git diff -w main...HEAD 忽略空白后还剩多少改动;改动差异里有没有新出现的 # noqa、eslint-disable、//nolint;有没有人改了 .editorconfig、pyproject.toml 的 [tool.ruff] 段或排版工具本身。
不再讨论这些 空格数量、花括号位置、引号用哪种、import 排序。这四类已经进 make check,红了就重跑 make fmt,不写进评审意见。
现象说明什么怎么处理
git diff --numstat 里某文件改动行数 > 该文件总行数的 60%,且逻辑没变排版和功能混在同一次提交里要求拆成两次提交:先只跑 make fmt 提交一次,再提交功能
改动差异里新出现 # noqa、eslint-disable、//nolint检查是被绕开而不是被修好按质量门禁的「不许改判据」处理,挡回
新增文件缩进和同目录其他文件不一致.editorconfig 没覆盖这个扩展名,或生成时没读配置补 .editorconfig 的分组,重跑 make fmt
同一份 pyproject.toml 在两次改动里 line-length 不同有人为了让自己那份文件通过而改了阈值阈值改动走单独一次合并请求,不夹带功能改动

工具查不到的边界:「改动超过 60% 就要求拆分」这条对新建文件不适用——新文件的改动比例必然是 100%。这条判据只对已有文件生效。

助手交出来的样子

$ git diff --stat origin/main
 app/api/notes.py | 247 ++++++++++++++++++++++++++--------------------
 1 file changed, 247 insertions(+), 121 deletions(-)

$ git diff -w --stat origin/main
 app/api/notes.py |   9 +++++----
 1 file changed, 9 insertions(+), 5 deletions(-)

认它的办法:加 -w 再看一遍,剩下的才是要审的。助手重写一个文件时,会把引号、空行、import 顺序一并重排:格式全部正确、检查全部通过,真正要改的三行却被埋在 240 行里。这不是格式问题,是评审注意力被挪走了——花在空格上的每一分钟,都是从那三行上挪走的。

必须死守的五条

换行符号统一为 LF判据:跑 git ls-files --eol,输出里不出现 crlf。违反后果:Windows 组员保存一次会把整个文件标成改动,要找的那几行埋在里面。
字符集统一为 UTF-8,不带 BOM判据:查文件头 3 字节,不是 EF BB BF。违反后果:脚本与配置文件里的中文变乱码,而且只在部分工具里出错。
提交与持续集成只跑检查命令,不跑排版命令判据:看工作流里有没有 ruff format . 或 prettier --write——有就是错的,只能是 --check。违反后果:服务器改了文件却没提交,本地和线上不一致。
不把一种语言的写法抄到另一种判据:Go 与 C# 的花括号位置、Java 与 Python 的常量写法。违反后果:make check 会报错,但它报的是「格式」不是「抄错了」,容易朝错的方向改。
新增目录或新增语言时同步更新 Makefile判据:全新克隆一份仓库,make fmt 与 make check 都能一次跑通。违反后果:仓库里留下一条永远失败的命令,其他人会开始习惯「这条红的是正常的」。

工具查不到的边界:第三条只管自动检查。在命令或提交钩子里放一条 make fmt 给作者手动跑是允许的——它由人触发,改完人能看见。

一份能抄的配置

四个文件加一份编辑器设置:换行与缩进集中在 .editorconfig,语言规则集中在 pyproject.toml,动作集中在 Makefile,触发在持续集成。其余语言的配置文件放哪,见本节最后一张表。

.editorconfig(根目录,管换行与缩进)

花括号、引号这类交给各语言自己的排版工具,这里只管跨语言共有的那几项。C# 的左花括号另起一行也写在这里,由 dotnet format 执行。

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.go]
indent_style = tab

[*.{js,jsx,ts,tsx,json,css,yml,yaml,md}]
indent_style = space
indent_size = 2

[*.py]
indent_style = space
indent_size = 4

[*.java]
indent_style = space
indent_size = 2

[*.cs]
indent_style = space
indent_size = 4
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
dotnet_sort_system_directives_first = true

[*.rs]
indent_style = space
indent_size = 4

pyproject.toml(Python 的排版与检查规则)

这份 pyproject.toml 是全仓库唯一的一份,与质量门禁指向的是同一份。不要在两处各维护一半,否则改了一处另一处会静默失效。命名(N)与复杂度语句数(PLR)从各自页面用 extend-select 追加。

[tool.ruff]
line-length = 100
indent-width = 4
target-version = "py312"
src = ["scripts", "src"]

[tool.ruff.lint]
select = ["E", "F", "I", "B", "S", "C901"]

[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

.vscode/settings.json(保存时动作)

扩展 id 以各自扩展页为准。保存时格式化只在装了对应扩展时生效,它不跑 ruff check——F401 只有 make check 拦得住。

{
  "editor.formatOnSave": true,
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true,
  "[go]": { "editor.defaultFormatter": "golang.go" },
  "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" },
  "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }
}

Makefile(本地与持续集成共用一条入口)

某种语言的目录还不存在时,删掉对应行,别留下一条永远失败的命令。make check 的聚合入口在质量门禁里定义,全仓库只有那一处;这一页只加 fmt-check 一个子目标,再把它挂进 check 的依赖里。

.PHONY: fmt fmt-check

fmt:
	ruff format .
	golangci-lint fmt
	npm --prefix frontend run format

# 挂进 check:check: fmt-check lint type secret complexity test
fmt-check:
	ruff format --check .
	npm --prefix frontend run format:check

.github/workflows/check.yml(触发)

工作流只把 make check 搬到服务器上,不在 YAML 里另抄一份规则。

name: check
on:
  pull_request:
  push:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements-dev.txt
      - run: make check
语言配置文件放哪关键项
Go根目录 .golangci.yml在 formatters 里开 gofmt 与 goimports;goimports.local-prefixes 填 go.mod 第一行的 module
TypeScriptfrontend/.prettierrc.json 与 frontend/eslint.config.mjsPrettier 管空格与引号,在 eslint 最后一项引入 eslint-config-prettier 关掉重复规则
Javajava/pom.xml 里的 spotless 插件把风格选成 GOOGLE;Maven 用 JRE 17 或更高版本运行
C#复用根目录 .editorconfig 的 [*.cs]规则来自 .editorconfig,不在 App.csproj 里另写一份
Rustrust/rustfmt.tomlmax_width = 100、newline_style = "Unix",与 .editorconfig 的 LF 一致

工具查不到的边界:仓库只用一种语言时,把 .editorconfig 里其他语言的分组和 Makefile 里对应的行都删掉。留着不会报错,但下一个读配置的人会以为仓库里还有那些语言。