diff --git a/.planning/SDD.md b/.planning/SDD.md index 0f4482ef..d41c14ab 100644 --- a/.planning/SDD.md +++ b/.planning/SDD.md @@ -3,23 +3,31 @@ document: kiwi-sdd title: Kiwi 架构设计与 SDD 开发计划 status: accepted-design authority: sole-project-entry -version: 1 +version: 2 updated_at: 2026-08-02 baseline_repository: arana-db/kiwi baseline_branch: main -baseline_ref: 0c4795ec716299598686fc7c5e0fac03a30e044d -github_snapshot_at: 2026-08-02T07:43:23Z +baseline_ref: 9820162ebdf2d26aa6349e704efe8737b2e73e4a +wp0_pr_number: 414 +wp0_pr_base_ref: 0c4795ec716299598686fc7c5e0fac03a30e044d +wp0_pr_head_ref: e2bfc7deb481590a757f0034874b7f21a4a31aa2 +wp0_merge_parent_ref: cbcbadc27068634d851ab0ed63989d2214ab2408 +wp0_merge_ref: 9820162ebdf2d26aa6349e704efe8737b2e73e4a +wp0_exact_main_verification_ref: none +wp0_exact_main_verification_run: none +wp0_exact_main_verification_status: pending +github_snapshot_at: 2026-08-02T12:57:50Z redis_oracle_tag: 8.8.1 redis_oracle_ref: 77b6c308396c9700672390a210143a8496fb4b10 required_runtime_mode: cache-off executable_scope: M0-M6 long_term_scope: M0-M10 current_work_package: WP0 -current_work_package_status: in-progress +current_work_package_status: implemented current_plan: .planning/SDD.md#wp0 current_issue: 413 current_pr: 414 -next_safe_action: push-pr-link-and-wait-for-final-head-checks +next_safe_action: repair-post-merge-validation-under-416-then-verify-wp0 --- # Kiwi 架构设计与 SDD 开发计划 @@ -154,18 +162,20 @@ M7 到 M10 进入长期架构,但不进入当前实现: | D | Discussion、RFC、Proposal | 设计输入,必须显式采纳 | | E | 历史计划、旧周报、过期 Issue、草稿 | 背景,不形成当前任务 | -### 3.2 2026-08-02T07:43:23Z 实时快照 +### 3.2 2026-08-02T12:57:50Z 实时快照 - Repository:arana-db/kiwi。 - Default branch:main。 -- main:0c4795ec716299598686fc7c5e0fac03a30e044d。 -- Open Issues:63,其中 #413 是本轮创建的 WP0 Primary Issue。 +- main:9820162ebdf2d26aa6349e704efe8737b2e73e4a。 +- Open Issues:63;#415 是 WP1 Oracle provenance 实施 Issue,#416 是 WP0 合并后验证修复 Issue。 - Discussions:17,其中 1 个关闭,没有 accepted answer。 - PR #409:MERGED,对应 merge commit 0c4795ec716299598686fc7c5e0fac03a30e044d。 - Issue #407:CLOSED,关闭时间 2026-08-02T04:44:40Z。 -- PR #412:OPEN,Head 2d5680b5245478637bea31a39e83e7e0be488bca,MERGEABLE/CLEAN,快照时可见检查成功;未合并前 #143 仍视为未完成。 -- PR #356:OPEN,Head f3af349247d02f70ef3adbdd2abd9c03ceb3b870,MERGEABLE/UNSTABLE,macOS Clippy 失败且其余多项检查仍在运行;VectorSet 不进入当前主线。 -- PR #261:OPEN,CONFLICTING/DIRTY,属于长期滞留 PR。 +- PR #412:MERGED,Head 9d1f83360eb52ed23b48b2e5cb1159c93e26e7af,merge commit cbcbadc27068634d851ab0ed63989d2214ab2408;Issue #143 已关闭。 +- PR #414:MERGED,base 0c4795ec716299598686fc7c5e0fac03a30e044d,Head e2bfc7deb481590a757f0034874b7f21a4a31aa2,merge commit 9820162ebdf2d26aa6349e704efe8737b2e73e4a;Issue #413 已关闭。 +- main@9820162ebdf2d26aa6349e704efe8737b2e73e4a 的 push CI run 30747510551 已完成但失败;唯一失败项是 planning SDD validation,其使用旧 baseline 累计了先合并的 PR #412 路径。 +- PR #356:OPEN,Head f1f8d7539812efd4c6c5e99d540744976d4f6261,CONFLICTING/DIRTY,Windows build 和 LeakSanitizer 检查失败;VectorSet 不进入当前主线。 +- PR #261:OPEN,Head fb092812234a54ad3757d35a62ad033136e422c7,GitHub 当前返回 mergeable/mergeStateStatus UNKNOWN,属于长期滞留 PR。 该快照只用于编制本版本。开始任一工作包、创建 PR、复审或验收前必须重新查询实时状态。 @@ -187,7 +197,7 @@ Issue 数量和分类会变化,工作包只依赖明确列出的 Issue,不 |---|---|---| | Compaction | [#88](https://github.com/arana-db/kiwi/issues/88) | WP5 物理清理 | | Compaction filter | [#138](https://github.com/arana-db/kiwi/issues/138) | WP5 filter 安装与生命周期 | -| Block cache | [#143](https://github.com/arana-db/kiwi/issues/143) | 支持轨道,等待 PR #412 | +| Block cache | [#143](https://github.com/arana-db/kiwi/issues/143) | 支持轨道,PR #412 已合并,Issue 已关闭,待能力证据评估 | | Error model | [#315](https://github.com/arana-db/kiwi/issues/315) | WP6 | | Test strategy | [#325](https://github.com/arana-db/kiwi/issues/325) | WP1/WP6/WP7 | | Raft apply Epic | [#332](https://github.com/arana-db/kiwi/issues/332) | WP4 Epic | @@ -859,7 +869,7 @@ Acceptance criteria;缺少任一字段时,工作包不得进入 ready。 ### WP0:单一 SDD 控制面与事实对账 -状态:in-progress。 +状态:implemented。 目标: @@ -901,14 +911,25 @@ Parent / Related:N/A。 Implementation PR:[#414](https://github.com/arana-db/kiwi/pull/414)。 +Post-merge validation repair:[#416](https://github.com/arana-db/kiwi/issues/416)。 + +合并证据: + +- PR 固定区间:0c4795ec716299598686fc7c5e0fac03a30e044d..e2bfc7deb481590a757f0034874b7f21a4a31aa2; +- merge 固定区间:cbcbadc27068634d851ab0ed63989d2214ab2408..9820162ebdf2d26aa6349e704efe8737b2e73e4a; +- PR #414 于 2026-08-02T12:17:13Z 合并,Issue #413 随后关闭; +- main push CI run 30747510551 / job 91495496924 失败,原因是旧 baseline_ref 把先合并的 PR #412 的 7 个源码路径计入 WP0,而不是 WP0 产物本身失败。 +- WP0 exact-main verification:status=pending,ref=none,run=none。 +- 状态提升为 passed/verified 时,必须同时将 baseline_ref 推进到 verification ref 或其后的 main 提交;保留旧 merge ref 会使验收证据不属于已记录的 baseline main 历史。 + Requirement: - REQ-WORK-001 至 REQ-WORK-007。 依赖: -- main@0c4795ec716299598686fc7c5e0fac03a30e044d 的源码事实基线; -- 2026-08-02T07:43:23Z 的 GitHub 快照; +- PR base main@0c4795ec716299598686fc7c5e0fac03a30e044d 的源码事实基线; +- 2026-08-02T12:57:50Z 的 GitHub 快照; - 无前置工作包;本节按 planning-only bootstrap 例外同时作为 WP0 plan。 非目标: @@ -916,7 +937,7 @@ Requirement: - 不修改 Runtime、Storage、Raft、协议、构建或测试行为; - 不实现 WP1-WP7; - 不实现未来的自动 PR traceability checker; -- 不 merge 本 PR,也不以 WP0 自动授权后续源码工作。 +- 不以 PR #414 已合并或 Issue #413 已关闭自动授权后续源码工作;只有合并后 exact-main 验证通过才能继续进入 verified/accepted。 退出门禁: @@ -930,6 +951,7 @@ Requirement: - `git diff --check` 和 committed-diff whitespace check; - `python scripts/validate_sdd.py --self-test` 的失败路径变异测试; - `python scripts/validate_sdd.py` 的 Markdown 链接、占位词、围栏和状态断言; +- WP0 exact-main 状态提升时,baseline_ref 必须推进到 verification ref 或其后的 main 提交,并在线核验 recorded GitHub Actions run 与 ci workflow、main push、精确 SHA 和 success 结论一致; - 63 个 REQ 和 18 个 Decision 的唯一注册、范围展开和引用全集闭包; - WP0、primary Issue #413、PR #414 和 20 个预期产物的一致性断言; - live Issue #413、开放 Issue 数量、关键 PR 状态和远端 main 复核; @@ -944,6 +966,7 @@ Primary Issue:#325。 Related: - #315; +- #415(M1-001-T2 Oracle provenance); - OQ-3; - OQ-10。 @@ -1386,7 +1409,7 @@ Requirement: #### Block Cache - Issue #143。 -- PR #412 仍 OPEN;合并前不得标记完成。 +- PR #412 已于 2026-08-02 合并为 cbcbadc27068634d851ab0ed63989d2214ab2408,Issue #143 已关闭;其能力仍按共享预算、全实例/全 CF、table options、指标和基准证据评估。 - 只允许在共享预算、全实例/全 CF、table options、指标和基准均闭合后 accepted。 #### Build Performance @@ -1578,18 +1601,19 @@ docs/sdd/WP-N/ | 字段 | 当前值 | |---|---| -| Baseline | main@0c4795ec716299598686fc7c5e0fac03a30e044d | +| Baseline | main@9820162ebdf2d26aa6349e704efe8737b2e73e4a | | Current milestone | M0 | | Current work package | WP0 | -| Status | in-progress | +| Status | implemented | | Current plan | [.planning/SDD.md 的 WP0 章节](#wp0) | | Current Issue | [#413](https://github.com/arana-db/kiwi/issues/413) | | Current PR | [#414](https://github.com/arana-db/kiwi/pull/414) | +| WP0 exact-main verification | pending | | Required mode | Cache OFF | | M7/M8 | frozen | -| Next safe action | push PR 链接回写,确认 final Head 后等待 required checks 与 review | +| Next safe action | 完成 #416 的固定提交区间验证修复;合并后在 exact main 重跑门禁,WP0 进入 verified/accepted 后才恢复 #415 | -当前规划工作位于隔离分支 codex/kiwi-sdd-architecture,Issue #413 与 PR #414 已建立追踪。本轮已授权完成文档提交、push 和关联 PR,未授权 merge,也未授权进入 WP1-WP7 的源码实现。 +PR #414 已合并且 Issue #413 已关闭,但 main push CI 的 planning SDD validation 因并发合并后的漂移 baseline 失败。WP0 因此只能标记为 implemented,不能标记为 verified 或 accepted。Issue #416 负责修复固定提交区间验证;在其合并并于 exact main 复验前,不进入 #415 或 WP1-WP7 的源码实现。 ## 18. 决策门禁 diff --git a/scripts/validate_sdd.py b/scripts/validate_sdd.py index 5bc4915c..2a6e49dc 100644 --- a/scripts/validate_sdd.py +++ b/scripts/validate_sdd.py @@ -23,12 +23,15 @@ import argparse from collections import Counter +from collections.abc import Callable import json +import os from pathlib import Path import re import shutil import subprocess import tempfile +import urllib.request REQ_ID = re.compile(r"\bREQ-[A-Z]+-\d{3}\b") @@ -74,6 +77,37 @@ "current_pr", ) +WP0_EVIDENCE_FIELDS = ( + "wp0_pr_base_ref", + "wp0_pr_head_ref", + "wp0_merge_parent_ref", + "wp0_merge_ref", +) + +WP0_IDENTITY_FIELDS = ("wp0_pr_number",) + +WP0_VERIFICATION_FIELDS = ( + "wp0_exact_main_verification_ref", + "wp0_exact_main_verification_run", + "wp0_exact_main_verification_status", +) + +EXPECTED_WP0_IMMUTABLE_EVIDENCE = { + "wp0_pr_number": "414", + "wp0_pr_base_ref": "0c4795ec716299598686fc7c5e0fac03a30e044d", + "wp0_pr_head_ref": "e2bfc7deb481590a757f0034874b7f21a4a31aa2", + "wp0_merge_parent_ref": "cbcbadc27068634d851ab0ed63989d2214ab2408", + "wp0_merge_ref": "9820162ebdf2d26aa6349e704efe8737b2e73e4a", +} + +GITHUB_REPOSITORY = "arana-db/kiwi" +EXPECTED_BASELINE_BRANCH = "main" +EXPECTED_WP0_VERIFICATION_WORKFLOW = { + "name": "ci", + "path": ".github/workflows/ci.yml", +} +GithubRunLoader = Callable[[str], dict[str, object]] + ALLOWED_WP_STATUSES = { "proposed", "accepted-design", @@ -104,6 +138,7 @@ "- `git diff --check` 和 committed-diff whitespace check;", "- `python scripts/validate_sdd.py --self-test` 的失败路径变异测试;", "- `python scripts/validate_sdd.py` 的 Markdown 链接、占位词、围栏和状态断言;", + "- WP0 exact-main 状态提升时,baseline_ref 必须推进到 verification ref 或其后的 main 提交,并在线核验 recorded GitHub Actions run 与 ci workflow、main push、精确 SHA 和 success 结论一致;", f"- {EXPECTED_REQUIREMENT_COUNT} 个 REQ 和 {EXPECTED_DECISION_COUNT} 个 Decision 的唯一注册、范围展开和引用全集闭包;", f"- WP0、primary Issue #413、PR #414 和 {len(EXPECTED_WP0_ARTIFACTS)} 个预期产物的一致性断言;", "- live Issue #413、开放 Issue 数量、关键 PR 状态和远端 main 复核;", @@ -154,7 +189,13 @@ def parse_front_matter(sdd: str, errors: list[str]) -> dict[str, str]: errors.append(f"duplicate front matter fields: {duplicates}") fields = dict(pairs) - for field in CURRENT_FIELDS: + required_fields = ( + CURRENT_FIELDS + + WP0_EVIDENCE_FIELDS + + WP0_IDENTITY_FIELDS + + WP0_VERIFICATION_FIELDS + ) + for field in required_fields: if keys.count(field) != 1: errors.append(f"front matter field {field} must occur exactly once") return fields @@ -280,6 +321,84 @@ def work_package_blocks(sdd: str) -> dict[str, str]: return blocks +def validate_wp0_immutable_evidence( + fields: dict[str, str], errors: list[str] +) -> None: + for field, expected in EXPECTED_WP0_IMMUTABLE_EVIDENCE.items(): + actual = fields.get(field, "") + if actual != expected: + errors.append(f"{field} must remain {expected}, found {actual}") + + +def fetch_github_actions_run(run_id: str) -> dict[str, object]: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "kiwi-sdd-validator", + "X-GitHub-Api-Version": "2022-11-28", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request( + f"https://api.github.com/repos/{GITHUB_REPOSITORY}/actions/runs/{run_id}", + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, ValueError) as error: + raise RuntimeError( + f"unable to load GitHub Actions run {run_id}: {type(error).__name__}" + ) from error + if not isinstance(payload, dict): + raise RuntimeError(f"GitHub Actions run {run_id} returned a non-object payload") + return payload + + +def validate_wp0_github_run_evidence( + fields: dict[str, str], run: dict[str, object], errors: list[str] +) -> None: + run_id = fields.get("wp0_exact_main_verification_run", "") + expected: dict[str, object] = { + "id": int(run_id), + "status": "completed", + "conclusion": "success", + "event": "push", + "head_branch": EXPECTED_BASELINE_BRANCH, + "head_sha": fields.get("wp0_exact_main_verification_ref", ""), + **EXPECTED_WP0_VERIFICATION_WORKFLOW, + } + for field, expected_value in expected.items(): + actual = run.get(field) + if actual != expected_value: + errors.append( + "WP0 exact-main GitHub Actions run " + f"{field} must be {expected_value!r}, found {actual!r}" + ) + + +def validate_wp0_exact_main_run( + fields: dict[str, str], + errors: list[str], + loader: GithubRunLoader | None = None, +) -> None: + if fields.get("wp0_exact_main_verification_status") != "passed": + return + run_id = fields.get("wp0_exact_main_verification_run", "") + if not re.fullmatch(r"[1-9][0-9]*", run_id): + return + load_run = loader or fetch_github_actions_run + try: + run = load_run(run_id) + except Exception as error: + errors.append( + f"unable to validate WP0 exact-main GitHub Actions run {run_id}: " + f"{type(error).__name__}: {error}" + ) + return + validate_wp0_github_run_evidence(fields, run, errors) + + def validate_current_state(sdd: str, fields: dict[str, str], errors: list[str]) -> None: work_package_ids = WP_HEADING.findall(sdd) duplicate_work_packages = duplicate_values(work_package_ids) @@ -344,9 +463,78 @@ def validate_current_state(sdd: str, fields: dict[str, str], errors: list[str]) if pr_matches != [fields.get("current_pr")]: errors.append("current WP implementation PR must occur once and match front matter") + wp0_pr_number = fields.get("wp0_pr_number", "") + if not re.fullmatch(r"[1-9][0-9]*", wp0_pr_number): + errors.append("wp0_pr_number must be a positive decimal GitHub PR number") + validate_wp0_immutable_evidence(fields, errors) + wp0_pr_matches = re.findall( + r"(?m)^Implementation PR:\[#(?P\d+)\]\(https://github\.com/arana-db/kiwi/pull/(?P=pr)\)。$", + blocks.get("WP0", ""), + ) + if wp0_pr_matches != [wp0_pr_number]: + errors.append("WP0 implementation PR must match immutable wp0_pr_number") + + verification_ref = fields.get("wp0_exact_main_verification_ref", "") + verification_run = fields.get("wp0_exact_main_verification_run", "") + verification_status = fields.get("wp0_exact_main_verification_status", "") + if verification_status not in {"pending", "passed"}: + errors.append( + "wp0_exact_main_verification_status must be pending or passed" + ) + if verification_status == "pending": + if verification_ref != "none" or verification_run != "none": + errors.append( + "pending WP0 exact-main verification must not claim a ref or run" + ) + elif verification_status == "passed": + if not re.fullmatch(r"[0-9a-f]{40}", verification_ref): + errors.append( + "passed WP0 exact-main verification requires a full Git SHA" + ) + if not re.fullmatch(r"[1-9][0-9]*", verification_run): + errors.append( + "passed WP0 exact-main verification requires a GitHub Actions run" + ) + + wp0_block = blocks.get("WP0", "") + evidence_projection = ( + ( + "- PR 固定区间:", + f"- PR 固定区间:{fields.get('wp0_pr_base_ref', '')}.." + f"{fields.get('wp0_pr_head_ref', '')};", + ), + ( + "- merge 固定区间:", + f"- merge 固定区间:{fields.get('wp0_merge_parent_ref', '')}.." + f"{fields.get('wp0_merge_ref', '')};", + ), + ( + "- WP0 exact-main verification:", + f"- WP0 exact-main verification:status={verification_status}," + f"ref={verification_ref},run={verification_run}。", + ), + ) + wp0_lines = wp0_block.splitlines() + for prefix, expected_line in evidence_projection: + matching_lines = [line for line in wp0_lines if line.startswith(prefix)] + if matching_lines != [expected_line]: + errors.append( + "WP0 evidence projection must contain exactly one line matching " + f"immutable front matter: expected={expected_line!r}, " + f"found={matching_lines}" + ) + + if statuses.get("WP0") in {"verified", "accepted", "released"}: + if verification_status != "passed": + errors.append( + f"WP0 status {statuses.get('WP0')} requires passed exact-main " + "verification evidence" + ) + table_expectations = { "Current work package": current_work_package, "Status": current_status, + "WP0 exact-main verification": verification_status, } for label, expected in table_expectations.items(): matches = re.findall(rf"(?m)^\| {re.escape(label)} \| (.*?) \|$", sdd) @@ -385,9 +573,25 @@ def validate_current_state(sdd: str, fields: dict[str, str], errors: list[str]) errors.append("current-state table PR label and URL must match front matter") baseline_ref = fields.get("baseline_ref", "") + baseline_repository = fields.get("baseline_repository", "") baseline_branch = fields.get("baseline_branch", "") + if baseline_repository != GITHUB_REPOSITORY: + errors.append( + f"baseline_repository must remain {GITHUB_REPOSITORY}, " + f"found {baseline_repository}" + ) + if baseline_branch != EXPECTED_BASELINE_BRANCH: + errors.append( + f"baseline_branch must remain {EXPECTED_BASELINE_BRANCH}, " + f"found {baseline_branch}" + ) if not re.fullmatch(r"[0-9a-f]{40}", baseline_ref): errors.append("baseline_ref must be a full 40-character lowercase Git SHA") + for field in WP0_EVIDENCE_FIELDS: + if not re.fullmatch(r"[0-9a-f]{40}", fields.get(field, "")): + errors.append( + f"{field} must be a full 40-character lowercase Git SHA" + ) baseline_matches = re.findall(r"(?m)^\| Baseline \| ([^|]+) \|$", sdd) if baseline_matches != [f"{baseline_branch}@{baseline_ref}"]: errors.append("current-state table Baseline must match front matter") @@ -433,50 +637,253 @@ def validate_invariants(sdd: str, errors: list[str]) -> int: return len(ids) -def git_changed_paths(root: Path, baseline_ref: str, errors: list[str]) -> set[str]: - object_check = subprocess.run( - ["git", "-C", str(root), "cat-file", "-e", f"{baseline_ref}^{{commit}}"], +def git_changed_paths_between( + root: Path, + base_ref: str, + head_ref: str, + label: str, + errors: list[str], +) -> set[str]: + unavailable: list[str] = [] + for role, ref in (("base", base_ref), ("head", head_ref)): + object_check = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{ref}^{{commit}}"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if object_check.returncode != 0: + unavailable.append(f"{role}={ref}") + if unavailable: + errors.append( + f"{label} references are not available as Git commits: {unavailable}" + ) + return set() + + diff = subprocess.run( + ["git", "-C", str(root), "diff", "--name-only", base_ref, head_ref, "--"], capture_output=True, text=True, encoding="utf-8", check=False, ) - if object_check.returncode != 0: - errors.append(f"baseline_ref is not available as a Git commit: {baseline_ref}") + whitespace = subprocess.run( + ["git", "-C", str(root), "diff", "--check", base_ref, head_ref, "--"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if whitespace.returncode != 0: + details = (whitespace.stdout + whitespace.stderr).strip() + errors.append(f"{label} diff has whitespace errors: {details}") + if diff.returncode != 0: + errors.append(f"unable to compute {label} changed paths") return set() + return { + path.strip().replace("\\", "/") + for path in diff.stdout.splitlines() + if path.strip() + } - diff = subprocess.run( - ["git", "-C", str(root), "diff", "--name-only", baseline_ref, "--"], + +def validate_expected_git_diff( + root: Path, + base_ref: str, + head_ref: str, + label: str, + expected: set[str], + errors: list[str], +) -> None: + changed = git_changed_paths_between(root, base_ref, head_ref, label, errors) + if changed != expected: + errors.append( + f"{label} changed paths differ from the expected artifact registry: " + f"missing={sorted(expected - changed)}, " + f"unexpected={sorted(changed - expected)}" + ) + + +def validate_wp0_git_evidence( + root: Path, + fields: dict[str, str], + expected: set[str], + errors: list[str], +) -> None: + pr_base_ref = fields.get("wp0_pr_base_ref", "") + pr_head_ref = fields.get("wp0_pr_head_ref", "") + pr_base_check = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{pr_base_ref}^{{commit}}"], capture_output=True, text=True, encoding="utf-8", check=False, ) - whitespace = subprocess.run( - ["git", "-C", str(root), "diff", "--check", baseline_ref, "--"], + if pr_base_check.returncode != 0: + errors.append(f"WP0 PR base ref is not available as a Git commit: {pr_base_ref}") + + pr_head_check = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{pr_head_ref}^{{commit}}"], capture_output=True, text=True, encoding="utf-8", check=False, ) - untracked = subprocess.run( - ["git", "-C", str(root), "ls-files", "--others", "--exclude-standard"], + if pr_base_check.returncode == 0 and pr_head_check.returncode == 0: + pr_ancestry = subprocess.run( + [ + "git", + "-C", + str(root), + "merge-base", + "--is-ancestor", + pr_base_ref, + pr_head_ref, + ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if pr_ancestry.returncode != 0: + errors.append("WP0 PR base ref must be an ancestor of the PR head ref") + validate_expected_git_diff( + root, + pr_base_ref, + pr_head_ref, + "WP0 PR base-to-head", + expected, + errors, + ) + + merge_parent_ref = fields.get("wp0_merge_parent_ref", "") + merge_ref = fields.get("wp0_merge_ref", "") + merge_parent_check = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{merge_parent_ref}^{{commit}}"], capture_output=True, text=True, encoding="utf-8", check=False, ) - if whitespace.returncode != 0: - details = (whitespace.stdout + whitespace.stderr).strip() - errors.append(f"WP0 committed/working diff has whitespace errors: {details}") - if diff.returncode != 0 or untracked.returncode != 0: - errors.append("unable to compute WP0 changed paths from baseline_ref") - return set() - return { - path.strip().replace("\\", "/") - for path in (diff.stdout + "\n" + untracked.stdout).splitlines() - if path.strip() - } + if pr_base_check.returncode == 0 and merge_parent_check.returncode == 0: + merge_ancestry = subprocess.run( + [ + "git", + "-C", + str(root), + "merge-base", + "--is-ancestor", + pr_base_ref, + merge_parent_ref, + ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if merge_ancestry.returncode != 0: + errors.append( + "WP0 PR base ref must be an ancestor of the squash-merge parent" + ) + validate_expected_git_diff( + root, + merge_parent_ref, + merge_ref, + "WP0 merge-parent-to-merge", + expected, + errors, + ) + + parents = subprocess.run( + ["git", "-C", str(root), "rev-list", "--parents", "-n", "1", merge_ref], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + expected_lineage = [merge_ref, merge_parent_ref] + actual_lineage = parents.stdout.strip().split() + if parents.returncode != 0 or actual_lineage != expected_lineage: + errors.append( + "WP0 squash-merge evidence must bind the merge commit and its parent exactly: " + f"expected={expected_lineage}, found={actual_lineage}" + ) + + if fields.get("wp0_exact_main_verification_status") == "passed": + verification_ref = fields.get("wp0_exact_main_verification_ref", "") + verification_ref_check = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{verification_ref}^{{commit}}"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if verification_ref_check.returncode != 0: + errors.append( + "passed WP0 exact-main verification ref is not available as a Git " + f"commit: {verification_ref}" + ) + else: + if verification_ref == merge_ref: + errors.append( + "passed WP0 exact-main verification ref must strictly descend " + "from the recorded WP0 merge commit" + ) + verification_ancestry = subprocess.run( + [ + "git", + "-C", + str(root), + "merge-base", + "--is-ancestor", + merge_ref, + verification_ref, + ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if verification_ancestry.returncode != 0: + errors.append( + "passed WP0 exact-main verification ref must descend from the " + "recorded WP0 merge commit" + ) + baseline_ancestry = subprocess.run( + [ + "git", + "-C", + str(root), + "merge-base", + "--is-ancestor", + verification_ref, + fields.get("baseline_ref", ""), + ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if baseline_ancestry.returncode != 0: + errors.append( + "passed WP0 exact-main verification requires baseline_ref to advance " + "to the verification ref or a later main commit" + ) + + subject = subprocess.run( + ["git", "-C", str(root), "show", "-s", "--format=%s", merge_ref], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + expected_pr_marker = f"(#{fields.get('wp0_pr_number', '')})" + if subject.returncode != 0 or expected_pr_marker not in subject.stdout.strip(): + errors.append( + "WP0 merge commit subject must identify the implementation PR: " + f"expected marker={expected_pr_marker}, found={subject.stdout.strip()!r}" + ) def validate_artifacts( @@ -527,13 +934,8 @@ def validate_artifacts( if len(text.splitlines()) > 20: errors.append(f"legacy pointer must not maintain an independent state copy: {relative}") - if check_git_diff and fields.get("current_work_package") == "WP0": - changed = git_changed_paths(root, fields.get("baseline_ref", ""), errors) - if changed != expected: - errors.append( - "WP0 changed paths differ from the expected artifact registry: " - f"missing={sorted(expected - changed)}, unexpected={sorted(changed - expected)}" - ) + if check_git_diff: + validate_wp0_git_evidence(root, fields, expected, errors) def validate_markdown( @@ -613,6 +1015,8 @@ def validate( check_git_diff: bool = True, check_markdown: bool = True, markdown_paths: tuple[str, ...] | None = None, + check_github_run: bool = True, + github_run_loader: GithubRunLoader | None = None, ) -> tuple[list[str], dict[str, object]]: errors: list[str] = [] sdd_path = root / ".planning/SDD.md" @@ -627,6 +1031,8 @@ def validate( errors.append(f"current_plan must point to {expected_plan}") requirement_count, decision_count = validate_registries(root, sdd, errors) validate_current_state(sdd, fields, errors) + if check_github_run: + validate_wp0_exact_main_run(fields, errors, github_run_loader) validate_wp0_gate_contract(sdd, errors) invariant_count = validate_invariants(sdd, errors) validate_artifacts(root, sdd, fields, errors, check_git_diff) @@ -659,6 +1065,94 @@ def copy_contract(root: Path, destination: Path) -> None: shutil.copy2(source, target) +def replace_once(text: str, pattern: str, replacement: str, label: str) -> str: + updated, replacements = re.subn(pattern, replacement, text, count=1) + if replacements != 1: + raise AssertionError(f"{label} mutation requires exactly one matching line") + return updated + + +def set_wp0_state_text( + sdd: str, + *, + work_package_status: str, + verification_ref: str, + verification_run: str, + verification_status: str, +) -> str: + updated = replace_once( + sdd, + r"(?m)^current_work_package_status: [a-z-]+$", + f"current_work_package_status: {work_package_status}", + "current WP status", + ) + for field, value in ( + ("wp0_exact_main_verification_ref", verification_ref), + ("wp0_exact_main_verification_run", verification_run), + ("wp0_exact_main_verification_status", verification_status), + ): + updated = replace_once( + updated, + rf"(?m)^{re.escape(field)}: .+$", + f"{field}: {value}", + field, + ) + + wp0_start = updated.find("### WP0:") + wp1_start = updated.find("### WP1:", wp0_start) + if wp0_start < 0 or wp1_start < 0: + raise AssertionError("WP0 state mutation requires WP0 and WP1 headings") + prefix = updated[:wp0_start] + wp0 = updated[wp0_start:wp1_start] + suffix = updated[wp1_start:] + wp0 = replace_once( + wp0, + r"(?m)^状态:[a-z-]+。$", + f"状态:{work_package_status}。", + "WP0 block status", + ) + wp0 = replace_once( + wp0, + r"(?m)^- WP0 exact-main verification:.+。$", + "- WP0 exact-main verification:" + f"status={verification_status},ref={verification_ref},run={verification_run}。", + "WP0 evidence projection", + ) + updated = prefix + wp0 + suffix + updated = replace_once( + updated, + r"(?m)^\| Status \| [a-z-]+ \|$", + f"| Status | {work_package_status} |", + "current-state table status", + ) + return replace_once( + updated, + r"(?m)^\| WP0 exact-main verification \| [a-z-]+ \|$", + f"| WP0 exact-main verification | {verification_status} |", + "current-state table exact-main verification", + ) + + +def promote_wp0_without_exact_main_evidence_text(sdd: str) -> str: + return set_wp0_state_text( + sdd, + work_package_status="verified", + verification_ref="none", + verification_run="none", + verification_status="pending", + ) + + +def drift_wp0_evidence_projection_text(sdd: str) -> str: + return replace_once( + sdd, + r"(?m)^- WP0 exact-main verification:.+。$", + "- WP0 exact-main verification:status=invalid,ref=" + f"{EXPECTED_WP0_IMMUTABLE_EVIDENCE['wp0_merge_ref']},run=invalid。", + "WP0 evidence projection drift", + ) + + def expect_failure( root: Path, mutation, @@ -676,6 +1170,7 @@ def expect_failure( check_git_diff=False, check_markdown=check_markdown, markdown_paths=markdown_paths, + check_github_run=False, ) if not any(expected_fragment in error for error in errors): raise AssertionError( @@ -684,10 +1179,297 @@ def expect_failure( def run_self_tests(root: Path) -> None: - errors, _ = validate(root) + errors, _ = validate(root, check_github_run=False) if errors: raise AssertionError(f"baseline contract must pass before mutations: {errors}") + field_errors: list[str] = [] + fields = parse_front_matter(read_text(root / ".planning/SDD.md"), field_errors) + if field_errors: + raise AssertionError(f"baseline front matter must parse: {field_errors}") + + stale_errors: list[str] = [] + validate_expected_git_diff( + root, + fields["wp0_pr_base_ref"], + fields["wp0_merge_ref"], + "stale-baseline regression", + set(EXPECTED_WP0_ARTIFACTS), + stale_errors, + ) + if not any( + "unexpected=" in error and "src/storage/src/storage.rs" in error + for error in stale_errors + ): + raise AssertionError( + "stale-baseline regression must expose the concurrently merged source paths: " + f"{stale_errors}" + ) + + wrong_parent_fields = dict(fields) + wrong_parent_fields["wp0_merge_parent_ref"] = fields["wp0_pr_base_ref"] + wrong_parent_errors: list[str] = [] + validate_wp0_git_evidence( + root, + wrong_parent_fields, + set(EXPECTED_WP0_ARTIFACTS), + wrong_parent_errors, + ) + if not any("squash-merge evidence" in error for error in wrong_parent_errors): + raise AssertionError( + "wrong merge parent must fail the immutable lineage check: " + f"{wrong_parent_errors}" + ) + + wrong_merge_fields = dict(fields) + wrong_merge_fields["wp0_merge_ref"] = fields["wp0_merge_parent_ref"] + wrong_merge_errors: list[str] = [] + validate_wp0_git_evidence( + root, + wrong_merge_fields, + set(EXPECTED_WP0_ARTIFACTS), + wrong_merge_errors, + ) + if not any("squash-merge evidence" in error for error in wrong_merge_errors): + raise AssertionError( + "wrong merge ref must fail the immutable lineage check: " + f"{wrong_merge_errors}" + ) + + wrong_base_fields = dict(fields) + wrong_base_fields["wp0_pr_base_ref"] = "0" * 40 + wrong_base_errors: list[str] = [] + validate_wp0_git_evidence( + root, + wrong_base_fields, + set(EXPECTED_WP0_ARTIFACTS), + wrong_base_errors, + ) + if not any("PR base ref is not available" in error for error in wrong_base_errors): + raise AssertionError( + "wrong PR base ref must fail even when the PR head object is unavailable: " + f"{wrong_base_errors}" + ) + + wrong_verification_fields = dict(fields) + wrong_verification_fields.update( + { + "wp0_exact_main_verification_status": "passed", + "wp0_exact_main_verification_ref": fields["wp0_pr_base_ref"], + "wp0_exact_main_verification_run": "1", + } + ) + wrong_verification_errors: list[str] = [] + validate_wp0_git_evidence( + root, + wrong_verification_fields, + set(EXPECTED_WP0_ARTIFACTS), + wrong_verification_errors, + ) + if not any( + "verification ref must descend" in error + for error in wrong_verification_errors + ): + raise AssertionError( + "unrelated exact-main verification ref must fail ancestry validation: " + f"{wrong_verification_errors}" + ) + + equal_verification_fields = dict(fields) + equal_verification_fields.update( + { + "wp0_exact_main_verification_status": "passed", + "wp0_exact_main_verification_ref": fields["wp0_merge_ref"], + "wp0_exact_main_verification_run": "1", + } + ) + equal_verification_errors: list[str] = [] + validate_wp0_git_evidence( + root, + equal_verification_fields, + set(EXPECTED_WP0_ARTIFACTS), + equal_verification_errors, + ) + if not any("strictly descend" in error for error in equal_verification_errors): + raise AssertionError( + "the merge commit itself must not satisfy exact-main verification: " + f"{equal_verification_errors}" + ) + + current_head = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ).stdout.strip() + unadvanced_baseline_fields = dict(fields) + unadvanced_baseline_fields.update( + { + "wp0_exact_main_verification_status": "passed", + "wp0_exact_main_verification_ref": current_head, + "wp0_exact_main_verification_run": "1", + } + ) + unadvanced_baseline_errors: list[str] = [] + validate_wp0_git_evidence( + root, + unadvanced_baseline_fields, + set(EXPECTED_WP0_ARTIFACTS), + unadvanced_baseline_errors, + ) + if not any( + "requires baseline_ref to advance" in error + for error in unadvanced_baseline_errors + ): + raise AssertionError( + "passed exact-main promotion must explain the required baseline advance: " + f"{unadvanced_baseline_errors}" + ) + + advanced_baseline_fields = dict(unadvanced_baseline_fields) + advanced_baseline_fields["baseline_ref"] = current_head + advanced_baseline_errors: list[str] = [] + validate_wp0_git_evidence( + root, + advanced_baseline_fields, + set(EXPECTED_WP0_ARTIFACTS), + advanced_baseline_errors, + ) + if advanced_baseline_errors: + raise AssertionError( + "passed exact-main promotion must be reachable after baseline_ref advances: " + f"{advanced_baseline_errors}" + ) + + valid_run: dict[str, object] = { + "id": 30750372362, + "status": "completed", + "conclusion": "success", + "event": "push", + "head_branch": "main", + "head_sha": "2" * 40, + "name": "ci", + "path": ".github/workflows/ci.yml", + } + invalid_run_values: dict[str, object] = { + "id": 30750372363, + "status": "in_progress", + "conclusion": "failure", + "event": "pull_request", + "head_branch": "codex/fix-sdd-post-merge-validation", + "head_sha": "3" * 40, + "name": "Benchmark", + "path": ".github/workflows/benchmark.yml", + } + baseline_sdd = read_text(root / ".planning/SDD.md") + synthetic_passed_sdd = set_wp0_state_text( + baseline_sdd, + work_package_status="verified", + verification_ref="2" * 40, + verification_run="30750372362", + verification_status="passed", + ) + with tempfile.TemporaryDirectory(prefix="kiwi-sdd-integration-test-") as temporary: + candidate = Path(temporary) + copy_contract(root, candidate) + candidate_sdd = candidate / ".planning/SDD.md" + + tampered_head = "1" * 40 + tampered_head_sdd = replace_once( + baseline_sdd, + r"(?m)^wp0_pr_head_ref: [0-9a-f]{40}$", + f"wp0_pr_head_ref: {tampered_head}", + "tampered immutable PR head", + ) + tampered_head_sdd = replace_once( + tampered_head_sdd, + r"(?m)^- PR 固定区间:.+;$", + f"- PR 固定区间:{fields['wp0_pr_base_ref']}..{tampered_head};", + "tampered immutable PR range projection", + ) + candidate_sdd.write_text(tampered_head_sdd, encoding="utf-8") + tampered_head_errors, _ = validate( + candidate, + check_git_diff=False, + check_markdown=False, + check_github_run=False, + ) + if not any( + "wp0_pr_head_ref must remain" in error + for error in tampered_head_errors + ): + raise AssertionError( + "the validate entry point must reject a syntactically valid but " + f"tampered immutable PR head: {tampered_head_errors}" + ) + + candidate_sdd.write_text(synthetic_passed_sdd, encoding="utf-8") + loaded_runs: list[str] = [] + + def load_valid_run(run_id: str) -> dict[str, object]: + loaded_runs.append(run_id) + return dict(valid_run) + + valid_run_errors, _ = validate( + candidate, + check_git_diff=False, + check_markdown=False, + github_run_loader=load_valid_run, + ) + if loaded_runs != ["30750372362"] or valid_run_errors: + raise AssertionError( + "the validate entry point must load and accept matching exact-main " + f"evidence once: loaded={loaded_runs}, errors={valid_run_errors}" + ) + + for field, invalid_value in invalid_run_values.items(): + invalid_run = dict(valid_run) + invalid_run[field] = invalid_value + invalid_run_errors, _ = validate( + candidate, + check_git_diff=False, + check_markdown=False, + github_run_loader=lambda _run_id, run=invalid_run: run, + ) + if not any( + f"run {field} must" in error for error in invalid_run_errors + ): + raise AssertionError( + "the validate entry point must reject GitHub Actions run " + f"{field} mismatch: {invalid_run_errors}" + ) + + evidence_removed_sdd = promote_wp0_without_exact_main_evidence_text( + synthetic_passed_sdd + ) + state_regression_lines = ( + "current_work_package_status: verified", + "wp0_exact_main_verification_ref: none", + "wp0_exact_main_verification_run: none", + "wp0_exact_main_verification_status: pending", + "状态:verified。", + "| Status | verified |", + "| WP0 exact-main verification | pending |", + "- WP0 exact-main verification:status=pending,ref=none,run=none。", + ) + missing_state_lines = [ + line for line in state_regression_lines if line not in evidence_removed_sdd + ] + if evidence_removed_sdd == synthetic_passed_sdd or missing_state_lines: + raise AssertionError( + "verified-without-evidence mutation must work from a passed baseline: " + f"missing={missing_state_lines}" + ) + drifted_passed_sdd = drift_wp0_evidence_projection_text(synthetic_passed_sdd) + if ( + drifted_passed_sdd == synthetic_passed_sdd + or "status=invalid" not in drifted_passed_sdd + ): + raise AssertionError( + "evidence projection drift mutation must work from a passed baseline" + ) + for relative in ( ".planning/REQUIREMENTS.md", ".planning/DECISIONS.md", @@ -904,6 +1686,46 @@ def break_baseline(candidate: Path) -> None: expect_failure(root, break_baseline, "baseline_ref must be a full") + def break_wp0_evidence_ref(candidate: Path) -> None: + path = candidate / ".planning/SDD.md" + text, replacements = re.subn( + r"(?m)^wp0_pr_head_ref: [0-9a-f]{40}$", + "wp0_pr_head_ref: deadbeef", + read_text(path), + count=1, + ) + if replacements != 1: + raise AssertionError( + "WP0 evidence mutation requires exactly one full wp0_pr_head_ref SHA" + ) + path.write_text(text, encoding="utf-8") + + expect_failure( + root, + break_wp0_evidence_ref, + "wp0_pr_head_ref must be a full", + ) + + def break_wp0_pr_number(candidate: Path) -> None: + path = candidate / ".planning/SDD.md" + text, replacements = re.subn( + r"(?m)^wp0_pr_number: [1-9][0-9]*$", + "wp0_pr_number: 999", + read_text(path), + count=1, + ) + if replacements != 1: + raise AssertionError( + "WP0 PR mutation requires exactly one wp0_pr_number field" + ) + path.write_text(text, encoding="utf-8") + + expect_failure( + root, + break_wp0_pr_number, + "WP0 implementation PR must match immutable wp0_pr_number", + ) + def break_current_anchor(candidate: Path) -> None: path = candidate / ".planning/SDD.md" text = read_text(path).replace('', '', 1) @@ -963,26 +1785,61 @@ def remove_live_gate(candidate: Path) -> None: expect_failure(root, remove_live_gate, "WP0 verification gate contract drifted") - with tempfile.TemporaryDirectory(prefix="kiwi-sdd-lifecycle-") as temporary: - candidate = Path(temporary) - copy_contract(root, candidate) + def promote_wp0_without_exact_main_evidence(candidate: Path) -> None: path = candidate / ".planning/SDD.md" - text = read_text(path) - text = text.replace("current_work_package_status: in-progress", "current_work_package_status: implemented", 1) - wp0 = text.index("### WP0:") - prefix, suffix = text[:wp0], text[wp0:] - suffix = suffix.replace("状态:in-progress。", "状态:implemented。", 1) - suffix = suffix.replace("| Status | in-progress |", "| Status | implemented |", 1) - path.write_text(prefix + suffix, encoding="utf-8") - lifecycle_errors, _ = validate( - candidate, check_git_diff=False, check_markdown=False - ) - if lifecycle_errors: - raise AssertionError(f"implemented lifecycle state must remain valid: {lifecycle_errors}") + path.write_text( + promote_wp0_without_exact_main_evidence_text(read_text(path)), + encoding="utf-8", + ) + + expect_failure( + root, + promote_wp0_without_exact_main_evidence, + "WP0 status verified requires passed exact-main verification evidence", + ) + + def drift_wp0_evidence_projection(candidate: Path) -> None: + path = candidate / ".planning/SDD.md" + path.write_text( + drift_wp0_evidence_projection_text(read_text(path)), + encoding="utf-8", + ) + + expect_failure( + root, + drift_wp0_evidence_projection, + "WP0 evidence projection must contain exactly one line", + ) + + def duplicate_wp0_evidence_projection(candidate: Path) -> None: + path = candidate / ".planning/SDD.md" + conflict = ( + "- PR 固定区间:" + "1111111111111111111111111111111111111111.." + "2222222222222222222222222222222222222222;" + ) + text, replacements = re.subn( + r"(?m)^- PR 固定区间:.+;$", + lambda match: f"{match.group(0)}\n{conflict}", + read_text(path), + count=1, + ) + if replacements != 1: + raise AssertionError( + "duplicate projection mutation requires exactly one PR range" + ) + path.write_text(text, encoding="utf-8") + + expect_failure( + root, + duplicate_wp0_evidence_projection, + "WP0 evidence projection must contain exactly one line", + ) print( "SDD validator self-tests passed " - "(25 failure-path mutations, 1 lifecycle transition, 1 prose guard)" + "(30 failure-path mutations, 1 prose guard, " + "immutable Git/live-run/state regressions, 1 reachable promotion path)" )