diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0bdaa..3acb65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/adr_kit/contract/builder.py b/adr_kit/contract/builder.py index ae1287f..fae8b6f 100644 --- a/adr_kit/contract/builder.py +++ b/adr_kit/contract/builder.py @@ -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) @@ -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: diff --git a/adr_kit/core/model.py b/adr_kit/core/model.py index 478cfb0..50f90b2 100644 --- a/adr_kit/core/model.py +++ b/adr_kit/core/model.py @@ -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.""" diff --git a/adr_kit/decision/workflows/creation.py b/adr_kit/decision/workflows/creation.py index 69657f1..036c30c 100644 --- a/adr_kit/decision/workflows/creation.py +++ b/adr_kit/decision/workflows/creation.py @@ -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 diff --git a/schemas/adr.schema.json b/schemas/adr.schema.json index e716457..38292c1 100644 --- a/schemas/adr.schema.json +++ b/schemas/adr.schema.json @@ -53,6 +53,18 @@ "type": "string" } }, + "depends_on": { + "type": "array", + "items": { + "type": "string" + } + }, + "related_to": { + "type": "array", + "items": { + "type": "string" + } + }, "policy": { "type": "object", "properties": { diff --git a/tests/unit/test_adr_schema.py b/tests/unit/test_adr_schema.py new file mode 100644 index 0000000..4ab7bc3 --- /dev/null +++ b/tests/unit/test_adr_schema.py @@ -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) diff --git a/tests/unit/test_contract_builder.py b/tests/unit/test_contract_builder.py new file mode 100644 index 0000000..bf2e9b2 --- /dev/null +++ b/tests/unit/test_contract_builder.py @@ -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 == [] diff --git a/tests/unit/test_enforcement_pipeline.py b/tests/unit/test_enforcement_pipeline.py index 51102c7..de9b7ab 100644 --- a/tests/unit/test_enforcement_pipeline.py +++ b/tests/unit/test_enforcement_pipeline.py @@ -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 diff --git a/tests/unit/test_policy_validation.py b/tests/unit/test_policy_validation.py index 7020997..e276ad9 100644 --- a/tests/unit/test_policy_validation.py +++ b/tests/unit/test_policy_validation.py @@ -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"])