From dcbb5ceeb0dbbd72926ec2fe7c865af6819e70d3 Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 30 Aug 2026 06:39:08 +0100 Subject: [PATCH] docs(decisions): accept ADRs 0018, 0020, 0021 and enforce the record contract Maintainer ss-o accepts three decision records whose source issues are closed and whose implementations already exist in the repository: - 0018 Adopt Portable Worktree Management (z-shell/.github#542) - 0020 Adopt Zsh Plugin Standard 2 (z-shell/.github#557) - 0021 Derive Chroma Command Knowledge at Runtime (z-shell/F-Sy-H#73) Acceptance follows runbooks/adr.md: the flip happens on main through a merged pull request and each record names its accepting maintainer. Two defects found while reviewing the set are fixed here: ADR 0011 used a different header shape from every other record, with a bare `## Status` section instead of the `- **Status:**` bullet block. Any tooling that reads status by the common pattern skipped it silently. Its header now matches its neighbours; no decision content changed. decisions/ had no index, so a stale status was discoverable only by reading all 21 files. Rather than add prose that would drift in turn, scripts/decision-records.py owns both the header contract and the generated index at decisions/README.md, and CI runs it. The contract checks field presence and order, recognized statuses, ISO dates, number agreement between title and filename, and refuses an ACCEPTED record that still names no deciding maintainer. The renderer pads table columns to match the repository formatter, so the generated index is a fixed point under `trunk fmt` and cannot oscillate against its own `--check`. Supporting changes: the agent policy validator learns a GENERATED_INVENTORY map so a generated file inside a scanned inventory directory is recognized without being unconditionally required of every fixture; both new files are declared as manifest surfaces; and the routed-worktree test now pins ADR 0018 to its accepted state instead of the pre-acceptance PROPOSED text. --- .github/instruction-surfaces.json | 24 ++ .github/workflows/agent-instructions.yml | 10 + ...zsh-lint-semantic-analyzer-architecture.md | 12 +- .../0018-portable-worktree-management.md | 2 +- decisions/0020-adopt-zsh-plugin-standard-2.md | 2 +- ...0021-derive-chroma-knowledge-at-runtime.md | 2 +- decisions/README.md | 34 ++ scripts/decision-records.py | 354 ++++++++++++++++++ scripts/test_decision_records.py | 164 ++++++++ scripts/test_validate_agent_policy.py | 13 +- scripts/validate-agent-policy.py | 13 +- 11 files changed, 612 insertions(+), 18 deletions(-) create mode 100644 decisions/README.md create mode 100644 scripts/decision-records.py create mode 100644 scripts/test_decision_records.py diff --git a/.github/instruction-surfaces.json b/.github/instruction-surfaces.json index 66c5b412d..8f33f84d0 100644 --- a/.github/instruction-surfaces.json +++ b/.github/instruction-surfaces.json @@ -875,6 +875,18 @@ "review_owner": "z-shell maintainers", "canonical_for": ["chroma-command-knowledge"] }, + { + "id": "decision-index", + "path": "decisions/README.md", + "kind": "runbook", + "authority": "advisory", + "consumers": ["codex", "claude-code", "copilot", "gemini-cli", "human"], + "tasks": ["architecture-decision", "onboarding"], + "file_patterns": ["**"], + "required": false, + "review_owner": "z-shell maintainers", + "canonical_for": [] + }, { "id": "zsh-standard-policy", "path": "lib/zsh-standard-policy.json", @@ -915,6 +927,18 @@ "review_owner": "z-shell maintainers", "canonical_for": ["public-policy-validation"] }, + { + "id": "decision-records-validator", + "path": "scripts/decision-records.py", + "kind": "enforcement", + "authority": "canonical-detail", + "consumers": ["ci", "human"], + "tasks": ["architecture-decision", "validation"], + "file_patterns": ["**"], + "required": true, + "review_owner": "z-shell maintainers", + "canonical_for": ["decision-record-validation"] + }, { "id": "agent-instruction-workflow", "path": ".github/workflows/agent-instructions.yml", diff --git a/.github/workflows/agent-instructions.yml b/.github/workflows/agent-instructions.yml index 90a6b535c..f1b096837 100644 --- a/.github/workflows/agent-instructions.yml +++ b/.github/workflows/agent-instructions.yml @@ -26,6 +26,8 @@ on: - "scripts/test_validate_agent_policy.py" - "scripts/validate-zsh-standard-policy.py" - "scripts/test_validate_zsh_standard_policy.py" + - "scripts/decision-records.py" + - "scripts/test_decision_records.py" push: branches: - main @@ -53,6 +55,8 @@ on: - "scripts/test_validate_agent_policy.py" - "scripts/validate-zsh-standard-policy.py" - "scripts/test_validate_zsh_standard_policy.py" + - "scripts/decision-records.py" + - "scripts/test_decision_records.py" permissions: contents: read @@ -80,6 +84,9 @@ jobs: - name: Run agent policy unit tests run: python3 -m unittest scripts/test_validate_agent_policy.py -v + - name: Run decision record unit tests + run: python3 -m unittest scripts/test_decision_records.py -v + - name: Run Zsh standard policy unit tests run: python3 -m unittest scripts/test_validate_zsh_standard_policy.py -v @@ -88,3 +95,6 @@ jobs: - name: Validate agent policy run: python3 scripts/validate-agent-policy.py + + - name: Validate decision records and index + run: python3 scripts/decision-records.py --check diff --git a/decisions/0011-zsh-lint-semantic-analyzer-architecture.md b/decisions/0011-zsh-lint-semantic-analyzer-architecture.md index 5e529ed02..9b763168a 100644 --- a/decisions/0011-zsh-lint-semantic-analyzer-architecture.md +++ b/decisions/0011-zsh-lint-semantic-analyzer-architecture.md @@ -1,12 +1,10 @@ # 11. zsh-lint Conditional Semantic Analysis Pipeline -Date: 2026-07-25 - -Deciders: ss-o - -## Status - -ACCEPTED +- **Status:** ACCEPTED +- **Date:** 2026-07-25 +- **Deciders:** ss-o +- **Supersedes:** None +- **Superseded by:** None ## Context diff --git a/decisions/0018-portable-worktree-management.md b/decisions/0018-portable-worktree-management.md index 7ab4f03e1..07d3cc7a8 100644 --- a/decisions/0018-portable-worktree-management.md +++ b/decisions/0018-portable-worktree-management.md @@ -1,6 +1,6 @@ # 18. Adopt Portable Worktree Management -- **Status:** PROPOSED +- **Status:** ACCEPTED - **Date:** 2026-08-27 - **Deciders:** ss-o - **Supersedes:** None diff --git a/decisions/0020-adopt-zsh-plugin-standard-2.md b/decisions/0020-adopt-zsh-plugin-standard-2.md index c9a3f854f..1ef9b6135 100644 --- a/decisions/0020-adopt-zsh-plugin-standard-2.md +++ b/decisions/0020-adopt-zsh-plugin-standard-2.md @@ -1,6 +1,6 @@ # 20. Adopt Zsh Plugin Standard 2 as a Clean Portable Contract -- **Status:** PROPOSED +- **Status:** ACCEPTED - **Date:** 2026-08-28 - **Deciders:** ss-o - **Supersedes:** None diff --git a/decisions/0021-derive-chroma-knowledge-at-runtime.md b/decisions/0021-derive-chroma-knowledge-at-runtime.md index 2052027ff..2319b80bd 100644 --- a/decisions/0021-derive-chroma-knowledge-at-runtime.md +++ b/decisions/0021-derive-chroma-knowledge-at-runtime.md @@ -1,6 +1,6 @@ # 21. Derive Chroma Command Knowledge at Runtime -- **Status:** PROPOSED +- **Status:** ACCEPTED - **Date:** 2026-08-29 - **Deciders:** ss-o - **Supersedes:** None diff --git a/decisions/README.md b/decisions/README.md new file mode 100644 index 000000000..6bc8bf0f1 --- /dev/null +++ b/decisions/README.md @@ -0,0 +1,34 @@ + + +# Architecture decision records + +Durable organization decisions. Draft new records with `runbooks/adr.md`; only +a maintainer moves a record from `PROPOSED` to `ACCEPTED`. + +| ADR | Title | Status | Date | Deciders | +| ------------------------------------------------------- | -------------------------------------------------------------- | -------- | ---------- | ----------------- | +| [0001](0001-meta-repo-and-agents-md.md) | Adopt a meta-repo pattern centered on `AGENTS.md` | ACCEPTED | 2026-05-29 | ss-o | +| [0002](0002-zi-as-canonical-plugin-manager.md) | `zi` is the canonical plugin manager for the z-shell ecosystem | ACCEPTED | 2026-05-29 | ss-o | +| [0003](0003-conventional-commits.md) | Adopt Conventional Commits across z-shell repositories | ACCEPTED | 2026-05-29 | ss-o | +| [0004](0004-dependabot-unification.md) | Standardize on Dependabot for Dependency Management | ACCEPTED | 2026-05-20 | ss-o, Gemini CLI | +| [0005](0005-workflow-naming-conventions.md) | No Emojis in Workflow and Job Name Fields | ACCEPTED | 2026-05-21 | ss-o, Claude Code | +| [0006](0006-wiki-content-root-boundaries.md) | Wiki Content-Root Boundaries | ACCEPTED | 2026-05-29 | ss-o, Claude Code | +| [0007](0007-release-publication-flow.md) | Release and Publication Flow | ACCEPTED | 2026-05-26 | ss-o, Claude Code | +| [0008](0008-branching-model.md) | Branching Model | ACCEPTED | 2026-07-25 | ss-o | +| [0009](0009-testing-ci-strategy.md) | Testing and CI Strategy | ACCEPTED | 2026-07-25 | ss-o | +| [0010](0010-security-incident-response.md) | Security Incident Response | PROPOSED | 2026-05-29 | TBD | +| [0011](0011-zsh-lint-semantic-analyzer-architecture.md) | zsh-lint Conditional Semantic Analysis Pipeline | ACCEPTED | 2026-07-25 | ss-o | +| [0012](0012-hybrid-dependency-management.md) | Split Dependency Updates Between Renovate and Dependabot | ACCEPTED | 2026-06-21 | ss-o | +| [0013](0013-repository-settings-baseline.md) | Repository Settings Baseline by Class | ACCEPTED | 2026-07-25 | ss-o | +| [0014](0014-portable-agent-instruction-architecture.md) | Adopt portable agent-instruction delivery | ACCEPTED | 2026-07-23 | ss-o | +| [0015](0015-zsh-scripting-standard.md) | Adopt an organization-wide Zsh scripting standard | ACCEPTED | 2026-08-27 | ss-o | +| [0016](0016-promotion-trigger-criteria.md) | Next-to-Main Promotion Trigger Criteria | ACCEPTED | 2026-08-16 | ss-o | +| [0017](0017-licensing-standard-by-provenance.md) | Licensing Standard by Provenance and Consumption | ACCEPTED | 2026-08-18 | ss-o | +| [0018](0018-portable-worktree-management.md) | Adopt Portable Worktree Management | ACCEPTED | 2026-08-27 | ss-o | +| [0019](0019-trunk-on-main-default.md) | Trunk-on-Main Default with a Zi Integration Exception | ACCEPTED | 2026-08-28 | ss-o | +| [0020](0020-adopt-zsh-plugin-standard-2.md) | Adopt Zsh Plugin Standard 2 as a Clean Portable Contract | ACCEPTED | 2026-08-28 | ss-o | +| [0021](0021-derive-chroma-knowledge-at-runtime.md) | Derive Chroma Command Knowledge at Runtime | ACCEPTED | 2026-08-29 | ss-o | diff --git a/scripts/decision-records.py b/scripts/decision-records.py new file mode 100644 index 000000000..3227bf7ce --- /dev/null +++ b/scripts/decision-records.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Validate ADR header blocks and render the decision index. + +Why this exists: + +`decisions/` had no index and no machine-checked header contract. A decision +record whose status went stale, or whose header used a different shape from its +neighbours, was discoverable only by reading every file. That is exactly the +class of drift the organization asks to catch with an executable check rather +than prose. + +This script owns two related outputs so they cannot disagree: + +1. the header contract for every `decisions/NNNN-*.md` record, and +2. the generated index at `decisions/README.md`. + +Usage: + + python3 scripts/decision-records.py # write the index + python3 scripts/decision-records.py --check # verify, change nothing +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +DECISIONS_DIRECTORY = "decisions" +INDEX_PATH = f"{DECISIONS_DIRECTORY}/README.md" +RECORD_PATTERN = re.compile(r"^(\d{4})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$") +TITLE_PATTERN = re.compile(r"^# (\d+)\. (.+)$") +FIELD_PATTERN = re.compile(r"^- \*\*(?P[^:*]+):\*\*(?P.*)$") + +REQUIRED_FIELDS = ( + "Status", + "Date", + "Deciders", + "Supersedes", + "Superseded by", +) +ALLOWED_STATUSES = ( + "PROPOSED", + "ACCEPTED", + "REJECTED", + "SUPERSEDED", +) +UNRESOLVED_DECIDERS = ("TBD", "None", "") +DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") + +INDEX_HEADER = """ + +# Architecture decision records + +Durable organization decisions. Draft new records with `runbooks/adr.md`; only +a maintainer moves a record from `PROPOSED` to `ACCEPTED`. + +""" + + +def error(path: str, rule: str, fix: str) -> str: + return f"{path}: {rule}; fix: {fix}" + + +class Record: + """One parsed decision record.""" + + def __init__(self, path: str, number: int, title: str, fields: dict[str, str]): + self.path = path + self.number = number + self.title = title + self.fields = fields + + @property + def filename(self) -> str: + return Path(self.path).name + + @property + def file_number(self) -> int: + """The number encoded in the filename, which orders the index.""" + match = RECORD_PATTERN.match(self.filename) + if match is None: # pragma: no cover - filenames are filtered on collection + raise ValueError(f"{self.path} is not a decision record filename") + return int(match.group(1)) + + @property + def status(self) -> str: + return self.fields.get("Status", "") + + @property + def date(self) -> str: + return self.fields.get("Date", "") + + @property + def deciders(self) -> str: + return self.fields.get("Deciders", "") + + +def _record_paths(root: Path) -> list[Path]: + directory = root / DECISIONS_DIRECTORY + if not directory.is_dir(): + return [] + return sorted( + path + for path in directory.iterdir() + if path.is_file() and RECORD_PATTERN.match(path.name) + ) + + +def _parse_header(text: str) -> tuple[int | None, str, dict[str, str], list[str]]: + """Return the record number, title, header fields, and structural problems.""" + lines = text.splitlines() + problems: list[str] = [] + + if not lines: + return None, "", {}, ["file is empty"] + + title_match = TITLE_PATTERN.match(lines[0]) + if title_match is None: + return None, "", {}, ["first line must be '# . '"] + number = int(title_match.group(1)) + title = title_match.group(2).strip() + + if len(lines) < 2 or lines[1].strip() != "": + problems.append("the title must be followed by one blank line") + + fields: dict[str, str] = {} + order: list[str] = [] + for line in lines[2:]: + if line.strip() == "": + break + field_match = FIELD_PATTERN.match(line) + if field_match is None: + if order: + # A continuation line belongs to the field opened above it. + fields[order[-1]] = f"{fields[order[-1]]} {line.strip()}".strip() + continue + problems.append("the header block must start at line 3") + break + field = field_match.group("field").strip() + if field in fields: + problems.append(f"duplicate header field {field!r}") + continue + fields[field] = field_match.group("value").strip() + order.append(field) + + if order and tuple(order) != REQUIRED_FIELDS: + expected = ", ".join(REQUIRED_FIELDS) + problems.append(f"header fields must be exactly, and in order: {expected}") + + return number, title, fields, problems + + +def _validate_record( + path: str, number: int | None, record: Record | None, problems: list[str] +) -> list[str]: + fix = ( + "match the header block used by the other records in decisions/ " + "and rerun python3 scripts/decision-records.py --check" + ) + errors = [error(path, problem, fix) for problem in problems] + if record is None: + return errors + + filename_number = record.file_number + if record.number != filename_number: + errors.append( + error( + path, + f"title number {record.number} does not match filename number " + f"{filename_number}", + f"renumber the title heading to '# {filename_number}. ...'", + ) + ) + + if record.status not in ALLOWED_STATUSES: + allowed = ", ".join(ALLOWED_STATUSES) + errors.append( + error( + path, + f"status {record.status!r} is not a recognized status", + f"set Status to one of: {allowed}", + ) + ) + + if record.status == "ACCEPTED" and record.deciders in UNRESOLVED_DECIDERS: + errors.append( + error( + path, + "an ACCEPTED record must name the accepting maintainer", + "record the accepting maintainer's handle in Deciders " + "(see runbooks/adr.md)", + ) + ) + + if not DATE_PATTERN.match(record.date): + errors.append( + error( + path, + f"date {record.date!r} is not an ISO 8601 calendar date", + "set Date to a YYYY-MM-DD value", + ) + ) + + return errors + + +def collect(root: Path) -> tuple[list[Record], list[str]]: + records: list[Record] = [] + errors: list[str] = [] + seen_numbers: dict[int, str] = {} + + for record_path in _record_paths(root): + display_path = f"{DECISIONS_DIRECTORY}/{record_path.name}" + try: + text = record_path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + errors.append( + error( + display_path, + f"cannot read decision record: {exc}", + f"restore a readable UTF-8 file at {display_path}", + ) + ) + continue + + number, title, fields, problems = _parse_header(text) + record = ( + Record(display_path, number, title, fields) if number is not None else None + ) + errors.extend(_validate_record(display_path, number, record, problems)) + if record is None: + continue + + filename_number = record.file_number + if filename_number in seen_numbers: + errors.append( + error( + display_path, + f"decision number {filename_number} is already used by " + f"{seen_numbers[filename_number]!r}", + "give every decision record a unique number", + ) + ) + else: + seen_numbers[filename_number] = display_path + records.append(record) + + records.sort(key=lambda item: item.file_number) + return records, errors + + +def render_index(records: list[Record]) -> str: + """Render the index table. + + Columns are padded to the widest cell so the generated file is already in + the repository formatter's preferred shape. Without this, `trunk fmt` would + rewrite the table and `--check` would then report the freshly generated + index as out of date. + """ + headings = ("ADR", "Title", "Status", "Date", "Deciders") + rows: list[tuple[str, ...]] = [] + for record in records: + rows.append( + ( + f"[{record.file_number:04d}]({record.filename})", + record.title, + record.status, + record.date, + record.deciders or "TBD", + ) + ) + + widths = [ + max(len(heading), *(len(row[column]) for row in rows)) if rows else len(heading) + for column, heading in enumerate(headings) + ] + + def render_row(cells: tuple[str, ...]) -> str: + padded = (cell.ljust(widths[column]) for column, cell in enumerate(cells)) + return f"| {' | '.join(padded)} |" + + separator = f"| {' | '.join('-' * width for width in widths)} |" + lines = [render_row(headings), separator] + lines.extend(render_row(row) for row in rows) + return INDEX_HEADER + "\n".join(lines) + "\n" + + +def run(root: Path, check_only: bool) -> tuple[int, list[str]]: + root = root.resolve() + records, errors = collect(root) + if errors: + return 1, sorted(set(errors)) + + if not records: + return 0, [] + + expected = render_index(records) + index_path = root / INDEX_PATH + + if check_only: + try: + current = index_path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + current = None + if current != expected: + return 1, [ + error( + INDEX_PATH, + "the decision index is missing or out of date", + "run python3 scripts/decision-records.py and commit the result", + ) + ] + return 0, [] + + index_path.write_text(expected, encoding="utf-8") + return 0, [] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate decision records and render the decision index" + ) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the script's repository)", + ) + parser.add_argument( + "--check", + action="store_true", + help="verify records and the index without writing anything", + ) + arguments = parser.parse_args() + + status, errors = run(arguments.root, arguments.check) + for message in errors: + print(f"ERROR: {message}") + if status == 0: + print( + "decision record validation passed" + if arguments.check + else f"wrote {INDEX_PATH}" + ) + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_decision_records.py b/scripts/test_decision_records.py new file mode 100644 index 000000000..c9c3d4461 --- /dev/null +++ b/scripts/test_decision_records.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from types import ModuleType + +sys.dont_write_bytecode = True +SCRIPT_PATH = Path(__file__).with_name("decision-records.py") +PUBLIC_ROOT = SCRIPT_PATH.resolve().parent.parent + + +def load_validator() -> ModuleType: + spec = importlib.util.spec_from_file_location("decision_records", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {SCRIPT_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = load_validator() + +VALID_RECORD = """# 1. A Valid Decision + +- **Status:** ACCEPTED +- **Date:** 2026-05-29 +- **Deciders:** ss-o +- **Supersedes:** None +- **Superseded by:** None + +## Context + +Context body. +""" + + +class DecisionRecordTests(unittest.TestCase): + def setUp(self) -> None: + self._temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self._temporary_directory.cleanup) + self.root = Path(self._temporary_directory.name) + (self.root / "decisions").mkdir() + + def write_record(self, filename: str, text: str) -> None: + (self.root / "decisions" / filename).write_text(text, encoding="utf-8") + + def errors(self) -> list[str]: + _records, errors = validator.collect(self.root) + return errors + + def test_valid_record_has_no_errors(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + self.assertEqual(self.errors(), []) + + def test_empty_directory_has_no_errors(self) -> None: + self.assertEqual(self.errors(), []) + + def test_rejects_legacy_section_header(self) -> None: + self.write_record( + "0001-legacy.md", + textwrap.dedent("""\ + # 1. Legacy Shape + + Date: 2026-05-29 + + ## Status + + ACCEPTED + """), + ) + errors = self.errors() + self.assertTrue(any("header block must start at line 3" in e for e in errors)) + + def test_rejects_accepted_record_without_deciders(self) -> None: + self.write_record("0001-no-decider.md", VALID_RECORD.replace("ss-o", "TBD")) + errors = self.errors() + self.assertTrue( + any("must name the accepting maintainer" in e for e in errors), errors + ) + + def test_allows_proposed_record_without_deciders(self) -> None: + text = VALID_RECORD.replace("ACCEPTED", "PROPOSED").replace("ss-o", "TBD") + self.write_record("0001-proposed.md", text) + self.assertEqual(self.errors(), []) + + def test_rejects_unknown_status(self) -> None: + self.write_record("0001-unknown.md", VALID_RECORD.replace("ACCEPTED", "MAYBE")) + errors = self.errors() + self.assertTrue(any("is not a recognized status" in e for e in errors), errors) + + def test_rejects_non_iso_date(self) -> None: + self.write_record( + "0001-bad-date.md", VALID_RECORD.replace("2026-05-29", "29 May 2026") + ) + errors = self.errors() + self.assertTrue(any("ISO 8601 calendar date" in e for e in errors), errors) + + def test_rejects_title_number_mismatch(self) -> None: + self.write_record("0002-mismatch.md", VALID_RECORD.replace("# 1.", "# 7.")) + errors = self.errors() + self.assertTrue(any("does not match filename number" in e for e in errors)) + + def test_rejects_out_of_order_header_fields(self) -> None: + text = textwrap.dedent("""\ + # 1. Reordered + + - **Date:** 2026-05-29 + - **Status:** ACCEPTED + - **Deciders:** ss-o + - **Supersedes:** None + - **Superseded by:** None + + ## Context + """) + self.write_record("0001-reordered.md", text) + errors = self.errors() + self.assertTrue(any("must be exactly, and in order" in e for e in errors)) + + def test_ignores_non_record_filenames(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + self.write_record("README.md", "# Not a record\n") + self.assertEqual(self.errors(), []) + + def test_check_reports_missing_index(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + status, errors = validator.run(self.root, check_only=True) + self.assertEqual(status, 1) + self.assertTrue(any("out of date" in e for e in errors), errors) + + def test_write_then_check_is_stable(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + self.assertEqual(validator.run(self.root, check_only=False), (0, [])) + self.assertEqual(validator.run(self.root, check_only=True), (0, [])) + + def test_index_lists_records_in_numeric_order(self) -> None: + self.write_record("0002-second.md", VALID_RECORD.replace("# 1.", "# 2.")) + self.write_record("0010-tenth.md", VALID_RECORD.replace("# 1.", "# 10.")) + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + validator.run(self.root, check_only=False) + index = (self.root / "decisions/README.md").read_text(encoding="utf-8") + self.assertLess(index.index("[0001]"), index.index("[0002]")) + self.assertLess(index.index("[0002]"), index.index("[0010]")) + + +class PublicRepositoryTests(unittest.TestCase): + """The real repository must satisfy its own decision-record contract.""" + + def test_public_records_and_index_are_valid(self) -> None: + status, errors = validator.run(PUBLIC_ROOT, check_only=True) + self.assertEqual(errors, []) + self.assertEqual(status, 0) + + def test_index_is_declared_in_the_manifest(self) -> None: + manifest = (PUBLIC_ROOT / ".github/instruction-surfaces.json").read_text() + self.assertIn("decisions/README.md", manifest) + self.assertIn("scripts/decision-records.py", manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_validate_agent_policy.py b/scripts/test_validate_agent_policy.py index a8a0994d5..f30ec448f 100644 --- a/scripts/test_validate_agent_policy.py +++ b/scripts/test_validate_agent_policy.py @@ -2000,12 +2000,13 @@ def test_public_repository_routes_portable_worktree_management(self) -> None: "`git worktree list --porcelain` as the authoritative inventory", (PUBLIC_ROOT / "AGENTS.md").read_text(), ) - self.assertIn( - "- **Status:** PROPOSED", - ( - PUBLIC_ROOT / "decisions/0018-portable-worktree-management.md" - ).read_text(), - ) + record = ( + PUBLIC_ROOT / "decisions/0018-portable-worktree-management.md" + ).read_text() + # The record is accepted; scripts/decision-records.py owns the general + # header contract, so this only pins the routed decision's own state. + self.assertIn("- **Status:** ACCEPTED", record) + self.assertIn("- **Deciders:** ss-o", record) def test_public_repository_prohibits_vendor_root_instruction_files(self) -> None: policy = (PUBLIC_ROOT / "AGENTS.md").read_text() diff --git a/scripts/validate-agent-policy.py b/scripts/validate-agent-policy.py index 61fba9ce2..cdc03e004 100644 --- a/scripts/validate-agent-policy.py +++ b/scripts/validate-agent-policy.py @@ -88,6 +88,12 @@ ".github/README.md": "runbook", ".github/copilot-instructions.md": "adapter", } +# Generated outputs that live inside a scanned inventory directory but are not +# records of that directory's kind. The inventory scan discovers them when they +# exist, so they are not unconditionally required here. +GENERATED_INVENTORY = { + "decisions/README.md": "runbook", +} INVENTORY_RULES = ( (".github/instructions", ".instructions.md", "scoped-guidance", True), (".github/agents", ".md", "agent", False), @@ -97,6 +103,7 @@ ENFORCEMENT_INVENTORY = { ".github/workflows/agent-instructions.yml": "enforcement", "scripts/validate-agent-policy.py": "enforcement", + "scripts/decision-records.py": "enforcement", } PUBLIC_SCAN_EXEMPTIONS = {"scripts/validate-agent-policy.py"} ALLOWED_MANIFEST_FIELDS = {"version", "repository", "canonical_policy", "surfaces"} @@ -260,8 +267,10 @@ def _inventory_path(relative_path: str) -> str: def _expected_inventory_kind(relative_path: str) -> str | None: inventory_path = _inventory_path(relative_path) - exact_kind = BASE_INVENTORY.get(inventory_path) or ENFORCEMENT_INVENTORY.get( - inventory_path + exact_kind = ( + BASE_INVENTORY.get(inventory_path) + or GENERATED_INVENTORY.get(inventory_path) + or ENFORCEMENT_INVENTORY.get(inventory_path) ) if exact_kind is not None: return exact_kind