diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 94ee24a7d..68836aa3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,6 +69,11 @@ repos: entry: uv run pytest tests/test_no_legacy_cursorrules.py -q -W error language: system pass_filenames: false + - id: adr-governance-phase1 + name: ADR governance Phase 1 (ADR-0045 anti-regression) + entry: uv run pytest tests/test_adr_governance_phase1.py -q -W error + language: system + pass_filenames: false - id: workflow-security-lint name: zizmor workflow security lint (optional manual stage) entry: pwsh -NoProfile -File scripts/workflow-security-lint.ps1 -SkipIfMissing diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cca0dc39..9da91a696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Human-readable summary of user-facing changes. **Detailed release notes:** [docs **Targeting (next dev line):** **`1.8.0-beta`** (#772) — after operator **release-ritual** for **1.7.4** (PR **#1024**). +- **ADR governance (Phase 1, #1162):** deterministic tests T1/T2/T5/T6 for ADR-0045 (`tests/test_adr_governance_phase1.py`); pre-commit hook `adr-governance-phase1`; plan [PLAN_ADR_GOVERNANCE_ENFORCEMENT.md](docs/plans/PLAN_ADR_GOVERNANCE_ENFORCEMENT.md). + --- ## 1.7.4 (2026-06-26) diff --git a/docs/plans/PLANS_HUB.md b/docs/plans/PLANS_HUB.md index b0336ea5c..04e34de1a 100644 --- a/docs/plans/PLANS_HUB.md +++ b/docs/plans/PLANS_HUB.md @@ -46,6 +46,7 @@ Do **not** edit the table manually; refresh with `python scripts/plans_hub_sync. | ------ | -------- | ----- | ------- | -------------- | | **Open** | [PLAN_ACTION_PLAN_GENERATOR_POST_SCAN.md](PLAN_ACTION_PLAN_GENERATOR_POST_SCAN.md) | Plan: Action Plan Generator (APG) — post-scan accountability and remediation hints | Post-scan Action Plan Generator: triage tiers, suggested mitigations, optional JSON/SQL snippets and evidence log—evidence for diligence, not legal conclusions. | [PLAN_SYNTHETIC_DATA_AND_CONFIDENCE_VALIDATION.md](PLAN_SYNTHETIC_DATA_AND_CONFIDENCE_VALIDATION.md) [PLAN_MATURITY_SELF_ASSESSMENT_GRC_QUESTIONNAIRE.md](completed/PLAN_MATURITY_SELF_ASSESSMENT_GRC_QUESTIONNAIRE.md) [REPORTS_AND_COMPLIANCE_OUTPUTS.md](../REPORTS_AND_COMPLIANCE_OUTPUTS.md) | | **Open** | [PLAN_ADDITIONAL_DETECTION_TECHNIQUES_AND_FN_REDUCTION.md](PLAN_ADDITIONAL_DETECTION_TECHNIQUES_AND_FN_REDUCTION.md) | Plan: Additional detection techniques and false-negative reduction | POC priorities 1-11 on main; v1.8.0 #1056 adds entity taxonomy, country checksum gates, span-alignment, plugin validators; priorities 5-7 NER/dictionaries still backlog. | [PLAN_EXTENDED_SENSITIVE_DISCOVERY_POSITIONING.md](PLAN_EXTENDED_SENSITIVE_DISCOVERY_POSITIONING.md) [PLAN_YAML_PLUGIN_SYSTEM.md](PLAN_YAML_PLUGIN_SYSTEM.md) [PLAN_SYNTHETIC_DATA_AND_CONFIDENCE_VALIDATION.md](PLAN_SYNTHETIC_DATA_AND_CONFIDENCE_VALIDATION.md) | +| **Open** | [PLAN_ADR_GOVERNANCE_ENFORCEMENT.md](PLAN_ADR_GOVERNANCE_ENFORCEMENT.md) | Plan: ADR governance enforcement (deterministic test suite) | Phase 1 anti-regression tests for ADR-0045 lifecycle, locale, immutability, and anti-deletion; Phase 2 prose/dangling refs. GitHub #1162. | — | | **Open** | [PLAN_ADR_GOVERNANCE_LIFECYCLE.md](PLAN_ADR_GOVERNANCE_LIFECYCLE.md) | Plan: ADR governance lifecycle (ADR 0045 amendment) | UMADR constitution — append-only Status history, Obsolete/Quarantined/Duplicate statuses, en_US ADRs; GitHub #803 | — | | **Open** | [PLAN_BUILD_IDENTITY_RELEASE_INTEGRITY.md](PLAN_BUILD_IDENTITY_RELEASE_INTEGRITY.md) | Plan: Build identity, runtime version display, and release integrity | **Status:** In progress — Phase E core landed (#856): SQLite integrity anchor (`core/integrity_anchor.py`), startup re-verify in any mode, TINTED/`-alpha` trust surfaces, `integrity_events`, open-mode worker clamp. Signe | — | | **Open** | [PLAN_CLAIMS_CONSISTENCY_AND_ANTI_OVERCLAIM.md](PLAN_CLAIMS_CONSISTENCY_AND_ANTI_OVERCLAIM.md) | PLAN: Claims consistency and anti-overclaim gate | gate determinístico offline anti-overclaim — invariante connector↔tier (build-time do #854) + manifesto docs/CLAIMS.yml com backed_by verificável; contraparte light do auditor on-demand claim-audit (lab-op) | [PLAN_CONNECTOR_TIER_GATING.md](PLAN_CONNECTOR_TIER_GATING.md) [PLAN_PRODUCT_TIERS_AND_OPEN_CORE.md](PLAN_PRODUCT_TIERS_AND_OPEN_CORE.md) | diff --git a/docs/plans/PLANS_TODO.md b/docs/plans/PLANS_TODO.md index aee6f8d4d..e15bfe556 100644 --- a/docs/plans/PLANS_TODO.md +++ b/docs/plans/PLANS_TODO.md @@ -89,6 +89,7 @@ When **partners** or **buyers** anchor on a vertical (MSP, insurance, RPO, real - **Sprint mirror:** [SPRINTS_AND_MILESTONES.md](SPRINTS_AND_MILESTONES.md) §3 + **M-RICH** when milestones change. - **Gemini / LLM doc triage (optional):** After a public-bundle review, use [PLAN_GEMINI_FEEDBACK_TRIAGE.md](PLAN_GEMINI_FEEDBACK_TRIAGE.md) for non-authoritative optional to-dos and promotion gates—does not override CI, pytest, or agreed sequencing until promoted here or in an issue. - **Taxonomy axes (G0-G3 + hub):** [PLAN_G_TIER.md](PLAN_G_TIER.md) ([pt-BR](PLAN_G_TIER.pt_BR.md)) formalizes **gravity** for findings; [PLAN_TAXONOMY_AXES.md](PLAN_TAXONOMY_AXES.md) maps orthogonal axes; [ADR-0055](../adr/ADR-0055-orthogonal-priority-axes-anti-collision-contract.md) records the anti-collision contract. **`scripts/inv-adr.ps1`** regenerates **`docs/adr/INVENTORY.txt`**. +- **ADR governance enforcement (#1162):** Phase 1 deterministic anti-regression tests (T1 lifecycle, T2 locale/structure, T5 anti-deletion rename-aware, T6 date immutability) — [PLAN_ADR_GOVERNANCE_ENFORCEMENT.md](PLAN_ADR_GOVERNANCE_ENFORCEMENT.md); pre-commit + CI. Phase 2 (T3 prose density, T4 anti-dangling refs, ADR-0074 smoke) ⬜ deferred. - **Hybrid taxonomy (#1071, Fideslang eval — closed 2026-06-30):** RO recommendation **B-interno + A-export-adapter** — three implementation slices (all **optional**, backward compatible): **(1)** [PLAN_NORM_TAG_HIERARCHY_AND_DATA_SUBJECT.md](PLAN_NORM_TAG_HIERARCHY_AND_DATA_SUBJECT.md) **[#1074](https://github.com/FabioLeitao/data-boar/issues/1074)** — hierarchical `norm_tag` + Data-Subject → closes **#1069** (supersedes [#1072](https://github.com/FabioLeitao/data-boar/issues/1072)); **(2)** [PLAN_DATA_USE_AXIS.md](PLAN_DATA_USE_AXIS.md) **[#1075](https://github.com/FabioLeitao/data-boar/issues/1075)**; **(3)** [PLAN_FIDESLANG_EXPORT_ADAPTER.md](PLAN_FIDESLANG_EXPORT_ADAPTER.md) **[#1076](https://github.com/FabioLeitao/data-boar/issues/1076)** → SWOT **W-novo-1** when customer-pull. Legacy Bearer-only plan: [PLAN_SUBJECT_CATEGORY_AXIS.md](PLAN_SUBJECT_CATEGORY_AXIS.md). - **Plan checkbox discipline:** When code ships for a named plan slice, update **`PLAN_*.md`** checkboxes in the **same PR** — see **`AGENTS.md`** (*Plan checkbox discipline*); **`plans-status-pl-sync.mdc`** stays situational (plan globs only). - **Licensing enforcement (open issues — PMO index):** Cluster **#704** [P0] (Maestro + JWT on four lab hosts) → **#719** [P1] **`[U1]`** (silent bypass via `DATA_BOAR_ENV=development` / `DEBUG=1` — CRITICAL log + Docker doc) → **#708–#722** (issuer, trial/grace/revocation ADRs). Table below under **`[H0][U1]` Licensing enforcement**; **A6** `license-smoke` should gain a **#719** regression when the fix lands. **After** Wave 1 **#656** U0/U1 slices and **#406** release gate — not a substitute for **#606** [P0] plugin hooks unless operator reprioritizes. diff --git a/docs/plans/PLAN_ADR_GOVERNANCE_ENFORCEMENT.md b/docs/plans/PLAN_ADR_GOVERNANCE_ENFORCEMENT.md new file mode 100644 index 000000000..e904dac09 --- /dev/null +++ b/docs/plans/PLAN_ADR_GOVERNANCE_ENFORCEMENT.md @@ -0,0 +1,74 @@ +# Plan: ADR governance enforcement (deterministic test suite) + + + +**Status:** Active +**Date:** 2026-07-04 +**Authors:** Fabio Leitao +**Priority:** P1 +**GitHub:** [#1162](https://github.com/DataBoar/data-boar/issues/1162) +**Related ADR:** [ADR 0045](../adr/ADR-0045-adr-metadata-and-format-standardization.md) · [ADR 0080](../adr/ADR-0080-local-validation-gate-inviolable.md) + +**Synced with:** [PLANS_TODO.md](PLANS_TODO.md) + +--- + +## Context + +The **ADR-0080 incident** showed that policy in `.cursor/rules/` and ADR-0045 did not prevent agents from materializing an illegal ADR (born `Accepted`, pt-BR body, wrong H1, delete attempt). Issue **#1162** adds **mechanical** anti-regression tests so governance holds regardless of which agent is at the keyboard. + +Golden rules (from #1162 — non-negotiable): + +1. **Normalize EOL** (`\r\n` → `\n`) before parsing — working tree may be CRLF. +2. **Prove GREEN** against the full ADR corpus before wiring a hard gate; tune tests, not legitimate ADRs. +3. **Never irrecoverable false-block** — smarter tests (rename-aware deletion) or operator override trailer (`ADR-Governance-Override-Approved-By:`). + +## Scope + +| Track | Deliverable | +| ----- | ----------- | +| Phase 1 | `tests/test_adr_governance_phase1.py` + `tests/adr_governance_support.py` — T1, T2, T5, T6 | +| Fixture | `tests/fixtures/adr_genesis_date_lines.json` — frozen genesis `Date (UTC)` lines (T6 corpus) | +| Hooks | `.pre-commit-config.yaml` — `adr-governance-phase1` pytest hook | +| Phase 2 | T3 prose locale density · T4 anti-dangling `ADR-NNNN` refs (CI) — **deferred** | +| ADR-0074 smoke | Materialize phantom ADR + planted violations — **deferred** (separate slice) | +| ADR-0045 amend | Codify “every mechanizable rule has a test” — **deferred** (in-place §8) | + +**Out of scope (existing coverage):** `test_adr_inventory_sync`, `test_adr_readme_index_sync`, cryptographic `inv-adr.ps1` inventory. + +## Phase 1 tests (deterministic) + +| ID | Rule | Mechanism | +| -- | ---- | ----------- | +| **T1** | New ADR `## Status` ∈ {`Proposed`, `Reserved`} | Staged `git diff --diff-filter=A` on `docs/adr/ADR-*.md` | +| **T2** | H1 `^# ADR \d{4} — `; exact metadata labels; no pt-BR `##`/`###` headings | Full corpus scan | +| **T5** | ADRs never deleted | Staged diff; `R*` rename allowed; bare `D` blocked | +| **T6** | `Date (UTC)` immutable | Frozen fixture vs working tree; staged diff must not mutate line | + +## Implementation checklist + +| Phase | Task | Status | +| ----- | ---- | ------ | +| 1a | `tests/adr_governance_support.py` + Phase 1 tests | ✅ | +| 1b | Genesis date fixture (78 ADRs) | ✅ | +| 1c | Pre-commit hook `adr-governance-phase1` | ✅ | +| 1d | ADR-0080 brought to ADR-0045 shape on branch (corpus GREEN for T2) | ✅ | +| 1e | `./scripts/check-all.sh --enforced` green | ✅ | +| 1f | PR `Closes #1162` — **operator merge only** | ⬜ | +| 2 | T3 + T4 + ADR-0074 smoke + ADR-0045 governance amend | ⬜ | + +## Operator override + +When a legitimate exception is required (rare), add to the commit message: + +```text +ADR-Governance-Override-Approved-By: Fabio Tavares Leitão +``` + +Incremental T1/T5/T6 staged checks skip when this trailer is present. Prefer fixing the test or using `git mv` for renames instead. + +## References + +- [ADR 0045](../adr/ADR-0045-adr-metadata-and-format-standardization.md) — UMADR constitution +- [PLAN_ADR_GOVERNANCE_LIFECYCLE.md](PLAN_ADR_GOVERNANCE_LIFECYCLE.md) — lifecycle manifesto +- GitHub [#1162](https://github.com/DataBoar/data-boar/issues/1162) — spec (Claude Code auditor) diff --git a/tests/adr_governance_support.py b/tests/adr_governance_support.py new file mode 100644 index 000000000..34bf05ae7 --- /dev/null +++ b/tests/adr_governance_support.py @@ -0,0 +1,149 @@ +"""Shared helpers for ADR governance anti-regression tests (issue #1162, ADR-0045).""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +ADR_DIR = REPO_ROOT / "docs" / "adr" +ADR_GLOB = "docs/adr/ADR-[0-9]*.md" +GENESIS_FIXTURE = ( + Path(__file__).resolve().parent / "fixtures" / "adr_genesis_date_lines.json" +) + +# H1 uses em dash (U+2014), not filename hyphen — ADR-0045 §3. +H1_RE = re.compile(r"^# ADR \d{4} — ") + +META_DATE_RE = re.compile(r"^- \*\*Date \(UTC\):\*\*", re.MULTILINE) +META_AUTHORS_RE = re.compile(r"^- \*\*Authors:\*\*", re.MULTILINE) +META_DECIDERS_RE = re.compile(r"^- \*\*Deciders:\*\*", re.MULTILINE) +DATE_LINE_RE = re.compile(r"^- \*\*Date \(UTC\):\*\*.*$", re.MULTILINE) +ISO_DATE_IN_DATE_LINE_RE = re.compile( + r"^- \*\*Date \(UTC\):\*\*.*?(\d{4}-\d{2}-\d{2})", re.MULTILINE +) + +STATUS_SECTION_RE = re.compile(r"(?ms)^## Status\s*\r?\n\s*([^\r\n#]+?)\s*(?:\r?\n|$)") + +PT_BR_HEADING_DENYLIST: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("Contexto", re.compile(r"\bContexto\b")), + ("Decisão", re.compile(r"\bDecis[aã]o\b")), + ("Consequências", re.compile(r"\bConsequ[eê]ncias\b")), + ("Governança", re.compile(r"\bGovernan[cç]a\b")), + ("Autoridade", re.compile(r"\bAutoridade\b")), + ("Justificativa", re.compile(r"\bJustificativa\b")), + ( + "Alternativas Consideradas", + re.compile(r"\bAlternativas Consideradas\b"), + ), + ( + "Decisões Relacionadas", + re.compile(r"\bDecis[oõ]es Relacionadas\b"), + ), + ("Referências", re.compile(r"\bRefer[eê]ncias\b")), +) + +NEW_ADR_ALLOWED_STATUSES = frozenset({"Proposed", "Reserved"}) + +OVERRIDE_MARKER_RE = re.compile(r"(?im)^\s*ADR-Governance-Override-Approved-By:\s*\S+") + + +def normalize_eol(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def read_adr_text(path: Path) -> str: + return normalize_eol(path.read_text(encoding="utf-8")) + + +def iter_adr_files() -> list[Path]: + return sorted(ADR_DIR.glob("ADR-*.md")) + + +def parse_status(text: str) -> str | None: + normalized = normalize_eol(text) + match = STATUS_SECTION_RE.search(normalized) + if not match: + return None + return match.group(1).strip() + + +def extract_date_line(text: str) -> str | None: + match = DATE_LINE_RE.search(normalize_eol(text)) + return match.group(0).strip() if match else None + + +def load_genesis_fixture() -> dict[str, str | None]: + data = json.loads(GENESIS_FIXTURE.read_text(encoding="utf-8")) + return {str(k): (None if v is None else str(v)) for k, v in data.items()} + + +def _git(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(REPO_ROOT), *args], + capture_output=True, + text=True, + check=False, + ) + + +def git_is_repo() -> bool: + return _git(["rev-parse", "--git-dir"]).returncode == 0 + + +def staged_adr_paths(*, diff_filter: str | None = None) -> list[str]: + args = ["diff", "--cached", "--name-only"] + if diff_filter: + args.insert(3, f"--diff-filter={diff_filter}") + args.append("--") + args.append(ADR_GLOB) + proc = _git(args) + if proc.returncode != 0: + return [] + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + + +def staged_name_status_with_renames() -> list[tuple[str, str, str | None]]: + """Return (status, old_path, new_path) from cached diff with rename detection.""" + proc = _git( + [ + "diff", + "--cached", + "-M", + "--find-renames", + "--name-status", + "--", + ADR_GLOB, + ] + ) + if proc.returncode != 0: + return [] + rows: list[tuple[str, str, str | None]] = [] + for line in proc.stdout.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0] + if status.startswith("R") and len(parts) >= 3: + rows.append((status, parts[1], parts[2])) + elif status == "D" and len(parts) >= 2: + rows.append((status, parts[1], None)) + elif status == "A" and len(parts) >= 2: + rows.append((status, parts[1], parts[1])) + elif len(parts) >= 2: + rows.append((status, parts[1], parts[1])) + return rows + + +def pending_commit_message() -> str: + for path in (REPO_ROOT / ".git" / "COMMIT_EDITMSG",): + if path.is_file(): + return path.read_text(encoding="utf-8", errors="replace") + proc = _git(["log", "-1", "--format=%B"]) + return proc.stdout if proc.returncode == 0 else "" + + +def governance_override_present() -> bool: + return bool(OVERRIDE_MARKER_RE.search(pending_commit_message())) diff --git a/tests/fixtures/adr_genesis_date_lines.json b/tests/fixtures/adr_genesis_date_lines.json new file mode 100644 index 000000000..fb14cb079 --- /dev/null +++ b/tests/fixtures/adr_genesis_date_lines.json @@ -0,0 +1,81 @@ +{ + "ADR-0000-project-origin-and-adr-baseline.md": "- **Date (UTC):** 2026-03-26", + "ADR-0001-markdown-fix-script-md029-and-semantic-step-lists.md": "- **Date (UTC):** 2026-03-25", + "ADR-0002-operator-facing-security-and-technical-docs.md": "- **Date (UTC):** 2026-03-25", + "ADR-0003-sbom-roadmap-cyclonedx-then-syft.md": "- **Date (UTC):** 2026-03-26", + "ADR-0004-external-docs-no-markdown-links-to-plans.md": "- **Date (UTC):** 2026-03-27", + "ADR-0005-ci-github-actions-supply-chain-pins.md": "- **Date (UTC):** 2026-03-27", + "ADR-0006-operator-today-mode-layout-and-published-sync.md": "- **Date (UTC):** 2026-03-30", + "ADR-0007-synthetic-data-corpus-before-real-data.md": "- **Date (UTC):** 2026-04-03", + "ADR-0008-docker-ce-swarm-over-docker-io-and-podman-only.md": "- **Date (UTC):** 2026-04-03", + "ADR-0009-ansible-idempotent-roles-as-single-automation-source.md": "- **Date (UTC):** 2026-04-03", + "ADR-0010-ip-declaration-prior-art-protection-at-employment.md": "- **Date (UTC):** 2026-03-27 (IP Declaration sent; git-commit: 2026-04-04)", + "ADR-0011-lab-op-observability-stack-layered.md": "- **Date (UTC):** 2026-04-03", + "ADR-0012-ocr-image-sensitive-data-detection.md": "- **Date (UTC):** 2026-04-03", + "ADR-0013-browser-artifact-sqlite-leveldb-scan-strategy.md": "- **Date (UTC):** 2026-04-03", + "ADR-0014-rename-repo-and-package-python3-lgpd-crawler-to-data-boar.md": "- **Date (UTC):** 2026-04-05", + "ADR-0015-poc-test-infrastructure-synthetic-corpus-and-api-testing.md": "- **Date (UTC):** 2026-04-05", + "ADR-0016-opentofu-corporate-iac-path-alongside-ansible.md": "- **Date (UTC):** 2026-04-05", + "ADR-0017-quasi-identification-risk-confidence-contract-and-lgpd-guardrails.md": "- **Date (UTC):** 2026-04-07", + "ADR-0018-pii-anti-recurrence-guardrails-for-tracked-files-and-branch-history.md": "- **Date (UTC):** 2026-04-07", + "ADR-0019-pii-verification-cadence-and-manual-review-gate.md": "- **Date (UTC):** 2026-04-07", + "ADR-0020-ci-full-git-history-pii-gate.md": "- **Date (UTC):** 2026-04-08", + "ADR-0021-public-web-presence-dns-alias-and-hosting.md": "- **Date (UTC):** 2026-04-08", + "ADR-0022-public-glossary-compliance-and-platform-terms.md": "- **Date (UTC):** 2026-04-08", + "ADR-0023-windows-primary-dev-filename-search-everything-es-first-with-fallback.md": "- **Date (UTC):** 2026-04-09", + "ADR-0024-enterprise-discovery-three-complementary-tracks.md": "- **Date (UTC):** 2026-04-15", + "ADR-0025-compliance-positioning-evidence-inventory-not-legal-conclusion-engine.md": "- **Date (UTC):** 2026-04-08", + "ADR-0026-optional-jurisdiction-hints-dpo-facing-heuristic-metadata-only.md": "- **Date (UTC):** 2026-04-08", + "ADR-0027-commercial-tier-boundaries-licensing-docs-and-future-jwt-claims.md": "- **Date (UTC):** 2026-04-17", + "ADR-0028-lab-external-connectivity-eval-playbook.md": "- **Date (UTC):** 2026-04-18", + "ADR-0029-cursor-markdown-preview-guardrail-and-lab-smoke-ansible-hook.md": "- **Date (UTC):** 2026-04-18", + "ADR-0030-python-dependency-update-closure-single-pass.md": "- **Date (UTC):** 2026-04-19", + "ADR-0031-pypi-packaging-hatchling-flat-layout.md": "- **Date (UTC):** 2026-04-19", + "ADR-0032-maturity-assessment-batch-history-sqlite.md": "- **Date (UTC):** 2026-04-20", + "ADR-0033-webauthn-open-relying-party-json-endpoints.md": "- **Date (UTC):** 2026-04-21", + "ADR-0034-outbound-http-user-agent-data-boar-prospector.md": "- **Date (UTC):** 2026-04-08", + "ADR-0035-readme-stakeholder-pitch-vs-deck-vocabulary.md": "- **Date (UTC):** 2026-04-22", + "ADR-0036-exception-and-log-pii-redaction-pipeline.md": "- **Date (UTC):** 2026-04-22", + "ADR-0037-data-boar-self-audit-log-governance.md": "- **Date (UTC):** 2026-04-22", + "ADR-0038-jurisdictional-ambiguity-alert-dont-decide.md": "- **Date (UTC):** 2026-04-08", + "ADR-0039-retention-evidence-posture-bonded-customs-adjacent-contexts.md": "- **Date (UTC):** 2026-04-08", + "ADR-0040-assistant-private-stack-evidence-mirrors-default.md": "- **Date (UTC):** 2026-04-22", + "ADR-0041-lab-completao-data-contract-preflight.md": "- **Date (UTC):** 2026-04-26", + "ADR-0042-lab-lessons-learned-archive-contract.md": "- **Date (UTC):** 2026-04-26", + "ADR-0043-sql-column-sampling-non-null-and-strategy-hook.md": "- **Date (UTC):** 2026-04-26", + "ADR-0044-dependabot-uv-ecosystem-for-pyproject-lock-closure.md": "- **Date (UTC):** 2026-05-09", + "ADR-0045-adr-metadata-and-format-standardization.md": "- **Date (UTC):** 2026-05-12", + "ADR-0046-operator-intent-and-blameless-collaboration.md": "- **Date (UTC):** 2026-05-12", + "ADR-0047-rca-first-defect-investigation-and-fix-discipline.md": "- **Date (UTC):** 2026-05-12", + "ADR-0048-operator-facing-taxonomy-and-naming-contract-preservation.md": "- **Date (UTC):** 2026-05-12", + "ADR-0049-no-brittle-mitigations-robust-input-handling.md": "- **Date (UTC):** 2026-05-12", + "ADR-0050-plan-document-metadata-standard.md": "- **Date (UTC):** 2026-05-12", + "ADR-0051-incremental-filesystem-scan-file-identity-fingerprint.md": "- **Date (UTC):** 2026-05-14", + "ADR-0052-yaml-plugin-system-centralized-schema.md": "- **Date (UTC):** 2026-05-14", + "ADR-0053-ebcdic-direct-upper-bound-and-dependabot-ignore.md": "- **Date (UTC):** 2026-05-15", + "ADR-0054-chardet-pinned-by-cyclonedx-bom.md": "- **Date (UTC):** 2026-05-15", + "ADR-0055-orthogonal-priority-axes-anti-collision-contract.md": "- **Date (UTC):** 2026-05-19", + "ADR-0056-cryptographic-adr-inventory-inv-adr-ssh-attestation.md": "- **Date (UTC):** 2026-05-20", + "ADR-0057-lightweight-hub-index-co-located-links.md": "- **Date (UTC):** 2026-05-20", + "ADR-0058-primer-hub-registration-ritual.md": "- **Date (UTC):** 2026-05-20", + "ADR-0060-db-lint-bandit-exclusion-risk-accepted.md": "- **Date (UTC):** 2026-06-21", + "ADR-0061-u-axis-issue-suborder-and-cross-milestone-gate.md": "- **Date (UTC):** 2026-05-22", + "ADR-0062-agent-containment-triple-audit-offband-pingpong.md": "- **Date (UTC):** 2026-05-22", + "ADR-0063-ed25519-license-jwt-signing.md": "- **Date (UTC):** 2026-06-21", + "ADR-0064-license-enforcement-additive-model.md": "- **Date (UTC):** 2026-06-21", + "ADR-0065-nist-sp800228a-api-security-reference.md": "- **Date (UTC):** 2026-06-11", + "ADR-0066-tampered-state-behavior.md": "- **Date (UTC):** 2026-05-25", + "ADR-0068-primary-linux-dev-workstation-temporary.md": "- **Date (UTC):** 2026-06-09", + "ADR-0069-cap-rpds-py-below-the-2026-calver-pivot.md": "- **Date (UTC):** 2026-06-17", + "ADR-0070-primer-taxonomy-and-home.md": "- **Date (UTC):** 2026-06-17", + "ADR-0071-self-protecting-pii-gate.md": "- **Date (UTC):** 2026-06-18", + "ADR-0072-commit-gate-vs-release-gate-distinct-criteria.md": "- **Date (UTC):** 2026-06-21", + "ADR-0073-version-scheme-octet-maturity-and-roadmap.md": "- **Date (UTC):** 2026-06-21", + "ADR-0075-plugin-auth-file-based-vs-bearer.md": "- **Date (UTC):** 2026-06-21", + "ADR-0076-opa-rego-ci-tier-drift-linter-not-runtime.md": "- **Date (UTC):** 2026-06-30", + "ADR-0077-filesystem-scan-no-client-gitignore-by-design.md": "- **Date (UTC):** 2026-06-30", + "ADR-0078-multi-pattern-regex-benchmark-gate-regexset-before-vectorscan.md": "- **Date (UTC):** 2026-06-30", + "ADR-0079-ecosystem-engineering-rigor-canon.md": "- **Date (UTC):** 2026-07-01", + "ADR-0080-local-validation-gate-inviolable.md": "- **Date (UTC):** 2026-07-03" +} + diff --git a/tests/test_adr_governance_phase1.py b/tests/test_adr_governance_phase1.py new file mode 100644 index 000000000..3de5463d9 --- /dev/null +++ b/tests/test_adr_governance_phase1.py @@ -0,0 +1,189 @@ +"""ADR governance Phase 1 anti-regression tests (issue #1162, ADR-0045). + +T1 lifecycle · T2 locale/structure · T5 anti-deletion (rename-aware) · T6 date immutability. + +Does not duplicate test_adr_inventory_sync or test_adr_readme_index_sync. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from tests.adr_governance_support import ( + ADR_DIR, + GENESIS_FIXTURE, + H1_RE, + ISO_DATE_IN_DATE_LINE_RE, + META_AUTHORS_RE, + META_DATE_RE, + META_DECIDERS_RE, + NEW_ADR_ALLOWED_STATUSES, + PT_BR_HEADING_DENYLIST, + REPO_ROOT, + extract_date_line, + git_is_repo, + governance_override_present, + iter_adr_files, + load_genesis_fixture, + parse_status, + read_adr_text, + staged_adr_paths, + staged_name_status_with_renames, +) + + +def test_t2_h1_uses_em_dash_title_form() -> None: + """T2: H1 must be '# ADR NNNN — Title' (space + em dash), not filename hyphen.""" + violations: list[str] = [] + for path in iter_adr_files(): + lines = read_adr_text(path).split("\n") + first = lines[0] if lines else "" + if not H1_RE.match(first): + violations.append(f"{path.name}: {first!r}") + assert not violations, "ADR H1 violations:\n" + "\n".join(violations) + + +def test_t2_metadata_labels_exact_anchored() -> None: + """T2: metadata labels are exact anchored tokens (not a loose 'Data*' prefix).""" + violations: list[str] = [] + for path in iter_adr_files(): + text = read_adr_text(path) + if not META_DATE_RE.search(text): + violations.append(f"{path.name}: missing '- **Date (UTC):**'") + if not META_AUTHORS_RE.search(text): + violations.append(f"{path.name}: missing '- **Authors:**'") + if not META_DECIDERS_RE.search(text): + violations.append(f"{path.name}: missing '- **Deciders:**'") + assert not violations, "ADR metadata label violations:\n" + "\n".join(violations) + + +def test_t2_headings_free_of_pt_br_denylist() -> None: + """T2: ## / ### headings must not use pt-BR section titles (ADR-0045 §7).""" + violations: list[str] = [] + for path in iter_adr_files(): + for line in read_adr_text(path).split("\n"): + if not (line.startswith("##") or line.startswith("###")): + continue + for label, pattern in PT_BR_HEADING_DENYLIST: + if pattern.search(line): + violations.append(f"{path.name}: {label} in {line!r}") + assert not violations, "pt-BR heading violations:\n" + "\n".join(violations) + + +def test_t6_genesis_date_lines_immutable_against_fixture() -> None: + """T6: '- **Date (UTC):**' line must not change on later edits (frozen corpus).""" + assert GENESIS_FIXTURE.is_file(), f"missing fixture: {GENESIS_FIXTURE}" + baseline = load_genesis_fixture() + adrs = {p.name for p in iter_adr_files()} + violations: list[str] = [] + for name in sorted(adrs): + path = ADR_DIR / name + current = extract_date_line(read_adr_text(path)) + expected = baseline.get(name) + if expected is None: + violations.append( + f"{name}: not in genesis fixture (add row when materializing)" + ) + continue + if current != expected: + violations.append(f"{name}: expected {expected!r}, got {current!r}") + extra = sorted(set(baseline) - adrs) + if extra: + violations.append(f"fixture lists removed ADRs: {extra}") + assert not violations, "genesis Date (UTC) immutability violations:\n" + "\n".join( + violations + ) + + +@pytest.mark.skipif(not git_is_repo(), reason="git required for incremental ADR gates") +def test_t1_new_staged_adrs_born_proposed_or_reserved() -> None: + """T1: newly added ADRs in the index must have Status Proposed or Reserved.""" + if governance_override_present(): + pytest.skip("operator override marker present") + violations: list[str] = [] + for rel in staged_adr_paths(diff_filter="A"): + path = ADR_DIR / rel.split("/")[-1] + if not path.is_file(): + path = REPO_ROOT / rel + status = parse_status(read_adr_text(path)) + if status not in NEW_ADR_ALLOWED_STATUSES: + violations.append(f"{rel}: Status={status!r} (want Proposed or Reserved)") + assert not violations, "new ADR lifecycle violations:\n" + "\n".join(violations) + + +@pytest.mark.skipif(not git_is_repo(), reason="git required for incremental ADR gates") +def test_t5_staged_adr_deletions_blocked_rename_aware() -> None: + """T5: true ADR erasure in the index is forbidden; renames/reformats are allowed.""" + if governance_override_present(): + pytest.skip("operator override marker present") + renamed_sources = { + old + for status, old, new in staged_name_status_with_renames() + if status.startswith("R") and new + } + violations: list[str] = [] + for status, old, _new in staged_name_status_with_renames(): + if status != "D": + continue + if old in renamed_sources: + continue + violations.append(old) + assert not violations, ( + "ADR deletion blocked (ADR-0045 §1). True erasures in index:\n" + + "\n".join(violations) + + "\nUse status transition or rename (git mv); override only with " + "ADR-Governance-Override-Approved-By: trailer." + ) + + +@pytest.mark.skipif(not git_is_repo(), reason="git required for incremental ADR gates") +def test_t6_staged_date_line_unchanged_for_existing_adrs() -> None: + """T6: staged edits must not mutate '- **Date (UTC):**' on existing ADRs.""" + if governance_override_present(): + pytest.skip("operator override marker present") + violations: list[str] = [] + for rel in staged_adr_paths(): + if rel in staged_adr_paths(diff_filter="A"): + continue + path = REPO_ROOT / rel + if not path.is_file(): + continue + proc_old = subprocess.run( + ["git", "-C", str(REPO_ROOT), "show", f"HEAD:{rel}"], + capture_output=True, + text=True, + check=False, + ) + if proc_old.returncode != 0: + continue + old_line = extract_date_line(proc_old.stdout) + new_line = extract_date_line(read_adr_text(path)) + if old_line and new_line and old_line != new_line: + violations.append(f"{rel}: {old_line!r} -> {new_line!r}") + assert not violations, "Date (UTC) immutability violations in index:\n" + "\n".join( + violations + ) + + +@pytest.mark.skipif(not git_is_repo(), reason="git required for incremental ADR gates") +def test_t6_new_staged_adrs_have_parseable_iso_date() -> None: + """T6: new ADRs must declare a parseable YYYY-MM-DD in the Date (UTC) line.""" + violations: list[str] = [] + for rel in staged_adr_paths(diff_filter="A"): + path = REPO_ROOT / rel + text = read_adr_text(path) + if not ISO_DATE_IN_DATE_LINE_RE.search(text): + violations.append(f"{rel}: no YYYY-MM-DD in '- **Date (UTC):**' line") + assert not violations, "new ADR date violations:\n" + "\n".join(violations) + + +def test_phase1_corpus_file_count_matches_fixture() -> None: + """Sanity: genesis fixture stays aligned with docs/adr/ADR-*.md count.""" + baseline = load_genesis_fixture() + adrs = [p.name for p in iter_adr_files()] + assert len(baseline) == len(adrs), ( + f"fixture rows={len(baseline)} adr files={len(adrs)}; " + "regenerate tests/fixtures/adr_genesis_date_lines.json" + )