Quartet / Quintet

2026-09-05  ·  typescript · api · 設計 · 個人開発 · cli

作成時に安全だった既定値が、更新時には破壊的になる

公開済みの記事9本に、後からリンクを足したくなりました。 自作の投稿ツールで一括更新するだけの作業です。

そのツールは、更新すると記事を非公開にしようとしました。

既定値の向きは、操作によって反転する

ツールはこういう設計でした。

const publish = rest.includes("--publish");

const article = {
  title: fm.title,
  body_markdown: body.trim(),
  published: publish,   // frontmatter ではなく明示フラグで決める
  tags: fm.tags ?? [],
};

published を記事の frontmatter から読まず、コマンドラインの --publish で決める。 付けなければ下書きです。

理由はコメントに書いてありました。

既定は下書き。--publish を明示したときだけ公開する。 事故で公開されるより、公開し忘れるほうが安全なため。

新規作成では、これは正しい判断です。 書きかけを誤って世に出すより、 出し忘れて後から出すほうが安い。

更新では、同じ既定値が逆を向きます。

--publish を忘れて既存記事を更新すると、published: false が送られ、 公開中の記事が下書きに落ちます。 URL は残ったまま 404 になります。

同じ「安全側」という言葉が、操作が変わると反対側を指していました。

直し方は「賢く推測する」ではなかった

最初に考えたのは、更新のときだけ既存の公開状態を引き継ぐことでした。 --publish が無ければ、いまの状態を維持する。

やめました。「意図した取り下げ」と「フラグの付け忘れ」を区別できないからです。 区別できないものを推測すると、今度は本当に取り下げたいときに黙って無視されます。

拒否することにしました。

// published は明示フラグで決めるので、--publish 無しの更新は
// 公開済みの記事を下書きへ引きずり下ろす。取り下げは事故のほうが多いので拒否する。
// 誤って下書きに戻すと URL は残るが 404 になり、集めた被リンクが死ぬ。
if (existing?.published && !publish) {
  throw new Error(
    `"${fm.title}" は公開済みです。--publish 無しで更新すると下書きに戻ります。\n` +
    "  更新するなら --publish を付けてください。",
  );
}

実際に付けずに叩いて、終了コード 1 で止まることを確認しました。

取り下げたいなら、取り下げ用の操作を作るべきです。 「更新のついでに起きる」形にしてはいけません。

もう1つ。同一性を、変わりうる値に置いていた

同じツールに、別の穴がありました。既存記事の探し方です。

// 同じタイトルが既にあれば更新、無ければ新規作成
const mine = await call("/articles/me/all?per_page=100");
const existing = mine.find((a: any) => a.title === fm.title);

タイトルで探していました。

同じ日に、frontmatter のパーサのバグを直しました。 title: "Treating \"It Worked\" as ..." の外側の引用符だけ外して、 中のエスケープを戻していなかったので、記事名にバックスラッシュが出たまま公開していた というものです。

// 直す前
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);

// 直した後
if (value.startsWith('"') && value.endsWith('"')) {
  value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
}

直した瞬間、fm.title の値が変わります。 そして既存記事のタイトルは、公開したときの古い値のままです。

つまり、find は一致しません。「既存なし」と判定され、新規作成に落ちます。

もし気づかずに push していたら、内容の同じ記事がもう1本、公開状態で作られていました。

バグを直すと壊れる、という構造

これは「バグを2つ踏んだ」話ではありません。1つを直したら、もう1つが顕在化した話です。

同一性の判定を、そのツール自身が計算している派生値に置いていました。 計算方法を直せば、値が変わります。値が変われば、同一性が壊れます。

同じ形は他にもあります。

「直すと変わりうる値」を同一性に使ってはいけません。

対処: 相手が決めた id を記録する

id は API 側が採番する値で、こちらの都合では変わりません。 一度作った記事の id を記録して持つことにしました。

{
  "_comment": "ファイル名 -> 記事 id。タイトルは変わりうるので、同一性は id で持つ",
  "ids": {
    "composite-github-action": 4576296,
    "linter-false-positives": 4577641
  }
}
const knownId = store.ids[slug];
let existing = knownId ? mine.find((a) => a.id === knownId) : undefined;

if (knownId && !existing) {
  throw new Error(
    `id=${knownId} (${slug}) が見つかりません。\n` +
    "  記事が削除されたか、別アカウントの鍵を使っています。\n" +
    "  そのまま進めると重複記事を作るので止めます。",
  );
}

記録があるのに見つからないときは、止めます。 黙って新規作成に落ちるのが、いちばん困る挙動だからです。

検証

タイトルをわざと変えて push しました。

title: "... (Without npm Publishing) (id lookup test)"

-> PUT /articles/4576296

POST /articles ではなく PUT /articles/4576296 が飛んでいます。 タイトルが違っても、id で同じ記事だと判定できています。記事の総数も増えていません。

なぜ最初からタイトルだったのか

ローカルに状態ファイルを置きたくなかったからです。 記事は Markdown ファイルだけで完結していてほしかった。

その気持ちは分かりますが、代償は**「同一性を毎回推測する」**ことでした。 推測できるうちは動きます。推測の材料が変わった日に壊れます。

状態を持たない設計は、状態を推測する設計とセットになりがちです。 持たない代わりに何を推測しているのかは、意識しておく必要がありました。

まとめ


あわせて読む


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 は有料版です。

無料版を見る 製品ページ