Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions adr_kit/enforcement/adapters/import_linter.py
Original file line number Diff line number Diff line change
@@ -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,
)
]
114 changes: 114 additions & 0 deletions adr_kit/enforcement/adapters/mypy.py
Original file line number Diff line number Diff line change
@@ -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,
)
]
111 changes: 111 additions & 0 deletions adr_kit/enforcement/adapters/tsconfig.py
Original file line number Diff line number Diff line change
@@ -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,
)
]
Loading
Loading