CIで“ドリフトを検知して弾く”と、互換維持 vs 改善/仕様変更の運用ルール
本トピックは3部構成の最終回となっています。
- AI時代における品質重視型プロジェクト運用モデルの提案(1/3)
- AI時代における品質重視型プロジェクト運用モデルの提案(2/3)
- AI時代における品質重視型プロジェクト運用モデルの提案(3/3)(本記事)
前編(1/3)では運用モデルの全体像、
中編(2/3)では成果物設計(Spec / BR / SC / I/F定義 / Delta)と参照方向ルールを具体化しました。
後編(3/3)では、その運用を「頑張って維持する」状態から卒業させるために、
- CIでトレーサビリティを機械検査して“弾く”(ドリフト検知の自動化)
- 互換維持 vs 改善/仕様変更を分類し、受入テスト・レビュー(特にQA)の判断基準を固定する
…の2点を、現場で採用できる粒度まで落とし込みます。
伝えたいこと
- AI時代は「仕様(Spec)を書く」だけでは足りません。つながり(Traceability)をCIで検査して“壊れたら弾く” 必要があります。
- 最小構成でも、次をCIで機械検査できます:
- BRがSpecに存在する
- SCが存在し、BRとI/F定義に接続している
- テスト/実装が変更したBR/SCを参照している(最低限)
- 品質ゲートを強くするほど、互換維持 vs 改善/仕様変更を曖昧にすると混乱します。
→ だから、分類ラベルをSpec/Deltaに書き、受入テストとQAレビューの判断基準を固定します。
CIで“ドリフトを検知して弾く”(Traceabilityの機械化)
なぜトレーサビリティが必要なのか(現状の課題と、運用の破綻リスク)
ここまで読んで「Spec/SC/I/F定義を用意するのは分かった。しかし なぜ それらを“つなぐ”必要があるのか?」と感じた方もいると思います。
結論から言うと、この運用モデルは “つながりを維持できないと破綻する” からです。
AI時代は、次の理由で 整合性の崩れ(ドリフト)が“必然” になります。
- 変更量が増える:AIは実装・修正を高速化します。その結果、1チケットあたりの差分が大きくなりやすい
- 並列化が進む:レビュー待ち中に次工程へ先行着手する運用では、上流と下流が時間差で更新される
- 「それっぽい」修正が紛れ込む:AIは意図を推測して補完します。推測が当たれば便利ですが、外れると静かにズレます
- 人間のレビュー帯域は増えない:差分が増えるほど「読み切り」は破綻し、見落としの確率が上がります
現状起きがちな課題(手動運用の限界)
トレーサビリティが無い(または弱い)状態では、典型的に次が起きます。
-
上流変更の“追随漏れ”が発生する
- Specの境界・例外条件が変わったが、SCが古いまま
- APIのエラー形式が変わったが、受入シナリオやE2Eが古いまま
- DB制約を変えたが、ユースケース側の期待結果が追随していない
-
テストが“通っているのに違う”が起きる
- テストが網羅していない境界条件で挙動がズレている
- AIがテストを都合よく弱め、CIが緑のまま品質が落ちている
- 「通ること」が目的化し、意図(Spec)から離れた抜け道実装が残る
-
真実のソースが分裂する(どれが正しいか分からない)
- Spec・SC・I/F定義・実装がそれぞれ別方向に進み、会議で“擦り合わせ”が発生する
- QAは「これは不具合か?改善か?仕様変更か?」の判断材料が足りず、レビューが詰まる
この運用モデルで“大変な部分”はどこか
率直に言うと、大変なのは 成果物を作ることではなく、成果物間の整合を維持することです。
- 上流(Spec/SC/I/F定義)は“定義資産”なので、更新すると 下流(テスト/実装)も必ず追随させる必要がある
- しかもAIがいることで差分が増え、追随漏れが起きやすい
- 手動で「この変更はどのSCに影響するか」「どのI/F定義を触ったか」を毎回追うのは、スケールしない
つまり、トレーサビリティは「きれいに管理できたら嬉しい」ものではなく、
**運用をスケールさせるための“安全装置”**です。
- つながりが壊れたら、CIで検知して止める
- 検知メッセージで、次に直すべき場所を機械的に指し示す
この仕組みが無いと、最終的に「人間が全部読む」しかなくなり、AI導入で加速した分だけ疲弊が増えます。
何を“ドリフト”と呼ぶのか
このモデルにおけるドリフトとは、ざっくり言うと次の状態です。
- 上流(Spec/SC/I/F定義)と下流(テスト/実装)の整合が壊れている
- しかも、人が目で追わないと気づけない(=AIがすり抜けやすい)
AIが入ると、差分が増えます。差分が増えるほど、人間の「読み切り品質」は下がります。
だから、“読んで検知する”ではなく、**CIで“弾ける形”**に寄せます。
最小の検査ルール(まずはこれだけで効果が出る)
中編で作った成果物設計を前提に、CIで検査する最小ルールを定義します。
ルールA:IDの存在(定義の一意性)
-
BR-xxxは Spec 内に存在する -
SC-xxxはシナリオ定義(例:docs/scenarios/)に存在する
ルールB:SCの接続(「シナリオは孤立させない」)
-
SC-xxxは 少なくとも1つのBR-xxxを参照する -
SC-xxxは I/F定義(UI/API/DB)のいずれか(または複数)に接続する
ルールC:下流の参照(「AIの作業が上流から逸れない」)
- 変更したBR/SCについて、テストコード・実装コードが参照を持つ(コメントでもOK)
※ポイント:ここで “テストID” や “実装ID” を作り始めると冗長になりやすいので、
テスト/実装は 参照する側に寄せて、上流IDへのリンクを残すだけにします。
CIで何を実行するか(GitHub Actionsのイメージ)
CIでは、一般的に次の順序にすると壊れにくいです。
- API定義の構文検査(例:OpenAPIのlint / validate)
- DBマイグレーションの最低限検査(SQL lint、あるいはテスト用DBへ適用)
-
Traceability検査(下記の
check-traceabilityを実行) - unit / e2e 実行(必要な範囲)
check-traceability はどう作るか(言語に依存しない設計)
ここは技術スタックに依存しますが、考え方は共通です。
「成果物のIDを収集 → 参照を収集 → ルールに照らして欠落を落とす」 だけです。
入力(例)
- Spec(Markdown)
- SC(Markdown)
- I/F定義(YAML/OpenAPI/DDL等。最低限“識別子”が取れればOK)
- テスト/実装(ソース。コメントでも良い)
収集するもの(例)
- BR一覧:
BR-[A-Z]+-[0-9]+ - SC一覧:
SC-[A-Z]+-[0-9]+ - I/F一覧:
UI-... / API-... / DB-...(プロジェクトの規約でOK) - 参照:各ファイル内の
BR-.../SC-.../UI-.../API-.../DB-...
実装例(Python 3 + ripgrep)
上の ````text` は「擬似コード(読み物)」で、特定の実在言語ではありません。
現場でそのまま試せる形にするため、ここでは Python 3 の最小スクリプト例で示します。
- 依存:Python 3 /
rg(ripgrep。任意。無い場合は--no-rgで代替) - 目的:まず ルールA/B(定義の存在・SC接続) をCIで落とす
- 追加:必要なら ルールC(変更IDが下流に参照される) も
--diff-rangeで有効化
使い方(例)
# 例: ローカル or CI(全体検査:ルールA/B)
python3 scripts/traceability/check_traceability.py \
--spec-glob "docs/specs/**/spec.md" \
--sc-glob "docs/scenarios/**/*.md" \
--if-pattern "UI-[A-Z0-9_-]+" --if-pattern "API-[A-Z0-9_-]+" --if-pattern "DB-[A-Z0-9_-]+" \
--code-glob "src/**" --code-glob "tests/**"
# 例: PR差分だけ厳しく見る(ルールC:変更IDが下流に参照されているか)
# ※base/headはCI側で適切に渡してください(例:PR base SHA と HEAD SHA)
python3 scripts/traceability/check_traceability.py \
--spec-glob "docs/specs/**/spec.md" \
--sc-glob "docs/scenarios/**/*.md" \
--code-glob "src/**" --code-glob "tests/**" \
--diff-range "<base_sha>..<head_sha>"
scripts/traceability/check_traceability.py(最小例)
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import glob
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
BR_RE = re.compile(r"BR-[A-Z0-9]+-[0-9]+")
SC_RE = re.compile(r"SC-[A-Z0-9]+-[0-9]+")
@dataclass(frozen=True)
class ScenarioInfo:
sc_id: str
br_refs: set[str]
if_refs: set[str]
path: str
def read_text(path: str) -> str:
return Path(path).read_text(encoding="utf-8", errors="ignore")
def expand_globs(globs: list[str]) -> list[str]:
files: list[str] = []
for g in globs:
files.extend(glob.glob(g, recursive=True))
# 既存ファイルのみ、重複除去
uniq = []
seen = set()
for f in files:
if os.path.isfile(f) and f not in seen:
uniq.append(f)
seen.add(f)
return uniq
def collect_ids(paths: Iterable[str], regex: re.Pattern[str]) -> set[str]:
found: set[str] = set()
for p in paths:
found.update(regex.findall(read_text(p)))
return found
def first_id(text: str, regex: re.Pattern[str]) -> str | None:
m = regex.search(text)
return m.group(0) if m else None
def collect_scenarios(sc_files: list[str], if_patterns: list[str]) -> list[ScenarioInfo]:
if_res = [re.compile(p) for p in if_patterns]
scenarios: list[ScenarioInfo] = []
for f in sc_files:
body = read_text(f)
sc_id = first_id(body, SC_RE)
if not sc_id:
# SCファイルだがIDが取れない場合は、そのままエラーにする方が運用が安定する
raise ValueError(f"SC id not found in: {f}")
br_refs = set(BR_RE.findall(body))
if_refs: set[str] = set()
for r in if_res:
if_refs.update(r.findall(body))
scenarios.append(ScenarioInfo(sc_id=sc_id, br_refs=br_refs, if_refs=if_refs, path=f))
return scenarios
def collect_code_refs(code_globs: list[str], extra_patterns: list[re.Pattern[str]]) -> set[str]:
code_files = expand_globs(code_globs)
all_res = [BR_RE, SC_RE, *extra_patterns]
refs: set[str] = set()
for f in code_files:
body = read_text(f)
for r in all_res:
refs.update(r.findall(body))
return refs
def git_diff_text(diff_range: str) -> str:
# diff_range 例: "<base>..<head>"
cmd = ["git", "diff", "--unified=0", diff_range]
return subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
def collect_changed_ids(diff_range: str, if_patterns: list[str]) -> set[str]:
diff = git_diff_text(diff_range)
ids = set(BR_RE.findall(diff)) | set(SC_RE.findall(diff))
for p in if_patterns:
ids |= set(re.findall(p, diff))
return ids
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--spec-glob", action="append", required=True)
ap.add_argument("--sc-glob", action="append", required=True)
ap.add_argument("--code-glob", action="append", default=[])
ap.add_argument("--if-pattern", action="append", default=["UI-[A-Z0-9_-]+", "API-[A-Z0-9_-]+", "DB-[A-Z0-9_-]+"])
ap.add_argument("--diff-range", default=None, help='Optional. e.g. "<base>..<head>"')
ap.add_argument("--no-rg", action="store_true", help="Not used in this minimal script (reserved).")
args = ap.parse_args()
spec_files = expand_globs(args.spec_glob)
sc_files = expand_globs(args.sc_glob)
br_defs = collect_ids(spec_files, BR_RE)
scenarios = collect_scenarios(sc_files, args.if_pattern)
errors: list[str] = []
# ルールA:定義の存在
if not br_defs:
errors.append("No BR definitions found in spec files (BR-... not detected).")
if not scenarios:
errors.append("No SC files found (or SC-... not detected).")
# ルールB:SCの接続(BR参照+I/F参照)
for sc in scenarios:
if not sc.br_refs:
errors.append(f"{sc.sc_id} has no BR references: {sc.path}")
else:
missing = sorted([br for br in sc.br_refs if br not in br_defs])
if missing:
errors.append(f"{sc.sc_id} references missing BR defs {missing}: {sc.path}")
if not sc.if_refs:
errors.append(f"{sc.sc_id} has no I/F links (UI/API/DB): {sc.path}")
# ルールC(任意):変更IDが下流(src/tests)に参照される
if args.diff_range and args.code_glob:
# 下流参照の集合を作っておき、変更IDが含まれるか確認する
# ※要件に合わせて「変更した上流IDだけ」や「変更したファイルだけ」に最適化してよい
extra_res = [re.compile(p) for p in args.if_pattern]
code_refs = collect_code_refs(args.code_glob, extra_res)
changed = collect_changed_ids(args.diff_range, args.if_pattern)
for cid in sorted(changed):
if cid not in code_refs:
errors.append(f"Changed id is not referenced in code (src/tests): {cid}")
if errors:
print("Traceability check failed:", file=sys.stderr)
for e in errors:
print(f"- {e}", file=sys.stderr)
return 1
print("Traceability check OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
※重要:最後の changed_ids_are_referenced_in_code が効きます。
「上流を変えたのに下流が追随していない」状態を、レビュー前に弾けるからです。
失敗時の出し方(CIメッセージの設計)
CIが落ちるとき、メッセージが雑だと運用が崩れます。
人間が次の一手を迷わないように、“欠落を列挙”します。
SC-ORDER-001 references missing BR: BR-ORDER-999SC-ORDER-002 has no interface links (UI/API/DB)Changed BR-ORDER-003 but no references found in src/ or tests/
互換維持 vs 改善/仕様変更:分類が曖昧だと、QAもAIも迷う
CIとテストで品質ゲートを強くする分、
「何を守るのか(互換)」と「何を変えるのか(改善/仕様変更)」 が曖昧だと混乱します。
特に移行・置換案件では「旧仕様を再現すること」自体が価値になる一方で、旧仕様には暗黙仕様や既知バグが混ざり得ます。
この“整理”をせずにテストを固めると、後から地獄になります。
決めるべき分類(例)
- 互換維持(Compatibility):旧仕様の挙動をそのまま維持する(期待値は“変えない”)
- 改善(Improvement):ユーザーにとって望ましい挙動へ直す(期待値を“良い方向へ”変える)
- 仕様変更(Change):要件上の理由で挙動を変える(期待値を“意図的に”変える)
- 既知バグ踏襲(Bug-for-bug Compatibility):旧システムの不具合を移行都合で一時的に踏襲する
※この分類は「良し悪し」ではなく、関係者の期待値・受入条件・移行影響を揃えるためのラベルです。
分類を誤ると起きること(典型)
- 改善なのに互換として扱い、QAが「落とすべき不具合」と「意図した変更」を判別できない
- 互換なのに改善として扱い、顧客の業務が想定外に変わる(移行トラブル)
- “テストが正”の運用ゆえに、分類が曖昧な変更はテスト修正の判断が属人化し、リードタイムが伸びる
どう運用に落とすか(このモデルでの位置づけ)
分類は、口頭合意だと必ず崩れます。よって、成果物に埋め込みます。
- Spec(SDD)側:変更の意図(WHY/WHAT)と、互換/改善/変更の結論を最低限の文章で明記する
- Delta(delta.md)側:分類と意思決定の経緯を記録する(「なぜ踏襲/なぜ改善するか」)
- 受入テスト(ATDD)側:分類に沿って期待結果を固定し、QAレビューの判断基準を揃える(特に“互換 vs 改善”)
- CIゲート:(可能なら)分類タグの欠落や未決事項の残存を検知し、警告または弾く
Spec / Delta の最小テンプレ
Specに入れる(例)
## 変更分類(Compatibility / Improvement / Change / Bug-for-bug)
- 分類:Compatibility
- 影響範囲:<ユーザー影響/移行影響>
- 受入の観点:<QAが見るべきポイント>
Deltaに入れる(例)
# Delta(Decision Log)
- 日付:2025-xx-xx
- 変更対象:BR-ORDER-002 / SC-ORDER-005
- 分類:Improvement
- 変更理由:<ユーザー価値/障害/運用負債など>
- 代替案:<不採用理由を1行>
- 注意点:<互換で壊れる可能性、移行手順など>
受入テスト(SC)とQAレビューの“判断基準”を固定する
分類が決まると、受入テストやQAレビューの迷いが大きく減ります。
Compatibility(互換維持)
- 期待結果は原則変えない
- SCの変更が必要なら、その理由(既存の期待結果の誤り/テストの不備)をDeltaに必ず残す
- “旧仕様の再現”を優先する(改善は別チケットへ)
Improvement(改善)
- 期待結果を良い方向へ変える(SCの更新が正当)
- ただし「改善の定義」が曖昧だと揉めるので、SpecにWHY/アウトカムを短く固定する
- 影響範囲(誰がどう嬉しいか/既存ユーザーへの副作用)をレビュー観点に入れる
Change(仕様変更)
- 期待結果は意図的に変わる
- 影響範囲(UI/API/DB、移行、運用手順)をセットでレビュー
- “移行で困る人”を想定し、注意点をDeltaに残す
Bug-for-bug
- 互換維持の一種だが、将来必ず返済が必要
- Deltaに「なぜ今踏襲するか」「いつ返済するか(期限 or 条件)」を残す
- 受入テストには TODO を残し、踏襲であることが見えるようにする
“壊れたら弾く”運用を、現場に定着させるコツ
最初から完璧に弾こうとしない
最初から「全部CIで落とす」は反発を生みます。おすすめは段階導入です。
- まずは 警告(warning) を出す(欠落の見える化)
- チームが慣れたら fail に切り替える(強制)
- 強制対象は「上流の整合」から始め、次に「分類の欠落」へ広げる
AI運用の観点:CIは“最終命令”として効く
AIにとって最も明確なフィードバックは、CIの赤です。
「落ちた理由」がIDベースで明確なら、AIは修正ループを自走できます。
- 人間:分類・境界・期待結果(上流)を決める
- AI:実装・テスト・修正ループを回す
- CI:ズレを検知し、統合を止める
この形に寄せることで、壁打ち疲れは「人間の会話」ではなく 機械のフィードバックへ移ります。
まとめ
- TraceabilityをCIで機械検査し、壊れたら弾くことで、AI時代のドリフトを現実的に抑えられます
- 互換維持 vs 改善/仕様変更を成果物に埋め込み、受入テストとQAレビューの判断基準を固定すると、リードタイムが安定します
- 最終的に、AIは「テストを通す」だけでなく、“上流に従う”ことをCIで強制されるため、自走しやすくなります
シリーズの補足(次にやるなら)
このシリーズは運用モデルの骨格です。次に磨くなら、以下が効きます。
- 役割別チェックリスト(PdM / QA / Eng)を固定して、レビューを“迷わない作業”にする
-
check-traceabilityの出力を、SC一覧・BR一覧として逆引き生成し、Specからの可視性を補完する - 「既知バグ踏襲」の返済を、プロダクトロードマップへ接続する(期限が無いと永遠に残る)