Just:用统一工程语义组织任务自动化

一个多语言项目的麻烦,往往不在于工具多,而在于入口撕裂:Python 用 pytest 和 ruff,Rust 用 cargo test 和 cargo clippy,前端用 npm run。每个工具在自己的生态里都合理;但对第一次进入项目的人、CI 和 Agent 而言,要先记住三套动词、三套参数和三处文档,才能完成同一类动作。

just 的价值不在于取代 Cargo、uv、npm 或 Make,而在于为项目提供一层稳定的工程语义接口:

just test  → 调用 pytest / cargo test / npm test
just lint  → 调用 ruff / cargo clippy / eslint
just build → 调用各模块真正的构建过程

底层工具仍保留各自最擅长的工作;项目对人呈现的却是一套一致的动作语言。这降低的不是打字数量,而是协作时切换心智模型的成本。

一、把项目当作一个面向协作者的 CLI

一个健康的项目应让人不用先读完 README,便知道下一步能做什么:

just
just --list

这里的 justfile 是项目的“操作菜单”,不是另一份解释性文档。它把可以稳定执行的动作固化成 recipe;README 解释背景、前置条件和架构决策;脚本文件承载复杂实现;语言工具链完成语言相关的工作。

位置应承担的职责
README / 文档为什么这样做、环境前提、概念与限制
justfile项目承诺的稳定操作入口与编排关系
scripts/复杂、可测试、可单独维护的实现逻辑
Cargo / uv / npm 等编译、依赖解析、测试与语言生态专有能力

这层边界很重要。把复杂业务逻辑塞进 recipe,会得到难测试的 shell 片段;反过来,若每次都要求协作者直接记住底层命令,justfile 又失去存在意义。

最理想的状态是同一动作在本地、CI 与 Agent 中复用同一入口:开发者运行 just ci,GitHub Actions 运行 just ci,Agent 也运行 just ci。这样失败时,大家讨论的是同一套操作,而不是三份相近但不相同的命令。

二、两层命名体系:稳定入口与具体实现

recipe 名称是项目对外的接口。它既要让新成员一眼理解,也要让 CI、文档和团队习惯能够长期依赖。一个实用的办法是把命名分为两层。

通用层:跨项目可迁移的动作

通用层使用已被广泛理解的动词,表达“任何工程项目都可能有”的操作。不是每个项目都必须拥有全部命令,但一旦提供,就应兑现读者的通常预期。

recipe语义
bootstrap / setup首次准备开发环境
dev / run启动开发环境或主程序
build生成可交付产物
test运行完整测试集
lint静态检查,包括约定的类型或质量检查
fmt格式化代码或文本
clean清理可再生的构建产物
ciCI 所执行的聚合验证入口
doctor检查本机是否满足项目要求

不要为了“统一”而虚构动作:没有构建产物的项目不需要 build;deploy 也不应在没有明确部署语义时出现。更不应轻易创造 check-all、run-app 这类与既有动词重叠的名字。少而可信的菜单,比长而含糊的菜单有用。

特化层:用具体限定词表达边界

当通用动词不足以表达范围、平台、模块或环境时,使用 <动词>-<限定词>:

test-unit              lint-python
test-integration       build-web
test-windows           deploy-staging

动词在前,限定词在后。这样同类命令在 just --list 中自然聚在一起,也让人先知道“做什么”,再知道“对什么做”。限定词应描述稳定且可辨认的边界,例如测试范围、部署环境、目标平台或项目模块;避免 test-fast、build-2 这类需要额外猜测的名称。

特化 recipe 一旦进入 CI、团队文档或其他项目的调用链,就已经成为接口。优先加新命令、保留兼容入口,再逐步迁移;不要把随意重命名当成无成本整理。

三、参数还是命名:给变化正确的形状

命名不是所有变化的容器。判断标准不是“名字能不能写出来”,而是这个变化是不是一个封闭的、少量的、逻辑不同的集合。

变化的性质应选择示例
固定、少量,且实现逻辑不同特化 recipetest-unit、test-integration
平台或环境等需要显式审阅的少数目标特化 recipedeploy-staging、deploy-prod
任意值、会持续增长或由调用者决定参数serve port="8000"
一个动作的可选配置参数train model="baseline"

例如,端口号不是一个应该穷举的接口:

serve port="8000":
    python -m http.server {{port}}

但单元测试与集成测试往往运行不同命令、耗时和资源也不同,使用 test-unit 与 test-integration 更清楚。若名字已经超过三段,先停下来检查:这通常意味着参数、模块拆分或独立配置会比继续堆限定词更好。

四、让命令菜单可发现、可组合,也可安全执行

justfile 的首要体验是发现,而不是炫技。默认 recipe 应把使用者带到命令列表:

default:
    @just --list --unsorted

紧邻 recipe 的文档注释会出现在列表中;[doc('说明')] 可在需要时显式指定说明。用 [group('通用')]、[group('特化')] 分组,可以让菜单保留层次;仅供其他 recipe 调用的辅助步骤则使用 [private] 隐藏,避免把内部细节伪装成公共接口。

依赖表达流程,不替代脚本

recipe 依赖适合表达明确的前置关系和聚合关系:

ci: lint test build

这说明 CI 由哪些稳定验证组成。若某一步包含条件分支、错误恢复、复杂输入校验或可独立测试的业务逻辑,把它放入 scripts/,让 recipe 只负责调用。justfile 应像目录,而不是把整个程序藏进 shell。

危险动作必须诚实

部署、删除数据、迁移或覆盖远端环境,不能因为命令简短就显得无害。至少同时做到三件事:名字说清目标、输出说明后果、执行前要求确认。

[confirm('即将部署到生产环境,确认继续?')]
deploy-prod: build
    ./scripts/deploy.sh production

clean 也应只删除可再生的产物;若范围可能包含用户数据或不可逆操作,应另起一个更具体、带确认的 recipe。命令菜单是信任契约,不是把危险藏在简写后的地方。

规模增长时再拆模块

单个 justfile 足以服务多数项目。当 Web、后端、数据任务各自形成完整子域时,才考虑 mod 拆分模块,并在根 justfile 保留通用入口。模块是组织边界,不是提早制造层级的理由;根菜单仍应回答“这个项目最常做什么”。

五、参考实现:多语言项目的一张操作菜单

下面的例子把“语义接口”和“底层实现”分开。它不是要复制到每个仓库的模板,而是展示每一层应承担什么责任。

# 直接运行 just 时显示项目菜单
default:
    @just --list --unsorted
 
[group('通用')]
# 准备开发环境
bootstrap:
    python -m pip install -r requirements.txt
    cargo fetch --manifest-path infer/Cargo.toml
    cd web && npm ci
 
[group('通用')]
# 运行完整验证
test: test-python test-rust test-web
 
[group('通用')]
# 静态检查
lint: lint-python lint-rust lint-web
 
[group('通用')]
# 构建全部交付物
build: build-python build-rust build-web
 
[group('通用')]
# 与 CI 共用的验证入口
ci: lint test build
 
[group('特化')]
test-python:
    pytest tests/
 
[group('特化')]
test-rust:
    cargo test --manifest-path infer/Cargo.toml
 
[group('特化')]
[working-directory('web')]
test-web:
    npm test
 
[group('特化')]
lint-python:
    ruff check .
 
[group('特化')]
lint-rust:
    cargo clippy --manifest-path infer/Cargo.toml -- -D warnings
 
[group('特化')]
[working-directory('web')]
lint-web:
    npm run lint
 
[group('特化')]
build-python:
    python -m build
 
[group('特化')]
build-rust:
    cargo build --release --manifest-path infer/Cargo.toml
 
[group('特化')]
[working-directory('web')]
build-web:
    npm run build
 
[private]
_check-production-access:
    ./scripts/check-production-access.sh
 
[group('特化')]
[confirm('即将部署到生产环境,确认继续?')]
deploy-prod: build _check-production-access
    ./scripts/deploy.sh production

这里的 test、lint、build 与 ci 是可迁移的公共语义;语言名和模块名留在特化层。[working-directory] 把目录切换写成可见的声明,避免依赖调用者当前所处的位置。部署的访问校验属于内部步骤,因此不占用公开菜单。

六、Just 快速参考

需求写法说明
定义 recipetest:recipe 名后的冒号开始定义
声明前置步骤ci: lint test build先执行列出的依赖 recipe
默认参数serve port="8000":调用时可覆盖默认值
引用参数{{port}}在命令中插入参数值
只显示输出@echo "完成"@ 不回显该命令本身
隐藏内部入口[private]不作为普通公开 recipe 展示
菜单分组[group('通用')]组织 --list 输出
执行前确认[confirm('提示')]用于部署等危险动作
锁定执行目录[working-directory('web')]避免相对路径语义漂移
预览将执行什么just --dry-run test不实际执行命令
检查格式just --fmt --check适合放入 CI
格式化 justfilejust --fmt修改 justfile 的排版

更多语法不等于更好的入口。只有当能力能让接口更清晰、更安全或更易维护时,才引入它。完整定义以 Just 官方手册 为准。

七、把约定落实到团队工作流

统一入口只有被持续使用才有价值。可从以下几项开始:

  • CI 只调用 just ci,不在工作流 YAML 中重新拼一套 lint、test、build 命令。
  • 本地提交前调用与 CI 对应的 recipe;Git hook 可以调用 just lint,但不应替代开发者理解失败原因。
  • 新增 recipe 时先问:已有通用动词能否覆盖?变体是参数还是特化命令?是否需要确认?
  • 在代码审查中把 recipe 名称、危险边界和本地/CI 一致性视为接口审查,而不只是脚本细节。
  • Agent 执行修复时优先调用项目公开 recipe;清晰、参数明确、输出稳定的 justfile 能减少它对 README 和临时命令的猜测。

这套约定并不承诺消除语言生态的差异。它承诺的是:无论底层使用 Python、Rust 还是 JavaScript,协作者进入项目后可以先用一套熟悉的动词开始工作;真正需要理解实现时,再沿着这张菜单进入相应工具链。

关联