実装計画の書き方
このページでは、計画のファイルそのもの(セクション、要素の id と change、受け入れ振る舞い、質問、コマンド)を説明します。計画を描画し、承認し、実装する流れは実装計画にあり、このページで使う言葉(要素、振る舞い、ステップ)はその用語の表で定義しています。
実装計画の書き方
このページでは、計画のファイルそのもの(セクション、要素の id と change、受け入れ振る舞い、質問、コマンド)を説明します。計画を描画し、承認し、実装する流れは実装計画にあり、このページで使う言葉(要素、振る舞い、ステップ)はその用語の表で定義しています。
ドキュメントの構成
| フィールド | 中身 |
|---|---|
planVersion |
1 |
title、summary、locale |
計画の題と要約、本文の言語 (en、ja) |
scope |
goals と nonGoals |
assumptions、questions |
指示なしに決めたことと、決めきれなかったこと |
models、validators、controllers、routes、views、resources、policies、sideEffects |
設計の本体。要素の種類ごとに 1 セクション |
flows |
計画で足すものの中を、リクエストがどう流れるか。ノードとエッジで書く |
commands |
変更の前に実行しておく guren add attachments などのコマンド |
tasks |
スライスごとに達成すべきことと、その受け入れ振る舞い |
hints |
task/entity/model.tag before task/entity/model.comment のような、順序についての助言 |
baseline |
plan:approve が書く。手では書かない |
どのセクションも省略できます。省略したセクションと空のセクションは、同じ計画として扱われます。
id と change
要素はすべて id と change を持ちます。id の名前空間は計画全体で 1 つです。先頭は英字にし、あとには英数字と _、.、:、- を使えます。慣例では、セクション名と名前をつなげて書きます (model.comment、route.comments.store)。要素どうしはこの id で互いを参照し、あとで改訂するときも要素は id だけで指すので、一度決めた id は変えないでください。
change.kind |
意味 |
|---|---|
existing |
参照するだけで変えない |
add |
新しく足す |
alter |
その場で変える |
rename |
名前を変える。from は旧名 (モデルならクラス名、カラムならプロパティ名、ルートならルート名) |
drop |
取り除く。reason に理由を書く |
既存のテーブルやカラムに alter、rename、drop を使う場合は、既存の行をどう扱うかを dataMigration に書きます。書けるのは { "kind": "none", "reason": "…" } か、description を付けた backfill または manual です。これを書いていない計画は承認されません。
column.post.summary: Column "summary" of "Post" is a "rename" on an existing table and states no dataMigration.
モデルに並べるのは、計画で変えるカラムと参照するカラムだけです。次の例は、例の計画で新しく足す Comment モデルを、5 つのカラムのうち 2 つに絞って示したものです。
{
"id": "model.comment",
"change": { "kind": "add" },
"name": "Comment",
"table": "comments",
"columns": [
{
"id": "column.comment.body",
"name": "body",
"change": { "kind": "add" },
"type": "text",
"nullable": false,
"unique": false,
"index": false
},
{
"id": "column.comment.postId",
"name": "postId",
"columnName": "post_id",
"change": { "kind": "add" },
"type": "integer",
"nullable": false,
"unique": false,
"index": true,
"references": { "model": "model.post", "column": "id", "onDelete": "cascade" }
}
],
"relationships": [
{ "name": "post", "type": "belongsTo", "target": "model.post" },
{ "name": "author", "type": "belongsTo", "target": "model.user" }
],
"fillable": ["body"]
}
カラム型は、Drizzle のビルダー名ではなく抽象的な型名で書きます。使えるのは string、text、integer、number、decimal、boolean、date、datetime、json、uuid の 10 種類です。datetime には withTimezone を、decimal には precision と scale を指定できます。
ほかのセクションも同じような形です。コントローラーにはアクションを並べ、各アクションに body、params、query で使う validator、authorization (ミドルウェアと Policy の ability)、response (Inertia のビュー、リダイレクト、Resource)、業務ルールを文章で書いた rules を書きます。ルートにはメソッド、パス、ルート名、アクションの id、ミドルウェア、bind を書きます。ビューにはページ id と props を書きます。フォームのフィールドにはルールを書き直さず、validator のフィールドを指定します。アプリケーションモジュールに属する要素には module を付けます。
受け入れ振る舞い
タスクの意図には、対象のエンティティ、そのタスクが受け持つ要素、動作を確かめる振る舞いを書きます。
{
"id": "AC-comments-4",
"description": "A user cannot delete someone else's comment.",
"kind": "forbidden",
"actor": "user",
"route": "route.comments.destroy",
"given": ["a comment written by another user exists"],
"expect": { "status": 403 }
}
kind には success、validation、unauthenticated、forbidden、not-found、state のどれかを書きます。検査はこの種類ごとに振る舞いを数え、validator があるのに validation の振る舞いがないルートや、認証が必要なのに unauthenticated の振る舞いがないルートを報告します。expect には status、redirect、inertia、errors、database を書けます。リクエストの input とデータベースの値は、{ "name": "body", "json": "\"Nice post\"" } のように値を JSON テキストとして書きます。
振る舞いは 1 つずつテストになり、テスト名には角括弧で囲んだ id を入れます。受け入れ振る舞いの id は AC- で始めてください。計画にない AC- の id が角括弧で書かれていると plan:verify が報告するので、打ち間違いに気づけます。
test("[AC-comments-4] a user cannot delete someone else's comment", async () => {
const comment = await Comment.forceCreate({ body: 'Mine', postId: post.id, userId: author.id })
await http.actingAs(reader).delete(`/comments/${comment!.id}`).assertStatus(403)
})
Guren は、GET 以外のリクエストに対するリダイレクトを 303 で返します。フォーム送信後にリダイレクトすることを期待する振る舞いには、"status": 303 と書いてください。
質問
質問には、書き手が一人では決めきれなかった判断を書きます。選択肢、計画が仮に選んだもの、答えが変わったときに影響を受ける要素を並べます。
{
"id": "Q-delete",
"question": "Does deleting a comment remove the row?",
"options": [
{ "label": "hard delete", "consequence": "The row is removed; no deleted_at column." },
{ "label": "soft delete", "consequence": "A deleted_at column is added and lists filter on it." }
],
"assumed": "hard delete",
"affects": ["model.comment", "action.comments.destroy"]
}
質問が残っている計画は承認できません。答えが出たら計画を編集して反映します。答えに沿って要素を直し、質問を消して、決めたことを assumptions に残してください。承認前の計画は、plan.json を直接編集して変えるのが普通です。
コマンド
計画の commands は plan:next が実装エージェントに渡し、エージェントは書かれたとおりに実行します。そのため、書けるのは Guren のジェネレーターだけです。
{ "id": "command.attachments", "command": "guren add attachments", "reason": "Comments take images." }
検査を通るのは、guren <subcommand> か bunx guren <subcommand> の形で、サブコマンドが make:migration 以外の make:*、lang:publish、add plugin 以外の add <blueprint> のどれかであるコマンドです。引数に使えるのは文字、数字、_-.,:/=@+% で、空白を含む値は一重引用符か二重引用符で囲みます (--fields "title:string,body:text?")。シェルの演算子、$、バックスラッシュ、閉じていない引用符があると検査は失敗します。絶対パスの引数や、.. でアプリケーションの外に出る引数も失敗します (--path /etc、--app=../other)。この検査で制限しているのはシェルの構文とジェネレーターの書き込み先で、--force などジェネレーターごとのその他のフラグは判断しません。ここに挙げた以外のコマンドも失敗します。bun run db:migrate は計画に書くものではなく、data ステップの検証コマンドとして実行されます。この検査が失敗している間は plan:approve が承認を拒否し、こうしたコマンドを含む計画には、下書きでも承認済みでも plan:next がステップを渡しません。