From 44ac11b71ce5be890b6c1ccab50fa4bcabc699f1 Mon Sep 17 00:00:00 2001 From: abmcar <52450271+abmcar@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:25:09 +0800 Subject: [PATCH] ci: enforce change document declarations --- .agents/skills/README.md | 2 +- .agents/skills/dev-workflow/SKILL.md | 40 ++- .claude/skills/dev-workflow/SKILL.md | 2 +- .github/pull_request_template.md | 19 +- .github/workflows/commit-lint.yml | 19 +- .../README.md | 89 +++++++ docs/changes/README.md | 33 ++- docs/modules/tools/spec.md | 21 +- tools/check_change_doc.py | 238 ++++++++++++++++++ tools/tests/test_check_change_doc.py | 166 ++++++++++++ 10 files changed, 607 insertions(+), 22 deletions(-) create mode 100644 docs/changes/2026-07-28-enforce-change-document-declaration/README.md create mode 100755 tools/check_change_doc.py create mode 100644 tools/tests/test_check_change_doc.py diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 52be2e060..8ff88e206 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -14,7 +14,7 @@ python3 .agents/tooling/generate_skill_mirrors.py | Skill | Description | |-------|-------------| -| `dev-workflow` | End-to-end feature development: propose, plan, execute, verify, archive | +| `dev-workflow` | Classify and execute features, enhancements, refactors, optimizations, and bug fixes | | `archive` | Archive completed change proposals to `docs/_archive/` | | `dtvm-perf-profile` | Performance profiling with hardware counters | | `dmir-compiler-analysis` | DMIR compiler analysis and cost model reference | diff --git a/.agents/skills/dev-workflow/SKILL.md b/.agents/skills/dev-workflow/SKILL.md index 89e1f7548..9315c394b 100644 --- a/.agents/skills/dev-workflow/SKILL.md +++ b/.agents/skills/dev-workflow/SKILL.md @@ -1,27 +1,39 @@ --- name: dev-workflow -description: Feature development workflow. Propose a change, plan implementation, execute with gates, and verify. +description: Development workflow for features, enhancements, refactors, optimizations, and bug fixes. Classify change-document requirements, plan implementation, execute with gates, and verify. --- # Development Workflow -End-to-end workflow for implementing changes: propose, plan, execute, verify, and optionally archive. +Use this workflow for implementation work that changes repository behavior or +development contracts. It covers change-document classification, planning, +execution, verification, and optional post-merge archiving. ## Phase A - Propose -1. Determine the change tier: +1. Classify the change before editing implementation files: - **Full**: cross-module, architecture, new capabilities, breaking changes - - **Light**: single-module, well-scoped, limited blast radius + - **Light**: single-module, well-scoped, limited blast radius, or a bug fix + with design implications + - **N/A**: only typo-only, comment-only, test-only, or clearly + behavior-preserving trivial fixes -2. Create a change document: + Any change to runtime semantics, determinism, gas accounting, compiler + scheduling, a module contract, or security requires at least a Light + document. File count alone never makes such a change exempt. + +2. For an exempt change, record a specific `N/A` reason and continue to + Phase C. Otherwise, create a change document: - Full tier: copy `docs/changes/template.md` to `docs/changes/YYYY-MM-DD-/README.md` - Light tier: copy `docs/changes/template-light.md` to `docs/changes/YYYY-MM-DD-/README.md` -3. Fill in the document and set status to `Proposed` +3. Fill in the document and set its status to `Proposed`. +4. After explicit approval, change the status to `Accepted` before + implementation starts. ## Phase B - Plan -After the change proposal is accepted: +For changes with an accepted proposal: 1. Consult relevant module specs in `docs/modules//spec.md` 2. Identify affected files, contracts, and tests @@ -38,15 +50,21 @@ Implement with quality gates: Mark each step complete as you go. -## Phase D - Verify and Archive +## Phase D - Verify and Handoff 1. Run the full build and test suite 2. Update affected module specs in `docs/modules/` if contracts changed -3. Update the change document status to `Implemented` -4. Optionally use the `archive` skill to move the completed change to `docs/_archive/` +3. After implementation and required verification are complete, update the + change document status to `Implemented` +4. Before creating a commit or pull request, validate exactly one declaration: + - `Change doc: docs/changes/YYYY-MM-DD-/README.md` + - `N/A: ` +5. After merge, use the `archive` skill only when the user explicitly requests + archiving ## Notes -- If the change is a simple bug fix or typo, skip to Phase C directly - Always consult module specs before modifying code in unfamiliar areas - When code conflicts with specs, code takes precedence, but update specs afterward +- CI checks that the declaration is present and the referenced path exists. + Reviewers decide whether an `N/A` reason satisfies the semantic skip criteria. diff --git a/.claude/skills/dev-workflow/SKILL.md b/.claude/skills/dev-workflow/SKILL.md index c643b87c6..6c3e8abab 100644 --- a/.claude/skills/dev-workflow/SKILL.md +++ b/.claude/skills/dev-workflow/SKILL.md @@ -2,7 +2,7 @@ # dev-workflow -**Description**: Feature development workflow. Propose a change, plan implementation, execute with gates, and verify. +**Description**: Development workflow for features, enhancements, refactors, optimizations, and bug fixes. Classify change-document requirements, plan implementation, execute with gates, and verify. ## Source of Truth diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 90bf97544..d5905ac59 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -80,10 +80,25 @@ test cases https://github.com/XXX/pull/44 benchmark stats: time XXX ms --> -#### 6. Release note +#### 6. Change document + + + +Change doc: + +#### 7. Release note ```release-note None -``` \ No newline at end of file +``` diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index fee6e6349..f7966d99c 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -77,4 +77,21 @@ jobs: exit 0 fi fi - fi \ No newline at end of file + fi + + change-document: + name: Validate Change Document Declaration + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout pull request head + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Test change-document validator + run: python3 -m unittest discover -s tools/tests -p 'test_check_change_doc.py' + + - name: Validate pull request declaration + run: python3 tools/check_change_doc.py --event-path "$GITHUB_EVENT_PATH" diff --git a/docs/changes/2026-07-28-enforce-change-document-declaration/README.md b/docs/changes/2026-07-28-enforce-change-document-declaration/README.md new file mode 100644 index 000000000..d5d72f24f --- /dev/null +++ b/docs/changes/2026-07-28-enforce-change-document-declaration/README.md @@ -0,0 +1,89 @@ +# Change: Enforce change-document declarations + +- **Status**: Implemented +- **Date**: 2026-07-28 +- **Tier**: Light +- **Review**: Pending independent review + +## Overview + +Require every pull request to declare either the change document that governs +the work or a concrete reason why no change document is needed. Align the +project guidance and development workflow on the same exemption criteria and +status transitions. + +## Motivation + +The project currently has conflicting entry rules. The agent guide and +development workflow allow simple bug fixes to skip proposal work, while the +change-proposal guide requires a Light document for bug fixes with design +implications. The workflow description also emphasizes features, and neither +the pull request template nor CI checks the final classification. These gaps +allow behavior-changing fixes to reach review without a change document. + +## Impact + +The change affects development-process contracts in the agent guide, +development workflow, pull request template, and CI. A small Python tool +validates declarations locally and in GitHub Actions. + +The exemption remains explicit and reviewable. Typo-only, comment-only, +test-only, and clearly behavior-preserving trivial fixes may use `N/A` with a +specific reason. Changes to runtime semantics, determinism, gas accounting, +compiler scheduling, module contracts, security, or bug fixes with design +implications require at least a Light change document. + +CI validates the declaration, not the semantic classification. It therefore +does not infer that every `src/` change needs a document. Reviewers retain +responsibility for rejecting an invalid exemption. + +The declaration accepts exactly one of these forms: + +```text +Change doc: docs/changes/YYYY-MM-DD-/README.md +N/A: +``` + +For a path declaration, the validator checks the repository-relative naming +convention and confirms that the file exists at the pull request head. For an +exemption, it rejects empty and placeholder reasons. The same tool reads a +literal declaration during local preparation and the pull request event +payload in CI. + +Status transitions have one definition across the project: + +- `Proposed` begins when the initial document is ready for review. +- `Accepted` begins after explicit approval and before implementation starts. +- `Implemented` begins after implementation and required verification are + complete. Merge is not part of this transition, so the document can describe + the verified state in the implementation pull request. + +## Checklist + +- [x] Align the DTVMDotfiles agent-guide SSOT with the project policy +- [x] Update the development workflow trigger, exemptions, and status mapping +- [x] Add a pull request declaration and a local/CI validation tool +- [x] Add automated tests and document the tool contract +- [x] Regenerate and check skill mirrors +- [x] Run the relevant build and tests + +## Verification + +- The validator test suite passes 12 tests covering accepted declarations, + rejected placeholders and duplicates, repository path checks, pre-commit + worktree handling, and policy wiring. +- The generated Claude skill mirrors match their `.agents/skills/` sources. +- The validator accepts both the governing change-document declaration and a + specific `N/A` declaration in local mode. +- The repository format check passes, the GitHub Actions workflow parses as + YAML, and `git diff --check` reports no whitespace errors. +- The CI-derived Release configuration builds `dtvmapi`; the post-change build + reports no additional work because no C++ inputs changed. +- DTVMDotfiles release preflight, release, drift detection, skill-state check, + and agent-skill integration test pass. + +`dtvm_local_test.sh --auto` selected the conservative multipass evmone unit and +state-test suites for the mixed tooling and CI paths. Its preflight stopped +before execution because the local evmone binaries and EEST fixture corpus are +not installed. No runtime source changed, and this missing environment is +reported rather than replaced with a different test category. diff --git a/docs/changes/README.md b/docs/changes/README.md index 8cd434788..9843b91e8 100644 --- a/docs/changes/README.md +++ b/docs/changes/README.md @@ -17,11 +17,16 @@ docs/changes/YYYY-MM-DD-/README.md | Status | Meaning | |--------|---------| -| **Proposed** | Under review, not yet approved for implementation | -| **Accepted** | Approved, ready for implementation | -| **Implemented** | Implementation complete and merged | +| **Proposed** | Initial document is ready for review; implementation is not approved | +| **Accepted** | Explicitly approved before implementation starts | +| **Implemented** | Implementation and required verification are complete; merge is not required for this transition | | **Rejected** | Declined with documented rationale | +Set `Proposed` when the initial document is ready for review. Set `Accepted` +only after explicit approval and before implementation begins. Set +`Implemented` after the implementation and required build, test, and spec +updates are complete, before pull request handoff. + ## Tiers ### Full Tier @@ -43,6 +48,20 @@ Typical triggers: - Bug fixes with design implications - Non-breaking enhancements +### When a Change Document Is Not Required + +A pull request may declare `N/A` only for: + +- typo-only changes; +- comment-only changes; +- test-only changes; +- clearly behavior-preserving trivial fixes. + +The declaration must include a specific reason. Changes to runtime semantics, +determinism, gas accounting, compiler scheduling, module contracts, or security +require at least a Light document. A bug fix with design implications also +requires at least a Light document, regardless of file count. + ## Current Proposals | Date | Name | Status | Tier | Description | @@ -52,6 +71,7 @@ Typical triggers: | 2026-04-14 | [handlecompare-bounds-check](2026-04-14-handlecompare-bounds-check/README.md) | Implemented | Light | Add bounds check before macro-fusion read in handleCompare | | 2026-04-14 | [from-raw-pointer-safety-checks](2026-04-14-from-raw-pointer-safety-checks/README.md) | Accepted | Light | Add null/alignment safety checks to `from_raw_pointer` in Rust bindings | | 2026-05-13 | [evm-ngram-macro-ops](2026-05-13-evm-ngram-macro-ops/README.md) | Implemented | Full | Initial EVM n-gram macro-op lowering and specialized keccak helpers for multipass JIT | +| 2026-07-28 | [enforce-change-document-declaration](2026-07-28-enforce-change-document-declaration/README.md) | Implemented | Light | Require a change-document path or explicit exemption on every pull request | Each active proposal lives in its own subdirectory. Browse `docs/changes/*/README.md` to see all current proposals, or use: @@ -64,5 +84,8 @@ ls docs/changes/*/README.md 1. Copy the appropriate template into a new `YYYY-MM-DD-/` directory 2. Fill in the change document -3. Follow the `dev-workflow` skill for implementation -4. After merging, move the completed change to `docs/_archive/` +3. Change `Proposed` to `Accepted` after explicit approval and before implementation +4. Follow the `dev-workflow` skill for implementation +5. Change `Accepted` to `Implemented` after implementation and verification complete +6. Before pull request handoff, validate a change-document path or an explicit `N/A` reason +7. After merging, use the `archive` skill only when archiving is explicitly requested diff --git a/docs/modules/tools/spec.md b/docs/modules/tools/spec.md index 9340347a3..94f722844 100644 --- a/docs/modules/tools/spec.md +++ b/docs/modules/tools/spec.md @@ -10,6 +10,8 @@ The tools module provides **development helper scripts**, responsible for: - **EVM test tools**: EVM assembly to bytecode, EVM test runner, state root/MPT comparison - **Solidity compilation**: Batch compile Solidity contracts to JSON - **Static analysis**: Parallel clang-tidy, performance regression checks +- **Contribution validation**: Change-document declaration checks for local + preparation and pull request CI - **Debugging aids**: GDB trace, CPU trace collection This module does not include: Build system (CMake), test framework (gtest/ctest), core runtime logic. @@ -58,7 +60,21 @@ This module does not include: Build system (CMake), test framework (gtest/ctest) | collect_cpu_trace.py | Collect CPU trace data | | bug_finder.py | Binary search for `GREEDY_FUNC_IDX_*` range that triggers exception (experimental) | -### 7. Miscellaneous +### 7. Contribution Validation + +`check_change_doc.py` requires exactly one declaration: + +- `Change doc: docs/changes/YYYY-MM-DD-/README.md` +- `N/A: ` + +For a change-document path, the tool validates the naming convention and file +existence at the checked head revision. For an exemption, it rejects empty and +placeholder reasons. Local callers provide a literal declaration and base +revision. GitHub Actions provides the pull request event payload. The tool does +not infer documentation requirements from changed paths; semantic exemption +review remains a reviewer responsibility. + +### 8. Miscellaneous | Script | Responsibility | |--------|----------------| @@ -76,10 +92,13 @@ This module does not include: Build system (CMake), test framework (gtest/ctest) | dtvm | EVM/WASM execution (run_evm_tests, bug_finder) | | gdb | Debug tracing | | Python 3 | pycryptodome, eth-hash, trie, rlp | +| Git | Revision and change-document path validation | ## Invariants and Permissions - **Read-only preference**: Format scripts default to check; format writes +- **Contribution checks are read-only**: Declaration validation never modifies + the worktree or Git index - **Paths**: Project root as working dir; relative paths to `tests/`, `build/` - **Idempotent**: easm2bytecode, solc_batch_compile may be run repeatedly diff --git a/tools/check_change_doc.py b/tools/check_change_doc.py new file mode 100755 index 000000000..5ed828d24 --- /dev/null +++ b/tools/check_change_doc.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Validate the change-document declaration used for pull request handoff.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + + +CHANGE_DOC_PATTERN = re.compile( + r"docs/changes/\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*/README\.md" +) +DECLARATION_PATTERN = re.compile(r"\s*(Change doc|N/A):\s*(.*?)\s*") +HTML_COMMENT_PATTERN = re.compile(r"", re.DOTALL) +PLACEHOLDERS = { + "-", + "", + "", + "", + "n/a", + "none", + "reason", + "tbd", + "todo", +} + + +class GateError(ValueError): + """A declaration or repository state failed validation.""" + + +@dataclass(frozen=True) +class Declaration: + kind: str + value: str + + +@dataclass(frozen=True) +class ValidationInput: + body: str + base_ref: str + head_ref: str + + +def parse_declaration(body: str) -> Declaration: + """Extract exactly one declaration from Markdown, ignoring HTML comments.""" + visible_body = HTML_COMMENT_PATTERN.sub("", body) + matches: list[Declaration] = [] + + for line in visible_body.splitlines(): + match = DECLARATION_PATTERN.fullmatch(line) + if match: + matches.append(Declaration(match.group(1), match.group(2).strip())) + + if not matches: + raise GateError( + "missing declaration; add either 'Change doc: ' or " + "'N/A: '" + ) + if len(matches) != 1: + raise GateError("provide exactly one Change doc or N/A declaration") + + declaration = matches[0] + if not declaration.value: + raise GateError(f"{declaration.kind} declaration must not be empty") + + normalized = declaration.value.casefold() + if normalized in PLACEHOLDERS: + raise GateError(f"{declaration.kind} declaration still contains a placeholder") + + if declaration.kind == "Change doc": + if not CHANGE_DOC_PATTERN.fullmatch(declaration.value): + raise GateError( + "change document must match " + "docs/changes/YYYY-MM-DD-/README.md" + ) + elif len(re.sub(r"\s+", "", declaration.value)) < 8: + raise GateError("N/A reason must be specific, not a short label") + + return declaration + + +def run_git(repo: Path, arguments: Sequence[str]) -> str: + """Run one read-only Git command and return stdout.""" + result = subprocess.run( + ["git", "-C", str(repo), *arguments], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise GateError(f"git {' '.join(arguments)} failed: {detail}") + return result.stdout + + +def validate_repository( + repo: Path, + declaration: Declaration, + base_ref: str, + head_ref: str, + include_worktree: bool = False, +) -> list[str]: + """Validate revisions, the comparison range, and a declared path.""" + run_git(repo, ["rev-parse", "--verify", f"{base_ref}^{{commit}}"]) + run_git(repo, ["rev-parse", "--verify", f"{head_ref}^{{commit}}"]) + changed_paths = { + path + for path in run_git( + repo, ["diff", "--name-only", f"{base_ref}...{head_ref}"] + ).splitlines() + if path + } + + if include_worktree: + for arguments in ( + ["diff", "--name-only"], + ["diff", "--cached", "--name-only"], + ["ls-files", "--others", "--exclude-standard"], + ): + changed_paths.update( + path for path in run_git(repo, arguments).splitlines() if path + ) + + if declaration.kind == "Change doc": + if include_worktree: + document_path = repo / declaration.value + if not document_path.is_file(): + raise GateError( + f"change document does not exist in the worktree: " + f"{declaration.value}" + ) + else: + run_git(repo, ["cat-file", "-e", f"{head_ref}:{declaration.value}"]) + + return sorted(changed_paths) + + +def load_event(path: Path) -> ValidationInput: + """Load pull request body and revisions from a GitHub event payload.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + pull_request = payload["pull_request"] + body = pull_request.get("body") or "" + base_ref = pull_request["base"]["sha"] + head_ref = pull_request["head"]["sha"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise GateError(f"invalid pull request event payload: {error}") from error + + if not isinstance(body, str): + raise GateError("pull request body must be text") + return ValidationInput(body=body, base_ref=base_ref, head_ref=head_ref) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Require one Change doc path or one explicit N/A reason. " + "The tool validates declarations, not semantic exemptions." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( + "--declaration", + help="literal local declaration, such as 'Change doc: docs/changes/...'", + ) + source.add_argument( + "--event-path", + type=Path, + help="GitHub pull request event JSON; CI normally passes GITHUB_EVENT_PATH", + ) + parser.add_argument( + "--base-ref", + help="base revision for local validation; required with --declaration", + ) + parser.add_argument( + "--head-ref", + default="HEAD", + help="head revision for local validation (default: HEAD)", + ) + parser.add_argument( + "--repo", + type=Path, + default=Path.cwd(), + help="repository root (default: current directory)", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + if args.event_path: + validation_input = load_event(args.event_path) + else: + if not args.base_ref: + parser.error("--base-ref is required with --declaration") + validation_input = ValidationInput( + body=args.declaration, + base_ref=args.base_ref, + head_ref=args.head_ref, + ) + + declaration = parse_declaration(validation_input.body) + changed_paths = validate_repository( + args.repo.resolve(), + declaration, + validation_input.base_ref, + validation_input.head_ref, + include_worktree=not bool(args.event_path), + ) + except GateError as error: + print(f"Change-document gate failed: {error}", file=sys.stderr) + return 1 + + print(f"OK: {declaration.kind}: {declaration.value}") + print( + f"Compared {len(changed_paths)} changed path(s) between " + f"{validation_input.base_ref} and {validation_input.head_ref}." + ) + if declaration.kind == "N/A": + print( + "Reviewer check required: confirm the reason satisfies the " + "semantic skip criteria." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_check_change_doc.py b/tools/tests/test_check_change_doc.py new file mode 100644 index 000000000..a0525a1b6 --- /dev/null +++ b/tools/tests/test_check_change_doc.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Tests for tools/check_change_doc.py.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "check_change_doc.py" +REPO_ROOT = SCRIPT_PATH.parents[1] +SPEC = importlib.util.spec_from_file_location("check_change_doc", SCRIPT_PATH) +assert SPEC and SPEC.loader +CHECK_CHANGE_DOC = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = CHECK_CHANGE_DOC +SPEC.loader.exec_module(CHECK_CHANGE_DOC) + + +class ParseDeclarationTests(unittest.TestCase): + def test_accepts_change_document_path(self) -> None: + declaration = CHECK_CHANGE_DOC.parse_declaration( + "Change doc: docs/changes/2026-07-28-change-gate/README.md" + ) + self.assertEqual(declaration.kind, "Change doc") + + def test_accepts_specific_exemption(self) -> None: + declaration = CHECK_CHANGE_DOC.parse_declaration( + "N/A: comment-only clarification with no behavior change" + ) + self.assertEqual(declaration.kind, "N/A") + + def test_ignores_template_examples_in_html_comments(self) -> None: + declaration = CHECK_CHANGE_DOC.parse_declaration( + "\n" + "Change doc: docs/changes/2026-07-28-change-gate/README.md" + ) + self.assertEqual(declaration.kind, "Change doc") + + def test_rejects_both_declarations(self) -> None: + with self.assertRaisesRegex(CHECK_CHANGE_DOC.GateError, "exactly one"): + CHECK_CHANGE_DOC.parse_declaration( + "Change doc: docs/changes/2026-07-28-change-gate/README.md\n" + "N/A: comment-only clarification with no behavior change" + ) + + def test_rejects_empty_declaration(self) -> None: + with self.assertRaisesRegex(CHECK_CHANGE_DOC.GateError, "must not be empty"): + CHECK_CHANGE_DOC.parse_declaration("Change doc:") + + def test_rejects_placeholder_reason(self) -> None: + with self.assertRaisesRegex(CHECK_CHANGE_DOC.GateError, "placeholder"): + CHECK_CHANGE_DOC.parse_declaration("N/A: ") + + def test_rejects_noncanonical_path(self) -> None: + with self.assertRaisesRegex(CHECK_CHANGE_DOC.GateError, "must match"): + CHECK_CHANGE_DOC.parse_declaration("Change doc: docs/change.md") + + +class RepositoryValidationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.repo = Path(self.temporary_directory.name) + self.git("init") + self.git("config", "user.name", "Test User") + self.git("config", "user.email", "test@example.com") + (self.repo / "README.md").write_text("base\n", encoding="utf-8") + self.git("add", "README.md") + self.git("commit", "-m", "base") + self.base_ref = self.git("rev-parse", "HEAD").strip() + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def git(self, *arguments: str) -> str: + return subprocess.run( + ["git", "-C", str(self.repo), *arguments], + check=True, + capture_output=True, + text=True, + ).stdout + + def test_validates_document_at_head_revision(self) -> None: + relative_path = "docs/changes/2026-07-28-change-gate/README.md" + document_path = self.repo / relative_path + document_path.parent.mkdir(parents=True) + document_path.write_text("# Change\n", encoding="utf-8") + self.git("add", relative_path) + self.git("commit", "-m", "add change document") + head_ref = self.git("rev-parse", "HEAD").strip() + + declaration = CHECK_CHANGE_DOC.parse_declaration( + f"Change doc: {relative_path}" + ) + changed_paths = CHECK_CHANGE_DOC.validate_repository( + self.repo, declaration, self.base_ref, head_ref + ) + + self.assertEqual(changed_paths, [relative_path]) + + def test_rejects_missing_document_at_head_revision(self) -> None: + declaration = CHECK_CHANGE_DOC.parse_declaration( + "Change doc: docs/changes/2026-07-28-missing/README.md" + ) + with self.assertRaisesRegex(CHECK_CHANGE_DOC.GateError, "cat-file"): + CHECK_CHANGE_DOC.validate_repository( + self.repo, declaration, self.base_ref, "HEAD" + ) + + def test_accepts_untracked_document_during_local_preparation(self) -> None: + relative_path = "docs/changes/2026-07-28-local-change/README.md" + document_path = self.repo / relative_path + document_path.parent.mkdir(parents=True) + document_path.write_text("# Local change\n", encoding="utf-8") + declaration = CHECK_CHANGE_DOC.parse_declaration( + f"Change doc: {relative_path}" + ) + + changed_paths = CHECK_CHANGE_DOC.validate_repository( + self.repo, + declaration, + self.base_ref, + "HEAD", + include_worktree=True, + ) + + self.assertEqual(changed_paths, [relative_path]) + + +class PolicyIntegrationTests(unittest.TestCase): + def test_workflow_covers_bug_fixes_and_closed_skip_set(self) -> None: + workflow = ( + REPO_ROOT / ".agents/skills/dev-workflow/SKILL.md" + ).read_text(encoding="utf-8") + + self.assertIn("optimizations, and bug fixes", workflow) + self.assertIn("typo-only, comment-only, test-only", workflow) + self.assertIn("runtime semantics, determinism, gas accounting", workflow) + self.assertNotIn("simple bug fix or typo, skip", workflow) + + def test_status_transition_and_pull_request_gate_stay_wired(self) -> None: + policy = (REPO_ROOT / "docs/changes/README.md").read_text(encoding="utf-8") + template = ( + REPO_ROOT / ".github/pull_request_template.md" + ).read_text(encoding="utf-8") + ci_workflow = ( + REPO_ROOT / ".github/workflows/commit-lint.yml" + ).read_text(encoding="utf-8") + + self.assertIn( + "Implementation and required verification are complete; " + "merge is not required", + policy, + ) + self.assertIn("\nChange doc:\n", template) + self.assertIn( + "python3 tools/check_change_doc.py --event-path", ci_workflow + ) + self.assertIn("types: [opened, synchronize, reopened, edited]", ci_workflow) + + +if __name__ == "__main__": + unittest.main()