From 7c689c3ebf56ccd1536e06fc2e12d87166c81163 Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 01:20:20 +0200 Subject: [PATCH] feat(enforcement): add enforceable clause taxonomy (ENF-CLA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ClauseKind — a first-class str enum of 9 semantic clause families (forbidden_import, allowed_import_surface, public_api_only, layer_boundary, forbidden_pattern, required_structure, config_invariant, workflow_policy, iac_policy) — to give the router and adapters a stable semantic target instead of ad-hoc policy key strings. classify_policy_rule() maps dot-separated policy paths to ClauseKind via prefix rules; returns None for unclassifiable paths so the router falls back to unfiltered granular_keys (backward-compatible). All 5 adapters now declare supported_clause_kinds and return ClauseKind values. ProvenanceEntry gains a clause_kind field populated during index build. PolicyRouter uses clause kinds as a secondary routing signal: adapters with non-empty supported_clause_kinds only receive keys whose classified kind matches; adapters with empty clause_kinds are unaffected. _build_enforcement_metadata() expanded from 2 to all 5 adapters and includes supported_clause_kinds in each adapter's details dict. 428 unit tests pass; ruff + mypy clean. --- adr_kit/decision/workflows/creation.py | 14 +- adr_kit/enforcement/adapters/base.py | 4 +- adr_kit/enforcement/adapters/eslint.py | 3 +- adr_kit/enforcement/adapters/import_linter.py | 3 +- adr_kit/enforcement/adapters/mypy.py | 3 +- adr_kit/enforcement/adapters/ruff.py | 3 +- adr_kit/enforcement/adapters/tsconfig.py | 3 +- adr_kit/enforcement/clause_kinds.py | 56 ++++ adr_kit/enforcement/pipeline.py | 7 + adr_kit/enforcement/router.py | 18 +- tests/unit/test_clause_kinds.py | 291 ++++++++++++++++++ tests/unit/test_config_adapters.py | 139 +++++++++ 12 files changed, 534 insertions(+), 10 deletions(-) create mode 100644 adr_kit/enforcement/clause_kinds.py create mode 100644 tests/unit/test_clause_kinds.py diff --git a/adr_kit/decision/workflows/creation.py b/adr_kit/decision/workflows/creation.py index 036c30c..9a9e119 100644 --- a/adr_kit/decision/workflows/creation.py +++ b/adr_kit/decision/workflows/creation.py @@ -889,9 +889,18 @@ def _build_enforcement_metadata(self) -> dict[str, Any]: reference without touching creation.py. """ from ...enforcement.adapters.eslint import ESLintAdapter + from ...enforcement.adapters.import_linter import ImportLinterAdapter + from ...enforcement.adapters.mypy import MypyAdapter from ...enforcement.adapters.ruff import RuffAdapter - - adapters = [ESLintAdapter(), RuffAdapter()] + from ...enforcement.adapters.tsconfig import TsconfigAdapter + + adapters = [ + ESLintAdapter(), + RuffAdapter(), + MypyAdapter(), + TsconfigAdapter(), + ImportLinterAdapter(), + ] # Map each policy key to the adapters that can enforce it policy_coverage: dict[str, list[str]] = {} @@ -902,6 +911,7 @@ def _build_enforcement_metadata(self) -> dict[str, Any]: "tool": adapter.name, "supported_policy_keys": adapter.supported_policy_keys, "supported_languages": adapter.supported_languages, + "supported_clause_kinds": list(adapter.supported_clause_kinds), "output_modes": adapter.output_modes, "supported_stages": adapter.supported_stages, "config_targets": adapter.config_targets, diff --git a/adr_kit/enforcement/adapters/base.py b/adr_kit/enforcement/adapters/base.py index 1193338..cb8933b 100644 --- a/adr_kit/enforcement/adapters/base.py +++ b/adr_kit/enforcement/adapters/base.py @@ -86,8 +86,8 @@ def config_targets(self) -> list[str]: def supported_clause_kinds(self) -> list[str]: """Clause families this adapter can enforce, e.g. 'forbidden_import'. - Provisional — ENF-CLA will define the canonical vocabulary. Until then, - free-form strings are acceptable. Defaults to empty (adapter handles all). + Use ClauseKind enum values from enforcement.clause_kinds for the canonical + vocabulary. Defaults to empty (no clause-kind filtering applied by router). """ return [] diff --git a/adr_kit/enforcement/adapters/eslint.py b/adr_kit/enforcement/adapters/eslint.py index 6d48591..77254e1 100644 --- a/adr_kit/enforcement/adapters/eslint.py +++ b/adr_kit/enforcement/adapters/eslint.py @@ -16,6 +16,7 @@ from ...core.model import ADR, ADRStatus from ...core.parse import ParseError, find_adr_files, parse_adr_file from ...core.policy_extractor import PolicyExtractor +from ..clause_kinds import ClauseKind from .base import BaseAdapter, ConfigFragment @@ -462,7 +463,7 @@ def config_targets(self) -> list[str]: @property def supported_clause_kinds(self) -> list[str]: - return ["forbidden_import", "preferred_import"] + return [ClauseKind.FORBIDDEN_IMPORT, ClauseKind.ALLOWED_IMPORT_SURFACE] @property def output_modes(self) -> list[str]: diff --git a/adr_kit/enforcement/adapters/import_linter.py b/adr_kit/enforcement/adapters/import_linter.py index 6898f60..54004b8 100644 --- a/adr_kit/enforcement/adapters/import_linter.py +++ b/adr_kit/enforcement/adapters/import_linter.py @@ -10,6 +10,7 @@ from ...contract.models import MergedConstraints from ...core.model import LayerBoundaryRule +from ..clause_kinds import ClauseKind from .base import BaseAdapter, ConfigFragment @@ -107,7 +108,7 @@ def config_targets(self) -> list[str]: @property def supported_clause_kinds(self) -> list[str]: - return ["layer_boundary"] + return [ClauseKind.LAYER_BOUNDARY] @property def output_modes(self) -> list[str]: diff --git a/adr_kit/enforcement/adapters/mypy.py b/adr_kit/enforcement/adapters/mypy.py index 23f33eb..3aac1b8 100644 --- a/adr_kit/enforcement/adapters/mypy.py +++ b/adr_kit/enforcement/adapters/mypy.py @@ -10,6 +10,7 @@ from typing import Any from ...contract.models import MergedConstraints +from ..clause_kinds import ClauseKind from .base import BaseAdapter, ConfigFragment @@ -71,7 +72,7 @@ def config_targets(self) -> list[str]: @property def supported_clause_kinds(self) -> list[str]: - return ["config_enforcement"] + return [ClauseKind.CONFIG_INVARIANT] @property def output_modes(self) -> list[str]: diff --git a/adr_kit/enforcement/adapters/ruff.py b/adr_kit/enforcement/adapters/ruff.py index 4bf8071..5f6c3a3 100644 --- a/adr_kit/enforcement/adapters/ruff.py +++ b/adr_kit/enforcement/adapters/ruff.py @@ -18,6 +18,7 @@ from ...contract.models import MergedConstraints from ...core.model import ADR, ADRStatus from ...core.parse import ParseError, find_adr_files, parse_adr_file +from ..clause_kinds import ClauseKind from .base import BaseAdapter, ConfigFragment @@ -421,7 +422,7 @@ def config_targets(self) -> list[str]: @property def supported_clause_kinds(self) -> list[str]: - return ["forbidden_import"] + return [ClauseKind.FORBIDDEN_IMPORT] @property def output_modes(self) -> list[str]: diff --git a/adr_kit/enforcement/adapters/tsconfig.py b/adr_kit/enforcement/adapters/tsconfig.py index eed5a88..626a383 100644 --- a/adr_kit/enforcement/adapters/tsconfig.py +++ b/adr_kit/enforcement/adapters/tsconfig.py @@ -9,6 +9,7 @@ from typing import Any from ...contract.models import MergedConstraints +from ..clause_kinds import ClauseKind from .base import BaseAdapter, ConfigFragment @@ -66,7 +67,7 @@ def config_targets(self) -> list[str]: @property def supported_clause_kinds(self) -> list[str]: - return ["config_enforcement"] + return [ClauseKind.CONFIG_INVARIANT] @property def output_modes(self) -> list[str]: diff --git a/adr_kit/enforcement/clause_kinds.py b/adr_kit/enforcement/clause_kinds.py new file mode 100644 index 0000000..7845fdd --- /dev/null +++ b/adr_kit/enforcement/clause_kinds.py @@ -0,0 +1,56 @@ +"""Clause kind taxonomy for the enforcement plane. + +Defines the canonical set of enforceable clause families that sit between +policy authoring and adapter routing. Adapters declare which kinds they +support; the router uses these declarations as a secondary routing signal. +""" + +from __future__ import annotations + +from enum import Enum + + +class ClauseKind(str, Enum): + """Canonical clause families for architectural enforcement. + + Each value represents a stable semantic target that adapters can declare + support for, independently of specific policy key paths. + """ + + FORBIDDEN_IMPORT = "forbidden_import" + ALLOWED_IMPORT_SURFACE = "allowed_import_surface" + PUBLIC_API_ONLY = "public_api_only" + LAYER_BOUNDARY = "layer_boundary" + FORBIDDEN_PATTERN = "forbidden_pattern" + REQUIRED_STRUCTURE = "required_structure" + CONFIG_INVARIANT = "config_invariant" + WORKFLOW_POLICY = "workflow_policy" + IAC_POLICY = "iac_policy" + + +# Prefix-based mapping from granular rule paths to clause kinds. +# Checked in order; first match wins. +_PREFIX_MAP: list[tuple[str, ClauseKind]] = [ + ("imports.disallow.", ClauseKind.FORBIDDEN_IMPORT), + ("imports.prefer.", ClauseKind.ALLOWED_IMPORT_SURFACE), + ("architecture.layer_boundaries.", ClauseKind.LAYER_BOUNDARY), + ("architecture.required_structure.", ClauseKind.REQUIRED_STRUCTURE), + ("patterns.", ClauseKind.FORBIDDEN_PATTERN), + ("config_enforcement.", ClauseKind.CONFIG_INVARIANT), + ("python.disallow_imports.", ClauseKind.FORBIDDEN_IMPORT), +] + + +def classify_policy_rule(rule_path: str) -> ClauseKind | None: + """Classify a granular rule path to a canonical ClauseKind. + + Args: + rule_path: A dot-separated rule path, e.g. 'imports.disallow.axios'. + + Returns: + The matching ClauseKind, or None if the path cannot be classified. + """ + for prefix, kind in _PREFIX_MAP: + if rule_path.startswith(prefix): + return kind + return None diff --git a/adr_kit/enforcement/pipeline.py b/adr_kit/enforcement/pipeline.py index e64e22e..1129ed2 100644 --- a/adr_kit/enforcement/pipeline.py +++ b/adr_kit/enforcement/pipeline.py @@ -24,6 +24,7 @@ from ..contract.builder import ConstraintsContractBuilder from ..contract.models import ConstraintsContract +from .clause_kinds import classify_policy_rule class AppliedFragment(BaseModel): @@ -71,6 +72,10 @@ class ProvenanceEntry(BaseModel): default_factory=list, description="Files/fragments generated from this rule (populated by adapters)", ) + clause_kind: str | None = Field( + default=None, + description="Canonical ClauseKind for this rule, e.g. 'forbidden_import'", + ) class EnforcementResult(BaseModel): @@ -417,10 +422,12 @@ def _build_provenance_index( """Convert contract provenance into ProvenanceEntry objects.""" index: dict[str, ProvenanceEntry] = {} for rule_path, prov in contract.provenance.items(): + kind = classify_policy_rule(rule_path) index[rule_path] = ProvenanceEntry( rule=rule_path, source_adr_id=prov.adr_id, clause_id=prov.clause_id, artifact_refs=[], + clause_kind=kind.value if kind is not None else None, ) return index diff --git a/adr_kit/enforcement/router.py b/adr_kit/enforcement/router.py index 4e17136..26dc678 100644 --- a/adr_kit/enforcement/router.py +++ b/adr_kit/enforcement/router.py @@ -10,6 +10,7 @@ from ..contract.models import ConstraintsContract, MergedConstraints from .adapters.base import BaseAdapter +from .clause_kinds import classify_policy_rule @dataclass @@ -85,11 +86,26 @@ def route( # Expand matched policy keys to granular rule paths from provenance granular_keys = self._expand_policy_keys(matched_keys, contract) + # Secondary filter: if adapter declares clause kinds, only keep granular + # keys whose classify_policy_rule result is in the declared set. + # Adapters with empty supported_clause_kinds skip this filter (backward-compatible). + clause_kinds = list(adapter.supported_clause_kinds) + if clause_kinds: + filtered = [ + k + for k in granular_keys + if (ck := classify_policy_rule(k)) is not None + and ck.value in clause_kinds + ] + # Fall back to unfiltered if nothing matched (e.g. top-level key fallback) + if filtered: + granular_keys = filtered + decisions.append( RoutingDecision( adapter=adapter, policy_keys=granular_keys, - clause_kinds=list(adapter.supported_clause_kinds), + clause_kinds=clause_kinds, output_modes=list(adapter.output_modes), supported_stages=list(adapter.supported_stages), ) diff --git a/tests/unit/test_clause_kinds.py b/tests/unit/test_clause_kinds.py new file mode 100644 index 0000000..4ad742f --- /dev/null +++ b/tests/unit/test_clause_kinds.py @@ -0,0 +1,291 @@ +"""Unit tests for enforcement.clause_kinds module and ProvenanceEntry enrichment.""" + +from datetime import datetime, timezone + +from adr_kit.contract.models import ( + ConstraintsContract, + ContractMetadata, + MergedConstraints, + PolicyProvenance, +) +from adr_kit.core.model import ImportPolicy +from adr_kit.enforcement.adapters.base import BaseAdapter, ConfigFragment +from adr_kit.enforcement.clause_kinds import ClauseKind, classify_policy_rule +from adr_kit.enforcement.pipeline import EnforcementPipeline +from adr_kit.enforcement.router import PolicyRouter + + +class TestClauseKindEnum: + def test_all_nine_members_exist(self): + kinds = {k.value for k in ClauseKind} + assert kinds == { + "forbidden_import", + "allowed_import_surface", + "public_api_only", + "layer_boundary", + "forbidden_pattern", + "required_structure", + "config_invariant", + "workflow_policy", + "iac_policy", + } + + def test_clause_kind_is_string(self): + for kind in ClauseKind: + assert isinstance(kind, str), f"{kind} should be a str subclass" + + def test_clause_kind_value_equals_name_lower(self): + assert ClauseKind.FORBIDDEN_IMPORT == "forbidden_import" + assert ClauseKind.ALLOWED_IMPORT_SURFACE == "allowed_import_surface" + assert ClauseKind.CONFIG_INVARIANT == "config_invariant" + + +class TestClassifyPolicyRule: + def test_imports_disallow_maps_to_forbidden_import(self): + assert ( + classify_policy_rule("imports.disallow.axios") + == ClauseKind.FORBIDDEN_IMPORT + ) + assert ( + classify_policy_rule("imports.disallow.flask") + == ClauseKind.FORBIDDEN_IMPORT + ) + + def test_imports_prefer_maps_to_allowed_import_surface(self): + assert ( + classify_policy_rule("imports.prefer.fastapi") + == ClauseKind.ALLOWED_IMPORT_SURFACE + ) + + def test_architecture_layer_boundaries_maps_to_layer_boundary(self): + assert ( + classify_policy_rule("architecture.layer_boundaries.0") + == ClauseKind.LAYER_BOUNDARY + ) + assert ( + classify_policy_rule("architecture.layer_boundaries.frontend->database") + == ClauseKind.LAYER_BOUNDARY + ) + + def test_architecture_required_structure_maps_to_required_structure(self): + assert ( + classify_policy_rule("architecture.required_structure.0") + == ClauseKind.REQUIRED_STRUCTURE + ) + + def test_patterns_maps_to_forbidden_pattern(self): + assert ( + classify_policy_rule("patterns.no_god_objects") + == ClauseKind.FORBIDDEN_PATTERN + ) + assert ( + classify_policy_rule("patterns.no_raw_db") == ClauseKind.FORBIDDEN_PATTERN + ) + + def test_config_enforcement_maps_to_config_invariant(self): + assert ( + classify_policy_rule("config_enforcement.python.mypy.strict") + == ClauseKind.CONFIG_INVARIANT + ) + assert ( + classify_policy_rule("config_enforcement.typescript.tsconfig.strict") + == ClauseKind.CONFIG_INVARIANT + ) + + def test_python_disallow_imports_maps_to_forbidden_import(self): + assert ( + classify_policy_rule("python.disallow_imports.requests") + == ClauseKind.FORBIDDEN_IMPORT + ) + + def test_unknown_prefix_returns_none(self): + assert classify_policy_rule("unknown.key") is None + assert classify_policy_rule("imports") is None + assert classify_policy_rule("") is None + assert classify_policy_rule("workflow.deploy") is None + + def test_top_level_key_without_suffix_returns_none(self): + # Top-level keys like "imports" (no dot) are unclassifiable + assert classify_policy_rule("imports") is None + assert classify_policy_rule("config_enforcement") is None + + +def _make_provenance(rule_path: str, adr_id: str = "ADR-001") -> PolicyProvenance: + return PolicyProvenance( + adr_id=adr_id, + adr_title="Test ADR", + rule_path=rule_path, + effective_date=datetime(2024, 1, 1, tzinfo=timezone.utc), + clause_id=PolicyProvenance.make_clause_id(adr_id, rule_path), + ) + + +def _make_contract(provenance: dict[str, PolicyProvenance]) -> ConstraintsContract: + return ConstraintsContract( + metadata=ContractMetadata(hash="abc123", source_adrs=[], adr_directory="."), + constraints=MergedConstraints(), + provenance=provenance, + ) + + +class TestProvenanceEntryClauseKind: + def setup_method(self, tmp_path_factory): + from pathlib import Path + + self.pipeline = EnforcementPipeline( + adr_dir=Path("/tmp"), project_path=Path("/tmp") + ) + + def _build_index(self, provenance: dict[str, PolicyProvenance]) -> dict: + contract = _make_contract(provenance) + return self.pipeline._build_provenance_index(contract) + + def test_known_rule_path_populates_clause_kind(self): + idx = self._build_index( + {"imports.disallow.axios": _make_provenance("imports.disallow.axios")} + ) + entry = idx["imports.disallow.axios"] + assert entry.clause_kind == "forbidden_import" + + def test_layer_boundary_rule_path_populates_clause_kind(self): + idx = self._build_index( + { + "architecture.layer_boundaries.0": _make_provenance( + "architecture.layer_boundaries.0" + ) + } + ) + entry = idx["architecture.layer_boundaries.0"] + assert entry.clause_kind == "layer_boundary" + + def test_config_enforcement_rule_path_populates_clause_kind(self): + idx = self._build_index( + { + "config_enforcement.python.mypy.strict": _make_provenance( + "config_enforcement.python.mypy.strict" + ) + } + ) + entry = idx["config_enforcement.python.mypy.strict"] + assert entry.clause_kind == "config_invariant" + + def test_unknown_rule_path_leaves_clause_kind_none(self): + idx = self._build_index({"unknown.rule": _make_provenance("unknown.rule")}) + entry = idx["unknown.rule"] + assert entry.clause_kind is None + + def test_existing_fields_unchanged(self): + rule_path = "imports.disallow.flask" + prov = _make_provenance(rule_path) + idx = self._build_index({rule_path: prov}) + entry = idx[rule_path] + assert entry.rule == rule_path + assert entry.source_adr_id == "ADR-001" + assert entry.clause_id == prov.clause_id + assert entry.artifact_refs == [] + + +# --------------------------------------------------------------------------- +# Router clause-kind filtering tests +# --------------------------------------------------------------------------- + + +class _ForbiddenOnlyAdapter(BaseAdapter): + """Test adapter that declares only forbidden_import clause kind.""" + + @property + def name(self) -> str: + return "forbidden_only" + + @property + def supported_policy_keys(self) -> list[str]: + return ["imports"] + + @property + def supported_languages(self) -> list[str]: + return ["python"] + + @property + def config_targets(self) -> list[str]: + return [] + + @property + def supported_clause_kinds(self) -> list[str]: + return [ClauseKind.FORBIDDEN_IMPORT] + + def generate_fragments(self, constraints): + return [] + + +class _NoClauseKindAdapter(BaseAdapter): + """Test adapter with empty supported_clause_kinds (backward-compatible).""" + + @property + def name(self) -> str: + return "no_clause_kind" + + @property + def supported_policy_keys(self) -> list[str]: + return ["imports"] + + @property + def supported_languages(self) -> list[str]: + return ["python"] + + @property + def config_targets(self) -> list[str]: + return [] + + def generate_fragments(self, constraints): + return [] + + +def _make_contract_with_prov( + provenance: dict[str, PolicyProvenance], +) -> ConstraintsContract: + return ConstraintsContract( + metadata=ContractMetadata(hash="abc123", source_adrs=[], adr_directory="."), + constraints=MergedConstraints( + imports=ImportPolicy(disallow=["axios"], prefer=["fastapi"]) + ), + provenance=provenance, + ) + + +class TestRouterClauseKindFiltering: + def _make_prov(self, rule_path: str) -> PolicyProvenance: + return PolicyProvenance( + adr_id="ADR-001", + adr_title="Test", + rule_path=rule_path, + effective_date=datetime(2024, 1, 1, tzinfo=timezone.utc), + clause_id=PolicyProvenance.make_clause_id("ADR-001", rule_path), + ) + + def test_forbidden_only_adapter_receives_disallow_keys(self): + prov = { + "imports.disallow.axios": self._make_prov("imports.disallow.axios"), + "imports.prefer.fastapi": self._make_prov("imports.prefer.fastapi"), + } + contract = _make_contract_with_prov(prov) + router = PolicyRouter([_ForbiddenOnlyAdapter()]) + decisions, _ = router.route(contract, ["python"]) + + assert len(decisions) == 1 + keys = decisions[0].policy_keys + assert "imports.disallow.axios" in keys + assert "imports.prefer.fastapi" not in keys + + def test_adapter_with_empty_clause_kinds_receives_all_keys(self): + prov = { + "imports.disallow.axios": self._make_prov("imports.disallow.axios"), + "imports.prefer.fastapi": self._make_prov("imports.prefer.fastapi"), + } + contract = _make_contract_with_prov(prov) + router = PolicyRouter([_NoClauseKindAdapter()]) + decisions, _ = router.route(contract, ["python"]) + + assert len(decisions) == 1 + keys = decisions[0].policy_keys + assert "imports.disallow.axios" in keys + assert "imports.prefer.fastapi" in keys diff --git a/tests/unit/test_config_adapters.py b/tests/unit/test_config_adapters.py index 1fc6064..da2b69c 100644 --- a/tests/unit/test_config_adapters.py +++ b/tests/unit/test_config_adapters.py @@ -30,6 +30,7 @@ TypeScriptConfig, ) from adr_kit.enforcement.adapters.base import BaseAdapter, ConfigFragment +from adr_kit.enforcement.adapters.eslint import ESLintAdapter from adr_kit.enforcement.adapters.import_linter import ( ImportLinterAdapter, generate_import_linter_config_from_contract, @@ -38,10 +39,12 @@ MypyAdapter, generate_mypy_config_from_contract, ) +from adr_kit.enforcement.adapters.ruff import RuffAdapter from adr_kit.enforcement.adapters.tsconfig import ( TsconfigAdapter, generate_tsconfig_from_contract, ) +from adr_kit.enforcement.clause_kinds import ClauseKind from adr_kit.enforcement.router import PolicyRouter # --------------------------------------------------------------------------- @@ -698,3 +701,139 @@ def test_no_config_enforcement_skips_new_adapters(self, tmp_path): assert "mypy" not in applied assert "tsconfig" not in applied assert "import_linter" not in applied + + +# --------------------------------------------------------------------------- +# ENF-CLA: Adapter supported_clause_kinds canonical vocabulary tests +# --------------------------------------------------------------------------- + + +class TestMypyAdapterClauseKinds: + def setup_method(self): + self.adapter = MypyAdapter() + + def test_returns_config_invariant(self): + assert ClauseKind.CONFIG_INVARIANT in self.adapter.supported_clause_kinds + + def test_all_values_are_clause_kind_instances(self): + for v in self.adapter.supported_clause_kinds: + assert isinstance(v, ClauseKind) + + def test_no_stale_config_enforcement_string(self): + assert "config_enforcement" not in self.adapter.supported_clause_kinds + + +class TestTsconfigAdapterClauseKinds: + def setup_method(self): + self.adapter = TsconfigAdapter() + + def test_returns_config_invariant(self): + assert ClauseKind.CONFIG_INVARIANT in self.adapter.supported_clause_kinds + + def test_all_values_are_clause_kind_instances(self): + for v in self.adapter.supported_clause_kinds: + assert isinstance(v, ClauseKind) + + def test_no_stale_config_enforcement_string(self): + assert "config_enforcement" not in self.adapter.supported_clause_kinds + + +class TestImportLinterAdapterClauseKinds: + def setup_method(self): + self.adapter = ImportLinterAdapter() + + def test_returns_layer_boundary(self): + assert ClauseKind.LAYER_BOUNDARY in self.adapter.supported_clause_kinds + + def test_all_values_are_clause_kind_instances(self): + for v in self.adapter.supported_clause_kinds: + assert isinstance(v, ClauseKind) + + +class TestESLintAdapterClauseKinds: + def setup_method(self): + self.adapter = ESLintAdapter() + + def test_returns_forbidden_import(self): + assert ClauseKind.FORBIDDEN_IMPORT in self.adapter.supported_clause_kinds + + def test_returns_allowed_import_surface(self): + assert ClauseKind.ALLOWED_IMPORT_SURFACE in self.adapter.supported_clause_kinds + + def test_no_stale_preferred_import_string(self): + assert "preferred_import" not in self.adapter.supported_clause_kinds + + def test_all_values_are_clause_kind_instances(self): + for v in self.adapter.supported_clause_kinds: + assert isinstance(v, ClauseKind) + + +class TestRuffAdapterClauseKinds: + def setup_method(self): + self.adapter = RuffAdapter() + + def test_returns_forbidden_import(self): + assert ClauseKind.FORBIDDEN_IMPORT in self.adapter.supported_clause_kinds + + def test_all_values_are_clause_kind_instances(self): + for v in self.adapter.supported_clause_kinds: + assert isinstance(v, ClauseKind) + + +# --------------------------------------------------------------------------- +# ENF-CLA: Creation workflow enforcement metadata tests +# --------------------------------------------------------------------------- + + +class TestEnforcementMetadataAllAdapters: + """Verify _build_enforcement_metadata includes all 5 adapters with clause_kinds.""" + + def setup_method(self): + from pathlib import Path + from unittest.mock import MagicMock + + # CreationWorkflow needs an adr_dir; use MagicMock to avoid filesystem setup + from adr_kit.decision.workflows.creation import CreationWorkflow + + self.workflow = CreationWorkflow.__new__(CreationWorkflow) + + def _get_metadata(self): + return self.workflow._build_enforcement_metadata() + + def test_all_five_adapters_present(self): + meta = self._get_metadata() + adapters = meta["adapters"] + assert "eslint" in adapters + assert "ruff" in adapters + assert "mypy" in adapters + assert "tsconfig" in adapters + assert "import_linter" in adapters + + def test_each_adapter_has_supported_clause_kinds_key(self): + meta = self._get_metadata() + for name, details in meta["adapters"].items(): + assert ( + "supported_clause_kinds" in details + ), f"{name} missing supported_clause_kinds" + + def test_mypy_adapter_reports_config_invariant(self): + meta = self._get_metadata() + assert "config_invariant" in meta["adapters"]["mypy"]["supported_clause_kinds"] + + def test_eslint_adapter_reports_forbidden_import(self): + meta = self._get_metadata() + assert ( + "forbidden_import" in meta["adapters"]["eslint"]["supported_clause_kinds"] + ) + + def test_policy_enforcement_paths_covers_all_known_keys(self): + meta = self._get_metadata() + paths = meta["policy_enforcement_paths"] + for key in [ + "imports", + "python", + "patterns", + "architecture", + "config_enforcement", + ]: + assert key in paths