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
6 changes: 5 additions & 1 deletion .claude/skills/close/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,13 @@ grep "^Status:" .agent/backlog/<task-file>.md
**Move** backlog file to `archive/` (e.g. `backlog/CRA-*.md` → `archive/CRA-*.md`)
**task-tracking.md**:
- Remove the row from the Priority Queue table
- Add entry to "Done" section with date and brief summary
- Add the task's ID + ✅ to the **Baseline** summary line in the header
- Remove this task's ID from "Depends On" column of any tasks that depended on it
- Update test count in header if tests were added
**CHANGELOG.md** (source of truth for what changed):
- Add user-facing changes to the `[Unreleased]` section under the appropriate heading (Added/Changed/Fixed/Removed)
- Write from the user's perspective — what the feature does, not implementation details
- Skip purely internal changes (dev tooling, .agent/ updates, workflow tweaks) unless they affect the installed package

## 7. Smart Next-Step Suggestion (Final Step or Session Ending)

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Canonical enforcement pipeline (`EnforcementPipeline`) — single entry point for all enforcement that reads exclusively from the compiled architecture contract, never from raw ADR files
- `EnforcementResult` audit envelope produced on every ADR approval: tracks which config fragments were applied, which adapters were skipped and why, any conflicts detected, clause-level provenance, and an idempotency hash (same contract → identical hash)
- Contract-driven ESLint adapter (`generate_eslint_config_from_contract`) — generates `no-restricted-imports` rules directly from compiled `MergedConstraints`
- Contract-driven Ruff adapter (`generate_ruff_config_from_contract`) — generates `banned-from` rules from compiled Python and import constraints
- `clause_id` field on every provenance entry — deterministic 12-char identifier (`sha256(adr_id:rule_path)[:12]`) enabling clause-level traceability from enforcement artifacts back to source ADRs
- Topological sort in policy merger — ADRs are now ordered by supersession relationships (Kahn's algorithm) before merging, so superseding ADRs correctly override their predecessors; falls back to date sort when no supersession relationships exist
- `CHANGELOG.md` with full version history
- `TECHNICAL.md` with implementation details for each layer
- `CONTRIBUTING.md` with development environment setup
Expand All @@ -30,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Individual ADR MCP resources (`adr://{adr_id}`) for progressive disclosure — agents fetch full ADR content on demand via `resource_uri` field

### Changed
- Internal module structure reorganized into three planes: `decision/` (workflows, gate, guidance) and `enforcement/` (adapters, validation, generation, config, detection, reporter) — no public API changes
- README rewritten for user focus: problem statement, quick start, tool reference, FAQ
- `ROADMAP.md` "Recent Additions" section replaced with link to this changelog
- CI workflow consolidated from 13 to 8 checks: dedicated lint job (blocks tests), trimmed test matrix to `(ubuntu + macOS) × (3.11–3.13) + ubuntu-only 3.10`
Expand Down
138 changes: 90 additions & 48 deletions adr_kit/contract/merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
from .models import MergedConstraints, PolicyProvenance


def _make_provenance(
adr_id: str,
adr_title: str,
rule_path: str,
effective_date: "datetime",
) -> PolicyProvenance:
"""Create a PolicyProvenance with a deterministic clause_id."""
return PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=rule_path,
effective_date=effective_date,
clause_id=PolicyProvenance.make_clause_id(adr_id, rule_path),
)


@dataclass
class PolicyConflict:
"""Represents a conflict between two ADR policies."""
Expand Down Expand Up @@ -174,10 +190,57 @@ def _topological_sort(self, adrs: list[ADR]) -> list[ADR]:
"""Sort ADRs topologically based on supersede relationships.

ADRs that supersede others come later in the list, so they can override.
Uses Kahn's algorithm. Falls back to date sort for ADRs with no
supersession relationships.
"""
# For now, simple sort by date (older first)
# TODO: Implement proper topological sort based on supersedes relationships
return sorted(adrs, key=lambda adr: adr.front_matter.date)
if not adrs:
return adrs

# Build index by ID for fast lookup
by_id = {adr.front_matter.id: adr for adr in adrs}

# Build adjacency: predecessor → set of successors
# If B supersedes A, then A must come before B (A → B edge)
successors: dict[str, set[str]] = {adr.front_matter.id: set() for adr in adrs}
in_degree: dict[str, int] = {adr.front_matter.id: 0 for adr in adrs}

for adr in adrs:
for superseded_id in adr.front_matter.supersedes or []:
if superseded_id in by_id:
# adr supersedes superseded_id → superseded_id must come first
successors[superseded_id].add(adr.front_matter.id)
in_degree[adr.front_matter.id] += 1

# Kahn's algorithm — start with nodes that have no predecessors
# Tie-break with date sort for determinism
queue = sorted(
[adr for adr in adrs if in_degree[adr.front_matter.id] == 0],
key=lambda a: a.front_matter.date,
)
result: list[ADR] = []

while queue:
node = queue.pop(0)
result.append(node)
for succ_id in sorted(successors[node.front_matter.id]):
in_degree[succ_id] -= 1
if in_degree[succ_id] == 0:
succ_adr = by_id[succ_id]
# Insert in date order among ready nodes
inserted = False
for i, q in enumerate(queue):
if succ_adr.front_matter.date < q.front_matter.date:
queue.insert(i, succ_adr)
inserted = True
break
if not inserted:
queue.append(succ_adr)

# If cycle detected (result shorter than input), fall back to date sort
if len(result) < len(adrs):
return sorted(adrs, key=lambda adr: adr.front_matter.date)

return result

def _merge_import_policy(
self,
Expand Down Expand Up @@ -215,11 +278,8 @@ def _merge_import_policy(
merged_prefer.discard(item)

merged_disallow.add(item)
provenance[f"imports.disallow.{item}"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=f"imports.disallow.{item}",
effective_date=effective_date,
provenance[f"imports.disallow.{item}"] = _make_provenance(
adr_id, adr_title, f"imports.disallow.{item}", effective_date
)

# Add new prefer items
Expand All @@ -243,11 +303,8 @@ def _merge_import_policy(
continue

merged_prefer.add(item)
provenance[f"imports.prefer.{item}"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=f"imports.prefer.{item}",
effective_date=effective_date,
provenance[f"imports.prefer.{item}"] = _make_provenance(
adr_id, adr_title, f"imports.prefer.{item}", effective_date
)

return (
Expand Down Expand Up @@ -275,11 +332,8 @@ def _merge_python_policy(
merged_disallow.update(new.disallow_imports)

for item in new.disallow_imports:
provenance[f"python.disallow_imports.{item}"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=f"python.disallow_imports.{item}",
effective_date=effective_date,
provenance[f"python.disallow_imports.{item}"] = _make_provenance(
adr_id, adr_title, f"python.disallow_imports.{item}", effective_date
)

return (
Expand Down Expand Up @@ -307,11 +361,8 @@ def _merge_pattern_policy(
if new.patterns:
for rule_name, rule in new.patterns.items():
merged_patterns[rule_name] = rule
provenance[f"patterns.{rule_name}"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=f"patterns.{rule_name}",
effective_date=effective_date,
provenance[f"patterns.{rule_name}"] = _make_provenance(
adr_id, adr_title, f"patterns.{rule_name}", effective_date
)

return merged_patterns, provenance
Expand All @@ -333,11 +384,11 @@ def _merge_architecture_policy(
for boundary in new.layer_boundaries:
merged_boundaries.append(boundary)
provenance[f"architecture.boundaries.{boundary.rule}"] = (
PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=f"architecture.boundaries.{boundary.rule}",
effective_date=effective_date,
_make_provenance(
adr_id,
adr_title,
f"architecture.boundaries.{boundary.rule}",
effective_date,
)
)

Expand All @@ -347,11 +398,11 @@ def _merge_architecture_policy(
for structure in new.required_structure:
merged_structures.append(structure)
provenance[f"architecture.structure.{structure.path}"] = (
PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path=f"architecture.structure.{structure.path}",
effective_date=effective_date,
_make_provenance(
adr_id,
adr_title,
f"architecture.structure.{structure.path}",
effective_date,
)
)

Expand Down Expand Up @@ -382,11 +433,8 @@ def _merge_config_policy(
merged_ts_config.update(existing.typescript.tsconfig)
if new.typescript and new.typescript.tsconfig:
merged_ts_config.update(new.typescript.tsconfig)
provenance["config.typescript"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path="config.typescript",
effective_date=effective_date,
provenance["config.typescript"] = _make_provenance(
adr_id, adr_title, "config.typescript", effective_date
)

# Merge Python config
Expand All @@ -402,19 +450,13 @@ def _merge_config_policy(
if new.python:
if new.python.ruff:
merged_py_ruff.update(new.python.ruff)
provenance["config.python.ruff"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path="config.python.ruff",
effective_date=effective_date,
provenance["config.python.ruff"] = _make_provenance(
adr_id, adr_title, "config.python.ruff", effective_date
)
if new.python.mypy:
merged_py_mypy.update(new.python.mypy)
provenance["config.python.mypy"] = PolicyProvenance(
adr_id=adr_id,
adr_title=adr_title,
rule_path="config.python.mypy",
effective_date=effective_date,
provenance["config.python.mypy"] = _make_provenance(
adr_id, adr_title, "config.python.mypy", effective_date
)

# Create merged config models
Expand Down
9 changes: 9 additions & 0 deletions adr_kit/contract/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ class PolicyProvenance(BaseModel):
..., description="Path to the specific rule (e.g., 'imports.disallow.axios')"
)
effective_date: datetime = Field(..., description="When this rule became active")
clause_id: str = Field(
"",
description="Deterministic 12-char identifier: sha256(adr_id:rule_path)[:12]",
)

@classmethod
def make_clause_id(cls, adr_id: str, rule_path: str) -> str:
"""Generate a deterministic clause ID from adr_id and rule_path."""
return hashlib.sha256(f"{adr_id}:{rule_path}".encode()).hexdigest()[:12]


class ContractMetadata(BaseModel):
Expand Down
Loading
Loading