From f3ae8180f83d085ee64cf3aada712a666bec2198 Mon Sep 17 00:00:00 2001 From: kschlt Date: Sat, 4 Apr 2026 17:32:03 +0200 Subject: [PATCH 1/3] feat(contract): add ContractRelations model and wire into ConstraintsContract First of three steps to make the architecture contract relationship-aware. ContractRelations holds forward indexes (depends_on, related_to, supersedes), their reverse counterparts, derived supersession_chains, and clause lookup tables (clause_to_adr, adr_to_clauses). The relations field on ConstraintsContract uses default_factory so all existing construction sites are unaffected. The field is excluded from the content hash because it is computed state, not authored policy. A separate model was preferred over inline embedding for clarity and testability. --- adr_kit/contract/models.py | 66 ++++++++++++ tests/unit/test_contract_relations_model.py | 105 ++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 tests/unit/test_contract_relations_model.py diff --git a/adr_kit/contract/models.py b/adr_kit/contract/models.py index 2ec3ec4..4a467a3 100644 --- a/adr_kit/contract/models.py +++ b/adr_kit/contract/models.py @@ -110,6 +110,65 @@ def is_empty(self) -> bool: ) +class ContractRelations(BaseModel): + """Computed relationship indexes derived from ADR frontmatter at build time. + + All forward indexes are keyed by ADR ID. Reverse indexes are computed + automatically from forward declarations. Unresolved references (IDs not found + in the parsed corpus) are silently dropped — they have already been surfaced + as warnings during _validate_relation_references. + + Not included in the contract content hash (computed state, not authored policy). + """ + + # Forward indexes + depends_on: dict[str, list[str]] = Field( + default_factory=dict, + description="Maps ADR-ID → list of ADR-IDs it explicitly depends on", + ) + related_to: dict[str, list[str]] = Field( + default_factory=dict, + description="Maps ADR-ID → list of ADR-IDs it is related to", + ) + supersedes: dict[str, list[str]] = Field( + default_factory=dict, + description="Maps ADR-ID → list of (older) ADR-IDs it supersedes", + ) + + # Reverse indexes + required_by: dict[str, list[str]] = Field( + default_factory=dict, + description="Reverse of depends_on: ADR-ID → list of ADRs that depend on it", + ) + related_from: dict[str, list[str]] = Field( + default_factory=dict, + description="Reverse of related_to: ADR-ID → list of ADRs that relate to it", + ) + superseded_by: dict[str, list[str]] = Field( + default_factory=dict, + description="Reverse of supersedes: ADR-ID → list of (newer) ADRs that supersede it", + ) + + # Derived supersession lineage + supersession_chains: list[list[str]] = Field( + default_factory=list, + description=( + "Each entry is a chronological chain [oldest, ..., newest] " + "tracing a complete supersession lineage" + ), + ) + + # Clause lookup tables (derived from contract provenance) + clause_to_adr: dict[str, str] = Field( + default_factory=dict, + description="Maps clause_id → adr_id for direct clause lookup", + ) + adr_to_clauses: dict[str, list[str]] = Field( + default_factory=dict, + description="Maps adr_id → [clause_ids] for all clauses from that ADR", + ) + + class ConstraintsContract(BaseModel): """The complete constraints contract - the definitive source of truth for agents. @@ -130,6 +189,13 @@ class ConstraintsContract(BaseModel): default_factory=list, description="List of all approved ADRs that contributed to this contract", ) + relations: "ContractRelations" = Field( + default_factory=ContractRelations, + description=( + "Computed relationship indexes (not included in content hash). " + "Built at compile time from ADR frontmatter." + ), + ) @classmethod def create_empty(cls, adr_directory: Path) -> "ConstraintsContract": diff --git a/tests/unit/test_contract_relations_model.py b/tests/unit/test_contract_relations_model.py new file mode 100644 index 0000000..443404f --- /dev/null +++ b/tests/unit/test_contract_relations_model.py @@ -0,0 +1,105 @@ +"""Unit tests for ContractRelations model and its integration into ConstraintsContract.""" + +from pathlib import Path + +from adr_kit.contract.models import ( + ConstraintsContract, + ContractRelations, + MergedConstraints, +) + + +class TestContractRelationsDefaults: + def test_all_fields_default_to_empty(self) -> None: + rel = ContractRelations() + assert rel.depends_on == {} + assert rel.related_to == {} + assert rel.supersedes == {} + assert rel.required_by == {} + assert rel.related_from == {} + assert rel.superseded_by == {} + assert rel.supersession_chains == [] + assert rel.clause_to_adr == {} + assert rel.adr_to_clauses == {} + + def test_round_trip_serialization(self) -> None: + rel = ContractRelations( + depends_on={"ADR-0002": ["ADR-0001"]}, + required_by={"ADR-0001": ["ADR-0002"]}, + related_to={"ADR-0003": ["ADR-0004"]}, + related_from={"ADR-0004": ["ADR-0003"]}, + supersedes={"ADR-0005": ["ADR-0004"]}, + superseded_by={"ADR-0004": ["ADR-0005"]}, + supersession_chains=[["ADR-0004", "ADR-0005"]], + clause_to_adr={"abc123def456": "ADR-0001"}, + adr_to_clauses={"ADR-0001": ["abc123def456"]}, + ) + dumped = rel.model_dump() + restored = ContractRelations.model_validate(dumped) + + assert restored.depends_on == {"ADR-0002": ["ADR-0001"]} + assert restored.required_by == {"ADR-0001": ["ADR-0002"]} + assert restored.related_to == {"ADR-0003": ["ADR-0004"]} + assert restored.related_from == {"ADR-0004": ["ADR-0003"]} + assert restored.supersedes == {"ADR-0005": ["ADR-0004"]} + assert restored.superseded_by == {"ADR-0004": ["ADR-0005"]} + assert restored.supersession_chains == [["ADR-0004", "ADR-0005"]] + assert restored.clause_to_adr == {"abc123def456": "ADR-0001"} + assert restored.adr_to_clauses == {"ADR-0001": ["abc123def456"]} + + +class TestConstraintsContractRelationsField: + def test_create_empty_has_relations_attribute(self) -> None: + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + assert hasattr(contract, "relations") + assert isinstance(contract.relations, ContractRelations) + + def test_create_empty_relations_are_empty(self) -> None: + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + assert contract.relations.depends_on == {} + assert contract.relations.supersession_chains == [] + + def test_relations_excluded_from_content_hash(self) -> None: + """Populating relations must not change the contract content hash.""" + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + hash_without_relations = contract.calculate_content_hash() + + contract.relations = ContractRelations( + depends_on={"ADR-0002": ["ADR-0001"]}, + required_by={"ADR-0001": ["ADR-0002"]}, + ) + hash_with_relations = contract.calculate_content_hash() + + assert hash_without_relations == hash_with_relations + + def test_relations_default_factory_per_instance(self) -> None: + """Each ConstraintsContract instance must have its own ContractRelations.""" + a = ConstraintsContract.create_empty(Path("/tmp/adrs")) + b = ConstraintsContract.create_empty(Path("/tmp/adrs")) + a.relations.depends_on["ADR-0001"] = ["ADR-0002"] + assert "ADR-0001" not in b.relations.depends_on + + def test_contract_with_populated_relations_round_trips(self) -> None: + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + contract.relations = ContractRelations( + supersedes={"ADR-0002": ["ADR-0001"]}, + superseded_by={"ADR-0001": ["ADR-0002"]}, + supersession_chains=[["ADR-0001", "ADR-0002"]], + ) + dumped = contract.model_dump() + restored = ConstraintsContract.model_validate(dumped) + assert restored.relations.supersedes == {"ADR-0002": ["ADR-0001"]} + assert restored.relations.supersession_chains == [["ADR-0001", "ADR-0002"]] + + def test_empty_dicts_not_excluded_by_exclude_none(self) -> None: + """Empty dicts are not None, so they appear in model_dump(exclude_none=True).""" + rel = ContractRelations() + dumped = rel.model_dump(exclude_none=True) + # Empty dicts should still be present (they are not None) + assert "depends_on" in dumped + assert dumped["depends_on"] == {} + + def test_constraints_contract_has_relations_field_in_dump(self) -> None: + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + dumped = contract.model_dump() + assert "relations" in dumped From 0e78828c1bf532edef41ce2681a509f0a60302d6 Mon Sep 17 00:00:00 2001 From: kschlt Date: Sat, 4 Apr 2026 17:34:47 +0200 Subject: [PATCH 2/3] feat(contract): compute relation indexes in ConstraintsContractBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements _compute_relations so the compiled ConstraintsContract now carries forward indexes (depends_on, related_to, supersedes), their reverses (required_by, related_from, superseded_by), supersession chains, and clause lookup tables (clause_to_adr, adr_to_clauses). All parsed ADRs (not just accepted ones) are used so that edges to proposed or deprecated ADRs are preserved — consistent with how _validate_relation_references already uses the full corpus. Unresolved references are silently dropped (already warned earlier in build_contract). Supersession chains are built from newest root backward then reversed to read oldest→newest; a visited set prevents infinite loops on circular edges. --- adr_kit/contract/builder.py | 115 +++++++++++++++++++++++- tests/unit/test_contract_builder.py | 132 +++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/adr_kit/contract/builder.py b/adr_kit/contract/builder.py index fae8b6f..8191e2e 100644 --- a/adr_kit/contract/builder.py +++ b/adr_kit/contract/builder.py @@ -10,7 +10,7 @@ from ..core.parse import ParseError, find_adr_files, parse_adr_file from .cache import ContractCache from .merger import PolicyMerger -from .models import ConstraintsContract, ContractMetadata +from .models import ConstraintsContract, ContractMetadata, ContractRelations class ConstraintsContractBuilder: @@ -83,6 +83,9 @@ def build_contract(self, force_rebuild: bool = False) -> ConstraintsContract: f"{[c.description for c in merge_result.conflicts if c.resolution is None]}" ) + # Compute relationship indexes from all parsed ADRs + relations = self._compute_relations(all_adrs, merge_result.provenance) + # Create contract metadata metadata = ContractMetadata( version="1.0", # Explicit default for mypy @@ -97,6 +100,7 @@ def build_contract(self, force_rebuild: bool = False) -> ConstraintsContract: constraints=merge_result.constraints, provenance=merge_result.provenance, approved_adrs=accepted_adrs, + relations=relations, ) # Cache the contract @@ -124,6 +128,115 @@ def _validate_relation_references(self, adrs: list, all_ids: set) -> list: ) return warnings + def _compute_relations(self, all_adrs: list, provenance: dict) -> ContractRelations: + """Compute forward and reverse relationship indexes from ADR frontmatter. + + Uses all parsed ADRs (not only accepted) so that edges to proposed or + deprecated ADRs are still recorded. Unresolved references are silently + dropped — they have already been warned about by _validate_relation_references. + + Also derives clause lookup tables from the contract provenance mapping. + """ + all_ids: set[str] = {a.front_matter.id for a in all_adrs} + + depends_on: dict[str, list[str]] = {} + related_to: dict[str, list[str]] = {} + supersedes_fwd: dict[str, list[str]] = {} + required_by: dict[str, list[str]] = {} + related_from: dict[str, list[str]] = {} + superseded_by_rev: dict[str, list[str]] = {} + + for adr in all_adrs: + src = adr.front_matter.id + fm = adr.front_matter + + # depends_on / required_by (reverse) + resolved_deps = [r for r in (fm.depends_on or []) if r in all_ids] + if resolved_deps: + depends_on[src] = resolved_deps + for dep in resolved_deps: + if src not in required_by.setdefault(dep, []): + required_by[dep].append(src) + + # related_to / related_from (reverse, symmetric) + resolved_rel = [r for r in (fm.related_to or []) if r in all_ids] + if resolved_rel: + related_to[src] = resolved_rel + for rel in resolved_rel: + if src not in related_from.setdefault(rel, []): + related_from[rel].append(src) + + # supersedes / superseded_by (reverse) + resolved_sup = [r for r in (fm.supersedes or []) if r in all_ids] + if resolved_sup: + supersedes_fwd[src] = resolved_sup + for old in resolved_sup: + if src not in superseded_by_rev.setdefault(old, []): + superseded_by_rev[old].append(src) + + chains = self._build_supersession_chains(supersedes_fwd, superseded_by_rev) + + # Build clause lookup tables from provenance + clause_to_adr: dict[str, str] = {} + adr_to_clauses: dict[str, list[str]] = {} + for prov in provenance.values(): + clause_to_adr[prov.clause_id] = prov.adr_id + if prov.clause_id not in adr_to_clauses.setdefault(prov.adr_id, []): + adr_to_clauses[prov.adr_id].append(prov.clause_id) + + return ContractRelations( + depends_on=depends_on, + related_to=related_to, + supersedes=supersedes_fwd, + required_by=required_by, + related_from=related_from, + superseded_by=superseded_by_rev, + supersession_chains=chains, + clause_to_adr=clause_to_adr, + adr_to_clauses=adr_to_clauses, + ) + + def _build_supersession_chains( + self, + supersedes_fwd: dict[str, list[str]], + superseded_by_rev: dict[str, list[str]], + ) -> list[list[str]]: + """Walk supersession edges to build full lineage chains (oldest → newest). + + A chain starts at the newest ADR in a lineage (one that supersedes something + but is not itself superseded) and follows the supersedes edges backward to the + oldest. The chain is then reversed so it reads oldest-to-newest. + + Circular references are broken by a visited set — nodes in a cycle produce + a truncated chain rather than an infinite loop. + """ + # Roots: ADRs that supersede something but are not themselves superseded + all_superseding = set(supersedes_fwd.keys()) + has_predecessor = set(superseded_by_rev.keys()) + roots = all_superseding - has_predecessor + + chains: list[list[str]] = [] + for root in sorted(roots): + chain: list[str] = [] + visited: set[str] = set() + current: str | None = root + while current and current not in visited: + visited.add(current) + chain.append(current) + nexts = supersedes_fwd.get(current, []) + if len(nexts) == 1: + current = nexts[0] + else: + # Fan-out: append all unvisited targets and stop + chain.extend(n for n in nexts if n not in visited) + break + # Reverse so chain reads oldest → newest + chain.reverse() + if len(chain) > 1: + chains.append(chain) + + return chains + def get_contract_summary(self) -> dict: """Get a summary of the current contract state.""" try: diff --git a/tests/unit/test_contract_builder.py b/tests/unit/test_contract_builder.py index bf2e9b2..fead258 100644 --- a/tests/unit/test_contract_builder.py +++ b/tests/unit/test_contract_builder.py @@ -1,4 +1,4 @@ -"""Unit tests for ConstraintsContractBuilder — relation reference validation.""" +"""Unit tests for ConstraintsContractBuilder — relation reference validation and computation.""" from datetime import date from pathlib import Path @@ -13,6 +13,7 @@ def _make_adr( adr_id: str, depends_on: list[str] | None = None, related_to: list[str] | None = None, + supersedes: list[str] | None = None, status: ADRStatus = ADRStatus.ACCEPTED, ) -> ADR: fm = ADRFrontMatter( @@ -22,6 +23,7 @@ def _make_adr( date=date(2024, 1, 1), depends_on=depends_on, related_to=related_to, + supersedes=supersedes, ) return ADR(front_matter=fm, content="## Decision\nContent.") @@ -89,3 +91,131 @@ def test_proposed_adr_ref_resolves_via_all_ids( all_ids = {"ADR-0001", "ADR-0002"} warnings = builder._validate_relation_references([accepted], all_ids) assert warnings == [] + + +class TestComputeRelations: + @pytest.fixture() + def builder(self, tmp_path: Path) -> ConstraintsContractBuilder: + return ConstraintsContractBuilder(adr_dir=tmp_path) + + def test_empty_corpus_returns_empty_relations( + self, builder: ConstraintsContractBuilder + ) -> None: + rel = builder._compute_relations([], {}) + assert rel.depends_on == {} + assert rel.required_by == {} + assert rel.related_to == {} + assert rel.related_from == {} + assert rel.supersedes == {} + assert rel.superseded_by == {} + assert rel.supersession_chains == [] + assert rel.clause_to_adr == {} + assert rel.adr_to_clauses == {} + + def test_depends_on_forward_index( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001"), _make_adr("ADR-0002", depends_on=["ADR-0001"])] + rel = builder._compute_relations(adrs, {}) + assert rel.depends_on == {"ADR-0002": ["ADR-0001"]} + + def test_required_by_reverse_index( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001"), _make_adr("ADR-0002", depends_on=["ADR-0001"])] + rel = builder._compute_relations(adrs, {}) + assert rel.required_by == {"ADR-0001": ["ADR-0002"]} + + def test_related_to_forward_and_reverse( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001"), _make_adr("ADR-0002", related_to=["ADR-0001"])] + rel = builder._compute_relations(adrs, {}) + assert rel.related_to == {"ADR-0002": ["ADR-0001"]} + assert rel.related_from == {"ADR-0001": ["ADR-0002"]} + + def test_supersedes_forward_and_reverse( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001"), _make_adr("ADR-0002", supersedes=["ADR-0001"])] + rel = builder._compute_relations(adrs, {}) + assert rel.supersedes == {"ADR-0002": ["ADR-0001"]} + assert rel.superseded_by == {"ADR-0001": ["ADR-0002"]} + + def test_supersession_chain_three_links( + self, builder: ConstraintsContractBuilder + ) -> None: + """ADR-0003 supersedes ADR-0002 supersedes ADR-0001 → chain [0001, 0002, 0003].""" + adrs = [ + _make_adr("ADR-0001"), + _make_adr("ADR-0002", supersedes=["ADR-0001"]), + _make_adr("ADR-0003", supersedes=["ADR-0002"]), + ] + rel = builder._compute_relations(adrs, {}) + assert rel.supersession_chains == [["ADR-0001", "ADR-0002", "ADR-0003"]] + + def test_unresolved_depends_on_silently_dropped( + self, builder: ConstraintsContractBuilder + ) -> None: + """Reference to a non-existent ADR ID must not appear in the index.""" + adrs = [_make_adr("ADR-0001", depends_on=["ADR-9999"])] + rel = builder._compute_relations(adrs, {}) + assert rel.depends_on == {} + assert rel.required_by == {} + + def test_circular_depends_on_does_not_raise( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [ + _make_adr("ADR-0001", depends_on=["ADR-0002"]), + _make_adr("ADR-0002", depends_on=["ADR-0001"]), + ] + rel = builder._compute_relations(adrs, {}) + # Both edges recorded as-is + assert "ADR-0001" in rel.depends_on + assert "ADR-0002" in rel.depends_on + + def test_circular_supersession_chain_terminates( + self, builder: ConstraintsContractBuilder + ) -> None: + """Circular supersedes must not cause infinite loop in chain builder.""" + adrs = [ + _make_adr("ADR-0001", supersedes=["ADR-0002"]), + _make_adr("ADR-0002", supersedes=["ADR-0001"]), + ] + # Should complete without error; chain will be a truncated fragment + rel = builder._compute_relations(adrs, {}) + # No assertion on chain content — just verify no infinite loop + + def test_no_duplicate_reverse_entries( + self, builder: ConstraintsContractBuilder + ) -> None: + """If both sides declare the relationship, reverse entry must not duplicate.""" + adrs = [ + _make_adr("ADR-0001", related_to=["ADR-0002"]), + _make_adr("ADR-0002", related_to=["ADR-0001"]), + ] + rel = builder._compute_relations(adrs, {}) + # Each related_from entry should list the other only once + assert rel.related_from.get("ADR-0001", []).count("ADR-0002") == 1 + assert rel.related_from.get("ADR-0002", []).count("ADR-0001") == 1 + + def test_clause_lookup_tables_built_from_provenance( + self, builder: ConstraintsContractBuilder + ) -> None: + from datetime import datetime, timezone + + from adr_kit.contract.models import PolicyProvenance + + prov = { + "imports.disallow.axios": PolicyProvenance( + adr_id="ADR-0001", + adr_title="No Axios", + rule_path="imports.disallow.axios", + effective_date=datetime(2024, 1, 1, tzinfo=timezone.utc), + clause_id="abc123def456", + ) + } + rel = builder._compute_relations([], prov) + assert rel.clause_to_adr == {"abc123def456": "ADR-0001"} + assert rel.adr_to_clauses == {"ADR-0001": ["abc123def456"]} From f16f1213286285f936d53e7f9135242cfb9636d6 Mon Sep 17 00:00:00 2001 From: kschlt Date: Sat, 4 Apr 2026 17:37:46 +0200 Subject: [PATCH 3/3] feat(context): surface ContractRelations in PlanningWorkflow and adr_planning_context response Completes the RIX item by making relationship data accessible to MCP callers. Added _get_adr_relations() helper to PlanningWorkflow that pulls per-ADR relationship slices (depends_on, required_by, related_to, related_from, supersedes, superseded_by) from the precomputed contract.relations indexes. Each ADR dict in _find_relevant_adrs is now enriched with a 'relations' key. After execute(), result.data carries 'contract_relations' (ContractRelations instance) which the MCP layer serializes as 'relations_summary' in the success_response data dict. Both the global view (cross-ADR impact analysis) and per-ADR view (focused per-decision context) are included. Covered by 5 new unit tests (test_planning_relations.py); full suite: 502 passed. --- CHANGELOG.md | 2 + adr_kit/mcp/server.py | 5 ++ adr_kit/workflows/planning.py | 20 +++++ tests/unit/test_planning_relations.py | 120 ++++++++++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 tests/unit/test_planning_relations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 152e0a4..64d8e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `ContractRelations` model — computed at contract build time from ADR frontmatter; provides forward indexes (`depends_on`, `related_to`, `supersedes`), reverse indexes (`required_by`, `related_from`, `superseded_by`), supersession chains (`[oldest → newest]`), and clause lookup tables (`clause_to_adr`, `adr_to_clauses`); attached as `relations` field on `ConstraintsContract` and excluded from the content hash +- Relationship surfacing in `adr_planning_context` — each ADR in `relevant_adrs` now includes a `relations` dict with its per-ADR relationship context; the response also includes a top-level `relations_summary` with the full global relationship indexes for the contract - Scenario taxonomy for `adr_planning_context` — four named scenarios (`strategic_planning`, `focused_implementation`, `pre_decision`, `supersession_impact`) replace the previous free-text `context_type` field; each scenario produces a different projection of the architecture contract - `ContextRequest` model — structured request contract with typed `scope_hints`, `change_mode`, `detail_level`, and `known_targets`; existing callers passing only `task_description` continue to work unchanged (defaults to `strategic_planning`) - `ScenarioContextPacket` response model — scenario-aware response shape with `overview`, ranked `constraints`, `warnings`, and `inspect_deeper` references to ADRs and clauses for progressive disclosure diff --git a/adr_kit/mcp/server.py b/adr_kit/mcp/server.py index 815147e..8a29bee 100644 --- a/adr_kit/mcp/server.py +++ b/adr_kit/mcp/server.py @@ -412,6 +412,10 @@ def adr_planning_context(request: PlanningContextRequest) -> dict[str, Any]: if result.success: context = result.data["architectural_context"] + contract_relations = result.data.get("contract_relations") + relations_summary = ( + contract_relations.model_dump() if contract_relations else {} + ) return success_response( message=f"Planning context provided with {len(context.relevant_adrs)} relevant ADRs", data={ @@ -427,6 +431,7 @@ def adr_planning_context(request: PlanningContextRequest) -> dict[str, Any]: "patterns": context.architecture_patterns, "checklist": context.compliance_checklist, "related_decisions": context.related_decisions, + "relations_summary": relations_summary, }, next_steps=[ "Review relevant ADRs before implementation", diff --git a/adr_kit/workflows/planning.py b/adr_kit/workflows/planning.py index adaf65f..15d909b 100644 --- a/adr_kit/workflows/planning.py +++ b/adr_kit/workflows/planning.py @@ -148,6 +148,7 @@ def execute(self, **kwargs: Any) -> WorkflowResult: self.result.data = { "architectural_context": context, "task_analysis": task_analysis, + "contract_relations": contract.relations, } self.result.guidance = ( f"Architectural context generated for {input_data.context_type} task" @@ -381,6 +382,7 @@ def _find_relevant_adrs( if len(adr.decision) > 200 else adr.decision ), + "relations": self._get_adr_relations(adr.id, contract), } ) @@ -714,3 +716,21 @@ def _identify_related_decisions( decisions.append(f"{adr_info['adr_id']}: {first_sentence}") return decisions + + def _get_adr_relations( + self, adr_id: str, contract: ConstraintsContract + ) -> dict[str, Any]: + """Return a filtered relationship summary for a single ADR. + + Pulls from the precomputed contract.relations indexes — no extra computation. + All lists default to empty when the ADR has no relationships of that type. + """ + rel = contract.relations + return { + "depends_on": rel.depends_on.get(adr_id, []), + "required_by": rel.required_by.get(adr_id, []), + "related_to": rel.related_to.get(adr_id, []), + "related_from": rel.related_from.get(adr_id, []), + "supersedes": rel.supersedes.get(adr_id, []), + "superseded_by": rel.superseded_by.get(adr_id, []), + } diff --git a/tests/unit/test_planning_relations.py b/tests/unit/test_planning_relations.py new file mode 100644 index 0000000..adb13d4 --- /dev/null +++ b/tests/unit/test_planning_relations.py @@ -0,0 +1,120 @@ +"""Unit tests for relations surfacing in PlanningWorkflow.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from adr_kit.contract.models import ConstraintsContract, ContractRelations +from adr_kit.workflows.planning import PlanningWorkflow + + +def _make_contract_with_relations(relations: ContractRelations) -> ConstraintsContract: + """Build a minimal ConstraintsContract with pre-populated relations.""" + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + contract.relations = relations + return contract + + +class TestGetAdrRelations: + def _workflow(self) -> PlanningWorkflow: + return PlanningWorkflow(adr_dir=Path("/tmp/adrs")) + + def test_populated_relations_for_known_adr(self) -> None: + rel = ContractRelations( + depends_on={"ADR-0002": ["ADR-0001"]}, + required_by={"ADR-0001": ["ADR-0002"]}, + related_to={"ADR-0002": ["ADR-0003"]}, + related_from={"ADR-0003": ["ADR-0002"]}, + supersedes={"ADR-0002": ["ADR-0001"]}, + superseded_by={"ADR-0001": ["ADR-0002"]}, + ) + contract = _make_contract_with_relations(rel) + wf = self._workflow() + + result = wf._get_adr_relations("ADR-0002", contract) + + assert result["depends_on"] == ["ADR-0001"] + assert result["required_by"] == [] + assert result["related_to"] == ["ADR-0003"] + assert result["related_from"] == [] + assert result["supersedes"] == ["ADR-0001"] + assert result["superseded_by"] == [] + + def test_all_lists_empty_for_unknown_adr(self) -> None: + contract = _make_contract_with_relations(ContractRelations()) + wf = self._workflow() + + result = wf._get_adr_relations("ADR-9999", contract) + + assert result["depends_on"] == [] + assert result["required_by"] == [] + assert result["related_to"] == [] + assert result["related_from"] == [] + assert result["supersedes"] == [] + assert result["superseded_by"] == [] + + def test_returns_dict_with_expected_keys(self) -> None: + contract = _make_contract_with_relations(ContractRelations()) + wf = self._workflow() + result = wf._get_adr_relations("ADR-0001", contract) + assert set(result.keys()) == { + "depends_on", + "required_by", + "related_to", + "related_from", + "supersedes", + "superseded_by", + } + + +class TestPlanningWorkflowRelations: + def test_contract_relations_in_result_data(self) -> None: + """After execute(), result.data must contain 'contract_relations'.""" + wf = PlanningWorkflow(adr_dir=Path("/tmp/adrs")) + + rel = ContractRelations(depends_on={"ADR-0002": ["ADR-0001"]}) + contract = _make_contract_with_relations(rel) + + with patch.object(wf, "_load_constraints_contract", return_value=contract): + from adr_kit.workflows.planning import PlanningInput + + result = wf.execute( + input_data=PlanningInput(task_description="implement a feature") + ) + + assert result.success + assert "contract_relations" in result.data + assert isinstance(result.data["contract_relations"], ContractRelations) + + def test_relevant_adrs_have_relations_key(self) -> None: + """Each ADR dict in relevant_adrs must include a 'relations' key.""" + from datetime import date + + from adr_kit.core.model import ADR, ADRFrontMatter, ADRStatus + + fm = ADRFrontMatter( + id="ADR-0001", + title="Use Python", + status=ADRStatus.ACCEPTED, + date=date(2024, 1, 1), + ) + adr = ADR(front_matter=fm, content="## Decision\nUse Python.") + + contract = ConstraintsContract.create_empty(Path("/tmp/adrs")) + contract.approved_adrs = [adr] + + wf = PlanningWorkflow(adr_dir=Path("/tmp/adrs")) + + with patch.object(wf, "_load_constraints_contract", return_value=contract): + from adr_kit.workflows.planning import PlanningInput + + result = wf.execute( + input_data=PlanningInput( + task_description="python implementation feature" + ) + ) + + assert result.success + relevant = result.data["architectural_context"].relevant_adrs + for adr_info in relevant: + assert "relations" in adr_info + assert isinstance(adr_info["relations"], dict)