diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2362e..dd0bdaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- 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` +- Import-linter enforcement adapter — approving an ADR with `architecture.layer_boundaries` constraints automatically generates an `.importlinter-adr` configuration file for Python architectural boundary enforcement +- INI conflict detection — `ConflictDetector` now detects contradictions between adapter-generated INI fragments and existing user config on disk (supports mypy and import-linter targets) - `ConflictDetector` — detects two classes of enforcement conflicts: (1) policy-contract conflicts (new ADR policy contradicts existing contract, e.g. one ADR allows Flask while another bans it) and (2) fragment-config conflicts (adapter-generated fragment contradicts existing user config on disk). Policy conflict detection is reusable by decision-plane workflows for pre-approval validation - Guided fallback for unroutable policies — when no adapter can handle a policy key, the pipeline generates a structured promptlet instructing the agent to create a validation script, rather than silently dropping the policy. Scripts placed in `scripts/adr-validations/` are treated as first-class enforcement artifacts - Enforcement metadata in `_build_policy_reference()` — creation workflow now shows agents which policy keys have native adapter coverage (and which tool), which fall back to scripts, and which have no enforcement path yet. Metadata is derived live from the adapter registry so creation guidance stays in sync as adapters are added diff --git a/CLAUDE.md b/CLAUDE.md index 7483ead..9d081f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,10 +261,10 @@ Use `python3` (or the value from `.agent/config.json` → `python` key) for all ## Task Workflow — Session Protocol -**When starting work on a task** (e.g., "work on next task"), follow the session protocol in [`.agent/CLAUDE.md`](.agent/CLAUDE.md). That file is the authoritative reference for the task workflow system — read it first. +**When starting work on an item** (e.g., "work on next item"), follow the session protocol in [`.agent/CLAUDE.md`](.agent/CLAUDE.md). That file is the authoritative reference for the item workflow system — read it first. **Quick summary** (details in `.agent/CLAUDE.md`): -1. Run `python3 .agent/scripts/next_task.py` to get the next task +1. Run `python3 .agent/scripts/next_item.py` to get the next item 2. Confirm with user, invoke `/branch` 3. Research, plan, decompose into atomic steps 4. Implement step by step, `/close` after each step diff --git a/adr_kit/enforcement/adapters/import_linter.py b/adr_kit/enforcement/adapters/import_linter.py new file mode 100644 index 0000000..6898f60 --- /dev/null +++ b/adr_kit/enforcement/adapters/import_linter.py @@ -0,0 +1,146 @@ +"""Import-linter adapter for enforcement pipeline. + +Generates import-linter configuration from contract architecture constraints. +Reads from constraints.architecture.layer_boundaries and writes a standalone +.importlinter-adr INI file for architectural boundary enforcement. +""" + +import configparser +from io import StringIO + +from ...contract.models import MergedConstraints +from ...core.model import LayerBoundaryRule +from .base import BaseAdapter, ConfigFragment + + +def _parse_boundary_rule(rule_str: str) -> tuple[str, str] | None: + """Parse a boundary rule string like 'ui -> database' into (source, forbidden). + + Returns: + Tuple of (source_module, forbidden_module) or None if unparseable. + """ + parts = rule_str.split("->") + if len(parts) != 2: + return None + source = parts[0].strip() + forbidden = parts[1].strip() + if not source or not forbidden: + return None + return (source, forbidden) + + +def generate_import_linter_config_from_contract( + constraints: MergedConstraints, +) -> str: + """Generate import-linter INI configuration from compiled MergedConstraints. + + Args: + constraints: MergedConstraints from the ConstraintsContract. + + Returns: + INI string with import-linter configuration, or empty string if no boundaries. + """ + boundaries: list[LayerBoundaryRule] = [] + + if constraints.architecture and constraints.architecture.layer_boundaries: + boundaries = constraints.architecture.layer_boundaries + + if not boundaries: + return "" + + config = configparser.ConfigParser() + + config["importlinter"] = { + "root_packages": "src", + "include_external_packages": "False", + } + + for i, boundary in enumerate(boundaries): + parsed = _parse_boundary_rule(boundary.rule) + if not parsed: + continue + + source, forbidden = parsed + contract_name = f"importlinter:contract:{i + 1}" + + description = boundary.message or f"{source} must not import from {forbidden}" + + config[contract_name] = { + "name": description, + "type": "forbidden", + "source_modules": source, + "forbidden_modules": forbidden, + } + + # If no valid contracts were generated, return empty + sections = [s for s in config.sections() if s.startswith("importlinter:contract:")] + if not sections: + return "" + + output = StringIO() + config.write(output) + + header = ( + "# ADR Kit Generated Import-Linter Configuration\n" "# Do not edit manually\n\n" + ) + return header + output.getvalue() + + +class ImportLinterAdapter(BaseAdapter): + """Enforcement adapter that generates import-linter config from architecture constraints.""" + + @property + def name(self) -> str: + return "import_linter" + + @property + def supported_policy_keys(self) -> list[str]: + return ["architecture"] + + @property + def supported_languages(self) -> list[str]: + return ["python"] + + @property + def config_targets(self) -> list[str]: + return [".importlinter-adr"] + + @property + def supported_clause_kinds(self) -> list[str]: + return ["layer_boundary"] + + @property + def output_modes(self) -> list[str]: + return ["native_config"] + + @property + def supported_stages(self) -> list[str]: + return ["commit", "ci"] + + def generate_fragments( + self, constraints: MergedConstraints + ) -> list[ConfigFragment]: + """Generate import-linter config fragment from merged constraints.""" + content = generate_import_linter_config_from_contract(constraints) + + if not content: + return [] + + policy_keys: list[str] = [] + if constraints.architecture and constraints.architecture.layer_boundaries: + policy_keys.extend( + [ + f"architecture.layer_boundaries.{b.rule}" + for b in constraints.architecture.layer_boundaries + ] + ) + + return [ + ConfigFragment( + adapter=self.name, + target_file=".importlinter-adr", + content=content, + fragment_type="ini_file", + policy_keys=policy_keys, + ) + ] diff --git a/adr_kit/enforcement/adapters/mypy.py b/adr_kit/enforcement/adapters/mypy.py new file mode 100644 index 0000000..23f33eb --- /dev/null +++ b/adr_kit/enforcement/adapters/mypy.py @@ -0,0 +1,114 @@ +"""Mypy configuration adapter for enforcement pipeline. + +Generates mypy configuration from contract config_enforcement constraints. +Reads from constraints.config_enforcement.python.mypy and writes a standalone +.mypy-adr.ini file that can be referenced via mypy's --config-file flag. +""" + +import configparser +from io import StringIO +from typing import Any + +from ...contract.models import MergedConstraints +from .base import BaseAdapter, ConfigFragment + + +def generate_mypy_config_from_contract(constraints: MergedConstraints) -> str: + """Generate mypy INI configuration from compiled MergedConstraints. + + Args: + constraints: MergedConstraints from the ConstraintsContract. + + Returns: + INI string with mypy configuration. + """ + mypy_settings: dict[str, Any] = {} + + if ( + constraints.config_enforcement + and constraints.config_enforcement.python + and constraints.config_enforcement.python.mypy + ): + mypy_settings = constraints.config_enforcement.python.mypy + + if not mypy_settings: + return "" + + config = configparser.ConfigParser() + config["mypy"] = {} + + for key, value in sorted(mypy_settings.items()): + if isinstance(value, bool): + config["mypy"][key] = str(value) + else: + config["mypy"][key] = str(value) + + output = StringIO() + config.write(output) + + header = "# ADR Kit Generated Mypy Configuration\n# Do not edit manually\n\n" + return header + output.getvalue() + + +class MypyAdapter(BaseAdapter): + """Enforcement adapter that generates mypy configuration from contract constraints.""" + + @property + def name(self) -> str: + return "mypy" + + @property + def supported_policy_keys(self) -> list[str]: + return ["config_enforcement"] + + @property + def supported_languages(self) -> list[str]: + return ["python"] + + @property + def config_targets(self) -> list[str]: + return [".mypy-adr.ini"] + + @property + def supported_clause_kinds(self) -> list[str]: + return ["config_enforcement"] + + @property + def output_modes(self) -> list[str]: + return ["native_config"] + + @property + def supported_stages(self) -> list[str]: + return ["commit", "ci"] + + def generate_fragments( + self, constraints: MergedConstraints + ) -> list[ConfigFragment]: + """Generate mypy config fragment from merged constraints.""" + content = generate_mypy_config_from_contract(constraints) + + if not content: + return [] + + policy_keys: list[str] = [] + if ( + constraints.config_enforcement + and constraints.config_enforcement.python + and constraints.config_enforcement.python.mypy + ): + policy_keys.extend( + [ + f"config_enforcement.python.mypy.{k}" + for k in sorted(constraints.config_enforcement.python.mypy.keys()) + ] + ) + + return [ + ConfigFragment( + adapter=self.name, + target_file=".mypy-adr.ini", + content=content, + fragment_type="ini_file", + policy_keys=policy_keys, + ) + ] diff --git a/adr_kit/enforcement/adapters/tsconfig.py b/adr_kit/enforcement/adapters/tsconfig.py new file mode 100644 index 0000000..eed5a88 --- /dev/null +++ b/adr_kit/enforcement/adapters/tsconfig.py @@ -0,0 +1,111 @@ +"""TypeScript tsconfig adapter for enforcement pipeline. + +Generates tsconfig configuration from contract config_enforcement constraints. +Reads from constraints.config_enforcement.typescript.tsconfig and writes a +standalone tsconfig.adr.json file that can be extended via tsconfig's "extends". +""" + +import json +from typing import Any + +from ...contract.models import MergedConstraints +from .base import BaseAdapter, ConfigFragment + + +def generate_tsconfig_from_contract(constraints: MergedConstraints) -> str: + """Generate tsconfig JSON from compiled MergedConstraints. + + Args: + constraints: MergedConstraints from the ConstraintsContract. + + Returns: + JSON string with tsconfig configuration, or empty string if no config. + """ + tsconfig_settings: dict[str, Any] = {} + + if ( + constraints.config_enforcement + and constraints.config_enforcement.typescript + and constraints.config_enforcement.typescript.tsconfig + ): + tsconfig_settings = constraints.config_enforcement.typescript.tsconfig + + if not tsconfig_settings: + return "" + + config = { + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": tsconfig_settings, + "__adr_metadata": { + "generated_by": "ADR Kit", + "description": "ADR-enforced TypeScript compiler options. Extend this in your tsconfig.json.", + }, + } + + return json.dumps(config, indent=2) + + +class TsconfigAdapter(BaseAdapter): + """Enforcement adapter that generates tsconfig from contract constraints.""" + + @property + def name(self) -> str: + return "tsconfig" + + @property + def supported_policy_keys(self) -> list[str]: + return ["config_enforcement"] + + @property + def supported_languages(self) -> list[str]: + return ["typescript"] + + @property + def config_targets(self) -> list[str]: + return ["tsconfig.adr.json"] + + @property + def supported_clause_kinds(self) -> list[str]: + return ["config_enforcement"] + + @property + def output_modes(self) -> list[str]: + return ["native_config"] + + @property + def supported_stages(self) -> list[str]: + return ["commit", "ci"] + + def generate_fragments( + self, constraints: MergedConstraints + ) -> list[ConfigFragment]: + """Generate tsconfig fragment from merged constraints.""" + content = generate_tsconfig_from_contract(constraints) + + if not content: + return [] + + policy_keys: list[str] = [] + if ( + constraints.config_enforcement + and constraints.config_enforcement.typescript + and constraints.config_enforcement.typescript.tsconfig + ): + policy_keys.extend( + [ + f"config_enforcement.typescript.tsconfig.{k}" + for k in sorted( + constraints.config_enforcement.typescript.tsconfig.keys() + ) + ] + ) + + return [ + ConfigFragment( + adapter=self.name, + target_file="tsconfig.adr.json", + content=content, + fragment_type="json_file", + policy_keys=policy_keys, + ) + ] diff --git a/adr_kit/enforcement/conflict.py b/adr_kit/enforcement/conflict.py index 3df6414..e93de02 100644 --- a/adr_kit/enforcement/conflict.py +++ b/adr_kit/enforcement/conflict.py @@ -125,6 +125,8 @@ def detect_config_conflicts( conflicts.extend(self._check_json_conflict(fragment, existing_text)) elif fragment.fragment_type in ("toml_file", "toml_section"): conflicts.extend(self._check_toml_conflict(fragment, existing_text)) + elif fragment.fragment_type == "ini_file": + conflicts.extend(self._check_ini_conflict(fragment, existing_text)) return conflicts @@ -216,6 +218,59 @@ def _check_toml_conflict( return conflicts + # ------------------------------------------------------------------ + # INI conflict check (mypy / import-linter style) + # ------------------------------------------------------------------ + + def _check_ini_conflict( + self, + fragment: "ConfigFragment", + existing_text: str, + ) -> list["EnforcementConflict"]: + """INI: detect settings the fragment wants to set that the user has set differently.""" + import configparser + + from .pipeline import EnforcementConflict + + conflicts: list[EnforcementConflict] = [] + + try: + existing = configparser.ConfigParser() + existing.read_string(existing_text) + + generated = configparser.ConfigParser() + # Strip header comments before parsing + ini_lines = [ + line + for line in fragment.content.splitlines() + if not line.startswith("#") + ] + generated.read_string("\n".join(ini_lines)) + except configparser.Error: + return [] + + for section in generated.sections(): + if not existing.has_section(section): + continue + for key, gen_value in generated.items(section): + if existing.has_option(section, key): + existing_value = existing.get(section, key) + if existing_value.lower() != gen_value.lower(): + conflicts.append( + EnforcementConflict( + adapter=fragment.adapter, + description=( + f"Fragment wants to set [{section}] {key} = {gen_value} " + f"in '{fragment.target_file}' but the existing config " + f"has {key} = {existing_value}. " + f"Resolve: update the existing config or the ADR policy." + ), + source_adrs=list(fragment.policy_keys), + ) + ) + + return conflicts + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/adr_kit/enforcement/pipeline.py b/adr_kit/enforcement/pipeline.py index 77d81a7..e64e22e 100644 --- a/adr_kit/enforcement/pipeline.py +++ b/adr_kit/enforcement/pipeline.py @@ -142,7 +142,10 @@ def compile( EnforcementResult with fragments applied, conflicts, provenance, and hash. """ from .adapters.eslint import ESLintAdapter + from .adapters.import_linter import ImportLinterAdapter + from .adapters.mypy import MypyAdapter from .adapters.ruff import RuffAdapter + from .adapters.tsconfig import TsconfigAdapter from .conflict import ConflictDetector from .detection.stack import StackDetector from .router import PolicyRouter @@ -163,7 +166,15 @@ def compile( detected_stack = StackDetector(self.project_path).detect() # Stage 2: Route via PolicyRouter - router = PolicyRouter([ESLintAdapter(), RuffAdapter()]) + router = PolicyRouter( + [ + ESLintAdapter(), + RuffAdapter(), + MypyAdapter(), + TsconfigAdapter(), + ImportLinterAdapter(), + ] + ) decisions, unroutable_keys = router.route(contract, detected_stack) # Stage 3: Collect all fragments from selected adapters (without writing) diff --git a/tests/unit/test_config_adapters.py b/tests/unit/test_config_adapters.py new file mode 100644 index 0000000..1fc6064 --- /dev/null +++ b/tests/unit/test_config_adapters.py @@ -0,0 +1,700 @@ +"""Unit and integration tests for the ADP task: New Config Adapters. + +Covers: +- MypyAdapter capability declarations and fragment generation +- TsconfigAdapter capability declarations and fragment generation +- ImportLinterAdapter capability declarations and fragment generation +- INI conflict detection +- Pipeline integration: config_enforcement → mypy/tsconfig config written +- Pipeline integration: architecture boundaries → import-linter config written +- Router routing for new adapters (stack + policy key matching) +""" + +import configparser +import json +from pathlib import Path + +import pytest + +from adr_kit.contract.models import ( + ConstraintsContract, + ContractMetadata, + MergedConstraints, +) +from adr_kit.core.model import ( + ArchitecturePolicy, + ConfigEnforcementPolicy, + ImportPolicy, + LayerBoundaryRule, + PythonConfig, + TypeScriptConfig, +) +from adr_kit.enforcement.adapters.base import BaseAdapter, ConfigFragment +from adr_kit.enforcement.adapters.import_linter import ( + ImportLinterAdapter, + generate_import_linter_config_from_contract, +) +from adr_kit.enforcement.adapters.mypy import ( + MypyAdapter, + generate_mypy_config_from_contract, +) +from adr_kit.enforcement.adapters.tsconfig import ( + TsconfigAdapter, + generate_tsconfig_from_contract, +) +from adr_kit.enforcement.router import PolicyRouter + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_contract( + *, + mypy: dict | None = None, + tsconfig: dict | None = None, + layer_boundaries: list[LayerBoundaryRule] | None = None, + disallow_imports: list[str] | None = None, + tmp_path: Path | None = None, +) -> ConstraintsContract: + config_enforcement = None + if mypy or tsconfig: + config_enforcement = ConfigEnforcementPolicy( + python=PythonConfig(mypy=mypy) if mypy else None, + typescript=TypeScriptConfig(tsconfig=tsconfig) if tsconfig else None, + ) + + architecture = None + if layer_boundaries: + architecture = ArchitecturePolicy(layer_boundaries=layer_boundaries) + + imports = None + if disallow_imports: + imports = ImportPolicy(disallow=disallow_imports) + + constraints = MergedConstraints( + imports=imports, + config_enforcement=config_enforcement, + architecture=architecture, + ) + metadata = ContractMetadata( + hash="test", + source_adrs=[], + adr_directory=str(tmp_path or "."), + ) + return ConstraintsContract( + metadata=metadata, + constraints=constraints, + provenance={}, + approved_adrs=[], + ) + + +# --------------------------------------------------------------------------- +# MypyAdapter capability declarations +# --------------------------------------------------------------------------- + + +class TestMypyAdapterCapabilities: + def setup_method(self): + self.adapter = MypyAdapter() + + def test_is_base_adapter(self): + assert isinstance(self.adapter, BaseAdapter) + + def test_name(self): + assert self.adapter.name == "mypy" + + def test_supported_policy_keys(self): + assert "config_enforcement" in self.adapter.supported_policy_keys + + def test_supported_languages(self): + assert "python" in self.adapter.supported_languages + + def test_config_targets(self): + assert ".mypy-adr.ini" in self.adapter.config_targets + + def test_output_modes(self): + assert "native_config" in self.adapter.output_modes + + def test_supported_stages(self): + assert len(self.adapter.supported_stages) > 0 + + +class TestMypyAdapterFragments: + def setup_method(self): + self.adapter = MypyAdapter() + + def test_generates_ini_fragment(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + python=PythonConfig( + mypy={"strict": True, "disallow_untyped_defs": True} + ) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert len(fragments) == 1 + frag = fragments[0] + assert isinstance(frag, ConfigFragment) + assert frag.adapter == "mypy" + assert frag.fragment_type == "ini_file" + assert frag.target_file == ".mypy-adr.ini" + + def test_fragment_contains_settings(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + python=PythonConfig(mypy={"strict": True}) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert "strict" in fragments[0].content + assert "True" in fragments[0].content + + def test_fragment_includes_policy_keys(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + python=PythonConfig( + mypy={"strict": True, "disallow_untyped_defs": True} + ) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + keys = fragments[0].policy_keys + assert "config_enforcement.python.mypy.strict" in keys + assert "config_enforcement.python.mypy.disallow_untyped_defs" in keys + + def test_empty_constraints_returns_no_fragments(self): + constraints = MergedConstraints() + fragments = self.adapter.generate_fragments(constraints) + assert fragments == [] + + def test_empty_mypy_dict_returns_no_fragments(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy(python=PythonConfig(mypy={})) + ) + fragments = self.adapter.generate_fragments(constraints) + assert fragments == [] + + +class TestMypyConfigGeneration: + def test_generates_valid_ini(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + python=PythonConfig( + mypy={"strict": True, "disallow_untyped_defs": True} + ) + ) + ) + ini_str = generate_mypy_config_from_contract(constraints) + config = configparser.ConfigParser() + config.read_string(ini_str) + assert config.has_section("mypy") + assert config.get("mypy", "strict") == "True" + assert config.get("mypy", "disallow_untyped_defs") == "True" + + def test_empty_constraints_returns_empty_string(self): + constraints = MergedConstraints() + assert generate_mypy_config_from_contract(constraints) == "" + + def test_has_header_comment(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + python=PythonConfig(mypy={"strict": True}) + ) + ) + ini_str = generate_mypy_config_from_contract(constraints) + assert "ADR Kit" in ini_str + assert "Do not edit manually" in ini_str + + +# --------------------------------------------------------------------------- +# TsconfigAdapter capability declarations +# --------------------------------------------------------------------------- + + +class TestTsconfigAdapterCapabilities: + def setup_method(self): + self.adapter = TsconfigAdapter() + + def test_is_base_adapter(self): + assert isinstance(self.adapter, BaseAdapter) + + def test_name(self): + assert self.adapter.name == "tsconfig" + + def test_supported_policy_keys(self): + assert "config_enforcement" in self.adapter.supported_policy_keys + + def test_supported_languages(self): + assert "typescript" in self.adapter.supported_languages + + def test_config_targets(self): + assert "tsconfig.adr.json" in self.adapter.config_targets + + +class TestTsconfigAdapterFragments: + def setup_method(self): + self.adapter = TsconfigAdapter() + + def test_generates_json_fragment(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig( + tsconfig={"strict": True, "noImplicitAny": True} + ) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert len(fragments) == 1 + frag = fragments[0] + assert frag.adapter == "tsconfig" + assert frag.fragment_type == "json_file" + assert frag.target_file == "tsconfig.adr.json" + + def test_fragment_contains_compiler_options(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig(tsconfig={"strict": True}) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + config = json.loads(fragments[0].content) + assert config["compilerOptions"]["strict"] is True + + def test_fragment_includes_policy_keys(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig( + tsconfig={"strict": True, "noImplicitAny": True} + ) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + keys = fragments[0].policy_keys + assert "config_enforcement.typescript.tsconfig.strict" in keys + assert "config_enforcement.typescript.tsconfig.noImplicitAny" in keys + + def test_empty_constraints_returns_no_fragments(self): + constraints = MergedConstraints() + fragments = self.adapter.generate_fragments(constraints) + assert fragments == [] + + def test_empty_tsconfig_dict_returns_no_fragments(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig(tsconfig={}) + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert fragments == [] + + +class TestTsconfigGeneration: + def test_generates_valid_json(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig( + tsconfig={"strict": True, "noImplicitAny": True} + ) + ) + ) + json_str = generate_tsconfig_from_contract(constraints) + config = json.loads(json_str) + assert "compilerOptions" in config + assert config["compilerOptions"]["strict"] is True + assert config["compilerOptions"]["noImplicitAny"] is True + + def test_has_schema_reference(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig(tsconfig={"strict": True}) + ) + ) + json_str = generate_tsconfig_from_contract(constraints) + config = json.loads(json_str) + assert "$schema" in config + + def test_has_metadata(self): + constraints = MergedConstraints( + config_enforcement=ConfigEnforcementPolicy( + typescript=TypeScriptConfig(tsconfig={"strict": True}) + ) + ) + json_str = generate_tsconfig_from_contract(constraints) + config = json.loads(json_str) + assert config["__adr_metadata"]["generated_by"] == "ADR Kit" + + def test_empty_constraints_returns_empty_string(self): + constraints = MergedConstraints() + assert generate_tsconfig_from_contract(constraints) == "" + + +# --------------------------------------------------------------------------- +# ImportLinterAdapter capability declarations +# --------------------------------------------------------------------------- + + +class TestImportLinterAdapterCapabilities: + def setup_method(self): + self.adapter = ImportLinterAdapter() + + def test_is_base_adapter(self): + assert isinstance(self.adapter, BaseAdapter) + + def test_name(self): + assert self.adapter.name == "import_linter" + + def test_supported_policy_keys(self): + assert "architecture" in self.adapter.supported_policy_keys + + def test_supported_languages(self): + assert "python" in self.adapter.supported_languages + + def test_config_targets(self): + assert ".importlinter-adr" in self.adapter.config_targets + + +class TestImportLinterAdapterFragments: + def setup_method(self): + self.adapter = ImportLinterAdapter() + + def test_generates_ini_fragment(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule(rule="ui -> database", action="block") + ] + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert len(fragments) == 1 + frag = fragments[0] + assert frag.adapter == "import_linter" + assert frag.fragment_type == "ini_file" + assert frag.target_file == ".importlinter-adr" + + def test_fragment_contains_boundary_rule(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule(rule="ui -> database", action="block") + ] + ) + ) + fragments = self.adapter.generate_fragments(constraints) + content = fragments[0].content + assert "ui" in content + assert "database" in content + assert "forbidden" in content + + def test_fragment_includes_policy_keys(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule(rule="ui -> database", action="block") + ] + ) + ) + fragments = self.adapter.generate_fragments(constraints) + keys = fragments[0].policy_keys + assert "architecture.layer_boundaries.ui -> database" in keys + + def test_multiple_boundaries(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule(rule="ui -> database", action="block"), + LayerBoundaryRule( + rule="domain -> infrastructure", + action="block", + message="Domain must not depend on infrastructure", + ), + ] + ) + ) + fragments = self.adapter.generate_fragments(constraints) + content = fragments[0].content + config = configparser.ConfigParser() + config.read_string(content) + # Should have importlinter section + 2 contract sections + contract_sections = [ + s for s in config.sections() if s.startswith("importlinter:contract:") + ] + assert len(contract_sections) == 2 + + def test_custom_message_used_as_description(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule( + rule="ui -> database", + action="block", + message="Keep UI decoupled from DB", + ) + ] + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert "Keep UI decoupled from DB" in fragments[0].content + + def test_empty_constraints_returns_no_fragments(self): + constraints = MergedConstraints() + fragments = self.adapter.generate_fragments(constraints) + assert fragments == [] + + def test_invalid_rule_format_skipped(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule(rule="invalid rule format", action="block") + ] + ) + ) + fragments = self.adapter.generate_fragments(constraints) + assert fragments == [] + + +class TestImportLinterConfigGeneration: + def test_generates_valid_ini(self): + constraints = MergedConstraints( + architecture=ArchitecturePolicy( + layer_boundaries=[ + LayerBoundaryRule(rule="ui -> database", action="block") + ] + ) + ) + ini_str = generate_import_linter_config_from_contract(constraints) + config = configparser.ConfigParser() + config.read_string(ini_str) + assert config.has_section("importlinter") + assert config.get("importlinter", "root_packages") == "src" + + def test_empty_constraints_returns_empty_string(self): + constraints = MergedConstraints() + assert generate_import_linter_config_from_contract(constraints) == "" + + +# --------------------------------------------------------------------------- +# INI conflict detection +# --------------------------------------------------------------------------- + + +class TestINIConflictDetection: + def test_no_conflict_when_no_existing_file(self, tmp_path): + from adr_kit.enforcement.conflict import ConflictDetector + + fragment = ConfigFragment( + adapter="mypy", + target_file=".mypy-adr.ini", + content="[mypy]\nstrict = True\n", + fragment_type="ini_file", + ) + detector = ConflictDetector() + conflicts = detector.detect_config_conflicts([fragment], tmp_path) + assert conflicts == [] + + def test_no_conflict_when_values_match(self, tmp_path): + from adr_kit.enforcement.conflict import ConflictDetector + + existing = tmp_path / ".mypy-adr.ini" + existing.write_text("[mypy]\nstrict = True\n") + + fragment = ConfigFragment( + adapter="mypy", + target_file=".mypy-adr.ini", + content="[mypy]\nstrict = True\n", + fragment_type="ini_file", + ) + detector = ConflictDetector() + conflicts = detector.detect_config_conflicts([fragment], tmp_path) + assert conflicts == [] + + def test_conflict_when_values_differ(self, tmp_path): + from adr_kit.enforcement.conflict import ConflictDetector + + existing = tmp_path / ".mypy-adr.ini" + existing.write_text("[mypy]\nstrict = False\n") + + fragment = ConfigFragment( + adapter="mypy", + target_file=".mypy-adr.ini", + content="[mypy]\nstrict = True\n", + fragment_type="ini_file", + ) + detector = ConflictDetector() + conflicts = detector.detect_config_conflicts([fragment], tmp_path) + assert len(conflicts) == 1 + assert "strict" in conflicts[0].description + + def test_no_conflict_for_new_keys(self, tmp_path): + from adr_kit.enforcement.conflict import ConflictDetector + + existing = tmp_path / ".mypy-adr.ini" + existing.write_text("[mypy]\nstrict = True\n") + + fragment = ConfigFragment( + adapter="mypy", + target_file=".mypy-adr.ini", + content="[mypy]\nstrict = True\ndisallow_untyped_defs = True\n", + fragment_type="ini_file", + ) + detector = ConflictDetector() + conflicts = detector.detect_config_conflicts([fragment], tmp_path) + assert conflicts == [] + + +# --------------------------------------------------------------------------- +# Router integration: new adapters selected by policy key + stack +# --------------------------------------------------------------------------- + + +class TestRouterWithNewAdapters: + def setup_method(self): + from adr_kit.enforcement.adapters.eslint import ESLintAdapter + from adr_kit.enforcement.adapters.ruff import RuffAdapter + + self.router = PolicyRouter( + [ + ESLintAdapter(), + RuffAdapter(), + MypyAdapter(), + TsconfigAdapter(), + ImportLinterAdapter(), + ] + ) + + def test_python_config_enforcement_routes_mypy(self, tmp_path): + contract = _make_contract(mypy={"strict": True}, tmp_path=tmp_path) + decisions, _ = self.router.route(contract, ["python"]) + adapter_names = [d.adapter.name for d in decisions] + assert "mypy" in adapter_names + + def test_typescript_config_enforcement_routes_tsconfig(self, tmp_path): + contract = _make_contract(tsconfig={"strict": True}, tmp_path=tmp_path) + decisions, _ = self.router.route(contract, ["typescript"]) + adapter_names = [d.adapter.name for d in decisions] + assert "tsconfig" in adapter_names + + def test_architecture_boundaries_routes_import_linter(self, tmp_path): + contract = _make_contract( + layer_boundaries=[LayerBoundaryRule(rule="ui -> database", action="block")], + tmp_path=tmp_path, + ) + decisions, _ = self.router.route(contract, ["python"]) + adapter_names = [d.adapter.name for d in decisions] + assert "import_linter" in adapter_names + + def test_mypy_not_routed_for_js_stack(self, tmp_path): + contract = _make_contract(mypy={"strict": True}, tmp_path=tmp_path) + decisions, _ = self.router.route(contract, ["javascript"]) + adapter_names = [d.adapter.name for d in decisions] + assert "mypy" not in adapter_names + + def test_tsconfig_not_routed_for_python_stack(self, tmp_path): + contract = _make_contract(tsconfig={"strict": True}, tmp_path=tmp_path) + decisions, _ = self.router.route(contract, ["python"]) + adapter_names = [d.adapter.name for d in decisions] + assert "tsconfig" not in adapter_names + + def test_import_linter_not_routed_for_js_stack(self, tmp_path): + contract = _make_contract( + layer_boundaries=[LayerBoundaryRule(rule="ui -> database", action="block")], + tmp_path=tmp_path, + ) + decisions, _ = self.router.route(contract, ["javascript"]) + adapter_names = [d.adapter.name for d in decisions] + assert "import_linter" not in adapter_names + + +# --------------------------------------------------------------------------- +# Pipeline integration +# --------------------------------------------------------------------------- + + +class TestPipelineWithNewAdapters: + def _make_pipeline(self, tmp_path): + from adr_kit.enforcement.pipeline import EnforcementPipeline + + return EnforcementPipeline(adr_dir=tmp_path, project_path=tmp_path) + + def test_mypy_config_written_to_disk(self, tmp_path): + contract = _make_contract( + mypy={"strict": True, "disallow_untyped_defs": True}, + tmp_path=tmp_path, + ) + pipeline = self._make_pipeline(tmp_path) + result = pipeline.compile(contract=contract, detected_stack=["python"]) + + applied = {f.adapter for f in result.fragments_applied} + assert "mypy" in applied + assert (tmp_path / ".mypy-adr.ini").exists() + + # Verify content + content = (tmp_path / ".mypy-adr.ini").read_text() + assert "strict" in content + + def test_tsconfig_written_to_disk(self, tmp_path): + contract = _make_contract( + tsconfig={"strict": True, "noImplicitAny": True}, + tmp_path=tmp_path, + ) + pipeline = self._make_pipeline(tmp_path) + result = pipeline.compile(contract=contract, detected_stack=["typescript"]) + + applied = {f.adapter for f in result.fragments_applied} + assert "tsconfig" in applied + assert (tmp_path / "tsconfig.adr.json").exists() + + # Verify content + config = json.loads((tmp_path / "tsconfig.adr.json").read_text()) + assert config["compilerOptions"]["strict"] is True + + def test_import_linter_config_written_to_disk(self, tmp_path): + contract = _make_contract( + layer_boundaries=[LayerBoundaryRule(rule="ui -> database", action="block")], + tmp_path=tmp_path, + ) + pipeline = self._make_pipeline(tmp_path) + result = pipeline.compile(contract=contract, detected_stack=["python"]) + + applied = {f.adapter for f in result.fragments_applied} + assert "import_linter" in applied + assert (tmp_path / ".importlinter-adr").exists() + + def test_mixed_stack_routes_correct_adapters(self, tmp_path): + """Python + TypeScript stack with config_enforcement routes both mypy and tsconfig.""" + contract = _make_contract( + mypy={"strict": True}, + tsconfig={"strict": True}, + tmp_path=tmp_path, + ) + pipeline = self._make_pipeline(tmp_path) + result = pipeline.compile( + contract=contract, detected_stack=["python", "typescript"] + ) + + applied = {f.adapter for f in result.fragments_applied} + assert "mypy" in applied + assert "tsconfig" in applied + + def test_pipeline_idempotent_with_new_adapters(self, tmp_path): + contract = _make_contract( + mypy={"strict": True}, + layer_boundaries=[LayerBoundaryRule(rule="ui -> database", action="block")], + tmp_path=tmp_path, + ) + pipeline = self._make_pipeline(tmp_path) + r1 = pipeline.compile(contract=contract, detected_stack=["python"]) + r2 = pipeline.compile(contract=contract, detected_stack=["python"]) + assert r1.idempotency_hash == r2.idempotency_hash + + def test_no_config_enforcement_skips_new_adapters(self, tmp_path): + contract = _make_contract(disallow_imports=["axios"], tmp_path=tmp_path) + pipeline = self._make_pipeline(tmp_path) + result = pipeline.compile(contract=contract, detected_stack=["python"]) + + applied = {f.adapter for f in result.fragments_applied} + assert "mypy" not in applied + assert "tsconfig" not in applied + assert "import_linter" not in applied