首页
/ gstack /ship 测试与质量闸门全解析:测试框架引导、失败归属分类与 Eval 门禁(Steps 4-6)

gstack /ship 测试与质量闸门全解析:测试框架引导、失败归属分类与 Eval 门禁(Steps 4-6)

2026-09-06 18:25:36作者:蔡怀权

在 gstack 的 /ship 全自动发布工作流中,真正拦住“带着坏代码上主干”这一事故的不是靠自觉,而是一套被编排进决策树里的质量闸门:测试框架引导(Test Framework Bootstrap)、测试执行与失败归属分类(Run Tests + Failure Ownership Triage)、以及有条件的 Eval 套件门禁。本篇指南将以 ship/sections/tests.md 为骨架,结合 gstack 仓库中对应的模板生成器、gstack-evidence/gstack-detach 实现与回归测试,逐行拆解 /ship 在 Step 4-6 究竟做了什么、为什么这样做、以及你自己接入时如何复用这套规则。

背景:tests.md 在 /ship 工作流中的位置

/ship 被设计成一棵“决策树骨架”。其主入口 ship/SKILL.md 用一个 Section Index 表格把 1500+ 行工作流按“何时发生”切成了若干按需加载的分节文档,而不是把所有细节灌进每次调用的上下文里:

场景 读取的分节
运行测试套件,以及(若 prompt 相关文件有变更)Eval 套件(Steps 4-6) ship/sections/tests.md
审计 diff 的测试覆盖率(Step 7) sections/test-coverage.md
审计计划完成度与范围漂移(Step 8) sections/plan-completion.md
落地前评审与专家调度(Step 9) sections/review-army.md

分节注册表位于 ship/sections/manifest.json,其中 tests 条目的定位是“Test bootstrap, run, triage + eval suites”,触发器为“running the test suites and (if prompt files changed) the eval suites (Steps 4-6)”。值得注意:这个 manifest 是一个 PASSIVE registry——它只存放 ID、文件路径、人类标题与触发文本,由谁在何时读取由 SKILL.md 的决策树散文决定,manifest 本身不含机器可执行谓词(见 manifest.json 的 note 字段)。

主入口在进入 Step 4-6 前有一段强制 STOP 指令:

STOP. Before running the test suites and (if prompt files changed) the eval suites (Steps 4-6), Read .../ship/sections/tests.md and execute it in full. Do not work from memory — that section is the source of truth for this step.

也就是说,本指南拆解的这个文件,就是 /ship 执行“跑测试前先怎么搭测试框架、跑挂后怎么判定责任、prompt 改了要不要跑 Eval”的事实标准(source of truth)

另一个值得先点明的工程细节:tests.md 顶部标注“AUTO-GENERATED from tests.md.tmpl”,它由 ship/sections/tests.md.tmpl 通过 bun run gen:skill-docs 生成。模板中嵌入了两个占位符 {{TEST_BOOTSTRAP}}{{TEST_FAILURE_TRIAGE}},渲染时由仓库中的 TypeScript 生成器填充:

因此你看到的分节正文其实是被“程序化生成”的规则——后文的源码级佐证全部围绕这条生成链路展开。


Step 4:测试框架引导(Test Framework Bootstrap)

先读项目 CLAUDE.md / TESTING.md:文档优先于检测

引导流程的第一条铁律是:如果项目的 CLAUDE.md(或 TESTING.md)已经写明了测试命令,那么项目自己已经告诉你了——不需要检测、不需要引导,直接跳过后续全部 bootstrap,用那条命令进入 Step 5。

gstack 自身就是范例:本仓库根目录 CLAUDE.md## Testing 一节明确写了 bun run test(提交前跑,全量约 7000 个测试)与 bun run test:evals(发布前跑,付费、按 diff 决定范围),并警告永远不要裸敲 bun test——它会遍历整个仓库、加载付费 eval 文件且缺少 strict-output 分类器。若在 gstack 上执行 /ship,该规则意味着直接命中“已文档化”分支。

采集环境标记(markers):证据,而非命令

若项目未文档化测试命令,才进入 marker 采集阶段。文档反复强调一个关键心智模型:

Every marker below is EVIDENCE for the question you ask — never a command to run blind.

标记只说明“你处在哪个生态、该向用户推荐哪个候选命令”,绝不代表那条命令一定可用。禁止把探测性执行当作“检查”——在一个从未装过该 runner 的项目上盲跑只会制造响亮而无效的失败;而在已有可用框架的项目上再装第二套框架则更糟。完整标记脚本如下:

setopt +o nomatch 2>/dev/null || true  # zsh compat
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
[ -f composer.json ] && echo "RUNTIME:php"
[ -f mix.exs ] && echo "RUNTIME:elixir"
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Detect sub-frameworks
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
# Existing test path — config files, declared scripts, AND test FILES.
# A project with real tests and no config file is the common miss.
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
# Rust keeps unit tests inside src/, so file names alone miss them
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
# Check opt-out marker
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"

这段脚本的中段逻辑尤其关键:检测“是否已有测试”时,配置文件和测试文件都要看ls jest.config.* ... 只负责配置类 marker,而 git ls-files | grep -cE '...' 那条则对已跟踪的测试文件做计数(TESTFILES: 前缀),覆盖了 .test.ts/.spec.ts/_test.go/tests.py/Test.java 等常见命名。最后两条还处理了两种“文件名检测不到”的常见漏判:

  • Rust:单元测试写在 src/ 内部,文件名根本不含 test,所以要 git grep -lF '#[test]' -- src
  • 退出标记:若存在 .gstack/no-test-bootstrap,说明用户此前已明确拒绝过测试引导,输出 BOOTSTRAP_DECLINED

Marker → 候选命令映射表

拿到标记后,只做一件事:把标记映射为将要向用户提供的候选命令(绝不基于猜测直接执行):

Marker 生态 建议提供的候选命令
manage.py Django python manage.py test(deps 含 pytest-django 时用 pytest
pytest.ini / tox.ini / pyproject 中 pytest / test_*.py Python pytest
go.mod(+ 任意 *_test.go Go go test ./...
Cargo.toml Rust cargo test
pom.xml JVM (Maven) mvn test
build.gradle / build.gradle.kts JVM (Gradle) ./gradlew test
Gemfile / Rakefile / .rspec Ruby bundle exec rspecbin/rails testrake test
mix.exs Elixir mix test
composer.json PHP composer test./vendor/bin/phpunit
test 脚本的 package.json Node 该脚本,用 lockfile 指明的包管理器运行
test: target 的 Makefile 任意 make test

分支一:已存在测试证据 → 不引导

只要出现任意“已存在测试”的证据(配置文件、声明的 test 脚本或 make target、非零的 TESTFILES: 计数、或 TESTS:rust in-source),项目就是“有测试的”,禁止 bootstrap。流程变为:

  1. 打印 Existing tests detected: {证据}
  2. 用与 Step 5 相同的方式取得命令——CLAUDE.md/TESTING.md 有记录就用记录的;没有则用 AskUserQuestion 提供上表候选加 “Other”。
  3. 把答案持久化到 CLAUDE.md 的 ## Testing 一节,确保这个问题永远不会再被问第二次
  4. 当生态自带 runner(Django、Go、Rust、Elixir、Maven/Gradle)时,该 runner 就是唯一候选——绝不在一套可用框架旁再装第二套。
  5. 抽样阅读 2-3 个现有测试文件,学习命名、导入、断言风格与 setup 模式,把约定以 prose context 存起来供 Step 7 使用,然后跳过引导的其余部分

文档特别点破了一个常见的“误判陷阱”:没有配置文件、没有 tests/ 目录,绝不等于“没有测试”。Django 的测试放在 <app>/tests.py,Go 放在源码旁的 *_test.go,Rust 的 #[test] 就写在 src/ 里。一个没有 pytest.ini 却全绿的 python manage.py test 项目,是有测试的项目,不是引导候选。

分支二:BOOTSTRAP_DECLINED → 尊重退出

如果出现 BOOTSTRAP_DECLINED,打印 Test bootstrap previously declined — skipping.,跳过引导其余部分。这个 .gstack/no-test-bootstrap 文件同时被 /qa 等其它 skill 复用(见 test/skill-validation.test.ts 中对多 skill 内容一致性的校验),它是 gstack 跨 skill 共享的“用户已决策”持久化标记。

分支三:无生态标记 → 询问运行时

若没有任何生态标记命中,用 AskUserQuestion 询问:

"I couldn't detect your project's language. What runtime are you using?"

候选:A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests。

  • 若所需运行时不在列表里,提供 "Other",并把运行时和测试命令作为自由文本记录。
  • 若用户选 H,写入 .gstack/no-test-bootstrap 并无测试地继续。

分支四:命中生态但无任何测试证据 → 开始引导(B2-B8)

B2. 调研最佳实践

先用 WebSearch 检索该运行时的当前最佳实践,检索词形如:

  • "[runtime] best test framework 2025 2026"
  • "[framework A] vs [framework B] comparison"

若 WebSearch 不可用,则退回内置知识表:

Runtime 首选方案 备选方案
Ruby/Rails minitest + fixtures + capybara rspec + factory_bot + shoulda-matchers
Node.js vitest + @testing-library jest + @testing-library
Next.js vitest + @testing-library/react + playwright jest + cypress
Python pytest + pytest-cov unittest
Django pytest + pytest-django Django 内置 manage.py test (unittest)
Go stdlib testing + testify 仅 stdlib
JVM (Maven/Gradle) JUnit 5 + AssertJ 仅 JUnit 5
Rust cargo test(内置)+ mockall
PHP phpunit + mockery pest
Elixir ExUnit(内置)+ ex_machina

B3. 框架选型(AskUserQuestion)

把选型权交给用户,而非由 Agent 独断。消息结构固定为:

"I detected this is a [Runtime/Framework] project with no test framework. I researched current best practices. Here are the options: A) [Primary] — [rationale]. Includes: [packages]. Supports: unit, integration, smoke, e2e B) [Alternative] — [rationale]. Includes: [packages] C) Skip — don't set up testing right now RECOMMENDATION: Choose A because [reason based on project context]"

  • 选 C → 写入 .gstack/no-test-bootstrap,并告知“日后想改主意,删除该文件重跑即可”,然后无测试地继续。
  • 多运行时(monorepo)→ 询问先为哪个运行时搭建,并提供“顺序完成两者”的选项。

B4. 安装与配置

  1. 安装所选包(npm/bun/gem/pip 等);
  2. 创建最小配置文件;
  3. 创建目录结构(test/、spec/ 等);
  4. 写一个匹配项目代码形态的示例测试来验证安装。

若安装失败 → 只调试一次;仍失败 → 用 git checkout -- package.json package-lock.json(或对应该运行时的等价命令)回滚,警告用户并无测试地继续。

B4.5. 第一批真实测试

目标是为已有代码生成 3-5 个真实测试:

  1. 找最近变更的文件git log --since=30.days --name-only --format="" | sort | uniq -c | sort -rn | head -10
  2. 按风险排序:错误处理器 > 含条件分支的业务逻辑 > API 端点 > 纯函数
  3. 每个文件:写一个验证真实行为、断言有意义的测试。严禁 expect(x).toBeDefined() 这种空断言——要测代码“做了什么”,而不是“存在”。
  4. 逐个运行。通过 → 保留;失败 → 修一次;仍失败 → 静默删除。
  5. 至少产出 1 个、封顶 5 个。

另有一条安全红线:测试文件里绝不导入 secrets、API keys 或凭据,改用环境变量或测试 fixtures。

B5. 验证

# Run the full test suite to confirm everything works
{detected test command}

失败 → 调试一次;仍失败 → 回滚全部 bootstrap 变更并警告用户。

B5.5. CI/CD 流水线

# Check CI provider
ls -d .github/ 2>/dev/null && echo "CI:github"
ls .gitlab-ci.yml .circleci/ bitrise.yml 2>/dev/null
  • 若存在 .github/(或检测不到 CI——默认按 GitHub Actions 处理):创建 .github/workflows/test.yml,包含 runs-on: ubuntu-latest、对应运行时(setup-node/setup-ruby/setup-python 等)、B5 中已验证的同一条测试命令、以及 push + pull_request 触发器。
  • 若检测到非 GitHub CI → 跳过 CI 生成,注明“Detected {provider} — CI pipeline generation supports GitHub Actions only. Add test step to your existing pipeline manually.”

B6. 编写 TESTING.md

先检查:若 TESTING.md 已存在 → 读它并更新/追加,绝不覆盖已有内容。写入内容包含:

  • 理念(vibe coding 的立场声明):“100% test coverage is the key to great vibe coding. Tests let you move fast, trust your instincts, and ship with confidence — without them, vibe coding is just yolo coding. With tests, it's a superpower.”
  • 框架名与版本;
  • 运行方式(B5 验证过的命令);
  • 测试分层:Unit(是什么/放哪/何时跑)、Integration、Smoke、E2E;
  • 约定:文件命名、断言风格、setup/teardown 模式。

B7. 更新 CLAUDE.md

先检查:若 CLAUDE.md 已有 ## Testing 一节 → 跳过,不要重复。否则追加 ## Testing 节,含运行命令与测试目录、TESTING.md 引用,以及一组测试期望:

  • 100% 覆盖率是目标——测试让 vibe coding 安全;
  • 写新函数时配套写测试;
  • 修 bug 时写回归测试;
  • 加错误处理时写触发该错误的测试;
  • 加条件分支(if/else、switch)时为两个分支都写测试;
  • 永远不要提交会让现有测试失败改动的代码。

B8. 提交

git status --porcelain

仅在确有变更时提交,stage 全部引导产物(配置文件、测试目录、TESTING.md、CLAUDE.md、生成的 .github/workflows/test.yml),提交信息形如: git commit -m "chore: bootstrap test framework ({framework name})"

工程化观察:上述 B2-B8 全套散文与表格并不重复散落在多份 SKILL.md 里,而是集中在 scripts/resolvers/testing.tsgenerateTestBootstrap() 一个函数中。/ship/qa/design-review 等技能最终渲染出的内容都源自这份单一实现,从源头避免了多份文档漂移。


Step 5:在合并后的代码上运行测试

/ship 是 pre-merge 门禁,所以 Step 5 的测试跑在已合并 base 分支之后的状态上(base 分支在 Step 3 被 git fetch + git merge --no-edit 拉入特性分支)。

第一个硬性警告:不要裸跑 Rails 迁移

Do NOT run RAILS_ENV=test bin/rails db:migratebin/test-lane already calls db:test:prepare internally, which loads the schema into the correct lane database.

在没有 INSTANCE 的情况下裸跑测试迁移会打到孤儿数据库并损坏 structure.sql。所有 schema 装载必须经由 bin/test-lane 内部完成的 db:test:prepare

并行跑两套测试,全部包进证据账本

以“Rails 测试车道 + Vitest 车道”为例,两条套件并行执行,每条命令都用 gstack-evidence run 包裹(该二进制的仓库源码位于 bin/gstack-evidence):

~/.claude/skills/gstack/bin/gstack-evidence run --label tests -- 'bin/test-lane 2>&1' &
~/.claude/skills/gstack/bin/gstack-evidence run --label vitest -- 'npm run test 2>&1' &
wait

这个 wrapper 具备两个核心性质(均有测试锁定):

  1. 透明:实时透流传出输出,退出码原样透传;
  2. 记账:把 {command, exit, working-tree fingerprint, log path} 记录到 ~/.gstack/projects/<slug>/<branch>-evidence.jsonl——Step 16 在内容未变化时直接引用这条记录,而不是重新跑一遍

从源码看,这套记账行为非常严格:test/evidence.test.tsgstack-evidence 覆盖了以下场景——记录完整证据并透传退出码 0、透传失败退出码并记录、spawn 失败(ENOENT)记录并透传 127、账本写入失败绝不破坏被包裹命令(append-failure injection)、账本与日志文件权限为 0600、两次快速运行得到互不相同的按次日志文件、日志超过 2MB 截断并打标记且不影响退出码、超过 30 天的日志被机会性清理、以及作为后台任务运行(对应 Step 5 的 & + wait 形态)。

两条命令完成后,检查形如 gstack-evidence: recorded label=... exit=... log=... 的摘要行——每行携带该车道的退出码与独立日志路径(并发 ship 之间不会共用 /tmp 而互相污染)。失败细节去读对应日志文件。

测试失败:不要立刻停下——先做归属分类

两条套件出现失败时,/ship 的策略不是“一票否决直接 STOP”,而是进入下一节的 Test Failure Ownership Triage。唯一例外见 Step 6:Eval 失败才是无条件 STOP。


Test Failure Ownership Triage(失败归属分类)

该子流程由 scripts/resolvers/preamble/generate-test-failure-triage.ts 生成,核心思想一句话:失败分两种——你的失败和别人的失败,处理方式完全不同。

T1:逐条给失败定性

对每个失败测试:

git diff origin/<base>...HEAD --name-only
  • In-branch(分支内):失败测试文件本身在本分支被改过,或测试输出指向了本分支改过的代码,或你能把失败追溯到分支 diff 中的某个变更。
  • Likely pre-existing(疑似既有):测试文件与被测代码都不在本分支被改过,且失败与你能识别的任何分支变更都无关。
  • 两可时默认归为 in-branch。宁可拦住开发者,也不能让坏测试上线;只有足够确信时才判为 pre-existing。

文档明确承认这是启发式判断(“You do not have a programmatic dependency graph”),需要结合 diff 与测试输出综合判断。

T2:In-branch 失败 = STOP

STOP. 这是你自己的失败,展示出来,不得继续。开发者在发布前必须修好自己弄坏的测试。

T3:Pre-existing 失败 → 依据 REPO_MODE 询问

先查 preamble 输出的 REPO_MODE

solo 模式(你是唯一的维护者):

These test failures appear pre-existing (not caused by your branch changes): [list each failure with file:line and brief error description] Since this is a solo repo, you're the only one who will fix these.

RECOMMENDATION: Choose A — fix now while the context is fresh. Completeness: 9/10. A) Investigate and fix now (human: ~2-4h / CC: ~15min) — Completeness: 10/10 B) Add as P0 TODO — fix after this branch lands — Completeness: 7/10 C) Skip — I know about this, ship anyway — Completeness: 3/10

collaborativeunknown 模式(可能是别人的责任,且未必由你来修):

These test failures appear pre-existing (not caused by your branch changes): [list each failure with file:line and brief error description] This is a collaborative repo — these may be someone else's responsibility.

RECOMMENDATION: Choose B — assign it to whoever broke it so the right person fixes it. Completeness: 9/10. A) Investigate and fix now anyway — Completeness: 10/10 B) Blame + assign GitHub issue to the author — Completeness: 9/10 C) Add as P0 TODO — Completeness: 7/10 D) Skip — ship anyway — Completeness: 3/10

注意两个版本的差异不是措辞,而是责任模型:solo 版把“现在就修”列为推荐(上下文还热着),collaborative 版把“指派给肇事者”列为推荐。每个选项都带 Completeness 打分,方便 /plan-tune 之类的偏好系统做 auto-decide。

T4:执行选定的动作

「现在调查并修复」

  • 切换到 /investigate 心态:先找根因,再做最小修复;
  • 将修复单独提交(与分支自身改动分离):git commit -m "fix: pre-existing test failure in <test-file>"
  • 继续工作流。

「记为 P0 TODO」

  • TODOS.md 存在,按 review/TODOS-format.md 规定的格式添加条目;
  • 不存在则用标准头创建后添加;
  • 条目应包含:标题、错误输出、在哪条分支上发现、优先级 P0;
  • 继续工作流——把 pre-existing 失败当作非阻塞处理。

「Blame + 指派 GitHub issue」(仅 collaborative)

  • 找出最可能的肇事者:测试文件与它覆盖的生产代码都要查

    # Who last touched the failing test?
    git log --format="%an (%ae)" -1 -- <failing-test-file>
    # Who last touched the production code the test covers? (often the actual breaker)
    git log --format="%an (%ae)" -1 -- <source-file-under-test>
    

    若两者不是同一人,优先采信生产代码的作者——回归通常是他引入的。

  • 按 Step 0 检测到的平台创建指派 issue:

    GitHub 用:

    gh issue create \
      --title "Pre-existing test failure: <test-name>" \
      --body "Found failing on branch <current-branch>. Failure is pre-existing.\n\n**Error:**\n```\n<first 10 lines>\n```\n\n**Last modified by:** <author>\n**Noticed by:** gstack /ship on <date>" \
      --assignee "<github-username>"
    

    GitLab 用:

    glab issue create \
      -t "Pre-existing test failure: <test-name>" \
      -d "Found failing on branch <current-branch>. Failure is pre-existing.\n\n**Error:**\n```\n<first 10 lines>\n```\n\n**Last modified by:** <author>\n**Noticed by:** gstack /ship on <date>" \
      -a "<gitlab-username>"
    
  • 两个 CLI 都不可用,或 --assignee/-a 失败(用户不在组织内等)→ 建不带 assignee 的 issue,并在正文注明应找谁。

  • 继续工作流。

「Skip」

  • 继续工作流,并在输出中记录:Pre-existing test failure skipped: <test-name>

Triage 之后:若仍有未修复的 in-branch 失败,STOP,不得继续。若所有失败都是 pre-existing 且已处理(修复 / 记 TODO / 指派 / 跳过),进入 Step 6。若全部通过,则静默继续,简要记录计数即可。


Step 6:Eval 套件(有条件的门禁)

Step 6 只在 prompt 相关文件发生变更时强制启用,否则整个跳过。逻辑顺序如下。

1. 检查 diff 是否触碰 prompt 相关文件

git diff origin/<base> --name-only

与 CLAUDE.md 中的模式清单(本模板以 Rails 形态为示例,随项目 CLAUDE.md 定制)比对:

  • app/services/*_prompt_builder.rb
  • app/services/*_generation_service.rb*_writer_service.rb*_designer_service.rb
  • app/services/*_evaluator.rb*_scorer.rb*_classifier_service.rb*_analyzer.rb
  • app/services/concerns/*voice*.rb*writing*.rb*prompt*.rb*token*.rb
  • app/services/chat_tools/*.rbapp/services/x_thread_tools/*.rb
  • config/system_prompts/*.txt
  • test/evals/**/*(eval 基础设施变更会影响所有套件)

无匹配 → 打印 No prompt-related files changed — skipping evals. 直接跳到 Step 9。

2. 识别受影响的 Eval 套件

每个 eval runner(test/evals/*_eval_runner.rb)声明一个 PROMPT_SOURCE_FILES,列出会影响它的源文件。用 grep 反查匹配:

grep -l "changed_file_basename" test/evals/*_eval_runner.rb

runner → 测试文件映射示例:post_generation_eval_runner.rbpost_generation_eval_test.rb

特殊情形:

  • test/evals/judges/*.rbtest/evals/support/*.rbtest/evals/fixtures/ 的变更影响所有使用这些 judge/support 文件的套件——去 eval 测试文件的 import 里确认波及面;
  • config/system_prompts/*.txt 变更 → 在 eval runner 里 grep 该 prompt 文件名来定位受影响套件;
  • 拿不准 → 把所有可能受影响的套件都跑。Over-testing 好过漏掉回归。

(在 gstack 自身仓库中,这类“prompt 相关改动触发付费 eval”的映射由 CLAUDE.mdbun run test:evals 承载——它是 diff-based 的、按变更范围决定付费面。)

3. 以 EVAL_JUDGE_TIER=full 运行受影响套件

/ship 是 pre-merge 门禁,因此始终使用 full tier(Sonnet 结构判官 + Opus persona 判官):

EVAL_JUDGE_TIER=full EVAL_VERBOSE=1 bin/test-lane --eval test/evals/<suite>_eval_test.rb 2>&1 | tee /tmp/ship_evals.txt

多套件时顺序执行(每套需要独立测试车道)。第一套失败就立刻停——不为剩余套件继续烧 API 成本。

长 Eval(30 分钟以上):detach,别只放后台

纯后台化的 eval 仍活在 harness 的进程组里,遇到回合边界(turn boundary)、被停止的 monitor 或中断时会收到 SIGTERM(文档记录的真实事故:/ship 中途 script terminated by signal SIGTERM)。因此长套件必须走 gstack-detach(源码见 bin/gstack-detach):

~/.claude/skills/gstack/bin/gstack-detach --label ship-evals --lock gstack-evals --timeout 5400 -- <project eval command>

它的三个保证:

  1. 自己的 session 中存活——回合边界杀不掉;
  2. 通过机器锁与其它 worktree 串行化,避免 API 饱和;
  3. 写出一个必然出现的 ### gstack-detach EXIT=<code> ### 哨兵——轮询日志路径,命中哨兵即结束(通过和崩溃都会触发;“沉默”绝不等于成功)。即使你的轮询进程被回收,detached 运行也照常存活。

gstack-detach--timeout 是带底线约束的硬性参数:见 test/eval-detach-timeout-floor.test.ts,它锁死了超时下限,防止未来改动把安全阀悄悄调没。

4. 检查结果

  • 任一 eval 失败 → 展示失败、成本面板,然后 STOP,不得继续
  • 全部通过 → 记录通过数与成本,进入 Step 9。

5. 保存 eval 输出

将 eval 结果与成本面板写进 PR 正文(供 Step 19 使用)。

Tier 参考(/ship 恒用 full)

Tier 何时使用 速度(缓存后) 成本
fast (Haiku) 开发迭代、冒烟测试 ~5s(快 14 倍) ~$0.07/次
standard (Sonnet) 默认开发、bin/test-lane --eval ~17s(快 4 倍) ~$0.37/次
full (Opus persona) /ship 与 pre-merge ~72s(基线) ~$1.27/次

源码级工程机制:一条从模板到规则的防漂移链路

理解 tests.md 的“生成属性”,比读懂它上面的命令更重要。整套机制由三块拼成:

  1. 内容绑定(content-binding)tests.md 不是手写维护的散文档,而是从 ship/sections/tests.md.tmpl 渲染而来,其中的 bootstrap 与 triage 两块正文又来自 scripts/resolvers/testing.tsscripts/resolvers/preamble/generate-test-failure-triage.ts 两个 TS 生成函数,最终在 scripts/resolvers/index.ts 注册为模板上下文键。修改规则的唯一正确姿势是改 .tmpl/resolver 再执行 bun run gen:skill-docs(详见 CLAUDE.md),提交时 .tmpl 与生成的 .md 一起提交。

  2. 漂移哨兵(drift tripwire)test/binding-template-drift.test.ts 直接把“承重规则文本”钉死在生成产物里——例如断言 ship/sections/tests.md 必须同时包含 gstack-evidence run --label testsgstack-evidence run --label vitest(Step 5 双车道包装),断言 ship 主文件必须包含 Step 16 的证据检查与“a failed CHECK never blocks”。这样即使某个模板重构想悄悄删掉一条规则,测试也会立刻报错——把“靠 prompt 跟随的散文级约束”升级成“机器可验证的约束”。

  3. 跨 skill 一致性:同一套 bootstrap 规则被 /ship/qa 等多个技能共用,test/skill-validation.test.ts 校验各技能内容都包含 no-test-bootstrapBOOTSTRAP_DECLINED 标记,避免同一语义在不同技能里出现不同口径。

小结:这三步如何共同构成 /ship 的质量闸门

  • Step 4 解决“测试从哪来”:项目文档优先、环境标记只当证据、已有测试绝不重复搭、拒绝状态用 .gstack/no-test-bootstrap 持久化。
  • Step 5 解决“测试怎么跑、跑挂了算谁的”:在合并后的代码上并行运行、每条命令都进 gstack-evidence 证据账本以便 Step 16 免重跑;失败先做 T1-T4 归属分类——in-branch 失败硬性 STOP,pre-existing 失败则按 solo/collaborative 责任模型用 AskUserQuestion 交给开发者决策。
  • Step 6 解决“LLM 行为变没变”:只有 prompt 相关文件变更才触发;受影响套件用 PROMPT_SOURCE_FILES 反查定位;pre-merge 恒跑 full tier;超过 30 分钟的长套件走 gstack-detach 的独立 session + 机器锁 + EXIT 哨兵。

把“测试”放进 /ship 决策树并用证据账本记账,是 gstack 把测试纪律从“提醒”变成“闸门”的关键设计。若你想为某个现有项目接入这套做法,最小的落地路径是:先在自己项目的 CLAUDE.md 里补上 ## Testing 节(写明命令与目录),让 /ship 与其它技能直接命中“已文档化”分支;再按本指南 B6/B7 的格式沉淀 TESTING.md 与 CLAUDE.md 的测试期望。剩下的自动引导、失败分类与 Eval 门禁,交给 gstack 的决策树与生成器即可。

登录后查看全文
热门项目推荐
相关项目推荐