Quartet / Quintet

2026-09-05  ·  設計 · リファクタリング · python · 個人開発 · 保守

同じ判断を2か所に書くと、片方だけが直る

売り場が2つあります。国内向けと海外向けで、海外向けはまだ開いていません。

そこで「海外向けが開いていなければ、国内の店へ送る」という方針を決めました。 国内の店には英語の UI があり、海外のカードも通ります。 買える場所がゼロであるより、日本の店でも繋がっているほうがよい。

この方針を、記事の末尾を生成する関数に書きました。

if store.get("gumroad"):
    parts.append(f"The full kit is available here.\n\n{store['gumroad']}")
elif store.get("booth"):
    # 海外の売り場が開くまでは国内へ送る
    parts.append(f"The full kit is on BOOTH ...\n\n{store['booth']}")

記事24本に反映されました。全部「ここで買えます」と書いてあります。

リンク先が「準備中」と言っていた

製品ページのボタンを書き換える関数は、別のところにありました。

url = store.get("booth") if name == "index.html" else store.get("gumroad")
new_btn = off if not url else f'<a class="btn" href="{url}">{label}</a>'

英語版のボタンは gumroad しか見ていません。まだ開いていないので None です。

<a class="btn" href="#" aria-disabled="true">Purchase Quintet (Coming soon)</a>

つまり、こうなっていました。

記事24本  「ここで買えます」→ 製品ページへリンク
製品ページ 「準備中」

読者は記事から製品ページへ来ます。 順番が最悪です。 記事を読んで、興味を持って、リンクを踏んだ人だけが「準備中」と言われる。 いちばん買う気のある読者を、買える店の手前で追い返していました。

「同じ判断」だと気づいていなかった

後から見れば、この2つは同じことを決めています。

この言語の読者を、どの売り場へ送るか。

なぜ気づかなかったか。片方は文章を組み立てる関数で、片方は HTML の属性を 書き換える関数だったからです。

def buy_line(store, lang) -> str:      # Markdown の段落を返す
def rewrite_lp(store) -> int:          # HTML を正規表現で置換する

引数も、戻り値も、扱うフォーマットも違います。 コードの見た目が違うと、同じ方針を実装しているという事実が意識に上りません。

重複は「同じコードが2回書いてある」ことだと思われがちですが、 **実際に危ないのは「同じ判断が別々の書き方で2回書いてある」**ほうです。 前者は grep で見つかります。後者は見つかりません。

捕まえたのは push 直前の diff だった

テストではありません。push する前に差分を読んだからです。

-<a class="btn" href="https://quartet-dev.booth.pm/items/...">Purchase Quintet</a>
+<a class="btn" href="#" aria-disabled="true">Purchase Quintet (Coming soon)</a>

生成物をリポジトリに入れていて、生成し直すと差分が出ます。 「意図した変更以外が混ざっていないか」を毎回見るのが、 この種のバグに対しては一番効きました。

意図していたのは記事15本目の追加です。ボタンが無効化されるはずがありません。 意図と差分が食い違ったときだけ気づけます。 差分を見ない運用なら通っていました。

行き先は1か所、見せ方は分けたまま

直し方には幅があります。2つの関数を1つに統合するのは行き過ぎです。 片方は Markdown を返し、片方は HTML を置換します。統合すると分岐が増えるだけです。

共通化するのは「判断」だけにします。

def destination(store: dict, lang: str) -> str | None:
    """その言語の読者を送る売り場の名前。開いていなければ None。

    以前は2か所に書いていて、フッタだけにフォールバックを足した結果、
    記事は「買える」と言い、LP は「準備中」と言う状態になった。
    行き先を決めるのはここ1か所。見せ方は呼ぶ側が決める。
    """
    order = ("booth",) if lang == "ja" else ("gumroad", "booth")
    for key in order:
        if store.get(key):
            return key
    return None

返すのは URL ではなく売り場の名前です。 呼ぶ側は、どこへ送るかではなくどこへ送ることになったかを知る必要があります。

見せ方の違いは残したまま、判断だけが1か所になります。

# 記事側
where = destination(store, "en")
if where == "gumroad":
    ...
elif where == "booth":
    ...

# LP 側
where = destination(store, "ja" if name == "index.html" else "en")
url = store[where] if where else None

分岐を全部通す

売り場は2つ、それぞれ開いているかいないかで4通りです。 全部回して、記事と LP が同じことを言うか確かめました。

booth=無 gumroad=無 -> 英LP=#            日LP=#            英記事=なし
booth=有 gumroad=無 -> 英LP=BOOTH        日LP=BOOTH        英記事=booth
booth=無 gumroad=有 -> 英LP=Gumroad      日LP=#            英記事=gumroad
booth=有 gumroad=有 -> 英LP=Gumroad      日LP=BOOTH        英記事=gumroad

2行目が今回のバグです。修正前はここで英 LP だけが # になっていました。

4通りしかないので全部回せます。組み合わせが数えられるうちは、 代表値を1つ選ぶのではなく全部通したほうが速い。

最後に、元の状態へ戻して差分がゼロになることも確認しました。 検査のために設定を書き換えるので、戻し忘れると本番のリンクが壊れます。

まとめ


あわせて読む


Claude Code に設計・実装・レビューを別々の人格として分担させ、GitHub Issue と ブランチを軸に並列開発を回すための設定一式を MIT で公開しています。 コピーして ./setup.sh を叩けば動きます。技術スタックには依存しません。

https://github.com/quintetkit/quartet

このワークフローだけで実際にツールを 1 つ作りました。Issue の分割から PR、 レビュー、マージまで記録が全部残っています。うまくいかなかった箇所も消していません。

https://github.com/quintetkit/mdlinkcheck

UI 設計人格・レビュー基準・Issue 単位の並列実行スクリプト・実践ガイド 10 章を 足したものは製品ページにあります。

実践ガイド全10章は Zenn Book で読めます(¥1,500・2章まで無料)。

https://zenn.dev/quintetkit/books/claude-code-parallel-workflow

設定一式(5人格・スクリプト・ガイド全文)の配布はこちらです。

https://quartet-dev.booth.pm/items/8807156

この運用そのものを配っています

4 人格版 Quartet は MIT で無料公開しています。UI 設計人格・レビュー基準・ Issue 単位の並列実行スクリプト・実践ガイド 10 章を足した Quintet は有料版です。

無料版を見る 製品ページ