2つのファイルに同じ関数をコピペしたことはありませんか。
そして数週間後、片方だけ直して、もう片方を直し忘れる。原因を追ったら「あ、こっちのコピーが古いままだ」——この現象を、ここでは ドリフト と呼びます。給料明細には載らないけど、確実に時間を溶かしているやつです。
普通は「共通モジュールに切り出して import すればいい」で終わる話です。ところが、その逃げ道が物理的に塞がれている場所があります。私たちが踏み抜いたのは、まさにそういう環境でした。
playpark は2人の会社です。AI に開発タスクを任せる自動パイプライン(issue 分析 → 計画 → 実装 → 評価 → PR)を内製しており、制御フローを書くファイルが2つあります。両方が同じ「品質判定ロジック」を必要とするのに、当初は両ファイルに同じ関数を手書きで複製していました。結果は、お察しの通りです。
前提を一言。これらの制御フローファイル(Claude Code の .claude/workflows/*.js。Pro $20/月、Max $100〜200/月で運用。料金は公式 pricing 参照)は普通の JavaScript ですが、import も require() も使えません。workflow ランタイムが隔離環境で実行するためです(公式は「isolated environment で実行」「ファイル・シェルアクセス不可」と説明)。さらに再現性を守るため、canonical 側に Date.now() や Math.random() も書かない規約を敷いています。共通モジュールに切り出して読み込む、という当たり前の手が封じられている——これが「逃げ道なし」の正体です。
この記事では、その極端な制約下で編み出した 正本1ファイル → 生成器で全文コピー → 差分は CI で検出 という設計を紹介します。import が使えるプロジェクトでも、import では消せない重複(後述)に効く汎用解です。
この記事で学べること
- コピペ・ドリフトの正体
importが使えない極端なケースで DRY をどう守るかsync-inlines生成器の設計--checkで CI 検出する全文一致テストの作り方importが使える一般的なプロジェクトへの転用
「2箇所に同じものがある」は、こうして事故る
抽象論で終わらせないため、実際に起きたドリフトを挙げます。いずれも「コピペした2つが片方だけ更新される」という、最初に書いた痛みそのものです。
ひとつは breaking 判定の正規表現でした。同一の正規表現が共通モジュール由来のものと merge tier 判定の手書きのものとで2箇所に存在し、語彙を1つ追加するたび片方だけ更新される drift 源でした。「複製・死コード掃除」のリファクタで isBreakingText(s) を _lib/triviality.mjs に抽出し解消しています。同じバンドルの analyzePrompt(depth) も逐語複製され、片方の末尾に全角スペースが混入する空白 drift が発生していました。
もうひとつは stuck 検出ロジックです。「同じ問題が何度も差し戻される=ループが詰まっている」を検出するコードでした。対象は planSeen / blockSeen / evalSeen(dev-flow 側)と reviewSeen(pr-iterate 側)。_lib/stuck-detector.mjs を canonical として新設し stuckTopicKey / makeSeenTracker に集約しています。
そして象徴的だったのが、品質ゲートのモデルを指定する QUALITY_MODEL 定数です。dev-flow.js と pr-iterate.js の冒頭に二重定義され、コメントで相互参照する手動同期でした。「新モデルの短期実験を終えて元に戻すとき、片方だけ書き換える事故」が現実に想定されていました。canonical 側のコメントには「Fable 5 試験運用中は 'fable'、戻すときはこの 1 行を 'opus' にする」と明記されています。同じ手順は AGENTS.md にも記録されています。その1行が2ファイルにある——これは規律の穴でした。
共通点は明白です。「2箇所に同じものがある」状態は、いつか必ず1箇所だけ変更される。 コピペした瞬間は誰も「将来ズレるぞ」とは思いません。
逃げ道がないとき、選択肢は3つしかない
普通なら _lib/ に切り出して 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: に挟まれた区間は生成物で直接触りません。編集するのは _lib/quality-model.mjs という正本(canonical)側だけ。node tools/sync-inlines.mjs --write で正本の中身がマーカー区間へ全文流し込まれます。
生成器 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 側は普通の ES module として書き、ユニットテストから直接テストできます。inline 生成時は先頭の export だけを剥がすので、import 禁止の workflow にそのまま貼っても動きます。「テストでは module、本番では inline」を1つの canonical で両立させています。
checkForbiddenTokens も重要です。canonical にうっかり import を書くと生成時に fail-fast で弾きます。コメント除去後に走査するので Date.now の誤検出もありません。
scanMarkers は地味に厳格です。ネストした BEGIN、END の欠落、パス不一致、同一 canonical の二重 inline——これらをすべて明示 error にします。マーカーが壊れた状態を「なんとなく動く」で通さない設計です。
ドリフトを CI で検出する: 全文一致テストと --check
生成方式の肝は、「生成し忘れ」を検出できることです。canonical を直したのに --write を忘れたら結局ドリフトします。これを防ぐのが2層のテストです。
ひとつめは --check モード。生成結果が現在のファイルと1バイトでも違えば exit 1 を返します。CI で回せば反映漏れの PR は赤になります。
node tools/sync-inlines.mjs --check
# inline 区間が canonical と全文一致していなければ exit 1
ふたつめは TDD pin テストです。_lib/quality-model.sync.test.mjs は scanMarkers を使い、両ファイルに BEGIN/END マーカーが存在することを検証します。これが守るのは「マーカー区間を消して手書きに書き戻す」という退行です。全文一致は _lib/workflow-inlines.sync.test.mjs が CI で保証します。
リポジトリのルールを要約すると、マーカー区間は生成物であり直接編集禁止。編集は _lib 側で行い --write で再生成します。blame は _lib 側を見る。 この一文が AGENTS.md に明記されています。
生成器に、後から「安全装置」を足す
生成器導入時点では、マーカー整合・forbidden token・export 形式は検査していましたが、結合後の生成物の妥当性は見ていませんでした。複数 canonical を単一スコープへ流し込むと識別子衝突や構文崩れが起き、後段テストでようやく気づく状態でした。
そこで生成後検証を追加しました。syncRepo の最後で結合ソースを構文検証し、全宣言名を集めて重複検出し、衝突があれば明示 error にします。
手書き複製ではドリフトが「人間の注意力」という縛れない対象に逃げますが、生成方式ではすべてテストで縛れる対象に移動します。
import が使えるプロジェクトでも、これは効く
import で消せる重複は消すべきですが、import では消せない重複もどんなプロジェクトにも地味に存在します。
- 言語をまたぐ定数: 正規表現やエラーコードを、TypeScript のフロントと Go/Python のバックの両方に持つケース。
importは言語の壁を越えません。 - 生成・ビルド成果物: OpenAPI から生成した型、protobuf。手で触れる場所と生成元がズレます。
- パッケージ境界をまたぐ共有: monorepo で、切り出すほどでもない小さな定数を2パッケージが必要とするケース。
共通するのは「import が届かず、コピペ運用になる」点です。本記事の3点セット——正本を1箇所に決める / 生成器で配る / CI で全文一致検証——をそのまま当てられます。
注意点・Tips
- 生成区間を直接編集しない規律は AI のほうが守りやすい: マーカーコメントに「直接編集禁止。
_libを編集して--write」と書いておくと素直に従います。 - canonical の依存はテスト側に逃がす: 依存もまとめて inline するか純関数に設計し直すかの二択です。私たちは後者を基本にしています。
--checkを CI 必須ジョブにする:--write忘れでも CI が赤くなる状態を作っておくこと。- 生成器の純関数は単体テストしやすい:
scanMarkersを named export しておくと、「壊れたマーカー → error」を直接テストできます。
まとめ
出発点は「コピペした2つが片方だけ更新されズレる」痛みでした。私たちは import が使えない極端な環境でこれに正面衝突しましたが、本質は環境を選びません。
QUALITY_MODEL 定数、stuck 検出ロジック、breaking 判定の正規表現——いずれも「2箇所にあるから、いつかズレる」ものでした。それを1箇所(正本)に集め sync-inlines で配り、--check と全文一致テストで監視する。DRY を生成 + 検証のパイプラインで実現したわけです。
ポイントは、ドリフト防止を「人間の注意力」から「機械検出」へ動かしたことです。



