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
14 changes: 12 additions & 2 deletions adr_kit/decision/workflows/creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions adr_kit/enforcement/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []

Expand Down
3 changes: 2 additions & 1 deletion adr_kit/enforcement/adapters/eslint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion adr_kit/enforcement/adapters/import_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ...contract.models import MergedConstraints
from ...core.model import LayerBoundaryRule
from ..clause_kinds import ClauseKind
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion adr_kit/enforcement/adapters/mypy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

from ...contract.models import MergedConstraints
from ..clause_kinds import ClauseKind
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion adr_kit/enforcement/adapters/ruff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion adr_kit/enforcement/adapters/tsconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

from ...contract.models import MergedConstraints
from ..clause_kinds import ClauseKind
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -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]:
Expand Down
56 changes: 56 additions & 0 deletions adr_kit/enforcement/clause_kinds.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions adr_kit/enforcement/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from ..contract.builder import ConstraintsContractBuilder
from ..contract.models import ConstraintsContract
from .clause_kinds import classify_policy_rule


class AppliedFragment(BaseModel):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
18 changes: 17 additions & 1 deletion adr_kit/enforcement/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ..contract.models import ConstraintsContract, MergedConstraints
from .adapters.base import BaseAdapter
from .clause_kinds import classify_policy_rule


@dataclass
Expand Down Expand Up @@ -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),
)
Expand Down
Loading
Loading