AIアシスタントが、小さな行読み取り関数から確認処理を削除するよう提案します。「呼び出し元はすでに有効な行番号を渡しています」と言います。関数は確かに単純に見えます。変更を受け入れる前にGit履歴を見ると、その行は「Compact comparison spacing」というコミットを指していました。

これで、次に調べる場所が見つかりました。役立つ問いは、その確認がどの入力を防ぐのか、その挙動がいつ加わったのか、現在の呼び出し元でも必要なのか、です。自分で再構築できる小さな履歴で、具体的に調べてみましょう。

気になる行から始める

ソースビューアーが1から始まる行番号を使うとします。現在の関数は次のようになっています。

def read_line(lines, line_number):
    """Read a source line using a one-based line number."""
    if line_number<1:
        raise ValueError("line number must be positive")
    if line_number > len(lines):
        raise IndexError("line number is past the end")
    return lines[line_number - 1]

二つの行 ["alpha", "omega"] に対して、1行目は alpha、2行目は omega を返すはずです。しかしPythonでは、添字 -1 は最後の要素を読みます。下限の確認がなければ、要求した行番号0は添字 -1 となり、omega を返します。行番号 -1 は添字 -2 となり、alpha を返します。

これは現在のコードから説明できます。履歴が答えるのは別の問いです。この入力を拒否する処理がいつ関数に入り、その変更に何が含まれていたのでしょうか。

四つのコミットを再構築する

空の作業フォルダーに git-history-lab.py をダウンロードします。Git 2.29以降とPython 3.9以降が必要です。パッケージの依存関係はありません。自分のターミナルで実行してください。

python3 git-history-lab.py create history-lab --record history-check
cd history-lab
git log --reverse --format='%h %s' HEAD

Windowsでは、必要に応じて python3 を自分のPython 3ランチャーに置き換えてください。この手順はGitとPythonを使い、macOSのAPIは使いません。記録した実行環境はmacOS上のGit 2.50.1とPython 3.9.6です。WindowsとLinuxでは個別にテストしていません。

スクリプトは新しいローカルリポジトリを作り、確認を実行し、結果を history-check に保存します。既存の出力先がある場合は拒否します。架空の作者を自分が作るコミットにだけ使い、グローバルのGit設定は変えません。名前、日付、コミットメッセージ、ビューアーの設定はすべて教材用のフィクスチャです。

ce4c0cf Add one-based source line lookup
e87088e Reject nonpositive source line numbers
7d41ebc Rename line reader module
27a0dc5 Compact comparison spacing

各コミットには、分かりやすいタグもあります。

タグ 変更
baseline line_reader.py を追加。テストは先頭行、最終行、末尾を超える入力を対象とする
fix-positive-lines 下限の確認と、0、-1、空の入力リストのテストを追加
rename-module ファイルを source_lines.py に改名し、テストのimportを更新
format-spacing line_number < 1 を line_number<1 に変更

詰めた空白は、最後の編集をすぐ見分けられるよう意図的に目立たせています。

blameを次の差分への手掛かりにする

3行目を調べます。

git blame -L 3,3 HEAD -- source_lines.py
git show --format=fuller format-spacing -- source_lines.py

Blameは、その行を空白のコミットである 27a0dc57 に帰属させます。パッチには次の変更があります。

-    if line_number < 1:
+    if line_number<1:

次に、空白を無視して行を追うようGitに指定します。

git blame -w -L 3,4 HEAD -- source_lines.py

結果は e87088e0 と古いパスの line_reader.py を指します。Git blameはファイル全体の改名を自動で追い、-w は帰属を調べるときに空白を無視します。ここで紛らわしい結果の原因となったのは、改名そのものではなく、その後の空白の編集です。公式の git-blameドキュメントを参照してください。

この編集については、検証スクリプトでPythonの構文木も比較し、変更前後でテストも実行します。両方の結果は一致します。空白を無視するのは帰属を調べる方法であり、挙動のテストではありません。Pythonではインデントや文字列内の空白が意味を持つ場合があります。

既知の整形コミットを調べた後は、明示的に除外することもできます。

git blame --ignore-rev format-spacing -L 3,3 HEAD -- source_lines.py

この実習では、これも修正コミットに到達します。タイトルが「format」だからというだけで除外せず、先にパッチを読んでください。Blameが記録するのは行の帰属であり、開発者の理由やバグの責任ではありません。

書き換え後にも残る文字列を探す

エラーメッセージは、改名と空白の編集を通じて同じままでした。導入された場所を検索します。

git log --format='%h %s' -p -S 'line number must be positive' HEAD -- '*.py'

line_reader.py 内の修正とパッチが返されます。*.py を引用符で囲むことで、パスの照合をGitに任せ、古いファイル名も含められます。このリポジトリで、そのメッセージを追加または削除するコミットは一つだけです。

-S は、文字列の出現回数が変化する変更を選びます。一致箇所の付近にあるすべての編集を選ぶわけではありません。Gitの pickaxeの説明を参照してください。

整形時に変わった文字列も試します。

git log --format='%h %s' -p -S 'line_number < 1' HEAD -- '*.py'

今度は、その正確な表記を削除する空白のコミットと、追加する修正コミットの両方が表示されます。余分な一致も役に立ちます。何が変わったかを読み、必要なら変化しにくい断片を選び直してください。結果がない場合も解釈が必要です。文字列は変わっても、出現回数が同じことがあります。

対象ファイルが分かっている場合、この実習では次も使えます。

git log --follow --format='%h %s' -p -S 'line number must be positive' HEAD -- source_lines.py

--follow は一つのファイルの改名履歴を扱います。一般的な複数ファイル横断検索ではありません。実際の調査で、狭い検索が早く途切れるときは、古いパスを調べるか、対象パスを広げてください。

log -Lで行の変遷を追う

関連する範囲が分かっているなら、その範囲のパッチを履歴から求めます。

git log --format='%h %s' -L 3,4:source_lines.py HEAD

このリポジトリでは、空白の編集と、ガード条件の最初の追加が表示されます。単純なファイル改名はまたいで追えますが、内容が変わっていない改名後のブロックには独立したパッチが表示されません。全体の順序が必要なら、先ほどの四つのコミットのログを使ってください。

関数全体を対象にするには、次を実行します。

git log --format='%h %s' -L :read_line:source_lines.py HEAD

これなら baseline の元の添字式にも到達します。実習では .gitattributes に *.py diff=python を宣言し、GitがPythonの関数ヘッダーの規則を使うようにしています。関数の認識は言語と差分設定に依存します。カスタムハンクヘッダーの定義を参照してください。このファイルで数値の範囲を使うなら、次のように指定できます。

git log --format='%h %s' -L 1,7:source_lines.py HEAD

log -L では、パスは範囲引数の中に入れます。-- path による絞り込みを後ろに追加しないでください。範囲は開始リビジョンに存在する必要があり、探索は一つのリビジョンから始まります。これらの制約は公式の git-logドキュメントに記載されています。

実際の履歴には、マージ、コピーされたコード、書き直された関数、shallow cloneで取得されていない古いコミットなどがあるかもしれません。行の追跡はパッチを読む出発点であり、記録されなかった理由までは復元できません。

両方の版で説明を検証する

見つかった修正で、実装とテストを一緒に読みます。

git show --format=fuller fix-positive-lines -- line_reader.py test_lines.py

パッチは下限の確認と、0と-1で ValueError を期待するテストを追加します。実習の検証スクリプトは、コミット済みのソースとテストを一時フォルダーに取り出します。同じ回帰テスト群をbaseline実装に対して実行した後、各コミット自身のテストを実行します。現在のチェックアウトは切り替えません。

記録された実行の観察結果 裏付けられること
Baselineは0に対して omega、-1に対して alpha を返す これらの入力はPythonの負の添字に到達する
修正のテスト群はbaseline上で両方の入力に対して失敗する 追加されたアサーションは古い挙動を検出する
修正、改名、空白の各版で五つのテストが成功する テストされたケースは修正後に意図したとおりに動く
改名はソースのバイト列を保持し、空白の編集は構文木を保持する 後続の編集はこの関数の実装を維持する

Baselineの元の三つのテストも成功します。成功するテスト群でも、気になる入力が含まれていないだけかもしれません。期待される結果の全文に確認内容が記載されています。自分の history-check/transcript.txt には、想定された二つの失敗を含む、実際のコマンドと出力が記録されます。

サンプルのコミットメッセージには「行番号 0 で最終行が返る」とあり、テストもその関数の動作を再現しています。自分のプロジェクトで検査を削除する前に、現在の呼び出し元が 0 や負の行番号をどう拒否するか確認してください。この例は整数入力を前提としており、ほかの型には別の検証が必要です。

AIに検証できる問いを渡す

リビジョンID、パッチ、テスト出力がそろったら、それらを結び付けるようアシスタントに頼みます。

この下限の確認が存在する理由を説明してください。提示した修正とその親コミットを使い、入力0と-1の結果を示し、回帰テストのアサーションを特定してください。差分と実行済みテストが示すことと、コミットメッセージの主張を分けてください。そのうえで、削除を勧める前に現在の呼び出し元を調べてください。呼び出し元や過去の報告が手元にない場合は、どの結論が未確定かを明記してください。

役に立つ回答には、挙動の変更、報告された動機、現在の呼び出し元について不明なこと、の三つが含まれます。メッセージに書いてあるだけで実際の障害が起きたと断言するなら、主張を絞るよう求めてください。確認の削除を提案するなら、サポートするすべての入口で問題の入力を排除できる根拠を求めます。その呼び出し元の調査には、初めて読むコードで一つの値を追うが次の一歩になります。AIが書いたパッチのレビューでは、提案された説明を対象の絞られた回帰テストにする方法を紹介しています。