設計書の正となるデータを、Markdownで持つのをやめた。型で定義した構造化データを真実のソースにして、Markdownはそこからの出力形式のひとつに降格させた。
そうすると設計書にlintがかけられる。必須項目の欠落も、参照切れも、ドキュメントをまたいだ不整合も、機械が拾えるようになる。
SpecForge という個人開発でそれをやっている。まだ0.1.0で、できていないことの方が多い。それでも、決めたことと決めなかったことは書いておく価値があると思ったので残しておく。
自分で決めた原則を、自分で破っていた
先にこの話から書く。
SpecForgeには docs/architecture/architecture-principles.md という原則ファイルがあって、その4番目にこう書いてある。
4. 品質検証を API 前提で実行可能にする
- lint は UI 実装に依存させない
apps/apiから同一ルールを利用できる設計にする
原則としては何も間違っていない。書いた時点では正しかった。
ところが、しばらく実装を進めてからロードマップを整理していて気づいた。バリデーションルールが全部 apps/web/lib/validation/ に溜まっていた。765行、20以上のルール。APIから呼べる場所には一行も置いていない。
docs/roadmap.md に、そのときの記述がそのまま残っている。
背景:
現在、Validation ルールがapps/web/lib/validation/に実装されており、API から再利用できない。Architecture Principle #4 違反。
原則を先にドキュメント化して、実装をそれに合わせるつもりだった。実際は逆で、書きやすい場所に実装が積み上がって、原則の方が置き去りになっていた。しかも、破っていることに数ヶ月気づかなかった。
Markdownで書かれた原則は、破っても何も起きない。これに尽きる。コードなら型が落ちるし、CIが赤くなる。ドキュメントは静かに嘘になっていく。
自分が作ろうとしているものを、自分で証明してしまった格好だった。正直、けっこう気まずかった。
「書けたかどうか」と「正しいかどうか」
docs/product/vision.md に、課題を4つ書いている。
- 設計書の品質が執筆者スキルに依存する
- 設計内容の構造が明示されず、機械的な検査が困難
- Markdown 中心の運用では、整形規則と設計規則が混在しやすい
- 「書けたかどうか」は判断できても、「正しいかどうか」を担保しづらい
4番目が全部だと思っている。
Markdownの設計書は、書けたかどうかは見ればわかる。見出しがあって、テーブルが埋まっていて、体裁が整っている。でもそれが設計として正しいかどうかは、読んだ人間が判断するしかない。判断できる人がレビューに入らなければ、体裁だけ整った設計書が通る。
3番目も地味に効いている。Markdownで設計書を運用していると、レビューコメントに「テーブルの列順を揃えてほしい」と「この画面から呼ぶAPIが定義されていない」が同じ粒度で並ぶ。整形規則と設計規則が混ざる。前者は機械がやるべきで、後者は人間が見るべきなのに、器がそれを分けてくれない。
設計書を型で持つ
なので packages/document-schema で、設計書の構造を型として定義することにした。中核はこれだけ。
export interface Document {
id: string;
key: string;
title: string;
required: boolean;
kind: DocumentKind;
version: string;
sections: Section[];
tags?: string[];
}
Section が Field を持ち、Field が値を持つ。その Field の定義がこう。
export interface Field {
id: string;
key: string;
label: string;
required: boolean;
valueType: FieldValueType;
description?: string;
placeholder?: string;
defaultValue?: string | number | boolean;
options?: FieldOption[];
table?: Table;
reference?: Reference;
}
扱う設計書の種類と、フィールドが取りうる型は、それぞれ閉じた集合にした。
export const documentKinds = ['screen-spec', 'api-spec', 'er-spec', 'business-rule'] as const;
export const fieldValueTypes = [
'text', 'textarea', 'number', 'boolean', 'enum', 'table', 'reference',
] as const;
export const referenceKinds = ['document', 'api', 'screen', 'field', 'event', 'message'] as const;
ここで一番効いているのは valueType に reference があることだと思う。
Markdownで設計書を書いていたときも、他の設計書への参照は書いていた。「API仕様書のPOST /paymentsを参照」みたいに、テキストで。あれは人間には読めるが、機械には文字列でしかない。参照先が消えても、リネームされても、誰も気づけない。
reference を値の型として持つと、参照が構造になる。しかも制約が付けられる。
export interface ReferenceConstraint {
kinds?: ReferenceKind[];
documentKinds?: DocumentKind[];
}
「このフィールドは api-spec の設計書しか参照できない」を、スキーマの側で宣言できる。
lintに「指摘」だけを返させない
packages/lint-rules が返す型を、こうした。抜粋。
export interface DesignValidationIssue {
id: string;
documentId: string;
severity: ValidationSeverity; // 'error' | 'warning' | 'info'
sectionId: string;
sectionTitle: string;
fieldId: string;
fieldLabel: string;
rowIndex?: number;
columnKey?: string;
message: string;
reason: string;
fix: string;
}
message / reason / fix の3つを必須にしてある。ここは意図的にそうした。
問題が起きたことだけを伝える警告は、無視される。ESLintのルールでも、なぜ駄目なのかがすぐ出てこないものは // eslint-disable-next-line で消される。設計書のlintは対象が非エンジニアになりうるので、なおさら「直し方まで書いていない指摘」は機能しない。
型で必須にしておくと、ルールを追加する人が理由と修正方法を書かざるを得なくなる。実際のルール実装だとこうなる。重複キー検出の部分。
issues.push({
id: `${sectionId}:${fieldId}:row${rowIndex}:${columnKey}:duplicate`,
severity: 'error',
...baseIssue(ctx),
rowIndex,
columnKey,
message: `${columnLabel ?? columnKey}が重複しています`,
reason: `同じセクション内で ${columnLabel ?? columnKey} は一意である必要があります。行 ${seen.get(raw)! + 1} と重複しています。`,
fix: `行 ${rowIndex + 1} の ${columnLabel ?? columnKey}「${raw}」を一意な値に修正してください。`,
});
書くのは面倒だ。面倒だが、レビューで毎回同じ説明をするよりは一回で済む。品質の定義をコード化する、というのは要するにそういうことだった。
Markdownでは無理な検査
構造で持って一番変わったのは、ドキュメントをまたいだ検査ができるようになったことだ。packages/lint-rules/src/rules/reference-integrity.ts に入っている。
画面仕様書(screen-spec)は screen-fields / events / messages / api-connections といったセクションを持つ。この4つは互いに依存している。実装するとき当たり前に依存しているのに、設計書の上では独立したテーブルとして並んでいるだけ、というのがずっと気持ち悪かった。
今は次の検査が動く。
参照先が存在するか、そして種別が合っているか
if (!targetDoc) {
// 参照先のAPI仕様書が見つかりません
} else if (targetDoc.kind !== 'api-spec') {
// 参照先「${targetDoc.title}」は ${targetDoc.kind} であり、api-spec ではありません。
}
参照切れだけでなく、種別違いも見る。ER図を参照しているつもりのAPI参照は弾く。
イベントの操作対象が、画面項目に実在するか
events テーブルの target 列に書かれた値が、screen-fields の fieldKey に存在しなければ警告を出す。設計書内での「押したボタンが定義されていない」を検出する。
メッセージが参照するイベントが実在するか
messages テーブルの条件列が event: で始まる場合、そのイベント名が events に定義されているかを見る。
const condition = getCellString(row, 'condition');
if (!condition.startsWith('event:')) return;
const eventName = condition.slice(6).trim();
if (!eventName || eventNames.has(eventName)) return;
// message が参照する event が存在しません
この3つは、Markdownのテーブルを目で追って人間が突き合わせていた作業そのものだ。画面仕様が10枚を超えたあたりで、まず追えなくなる。
正直に書くと、全部が構造で解決しているわけではない。イベントがAPI呼び出しかどうかの判定は、まだキーワードマッチでやっている。
const API_ACTION_KEYWORDS = ['api', 'API', '通信', '呼出', 'リクエスト', 'fetch', '送信', '取得'];
「送信」と書いてあったらAPI呼び出しだろう、という推測。構造化すると言っておきながら、こういう泥臭いヒューリスティックが残っている。ここは早めに actionType を enum にして潰したい。
100点満点にして、減点に上限をつけた
検査結果は品質スコアとして出している。減点方式で、定義はこれだけ。
export const QUALITY_SCORE_PENALTIES = {
error: 10,
warning: 3,
info: 0,
} as const;
で、ここに上限を入れた。
export const QUALITY_SCORE_CATEGORY_CAPS = {
duplicate: 20, // 重複キー
required: 30, // 必須フィールド未入力
'empty-row': 10, // 空行
'invalid-path': 15, // パス形式エラー
'invalid-api': 15, // API関連エラー
'missing-options': 15, // 選択肢未設定
reference: 20, // 参照エラー
default: 40, // その他
} as const;
理由は単純で、上限なしだと必須項目が10個空いているだけでスコアが0になるからだ。書きかけの設計書はほぼ全部0点になる。0点が並ぶ画面は誰も見ない。見ないスコアは無いのと同じで、そうなると検査自体が死ぬ。
同じ種類のミスは一定以上減点しない。別種類のミスが増えると効いてくる。品質を数値にするなら、数値が行動を変える形になっていないと意味がない、というあたりを考えて置いた閾値がこれ。
export const QUALITY_SCORE_THRESHOLDS = { good: 80, caution: 60 } as const;
80以上が「良好」、60以上が「注意」、それ未満が「要改善」。この数字に根拠はない。運用しながら動かすつもりでいる。
Markdownは出力に降格した
現在のMarkdownの扱いは apps/web/lib/export/markdown-export.ts にある。内部モデルを受け取って文字列を組み立てるだけの関数になった。
export function exportDocumentToMarkdown(
document: Document,
fieldValues: Record<string, FieldValue>
): ExportResponse<string> {
同じ階層にCSVとJSONのexport / importもある。Markdownは並列に置かれた出力形式のひとつでしかない。
docs/product/quality-principles.md にはこう書いた。
4. 出力より内部整合性を優先する
- Markdown / HTML / PDF は派生物であり、真実のソースは内部モデル
- 出力フォーマット都合でコアモデルを汚染しない
一度、逆方向を検討した。既存のMarkdown設計書をパースして構造を復元する経路を作れないか、と。やめた理由は、構造を持っていないものから構造を復元する処理は、必ず推測を含むからだ。推測で復元した構造を検査に通すと、推測が正しいという前提で品質スコアが出る。それはスコアと呼べない。
今どこまでできているか
盛らずに書く。
動いているのは、画面仕様・API仕様・ER設計書・ビジネスルールの4種類のテンプレート、20以上のバリデーションルール、ドキュメント間の参照整合性チェック、品質スコアとプロジェクト全体のヘルスダッシュボード、Markdown / CSV / JSON の入出力、JWT認証。テストは26ファイル235ケース。
できていないのは、packages/editor-engine と packages/ui。ロードマップ上は骨組みだけで、優先度も低に置いてある。AST・正規化・各出力形式へのトランスフォーマという構想はあるが、着手していない。現状はMarkdown出力のロジックが apps/web 側に残っていて、原則3(Markdown非依存を維持する)を完全には満たせていない。
つまり、冒頭に書いた原則違反と同じ構図がもう一つ残っている。順番に返済していく。
構成はTurborepo + pnpmのモノレポで、apps/web がNext.js、apps/api がFastAPI、DBはPostgreSQL 16。ローカルはDocker Composeで立つ。
cp .env.example .env
docker compose up --build
まだ答えが出ていない
型で縛るほど、書き手の自由は減る。SpecForgeは docs/product/quality-principles.md に「書きやすさより崩れにくさを優先する」と明記していて、そこは迷っていない。ただ、どこまで縛ると人が使うのをやめるのか、その線がまだ全然わかっていない。自分ひとりで使っている限り、たぶん永遠にわからない。
もう一つ引っかかっているのは、設計書を構造化データとして持つことの価値が、読み手が人間だけではなくなった今、どう変わるのかという点だ。構造化された設計書は機械が読める。読めるということは、検査できるだけでなく、そこから実装を起こす側にも渡せるということになる。ただ、そこまで踏み込むと「設計書の品質を保つツール」ではなくなってくる。スコープをどこで切るべきなのか、今のところ決めきれていない。
設計ドキュメントを構造で持つか、書きやすさを取るかで判断した経験のある方がいれば、どちらに倒したか聞いてみたい。
- リポジトリ: https://github.com/masakiShito/specforge
- 関連: なぜ要件定義で仕様を詰めきれないのか