Claude Media
Claude CodeでPerlの古いスクリプトを読解し、仕様と移行テストを作る

Claude CodeでPerlの古いスクリプトを読解し、仕様と移行テストを作る

グローバル変数と正規表現だらけのPerlを、入出力の組を固定してから仕様の箇条書きに起こし、Pythonへの移行を新旧の出力diffで検収する手順です。

古いPerlのバッチスクリプトは、コードを上から読み解くより、先に「何を入れると何が出るか」を固定したほうが速く進みます。入力ファイルと出力ファイルの組を旧スクリプトで作り、その組から仕様を箇条書きに起こし、移行後のコードを同じ入力で動かして出力をdiffで比べる。Claude Codeには、この3段のどこでも「合否が返る確認」を持たせられます。

この記事は、1998年ごろに書かれて2004年に継ぎ足された月次売上集計のスクリプトを題材にします。題材は手順を示すために作った小さな例で、移行先はPythonです。旧Perlと新Pythonの出力が一致しない箇所が実際に出るところまで、実行結果つきで追います。

読解の起点は「入出力の組」に置く

Perlのコードから読み始めると、正規表現とグローバル変数の追跡で時間が消えます。起点を変えます。最初に洗い出すのは次の6項目です。

  • コマンドライン引数(shift、@ARGV)
  • 読むファイルとその書式
  • 書くファイルと標準出力・標準エラー
  • 終了コード
  • 環境変数と作業ディレクトリへの依存
  • 実行日時など、実行のたびに変わる値

題材のスクリプトでは、引数が2つ(データのディレクトリと対象年月)、読むのがsales.dat、書くのがsummary_<年月>.txtです。この棚卸しだけで、後でテストに入れる固定データの形が決まります。

Claude Codeに洗い出させるときは、まだ何も書き換えさせません。plan modeなら、ファイルを読んで計画を出すところまでで編集は止まります。

claude --permission-mode plan

最初のプロンプトは、読者の側で決めたい範囲を先に伝える形にします。ドキュメントでも、ファイル名と「直った状態」を先に言うほうが後の修正が減ると説明されています。

legacy/monthly.pl を読んで、実行の入出力だけを表にして。
引数、読むファイル、書くファイル、標準出力、終了コード、環境依存の6項目。
処理内容の説明は不要。コードは変更しない。
推測した項目には「推測」と付けて。

「推測と付ける」の一言が効きます。後の仕様書で、確定した事実と読み取りの推測を分けられるからです。

旧スクリプトを動かして正解データを作る

仕様書より先に、旧スクリプトの出力を保存します。この出力が、以降の移行で唯一の正解になります。

実行の前に確認しておきたい点が2つあります。

  1. perl -cは無害な構文チェックではありません。perldoc perlrunによると、構文だけ調べて終了しますが、BEGINブロックとuse文は実行されます。INITとENDだけが飛ばされます。古いスクリプトがuseでモジュールを読み込んだ時点で何かをする作りだと、-cでも副作用が出ます。
  2. 古いスクリプトは、引数のディレクトリにそのまま出力ファイルを書きます。本番のデータで動かさず、固定データのコピーに向けます。

Claude Codeに実行させるなら、Bashのsandboxが書き込み先を作業ディレクトリと一時ディレクトリに絞ります。ネットワークは、許可したドメインの一覧を通る経路に限られます。旧スクリプトの挙動を確かめる実行は、この枠の中で回すのが無理のない選択です。

固定データは、題材では次のように作りました。コメント行、空行、カンマ入りの数量、通貨記号つきの単価、対象月外の行を、あえて混ぜています。

# date|code|qty|price
20261001|A100|1,200|3.5
20261002|B200|10|¥120
20261002|A100|5|3.5
 
20261105|C300|1|9
20261003|B200|3|120

perl 5.34.1でperl monthly.pl <dir> 202610を実行すると、summary_202610.txtは次の内容になります。

A100       2      4217.50
B200       2      1560.00
TOTAL    2 5777.50

この入力と出力の組をfixtures/basic/に置きます。ここまでは、仕様の文章を1行も書いていません。

題材の旧スクリプト monthly.pl(全文)
#!/usr/bin/perl
# monthly.pl  月次売上集計 (1998 Y.K. / 2004 改修)
$dir = shift; $ym = shift;
open(F, "$dir/sales.dat") || die "no sales.dat";
while (<F>) {
  chomp; next if /^#/ || /^\s*$/;
  ($d,$code,$q,$p) = split /\|/;
  next unless $d =~ /^$ym/;
  $q =~ s/,//g; $p =~ s/[^\d.]//g;
  $amt = $q * $p;
  $t{$code} += $amt; $n{$code}++;
  $all += $amt;
}
close(F);
open(O, ">$dir/summary_$ym.txt");
foreach $k (sort { $t{$b} <=> $t{$a} || $a cmp $b } keys %t) {
  printf O "%-8s %3d %12.2f\n", $k, $n{$k}, $t{$k};
}
print O "TOTAL    ", scalar(keys %t), " ", sprintf("%.2f", $all), "\n";
close(O);

Deparseで、コードに書かれていない動作を表に出す

Perlの読みにくさの半分は、書かれていない動作にあります。暗黙の$_、splitの暗黙の引数、orと||の優先順位。ここでB::Deparseが役に立ちます。Perlがコンパイルした内部構造からソースを復元する仕組みで、-pを付けると括弧を省略せずに出すため、perlがどう解釈したかが見えます。

perl -MO=Deparse,-p monthly.pl

題材の出力の冒頭は次のとおりです。

($dir = shift(@ARGV));
($ym = shift(@ARGV));
(open(F, ("$dir/sales.dat")) or die('no sales.dat'));
while (defined(($_ = readline(F)))) {
    chomp($_);
    ((/^#/ || /^\s*$/) and (next));
    (($d, $code, $q, $p) = split(/\|/, $_, 5));

元のソースにあったsplit /\|/;が、split(/\|/, $_, 5)と復元されています。代入先が4つのとき、Perlは暗黙に上限を5にします。5つ目以降の項目は切り捨てずに5つ目へ丸められるため、5列以上の行でも$pは4列目の値になります。Python側ではsplit("|", 3)と書いて4要素に収める必要があり、split("|")では5列の行で代入が失敗します。

B::Deparseの出力は元のソースと同じではありません。コメントと空白は失われます。ドキュメントにも、完全には一致しないが近い形になるとあります。仕様の根拠にするのは、あくまで「perlはこう解釈した」という補助情報までです。Claude Codeには、このDeparseの出力と元のソースを並べて渡し、暗黙の動作だけを列挙させます。

仕様は「外から見える振る舞い」だけを箇条書きにする

仕様書は、実装の言い換えでなく観測できる振る舞いで書きます。「$t{$code}に加算する」は実装、「コードごとに金額を合計する」は振る舞いです。Perlの変数名を仕様書に持ち込むと、移行先のコードも同じ構造に引きずられます。

題材から起こした仕様は、次のようになります。各項目の末尾に、根拠の強さを付けます。

  • 引数は、データディレクトリ・対象年月の2つ(確定)
  • 年月は正規表現として解釈され、日付列の先頭に一致した行だけが対象になる(確定。.のような文字もそのまま働く)
  • #で始まる行と、空白だけの行は読み飛ばす(確定)
  • 列は|区切りで、日付・コード・数量・単価の4列。5列以上の行でも4列目までを使う(確定。Deparseのsplit(…, 5)で裏づけ)
  • 数量からカンマを除く。単価は数字と.以外をすべて除く(確定)
  • 金額は数量×単価で、コードごとに合計と件数を持つ(確定)
  • 並びは合計の降順、同額ならコードの昇順(確定)
  • 出力先は<dir>/summary_<年月>.txt。コード行は%-8s %3d %12.2f、最後にTOTAL行(確定)
  • 出力ファイルを書けないときの挙動(推測。openの失敗を調べていない)
  • 改行がCRLFの入力を渡したときの挙動(要確認。固定データがまだ無い)

最後の2行が大事です。仕様書に「分からない」を残すと、Claude Codeが自信ありげに穴を埋めることを防げます。要確認の項目は、固定データを足して旧スクリプトを動かし、確定に変えていきます。

仕様の穴を人間の側から埋めたいときは、ドキュメントにある「インタビューさせる」型が合います。

legacy/monthly.pl の仕様で「推測」と「要確認」の項目について、私に質問して。
質問は1つずつ、選択肢つきで。答えが出た項目から SPEC.md に確定として書き足して。

SPEC.mdができたら新しいセッションを開き、そこから移行を始めます。会話の履歴を持ち込まず、書かれた仕様だけを入口にするためです。

移行テストは、新旧の出力を比べるスクリプトにする

テストに使う比較は単純です。同じ固定データを旧と新のそれぞれ別ディレクトリにコピーし、同じ引数で実行して、できた出力ファイルをdiffにかけます。

cat tests/compare.sh
#!/bin/sh
# usage: compare.sh <fixture-dir> <YYYYMM>
set -e
rm -rf .cmp_old .cmp_new; mkdir .cmp_old .cmp_new
cp "$1/sales.dat" .cmp_old/; cp "$1/sales.dat" .cmp_new/
perl legacy/monthly.pl .cmp_old "$2"
python3 port/monthly.py .cmp_new "$2"
diff -u .cmp_old/summary_$2.txt .cmp_new/summary_$2.txt \
  && echo IDENTICAL

題材の基本データでは、Pythonへ素直に移したコードでIDENTICALが出ます。ここで終わると「移行できた」と判断しがちですが、基本データは作者が想像できた範囲にすぎません。

落とし穴

全角数字の単価で、PythonがPerlと違う金額を出す

¥120の¥は、どちらも除かれます。差が出るのは単価が120のように全角数字で書かれた行です。旧スクリプトはuse utf8もbinmodeも持たず、入力をバイト列として読むため、s/[^\d.]//gは全角数字を数字と見なさず除きます。単価が空になり、金額は0です。Pythonは文字列を読み込む時点でUnicodeとして扱い、\dが全角数字にも一致するため、120として計算します。

全角の単価を含む小さな固定データfixtures/fullwidth/を用意します。sales.datは2行だけです。

20261001|A100|2|120
20261002|B200|1|10

この入力で新旧を比べると、実際に次の差が出ました(perl 5.34.1、Python 3.12.2)。

--- .cmp_old/summary_202610.txt
+++ .cmp_new/summary_202610.txt
@@ -1,3 +1,3 @@
+A100       1       240.00
 B200       1        10.00
-A100       1         0.00
-TOTAL    2 10.00
+TOTAL    2 250.00

旧スクリプトではA100の金額が0.00になり、合計の順位も入れ替わります。この結果を「バグ」と呼ぶかどうかは、移行の方針です。旧と同じ出力を維持するなら、Python側の正規表現にre.ASCIIを付けます。

p = re.sub(r"[^\d.]", "", p, flags=re.ASCII)

付けて再実行するとIDENTICALになりました。全角数字を正しく読む方向へ直すなら、仕様書に「全角数字の単価は数値として扱う(旧との差分)」と書き、テストの期待値も変えます。どちらを選んでも、決めるのは人間です。Claude Codeには、差分と選択肢を示して判断を仰がせます。

diffが空になるまで終わらせない

ここまでの比較を、Claude Codeが自分で回せる形にします。ドキュメントでは、完了の確認方法を3段階で整理しています。プロンプトで頼む、/goalで条件を置く、StopフックでClaude Code側が強制する。移行では、固定データが増えるほど「確認し忘れ」が増えるため、Stopフックが向きます。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "sh tests/compare_all.sh 1>&2 || exit 2" }
        ]
      }
    ]
  }
}

compare_all.shは、fixtures/*/を順にcompare.shへ渡し、1つでもdiffが空でなければ非0で終わるスクリプトです。終了コード2でClaudeの停止が止まり、標準エラーに出た差分が理由として渡されます。差分を見たClaude Codeは、Pythonを直して再び実行します。

止まり方に上限があることは覚えておきます。連続8回ブロックしても、間にツール呼び出しが無ければ、次のブロックは無視されてターンが終わります。上限はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられます。移行で全角数字のような判断待ちの差分に当たると、直しようのない差分を前に何度もブロックされる場面があり得ます。判断が要る差分は、フックの前に人間へ回します。

スクリプトが何十本もあるときは、3本で試してから流す

1本で型ができたら、残りは非対話モードで回します。ドキュメントのループ例と同じ作りです。

for f in $(cat scripts.txt); do
  claude --bare -p "legacy/$f を読み、tests/spec-template.md の形で
specs/$f.md に仕様を書く。コードは変更しない。" \
    --allowedTools "Read,Write" \
    --permission-mode dontAsk \
    --max-turns 20
done

注意点が3つあります。

  • --bareはCLAUDE.md、フック、プラグイン、MCPサーバーの自動読み込みを止めます。仕様の書き方などの前提は、プロンプトか--append-system-promptで明示的に渡す必要があります。
  • --allowedToolsで許可した道具以外は、dontAskにより拒否されます。仕様を書く段階では、書き込みをspecs/だけにする設定のほうが安全です。
  • 最初は3本程度で試します。ドキュメントも、3ファイルで試して失敗の傾向を見て、プロンプトを直してから残りを回す進め方を勧めています。

仕様書づくりは非対話で量産できても、テストの合否は別です。移行コードを書く段階は、スクリプトごとにcompare.shが通るところまでを1単位にします。

人間が決める部分

読解と比較は機械に任せられても、次の判断は残ります。

  • 旧の挙動を保つか、直すか(全角数字の例)
  • 本番のデータ量・実行頻度・cronの設定など、リポジトリに無い運用情報
  • 出力を受け取る側(別システムや担当者)が、出力の細部に依存していないか

移行全体の進め方は、COBOLを例にしたClaude Codeのコードモダナイゼーションにまとめています。出力の突き合わせを検証ループに固定する発想は、Claude CodeでCOBOLを移すと同じ形です。移行先が管理者向けのスクリプト言語になる場合は、PowerShellのPesterテストを完了条件にする手順が参考になります。旧スクリプトの試し実行を囲う枠の選び方は、Claude Codeのサンドボックスの選び方にあります。

まとめ

Perlの移行で最初に作るのは、コードの解説でなく、新旧で比べられる入出力の組です。それがあれば、仕様書は組から起こせて、「分からない」を明示した行が次の固定データの候補になります。全角数字の例が示すとおり、基本データの一致は検収の根拠になりません。差が出そうな入力を足しながらIDENTICALを保つ作業が、移行の中身になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. code.claude.com/docs/en/headless
  3. code.claude.com/docs/en/sandboxing
  4. code.claude.com/docs/en/common-workflows
  5. perldoc.perl.org/perlrun
  6. perldoc.perl.org/B::Deparse
この記事を共有:XはてブLinkedIn