2つのファイルに、同じ関数をコピペしたことはありませんか。
そして数週間後、片方だけを直して、もう片方を直し忘れる。テストは通るのに挙動がなぜか食い違う。原因を追ったら「あ、こっちのコピーが古いままだ」——この、コピペした2つがいつの間にか別物になっていく現象を、ここでは ドリフト と呼びます。給料明細には載らないけど、確実に時間を溶かしているやつです。半日かけて追った原因が「コピーの片方が古かっただけ」だった、という脱力する経験、ありませんか?
普通は「共通モジュールに切り出して import すればいい」で終わる話です。ところが、その逃げ道が物理的に塞がれている場所があります。私たちが踏み抜いたのは、まさにそういう環境でした。だからこそ「コピペに頼らずに DRY を守る方法」を、逃げ道なしで設計するハメになりました。
playpark は2人の会社です。AI に開発タスクを任せる自動パイプライン(issue 分析 → 計画 → 実装 → 評価 → PR)を内製していて、その制御フローを書くファイルが2つあります。両方が同じ「品質判定ロジック」を必要とするのに、当初は両ファイルに同じ関数を手書きで複製し、「片方を直したらもう片方も直す」とコメントで自分に念を押していました。結果は、お察しの通りです。
先に前提を一言だけ。これらの制御フローファイル(Claude Code の .claude/workflows/*.js。私たちはサブスクリプション枠の Pro $20/月、Max $100〜200/月で運用しています。料金は公式 pricing 参照)は、見た目は普通の JavaScript ですが、import も require() も使えません。workflow ランタイムがスクリプトを会話とは独立した隔離環境で実行する仕組みで、現状この loader が ESM import に対応していないためです(公式には「isolated environment で実行」「workflow 自身からのファイル・シェルアクセスは不可」と説明されています。ハーネス側の制約なので、将来 import が解禁されたら畳む前提のものです)。さらに私たちのリポジトリでは、生成物の再現性を守るため canonical 側に Date.now() や Math.random() も書かない規約を敷いています。いずれにせよ、共通モジュールに切り出して読み込む、という当たり前の手が封じられている——これが、コピペ以外の選択肢を奪う「逃げ道なし」の正体です。
この記事では、その極端な制約下で編み出した 正本1ファイル → 生成器で各ファイルへ全文コピー → 差分が出たら CI で検出 という設計を紹介します。そして大事なのは、これが「import が使えない特殊環境」専用の裏技ではないこと。import が普通に使えるプロジェクトでも、import では消せない重複(後述)に効く汎用解です。
この記事で学べること
- コピペ・ドリフトの正体と、なぜ「2箇所に同じものがある」状態が必ず事故るのか
- 共通モジュールへの切り出しが使えない極端なケースで、DRY をどう守るか
sync-inlines生成器の設計 — マーカー区間に正本を全文流し込む方式- 生成物のドリフトを
--checkで CI 検出する「全文一致テスト」の作り方 - この設計を、
importが使える一般的なプロジェクトへ転用する勘どころ
「2箇所に同じものがある」は、こうして事故る
抽象論で終わらせると説得力がないので、私たちのリポジトリで実際に起きたドリフトを挙げます。いずれも「コピペした2つが片方だけ更新される」という、最初に書いた痛みそのものです。
ひとつは breaking 判定の正規表現でした。「この変更は破壊的か」を判定する同一の正規表現が、共通モジュール由来のものと merge tier 判定の手書きのものとで2箇所に存在していました。語彙を1つ追加するとき、片方だけ更新される drift 源です。実際にこれは「複製・死コード掃除」のリファクタで isBreakingText(s) という関数を _lib/triviality.mjs に抽出し、生成区間に置き換えることで解消しています。同じバンドルでは、analyzePrompt(depth) という分析プロンプトも2箇所に逐語複製されていて、片方の末尾に全角スペースが混入する空白 drift が既に発生していました。
もうひとつは stuck 検出ロジックです。「同じ問題が何度も差し戻される=ループが詰まっている」を検出する planSeen / blockSeen / evalSeen(dev-flow 側)と reviewSeen(pr-iterate 側)のコードが、両ファイルに手書きで複製されていました。これも _lib/stuck-detector.mjs を canonical として新設し、stuckTopicKey / makeSeenTracker の2関数に集約して inline 生成へ移行しています。canonical のコメントには「goal-ledger.mjs の topicKey と同一ファイル dev-flow.js に inline されるため識別子衝突を避けて stuckTopicKey と命名」という、複製ならではの地雷を回避した痕跡まで残っています。
そして象徴的だったのが、品質ゲートに使うモデルを指定する QUALITY_MODEL 定数です。これは dev-flow.js と pr-iterate.js の冒頭に二重定義され、コメントで相互参照する手動同期でした。実害として「新モデルの短期実験を終えて元のモデルに戻すとき、片方だけ書き換える事故」が現実に想定されていました。canonical 側のコメントには「Fable 5 試験運用中は 'fable'、戻すときはこの 1 行を 'opus' にする」と明記され、リポジトリ規約(AGENTS.md)にも同じ手順が記録されています。新モデルの試験運用と撤退を定数1行でやりたいのに、その1行が2ファイルにある——これは規律の穴でした。
共通点は明白です。「2箇所に同じものがある」状態は、いつか必ず1箇所だけ変更される。 それが今日でなくても、半年後の自分が、あるいは AI が、古いドキュメントに誘導されてやらかす。コピペした瞬間は誰も「将来ズレるぞ」とは思わない。だから余計にタチが悪いわけです。
逃げ道がないとき、選択肢は3つしかない
普通なら _lib/ に切り出して import で解決——ですが、前述のとおりこの環境では import が使えません。残った選択肢を並べると、こうなります。
| 選択肢 | 内容 | 問題点 |
|---|---|---|
| 手書き複製 | 各ファイルに同じコードを貼る | ドリフトする(上の実例) |
| 巨大1ファイル化 | 共通化をあきらめ全部1ファイルに | 実際 orchestration 本体は2千行超でメンテ困難 |
| ✅ 生成 | 正本1ファイルを生成器で各ファイルへ流し込む | 生成器の正しさが新たな関心事になる |
私たちは3番目を採りました。「正しさが新たな関心事になる」のは事実ですが、それはテストで縛れる対象です。一方、手書き複製のドリフトは人間の注意力という最も信用できないものに依存します。問題を「縛れる場所」へ動かす——これが設計の芯です。
sync-inlines: 正本を「全文流し込む」生成器
採用した方式はシンプルです。生成先のファイルの中に、こういうマーカー区間を置きます。
// ==== BEGIN inline: _lib/quality-model.mjs (生成区間 — 直接編集禁止。_lib を編集して tools/sync-inlines.mjs --write) ====
const QUALITY_MODEL = 'opus'
// ==== END inline: _lib/quality-model.mjs ====
BEGIN inline: と END inline: に挟まれた区間は生成物で、人間も AI も直接触りません。編集するのは _lib/quality-model.mjs という正本(canonical)側だけ。そして node tools/sync-inlines.mjs --write を実行すると、正本の中身がマーカー区間へ全文流し込まれます。以降「canonical」は、この「コピー元として1箇所に決めた正本」の意味です。
生成器 tools/sync-inlines.mjs の中核は、次の関数に分かれています(前3つは入力を受けて値を返す純関数、syncRepo だけが実際のファイル I/O を担うオーケストレータです)。
| 関数 | 役割 |
|---|---|
scanMarkers(wfSrc, label) | BEGIN/END ペアを解析し {source, beginLine, endLine} を返す |
checkForbiddenTokens(src, label) | canonical に import / require() / Date.now / Math.random があれば error |
transformCanonical(src, label) | 宣言の先頭 export を剥がし、末尾改行を1つに正規化 |
syncRepo(root, {write}) | 全 workflow ファイルを走査して区間を置換 |
transformCanonical の仕事が地味に効いています。canonical 側は export const QUALITY_MODEL = 'opus' のように普通の ES module として書き、ユニットテストからは import して直接テストできます。inline 生成時には先頭の export だけを剥がすので、import が禁止された workflow の単一スコープにそのまま貼っても動きます。「テストでは module、本番では inline」を1つの canonical で両立させているわけです。
checkForbiddenTokens も重要です。canonical にうっかり import を書くと、生成された workflow が loader 制約に違反します。それを生成時に fail-fast で弾く。しかもコメントを除去してから走査するので、コメント中の Date.now を誤検出しません。
scanMarkers は地味に厳格で、ネストした BEGIN、対応する END の欠落、BEGIN/END のパス不一致、同一ファイル内での同じ canonical の二重 inline——これらをすべて明示 error にします。マーカーが壊れた状態を「なんとなく動く」で通さない設計です。
ドリフトを CI で検出する: 全文一致テストと --check
生成方式の肝は、「生成し忘れ」を検出できることです。canonical を直したのに --write を実行し忘れたら、結局ドリフトします。これを防ぐのが2層のテストです。
ひとつめは --check モード。生成を実行してみて、結果が現在のファイルと1バイトでも違えば exit 1 を返します。CI でこれを回しておけば、「canonical を直したが workflow に反映していない PR」は赤になります。
node tools/sync-inlines.mjs --check
# inline 区間が canonical と全文一致していなければ exit 1
ふたつめは TDD pin テストです。たとえば _lib/quality-model.sync.test.mjs は、dev-flow.js と pr-iterate.js の両方に _lib/quality-model.mjs の BEGIN/END マーカーが存在することを scanMarkers で検証します。これが守るのは「誰かがマーカー区間を消して、また手書きの const QUALITY_MODEL を書き戻す」という退行です。生成方式そのものを剥がそうとする変更を、テストが叩き落とします。区間ごとの全文一致は _lib/workflow-inlines.sync.test.mjs が CI で保証する、という役割分担です。
ここまでで、リポジトリのルールはこう要約できます。.claude/workflows/*.js 内のマーカー区間は生成物であり直接編集禁止。編集は _lib 側で行い --write で再生成する。blame は _lib 側を見る。 この一文が AGENTS.md(リポジトリ規約)に明記され、AI エージェントもこの規約を読んでから作業します。
生成器に、後から「安全装置」を足す
面白いのはここからです。生成器を導入した時点では、マーカー整合・forbidden token・export 形式は検査していましたが、結合後の生成物の妥当性は見ていませんでした。複数の canonical を単一トップレベルスコープへ流し込んだ結果、識別子が衝突したり構文が壊れたりしても、生成器は素通しで、ずっと後段のテストでようやく気づく状態だったのです。
そこで生成後検証を追加しました。syncRepo の最後で結合ソースを構文検証し、全 inline 区間のトップレベル宣言名を集めて重複を検出し、衝突があれば明示 error にする。先ほどの「topicKey と stuckTopicKey の衝突回避が canonical 内の手動コメント頼みだった」という不安を、機械検出に格上げした形です。これで「衝突する canonical を用意したら sync が error になる」「構文エラーを生む canonical でも同様」というテストが書けるようになりました。
この「生成器に安全装置を後付けする」流れは、設計判断としても示唆的です。最初から完璧な生成器を作ろうとせず、実際に踏みそうな地雷を1つずつテストで踏み固めていく。手書き複製ではドリフトが「人間の注意力」という縛れない対象に逃げていましたが、生成方式ではドリフトも衝突も構文崩れも、すべてテストで縛れる対象に移動します。
import が使えるプロジェクトでも、これは効く
ここまで読んで「うちは普通に import 使えるから関係ない」と思ったなら、半分正解で半分もったいない。import で消せる重複は、もちろん import で消すべきです。問題は、import では消せない重複が、どんなプロジェクトにも地味に存在することです。
- 言語をまたぐ定数: バリデーションの正規表現やエラーコードを、TypeScript のフロントと Go/Python のバックの両方に持つケース。
importは言語の壁を越えません。 - 生成・ビルド成果物: OpenAPI から生成した型、protobuf、設定ファイルのテンプレート展開。手で触れる場所と生成元がズレる。
- パッケージ境界をまたぐ共有: monorepo で、まだパッケージ化していない小さな定数を2パッケージが必要とする。切り出すほどでもない、でもコピペするとズレる。
- ドキュメントとコードの同期: README のコード例や CLI の
--help文言が、実装と乖離していく。
これらに共通するのは「import という言語機能が届かないので、結局コピペで運用してしまう」点です。そこに本記事の3点セット——正本を1箇所に決める / 生成器で配る / 生成物の全文一致を CI で検証する——をそのまま当てられます。sync-inlines は数百行の素朴なスクリプトで、特別なフレームワークは要りません。「コピペ + コメントでお願い」を「生成 + テストで強制」に置き換えるだけです。
注意点・Tips
- 生成区間を直接編集しない規律は、人間より AI のほうが守りやすい: マーカーコメントに「直接編集禁止。
_libを編集して--write」と書いておくと、AI エージェントは素直に従います。むしろ stale なドキュメントに誘導されて手書きに戻すほうが事故りやすいので、規約と pin テストの両方で縛るのが効きます。 - canonical に
importを書けない制約はテスト側に逃がす: 共通ロジックが他モジュールに依存するなら、その依存もまとめて inline するか、依存を持たない純関数に設計し直すか、二択になります。私たちは後者(純関数化)を基本にしています。 --checkを CI の必須ジョブにする: ローカルで--writeを忘れても CI が赤くなる状態を作っておくこと。これがないと生成方式の意味が半減します。- 生成器の純関数は単体テストしやすい:
scanMarkersなどを named export しておくと、fixture ベースで「壊れたマーカー → error」を直接テストできます。
まとめ
出発点は、誰もが経験する「コピペした2つが片方だけ更新されてズレる」という痛みでした。私たちはたまたま import が使えない極端な環境でこれに正面衝突しましたが、本質は環境を選びません。
QUALITY_MODEL 定数、stuck 検出ロジック、breaking 判定の正規表現——いずれも「2箇所にあるから、いつかズレる」ものでした。それを1箇所(正本)に集め、sync-inlines で各ファイルへ配り、--check と全文一致テストで監視する。DRY を言語機能(import)ではなく生成 + 検証のパイプラインで実現したわけです。
ポイントは、ドリフト防止を「人間の注意力」から「機械が検出できる場所」へ動かしたこと。import が届かない重複——言語をまたぐ定数、生成物、パッケージ境界——を抱えているなら、規模が大きくなるほどこの発想が効いてきます。コピペしてコメントで祈るのは、もう卒業しましょう。



