kikuta@dev:~$cat ./notes/spex-rebuild-25000-lines.md
·16 min·kikuta

Spex v3をゼロから作り直す:25,000行削除から再設計まで

約25,000行を削除して最小構成へ戻し、実Google Sheetsで観測したfailure modeから必要な複雑さだけを戻した記録。

Google Sheetsをキーボード中心で操作するChrome拡張 Spex を作っています。

Spexは一度、かなり大きく作り直しています。

Git上では、v3の再構築commitが次の規模になりました。

151 files changed
3,072 insertions
25,112 deletions

約2.5万行を削除しました。

もちろん、その全部がproduction sourceではありません。古いcoverage、E2E成果物、設計文書なども含まれています。

ただ、意思決定としては明確でした。

既存実装を継ぎ足さず、一度できるだけ小さく作り直す。

当時の再設計文書には、かなり極端なことを書いています。

本質的にやることは4つだけ。

1. Alt + キーのsequenceを受け取る
2. shortcut定義から操作を引く
3. DOM要素を探してclickする
4. KeyTips / Toastでfeedbackする

そして、

  • 再帰的shortcut treeをやめる
  • 二重のkeyboard handlerをやめる
  • State Machineをやめる
  • Reactをやめる
  • shadcn / Radix / sonner等をやめる
  • type abstractionを減らす

という方針にしました。

今振り返ると面白いのは、その後のSpexはまたかなり複雑になったことです。

では作り直しは失敗だったのか。

私は逆だと思っています。

一度全部捨てたことで、後から増えた複雑さの一つ一つに「なぜ必要なのか」を説明できるようになりました。

旧実装で嫌だったのは「複雑であること」ではなかった

古い設計には、

  • MenuNavigationManager
  • KeyboardEventHandler
  • State Machine
  • React Context
  • recursive shortcut definition
  • 多数のResult / Union型

などがありました。

個々の技術が悪いわけではありません。

問題だったのは、まだ観測していない将来の問題のための抽象化が多かったことです。

一方で、実Google Sheets上で本当に壊れていたのは、もっと泥臭いところでした。

  • IME
  • macOS Option
  • cell editing判定
  • dynamic menu
  • synthetic event
  • focus
  • host locale

でした。

抽象化の量と、実際に困っている問題の位置が噛み合っていませんでした。

そこでv3では、まずvertical sliceを小さく作りました。

Keyboard
  ↓
Shortcut Machine
  ↓
Semantic Command
  ↓
Google Sheets Adapter

最初はBoldのような小さいcommandからです。

最初のv3も、今見ると単純すぎる

再設計時には、Google Sheets操作をかなり直接的に考えていました。

{
  type: "click",
  selector: "#t-bold"
}

のようなoperationをshortcutへ結び付け、DOMを探して実行する。

しかし、実Google Sheetsを触るとすぐに崩れます。

1. contenteditable=trueでも編集中とは限らない

通常セル選択時にもrich text editorがactiveElementになります。

さらに日本語入力後には、editable classだけ残して画面外へ退避するstale editorもありました。

単純な isContentEditable では判定できません。

ここからContext Gateが必要になりました。

2. event.keyだけではmacOSで壊れる

Option + Oが ø になります。

Option + Eが Dead になります。

物理キーとしての KeyboardEvent.code とIME ownershipを分ける必要が出ました。

3. click()しても動かないcommandがある

一部のSheets操作はsynthetic DOM eventでは起動しませんでした。

ここからexecution strategyをcommandごとに分ける必要が出ました。

4. 「eventを送れた」と「成功した」が違う

Boldなら aria-pressed が反転したか。

行挿入ならsheet geometryが変わったか。

dialogなら本当に開いたか。

observable result verificationが必要になりました。

5. focusはcommand完了後に戻せばよい、でもなかった

非同期verification中にユーザーが次の操作を始めると、古いcommandのfinallyが新しいfocusを奪うことがあります。

ここからinteraction ownershipが必要になりました。

こうして見ると、現在のSpexには再び、

  • Context Gate
  • Adapter
  • Execution Capability
  • Interaction Coordinator
  • Focus ownership
  • Dynamic Registry
  • Verification

などがあります。

単純な400行ではありません。

何が違うのか

違いは、各抽象化に実際のfailure modeが紐付いていることです。

例えば、

Context Gate

は「将来editing stateが複雑になりそうだから」ではありません。

実際に、

cell-input editable

が画面外に残り、Altが使えなくなる問題が起きたから存在します。

Execution Capabilityも、きれいなdomain modelを作りたかったからではありません。

KeyTipsに「押せるのに実行できないキー」が出たから分離しました。

Interaction IDも、非同期処理にはIDがあると美しいからではありません。

古いcompletionが新しいfocusやToastを上書きできたからです。

現在の複雑さは、かなりの部分が過去の具体的なバグを圧縮したものになっています。

一度シンプルにしたから、戻す判断ができた

もし旧実装へそのままpatchを足していたら、

このState Machineは本当に必要なのか この層は過去の都合なのか、今も必要なのか

が分かりにくかったと思います。

一度、

Altを受ける
DOMを探す
clickする

まで戻したことで、その後追加するものに高いハードルを設けられました。

概念的には、

simple implementation
    ↓
real failure observed
    ↓
local patchで解決できるか
    ↓ no
responsibilityを分離
    ↓
regression testで固定

という順にしています。

「シンプル」は行数ではなかった

再構築直後は、ファイル数や型数、dependency数をかなり減らしました。

これは有効でした。

ただ、現在は「コード量が少ないこと」を最終目標にはしていません。

Google Sheetsのような外部Webアプリを操作する以上、host側の複雑さは消せません。

消せない複雑さを無理に一つのfunctionへ押し込むと、見かけの行数だけ減ってfailure modeが混ざります。

今は、

複雑さをなくす

より、

複雑さの所有者を明確にする

という考え方に近いです。

例えば、

Shortcut Machine
  sequenceだけ知る

Semantic Command
  操作の意味だけ知る

Adapter
  Google Sheetsでの実行方法を知る

Verifier
  成功条件を知る

Context Gate
  今Spexがkeyboardを所有してよいか知る

と分けます。

v3で持ち込んだのはコードではなく、失敗の知識だった

再構築時、旧v1/v2を完全に忘れたわけではありません。

むしろ残したかったのは、

  • Google Sheetsの挙動観測
  • IME / macOS Optionの知見
  • shortcut catalog
  • E2Eで結果を見るべきという教訓

でした。

コードは捨てても、失敗から得た契約は捨てない

これはかなり重要でした。

古いimplementationをそのまま移植すると、設計上の負債も一緒に持ち込みます。

一方で、過去のregressionを全部忘れると同じバグを繰り返します。

そこで、旧実装からは「何が壊れたか」をcontractとして持ち込み、implementationは作り直しました。

今ならどう言うか

当時の再設計文書には、

State Machineはオーバーエンジニアリング

とかなり強く書いています。

今ならもう少し慎重に書きます。

State Machine自体が悪いのではありません。

まだ状態遷移を理解していない段階で、立派なState Machineを先に作るのが危険だったのだと思います。

実際、現在のSpexには明確なinteraction stateがあります。

ただ、それは設計パターンを使いたかったからではなく、

  • invalid keyから戻る
  • Escapeする
  • command実行中
  • pointerへhandoffする
  • stale completionを無視する

という実際の遷移が増えた結果です。

一般化すると

大きなrewriteを勧めたいわけではありません。

25,000行削除はかなり極端です。

ただ、今回得た感覚は他のプロジェクトにも使えると思っています。

消したい複雑さ

  • 未来のためだけの抽象化
  • 同じ状態の二重管理
  • 実際のfailure modeと対応しない層
  • framework都合だけで存在する境界

残したい複雑さ

  • 現実に異なる責務
  • 異なるfailure mode
  • host boundary
  • security / privacy boundary
  • async ownership
  • observable verification

設計をシンプルにするとは、全部を一つにすることではありません。

説明できない複雑さを消し、説明できる複雑さだけを残すことに近いのかもしれません。

Spex

この再構築後の設計で開発しているChrome拡張がSpexです。