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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `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`
- Import-linter enforcement adapter — approving an ADR with `architecture.layer_boundaries` constraints automatically generates an `.importlinter-adr` configuration file for Python architectural boundary enforcement
Expand Down
28 changes: 26 additions & 2 deletions adr_kit/contract/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,24 @@ def build_contract(self, force_rebuild: bool = False) -> ConstraintsContract:
return empty_contract

# Parse and filter to only accepted ADRs
all_adrs = []
accepted_adrs = []
for file_path in adr_files:
try:
adr = parse_adr_file(file_path, strict=False)
if adr and adr.front_matter.status == ADRStatus.ACCEPTED:
accepted_adrs.append(adr)
if adr:
all_adrs.append(adr)
if adr.front_matter.status == ADRStatus.ACCEPTED:
accepted_adrs.append(adr)
except ParseError as e:
print(f"Warning: Skipping malformed ADR {file_path}: {e}")
continue

# Warn on depends_on/related_to references that don't resolve
all_ids = {a.front_matter.id for a in all_adrs}
for warning in self._validate_relation_references(accepted_adrs, all_ids):
print(f"Warning: {warning}")

if not accepted_adrs:
# No accepted ADRs, return empty contract
empty_contract = ConstraintsContract.create_empty(self.adr_dir)
Expand Down Expand Up @@ -100,6 +108,22 @@ def build(self, force_rebuild: bool = False) -> ConstraintsContract:
"""Alias for build_contract() for compatibility with existing workflows."""
return self.build_contract(force_rebuild=force_rebuild)

def _validate_relation_references(self, adrs: list, all_ids: set) -> list:
"""Return warning strings for depends_on/related_to refs to unknown ADR IDs."""
warnings = []
for adr in adrs:
fm = adr.front_matter
for field_name, refs in [
("depends_on", fm.depends_on or []),
("related_to", fm.related_to or []),
]:
for ref in refs:
if ref not in all_ids:
warnings.append(
f"{fm.id}: {field_name} references unknown ADR '{ref}'"
)
return warnings

def get_contract_summary(self) -> dict:
"""Get a summary of the current contract state."""
try:
Expand Down
16 changes: 15 additions & 1 deletion adr_kit/core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,25 @@ class ADRFrontMatter(BaseModel):
superseded_by: list[str] | None = Field(
None, description="List of ADR IDs that supersede this one"
)
depends_on: list[str] | None = Field(
None, description="ADR IDs this decision depends on"
)
related_to: list[str] | None = Field(
None, description="ADR IDs with a non-hierarchical relationship to this one"
)
policy: PolicyModel | None = Field(
None, description="Structured policy for enforcement"
)

@field_validator("deciders", "tags", "supersedes", "superseded_by", mode="before")
@field_validator(
"deciders",
"tags",
"supersedes",
"superseded_by",
"depends_on",
"related_to",
mode="before",
)
@classmethod
def ensure_list_or_none(cls, v: Any) -> list[str] | None:
"""Ensure array fields are lists or None, not empty lists."""
Expand Down
2 changes: 2 additions & 0 deletions adr_kit/decision/workflows/creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ def _build_adr_structure(self, adr_id: str, input_data: CreationInput) -> ADR:
tags=input_data.tags or [],
supersedes=[],
superseded_by=[],
depends_on=[],
related_to=[],
policy=(
PolicyModel.model_validate(input_data.policy)
if input_data.policy
Expand Down
12 changes: 12 additions & 0 deletions schemas/adr.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@
"type": "string"
}
},
"depends_on": {
"type": "array",
"items": {
"type": "string"
}
},
"related_to": {
"type": "array",
"items": {
"type": "string"
}
},
"policy": {
"type": "object",
"properties": {
Expand Down
45 changes: 45 additions & 0 deletions tests/unit/test_adr_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Unit tests for schemas/adr.schema.json — relation fields."""

import json
from pathlib import Path

import jsonschema
import pytest

SCHEMA_PATH = Path(__file__).parents[2] / "schemas" / "adr.schema.json"
SCHEMA = json.loads(SCHEMA_PATH.read_text())


def _minimal_adr(**extra: object) -> dict:
return {
"id": "ADR-0001",
"title": "Use FastAPI",
"status": "accepted",
"date": "2024-01-01",
**extra,
}


class TestRelationFieldsInSchema:
def test_depends_on_valid(self) -> None:
jsonschema.validate(_minimal_adr(depends_on=["ADR-0002"]), SCHEMA)

def test_related_to_valid(self) -> None:
jsonschema.validate(_minimal_adr(related_to=["ADR-0003"]), SCHEMA)

def test_both_fields_valid(self) -> None:
jsonschema.validate(
_minimal_adr(depends_on=["ADR-0001"], related_to=["ADR-0002", "ADR-0003"]),
SCHEMA,
)

def test_depends_on_wrong_type_fails(self) -> None:
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(_minimal_adr(depends_on="ADR-0002"), SCHEMA)

def test_related_to_wrong_type_fails(self) -> None:
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(_minimal_adr(related_to="ADR-0003"), SCHEMA)

def test_adr_without_relation_fields_valid(self) -> None:
jsonschema.validate(_minimal_adr(), SCHEMA)
91 changes: 91 additions & 0 deletions tests/unit/test_contract_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Unit tests for ConstraintsContractBuilder — relation reference validation."""

from datetime import date
from pathlib import Path

import pytest

from adr_kit.contract.builder import ConstraintsContractBuilder
from adr_kit.core.model import ADR, ADRFrontMatter, ADRStatus


def _make_adr(
adr_id: str,
depends_on: list[str] | None = None,
related_to: list[str] | None = None,
status: ADRStatus = ADRStatus.ACCEPTED,
) -> ADR:
fm = ADRFrontMatter(
id=adr_id,
title=f"ADR {adr_id}",
status=status,
date=date(2024, 1, 1),
depends_on=depends_on,
related_to=related_to,
)
return ADR(front_matter=fm, content="## Decision\nContent.")


class TestRelationReferenceValidation:
@pytest.fixture()
def builder(self, tmp_path: Path) -> ConstraintsContractBuilder:
return ConstraintsContractBuilder(adr_dir=tmp_path)

def test_no_warnings_when_refs_resolve(
self, builder: ConstraintsContractBuilder
) -> None:
adrs = [
_make_adr("ADR-0001"),
_make_adr("ADR-0002", depends_on=["ADR-0001"]),
]
all_ids = {a.front_matter.id for a in adrs}
warnings = builder._validate_relation_references(adrs, all_ids)
assert warnings == []

def test_warning_for_unknown_depends_on(
self, builder: ConstraintsContractBuilder
) -> None:
adrs = [_make_adr("ADR-0001", depends_on=["ADR-9999"])]
all_ids = {"ADR-0001"}
warnings = builder._validate_relation_references(adrs, all_ids)
assert len(warnings) == 1
assert "ADR-9999" in warnings[0]
assert "depends_on" in warnings[0]
assert "ADR-0001" in warnings[0]

def test_warning_for_unknown_related_to(
self, builder: ConstraintsContractBuilder
) -> None:
adrs = [_make_adr("ADR-0001", related_to=["ADR-9999"])]
all_ids = {"ADR-0001"}
warnings = builder._validate_relation_references(adrs, all_ids)
assert len(warnings) == 1
assert "ADR-9999" in warnings[0]
assert "related_to" in warnings[0]

def test_none_fields_produce_no_warnings(
self, builder: ConstraintsContractBuilder
) -> None:
adrs = [_make_adr("ADR-0001")]
all_ids = {"ADR-0001"}
warnings = builder._validate_relation_references(adrs, all_ids)
assert warnings == []

def test_multiple_unknown_refs_each_warn(
self, builder: ConstraintsContractBuilder
) -> None:
adrs = [_make_adr("ADR-0001", depends_on=["ADR-8888", "ADR-9999"])]
all_ids = {"ADR-0001"}
warnings = builder._validate_relation_references(adrs, all_ids)
assert len(warnings) == 2

def test_proposed_adr_ref_resolves_via_all_ids(
self, builder: ConstraintsContractBuilder
) -> None:
"""depends_on referencing a proposed (not accepted) ADR should not warn."""
proposed = _make_adr("ADR-0001", status=ADRStatus.PROPOSED)
accepted = _make_adr("ADR-0002", depends_on=["ADR-0001"])
# all_ids includes all parsed ADRs, not just accepted ones
all_ids = {"ADR-0001", "ADR-0002"}
warnings = builder._validate_relation_references([accepted], all_ids)
assert warnings == []
15 changes: 15 additions & 0 deletions tests/unit/test_enforcement_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ def test_provenance_has_clause_id_via_merger(self):
assert len(prov.clause_id) == 12
assert prov.clause_id != ""

def test_clause_id_survives_exclude_none_serialization(self):
"""clause_id must survive model_dump(exclude_none=True) used by to_json_file."""
prov = PolicyProvenance(
adr_id="ADR-0001",
adr_title="Test",
rule_path="imports.disallow.axios",
effective_date=datetime(2024, 1, 1, tzinfo=timezone.utc),
clause_id=PolicyProvenance.make_clause_id(
"ADR-0001", "imports.disallow.axios"
),
)
dumped = prov.model_dump(exclude_none=True)
assert "clause_id" in dumped
assert len(dumped["clause_id"]) == 12


# ---------------------------------------------------------------------------
# Topological sort tests
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/test_policy_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,5 +377,59 @@ def test_has_extractable_policy_with_new_types(self):
assert extractor.has_extractable_policy(adr_config) is True


class TestRelationFields:
"""Test depends_on and related_to fields on ADRFrontMatter."""

def _make_fm(self, **kwargs: object) -> ADRFrontMatter:
from datetime import date

return ADRFrontMatter(
id="ADR-0002",
title="Test ADR",
status=ADRStatus.ACCEPTED,
date=date(2024, 1, 1),
**kwargs,
)

def test_depends_on_accepted(self) -> None:
fm = self._make_fm(depends_on=["ADR-0001"])
assert fm.depends_on == ["ADR-0001"]

def test_related_to_accepted(self) -> None:
fm = self._make_fm(related_to=["ADR-0003"])
assert fm.related_to == ["ADR-0003"]

def test_empty_list_normalised_to_none(self) -> None:
fm = self._make_fm(depends_on=[], related_to=[])
assert fm.depends_on is None
assert fm.related_to is None

def test_omitted_fields_are_none(self) -> None:
fm = self._make_fm()
assert fm.depends_on is None
assert fm.related_to is None

def test_multiple_ids_accepted(self) -> None:
fm = self._make_fm(depends_on=["ADR-0001", "ADR-0003"])
assert len(fm.depends_on) == 2 # type: ignore[arg-type]

def test_both_fields_together(self) -> None:
fm = self._make_fm(depends_on=["ADR-0001"], related_to=["ADR-0003", "ADR-0004"])
assert fm.depends_on == ["ADR-0001"]
assert fm.related_to == ["ADR-0003", "ADR-0004"]

def test_relation_fields_excluded_from_dump_when_none(self) -> None:
fm = self._make_fm()
dumped = fm.model_dump(exclude_none=True)
assert "depends_on" not in dumped
assert "related_to" not in dumped

def test_relation_fields_in_dump_when_set(self) -> None:
fm = self._make_fm(depends_on=["ADR-0001"])
dumped = fm.model_dump(exclude_none=True)
assert "depends_on" in dumped
assert dumped["depends_on"] == ["ADR-0001"]


if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading