エージェントに「削除APIを一本足しておいてください」と頼みました。返ってきた差分は、関数がひとつ増えただけの、素直な変更です。念のため保存前のリントを走らせて、手が止まりました。
警告が13件。
画面を上から追っても、そのほとんどが数年前に自分で書いた console.log や var でした。エージェントが今回足した行は、そのうちのどれなのか。ひとつずつ照合して、ようやく「新しく混ざった違反は3件」だと分かった頃には、レビューする気力の半分が削られていました。
問題はエージェントではありません。ファイル全体にリントを当てているから、警告の数が「変更の大きさ」ではなく「そのファイルが抱える負債の量」に比例してしまう。その比例を断ち切る小さな仕組みを、ここで組んでいきます。エージェントが触れた行だけを検品する、変更行スコープのリンターです。個人開発でエージェントに編集を任せる場面が増えるほど、この一手は効いてきます。
なぜ「ファイル全体」ではなく「変更行」なのか
エージェント時代のコードレビューには、以前と違う手触りがあります。人間が一行ずつ書いていた頃は、変更とファイルの範囲がだいたい一致していました。新しく書いたファイルには新しい規約が最初から効いていて、古いファイルは古いまま、境界がはっきりしていた。
エージェントはその境界を軽々と越えます。「この関数の隣に一本足して」という指示だけで、10年もののレガシーファイルにためらいなく手を入れる。結果として、5行の追加に対して、そのファイルが積み上げてきた数百行分の古い違反が一緒に画面へ流れ込みます。
ここで全体リントを回すと、レビュアーは二つの損をします。ひとつは、本当に見るべきエージェントの追加行が、既存違反のノイズに埋もれること。もうひとつは、「ついでだから古い違反も直そう」という誘惑に負けて、頼んでいない範囲までエージェントに書き換えさせ、差分が肥大化することです。
変更行スコープは、この両方をほどきます。判定の対象を「今回の差分で追加された行」に限定する。レガシーの負債はそのまま残しますが、少なくとも新しく足された違反は一件残らず捕まえられます。負債を増やさないことに専念する、という割り切りです。
差分から「追加された行」を取り出す
仕組みの心臓は、git diff の出力から追加行を行番号つきで拾うことです。ポイントは --unified=0 を指定して、前後のコンテキスト行を出させないこと。コンテキストが混ざると「変更していない行」まで対象に入ってしまい、スコープを絞る意味が薄れます。
ハンクヘッダ @@ -a,b +c,d @@ の +c が、新しいファイルでの開始行番号です。そこから + で始まる行を数えていけば、追加行の実際の行番号が分かります。
import re, subprocess
def added_lines (path):
"""差分で追加された (新しい行番号, テキスト) の一覧を返す"""
out = subprocess.run(
[ "git" , "--no-pager" , "diff" , "--unified=0" , path],
capture_output = True , text = True ).stdout
added, newno = [], None
for ln in out.splitlines():
m = re.match( r '@@ - \d + (?: , \d + ) ? \+ (\d + )(?: , \d + ) ? @@' , ln)
if m:
newno = int (m.group( 1 ))
continue
if newno is None :
continue
if ln.startswith( '+' ) and not ln.startswith( '+++' ):
added.append((newno, ln[ 1 :]))
newno += 1
elif not ln.startswith( '-' ):
newno += 1
return added
- で始まる削除行は新しいファイルには存在しないので、行番号を進めません。削除行を数えてしまうと、以降の行番号が全部ずれます。ここが地味に事故りやすい落とし穴です。
複数のハンク(ファイルの離れた場所を二箇所いじった、など)も、ハンクヘッダを見るたびに newno を貼り替えるので、そのまま正しく追従します。私自身、手元で上下二箇所を編集した差分に当てて、上の追加行と下の追加行の両方を、それぞれ正しい行番号で拾えることを確かめました。
変更行だけに規約を当てる
追加行が取り出せれば、あとはそこに規約を当てるだけです。規約は正規表現の小さな表として持ちます。チーム独自のルールを足していく前提なので、増やしやすい形にしておきます。
RULES = [
( "no-console" , re.compile( r ' \b console \. ( log | debug )\b ' ), "console.log は残さない" ),
( "no-var" , re.compile( r ' (^ | \s) var \s ' ), "var ではなく const/let を使う" ),
( "no-any" , re.compile( r ': \s * any \b ' ), "any 型を避ける" ),
( "no-trailing-ws" , re.compile( r ' [ \t ] + $ ' ), "行末の空白を残さない" ),
]
def lint (lines):
hits = []
for no, text in lines:
for rid, rx, msg in RULES :
if rx.search(text):
hits.append((no, rid, msg, text.rstrip()))
return hits
lint() に渡す lines を、ファイル全体にするか追加行だけにするかで、同じルールが二つのモードを持ちます。この対称性のおかげで、「変更行モードは甘いのではないか」という不安を、いつでも全体モードと突き合わせて確かめられます。ルール自体は共通で、見る範囲だけが違う。ここは意図して揃えておく価値があります。
実測: 警告はどれだけ減るか
手を動かして確かめます。数年前に書いた、console.log と var と any が散らばったレガシーな TypeScript ファイルを用意しました。そこにエージェントが deleteUser を一本追加した、という差分です。追加された関数の中にも、わざと any と console.log と行末空白を混ぜてあります。
同じルールを、全体モードと変更行モードの両方で走らせた結果です。
モード 検出された警告 内訳
ファイル全体 13 件 既存の違反 10 件 + 今回の追加 3 件
変更行のみ 3 件 今回の追加 3 件だけ
警告は13件から3件へ、約77%減りました。変更行モードが拾った3件は、any 型の使用、行末の空白、そして console.log の残り。どれも、たった今エージェントが書いた行の中にあります。既存の10件は視界から消えました。消えたのではなく、対象外として意図的に脇へ置いた、というのが正しい言い方です。
差の意味は、ファイルの負債が深いほど大きくなります。今回は13対3でしたが、数百行のレガシーなら、全体モードは軽く三桁の警告を吐き、その中に紛れた数件を人間の目で探すことになります。変更行モードは、常に「今回足したぶん」しか返しません。レビューにかかる時間が、負債の量から切り離されるということです。
AGENTS.md と噛み合わせる
このリンターは、単体でも効きますが、AGENTS.md と二段構えにすると本領を発揮します。
一段目は助言です。同じ規約を AGENTS.md に言葉で書いておき、エージェントに最初から守らせる。
# AGENTS.md(抜粋)
## コード規約
- console.log / console.debug を残さない(デバッグは削除してからコミット)
- var を使わない。const を第一に、再代入が要る時だけ let
- any 型を避ける。型が定まらない時は unknown で受けて絞り込む
- 行末に空白を残さない
助言レイヤーだけで、エージェントの出力はかなり綺麗になります。ただし助言は確率的です。守られることが多い、というだけで、保証ではありません。長いセッションや、指示が込み入った場面では、するりと抜けます。
そこで二段目、決定的なゲートとしてリンターを置きます。エージェントが編集を終えた直後、あるいはコミット前のフックで変更行リンターを走らせ、違反があれば非ゼロで終了させる。助言が抜けた3件を、ここで確実に捕まえる。
レイヤー 仕組み 性質 役割
助言 AGENTS.md の規約記述 確率的 そもそも違反を生ませない
ゲート 変更行リンター(exit code) 決定的 抜けた違反を通さない
この二段は、どちらか一方では足りません。ゲートだけだと、エージェントは毎回同じ違反を書いてはゲートに弾かれる、という非効率を繰り返します。助言だけだと、たまに抜ける。助言で母数を減らし、ゲートで最後を締める。両方あって、はじめて手離れが良くなります。本番運用に載せる前に、この二段を先に用意しておくことをお勧めします。
運用の勘所
しばらく回してみて見えた、いくつかの注意点を残しておきます。
リネームや移動には弱い、と割り切る。 ブロックをまるごと別の場所へ動かしただけの変更でも、差分上は「削除+追加」として現れます。追加側に既存の違反が乗っていれば、変更行リンターはそれを新規として拾います。これは誤検知ではなく、スコープの定義どおりの挙動です。移動が多いリファクタリングの時だけは、全体モードと併用するか、リンターを一時的に緩めて回避するのが現実的です。
exit code を CI の契約にする。 ローカルのコミット前フックと、CI の両方で同じスクリプトを走らせ、違反があれば exit 1。人間の「気をつける」に頼らず、機械が止める。エージェント運用では特に、判定を人の注意力の外へ出しておくことが効きます。
ルールは小さく始めて、痛かったものだけ足す。 最初から30個のルールを並べると、誤検知の調整に時間を取られて続きません。実際にレビューで何度も指摘した項目を、ひとつずつ正規表現にしていく。表に一行足すだけで増やせる形にしてあるのは、そのためです。私はこの「痛みドリブン」でルールを育てる進め方を勧めています。
「変更行だけ」は、負債を許す設計だと自覚する。 この仕組みは、古い違反をあえて見逃します。負債の返済は別のタスクとして、意図と計画のもとに進めるべきものです。日々のエージェント編集で新しい負債を増やさないこと、それだけにこの道具の役割を絞る。守備範囲を欲張らないことが、続く条件だと感じています。
小さなスクリプトですが、レビューのたびに感じていた「どれがエージェントの仕業か」という探し物が消えて、手元がずいぶん軽くなりました。同じ煩わしさを抱えている方の、変更行に目を戻すきっかけになれば嬉しいです。お読みいただき、ありがとうございました。