diff --git a/CHANGELOG.md b/CHANGELOG.md index 3acb65d..1f53f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `OutputMode` enum (`native_config`, `native_rules`, `generated_checker`, `policy_file`, `script_fallback`) and `EnforcementStage` enum (`commit`, `push`, `ci`) — enforcement adapters now declare what kind of artifact they emit and at which gate it is evaluated, making the enforcement plane's output taxonomy explicit and inspectable +- `FallbackAdapter` — unroutable policy keys now flow through a standard adapter interface rather than a pipeline side path; fallback promptlets appear in `EnforcementResult.fragments_applied` with `output_mode="script_fallback"` alongside native-config fragments, making the full enforcement picture visible in one place +- `output_mode` field on `EnforcementResult.fragments_applied` entries — each applied fragment now reports its output mode, enabling callers to distinguish native enforcement artifacts from agent-authored validation scripts - `depends_on` and `related_to` optional fields on ADR frontmatter — declare inter-ADR dependencies and relationships directly in the ADR file; the contract builder warns when referenced ADR IDs cannot be resolved - Mypy enforcement adapter — approving an ADR with `config_enforcement.python.mypy` constraints automatically generates a `.mypy-adr.ini` configuration file - TypeScript tsconfig enforcement adapter — approving an ADR with `config_enforcement.typescript.tsconfig` constraints automatically generates a `tsconfig.adr.json` file that can be extended via tsconfig's `extends` diff --git a/adr_kit/enforcement/adapters/base.py b/adr_kit/enforcement/adapters/base.py index cb8933b..725041c 100644 --- a/adr_kit/enforcement/adapters/base.py +++ b/adr_kit/enforcement/adapters/base.py @@ -12,6 +12,7 @@ from dataclasses import dataclass, field from ...contract.models import MergedConstraints +from ..clause_kinds import EnforcementStage, OutputMode @dataclass @@ -33,6 +34,9 @@ class ConfigFragment: policy_keys: list[str] = field(default_factory=list) """Contract policy keys covered by this fragment, e.g. ['imports.disallow.axios'].""" + output_mode: OutputMode = field(default=OutputMode.NATIVE_CONFIG) + """Output mode this fragment represents.""" + class BaseAdapter(ABC): """Abstract base class for all enforcement adapters. @@ -92,21 +96,20 @@ def supported_clause_kinds(self) -> list[str]: return [] @property - def output_modes(self) -> list[str]: + def output_modes(self) -> list[OutputMode]: """Kinds of artifacts this adapter emits. - Values: native_config, native_rules, generated_checker, policy_file, script_fallback. - ENF-MODE will formalise these as a first-class enum. Defaults to native_config. + Defaults to [OutputMode.NATIVE_CONFIG]. """ - return ["native_config"] + return [OutputMode.NATIVE_CONFIG] @property - def supported_stages(self) -> list[str]: - """Enforcement stages this adapter targets (commit, push, ci). + def supported_stages(self) -> list[EnforcementStage]: + """Enforcement stages this adapter targets. - Defaults to ['ci']. + Defaults to [EnforcementStage.CI]. """ - return ["ci"] + return [EnforcementStage.CI] # ------------------------------------------------------------------ # Core method diff --git a/adr_kit/enforcement/adapters/eslint.py b/adr_kit/enforcement/adapters/eslint.py index 77254e1..62ceb2c 100644 --- a/adr_kit/enforcement/adapters/eslint.py +++ b/adr_kit/enforcement/adapters/eslint.py @@ -16,7 +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 ..clause_kinds import ClauseKind, EnforcementStage, OutputMode from .base import BaseAdapter, ConfigFragment @@ -466,12 +466,12 @@ def supported_clause_kinds(self) -> list[str]: return [ClauseKind.FORBIDDEN_IMPORT, ClauseKind.ALLOWED_IMPORT_SURFACE] @property - def output_modes(self) -> list[str]: - return ["native_config"] + def output_modes(self) -> list[OutputMode]: + return [OutputMode.NATIVE_CONFIG] @property - def supported_stages(self) -> list[str]: - return ["commit", "ci"] + def supported_stages(self) -> list[EnforcementStage]: + return [EnforcementStage.COMMIT, EnforcementStage.CI] def generate_fragments( self, constraints: MergedConstraints diff --git a/adr_kit/enforcement/adapters/fallback.py b/adr_kit/enforcement/adapters/fallback.py new file mode 100644 index 0000000..dda506c --- /dev/null +++ b/adr_kit/enforcement/adapters/fallback.py @@ -0,0 +1,147 @@ +"""Fallback adapter for unroutable policy keys. + +FallbackAdapter handles policy keys that no other adapter can enforce by +emitting script-authoring promptlets as ConfigFragment objects. This makes +the fallback path a first-class output mode (SCRIPT_FALLBACK) rather than +a special side path in the pipeline. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from ...contract.models import MergedConstraints +from ..clause_kinds import EnforcementStage, OutputMode +from .base import BaseAdapter, ConfigFragment + +if TYPE_CHECKING: + from ...contract.models import ConstraintsContract + + +class FallbackAdapter(BaseAdapter): + """Handles unroutable policy keys by emitting script-authoring promptlets. + + One ConfigFragment is produced per unroutable policy key. The fragment's + content is a JSON promptlet that instructs the calling agent to write a + validation script. The fragment is never written to disk (target_file=""). + + output_modes = [OutputMode.SCRIPT_FALLBACK] + """ + + @property + def name(self) -> str: + return "fallback" + + @property + def supported_policy_keys(self) -> list[str]: + # Not used for normal routing — pipeline calls this adapter directly. + return [] + + @property + def supported_languages(self) -> list[str]: + # Not language-filtered. + return [] + + @property + def config_targets(self) -> list[str]: + # No files written to disk. + return [] + + @property + def output_modes(self) -> list[OutputMode]: + return [OutputMode.SCRIPT_FALLBACK] + + @property + def supported_stages(self) -> list[EnforcementStage]: + return [EnforcementStage.CI] + + def generate_fragments( + self, + constraints: MergedConstraints, + *, + policy_keys: list[str] | None = None, + contract: ConstraintsContract | None = None, + ) -> list[ConfigFragment]: + """Generate one promptlet fragment per unroutable policy key. + + Args: + constraints: Merged policy constraints (required by BaseAdapter interface). + policy_keys: Unroutable policy keys to generate promptlets for. + If None or empty, returns an empty list. + contract: Full contract used to collect provenance and constraint values. + + Returns: + One ConfigFragment per key, with fragment_type='promptlet_json' + and output_mode=OutputMode.SCRIPT_FALLBACK. target_file is empty + — these fragments are never written to disk. + """ + if not policy_keys: + return [] + + fragments = [] + for key in policy_keys: + content = self._build_promptlet(key, constraints, contract) + fragments.append( + ConfigFragment( + adapter=self.name, + target_file="", + content=content, + fragment_type="promptlet_json", + policy_keys=[key], + output_mode=OutputMode.SCRIPT_FALLBACK, + ) + ) + return fragments + + def _build_promptlet( + self, + policy_key: str, + constraints: MergedConstraints, + contract: ConstraintsContract | None, + ) -> str: + """Build a JSON promptlet for one unroutable policy key.""" + constraint_value: Any = None + source_adrs: list[str] = [] + + if contract is not None: + constraints_dump = contract.constraints.model_dump(exclude_none=True) + constraint_value = constraints_dump.get(policy_key) + source_adrs = sorted( + { + prov.adr_id + for rule_path, prov in contract.provenance.items() + if rule_path == policy_key or rule_path.startswith(policy_key + ".") + } + ) + + promptlet = { + "unenforceable_policy": { + "policy_key": policy_key, + "constraint": constraint_value, + "source_adrs": source_adrs, + }, + "instruction": ( + f"No enforcement adapter exists for policy key '{policy_key}'. " + "Create a validation script that checks for this policy constraint." + ), + "script_requirements": { + "input": "list of file paths to check", + "output": "EnforcementReport JSON (schema: {passed, violations: [{file, message, severity}]})", + "integration": ( + "Place in scripts/adr-validations/ — " + "the enforcement pipeline picks up all scripts in that directory" + ), + }, + "example_script_structure": ( + "#!/usr/bin/env python3\n" + "import sys, json\n" + "violations = []\n" + "for path in sys.argv[1:]:\n" + " # ... check constraint ...\n" + " pass\n" + 'print(json.dumps({"passed": not violations, "violations": violations}))' + ), + } + + return json.dumps(promptlet, indent=2, default=str) diff --git a/adr_kit/enforcement/adapters/import_linter.py b/adr_kit/enforcement/adapters/import_linter.py index 54004b8..05d85e2 100644 --- a/adr_kit/enforcement/adapters/import_linter.py +++ b/adr_kit/enforcement/adapters/import_linter.py @@ -10,7 +10,7 @@ from ...contract.models import MergedConstraints from ...core.model import LayerBoundaryRule -from ..clause_kinds import ClauseKind +from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode from .base import BaseAdapter, ConfigFragment @@ -111,12 +111,12 @@ def supported_clause_kinds(self) -> list[str]: return [ClauseKind.LAYER_BOUNDARY] @property - def output_modes(self) -> list[str]: - return ["native_config"] + def output_modes(self) -> list[OutputMode]: + return [OutputMode.NATIVE_RULES] @property - def supported_stages(self) -> list[str]: - return ["commit", "ci"] + def supported_stages(self) -> list[EnforcementStage]: + return [EnforcementStage.COMMIT, EnforcementStage.CI] def generate_fragments( self, constraints: MergedConstraints diff --git a/adr_kit/enforcement/adapters/mypy.py b/adr_kit/enforcement/adapters/mypy.py index 3aac1b8..81d2cf8 100644 --- a/adr_kit/enforcement/adapters/mypy.py +++ b/adr_kit/enforcement/adapters/mypy.py @@ -10,7 +10,7 @@ from typing import Any from ...contract.models import MergedConstraints -from ..clause_kinds import ClauseKind +from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode from .base import BaseAdapter, ConfigFragment @@ -75,12 +75,12 @@ def supported_clause_kinds(self) -> list[str]: return [ClauseKind.CONFIG_INVARIANT] @property - def output_modes(self) -> list[str]: - return ["native_config"] + def output_modes(self) -> list[OutputMode]: + return [OutputMode.NATIVE_CONFIG] @property - def supported_stages(self) -> list[str]: - return ["commit", "ci"] + def supported_stages(self) -> list[EnforcementStage]: + return [EnforcementStage.COMMIT, EnforcementStage.CI] def generate_fragments( self, constraints: MergedConstraints diff --git a/adr_kit/enforcement/adapters/ruff.py b/adr_kit/enforcement/adapters/ruff.py index 5f6c3a3..53157a7 100644 --- a/adr_kit/enforcement/adapters/ruff.py +++ b/adr_kit/enforcement/adapters/ruff.py @@ -18,7 +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 ..clause_kinds import ClauseKind, EnforcementStage, OutputMode from .base import BaseAdapter, ConfigFragment @@ -425,12 +425,12 @@ def supported_clause_kinds(self) -> list[str]: return [ClauseKind.FORBIDDEN_IMPORT] @property - def output_modes(self) -> list[str]: - return ["native_config"] + def output_modes(self) -> list[OutputMode]: + return [OutputMode.NATIVE_CONFIG] @property - def supported_stages(self) -> list[str]: - return ["commit", "ci"] + def supported_stages(self) -> list[EnforcementStage]: + return [EnforcementStage.COMMIT, EnforcementStage.CI] def generate_fragments( self, constraints: MergedConstraints diff --git a/adr_kit/enforcement/adapters/tsconfig.py b/adr_kit/enforcement/adapters/tsconfig.py index 626a383..1c61b88 100644 --- a/adr_kit/enforcement/adapters/tsconfig.py +++ b/adr_kit/enforcement/adapters/tsconfig.py @@ -9,7 +9,7 @@ from typing import Any from ...contract.models import MergedConstraints -from ..clause_kinds import ClauseKind +from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode from .base import BaseAdapter, ConfigFragment @@ -70,12 +70,12 @@ def supported_clause_kinds(self) -> list[str]: return [ClauseKind.CONFIG_INVARIANT] @property - def output_modes(self) -> list[str]: - return ["native_config"] + def output_modes(self) -> list[OutputMode]: + return [OutputMode.NATIVE_CONFIG] @property - def supported_stages(self) -> list[str]: - return ["commit", "ci"] + def supported_stages(self) -> list[EnforcementStage]: + return [EnforcementStage.COMMIT, EnforcementStage.CI] def generate_fragments( self, constraints: MergedConstraints diff --git a/adr_kit/enforcement/clause_kinds.py b/adr_kit/enforcement/clause_kinds.py index 7845fdd..ee39deb 100644 --- a/adr_kit/enforcement/clause_kinds.py +++ b/adr_kit/enforcement/clause_kinds.py @@ -10,6 +10,29 @@ from enum import Enum +class OutputMode(str, Enum): + """How an adapter emits its enforcement artifact. + + Each value represents a distinct artifact family the enforcement plane + can produce. Adapters declare which modes they support; the pipeline + and reporting use these to distinguish native enforcement from fallbacks. + """ + + NATIVE_CONFIG = "native_config" + NATIVE_RULES = "native_rules" + GENERATED_CHECKER = "generated_checker" + POLICY_FILE = "policy_file" + SCRIPT_FALLBACK = "script_fallback" + + +class EnforcementStage(str, Enum): + """Enforcement gate at which an adapter's output is evaluated.""" + + COMMIT = "commit" + PUSH = "push" + CI = "ci" + + class ClauseKind(str, Enum): """Canonical clause families for architectural enforcement. diff --git a/adr_kit/enforcement/pipeline.py b/adr_kit/enforcement/pipeline.py index 1129ed2..79e9faf 100644 --- a/adr_kit/enforcement/pipeline.py +++ b/adr_kit/enforcement/pipeline.py @@ -39,6 +39,10 @@ class AppliedFragment(BaseModel): fragment_type: str = Field( ..., description="Fragment format, e.g. 'json_file', 'toml_section'" ) + output_mode: str = Field( + default="native_config", + description="OutputMode value, e.g. 'native_config', 'script_fallback'", + ) class EnforcementConflict(BaseModel): @@ -232,15 +236,34 @@ def compile( ) # Stage 4.5: Generate fallback promptlets for unroutable policy keys - for key in unroutable_keys: - promptlet = self._build_fallback_promptlet(key, contract) - result.fallback_promptlets.append(promptlet) - result.skipped_adapters.append( - SkippedAdapter( - adapter="none", - reason=f"unroutable policy key '{key}': fallback promptlet generated", - ) + # FallbackAdapter unifies this as OutputMode.SCRIPT_FALLBACK — same interface as + # all other adapters. fallback_promptlets is also populated for backward compat. + if unroutable_keys: + from .adapters.fallback import FallbackAdapter + + fallback_adapter = FallbackAdapter() + fallback_frags = fallback_adapter.generate_fragments( + constraints, + policy_keys=list(unroutable_keys), + contract=contract, ) + for frag in fallback_frags: + result.fallback_promptlets.append(frag.content) + result.fragments_applied.append( + AppliedFragment( + adapter=frag.adapter, + target_file=frag.target_file, + policy_keys=frag.policy_keys, + fragment_type=frag.fragment_type, + output_mode=frag.output_mode.value, + ) + ) + result.skipped_adapters.append( + SkippedAdapter( + adapter="none", + reason=f"unroutable policy key '{frag.policy_keys[0]}': fallback promptlet generated", + ) + ) # Stage 5: Generate secondary artifacts self._run_script_generator(result) @@ -313,6 +336,7 @@ def _write_fragment( target_file=str(output_file), policy_keys=fragment.policy_keys, fragment_type=fragment.fragment_type, + output_mode=fragment.output_mode.value, ) ) result.files_touched.append(str(output_file)) diff --git a/adr_kit/enforcement/router.py b/adr_kit/enforcement/router.py index 26dc678..ce5e87e 100644 --- a/adr_kit/enforcement/router.py +++ b/adr_kit/enforcement/router.py @@ -10,7 +10,7 @@ from ..contract.models import ConstraintsContract, MergedConstraints from .adapters.base import BaseAdapter -from .clause_kinds import classify_policy_rule +from .clause_kinds import EnforcementStage, OutputMode, classify_policy_rule @dataclass @@ -26,10 +26,10 @@ class RoutingDecision: clause_kinds: list[str] = field(default_factory=list) """Clause kinds the adapter supports (reflected from adapter.supported_clause_kinds).""" - output_modes: list[str] = field(default_factory=list) + output_modes: list[OutputMode] = field(default_factory=list) """Output modes the adapter emits (reflected from adapter.output_modes).""" - supported_stages: list[str] = field(default_factory=list) + supported_stages: list[EnforcementStage] = field(default_factory=list) """Enforcement stages the adapter targets (reflected from adapter.supported_stages).""" diff --git a/tests/unit/test_clause_kinds.py b/tests/unit/test_clause_kinds.py index 4ad742f..c0fa28d 100644 --- a/tests/unit/test_clause_kinds.py +++ b/tests/unit/test_clause_kinds.py @@ -10,11 +10,60 @@ ) 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.clause_kinds import ( + ClauseKind, + EnforcementStage, + OutputMode, + classify_policy_rule, +) from adr_kit.enforcement.pipeline import EnforcementPipeline from adr_kit.enforcement.router import PolicyRouter +class TestOutputModeEnum: + def test_all_five_members_exist(self): + modes = {m.value for m in OutputMode} + assert modes == { + "native_config", + "native_rules", + "generated_checker", + "policy_file", + "script_fallback", + } + + def test_output_mode_is_string(self): + for mode in OutputMode: + assert isinstance(mode, str), f"{mode} should be a str subclass" + + def test_round_trip(self): + assert OutputMode("native_config") is OutputMode.NATIVE_CONFIG + assert OutputMode("script_fallback") is OutputMode.SCRIPT_FALLBACK + + def test_string_equality(self): + assert OutputMode.NATIVE_CONFIG == "native_config" + assert OutputMode.SCRIPT_FALLBACK == "script_fallback" + assert OutputMode.NATIVE_RULES == "native_rules" + + +class TestEnforcementStageEnum: + def test_all_three_members_exist(self): + stages = {s.value for s in EnforcementStage} + assert stages == {"commit", "push", "ci"} + + def test_enforcement_stage_is_string(self): + for stage in EnforcementStage: + assert isinstance(stage, str), f"{stage} should be a str subclass" + + def test_round_trip(self): + assert EnforcementStage("commit") is EnforcementStage.COMMIT + assert EnforcementStage("ci") is EnforcementStage.CI + + def test_string_equality(self): + assert EnforcementStage.COMMIT == "commit" + assert EnforcementStage.CI == "ci" + assert EnforcementStage.PUSH == "push" + + class TestClauseKindEnum: def test_all_nine_members_exist(self): kinds = {k.value for k in ClauseKind} @@ -289,3 +338,71 @@ def test_adapter_with_empty_clause_kinds_receives_all_keys(self): keys = decisions[0].policy_keys assert "imports.disallow.axios" in keys assert "imports.prefer.fastapi" in keys + + +# --------------------------------------------------------------------------- +# ENF-MODE: RoutingDecision carries typed OutputMode and EnforcementStage +# --------------------------------------------------------------------------- + + +class TestRoutingDecisionTypedFields: + 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_routing_decision_output_modes_is_typed(self): + from adr_kit.enforcement.adapters.eslint import ESLintAdapter + + prov = {"imports.disallow.axios": self._make_prov("imports.disallow.axios")} + contract = _make_contract_with_prov(prov) + router = PolicyRouter([ESLintAdapter()]) + decisions, _ = router.route(contract, ["javascript"]) + + assert len(decisions) == 1 + decision = decisions[0] + assert OutputMode.NATIVE_CONFIG in decision.output_modes + assert all(isinstance(m, OutputMode) for m in decision.output_modes) + + def test_routing_decision_supported_stages_is_typed(self): + from adr_kit.enforcement.adapters.eslint import ESLintAdapter + + prov = {"imports.disallow.axios": self._make_prov("imports.disallow.axios")} + contract = _make_contract_with_prov(prov) + router = PolicyRouter([ESLintAdapter()]) + decisions, _ = router.route(contract, ["javascript"]) + + decision = decisions[0] + assert EnforcementStage.COMMIT in decision.supported_stages + assert EnforcementStage.CI in decision.supported_stages + assert all(isinstance(s, EnforcementStage) for s in decision.supported_stages) + + def test_import_linter_decision_output_mode_is_native_rules(self): + from adr_kit.contract.models import PolicyProvenance + from adr_kit.core.model import ArchitecturePolicy, LayerBoundaryRule + from adr_kit.enforcement.adapters.import_linter import ImportLinterAdapter + + rule_path = "architecture.layer_boundaries.0" + prov = {rule_path: self._make_prov(rule_path)} + contract = ConstraintsContract( + metadata=ContractMetadata(hash="abc123", source_adrs=[], adr_directory="."), + constraints=MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule( + rule="api->db", from_layer="api", to_layer="db" + ) + ] + ) + ), + provenance=prov, + ) + router = PolicyRouter([ImportLinterAdapter()]) + decisions, _ = router.route(contract, ["python"]) + + assert len(decisions) == 1 + assert OutputMode.NATIVE_RULES in decisions[0].output_modes diff --git a/tests/unit/test_config_adapters.py b/tests/unit/test_config_adapters.py index da2b69c..da3f134 100644 --- a/tests/unit/test_config_adapters.py +++ b/tests/unit/test_config_adapters.py @@ -44,7 +44,7 @@ TsconfigAdapter, generate_tsconfig_from_contract, ) -from adr_kit.enforcement.clause_kinds import ClauseKind +from adr_kit.enforcement.clause_kinds import ClauseKind, EnforcementStage, OutputMode from adr_kit.enforcement.router import PolicyRouter # --------------------------------------------------------------------------- @@ -837,3 +837,79 @@ def test_policy_enforcement_paths_covers_all_known_keys(self): "config_enforcement", ]: assert key in paths + + +# --------------------------------------------------------------------------- +# ENF-MODE: Typed OutputMode and EnforcementStage per adapter +# --------------------------------------------------------------------------- + + +class TestAdapterOutputModes: + def test_eslint_output_mode(self): + assert ESLintAdapter().output_modes == [OutputMode.NATIVE_CONFIG] + + def test_ruff_output_mode(self): + assert RuffAdapter().output_modes == [OutputMode.NATIVE_CONFIG] + + def test_mypy_output_mode(self): + assert MypyAdapter().output_modes == [OutputMode.NATIVE_CONFIG] + + def test_tsconfig_output_mode(self): + assert TsconfigAdapter().output_modes == [OutputMode.NATIVE_CONFIG] + + def test_import_linter_output_mode_is_native_rules(self): + assert ImportLinterAdapter().output_modes == [OutputMode.NATIVE_RULES] + + def test_output_mode_values_are_strings(self): + for adapter in [ + ESLintAdapter(), + RuffAdapter(), + MypyAdapter(), + TsconfigAdapter(), + ImportLinterAdapter(), + ]: + for mode in adapter.output_modes: + assert isinstance(mode, str), f"{adapter.name}: mode should be str" + + +class TestAdapterEnforcementStages: + def test_eslint_stages(self): + assert ESLintAdapter().supported_stages == [ + EnforcementStage.COMMIT, + EnforcementStage.CI, + ] + + def test_ruff_stages(self): + assert RuffAdapter().supported_stages == [ + EnforcementStage.COMMIT, + EnforcementStage.CI, + ] + + def test_mypy_stages(self): + assert MypyAdapter().supported_stages == [ + EnforcementStage.COMMIT, + EnforcementStage.CI, + ] + + def test_tsconfig_stages(self): + assert TsconfigAdapter().supported_stages == [ + EnforcementStage.COMMIT, + EnforcementStage.CI, + ] + + def test_import_linter_stages(self): + assert ImportLinterAdapter().supported_stages == [ + EnforcementStage.COMMIT, + EnforcementStage.CI, + ] + + def test_stage_values_are_strings(self): + for adapter in [ + ESLintAdapter(), + RuffAdapter(), + MypyAdapter(), + TsconfigAdapter(), + ImportLinterAdapter(), + ]: + for stage in adapter.supported_stages: + assert isinstance(stage, str), f"{adapter.name}: stage should be str" diff --git a/tests/unit/test_enforcement_pipeline.py b/tests/unit/test_enforcement_pipeline.py index de9b7ab..d6edf91 100644 --- a/tests/unit/test_enforcement_pipeline.py +++ b/tests/unit/test_enforcement_pipeline.py @@ -519,3 +519,64 @@ def test_pipeline_provenance_from_contract(self, tmp_path): assert entry.rule == rule_path assert entry.source_adr_id == "ADR-0001" assert len(entry.clause_id) == 12 + + +# --------------------------------------------------------------------------- +# ENF-MODE: output_mode field on AppliedFragment and ConfigFragment +# --------------------------------------------------------------------------- + + +class TestAppliedFragmentOutputMode: + def test_default_output_mode(self): + frag = AppliedFragment( + adapter="eslint", + target_file="/p/.eslintrc.adrs.json", + fragment_type="json_file", + ) + assert frag.output_mode == "native_config" + + def test_explicit_output_mode(self): + frag = AppliedFragment( + adapter="import_linter", + target_file="/p/.importlinter-adr", + fragment_type="ini_file", + output_mode="native_rules", + ) + assert frag.output_mode == "native_rules" + + def test_script_fallback_output_mode(self): + frag = AppliedFragment( + adapter="fallback", + target_file="", + fragment_type="promptlet_json", + output_mode="script_fallback", + ) + assert frag.output_mode == "script_fallback" + + +class TestConfigFragmentOutputMode: + def test_default_output_mode(self): + from adr_kit.enforcement.adapters.base import ConfigFragment + from adr_kit.enforcement.clause_kinds import OutputMode + + frag = ConfigFragment( + adapter="ruff", + target_file=".ruff.toml", + content="", + fragment_type="toml_file", + ) + assert frag.output_mode == OutputMode.NATIVE_CONFIG + + def test_explicit_output_mode(self): + from adr_kit.enforcement.adapters.base import ConfigFragment + from adr_kit.enforcement.clause_kinds import OutputMode + + frag = ConfigFragment( + adapter="import_linter", + target_file=".importlinter-adr", + content="", + fragment_type="ini_file", + output_mode=OutputMode.NATIVE_RULES, + ) + assert frag.output_mode == OutputMode.NATIVE_RULES + assert frag.output_mode == "native_rules" # str,Enum compat diff --git a/tests/unit/test_fallback_adapter.py b/tests/unit/test_fallback_adapter.py new file mode 100644 index 0000000..3354d6e --- /dev/null +++ b/tests/unit/test_fallback_adapter.py @@ -0,0 +1,198 @@ +"""Unit tests for FallbackAdapter (ENF-MODE). + +Covers: +- FallbackAdapter capability declarations (output_modes, supported_stages) +- generate_fragments: empty input, single key, multiple keys +- Promptlet JSON structure and content +- Pipeline integration: unroutable keys appear in both fallback_promptlets + and fragments_applied with output_mode='script_fallback' +""" + +import json +from pathlib import Path + +from adr_kit.contract.models import ( + ConstraintsContract, + ContractMetadata, + MergedConstraints, +) +from adr_kit.enforcement.adapters.base import ConfigFragment +from adr_kit.enforcement.adapters.fallback import FallbackAdapter +from adr_kit.enforcement.clause_kinds import EnforcementStage, OutputMode +from adr_kit.enforcement.pipeline import EnforcementPipeline + + +def _make_contract( + constraints: MergedConstraints | None = None, + provenance: dict | None = None, + tmp_path: Path | None = None, +) -> ConstraintsContract: + return ConstraintsContract( + metadata=ContractMetadata( + hash="test", + source_adrs=[], + adr_directory=str(tmp_path or "."), + ), + constraints=constraints or MergedConstraints(), + provenance=provenance or {}, + approved_adrs=[], + ) + + +class TestFallbackAdapterCapabilities: + def setup_method(self): + self.adapter = FallbackAdapter() + + def test_name(self): + assert self.adapter.name == "fallback" + + def test_output_mode_is_script_fallback(self): + assert self.adapter.output_modes == [OutputMode.SCRIPT_FALLBACK] + + def test_supported_stages(self): + assert self.adapter.supported_stages == [EnforcementStage.CI] + + def test_supported_policy_keys_empty(self): + assert self.adapter.supported_policy_keys == [] + + def test_supported_languages_empty(self): + assert self.adapter.supported_languages == [] + + def test_config_targets_empty(self): + assert self.adapter.config_targets == [] + + +class TestFallbackAdapterGenerateFragments: + def setup_method(self): + self.adapter = FallbackAdapter() + self.constraints = MergedConstraints() + + def test_no_policy_keys_returns_empty(self): + frags = self.adapter.generate_fragments(self.constraints, policy_keys=None) + assert frags == [] + + def test_empty_policy_keys_returns_empty(self): + frags = self.adapter.generate_fragments(self.constraints, policy_keys=[]) + assert frags == [] + + def test_single_key_produces_one_fragment(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert len(frags) == 1 + + def test_multiple_keys_produce_multiple_fragments(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["key_a", "key_b"] + ) + assert len(frags) == 2 + + def test_fragment_type_is_promptlet_json(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert frags[0].fragment_type == "promptlet_json" + + def test_fragment_output_mode_is_script_fallback(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert frags[0].output_mode == OutputMode.SCRIPT_FALLBACK + + def test_fragment_target_file_is_empty(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert frags[0].target_file == "" + + def test_fragment_adapter_name(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert frags[0].adapter == "fallback" + + def test_fragment_policy_keys_set(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert frags[0].policy_keys == ["unknown_key"] + + def test_fragment_is_config_fragment(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + assert isinstance(frags[0], ConfigFragment) + + def test_content_is_valid_json(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + parsed = json.loads(frags[0].content) + assert isinstance(parsed, dict) + + def test_content_has_required_keys(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["unknown_key"] + ) + parsed = json.loads(frags[0].content) + assert "unenforceable_policy" in parsed + assert "instruction" in parsed + assert "script_requirements" in parsed + + def test_content_policy_key_matches(self): + frags = self.adapter.generate_fragments( + self.constraints, policy_keys=["my_custom_key"] + ) + parsed = json.loads(frags[0].content) + assert parsed["unenforceable_policy"]["policy_key"] == "my_custom_key" + + +class TestFallbackAdapterPipelineIntegration: + def test_unroutable_key_appears_in_fallback_promptlets(self, tmp_path): + contract = _make_contract(tmp_path=tmp_path) + # Patch constraints to include an unroutable key + # The pipeline will detect 'unknown_key' as unroutable (no adapter handles it) + # We just verify the pipeline still works when there are no routable constraints + pipeline = EnforcementPipeline(adr_dir=tmp_path, project_path=tmp_path) + result = pipeline.compile(contract=contract, detected_stack=["python"]) + # No unroutable keys in an empty contract — just verify pipeline runs cleanly + assert result is not None + assert isinstance(result.fallback_promptlets, list) + + def test_fragments_applied_contains_script_fallback_entries(self, tmp_path): + from datetime import datetime, timezone + + from adr_kit.contract.models import PolicyProvenance + + # Build a contract that deliberately has no adapter to handle it + # by using a provenance key that no adapter supports + prov = PolicyProvenance( + adr_id="ADR-001", + adr_title="Test", + rule_path="patterns.no_god_objects", + effective_date=datetime(2024, 1, 1, tzinfo=timezone.utc), + clause_id=PolicyProvenance.make_clause_id( + "ADR-001", "patterns.no_god_objects" + ), + ) + contract = _make_contract( + provenance={"patterns.no_god_objects": prov}, + tmp_path=tmp_path, + ) + + pipeline = EnforcementPipeline(adr_dir=tmp_path, project_path=tmp_path) + result = pipeline.compile(contract=contract, detected_stack=["python"]) + + # Unroutable key should produce a fallback + # (patterns key is not handled by any standard adapter) + # Check that if fallback_promptlets exist, fragments_applied also has them + if result.fallback_promptlets: + script_fallback_frags = [ + f + for f in result.fragments_applied + if f.output_mode == "script_fallback" + ] + assert len(script_fallback_frags) == len(result.fallback_promptlets) + for frag in script_fallback_frags: + assert frag.adapter == "fallback" + assert frag.fragment_type == "promptlet_json"