import io
import json
import os
import subprocess
import tempfile
import unittest
from contextlib import redirect_stderr
from datetime import date
from pathlib import Path
from unittest.mock import patch

from gzkit.hooks.core import record_artifact_edit
from gzkit.hooks.obpi import ObpiValidator
from gzkit.ledger import (
    Ledger,
    adr_created_event,
    obpi_created_event,
    obpi_receipt_emitted_event,
    project_init_event,
)
from gzkit.traceability import covers  # noqa: F401


class TestObpiValidator(unittest.TestCase):
    _template_dir: tempfile.TemporaryDirectory

    @classmethod
    def setUpClass(cls) -> None:
        import shutil

        cls._template_dir = tempfile.TemporaryDirectory()
        root = Path(cls._template_dir.name)
        gzkit_dir = root / ".gzkit"
        gzkit_dir.mkdir()

        (gzkit_dir / "manifest.json").write_text(
            json.dumps(
                {
                    "schema": "gzkit.manifest.v2",
                    "structure": {"design_root": "docs/design"},
                    "gates": {"lite": [1, 2], "heavy": [1, 2, 3, 4, 5]},
                }
            ),
            encoding="utf-8",
        )

        (root / ".gzkit.json").write_text(
            json.dumps({"mode": "lite", "paths": {"ledger": ".gzkit/ledger.jsonl"}}),
            encoding="utf-8",
        )

        ledger = Ledger(gzkit_dir / "ledger.jsonl")
        ledger.append(project_init_event("test-project", "lite"))

        subprocess.run(["git", "init", "-b", "main"], cwd=root, check=True, capture_output=True)
        # Append the [user] section directly instead of ``git config`` x2.
        config_path = root / ".git" / "config"
        config_path.write_text(
            config_path.read_text(encoding="utf-8")
            + "[user]\n\tname = Test User\n\temail = test@example.com\n",
            encoding="utf-8",
        )
        subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
        subprocess.run(
            ["git", "commit", "-m", "chore: initial"],
            cwd=root,
            check=True,
            capture_output=True,
        )
        cls._shutil = shutil

    @classmethod
    def tearDownClass(cls) -> None:
        cls._template_dir.cleanup()

    def setUp(self):
        self._tmp_ctx = tempfile.TemporaryDirectory()
        self.test_dir = Path(self._tmp_ctx.name)
        self.project_root = self.test_dir / "repo"
        self._shutil.copytree(self._template_dir.name, self.project_root)
        self.gzkit_dir = self.project_root / ".gzkit"

        self.ledger_path = self.gzkit_dir / "ledger.jsonl"
        self.ledger = Ledger(self.ledger_path)

        self.validator = ObpiValidator(self.project_root)

    def tearDown(self):
        self._tmp_ctx.cleanup()

    def _git(self, *args: str) -> str:
        result = subprocess.run(
            ["git", *args],
            cwd=self.project_root,
            check=True,
            capture_output=True,
            text=True,
        )
        return result.stdout.strip()

    def _commit_all(self, message: str) -> None:
        self._git("add", "-A")
        self._git("commit", "--allow-empty", "-m", message)

    def _register_adr(self, adr_id: str, lane: str = "lite", *, commit: bool = True) -> None:
        self.ledger.append(adr_created_event(adr_id, "PRD-TEST", lane))
        if commit:
            self._commit_all(f"chore: register {adr_id}")

    def _create_obpi(
        self,
        adr_id: str,
        *,
        status: str = "Draft",
        summary: str = "- Evidence: Done",
        proof: str = "Proof here",
        attestation: str | None = None,
        lane: str = "lite",
        relative_path: str | None = None,
        allowed_paths: list[str] | None = None,
        commit: bool = True,
    ) -> Path:
        self._register_adr(adr_id, lane, commit=commit)

        obpi_id = f"OBPI-{adr_id}-01"
        rel_path = relative_path or f"{obpi_id}.md"
        path = self.project_root / rel_path
        path.parent.mkdir(parents=True, exist_ok=True)

        allowlist = allowed_paths or [rel_path]
        allowlist_lines = "\n".join(f"- `{item}` - in scope" for item in allowlist)
        content = f"""---
id: {obpi_id}
parent: {adr_id}
status: {status}
---

# {obpi_id}

## Allowed Paths
{allowlist_lines}

### Implementation Summary
{summary}

## Key Proof
{proof}
"""
        if attestation:
            content += f"\n## Human Attestation\n{attestation}\n"

        path.write_text(content, encoding="utf-8")
        return path

    def test_validate_draft_is_always_valid(self):
        path = self._create_obpi("ADR-0.1.0", status="Draft", summary="...", commit=False)
        errors = self.validator.validate_file(path)
        self.assertEqual(errors, [])

    @covers("REQ-0.11.0-02-01")
    def test_validate_completed_passes_with_allowlisted_changes(self):
        obpi_path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
            allowed_paths=["OBPI-ADR-0.1.0-01.md", "src/**"],
        )
        module_path = self.project_root / "src" / "demo.py"
        module_path.parent.mkdir(parents=True, exist_ok=True)
        module_path.write_text("print('ok')\n", encoding="utf-8")

        errors = self.validator.validate_file(obpi_path)
        self.assertEqual(errors, [])

    @covers("REQ-0.11.0-01-01")
    def test_validate_completed_blocks_out_of_scope_changes(self):
        obpi_path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
        )
        outside_path = self.project_root / "docs" / "outside.md"
        outside_path.parent.mkdir(parents=True, exist_ok=True)
        outside_path.write_text("out of scope\n", encoding="utf-8")

        errors = self.validator.validate_file(obpi_path)
        self.assertIn(
            "Changed-files audit found out-of-allowlist path: docs/outside.md. "
            "Amend the OBPI or revert the change.",
            errors,
        )

    @covers("REQ-0.11.0-02-02")
    def test_validate_completed_requires_allowed_paths(self):
        self._register_adr("ADR-0.1.0")
        obpi_path = self.project_root / "OBPI-ADR-0.1.0-01.md"
        obpi_path.write_text(
            "---\n"
            "id: OBPI-ADR-0.1.0-01\n"
            "parent: ADR-0.1.0\n"
            "status: Completed\n"
            "---\n\n"
            "# OBPI-ADR-0.1.0-01\n\n"
            "### Implementation Summary\n"
            "- Task: Finished implementation\n\n"
            "## Key Proof\n"
            "Works as expected\n",
            encoding="utf-8",
        )

        errors = self.validator.validate_file(obpi_path)
        self.assertIn("Missing or empty 'Allowed Paths' allowlist.", errors)

    @covers("REQ-0.11.0-02-03")
    def test_validate_heavy_completed_missing_attestation(self):
        path = self._create_obpi(
            "ADR-0.2.0",
            status="Completed",
            summary="- Done: Yes",
            proof="Proof",
            lane="heavy",
        )
        errors = self.validator.validate_file(path)
        self.assertIn("Heavy/Foundation OBPI requires a '## Human Attestation' section.", errors)

    def test_validate_heavy_completed_valid_attestation(self):
        attestation = (
            f"- Attestor: human:jeff\n- Attestation: Looks good\n- Date: {date.today().isoformat()}"
        )
        path = self._create_obpi(
            "ADR-0.2.0",
            status="Completed",
            summary="- Done: Yes",
            proof="Proof",
            attestation=attestation,
            lane="heavy",
        )
        errors = self.validator.validate_file(path)
        self.assertEqual(errors, [])

    def test_validate_strict_placeholders(self):
        path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: TODO",
            proof="...",
        )
        errors = self.validator.validate_file(path)
        self.assertIn("Missing or non-substantive 'Implementation Summary'.", errors)
        self.assertIn("Missing or non-substantive 'Key Proof'.", errors)

    def test_validate_requires_git_repo(self):
        path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
        )
        import shutil
        import stat

        def _force_remove_readonly(func, path, exc_info):  # noqa: ARG001
            os.chmod(path, stat.S_IWRITE)
            func(path)

        shutil.rmtree(self.project_root / ".git", onexc=_force_remove_readonly)

        errors = self.validator.validate_file(path)
        self.assertIn("Not a git repository.", errors)

    def test_validate_blocks_skip_values_that_bypass_xenon(self):
        path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
        )
        original_skip = os.environ.get("SKIP")
        os.environ["SKIP"] = "xenon-complexity"
        try:
            errors = self.validator.validate_file(path)
        finally:
            if original_skip is None:
                os.environ.pop("SKIP", None)
            else:
                os.environ["SKIP"] = original_skip

        self.assertIn(
            "Refusing completion validation with SKIP that can bypass xenon complexity checks.",
            errors,
        )

    @covers("REQ-0.11.0-03-01")
    @covers("REQ-0.11.0-03-02")
    def test_recorder_appends_receipt_to_ledger(self):
        final_rel_path = "docs/design/adr/pre-release/ADR-0.1.0/obpis/OBPI-ADR-0.1.0-01.md"
        final_path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Done: Yes",
            proof="Verified",
            relative_path=final_rel_path,
            allowed_paths=["docs/design/adr/pre-release/ADR-0.1.0/**"],
        )
        rel_path = final_path.relative_to(self.project_root).as_posix()

        record_artifact_edit(self.project_root, rel_path, session="test-session")

        events = self.ledger.read_all()
        self.assertTrue(any(e.event == "obpi_receipt_emitted" for e in events))

        receipt = [e for e in events if e.event == "obpi_receipt_emitted"][-1]
        self.assertEqual(receipt.extra.get("receipt_event"), "completed")
        self.assertEqual(receipt.id, final_path.stem)
        evidence = receipt.extra.get("evidence")
        self.assertIsInstance(evidence, dict)
        assert isinstance(evidence, dict)
        self.assertEqual(evidence.get("key_proof"), "Verified")
        self.assertTrue(evidence.get("req_proof_inputs"))
        self.assertEqual(evidence.get("recorder_source"), "hook:auto")
        self.assertEqual(
            evidence.get("recorder_warnings"),
            ["Working tree was dirty when the completion receipt was captured."],
        )

        scope_audit = evidence.get("scope_audit")
        self.assertEqual(
            scope_audit,
            {
                "allowlist": ["docs/design/adr/pre-release/ADR-0.1.0/**"],
                "changed_files": [rel_path],
                "out_of_scope_files": [],
            },
        )
        git_sync_state = evidence.get("git_sync_state")
        self.assertIsInstance(git_sync_state, dict)
        self.assertTrue(git_sync_state.get("dirty"))
        self.assertIsInstance(git_sync_state.get("ahead"), int)
        self.assertIsInstance(git_sync_state.get("behind"), int)
        self.assertIsInstance(git_sync_state.get("blockers"), list)

        anchor = receipt.extra.get("anchor")
        self.assertIsNotNone(anchor)
        assert isinstance(anchor, dict)
        self.assertNotEqual(anchor.get("commit"), "0000000")
        self.assertEqual(anchor.get("semver"), "0.1.0")

    @covers("REQ-0.11.0-03-03")
    def test_recorder_warns_but_keeps_receipt_when_anchor_capture_degrades(self):
        final_rel_path = "docs/design/adr/pre-release/ADR-0.1.0/obpis/OBPI-ADR-0.1.0-01.md"
        final_path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Done: Yes",
            proof="Verified",
            relative_path=final_rel_path,
            allowed_paths=["docs/design/adr/pre-release/ADR-0.1.0/**"],
        )
        rel_path = final_path.relative_to(self.project_root).as_posix()

        with patch(
            "gzkit.hooks.core.capture_validation_anchor_with_warnings",
            return_value=(None, ["Could not resolve HEAD commit for receipt anchor."]),
        ):
            record_artifact_edit(self.project_root, rel_path, session="test-session")

        receipt = [e for e in self.ledger.read_all() if e.event == "obpi_receipt_emitted"][-1]
        evidence = receipt.extra.get("evidence")
        self.assertIsInstance(evidence, dict)
        assert isinstance(evidence, dict)
        self.assertIn(
            "Could not resolve HEAD commit for receipt anchor.",
            evidence.get("recorder_warnings", []),
        )
        self.assertNotIn("anchor", receipt.extra)

    @covers("REQ-0.11.0-03-02")
    def test_recorder_append_failure_is_warning_only(self):
        final_rel_path = "docs/design/adr/pre-release/ADR-0.1.0/obpis/OBPI-ADR-0.1.0-01.md"
        final_path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Done: Yes",
            proof="Verified",
            relative_path=final_rel_path,
            allowed_paths=["docs/design/adr/pre-release/ADR-0.1.0/**"],
        )
        rel_path = final_path.relative_to(self.project_root).as_posix()
        stderr = io.StringIO()

        with (
            patch.object(Ledger, "append", side_effect=[None, OSError("disk full")]),
            redirect_stderr(stderr),
        ):
            result = record_artifact_edit(self.project_root, rel_path, session="test-session")

        self.assertTrue(result)
        self.assertIn("Could not append OBPI completion receipt", stderr.getvalue())

    def test_validate_blocks_merge_head(self):
        path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
        )
        current_branch = self._git("rev-parse", "--abbrev-ref", "HEAD")
        self._git("checkout", "-b", "feature")
        (self.project_root / "feature.txt").write_text("feature\n", encoding="utf-8")
        self._commit_all("feat: branch change")
        self._git("checkout", current_branch)
        (self.project_root / "main.txt").write_text("main\n", encoding="utf-8")
        self._commit_all("feat: main change")
        self._git("merge", "--no-ff", "feature", "-m", "merge feature")
        path.write_text(path.read_text(encoding="utf-8") + "\n", encoding="utf-8")

        errors = self.validator.validate_file(path)
        self.assertIn("Merge commit at HEAD. Linearize history before completion.", errors)

    # ------------------------------------------------------------------
    # Template scaffold detection (GHI #27)
    # ------------------------------------------------------------------

    def _create_scaffold_obpi(
        self, adr_id: str, *, status: str = "Draft", commit: bool = True
    ) -> Path:
        """Create an OBPI with the exact template defaults from superbook."""
        self._register_adr(adr_id, commit=commit)
        obpi_id = f"OBPI-{adr_id}-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: {adr_id}\nstatus: {status}\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Allowed Paths\n"
            "- `src/module/` - Reason this is in scope\n"
            "- `tests/test_module.py` - Reason\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: First constraint\n"
            "1. REQUIREMENT: Second constraint\n"
            "1. NEVER: What must not happen\n"
            "1. ALWAYS: What must always be true\n\n"
            "## Acceptance Criteria\n"
            "- [ ] REQ-0.1.0-01-01: Given/When/Then behavior criterion 1\n"
            "- [ ] REQ-0.1.0-01-02: Given/When/Then behavior criterion 2\n\n"
            "### Implementation Summary\n"
            "- Files created/modified:\n"
            "- Tests added:\n\n"
            "## Key Proof\n\n",
            encoding="utf-8",
        )
        return path

    def test_draft_scaffold_detected(self):
        """Draft OBPIs with template scaffold return warnings."""
        path = self._create_scaffold_obpi("ADR-0.1.0", status="Draft", commit=False)
        errors = self.validator.validate_file(path)
        self.assertTrue(len(errors) >= 3, f"Expected >=3 scaffold warnings, got {errors}")
        self.assertTrue(any("src/module/" in e for e in errors))
        self.assertTrue(any("First constraint" in e for e in errors))
        self.assertTrue(any("Given/When/Then" in e for e in errors))

    def test_completed_scaffold_also_detected(self):
        """Completed OBPIs with template scaffold include scaffold errors."""
        path = self._create_scaffold_obpi("ADR-0.1.0", status="Completed")
        errors = self.validator.validate_file(path)
        self.assertTrue(any("src/module/" in e for e in errors))

    def test_draft_without_scaffold_passes(self):
        """Draft OBPIs with real content return no warnings."""
        path = self._create_obpi(
            "ADR-0.1.0", status="Draft", summary="- Done: Real work", commit=False
        )
        errors = self.validator.validate_file(path)
        self.assertEqual(errors, [])

    def test_scaffold_partial_detection(self):
        """Brief with only some scaffold sections still catches those."""
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Allowed Paths\n"
            "- `src/gzkit/closeout.py` - Real path\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: First constraint\n\n"
            "## Acceptance Criteria\n"
            "- [ ] REQ-0.1.0-01-01: Closeout bumps version\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path)
        self.assertEqual(len(errors), 1)
        self.assertIn("First constraint", errors[0])

    def test_detection_includes_verification_placeholder(self):
        """Verification scaffold placeholder is rejected for draft briefs."""
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Allowed Paths\n- `docs/design/**` - real path\n\n"
            "## Requirements (FAIL-CLOSED)\n1. REQUIREMENT: Real requirement\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion\n\n"
            "## Verification\n```bash\ncommand --to --verify\n```\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path)
        self.assertEqual(len(errors), 1)
        self.assertIn("command --to --verify", errors[0])

    def test_detection_flags_lane_mismatch(self):
        """Lane section prose must match frontmatter lane."""
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Heavy\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Allowed Paths\n- `docs/design/**` - real path\n\n"
            "## Requirements (FAIL-CLOSED)\n1. REQUIREMENT: Real requirement\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion\n\n"
            "## Lane\n**Lite** - Incorrect lane prose.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path)
        self.assertEqual(len(errors), 1)
        self.assertIn("Lane section body does not match frontmatter lane", errors[0])

    def test_authored_validation_blocks_thin_draft(self):
        """Authored mode fails drafts that only satisfy scaffold-free structure."""
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Allowed Paths\n- `src/gzkit/ports/` - Port definitions\n\n"
            "## Requirements (FAIL-CLOSED)\n1. REQUIREMENT: Use Protocols for ports.\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Ports are defined.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        self.assertTrue(any("'Objective'" in error for error in errors))
        self.assertTrue(any("'Denied Paths'" in error for error in errors))
        self.assertTrue(any("'Discovery Checklist'" in error for error in errors))
        self.assertTrue(any("Verification must include" in error for error in errors))

    def test_authored_validation_flags_unregistered_gz_command_in_brief(self):
        """GHI #194: brief-prescribed `gz` commands must resolve against the parser.

        Seeds a brief with the same command-shape defect as OBPI-0.0.16-04
        (singular `gz chore run` instead of plural `gz chores run`). Pre-fix
        the validator missed this; post-fix it must surface the error
        before the agent runs the wrong command at pipeline runtime.
        """
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Objective\nTest brief with deliberately-broken gz command.\n\n"
            "## Lane\n**Lite** - Internal contract.\n\n"
            "## Allowed Paths\n- `src/gzkit/ports/` - Port definitions\n\n"
            "## Denied Paths\n- `docs/user/manpages/` - No operator-surface changes\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: Run `uv run gz chore run frontmatter-ledger-coherence "
            "--dry-run` first.\n\n"
            "## Discovery Checklist\n"
            "**Prerequisites (check existence, STOP if missing):**\n"
            "- [ ] `src/gzkit/runtime.py`\n\n"
            "**Existing Code (understand current state):**\n"
            "- [ ] `src/gzkit/ports.py`\n\n"
            "## Verification\n```bash\nuv run gz lint\n```\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        # Must flag the broken `chore` (singular) verb chain — message names
        # the unregistered verb and offers the available alternatives.
        chore_errors = [
            e for e in errors if "command-shape" in e.lower() and "'chore'" in e and "chores" in e
        ]
        self.assertTrue(
            chore_errors,
            f"Expected command-shape error naming 'chore' with 'chores' in alternatives, "
            f"got: {errors}",
        )

    def test_authored_validation_passes_when_all_gz_commands_resolve(self):
        """GHI #194: brief with valid `gz` commands has zero command-shape errors."""
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Objective\nValid brief with correct gz commands.\n\n"
            "## Lane\n**Lite** - Internal contract.\n\n"
            "## Allowed Paths\n- `src/gzkit/ports/` - Port definitions\n\n"
            "## Denied Paths\n- `docs/user/manpages/` - No operator-surface changes\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: Run `uv run gz chores run frontmatter-ledger-coherence` "
            "(plural).\n"
            "2. REQUIREMENT: Run `uv run gz frontmatter reconcile --dry-run`.\n\n"
            "## Discovery Checklist\n"
            "**Prerequisites (check existence, STOP if missing):**\n"
            "- [ ] `src/gzkit/runtime.py`\n\n"
            "**Existing Code (understand current state):**\n"
            "- [ ] `src/gzkit/ports.py`\n\n"
            "## Verification\n```bash\n"
            "uv run gz lint\n"
            "uv run gz typecheck\n"
            "uv run gz validate --documents\n"
            "uv run gz adr status ADR-0.1.0\n"
            "uv run gz obpi reconcile OBPI-0.1.0-01\n"
            "```\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        cmd_errors = [e for e in errors if "command-shape" in e.lower()]
        self.assertEqual(
            cmd_errors,
            [],
            msg=f"Expected no command-shape errors, got: {cmd_errors}",
        )

    def test_speculative_marker_suppresses_inline_gz_chain(self):
        """GHI #432: speculative-skip marker suppresses immediately-following inline code.

        An OBPI brief that introduces a new CLI verb (e.g. `gz storybook`)
        must reference the unregistered verb in Demo/Verification sections.
        The marker convention `<!-- gz-validate-skip: command-shape -->`
        suppresses command-shape validation for the line that immediately
        follows it — same shape as the precedent in
        ``complexity_doctrine_links.py``.
        """
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Objective\nIntroduce a forward-reference CLI verb under this OBPI.\n\n"
            "## Lane\n**Lite** - Internal contract.\n\n"
            "## Allowed Paths\n- `src/gzkit/ports/` - Port definitions\n\n"
            "## Denied Paths\n- `docs/user/manpages/` - No operator-surface changes\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: This OBPI introduces a new verb. Forward-reference:\n"
            "<!-- gz-validate-skip: command-shape -->\n"
            "`uv run gz storybook derive --arc demo`.\n\n"
            "## Discovery Checklist\n"
            "**Prerequisites (check existence, STOP if missing):**\n"
            "- [ ] `src/gzkit/runtime.py`\n\n"
            "**Existing Code (understand current state):**\n"
            "- [ ] `src/gzkit/ports.py`\n\n"
            "## Verification\n```bash\nuv run gz lint\n```\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        cmd_errors = [e for e in errors if "command-shape" in e.lower()]
        self.assertEqual(
            cmd_errors,
            [],
            msg=(
                f"Expected speculative marker to suppress 'storybook' command-shape "
                f"error, got: {cmd_errors}"
            ),
        )

    def test_speculative_marker_suppresses_fenced_block(self):
        """GHI #432: marker preceding an opening fence suppresses the whole block.

        Fenced code blocks are the common operator-facing placement for
        gz invocations in Verification sections. A marker on the line
        immediately preceding the opening fence suppresses every
        ``gz <verb-chain>`` reference inside that fenced block, matching
        the inline-code suppression semantics.
        """
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Objective\nIntroduce a forward-reference CLI verb under this OBPI.\n\n"
            "## Lane\n**Lite** - Internal contract.\n\n"
            "## Allowed Paths\n- `src/gzkit/ports/` - Port definitions\n\n"
            "## Denied Paths\n- `docs/user/manpages/` - No operator-surface changes\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: Forward-reference verbs introduced by this brief.\n\n"
            "## Discovery Checklist\n"
            "**Prerequisites (check existence, STOP if missing):**\n"
            "- [ ] `src/gzkit/runtime.py`\n\n"
            "**Existing Code (understand current state):**\n"
            "- [ ] `src/gzkit/ports.py`\n\n"
            "## Verification\n"
            "<!-- gz-validate-skip: command-shape -->\n"
            "```bash\n"
            "uv run gz storybook derive --arc demo\n"
            "uv run gz storybook publish --target docs\n"
            "```\n\n"
            "Real verification:\n"
            "```bash\nuv run gz lint\n```\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        cmd_errors = [e for e in errors if "command-shape" in e.lower()]
        self.assertEqual(
            cmd_errors,
            [],
            msg=(
                f"Expected marker to suppress entire fenced block of 'storybook' "
                f"references, got: {cmd_errors}"
            ),
        )

    def test_speculative_marker_does_not_suppress_unrelated_lines(self):
        """GHI #432: marker suppresses only the immediately-following line/block.

        Brief authors should not be able to silence the validator across
        the whole document with a single marker. Suppression is strictly
        local — a typo on a later, non-suppressed line must still fail
        closed.
        """
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Objective\nMixed forward-reference and ordinary commands.\n\n"
            "## Lane\n**Lite** - Internal contract.\n\n"
            "## Allowed Paths\n- `src/gzkit/ports/` - Port definitions\n\n"
            "## Denied Paths\n- `docs/user/manpages/` - No operator-surface changes\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: Forward-reference verb (suppressed):\n"
            "<!-- gz-validate-skip: command-shape -->\n"
            "`uv run gz storybook derive --arc demo`.\n"
            "2. REQUIREMENT: Ordinary typo (must still fail):\n"
            "`uv run gz chore run frontmatter-ledger-coherence`.\n\n"
            "## Discovery Checklist\n"
            "**Prerequisites (check existence, STOP if missing):**\n"
            "- [ ] `src/gzkit/runtime.py`\n\n"
            "**Existing Code (understand current state):**\n"
            "- [ ] `src/gzkit/ports.py`\n\n"
            "## Verification\n```bash\nuv run gz lint\n```\n\n"
            "## Acceptance Criteria\n- [ ] REQ-0.1.0-01-01: Real criterion.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        chore_errors = [
            e for e in errors if "command-shape" in e.lower() and "'chore'" in e and "chores" in e
        ]
        self.assertTrue(
            chore_errors,
            f"Expected unsuppressed 'chore' typo to still fail closed, got: {errors}",
        )
        storybook_errors = [e for e in errors if "command-shape" in e.lower() and "storybook" in e]
        self.assertEqual(
            storybook_errors,
            [],
            msg=(
                f"Expected marker to suppress 'storybook' (forward-reference), "
                f"got: {storybook_errors}"
            ),
        )

    def test_authored_validation_passes_substantive_draft(self):
        """Authored mode passes drafts that are ready for pipeline execution."""
        self._register_adr("ADR-0.1.0", commit=False)
        obpi_id = "OBPI-ADR-0.1.0-01"
        path = self.project_root / f"{obpi_id}.md"
        path.write_text(
            f"---\nid: {obpi_id}\nparent: ADR-0.1.0\nstatus: Draft\nlane: Lite\n---\n\n"
            f"# {obpi_id}\n\n"
            "## Objective\nDefine typed port interfaces for the runtime adapter boundary.\n\n"
            "## Lane\n**Lite** - Internal Python contract only.\n\n"
            "## Allowed Paths\n"
            "- `src/gzkit/ports/` - Port definitions\n"
            "- `tests/test_ports.py` - Verification\n\n"
            "## Denied Paths\n- `docs/user/manpages/` - No operator-surface changes\n\n"
            "## Requirements (FAIL-CLOSED)\n"
            "1. REQUIREMENT: Port protocols MUST be importable from one module.\n"
            "2. NEVER: Concrete adapters must not import CLI modules.\n\n"
            "## Discovery Checklist\n"
            "**Prerequisites (check existence, STOP if missing):**\n"
            "- [ ] `src/gzkit/runtime.py` - Current runtime entry point\n\n"
            "**Existing Code (understand current state):**\n"
            "- [ ] `src/gzkit/ports.py` - Current port patterns\n\n"
            "## Verification\n```bash\n"
            "uv run gz validate --documents\n"
            "uv run gz lint\n"
            "uv run gz typecheck\n"
            "uv run gz test\n"
            "uv run -m unittest tests.test_ports\n"
            "```\n\n"
            "## Acceptance Criteria\n"
            "- [ ] REQ-0.1.0-01-01: Runtime ports are declared in one typed module.\n",
            encoding="utf-8",
        )
        errors = self.validator.validate_file(path, require_authored=True)
        self.assertEqual(errors, [])

    # ------------------------------------------------------------------
    # GHI #66: Validate on clean tree with ledger completion evidence
    # ------------------------------------------------------------------

    def test_validate_clean_tree_passes_with_ledger_completion_evidence(self):
        """Completed OBPI with sealed scope_audit in ledger passes on clean tree."""
        adr_id = "ADR-0.1.0"
        obpi_id = "OBPI-ADR-0.1.0-01"
        rel_path = f"{obpi_id}.md"

        obpi_path = self._create_obpi(
            adr_id,
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
            allowed_paths=[rel_path],
        )

        # Register OBPI and emit completion receipt with scope_audit evidence
        self.ledger.append(obpi_created_event(obpi_id, adr_id))
        self.ledger.append(
            obpi_receipt_emitted_event(
                obpi_id,
                receipt_event="completed",
                attestor="agent:test",
                parent_adr=adr_id,
                obpi_completion="completed",
                evidence={
                    "scope_audit": {
                        "allowlist": [rel_path],
                        "changed_files": [rel_path],
                        "out_of_scope_files": [],
                    },
                    "key_proof": "Works as expected",
                    "value_narrative": "Test value",
                },
            )
        )

        # Commit everything so tree is clean
        self._commit_all("chore: complete OBPI work")

        # Recreate validator to pick up new ledger state
        self.validator = ObpiValidator(self.project_root)
        errors = self.validator.validate_file(obpi_path)
        self.assertEqual(errors, [])

    def test_validate_clean_tree_fails_without_ledger_completion(self):
        """Non-completed OBPI still fails on clean tree (no fallback)."""
        obpi_path = self._create_obpi(
            "ADR-0.1.0",
            status="Completed",
            summary="- Task: Finished implementation",
            proof="Works as expected",
        )

        # Commit everything so tree is clean — but no completion receipt in ledger
        self._commit_all("chore: commit work")

        self.validator = ObpiValidator(self.project_root)
        errors = self.validator.validate_file(obpi_path)
        self.assertIn(
            "Changed-files audit found no modified paths. Completion requires live scope evidence.",
            errors,
        )
