@teps/gauntlet
v0.28.0
Published
エージェントが書いたコードを、人間がコードを読まずに機械的な制約で縛るチーム共用の品質ゲート
Readme
gauntlet
エージェントが書いたコードを、人間がコードを読まずに機械的な制約で縛るための品質ゲート。
Robert C. Martin が 2026 年 7 月に「自分はもうエージェントのコードを読まない。代わりにテスト・ カバレッジ・複雑度・mutation testing で囲む」と述べた方針を、社内の複数リポジトリに適用できる形に したものです。
人間がコードを読む速度がボトルネックになるなら、読まずに済ませる。ただし「読まない」を成立させる だけの機械的な保証を敷く、というのが考え方です。
入れると何が変わるか
2 か所で止まるようになります。
| | いつ | 何が起きる |
| --- | --- | --- |
| quick | エージェントが git commit する直前(手動でも叩ける) | 赤ならコミットできず、理由がエージェントに届いてそのまま直しにいく |
| full | CI | 赤ならマージできない |
quick が効くのが一番の違いです。人間が「テスト通してね」と言わなくても、エージェントは
緑になるまで終われません。数秒〜数十秒で終わるように作ってあります(下の実測表)。
何を見るか
CRAP — 複雑さと網羅率をひとつの数にする
CRAP = 複雑度² × (1 − 網羅率)³ + 複雑度。閾値は 8。 意味はこの表が分かりやすいです。
| その関数の網羅率 | 許される複雑度 | | --- | --- | | 100% | 8 | | 80% | 7 | | 50% | 4 | | 0% | 2 |
テストすれば複雑さを許すという自己調整が組み込まれています。単純な関数はテストが無くても通り、 複雑な関数はテストが無いと通りません。ノブがひとつで済むので、全社で同じ数字を使えます。
裏返すと、閾値 8 は複雑度そのものの上限でもあります — 網羅率 100% では CRAP = 複雑度 に
なるので、複雑度 9 以上はどれだけテストを足しても通りません(触った関数だけ。触らなければ
baseline が現状維持を許します)。そこで要るのはテストではなく関数を割ることなので、
gauntlet は違反 1 件ごとにどちらなのかを言います:
CRAP 30.0 (> 8) 複雑度 5 / 網羅率 0% src/a.ts:10 f → 網羅率 51% で通ります
CRAP 31.0 (> 8) 複雑度 31 / 網羅率 100% src/proxy.ts:278 proxy → 複雑度 9 以上はテストでは通りません。分岐を表に落とすか、まとまりごとに関数を割ってくださいmutation testing — 0.27.0 で外しました
コードを 1 か所ずつ機械的に壊してテストが気づくかを見るゲートを 0.26 まで持っていましたが、 実行時間が構造的に高く(変異数に比例。CI で数時間かかるリポジトリが実際に出た)、 0.27.0 でスコープから外しました。テストの弱さを直接測るゲートは当面ありません — この穴は承知の上で開けています。理由と、戻すなら何が変わるか(将来課題)は DESIGN.md にあります。
重複 — コピペを見る唯一のゲート
テストごと複製されたコードは CRAP を通ります。jscpd で重複トークン数を測り、
増やさないことだけを要求します(絶対閾値なし・full のみ・減れば自動で締まる)。
jscpd は gauntlet が同梱するので、対象リポジトリに入れるものはありません。
落ちるときは、いま重複している対をその場に並べます。数だけ言われても場所に辿り着けず、
list を走らせるかは本人の判断に掛かっているためです(多いときは 10 件で切って残りを件数で言います)。
✗ duplication (89ms) 重複 221 トークン / 対象 26 ファイル
重複が 0 → 221 トークンに増えました
124 src/analyze.ts ↔ src/measureTextCoverage.ts
97 src/crawl.ts ↔ src/download.ts網羅率の対象は gauntlet の宣言で決まります。 vitest の coverage.include は
gauntlet が上書きします。ただし coverage.exclude は上書きできないので、そこで消された
ファイルが測る範囲に入っているとテストを書いても網羅率が上がりません。full はそれを
検出して名指しで落とします(網羅率 0% と同じ顔で通すと、直せない赤を仕込むことになります)。
型チェック
リポジトリの tsc --noEmit を走らせ、診断が出たら落とします。gauntlet が見るのは
「文句を言ったか」だけなので、2 パスや vue-tsc などコマンドが違っても
commands.typecheck に書けば動きます。
テストファイルは測りません(*.test.ts / *.spec.ts)。テストにテストは書けないので、
テストの中の複雑な関数は網羅率 0% で必ず違反になります。重複も、beforeEach の並びや
AAA の骨格が似るのは自然です。exclude に書く必要はありません。
lint は見ません。 どのルールを有効にするかはリポジトリが決めることで、gauntlet は そこに判断を持ちません — 何を守っているか言えないゲートは置かない、という判断です (0.18.0 で外しました。lint は各リポジトリの CI が回してください)。
既存のリポジトリが赤で埋まらないか
埋まりません。 導入時点の違反数を gauntlet.baseline.json に記録し、それを増やさないこと
だけを要求します。減れば自動で締まります。
- 触った関数 — 絶対的に CRAP ≤ 8 を要求(これから書くコードには厳しい)
- 触っていない箇所 — 現状維持でよい
「初日から全部直せ」にはなりません。触ったところから良くなっていきます。
gauntlet.baseline.json はエージェントが編集できないよう PreToolUse フックで守られます。
赤を消す最短経路が「基準を緩める」になってしまうためです。人間は編集できます。
複数のブランチ(worktree)が両方で記録を締めてマージ衝突になったときも、手で解決する
必要はありません — quick か full を一度実行すると、gauntlet がフィールドごとに
厳しい側を取って解決します(両側とも実測なので、値を選ばせない形が保てます)。
記録されるのは数だけなので、「どの関数か・どこが重複しているか」は
list で見ます(ゲートではないので通ります)。
npx gauntlet list出るのは CRAP → 重複 の 2 つの節です。以下は別々のリポジトリからの抜粋で、 まず h3 の CRAP:
CRAP 違反 35 件 / 測る対象 411 関数(50 ファイル)(gauntlet.baseline.json の許容 35)
CRAP 132.0 (> 8) 複雑度 11 / 網羅率 0% src/utils/internal/path.ts:12 joinURL → 複雑度 9 以上は…悪い順に全部並ぶので、上から手を付けられます(h3 では未参照のまま残っていた関数が 2 つと、網羅率 0 の公開 API が 1 つ、この一覧から見つかりました)。
重複の節はファイルの対で出ます。総数だけだと「何トークンあるか」は分かっても「どこにあるか」に 辿り着けず、実質だれも中身を見ません(別のパイロットでは 338 トークンが一度も動かないまま 緑で、同じ並行処理ヘルパーが 4 ファイルに複製されていました。以下はそのときの実測です):
重複 338 トークン(4 か所)/ 対象 26 ファイル(gauntlet.baseline.json の許容 338):
124 src/analyze.ts ↔ src/measureTextCoverage.ts
97 src/crawl.ts ↔ src/download.ts
65 src/genPanel.ts ↔ src/genPhoto.ts
52 src/makeInterview.ts ↔ src/validateStyle.ts重複が 0 でも見出しだけは出ます — 節ごと消えると「重複が無い」と「測っていない」の 区別が付かないためです。
導入
出発点は 1 コマンドです。 gauntlet のインストールも不要です:
npx skills add tepshq/gauntlet -a claude-code置かれるのは skill 1 枚(.claude/skills/gauntlet-setup/)と skills-lock.json だけ。
この時点では何も有効になりません — ゲートも設定も、範囲が決まってから入ります。
あとは Claude Code で /gauntlet-setup を実行するだけです。skill がここから先の全部 —
依存の投入(パッケージマネージャの検出込み)、測る範囲の決定、外部サービスを要するテストの
分離、CI への 1 行、ラチェットの種置き、ゲートが実際に噛むことの確認 — をユーザーと対話
しながら進めます。
起動できるのは人間だけです(disable-model-invocation)。測る範囲を書き換えられる
唯一の skill なので、エージェントが自分で起動できると、赤いときの最短経路が「範囲を狭める」
になります。常駐する description の分の context も要りません。
推測で範囲を入れると、狭いまま緑になり、それが一番気づけない失敗になります。
エージェントがリポジトリを読み、理由つきで範囲を提案し、合意してから確定する流れに
してあるのはそのためです(tsconfig.json の include は当てになりません —
生成物・設定ファイル・e2e が混ざります)。
更新のやり方は下の「更新」にあります。
# パッケージマネージャはリポジトリに合わせる(pnpm なら pnpm add -D)
# 版は npm view で調べて明示する — pnpm は既定で公開から 24 時間経った版しか選ばないので、
# latest を任せると古い版が黙って入ります(minimumReleaseAge。実測)。
npm i -D "@teps/gauntlet@$(npm view @teps/gauntlet version)"
npx gauntlet init --default-branch=main --include='src/**/*.ts'
npx gauntlet quickcoverage provider は手で足さないでください。 @vitest/coverage-v8 はリポジトリの
vitest と完全一致する版でなければ install 自体が失敗します(peer vitest@"3.2.7" —
範囲ではありません)。足りなければ gauntlet quick が版を埋めた 1 行を出すので、
それをそのまま打てば済みます。範囲の決め方・CI・種置きは
skills/gauntlet-setup/SKILL.md に全手順があります。
0.0.13 以前を GitHub Packages から入れていたリポジトリは、
.npmrcの@tepshq:registry=https://npm.pkg.github.comの行と、workflow のregistry-url/scope/NODE_AUTH_TOKEN(gauntlet のためだけのもの)を消してください。 残っていると新しいバージョンが見えません。
skill が範囲を決めたあと gauntlet init --include=... を叩き、薄いファイル 3 枚が
置かれます。ロジックは全てパッケージ側にあるので、更新はパッケージの版を上げて
init を叩き直すだけで済みます(フックの形が変わっても、あなたの設定はそのままに配線だけ
入れ替わります)。
| 置くもの | 内容 |
| --- | --- |
| .claude/settings.json | フック 1 本(下記の 2 つの検問)。既存の設定は壊しません |
| gauntlet.config.json | このリポジトリの事実。閾値は入りません |
| .gitignore | 足りない行だけ追記 |
既にある .claude/settings.json や .gitignore は置き換えません(足りないものだけ
追記)。測る範囲を書き換えるのはフラグを渡したときだけなので、gauntlet を上げたあと
npx gauntlet init を叩き直しても、決めた範囲も手書きの commands も消えません。
CI の workflow は置きません。 CI が要るもの(Node のバージョン、postinstall が
要求する環境変数など)は gauntlet からは見えないので、
既に動いている job に 1 行足すのが基本形です。
- run: npx gauntlet fullその job には fetch-depth: 0(差分の起点に全履歴が要る)と Node 22 以上が必要です。
足せる job が無い場合の雛形は skill が持っています。
Claude Code の挙動が変わります
init が .claude/settings.json に PreToolUse フック(npx gauntlet hook)を 1 本足します。
検問は 2 つで、どちらに当たるかは gauntlet が自分で判定します。
配線の手作業はありません — このファイルはコミットで伝播するので、clone した全員に効きます。
1. コミットの検問 — エージェントが git commit しようとすると gauntlet quick が走り、
赤ならコミットそのものが実行されません。「履歴に入った状態はすべて検査済み」が
不変条件になります。違反の内容はエージェントに直接届くので、そのまま直しにいきます:
PreToolUse:Bash hook error: [npx gauntlet hook]: gauntlet quick: fail
✗ crap CRAP 42.0 (> 8) 複雑度 6 / 網羅率 0% src/probe.ts:1 tangled2. baseline の保護 — エージェントが gauntlet.baseline.json を編集しようとすると
止めます。赤を消す最短経路が「基準を緩める」になってしまうためです。人間は普通に編集できます。
人間がターミナルで直接打つコミットは検査されません(Claude Code のフックなので)。 gauntlet の前提は「コードを書くのはエージェント」です。
既に .claude/settings.json がある場合、既存のフックやプラグイン設定はそのまま残ります
(追記するだけで、同じものは二度足しません)。
また .claude/skills/gauntlet-setup/ に skill が 1 枚入ります。測る範囲を決め直すときや
gauntlet を上げるときに、人間が /gauntlet-setup を実行してください。
更新
人が新しい版に気づいたときに実行します。 gauntlet は新しい版が出たことを自分からは
知らせません — quick や full が npm を見に行く形は、ネットワークの有無でゲートの答えが
変わるからです(DESIGN.md の「検討して外したもの」)。
対象リポジトリのルートで、まず skill を入れ直します。
npx skills add tepshq/gauntlet -a claude-code -s gauntlet-setup -yそのあと /gauntlet-setup。これで版上げ・init の叩き直し・版ごとの後始末・確認まで
通ります。
skill を先に入れ直すのは、新しい版が要求する後始末が新しい skill にしか書かれていない からです。古い手順書のまま上げると、記録の形式が変わったことも配線が変わったことも 知らないまま緑に見えます。
npx skills update は使わないでください。実体を .agents/skills/(多数の agent が
共有する置き場)へ移して .claude/skills/ を symlink にするため、コミット済みの
SKILL.md が git から「削除」に見えます。update のオプションは -g / -p / -y だけで、
置き場所を指定する -a を受け付けません。
途中で止まることがあります
どれも設計どおりで、故障ではありません。
| 止まる場所 | なぜ |
| --- | --- |
| 記録のリセット | 古い版から上げると gauntlet.baseline.json の該当する欄(crap)を空にする必要があることがあります。これは guard がエージェントの手を止めるので、人間が編集します。基準を緩める経路を塞ぐ仕組みに、更新のための例外を開けないためです |
| pnpm の 24 時間ルール | 公開直後の版を名指しすると pnpm が pnpm-workspace.yaml に除外行を書きます。版ピンとワイルドカードのどちらにするかを訊かれます |
| coverage provider の版ずれ | vitest の版が動いていると @vitest/coverage-v8 の完全一致が崩れます。gauntlet が版を埋めた 1 行を出すので、それを打てば済みます |
測ったのはここまでです。 0.21.1 → 0.23.4 を使い捨てリポジトリで通し、フラグ無しの init が
範囲を保ったまま旧配線を hook 1 本に入れ替えること、旧形式の mutation 記録は自動では
置き換わらないことを確認しました。0.23.4 → 0.25.0 も同じ形で通し、汚染された crap の欄は
上げても救われないこと(消すのは 1 行でよいこと)と、seed が更新後もそのまま使えることを
確認しました。0.25.0 → 0.26.0 も通し、記録済みの重複トークン数が動かないこと(204 →
204。ゲートは緑のまま)と、範囲と配線が保たれること、記録の手当てが要らないことを確認して
います(PLAN.md)。実リポジトリ規模での更新はまだ測っていません。
gauntlet が走らせるテストは宣言する
gauntlet は gauntlet.config.json の tests.projects に宣言された vitest project だけを
走らせます(実行も coverage も。宣言が無ければ全部走ります)。
DB・ネットワーク・実ファイルシステムに触れるテストは、専用の project に分けて宣言から 外してください。そういうテストを走らせる場所は各リポジトリの既存 CI です。project の 名前も分け方も自由です — gauntlet に「integration テスト」という概念はありません。
// vitest.config.ts — 分け方の例
projects: [
{ extends: true, test: { name: "unit", include: ["**/*.test.ts"],
exclude: ["**/node_modules/**", "**/*.db.test.ts"] } },
{ extends: true, test: { name: "db", include: ["**/*.db.test.ts"] } },
]// gauntlet.config.json — この例なら unit だけを宣言する
"tests": { "projects": ["unit"] }宣言は init のフラグでも書けます: npx gauntlet init ... --test-projects=unit
手元に DB が無いだけで毎ターン赤になると、ゲートが環境によって答えを変えます。 それが数回 起きると、緑の意味が信じられなくなって誰も見なくなります。
projects を使っていないリポジトリでは何もしなくて構いません(宣言なし = 全部)。
glob でファイル名を除外する案は動きません — vitest の
--excludeはprojectsに 伝わらず、project を使うリポジトリで黙って無効になります。project が vitest の選択を 正しく解釈する唯一の境界です。宣言し忘れは黙って通りません。 新しい project を作って宣言し忘れると、そのテストの coverage が gauntlet に届かず、触った関数が CRAP で赤になります。そのとき宣言を直して ください。
使う
npx gauntlet quick
npx gauntlet full
npx gauntlet list # ゲートではない。許容している CRAP 違反と重複の在り処を全部並べる通れば exit 0、違反または gauntlet 自身が走れなければ exit 2 です。「走れなかった」を緑に しません — 走らないゲートが緑に見えると、緑の意味が実行ごとに変わってしまうためです。
要件
- Node >= 22
- vitest
- 単一パッケージ(workspaces は未対応)
- TypeScript のバージョンに下限はありません。gauntlet がパースできれば構いません
実測
| repo | テスト数 | quick | full |
| --- | --- | --- | --- |
| gauntlet 自身 | 698 | 3.0 秒 | 3.3 秒 |
| hue | 412 | 5.5 秒 | 24 秒(CI) |
| teps | 3822 | 10.4 秒 | 199 秒(CI) |
| duct | 7358 | 64 秒 | 75 秒(手元) |
quick は差分に関係するテストだけを走らせるので、変更したファイルによって前後します。
hue / teps / duct の full は mutation を持っていた版で「変異対象 0」だった回の実測です —
0.27.0 の full が回す中身(全テスト + coverage + CRAP + 重複 + 型チェック)とほぼ同じなので、
目安になります。支配項は全テストの実行時間です。
