From 972dd330237395853228021fe06d2ab697ece31e Mon Sep 17 00:00:00 2001 From: kschlt Date: Tue, 24 Mar 2026 10:02:35 +0100 Subject: [PATCH 1/5] refactor: move files into decision/ and enforcement/ planes with backward-compat shims Files moved into new three-plane structure: - adr_kit/decision/workflows/ (from workflows/) - adr_kit/decision/guidance/ (from workflows/decision_guidance.py) - adr_kit/decision/gate/ (from gate/) - adr_kit/enforcement/adapters/ (from enforce/eslint.py, ruff.py) - adr_kit/enforcement/validation/ (from enforce/validator.py, stages.py) - adr_kit/enforcement/generation/ (from enforce/script_generator.py, ci.py, hooks.py) - adr_kit/enforcement/config/ (from guardrail/) - adr_kit/enforcement/detection/ (from guard/) - adr_kit/enforcement/reporter.py (from enforce/reporter.py) Backward-compat shims created at old locations so all 309 tests pass. --- .claude/plans/RST-module-restructure.md | 441 +++++ adr_kit/decision/__init__.py | 0 adr_kit/decision/gate/__init__.py | 39 + adr_kit/decision/gate/models.py | 204 +++ adr_kit/decision/gate/policy_engine.py | 272 +++ adr_kit/decision/gate/policy_gate.py | 348 ++++ adr_kit/decision/gate/technical_choice.py | 206 +++ adr_kit/decision/guidance/__init__.py | 0 .../decision/guidance/decision_guidance.py | 491 ++++++ adr_kit/decision/workflows/__init__.py | 0 adr_kit/decision/workflows/analyze.py | 580 +++++++ adr_kit/decision/workflows/approval.py | 746 +++++++++ adr_kit/decision/workflows/base.py | 252 +++ adr_kit/decision/workflows/creation.py | 1472 ++++++++++++++++ adr_kit/decision/workflows/preflight.py | 528 ++++++ adr_kit/decision/workflows/supersede.py | 380 +++++ adr_kit/enforce/__init__.py | 6 +- adr_kit/enforce/ci.py | 151 +- adr_kit/enforce/eslint.py | 384 +---- adr_kit/enforce/hooks.py | 175 +- adr_kit/enforce/reporter.py | 92 +- adr_kit/enforce/ruff.py | 359 +--- adr_kit/enforce/script_generator.py | 523 +----- adr_kit/enforce/stages.py | 189 +-- adr_kit/enforce/validator.py | 415 +---- adr_kit/enforcement/__init__.py | 0 adr_kit/enforcement/adapters/__init__.py | 0 adr_kit/enforcement/adapters/eslint.py | 383 +++++ adr_kit/enforcement/adapters/ruff.py | 358 ++++ adr_kit/enforcement/config/__init__.py | 0 adr_kit/enforcement/config/manager.py | 394 +++++ adr_kit/enforcement/config/models.py | 166 ++ adr_kit/enforcement/config/monitor.py | 221 +++ adr_kit/enforcement/config/writer.py | 265 +++ adr_kit/enforcement/detection/__init__.py | 9 + adr_kit/enforcement/detection/detector.py | 556 +++++++ adr_kit/enforcement/generation/__init__.py | 0 adr_kit/enforcement/generation/ci.py | 149 ++ adr_kit/enforcement/generation/hooks.py | 173 ++ adr_kit/enforcement/generation/scripts.py | 522 ++++++ adr_kit/enforcement/reporter.py | 91 + adr_kit/enforcement/validation/__init__.py | 0 adr_kit/enforcement/validation/staged.py | 414 +++++ adr_kit/enforcement/validation/stages.py | 188 +++ adr_kit/gate/__init__.py | 26 +- adr_kit/gate/models.py | 205 +-- adr_kit/gate/policy_engine.py | 273 +-- adr_kit/gate/policy_gate.py | 349 +--- adr_kit/gate/technical_choice.py | 207 +-- adr_kit/guard/__init__.py | 12 +- adr_kit/guard/detector.py | 557 +------ adr_kit/guardrail/__init__.py | 19 +- adr_kit/guardrail/config_writer.py | 266 +-- adr_kit/guardrail/file_monitor.py | 222 +-- adr_kit/guardrail/manager.py | 395 +---- adr_kit/guardrail/models.py | 167 +- adr_kit/workflows/__init__.py | 32 +- adr_kit/workflows/analyze.py | 581 +------ adr_kit/workflows/approval.py | 747 +-------- adr_kit/workflows/base.py | 253 +-- adr_kit/workflows/creation.py | 1473 +---------------- adr_kit/workflows/decision_guidance.py | 493 +----- adr_kit/workflows/planning.py | 3 +- adr_kit/workflows/preflight.py | 529 +----- adr_kit/workflows/supersede.py | 381 +---- 65 files changed, 9917 insertions(+), 9415 deletions(-) create mode 100644 .claude/plans/RST-module-restructure.md create mode 100644 adr_kit/decision/__init__.py create mode 100644 adr_kit/decision/gate/__init__.py create mode 100644 adr_kit/decision/gate/models.py create mode 100644 adr_kit/decision/gate/policy_engine.py create mode 100644 adr_kit/decision/gate/policy_gate.py create mode 100644 adr_kit/decision/gate/technical_choice.py create mode 100644 adr_kit/decision/guidance/__init__.py create mode 100644 adr_kit/decision/guidance/decision_guidance.py create mode 100644 adr_kit/decision/workflows/__init__.py create mode 100644 adr_kit/decision/workflows/analyze.py create mode 100644 adr_kit/decision/workflows/approval.py create mode 100644 adr_kit/decision/workflows/base.py create mode 100644 adr_kit/decision/workflows/creation.py create mode 100644 adr_kit/decision/workflows/preflight.py create mode 100644 adr_kit/decision/workflows/supersede.py create mode 100644 adr_kit/enforcement/__init__.py create mode 100644 adr_kit/enforcement/adapters/__init__.py create mode 100644 adr_kit/enforcement/adapters/eslint.py create mode 100644 adr_kit/enforcement/adapters/ruff.py create mode 100644 adr_kit/enforcement/config/__init__.py create mode 100644 adr_kit/enforcement/config/manager.py create mode 100644 adr_kit/enforcement/config/models.py create mode 100644 adr_kit/enforcement/config/monitor.py create mode 100644 adr_kit/enforcement/config/writer.py create mode 100644 adr_kit/enforcement/detection/__init__.py create mode 100644 adr_kit/enforcement/detection/detector.py create mode 100644 adr_kit/enforcement/generation/__init__.py create mode 100644 adr_kit/enforcement/generation/ci.py create mode 100644 adr_kit/enforcement/generation/hooks.py create mode 100644 adr_kit/enforcement/generation/scripts.py create mode 100644 adr_kit/enforcement/reporter.py create mode 100644 adr_kit/enforcement/validation/__init__.py create mode 100644 adr_kit/enforcement/validation/staged.py create mode 100644 adr_kit/enforcement/validation/stages.py diff --git a/.claude/plans/RST-module-restructure.md b/.claude/plans/RST-module-restructure.md new file mode 100644 index 0000000..f512d5a --- /dev/null +++ b/.claude/plans/RST-module-restructure.md @@ -0,0 +1,441 @@ +# RST — Module Restructure by Plane + +## Context + +Restructure `adr_kit/` into the three-plane architecture defined in `.agent/architecture.md`. Pure mechanical work — move files, update imports, no logic changes. Uses backward-compatible shims so tests pass at every intermediate step. + +**Strategy**: Move files → create shims at old locations → verify tests pass → update source imports → update test imports → remove shims → final verify. + +--- + +## Phase 0: Branch + Green Baseline + +1. Invoke `/branch` with args: `"RST Module Restructure by Plane — architecture realignment"` +2. Run `make test-all && make lint` — must pass. If not, stop. + +--- + +## Phase 1: Create New Directory Structure + +Create directories and empty `__init__.py` files: + +```bash +mkdir -p adr_kit/decision/workflows +mkdir -p adr_kit/decision/guidance +mkdir -p adr_kit/decision/gate +mkdir -p adr_kit/enforcement/adapters +mkdir -p adr_kit/enforcement/validation +mkdir -p adr_kit/enforcement/generation +mkdir -p adr_kit/enforcement/config +mkdir -p adr_kit/enforcement/detection +``` + +Create empty `__init__.py` in each new directory: +- `adr_kit/decision/__init__.py` +- `adr_kit/decision/workflows/__init__.py` +- `adr_kit/decision/guidance/__init__.py` +- `adr_kit/enforcement/__init__.py` +- `adr_kit/enforcement/adapters/__init__.py` +- `adr_kit/enforcement/validation/__init__.py` +- `adr_kit/enforcement/generation/__init__.py` +- `adr_kit/enforcement/config/__init__.py` + +(gate/ and detection/ `__init__.py` come from `git mv` in Phase 2) + +--- + +## Phase 2: Move Files with `git mv` + +### Decision Plane — workflows +``` +git mv adr_kit/workflows/base.py adr_kit/decision/workflows/base.py +git mv adr_kit/workflows/creation.py adr_kit/decision/workflows/creation.py +git mv adr_kit/workflows/approval.py adr_kit/decision/workflows/approval.py +git mv adr_kit/workflows/supersede.py adr_kit/decision/workflows/supersede.py +git mv adr_kit/workflows/preflight.py adr_kit/decision/workflows/preflight.py +git mv adr_kit/workflows/analyze.py adr_kit/decision/workflows/analyze.py +``` + +### Decision Plane — guidance +``` +git mv adr_kit/workflows/decision_guidance.py adr_kit/decision/guidance/decision_guidance.py +``` + +### Decision Plane — gate (entire package) +``` +git mv adr_kit/gate/__init__.py adr_kit/decision/gate/__init__.py +git mv adr_kit/gate/models.py adr_kit/decision/gate/models.py +git mv adr_kit/gate/policy_engine.py adr_kit/decision/gate/policy_engine.py +git mv adr_kit/gate/policy_gate.py adr_kit/decision/gate/policy_gate.py +git mv adr_kit/gate/technical_choice.py adr_kit/decision/gate/technical_choice.py +``` + +### Enforcement Plane — adapters +``` +git mv adr_kit/enforce/eslint.py adr_kit/enforcement/adapters/eslint.py +git mv adr_kit/enforce/ruff.py adr_kit/enforcement/adapters/ruff.py +``` + +### Enforcement Plane — validation +``` +git mv adr_kit/enforce/validator.py adr_kit/enforcement/validation/staged.py # RENAMED +git mv adr_kit/enforce/stages.py adr_kit/enforcement/validation/stages.py +``` + +### Enforcement Plane — reporter +``` +git mv adr_kit/enforce/reporter.py adr_kit/enforcement/reporter.py +``` + +### Enforcement Plane — generation +``` +git mv adr_kit/enforce/script_generator.py adr_kit/enforcement/generation/scripts.py # RENAMED +git mv adr_kit/enforce/ci.py adr_kit/enforcement/generation/ci.py +git mv adr_kit/enforce/hooks.py adr_kit/enforcement/generation/hooks.py +``` + +### Enforcement Plane — config (from guardrail/) +``` +git mv adr_kit/guardrail/config_writer.py adr_kit/enforcement/config/writer.py # RENAMED +git mv adr_kit/guardrail/file_monitor.py adr_kit/enforcement/config/monitor.py # RENAMED +git mv adr_kit/guardrail/manager.py adr_kit/enforcement/config/manager.py +git mv adr_kit/guardrail/models.py adr_kit/enforcement/config/models.py +``` + +### Enforcement Plane — detection (from guard/) +``` +git mv adr_kit/guard/__init__.py adr_kit/enforcement/detection/__init__.py +git mv adr_kit/guard/detector.py adr_kit/enforcement/detection/detector.py +``` + +### NOT moved +- `workflows/planning.py` — stays in `workflows/` (context plane, imported by mcp/server.py) +- `workflows/__init__.py` — stays, will be updated +- `knowledge/` — empty (only `__pycache__`), delete in Phase 7 + +--- + +## Phase 3: Fix Relative Imports in Moved Files + +Each moved file that uses `from ..X` imports needs depth adjusted (`..` → `...`) because it's now one level deeper. Also fix cross-module references for renamed files. + +### Decision Plane files + +**`adr_kit/decision/workflows/creation.py`** — 4 top-level + 1 lazy: +- `from ..contract.builder` → `from ...contract.builder` +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` +- `from ..core.validate` → `from ...core.validate` +- Line ~555 (lazy): `from ..core.policy_extractor` → `from ...core.policy_extractor` + +**`adr_kit/decision/workflows/approval.py`** — 7 top-level + 3 lazy: +- `from ..contract.builder` → `from ...contract.builder` +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` +- `from ..core.validate` → `from ...core.validate` +- `from ..enforce.eslint` → `from ...enforcement.adapters.eslint` +- `from ..enforce.ruff` → `from ...enforcement.adapters.ruff` +- `from ..guardrail.manager` → `from ...enforcement.config.manager` +- `from ..index.json_index` → `from ...index.json_index` +- Line ~287 (lazy): `from ..core.model` → `from ...core.model` +- Line ~383 (lazy): `from ..enforce.script_generator` → `from ...enforcement.generation.scripts` +- Line ~410 (lazy): `from ..enforce.hooks` → `from ...enforcement.generation.hooks` + +**`adr_kit/decision/workflows/supersede.py`** — 2 top-level: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` + +**`adr_kit/decision/workflows/preflight.py`** — 2 top-level: +- `from ..contract.builder` → `from ...contract.builder` +- `from ..contract.models` → `from ...contract.models` + +**`adr_kit/decision/workflows/analyze.py`** — 1 top-level: +- `from ..core.parse` → `from ...core.parse` + +**`adr_kit/decision/workflows/base.py`** — no `..` imports. No changes. + +**`adr_kit/decision/guidance/decision_guidance.py`** — no `..` imports. No changes. + +**`adr_kit/decision/gate/policy_engine.py`** — 1 top-level: +- `from ..contract` → `from ...contract` + +### Enforcement Plane files + +**`adr_kit/enforcement/adapters/eslint.py`** — 3 top-level: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` +- `from ..core.policy_extractor` → `from ...core.policy_extractor` + +**`adr_kit/enforcement/adapters/ruff.py`** — 2 top-level: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` + +**`adr_kit/enforcement/validation/staged.py`** (was validator.py) — 2 top-level: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` +- `from .stages import` — stays (sibling) + +**`adr_kit/enforcement/validation/stages.py`** — no `..` imports. No changes. + +**`adr_kit/enforcement/reporter.py`** — 1 internal: +- `from .validator import ValidationResult` → `from .validation.staged import ValidationResult` + +**`adr_kit/enforcement/generation/scripts.py`** (was script_generator.py) — 2 top-level + 1 sibling: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` +- `from .stages import` → `from ..validation.stages import` (stages moved to validation/) + +**`adr_kit/enforcement/generation/ci.py`** — no `..` imports. No changes. + +**`adr_kit/enforcement/generation/hooks.py`** — no `..` imports. No changes. + +**`adr_kit/enforcement/config/manager.py`** — 1 top-level + 2 sibling renames: +- `from ..contract` → `from ...contract` +- `from .config_writer` → `from .writer` (file renamed) +- `from .file_monitor` → `from .monitor` (file renamed) + +**`adr_kit/enforcement/config/monitor.py`** (was file_monitor.py) — 2 top-level: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` + +**`adr_kit/enforcement/config/writer.py`** (was config_writer.py) — no `..` imports. Only `.models` (stays). + +**`adr_kit/enforcement/detection/detector.py`** — 4 top-level: +- `from ..core.model` → `from ...core.model` +- `from ..core.parse` → `from ...core.parse` +- `from ..core.policy_extractor` → `from ...core.policy_extractor` +- `from ..semantic.retriever` → `from ...semantic.retriever` + +--- + +## Phase 4: Create Backward-Compatible Shims at Old Locations + +### `adr_kit/workflows/` shims + +Create these files (one-liner each): +``` +adr_kit/workflows/base.py: from adr_kit.decision.workflows.base import * # noqa: F401,F403 +adr_kit/workflows/creation.py: from adr_kit.decision.workflows.creation import * # noqa: F401,F403 +adr_kit/workflows/approval.py: from adr_kit.decision.workflows.approval import * # noqa: F401,F403 +adr_kit/workflows/supersede.py: from adr_kit.decision.workflows.supersede import * # noqa: F401,F403 +adr_kit/workflows/preflight.py: from adr_kit.decision.workflows.preflight import * # noqa: F401,F403 +adr_kit/workflows/analyze.py: from adr_kit.decision.workflows.analyze import * # noqa: F401,F403 +adr_kit/workflows/decision_guidance.py: from adr_kit.decision.guidance.decision_guidance import * # noqa: F401,F403 +``` + +**`adr_kit/workflows/__init__.py`** — replace content with: +```python +"""Workflow orchestration — shim for backward compatibility.""" +from .planning import PlanningWorkflow +from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow +from adr_kit.decision.workflows.approval import ApprovalWorkflow +from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus +from adr_kit.decision.workflows.creation import CreationWorkflow +from adr_kit.decision.workflows.preflight import PreflightWorkflow +from adr_kit.decision.workflows.supersede import SupersedeWorkflow + +__all__ = [ + "BaseWorkflow", "WorkflowResult", "WorkflowError", "WorkflowStatus", + "ApprovalWorkflow", "CreationWorkflow", "PreflightWorkflow", + "PlanningWorkflow", "SupersedeWorkflow", "AnalyzeProjectWorkflow", +] +``` + +**`adr_kit/workflows/planning.py`** — fix broken sibling import: +- `from .base import BaseWorkflow, WorkflowResult` → `from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowResult` + +### `adr_kit/gate/` shims + +Recreate the directory and files: +``` +adr_kit/gate/__init__.py: (re-export all names from adr_kit.decision.gate — copy the original __all__ list, import each from new location) +adr_kit/gate/models.py: from adr_kit.decision.gate.models import * # noqa: F401,F403 +adr_kit/gate/policy_engine.py: from adr_kit.decision.gate.policy_engine import * # noqa: F401,F403 +adr_kit/gate/policy_gate.py: from adr_kit.decision.gate.policy_gate import * # noqa: F401,F403 +adr_kit/gate/technical_choice.py: from adr_kit.decision.gate.technical_choice import * # noqa: F401,F403 +``` + +### `adr_kit/enforce/` shims + +Replace all files (they were emptied by `git mv`): +``` +adr_kit/enforce/__init__.py: (re-export CIWorkflowGenerator from enforcement.generation.ci, ScriptGenerator from enforcement.generation.scripts) +adr_kit/enforce/eslint.py: from adr_kit.enforcement.adapters.eslint import * # noqa: F401,F403 +adr_kit/enforce/ruff.py: from adr_kit.enforcement.adapters.ruff import * # noqa: F401,F403 +adr_kit/enforce/validator.py: from adr_kit.enforcement.validation.staged import * # noqa: F401,F403 +adr_kit/enforce/stages.py: from adr_kit.enforcement.validation.stages import * # noqa: F401,F403 +adr_kit/enforce/reporter.py: from adr_kit.enforcement.reporter import * # noqa: F401,F403 +adr_kit/enforce/script_generator.py: from adr_kit.enforcement.generation.scripts import * # noqa: F401,F403 +adr_kit/enforce/ci.py: from adr_kit.enforcement.generation.ci import * # noqa: F401,F403 +adr_kit/enforce/hooks.py: from adr_kit.enforcement.generation.hooks import * # noqa: F401,F403 +``` + +### `adr_kit/guardrail/` shims + +Replace all files: +``` +adr_kit/guardrail/__init__.py: (re-export all 12 names from enforcement.config.* modules — copy original __all__) +adr_kit/guardrail/config_writer.py: from adr_kit.enforcement.config.writer import * # noqa: F401,F403 +adr_kit/guardrail/file_monitor.py: from adr_kit.enforcement.config.monitor import * # noqa: F401,F403 +adr_kit/guardrail/manager.py: from adr_kit.enforcement.config.manager import * # noqa: F401,F403 +adr_kit/guardrail/models.py: from adr_kit.enforcement.config.models import * # noqa: F401,F403 +``` + +### `adr_kit/guard/` shims + +Recreate: +``` +adr_kit/guard/__init__.py: (re-export GuardSystem, PolicyViolation, CodeAnalysisResult from enforcement.detection) +adr_kit/guard/detector.py: from adr_kit.enforcement.detection.detector import * # noqa: F401,F403 +``` + +### Verify: `make test-all && make lint` + +All 309+ tests must pass. If not, debug shims before proceeding. + +### Commit: `refactor: move files into decision/ and enforcement/ planes with backward-compat shims` + +--- + +## Phase 5: Update Source Imports + +### `adr_kit/mcp/server.py` (lines 13-18) + +| Old | New | +|-----|-----| +| `from ..workflows.analyze import ...` | `from ..decision.workflows.analyze import ...` | +| `from ..workflows.approval import ...` | `from ..decision.workflows.approval import ...` | +| `from ..workflows.creation import ...` | `from ..decision.workflows.creation import ...` | +| `from ..workflows.planning import ...` | **NO CHANGE** (stays in workflows/) | +| `from ..workflows.preflight import ...` | `from ..decision.workflows.preflight import ...` | +| `from ..workflows.supersede import ...` | `from ..decision.workflows.supersede import ...` | + +### `adr_kit/cli.py` (all lazy imports in function bodies) + +Find and replace each: +| Old | New | +|-----|-----| +| `from .workflows.analyze import` | `from .decision.workflows.analyze import` | +| `from .workflows.approval import` | `from .decision.workflows.approval import` | +| `from .workflows.creation import` | `from .decision.workflows.creation import` | +| `from .workflows.preflight import` | `from .decision.workflows.preflight import` | +| `from .enforce.hooks import` | `from .enforcement.generation.hooks import` | +| `from .gate import PolicyGate, create_technical_choice` | `from .decision.gate import PolicyGate, create_technical_choice` | +| `from .gate import PolicyGate` | `from .decision.gate import PolicyGate` | +| `from .guardrail import GuardrailManager` | `from .enforcement.config.manager import GuardrailManager` | +| `from .enforce.stages import` | `from .enforcement.validation.stages import` | +| `from .enforce.validator import` | `from .enforcement.validation.staged import` | +| `from .enforce.reporter import` | `from .enforcement.reporter import` | +| `from .enforce.script_generator import` | `from .enforcement.generation.scripts import` | +| `from .enforce.ci import` | `from .enforcement.generation.ci import` | + +### Verify: `make test-all && make lint` + +### Commit: `refactor: update source imports to new decision/ and enforcement/ paths` + +--- + +## Phase 6: Update Test Imports + +### Workflow test imports — replace `adr_kit.workflows.X` → `adr_kit.decision.workflows.X` + +Files (8): +- `tests/integration/test_mcp_workflow_integration.py` — many imports, but `adr_kit.workflows.planning` stays unchanged +- `tests/integration/test_comprehensive_scenarios.py` +- `tests/integration/test_workflow_creation.py` +- `tests/integration/test_workflow_analyze.py` +- `tests/unit/test_workflow_base.py` +- `tests/unit/test_policy_validation.py` +- `tests/integration/test_decision_quality_assessment.py` + +Special case: +- `tests/unit/test_decision_guidance.py`: `adr_kit.workflows.decision_guidance` → `adr_kit.decision.guidance.decision_guidance` + +### Enforcement test imports + +| File | Old | New | +|------|-----|-----| +| `tests/unit/test_staged_enforcement.py` | `adr_kit.enforce.stages` | `adr_kit.enforcement.validation.stages` | +| `tests/unit/test_staged_enforcement.py` | `adr_kit.enforce.validator` | `adr_kit.enforcement.validation.staged` | +| `tests/unit/test_reporter.py` | `adr_kit.enforce.reporter` | `adr_kit.enforcement.reporter` | +| `tests/unit/test_reporter.py` | `adr_kit.enforce.stages` | `adr_kit.enforcement.validation.stages` | +| `tests/unit/test_reporter.py` | `adr_kit.enforce.validator` | `adr_kit.enforcement.validation.staged` | +| `tests/unit/test_script_generator.py` | `adr_kit.enforce.script_generator` | `adr_kit.enforcement.generation.scripts` | +| `tests/unit/test_hook_generator.py` | `adr_kit.enforce.hooks` | `adr_kit.enforcement.generation.hooks` | +| `tests/unit/test_ci_generator.py` | `adr_kit.enforce.ci` | `adr_kit.enforcement.generation.ci` | + +**IMPORTANT**: `test_staged_enforcement.py` has dynamic imports inside test function bodies (not just at the top). Search the ENTIRE file for `from adr_kit.enforce.` patterns. + +### Verify: `make test-all && make lint` + +### Commit: `refactor: update test imports to new decision/ and enforcement/ paths` + +--- + +## Phase 7: Remove Shims and Clean Up + +### Delete workflow shims (keep `__init__.py` and `planning.py`) +```bash +rm adr_kit/workflows/base.py adr_kit/workflows/creation.py adr_kit/workflows/approval.py +rm adr_kit/workflows/supersede.py adr_kit/workflows/preflight.py adr_kit/workflows/analyze.py +rm adr_kit/workflows/decision_guidance.py +``` + +### Update `adr_kit/workflows/__init__.py` — final form: +```python +"""Workflow orchestration. Planning workflow stays here (context plane). +All other workflows have moved to adr_kit.decision.workflows.""" +from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus +from .planning import PlanningWorkflow + +__all__ = [ + "BaseWorkflow", "WorkflowResult", "WorkflowError", "WorkflowStatus", + "PlanningWorkflow", +] +``` + +### Delete entire old directories +```bash +rm -rf adr_kit/gate/ +rm -rf adr_kit/enforce/ +rm -rf adr_kit/guardrail/ +rm -rf adr_kit/guard/ +rm -rf adr_kit/knowledge/ # empty, only __pycache__ +``` + +### Verify: `make test-all && make lint` + +### Commit: `refactor: remove backward-compat shims and old directories` + +--- + +## Phase 8: Final Verification + +1. `make test-all` — all 309+ tests pass +2. `make lint` — clean +3. Verify no stale imports remain: +```bash +grep -rn "from.*\.gate\." adr_kit/ --include="*.py" | grep -v __pycache__ | grep -v decision +grep -rn "from.*\.enforce\." adr_kit/ --include="*.py" | grep -v __pycache__ | grep -v enforcement +grep -rn "from.*\.guardrail\." adr_kit/ --include="*.py" | grep -v __pycache__ +grep -rn "from.*\.guard\." adr_kit/ --include="*.py" | grep -v __pycache__ | grep -v enforcement +grep -rn "adr_kit\.gate\." tests/ --include="*.py" +grep -rn "adr_kit\.enforce\." tests/ --include="*.py" +grep -rn "adr_kit\.guardrail\." tests/ --include="*.py" +grep -rn "adr_kit\.guard\." tests/ --include="*.py" +``` +All must return zero results. + +4. Verify directory structure: `find adr_kit/ -name "*.py" -not -path "*__pycache__*" | sort` + +--- + +## Critical Notes for Executor + +1. **Line numbers are approximate** — always use string matching (Edit tool's `old_string`), never line numbers alone. +2. **`approval.py` is the trickiest file** — 10 relative imports including 3 lazy ones in function bodies. +3. **`reporter.py`** has an internal import `from .validator` that changes to `from .validation.staged` (not a depth change, a path restructure). +4. **`scripts.py`** (was script_generator.py) has `from .stages` that changes to `from ..validation.stages`. +5. **`manager.py`** (in config/) has `from .config_writer` → `from .writer` and `from .file_monitor` → `from .monitor` (file renames). +6. **`planning.py`** stays but its `from .base` import breaks — fix it in Phase 4. +7. **After each phase, run `make test-all && make lint`** before proceeding. diff --git a/adr_kit/decision/__init__.py b/adr_kit/decision/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/decision/gate/__init__.py b/adr_kit/decision/gate/__init__.py new file mode 100644 index 0000000..7c9380a --- /dev/null +++ b/adr_kit/decision/gate/__init__.py @@ -0,0 +1,39 @@ +"""Preflight policy gate system for ADR-Kit. + +The gate system provides proactive architectural control by intercepting major +technical choices BEFORE they're implemented. This ensures agents pause for +human approval when architectural decisions are needed. + +Key components: +- PolicyGate: Main gate engine that evaluates technical choices +- TechnicalChoice: Models for representing decisions that need evaluation +- GateDecision: Result types (ALLOWED, REQUIRES_ADR, BLOCKED, CONFLICT) +- PolicyEngine: Rule evaluation engine with allow/deny lists and defaults +""" + +from .models import CategoryRule, GateConfig, GateDecision, NameMapping +from .policy_engine import PolicyConfig, PolicyEngine +from .policy_gate import GateResult, PolicyGate +from .technical_choice import ( + ChoiceType, + DependencyChoice, + FrameworkChoice, + TechnicalChoice, + create_technical_choice, +) + +__all__ = [ + "PolicyGate", + "GateDecision", + "GateResult", + "TechnicalChoice", + "ChoiceType", + "DependencyChoice", + "FrameworkChoice", + "create_technical_choice", + "PolicyEngine", + "PolicyConfig", + "GateConfig", + "CategoryRule", + "NameMapping", +] diff --git a/adr_kit/decision/gate/models.py b/adr_kit/decision/gate/models.py new file mode 100644 index 0000000..74d1816 --- /dev/null +++ b/adr_kit/decision/gate/models.py @@ -0,0 +1,204 @@ +"""Data models for the preflight policy gate system.""" + +from enum import Enum + +from pydantic import BaseModel, Field + + +class GateDecision(str, Enum): + """Possible outcomes from the preflight policy gate.""" + + ALLOWED = "allowed" # Choice is explicitly allowed, proceed + REQUIRES_ADR = "requires_adr" # Choice needs human approval via ADR + BLOCKED = "blocked" # Choice is explicitly denied + CONFLICT = "conflict" # Choice conflicts with existing ADRs + + +class CategoryRule(BaseModel): + """Rule for categorizing technical choices.""" + + category: str = Field( + ..., description="Category name (e.g., 'runtime_dependency', 'framework')" + ) + patterns: list[str] = Field(..., description="Regex patterns to match choice names") + keywords: list[str] = Field( + default_factory=list, description="Keywords that indicate this category" + ) + examples: list[str] = Field( + default_factory=list, description="Example choices in this category" + ) + + +class NameMapping(BaseModel): + """Mapping for normalizing choice names and aliases.""" + + canonical_name: str = Field(..., description="The canonical/preferred name") + aliases: list[str] = Field( + ..., description="Alternative names that map to the canonical name" + ) + + +class GateConfig(BaseModel): + """Configuration for the preflight policy gate.""" + + # Default policies + default_dependency_policy: GateDecision = Field( + GateDecision.REQUIRES_ADR, + description="Default action for new runtime dependencies", + ) + default_framework_policy: GateDecision = Field( + GateDecision.REQUIRES_ADR, + description="Default action for new frameworks/libraries", + ) + default_tool_policy: GateDecision = Field( + GateDecision.ALLOWED, description="Default action for development tools" + ) + + # Allow/deny lists + always_allow: list[str] = Field( + default_factory=list, description="Choices that are always allowed without ADR" + ) + always_deny: list[str] = Field( + default_factory=list, description="Choices that are always blocked" + ) + development_tools: list[str] = Field( + default_factory=lambda: [ + "eslint", + "prettier", + "jest", + "vitest", + "webpack", + "vite", + "typescript", + "babel", + "rollup", + "esbuild", + "parcel", + "pytest", + "black", + "mypy", + "ruff", + "pre-commit", + "husky", + "lint-staged", + "nodemon", + "concurrently", + ], + description="Development tools that typically don't need ADRs", + ) + + # Categorization rules + categories: list[CategoryRule] = Field( + default_factory=lambda: [ + CategoryRule( + category="runtime_dependency", + patterns=[r"^[^@].*", r"^@[^/]+/[^/]+$"], # Regular packages + keywords=["runtime", "production", "dependency"], + examples=["react", "express", "fastapi", "requests"], + ), + CategoryRule( + category="framework", + patterns=[r".*framework.*", r".*-cli$", r"create-.*"], + keywords=["framework", "cli", "generator", "boilerplate"], + examples=["next.js", "vue-cli", "create-react-app", "django"], + ), + CategoryRule( + category="build_tool", + patterns=[r".*webpack.*", r".*vite.*", r".*rollup.*"], + keywords=["build", "bundler", "compiler"], + examples=["webpack", "vite", "rollup", "esbuild"], + ), + ] + ) + + # Name mappings for normalization + name_mappings: list[NameMapping] = Field( + default_factory=lambda: [ + NameMapping(canonical_name="react", aliases=["reactjs", "react.js"]), + NameMapping(canonical_name="vue", aliases=["vuejs", "vue.js"]), + NameMapping( + canonical_name="@tanstack/react-query", + aliases=["react-query", "tanstack-query"], + ), + NameMapping(canonical_name="fastapi", aliases=["fast-api", "FastAPI"]), + NameMapping(canonical_name="requests", aliases=["python-requests"]), + NameMapping(canonical_name="axios", aliases=["axios-http"]), + ] + ) + + # Metadata + version: str = Field("1.0", description="Config version") + created_by: str = Field("adr-kit", description="Who created this config") + + def normalize_name(self, choice_name: str) -> str: + """Normalize a choice name using the name mappings.""" + choice_name = choice_name.lower().strip() + + for mapping in self.name_mappings: + if choice_name == mapping.canonical_name.lower(): + return mapping.canonical_name + if choice_name in [alias.lower() for alias in mapping.aliases]: + return mapping.canonical_name + + return choice_name + + def categorize_choice(self, choice_name: str) -> str | None: + """Determine the category of a technical choice.""" + import re + + normalized_name = self.normalize_name(choice_name) + + # Check if it's a development tool + if normalized_name in [tool.lower() for tool in self.development_tools]: + return "development_tool" + + # Check against category patterns + for category in self.categories: + # Check patterns + for pattern in category.patterns: + if re.match(pattern, normalized_name, re.IGNORECASE): + return category.category + + # Check keywords (in choice name) + for keyword in category.keywords: + if keyword.lower() in normalized_name.lower(): + return category.category + + # Default categorization based on common patterns + if any( + dev_tool in normalized_name + for dev_tool in ["test", "lint", "format", "build"] + ): + return "development_tool" + + # Default to runtime dependency if unclear + return "runtime_dependency" + + def to_file(self, file_path: str) -> None: + """Save configuration to a JSON file.""" + import json + + with open(file_path, "w", encoding="utf-8") as f: + json.dump(self.model_dump(exclude_none=True), f, indent=2, sort_keys=True) + + @classmethod + def from_file(cls, file_path: str) -> "GateConfig": + """Load configuration from a JSON file.""" + import json + from pathlib import Path + + path = Path(file_path) + if not path.exists(): + # Return default config if file doesn't exist - mypy needs explicit defaults + return cls( + default_dependency_policy=GateDecision.REQUIRES_ADR, + default_framework_policy=GateDecision.REQUIRES_ADR, + default_tool_policy=GateDecision.ALLOWED, + version="1.0", + created_by="adr-kit", + ) + + with open(path, encoding="utf-8") as f: + data = json.load(f) + + return cls.model_validate(data) diff --git a/adr_kit/decision/gate/policy_engine.py b/adr_kit/decision/gate/policy_engine.py new file mode 100644 index 0000000..94bc421 --- /dev/null +++ b/adr_kit/decision/gate/policy_engine.py @@ -0,0 +1,272 @@ +"""Policy engine for evaluating technical choices against gate rules.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ...contract import ConstraintsContractBuilder +from .models import GateConfig, GateDecision +from .technical_choice import ChoiceType, TechnicalChoice + + +@dataclass +class PolicyConfig: + """Configuration for the policy engine.""" + + adr_dir: Path + gate_config_path: Path | None = None + + def __post_init__(self) -> None: + if self.gate_config_path is None: + self.gate_config_path = self.adr_dir / ".adr" / "policy.json" + + +class PolicyEngine: + """Engine for evaluating technical choices against policy rules.""" + + def __init__(self, config: PolicyConfig): + self.config = config + self.gate_config = self._load_gate_config() + self.contract_builder = ConstraintsContractBuilder(config.adr_dir) + + def _load_gate_config(self) -> GateConfig: + """Load gate configuration from file or create default.""" + if self.config.gate_config_path and self.config.gate_config_path.exists(): + return GateConfig.from_file(str(self.config.gate_config_path)) + else: + # Create default config and save it - explicit defaults for mypy + default_config = GateConfig( + default_dependency_policy=GateDecision.REQUIRES_ADR, + default_framework_policy=GateDecision.REQUIRES_ADR, + default_tool_policy=GateDecision.ALLOWED, + version="1.0", + created_by="adr-kit", + ) + self._save_gate_config(default_config) + return default_config + + def _save_gate_config(self, config: GateConfig) -> None: + """Save gate configuration to file.""" + if self.config.gate_config_path: + # Ensure directory exists + self.config.gate_config_path.parent.mkdir(parents=True, exist_ok=True) + config.to_file(str(self.config.gate_config_path)) + + def evaluate_choice( + self, choice: TechnicalChoice + ) -> tuple[GateDecision, str, dict[str, Any]]: + """Evaluate a technical choice and return decision with reasoning. + + Returns: + Tuple of (decision, reasoning, metadata) + """ + # Normalize the choice name + normalized_name = self.gate_config.normalize_name(choice.name) + category = self.gate_config.categorize_choice(normalized_name) + + # Check explicit allow list first + if normalized_name in [name.lower() for name in self.gate_config.always_allow]: + return ( + GateDecision.ALLOWED, + f"'{choice.name}' is in the always-allow list", + {"category": category, "normalized_name": normalized_name}, + ) + + # Check explicit deny list + if normalized_name in [name.lower() for name in self.gate_config.always_deny]: + return ( + GateDecision.BLOCKED, + f"'{choice.name}' is in the always-deny list", + {"category": category, "normalized_name": normalized_name}, + ) + + # Check against existing constraints contract + conflict_reason = self._check_contract_conflicts(choice, normalized_name) + if conflict_reason: + return ( + GateDecision.CONFLICT, + conflict_reason, + {"category": category, "normalized_name": normalized_name}, + ) + + # Apply default policies based on category and choice type + decision = self._apply_default_policy( + choice, category or "general", normalized_name + ) + reasoning = self._get_default_policy_reasoning( + choice, category or "general", decision + ) + + return ( + decision, + reasoning, + {"category": category, "normalized_name": normalized_name}, + ) + + def _check_contract_conflicts( + self, choice: TechnicalChoice, normalized_name: str + ) -> str | None: + """Check if choice conflicts with existing constraints contract.""" + try: + # Get current constraints contract + contract = self.contract_builder.build_contract() + + # Check import constraints + if contract.constraints.imports: + # Check if choice is disallowed + if contract.constraints.imports.disallow: + for disallowed in contract.constraints.imports.disallow: + if normalized_name.lower() == disallowed.lower(): + # Find which ADR disallows it + for rule_path, provenance in contract.provenance.items(): + if rule_path == f"imports.disallow.{disallowed}": + return f"'{choice.name}' is disallowed by {provenance.adr_id}: {provenance.adr_title}" + + return ( + f"'{choice.name}' is disallowed by existing ADR policy" + ) + + # Check if there's a preferred alternative + if contract.constraints.imports.prefer: + # For dependency choices, suggest preferred alternatives + if choice.choice_type in [ + ChoiceType.DEPENDENCY, + ChoiceType.FRAMEWORK, + ]: + for preferred in contract.constraints.imports.prefer: + # If this choice serves similar purpose as a preferred one, suggest conflict + if self._are_similar_choices(normalized_name, preferred): + # Find which ADR prefers the alternative + for ( + rule_path, + provenance, + ) in contract.provenance.items(): + if rule_path == f"imports.prefer.{preferred}": + return f"'{choice.name}' conflicts with preferred choice '{preferred}' from {provenance.adr_id}: {provenance.adr_title}" + + return None + + except Exception: + # If we can't load the contract, don't block the choice + return None + + def _are_similar_choices(self, choice1: str, choice2: str) -> bool: + """Heuristic to determine if two choices serve similar purposes.""" + # Simple heuristic based on common library categories + similar_groups = [ + ["axios", "fetch", "request", "http-client"], + ["lodash", "underscore", "ramda", "remeda"], + ["moment", "dayjs", "date-fns"], + ["jest", "vitest", "mocha", "jasmine"], + ["webpack", "vite", "rollup", "esbuild", "parcel"], + ["react", "vue", "angular", "svelte"], + ["express", "koa", "fastify", "hapi"], + ["django", "flask", "fastapi"], + ] + + choice1_lower = choice1.lower() + choice2_lower = choice2.lower() + + for group in similar_groups: + if choice1_lower in group and choice2_lower in group: + return True + + return False + + def _apply_default_policy( + self, choice: TechnicalChoice, category: str, normalized_name: str + ) -> GateDecision: + """Apply default policy based on choice category.""" + + # Development tools are typically allowed by default + if category == "development_tool": + return GateDecision.ALLOWED + + # Apply policies based on choice type + if choice.choice_type == ChoiceType.DEPENDENCY: + if hasattr(choice, "is_dev_dependency") and choice.is_dev_dependency: + return self.gate_config.default_tool_policy + else: + return self.gate_config.default_dependency_policy + + elif choice.choice_type == ChoiceType.FRAMEWORK: + return self.gate_config.default_framework_policy + + elif choice.choice_type == ChoiceType.TOOL: + return self.gate_config.default_tool_policy + + else: + # For other types (architecture, language, database, etc.) + # Default to requiring ADR for major decisions + return GateDecision.REQUIRES_ADR + + def _get_default_policy_reasoning( + self, choice: TechnicalChoice, category: str, decision: GateDecision + ) -> str: + """Get human-readable reasoning for the default policy decision.""" + + if decision == GateDecision.ALLOWED: + if category == "development_tool": + return f"'{choice.name}' is categorized as a development tool and is allowed by default policy" + else: + return f"'{choice.name}' is allowed by default policy for {category}" + + elif decision == GateDecision.REQUIRES_ADR: + if choice.choice_type == ChoiceType.DEPENDENCY: + return f"New runtime dependency '{choice.name}' requires ADR approval (default policy)" + elif choice.choice_type == ChoiceType.FRAMEWORK: + return f"New framework '{choice.name}' requires ADR approval (default policy)" + else: + return f"'{choice.name}' ({choice.choice_type.value}) requires ADR approval (default policy)" + + elif decision == GateDecision.BLOCKED: + return f"'{choice.name}' is blocked by default policy" + + else: + return f"Default policy decision: {decision.value}" + + def get_config_summary(self) -> dict[str, Any]: + """Get summary of current gate configuration.""" + return { + "config_file": str(self.config.gate_config_path), + "config_exists": ( + self.config.gate_config_path.exists() + if self.config.gate_config_path + else False + ), + "default_policies": { + "dependency": self.gate_config.default_dependency_policy.value, + "framework": self.gate_config.default_framework_policy.value, + "tool": self.gate_config.default_tool_policy.value, + }, + "always_allow": self.gate_config.always_allow, + "always_deny": self.gate_config.always_deny, + "development_tools": len(self.gate_config.development_tools), + "categories": len(self.gate_config.categories), + "name_mappings": len(self.gate_config.name_mappings), + } + + def add_to_allow_list(self, choice_name: str) -> None: + """Add a choice to the always-allow list.""" + normalized = self.gate_config.normalize_name(choice_name) + if normalized not in self.gate_config.always_allow: + self.gate_config.always_allow.append(normalized) + self._save_gate_config(self.gate_config) + + def add_to_deny_list(self, choice_name: str) -> None: + """Add a choice to the always-deny list.""" + normalized = self.gate_config.normalize_name(choice_name) + if normalized not in self.gate_config.always_deny: + self.gate_config.always_deny.append(normalized) + self._save_gate_config(self.gate_config) + + def update_default_policy(self, choice_type: str, decision: GateDecision) -> None: + """Update default policy for a choice type.""" + if choice_type == "dependency": + self.gate_config.default_dependency_policy = decision + elif choice_type == "framework": + self.gate_config.default_framework_policy = decision + elif choice_type == "tool": + self.gate_config.default_tool_policy = decision + + self._save_gate_config(self.gate_config) diff --git a/adr_kit/decision/gate/policy_gate.py b/adr_kit/decision/gate/policy_gate.py new file mode 100644 index 0000000..7305109 --- /dev/null +++ b/adr_kit/decision/gate/policy_gate.py @@ -0,0 +1,348 @@ +"""Main policy gate for intercepting and evaluating technical choices.""" + +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .models import GateDecision +from .policy_engine import PolicyConfig, PolicyEngine +from .technical_choice import TechnicalChoice, create_technical_choice + + +@dataclass +class GateResult: + """Result from evaluating a technical choice through the policy gate.""" + + choice: TechnicalChoice + decision: GateDecision + reasoning: str + metadata: dict[str, Any] + evaluated_at: datetime + + @property + def should_proceed(self) -> bool: + """Whether the agent should proceed with the choice.""" + return self.decision == GateDecision.ALLOWED + + @property + def requires_human_approval(self) -> bool: + """Whether this choice requires human approval via ADR.""" + return self.decision == GateDecision.REQUIRES_ADR + + @property + def is_blocked(self) -> bool: + """Whether this choice is blocked and should not proceed.""" + return self.decision in [GateDecision.BLOCKED, GateDecision.CONFLICT] + + def get_agent_guidance(self) -> str: + """Get guidance message for the agent based on the gate result.""" + if self.decision == GateDecision.ALLOWED: + return f"✅ Approved: {self.reasoning}. You may proceed with implementing '{self.choice.name}'." + + elif self.decision == GateDecision.REQUIRES_ADR: + return ( + f"🛑 ADR Required: {self.reasoning}. Please draft an ADR for '{self.choice.name}' " + f"and request human approval before proceeding with implementation." + ) + + elif self.decision == GateDecision.BLOCKED: + return ( + f"❌ Blocked: {self.reasoning}. Do not implement '{self.choice.name}'." + ) + + elif self.decision == GateDecision.CONFLICT: + return ( + f"⚠️ Conflict: {self.reasoning}. Consider using the recommended alternative " + f"or updating existing ADRs if '{self.choice.name}' is truly needed." + ) + + else: + return f"Unknown gate decision: {self.decision.value}" + + def to_dict(self) -> dict[str, Any]: + """Convert result to dictionary for serialization.""" + return { + "choice": { + "type": self.choice.choice_type.value, + "name": self.choice.name, + "context": self.choice.context, + "alternatives_considered": self.choice.alternatives_considered, + }, + "decision": self.decision.value, + "reasoning": self.reasoning, + "metadata": self.metadata, + "evaluated_at": self.evaluated_at.isoformat(), + "should_proceed": self.should_proceed, + "requires_human_approval": self.requires_human_approval, + "is_blocked": self.is_blocked, + "agent_guidance": self.get_agent_guidance(), + } + + +class PolicyGate: + """Main policy gate for intercepting and evaluating technical choices. + + The PolicyGate is the primary interface for agents to check whether + a technical choice (dependency, framework, etc.) should proceed or + requires human approval via an ADR. + """ + + def __init__(self, adr_dir: Path, gate_config_path: Path | None = None): + self.adr_dir = Path(adr_dir) + self.config = PolicyConfig( + adr_dir=self.adr_dir, gate_config_path=gate_config_path + ) + self.engine = PolicyEngine(self.config) + + def evaluate(self, choice: TechnicalChoice) -> GateResult: + """Evaluate a technical choice through the policy gate. + + Args: + choice: The technical choice to evaluate + + Returns: + GateResult with decision and guidance + """ + decision, reasoning, metadata = self.engine.evaluate_choice(choice) + + return GateResult( + choice=choice, + decision=decision, + reasoning=reasoning, + metadata=metadata, + evaluated_at=datetime.now(timezone.utc), + ) + + def evaluate_dependency( + self, + package_name: str, + context: str, + ecosystem: str = "npm", + version_constraint: str | None = None, + is_dev_dependency: bool = False, + alternatives_considered: list[str] | None = None, + **kwargs: Any, + ) -> GateResult: + """Convenience method for evaluating dependency choices. + + Args: + package_name: Name of the package/dependency + context: Why this dependency is needed + ecosystem: Package ecosystem (npm, pypi, gem, etc.) + version_constraint: Version constraint if any + is_dev_dependency: Whether this is a dev dependency + alternatives_considered: Other options considered + + Returns: + GateResult with decision and guidance + """ + choice = create_technical_choice( + choice_type="dependency", + name=package_name, + context=context, + package_name=package_name, + ecosystem=ecosystem, + version_constraint=version_constraint, + is_dev_dependency=is_dev_dependency, + alternatives_considered=alternatives_considered or [], + **kwargs, + ) + + return self.evaluate(choice) + + def evaluate_framework( + self, + framework_name: str, + context: str, + use_case: str, + architectural_impact: str = "To be determined", + current_solution: str | None = None, + migration_required: bool = False, + alternatives_considered: list[str] | None = None, + **kwargs: Any, + ) -> GateResult: + """Convenience method for evaluating framework choices. + + Args: + framework_name: Name of the framework + context: Why this framework is needed + use_case: What the framework will be used for + architectural_impact: How it affects the architecture + current_solution: What it replaces (if any) + migration_required: Whether migration is needed + alternatives_considered: Other options considered + + Returns: + GateResult with decision and guidance + """ + choice = create_technical_choice( + choice_type="framework", + name=framework_name, + context=context, + framework_name=framework_name, + use_case=use_case, + architectural_impact=architectural_impact, + current_solution=current_solution, + migration_required=migration_required, + alternatives_considered=alternatives_considered or [], + **kwargs, + ) + + return self.evaluate(choice) + + def evaluate_from_text( + self, description: str, choice_hints: dict[str, Any] | None = None + ) -> GateResult: + """Evaluate a technical choice from text description. + + This method attempts to parse a natural language description + of a technical choice and evaluate it through the gate. + + Args: + description: Natural language description of the choice + choice_hints: Optional hints about the choice type and details + + Returns: + GateResult with decision and guidance + """ + # Parse the description to extract choice details + parsed_choice = self._parse_choice_description(description, choice_hints or {}) + + return self.evaluate(parsed_choice) + + def _parse_choice_description( + self, description: str, hints: dict[str, Any] + ) -> TechnicalChoice: + """Parse a natural language description into a TechnicalChoice. + + This is a simple heuristic parser. In a production system, + you might use more sophisticated NLP or LLM-based parsing. + """ + import re + + description_lower = description.lower() + + # Detect choice type from description + if any( + word in description_lower + for word in ["install", "add", "use package", "import", "require"] + ): + choice_type = "dependency" + elif any( + word in description_lower + for word in ["framework", "library", "adopt", "switch to"] + ): + choice_type = "framework" + else: + choice_type = hints.get("choice_type", "other") + + # Extract package/framework names (simple heuristic) + # Look for quoted names, npm packages, or known patterns + quoted_names = re.findall(r"['\"]([^'\"]+)['\"]", description) + npm_packages = re.findall( + r"@?[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+|[a-zA-Z0-9-_]+", description + ) + + # Take the first reasonable match + name = None + if quoted_names: + name = quoted_names[0] + elif npm_packages: + # Filter out common words + common_words = { + "use", + "add", + "install", + "with", + "for", + "from", + "to", + "the", + "a", + "an", + } + for package in npm_packages: + if package.lower() not in common_words and len(package) > 2: + name = package + break + + if not name: + name = hints.get("name", "unknown") + + # Create the choice + return create_technical_choice( + choice_type=choice_type, name=name, context=description, **hints + ) + + def get_gate_status(self) -> dict[str, Any]: + """Get current status of the policy gate.""" + config_summary = self.engine.get_config_summary() + + return { + "gate_ready": True, + "adr_directory": str(self.adr_dir), + "config": config_summary, + "message": "Policy gate is ready to evaluate technical choices", + } + + def get_recommendations_for_choice(self, choice_name: str) -> dict[str, Any]: + """Get recommendations for a specific choice based on existing constraints.""" + try: + contract = self.engine.contract_builder.build_contract() + + recommendations: dict[str, Any] = { + "choice_name": choice_name, + "normalized_name": self.engine.gate_config.normalize_name(choice_name), + "category": self.engine.gate_config.categorize_choice(choice_name), + "alternatives": [], + "conflicts": [], + "relevant_adrs": [], + } + + # Check for preferred alternatives + if contract.constraints.imports and contract.constraints.imports.prefer: + for preferred in contract.constraints.imports.prefer: + if self.engine._are_similar_choices(choice_name, preferred): + recommendations["alternatives"].append( + { + "name": preferred, + "reason": "Preferred by existing ADR policy", + "source_adr": self._find_source_adr( + contract, f"imports.prefer.{preferred}" + ), + } + ) + + # Check for conflicts + if contract.constraints.imports and contract.constraints.imports.disallow: + for disallowed in contract.constraints.imports.disallow: + if choice_name.lower() == disallowed.lower(): + recommendations["conflicts"].append( + { + "name": disallowed, + "reason": "Disallowed by existing ADR policy", + "source_adr": self._find_source_adr( + contract, f"imports.disallow.{disallowed}" + ), + } + ) + + # Add source ADRs + recommendations["relevant_adrs"] = contract.metadata.source_adrs + + return recommendations + + except Exception as e: + return { + "choice_name": choice_name, + "error": str(e), + "message": "Unable to get recommendations", + } + + def _find_source_adr(self, contract: Any, rule_path: str) -> str | None: + """Find the source ADR for a specific rule path.""" + for path, provenance in contract.provenance.items(): + if path == rule_path: + return str(provenance.adr_id) + return None diff --git a/adr_kit/decision/gate/technical_choice.py b/adr_kit/decision/gate/technical_choice.py new file mode 100644 index 0000000..4d593af --- /dev/null +++ b/adr_kit/decision/gate/technical_choice.py @@ -0,0 +1,206 @@ +"""Models for representing technical choices that need gate evaluation.""" + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class ChoiceType(str, Enum): + """Types of technical choices that can be evaluated by the gate.""" + + DEPENDENCY = "dependency" # Adding a new runtime dependency + FRAMEWORK = "framework" # Adopting a new framework or major library + TOOL = "tool" # Development/build tool + ARCHITECTURE = "architecture" # Architectural pattern or approach + LANGUAGE = "language" # Programming language choice + DATABASE = "database" # Database technology + CLOUD_SERVICE = "cloud_service" # Cloud service or provider + API_DESIGN = "api_design" # API design approach + OTHER = "other" # Other technical choices + + +class TechnicalChoice(BaseModel): + """Base class for representing a technical choice that needs evaluation.""" + + choice_type: ChoiceType = Field(..., description="Type of technical choice") + name: str = Field( + ..., description="Name of the choice (e.g., 'react', 'postgresql')" + ) + context: str = Field(..., description="Context or reason for the choice") + alternatives_considered: list[str] = Field( + default_factory=list, description="Other options that were considered" + ) + metadata: dict[str, Any] = Field( + default_factory=dict, description="Additional metadata about the choice" + ) + + def get_canonical_name(self) -> str: + """Get the canonical name for this choice.""" + # This will be overridden in subclasses for specific normalization + return self.name.lower().strip() + + def get_search_terms(self) -> list[str]: + """Get terms for searching existing ADRs.""" + terms = [self.name, self.get_canonical_name()] + terms.extend(self.alternatives_considered) + + # Add context keywords + import re + + context_words = re.findall(r"\b\w+\b", self.context.lower()) + terms.extend([word for word in context_words if len(word) > 3]) + + return list(set(terms)) # Remove duplicates + + def to_search_description(self) -> str: + """Create a description suitable for searching related ADRs.""" + return f"{self.choice_type.value} choice: {self.name} - {self.context}" + + +class DependencyChoice(TechnicalChoice): + """A choice about adding a runtime dependency.""" + + choice_type: ChoiceType = Field( + default=ChoiceType.DEPENDENCY, description="Always dependency" + ) + package_name: str = Field(..., description="Package/library name") + version_constraint: str | None = Field( + None, description="Version constraint (e.g., '^1.0.0')" + ) + ecosystem: str = Field(..., description="Package ecosystem (npm, pypi, gem, etc.)") + is_dev_dependency: bool = Field( + False, description="Whether this is a development dependency" + ) + replaces: list[str] | None = Field(None, description="Dependencies this replaces") + + def get_canonical_name(self) -> str: + """Get canonical package name with ecosystem normalization.""" + # Handle scoped packages + if self.package_name.startswith("@"): + return self.package_name.lower() + + # Handle ecosystem-specific normalization + if self.ecosystem == "pypi": + # Python package names are case-insensitive and use hyphens/underscores interchangeably + return self.package_name.lower().replace("_", "-") + + return self.package_name.lower() + + def get_search_terms(self) -> list[str]: + """Get dependency-specific search terms.""" + terms = super().get_search_terms() + + # Add package-specific terms + terms.append(self.package_name) + terms.append(self.get_canonical_name()) + + if self.replaces: + terms.extend(self.replaces) + + # Add ecosystem terms + terms.append(self.ecosystem) + + return list(set(terms)) + + +class FrameworkChoice(TechnicalChoice): + """A choice about adopting a framework or major architectural library.""" + + choice_type: ChoiceType = Field( + default=ChoiceType.FRAMEWORK, description="Always framework" + ) + framework_name: str = Field(..., description="Framework name") + use_case: str = Field(..., description="What this framework will be used for") + architectural_impact: str = Field( + ..., description="How this affects the overall architecture" + ) + migration_required: bool = Field( + False, description="Whether migration from existing solution is needed" + ) + current_solution: str | None = Field( + None, description="What this framework replaces" + ) + + def get_canonical_name(self) -> str: + """Get canonical framework name.""" + # Common framework name mappings + mappings = { + "reactjs": "react", + "react.js": "react", + "vuejs": "vue", + "vue.js": "vue", + "angular.js": "angularjs", + "next.js": "nextjs", + "fast-api": "fastapi", + "fastapi": "fastapi", + } + + normalized = self.framework_name.lower().strip() + return mappings.get(normalized, normalized) + + def get_search_terms(self) -> list[str]: + """Get framework-specific search terms.""" + terms = super().get_search_terms() + + # Add framework-specific terms + terms.append(self.framework_name) + terms.append(self.get_canonical_name()) + + if self.current_solution: + terms.append(self.current_solution) + + # Add use case terms + import re + + use_case_words = re.findall(r"\b\w+\b", self.use_case.lower()) + terms.extend([word for word in use_case_words if len(word) > 3]) + + return list(set(terms)) + + +# Factory function for creating technical choices +def create_technical_choice( + choice_type: str | ChoiceType, name: str, context: str, **kwargs: Any +) -> TechnicalChoice: + """Factory function to create the appropriate TechnicalChoice subclass.""" + + if isinstance(choice_type, str): + choice_type = ChoiceType(choice_type.lower()) + + if choice_type == ChoiceType.DEPENDENCY: + # Extract dependency-specific info from kwargs + return DependencyChoice( + name=name, + package_name=kwargs.get("package_name", name), + context=context, + ecosystem=kwargs.get("ecosystem", "npm"), # Default to npm + version_constraint=kwargs.get("version_constraint"), + is_dev_dependency=kwargs.get("is_dev_dependency", False), + replaces=kwargs.get("replaces"), + alternatives_considered=kwargs.get("alternatives_considered", []), + metadata=kwargs.get("metadata", {}), + ) + + elif choice_type == ChoiceType.FRAMEWORK: + return FrameworkChoice( + name=name, + framework_name=kwargs.get("framework_name", name), + context=context, + use_case=kwargs.get("use_case", context), + architectural_impact=kwargs.get("architectural_impact", "To be determined"), + migration_required=kwargs.get("migration_required", False), + current_solution=kwargs.get("current_solution"), + alternatives_considered=kwargs.get("alternatives_considered", []), + metadata=kwargs.get("metadata", {}), + ) + + else: + # Generic technical choice + return TechnicalChoice( + choice_type=choice_type, + name=name, + context=context, + alternatives_considered=kwargs.get("alternatives_considered", []), + metadata=kwargs.get("metadata", {}), + ) diff --git a/adr_kit/decision/guidance/__init__.py b/adr_kit/decision/guidance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/decision/guidance/decision_guidance.py b/adr_kit/decision/guidance/decision_guidance.py new file mode 100644 index 0000000..0135691 --- /dev/null +++ b/adr_kit/decision/guidance/decision_guidance.py @@ -0,0 +1,491 @@ +"""Decision Quality Guidance - Promptlets for high-quality ADR creation. + +This module provides comprehensive guidance for agents writing architectural decisions. +It follows the "ADR Kit provides structure, agents provide intelligence" principle by +offering focused promptlets that guide reasoning without prescribing exact outputs. +""" + +from typing import Any + + +def build_decision_guidance( + include_examples: bool = True, focus_area: str | None = None +) -> dict[str, Any]: + """Build comprehensive decision quality guidance promptlet for agents. + + This is Task 1 of the two-step ADR creation flow: + - Task 1 (this module): Guide agents to write high-quality decision content + - Task 2 (creation.py): Extract enforceable policies from the decision + + The guidance follows the reasoning-agent promptlet architecture pattern, + providing structure and letting the agent's intelligence fill in the details. + + Args: + include_examples: Whether to include good vs bad ADR examples + focus_area: Optional focus area for tailored examples (e.g., 'database', 'frontend') + + Returns: + Comprehensive promptlet with ADR structure, quality criteria, examples, and guidance + """ + guidance = { + "agent_task": { + "role": "Architectural Decision Documenter", + "objective": ( + "Document a significant technical decision with clarity, completeness, " + "and sufficient detail to enable automated policy extraction and future reasoning." + ), + "reasoning_steps": [ + "1. Understand the PROBLEM or OPPORTUNITY that prompted this decision (Context)", + "2. State the DECISION explicitly - what specific technology/pattern/approach are you choosing?", + "3. Analyze CONSEQUENCES - document both positive outcomes AND negative trade-offs", + "4. Document ALTERNATIVES - what did you consider and why did you reject each option?", + "5. Identify DECIDERS - who made or approved this choice?", + "6. Extract CONSTRAINTS - what enforceable rules emerge from this decision?", + ], + "focus": ( + "Create a decision document that is specific, actionable, complete, and " + "policy-extraction-ready. Good Task 1 output makes Task 2 (policy extraction) trivial." + ), + }, + "adr_structure": { + "overview": ( + "ADRs follow MADR (Markdown Architectural Decision Records) format with " + "four main sections. Each serves a distinct purpose in documenting architectural reasoning." + ), + "sections": { + "context": { + "purpose": "WHY this decision is needed - the problem or opportunity", + "required": True, + "what_to_include": [ + "The problem statement or opportunity being addressed", + "Current state and why it's insufficient", + "Requirements that must be met", + "Constraints or limitations to consider", + "Business or technical drivers", + ], + "what_to_avoid": [ + "Describing the solution (that's the Decision section)", + "Being too vague ('We need a database')", + "Skipping the 'why' - context must explain the need", + ], + "quality_bar": "After reading Context, someone should understand the problem without reading the Decision.", + }, + "decision": { + "purpose": "WHAT you're choosing - the specific technology, pattern, or approach", + "required": True, + "what_to_include": [ + "Explicit statement of what is being chosen", + "Specific technology names and versions if relevant", + "Explicit constraints ('Don't use X', 'Must have Y')", + "Scope of applicability ('All new services', 'Frontend only')", + ], + "what_to_avoid": [ + "Being generic ('Use a modern framework' → 'Use React 18')", + "Ambiguity about scope ('sometimes', 'maybe', 'consider')", + "Missing explicit constraints (makes policy extraction harder)", + ], + "quality_bar": "After reading Decision, it should be crystal clear what technology/approach was chosen and what's forbidden.", + }, + "consequences": { + "purpose": "Trade-offs - both POSITIVE and NEGATIVE outcomes of this decision", + "required": True, + "what_to_include": [ + "Positive consequences (benefits, improvements)", + "Negative consequences (drawbacks, limitations)", + "Risks and how they'll be mitigated", + "Impact on team, operations, or future flexibility", + "Known pitfalls or gotchas (AI-centric warnings)", + ], + "what_to_avoid": [ + "Only listing benefits (every decision has trade-offs)", + "Generic statements ('It will work well')", + "Hiding or minimizing negative consequences", + ], + "quality_bar": "Consequences should list both pros AND cons. If you see only positives, something's missing.", + "structure_tip": "Use subsections: ### Positive, ### Negative, ### Risks, ### Mitigation", + }, + "alternatives": { + "purpose": "What ELSE did you consider and WHY did you reject each option?", + "required": False, + "importance": "CRITICAL for policy extraction - rejected alternatives often become 'disallow' policies", + "what_to_include": [ + "Each alternative considered", + "Pros and cons of each", + "Specific reason for rejection", + "Under what conditions you might reconsider", + ], + "what_to_avoid": [ + "Saying 'We considered other options' without naming them", + "Not explaining WHY each was rejected", + "Unfairly dismissing alternatives", + ], + "quality_bar": "Each alternative should have a clear rejection reason that could become a policy.", + "example_structure": "### Flask\n**Rejected**: Lacks native async support.\n- Pros: ...\n- Cons: ...\n- Why not: ...", + }, + }, + }, + "quality_criteria": { + "specific": { + "description": "Use exact technology names, not generic categories", + "good": "Use PostgreSQL 15 as the primary database", + "bad": "Use a SQL database", + "why_it_matters": "Specific decisions enable precise policy extraction and clear implementation guidance", + }, + "actionable": { + "description": "Team can implement this decision immediately", + "good": "Use FastAPI for all new backend services. Migrate existing Flask services opportunistically.", + "bad": "Consider using FastAPI at some point", + "why_it_matters": "Vague decisions lead to inconsistent implementation and drift", + }, + "complete": { + "description": "All required fields filled with meaningful content", + "good": "Context explains the problem, Decision states the choice, Consequences list pros AND cons, Alternatives show what was rejected", + "bad": "Context: 'We need this.' Decision: 'Use X.' Consequences: 'It's good.'", + "why_it_matters": "Incomplete ADRs don't provide enough information for future reasoning or policy extraction", + }, + "policy_ready": { + "description": "Constraints are stated explicitly for automated extraction", + "good": "Use FastAPI. **Don't use Flask** or Django due to lack of native async support.", + "bad": "FastAPI is preferred in most cases", + "why_it_matters": "Explicit constraints ('Don't use X', 'Must have Y') enable Task 2 to extract enforceable policies", + }, + "balanced": { + "description": "Documents both benefits AND drawbacks honestly", + "good": "+ Native async support, + Auto docs, - Smaller ecosystem, - Team learning curve", + "bad": "FastAPI is perfect for everything", + "why_it_matters": "Unbalanced ADRs don't help future decision-makers understand when to reconsider", + }, + }, + "anti_patterns": { + "too_vague": { + "bad": "Use a modern web framework", + "good": "Use React 18 with TypeScript for frontend development", + "fix": "Replace generic categories with specific technology names and versions", + }, + "no_trade_offs": { + "bad": "PostgreSQL is the best database. It has ACID compliance and great performance.", + "good": "+ ACID compliance, + Great performance, + Rich features, - Higher resource usage than SQLite, - Requires operational expertise", + "fix": "Always list both positive AND negative consequences. Every decision has trade-offs.", + }, + "missing_context": { + "bad": "Decision: Use PostgreSQL", + "good": "Context: We need ACID transactions for financial data integrity and support for concurrent writes. Decision: Use PostgreSQL.", + "fix": "Explain WHY before stating WHAT. Context must justify the decision.", + }, + "no_alternatives": { + "bad": "(No alternatives section)", + "good": "### MySQL\nRejected: Weaker JSON support and extensibility vs PostgreSQL.\n### MongoDB\nRejected: Our data is highly relational, ACID compliance is critical.", + "fix": "Document what else you considered and specific reasons for rejection. This enables 'disallow' policy extraction.", + }, + "weak_constraints": { + "bad": "FastAPI is recommended for new services", + "good": "Use FastAPI for all new services. **Don't use Flask** or Django for new development.", + "fix": "Use explicit constraint language: 'Don't use', 'Must have', 'All X must Y'. This enables automated policy extraction.", + }, + }, + "example_workflow": { + "description": "How Task 1 (decision quality) enables Task 2 (policy extraction)", + "scenario": "Team needs to choose a web framework for a new API service", + "bad_adr": { + "title": "Use a web framework", + "context": "We need a framework for the API", + "decision": "Use a modern framework with good performance", + "consequences": "It will work well for our needs", + "alternatives": None, + "why_bad": [ + "Too vague - 'modern framework' could mean anything", + "No specific technology named", + "Consequences are generic platitudes", + "No alternatives documented", + "No explicit constraints for policy extraction", + ], + "task_2_result": "❌ Cannot extract any policies - no specific constraints stated", + }, + "good_adr": { + "title": "Use FastAPI for API Service", + "context": ( + "New API service requires async I/O for handling 1000+ concurrent connections. " + "Need automatic OpenAPI documentation for external partners. Team has Python experience." + ), + "decision": ( + "Use **FastAPI** as the web framework for all new backend API services. " + "**Don't use Flask or Django** for new services - they lack native async support. " + "Existing Flask services can be migrated opportunistically." + ), + "consequences": ( + "### Positive\n" + "- Native async/await support enables 10x higher concurrent connections\n" + "- Automatic OpenAPI/Swagger documentation reduces API maintenance burden\n" + "- Strong typing with Pydantic catches errors at API boundaries\n" + "- Modern Python features (3.10+) and excellent IDE support\n\n" + "### Negative\n" + "- Smaller plugin ecosystem compared to Django/Flask\n" + "- Team needs training on async/await patterns\n" + "- Async code can be harder to debug than synchronous code\n\n" + "### Risks\n" + "- Team unfamiliarity with async Python could cause subtle bugs\n\n" + "### Mitigation\n" + "- Provide async Python training (scheduled Q1 2026)\n" + "- Create internal FastAPI template with best practices" + ), + "alternatives": ( + "### Flask\n" + "**Rejected**: Lacks native async support.\n" + "- Pros: Lightweight, huge ecosystem, team familiarity\n" + "- Cons: No native async (requires Quart/ASGI), manual validation\n" + "- Why not: Async support is bolt-on, not native\n\n" + "### Django\n" + "**Rejected**: Too heavyweight for API-only services.\n" + "- Pros: Mature, batteries-included, excellent admin\n" + "- Cons: Synchronous by default, opinionated structure\n" + "- Why not: Don't need ORM or admin for API-only service" + ), + "why_good": [ + "Specific technology named (FastAPI)", + "Context explains requirements (async I/O, API docs)", + "Decision includes explicit constraints ('Don't use Flask or Django')", + "Consequences balanced (pros AND cons, risks AND mitigation)", + "Alternatives documented with clear rejection reasons", + "Policy-extraction-ready language", + ], + "task_2_result": ( + "✅ Can extract clear policies:\n" + "{'imports': {'disallow': ['flask', 'django'], 'prefer': ['fastapi']}, " + "'rationales': ['Native async support required', 'Automatic API documentation reduces maintenance']}" + ), + }, + "key_insight": ( + "Good Task 1 output (clear constraints + rejected alternatives) " + "makes Task 2 (policy extraction) trivial. The agent can directly " + "map 'Don't use Flask' to {'imports': {'disallow': ['flask']}}." + ), + }, + "connection_to_task_2": { + "overview": ( + "Task 1 (Decision Quality) and Task 2 (Policy Construction) work together. " + "The quality of your decision content directly impacts how easily policies can be extracted." + ), + "how_task_1_enables_task_2": [ + { + "decision_pattern": "Use FastAPI. Don't use Flask or Django.", + "extracted_policy": "{'imports': {'disallow': ['flask', 'django'], 'prefer': ['fastapi']}}", + "principle": "Explicit 'Don't use X' statements become 'disallow' policies", + }, + { + "decision_pattern": "All FastAPI handlers must be async functions", + "extracted_policy": "{'patterns': {'async_handlers': {'rule': 'async def', 'severity': 'error'}}}", + "principle": "'All X must be Y' statements become pattern policies", + }, + { + "decision_pattern": "Frontend must not access database directly", + "extracted_policy": "{'architecture': {'layer_boundaries': [{'rule': 'frontend -> database', 'action': 'block'}]}}", + "principle": "'X must not access Y' becomes architecture boundary", + }, + { + "decision_pattern": "TypeScript strict mode required for all frontend code", + "extracted_policy": "{'config_enforcement': {'typescript': {'tsconfig': {'strict': True}}}}", + "principle": "Config requirements become config enforcement policies", + }, + ], + "best_practices": [ + "Use explicit constraint language: 'Don't use', 'Must have', 'All X must Y'", + "Document alternatives with clear rejection reasons (enables 'disallow' extraction)", + "Be specific about technology names (not 'a modern framework', but 'React 18')", + "State scope clearly ('All new services', 'Frontend only')", + ], + }, + "dos_and_donts": { + "dos": [ + "✅ Use specific technology names and versions", + "✅ Document both positive AND negative consequences", + "✅ Explain WHY in Context before stating WHAT in Decision", + "✅ List alternatives with clear rejection reasons", + "✅ Use explicit constraint language ('Don't use', 'Must have')", + "✅ Include risks and mitigation strategies", + "✅ State scope of applicability clearly", + "✅ Identify who made the decision (deciders)", + ], + "donts": [ + "❌ Don't be vague or generic ('Use a modern framework')", + "❌ Don't only list benefits - every decision has trade-offs", + "❌ Don't skip Context - explain the problem first", + "❌ Don't forget Alternatives - they become 'disallow' policies", + "❌ Don't use weak language ('consider', 'maybe', 'sometimes')", + "❌ Don't hide negative consequences or risks", + "❌ Don't make decisions sound perfect - honest trade-offs matter", + ], + }, + "next_steps": [ + "1. Follow this guidance to draft your ADR content", + "2. Use adr_create() with your title, context, decision, consequences, and alternatives", + "3. Review the policy_guidance in the response to construct enforcement policies (Task 2)", + "4. Call adr_create() again with the policy parameter if you want automated enforcement", + ], + } + + # Add examples if requested + if include_examples: + guidance["examples"] = _build_examples(focus_area) + + return guidance + + +def _build_examples(focus_area: str | None = None) -> dict[str, Any]: + """Build good vs bad ADR examples. + + Args: + focus_area: Optional focus to tailor examples (e.g., 'database', 'frontend') + + Returns: + Dictionary with categorized examples + """ + examples = { + "database": { + "good": { + "title": "Use PostgreSQL for Primary Database", + "context": ( + "Application requires ACID transactions for financial data integrity. " + "Need support for complex queries with joins, concurrent writes from multiple services, " + "and JSON document storage for flexible user metadata. Team has SQL experience." + ), + "decision": ( + "Use **PostgreSQL 15** as the primary database for all application data. " + "**Don't use MySQL** (weaker JSON support) or **MongoDB** (eventual consistency conflicts with financial requirements). " + "Deploy on AWS RDS with Multi-AZ for high availability." + ), + "consequences": ( + "### Positive\n" + "- ACID compliance guarantees data consistency for transactions\n" + "- Rich feature set: JSON, full-text search, advanced indexing\n" + "- Excellent query planner handles complex joins efficiently\n" + "- Mature tooling and ecosystem\n\n" + "### Negative\n" + "- Higher resource usage (memory/CPU) than simpler databases\n" + "- Requires operational expertise for tuning and maintenance\n" + "- Vertical scaling limits (single-server architecture)\n\n" + "### Risks & Mitigation\n" + "- Risk: Poor indexing causes performance issues at scale\n" + "- Mitigation: Use connection pooling (PgBouncer), monitor with pg_stat_statements" + ), + "alternatives": ( + "### MySQL\n" + "**Rejected**: Weaker JSON support and extensibility compared to PostgreSQL.\n\n" + "### MongoDB\n" + "**Rejected**: Eventual consistency model conflicts with financial transaction requirements. " + "ACID transactions added in 4.0 but less mature than PostgreSQL." + ), + }, + "bad": { + "title": "Use a Database", + "context": "We need to store data", + "decision": "Use PostgreSQL", + "consequences": "PostgreSQL is good for data storage", + "alternatives": None, + }, + }, + "frontend": { + "good": { + "title": "Use React 18 with TypeScript for Frontend", + "context": ( + "Building complex interactive dashboard with real-time data updates. " + "Need component reusability, strong typing to catch errors early, and excellent developer tooling. " + "Team has JavaScript experience but new to TypeScript." + ), + "decision": ( + "Use **React 18** with **TypeScript** for all frontend development. " + "**Don't use Vue or Angular** - smaller ecosystems and steeper learning curves for our use case. " + "All new components must be written in TypeScript with strict mode enabled." + ), + "consequences": ( + "### Positive\n" + "- Huge ecosystem of components and libraries\n" + "- TypeScript catches errors at compile time, reducing runtime bugs\n" + "- Concurrent features in React 18 improve perceived performance\n" + "- Excellent IDE support and developer experience\n\n" + "### Negative\n" + "- TypeScript learning curve for team\n" + "- More boilerplate than plain JavaScript\n" + "- React hooks mental model takes time to master\n\n" + "### Risks & Mitigation\n" + "- Risk: Team struggles with TypeScript\n" + "- Mitigation: 2-week TypeScript training, pair programming on first components" + ), + "alternatives": ( + "### Vue 3\n" + "**Rejected**: Smaller ecosystem, less corporate backing than React.\n\n" + "### Angular\n" + "**Rejected**: Steep learning curve, very opinionated, our team has React experience not Angular." + ), + }, + "bad": { + "title": "Use a Frontend Framework", + "context": "We need to build a UI", + "decision": "Use React because it's popular", + "consequences": "React will work well", + "alternatives": None, + }, + }, + "generic": { + "good": { + "title": "Use FastAPI for Backend API Services", + "context": ( + "Building API service for mobile app with 1000+ concurrent users. " + "Need automatic API documentation for mobile team, async I/O for performance, " + "and strong typing for reliability. Team knows Python." + ), + "decision": ( + "Use **FastAPI** for all new backend API services. " + "**Don't use Flask** (no native async) or **Django** (too heavyweight for API-only). " + "Existing Flask services can migrate opportunistically." + ), + "consequences": ( + "### Positive\n" + "- Native async/await for 10x better concurrent performance\n" + "- Automatic OpenAPI docs reduce coordination overhead with mobile team\n" + "- Pydantic validation catches errors at API boundaries\n\n" + "### Negative\n" + "- Smaller ecosystem than Flask/Django\n" + "- Team needs async Python training\n" + "- Debugging async code is harder\n\n" + "### Mitigation\n" + "- Async Python training scheduled Q1 2026\n" + "- Internal template with best practices" + ), + "alternatives": ( + "### Flask\n" + "**Rejected**: No native async support, would require Quart/ASGI.\n\n" + "### Django\n" + "**Rejected**: Too heavyweight for API-only service, don't need ORM/admin." + ), + }, + "bad": { + "title": "Use Python Web Framework", + "context": "Need backend framework", + "decision": "Use FastAPI", + "consequences": "FastAPI is fast and modern", + "alternatives": None, + }, + }, + } + + # Return focused examples if specified + if focus_area and focus_area in examples: + return { + "focus": focus_area, + "good_example": examples[focus_area]["good"], + "bad_example": examples[focus_area]["bad"], + "comparison": ( + "Notice how the good example is specific, documents trade-offs, " + "includes alternatives with rejection reasons, and uses explicit constraint language." + ), + } + + # Return all examples + return { + "by_category": examples, + "comparison": ( + "Good examples are specific, document both pros and cons, explain context thoroughly, " + "list alternatives with clear rejection reasons, and use explicit constraint language. " + "Bad examples are vague, incomplete, and don't provide enough information for policy extraction." + ), + } diff --git a/adr_kit/decision/workflows/__init__.py b/adr_kit/decision/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/decision/workflows/analyze.py b/adr_kit/decision/workflows/analyze.py new file mode 100644 index 0000000..42b7e29 --- /dev/null +++ b/adr_kit/decision/workflows/analyze.py @@ -0,0 +1,580 @@ +"""Analyze Project Workflow - For existing projects wanting to adopt ADR-Kit.""" + +import os +from collections import Counter +from pathlib import Path +from typing import Any + +from ...core.parse import find_adr_files +from .base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus + + +class AnalyzeProjectWorkflow(BaseWorkflow): + """Workflow for analyzing existing projects and generating agent prompts. + + This workflow is pure automation that: + 1. Scans project structure and detects technologies + 2. Checks for existing ADR setup + 3. Generates intelligent prompts for agents to analyze architectural decisions + + The agent provides ALL intelligence - this workflow just provides data and prompts. + """ + + def execute(self, **kwargs: Any) -> WorkflowResult: + """Execute project analysis workflow. + + Args: + **kwargs: Keyword arguments that should contain: + project_path: Path to project root (defaults to current directory) + focus_areas: Optional focus areas like ["dependencies", "patterns", "architecture"] + + Returns: + WorkflowResult with detected technologies and analysis prompt for agent + """ + # Extract parameters from kwargs + project_path = kwargs.get("project_path") + focus_areas = kwargs.get("focus_areas") + + self._start_workflow("Analyze Project") + + try: + # Step 1: Validate inputs and setup + project_root = self._execute_step( + "validate_inputs", self._validate_inputs, project_path + ) + + # Step 1.5: Validate ADR directory (but allow creation if needed) + self._execute_step( + "validate_adr_directory", self._validate_adr_directory_for_analysis + ) + + # Step 2: Scan project structure + project_structure = self._execute_step( + "scan_project_structure", self._scan_project_structure, project_root + ) + + # Step 3: Detect technologies + detected_technologies = self._execute_step( + "detect_technologies", + self._detect_technologies, + project_root, + project_structure, + ) + + # Step 4: Check existing ADR setup + existing_adr_info = self._execute_step( + "check_existing_adrs", self._check_existing_adrs, project_root + ) + + # Step 5: Generate analysis prompt for agent + analysis_prompt = self._execute_step( + "generate_analysis_prompt", + self._generate_analysis_prompt, + detected_technologies, + existing_adr_info, + focus_areas, + ) + + # Build project context summary + project_context = { + "technologies": detected_technologies["technologies"], + "confidence_scores": detected_technologies["confidence_scores"], + "project_structure": project_structure, + "existing_adrs": { + "count": existing_adr_info["adr_count"], + "directory": ( + str(existing_adr_info["adr_directory"]) + if existing_adr_info["adr_directory"] + else None + ), + "files": existing_adr_info["adr_files"], + }, + "suggested_focus": analysis_prompt["suggested_focus"], + } + + # Set workflow output data + self._set_workflow_data( + detected_technologies=detected_technologies["technologies"], + technology_confidence=detected_technologies["confidence_scores"], + existing_adr_count=existing_adr_info["adr_count"], + existing_adr_directory=( + str(existing_adr_info["adr_directory"]) + if existing_adr_info["adr_directory"] + else None + ), + analysis_prompt=analysis_prompt["prompt"], + suggested_focus=analysis_prompt["suggested_focus"], + project_context=project_context, + ) + + # Add agent guidance + if existing_adr_info["adr_count"] > 0: + guidance = f"Found {existing_adr_info['adr_count']} existing ADRs. Use the analysis prompt to identify missing architectural decisions and create additional ADRs as needed." + next_steps = [ + "Follow the analysis prompt to review existing architectural decisions", + "Identify architectural decisions not yet documented", + "Use adr_create() for each new architectural decision you identify", + "Consider updating existing ADRs if they're incomplete", + ] + else: + guidance = "No existing ADRs found. Use the analysis prompt to identify all architectural decisions and create initial ADR set." + next_steps = [ + "Follow the analysis prompt to analyze the entire project architecture", + "Identify all significant architectural decisions made in the project", + "Use adr_create() for each architectural decision you identify", + "Start with the most fundamental decisions (framework choices, data layer, etc.)", + ] + + self._add_agent_guidance(guidance, next_steps) + + self._complete_workflow( + success=True, + message=f"Project analysis completed - found {len(detected_technologies['technologies'])} technologies", + status=WorkflowStatus.SUCCESS, + ) + + except WorkflowError as e: + self.result.add_error(str(e)) + self._complete_workflow( + success=False, + message="Project analysis failed", + status=WorkflowStatus.FAILED, + ) + except Exception as e: + self.result.add_error(f"Unexpected error: {str(e)}") + self._complete_workflow( + success=False, + message=f"Project analysis failed: {str(e)}", + status=WorkflowStatus.FAILED, + ) + + return self.result + + def _validate_inputs(self, project_path: str | None) -> Path: + """Validate inputs and return project root path.""" + if project_path: + project_root = Path(project_path) + else: + project_root = Path.cwd() + + if not project_root.exists(): + raise WorkflowError(f"Project path does not exist: {project_root}") + + if not project_root.is_dir(): + raise WorkflowError(f"Project path is not a directory: {project_root}") + + return project_root + + def _validate_adr_directory_for_analysis(self) -> None: + """Validate ADR directory for analysis workflow.""" + # For analysis, we need the parent directory to exist so we can potentially create ADR directory + parent_dir = self.adr_dir.parent + if not parent_dir.exists(): + raise WorkflowError(f"ADR parent directory does not exist: {parent_dir}") + + # If ADR directory exists, it must be a directory and writable + if self.adr_dir.exists(): + if not self.adr_dir.is_dir(): + raise WorkflowError(f"ADR path is not a directory: {self.adr_dir}") + + # Check if we can write to the directory + try: + test_file = self.adr_dir / ".adr_kit_test" + test_file.touch() + test_file.unlink() + except Exception as e: + raise WorkflowError( + f"Cannot write to ADR directory: {self.adr_dir} - {e}" + ) from e + + def _scan_project_structure(self, project_root: Path) -> dict[str, Any]: + """Scan project structure to understand layout and files.""" + + structure: dict[str, Any] = { + "total_files": 0, + "directories": [], + "file_types": Counter(), + "config_files": [], + "package_managers": [], + "common_directories": [], + } + + # Define patterns to look for + config_patterns = [ + "package.json", + "package-lock.json", + "yarn.lock", + "requirements.txt", + "setup.py", + "pyproject.toml", + "Pipfile", + "Cargo.toml", + "go.mod", + "build.gradle", + "pom.xml", + ".eslintrc*", + "tsconfig.json", + "webpack.config.*", + "Dockerfile", + "docker-compose.yml", + ".env*", + ] + + common_dir_patterns = [ + "src", + "lib", + "app", + "components", + "services", + "utils", + "test", + "tests", + "spec", + "__tests__", + "docs", + "documentation", + "config", + "configs", + "settings", + ] + + try: + for root, dirs, files in os.walk(project_root): + # Skip hidden directories and common ignore patterns + dirs[:] = [ + d + for d in dirs + if not d.startswith(".") + and d + not in ["node_modules", "__pycache__", "target", "build", "dist"] + ] + + structure["directories"].extend([Path(root) / d for d in dirs]) + + for file in files: + if not file.startswith("."): + structure["total_files"] += 1 + + # Track file extensions + suffix = Path(file).suffix.lower() + if suffix: + structure["file_types"][suffix] += 1 + + # Check for config files + for pattern in config_patterns: + if file.startswith(pattern.replace("*", "")): + structure["config_files"].append(str(Path(root) / file)) + + # Check for common directories + for dir_name in dirs: + if dir_name.lower() in common_dir_patterns: + structure["common_directories"].append( + str(Path(root) / dir_name) + ) + + except Exception as e: + raise WorkflowError(f"Failed to scan project structure: {e}") from e + + return structure + + def _detect_technologies( + self, project_root: Path, structure: dict[str, Any] + ) -> dict[str, Any]: + """Detect technologies based on project structure and files.""" + + technologies = [] + confidence_scores = {} + + # Technology detection patterns + tech_patterns = { + # Frontend frameworks + "React": { + "files": ["package.json"], + "content_patterns": ['"react":', "import React", 'from "react"'], + "extensions": [".jsx", ".tsx"], + }, + "Vue": { + "files": ["package.json"], + "content_patterns": ['"vue":', "import Vue", ".vue"], + "extensions": [".vue"], + }, + "Angular": { + "files": ["package.json", "angular.json"], + "content_patterns": ['"@angular/', "import { Component }"], + "extensions": [".ts"], + }, + # Backend frameworks + "Express.js": { + "files": ["package.json"], + "content_patterns": [ + '"express":', + 'require("express")', + "import express", + ], + }, + "FastAPI": { + "files": ["requirements.txt", "pyproject.toml"], + "content_patterns": ["fastapi", "from fastapi import"], + }, + "Django": { + "files": ["requirements.txt", "manage.py"], + "content_patterns": ["django", "DJANGO_SETTINGS_MODULE"], + }, + "Flask": { + "files": ["requirements.txt"], + "content_patterns": ["flask", "from flask import"], + }, + # Languages + "TypeScript": { + "files": ["tsconfig.json", "package.json"], + "content_patterns": ['"typescript":', '"@types/'], + "extensions": [".ts", ".tsx"], + }, + "Python": { + "extensions": [".py"], + "files": ["requirements.txt", "setup.py", "pyproject.toml"], + }, + "JavaScript": {"extensions": [".js", ".jsx"], "files": ["package.json"]}, + "Rust": {"files": ["Cargo.toml"], "extensions": [".rs"]}, + "Go": {"files": ["go.mod", "go.sum"], "extensions": [".go"]}, + # Databases + "PostgreSQL": { + "content_patterns": ["postgresql", "psycopg2", "pg", "postgres"] + }, + "MySQL": {"content_patterns": ["mysql", "pymysql", "mysql2"]}, + "MongoDB": {"content_patterns": ["mongodb", "mongoose", "pymongo"]}, + "Redis": {"content_patterns": ["redis", "ioredis"]}, + # Tools + "Docker": { + "files": ["Dockerfile", "docker-compose.yml", "docker-compose.yaml"] + }, + "Webpack": { + "files": ["webpack.config.js", "webpack.config.ts"], + "content_patterns": ['"webpack":'], + }, + "Vite": { + "files": ["vite.config.js", "vite.config.ts"], + "content_patterns": ['"vite":'], + }, + } + + # Check each technology + for tech_name, patterns in tech_patterns.items(): + confidence: float = 0.0 + + # Check file extensions + if "extensions" in patterns: + for ext in patterns["extensions"]: + if ( + ext in structure["file_types"] + and structure["file_types"][ext] > 0 + ): + confidence += min(structure["file_types"][ext] * 0.1, 0.5) + + # Check specific files + if "files" in patterns: + for file_pattern in patterns["files"]: + for config_file in structure["config_files"]: + if file_pattern in config_file: + confidence += 0.3 + + # Check content patterns if available + if "content_patterns" in patterns: + try: + content = Path(config_file).read_text( + encoding="utf-8" + ) + for pattern in patterns["content_patterns"]: + if pattern in content: + confidence += 0.2 + except Exception: + pass # Skip file read errors + + # Check content patterns in all relevant files + if "content_patterns" in patterns and confidence == 0: + # Do a broader search if we haven't found anything yet + try: + for config_file in structure["config_files"][:10]: # Limit search + content = Path(config_file).read_text(encoding="utf-8") + for pattern in patterns["content_patterns"]: + if pattern in content: + confidence += 0.1 + break + except Exception: + pass + + # Add technology if confidence is high enough + if confidence >= 0.3: + technologies.append(tech_name) + confidence_scores[tech_name] = min(confidence, 1.0) + + return {"technologies": technologies, "confidence_scores": confidence_scores} + + def _check_existing_adrs(self, project_root: Path) -> dict[str, Any]: + """Check if project already has ADRs set up.""" + + # Common ADR directory locations (relative to project root) + possible_adr_dirs = [ + project_root / "docs" / "adr", + project_root / "docs" / "adrs", + project_root / "docs" / "decisions", + project_root / "adr", + project_root / "adrs", + project_root / "decisions", + project_root / "architecture" / "decisions", + ] + + # Also check the configured ADR directory + if self.adr_dir not in possible_adr_dirs: + possible_adr_dirs.append(self.adr_dir) + + existing_adr_info: dict[str, Any] = { + "adr_directory": None, + "adr_count": 0, + "adr_files": [], + } + + for adr_dir in possible_adr_dirs: + if adr_dir.exists() and adr_dir.is_dir(): + try: + adr_files = find_adr_files(adr_dir) + if adr_files: + existing_adr_info["adr_directory"] = str(adr_dir) + existing_adr_info["adr_count"] = len(adr_files) + existing_adr_info["adr_files"] = [str(f) for f in adr_files] + break + except Exception: + continue # Skip directories we can't read + + return existing_adr_info + + def _generate_analysis_prompt( + self, + detected_technologies: dict[str, Any], + existing_adr_info: dict[str, Any], + focus_areas: list[str] | None, + ) -> dict[str, Any]: + """Generate analysis prompt for the agent.""" + + technologies = detected_technologies["technologies"] + adr_count = existing_adr_info["adr_count"] + + # Build context-aware prompt + prompt_parts = [ + "Please analyze this project for architectural decisions that should be documented as ADRs.", + "", + "**Project Context:**", + f"- Detected technologies: {', '.join(technologies) if technologies else 'Unable to detect specific technologies'}", + ( + f"- Existing ADRs: {adr_count} found" + if adr_count > 0 + else "- No existing ADRs found" + ), + "", + ] + + if adr_count > 0: + prompt_parts.extend( + [ + "**Analysis Focus:**", + "1. Review existing ADRs to understand what's already documented", + "2. Identify architectural decisions that are missing from the ADR set", + "3. Look for inconsistencies between code and documented decisions", + "4. Propose new ADRs for undocumented architectural choices", + "", + ] + ) + else: + prompt_parts.extend( + [ + "**Analysis Focus:**", + "1. Identify all significant architectural decisions made in this project", + "2. Focus on framework choices, data architecture, API design, and deployment patterns", + "3. Look for established conventions and patterns in the codebase", + "4. Propose ADRs for each major architectural decision you identify", + "", + ] + ) + + # Add technology-specific guidance + if technologies: + prompt_parts.extend(["**Technology-Specific Areas to Examine:**"]) + + for tech in technologies: + if tech in ["React", "Vue", "Angular"]: + prompt_parts.append( + f"- {tech}: Component architecture, state management, routing decisions" + ) + elif tech in ["Express.js", "FastAPI", "Django", "Flask"]: + prompt_parts.append( + f"- {tech}: API design patterns, middleware choices, authentication strategy" + ) + elif tech in ["PostgreSQL", "MySQL", "MongoDB"]: + prompt_parts.append( + f"- {tech}: Database schema design, migration strategy, connection patterns" + ) + elif tech == "TypeScript": + prompt_parts.append( + f"- {tech}: Type system usage, configuration choices, strict mode settings" + ) + elif tech == "Docker": + prompt_parts.append( + f"- {tech}: Containerization strategy, multi-stage builds, orchestration" + ) + + prompt_parts.append("") + + # Add focus area guidance if provided + if focus_areas: + prompt_parts.extend( + [ + "**Specific Focus Areas Requested:**", + *[f"- {area.title()}" for area in focus_areas], + "", + ] + ) + + # Add action guidance + prompt_parts.extend( + [ + "**Instructions:**", + "1. Examine the codebase thoroughly for architectural patterns and decisions", + "2. For each significant architectural decision you identify:", + " - Consider the context that led to this decision", + " - Identify the alternatives that were likely considered", + " - Understand the consequences and trade-offs", + " - Draft an ADR using adr_create() with comprehensive rationale", + "", + "3. Focus on decisions that:", + " - Affect multiple parts of the system", + " - Have significant impact on development workflow", + " - Establish patterns other developers should follow", + " - Involve technology choices or architectural patterns", + "", + "4. Wait for human approval before accepting each proposed ADR", + "", + "Start your analysis now and propose ADRs for the architectural decisions you discover.", + ] + ) + + prompt = "\n".join(prompt_parts) + + # Generate suggested focus areas based on detected technologies + suggested_focus = [] + if any(tech in technologies for tech in ["React", "Vue", "Angular"]): + suggested_focus.append("frontend_architecture") + if any( + tech in technologies + for tech in ["Express.js", "FastAPI", "Django", "Flask"] + ): + suggested_focus.append("api_design") + if any( + tech in technologies for tech in ["PostgreSQL", "MySQL", "MongoDB", "Redis"] + ): + suggested_focus.append("data_architecture") + if "Docker" in technologies: + suggested_focus.append("deployment_strategy") + if "TypeScript" in technologies: + suggested_focus.append("type_system") + + return {"prompt": prompt, "suggested_focus": suggested_focus} diff --git a/adr_kit/decision/workflows/approval.py b/adr_kit/decision/workflows/approval.py new file mode 100644 index 0000000..63d7754 --- /dev/null +++ b/adr_kit/decision/workflows/approval.py @@ -0,0 +1,746 @@ +"""Approval Workflow - Approve ADR and trigger complete automation pipeline.""" + +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from ...contract.builder import ConstraintsContractBuilder +from ...core.model import ADR +from ...core.parse import find_adr_files, parse_adr_file +from ...core.validate import validate_adr +from ...enforcement.adapters.eslint import generate_eslint_config +from ...enforcement.adapters.ruff import generate_ruff_config +from ...enforcement.config.manager import GuardrailManager +from ...index.json_index import generate_adr_index +from .base import BaseWorkflow, WorkflowResult + + +@dataclass +class ApprovalInput: + """Input for ADR approval workflow.""" + + adr_id: str + digest_check: bool = True # Whether to verify content digest hasn't changed + force_approve: bool = False # Override conflicts and warnings + approval_notes: str | None = None # Human approval notes + + +@dataclass +class ApprovalResult: + """Result of ADR approval.""" + + adr_id: str + previous_status: str + new_status: str + content_digest: str # SHA-256 hash of approved content + automation_results: dict[str, Any] # Results from triggered automation + policy_rules_applied: int # Number of policy rules applied + configurations_updated: list[str] # List of config files updated + warnings: list[str] # Non-blocking warnings + next_steps: str # Guidance for what happens next + + +class ApprovalWorkflow(BaseWorkflow): + """ + Approval Workflow handles ADR approval and triggers comprehensive automation. + + This is the most complex workflow as it orchestrates the entire ADR ecosystem + when a decision is approved. All policy enforcement, configuration updates, + and validation happens here. + + Workflow Steps: + 1. Load and validate the ADR to be approved + 2. Verify content integrity (digest check) + 3. Update ADR status to 'accepted' + 4. Rebuild constraints contract with new ADR + 5. Apply guardrails and update configurations + 6. Generate enforcement rules (ESLint, Ruff, etc.) + 7. Update indexes and catalogs + 8. Validate codebase against new policies + 9. Generate comprehensive approval report + """ + + def execute(self, **kwargs: Any) -> WorkflowResult: + """Execute comprehensive ADR approval workflow.""" + # Extract input_data from kwargs + input_data = kwargs.get("input_data") + if not input_data or not isinstance(input_data, ApprovalInput): + raise ValueError("input_data must be provided as ApprovalInput instance") + + self._start_workflow("Approve ADR") + + try: + # Step 1: Load and validate ADR + adr, file_path = self._execute_step( + "load_adr", self._load_adr_for_approval, input_data.adr_id + ) + self._execute_step( + "validate_preconditions", + self._validate_approval_preconditions, + adr, + input_data, + ) + + previous_status = adr.status + + # Step 2: Content integrity check + if input_data.digest_check: + content_digest = self._execute_step( + "check_content_integrity", + self._calculate_content_digest, + str(file_path), + ) + else: + content_digest = "skipped" + + # Step 3: Update ADR status + updated_adr = self._execute_step( + "update_adr_status", + self._update_adr_status, + adr, + str(file_path), + input_data, + ) + + # Step 4: Rebuild constraints contract + contract_result = self._execute_step( + "rebuild_constraints_contract", self._rebuild_constraints_contract + ) + + # Step 5: Apply guardrails + guardrail_result = self._execute_step( + "apply_guardrails", self._apply_guardrails, updated_adr + ) + + # Step 6: Generate enforcement rules + enforcement_result = self._execute_step( + "generate_enforcement_rules", + self._generate_enforcement_rules, + updated_adr, + ) + + # Step 7: Update indexes + index_result = self._execute_step("update_indexes", self._update_indexes) + + # Step 8: Validate codebase (optional, can be time-consuming) + validation_result = self._execute_step( + "validate_codebase_compliance", + self._validate_codebase_compliance, + updated_adr, + ) + + # Step 9: Generate approval report + automation_results = { + "status_update": { + "success": True, + "old_status": previous_status, + "new_status": "accepted", + }, + "contract_rebuild": contract_result, + "guardrail_application": guardrail_result, + "enforcement_generation": enforcement_result, + "index_update": index_result, + "codebase_validation": validation_result, + } + + approval_report = self._execute_step( + "generate_approval_report", + self._generate_approval_report, + updated_adr, + automation_results, + content_digest, + input_data, + ) + + result = ApprovalResult( + adr_id=input_data.adr_id, + previous_status=previous_status, + new_status="accepted", + content_digest=content_digest, + automation_results=automation_results, + policy_rules_applied=self._count_policy_rules_applied( + automation_results + ), + configurations_updated=self._extract_updated_configurations( + automation_results + ), + warnings=approval_report.get("warnings", []), + next_steps=approval_report.get("next_steps", ""), + ) + + self._complete_workflow( + success=True, + message=f"ADR {input_data.adr_id} approved and automation completed", + ) + self.result.data = { + "approval_result": result, + "full_report": approval_report, + } + self.result.guidance = approval_report.get("guidance", "") + self.result.next_steps = approval_report.get( + "next_steps_list", + [ + f"ADR {input_data.adr_id} is now active", + "Review automation results for any issues", + "Monitor compliance with generated policy rules", + ], + ) + + except Exception as e: + self._complete_workflow( + success=False, + message=f"Approval workflow failed: {str(e)}", + ) + self.result.add_error(f"ApprovalError: {str(e)}") + + return self.result + + def _load_adr_for_approval(self, adr_id: str) -> tuple[ADR, Path]: + """Load the ADR that needs to be approved.""" + adr_files = find_adr_files(self.adr_dir) + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path) + if adr.id == adr_id: + return adr, file_path + except Exception: + continue + + raise ValueError(f"ADR {adr_id} not found in {self.adr_dir}") + + def _validate_approval_preconditions( + self, adr: ADR, input_data: ApprovalInput + ) -> None: + """Validate that ADR can be approved.""" + + # Check current status + if adr.status == "accepted": + if not input_data.force_approve: + raise ValueError(f"ADR {adr.id} is already approved") + + if adr.status == "superseded": + raise ValueError(f"ADR {adr.id} is superseded and cannot be approved") + + # Validate ADR structure + # Note: validate_adr signature is (adr, schema_path, project_root) + # Pass None for schema_path to use default, and self.adr_dir.parent as project_root + validation_result = validate_adr( + adr, + schema_path=None, + project_root=self.adr_dir.parent if self.adr_dir else None, + ) + if not validation_result.is_valid and not input_data.force_approve: + errors = [str(error) for error in validation_result.errors] + raise ValueError(f"ADR {adr.id} has validation errors: {', '.join(errors)}") + + def _calculate_content_digest(self, file_path: str) -> str: + """Calculate SHA-256 digest of ADR content for integrity checking.""" + with open(file_path, "rb") as f: + content = f.read() + return hashlib.sha256(content).hexdigest() + + def _update_adr_status( + self, adr: ADR, file_path: str, input_data: ApprovalInput + ) -> ADR: + """Update ADR status to accepted and write back to file.""" + + # Read current file content + with open(file_path, encoding="utf-8") as f: + content = f.read() + + # Update status in YAML front-matter + import re + + # Find status line and replace it + status_pattern = r"^status:\s*\w+$" + new_content = re.sub( + status_pattern, "status: accepted", content, flags=re.MULTILINE + ) + + # Add approval metadata if notes provided + if input_data.approval_notes: + # Find end of YAML front-matter + yaml_end = new_content.find("\n---\n") + if yaml_end != -1: + approval_metadata = ( + f'approval_date: {datetime.now().strftime("%Y-%m-%d")}\n' + ) + if input_data.approval_notes: + approval_metadata += ( + f'approval_notes: "{input_data.approval_notes}"\n' + ) + + # Insert before the closing --- + new_content = ( + new_content[:yaml_end] + approval_metadata + new_content[yaml_end:] + ) + + # Write updated content + with open(file_path, "w", encoding="utf-8") as f: + f.write(new_content) + + # Return updated ADR object - create a new one with updated status + from ...core.model import ADRStatus + + updated_front_matter = adr.front_matter.model_copy( + update={"status": ADRStatus.ACCEPTED} + ) + updated_adr = ADR( + front_matter=updated_front_matter, + content=adr.content, + file_path=adr.file_path, + ) + return updated_adr + + def _rebuild_constraints_contract(self) -> dict[str, Any]: + """Rebuild the constraints contract with all approved ADRs.""" + try: + builder = ConstraintsContractBuilder(adr_dir=self.adr_dir) + contract = builder.build() + + return { + "success": True, + "approved_adrs": len(contract.approved_adrs), + "constraints_exist": not contract.constraints.is_empty(), + "constraints": 1 if not contract.constraints.is_empty() else 0, + "message": "Constraints contract rebuilt successfully", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to rebuild constraints contract", + } + + def _apply_guardrails(self, adr: ADR) -> dict[str, Any]: + """Apply guardrails based on the approved ADR.""" + try: + # Apply guardrails using GuardrailManager + GuardrailManager(adr_dir=Path(self.adr_dir)) + + # This is a simplified implementation - would need to be enhanced + # to fully integrate with the GuardrailManager's apply methods + + return { + "success": True, + "guardrails_applied": 0, # Simplified for now + "configurations_updated": [], + "message": "Guardrails system initialized (simplified implementation)", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to apply guardrails", + } + + def _generate_enforcement_rules(self, adr: ADR) -> dict[str, Any]: + """Generate enforcement rules (ESLint, Ruff, git hooks) from ADR policies.""" + results = {} + + try: + # Generate ESLint rules if JavaScript/TypeScript policies exist + if self._has_javascript_policies(adr): + eslint_result = self._generate_eslint_rules(adr) + results["eslint"] = eslint_result + + # Generate Ruff rules if Python policies exist + if self._has_python_policies(adr): + ruff_result = self._generate_ruff_rules(adr) + results["ruff"] = ruff_result + + # Generate standalone validation scripts + scripts_result = self._generate_validation_scripts(adr) + results["scripts"] = scripts_result + + # Always update git hooks so staged enforcement reflects new rules + hooks_result = self._update_git_hooks() + results["hooks"] = hooks_result + + return { + "success": True, + "rule_generators": list(results.keys()), + "details": results, + "message": "Enforcement rules generated successfully", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to generate enforcement rules", + } + + def _generate_validation_scripts(self, adr: ADR) -> dict[str, Any]: + """Generate standalone validation scripts for an ADR's policies.""" + try: + from ...enforcement.generation.scripts import ScriptGenerator + + generator = ScriptGenerator(adr_dir=self.adr_dir) + output_dir = Path.cwd() / "scripts" / "adr" + path = generator.generate_for_adr(adr, output_dir) + + if path: + return { + "success": True, + "script": str(path), + "message": f"Validation script generated: {path.name}", + } + return { + "success": True, + "script": None, + "message": "No enforceable policies — no script generated", + } + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to generate validation script", + } + + def _update_git_hooks(self) -> dict[str, Any]: + """Update git hooks to run staged enforcement checks.""" + try: + from ...enforcement.generation.hooks import HookGenerator + + generator = HookGenerator() + hook_results = generator.generate(project_root=Path.cwd()) + + updated = [ + name + for name, action in hook_results.items() + if action not in ("unchanged", "skipped") + ] + skipped = [ + name for name, action in hook_results.items() if "skipped" in action + ] + + return { + "success": True, + "hooks_updated": updated, + "hooks_skipped": skipped, + "details": hook_results, + "message": f"Git hooks updated: {', '.join(updated) if updated else 'all unchanged'}", + } + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to update git hooks (non-blocking)", + } + + def _has_javascript_policies(self, adr: ADR) -> bool: + """Check if ADR has JavaScript/TypeScript related policies.""" + if not adr.policy: + return False + + # Check for import restrictions, frontend policies, etc. + js_indicators = [] + + # Check if it has imports policy + if adr.policy.imports: + js_indicators.append(True) + + # Check for frontend-related terms in policy + policy_text = str(adr.policy.model_dump()).lower() + js_indicators.extend( + [ + "javascript" in policy_text, + "typescript" in policy_text, + "frontend" in policy_text, + "react" in policy_text, + "vue" in policy_text, + ] + ) + + return any(js_indicators) + + def _has_python_policies(self, adr: ADR) -> bool: + """Check if ADR has Python related policies.""" + if not adr.policy: + return False + + # Check for Python-specific policies + python_indicators = [] + + # Check for python-specific policy + if adr.policy.python: + python_indicators.append(True) + + # Check for imports policy + if adr.policy.imports: + python_indicators.append(True) + + # Check for Python-related terms in policy + policy_text = str(adr.policy.model_dump()).lower() + python_indicators.extend( + [ + "django" in policy_text, + "flask" in policy_text, + ] + ) + + return any(python_indicators) + + def _generate_eslint_rules(self, adr: ADR) -> dict[str, Any]: + """Generate ESLint rules from ADR policies.""" + try: + config = generate_eslint_config(self.adr_dir) + + # Write to .eslintrc.adrs.json + output_file = Path.cwd() / ".eslintrc.adrs.json" + with open(output_file, "w") as f: + f.write(config) + rules: list[dict[str, Any]] = [] # Simplified for now + + return { + "success": True, + "rules_generated": len(rules), + "output_file": str(output_file), + "rules": rules, + } + + except Exception as e: + return {"success": False, "error": str(e)} + + def _generate_ruff_rules(self, adr: ADR) -> dict[str, Any]: + """Generate Ruff configuration from ADR policies.""" + try: + config_content = generate_ruff_config(self.adr_dir) + + # Update pyproject.toml + output_file = Path.cwd() / "pyproject.toml" + # For now, just create a simple config file + with open(output_file, "a") as f: + f.write("\n" + config_content) + config: dict[str, Any] = {} # Simplified for now + + return { + "success": True, + "config_sections": len(config), + "output_file": str(output_file), + "config": config, + } + + except Exception as e: + return {"success": False, "error": str(e)} + + def _update_indexes(self) -> dict[str, Any]: + """Update JSON and other indexes after ADR approval.""" + try: + # Update JSON index + adr_index = generate_adr_index(self.adr_dir) + + # Write to standard location + index_file = Path(self.adr_dir) / "adr-index.json" + with open(index_file, "w") as f: + json.dump(adr_index.to_dict(), f, indent=2) + + return { + "success": True, + "total_adrs": len(adr_index.entries), + "approved_adrs": len( + [ + entry + for entry in adr_index.entries + if entry.adr.status == "accepted" + ] + ), + "index_file": str(index_file), + "message": "Indexes updated successfully", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Failed to update indexes", + } + + def _validate_codebase_compliance(self, adr: ADR) -> dict[str, Any]: + """Validate existing codebase against new ADR policies (optional).""" + try: + # This is a lightweight validation - full validation might be expensive + # and should be run separately via CI/CD + + violations = [] + + # Check for obvious policy violations + if adr.policy and adr.policy.imports and adr.policy.imports.disallow: + disallowed = adr.policy.imports.disallow + if disallowed: + # Quick scan for disallowed imports in common files + violations.extend(self._quick_scan_for_violations(disallowed)) + + return { + "success": True, + "violations_found": len(violations), + "violations": violations[:10], # Limit to first 10 + "message": "Quick compliance check completed", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "Compliance validation failed", + } + + def _quick_scan_for_violations( + self, disallowed_imports: list[str] + ) -> list[dict[str, Any]]: + """Quick scan for obvious policy violations.""" + violations = [] + + # Scan common file types + file_patterns = ["**/*.js", "**/*.ts", "**/*.py", "**/*.jsx", "**/*.tsx"] + + for pattern in file_patterns: + try: + from pathlib import Path + + for file_path in Path.cwd().glob(pattern): + if ( + file_path.is_file() and file_path.stat().st_size < 1024 * 1024 + ): # Skip large files + try: + with open(file_path, encoding="utf-8") as f: + content = f.read() + + for disallowed in disallowed_imports: + if ( + f"import {disallowed}" in content + or f"from {disallowed}" in content + ): + violations.append( + { + "file": str(file_path), + "violation": f"Uses disallowed import: {disallowed}", + "type": "import_violation", + } + ) + except Exception: + continue # Skip problematic files + + except Exception: + continue # Skip problematic patterns + + return violations + + def _count_policy_rules_applied(self, automation_results: dict[str, Any]) -> int: + """Count total policy rules applied across all systems.""" + count = 0 + + if "enforcement_generation" in automation_results: + enforcement = automation_results["enforcement_generation"] + if enforcement.get("success"): + details = enforcement.get("details", {}) + for _system, result in details.items(): + if result.get("success"): + count += result.get("rules_generated", 0) + + return count + + def _extract_updated_configurations( + self, automation_results: dict[str, Any] + ) -> list[str]: + """Extract list of configuration files that were updated.""" + updated_files = [] + + # From guardrail application + if "guardrail_application" in automation_results: + guardrails = automation_results["guardrail_application"] + if guardrails.get("success"): + updated_files.extend(guardrails.get("configurations_updated", [])) + + # From enforcement rule generation + if "enforcement_generation" in automation_results: + enforcement = automation_results["enforcement_generation"] + if enforcement.get("success"): + details = enforcement.get("details", {}) + for _system, result in details.items(): + if result.get("success") and result.get("output_file"): + updated_files.append(result["output_file"]) + + # From index updates + if "index_update" in automation_results: + index = automation_results["index_update"] + if index.get("success") and index.get("index_file"): + updated_files.append(index["index_file"]) + + return list(set(updated_files)) # Remove duplicates + + def _generate_approval_report( + self, + adr: ADR, + automation_results: dict[str, Any], + content_digest: str, + input_data: ApprovalInput, + ) -> dict[str, Any]: + """Generate comprehensive approval report.""" + + # Count successes and failures + successes = sum( + 1 + for result in automation_results.values() + if isinstance(result, dict) and result.get("success") + ) + failures = len(automation_results) - successes + + warnings = [] + + # Check for automation failures + for step, result in automation_results.items(): + if isinstance(result, dict) and not result.get("success"): + warnings.append( + f"{step.replace('_', ' ').title()} failed: {result.get('message', 'Unknown error')}" + ) + + # Generate next steps + if failures > 0: + next_steps = ( + f"⚠️ ADR {adr.id} approved but {failures} automation step(s) failed. " + f"Review warnings and consider running manual enforcement. " + f"Use adr_validate() to check compliance." + ) + else: + policy_count = self._count_policy_rules_applied(automation_results) + config_count = len(self._extract_updated_configurations(automation_results)) + + next_steps = ( + f"✅ ADR {adr.id} fully approved and operational. " + f"{policy_count} policy rules applied, {config_count} configurations updated. " + f"All systems are enforcing this decision." + ) + + return { + "adr_id": adr.id, + "approval_timestamp": datetime.now().isoformat(), + "content_digest": content_digest, + "automation_summary": { + "total_steps": len(automation_results), + "successful_steps": successes, + "failed_steps": failures, + "success_rate": ( + f"{(successes/len(automation_results)*100):.1f}%" + if automation_results + else "0%" + ), + }, + "policy_enforcement": { + "rules_applied": self._count_policy_rules_applied(automation_results), + "configurations_updated": len( + self._extract_updated_configurations(automation_results) + ), + "enforcement_active": failures == 0, + }, + "warnings": warnings, + "next_steps": next_steps, + "full_automation_details": automation_results, + } diff --git a/adr_kit/decision/workflows/base.py b/adr_kit/decision/workflows/base.py new file mode 100644 index 0000000..8aa14e2 --- /dev/null +++ b/adr_kit/decision/workflows/base.py @@ -0,0 +1,252 @@ +"""Base classes for internal workflow orchestration.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any + + +class WorkflowStatus(str, Enum): + """Status of workflow execution.""" + + SUCCESS = "success" + PARTIAL_SUCCESS = "partial_success" # Some steps succeeded, some failed + FAILED = "failed" + VALIDATION_ERROR = "validation_error" + CONFLICT_ERROR = "conflict_error" + REQUIRES_ACTION = ( + "requires_action" # Quality gate or other check requires user action + ) + + +@dataclass +class WorkflowStep: + """Represents a single step in a workflow.""" + + name: str + status: WorkflowStatus + message: str + duration_ms: int | None = None + details: dict[str, Any] = field(default_factory=dict) + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +@dataclass +class WorkflowResult: + """Result of workflow execution.""" + + success: bool + status: WorkflowStatus + message: str + + # Execution details + steps: list[WorkflowStep] = field(default_factory=list) + duration_ms: int = 0 + executed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + # Output data (workflow-specific) + data: dict[str, Any] = field(default_factory=dict) + + # Agent guidance + next_steps: list[str] = field(default_factory=list) + guidance: str = "" + + # Error handling + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def add_step(self, step: WorkflowStep) -> None: + """Add a workflow step result.""" + self.steps.append(step) + + def add_error(self, error: str, step_name: str | None = None) -> None: + """Add an error to the result.""" + self.errors.append(error) + if step_name: + # Also add to the specific step if it exists + for step in self.steps: + if step.name == step_name: + step.errors.append(error) + break + + def add_warning(self, warning: str, step_name: str | None = None) -> None: + """Add a warning to the result.""" + self.warnings.append(warning) + if step_name: + for step in self.steps: + if step.name == step_name: + step.warnings.append(warning) + break + + def get_summary(self) -> str: + """Get human-readable summary of workflow execution.""" + if self.success: + successful_steps = len( + [s for s in self.steps if s.status == WorkflowStatus.SUCCESS] + ) + return f"✅ {self.message} ({successful_steps}/{len(self.steps)} steps completed)" + else: + failed_steps = len( + [s for s in self.steps if s.status == WorkflowStatus.FAILED] + ) + return f"❌ {self.message} ({failed_steps}/{len(self.steps)} steps failed)" + + def to_agent_response(self) -> dict[str, Any]: + """Convert to agent-friendly response format.""" + return { + "success": self.success, + "status": self.status.value, + "message": self.message, + "data": self.data, + "steps_completed": len( + [s for s in self.steps if s.status == WorkflowStatus.SUCCESS] + ), + "total_steps": len(self.steps), + "duration_ms": self.duration_ms, + "next_steps": self.next_steps, + "guidance": self.guidance, + "errors": self.errors, + "warnings": self.warnings, + "summary": self.get_summary(), + } + + +class WorkflowError(Exception): + """Exception raised during workflow execution.""" + + def __init__( + self, + message: str, + step_name: str | None = None, + details: dict | None = None, + ): + super().__init__(message) + self.message = message + self.step_name = step_name + self.details = details or {} + + +class BaseWorkflow(ABC): + """Base class for all internal workflows. + + Workflows are pure automation/orchestration that use existing components + to accomplish complex tasks triggered by agent entry points. + """ + + def __init__(self, adr_dir: Path | str): + self.adr_dir = Path(adr_dir) + self.result = WorkflowResult( + success=False, status=WorkflowStatus.FAILED, message="" + ) + self._start_time: datetime | None = None + + @abstractmethod + def execute(self, **kwargs: Any) -> WorkflowResult: + """Execute the workflow with given parameters. + + This is the main entry point that agents call through MCP tools. + Implementations should: + 1. Validate inputs + 2. Execute workflow steps in sequence + 3. Handle errors gracefully + 4. Return comprehensive results with agent guidance + """ + pass + + def _start_workflow(self, workflow_name: str) -> None: + """Initialize workflow execution.""" + self._start_time = datetime.now() + self.result = WorkflowResult( + success=False, + status=WorkflowStatus.FAILED, + message=f"{workflow_name} workflow started", + ) + + def _complete_workflow( + self, success: bool, message: str, status: WorkflowStatus | None = None + ) -> None: + """Complete workflow execution.""" + if self._start_time: + duration_ms = int( + (datetime.now() - self._start_time).total_seconds() * 1000 + ) + self.result.duration_ms = self._ensure_minimum_duration(duration_ms) + else: + # Fallback: use a minimal duration if start time wasn't set + self.result.duration_ms = 1 + + self.result.success = success + self.result.message = message + + if status: + self.result.status = status + else: + self.result.status = ( + WorkflowStatus.SUCCESS if success else WorkflowStatus.FAILED + ) + + def _ensure_minimum_duration(self, duration_ms: int) -> int: + """Ensure duration is at least 1ms for consistent timing.""" + return max(duration_ms, 1) + + def _execute_step( + self, step_name: str, step_func: Any, *args: Any, **kwargs: Any + ) -> Any: + """Execute a single workflow step with error handling.""" + start_time = datetime.now() + step = WorkflowStep( + name=step_name, status=WorkflowStatus.FAILED, message="Step started" + ) + + try: + result = step_func(*args, **kwargs) + + step.status = WorkflowStatus.SUCCESS + step.message = f"{step_name} completed successfully" + duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) + step.duration_ms = self._ensure_minimum_duration(duration_ms) + + self.result.add_step(step) + return result + + except Exception as e: + step.status = WorkflowStatus.FAILED + step.message = f"{step_name} failed: {str(e)}" + duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) + step.duration_ms = self._ensure_minimum_duration(duration_ms) + step.errors.append(str(e)) + + self.result.add_step(step) + raise WorkflowError( + f"{step_name} failed: {str(e)}", step_name, {"exception": str(e)} + ) from e + + def _validate_adr_directory(self) -> None: + """Validate that ADR directory exists and is accessible.""" + if not self.adr_dir.exists(): + raise WorkflowError(f"ADR directory does not exist: {self.adr_dir}") + + if not self.adr_dir.is_dir(): + raise WorkflowError(f"ADR path is not a directory: {self.adr_dir}") + + # Check if we can write to the directory + try: + test_file = self.adr_dir / ".adr_kit_test" + test_file.touch() + test_file.unlink() + except Exception as e: + raise WorkflowError( + f"Cannot write to ADR directory: {self.adr_dir} - {e}" + ) from e + + def _add_agent_guidance(self, guidance: str, next_steps: list[str]) -> None: + """Add guidance for the agent on what to do next.""" + self.result.guidance = guidance + self.result.next_steps = next_steps + + def _set_workflow_data(self, **data: Any) -> None: + """Set workflow-specific output data.""" + self.result.data.update(data) diff --git a/adr_kit/decision/workflows/creation.py b/adr_kit/decision/workflows/creation.py new file mode 100644 index 0000000..aab32df --- /dev/null +++ b/adr_kit/decision/workflows/creation.py @@ -0,0 +1,1472 @@ +"""Creation Workflow - Create new ADR proposals with conflict detection.""" + +import re +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any + +from ...contract.builder import ConstraintsContractBuilder +from ...core.model import ADR, ADRFrontMatter, ADRStatus, PolicyModel +from ...core.parse import find_adr_files, parse_adr_file +from ...core.validate import validate_adr +from .base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus + + +@dataclass +class CreationInput: + """Input for ADR creation workflow.""" + + title: str + context: str # The problem/situation that prompted this decision + decision: str # The architectural decision being made + consequences: str # Expected positive and negative consequences + status: str = "proposed" # Always start as proposed + deciders: list[str] | None = None + tags: list[str] | None = None + policy: dict[str, Any] | None = None # Structured policy block + alternatives: str | None = None # Alternative options considered + skip_quality_gate: bool = False # Skip quality gate (for testing or override) + + +@dataclass +class CreationResult: + """Result of ADR creation.""" + + adr_id: str + file_path: str + conflicts_detected: list[str] # ADR IDs that conflict with this proposal + related_adrs: list[str] # ADR IDs that are related but don't conflict + validation_warnings: list[str] # Non-blocking validation issues + next_steps: str # What agent should do next + review_required: bool # Whether human review is needed before approval + + +class CreationWorkflow(BaseWorkflow): + """ + Creation Workflow creates new ADR proposals with comprehensive validation. + + This workflow ensures new ADRs are properly structured, don't conflict with + existing decisions, and follow the project's ADR conventions. + + Workflow Steps: + 1. Generate next ADR ID and validate basic structure + 2. Query related ADRs using semantic search (if available) + 3. Detect conflicts with existing approved ADRs + 4. Validate ADR structure and policy format + 5. Generate ADR file in proposed status + 6. Return creation result with guidance for next steps + """ + + def execute( + self, input_data: CreationInput | None = None, **kwargs: Any + ) -> WorkflowResult: + """Execute ADR creation workflow with quality gate.""" + # Use positional input_data if provided, otherwise extract from kwargs + if input_data is None: + input_data = kwargs.get("input_data") + if not input_data or not isinstance(input_data, CreationInput): + raise ValueError("input_data must be provided as CreationInput instance") + + self._start_workflow("Create ADR") + + try: + # Step 1: Basic validation (minimum requirements) + self._execute_step( + "validate_input", self._validate_creation_input, input_data + ) + + # Step 2: Quality gate - run BEFORE any file operations (unless skipped) + quality_feedback = None + if not input_data.skip_quality_gate: + quality_feedback = self._execute_step( + "quality_gate", self._quick_quality_gate, input_data + ) + + # Step 3: Check quality threshold + if not quality_feedback.get("passes_threshold", True): + # Quality below threshold - BLOCK creation and return feedback + self._complete_workflow( + success=False, + message=f"Quality threshold not met (score: {quality_feedback['quality_score']}/{quality_feedback['threshold']})", + status=WorkflowStatus.REQUIRES_ACTION, + ) + self.result.data = { + "quality_feedback": quality_feedback, + "correction_prompt": ( + "Please address the quality issues identified above and resubmit. " + "Focus on high-priority issues first for maximum impact." + ), + } + self.result.next_steps = quality_feedback.get("next_steps", []) + return self.result + else: + # Quality gate skipped - generate basic feedback for backward compatibility + quality_feedback = { + "quality_score": None, + "grade": None, + "passes_threshold": True, + "summary": "Quality gate skipped (skip_quality_gate=True)", + "issues": [], + "strengths": [], + "recommendations": [], + "next_steps": [], + } + + # Quality passed threshold - proceed with ADR creation + # Step 4: Generate ADR ID + adr_id = self._execute_step("generate_adr_id", self._generate_adr_id) + + # Step 5: Check conflicts + related_adrs = self._execute_step( + "find_related_adrs", self._find_related_adrs, input_data + ) + conflicts = self._execute_step( + "check_conflicts", self._detect_conflicts, input_data, related_adrs + ) + + # Step 6: Create ADR content + adr = self._execute_step( + "create_adr_content", self._build_adr_structure, adr_id, input_data + ) + + # Step 7: Write ADR file (only happens if quality passed) + file_path = self._execute_step( + "write_adr_file", self._generate_adr_file, adr + ) + + # Additional processing + validation_result = self._validate_adr_structure(adr) + policy_warnings = self._validate_policy_completeness(adr, input_data) + validation_result["warnings"].extend(policy_warnings) + review_required = self._determine_review_requirements( + adr, conflicts, validation_result + ) + next_steps = self._generate_next_steps_guidance( + adr_id, conflicts, review_required + ) + + result = CreationResult( + adr_id=adr_id, + file_path=file_path, + conflicts_detected=[c["adr_id"] for c in conflicts], + related_adrs=[r["adr_id"] for r in related_adrs], + validation_warnings=validation_result.get("warnings", []), + next_steps=next_steps, + review_required=review_required, + ) + + # Generate policy suggestions if no policy was provided (Task 2) + policy_guidance = self._generate_policy_guidance(adr, input_data) + + self._complete_workflow( + success=True, message=f"ADR {adr_id} created successfully" + ) + self.result.data = { + "creation_result": result, + "quality_feedback": quality_feedback, # Task 1: Quality gate results + "policy_guidance": policy_guidance, # Task 2: Policy construction guidance + } + self.result.guidance = next_steps + self.result.next_steps = self._generate_next_steps_list( + adr_id, conflicts, review_required + ) + return self.result + + except WorkflowError as e: + # Check if this was a validation error + if "must be at least" in str(e) or "validation" in str(e).lower(): + self._complete_workflow( + success=False, + message=f"ADR creation failed: {str(e)}", + status=WorkflowStatus.VALIDATION_ERROR, + ) + else: + self._complete_workflow( + success=False, message=f"ADR creation failed: {str(e)}" + ) + self.result.errors = [f"CreationError: {str(e)}"] + return self.result + except Exception as e: + self._complete_workflow( + success=False, message=f"ADR creation failed: {str(e)}" + ) + self.result.errors = [f"CreationError: {str(e)}"] + return self.result + + def _generate_adr_id(self) -> str: + """Generate next available ADR ID.""" + # Scan directory for existing ADR files + adr_files = find_adr_files(self.adr_dir) + if not adr_files: + return "ADR-0001" + + # Extract numbers from existing ADR files + numbers = [] + for file_path in adr_files: + filename = Path(file_path).stem + match = re.search(r"ADR-(\d+)", filename) + if match: + numbers.append(int(match.group(1))) + + if not numbers: + return "ADR-0001" + + next_num = max(numbers) + 1 + return f"ADR-{next_num:04d}" + + def _validate_creation_input(self, input_data: CreationInput) -> None: + """Validate the input data for ADR creation with helpful error messages.""" + if not input_data.title or len(input_data.title.strip()) < 3: + raise ValueError( + "Title must be at least 3 characters. " + "Example: 'Use PostgreSQL for Primary Database' or 'Use React 18 with TypeScript'" + ) + + if not input_data.context or len(input_data.context.strip()) < 10: + raise ValueError( + "Context must be at least 10 characters. " + "Context should explain WHY this decision is needed - the problem or opportunity. " + "Example: 'We need ACID transactions for financial data integrity. Current SQLite " + "setup doesn't support concurrent writes from multiple services.'" + ) + + if not input_data.decision or len(input_data.decision.strip()) < 5: + raise ValueError( + "Decision must be at least 5 characters. " + "Decision should state WHAT specific technology/pattern/approach is chosen. " + "Example: 'Use PostgreSQL 15 as the primary database. Don't use MySQL or MongoDB.' " + "Be specific and include explicit constraints." + ) + + if not input_data.consequences or len(input_data.consequences.strip()) < 5: + raise ValueError( + "Consequences must be at least 5 characters. " + "Consequences should document BOTH positive and negative outcomes (trade-offs). " + "Example: '+ ACID compliance, + Rich features, - Higher resource usage, - Ops expertise required'" + ) + + if input_data.status and input_data.status not in [ + "proposed", + "accepted", + "superseded", + ]: + raise ValueError("Status must be one of: proposed, accepted, superseded") + + def _find_related_adrs(self, input_data: CreationInput) -> list[dict[str, Any]]: + """Find ADRs related to this proposal using various matching strategies.""" + related = [] + + try: + adr_files = find_adr_files(self.adr_dir) + + # Keywords from the proposal + proposal_text = ( + f"{input_data.title} {input_data.context} {input_data.decision}" + ).lower() + + # Extract key terms (simple approach - could be enhanced with NLP) + key_terms = self._extract_key_terms(proposal_text) + + for file_path in adr_files: + try: + existing_adr = parse_adr_file(file_path) + if existing_adr.status == "superseded": + continue # Skip superseded ADRs + + # Check for related content + existing_text = ( + f"{existing_adr.title} {existing_adr.context} {existing_adr.decision}" + ).lower() + + relevance_score = self._calculate_relevance( + key_terms, existing_text + ) + + if relevance_score > 0.3: # Threshold for relevance + related.append( + { + "adr_id": existing_adr.id, + "title": existing_adr.title, + "relevance_score": relevance_score, + "matching_terms": [ + term for term in key_terms if term in existing_text + ], + "tags_overlap": bool( + set(input_data.tags or []) + & set(existing_adr.front_matter.tags or []) + ), + } + ) + + except Exception: + continue # Skip problematic files + + # Sort by relevance + related.sort( + key=lambda x: ( + float(x["relevance_score"]) + if isinstance(x["relevance_score"], int | float | str) + else 0.0 + ), + reverse=True, + ) + return related[:10] # Return top 10 most relevant + + except Exception: + return [] # Return empty if search fails + + def _extract_key_terms(self, text: str) -> list[str]: + """Extract key technical terms from text.""" + # Common technology and architecture terms + tech_patterns = [ + r"\b\w*sql\w*\b", + r"\bmongo\w*\b", + r"\bredis\b", # Databases + r"\breact\b", + r"\bvue\b", + r"\bangular\b", + r"\bsvelte\b", # Frontend + r"\bexpress\b", + r"\bdjango\b", + r"\bflask\b", + r"\bspring\b", # Backend + r"\bmicroservice\w*\b", + r"\bmonolith\w*\b", + r"\bserverless\b", # Architecture + r"\bapi\b", + r"\brest\b", + r"\bgraphql\b", + r"\bgrpc\b", # APIs + r"\bdocker\b", + r"\bkubernetes\b", + r"\baws\b", + r"\bazure\b", # Infrastructure + r"\btypescript\b", + r"\bjavascript\b", + r"\bpython\b", + r"\bjava\b", # Languages + ] + + terms = [] + for pattern in tech_patterns: + matches = re.findall(pattern, text, re.IGNORECASE) + terms.extend([match.lower() for match in matches]) + + # Add important words (length > 5) + words = re.findall(r"\b\w{5,}\b", text.lower()) + terms.extend(words) + + return list(set(terms)) # Remove duplicates + + def _calculate_relevance(self, key_terms: list[str], existing_text: str) -> float: + """Calculate relevance score between proposal and existing ADR.""" + if not key_terms: + return 0.0 + + matching_terms = [term for term in key_terms if term in existing_text] + return len(matching_terms) / len(key_terms) + + def _detect_conflicts( + self, input_data: CreationInput, related_adrs: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Detect conflicts between proposal and existing ADRs.""" + conflicts = [] + + try: + # Load constraints contract to check policy conflicts + builder = ConstraintsContractBuilder(adr_dir=self.adr_dir) + contract = builder.build() + + # Check policy conflicts + if input_data.policy: + policy_conflicts = self._detect_policy_conflicts( + input_data.policy, contract + ) + conflicts.extend(policy_conflicts) + + # Check direct contradictions in highly related ADRs + for related_adr in related_adrs: + if related_adr["relevance_score"] > 0.7: # High relevance threshold + contradiction = self._check_for_contradictions( + input_data, related_adr["adr_id"] + ) + if contradiction: + conflicts.append(contradiction) + + except Exception: + pass # Conflict detection is best-effort + + return conflicts + + def _detect_policy_conflicts( + self, proposed_policy: dict[str, Any], contract: Any + ) -> list[dict[str, Any]]: + """Detect conflicts between proposed policy and existing policies.""" + conflicts = [] + + # Check if proposed policy contradicts existing constraints + for constraint in contract.constraints: + if self._policies_conflict(proposed_policy, constraint.policy): + conflicts.append( + { + "adr_id": constraint.adr_id, + "conflict_type": "policy_contradiction", + "conflict_detail": f"Proposed policy conflicts with {constraint.adr_id} policy", + } + ) + + return conflicts + + def _policies_conflict( + self, policy1: dict[str, Any], policy2: dict[str, Any] + ) -> bool: + """Check if two policies contradict each other.""" + # Simple conflict detection - can be enhanced + + # Check import conflicts + if "imports" in policy1 and "imports" in policy2: + p1_disallow = set(policy1["imports"].get("disallow", [])) + p2_prefer = set(policy2["imports"].get("prefer", [])) + + if p1_disallow & p2_prefer: # Intersection means conflict + return True + + return False + + def _check_for_contradictions( + self, input_data: CreationInput, related_adr_id: str + ) -> dict[str, Any] | None: + """Check if proposal contradicts a specific ADR.""" + # This is a simplified version - could be enhanced with NLP + + # Load the related ADR + try: + adr_files = find_adr_files(self.adr_dir) + for file_path in adr_files: + adr = parse_adr_file(file_path) + if adr.id == related_adr_id: + # Simple keyword-based contradiction detection + proposal_decision = input_data.decision.lower() + existing_decision = adr.decision.lower() + + # Look for opposing terms + opposing_pairs = [ + ("use", "avoid"), + ("adopt", "reject"), + ("implement", "remove"), + ("enable", "disable"), + ("allow", "forbid"), + ] + + for word1, word2 in opposing_pairs: + if word1 in proposal_decision and word2 in existing_decision: + return { + "adr_id": related_adr_id, + "conflict_type": "decision_contradiction", + "conflict_detail": f"Proposal uses '{word1}' while {related_adr_id} uses '{word2}'", + } + break + except Exception: + pass + + return None + + def _build_adr_structure(self, adr_id: str, input_data: CreationInput) -> ADR: + """Build ADR data structure from input.""" + + # Build front matter + front_matter = ADRFrontMatter( + id=adr_id, + title=input_data.title.strip(), + status=ADRStatus(input_data.status), + date=date.today(), + deciders=input_data.deciders or [], + tags=input_data.tags or [], + supersedes=[], + superseded_by=[], + policy=( + PolicyModel.model_validate(input_data.policy) + if input_data.policy + else None + ), + ) + + # Build content sections + content_parts = [ + "## Context", + "", + input_data.context.strip(), + "", + "## Decision", + "", + input_data.decision.strip(), + "", + "## Consequences", + "", + input_data.consequences.strip(), + ] + + if input_data.alternatives: + content_parts.extend( + [ + "", + "## Alternatives", + "", + input_data.alternatives.strip(), + ] + ) + + content = "\n".join(content_parts) + + return ADR( + front_matter=front_matter, + content=content, + file_path=None, # Not loaded from disk + ) + + def _validate_adr_structure(self, adr: ADR) -> dict[str, Any]: + """Validate the ADR structure.""" + try: + # Use existing validation + validation_result = validate_adr(adr, self.adr_dir) + return { + "valid": validation_result.is_valid, + "errors": [str(error) for error in validation_result.errors], + "warnings": [str(warning) for warning in validation_result.warnings], + } + except Exception as e: + return { + "valid": False, + "errors": [f"Validation failed: {str(e)}"], + "warnings": [], + } + + def _validate_policy_completeness( + self, adr: ADR, creation_input: CreationInput + ) -> list[str]: + """Validate that ADR has extractable policy information. + + Returns list of warnings if policy is missing or insufficient. + + Note: This is a lightweight check. Policy construction guidance is provided + via the policy_guidance promptlet, which agents can use to construct policies. + """ + from ...core.policy_extractor import PolicyExtractor + + extractor = PolicyExtractor() + warnings = [] + + # Check if policy is extractable + if not extractor.has_extractable_policy(adr): + # Provide brief warning - detailed guidance is in policy_guidance promptlet + warnings.append( + "⚠️ No structured policy provided. Review the policy_guidance in the response " + "for instructions on constructing enforcement policies." + ) + + return warnings + + def _generate_adr_file(self, adr: ADR) -> str: + """Generate the ADR file.""" + # Create filename with slugified title + title_slug = re.sub(r"[^\w\s-]", "", adr.title.lower()) + title_slug = re.sub(r"[\s_-]+", "-", title_slug).strip("-") + file_path = Path(self.adr_dir) / f"{adr.id}-{title_slug}.md" + + # Ensure directory exists + Path(self.adr_dir).mkdir(parents=True, exist_ok=True) + + # Generate MADR format content + content = self._generate_madr_content(adr) + + # Write file + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + return str(file_path) + + def _generate_madr_content(self, adr: ADR) -> str: + """Generate MADR format content for the ADR.""" + lines = [] + + # YAML front-matter + lines.append("---") + lines.append(f'id: "{adr.front_matter.id}"') + lines.append(f'title: "{adr.front_matter.title}"') + lines.append(f"status: {adr.front_matter.status}") + lines.append(f"date: {adr.front_matter.date}") + + if adr.front_matter.deciders: + lines.append(f"deciders: {adr.front_matter.deciders}") + + if adr.front_matter.tags: + lines.append(f"tags: {adr.front_matter.tags}") + + if adr.front_matter.supersedes: + lines.append(f"supersedes: {adr.front_matter.supersedes}") + + if adr.front_matter.superseded_by: + lines.append(f"superseded_by: {adr.front_matter.superseded_by}") + + if adr.front_matter.policy: + lines.append("policy:") + policy_dict = adr.front_matter.policy.model_dump(exclude_none=True) + for key, value in policy_dict.items(): + lines.append(f" {key}: {value}") + + lines.append("---") + lines.append("") + + # MADR content sections (already formatted in adr.content) + lines.append(adr.content) + + return "\n".join(lines) + + def _determine_review_requirements( + self, + adr: ADR, + conflicts: list[dict[str, Any]], + validation_result: dict[str, Any], + ) -> bool: + """Determine if human review is required before approval.""" + + # Always require review for conflicts + if conflicts: + return True + + # Require review for validation errors + if not validation_result.get("valid", True): + return True + + # Require review for significant architectural decisions + significant_terms = [ + "database", + "architecture", + "framework", + "security", + "performance", + "scalability", + "microservice", + "monolith", + ] + + adr_text = f"{adr.title} {adr.decision}".lower() + if any(term in adr_text for term in significant_terms): + return True + + # Default: minor decisions can be auto-approved if no conflicts + return False + + def _generate_next_steps_guidance( + self, adr_id: str, conflicts: list[dict[str, Any]], review_required: bool + ) -> str: + """Generate guidance for what the agent should do next.""" + + if conflicts: + conflict_ids = [c["adr_id"] for c in conflicts] + return ( + f"⚠️ {adr_id} has conflicts with {', '.join(conflict_ids)}. " + f"Review conflicts and consider using adr_supersede() if this decision should replace existing ones. " + f"Otherwise, revise the proposal to avoid conflicts." + ) + + if review_required: + return ( + f"📋 {adr_id} requires human review due to architectural significance. " + f"Have a human review the proposal, then use adr_approve() to activate it." + ) + + return ( + f"✅ {adr_id} is ready for approval. " + f"Use adr_approve('{adr_id}') to activate this decision and trigger policy enforcement." + ) + + def _generate_next_steps_list( + self, adr_id: str, conflicts: list[dict[str, Any]], review_required: bool + ) -> list[str]: + """Generate next steps as a list for the agent.""" + + if conflicts: + conflict_ids = [c["adr_id"] for c in conflicts] + return [ + f"Review conflicts with {', '.join(conflict_ids)}", + f"Consider using adr_supersede() if {adr_id} should replace existing decisions", + "Revise the proposal to avoid conflicts if superseding is not appropriate", + ] + + if review_required: + return [ + f"Have a human review {adr_id} due to architectural significance", + f"Use adr_approve('{adr_id}') after review to activate the decision", + ] + + return [ + f"Review the created ADR {adr_id}", + f"Use adr_approve('{adr_id}') to activate this decision", + "Trigger policy enforcement for the decision", + ] + + def _generate_policy_guidance( + self, adr: ADR, creation_input: CreationInput + ) -> dict[str, Any] | None: + """Generate policy guidance promptlet for agents. + + This method provides a structured promptlet that guides reasoning agents + through the process of constructing enforcement policies. Rather than + using regex to extract policies from text (which is fragile and redundant), + we provide the schema and let the agent reason about how to map their + architectural decision to the available policy capabilities. + + This follows the principle: "ADR Kit provides structure, agents provide intelligence." + + Returns: + Policy guidance dict with schema and reasoning prompts, or None if policy already provided + """ + # If policy was already provided, no guidance needed + if adr.front_matter.policy: + return { + "has_policy": True, + "message": "✅ Structured policy provided and validated", + } + + # No policy provided - guide the agent through policy construction + return { + "has_policy": False, + "message": ( + "📋 No policy provided. To enable automated enforcement, review your " + "architectural decision and construct a policy dict using the schema below." + ), + "agent_task": { + "role": "Policy Constructor", + "objective": ( + "Analyze your architectural decision and identify enforceable constraints " + "that can be automated. Map these constraints to the policy schema capabilities." + ), + "reasoning_steps": [ + "1. Review your decision text for enforceable rules (what you said 'yes' or 'no' to)", + "2. Identify which policy types apply (imports, patterns, architecture, config)", + "3. Map your constraints to the schema structures below", + "4. Construct a policy dict with only the relevant policy types", + "5. Call adr_create() again with the policy parameter", + ], + "focus": ( + "Look for explicit constraints in your decision: library choices, " + "code patterns, architectural boundaries, or configuration requirements." + ), + }, + "policy_capabilities": self._build_policy_reference(), + "example_workflow": { + "scenario": "Decision says: 'Use FastAPI. Don't use Flask or Django due to lack of async support.'", + "reasoning": "This is an import restriction - FastAPI is preferred, Flask/Django are disallowed.", + "constructed_policy": { + "imports": { + "disallow": ["flask", "django"], + "prefer": ["fastapi"], + }, + "rationales": ["Native async support required for I/O operations"], + }, + "next_call": "adr_create(..., policy={...})", + }, + "guidance": [ + "Only create policies for explicit constraints in your decision", + "Don't invent constraints that weren't in your decision", + "Multiple policy types can be combined in one policy dict", + "Rationales help explain why constraints exist", + ], + } + + def _build_policy_reference(self) -> dict[str, Any]: + """Build comprehensive policy structure reference documentation. + + This reference is provided just-in-time when agents need to construct + structured policies, avoiding context bloat in MCP tool docstrings. + """ + return { + "imports": { + "description": "Import/library restrictions", + "fields": { + "disallow": "List of banned libraries/modules", + "prefer": "List of recommended alternatives", + }, + "example": {"disallow": ["flask", "django"], "prefer": ["fastapi"]}, + }, + "patterns": { + "description": "Code pattern enforcement rules", + "fields": { + "patterns": "Dict of named pattern rules, each containing:", + " description": "Human-readable description", + " language": "Target language (python, typescript, etc.)", + " rule": "Regex pattern or structured query", + " severity": "error | warning | info", + " autofix": "Optional boolean for auto-fix support", + }, + "example": { + "patterns": { + "async_handlers": { + "description": "All FastAPI handlers must be async", + "language": "python", + "rule": r"def\s+\w+", + "severity": "error", + "autofix": False, + } + } + }, + }, + "architecture": { + "description": "Architecture policies (boundaries + required structure)", + "fields": { + "layer_boundaries": "List of forbidden dependencies", + " rule": "Format: 'source -> target' (e.g., 'frontend -> database')", + " action": "block | warn", + " message": "Error message to display", + " check": "Optional path pattern to scope the rule", + "required_structure": "List of required files/directories", + " path": "File or directory path (glob patterns supported)", + " description": "Why this structure is required", + }, + "example": { + "layer_boundaries": [ + { + "rule": "frontend -> database", + "action": "block", + "message": "Frontend must not access database directly", + "check": "src/frontend/**/*.py", + } + ], + "required_structure": [ + { + "path": "src/models/*.py", + "description": "Model layer required", + } + ], + }, + }, + "config_enforcement": { + "description": "Configuration enforcement for TypeScript/Python tools", + "fields": { + "typescript": "TypeScript config requirements", + " tsconfig": "Required tsconfig.json settings", + " eslintConfig": "Required ESLint config", + "python": "Python config requirements", + " ruff": "Required Ruff settings", + " mypy": "Required mypy settings", + }, + "example": { + "typescript": { + "tsconfig": { + "strict": True, + "compilerOptions": {"noImplicitAny": True}, + } + }, + "python": { + "ruff": {"lint": {"select": ["I"]}}, + "mypy": {"strict": True}, + }, + }, + }, + "rationales": { + "description": "List of reasons for the policies", + "example": [ + "FastAPI provides native async support", + "Better performance for I/O operations", + ], + }, + } + + def _quick_quality_gate(self, creation_input: CreationInput) -> dict[str, Any]: + """Quick quality gate that runs BEFORE ADR file creation. + + This pre-validation check runs deterministic quality checks on the input + to ensure decision quality meets the minimum threshold BEFORE creating + any files. This enables a correction loop without file pollution. + + Args: + creation_input: The input data for ADR creation + + Returns: + Quality assessment with passes_threshold boolean and feedback + """ + issues = [] + strengths = [] + score = 100 # Start perfect, deduct points for issues + QUALITY_THRESHOLD = 75 # B grade minimum (anything lower blocks creation) + + context_text = creation_input.context.lower() + decision_text = creation_input.decision.lower() + consequences_text = creation_input.consequences.lower() + + # Check 1: Specificity - detect generic/vague language + generic_terms = [ + "modern", + "good", + "best", + "framework", + "library", + "tool", + "better", + "nice", + ] + vague_count = sum( + 1 for term in generic_terms if term in decision_text or term in context_text + ) + + if vague_count >= 2: + score -= 15 + issues.append( + { + "category": "specificity", + "severity": "medium", + "issue": f"Decision uses {vague_count} generic terms ('{', '.join([t for t in generic_terms if t in decision_text or t in context_text][:3])}...')", + "suggestion": "Replace generic terms with specific technology names and versions", + "example_fix": "Instead of 'use a modern framework', write 'Use React 18 with TypeScript'", + } + ) + else: + strengths.append("Decision uses specific, concrete terminology") + + # Check 2: Balanced consequences - must have BOTH pros AND cons + positive_keywords = [ + "benefit", + "advantage", + "positive", + "improve", + "better", + "gain", + ] + negative_keywords = [ + "drawback", + "limitation", + "negative", + "cost", + "risk", + "challenge", + ] + + has_positives = any(kw in consequences_text for kw in positive_keywords) + has_negatives = any(kw in consequences_text for kw in negative_keywords) + + if not (has_positives and has_negatives): + score -= 25 + issues.append( + { + "category": "balance", + "severity": "high", + "issue": "Consequences are one-sided (only pros or only cons)", + "suggestion": "Document BOTH positive and negative consequences - every technical decision has trade-offs", + "example_fix": "Add '### Negative' section listing drawbacks, limitations, or risks", + "why_it_matters": "Balanced trade-off analysis enables informed decision-making", + } + ) + else: + strengths.append("Consequences show balanced trade-off analysis") + + # Check 3: Context quality - sufficient detail + context_length = len(creation_input.context) + + if context_length < 50: + score -= 20 + issues.append( + { + "category": "context", + "severity": "high", + "issue": f"Context is too brief ({context_length} characters)", + "suggestion": "Expand context to explain WHY this decision is needed: current state, requirements, drivers", + "example_fix": "Add: business requirements, technical constraints, user needs", + } + ) + elif context_length >= 150: + strengths.append("Context provides detailed problem background") + + # Check 4: Explicit constraints - policy-ready language + import re + + constraint_patterns = [ + r"\bdon[''']t\s+use\b", + r"\bmust\s+not\s+use\b", + r"\bavoid\b", + r"\bmust\s+(?:use|have|be)\b", + r"\ball\s+\w+\s+must\b", + ] + + has_explicit_constraints = any( + re.search(pattern, decision_text, re.IGNORECASE) + for pattern in constraint_patterns + ) + + if not has_explicit_constraints: + score -= 15 + issues.append( + { + "category": "policy_readiness", + "severity": "medium", + "issue": "Decision lacks explicit constraints (enables policy extraction)", + "suggestion": "Add explicit constraints using 'Don't use X', 'Must use Y', 'All Z must...'", + "example_fix": "Use FastAPI for APIs. **Don't use Flask** or Django.", + "why_it_matters": "Explicit constraints enable automated policy enforcement (Task 2)", + } + ) + else: + strengths.append( + "Decision includes explicit constraints ready for policy extraction" + ) + + # Check 5: Alternatives - critical for 'disallow' policies + if not creation_input.alternatives or len(creation_input.alternatives) < 20: + score -= 15 + issues.append( + { + "category": "alternatives", + "severity": "medium", + "issue": "Missing or minimal alternatives section", + "suggestion": "Document rejected alternatives with specific reasons", + "example_fix": "### MySQL\\n**Rejected**: Weaker JSON support\\n\\n### MongoDB\\n**Rejected**: Conflicts with ACID requirements", + "why_it_matters": "Alternatives section enables extraction of 'disallow' policies", + } + ) + else: + strengths.append("Alternatives documented (enables disallow policies)") + + # Check 6: Decision completeness + decision_length = len(creation_input.decision) + + if decision_length < 30: + score -= 10 + issues.append( + { + "category": "completeness", + "severity": "low", + "issue": f"Decision is very brief ({decision_length} characters)", + "suggestion": "Expand decision with: specific technology, scope, and constraints", + "example_fix": "Use PostgreSQL 15 for all application data. Deploy on AWS RDS with Multi-AZ.", + } + ) + + # Clamp score to valid range + score = max(0, min(100, score)) + + # Determine grade (A=90+, B=75+, C=60+, D=40+, F=<40) + if score >= 90: + grade = "A" + elif score >= 75: + grade = "B" + elif score >= 60: + grade = "C" + elif score >= 40: + grade = "D" + else: + grade = "F" + + passes_threshold = score >= QUALITY_THRESHOLD + + # Generate summary + if passes_threshold: + summary = f"Decision quality is acceptable (Grade {grade}, {score}/100). {len(issues)} minor improvements suggested." + else: + summary = f"Decision quality is below threshold (Grade {grade}, {score}/100). {len(issues)} issues must be addressed before ADR creation." + + # Generate prioritized recommendations + high_priority = [i for i in issues if i["severity"] == "high"] + medium_priority = [i for i in issues if i["severity"] == "medium"] + + recommendations = [] + if high_priority: + recommendations.append( + f"🔴 **High Priority**: Fix {len(high_priority)} critical issues first" + ) + for issue in high_priority[:2]: # Top 2 high priority + recommendations.append( + f" - {issue['category'].title()}: {issue['suggestion']}" + ) + + if medium_priority and score < QUALITY_THRESHOLD: + recommendations.append( + f"🟡 **Medium Priority**: Address {len(medium_priority)} quality issues" + ) + for issue in medium_priority[:2]: # Top 2 medium priority + recommendations.append( + f" - {issue['category'].title()}: {issue['suggestion']}" + ) + + # Next steps vary by quality score + next_steps = [] + if not passes_threshold: + next_steps.append( + "⛔ **ADR Creation Blocked**: Quality score below threshold" + ) + next_steps.append( + "📝 **Action Required**: Address the issues above and resubmit" + ) + next_steps.append( + "💡 **Tip**: Focus on high-priority issues first for maximum impact" + ) + else: + next_steps.append( + "✅ **Quality Gate Passed**: ADR will be created with this input" + ) + if issues: + next_steps.append( + f"💡 **Optional**: Consider addressing {len(issues)} suggestions for even higher quality" + ) + + return { + "quality_score": score, + "grade": grade, + "passes_threshold": passes_threshold, + "threshold": QUALITY_THRESHOLD, + "summary": summary, + "issues": issues, + "strengths": strengths, + "recommendations": recommendations, + "next_steps": next_steps, + } + + def _assess_decision_quality( + self, adr: ADR, creation_input: CreationInput + ) -> dict[str, Any]: + """Assess decision quality and provide targeted feedback. + + This implements Task 1 of the two-step ADR creation flow: + - Task 1 (this method): Assess decision quality and provide guidance + - Task 2 (_generate_policy_guidance): Extract enforceable policies + + The assessment identifies common quality issues and provides actionable + feedback to help agents improve their ADRs. It follows the principle: + "ADR Kit provides structure, agents provide intelligence." + + Args: + adr: The created ADR + creation_input: The input data used to create the ADR + + Returns: + Quality assessment with issues found and improvement suggestions + """ + issues = [] + strengths = [] + score = 100 # Start with perfect score, deduct for issues + + # Check 1: Specificity (are technology names specific?) + generic_terms = [ + "modern", + "good", + "best", + "framework", + "library", + "tool", + "system", + "platform", + ] + decision_lower = adr.decision.lower() + title_lower = adr.title.lower() + + vague_terms_found = [ + term + for term in generic_terms + if term in decision_lower or term in title_lower + ] + if vague_terms_found: + issues.append( + { + "category": "specificity", + "severity": "medium", + "issue": f"Decision uses generic terms: {', '.join(vague_terms_found)}", + "suggestion": ( + "Replace generic terms with specific technology names and versions. " + "Example: Instead of 'modern framework', use 'React 18' or 'FastAPI 0.104'." + ), + "example_fix": { + "bad": "Use a modern web framework", + "good": "Use React 18 with TypeScript for frontend development", + }, + } + ) + score -= 15 + else: + strengths.append("Decision is specific with clear technology choices") + + # Check 2: Balanced consequences (are there both pros AND cons?) + consequences_lower = adr.consequences.lower() + has_positives = any( + word in consequences_lower + for word in [ + "benefit", + "advantage", + "positive", + "+", + "pro:", + "pros:", + "good", + "better", + "improve", + ] + ) + has_negatives = any( + word in consequences_lower + for word in [ + "drawback", + "disadvantage", + "negative", + "-", + "con:", + "cons:", + "risk", + "limitation", + "downside", + "trade-off", + "tradeoff", + ] + ) + + if not (has_positives and has_negatives): + issues.append( + { + "category": "balance", + "severity": "high", + "issue": "Consequences appear one-sided (missing pros or cons)", + "suggestion": ( + "Every technical decision has trade-offs. Document BOTH positive outcomes " + "AND negative consequences honestly. Use structure like:\n" + "### Positive\n- Benefit 1\n- Benefit 2\n\n" + "### Negative\n- Drawback 1\n- Drawback 2" + ), + "why_it_matters": ( + "Balanced consequences help future decision-makers understand when to " + "reconsider this choice. Hiding drawbacks leads to technical debt." + ), + } + ) + score -= 25 + else: + strengths.append("Consequences document both benefits and drawbacks") + + # Check 3: Context quality (does it explain WHY?) + context_length = len(adr.context.strip()) + if context_length < 50: + issues.append( + { + "category": "context", + "severity": "high", + "issue": "Context is too brief (less than 50 characters)", + "suggestion": ( + "Context should explain WHY this decision is needed. Include:\n" + "- The problem or opportunity\n" + "- Current state and why it's insufficient\n" + "- Requirements that must be met\n" + "- Constraints or limitations" + ), + "example": ( + "Good Context: 'We need ACID transactions for financial data integrity. " + "Current SQLite setup doesn't support concurrent writes from multiple services. " + "Requires complex queries with joins and JSON document storage.'" + ), + } + ) + score -= 20 + else: + strengths.append("Context provides sufficient detail about the problem") + + # Check 4: Explicit constraints (for policy extraction) + constraint_patterns = [ + r"\bdon[''']t\s+use\b", + r"\bavoid\b.*\b(?:using|use)\b", + r"\bmust\s+(?:not\s+)?(?:use|have|be)\b", + r"\ball\s+\w+\s+must\b", + r"\brequired?\b", + r"\bprohibited?\b", + ] + + has_explicit_constraints = any( + re.search(pattern, decision_lower, re.IGNORECASE) + for pattern in constraint_patterns + ) + + if not has_explicit_constraints: + issues.append( + { + "category": "policy_readiness", + "severity": "medium", + "issue": "Decision lacks explicit constraints for policy extraction", + "suggestion": ( + "Use explicit constraint language to enable automated policy extraction:\n" + "- 'Don't use X' / 'Avoid X'\n" + "- 'Use Y instead of X'\n" + "- 'All X must have Y'\n" + "- 'Must not access'\n" + "Example: 'Use FastAPI. Don't use Flask or Django due to lack of async support.'" + ), + "why_it_matters": ( + "Explicit constraints enable Task 2 (policy extraction) to generate " + "enforceable rules automatically. Vague language can't be automated." + ), + } + ) + score -= 15 + else: + strengths.append( + "Decision includes explicit constraints ready for policy extraction" + ) + + # Check 5: Alternatives (critical for policy extraction) + if ( + not creation_input.alternatives + or len(creation_input.alternatives.strip()) < 20 + ): + issues.append( + { + "category": "alternatives", + "severity": "medium", + "issue": "Missing or insufficient alternatives documentation", + "suggestion": ( + "Document what alternatives you considered and WHY you rejected each one. " + "This is CRITICAL for policy extraction - rejected alternatives often become " + "'disallow' policies.\n\n" + "Structure:\n" + "### Alternative Name\n" + "**Rejected**: Specific reason for rejection\n" + "- Pros: ...\n" + "- Cons: ...\n" + "- Why not: ..." + ), + "example": ( + "### Flask\n" + "**Rejected**: Lacks native async support.\n" + "- Pros: Lightweight, huge ecosystem\n" + "- Cons: No native async, requires Quart\n" + "- Why not: Async support is critical for our use case" + ), + "why_it_matters": ( + "Alternatives with clear rejection reasons enable extraction of 'disallow' policies. " + "Example: 'Rejected Flask' becomes {'imports': {'disallow': ['flask']}}" + ), + } + ) + score -= 15 + else: + strengths.append( + "Alternatives documented with clear rejection reasons (enables 'disallow' policies)" + ) + + # Check 6: Decision length (too short is usually vague) + decision_length = len(adr.decision.strip()) + if decision_length < 30: + issues.append( + { + "category": "completeness", + "severity": "medium", + "issue": "Decision section is very brief (less than 30 characters)", + "suggestion": ( + "Decision should clearly state:\n" + "1. What technology/pattern/approach is chosen\n" + "2. Scope of applicability ('All new services', 'Frontend only')\n" + "3. Explicit constraints ('Don't use X', 'Must have Y')\n" + "4. Migration path if replacing existing technology" + ), + } + ) + score -= 10 + + # Determine overall quality grade + if score >= 90: + grade = "A" + summary = "Excellent ADR - ready for policy extraction" + elif score >= 75: + grade = "B" + summary = "Good ADR - minor improvements would help" + elif score >= 60: + grade = "C" + summary = "Acceptable ADR - several areas need improvement" + elif score >= 40: + grade = "D" + summary = ( + "Weak ADR - significant improvements needed before policy extraction" + ) + else: + grade = "F" + summary = "Poor ADR - needs major revision" + + return { + "quality_score": score, + "grade": grade, + "summary": summary, + "issues": issues, + "strengths": strengths, + "recommendations": self._generate_quality_recommendations(issues), + "next_steps": self._generate_quality_next_steps(issues, score), + } + + def _generate_quality_recommendations( + self, issues: list[dict[str, Any]] + ) -> list[str]: + """Generate prioritized recommendations based on quality issues. + + Args: + issues: List of quality issues found + + Returns: + Prioritized list of actionable recommendations + """ + if not issues: + return [ + "✅ Your ADR meets quality standards", + "Consider reviewing the policy_guidance to add automated enforcement", + ] + + recommendations = [] + + # Prioritize by severity + high_severity = [issue for issue in issues if issue["severity"] == "high"] + medium_severity = [issue for issue in issues if issue["severity"] == "medium"] + + if high_severity: + recommendations.append( + f"🔴 High Priority: Address {len(high_severity)} critical quality issue(s):" + ) + for issue in high_severity: + recommendations.append(f" - {issue['issue']}") + recommendations.append(f" → {issue['suggestion']}") + + if medium_severity: + recommendations.append( + f"🟡 Medium Priority: Improve {len(medium_severity)} quality aspect(s):" + ) + for issue in medium_severity: + recommendations.append(f" - {issue['issue']}") + + return recommendations + + def _generate_quality_next_steps( + self, issues: list[dict[str, Any]], score: int + ) -> list[str]: + """Generate next steps based on quality assessment. + + Args: + issues: List of quality issues found + score: Overall quality score + + Returns: + List of recommended next steps + """ + if score >= 80: + # High quality - ready to proceed + return [ + "Your ADR is high quality and ready for review", + "Review the policy_guidance to add automated enforcement policies", + "Use adr_approve() after human review to activate the decision", + ] + elif score >= 60: + # Acceptable but could improve + return [ + "ADR is acceptable but could be strengthened", + "Consider addressing the quality issues listed above", + "You can proceed with approval or revise for better policy extraction", + ] + else: + # Needs significant improvement + return [ + "⚠️ ADR quality is below recommended threshold", + "Strongly recommend revising before approval:", + " 1. Address high-priority issues (context, balance, specificity)", + " 2. Add alternatives with rejection reasons (enables policy extraction)", + " 3. Use explicit constraint language ('Don't use', 'Must have')", + "After revision, create a new ADR with improved content", + ] diff --git a/adr_kit/decision/workflows/preflight.py b/adr_kit/decision/workflows/preflight.py new file mode 100644 index 0000000..eec9889 --- /dev/null +++ b/adr_kit/decision/workflows/preflight.py @@ -0,0 +1,528 @@ +"""Preflight Workflow - Check if technical choice requires ADR before proceeding.""" + +from dataclasses import dataclass +from typing import Any + +from ...contract.builder import ConstraintsContractBuilder +from ...contract.models import ConstraintsContract +from .base import BaseWorkflow, WorkflowResult + + +@dataclass +class PreflightInput: + """Input for preflight workflow.""" + + choice: str # Technical choice being evaluated (e.g., "postgresql", "react", "microservices") + context: dict[str, Any] | None = None # Additional context about the choice + category: str | None = ( + None # Category hint (database, frontend, architecture, etc.) + ) + + +@dataclass +class PreflightDecision: + """Result of preflight evaluation.""" + + status: str # ALLOWED, REQUIRES_ADR, BLOCKED + reasoning: str # Human-readable explanation + conflicting_adrs: list[str] # ADR IDs that conflict with this choice + related_adrs: list[str] # ADR IDs that are related but don't conflict + required_policies: list[str] # Policies that would need to be addressed in ADR + next_steps: str # What the agent should do next + urgency: str # LOW, MEDIUM, HIGH - how important it is to create ADR + + +class PreflightWorkflow(BaseWorkflow): + """ + Preflight Workflow evaluates technical choices against existing ADRs. + + This is one of the most important entry points for agents - it prevents + architectural violations before they happen and guides agents toward + compliant technical choices. + + Workflow Steps: + 1. Load current constraints contract + 2. Categorize the technical choice + 3. Check against existing policy gates + 4. Identify conflicting and related ADRs + 5. Evaluate if choice requires new ADR + 6. Generate actionable guidance for agent + """ + + def execute(self, **kwargs: Any) -> WorkflowResult: + """Execute preflight evaluation workflow.""" + # Extract input_data from kwargs + input_data = kwargs.get("input_data") + if not input_data or not isinstance(input_data, PreflightInput): + raise ValueError("input_data must be provided as PreflightInput instance") + + self._start_workflow("Preflight Check") + + try: + # Step 1: Load constraints contract + contract = self._execute_step( + "load_constraints_contract", self._load_constraints_contract + ) + + # Step 2: Categorize and normalize choice + categorized_choice = self._execute_step( + "categorize_choice", self._categorize_choice, input_data + ) + + # Step 3: Check against policy gates + gate_result = self._execute_step( + "check_policy_gates", + self._check_policy_gates, + categorized_choice, + contract, + ) + + # Step 4: Find related and conflicting ADRs + related_adrs = self._execute_step( + "find_related_adrs", + self._find_related_adrs, + categorized_choice, + contract, + ) + conflicting_adrs = self._execute_step( + "find_conflicting_adrs", + self._find_conflicting_adrs, + categorized_choice, + contract, + ) + + # Step 5: Evaluate decision + decision = self._execute_step( + "make_preflight_decision", + self._make_preflight_decision, + categorized_choice, + gate_result, + related_adrs, + conflicting_adrs, + contract, + ) + + # Step 6: Generate guidance + guidance = self._execute_step( + "generate_agent_guidance", + self._generate_agent_guidance, + decision, + input_data, + ) + + result_data = { + "decision": decision, + "guidance": guidance, + "technical_choice": categorized_choice, + "evaluated_against": { + "total_adrs": len(contract.approved_adrs), + "constraints_exist": not contract.constraints.is_empty(), + "constraints": 1 if not contract.constraints.is_empty() else 0, + }, + } + + self._complete_workflow( + success=True, + message=f"Preflight check completed: {decision.status}", + ) + self.result.data = result_data + self.result.guidance = guidance + self.result.next_steps = ( + decision.next_steps.split(". ") + if hasattr(decision, "next_steps") and decision.next_steps + else [ + f"Technical choice {input_data.choice} evaluated: {decision.status}", + "Review preflight decision and proceed accordingly", + ] + ) + + except Exception as e: + self._complete_workflow( + success=False, + message=f"Preflight workflow failed: {str(e)}", + ) + self.result.add_error(f"PreflightError: {str(e)}") + + return self.result + + def _load_constraints_contract(self) -> ConstraintsContract: + """Load current constraints contract from approved ADRs.""" + try: + builder = ConstraintsContractBuilder(adr_dir=self.adr_dir) + return builder.build() + except Exception: + # If no contract exists, return empty contract + from pathlib import Path + + from ...contract.models import ConstraintsContract + + # Use the proper create_empty method instead of incorrect constructor + return ConstraintsContract.create_empty(Path(".")) + + def _categorize_choice(self, input_data: PreflightInput) -> dict[str, Any]: + """Categorize and normalize the technical choice.""" + choice = input_data.choice.lower().strip() + + # Common technology categories + database_terms = { + "postgresql", + "postgres", + "mysql", + "mongodb", + "redis", + "sqlite", + "cassandra", + "dynamodb", + "elasticsearch", + } + frontend_terms = { + "react", + "vue", + "angular", + "svelte", + "next.js", + "nuxt", + "gatsby", + "typescript", + "javascript", + "tailwind", + "bootstrap", + } + backend_terms = { + "express", + "fastapi", + "django", + "flask", + "spring", + "rails", + "node.js", + "python", + "java", + "go", + "rust", + } + architecture_terms = { + "microservices", + "monolith", + "serverless", + "event-driven", + "rest", + "graphql", + "grpc", + "kubernetes", + "docker", + } + + # Determine category + category = input_data.category + if not category: + if choice in database_terms: + category = "database" + elif choice in frontend_terms: + category = "frontend" + elif choice in backend_terms: + category = "backend" + elif choice in architecture_terms: + category = "architecture" + else: + category = "technology" + + return { + "original": input_data.choice, + "normalized": choice, + "category": category, + "context": input_data.context or {}, + "aliases": self._get_technology_aliases(choice), + } + + def _get_technology_aliases(self, choice: str) -> list[str]: + """Get common aliases for a technology choice.""" + alias_map = { + "postgres": ["postgresql", "pg"], + "postgresql": ["postgres", "pg"], + "javascript": ["js", "node", "node.js"], + "typescript": ["ts"], + "react": ["reactjs", "react.js"], + "vue": ["vuejs", "vue.js"], + "next.js": ["nextjs", "next"], + "nuxt": ["nuxtjs", "nuxt.js"], + } + return alias_map.get(choice, [choice]) + + def _check_policy_gates( + self, choice: dict[str, Any], contract: ConstraintsContract + ) -> dict[str, Any]: + """Check choice against existing policy gates.""" + # Simplified gate checking - will be enhanced later + # This would integrate with the actual PolicyGate system when fully implemented + + blocked = False + pre_approved = False + requirements: list[str] = [] + + # Basic implementation - check against contract constraints + if not contract.constraints.is_empty(): + # Simple check for blocked technologies in constraints + constraint_text = str(contract.constraints.model_dump()).lower() + if choice["normalized"] in constraint_text: + # This is a very basic check - real implementation would be more sophisticated + pass + + return { + "blocked": blocked, + "pre_approved": pre_approved, + "requirements": requirements, + "applicable_gates": [], + } + + def _find_related_adrs( + self, choice: dict[str, Any], contract: ConstraintsContract + ) -> list[dict[str, Any]]: + """Find ADRs related to this technical choice.""" + related = [] + + for adr in contract.approved_adrs: + # Check title and tags + tags_text = " ".join(adr.tags) if adr.tags else "" + adr_text = f"{adr.title.lower()} {tags_text.lower()}" + + # Check if choice or aliases appear in ADR + if choice["normalized"] in adr_text: + related.append( + { + "adr_id": adr.id, + "title": adr.title, + "relevance": "direct_mention", + "category_match": choice["category"] in (adr.tags or []), + } + ) + continue + + # Check aliases + for alias in choice["aliases"]: + if alias in adr_text: + related.append( + { + "adr_id": adr.id, + "title": adr.title, + "relevance": "alias_match", + "category_match": choice["category"] in (adr.tags or []), + } + ) + break + + return related + + def _find_conflicting_adrs( + self, choice: dict[str, Any], contract: ConstraintsContract + ) -> list[dict[str, Any]]: + """Find ADRs that conflict with this technical choice.""" + conflicts = [] + + for adr in contract.approved_adrs: + # Check policy blocks + if adr.policy: + policy = adr.policy + + # Check disallowed imports/technologies + disallowed = [] + if policy.imports and policy.imports.disallow: + disallowed.extend(policy.imports.disallow) + if policy.python and policy.python.disallow_imports: + disallowed.extend(policy.python.disallow_imports) + + # Check if choice conflicts + choice_terms = [choice["normalized"]] + choice["aliases"] + for term in choice_terms: + if term in [d.lower() for d in disallowed]: + conflicts.append( + { + "adr_id": adr.id, + "title": adr.title, + "conflict_type": "policy_disallow", + "conflict_detail": f"ADR disallows '{term}'", + } + ) + + return conflicts + + def _make_preflight_decision( + self, + choice: dict[str, Any], + gate_result: dict[str, Any], + related_adrs: list[dict[str, Any]], + conflicting_adrs: list[dict[str, Any]], + contract: ConstraintsContract, + ) -> PreflightDecision: + """Make the final preflight decision.""" + + # BLOCKED - explicit conflicts found + if conflicting_adrs: + return PreflightDecision( + status="BLOCKED", + reasoning=f"Choice '{choice['original']}' conflicts with existing ADRs", + conflicting_adrs=[c["adr_id"] for c in conflicting_adrs], + related_adrs=[r["adr_id"] for r in related_adrs], + required_policies=[], + next_steps="Review conflicting ADRs and consider superseding them if this choice is necessary", + urgency="HIGH", + ) + + # BLOCKED - policy gate blocks + if gate_result["blocked"]: + return PreflightDecision( + status="BLOCKED", + reasoning=f"Choice '{choice['original']}' is blocked by policy gates", + conflicting_adrs=[], + related_adrs=[r["adr_id"] for r in related_adrs], + required_policies=gate_result["requirements"], + next_steps="Review policy gates and consider updating them if this choice is necessary", + urgency="HIGH", + ) + + # ALLOWED - pre-approved choice + if gate_result["pre_approved"]: + return PreflightDecision( + status="ALLOWED", + reasoning=f"Choice '{choice['original']}' is pre-approved by existing ADRs", + conflicting_adrs=[], + related_adrs=[r["adr_id"] for r in related_adrs], + required_policies=[], + next_steps="Proceed with implementation", + urgency="LOW", + ) + + # REQUIRES_ADR - significant choice not covered + if self._is_significant_choice(choice, related_adrs, contract): + return PreflightDecision( + status="REQUIRES_ADR", + reasoning=f"Choice '{choice['original']}' is architecturally significant and requires ADR", + conflicting_adrs=[], + related_adrs=[r["adr_id"] for r in related_adrs], + required_policies=self._suggest_required_policies(choice), + next_steps="Create ADR proposal documenting this architectural decision", + urgency="MEDIUM", + ) + + # ALLOWED - minor choice, proceed + return PreflightDecision( + status="ALLOWED", + reasoning=f"Choice '{choice['original']}' is minor and doesn't require ADR", + conflicting_adrs=[], + related_adrs=[r["adr_id"] for r in related_adrs], + required_policies=[], + next_steps="Proceed with implementation", + urgency="LOW", + ) + + def _is_significant_choice( + self, + choice: dict[str, Any], + related_adrs: list[dict[str, Any]], + contract: ConstraintsContract, + ) -> bool: + """Determine if a technical choice is significant enough to require ADR.""" + + # Always significant categories + significant_categories = {"database", "architecture", "framework"} + if choice["category"] in significant_categories: + return True + + # Frontend frameworks are significant + frontend_frameworks = {"react", "vue", "angular", "svelte"} + if choice["normalized"] in frontend_frameworks: + return True + + # Backend frameworks are significant + backend_frameworks = {"express", "fastapi", "django", "flask", "spring"} + if choice["normalized"] in backend_frameworks: + return True + + # If no existing ADRs, even minor choices might be worth documenting + if len(contract.approved_adrs) == 0: + return True + + return False + + def _suggest_required_policies(self, choice: dict[str, Any]) -> list[str]: + """Suggest policies that should be included in ADR for this choice.""" + policies = [] + + category = choice["category"] + choice["normalized"] + + if category == "database": + policies.extend( + [ + "Database access patterns", + "Migration strategy", + "Backup and recovery approach", + "Connection pooling configuration", + ] + ) + elif category == "frontend": + policies.extend( + [ + "Component structure guidelines", + "State management approach", + "Styling methodology", + "Bundle size constraints", + ] + ) + elif category == "backend": + policies.extend( + [ + "API design principles", + "Error handling patterns", + "Logging and monitoring", + "Security considerations", + ] + ) + elif category == "architecture": + policies.extend( + [ + "Service boundaries", + "Communication patterns", + "Data consistency approach", + "Deployment strategy", + ] + ) + + return policies + + def _generate_agent_guidance( + self, decision: PreflightDecision, input_data: PreflightInput + ) -> str: + """Generate actionable guidance for the agent.""" + + if decision.status == "ALLOWED": + return ( + f"✅ You can proceed with '{input_data.choice}'. " + f"{decision.reasoning}. {decision.next_steps}." + ) + + elif decision.status == "BLOCKED": + conflicts_text = "" + if decision.conflicting_adrs: + conflicts_text = ( + f" (conflicts with {', '.join(decision.conflicting_adrs)})" + ) + + return ( + f"🚫 Cannot use '{input_data.choice}'{conflicts_text}. " + f"{decision.reasoning}. " + f"Next step: {decision.next_steps}" + ) + + elif decision.status == "REQUIRES_ADR": + related_text = "" + if decision.related_adrs: + related_text = f" (related: {', '.join(decision.related_adrs)})" + + return ( + f"📝 '{input_data.choice}' requires ADR{related_text}. " + f"{decision.reasoning}. " + f"Use adr_create() to document this decision before proceeding." + ) + + return f"Evaluation complete: {decision.reasoning}" diff --git a/adr_kit/decision/workflows/supersede.py b/adr_kit/decision/workflows/supersede.py new file mode 100644 index 0000000..dfef624 --- /dev/null +++ b/adr_kit/decision/workflows/supersede.py @@ -0,0 +1,380 @@ +"""Supersede Workflow - Replace existing ADR with new decision.""" + +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from ...core.model import ADR +from ...core.parse import find_adr_files, parse_adr_file +from .approval import ApprovalInput, ApprovalWorkflow +from .base import BaseWorkflow, WorkflowResult, WorkflowStatus +from .creation import CreationInput, CreationWorkflow + + +@dataclass +class SupersedeInput: + """Input for ADR superseding workflow.""" + + old_adr_id: str # ADR to be superseded + new_proposal: CreationInput # New ADR proposal + supersede_reason: str # Why the old ADR is being replaced + auto_approve: bool = False # Whether to auto-approve the new ADR + preserve_history: bool = True # Whether to maintain bidirectional links + + +@dataclass +class SupersedeResult: + """Result of ADR superseding.""" + + old_adr_id: str + new_adr_id: str + old_adr_status: str # Previous status of old ADR + new_adr_status: str # Status of new ADR + relationships_updated: list[str] # ADR IDs that had relationships updated + automation_triggered: bool # Whether approval automation was triggered + conflicts_resolved: list[str] # Conflicts that were resolved by superseding + next_steps: str # Guidance for what happens next + + +class SupersedeWorkflow(BaseWorkflow): + """ + Supersede Workflow handles replacing existing ADRs with new decisions. + + This workflow manages the complex process of replacing an architectural + decision while maintaining proper relationships and triggering automation. + + Workflow Steps: + 1. Validate that old ADR exists and can be superseded + 2. Create new ADR proposal using CreationWorkflow + 3. Update old ADR status to 'superseded' + 4. Update bidirectional relationships (supersedes/superseded_by) + 5. Update any ADRs that referenced the old ADR + 6. Optionally approve new ADR (triggering ApprovalWorkflow) + 7. Generate comprehensive superseding report + """ + + def execute(self, **kwargs: Any) -> WorkflowResult: + """Execute ADR superseding workflow.""" + # Extract input_data from kwargs + input_data = kwargs.get("input_data") + if not input_data or not isinstance(input_data, SupersedeInput): + raise ValueError("input_data must be provided as SupersedeInput instance") + + self._start_workflow("Supersede ADR") + + try: + # Step 1: Validate superseding preconditions + old_adr, old_adr_file = self._execute_step( + "validate_supersede_preconditions", + self._validate_supersede_preconditions, + input_data.old_adr_id, + ) + old_status = old_adr.status + + # Step 2: Create new ADR proposal + creation_result = self._execute_step( + "create_new_adr", self._create_new_adr, input_data.new_proposal + ) + + new_adr_id = creation_result.data["creation_result"].adr_id + + # Step 3: Update old ADR to superseded status + self._execute_step( + "update_old_adr_status", + self._update_old_adr_status, + old_adr, + old_adr_file, + new_adr_id, + input_data.supersede_reason, + ) + + # Step 4: Update new ADR with supersedes relationship + self._execute_step( + "update_new_adr_relationships", + self._update_new_adr_relationships, + new_adr_id, + input_data.old_adr_id, + ) + + # Step 5: Update related ADRs + updated_relationships = self._execute_step( + "update_related_adr_relationships", + self._update_related_adr_relationships, + input_data.old_adr_id, + new_adr_id, + ) + + # Step 6: Resolve conflicts + resolved_conflicts = self._execute_step( + "resolve_conflicts", + self._resolve_conflicts_through_superseding, + input_data.old_adr_id, + creation_result.data["creation_result"].conflicts_detected, + ) + + # Step 7: Optionally approve new ADR + automation_triggered = False + new_adr_status = "proposed" + + if input_data.auto_approve: + approval_result = self._execute_step( + "auto_approve_new_adr", self._auto_approve_new_adr, new_adr_id + ) + automation_triggered = approval_result.get("success", False) + new_adr_status = "accepted" if automation_triggered else "proposed" + + # Step 8: Generate guidance + next_steps = self._execute_step( + "generate_supersede_guidance", + self._generate_supersede_guidance, + new_adr_id, + automation_triggered, + resolved_conflicts, + ) + + result = SupersedeResult( + old_adr_id=input_data.old_adr_id, + new_adr_id=new_adr_id, + old_adr_status=old_status, + new_adr_status=new_adr_status, + relationships_updated=updated_relationships, + automation_triggered=automation_triggered, + conflicts_resolved=resolved_conflicts, + next_steps=next_steps, + ) + + self._complete_workflow( + success=True, + message=f"ADR {input_data.old_adr_id} superseded by {new_adr_id}", + ) + self.result.data = {"supersede_result": result} + self.result.guidance = ( + f"ADR {input_data.old_adr_id} has been superseded by {new_adr_id}" + ) + self.result.next_steps = ( + next_steps.split(". ") + if isinstance(next_steps, str) + else [ + f"ADR {new_adr_id} has replaced {input_data.old_adr_id}", + "Review the new ADR and approve if ready", + "Update any dependent systems or documentation", + ] + ) + + except Exception as e: + self._complete_workflow( + success=False, + message=f"Supersede workflow failed: {str(e)}", + ) + self.result.add_error(f"SupersedeError: {str(e)}") + + return self.result + + def _create_new_adr(self, new_proposal: Any) -> WorkflowResult: + """Create new ADR using the creation workflow.""" + creation_workflow = CreationWorkflow(adr_dir=self.adr_dir) + creation_result = creation_workflow.execute(input_data=new_proposal) + + if creation_result.status != WorkflowStatus.SUCCESS: + raise Exception(f"Failed to create new ADR: {creation_result.message}") + + return creation_result + + def _auto_approve_new_adr(self, new_adr_id: str) -> dict: + """Auto-approve the new ADR if requested.""" + + approval_workflow = ApprovalWorkflow(adr_dir=self.adr_dir) + approval_input = ApprovalInput(adr_id=new_adr_id, force_approve=True) + approval_result = approval_workflow.execute(input_data=approval_input) + + return { + "success": approval_result.status == WorkflowStatus.SUCCESS, + "result": approval_result, + } + + def _validate_supersede_preconditions(self, old_adr_id: str) -> tuple[ADR, Path]: + """Validate that the old ADR exists and can be superseded.""" + adr_files = find_adr_files(self.adr_dir) + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path) + if adr.id == old_adr_id: + # Check if already superseded + if adr.status == "superseded": + raise ValueError(f"ADR {old_adr_id} is already superseded") + + return adr, file_path + except Exception: + continue + + raise ValueError(f"ADR {old_adr_id} not found in {self.adr_dir}") + + def _update_old_adr_status( + self, old_adr: ADR, old_adr_file: Path, new_adr_id: str, reason: str + ) -> None: + """Update old ADR status to superseded and add superseded_by relationship.""" + + # Read current file content + with open(old_adr_file, encoding="utf-8") as f: + content = f.read() + + # Update status + status_pattern = r"^status:\s*\w+$" + content = re.sub( + status_pattern, "status: superseded", content, flags=re.MULTILINE + ) + + # Update or add superseded_by field + superseded_by_pattern = r"^superseded_by:\s*.*$" + superseded_by_line = f'superseded_by: ["{new_adr_id}"]' + + if re.search(superseded_by_pattern, content, flags=re.MULTILINE): + # Replace existing superseded_by + content = re.sub( + superseded_by_pattern, superseded_by_line, content, flags=re.MULTILINE + ) + else: + # Add superseded_by before end of YAML front-matter + yaml_end = content.find("\n---\n") + if yaml_end != -1: + supersede_metadata = ( + f"{superseded_by_line}\n" + f'supersede_date: {datetime.now().strftime("%Y-%m-%d")}\n' + f'supersede_reason: "{reason}"\n' + ) + content = content[:yaml_end] + supersede_metadata + content[yaml_end:] + + # Write updated content + with open(old_adr_file, "w", encoding="utf-8") as f: + f.write(content) + + def _update_new_adr_relationships(self, new_adr_id: str, old_adr_id: str) -> None: + """Update new ADR to include supersedes relationship.""" + adr_files = find_adr_files(self.adr_dir) + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path) + if adr.id == new_adr_id: + # Read and update file + with open(file_path, encoding="utf-8") as f: + content = f.read() + + # Update or add supersedes field + supersedes_pattern = r"^supersedes:\s*.*$" + supersedes_line = f'supersedes: ["{old_adr_id}"]' + + if re.search(supersedes_pattern, content, flags=re.MULTILINE): + # Replace existing supersedes + content = re.sub( + supersedes_pattern, + supersedes_line, + content, + flags=re.MULTILINE, + ) + else: + # Add supersedes before end of YAML front-matter + yaml_end = content.find("\n---\n") + if yaml_end != -1: + content = ( + content[:yaml_end] + + supersedes_line + + "\n" + + content[yaml_end:] + ) + + # Write updated content + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + break + except Exception: + continue + + def _update_related_adr_relationships( + self, old_adr_id: str, new_adr_id: str + ) -> list[str]: + """Update any ADRs that referenced the old ADR.""" + updated_relationships = [] + adr_files = find_adr_files(self.adr_dir) + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path) + + # Skip the ADRs we're already updating + if adr.id in [old_adr_id, new_adr_id]: + continue + + # Check if this ADR references the old ADR + needs_update = False + + # Check supersedes relationships + if old_adr_id in (adr.supersedes or []): + needs_update = True + + # Check superseded_by relationships + if old_adr_id in (adr.superseded_by or []): + needs_update = True + + if needs_update: + # Read and update file + with open(file_path, encoding="utf-8") as f: + content = f.read() + + # Replace old ADR ID with new ADR ID in relationships + content = content.replace(f'"{old_adr_id}"', f'"{new_adr_id}"') + content = content.replace(f"'{old_adr_id}'", f"'{new_adr_id}'") + + # Write updated content + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + updated_relationships.append(adr.id) + + except Exception: + continue # Skip problematic files + + return updated_relationships + + def _resolve_conflicts_through_superseding( + self, old_adr_id: str, detected_conflicts: list[str] + ) -> list[str]: + """Resolve conflicts that existed with the old ADR.""" + resolved_conflicts = [] + + # If the new ADR had conflicts with the old ADR, those are now resolved + if old_adr_id in detected_conflicts: + resolved_conflicts.append(old_adr_id) + + # Additional conflict resolution logic could be added here + # For example, checking if superseding resolves policy conflicts + + return resolved_conflicts + + def _generate_supersede_guidance( + self, new_adr_id: str, automation_triggered: bool, resolved_conflicts: list[str] + ) -> str: + """Generate guidance for what happens next after superseding.""" + + if automation_triggered: + conflicts_text = "" + if resolved_conflicts: + conflicts_text = ( + f" and resolved conflicts with {', '.join(resolved_conflicts)}" + ) + + return ( + f"✅ Superseding complete! {new_adr_id} is now active{conflicts_text}. " + f"All automation has been triggered and policies are being enforced. " + f"The old decision is superseded and no longer active." + ) + else: + return ( + f"📋 Superseding complete! {new_adr_id} created but requires approval. " + f"Use adr_approve('{new_adr_id}') to activate the new decision and " + f"trigger policy enforcement. The old ADR is marked as superseded." + ) diff --git a/adr_kit/enforce/__init__.py b/adr_kit/enforce/__init__.py index 3b90a27..980b53e 100644 --- a/adr_kit/enforce/__init__.py +++ b/adr_kit/enforce/__init__.py @@ -1,6 +1,6 @@ -"""ADR enforcement functionality.""" +"""ADR enforcement functionality — shim for backward compatibility.""" -from .ci import CIWorkflowGenerator -from .script_generator import ScriptGenerator +from adr_kit.enforcement.generation.ci import CIWorkflowGenerator +from adr_kit.enforcement.generation.scripts import ScriptGenerator __all__ = ["CIWorkflowGenerator", "ScriptGenerator"] diff --git a/adr_kit/enforce/ci.py b/adr_kit/enforce/ci.py index a283f5d..021f8d0 100644 --- a/adr_kit/enforce/ci.py +++ b/adr_kit/enforce/ci.py @@ -1,149 +1,2 @@ -"""CI workflow generator for ADR enforcement. - -Generates GitHub Actions workflow YAML that runs ADR policy enforcement -checks on pull requests. The workflow: -- Runs `adr-kit enforce ci --format json` -- Fails the build on violations -- Posts structured PR comments for AI agents to self-correct -""" - -from pathlib import Path - -_MANAGED_HEADER = "# Auto-generated by ADR Kit — do not edit manually" - - -class CIWorkflowGenerator: - """Generate GitHub Actions workflow for ADR enforcement.""" - - def generate(self, output_path: Path | None = None) -> str: - """Generate a GitHub Actions workflow YAML. - - Args: - output_path: Where to write the file. If provided, writes to disk. - Defaults to None (return content only). - - Returns: - The workflow YAML content. - - Raises: - FileExistsError: If output_path exists and wasn't generated by ADR Kit. - """ - content = self._build_workflow() - - if output_path: - self._safe_write(output_path, content) - - return content - - def _build_workflow(self) -> str: - """Build the workflow YAML content.""" - return f"""{_MANAGED_HEADER} -# Regenerate: adr-kit generate-ci -name: ADR Enforcement - -on: - pull_request: - branches: [main, master] - -permissions: - contents: read - pull-requests: write - -jobs: - enforce: - name: ADR Policy Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install adr-kit - run: pip install adr-kit - - - name: Run enforcement checks - id: enforce - run: | - adr-kit enforce ci --format json > adr-report.json - echo "exit_code=$?" >> "$GITHUB_OUTPUT" - continue-on-error: true - - - name: Post PR comment - if: always() && steps.enforce.outcome == 'failure' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - let report; - try {{ - report = JSON.parse(fs.readFileSync('adr-report.json', 'utf8')); - }} catch (e) {{ - report = {{ passed: false, violations: [], errors: ['Failed to parse report'] }}; - }} - - let body = '## ADR Enforcement Report\\n\\n'; - - if (report.violations && report.violations.length > 0) {{ - body += '### Violations\\n\\n'; - for (const v of report.violations) {{ - const icon = v.severity === 'error' ? ':x:' : ':warning:'; - const loc = v.line ? `${{v.file}}:${{v.line}}` : v.file; - body += `${{icon}} **${{v.adr_id}}**: ${{v.message}}\\n`; - body += ` - Location: ` + '`' + `${{loc}}` + '`' + `\\n`; - if (v.fix_suggestion) {{ - body += ` - Fix: ${{v.fix_suggestion}}\\n`; - }} - body += '\\n'; - }} - }} - - if (report.summary) {{ - body += `\\n**Summary**: ${{report.summary.error_count}} error(s), ${{report.summary.warning_count}} warning(s)\\n`; - }} - - body += '\\n
Raw JSON report\\n\\n```json\\n' + JSON.stringify(report, null, 2) + '\\n```\\n
'; - - // Find existing comment to update - const {{ data: comments }} = await github.rest.issues.listComments({{ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }}); - const existing = comments.find(c => c.body.includes('## ADR Enforcement Report')); - - if (existing) {{ - await github.rest.issues.updateComment({{ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }}); - }} else {{ - await github.rest.issues.createComment({{ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }}); - }} - - - name: Fail on violations - if: steps.enforce.outcome == 'failure' - run: exit 1 -""" - - def _safe_write(self, output_path: Path, content: str) -> None: - """Write content, refusing to overwrite non-managed files.""" - if output_path.exists(): - existing = output_path.read_text(encoding="utf-8") - if not existing.startswith(_MANAGED_HEADER): - raise FileExistsError( - f"{output_path} exists and was not generated by ADR Kit. " - f"Remove it manually or add '{_MANAGED_HEADER}' as the first line." - ) - - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(content, encoding="utf-8") +from adr_kit.enforcement.generation.ci import * # noqa: F401,F403 +from adr_kit.enforcement.generation.ci import _MANAGED_HEADER # noqa: F401 diff --git a/adr_kit/enforce/eslint.py b/adr_kit/enforce/eslint.py index 634203b..c84ae81 100644 --- a/adr_kit/enforce/eslint.py +++ b/adr_kit/enforce/eslint.py @@ -1,383 +1 @@ -"""ESLint configuration generation from ADRs. - -Design decisions: -- Parse ADRs to extract library/framework decisions -- Generate ESLint rules to ban disallowed imports -- Support common patterns like "Use React Query instead of X" -- Generate rules for deprecated patterns based on superseded ADRs -""" - -import json -import re -from pathlib import Path -from typing import Any, TypedDict - -from ..core.model import ADR, ADRStatus -from ..core.parse import ParseError, find_adr_files, parse_adr_file -from ..core.policy_extractor import PolicyExtractor - - -class ADRMetadata(TypedDict): - """Type definition for ADR metadata in ESLint config.""" - - id: str - title: str - file_path: str | None - - -class ESLintADRMetadata(TypedDict): - """Type definition for __adr_metadata section.""" - - generated_by: str - source_adrs: list[ADRMetadata] - generation_timestamp: str | None - preferred_libraries: dict[str, str] | None - - -class ESLintConfig(TypedDict): - """Type definition for complete ESLint configuration.""" - - rules: dict[str, Any] - settings: dict[str, Any] - env: dict[str, Any] - extends: list[str] - __adr_metadata: ESLintADRMetadata - - -class StructuredESLintGenerator: - """Generate ESLint configuration from structured ADR policies.""" - - def __init__(self) -> None: - self.policy_extractor = PolicyExtractor() - - def generate_eslint_config(self, adr_dir: str = "docs/adr") -> ESLintConfig: - """Generate complete ESLint configuration from all accepted ADRs. - - Args: - adr_dir: Directory containing ADR files - - Returns: - ESLint configuration dictionary - """ - config: ESLintConfig = { - "rules": {}, - "settings": {}, - "env": {}, - "extends": [], - "__adr_metadata": { - "generated_by": "ADR Kit", - "source_adrs": [], - "generation_timestamp": None, - "preferred_libraries": None, - }, - } - - # Find all ADR files - adr_files = find_adr_files(adr_dir) - 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) - except ParseError: - continue - - # Extract policies and generate rules - banned_imports = [] - preferred_mappings = {} - - for adr in accepted_adrs: - policy = self.policy_extractor.extract_policy(adr) - config["__adr_metadata"]["source_adrs"].append( - { - "id": adr.front_matter.id, - "title": adr.front_matter.title, - "file_path": str(adr.file_path) if adr.file_path else None, - } - ) - - # Process import policies - if policy.imports: - if policy.imports.disallow: - for lib in policy.imports.disallow: - banned_imports.append( - { - "name": lib, - "message": f"Use alternative instead of {lib} (per {adr.front_matter.id}: {adr.front_matter.title})", - "adr_id": adr.front_matter.id, - } - ) - - if policy.imports.prefer: - for lib in policy.imports.prefer: - preferred_mappings[lib] = adr.front_matter.id - - # Generate no-restricted-imports rule - if banned_imports: - config["rules"]["no-restricted-imports"] = [ - "error", - {"paths": banned_imports}, - ] - - # Generate additional rules based on preferences - if preferred_mappings: - config["__adr_metadata"]["preferred_libraries"] = preferred_mappings - - # Add timestamp - from datetime import datetime - - config["__adr_metadata"]["generation_timestamp"] = datetime.now().isoformat() - - return config - - -class ESLintRuleExtractor: - """Extract ESLint rules from ADR content (legacy pattern-based approach).""" - - def __init__(self) -> None: - # Common patterns for identifying banned imports/libraries - self.ban_patterns = [ - # "Don't use X", "Avoid X", "Ban X" - r"(?i)(?:don't\s+use|avoid|ban|deprecated?)\s+([a-zA-Z0-9\-_@/]+)", - # "Use Y instead of X" - r"(?i)use\s+([a-zA-Z0-9\-_@/]+)\s+instead\s+of\s+([a-zA-Z0-9\-_@/]+)", - # "Replace X with Y" - r"(?i)replace\s+([a-zA-Z0-9\-_@/]+)\s+with\s+([a-zA-Z0-9\-_@/]+)", - # "No longer use X" - r"(?i)no\s+longer\s+use\s+([a-zA-Z0-9\-_@/]+)", - ] - - # Common library name mappings - self.library_mappings = { - "react-query": "@tanstack/react-query", - "react query": "@tanstack/react-query", - "axios": "axios", - "fetch": "fetch", - "lodash": "lodash", - "moment": "moment", - "date-fns": "date-fns", - "dayjs": "dayjs", - "jquery": "jquery", - "underscore": "underscore", - } - - def extract_from_adr(self, adr: ADR) -> dict[str, Any]: - """Extract ESLint rules from a single ADR. - - Args: - adr: The ADR object to extract rules from - - Returns: - Dictionary with extracted rule information - """ - banned_imports: list[str] = [] - preferred_imports: dict[str, str] = {} - custom_rules: list[dict[str, Any]] = [] - - rules: dict[str, Any] = { - "banned_imports": banned_imports, - "preferred_imports": preferred_imports, - "custom_rules": custom_rules, - } - - # Only extract rules from accepted ADRs - if adr.front_matter.status != ADRStatus.ACCEPTED: - return rules - - content = f"{adr.front_matter.title} {adr.content}".lower() - - # Extract banned imports using patterns - for pattern in self.ban_patterns: - matches = re.findall(pattern, content) - for match in matches: - if isinstance(match, tuple): - # Pattern with replacement (e.g., "use Y instead of X") - if len(match) == 2: - preferred, banned = match - banned_lib = self._normalize_library_name(banned.strip()) - preferred_lib = self._normalize_library_name(preferred.strip()) - - if banned_lib: - rules["banned_imports"].append(banned_lib) - if preferred_lib: - rules["preferred_imports"][banned_lib] = preferred_lib - else: - # Simple ban pattern - banned_lib = self._normalize_library_name(match.strip()) - if banned_lib: - rules["banned_imports"].append(banned_lib) - - # Check for frontend-specific rules - if "frontend" in (adr.front_matter.tags or []): - rules.update(self._extract_frontend_rules(content)) - - # Check for backend-specific rules - if any( - tag in (adr.front_matter.tags or []) for tag in ["backend", "api", "server"] - ): - rules.update(self._extract_backend_rules(content)) - - return rules - - def _normalize_library_name(self, name: str) -> str | None: - """Normalize library name to common import format.""" - name = name.lower().strip() - - # Check direct mappings - if name in self.library_mappings: - return self.library_mappings[name] - - # Skip common words that aren't libraries - skip_words = { - "the", - "a", - "an", - "and", - "or", - "but", - "in", - "on", - "at", - "to", - "for", - "of", - "with", - "by", - } - if name in skip_words or len(name) < 2: - return None - - # Basic validation - should look like a library name - if re.match(r"^[a-zA-Z0-9\-_@/]+$", name): - return name - - return None - - def _extract_frontend_rules(self, content: str) -> dict[str, Any]: - """Extract frontend-specific ESLint rules.""" - rules: dict[str, list[dict[str, str]]] = {"custom_rules": []} - - # React-specific patterns - if "react" in content: - if "hooks" in content and ("don't" in content or "avoid" in content): - rules["custom_rules"].append( - {"rule": "react-hooks/rules-of-hooks", "severity": "error"} - ) - - return rules - - def _extract_backend_rules(self, content: str) -> dict[str, Any]: - """Extract backend-specific ESLint rules.""" - rules: dict[str, list[dict[str, str]]] = {"custom_rules": []} - - # Node.js specific patterns - if "node" in content or "nodejs" in content: - if "synchronous" in content and ("don't" in content or "avoid" in content): - rules["custom_rules"].append({"rule": "no-sync", "severity": "error"}) - - return rules - - -def generate_eslint_config(adr_directory: Path | str = "docs/adr") -> str: - """Generate ESLint configuration from ADRs using hybrid approach. - - Uses structured policies first, falls back to pattern matching. - - Args: - adr_directory: Directory containing ADR files - - Returns: - JSON string with ESLint configuration - """ - # Use structured policy generator (primary) - structured_generator = StructuredESLintGenerator() - config = structured_generator.generate_eslint_config(str(adr_directory)) - - # Enhance with pattern-based extraction (backup for legacy ADRs) - extractor = ESLintRuleExtractor() - - # Enhance with legacy pattern-based extraction for ADRs without structured policies - additional_banned = set() - adr_files = find_adr_files(adr_directory) - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path, strict=False) - if not adr or adr.front_matter.status != ADRStatus.ACCEPTED: - continue - - # Skip if already has structured policy - if adr.front_matter.policy and adr.front_matter.policy.imports: - continue - - # Use pattern extraction for legacy ADRs - rules = extractor.extract_from_adr(adr) - additional_banned.update(rules["banned_imports"]) - - except ParseError: - continue - - # Merge additional pattern-based rules into structured config - if additional_banned and "no-restricted-imports" in config["rules"]: - existing_paths = config["rules"]["no-restricted-imports"][1]["paths"] - existing_names = {item["name"] for item in existing_paths} - - for lib in additional_banned: - if lib not in existing_names: - existing_paths.append( - { - "name": lib, - "message": f"Import of '{lib}' is not allowed (extracted from ADR content)", - } - ) - elif additional_banned: - # No structured rules, use pattern-based only - banned_patterns = [] - for lib in additional_banned: - banned_patterns.append( - { - "name": lib, - "message": f"Import of '{lib}' is not allowed according to ADR decisions", - } - ) - config["rules"]["no-restricted-imports"] = ["error", {"paths": banned_patterns}] - - # Return the enhanced configuration as JSON - return json.dumps(config, indent=2) - - -def generate_eslint_overrides( - adr_directory: Path | str = "docs/adr", -) -> dict[str, Any]: - """Generate ESLint override configuration for specific file patterns. - - Args: - adr_directory: Directory containing ADR files - - Returns: - Dictionary with override configuration - """ - # This could be extended to create file-pattern-specific rules - # based on ADR tags or content analysis - - overrides = [] - - # Example: Stricter rules for production files - overrides.append( - { - "files": ["src/components/**/*.tsx", "src/pages/**/*.tsx"], - "rules": {"no-console": "error", "no-debugger": "error"}, - } - ) - - # Example: Relaxed rules for test files - overrides.append( - { - "files": ["**/*.test.{js,ts,jsx,tsx}", "**/*.spec.{js,ts,jsx,tsx}"], - "rules": {"no-console": "warn"}, - } - ) - - return {"overrides": overrides} +from adr_kit.enforcement.adapters.eslint import * # noqa: F401,F403 diff --git a/adr_kit/enforce/hooks.py b/adr_kit/enforce/hooks.py index adf1bce..6f090fd 100644 --- a/adr_kit/enforce/hooks.py +++ b/adr_kit/enforce/hooks.py @@ -1,173 +1,2 @@ -"""Git hook generator for staged ADR enforcement. - -Writes a managed section into .git/hooks/pre-commit and .git/hooks/pre-push -so that ADR policy checks run automatically at the right workflow stage. - -Design: -- Non-interfering: appends a managed section to existing hooks, never overwrites. -- Idempotent: re-running updates the managed section in-place. -- Clearly marked: ADR-KIT markers make ownership obvious. -- First-run bootstraps: creates hook file if it doesn't exist. -""" - -import stat -from pathlib import Path - -# Sentinel markers — must be unique and stable across versions -MANAGED_START = "# >>> ADR-KIT MANAGED - DO NOT EDIT >>>" -MANAGED_END = "# <<< ADR-KIT MANAGED <<<" - -_HOOK_HEADER = "#!/bin/sh" - -# Per-hook managed content -_COMMIT_SECTION = f"""\ -{MANAGED_START} -adr-kit enforce commit -{MANAGED_END}""" - -_PUSH_SECTION = f"""\ -{MANAGED_START} -adr-kit enforce push -{MANAGED_END}""" - - -def _make_executable(path: Path) -> None: - """Ensure the hook file has executable permission.""" - current = path.stat().st_mode - path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - -def _apply_managed_section(hook_path: Path, managed_content: str) -> str: - """Insert or replace the ADR-Kit managed section in a hook file. - - If the hook doesn't exist, creates it with a shebang + managed section. - Returns a string describing what changed: "created" | "updated" | "unchanged". - """ - if not hook_path.exists(): - hook_path.write_text(f"{_HOOK_HEADER}\n\n{managed_content}\n") - _make_executable(hook_path) - return "created" - - existing = hook_path.read_text() - - # Replace existing managed section - if MANAGED_START in existing and MANAGED_END in existing: - start_idx = existing.index(MANAGED_START) - end_idx = existing.index(MANAGED_END) + len(MANAGED_END) - new_section = ( - existing[:start_idx].rstrip("\n") - + "\n\n" - + managed_content - + "\n" - + existing[end_idx:].lstrip("\n") - ) - if new_section == existing: - return "unchanged" - hook_path.write_text(new_section) - _make_executable(hook_path) - return "updated" - - # No managed section yet — append - separator = "\n\n" if existing.rstrip() else "" - hook_path.write_text(existing.rstrip() + separator + managed_content + "\n") - _make_executable(hook_path) - return "appended" - - -class HookGenerator: - """Generates and updates git hooks for staged ADR enforcement. - - Writes ADR-Kit managed sections into .git/hooks/pre-commit and - .git/hooks/pre-push. Safe to call repeatedly — idempotent. - """ - - def generate(self, project_root: Path | None = None) -> dict[str, str]: - """Write managed sections into pre-commit and pre-push hooks. - - Args: - project_root: Root of the git repository. Defaults to cwd. - - Returns: - Dict mapping hook name → action taken ("created"|"updated"|"appended"|"unchanged"|"skipped"). - """ - project_root = project_root or Path.cwd() - hooks_dir = project_root / ".git" / "hooks" - - if not hooks_dir.exists(): - # Not a git repo or hooks dir missing — skip silently - return { - "pre-commit": "skipped (no .git/hooks directory)", - "pre-push": "skipped (no .git/hooks directory)", - } - - results: dict[str, str] = {} - - results["pre-commit"] = _apply_managed_section( - hooks_dir / "pre-commit", _COMMIT_SECTION - ) - results["pre-push"] = _apply_managed_section( - hooks_dir / "pre-push", _PUSH_SECTION - ) - - return results - - def remove(self, project_root: Path | None = None) -> dict[str, str]: - """Remove ADR-Kit managed sections from git hooks. - - Useful when uninstalling or disabling enforcement. - - Returns: - Dict mapping hook name → action taken ("removed"|"not_found"|"skipped"). - """ - project_root = project_root or Path.cwd() - hooks_dir = project_root / ".git" / "hooks" - - if not hooks_dir.exists(): - return { - "pre-commit": "skipped (no .git/hooks directory)", - "pre-push": "skipped (no .git/hooks directory)", - } - - results: dict[str, str] = {} - for hook_name in ("pre-commit", "pre-push"): - hook_path = hooks_dir / hook_name - if not hook_path.exists(): - results[hook_name] = "not_found" - continue - - content = hook_path.read_text() - if MANAGED_START not in content: - results[hook_name] = "not_found" - continue - - start_idx = content.index(MANAGED_START) - end_idx = content.index(MANAGED_END) + len(MANAGED_END) - # Strip surrounding blank lines added when appending - cleaned = content[:start_idx].rstrip("\n") + content[end_idx:].lstrip("\n") - if not cleaned.strip(): - # Hook only contained our section — remove the file - hook_path.unlink() - else: - hook_path.write_text(cleaned) - results[hook_name] = "removed" - - return results - - def status(self, project_root: Path | None = None) -> dict[str, bool]: - """Check whether ADR-Kit managed sections are present in hooks. - - Returns: - Dict mapping hook name → True if managed section is present. - """ - project_root = project_root or Path.cwd() - hooks_dir = project_root / ".git" / "hooks" - - result: dict[str, bool] = {} - for hook_name in ("pre-commit", "pre-push"): - hook_path = hooks_dir / hook_name - if not hook_path.exists(): - result[hook_name] = False - continue - result[hook_name] = MANAGED_START in hook_path.read_text() - - return result +from adr_kit.enforcement.generation.hooks import * # noqa: F401,F403 +from adr_kit.enforcement.generation.hooks import _apply_managed_section # noqa: F401 diff --git a/adr_kit/enforce/reporter.py b/adr_kit/enforce/reporter.py index cf2d6ca..abc5c42 100644 --- a/adr_kit/enforce/reporter.py +++ b/adr_kit/enforce/reporter.py @@ -1,91 +1 @@ -"""AI-readable enforcement report generation. - -Converts ValidationResult into structured JSON that agents and CI pipelines -can consume for automated violation handling and self-correction. - -Output schema: - - schema_version: report format version (currently "1.0") - - level: enforcement level that was run - - passed: whether the check passed (no error-severity violations) - - summary: counts of files, checks, errors, warnings - - violations: list of structured violations with fix suggestions - - errors: any errors encountered during validation -""" - -from datetime import datetime, timezone - -from pydantic import BaseModel - -from .validator import ValidationResult - - -class ViolationEntry(BaseModel): - """Single violation in an enforcement report.""" - - file: str - line: int | None = None - adr_id: str - adr_title: str | None = None - message: str - severity: str - level: str - fix_suggestion: str | None = None - - -class ReportSummary(BaseModel): - """Aggregate counts for an enforcement run.""" - - files_checked: int - checks_run: int - error_count: int - warning_count: int - - -class EnforcementReport(BaseModel): - """AI-readable enforcement report — JSON output for agents and CI.""" - - schema_version: str = "1.0" - level: str - timestamp: str - passed: bool - summary: ReportSummary - violations: list[ViolationEntry] - errors: list[str] - - -def build_report(result: ValidationResult) -> EnforcementReport: - """Convert a ValidationResult to a serializable EnforcementReport. - - Args: - result: ValidationResult from StagedValidator.validate(). - - Returns: - EnforcementReport ready for JSON serialization. - """ - violations = [ - ViolationEntry( - file=v.file, - line=v.line, - adr_id=v.adr_id, - adr_title=v.adr_title, - message=v.message, - severity=v.severity, - level=v.level.value, - fix_suggestion=v.fix_suggestion, - ) - for v in result.violations - ] - - return EnforcementReport( - level=result.level.value, - timestamp=datetime.now(timezone.utc).isoformat(), - passed=result.passed, - summary=ReportSummary( - files_checked=result.files_checked, - checks_run=result.checks_run, - error_count=result.error_count, - warning_count=result.warning_count, - ), - violations=violations, - errors=result.errors, - ) +from adr_kit.enforcement.reporter import * # noqa: F401,F403 diff --git a/adr_kit/enforce/ruff.py b/adr_kit/enforce/ruff.py index 4bf5b24..966b0a8 100644 --- a/adr_kit/enforce/ruff.py +++ b/adr_kit/enforce/ruff.py @@ -1,358 +1 @@ -"""Ruff and import-linter configuration generation from ADRs. - -Design decisions: -- Generate Ruff rules for Python code quality based on ADR decisions -- Create import-linter rules to enforce architectural boundaries -- Support common Python library migration patterns -- Generate rules for deprecated packages based on superseded ADRs -""" - -import configparser -import re -from io import StringIO -from pathlib import Path -from typing import Any - -import toml - -from ..core.model import ADR, ADRStatus -from ..core.parse import ParseError, find_adr_files, parse_adr_file - - -class PythonRuleExtractor: - """Extract Python linting rules from ADR content.""" - - def __init__(self) -> None: - # Common patterns for Python library decisions - self.python_ban_patterns = [ - r"(?i)(?:don't\s+use|avoid|ban|deprecated?)\s+([a-zA-Z0-9\-_]+)", - r"(?i)use\s+([a-zA-Z0-9\-_]+)\s+instead\s+of\s+([a-zA-Z0-9\-_]+)", - r"(?i)replace\s+([a-zA-Z0-9\-_]+)\s+with\s+([a-zA-Z0-9\-_]+)", - r"(?i)no\s+longer\s+use\s+([a-zA-Z0-9\-_]+)", - ] - - # Python library mappings - self.python_libraries = { - "requests": "requests", - "urllib": "urllib", - "httpx": "httpx", - "aiohttp": "aiohttp", - "flask": "flask", - "django": "django", - "fastapi": "fastapi", - "sqlalchemy": "sqlalchemy", - "peewee": "peewee", - "pydantic": "pydantic", - "marshmallow": "marshmallow", - "pandas": "pandas", - "numpy": "numpy", - "pytest": "pytest", - "unittest": "unittest", - "click": "click", - "argparse": "argparse", - "typer": "typer", - } - - def extract_from_adr(self, adr: Any) -> dict[str, Any]: - """Extract Python rules from a single ADR.""" - rules: dict[str, Any] = { - "banned_imports": [], - "preferred_imports": {}, - "architectural_rules": [], - "ruff_rules": {}, - } - - # Only extract from accepted ADRs - if adr.front_matter.status != ADRStatus.ACCEPTED: - return rules - - content = f"{adr.front_matter.title} {adr.content}".lower() - tags = adr.front_matter.tags or [] - - # Extract banned Python imports - for pattern in self.python_ban_patterns: - matches = re.findall(pattern, content) - for match in matches: - if isinstance(match, tuple): - if len(match) == 2: - preferred, banned = match - banned_lib = self._normalize_python_library(banned.strip()) - preferred_lib = self._normalize_python_library( - preferred.strip() - ) - - if banned_lib: - rules["banned_imports"].append(banned_lib) - if preferred_lib: - rules["preferred_imports"][banned_lib] = preferred_lib - else: - banned_lib = self._normalize_python_library(match.strip()) - if banned_lib: - rules["banned_imports"].append(banned_lib) - - # Extract architectural rules - if "architecture" in tags or "layering" in tags: - rules["architectural_rules"].extend( - self._extract_architectural_rules(content, adr) - ) - - # Extract code quality rules - if "code-quality" in tags or "standards" in tags: - rules["ruff_rules"].update(self._extract_ruff_rules(content)) - - return rules - - def _normalize_python_library(self, name: str) -> str | None: - """Normalize Python library name.""" - name = name.lower().strip() - - if name in self.python_libraries: - return self.python_libraries[name] - - # Skip common words - skip_words = { - "the", - "a", - "an", - "and", - "or", - "but", - "in", - "on", - "at", - "to", - "for", - "of", - "with", - "by", - } - if name in skip_words or len(name) < 2: - return None - - # Basic validation for Python module names - if re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name): - return name - - return None - - def _extract_architectural_rules( - self, content: str, adr: ADR - ) -> list[dict[str, Any]]: - """Extract architectural/layering rules.""" - rules = [] - - # Look for layer separation rules - if "layer" in content or "boundary" in content: - # Example: "Domain layer should not depend on infrastructure" - domain_infra_pattern = r"domain.*should not.*depend.*infrastructure" - if re.search(domain_infra_pattern, content, re.IGNORECASE): - rules.append( - { - "name": f"domain-infra-separation-{adr.front_matter.id.lower()}", - "source_modules": ["domain", "core"], - "forbidden_modules": ["infrastructure", "adapters"], - "description": "Domain layer should not depend on infrastructure", - } - ) - - # Look for service separation rules - if "service" in content and ("separate" in content or "isolated" in content): - # This would need more sophisticated parsing to extract specific services - pass - - return rules - - def _extract_ruff_rules(self, content: str) -> dict[str, str]: - """Extract Ruff-specific rules.""" - rules = {} - - # Type checking rules - if "type" in content and ("enforce" in content or "strict" in content): - rules.update( - { - "ANN": "error", # flake8-annotations - "UP": "error", # pyupgrade - } - ) - - # Code complexity rules - if "complexity" in content or "cyclomatic" in content: - rules["C901"] = "error" # mccabe complexity - - # Security rules - if "security" in content: - rules["S"] = "error" # flake8-bandit - - # Performance rules - if "performance" in content: - rules["PERF"] = "error" # perflint - - return rules - - -def generate_ruff_config(adr_directory: Path | str = "docs/adr") -> str: - """Generate Ruff configuration from ADRs. - - Args: - adr_directory: Directory containing ADR files - - Returns: - TOML string with Ruff configuration - """ - extractor = PythonRuleExtractor() - - # Find and parse all ADRs - adr_files = find_adr_files(adr_directory) - all_banned_imports = set() - preferred_imports = {} - all_ruff_rules = {} - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path, strict=False) - if not adr: - continue - - rules = extractor.extract_from_adr(adr) - - all_banned_imports.update(rules["banned_imports"]) - preferred_imports.update(rules["preferred_imports"]) - all_ruff_rules.update(rules["ruff_rules"]) - - except ParseError: - continue - - # Build Ruff configuration - ruff_config = { - "# ADR Kit Generated Configuration": "Do not edit manually", - "target-version": "py312", - "line-length": 88, - "select": list(all_ruff_rules.keys()) if all_ruff_rules else ["E", "W", "F"], - "extend-ignore": [], - } - - # Add banned imports if any - if all_banned_imports: - banned_list = list(all_banned_imports) - ruff_config["flake8-import-conventions"] = {"banned-imports": banned_list} - - # Add custom error messages - ruff_config["# Banned imports from ADRs"] = { - lib: f"Import of '{lib}' is not allowed according to ADR decisions" - for lib in banned_list - } - - return toml.dumps(ruff_config) - - -def generate_import_linter_config(adr_directory: Path | str = "docs/adr") -> str: - """Generate import-linter configuration from ADRs. - - Args: - adr_directory: Directory containing ADR files - - Returns: - INI string with import-linter configuration - """ - extractor = PythonRuleExtractor() - - # Find and parse all ADRs - adr_files = find_adr_files(adr_directory) - all_architectural_rules = [] - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path, strict=False) - if not adr: - continue - - rules = extractor.extract_from_adr(adr) - all_architectural_rules.extend(rules["architectural_rules"]) - - except ParseError: - continue - - # Build import-linter configuration - config = configparser.ConfigParser() - - # Main configuration - config["importlinter"] = { - "root_package": "src", - "include_external_packages": "False", - } - - # Add architectural rules as contracts - for _i, rule in enumerate(all_architectural_rules): - contract_name = f"contract:{rule['name']}" - config[contract_name] = { - "name": rule["description"], - "type": "forbidden", - "source_modules": "\n ".join(rule["source_modules"]), - "forbidden_modules": "\n ".join(rule["forbidden_modules"]), - } - - # Add general layer separation if no specific rules found - if not all_architectural_rules: - config["contract:domain-infrastructure"] = { - "name": "Domain layer should not depend on infrastructure", - "type": "forbidden", - "source_modules": "\n domain\n core", - "forbidden_modules": "\n infrastructure\n adapters", - } - - # Convert to string - output = StringIO() - config.write(output) - content = output.getvalue() - - # Add header comment - header = """# Import Linter Configuration Generated from ADRs -# Do not edit manually - regenerate using: adr-kit export-lint import-linter - -""" - - return header + content - - -def generate_pyproject_ruff_section( - adr_directory: Path | str = "docs/adr", -) -> dict[str, Any]: - """Generate Ruff section for pyproject.toml from ADRs. - - Args: - adr_directory: Directory containing ADR files - - Returns: - Dictionary with Ruff configuration for pyproject.toml - """ - extractor = PythonRuleExtractor() - - # Find and parse all ADRs - adr_files = find_adr_files(adr_directory) - all_ruff_rules = {} - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path, strict=False) - if not adr: - continue - - rules = extractor.extract_from_adr(adr) - all_ruff_rules.update(rules["ruff_rules"]) - - except ParseError: - continue - - # Return pyproject.toml compatible structure - return { - "tool": { - "ruff": { - "target-version": "py312", - "line-length": 88, - "select": ( - list(all_ruff_rules.keys()) if all_ruff_rules else ["E", "W", "F"] - ), - "extend-ignore": [], - } - } - } +from adr_kit.enforcement.adapters.ruff import * # noqa: F401,F403 diff --git a/adr_kit/enforce/script_generator.py b/adr_kit/enforce/script_generator.py index 2b5b567..347d83b 100644 --- a/adr_kit/enforce/script_generator.py +++ b/adr_kit/enforce/script_generator.py @@ -1,522 +1 @@ -"""Standalone validation script generator. - -Generates stdlib-only Python scripts from ADR policies. Each script: -- Checks one ADR's policies against source files -- Outputs JSON matching the EnforcementReport schema (reporter.py) -- Supports --quick (git changed files) and --full (all files) modes -- Requires no adr-kit installation at runtime - -Also generates a validate_all.py runner that aggregates results. -""" - -import stat -from pathlib import Path - -from ..core.model import ADR, ADRStatus -from ..core.parse import ParseError, find_adr_files, parse_adr_file -from .stages import StagedCheck, classify_adr_checks - - -class ScriptGenerator: - """Generate standalone validation scripts from ADR policies.""" - - def __init__(self, adr_dir: str | Path = "docs/adr") -> None: - self.adr_dir = Path(adr_dir) - - def generate_for_adr(self, adr: ADR, output_dir: Path) -> Path | None: - """Generate a validation script for a single ADR. - - Returns: - Path to the generated script, or None if ADR has no enforceable policies. - """ - checks = classify_adr_checks([adr]) - # Skip config checks — they need TOML/JSON parsing not yet implemented - enforceable = [c for c in checks if c.check_type != "config"] - if not enforceable: - return None - - script = self._build_script(adr, enforceable) - if not script: - return None - - output_dir.mkdir(parents=True, exist_ok=True) - safe_id = adr.id.lower().replace("-", "_") - script_path = output_dir / f"validate_{safe_id}.py" - script_path.write_text(script, encoding="utf-8") - script_path.chmod( - script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH - ) - - return script_path - - def generate_all(self, output_dir: Path) -> list[Path]: - """Generate scripts for all accepted ADRs and a runner. - - Returns: - List of all generated file paths (individual scripts + runner). - """ - adrs = self._load_accepted_adrs() - paths: list[Path] = [] - - for adr in adrs: - path = self.generate_for_adr(adr, output_dir) - if path: - paths.append(path) - - runner = self._generate_runner_script(output_dir) - paths.append(runner) - - return paths - - # --- Script building --- - - def _build_script(self, adr: ADR, checks: list[StagedCheck]) -> str: - """Build the complete script content for an ADR.""" - adr_id = _escape(adr.id) - adr_title = _escape(adr.title) - - check_functions: list[str] = [] - check_calls: list[str] = [] - - for i, check in enumerate(checks): - func_name = f"check_{i}" - func_code = self._generate_check_function(func_name, check) - if func_code: - check_functions.append(func_code) - check_calls.append(func_name) - - if not check_functions: - return "" - - checks_code = "\n\n\n".join(check_functions) - calls_code = "\n".join( - f" violations.extend({name}(files))" for name in check_calls - ) - num_checks = len(check_calls) - - return f'''\ -#!/usr/bin/env python3 -"""{adr_id} validation: {adr_title} - -Auto-generated by ADR Kit. Regenerate: adr-kit generate-scripts -""" - -import argparse -import fnmatch -import json -import os -import re -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - -ADR_ID = "{adr_id}" -ADR_TITLE = "{adr_title}" - -SOURCE_EXTENSIONS = {{".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".kt"}} - -EXCLUDE_DIRS = {{ - ".git", ".venv", "venv", "node_modules", "__pycache__", - ".pytest_cache", ".mypy_cache", ".ruff_cache", "dist", "build", - ".adr-kit", ".project-index", -}} - - -def get_files(mode, project_root="."): - """Collect files based on mode.""" - root = Path(project_root) - - if mode == "quick": - try: - result = subprocess.run( - ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], - capture_output=True, text=True, cwd=root, - ) - if result.returncode == 0: - files = [root / f for f in result.stdout.strip().splitlines() if f] - return [f for f in files if f.exists() and f.suffix in SOURCE_EXTENSIONS] - except FileNotFoundError: - print("Warning: git not available, falling back to full scan", file=sys.stderr) - - files = [] - for f in root.rglob("*"): - if not f.is_file(): - continue - if f.suffix not in SOURCE_EXTENSIONS: - continue - if any(part in EXCLUDE_DIRS for part in f.parts): - continue - files.append(f) - return files - - -{checks_code} - - -def main(): - parser = argparse.ArgumentParser(description=f"Validate {{ADR_ID}}: {{ADR_TITLE}}") - parser.add_argument("--quick", action="store_true", help="Check staged/changed files only") - parser.add_argument("--full", action="store_true", default=True, help="Check all files (default)") - parser.add_argument("--root", default=".", help="Project root directory") - args = parser.parse_args() - - mode = "quick" if args.quick else "full" - project_root = Path(args.root).resolve() - files = get_files(mode, project_root) - - violations = [] -{calls_code} - - error_count = sum(1 for v in violations if v["severity"] == "error") - warning_count = sum(1 for v in violations if v["severity"] == "warning") - - report = {{ - "schema_version": "1.0", - "level": "ci", - "timestamp": datetime.now(timezone.utc).isoformat(), - "passed": error_count == 0, - "summary": {{ - "files_checked": len(files), - "checks_run": {num_checks}, - "error_count": error_count, - "warning_count": warning_count, - }}, - "violations": violations, - "errors": [], - }} - - print(json.dumps(report, indent=2)) - sys.exit(0 if report["passed"] else 1) - - -if __name__ == "__main__": - main() -''' - - def _generate_check_function( - self, func_name: str, check: StagedCheck - ) -> str | None: - """Generate a check function for a single StagedCheck.""" - if check.check_type in ("import", "python_import"): - return self._generate_import_check(func_name, check) - elif check.check_type == "pattern": - return self._generate_pattern_check(func_name, check) - elif check.check_type == "architecture": - return self._generate_architecture_check(func_name, check) - elif check.check_type == "required_structure": - return self._generate_structure_check(func_name, check) - return None - - def _generate_import_check(self, func_name: str, check: StagedCheck) -> str: - """Generate code for an import restriction check.""" - if check.check_type == "python_import": - filter_line = ' target_files = [f for f in files if f.suffix == ".py"]' - else: - filter_line = ( - " target_files = [f for f in files if f.suffix in " - '{".js", ".ts", ".jsx", ".tsx"}]' - ) - - lib = _escape(check.pattern) - adr_id = _escape(check.adr_id) - severity = _escape(check.severity) - message = _escape(check.message) - adr_title = _escape(check.adr_title or "") - level = _escape(check.level.value) - - return f'''\ -def {func_name}(files): - """From {adr_id}: import check for '{lib}'""" -{filter_line} - violations = [] - escaped = re.escape("{lib}") - _q = "[" + chr(39) + chr(34) + "]" - _nq = "[^" + chr(39) + chr(34) + "]*?" - import_patterns = [ - re.compile(r"(from|import)\\s+" + escaped + r"(\\.|\\s|$|;)"), - re.compile(r"(import|from)\\s+" + _q + r"(" + _nq + r"/)?" + escaped + _q), - re.compile(r"require\\s*\\(\\s*" + _q + r"(" + _nq + r"/)?" + escaped + _q + r"\\s*\\)"), - ] - for file_path in target_files: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - for line_num, line in enumerate(content.splitlines(), 1): - for pattern in import_patterns: - if pattern.search(line): - violations.append({{ - "file": str(file_path), - "line": line_num, - "adr_id": "{adr_id}", - "adr_title": "{adr_title}", - "message": "{message}", - "severity": "{severity}", - "level": "{level}", - "fix_suggestion": "Remove or replace this import — see {adr_id}", - }}) - break - except Exception: - continue - return violations''' - - def _generate_pattern_check(self, func_name: str, check: StagedCheck) -> str: - """Generate code for a regex pattern check.""" - # Only escape quotes — pattern goes in r"..." where backslashes are literal - pattern_escaped = check.pattern.replace('"', '\\"') - adr_id = _escape(check.adr_id) - severity = _escape(check.severity) - message = _escape(check.message) - adr_title = _escape(check.adr_title or "") - level = _escape(check.level.value) - - if check.file_glob and check.file_glob.startswith("*."): - ext = check.file_glob[1:] - filter_line = ( - f' target_files = [f for f in files if f.name.endswith("{ext}")]' - ) - else: - filter_line = " target_files = files" - - return f'''\ -def {func_name}(files): - """From {adr_id}: pattern check""" -{filter_line} - violations = [] - try: - compiled = re.compile(r"{pattern_escaped}") - except re.error: - return [] - for file_path in target_files: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - for line_num, line in enumerate(content.splitlines(), 1): - if compiled.search(line): - violations.append({{ - "file": str(file_path), - "line": line_num, - "adr_id": "{adr_id}", - "adr_title": "{adr_title}", - "message": "{message}", - "severity": "{severity}", - "level": "{level}", - "fix_suggestion": None, - }}) - except Exception: - continue - return violations''' - - def _generate_architecture_check(self, func_name: str, check: StagedCheck) -> str: - """Generate code for an architecture layer boundary check.""" - parts = check.pattern.split("->") - if len(parts) != 2: - return "" - source_layer = parts[0].strip().lower() - target_layer = parts[1].strip().lower() - if not source_layer or not target_layer: - return "" - - check_glob = _escape(check.metadata.get("check", "")) - adr_id = _escape(check.adr_id) - severity = _escape(check.severity) - message = _escape(check.message) - adr_title = _escape(check.adr_title or "") - level = _escape(check.level.value) - - return f'''\ -def {func_name}(files): - """From {adr_id}: architecture check '{source_layer} -> {target_layer}'""" - source_layer = "{source_layer}" - target_layer = "{target_layer}" - check_glob = "{check_glob}" - violations = [] - - if check_glob: - # Extract directory prefix from glob (e.g., "ui/**/*.py" -> "ui") - glob_dir = check_glob.split("/")[0].lower() if "/" in check_glob else check_glob.lower() - glob_ext = None - if check_glob.endswith(".py"): - glob_ext = ".py" - elif check_glob.endswith(".ts") or check_glob.endswith(".tsx"): - glob_ext = (".ts", ".tsx") - elif check_glob.endswith(".js") or check_glob.endswith(".jsx"): - glob_ext = (".js", ".jsx") - source_files = [] - for f in files: - if glob_dir not in [p.lower() for p in f.parts]: - continue - if glob_ext and not f.suffix in (glob_ext if isinstance(glob_ext, tuple) else (glob_ext,)): - continue - source_files.append(f) - else: - source_files = [f for f in files if source_layer in [p.lower() for p in f.parts]] - - escaped = re.escape(target_layer) - _q = "[" + chr(39) + chr(34) + "]" - _nq = "[^" + chr(39) + chr(34) + "]*?" - target_patterns = [ - re.compile(r"(from|import)\\s+" + escaped + r"(\\.\\w+)*(\\s|$|;)", re.IGNORECASE), - re.compile(r"(import|from)\\s+" + _q + r"(" + _nq + r"[/\\\\])?" + escaped + r"([/\\\\]" + _nq + r")?" + _q, re.IGNORECASE), - re.compile(r"require\\s*\\(\\s*" + _q + r"(" + _nq + r"[/\\\\])?" + escaped + r"([/\\\\]" + _nq + r")?" + _q + r"\\s*\\)", re.IGNORECASE), - ] - - for file_path in source_files: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - for line_num, line in enumerate(content.splitlines(), 1): - for pattern in target_patterns: - if pattern.search(line): - violations.append({{ - "file": str(file_path), - "line": line_num, - "adr_id": "{adr_id}", - "adr_title": "{adr_title}", - "message": "{message}", - "severity": "{severity}", - "level": "{level}", - "fix_suggestion": "Move this import out of " + source_layer + " code — see {adr_id}", - }}) - break - except Exception: - continue - return violations''' - - def _generate_structure_check(self, func_name: str, check: StagedCheck) -> str: - """Generate code for a required structure check.""" - adr_id = _escape(check.adr_id) - severity = _escape(check.severity) - message = _escape(check.message) - adr_title = _escape(check.adr_title or "") - level = _escape(check.level.value) - pattern = _escape(check.pattern) - - return f'''\ -def {func_name}(files): - """From {adr_id}: required structure '{check.pattern}'""" - import glob as glob_mod - matches = list(glob_mod.glob("{pattern}")) - if not matches: - return [{{ - "file": "{pattern}", - "line": None, - "adr_id": "{adr_id}", - "adr_title": "{adr_title}", - "message": "{message}", - "severity": "{severity}", - "level": "{level}", - "fix_suggestion": "Create the required path: {pattern} — see {adr_id}", - }}] - return []''' - - def _generate_runner_script(self, output_dir: Path) -> Path: - """Generate validate_all.py that runs all individual scripts and aggregates.""" - output_dir.mkdir(parents=True, exist_ok=True) - runner_path = output_dir / "validate_all.py" - - runner = '''\ -#!/usr/bin/env python3 -"""Run all ADR validation scripts and aggregate results. - -Auto-generated by ADR Kit. Regenerate: adr-kit generate-scripts -""" - -import argparse -import json -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - - -def main(): - parser = argparse.ArgumentParser(description="Run all ADR validation scripts") - parser.add_argument("--quick", action="store_true", help="Check staged/changed files only") - parser.add_argument("--full", action="store_true", default=True, help="Check all files (default)") - parser.add_argument("--root", default=".", help="Project root directory") - args = parser.parse_args() - - mode_flag = "--quick" if args.quick else "--full" - script_dir = Path(__file__).parent - - scripts = sorted(script_dir.glob("validate_adr_*.py")) - - total_files = 0 - total_checks = 0 - total_errors = 0 - total_warnings = 0 - all_violations = [] - all_errors = [] - passed = True - - for script in scripts: - try: - result = subprocess.run( - [sys.executable, str(script), mode_flag, "--root", args.root], - capture_output=True, text=True, - ) - if result.stdout.strip(): - report = json.loads(result.stdout) - total_files = max(total_files, report["summary"]["files_checked"]) - total_checks += report["summary"]["checks_run"] - total_errors += report["summary"]["error_count"] - total_warnings += report["summary"]["warning_count"] - all_violations.extend(report.get("violations", [])) - all_errors.extend(report.get("errors", [])) - if not report["passed"]: - passed = False - elif result.returncode != 0: - all_errors.append(f"Script {script.name} failed: {result.stderr.strip()}") - passed = False - except json.JSONDecodeError: - all_errors.append(f"Script {script.name} produced invalid JSON") - except Exception as e: - all_errors.append(f"Script {script.name} error: {e}") - - merged = { - "schema_version": "1.0", - "level": "ci", - "timestamp": datetime.now(timezone.utc).isoformat(), - "passed": passed, - "summary": { - "files_checked": total_files, - "checks_run": total_checks, - "error_count": total_errors, - "warning_count": total_warnings, - }, - "violations": all_violations, - "errors": all_errors, - } - - print(json.dumps(merged, indent=2)) - sys.exit(0 if passed else 1) - - -if __name__ == "__main__": - main() -''' - - runner_path.write_text(runner, encoding="utf-8") - runner_path.chmod( - runner_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH - ) - - return runner_path - - # --- Helpers --- - - def _load_accepted_adrs(self) -> list[ADR]: - adrs: list[ADR] = [] - if not self.adr_dir.exists(): - return adrs - for file_path in find_adr_files(self.adr_dir): - try: - adr = parse_adr_file(file_path, strict=False) - if adr and adr.front_matter.status == ADRStatus.ACCEPTED: - adrs.append(adr) - except ParseError: - continue - return adrs - - -def _escape(value: str) -> str: - """Escape a string for safe embedding in generated Python code.""" - return value.replace("\\", "\\\\").replace('"', '\\"') +from adr_kit.enforcement.generation.scripts import * # noqa: F401,F403 diff --git a/adr_kit/enforce/stages.py b/adr_kit/enforce/stages.py index ebdf797..6f37b65 100644 --- a/adr_kit/enforce/stages.py +++ b/adr_kit/enforce/stages.py @@ -1,188 +1 @@ -"""Enforcement stage classification model. - -Maps ADR policy types to workflow stages (commit/push/ci) based on: -- Speed: how fast the check runs -- Scope: what files and context it needs - -Stage semantics: -- commit (<5s): staged files only, fast grep — first checkpoint -- push (<15s): changed files, broader context -- ci (<2min): full codebase, all checks — safety net - -A check assigned to level X also runs at all higher levels -(commit checks run at push and ci too). -""" - -from dataclasses import dataclass, field -from enum import Enum - - -class EnforcementLevel(str, Enum): - """Workflow stage at which enforcement checks run.""" - - COMMIT = "commit" - PUSH = "push" - CI = "ci" - - -# Ordered levels for inclusion logic (lower index = earlier stage) -_LEVEL_ORDER: dict[EnforcementLevel, int] = { - EnforcementLevel.COMMIT: 0, - EnforcementLevel.PUSH: 1, - EnforcementLevel.CI: 2, -} - -# Policy type → minimum enforcement level -# A policy type at level X also runs at all higher levels. -POLICY_LEVEL_MAP: dict[str, EnforcementLevel] = { - "imports": EnforcementLevel.COMMIT, # fast grep — always first - "python": EnforcementLevel.COMMIT, # fast grep — always first - "patterns": EnforcementLevel.COMMIT, # fast regex — always first - "architecture": EnforcementLevel.PUSH, # needs broader file context - "required_structure": EnforcementLevel.CI, # full codebase check - "config_enforcement": EnforcementLevel.CI, # config deep check -} - - -@dataclass -class StagedCheck: - """A single enforceable check classified to an enforcement level.""" - - adr_id: str - adr_title: str - check_type: str # "import" | "python_import" | "pattern" | "architecture" | "required_structure" | "config" - level: EnforcementLevel - pattern: str # what to grep/check for - message: str # human-readable violation message - file_glob: str | None = None # file extension filter - severity: str = "error" - metadata: dict = field(default_factory=dict) # extra context for complex checks - - -def classify_adr_checks(adrs: list) -> list[StagedCheck]: - """Extract and classify all enforceable checks from a list of accepted ADRs. - - Returns one StagedCheck per enforceable rule across all policy types. - Architecture and config checks are classified but not yet executed - (reserved for ENF task — reported here for transparency). - """ - checks: list[StagedCheck] = [] - - for adr in adrs: - if not adr.policy: - continue - - policy = adr.policy - adr_id = adr.id - adr_title = adr.title - - # imports: disallowed JS/TS imports — COMMIT level - if policy.imports and policy.imports.disallow: - for lib in policy.imports.disallow: - checks.append( - StagedCheck( - adr_id=adr_id, - adr_title=adr_title, - check_type="import", - level=EnforcementLevel.COMMIT, - pattern=lib, - message=f"Import of '{lib}' is disallowed — see {adr_id}: {adr_title}", - ) - ) - - # python: disallowed Python imports — COMMIT level - if policy.python and policy.python.disallow_imports: - for lib in policy.python.disallow_imports: - checks.append( - StagedCheck( - adr_id=adr_id, - adr_title=adr_title, - check_type="python_import", - level=EnforcementLevel.COMMIT, - pattern=lib, - message=f"Python import of '{lib}' is disallowed — see {adr_id}: {adr_title}", - file_glob="*.py", - ) - ) - - # patterns: regex code pattern rules — COMMIT level (fast grep) - if policy.patterns and policy.patterns.patterns: - for name, rule in policy.patterns.patterns.items(): - if isinstance(rule.rule, str): # only handle regex patterns - checks.append( - StagedCheck( - adr_id=adr_id, - adr_title=adr_title, - check_type="pattern", - level=EnforcementLevel.COMMIT, - pattern=rule.rule, - message=f"Pattern '{name}': {rule.description} — see {adr_id}", - file_glob=f"*.{rule.language}" if rule.language else None, - severity=rule.severity, - ) - ) - - # architecture: layer boundaries — PUSH level - if policy.architecture and policy.architecture.layer_boundaries: - for boundary in policy.architecture.layer_boundaries: - checks.append( - StagedCheck( - adr_id=adr_id, - adr_title=adr_title, - check_type="architecture", - level=EnforcementLevel.PUSH, - pattern=boundary.rule, - message=boundary.message - or f"Architecture violation: {boundary.rule} — see {adr_id}", - severity="error" if boundary.action == "block" else "warning", - metadata={"rule": boundary.rule, "check": boundary.check}, - ) - ) - - # required_structure: file/dir existence — CI level - if policy.architecture and policy.architecture.required_structure: - for required in policy.architecture.required_structure: - checks.append( - StagedCheck( - adr_id=adr_id, - adr_title=adr_title, - check_type="required_structure", - level=EnforcementLevel.CI, - pattern=required.path, - message=required.description - or f"Required path missing: {required.path} — see {adr_id}", - ) - ) - - # config_enforcement — CI level - if policy.config_enforcement: - checks.append( - StagedCheck( - adr_id=adr_id, - adr_title=adr_title, - check_type="config", - level=EnforcementLevel.CI, - pattern="config_check", - message=f"Configuration requirements from {adr_id}: {adr_title}", - metadata={ - "policy": policy.config_enforcement.model_dump( - exclude_none=True - ) - }, - ) - ) - - return checks - - -def checks_for_level( - checks: list[StagedCheck], level: EnforcementLevel -) -> list[StagedCheck]: - """Return checks that should run at the given level (inclusive of lower levels). - - commit → runs commit checks only - push → runs commit + push checks - ci → runs all checks - """ - target_order = _LEVEL_ORDER[level] - return [c for c in checks if _LEVEL_ORDER[c.level] <= target_order] +from adr_kit.enforcement.validation.stages import * # noqa: F401,F403 diff --git a/adr_kit/enforce/validator.py b/adr_kit/enforce/validator.py index 2bbe289..1f3f473 100644 --- a/adr_kit/enforce/validator.py +++ b/adr_kit/enforce/validator.py @@ -1,414 +1 @@ -"""Staged validation runner. - -Executes ADR policy checks against files based on enforcement level: -- commit: staged files only (git diff --cached) — fast grep, <5s -- push: changed files (git diff @{upstream}..HEAD) — broader, <15s -- ci: all project files — comprehensive safety net, <2min - -Architecture and config checks are classified but not yet executed -(reserved for ENF task). They appear in the check count but produce -no violations today — this is intentional and documented. -""" - -import fnmatch -import re -import subprocess -from dataclasses import dataclass, field -from pathlib import Path - -from ..core.model import ADR, ADRStatus -from ..core.parse import ParseError, find_adr_files, parse_adr_file -from .stages import EnforcementLevel, StagedCheck, checks_for_level, classify_adr_checks - -# Source file extensions scanned during CI full-codebase pass -_SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".kt"} - -# Directories never scanned — generated/installed content -_EXCLUDE_DIRS = { - ".git", - ".venv", - "venv", - "node_modules", - "__pycache__", - ".pytest_cache", - ".mypy_cache", - ".ruff_cache", - "dist", - "build", - ".adr-kit", - ".project-index", -} - - -@dataclass -class Violation: - """A single policy violation found during validation.""" - - file: str - adr_id: str - message: str - level: EnforcementLevel - severity: str = "error" - line: int | None = None - adr_title: str | None = None - fix_suggestion: str | None = None - - -@dataclass -class ValidationResult: - """Result of a staged validation run.""" - - level: EnforcementLevel - files_checked: int - checks_run: int - violations: list[Violation] = field(default_factory=list) - errors: list[str] = field(default_factory=list) - - @property - def passed(self) -> bool: - """True when no error-severity violations exist.""" - return not any(v.severity == "error" for v in self.violations) - - @property - def has_warnings(self) -> bool: - return any(v.severity == "warning" for v in self.violations) - - @property - def error_count(self) -> int: - return sum(1 for v in self.violations if v.severity == "error") - - @property - def warning_count(self) -> int: - return sum(1 for v in self.violations if v.severity == "warning") - - -class StagedValidator: - """Runs ADR policy checks classified by enforcement level.""" - - def __init__(self, adr_dir: str | Path = "docs/adr"): - self.adr_dir = Path(adr_dir) - - def validate( - self, - level: EnforcementLevel, - project_root: Path | None = None, - ) -> ValidationResult: - """Run all checks active at the given level. - - Args: - level: Enforcement level to run (commit/push/ci). - project_root: Root directory for file resolution. Defaults to cwd. - - Returns: - ValidationResult with all violations and metadata. - """ - project_root = project_root or Path.cwd() - - adrs = self._load_accepted_adrs() - all_checks = classify_adr_checks(adrs) - active_checks = checks_for_level(all_checks, level) - files = self._get_files(level, project_root) - - result = ValidationResult( - level=level, - files_checked=len(files), - checks_run=len(active_checks), - ) - - for check in active_checks: - violations = self._run_check(check, files, project_root) - result.violations.extend(violations) - - return result - - # --- ADR loading --- - - def _load_accepted_adrs(self) -> list[ADR]: - adrs: list[ADR] = [] - if not self.adr_dir.exists(): - return adrs - for file_path in find_adr_files(self.adr_dir): - try: - adr = parse_adr_file(file_path, strict=False) - if adr and adr.front_matter.status == ADRStatus.ACCEPTED: - adrs.append(adr) - except ParseError: - continue - return adrs - - # --- File collection --- - - def _get_files(self, level: EnforcementLevel, project_root: Path) -> list[Path]: - if level == EnforcementLevel.COMMIT: - return self._get_staged_files(project_root) - elif level == EnforcementLevel.PUSH: - files = self._get_changed_files(project_root) - # Fall back to staged if no upstream info available - return files or self._get_staged_files(project_root) - else: # CI - return self._get_all_files(project_root) - - def _get_staged_files(self, project_root: Path) -> list[Path]: - try: - result = subprocess.run( - ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], - capture_output=True, - text=True, - cwd=project_root, - ) - if result.returncode != 0: - return [] - files = [project_root / f for f in result.stdout.strip().splitlines() if f] - return [f for f in files if f.exists()] - except Exception: - return [] - - def _get_changed_files(self, project_root: Path) -> list[Path]: - """Files changed since last push. Falls back gracefully if no upstream.""" - for cmd in [ - ["git", "diff", "--name-only", "@{upstream}..HEAD"], - ["git", "diff", "--name-only", "HEAD~1..HEAD"], - ]: - try: - result = subprocess.run( - cmd, capture_output=True, text=True, cwd=project_root - ) - if result.returncode == 0 and result.stdout.strip(): - files = [ - project_root / f - for f in result.stdout.strip().splitlines() - if f - ] - return [f for f in files if f.exists()] - except Exception: - continue - return [] - - def _get_all_files(self, project_root: Path) -> list[Path]: - files = [] - for f in project_root.rglob("*"): - if not f.is_file(): - continue - if f.suffix not in _SOURCE_EXTENSIONS: - continue - # Skip excluded directories - if any(part in _EXCLUDE_DIRS for part in f.parts): - continue - files.append(f) - return files - - # --- Check dispatch --- - - def _run_check( - self, check: StagedCheck, files: list[Path], project_root: Path - ) -> list[Violation]: - if check.check_type in ("import", "python_import"): - return self._run_import_check(check, files, project_root) - elif check.check_type == "pattern": - return self._run_pattern_check(check, files, project_root) - elif check.check_type == "required_structure": - return self._run_structure_check(check, project_root) - elif check.check_type == "architecture": - return self._run_architecture_check(check, files, project_root) - # config: classified but not yet executed - return [] - - def _filter_files_for_check( - self, files: list[Path], check: StagedCheck - ) -> list[Path]: - """Filter file list to those relevant for the check type.""" - if check.check_type == "python_import": - return [f for f in files if f.suffix == ".py"] - if check.check_type == "import": - return [f for f in files if f.suffix in {".js", ".ts", ".jsx", ".tsx"}] - if check.file_glob and check.file_glob.startswith("*."): - ext = check.file_glob[1:] # "*.py" → ".py" - return [f for f in files if f.name.endswith(ext)] - return files - - def _run_import_check( - self, check: StagedCheck, files: list[Path], project_root: Path - ) -> list[Violation]: - target_files = self._filter_files_for_check(files, check) - violations = [] - escaped = re.escape(check.pattern) - - # Matches: import 'lib', from 'lib', require('lib') — with or without path prefix - import_patterns = [ - re.compile(rf"""(import|from)\s+['"]([^'"]*?/)?{escaped}['"]"""), - re.compile(rf"""require\s*\(\s*['"]([^'"]*?/)?{escaped}['"]\s*\)"""), - re.compile(rf"""(import|from)\s+{escaped}(\s|$|;)"""), # Python style - ] - - for file_path in target_files: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - for line_num, line in enumerate(content.splitlines(), 1): - for pattern in import_patterns: - if pattern.search(line): - violations.append( - Violation( - file=str(file_path.relative_to(project_root)), - adr_id=check.adr_id, - message=check.message, - level=check.level, - severity=check.severity, - line=line_num, - adr_title=check.adr_title, - fix_suggestion=f"Remove or replace this import — see {check.adr_id}", - ) - ) - break # one violation per line - except Exception: - continue - - return violations - - def _run_pattern_check( - self, check: StagedCheck, files: list[Path], project_root: Path - ) -> list[Violation]: - target_files = self._filter_files_for_check(files, check) - violations = [] - - try: - compiled = re.compile(check.pattern) - except re.error: - return [] # invalid regex in ADR policy — skip silently - - for file_path in target_files: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - for line_num, line in enumerate(content.splitlines(), 1): - if compiled.search(line): - violations.append( - Violation( - file=str(file_path.relative_to(project_root)), - adr_id=check.adr_id, - message=check.message, - level=check.level, - severity=check.severity, - line=line_num, - adr_title=check.adr_title, - ) - ) - except Exception: - continue - - return violations - - def _run_structure_check( - self, check: StagedCheck, project_root: Path - ) -> list[Violation]: - """Check that a required path (glob pattern) exists in the project.""" - import glob - - matches = list(glob.glob(check.pattern, root_dir=str(project_root))) - if not matches: - return [ - Violation( - file=check.pattern, - adr_id=check.adr_id, - message=check.message, - level=check.level, - severity=check.severity, - adr_title=check.adr_title, - fix_suggestion=f"Create the required path: {check.pattern} — see {check.adr_id}", - ) - ] - return [] - - def _run_architecture_check( - self, check: StagedCheck, files: list[Path], project_root: Path - ) -> list[Violation]: - """Check that source-layer files don't import from the target layer. - - Parses the rule string "source -> target" from check.pattern. - Uses metadata["check"] glob to identify source-layer files. - Scans those files for imports referencing the target layer. - """ - # Parse "source -> target" from the rule string - parts = check.pattern.split("->") - if len(parts) != 2: - return [] # malformed rule — degrade gracefully - source_layer = parts[0].strip().lower() - target_layer = parts[1].strip().lower() - if not source_layer or not target_layer: - return [] - - # Filter files to source layer - check_glob = check.metadata.get("check") - source_files = self._filter_architecture_files( - files, project_root, check_glob, source_layer - ) - - # Build regex patterns for imports containing target layer - escaped = re.escape(target_layer) - target_patterns = [ - # Python: from target_layer or from target_layer.sub - re.compile(rf"(from|import)\s+{escaped}(\.\w+)*(\s|$|;)", re.IGNORECASE), - # JS/TS: from '...target_layer...' or require('...target_layer...') - re.compile( - rf"""(import|from)\s+['"]([^'"]*[/\\])?{escaped}([/\\][^'"]*)?['"]""", - re.IGNORECASE, - ), - re.compile( - rf"""require\s*\(\s*['"]([^'"]*[/\\])?{escaped}([/\\][^'"]*)?['"]\s*\)""", - re.IGNORECASE, - ), - ] - - violations = [] - for file_path in source_files: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - rel_path = str(file_path.relative_to(project_root)) - for line_num, line in enumerate(content.splitlines(), 1): - for pattern in target_patterns: - if pattern.search(line): - violations.append( - Violation( - file=rel_path, - adr_id=check.adr_id, - message=check.message, - level=check.level, - severity=check.severity, - line=line_num, - adr_title=check.adr_title, - fix_suggestion=( - f"Move this import out of {source_layer} code, " - f"or refactor to avoid direct {target_layer} " - f"dependency — see {check.adr_id}" - ), - ) - ) - break # one violation per line - except Exception: - continue - - return violations - - def _filter_architecture_files( - self, - files: list[Path], - project_root: Path, - check_glob: str | None, - source_layer: str, - ) -> list[Path]: - """Filter files to those belonging to the source layer. - - Uses check_glob if provided, otherwise falls back to matching - source_layer as a directory segment in the file path. - """ - if check_glob: - return [ - f - for f in files - if fnmatch.fnmatch(str(f.relative_to(project_root)), check_glob) - ] - - # Fallback: match source_layer as a directory segment - return [ - f - for f in files - if source_layer in [p.lower() for p in f.relative_to(project_root).parts] - ] +from adr_kit.enforcement.validation.staged import * # noqa: F401,F403 diff --git a/adr_kit/enforcement/__init__.py b/adr_kit/enforcement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/enforcement/adapters/__init__.py b/adr_kit/enforcement/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/enforcement/adapters/eslint.py b/adr_kit/enforcement/adapters/eslint.py new file mode 100644 index 0000000..3673add --- /dev/null +++ b/adr_kit/enforcement/adapters/eslint.py @@ -0,0 +1,383 @@ +"""ESLint configuration generation from ADRs. + +Design decisions: +- Parse ADRs to extract library/framework decisions +- Generate ESLint rules to ban disallowed imports +- Support common patterns like "Use React Query instead of X" +- Generate rules for deprecated patterns based on superseded ADRs +""" + +import json +import re +from pathlib import Path +from typing import Any, TypedDict + +from ...core.model import ADR, ADRStatus +from ...core.parse import ParseError, find_adr_files, parse_adr_file +from ...core.policy_extractor import PolicyExtractor + + +class ADRMetadata(TypedDict): + """Type definition for ADR metadata in ESLint config.""" + + id: str + title: str + file_path: str | None + + +class ESLintADRMetadata(TypedDict): + """Type definition for __adr_metadata section.""" + + generated_by: str + source_adrs: list[ADRMetadata] + generation_timestamp: str | None + preferred_libraries: dict[str, str] | None + + +class ESLintConfig(TypedDict): + """Type definition for complete ESLint configuration.""" + + rules: dict[str, Any] + settings: dict[str, Any] + env: dict[str, Any] + extends: list[str] + __adr_metadata: ESLintADRMetadata + + +class StructuredESLintGenerator: + """Generate ESLint configuration from structured ADR policies.""" + + def __init__(self) -> None: + self.policy_extractor = PolicyExtractor() + + def generate_eslint_config(self, adr_dir: str = "docs/adr") -> ESLintConfig: + """Generate complete ESLint configuration from all accepted ADRs. + + Args: + adr_dir: Directory containing ADR files + + Returns: + ESLint configuration dictionary + """ + config: ESLintConfig = { + "rules": {}, + "settings": {}, + "env": {}, + "extends": [], + "__adr_metadata": { + "generated_by": "ADR Kit", + "source_adrs": [], + "generation_timestamp": None, + "preferred_libraries": None, + }, + } + + # Find all ADR files + adr_files = find_adr_files(adr_dir) + 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) + except ParseError: + continue + + # Extract policies and generate rules + banned_imports = [] + preferred_mappings = {} + + for adr in accepted_adrs: + policy = self.policy_extractor.extract_policy(adr) + config["__adr_metadata"]["source_adrs"].append( + { + "id": adr.front_matter.id, + "title": adr.front_matter.title, + "file_path": str(adr.file_path) if adr.file_path else None, + } + ) + + # Process import policies + if policy.imports: + if policy.imports.disallow: + for lib in policy.imports.disallow: + banned_imports.append( + { + "name": lib, + "message": f"Use alternative instead of {lib} (per {adr.front_matter.id}: {adr.front_matter.title})", + "adr_id": adr.front_matter.id, + } + ) + + if policy.imports.prefer: + for lib in policy.imports.prefer: + preferred_mappings[lib] = adr.front_matter.id + + # Generate no-restricted-imports rule + if banned_imports: + config["rules"]["no-restricted-imports"] = [ + "error", + {"paths": banned_imports}, + ] + + # Generate additional rules based on preferences + if preferred_mappings: + config["__adr_metadata"]["preferred_libraries"] = preferred_mappings + + # Add timestamp + from datetime import datetime + + config["__adr_metadata"]["generation_timestamp"] = datetime.now().isoformat() + + return config + + +class ESLintRuleExtractor: + """Extract ESLint rules from ADR content (legacy pattern-based approach).""" + + def __init__(self) -> None: + # Common patterns for identifying banned imports/libraries + self.ban_patterns = [ + # "Don't use X", "Avoid X", "Ban X" + r"(?i)(?:don't\s+use|avoid|ban|deprecated?)\s+([a-zA-Z0-9\-_@/]+)", + # "Use Y instead of X" + r"(?i)use\s+([a-zA-Z0-9\-_@/]+)\s+instead\s+of\s+([a-zA-Z0-9\-_@/]+)", + # "Replace X with Y" + r"(?i)replace\s+([a-zA-Z0-9\-_@/]+)\s+with\s+([a-zA-Z0-9\-_@/]+)", + # "No longer use X" + r"(?i)no\s+longer\s+use\s+([a-zA-Z0-9\-_@/]+)", + ] + + # Common library name mappings + self.library_mappings = { + "react-query": "@tanstack/react-query", + "react query": "@tanstack/react-query", + "axios": "axios", + "fetch": "fetch", + "lodash": "lodash", + "moment": "moment", + "date-fns": "date-fns", + "dayjs": "dayjs", + "jquery": "jquery", + "underscore": "underscore", + } + + def extract_from_adr(self, adr: ADR) -> dict[str, Any]: + """Extract ESLint rules from a single ADR. + + Args: + adr: The ADR object to extract rules from + + Returns: + Dictionary with extracted rule information + """ + banned_imports: list[str] = [] + preferred_imports: dict[str, str] = {} + custom_rules: list[dict[str, Any]] = [] + + rules: dict[str, Any] = { + "banned_imports": banned_imports, + "preferred_imports": preferred_imports, + "custom_rules": custom_rules, + } + + # Only extract rules from accepted ADRs + if adr.front_matter.status != ADRStatus.ACCEPTED: + return rules + + content = f"{adr.front_matter.title} {adr.content}".lower() + + # Extract banned imports using patterns + for pattern in self.ban_patterns: + matches = re.findall(pattern, content) + for match in matches: + if isinstance(match, tuple): + # Pattern with replacement (e.g., "use Y instead of X") + if len(match) == 2: + preferred, banned = match + banned_lib = self._normalize_library_name(banned.strip()) + preferred_lib = self._normalize_library_name(preferred.strip()) + + if banned_lib: + rules["banned_imports"].append(banned_lib) + if preferred_lib: + rules["preferred_imports"][banned_lib] = preferred_lib + else: + # Simple ban pattern + banned_lib = self._normalize_library_name(match.strip()) + if banned_lib: + rules["banned_imports"].append(banned_lib) + + # Check for frontend-specific rules + if "frontend" in (adr.front_matter.tags or []): + rules.update(self._extract_frontend_rules(content)) + + # Check for backend-specific rules + if any( + tag in (adr.front_matter.tags or []) for tag in ["backend", "api", "server"] + ): + rules.update(self._extract_backend_rules(content)) + + return rules + + def _normalize_library_name(self, name: str) -> str | None: + """Normalize library name to common import format.""" + name = name.lower().strip() + + # Check direct mappings + if name in self.library_mappings: + return self.library_mappings[name] + + # Skip common words that aren't libraries + skip_words = { + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + } + if name in skip_words or len(name) < 2: + return None + + # Basic validation - should look like a library name + if re.match(r"^[a-zA-Z0-9\-_@/]+$", name): + return name + + return None + + def _extract_frontend_rules(self, content: str) -> dict[str, Any]: + """Extract frontend-specific ESLint rules.""" + rules: dict[str, list[dict[str, str]]] = {"custom_rules": []} + + # React-specific patterns + if "react" in content: + if "hooks" in content and ("don't" in content or "avoid" in content): + rules["custom_rules"].append( + {"rule": "react-hooks/rules-of-hooks", "severity": "error"} + ) + + return rules + + def _extract_backend_rules(self, content: str) -> dict[str, Any]: + """Extract backend-specific ESLint rules.""" + rules: dict[str, list[dict[str, str]]] = {"custom_rules": []} + + # Node.js specific patterns + if "node" in content or "nodejs" in content: + if "synchronous" in content and ("don't" in content or "avoid" in content): + rules["custom_rules"].append({"rule": "no-sync", "severity": "error"}) + + return rules + + +def generate_eslint_config(adr_directory: Path | str = "docs/adr") -> str: + """Generate ESLint configuration from ADRs using hybrid approach. + + Uses structured policies first, falls back to pattern matching. + + Args: + adr_directory: Directory containing ADR files + + Returns: + JSON string with ESLint configuration + """ + # Use structured policy generator (primary) + structured_generator = StructuredESLintGenerator() + config = structured_generator.generate_eslint_config(str(adr_directory)) + + # Enhance with pattern-based extraction (backup for legacy ADRs) + extractor = ESLintRuleExtractor() + + # Enhance with legacy pattern-based extraction for ADRs without structured policies + additional_banned = set() + adr_files = find_adr_files(adr_directory) + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path, strict=False) + if not adr or adr.front_matter.status != ADRStatus.ACCEPTED: + continue + + # Skip if already has structured policy + if adr.front_matter.policy and adr.front_matter.policy.imports: + continue + + # Use pattern extraction for legacy ADRs + rules = extractor.extract_from_adr(adr) + additional_banned.update(rules["banned_imports"]) + + except ParseError: + continue + + # Merge additional pattern-based rules into structured config + if additional_banned and "no-restricted-imports" in config["rules"]: + existing_paths = config["rules"]["no-restricted-imports"][1]["paths"] + existing_names = {item["name"] for item in existing_paths} + + for lib in additional_banned: + if lib not in existing_names: + existing_paths.append( + { + "name": lib, + "message": f"Import of '{lib}' is not allowed (extracted from ADR content)", + } + ) + elif additional_banned: + # No structured rules, use pattern-based only + banned_patterns = [] + for lib in additional_banned: + banned_patterns.append( + { + "name": lib, + "message": f"Import of '{lib}' is not allowed according to ADR decisions", + } + ) + config["rules"]["no-restricted-imports"] = ["error", {"paths": banned_patterns}] + + # Return the enhanced configuration as JSON + return json.dumps(config, indent=2) + + +def generate_eslint_overrides( + adr_directory: Path | str = "docs/adr", +) -> dict[str, Any]: + """Generate ESLint override configuration for specific file patterns. + + Args: + adr_directory: Directory containing ADR files + + Returns: + Dictionary with override configuration + """ + # This could be extended to create file-pattern-specific rules + # based on ADR tags or content analysis + + overrides = [] + + # Example: Stricter rules for production files + overrides.append( + { + "files": ["src/components/**/*.tsx", "src/pages/**/*.tsx"], + "rules": {"no-console": "error", "no-debugger": "error"}, + } + ) + + # Example: Relaxed rules for test files + overrides.append( + { + "files": ["**/*.test.{js,ts,jsx,tsx}", "**/*.spec.{js,ts,jsx,tsx}"], + "rules": {"no-console": "warn"}, + } + ) + + return {"overrides": overrides} diff --git a/adr_kit/enforcement/adapters/ruff.py b/adr_kit/enforcement/adapters/ruff.py new file mode 100644 index 0000000..83dfdfe --- /dev/null +++ b/adr_kit/enforcement/adapters/ruff.py @@ -0,0 +1,358 @@ +"""Ruff and import-linter configuration generation from ADRs. + +Design decisions: +- Generate Ruff rules for Python code quality based on ADR decisions +- Create import-linter rules to enforce architectural boundaries +- Support common Python library migration patterns +- Generate rules for deprecated packages based on superseded ADRs +""" + +import configparser +import re +from io import StringIO +from pathlib import Path +from typing import Any + +import toml + +from ...core.model import ADR, ADRStatus +from ...core.parse import ParseError, find_adr_files, parse_adr_file + + +class PythonRuleExtractor: + """Extract Python linting rules from ADR content.""" + + def __init__(self) -> None: + # Common patterns for Python library decisions + self.python_ban_patterns = [ + r"(?i)(?:don't\s+use|avoid|ban|deprecated?)\s+([a-zA-Z0-9\-_]+)", + r"(?i)use\s+([a-zA-Z0-9\-_]+)\s+instead\s+of\s+([a-zA-Z0-9\-_]+)", + r"(?i)replace\s+([a-zA-Z0-9\-_]+)\s+with\s+([a-zA-Z0-9\-_]+)", + r"(?i)no\s+longer\s+use\s+([a-zA-Z0-9\-_]+)", + ] + + # Python library mappings + self.python_libraries = { + "requests": "requests", + "urllib": "urllib", + "httpx": "httpx", + "aiohttp": "aiohttp", + "flask": "flask", + "django": "django", + "fastapi": "fastapi", + "sqlalchemy": "sqlalchemy", + "peewee": "peewee", + "pydantic": "pydantic", + "marshmallow": "marshmallow", + "pandas": "pandas", + "numpy": "numpy", + "pytest": "pytest", + "unittest": "unittest", + "click": "click", + "argparse": "argparse", + "typer": "typer", + } + + def extract_from_adr(self, adr: Any) -> dict[str, Any]: + """Extract Python rules from a single ADR.""" + rules: dict[str, Any] = { + "banned_imports": [], + "preferred_imports": {}, + "architectural_rules": [], + "ruff_rules": {}, + } + + # Only extract from accepted ADRs + if adr.front_matter.status != ADRStatus.ACCEPTED: + return rules + + content = f"{adr.front_matter.title} {adr.content}".lower() + tags = adr.front_matter.tags or [] + + # Extract banned Python imports + for pattern in self.python_ban_patterns: + matches = re.findall(pattern, content) + for match in matches: + if isinstance(match, tuple): + if len(match) == 2: + preferred, banned = match + banned_lib = self._normalize_python_library(banned.strip()) + preferred_lib = self._normalize_python_library( + preferred.strip() + ) + + if banned_lib: + rules["banned_imports"].append(banned_lib) + if preferred_lib: + rules["preferred_imports"][banned_lib] = preferred_lib + else: + banned_lib = self._normalize_python_library(match.strip()) + if banned_lib: + rules["banned_imports"].append(banned_lib) + + # Extract architectural rules + if "architecture" in tags or "layering" in tags: + rules["architectural_rules"].extend( + self._extract_architectural_rules(content, adr) + ) + + # Extract code quality rules + if "code-quality" in tags or "standards" in tags: + rules["ruff_rules"].update(self._extract_ruff_rules(content)) + + return rules + + def _normalize_python_library(self, name: str) -> str | None: + """Normalize Python library name.""" + name = name.lower().strip() + + if name in self.python_libraries: + return self.python_libraries[name] + + # Skip common words + skip_words = { + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + } + if name in skip_words or len(name) < 2: + return None + + # Basic validation for Python module names + if re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name): + return name + + return None + + def _extract_architectural_rules( + self, content: str, adr: ADR + ) -> list[dict[str, Any]]: + """Extract architectural/layering rules.""" + rules = [] + + # Look for layer separation rules + if "layer" in content or "boundary" in content: + # Example: "Domain layer should not depend on infrastructure" + domain_infra_pattern = r"domain.*should not.*depend.*infrastructure" + if re.search(domain_infra_pattern, content, re.IGNORECASE): + rules.append( + { + "name": f"domain-infra-separation-{adr.front_matter.id.lower()}", + "source_modules": ["domain", "core"], + "forbidden_modules": ["infrastructure", "adapters"], + "description": "Domain layer should not depend on infrastructure", + } + ) + + # Look for service separation rules + if "service" in content and ("separate" in content or "isolated" in content): + # This would need more sophisticated parsing to extract specific services + pass + + return rules + + def _extract_ruff_rules(self, content: str) -> dict[str, str]: + """Extract Ruff-specific rules.""" + rules = {} + + # Type checking rules + if "type" in content and ("enforce" in content or "strict" in content): + rules.update( + { + "ANN": "error", # flake8-annotations + "UP": "error", # pyupgrade + } + ) + + # Code complexity rules + if "complexity" in content or "cyclomatic" in content: + rules["C901"] = "error" # mccabe complexity + + # Security rules + if "security" in content: + rules["S"] = "error" # flake8-bandit + + # Performance rules + if "performance" in content: + rules["PERF"] = "error" # perflint + + return rules + + +def generate_ruff_config(adr_directory: Path | str = "docs/adr") -> str: + """Generate Ruff configuration from ADRs. + + Args: + adr_directory: Directory containing ADR files + + Returns: + TOML string with Ruff configuration + """ + extractor = PythonRuleExtractor() + + # Find and parse all ADRs + adr_files = find_adr_files(adr_directory) + all_banned_imports = set() + preferred_imports = {} + all_ruff_rules = {} + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path, strict=False) + if not adr: + continue + + rules = extractor.extract_from_adr(adr) + + all_banned_imports.update(rules["banned_imports"]) + preferred_imports.update(rules["preferred_imports"]) + all_ruff_rules.update(rules["ruff_rules"]) + + except ParseError: + continue + + # Build Ruff configuration + ruff_config = { + "# ADR Kit Generated Configuration": "Do not edit manually", + "target-version": "py312", + "line-length": 88, + "select": list(all_ruff_rules.keys()) if all_ruff_rules else ["E", "W", "F"], + "extend-ignore": [], + } + + # Add banned imports if any + if all_banned_imports: + banned_list = list(all_banned_imports) + ruff_config["flake8-import-conventions"] = {"banned-imports": banned_list} + + # Add custom error messages + ruff_config["# Banned imports from ADRs"] = { + lib: f"Import of '{lib}' is not allowed according to ADR decisions" + for lib in banned_list + } + + return toml.dumps(ruff_config) + + +def generate_import_linter_config(adr_directory: Path | str = "docs/adr") -> str: + """Generate import-linter configuration from ADRs. + + Args: + adr_directory: Directory containing ADR files + + Returns: + INI string with import-linter configuration + """ + extractor = PythonRuleExtractor() + + # Find and parse all ADRs + adr_files = find_adr_files(adr_directory) + all_architectural_rules = [] + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path, strict=False) + if not adr: + continue + + rules = extractor.extract_from_adr(adr) + all_architectural_rules.extend(rules["architectural_rules"]) + + except ParseError: + continue + + # Build import-linter configuration + config = configparser.ConfigParser() + + # Main configuration + config["importlinter"] = { + "root_package": "src", + "include_external_packages": "False", + } + + # Add architectural rules as contracts + for _i, rule in enumerate(all_architectural_rules): + contract_name = f"contract:{rule['name']}" + config[contract_name] = { + "name": rule["description"], + "type": "forbidden", + "source_modules": "\n ".join(rule["source_modules"]), + "forbidden_modules": "\n ".join(rule["forbidden_modules"]), + } + + # Add general layer separation if no specific rules found + if not all_architectural_rules: + config["contract:domain-infrastructure"] = { + "name": "Domain layer should not depend on infrastructure", + "type": "forbidden", + "source_modules": "\n domain\n core", + "forbidden_modules": "\n infrastructure\n adapters", + } + + # Convert to string + output = StringIO() + config.write(output) + content = output.getvalue() + + # Add header comment + header = """# Import Linter Configuration Generated from ADRs +# Do not edit manually - regenerate using: adr-kit export-lint import-linter + +""" + + return header + content + + +def generate_pyproject_ruff_section( + adr_directory: Path | str = "docs/adr", +) -> dict[str, Any]: + """Generate Ruff section for pyproject.toml from ADRs. + + Args: + adr_directory: Directory containing ADR files + + Returns: + Dictionary with Ruff configuration for pyproject.toml + """ + extractor = PythonRuleExtractor() + + # Find and parse all ADRs + adr_files = find_adr_files(adr_directory) + all_ruff_rules = {} + + for file_path in adr_files: + try: + adr = parse_adr_file(file_path, strict=False) + if not adr: + continue + + rules = extractor.extract_from_adr(adr) + all_ruff_rules.update(rules["ruff_rules"]) + + except ParseError: + continue + + # Return pyproject.toml compatible structure + return { + "tool": { + "ruff": { + "target-version": "py312", + "line-length": 88, + "select": ( + list(all_ruff_rules.keys()) if all_ruff_rules else ["E", "W", "F"] + ), + "extend-ignore": [], + } + } + } diff --git a/adr_kit/enforcement/config/__init__.py b/adr_kit/enforcement/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/enforcement/config/manager.py b/adr_kit/enforcement/config/manager.py new file mode 100644 index 0000000..fcc033e --- /dev/null +++ b/adr_kit/enforcement/config/manager.py @@ -0,0 +1,394 @@ +"""Main Guardrail Manager - orchestrates automatic configuration application.""" + +import json +from pathlib import Path +from typing import Any + +from ...contract import ConstraintsContractBuilder +from .models import ( + ApplicationStatus, + ApplyResult, + ConfigFragment, + ConfigTemplate, + FragmentTarget, + FragmentType, + GuardrailConfig, +) +from .monitor import ChangeEvent, ChangeType, FileMonitor +from .writer import ConfigWriter + + +class GuardrailManager: + """Main service for automatic guardrail management. + + Orchestrates the application of configuration fragments when + ADR policies change, implementing the "Guardrail Manager" component + from the architectural vision. + """ + + def __init__(self, adr_dir: Path, config: GuardrailConfig | None = None): + self.adr_dir = Path(adr_dir) + self.config = config or self._create_default_config() + + self.contract_builder = ConstraintsContractBuilder(adr_dir) + self.config_writer = ConfigWriter( + backup_enabled=self.config.backup_enabled, backup_dir=self.config.backup_dir + ) + self.file_monitor = FileMonitor(adr_dir) + + self._last_contract_hash = "" + + def _create_default_config(self) -> GuardrailConfig: + """Create default guardrail configuration.""" + + # Default targets for common configuration files + targets = [ + FragmentTarget( + file_path=Path(".eslintrc.adrs.json"), fragment_type=FragmentType.ESLINT + ), + FragmentTarget( + file_path=Path("pyproject.toml"), + fragment_type=FragmentType.RUFF, + section_name="tool.ruff", + ), + FragmentTarget( + file_path=Path(".import-linter.adrs.ini"), + fragment_type=FragmentType.IMPORT_LINTER, + ), + ] + + # Default templates + templates = [ + ConfigTemplate( + fragment_type=FragmentType.ESLINT, + template_content="""{ + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + {disallow_rules} + ] + } + ] + } +}""", + variables={"disallow_rules": "[]"}, + ), + ConfigTemplate( + fragment_type=FragmentType.RUFF, + template_content="""[tool.ruff.flake8-banned-api] +banned-api = [ +{disallow_imports} +]""", + variables={"disallow_imports": ""}, + ), + ] + + return GuardrailConfig(targets=targets, templates=templates) + + def apply_guardrails(self, force: bool = False) -> list[ApplyResult]: + """Apply guardrails based on current ADR policies.""" + + results: list[ApplyResult] = [] + + if not self.config.enabled: + return results + + # Build current constraints contract + try: + contract = self.contract_builder.build_contract(force_rebuild=force) + except Exception as e: + # If contract building fails, skip guardrail application + return [ + ApplyResult( + target=target, + status=ApplicationStatus.FAILED, + message=f"Failed to build constraints contract: {e}", + ) + for target in self.config.targets + ] + + # Check if contract has changed (optimization) + if not force and contract.metadata.hash == self._last_contract_hash: + return results # No changes needed + + # Generate fragments for each target type + fragment_map = self._generate_fragments(contract) + + # Apply fragments to each target + for target in self.config.targets: + if target.fragment_type in fragment_map: + fragments = fragment_map[target.fragment_type] + if fragments or self.config_writer.has_managed_section(target): + # Apply fragments (or remove section if no fragments) + result = self.config_writer.apply_fragments(target, fragments) + results.append(result) + + # Update contract hash + self._last_contract_hash = contract.metadata.hash + + return results + + def watch_and_apply(self) -> list[ApplyResult]: + """Watch for ADR changes and apply guardrails automatically.""" + + results: list[ApplyResult] = [] + + if not self.config.auto_apply: + return results + + # Detect changes + changes = self.file_monitor.detect_changes() + policy_changes = self.file_monitor.get_policy_relevant_changes(changes) + + if policy_changes: + # Apply guardrails due to policy changes + results = self.apply_guardrails(force=False) + + # Log changes for audit + self._log_policy_changes(policy_changes, results) + + return results + + def _generate_fragments( + self, contract: Any + ) -> dict[FragmentType, list[ConfigFragment]]: + """Generate configuration fragments from constraints contract.""" + + fragments: dict[FragmentType, list[ConfigFragment]] = { + FragmentType.ESLINT: [], + FragmentType.RUFF: [], + FragmentType.IMPORT_LINTER: [], + } + + if contract.constraints.is_empty(): + return fragments + + # Generate ESLint fragments + if contract.constraints.imports: + eslint_fragment = self._generate_eslint_fragment(contract) + if eslint_fragment: + fragments[FragmentType.ESLINT].append(eslint_fragment) + + # Generate Ruff fragments + if contract.constraints.python and contract.constraints.python.disallow_imports: + ruff_fragment = self._generate_ruff_fragment(contract) + if ruff_fragment: + fragments[FragmentType.RUFF].append(ruff_fragment) + + # Generate import-linter fragments + if contract.constraints.boundaries: + import_linter_fragment = self._generate_import_linter_fragment(contract) + if import_linter_fragment: + fragments[FragmentType.IMPORT_LINTER].append(import_linter_fragment) + + return fragments + + def _generate_eslint_fragment(self, contract: Any) -> ConfigFragment | None: + """Generate ESLint configuration fragment.""" + + if ( + not contract.constraints.imports + or not contract.constraints.imports.disallow + ): + return None + + # Build disallow rules + disallow_rules = [] + for item in contract.constraints.imports.disallow: + # Find source ADRs for this constraint by checking rule paths + source_adrs = [] + for adr_id, provenance in contract.provenance.items(): + # Check if this provenance rule matches our import constraint + if f"imports.disallow.{item}" in provenance.rule_path: + source_adrs.append(adr_id) + + adr_refs = f" ({', '.join(source_adrs)})" if source_adrs else "" + rule = { + "name": item, + "message": f"Use approved alternative instead{adr_refs}", + } + disallow_rules.append(json.dumps(rule)) + + # Use template to generate content + template = self.config.get_template_for_type(FragmentType.ESLINT) + if template: + content = template.render( + disallow_rules=",\n ".join(disallow_rules) + ) + else: + # Fallback content generation + content = f"""{{ + "rules": {{ + "no-restricted-imports": [ + "error", + {{ + "paths": [ + {",".join(disallow_rules)} + ] + }} + ] + }} +}}""" + + return ConfigFragment( + fragment_type=FragmentType.ESLINT, + content=content, + source_adr_ids=list(contract.provenance.keys()), + ) + + def _generate_ruff_fragment(self, contract: Any) -> ConfigFragment | None: + """Generate Ruff configuration fragment.""" + + if ( + not contract.constraints.python + or not contract.constraints.python.disallow_imports + ): + return None + + # Build banned import rules + banned_imports = [] + for item in contract.constraints.python.disallow_imports: + # Find source ADRs by checking rule paths + source_adrs = [] + for adr_id, provenance in contract.provenance.items(): + if f"python.disallow_imports.{item}" in provenance.rule_path: + source_adrs.append(adr_id) + + adr_refs = f" ({', '.join(source_adrs)})" if source_adrs else "" + rule = f' "{item} = Use approved alternative instead{adr_refs}"' + banned_imports.append(rule) + + # Use template to generate content + template = self.config.get_template_for_type(FragmentType.RUFF) + if template: + content = template.render(disallow_imports=",\n".join(banned_imports)) + else: + # Fallback content generation + content = f"""[tool.ruff.flake8-banned-api] +banned-api = [ +{",".join(banned_imports)} +]""" + + return ConfigFragment( + fragment_type=FragmentType.RUFF, + content=content, + source_adr_ids=list(contract.provenance.keys()), + ) + + def _generate_import_linter_fragment(self, contract: Any) -> ConfigFragment | None: + """Generate import-linter configuration fragment.""" + + if ( + not contract.constraints.boundaries + or not contract.constraints.boundaries.rules + ): + return None + + # Build import-linter contracts + contracts = [] + for i, rule in enumerate(contract.constraints.boundaries.rules): + contract_name = f"adr-boundary-{i+1}" + contracts.append( + f"""[contracts.{contract_name}] +name = "ADR Boundary Rule" +type = "forbidden" +source_modules = ["**"] +forbidden_modules = ["{rule.forbid}"]""" + ) + + content = "\n\n".join(contracts) + + return ConfigFragment( + fragment_type=FragmentType.IMPORT_LINTER, + content=content, + source_adr_ids=list(contract.provenance.keys()), + ) + + def _log_policy_changes( + self, changes: list[ChangeEvent], results: list[ApplyResult] + ) -> None: + """Log policy changes for audit purposes.""" + + if not self.config.notify_on_apply: + return + + # Simple logging - could be enhanced with structured logging + print(f"🔧 ADR-Kit: Applied guardrails due to {len(changes)} policy changes:") + + for change in changes: + if change.change_type == ChangeType.STATUS_CHANGED: + print(f" - {change.adr_id}: {change.old_status} → {change.new_status}") + elif change.change_type == ChangeType.POLICY_CHANGED: + print(f" - {change.adr_id}: Policy updated") + elif change.change_type == ChangeType.CREATED: + print(f" - New ADR: {change.file_path.name}") + + success_count = len( + [r for r in results if r.status == ApplicationStatus.SUCCESS] + ) + print(f" ✅ {success_count}/{len(results)} configurations updated") + + def remove_all_guardrails(self) -> list[ApplyResult]: + """Remove all managed guardrail sections from target files.""" + + results = [] + + for target in self.config.targets: + if self.config_writer.has_managed_section(target): + result = self.config_writer.remove_managed_sections(target) + results.append(result) + + return results + + def get_status(self) -> dict[str, Any]: + """Get current status of the guardrail management system.""" + + try: + contract = self.contract_builder.build_contract() + contract_valid = True + constraint_count = ( + len(contract.constraints.imports.disallow or []) + if contract.constraints.imports + else ( + 0 + len(contract.constraints.imports.prefer or []) + if contract.constraints.imports + else ( + 0 + + len(contract.constraints.architecture.layer_boundaries or []) + if contract.constraints.architecture + else ( + 0 + len(contract.constraints.python.disallow_imports or []) + if contract.constraints.python + else 0 + ) + ) + ) + ) + except Exception: + contract_valid = False + constraint_count = 0 + + # Check target file status + target_status = {} + for target in self.config.targets: + target_status[str(target.file_path)] = { + "exists": target.file_path.exists(), + "has_managed_section": ( + self.config_writer.has_managed_section(target) + if target.file_path.exists() + else False + ), + "fragment_type": target.fragment_type.value, + } + + return { + "enabled": self.config.enabled, + "auto_apply": self.config.auto_apply, + "contract_valid": contract_valid, + "active_constraints": constraint_count, + "target_count": len(self.config.targets), + "targets": target_status, + "last_contract_hash": self._last_contract_hash, + } diff --git a/adr_kit/enforcement/config/models.py b/adr_kit/enforcement/config/models.py new file mode 100644 index 0000000..34143ae --- /dev/null +++ b/adr_kit/enforcement/config/models.py @@ -0,0 +1,166 @@ +"""Data models for the Automatic Guardrail Management System.""" + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class FragmentType(str, Enum): + """Types of configuration fragments.""" + + ESLINT = "eslint" + RUFF = "ruff" + IMPORT_LINTER = "import_linter" + PRETTIER = "prettier" + MYPY = "mypy" + CUSTOM = "custom" + + +class ApplicationStatus(str, Enum): + """Status of configuration fragment application.""" + + SUCCESS = "success" + FAILED = "failed" + SKIPPED = "skipped" + PARTIAL = "partial" + + +@dataclass +class FragmentTarget: + """Target configuration for applying a fragment.""" + + file_path: Path + fragment_type: FragmentType + section_name: str | None = None # For multi-section configs + backup_enabled: bool = True + + +class ConfigFragment(BaseModel): + """A configuration fragment to be applied to a target file.""" + + fragment_type: FragmentType + content: str = Field(..., description="The configuration content") + source_adr_ids: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class SentinelBlock(BaseModel): + """Sentinel block markers for tool-owned configuration sections.""" + + start_marker: str + end_marker: str + description: str | None = None + + @classmethod + def for_fragment_type( + cls, fragment_type: FragmentType, tool_name: str = "adr-kit" + ) -> "SentinelBlock": + """Create standard sentinel block for a fragment type.""" + markers = { + FragmentType.ESLINT: ( + f"/* === {tool_name.upper()} ADR RULES START === */", + f"/* === {tool_name.upper()} ADR RULES END === */", + ), + FragmentType.RUFF: ( + f"# === {tool_name.upper()} ADR RULES START ===", + f"# === {tool_name.upper()} ADR RULES END ===", + ), + FragmentType.IMPORT_LINTER: ( + f"# === {tool_name.upper()} ADR CONTRACTS START ===", + f"# === {tool_name.upper()} ADR CONTRACTS END ===", + ), + FragmentType.PRETTIER: ( + f"// === {tool_name.upper()} ADR RULES START ===", + f"// === {tool_name.upper()} ADR RULES END ===", + ), + FragmentType.MYPY: ( + f"# === {tool_name.upper()} ADR RULES START ===", + f"# === {tool_name.upper()} ADR RULES END ===", + ), + FragmentType.CUSTOM: ( + f"# === {tool_name.upper()} START ===", + f"# === {tool_name.upper()} END ===", + ), + } + + start_marker, end_marker = markers.get( + fragment_type, markers[FragmentType.CUSTOM] + ) + + return cls( + start_marker=start_marker, + end_marker=end_marker, + description=f"Auto-managed {fragment_type.value} rules from ADR policies", + ) + + +class ApplyResult(BaseModel): + """Result of applying configuration fragments.""" + + target: FragmentTarget + status: ApplicationStatus + message: str + fragments_applied: int = 0 + backup_created: Path | None = None + errors: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + +class ConfigTemplate(BaseModel): + """Template for generating configuration fragments.""" + + fragment_type: FragmentType + template_content: str + variables: dict[str, Any] = Field(default_factory=dict) + + def render(self, **kwargs: Any) -> str: + """Render template with provided variables.""" + merged_vars = {**self.variables, **kwargs} + try: + return self.template_content.format(**merged_vars) + except KeyError as e: + raise ValueError(f"Missing template variable: {e}") from e + + +class GuardrailConfig(BaseModel): + """Configuration for the guardrail management system.""" + + enabled: bool = True + auto_apply: bool = True # Whether to automatically apply changes + backup_enabled: bool = True + backup_dir: Path | None = None + + # Target configurations + targets: list[FragmentTarget] = Field(default_factory=list) + + # Fragment type settings + fragment_settings: dict[FragmentType, dict[str, Any]] = Field(default_factory=dict) + + # Templates for different configuration types + templates: list[ConfigTemplate] = Field(default_factory=list) + + # Notification settings + notify_on_apply: bool = True + notify_on_error: bool = True + + model_config = ConfigDict(use_enum_values=True) + + def get_targets_for_type(self, fragment_type: FragmentType) -> list[FragmentTarget]: + """Get all targets for a specific fragment type.""" + return [ + target for target in self.targets if target.fragment_type == fragment_type + ] + + def get_template_for_type( + self, fragment_type: FragmentType + ) -> ConfigTemplate | None: + """Get template for a specific fragment type.""" + for template in self.templates: + if template.fragment_type == fragment_type: + return template + return None diff --git a/adr_kit/enforcement/config/monitor.py b/adr_kit/enforcement/config/monitor.py new file mode 100644 index 0000000..7518b79 --- /dev/null +++ b/adr_kit/enforcement/config/monitor.py @@ -0,0 +1,221 @@ +"""File monitoring system for detecting ADR changes.""" + +import hashlib +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path + +from ...core.model import ADR +from ...core.parse import ParseError, find_adr_files, parse_adr_file + + +class ChangeType(str, Enum): + """Types of file changes that can be detected.""" + + CREATED = "created" + MODIFIED = "modified" + DELETED = "deleted" + STATUS_CHANGED = "status_changed" # ADR status changed + POLICY_CHANGED = "policy_changed" # ADR policy changed + + +@dataclass +class ChangeEvent: + """Represents a detected change in an ADR file.""" + + file_path: Path + change_type: ChangeType + adr_id: str | None = None + old_status: str | None = None + new_status: str | None = None + detected_at: datetime | None = None + + def __post_init__(self) -> None: + if self.detected_at is None: + self.detected_at = datetime.now() + + +class FileMonitor: + """Monitors ADR directory for changes and detects policy-relevant events.""" + + def __init__(self, adr_dir: Path): + self.adr_dir = Path(adr_dir) + self._file_hashes: dict[Path, str] = {} + self._adr_statuses: dict[str, str] = {} + self._adr_policies: dict[str, str] = {} + + # Initialize baseline state + self._update_baseline() + + def _update_baseline(self) -> None: + """Update the baseline state of all ADR files.""" + + adr_files = find_adr_files(self.adr_dir) + + for file_path in adr_files: + try: + # Calculate file hash + file_hash = self._calculate_file_hash(file_path) + self._file_hashes[file_path] = file_hash + + # Parse ADR and store status/policy + adr = parse_adr_file(file_path, strict=False) + if adr: + status_value = ( + adr.front_matter.status.value + if hasattr(adr.front_matter.status, "value") + else str(adr.front_matter.status) + ) + self._adr_statuses[adr.id] = status_value + + # Create a simple hash of the policy for change detection + policy_hash = self._calculate_policy_hash(adr) + self._adr_policies[adr.id] = policy_hash + + except (OSError, ParseError): + # Skip files that can't be read or parsed + continue + + def detect_changes(self) -> list[ChangeEvent]: + """Detect changes since last check and update baseline.""" + + changes = [] + current_files = set(find_adr_files(self.adr_dir)) + previous_files = set(self._file_hashes.keys()) + + # Check for deleted files + for deleted_file in previous_files - current_files: + changes.append( + ChangeEvent(file_path=deleted_file, change_type=ChangeType.DELETED) + ) + # Clean up tracking + del self._file_hashes[deleted_file] + + # Check for new and modified files + for file_path in current_files: + current_hash = self._calculate_file_hash(file_path) + + if file_path not in self._file_hashes: + # New file + changes.append( + ChangeEvent(file_path=file_path, change_type=ChangeType.CREATED) + ) + elif self._file_hashes[file_path] != current_hash: + # Modified file - check what changed + try: + adr = parse_adr_file(file_path, strict=False) + if adr: + # Check for status changes + old_status = self._adr_statuses.get(adr.id) + new_status = ( + adr.front_matter.status.value + if hasattr(adr.front_matter.status, "value") + else str(adr.front_matter.status) + ) + + if old_status != new_status: + changes.append( + ChangeEvent( + file_path=file_path, + change_type=ChangeType.STATUS_CHANGED, + adr_id=adr.id, + old_status=old_status, + new_status=new_status, + ) + ) + + # Check for policy changes + old_policy_hash = self._adr_policies.get(adr.id, "") + new_policy_hash = self._calculate_policy_hash(adr) + + if old_policy_hash != new_policy_hash: + changes.append( + ChangeEvent( + file_path=file_path, + change_type=ChangeType.POLICY_CHANGED, + adr_id=adr.id, + ) + ) + + # Update tracking + self._adr_statuses[adr.id] = new_status + self._adr_policies[adr.id] = new_policy_hash + + except (OSError, ParseError): + # If we can't parse, just mark as modified + changes.append( + ChangeEvent( + file_path=file_path, change_type=ChangeType.MODIFIED + ) + ) + + # Update hash tracking + self._file_hashes[file_path] = current_hash + + return changes + + def _calculate_file_hash(self, file_path: Path) -> str: + """Calculate SHA-256 hash of file content.""" + try: + content = file_path.read_bytes() + return hashlib.sha256(content).hexdigest() + except OSError: + return "" + + def _calculate_policy_hash(self, adr: ADR) -> str: + """Calculate a hash representing the ADR's policy content.""" + + policy_parts = [] + + if adr.front_matter.policy: + policy = adr.front_matter.policy + + # Include import policies + if policy.imports: + if policy.imports.disallow: + policy_parts.extend(sorted(policy.imports.disallow)) + if policy.imports.prefer: + policy_parts.extend(sorted(policy.imports.prefer)) + + # Include boundary policies + if policy.boundaries and policy.boundaries.rules: + for rule in policy.boundaries.rules: + policy_parts.append(rule.forbid) + + # Include Python policies + if policy.python and policy.python.disallow_imports: + policy_parts.extend(sorted(policy.python.disallow_imports)) + + # Create hash from sorted policy parts + policy_text = "|".join(sorted(policy_parts)) + return hashlib.sha256(policy_text.encode("utf-8")).hexdigest() + + def get_policy_relevant_changes( + self, changes: list[ChangeEvent] + ) -> list[ChangeEvent]: + """Filter changes to only those that affect policy enforcement.""" + + policy_relevant = [] + + for change in changes: + if change.change_type in [ + ChangeType.STATUS_CHANGED, + ChangeType.POLICY_CHANGED, + ChangeType.CREATED, # New ADRs might have policies + ]: + policy_relevant.append(change) + + # Status changes to/from 'accepted' are always relevant + elif change.change_type == ChangeType.STATUS_CHANGED: + if change.old_status == "accepted" or change.new_status == "accepted": + policy_relevant.append(change) + + return policy_relevant + + def force_refresh(self) -> None: + """Force a complete refresh of the baseline state.""" + self._file_hashes.clear() + self._adr_statuses.clear() + self._adr_policies.clear() + self._update_baseline() diff --git a/adr_kit/enforcement/config/writer.py b/adr_kit/enforcement/config/writer.py new file mode 100644 index 0000000..c524c86 --- /dev/null +++ b/adr_kit/enforcement/config/writer.py @@ -0,0 +1,265 @@ +"""Configuration file writer with sentinel block management.""" + +import json +import re +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +import toml + +from .models import ( + ApplicationStatus, + ApplyResult, + ConfigFragment, + FragmentTarget, + FragmentType, + SentinelBlock, +) + + +class ConfigWriter: + """Writes configuration fragments to target files with sentinel block management.""" + + def __init__(self, backup_enabled: bool = True, backup_dir: Path | None = None): + self.backup_enabled = backup_enabled + self.backup_dir = backup_dir or Path(".adr-kit/backups") + + def apply_fragments( + self, target: FragmentTarget, fragments: list[ConfigFragment] + ) -> ApplyResult: + """Apply configuration fragments to a target file.""" + + result = ApplyResult( + target=target, + status=ApplicationStatus.SUCCESS, + message="Fragments applied successfully", + ) + + try: + # Ensure target file exists + if not target.file_path.exists(): + result.status = ApplicationStatus.FAILED + result.message = f"Target file does not exist: {target.file_path}" + return result + + # Create backup if enabled + if self.backup_enabled: + backup_path = self._create_backup(target.file_path) + result.backup_created = backup_path + + # Read current content + original_content = target.file_path.read_text(encoding="utf-8") + + # Apply fragments based on file type + if target.fragment_type in [FragmentType.ESLINT, FragmentType.PRETTIER]: + updated_content = self._apply_json_fragments( + original_content, fragments, target + ) + elif target.fragment_type in [ + FragmentType.RUFF, + FragmentType.MYPY, + FragmentType.IMPORT_LINTER, + ]: + updated_content = self._apply_toml_fragments( + original_content, fragments, target + ) + else: + updated_content = self._apply_text_fragments( + original_content, fragments, target + ) + + # Write updated content + target.file_path.write_text(updated_content, encoding="utf-8") + result.fragments_applied = len(fragments) + + except Exception as e: + result.status = ApplicationStatus.FAILED + result.message = f"Failed to apply fragments: {str(e)}" + result.errors.append(str(e)) + + # Restore from backup if available + if result.backup_created and result.backup_created.exists(): + try: + shutil.copy2(result.backup_created, target.file_path) + result.warnings.append("Restored from backup after failure") + except Exception as restore_error: + result.errors.append(f"Failed to restore backup: {restore_error}") + + return result + + def _create_backup(self, file_path: Path) -> Path: + """Create a backup of the target file.""" + self.backup_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_name = f"{file_path.name}_{timestamp}.backup" + backup_path = self.backup_dir / backup_name + + shutil.copy2(file_path, backup_path) + return backup_path + + def _apply_json_fragments( + self, content: str, fragments: list[ConfigFragment], target: FragmentTarget + ) -> str: + """Apply fragments to JSON configuration files (ESLint, Prettier).""" + + try: + config = json.loads(content) + except json.JSONDecodeError: + # If not valid JSON, treat as text + return self._apply_text_fragments(content, fragments, target) + + # Merge fragment content into config + for fragment in fragments: + try: + fragment_config = json.loads(fragment.content) + config = self._merge_json_configs(config, fragment_config) + except json.JSONDecodeError: + # Skip invalid JSON fragments + continue + + return json.dumps(config, indent=2) + + def _apply_toml_fragments( + self, content: str, fragments: list[ConfigFragment], target: FragmentTarget + ) -> str: + """Apply fragments to TOML configuration files (Ruff, Mypy).""" + + if toml is None: + # TOML library not available, fall back to text mode + return self._apply_text_fragments(content, fragments, target) + + try: + config = toml.loads(content) + except ( + Exception + ): # Broad exception handling since different TOML libs have different exceptions + # If not valid TOML, treat as text + return self._apply_text_fragments(content, fragments, target) + + # Merge fragment content into config + for fragment in fragments: + try: + fragment_config = toml.loads(fragment.content) + config = self._merge_dict_configs(config, fragment_config) + except Exception: # Broad exception handling + # Skip invalid TOML fragments + continue + + return toml.dumps(config) + + def _apply_text_fragments( + self, content: str, fragments: list[ConfigFragment], target: FragmentTarget + ) -> str: + """Apply fragments to plain text files using sentinel blocks.""" + + sentinel = SentinelBlock.for_fragment_type(target.fragment_type) + + # Combine all fragment content + fragment_content = "\n".join(fragment.content for fragment in fragments) + + # Create the managed section + managed_section = ( + f"{sentinel.start_marker}\n{fragment_content}\n{sentinel.end_marker}" + ) + + # Find and replace existing managed section + pattern = ( + re.escape(sentinel.start_marker) + r".*?" + re.escape(sentinel.end_marker) + ) + + if re.search(pattern, content, re.DOTALL): + # Replace existing section + updated_content = re.sub(pattern, managed_section, content, flags=re.DOTALL) + else: + # Append new section + updated_content = content.rstrip() + "\n\n" + managed_section + "\n" + + return updated_content + + def _merge_json_configs( + self, base: dict[str, Any], fragment: dict[str, Any] + ) -> dict[str, Any]: + """Merge JSON configuration objects.""" + return self._merge_dict_configs(base, fragment) + + def _merge_dict_configs( + self, base: dict[str, Any], fragment: dict[str, Any] + ) -> dict[str, Any]: + """Deep merge dictionary configurations.""" + + result = base.copy() + + for key, value in fragment.items(): + if key in result: + if isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._merge_dict_configs(result[key], value) + elif isinstance(result[key], list) and isinstance(value, list): + # Merge lists, removing duplicates while preserving order + seen = set(result[key]) + result[key] = result[key] + [ + item for item in value if item not in seen + ] + else: + # Fragment value overwrites base value + result[key] = value + else: + result[key] = value + + return result + + def remove_managed_sections(self, target: FragmentTarget) -> ApplyResult: + """Remove all managed sections from a target file.""" + + result = ApplyResult( + target=target, + status=ApplicationStatus.SUCCESS, + message="Managed sections removed successfully", + ) + + try: + if not target.file_path.exists(): + result.status = ApplicationStatus.SKIPPED + result.message = "Target file does not exist" + return result + + # Create backup if enabled + if self.backup_enabled: + backup_path = self._create_backup(target.file_path) + result.backup_created = backup_path + + content = target.file_path.read_text(encoding="utf-8") + sentinel = SentinelBlock.for_fragment_type(target.fragment_type) + + # Remove managed section + pattern = ( + re.escape(sentinel.start_marker) + + r".*?" + + re.escape(sentinel.end_marker) + ) + updated_content = re.sub(pattern, "", content, flags=re.DOTALL) + + # Clean up excessive blank lines + updated_content = re.sub(r"\n{3,}", "\n\n", updated_content) + + target.file_path.write_text(updated_content, encoding="utf-8") + + except Exception as e: + result.status = ApplicationStatus.FAILED + result.message = f"Failed to remove managed sections: {str(e)}" + result.errors.append(str(e)) + + return result + + def has_managed_section(self, target: FragmentTarget) -> bool: + """Check if target file has a managed section.""" + + if not target.file_path.exists(): + return False + + content = target.file_path.read_text(encoding="utf-8") + sentinel = SentinelBlock.for_fragment_type(target.fragment_type) + + return sentinel.start_marker in content and sentinel.end_marker in content diff --git a/adr_kit/enforcement/detection/__init__.py b/adr_kit/enforcement/detection/__init__.py new file mode 100644 index 0000000..5d82f7a --- /dev/null +++ b/adr_kit/enforcement/detection/__init__.py @@ -0,0 +1,9 @@ +"""Guard system for ADR policy enforcement in code changes. + +This package provides semantic-aware policy violation detection for code diffs, +integrating with the ADR semantic retrieval system for context-aware enforcement. +""" + +from .detector import CodeAnalysisResult, GuardSystem, PolicyViolation + +__all__ = ["GuardSystem", "PolicyViolation", "CodeAnalysisResult"] diff --git a/adr_kit/enforcement/detection/detector.py b/adr_kit/enforcement/detection/detector.py new file mode 100644 index 0000000..cd0c195 --- /dev/null +++ b/adr_kit/enforcement/detection/detector.py @@ -0,0 +1,556 @@ +"""Semantic-aware policy violation detection for code changes. + +Design decisions: +- Use semantic retrieval to find relevant ADRs for code changes +- Parse git diffs to extract file changes and imports +- Match policy violations using both pattern matching and semantic similarity +- Provide actionable guidance with specific ADR references +- Support multiple languages (Python, JavaScript, TypeScript, etc.) +""" + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ...core.model import ADR +from ...core.parse import ParseError, find_adr_files, parse_adr_file +from ...core.policy_extractor import PolicyExtractor +from ...semantic.retriever import SemanticIndex, SemanticMatch + + +@dataclass +class PolicyViolation: + """Represents a policy violation detected in code changes.""" + + violation_type: ( + str # 'import_disallowed', 'import_not_preferred', 'boundary_violated' + ) + severity: str # 'error', 'warning', 'info' + message: str # Human-readable description + file_path: str # File where violation occurred + line_number: int | None = None # Line number if applicable + adr_id: str | None = None # ADR that defines the violated policy + adr_title: str | None = None # Title of the relevant ADR + suggested_fix: str | None = None # Suggested resolution + context: str | None = None # Additional context + + +@dataclass +class CodeAnalysisResult: + """Result of analyzing code changes for policy violations.""" + + violations: list[PolicyViolation] + analyzed_files: list[str] + relevant_adrs: list[SemanticMatch] + summary: str + + @property + def has_errors(self) -> bool: + """Check if any error-level violations were found.""" + return any(v.severity == "error" for v in self.violations) + + @property + def has_warnings(self) -> bool: + """Check if any warning-level violations were found.""" + return any(v.severity == "warning" for v in self.violations) + + +class DiffParser: + """Parse git diffs to extract meaningful code changes.""" + + def __init__(self) -> None: + self.import_patterns = { + "python": [ + r"^import\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", + r"^from\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+import", + ], + "javascript": [ + r'import\s+.*?\s+from\s+[\'"]([^\'"]+)[\'"]', + r'require\([\'"]([^\'"]+)[\'"]\)', + ], + "typescript": [ + r'import\s+.*?\s+from\s+[\'"]([^\'"]+)[\'"]', + r'require\([\'"]([^\'"]+)[\'"]\)', + ], + } + + def parse_diff(self, diff_text: str) -> dict[str, list[str]]: + """Parse a git diff and extract added imports per file. + + Args: + diff_text: Raw git diff output + + Returns: + Dictionary mapping file paths to lists of added imports + """ + file_changes: dict[str, list[str]] = {} + current_file: str | None = None + + lines = diff_text.split("\n") + for line in lines: + # Track which file we're in + if line.startswith("diff --git"): + # Extract file path from "diff --git a/path/file.py b/path/file.py" + parts = line.split() + if len(parts) >= 4: + current_file = parts[3][2:] # Remove "b/" prefix + file_changes[current_file] = [] + + # Look for added lines (starting with +) + elif line.startswith("+") and not line.startswith("+++"): + if current_file: + added_line = line[1:] # Remove + prefix + imports = self._extract_imports_from_line(added_line, current_file) + file_changes[current_file].extend(imports) + + return file_changes + + def _extract_imports_from_line(self, line: str, file_path: str) -> list[str]: + """Extract import statements from a single line of code.""" + line = line.strip() + if not line: + return [] + + # Determine language from file extension + file_ext = Path(file_path).suffix.lower() + language = self._get_language_from_extension(file_ext) + + imports = [] + patterns = self.import_patterns.get(language, []) + + for pattern in patterns: + matches = re.findall(pattern, line) + imports.extend(matches) + + return imports + + def _get_language_from_extension(self, ext: str) -> str: + """Map file extension to language.""" + ext_map = { + ".py": "python", + ".js": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + } + return ext_map.get(ext, "unknown") + + +class SemanticPolicyMatcher: + """Match code changes to relevant ADRs using semantic similarity.""" + + def __init__(self, semantic_index: SemanticIndex): + self.semantic_index = semantic_index + + def find_relevant_adrs( + self, file_changes: dict[str, list[str]], context_lines: list[str] | None = None + ) -> list[SemanticMatch]: + """Find ADRs that are semantically relevant to the code changes. + + Args: + file_changes: Dictionary of file paths to imported modules + context_lines: Additional context lines from the diff + + Returns: + List of relevant ADR matches + """ + # Build query from file paths, imports, and context + query_parts: list[str] = [] + + # Add file path context + for file_path in file_changes.keys(): + path_parts = Path(file_path).parts + query_parts.extend(path_parts) + + # Add import context + for imports in file_changes.values(): + query_parts.extend(imports) + + # Add code context if available + if context_lines: + for line in context_lines[:5]: # Limit to avoid noise + clean_line = re.sub(r"[^\w\s]", " ", line).strip() + if clean_line and len(clean_line) > 3: + query_parts.append(clean_line) + + # Create semantic query + query = " ".join(query_parts) + + # Search for relevant ADRs (accepted ones are most relevant) + matches = self.semantic_index.search( + query=query, + k=10, + filter_status={"accepted", "proposed"}, # Focus on active ADRs + ) + + return matches + + +class GuardSystem: + """Main guard system for detecting ADR policy violations in code changes.""" + + def __init__(self, project_root: Path | None = None, adr_dir: str = "docs/adr"): + """Initialize guard system with semantic index and policy extractor. + + Args: + project_root: Project root directory + adr_dir: Directory containing ADR files + """ + self.project_root = project_root or Path.cwd() + self.adr_dir = adr_dir + + # Initialize components + self.semantic_index = SemanticIndex(project_root) + self.policy_extractor = PolicyExtractor() + self.diff_parser = DiffParser() + self.semantic_matcher = SemanticPolicyMatcher(self.semantic_index) + + # Load ADR policies cache + self._policy_cache: dict[str, Any] = {} + self._load_adr_policies() + + def _load_adr_policies(self) -> None: + """Load and cache policies from all ADRs.""" + print("🔍 Loading ADR policies for guard system...") + + adr_files = find_adr_files(Path(self.adr_dir)) + for file_path in adr_files: + try: + adr = parse_adr_file(file_path, strict=False) + if not adr: + continue + + # Extract policy using hybrid approach + policy = self.policy_extractor.extract_policy(adr) + if policy: + self._policy_cache[adr.front_matter.id] = { + "adr": adr, + "policy": policy, + } + + except ParseError: + continue + + print(f"✅ Loaded {len(self._policy_cache)} ADRs with policies") + + def analyze_diff( + self, diff_text: str, build_index: bool = True + ) -> CodeAnalysisResult: + """Analyze a git diff for policy violations. + + Args: + diff_text: Raw git diff output + build_index: Whether to rebuild semantic index before analysis + + Returns: + CodeAnalysisResult with violations and recommendations + """ + print("🛡️ Analyzing code changes for policy violations...") + + # Build semantic index if requested + if build_index: + print("📊 Building semantic index...") + self.semantic_index.build_index(self.adr_dir) + + # Parse diff to extract file changes + file_changes = self.diff_parser.parse_diff(diff_text) + + if not file_changes: + return CodeAnalysisResult( + violations=[], + analyzed_files=[], + relevant_adrs=[], + summary="No code changes detected in diff", + ) + + print(f"📁 Analyzing changes in {len(file_changes)} files") + + # Find semantically relevant ADRs + relevant_adrs = self.semantic_matcher.find_relevant_adrs(file_changes) + + # Check for policy violations + violations = [] + for file_path, imports in file_changes.items(): + # Check violations against ALL ADRs with policies, not just semantically relevant ones + all_adrs_for_violation_check = [] + for adr_id, policy_info in self._policy_cache.items(): + # Create a mock SemanticMatch for all ADRs with policies + all_adrs_for_violation_check.append( + type( + "MockMatch", + (), + { + "adr_id": adr_id, + "title": policy_info["adr"].front_matter.title, + "score": 1.0, # Full score since we're checking all + }, + )() + ) + + file_violations = self._check_file_violations( + file_path, imports, all_adrs_for_violation_check + ) + violations.extend(file_violations) + + # Generate summary + summary = self._generate_summary(violations, file_changes, relevant_adrs) + + return CodeAnalysisResult( + violations=violations, + analyzed_files=list(file_changes.keys()), + relevant_adrs=relevant_adrs, + summary=summary, + ) + + def _check_file_violations( + self, file_path: str, imports: list[str], relevant_adrs: list[SemanticMatch] + ) -> list[PolicyViolation]: + """Check a single file for policy violations.""" + violations = [] + + # Get file language for targeted policy checking + file_ext = Path(file_path).suffix.lower() + language = self._get_language_from_extension(file_ext) + + # Check each relevant ADR for violations + for adr_match in relevant_adrs[:5]: # Check top 5 most relevant + if adr_match.adr_id not in self._policy_cache: + continue + + policy_info = self._policy_cache[adr_match.adr_id] + adr = policy_info["adr"] + policy = policy_info["policy"] + + # Check import violations + violations.extend( + self._check_import_violations(file_path, imports, adr, policy, language) + ) + + # Check boundary violations (simplified for now) + violations.extend( + self._check_boundary_violations(file_path, imports, adr, policy) + ) + + return violations + + def _check_import_violations( + self, file_path: str, imports: list[str], adr: ADR, policy: Any, language: str + ) -> list[PolicyViolation]: + """Check for import policy violations.""" + violations = [] + + # Check disallowed imports + disallowed_imports = [] + if policy.imports and policy.imports.disallow: + disallowed_imports.extend(policy.imports.disallow) + if language == "python" and policy.python and policy.python.disallow_imports: + disallowed_imports.extend(policy.python.disallow_imports) + + for import_name in imports: + # Check against disallowed list + for disallowed in disallowed_imports: + if self._import_matches_pattern(import_name, disallowed): + violations.append( + PolicyViolation( + violation_type="import_disallowed", + severity="error", + message=f"Import '{import_name}' is disallowed by ADR {adr.front_matter.id}", + file_path=file_path, + adr_id=adr.front_matter.id, + adr_title=adr.front_matter.title, + suggested_fix=self._suggest_import_alternative( + import_name, policy + ), + context=f"Disallowed pattern: {disallowed}", + ) + ) + + # Check preferred imports (warning if not used) + if policy.imports and policy.imports.prefer: + for import_name in imports: + # Check if there's a preferred alternative + preferred_alternative = self._find_preferred_alternative( + import_name, policy.imports.prefer + ) + if preferred_alternative: + violations.append( + PolicyViolation( + violation_type="import_not_preferred", + severity="warning", + message=f"Consider using '{preferred_alternative}' instead of '{import_name}' (ADR {adr.front_matter.id})", + file_path=file_path, + adr_id=adr.front_matter.id, + adr_title=adr.front_matter.title, + suggested_fix=f"Replace with: {preferred_alternative}", + context="Preferred by ADR policy", + ) + ) + + return violations + + def _check_boundary_violations( + self, file_path: str, imports: list[str], adr: ADR, policy: Any + ) -> list[PolicyViolation]: + """Check for architectural boundary violations.""" + violations: list[PolicyViolation] = [] + + if not policy.boundaries or not policy.boundaries.rules: + return violations + + # Simplified boundary checking - can be expanded + for rule in policy.boundaries.rules: + if "cross-layer" in rule.forbid.lower() or "layer" in rule.forbid.lower(): + # Check for layer violations based on file path + file_layer = self._determine_file_layer(file_path) + for import_name in imports: + import_layer = self._determine_import_layer(import_name) + if ( + file_layer + and import_layer + and self._violates_layer_rule(file_layer, import_layer) + ): + violations.append( + PolicyViolation( + violation_type="boundary_violated", + severity="error", + message=f"Cross-layer import violation: {file_layer} → {import_layer} (ADR {adr.front_matter.id})", + file_path=file_path, + adr_id=adr.front_matter.id, + adr_title=adr.front_matter.title, + context=f"Rule: {rule.forbid}", + ) + ) + + return violations + + def _import_matches_pattern(self, import_name: str, pattern: str) -> bool: + """Check if import matches a disallow pattern.""" + # Support glob-like patterns + if "*" in pattern: + # Convert glob to regex + regex_pattern = pattern.replace("*", ".*") + return re.match(regex_pattern, import_name) is not None + else: + # Exact match or prefix match + return import_name == pattern or import_name.startswith(pattern + ".") + + def _suggest_import_alternative(self, import_name: str, policy: Any) -> str | None: + """Suggest an alternative import based on policy preferences.""" + if policy.imports and policy.imports.prefer: + # Find a preferred import that might replace this one + for preferred in policy.imports.prefer: + if self._are_similar_imports(import_name, preferred): + return str(preferred) + return None + + def _find_preferred_alternative( + self, import_name: str, preferred_imports: list[str] + ) -> str | None: + """Find if there's a preferred alternative to the current import.""" + for preferred in preferred_imports: + if self._are_similar_imports(import_name, preferred): + return preferred + return None + + def _are_similar_imports(self, import1: str, import2: str) -> bool: + """Check if two imports are functionally similar.""" + # Simplified similarity check - can be made more sophisticated + common_alternatives = { + "lodash": ["ramda", "underscore"], + "moment": ["dayjs", "date-fns"], + "axios": ["fetch", "node-fetch"], + "jquery": ["vanilla-js", "native-dom"], + } + + for base, alternatives in common_alternatives.items(): + if import1.startswith(base) and any( + import2.startswith(alt) for alt in alternatives + ): + return True + if import2.startswith(base) and any( + import1.startswith(alt) for alt in alternatives + ): + return True + + return False + + def _determine_file_layer(self, file_path: str) -> str | None: + """Determine architectural layer from file path.""" + path_parts = file_path.lower().split("/") + + layer_indicators = { + "controller": ["controller", "api", "route"], + "service": ["service", "business", "logic"], + "repository": ["repository", "data", "model", "db"], + "view": ["view", "template", "component", "ui"], + } + + for layer, indicators in layer_indicators.items(): + if any(indicator in path_parts for indicator in indicators): + return layer + + return None + + def _determine_import_layer(self, import_name: str) -> str | None: + """Determine architectural layer from import name.""" + import_lower = import_name.lower() + + if any(x in import_lower for x in ["express", "fastapi", "flask"]): + return "controller" + elif any(x in import_lower for x in ["mongoose", "sqlalchemy", "prisma"]): + return "repository" + elif any(x in import_lower for x in ["react", "vue", "angular"]): + return "view" + + return None + + def _violates_layer_rule(self, from_layer: str, to_layer: str) -> bool: + """Check if import between layers violates architecture rules.""" + # Simplified layer rule: views shouldn't import repositories directly + if from_layer == "view" and to_layer == "repository": + return True + + return False + + def _get_language_from_extension(self, ext: str) -> str: + """Map file extension to language.""" + ext_map = { + ".py": "python", + ".js": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + } + return ext_map.get(ext, "unknown") + + def _generate_summary( + self, + violations: list[PolicyViolation], + file_changes: dict[str, list[str]], + relevant_adrs: list[SemanticMatch], + ) -> str: + """Generate human-readable summary of the analysis.""" + if not violations: + return f"✅ No policy violations found in {len(file_changes)} files" + + error_count = sum(1 for v in violations if v.severity == "error") + warning_count = sum(1 for v in violations if v.severity == "warning") + + summary_parts = ["🛡️ Policy analysis complete:"] + + if error_count > 0: + summary_parts.append( + f"❌ {error_count} error{'s' if error_count != 1 else ''}" + ) + + if warning_count > 0: + summary_parts.append( + f"⚠️ {warning_count} warning{'s' if warning_count != 1 else ''}" + ) + + if relevant_adrs: + adr_ids = [adr.adr_id for adr in relevant_adrs[:3]] + summary_parts.append(f"📋 Relevant ADRs: {', '.join(adr_ids)}") + + return " | ".join(summary_parts) diff --git a/adr_kit/enforcement/generation/__init__.py b/adr_kit/enforcement/generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/enforcement/generation/ci.py b/adr_kit/enforcement/generation/ci.py new file mode 100644 index 0000000..a283f5d --- /dev/null +++ b/adr_kit/enforcement/generation/ci.py @@ -0,0 +1,149 @@ +"""CI workflow generator for ADR enforcement. + +Generates GitHub Actions workflow YAML that runs ADR policy enforcement +checks on pull requests. The workflow: +- Runs `adr-kit enforce ci --format json` +- Fails the build on violations +- Posts structured PR comments for AI agents to self-correct +""" + +from pathlib import Path + +_MANAGED_HEADER = "# Auto-generated by ADR Kit — do not edit manually" + + +class CIWorkflowGenerator: + """Generate GitHub Actions workflow for ADR enforcement.""" + + def generate(self, output_path: Path | None = None) -> str: + """Generate a GitHub Actions workflow YAML. + + Args: + output_path: Where to write the file. If provided, writes to disk. + Defaults to None (return content only). + + Returns: + The workflow YAML content. + + Raises: + FileExistsError: If output_path exists and wasn't generated by ADR Kit. + """ + content = self._build_workflow() + + if output_path: + self._safe_write(output_path, content) + + return content + + def _build_workflow(self) -> str: + """Build the workflow YAML content.""" + return f"""{_MANAGED_HEADER} +# Regenerate: adr-kit generate-ci +name: ADR Enforcement + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + pull-requests: write + +jobs: + enforce: + name: ADR Policy Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install adr-kit + run: pip install adr-kit + + - name: Run enforcement checks + id: enforce + run: | + adr-kit enforce ci --format json > adr-report.json + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + continue-on-error: true + + - name: Post PR comment + if: always() && steps.enforce.outcome == 'failure' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let report; + try {{ + report = JSON.parse(fs.readFileSync('adr-report.json', 'utf8')); + }} catch (e) {{ + report = {{ passed: false, violations: [], errors: ['Failed to parse report'] }}; + }} + + let body = '## ADR Enforcement Report\\n\\n'; + + if (report.violations && report.violations.length > 0) {{ + body += '### Violations\\n\\n'; + for (const v of report.violations) {{ + const icon = v.severity === 'error' ? ':x:' : ':warning:'; + const loc = v.line ? `${{v.file}}:${{v.line}}` : v.file; + body += `${{icon}} **${{v.adr_id}}**: ${{v.message}}\\n`; + body += ` - Location: ` + '`' + `${{loc}}` + '`' + `\\n`; + if (v.fix_suggestion) {{ + body += ` - Fix: ${{v.fix_suggestion}}\\n`; + }} + body += '\\n'; + }} + }} + + if (report.summary) {{ + body += `\\n**Summary**: ${{report.summary.error_count}} error(s), ${{report.summary.warning_count}} warning(s)\\n`; + }} + + body += '\\n
Raw JSON report\\n\\n```json\\n' + JSON.stringify(report, null, 2) + '\\n```\\n
'; + + // Find existing comment to update + const {{ data: comments }} = await github.rest.issues.listComments({{ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }}); + const existing = comments.find(c => c.body.includes('## ADR Enforcement Report')); + + if (existing) {{ + await github.rest.issues.updateComment({{ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }}); + }} else {{ + await github.rest.issues.createComment({{ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }}); + }} + + - name: Fail on violations + if: steps.enforce.outcome == 'failure' + run: exit 1 +""" + + def _safe_write(self, output_path: Path, content: str) -> None: + """Write content, refusing to overwrite non-managed files.""" + if output_path.exists(): + existing = output_path.read_text(encoding="utf-8") + if not existing.startswith(_MANAGED_HEADER): + raise FileExistsError( + f"{output_path} exists and was not generated by ADR Kit. " + f"Remove it manually or add '{_MANAGED_HEADER}' as the first line." + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") diff --git a/adr_kit/enforcement/generation/hooks.py b/adr_kit/enforcement/generation/hooks.py new file mode 100644 index 0000000..adf1bce --- /dev/null +++ b/adr_kit/enforcement/generation/hooks.py @@ -0,0 +1,173 @@ +"""Git hook generator for staged ADR enforcement. + +Writes a managed section into .git/hooks/pre-commit and .git/hooks/pre-push +so that ADR policy checks run automatically at the right workflow stage. + +Design: +- Non-interfering: appends a managed section to existing hooks, never overwrites. +- Idempotent: re-running updates the managed section in-place. +- Clearly marked: ADR-KIT markers make ownership obvious. +- First-run bootstraps: creates hook file if it doesn't exist. +""" + +import stat +from pathlib import Path + +# Sentinel markers — must be unique and stable across versions +MANAGED_START = "# >>> ADR-KIT MANAGED - DO NOT EDIT >>>" +MANAGED_END = "# <<< ADR-KIT MANAGED <<<" + +_HOOK_HEADER = "#!/bin/sh" + +# Per-hook managed content +_COMMIT_SECTION = f"""\ +{MANAGED_START} +adr-kit enforce commit +{MANAGED_END}""" + +_PUSH_SECTION = f"""\ +{MANAGED_START} +adr-kit enforce push +{MANAGED_END}""" + + +def _make_executable(path: Path) -> None: + """Ensure the hook file has executable permission.""" + current = path.stat().st_mode + path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _apply_managed_section(hook_path: Path, managed_content: str) -> str: + """Insert or replace the ADR-Kit managed section in a hook file. + + If the hook doesn't exist, creates it with a shebang + managed section. + Returns a string describing what changed: "created" | "updated" | "unchanged". + """ + if not hook_path.exists(): + hook_path.write_text(f"{_HOOK_HEADER}\n\n{managed_content}\n") + _make_executable(hook_path) + return "created" + + existing = hook_path.read_text() + + # Replace existing managed section + if MANAGED_START in existing and MANAGED_END in existing: + start_idx = existing.index(MANAGED_START) + end_idx = existing.index(MANAGED_END) + len(MANAGED_END) + new_section = ( + existing[:start_idx].rstrip("\n") + + "\n\n" + + managed_content + + "\n" + + existing[end_idx:].lstrip("\n") + ) + if new_section == existing: + return "unchanged" + hook_path.write_text(new_section) + _make_executable(hook_path) + return "updated" + + # No managed section yet — append + separator = "\n\n" if existing.rstrip() else "" + hook_path.write_text(existing.rstrip() + separator + managed_content + "\n") + _make_executable(hook_path) + return "appended" + + +class HookGenerator: + """Generates and updates git hooks for staged ADR enforcement. + + Writes ADR-Kit managed sections into .git/hooks/pre-commit and + .git/hooks/pre-push. Safe to call repeatedly — idempotent. + """ + + def generate(self, project_root: Path | None = None) -> dict[str, str]: + """Write managed sections into pre-commit and pre-push hooks. + + Args: + project_root: Root of the git repository. Defaults to cwd. + + Returns: + Dict mapping hook name → action taken ("created"|"updated"|"appended"|"unchanged"|"skipped"). + """ + project_root = project_root or Path.cwd() + hooks_dir = project_root / ".git" / "hooks" + + if not hooks_dir.exists(): + # Not a git repo or hooks dir missing — skip silently + return { + "pre-commit": "skipped (no .git/hooks directory)", + "pre-push": "skipped (no .git/hooks directory)", + } + + results: dict[str, str] = {} + + results["pre-commit"] = _apply_managed_section( + hooks_dir / "pre-commit", _COMMIT_SECTION + ) + results["pre-push"] = _apply_managed_section( + hooks_dir / "pre-push", _PUSH_SECTION + ) + + return results + + def remove(self, project_root: Path | None = None) -> dict[str, str]: + """Remove ADR-Kit managed sections from git hooks. + + Useful when uninstalling or disabling enforcement. + + Returns: + Dict mapping hook name → action taken ("removed"|"not_found"|"skipped"). + """ + project_root = project_root or Path.cwd() + hooks_dir = project_root / ".git" / "hooks" + + if not hooks_dir.exists(): + return { + "pre-commit": "skipped (no .git/hooks directory)", + "pre-push": "skipped (no .git/hooks directory)", + } + + results: dict[str, str] = {} + for hook_name in ("pre-commit", "pre-push"): + hook_path = hooks_dir / hook_name + if not hook_path.exists(): + results[hook_name] = "not_found" + continue + + content = hook_path.read_text() + if MANAGED_START not in content: + results[hook_name] = "not_found" + continue + + start_idx = content.index(MANAGED_START) + end_idx = content.index(MANAGED_END) + len(MANAGED_END) + # Strip surrounding blank lines added when appending + cleaned = content[:start_idx].rstrip("\n") + content[end_idx:].lstrip("\n") + if not cleaned.strip(): + # Hook only contained our section — remove the file + hook_path.unlink() + else: + hook_path.write_text(cleaned) + results[hook_name] = "removed" + + return results + + def status(self, project_root: Path | None = None) -> dict[str, bool]: + """Check whether ADR-Kit managed sections are present in hooks. + + Returns: + Dict mapping hook name → True if managed section is present. + """ + project_root = project_root or Path.cwd() + hooks_dir = project_root / ".git" / "hooks" + + result: dict[str, bool] = {} + for hook_name in ("pre-commit", "pre-push"): + hook_path = hooks_dir / hook_name + if not hook_path.exists(): + result[hook_name] = False + continue + result[hook_name] = MANAGED_START in hook_path.read_text() + + return result diff --git a/adr_kit/enforcement/generation/scripts.py b/adr_kit/enforcement/generation/scripts.py new file mode 100644 index 0000000..ded19cd --- /dev/null +++ b/adr_kit/enforcement/generation/scripts.py @@ -0,0 +1,522 @@ +"""Standalone validation script generator. + +Generates stdlib-only Python scripts from ADR policies. Each script: +- Checks one ADR's policies against source files +- Outputs JSON matching the EnforcementReport schema (reporter.py) +- Supports --quick (git changed files) and --full (all files) modes +- Requires no adr-kit installation at runtime + +Also generates a validate_all.py runner that aggregates results. +""" + +import stat +from pathlib import Path + +from ...core.model import ADR, ADRStatus +from ...core.parse import ParseError, find_adr_files, parse_adr_file +from ..validation.stages import StagedCheck, classify_adr_checks + + +class ScriptGenerator: + """Generate standalone validation scripts from ADR policies.""" + + def __init__(self, adr_dir: str | Path = "docs/adr") -> None: + self.adr_dir = Path(adr_dir) + + def generate_for_adr(self, adr: ADR, output_dir: Path) -> Path | None: + """Generate a validation script for a single ADR. + + Returns: + Path to the generated script, or None if ADR has no enforceable policies. + """ + checks = classify_adr_checks([adr]) + # Skip config checks — they need TOML/JSON parsing not yet implemented + enforceable = [c for c in checks if c.check_type != "config"] + if not enforceable: + return None + + script = self._build_script(adr, enforceable) + if not script: + return None + + output_dir.mkdir(parents=True, exist_ok=True) + safe_id = adr.id.lower().replace("-", "_") + script_path = output_dir / f"validate_{safe_id}.py" + script_path.write_text(script, encoding="utf-8") + script_path.chmod( + script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH + ) + + return script_path + + def generate_all(self, output_dir: Path) -> list[Path]: + """Generate scripts for all accepted ADRs and a runner. + + Returns: + List of all generated file paths (individual scripts + runner). + """ + adrs = self._load_accepted_adrs() + paths: list[Path] = [] + + for adr in adrs: + path = self.generate_for_adr(adr, output_dir) + if path: + paths.append(path) + + runner = self._generate_runner_script(output_dir) + paths.append(runner) + + return paths + + # --- Script building --- + + def _build_script(self, adr: ADR, checks: list[StagedCheck]) -> str: + """Build the complete script content for an ADR.""" + adr_id = _escape(adr.id) + adr_title = _escape(adr.title) + + check_functions: list[str] = [] + check_calls: list[str] = [] + + for i, check in enumerate(checks): + func_name = f"check_{i}" + func_code = self._generate_check_function(func_name, check) + if func_code: + check_functions.append(func_code) + check_calls.append(func_name) + + if not check_functions: + return "" + + checks_code = "\n\n\n".join(check_functions) + calls_code = "\n".join( + f" violations.extend({name}(files))" for name in check_calls + ) + num_checks = len(check_calls) + + return f'''\ +#!/usr/bin/env python3 +"""{adr_id} validation: {adr_title} + +Auto-generated by ADR Kit. Regenerate: adr-kit generate-scripts +""" + +import argparse +import fnmatch +import json +import os +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +ADR_ID = "{adr_id}" +ADR_TITLE = "{adr_title}" + +SOURCE_EXTENSIONS = {{".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".kt"}} + +EXCLUDE_DIRS = {{ + ".git", ".venv", "venv", "node_modules", "__pycache__", + ".pytest_cache", ".mypy_cache", ".ruff_cache", "dist", "build", + ".adr-kit", ".project-index", +}} + + +def get_files(mode, project_root="."): + """Collect files based on mode.""" + root = Path(project_root) + + if mode == "quick": + try: + result = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], + capture_output=True, text=True, cwd=root, + ) + if result.returncode == 0: + files = [root / f for f in result.stdout.strip().splitlines() if f] + return [f for f in files if f.exists() and f.suffix in SOURCE_EXTENSIONS] + except FileNotFoundError: + print("Warning: git not available, falling back to full scan", file=sys.stderr) + + files = [] + for f in root.rglob("*"): + if not f.is_file(): + continue + if f.suffix not in SOURCE_EXTENSIONS: + continue + if any(part in EXCLUDE_DIRS for part in f.parts): + continue + files.append(f) + return files + + +{checks_code} + + +def main(): + parser = argparse.ArgumentParser(description=f"Validate {{ADR_ID}}: {{ADR_TITLE}}") + parser.add_argument("--quick", action="store_true", help="Check staged/changed files only") + parser.add_argument("--full", action="store_true", default=True, help="Check all files (default)") + parser.add_argument("--root", default=".", help="Project root directory") + args = parser.parse_args() + + mode = "quick" if args.quick else "full" + project_root = Path(args.root).resolve() + files = get_files(mode, project_root) + + violations = [] +{calls_code} + + error_count = sum(1 for v in violations if v["severity"] == "error") + warning_count = sum(1 for v in violations if v["severity"] == "warning") + + report = {{ + "schema_version": "1.0", + "level": "ci", + "timestamp": datetime.now(timezone.utc).isoformat(), + "passed": error_count == 0, + "summary": {{ + "files_checked": len(files), + "checks_run": {num_checks}, + "error_count": error_count, + "warning_count": warning_count, + }}, + "violations": violations, + "errors": [], + }} + + print(json.dumps(report, indent=2)) + sys.exit(0 if report["passed"] else 1) + + +if __name__ == "__main__": + main() +''' + + def _generate_check_function( + self, func_name: str, check: StagedCheck + ) -> str | None: + """Generate a check function for a single StagedCheck.""" + if check.check_type in ("import", "python_import"): + return self._generate_import_check(func_name, check) + elif check.check_type == "pattern": + return self._generate_pattern_check(func_name, check) + elif check.check_type == "architecture": + return self._generate_architecture_check(func_name, check) + elif check.check_type == "required_structure": + return self._generate_structure_check(func_name, check) + return None + + def _generate_import_check(self, func_name: str, check: StagedCheck) -> str: + """Generate code for an import restriction check.""" + if check.check_type == "python_import": + filter_line = ' target_files = [f for f in files if f.suffix == ".py"]' + else: + filter_line = ( + " target_files = [f for f in files if f.suffix in " + '{".js", ".ts", ".jsx", ".tsx"}]' + ) + + lib = _escape(check.pattern) + adr_id = _escape(check.adr_id) + severity = _escape(check.severity) + message = _escape(check.message) + adr_title = _escape(check.adr_title or "") + level = _escape(check.level.value) + + return f'''\ +def {func_name}(files): + """From {adr_id}: import check for '{lib}'""" +{filter_line} + violations = [] + escaped = re.escape("{lib}") + _q = "[" + chr(39) + chr(34) + "]" + _nq = "[^" + chr(39) + chr(34) + "]*?" + import_patterns = [ + re.compile(r"(from|import)\\s+" + escaped + r"(\\.|\\s|$|;)"), + re.compile(r"(import|from)\\s+" + _q + r"(" + _nq + r"/)?" + escaped + _q), + re.compile(r"require\\s*\\(\\s*" + _q + r"(" + _nq + r"/)?" + escaped + _q + r"\\s*\\)"), + ] + for file_path in target_files: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + for line_num, line in enumerate(content.splitlines(), 1): + for pattern in import_patterns: + if pattern.search(line): + violations.append({{ + "file": str(file_path), + "line": line_num, + "adr_id": "{adr_id}", + "adr_title": "{adr_title}", + "message": "{message}", + "severity": "{severity}", + "level": "{level}", + "fix_suggestion": "Remove or replace this import — see {adr_id}", + }}) + break + except Exception: + continue + return violations''' + + def _generate_pattern_check(self, func_name: str, check: StagedCheck) -> str: + """Generate code for a regex pattern check.""" + # Only escape quotes — pattern goes in r"..." where backslashes are literal + pattern_escaped = check.pattern.replace('"', '\\"') + adr_id = _escape(check.adr_id) + severity = _escape(check.severity) + message = _escape(check.message) + adr_title = _escape(check.adr_title or "") + level = _escape(check.level.value) + + if check.file_glob and check.file_glob.startswith("*."): + ext = check.file_glob[1:] + filter_line = ( + f' target_files = [f for f in files if f.name.endswith("{ext}")]' + ) + else: + filter_line = " target_files = files" + + return f'''\ +def {func_name}(files): + """From {adr_id}: pattern check""" +{filter_line} + violations = [] + try: + compiled = re.compile(r"{pattern_escaped}") + except re.error: + return [] + for file_path in target_files: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + for line_num, line in enumerate(content.splitlines(), 1): + if compiled.search(line): + violations.append({{ + "file": str(file_path), + "line": line_num, + "adr_id": "{adr_id}", + "adr_title": "{adr_title}", + "message": "{message}", + "severity": "{severity}", + "level": "{level}", + "fix_suggestion": None, + }}) + except Exception: + continue + return violations''' + + def _generate_architecture_check(self, func_name: str, check: StagedCheck) -> str: + """Generate code for an architecture layer boundary check.""" + parts = check.pattern.split("->") + if len(parts) != 2: + return "" + source_layer = parts[0].strip().lower() + target_layer = parts[1].strip().lower() + if not source_layer or not target_layer: + return "" + + check_glob = _escape(check.metadata.get("check", "")) + adr_id = _escape(check.adr_id) + severity = _escape(check.severity) + message = _escape(check.message) + adr_title = _escape(check.adr_title or "") + level = _escape(check.level.value) + + return f'''\ +def {func_name}(files): + """From {adr_id}: architecture check '{source_layer} -> {target_layer}'""" + source_layer = "{source_layer}" + target_layer = "{target_layer}" + check_glob = "{check_glob}" + violations = [] + + if check_glob: + # Extract directory prefix from glob (e.g., "ui/**/*.py" -> "ui") + glob_dir = check_glob.split("/")[0].lower() if "/" in check_glob else check_glob.lower() + glob_ext = None + if check_glob.endswith(".py"): + glob_ext = ".py" + elif check_glob.endswith(".ts") or check_glob.endswith(".tsx"): + glob_ext = (".ts", ".tsx") + elif check_glob.endswith(".js") or check_glob.endswith(".jsx"): + glob_ext = (".js", ".jsx") + source_files = [] + for f in files: + if glob_dir not in [p.lower() for p in f.parts]: + continue + if glob_ext and not f.suffix in (glob_ext if isinstance(glob_ext, tuple) else (glob_ext,)): + continue + source_files.append(f) + else: + source_files = [f for f in files if source_layer in [p.lower() for p in f.parts]] + + escaped = re.escape(target_layer) + _q = "[" + chr(39) + chr(34) + "]" + _nq = "[^" + chr(39) + chr(34) + "]*?" + target_patterns = [ + re.compile(r"(from|import)\\s+" + escaped + r"(\\.\\w+)*(\\s|$|;)", re.IGNORECASE), + re.compile(r"(import|from)\\s+" + _q + r"(" + _nq + r"[/\\\\])?" + escaped + r"([/\\\\]" + _nq + r")?" + _q, re.IGNORECASE), + re.compile(r"require\\s*\\(\\s*" + _q + r"(" + _nq + r"[/\\\\])?" + escaped + r"([/\\\\]" + _nq + r")?" + _q + r"\\s*\\)", re.IGNORECASE), + ] + + for file_path in source_files: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + for line_num, line in enumerate(content.splitlines(), 1): + for pattern in target_patterns: + if pattern.search(line): + violations.append({{ + "file": str(file_path), + "line": line_num, + "adr_id": "{adr_id}", + "adr_title": "{adr_title}", + "message": "{message}", + "severity": "{severity}", + "level": "{level}", + "fix_suggestion": "Move this import out of " + source_layer + " code — see {adr_id}", + }}) + break + except Exception: + continue + return violations''' + + def _generate_structure_check(self, func_name: str, check: StagedCheck) -> str: + """Generate code for a required structure check.""" + adr_id = _escape(check.adr_id) + severity = _escape(check.severity) + message = _escape(check.message) + adr_title = _escape(check.adr_title or "") + level = _escape(check.level.value) + pattern = _escape(check.pattern) + + return f'''\ +def {func_name}(files): + """From {adr_id}: required structure '{check.pattern}'""" + import glob as glob_mod + matches = list(glob_mod.glob("{pattern}")) + if not matches: + return [{{ + "file": "{pattern}", + "line": None, + "adr_id": "{adr_id}", + "adr_title": "{adr_title}", + "message": "{message}", + "severity": "{severity}", + "level": "{level}", + "fix_suggestion": "Create the required path: {pattern} — see {adr_id}", + }}] + return []''' + + def _generate_runner_script(self, output_dir: Path) -> Path: + """Generate validate_all.py that runs all individual scripts and aggregates.""" + output_dir.mkdir(parents=True, exist_ok=True) + runner_path = output_dir / "validate_all.py" + + runner = '''\ +#!/usr/bin/env python3 +"""Run all ADR validation scripts and aggregate results. + +Auto-generated by ADR Kit. Regenerate: adr-kit generate-scripts +""" + +import argparse +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description="Run all ADR validation scripts") + parser.add_argument("--quick", action="store_true", help="Check staged/changed files only") + parser.add_argument("--full", action="store_true", default=True, help="Check all files (default)") + parser.add_argument("--root", default=".", help="Project root directory") + args = parser.parse_args() + + mode_flag = "--quick" if args.quick else "--full" + script_dir = Path(__file__).parent + + scripts = sorted(script_dir.glob("validate_adr_*.py")) + + total_files = 0 + total_checks = 0 + total_errors = 0 + total_warnings = 0 + all_violations = [] + all_errors = [] + passed = True + + for script in scripts: + try: + result = subprocess.run( + [sys.executable, str(script), mode_flag, "--root", args.root], + capture_output=True, text=True, + ) + if result.stdout.strip(): + report = json.loads(result.stdout) + total_files = max(total_files, report["summary"]["files_checked"]) + total_checks += report["summary"]["checks_run"] + total_errors += report["summary"]["error_count"] + total_warnings += report["summary"]["warning_count"] + all_violations.extend(report.get("violations", [])) + all_errors.extend(report.get("errors", [])) + if not report["passed"]: + passed = False + elif result.returncode != 0: + all_errors.append(f"Script {script.name} failed: {result.stderr.strip()}") + passed = False + except json.JSONDecodeError: + all_errors.append(f"Script {script.name} produced invalid JSON") + except Exception as e: + all_errors.append(f"Script {script.name} error: {e}") + + merged = { + "schema_version": "1.0", + "level": "ci", + "timestamp": datetime.now(timezone.utc).isoformat(), + "passed": passed, + "summary": { + "files_checked": total_files, + "checks_run": total_checks, + "error_count": total_errors, + "warning_count": total_warnings, + }, + "violations": all_violations, + "errors": all_errors, + } + + print(json.dumps(merged, indent=2)) + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main() +''' + + runner_path.write_text(runner, encoding="utf-8") + runner_path.chmod( + runner_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH + ) + + return runner_path + + # --- Helpers --- + + def _load_accepted_adrs(self) -> list[ADR]: + adrs: list[ADR] = [] + if not self.adr_dir.exists(): + return adrs + for file_path in find_adr_files(self.adr_dir): + try: + adr = parse_adr_file(file_path, strict=False) + if adr and adr.front_matter.status == ADRStatus.ACCEPTED: + adrs.append(adr) + except ParseError: + continue + return adrs + + +def _escape(value: str) -> str: + """Escape a string for safe embedding in generated Python code.""" + return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/adr_kit/enforcement/reporter.py b/adr_kit/enforcement/reporter.py new file mode 100644 index 0000000..18a752d --- /dev/null +++ b/adr_kit/enforcement/reporter.py @@ -0,0 +1,91 @@ +"""AI-readable enforcement report generation. + +Converts ValidationResult into structured JSON that agents and CI pipelines +can consume for automated violation handling and self-correction. + +Output schema: + - schema_version: report format version (currently "1.0") + - level: enforcement level that was run + - passed: whether the check passed (no error-severity violations) + - summary: counts of files, checks, errors, warnings + - violations: list of structured violations with fix suggestions + - errors: any errors encountered during validation +""" + +from datetime import datetime, timezone + +from pydantic import BaseModel + +from .validation.staged import ValidationResult + + +class ViolationEntry(BaseModel): + """Single violation in an enforcement report.""" + + file: str + line: int | None = None + adr_id: str + adr_title: str | None = None + message: str + severity: str + level: str + fix_suggestion: str | None = None + + +class ReportSummary(BaseModel): + """Aggregate counts for an enforcement run.""" + + files_checked: int + checks_run: int + error_count: int + warning_count: int + + +class EnforcementReport(BaseModel): + """AI-readable enforcement report — JSON output for agents and CI.""" + + schema_version: str = "1.0" + level: str + timestamp: str + passed: bool + summary: ReportSummary + violations: list[ViolationEntry] + errors: list[str] + + +def build_report(result: ValidationResult) -> EnforcementReport: + """Convert a ValidationResult to a serializable EnforcementReport. + + Args: + result: ValidationResult from StagedValidator.validate(). + + Returns: + EnforcementReport ready for JSON serialization. + """ + violations = [ + ViolationEntry( + file=v.file, + line=v.line, + adr_id=v.adr_id, + adr_title=v.adr_title, + message=v.message, + severity=v.severity, + level=v.level.value, + fix_suggestion=v.fix_suggestion, + ) + for v in result.violations + ] + + return EnforcementReport( + level=result.level.value, + timestamp=datetime.now(timezone.utc).isoformat(), + passed=result.passed, + summary=ReportSummary( + files_checked=result.files_checked, + checks_run=result.checks_run, + error_count=result.error_count, + warning_count=result.warning_count, + ), + violations=violations, + errors=result.errors, + ) diff --git a/adr_kit/enforcement/validation/__init__.py b/adr_kit/enforcement/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adr_kit/enforcement/validation/staged.py b/adr_kit/enforcement/validation/staged.py new file mode 100644 index 0000000..4de619b --- /dev/null +++ b/adr_kit/enforcement/validation/staged.py @@ -0,0 +1,414 @@ +"""Staged validation runner. + +Executes ADR policy checks against files based on enforcement level: +- commit: staged files only (git diff --cached) — fast grep, <5s +- push: changed files (git diff @{upstream}..HEAD) — broader, <15s +- ci: all project files — comprehensive safety net, <2min + +Architecture and config checks are classified but not yet executed +(reserved for ENF task). They appear in the check count but produce +no violations today — this is intentional and documented. +""" + +import fnmatch +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +from ...core.model import ADR, ADRStatus +from ...core.parse import ParseError, find_adr_files, parse_adr_file +from .stages import EnforcementLevel, StagedCheck, checks_for_level, classify_adr_checks + +# Source file extensions scanned during CI full-codebase pass +_SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".kt"} + +# Directories never scanned — generated/installed content +_EXCLUDE_DIRS = { + ".git", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "dist", + "build", + ".adr-kit", + ".project-index", +} + + +@dataclass +class Violation: + """A single policy violation found during validation.""" + + file: str + adr_id: str + message: str + level: EnforcementLevel + severity: str = "error" + line: int | None = None + adr_title: str | None = None + fix_suggestion: str | None = None + + +@dataclass +class ValidationResult: + """Result of a staged validation run.""" + + level: EnforcementLevel + files_checked: int + checks_run: int + violations: list[Violation] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + @property + def passed(self) -> bool: + """True when no error-severity violations exist.""" + return not any(v.severity == "error" for v in self.violations) + + @property + def has_warnings(self) -> bool: + return any(v.severity == "warning" for v in self.violations) + + @property + def error_count(self) -> int: + return sum(1 for v in self.violations if v.severity == "error") + + @property + def warning_count(self) -> int: + return sum(1 for v in self.violations if v.severity == "warning") + + +class StagedValidator: + """Runs ADR policy checks classified by enforcement level.""" + + def __init__(self, adr_dir: str | Path = "docs/adr"): + self.adr_dir = Path(adr_dir) + + def validate( + self, + level: EnforcementLevel, + project_root: Path | None = None, + ) -> ValidationResult: + """Run all checks active at the given level. + + Args: + level: Enforcement level to run (commit/push/ci). + project_root: Root directory for file resolution. Defaults to cwd. + + Returns: + ValidationResult with all violations and metadata. + """ + project_root = project_root or Path.cwd() + + adrs = self._load_accepted_adrs() + all_checks = classify_adr_checks(adrs) + active_checks = checks_for_level(all_checks, level) + files = self._get_files(level, project_root) + + result = ValidationResult( + level=level, + files_checked=len(files), + checks_run=len(active_checks), + ) + + for check in active_checks: + violations = self._run_check(check, files, project_root) + result.violations.extend(violations) + + return result + + # --- ADR loading --- + + def _load_accepted_adrs(self) -> list[ADR]: + adrs: list[ADR] = [] + if not self.adr_dir.exists(): + return adrs + for file_path in find_adr_files(self.adr_dir): + try: + adr = parse_adr_file(file_path, strict=False) + if adr and adr.front_matter.status == ADRStatus.ACCEPTED: + adrs.append(adr) + except ParseError: + continue + return adrs + + # --- File collection --- + + def _get_files(self, level: EnforcementLevel, project_root: Path) -> list[Path]: + if level == EnforcementLevel.COMMIT: + return self._get_staged_files(project_root) + elif level == EnforcementLevel.PUSH: + files = self._get_changed_files(project_root) + # Fall back to staged if no upstream info available + return files or self._get_staged_files(project_root) + else: # CI + return self._get_all_files(project_root) + + def _get_staged_files(self, project_root: Path) -> list[Path]: + try: + result = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], + capture_output=True, + text=True, + cwd=project_root, + ) + if result.returncode != 0: + return [] + files = [project_root / f for f in result.stdout.strip().splitlines() if f] + return [f for f in files if f.exists()] + except Exception: + return [] + + def _get_changed_files(self, project_root: Path) -> list[Path]: + """Files changed since last push. Falls back gracefully if no upstream.""" + for cmd in [ + ["git", "diff", "--name-only", "@{upstream}..HEAD"], + ["git", "diff", "--name-only", "HEAD~1..HEAD"], + ]: + try: + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=project_root + ) + if result.returncode == 0 and result.stdout.strip(): + files = [ + project_root / f + for f in result.stdout.strip().splitlines() + if f + ] + return [f for f in files if f.exists()] + except Exception: + continue + return [] + + def _get_all_files(self, project_root: Path) -> list[Path]: + files = [] + for f in project_root.rglob("*"): + if not f.is_file(): + continue + if f.suffix not in _SOURCE_EXTENSIONS: + continue + # Skip excluded directories + if any(part in _EXCLUDE_DIRS for part in f.parts): + continue + files.append(f) + return files + + # --- Check dispatch --- + + def _run_check( + self, check: StagedCheck, files: list[Path], project_root: Path + ) -> list[Violation]: + if check.check_type in ("import", "python_import"): + return self._run_import_check(check, files, project_root) + elif check.check_type == "pattern": + return self._run_pattern_check(check, files, project_root) + elif check.check_type == "required_structure": + return self._run_structure_check(check, project_root) + elif check.check_type == "architecture": + return self._run_architecture_check(check, files, project_root) + # config: classified but not yet executed + return [] + + def _filter_files_for_check( + self, files: list[Path], check: StagedCheck + ) -> list[Path]: + """Filter file list to those relevant for the check type.""" + if check.check_type == "python_import": + return [f for f in files if f.suffix == ".py"] + if check.check_type == "import": + return [f for f in files if f.suffix in {".js", ".ts", ".jsx", ".tsx"}] + if check.file_glob and check.file_glob.startswith("*."): + ext = check.file_glob[1:] # "*.py" → ".py" + return [f for f in files if f.name.endswith(ext)] + return files + + def _run_import_check( + self, check: StagedCheck, files: list[Path], project_root: Path + ) -> list[Violation]: + target_files = self._filter_files_for_check(files, check) + violations = [] + escaped = re.escape(check.pattern) + + # Matches: import 'lib', from 'lib', require('lib') — with or without path prefix + import_patterns = [ + re.compile(rf"""(import|from)\s+['"]([^'"]*?/)?{escaped}['"]"""), + re.compile(rf"""require\s*\(\s*['"]([^'"]*?/)?{escaped}['"]\s*\)"""), + re.compile(rf"""(import|from)\s+{escaped}(\s|$|;)"""), # Python style + ] + + for file_path in target_files: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + for line_num, line in enumerate(content.splitlines(), 1): + for pattern in import_patterns: + if pattern.search(line): + violations.append( + Violation( + file=str(file_path.relative_to(project_root)), + adr_id=check.adr_id, + message=check.message, + level=check.level, + severity=check.severity, + line=line_num, + adr_title=check.adr_title, + fix_suggestion=f"Remove or replace this import — see {check.adr_id}", + ) + ) + break # one violation per line + except Exception: + continue + + return violations + + def _run_pattern_check( + self, check: StagedCheck, files: list[Path], project_root: Path + ) -> list[Violation]: + target_files = self._filter_files_for_check(files, check) + violations = [] + + try: + compiled = re.compile(check.pattern) + except re.error: + return [] # invalid regex in ADR policy — skip silently + + for file_path in target_files: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + for line_num, line in enumerate(content.splitlines(), 1): + if compiled.search(line): + violations.append( + Violation( + file=str(file_path.relative_to(project_root)), + adr_id=check.adr_id, + message=check.message, + level=check.level, + severity=check.severity, + line=line_num, + adr_title=check.adr_title, + ) + ) + except Exception: + continue + + return violations + + def _run_structure_check( + self, check: StagedCheck, project_root: Path + ) -> list[Violation]: + """Check that a required path (glob pattern) exists in the project.""" + import glob + + matches = list(glob.glob(check.pattern, root_dir=str(project_root))) + if not matches: + return [ + Violation( + file=check.pattern, + adr_id=check.adr_id, + message=check.message, + level=check.level, + severity=check.severity, + adr_title=check.adr_title, + fix_suggestion=f"Create the required path: {check.pattern} — see {check.adr_id}", + ) + ] + return [] + + def _run_architecture_check( + self, check: StagedCheck, files: list[Path], project_root: Path + ) -> list[Violation]: + """Check that source-layer files don't import from the target layer. + + Parses the rule string "source -> target" from check.pattern. + Uses metadata["check"] glob to identify source-layer files. + Scans those files for imports referencing the target layer. + """ + # Parse "source -> target" from the rule string + parts = check.pattern.split("->") + if len(parts) != 2: + return [] # malformed rule — degrade gracefully + source_layer = parts[0].strip().lower() + target_layer = parts[1].strip().lower() + if not source_layer or not target_layer: + return [] + + # Filter files to source layer + check_glob = check.metadata.get("check") + source_files = self._filter_architecture_files( + files, project_root, check_glob, source_layer + ) + + # Build regex patterns for imports containing target layer + escaped = re.escape(target_layer) + target_patterns = [ + # Python: from target_layer or from target_layer.sub + re.compile(rf"(from|import)\s+{escaped}(\.\w+)*(\s|$|;)", re.IGNORECASE), + # JS/TS: from '...target_layer...' or require('...target_layer...') + re.compile( + rf"""(import|from)\s+['"]([^'"]*[/\\])?{escaped}([/\\][^'"]*)?['"]""", + re.IGNORECASE, + ), + re.compile( + rf"""require\s*\(\s*['"]([^'"]*[/\\])?{escaped}([/\\][^'"]*)?['"]\s*\)""", + re.IGNORECASE, + ), + ] + + violations = [] + for file_path in source_files: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + rel_path = str(file_path.relative_to(project_root)) + for line_num, line in enumerate(content.splitlines(), 1): + for pattern in target_patterns: + if pattern.search(line): + violations.append( + Violation( + file=rel_path, + adr_id=check.adr_id, + message=check.message, + level=check.level, + severity=check.severity, + line=line_num, + adr_title=check.adr_title, + fix_suggestion=( + f"Move this import out of {source_layer} code, " + f"or refactor to avoid direct {target_layer} " + f"dependency — see {check.adr_id}" + ), + ) + ) + break # one violation per line + except Exception: + continue + + return violations + + def _filter_architecture_files( + self, + files: list[Path], + project_root: Path, + check_glob: str | None, + source_layer: str, + ) -> list[Path]: + """Filter files to those belonging to the source layer. + + Uses check_glob if provided, otherwise falls back to matching + source_layer as a directory segment in the file path. + """ + if check_glob: + return [ + f + for f in files + if fnmatch.fnmatch(str(f.relative_to(project_root)), check_glob) + ] + + # Fallback: match source_layer as a directory segment + return [ + f + for f in files + if source_layer in [p.lower() for p in f.relative_to(project_root).parts] + ] diff --git a/adr_kit/enforcement/validation/stages.py b/adr_kit/enforcement/validation/stages.py new file mode 100644 index 0000000..ebdf797 --- /dev/null +++ b/adr_kit/enforcement/validation/stages.py @@ -0,0 +1,188 @@ +"""Enforcement stage classification model. + +Maps ADR policy types to workflow stages (commit/push/ci) based on: +- Speed: how fast the check runs +- Scope: what files and context it needs + +Stage semantics: +- commit (<5s): staged files only, fast grep — first checkpoint +- push (<15s): changed files, broader context +- ci (<2min): full codebase, all checks — safety net + +A check assigned to level X also runs at all higher levels +(commit checks run at push and ci too). +""" + +from dataclasses import dataclass, field +from enum import Enum + + +class EnforcementLevel(str, Enum): + """Workflow stage at which enforcement checks run.""" + + COMMIT = "commit" + PUSH = "push" + CI = "ci" + + +# Ordered levels for inclusion logic (lower index = earlier stage) +_LEVEL_ORDER: dict[EnforcementLevel, int] = { + EnforcementLevel.COMMIT: 0, + EnforcementLevel.PUSH: 1, + EnforcementLevel.CI: 2, +} + +# Policy type → minimum enforcement level +# A policy type at level X also runs at all higher levels. +POLICY_LEVEL_MAP: dict[str, EnforcementLevel] = { + "imports": EnforcementLevel.COMMIT, # fast grep — always first + "python": EnforcementLevel.COMMIT, # fast grep — always first + "patterns": EnforcementLevel.COMMIT, # fast regex — always first + "architecture": EnforcementLevel.PUSH, # needs broader file context + "required_structure": EnforcementLevel.CI, # full codebase check + "config_enforcement": EnforcementLevel.CI, # config deep check +} + + +@dataclass +class StagedCheck: + """A single enforceable check classified to an enforcement level.""" + + adr_id: str + adr_title: str + check_type: str # "import" | "python_import" | "pattern" | "architecture" | "required_structure" | "config" + level: EnforcementLevel + pattern: str # what to grep/check for + message: str # human-readable violation message + file_glob: str | None = None # file extension filter + severity: str = "error" + metadata: dict = field(default_factory=dict) # extra context for complex checks + + +def classify_adr_checks(adrs: list) -> list[StagedCheck]: + """Extract and classify all enforceable checks from a list of accepted ADRs. + + Returns one StagedCheck per enforceable rule across all policy types. + Architecture and config checks are classified but not yet executed + (reserved for ENF task — reported here for transparency). + """ + checks: list[StagedCheck] = [] + + for adr in adrs: + if not adr.policy: + continue + + policy = adr.policy + adr_id = adr.id + adr_title = adr.title + + # imports: disallowed JS/TS imports — COMMIT level + if policy.imports and policy.imports.disallow: + for lib in policy.imports.disallow: + checks.append( + StagedCheck( + adr_id=adr_id, + adr_title=adr_title, + check_type="import", + level=EnforcementLevel.COMMIT, + pattern=lib, + message=f"Import of '{lib}' is disallowed — see {adr_id}: {adr_title}", + ) + ) + + # python: disallowed Python imports — COMMIT level + if policy.python and policy.python.disallow_imports: + for lib in policy.python.disallow_imports: + checks.append( + StagedCheck( + adr_id=adr_id, + adr_title=adr_title, + check_type="python_import", + level=EnforcementLevel.COMMIT, + pattern=lib, + message=f"Python import of '{lib}' is disallowed — see {adr_id}: {adr_title}", + file_glob="*.py", + ) + ) + + # patterns: regex code pattern rules — COMMIT level (fast grep) + if policy.patterns and policy.patterns.patterns: + for name, rule in policy.patterns.patterns.items(): + if isinstance(rule.rule, str): # only handle regex patterns + checks.append( + StagedCheck( + adr_id=adr_id, + adr_title=adr_title, + check_type="pattern", + level=EnforcementLevel.COMMIT, + pattern=rule.rule, + message=f"Pattern '{name}': {rule.description} — see {adr_id}", + file_glob=f"*.{rule.language}" if rule.language else None, + severity=rule.severity, + ) + ) + + # architecture: layer boundaries — PUSH level + if policy.architecture and policy.architecture.layer_boundaries: + for boundary in policy.architecture.layer_boundaries: + checks.append( + StagedCheck( + adr_id=adr_id, + adr_title=adr_title, + check_type="architecture", + level=EnforcementLevel.PUSH, + pattern=boundary.rule, + message=boundary.message + or f"Architecture violation: {boundary.rule} — see {adr_id}", + severity="error" if boundary.action == "block" else "warning", + metadata={"rule": boundary.rule, "check": boundary.check}, + ) + ) + + # required_structure: file/dir existence — CI level + if policy.architecture and policy.architecture.required_structure: + for required in policy.architecture.required_structure: + checks.append( + StagedCheck( + adr_id=adr_id, + adr_title=adr_title, + check_type="required_structure", + level=EnforcementLevel.CI, + pattern=required.path, + message=required.description + or f"Required path missing: {required.path} — see {adr_id}", + ) + ) + + # config_enforcement — CI level + if policy.config_enforcement: + checks.append( + StagedCheck( + adr_id=adr_id, + adr_title=adr_title, + check_type="config", + level=EnforcementLevel.CI, + pattern="config_check", + message=f"Configuration requirements from {adr_id}: {adr_title}", + metadata={ + "policy": policy.config_enforcement.model_dump( + exclude_none=True + ) + }, + ) + ) + + return checks + + +def checks_for_level( + checks: list[StagedCheck], level: EnforcementLevel +) -> list[StagedCheck]: + """Return checks that should run at the given level (inclusive of lower levels). + + commit → runs commit checks only + push → runs commit + push checks + ci → runs all checks + """ + target_order = _LEVEL_ORDER[level] + return [c for c in checks if _LEVEL_ORDER[c.level] <= target_order] diff --git a/adr_kit/gate/__init__.py b/adr_kit/gate/__init__.py index 7c9380a..7cf9cf5 100644 --- a/adr_kit/gate/__init__.py +++ b/adr_kit/gate/__init__.py @@ -1,20 +1,14 @@ -"""Preflight policy gate system for ADR-Kit. +"""Gate package — shim for backward compatibility.""" -The gate system provides proactive architectural control by intercepting major -technical choices BEFORE they're implemented. This ensures agents pause for -human approval when architectural decisions are needed. - -Key components: -- PolicyGate: Main gate engine that evaluates technical choices -- TechnicalChoice: Models for representing decisions that need evaluation -- GateDecision: Result types (ALLOWED, REQUIRES_ADR, BLOCKED, CONFLICT) -- PolicyEngine: Rule evaluation engine with allow/deny lists and defaults -""" - -from .models import CategoryRule, GateConfig, GateDecision, NameMapping -from .policy_engine import PolicyConfig, PolicyEngine -from .policy_gate import GateResult, PolicyGate -from .technical_choice import ( +from adr_kit.decision.gate.models import ( + CategoryRule, + GateConfig, + GateDecision, + NameMapping, +) +from adr_kit.decision.gate.policy_engine import PolicyConfig, PolicyEngine +from adr_kit.decision.gate.policy_gate import GateResult, PolicyGate +from adr_kit.decision.gate.technical_choice import ( ChoiceType, DependencyChoice, FrameworkChoice, diff --git a/adr_kit/gate/models.py b/adr_kit/gate/models.py index 74d1816..2b1d982 100644 --- a/adr_kit/gate/models.py +++ b/adr_kit/gate/models.py @@ -1,204 +1 @@ -"""Data models for the preflight policy gate system.""" - -from enum import Enum - -from pydantic import BaseModel, Field - - -class GateDecision(str, Enum): - """Possible outcomes from the preflight policy gate.""" - - ALLOWED = "allowed" # Choice is explicitly allowed, proceed - REQUIRES_ADR = "requires_adr" # Choice needs human approval via ADR - BLOCKED = "blocked" # Choice is explicitly denied - CONFLICT = "conflict" # Choice conflicts with existing ADRs - - -class CategoryRule(BaseModel): - """Rule for categorizing technical choices.""" - - category: str = Field( - ..., description="Category name (e.g., 'runtime_dependency', 'framework')" - ) - patterns: list[str] = Field(..., description="Regex patterns to match choice names") - keywords: list[str] = Field( - default_factory=list, description="Keywords that indicate this category" - ) - examples: list[str] = Field( - default_factory=list, description="Example choices in this category" - ) - - -class NameMapping(BaseModel): - """Mapping for normalizing choice names and aliases.""" - - canonical_name: str = Field(..., description="The canonical/preferred name") - aliases: list[str] = Field( - ..., description="Alternative names that map to the canonical name" - ) - - -class GateConfig(BaseModel): - """Configuration for the preflight policy gate.""" - - # Default policies - default_dependency_policy: GateDecision = Field( - GateDecision.REQUIRES_ADR, - description="Default action for new runtime dependencies", - ) - default_framework_policy: GateDecision = Field( - GateDecision.REQUIRES_ADR, - description="Default action for new frameworks/libraries", - ) - default_tool_policy: GateDecision = Field( - GateDecision.ALLOWED, description="Default action for development tools" - ) - - # Allow/deny lists - always_allow: list[str] = Field( - default_factory=list, description="Choices that are always allowed without ADR" - ) - always_deny: list[str] = Field( - default_factory=list, description="Choices that are always blocked" - ) - development_tools: list[str] = Field( - default_factory=lambda: [ - "eslint", - "prettier", - "jest", - "vitest", - "webpack", - "vite", - "typescript", - "babel", - "rollup", - "esbuild", - "parcel", - "pytest", - "black", - "mypy", - "ruff", - "pre-commit", - "husky", - "lint-staged", - "nodemon", - "concurrently", - ], - description="Development tools that typically don't need ADRs", - ) - - # Categorization rules - categories: list[CategoryRule] = Field( - default_factory=lambda: [ - CategoryRule( - category="runtime_dependency", - patterns=[r"^[^@].*", r"^@[^/]+/[^/]+$"], # Regular packages - keywords=["runtime", "production", "dependency"], - examples=["react", "express", "fastapi", "requests"], - ), - CategoryRule( - category="framework", - patterns=[r".*framework.*", r".*-cli$", r"create-.*"], - keywords=["framework", "cli", "generator", "boilerplate"], - examples=["next.js", "vue-cli", "create-react-app", "django"], - ), - CategoryRule( - category="build_tool", - patterns=[r".*webpack.*", r".*vite.*", r".*rollup.*"], - keywords=["build", "bundler", "compiler"], - examples=["webpack", "vite", "rollup", "esbuild"], - ), - ] - ) - - # Name mappings for normalization - name_mappings: list[NameMapping] = Field( - default_factory=lambda: [ - NameMapping(canonical_name="react", aliases=["reactjs", "react.js"]), - NameMapping(canonical_name="vue", aliases=["vuejs", "vue.js"]), - NameMapping( - canonical_name="@tanstack/react-query", - aliases=["react-query", "tanstack-query"], - ), - NameMapping(canonical_name="fastapi", aliases=["fast-api", "FastAPI"]), - NameMapping(canonical_name="requests", aliases=["python-requests"]), - NameMapping(canonical_name="axios", aliases=["axios-http"]), - ] - ) - - # Metadata - version: str = Field("1.0", description="Config version") - created_by: str = Field("adr-kit", description="Who created this config") - - def normalize_name(self, choice_name: str) -> str: - """Normalize a choice name using the name mappings.""" - choice_name = choice_name.lower().strip() - - for mapping in self.name_mappings: - if choice_name == mapping.canonical_name.lower(): - return mapping.canonical_name - if choice_name in [alias.lower() for alias in mapping.aliases]: - return mapping.canonical_name - - return choice_name - - def categorize_choice(self, choice_name: str) -> str | None: - """Determine the category of a technical choice.""" - import re - - normalized_name = self.normalize_name(choice_name) - - # Check if it's a development tool - if normalized_name in [tool.lower() for tool in self.development_tools]: - return "development_tool" - - # Check against category patterns - for category in self.categories: - # Check patterns - for pattern in category.patterns: - if re.match(pattern, normalized_name, re.IGNORECASE): - return category.category - - # Check keywords (in choice name) - for keyword in category.keywords: - if keyword.lower() in normalized_name.lower(): - return category.category - - # Default categorization based on common patterns - if any( - dev_tool in normalized_name - for dev_tool in ["test", "lint", "format", "build"] - ): - return "development_tool" - - # Default to runtime dependency if unclear - return "runtime_dependency" - - def to_file(self, file_path: str) -> None: - """Save configuration to a JSON file.""" - import json - - with open(file_path, "w", encoding="utf-8") as f: - json.dump(self.model_dump(exclude_none=True), f, indent=2, sort_keys=True) - - @classmethod - def from_file(cls, file_path: str) -> "GateConfig": - """Load configuration from a JSON file.""" - import json - from pathlib import Path - - path = Path(file_path) - if not path.exists(): - # Return default config if file doesn't exist - mypy needs explicit defaults - return cls( - default_dependency_policy=GateDecision.REQUIRES_ADR, - default_framework_policy=GateDecision.REQUIRES_ADR, - default_tool_policy=GateDecision.ALLOWED, - version="1.0", - created_by="adr-kit", - ) - - with open(path, encoding="utf-8") as f: - data = json.load(f) - - return cls.model_validate(data) +from adr_kit.decision.gate.models import * # noqa: F401,F403 diff --git a/adr_kit/gate/policy_engine.py b/adr_kit/gate/policy_engine.py index 31234f8..37f3fce 100644 --- a/adr_kit/gate/policy_engine.py +++ b/adr_kit/gate/policy_engine.py @@ -1,272 +1 @@ -"""Policy engine for evaluating technical choices against gate rules.""" - -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from ..contract import ConstraintsContractBuilder -from .models import GateConfig, GateDecision -from .technical_choice import ChoiceType, TechnicalChoice - - -@dataclass -class PolicyConfig: - """Configuration for the policy engine.""" - - adr_dir: Path - gate_config_path: Path | None = None - - def __post_init__(self) -> None: - if self.gate_config_path is None: - self.gate_config_path = self.adr_dir / ".adr" / "policy.json" - - -class PolicyEngine: - """Engine for evaluating technical choices against policy rules.""" - - def __init__(self, config: PolicyConfig): - self.config = config - self.gate_config = self._load_gate_config() - self.contract_builder = ConstraintsContractBuilder(config.adr_dir) - - def _load_gate_config(self) -> GateConfig: - """Load gate configuration from file or create default.""" - if self.config.gate_config_path and self.config.gate_config_path.exists(): - return GateConfig.from_file(str(self.config.gate_config_path)) - else: - # Create default config and save it - explicit defaults for mypy - default_config = GateConfig( - default_dependency_policy=GateDecision.REQUIRES_ADR, - default_framework_policy=GateDecision.REQUIRES_ADR, - default_tool_policy=GateDecision.ALLOWED, - version="1.0", - created_by="adr-kit", - ) - self._save_gate_config(default_config) - return default_config - - def _save_gate_config(self, config: GateConfig) -> None: - """Save gate configuration to file.""" - if self.config.gate_config_path: - # Ensure directory exists - self.config.gate_config_path.parent.mkdir(parents=True, exist_ok=True) - config.to_file(str(self.config.gate_config_path)) - - def evaluate_choice( - self, choice: TechnicalChoice - ) -> tuple[GateDecision, str, dict[str, Any]]: - """Evaluate a technical choice and return decision with reasoning. - - Returns: - Tuple of (decision, reasoning, metadata) - """ - # Normalize the choice name - normalized_name = self.gate_config.normalize_name(choice.name) - category = self.gate_config.categorize_choice(normalized_name) - - # Check explicit allow list first - if normalized_name in [name.lower() for name in self.gate_config.always_allow]: - return ( - GateDecision.ALLOWED, - f"'{choice.name}' is in the always-allow list", - {"category": category, "normalized_name": normalized_name}, - ) - - # Check explicit deny list - if normalized_name in [name.lower() for name in self.gate_config.always_deny]: - return ( - GateDecision.BLOCKED, - f"'{choice.name}' is in the always-deny list", - {"category": category, "normalized_name": normalized_name}, - ) - - # Check against existing constraints contract - conflict_reason = self._check_contract_conflicts(choice, normalized_name) - if conflict_reason: - return ( - GateDecision.CONFLICT, - conflict_reason, - {"category": category, "normalized_name": normalized_name}, - ) - - # Apply default policies based on category and choice type - decision = self._apply_default_policy( - choice, category or "general", normalized_name - ) - reasoning = self._get_default_policy_reasoning( - choice, category or "general", decision - ) - - return ( - decision, - reasoning, - {"category": category, "normalized_name": normalized_name}, - ) - - def _check_contract_conflicts( - self, choice: TechnicalChoice, normalized_name: str - ) -> str | None: - """Check if choice conflicts with existing constraints contract.""" - try: - # Get current constraints contract - contract = self.contract_builder.build_contract() - - # Check import constraints - if contract.constraints.imports: - # Check if choice is disallowed - if contract.constraints.imports.disallow: - for disallowed in contract.constraints.imports.disallow: - if normalized_name.lower() == disallowed.lower(): - # Find which ADR disallows it - for rule_path, provenance in contract.provenance.items(): - if rule_path == f"imports.disallow.{disallowed}": - return f"'{choice.name}' is disallowed by {provenance.adr_id}: {provenance.adr_title}" - - return ( - f"'{choice.name}' is disallowed by existing ADR policy" - ) - - # Check if there's a preferred alternative - if contract.constraints.imports.prefer: - # For dependency choices, suggest preferred alternatives - if choice.choice_type in [ - ChoiceType.DEPENDENCY, - ChoiceType.FRAMEWORK, - ]: - for preferred in contract.constraints.imports.prefer: - # If this choice serves similar purpose as a preferred one, suggest conflict - if self._are_similar_choices(normalized_name, preferred): - # Find which ADR prefers the alternative - for ( - rule_path, - provenance, - ) in contract.provenance.items(): - if rule_path == f"imports.prefer.{preferred}": - return f"'{choice.name}' conflicts with preferred choice '{preferred}' from {provenance.adr_id}: {provenance.adr_title}" - - return None - - except Exception: - # If we can't load the contract, don't block the choice - return None - - def _are_similar_choices(self, choice1: str, choice2: str) -> bool: - """Heuristic to determine if two choices serve similar purposes.""" - # Simple heuristic based on common library categories - similar_groups = [ - ["axios", "fetch", "request", "http-client"], - ["lodash", "underscore", "ramda", "remeda"], - ["moment", "dayjs", "date-fns"], - ["jest", "vitest", "mocha", "jasmine"], - ["webpack", "vite", "rollup", "esbuild", "parcel"], - ["react", "vue", "angular", "svelte"], - ["express", "koa", "fastify", "hapi"], - ["django", "flask", "fastapi"], - ] - - choice1_lower = choice1.lower() - choice2_lower = choice2.lower() - - for group in similar_groups: - if choice1_lower in group and choice2_lower in group: - return True - - return False - - def _apply_default_policy( - self, choice: TechnicalChoice, category: str, normalized_name: str - ) -> GateDecision: - """Apply default policy based on choice category.""" - - # Development tools are typically allowed by default - if category == "development_tool": - return GateDecision.ALLOWED - - # Apply policies based on choice type - if choice.choice_type == ChoiceType.DEPENDENCY: - if hasattr(choice, "is_dev_dependency") and choice.is_dev_dependency: - return self.gate_config.default_tool_policy - else: - return self.gate_config.default_dependency_policy - - elif choice.choice_type == ChoiceType.FRAMEWORK: - return self.gate_config.default_framework_policy - - elif choice.choice_type == ChoiceType.TOOL: - return self.gate_config.default_tool_policy - - else: - # For other types (architecture, language, database, etc.) - # Default to requiring ADR for major decisions - return GateDecision.REQUIRES_ADR - - def _get_default_policy_reasoning( - self, choice: TechnicalChoice, category: str, decision: GateDecision - ) -> str: - """Get human-readable reasoning for the default policy decision.""" - - if decision == GateDecision.ALLOWED: - if category == "development_tool": - return f"'{choice.name}' is categorized as a development tool and is allowed by default policy" - else: - return f"'{choice.name}' is allowed by default policy for {category}" - - elif decision == GateDecision.REQUIRES_ADR: - if choice.choice_type == ChoiceType.DEPENDENCY: - return f"New runtime dependency '{choice.name}' requires ADR approval (default policy)" - elif choice.choice_type == ChoiceType.FRAMEWORK: - return f"New framework '{choice.name}' requires ADR approval (default policy)" - else: - return f"'{choice.name}' ({choice.choice_type.value}) requires ADR approval (default policy)" - - elif decision == GateDecision.BLOCKED: - return f"'{choice.name}' is blocked by default policy" - - else: - return f"Default policy decision: {decision.value}" - - def get_config_summary(self) -> dict[str, Any]: - """Get summary of current gate configuration.""" - return { - "config_file": str(self.config.gate_config_path), - "config_exists": ( - self.config.gate_config_path.exists() - if self.config.gate_config_path - else False - ), - "default_policies": { - "dependency": self.gate_config.default_dependency_policy.value, - "framework": self.gate_config.default_framework_policy.value, - "tool": self.gate_config.default_tool_policy.value, - }, - "always_allow": self.gate_config.always_allow, - "always_deny": self.gate_config.always_deny, - "development_tools": len(self.gate_config.development_tools), - "categories": len(self.gate_config.categories), - "name_mappings": len(self.gate_config.name_mappings), - } - - def add_to_allow_list(self, choice_name: str) -> None: - """Add a choice to the always-allow list.""" - normalized = self.gate_config.normalize_name(choice_name) - if normalized not in self.gate_config.always_allow: - self.gate_config.always_allow.append(normalized) - self._save_gate_config(self.gate_config) - - def add_to_deny_list(self, choice_name: str) -> None: - """Add a choice to the always-deny list.""" - normalized = self.gate_config.normalize_name(choice_name) - if normalized not in self.gate_config.always_deny: - self.gate_config.always_deny.append(normalized) - self._save_gate_config(self.gate_config) - - def update_default_policy(self, choice_type: str, decision: GateDecision) -> None: - """Update default policy for a choice type.""" - if choice_type == "dependency": - self.gate_config.default_dependency_policy = decision - elif choice_type == "framework": - self.gate_config.default_framework_policy = decision - elif choice_type == "tool": - self.gate_config.default_tool_policy = decision - - self._save_gate_config(self.gate_config) +from adr_kit.decision.gate.policy_engine import * # noqa: F401,F403 diff --git a/adr_kit/gate/policy_gate.py b/adr_kit/gate/policy_gate.py index 7305109..7942ba8 100644 --- a/adr_kit/gate/policy_gate.py +++ b/adr_kit/gate/policy_gate.py @@ -1,348 +1 @@ -"""Main policy gate for intercepting and evaluating technical choices.""" - -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from .models import GateDecision -from .policy_engine import PolicyConfig, PolicyEngine -from .technical_choice import TechnicalChoice, create_technical_choice - - -@dataclass -class GateResult: - """Result from evaluating a technical choice through the policy gate.""" - - choice: TechnicalChoice - decision: GateDecision - reasoning: str - metadata: dict[str, Any] - evaluated_at: datetime - - @property - def should_proceed(self) -> bool: - """Whether the agent should proceed with the choice.""" - return self.decision == GateDecision.ALLOWED - - @property - def requires_human_approval(self) -> bool: - """Whether this choice requires human approval via ADR.""" - return self.decision == GateDecision.REQUIRES_ADR - - @property - def is_blocked(self) -> bool: - """Whether this choice is blocked and should not proceed.""" - return self.decision in [GateDecision.BLOCKED, GateDecision.CONFLICT] - - def get_agent_guidance(self) -> str: - """Get guidance message for the agent based on the gate result.""" - if self.decision == GateDecision.ALLOWED: - return f"✅ Approved: {self.reasoning}. You may proceed with implementing '{self.choice.name}'." - - elif self.decision == GateDecision.REQUIRES_ADR: - return ( - f"🛑 ADR Required: {self.reasoning}. Please draft an ADR for '{self.choice.name}' " - f"and request human approval before proceeding with implementation." - ) - - elif self.decision == GateDecision.BLOCKED: - return ( - f"❌ Blocked: {self.reasoning}. Do not implement '{self.choice.name}'." - ) - - elif self.decision == GateDecision.CONFLICT: - return ( - f"⚠️ Conflict: {self.reasoning}. Consider using the recommended alternative " - f"or updating existing ADRs if '{self.choice.name}' is truly needed." - ) - - else: - return f"Unknown gate decision: {self.decision.value}" - - def to_dict(self) -> dict[str, Any]: - """Convert result to dictionary for serialization.""" - return { - "choice": { - "type": self.choice.choice_type.value, - "name": self.choice.name, - "context": self.choice.context, - "alternatives_considered": self.choice.alternatives_considered, - }, - "decision": self.decision.value, - "reasoning": self.reasoning, - "metadata": self.metadata, - "evaluated_at": self.evaluated_at.isoformat(), - "should_proceed": self.should_proceed, - "requires_human_approval": self.requires_human_approval, - "is_blocked": self.is_blocked, - "agent_guidance": self.get_agent_guidance(), - } - - -class PolicyGate: - """Main policy gate for intercepting and evaluating technical choices. - - The PolicyGate is the primary interface for agents to check whether - a technical choice (dependency, framework, etc.) should proceed or - requires human approval via an ADR. - """ - - def __init__(self, adr_dir: Path, gate_config_path: Path | None = None): - self.adr_dir = Path(adr_dir) - self.config = PolicyConfig( - adr_dir=self.adr_dir, gate_config_path=gate_config_path - ) - self.engine = PolicyEngine(self.config) - - def evaluate(self, choice: TechnicalChoice) -> GateResult: - """Evaluate a technical choice through the policy gate. - - Args: - choice: The technical choice to evaluate - - Returns: - GateResult with decision and guidance - """ - decision, reasoning, metadata = self.engine.evaluate_choice(choice) - - return GateResult( - choice=choice, - decision=decision, - reasoning=reasoning, - metadata=metadata, - evaluated_at=datetime.now(timezone.utc), - ) - - def evaluate_dependency( - self, - package_name: str, - context: str, - ecosystem: str = "npm", - version_constraint: str | None = None, - is_dev_dependency: bool = False, - alternatives_considered: list[str] | None = None, - **kwargs: Any, - ) -> GateResult: - """Convenience method for evaluating dependency choices. - - Args: - package_name: Name of the package/dependency - context: Why this dependency is needed - ecosystem: Package ecosystem (npm, pypi, gem, etc.) - version_constraint: Version constraint if any - is_dev_dependency: Whether this is a dev dependency - alternatives_considered: Other options considered - - Returns: - GateResult with decision and guidance - """ - choice = create_technical_choice( - choice_type="dependency", - name=package_name, - context=context, - package_name=package_name, - ecosystem=ecosystem, - version_constraint=version_constraint, - is_dev_dependency=is_dev_dependency, - alternatives_considered=alternatives_considered or [], - **kwargs, - ) - - return self.evaluate(choice) - - def evaluate_framework( - self, - framework_name: str, - context: str, - use_case: str, - architectural_impact: str = "To be determined", - current_solution: str | None = None, - migration_required: bool = False, - alternatives_considered: list[str] | None = None, - **kwargs: Any, - ) -> GateResult: - """Convenience method for evaluating framework choices. - - Args: - framework_name: Name of the framework - context: Why this framework is needed - use_case: What the framework will be used for - architectural_impact: How it affects the architecture - current_solution: What it replaces (if any) - migration_required: Whether migration is needed - alternatives_considered: Other options considered - - Returns: - GateResult with decision and guidance - """ - choice = create_technical_choice( - choice_type="framework", - name=framework_name, - context=context, - framework_name=framework_name, - use_case=use_case, - architectural_impact=architectural_impact, - current_solution=current_solution, - migration_required=migration_required, - alternatives_considered=alternatives_considered or [], - **kwargs, - ) - - return self.evaluate(choice) - - def evaluate_from_text( - self, description: str, choice_hints: dict[str, Any] | None = None - ) -> GateResult: - """Evaluate a technical choice from text description. - - This method attempts to parse a natural language description - of a technical choice and evaluate it through the gate. - - Args: - description: Natural language description of the choice - choice_hints: Optional hints about the choice type and details - - Returns: - GateResult with decision and guidance - """ - # Parse the description to extract choice details - parsed_choice = self._parse_choice_description(description, choice_hints or {}) - - return self.evaluate(parsed_choice) - - def _parse_choice_description( - self, description: str, hints: dict[str, Any] - ) -> TechnicalChoice: - """Parse a natural language description into a TechnicalChoice. - - This is a simple heuristic parser. In a production system, - you might use more sophisticated NLP or LLM-based parsing. - """ - import re - - description_lower = description.lower() - - # Detect choice type from description - if any( - word in description_lower - for word in ["install", "add", "use package", "import", "require"] - ): - choice_type = "dependency" - elif any( - word in description_lower - for word in ["framework", "library", "adopt", "switch to"] - ): - choice_type = "framework" - else: - choice_type = hints.get("choice_type", "other") - - # Extract package/framework names (simple heuristic) - # Look for quoted names, npm packages, or known patterns - quoted_names = re.findall(r"['\"]([^'\"]+)['\"]", description) - npm_packages = re.findall( - r"@?[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+|[a-zA-Z0-9-_]+", description - ) - - # Take the first reasonable match - name = None - if quoted_names: - name = quoted_names[0] - elif npm_packages: - # Filter out common words - common_words = { - "use", - "add", - "install", - "with", - "for", - "from", - "to", - "the", - "a", - "an", - } - for package in npm_packages: - if package.lower() not in common_words and len(package) > 2: - name = package - break - - if not name: - name = hints.get("name", "unknown") - - # Create the choice - return create_technical_choice( - choice_type=choice_type, name=name, context=description, **hints - ) - - def get_gate_status(self) -> dict[str, Any]: - """Get current status of the policy gate.""" - config_summary = self.engine.get_config_summary() - - return { - "gate_ready": True, - "adr_directory": str(self.adr_dir), - "config": config_summary, - "message": "Policy gate is ready to evaluate technical choices", - } - - def get_recommendations_for_choice(self, choice_name: str) -> dict[str, Any]: - """Get recommendations for a specific choice based on existing constraints.""" - try: - contract = self.engine.contract_builder.build_contract() - - recommendations: dict[str, Any] = { - "choice_name": choice_name, - "normalized_name": self.engine.gate_config.normalize_name(choice_name), - "category": self.engine.gate_config.categorize_choice(choice_name), - "alternatives": [], - "conflicts": [], - "relevant_adrs": [], - } - - # Check for preferred alternatives - if contract.constraints.imports and contract.constraints.imports.prefer: - for preferred in contract.constraints.imports.prefer: - if self.engine._are_similar_choices(choice_name, preferred): - recommendations["alternatives"].append( - { - "name": preferred, - "reason": "Preferred by existing ADR policy", - "source_adr": self._find_source_adr( - contract, f"imports.prefer.{preferred}" - ), - } - ) - - # Check for conflicts - if contract.constraints.imports and contract.constraints.imports.disallow: - for disallowed in contract.constraints.imports.disallow: - if choice_name.lower() == disallowed.lower(): - recommendations["conflicts"].append( - { - "name": disallowed, - "reason": "Disallowed by existing ADR policy", - "source_adr": self._find_source_adr( - contract, f"imports.disallow.{disallowed}" - ), - } - ) - - # Add source ADRs - recommendations["relevant_adrs"] = contract.metadata.source_adrs - - return recommendations - - except Exception as e: - return { - "choice_name": choice_name, - "error": str(e), - "message": "Unable to get recommendations", - } - - def _find_source_adr(self, contract: Any, rule_path: str) -> str | None: - """Find the source ADR for a specific rule path.""" - for path, provenance in contract.provenance.items(): - if path == rule_path: - return str(provenance.adr_id) - return None +from adr_kit.decision.gate.policy_gate import * # noqa: F401,F403 diff --git a/adr_kit/gate/technical_choice.py b/adr_kit/gate/technical_choice.py index 4d593af..4c42d60 100644 --- a/adr_kit/gate/technical_choice.py +++ b/adr_kit/gate/technical_choice.py @@ -1,206 +1 @@ -"""Models for representing technical choices that need gate evaluation.""" - -from enum import Enum -from typing import Any - -from pydantic import BaseModel, Field - - -class ChoiceType(str, Enum): - """Types of technical choices that can be evaluated by the gate.""" - - DEPENDENCY = "dependency" # Adding a new runtime dependency - FRAMEWORK = "framework" # Adopting a new framework or major library - TOOL = "tool" # Development/build tool - ARCHITECTURE = "architecture" # Architectural pattern or approach - LANGUAGE = "language" # Programming language choice - DATABASE = "database" # Database technology - CLOUD_SERVICE = "cloud_service" # Cloud service or provider - API_DESIGN = "api_design" # API design approach - OTHER = "other" # Other technical choices - - -class TechnicalChoice(BaseModel): - """Base class for representing a technical choice that needs evaluation.""" - - choice_type: ChoiceType = Field(..., description="Type of technical choice") - name: str = Field( - ..., description="Name of the choice (e.g., 'react', 'postgresql')" - ) - context: str = Field(..., description="Context or reason for the choice") - alternatives_considered: list[str] = Field( - default_factory=list, description="Other options that were considered" - ) - metadata: dict[str, Any] = Field( - default_factory=dict, description="Additional metadata about the choice" - ) - - def get_canonical_name(self) -> str: - """Get the canonical name for this choice.""" - # This will be overridden in subclasses for specific normalization - return self.name.lower().strip() - - def get_search_terms(self) -> list[str]: - """Get terms for searching existing ADRs.""" - terms = [self.name, self.get_canonical_name()] - terms.extend(self.alternatives_considered) - - # Add context keywords - import re - - context_words = re.findall(r"\b\w+\b", self.context.lower()) - terms.extend([word for word in context_words if len(word) > 3]) - - return list(set(terms)) # Remove duplicates - - def to_search_description(self) -> str: - """Create a description suitable for searching related ADRs.""" - return f"{self.choice_type.value} choice: {self.name} - {self.context}" - - -class DependencyChoice(TechnicalChoice): - """A choice about adding a runtime dependency.""" - - choice_type: ChoiceType = Field( - default=ChoiceType.DEPENDENCY, description="Always dependency" - ) - package_name: str = Field(..., description="Package/library name") - version_constraint: str | None = Field( - None, description="Version constraint (e.g., '^1.0.0')" - ) - ecosystem: str = Field(..., description="Package ecosystem (npm, pypi, gem, etc.)") - is_dev_dependency: bool = Field( - False, description="Whether this is a development dependency" - ) - replaces: list[str] | None = Field(None, description="Dependencies this replaces") - - def get_canonical_name(self) -> str: - """Get canonical package name with ecosystem normalization.""" - # Handle scoped packages - if self.package_name.startswith("@"): - return self.package_name.lower() - - # Handle ecosystem-specific normalization - if self.ecosystem == "pypi": - # Python package names are case-insensitive and use hyphens/underscores interchangeably - return self.package_name.lower().replace("_", "-") - - return self.package_name.lower() - - def get_search_terms(self) -> list[str]: - """Get dependency-specific search terms.""" - terms = super().get_search_terms() - - # Add package-specific terms - terms.append(self.package_name) - terms.append(self.get_canonical_name()) - - if self.replaces: - terms.extend(self.replaces) - - # Add ecosystem terms - terms.append(self.ecosystem) - - return list(set(terms)) - - -class FrameworkChoice(TechnicalChoice): - """A choice about adopting a framework or major architectural library.""" - - choice_type: ChoiceType = Field( - default=ChoiceType.FRAMEWORK, description="Always framework" - ) - framework_name: str = Field(..., description="Framework name") - use_case: str = Field(..., description="What this framework will be used for") - architectural_impact: str = Field( - ..., description="How this affects the overall architecture" - ) - migration_required: bool = Field( - False, description="Whether migration from existing solution is needed" - ) - current_solution: str | None = Field( - None, description="What this framework replaces" - ) - - def get_canonical_name(self) -> str: - """Get canonical framework name.""" - # Common framework name mappings - mappings = { - "reactjs": "react", - "react.js": "react", - "vuejs": "vue", - "vue.js": "vue", - "angular.js": "angularjs", - "next.js": "nextjs", - "fast-api": "fastapi", - "fastapi": "fastapi", - } - - normalized = self.framework_name.lower().strip() - return mappings.get(normalized, normalized) - - def get_search_terms(self) -> list[str]: - """Get framework-specific search terms.""" - terms = super().get_search_terms() - - # Add framework-specific terms - terms.append(self.framework_name) - terms.append(self.get_canonical_name()) - - if self.current_solution: - terms.append(self.current_solution) - - # Add use case terms - import re - - use_case_words = re.findall(r"\b\w+\b", self.use_case.lower()) - terms.extend([word for word in use_case_words if len(word) > 3]) - - return list(set(terms)) - - -# Factory function for creating technical choices -def create_technical_choice( - choice_type: str | ChoiceType, name: str, context: str, **kwargs: Any -) -> TechnicalChoice: - """Factory function to create the appropriate TechnicalChoice subclass.""" - - if isinstance(choice_type, str): - choice_type = ChoiceType(choice_type.lower()) - - if choice_type == ChoiceType.DEPENDENCY: - # Extract dependency-specific info from kwargs - return DependencyChoice( - name=name, - package_name=kwargs.get("package_name", name), - context=context, - ecosystem=kwargs.get("ecosystem", "npm"), # Default to npm - version_constraint=kwargs.get("version_constraint"), - is_dev_dependency=kwargs.get("is_dev_dependency", False), - replaces=kwargs.get("replaces"), - alternatives_considered=kwargs.get("alternatives_considered", []), - metadata=kwargs.get("metadata", {}), - ) - - elif choice_type == ChoiceType.FRAMEWORK: - return FrameworkChoice( - name=name, - framework_name=kwargs.get("framework_name", name), - context=context, - use_case=kwargs.get("use_case", context), - architectural_impact=kwargs.get("architectural_impact", "To be determined"), - migration_required=kwargs.get("migration_required", False), - current_solution=kwargs.get("current_solution"), - alternatives_considered=kwargs.get("alternatives_considered", []), - metadata=kwargs.get("metadata", {}), - ) - - else: - # Generic technical choice - return TechnicalChoice( - choice_type=choice_type, - name=name, - context=context, - alternatives_considered=kwargs.get("alternatives_considered", []), - metadata=kwargs.get("metadata", {}), - ) +from adr_kit.decision.gate.technical_choice import * # noqa: F401,F403 diff --git a/adr_kit/guard/__init__.py b/adr_kit/guard/__init__.py index 5d82f7a..dfed98e 100644 --- a/adr_kit/guard/__init__.py +++ b/adr_kit/guard/__init__.py @@ -1,9 +1,9 @@ -"""Guard system for ADR policy enforcement in code changes. +"""Guard package — shim for backward compatibility.""" -This package provides semantic-aware policy violation detection for code diffs, -integrating with the ADR semantic retrieval system for context-aware enforcement. -""" - -from .detector import CodeAnalysisResult, GuardSystem, PolicyViolation +from adr_kit.enforcement.detection.detector import ( + CodeAnalysisResult, + GuardSystem, + PolicyViolation, +) __all__ = ["GuardSystem", "PolicyViolation", "CodeAnalysisResult"] diff --git a/adr_kit/guard/detector.py b/adr_kit/guard/detector.py index d073a5b..22ae663 100644 --- a/adr_kit/guard/detector.py +++ b/adr_kit/guard/detector.py @@ -1,556 +1 @@ -"""Semantic-aware policy violation detection for code changes. - -Design decisions: -- Use semantic retrieval to find relevant ADRs for code changes -- Parse git diffs to extract file changes and imports -- Match policy violations using both pattern matching and semantic similarity -- Provide actionable guidance with specific ADR references -- Support multiple languages (Python, JavaScript, TypeScript, etc.) -""" - -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from ..core.model import ADR -from ..core.parse import ParseError, find_adr_files, parse_adr_file -from ..core.policy_extractor import PolicyExtractor -from ..semantic.retriever import SemanticIndex, SemanticMatch - - -@dataclass -class PolicyViolation: - """Represents a policy violation detected in code changes.""" - - violation_type: ( - str # 'import_disallowed', 'import_not_preferred', 'boundary_violated' - ) - severity: str # 'error', 'warning', 'info' - message: str # Human-readable description - file_path: str # File where violation occurred - line_number: int | None = None # Line number if applicable - adr_id: str | None = None # ADR that defines the violated policy - adr_title: str | None = None # Title of the relevant ADR - suggested_fix: str | None = None # Suggested resolution - context: str | None = None # Additional context - - -@dataclass -class CodeAnalysisResult: - """Result of analyzing code changes for policy violations.""" - - violations: list[PolicyViolation] - analyzed_files: list[str] - relevant_adrs: list[SemanticMatch] - summary: str - - @property - def has_errors(self) -> bool: - """Check if any error-level violations were found.""" - return any(v.severity == "error" for v in self.violations) - - @property - def has_warnings(self) -> bool: - """Check if any warning-level violations were found.""" - return any(v.severity == "warning" for v in self.violations) - - -class DiffParser: - """Parse git diffs to extract meaningful code changes.""" - - def __init__(self) -> None: - self.import_patterns = { - "python": [ - r"^import\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", - r"^from\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+import", - ], - "javascript": [ - r'import\s+.*?\s+from\s+[\'"]([^\'"]+)[\'"]', - r'require\([\'"]([^\'"]+)[\'"]\)', - ], - "typescript": [ - r'import\s+.*?\s+from\s+[\'"]([^\'"]+)[\'"]', - r'require\([\'"]([^\'"]+)[\'"]\)', - ], - } - - def parse_diff(self, diff_text: str) -> dict[str, list[str]]: - """Parse a git diff and extract added imports per file. - - Args: - diff_text: Raw git diff output - - Returns: - Dictionary mapping file paths to lists of added imports - """ - file_changes: dict[str, list[str]] = {} - current_file: str | None = None - - lines = diff_text.split("\n") - for line in lines: - # Track which file we're in - if line.startswith("diff --git"): - # Extract file path from "diff --git a/path/file.py b/path/file.py" - parts = line.split() - if len(parts) >= 4: - current_file = parts[3][2:] # Remove "b/" prefix - file_changes[current_file] = [] - - # Look for added lines (starting with +) - elif line.startswith("+") and not line.startswith("+++"): - if current_file: - added_line = line[1:] # Remove + prefix - imports = self._extract_imports_from_line(added_line, current_file) - file_changes[current_file].extend(imports) - - return file_changes - - def _extract_imports_from_line(self, line: str, file_path: str) -> list[str]: - """Extract import statements from a single line of code.""" - line = line.strip() - if not line: - return [] - - # Determine language from file extension - file_ext = Path(file_path).suffix.lower() - language = self._get_language_from_extension(file_ext) - - imports = [] - patterns = self.import_patterns.get(language, []) - - for pattern in patterns: - matches = re.findall(pattern, line) - imports.extend(matches) - - return imports - - def _get_language_from_extension(self, ext: str) -> str: - """Map file extension to language.""" - ext_map = { - ".py": "python", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "typescript", - ".tsx": "typescript", - } - return ext_map.get(ext, "unknown") - - -class SemanticPolicyMatcher: - """Match code changes to relevant ADRs using semantic similarity.""" - - def __init__(self, semantic_index: SemanticIndex): - self.semantic_index = semantic_index - - def find_relevant_adrs( - self, file_changes: dict[str, list[str]], context_lines: list[str] | None = None - ) -> list[SemanticMatch]: - """Find ADRs that are semantically relevant to the code changes. - - Args: - file_changes: Dictionary of file paths to imported modules - context_lines: Additional context lines from the diff - - Returns: - List of relevant ADR matches - """ - # Build query from file paths, imports, and context - query_parts: list[str] = [] - - # Add file path context - for file_path in file_changes.keys(): - path_parts = Path(file_path).parts - query_parts.extend(path_parts) - - # Add import context - for imports in file_changes.values(): - query_parts.extend(imports) - - # Add code context if available - if context_lines: - for line in context_lines[:5]: # Limit to avoid noise - clean_line = re.sub(r"[^\w\s]", " ", line).strip() - if clean_line and len(clean_line) > 3: - query_parts.append(clean_line) - - # Create semantic query - query = " ".join(query_parts) - - # Search for relevant ADRs (accepted ones are most relevant) - matches = self.semantic_index.search( - query=query, - k=10, - filter_status={"accepted", "proposed"}, # Focus on active ADRs - ) - - return matches - - -class GuardSystem: - """Main guard system for detecting ADR policy violations in code changes.""" - - def __init__(self, project_root: Path | None = None, adr_dir: str = "docs/adr"): - """Initialize guard system with semantic index and policy extractor. - - Args: - project_root: Project root directory - adr_dir: Directory containing ADR files - """ - self.project_root = project_root or Path.cwd() - self.adr_dir = adr_dir - - # Initialize components - self.semantic_index = SemanticIndex(project_root) - self.policy_extractor = PolicyExtractor() - self.diff_parser = DiffParser() - self.semantic_matcher = SemanticPolicyMatcher(self.semantic_index) - - # Load ADR policies cache - self._policy_cache: dict[str, Any] = {} - self._load_adr_policies() - - def _load_adr_policies(self) -> None: - """Load and cache policies from all ADRs.""" - print("🔍 Loading ADR policies for guard system...") - - adr_files = find_adr_files(Path(self.adr_dir)) - for file_path in adr_files: - try: - adr = parse_adr_file(file_path, strict=False) - if not adr: - continue - - # Extract policy using hybrid approach - policy = self.policy_extractor.extract_policy(adr) - if policy: - self._policy_cache[adr.front_matter.id] = { - "adr": adr, - "policy": policy, - } - - except ParseError: - continue - - print(f"✅ Loaded {len(self._policy_cache)} ADRs with policies") - - def analyze_diff( - self, diff_text: str, build_index: bool = True - ) -> CodeAnalysisResult: - """Analyze a git diff for policy violations. - - Args: - diff_text: Raw git diff output - build_index: Whether to rebuild semantic index before analysis - - Returns: - CodeAnalysisResult with violations and recommendations - """ - print("🛡️ Analyzing code changes for policy violations...") - - # Build semantic index if requested - if build_index: - print("📊 Building semantic index...") - self.semantic_index.build_index(self.adr_dir) - - # Parse diff to extract file changes - file_changes = self.diff_parser.parse_diff(diff_text) - - if not file_changes: - return CodeAnalysisResult( - violations=[], - analyzed_files=[], - relevant_adrs=[], - summary="No code changes detected in diff", - ) - - print(f"📁 Analyzing changes in {len(file_changes)} files") - - # Find semantically relevant ADRs - relevant_adrs = self.semantic_matcher.find_relevant_adrs(file_changes) - - # Check for policy violations - violations = [] - for file_path, imports in file_changes.items(): - # Check violations against ALL ADRs with policies, not just semantically relevant ones - all_adrs_for_violation_check = [] - for adr_id, policy_info in self._policy_cache.items(): - # Create a mock SemanticMatch for all ADRs with policies - all_adrs_for_violation_check.append( - type( - "MockMatch", - (), - { - "adr_id": adr_id, - "title": policy_info["adr"].front_matter.title, - "score": 1.0, # Full score since we're checking all - }, - )() - ) - - file_violations = self._check_file_violations( - file_path, imports, all_adrs_for_violation_check - ) - violations.extend(file_violations) - - # Generate summary - summary = self._generate_summary(violations, file_changes, relevant_adrs) - - return CodeAnalysisResult( - violations=violations, - analyzed_files=list(file_changes.keys()), - relevant_adrs=relevant_adrs, - summary=summary, - ) - - def _check_file_violations( - self, file_path: str, imports: list[str], relevant_adrs: list[SemanticMatch] - ) -> list[PolicyViolation]: - """Check a single file for policy violations.""" - violations = [] - - # Get file language for targeted policy checking - file_ext = Path(file_path).suffix.lower() - language = self._get_language_from_extension(file_ext) - - # Check each relevant ADR for violations - for adr_match in relevant_adrs[:5]: # Check top 5 most relevant - if adr_match.adr_id not in self._policy_cache: - continue - - policy_info = self._policy_cache[adr_match.adr_id] - adr = policy_info["adr"] - policy = policy_info["policy"] - - # Check import violations - violations.extend( - self._check_import_violations(file_path, imports, adr, policy, language) - ) - - # Check boundary violations (simplified for now) - violations.extend( - self._check_boundary_violations(file_path, imports, adr, policy) - ) - - return violations - - def _check_import_violations( - self, file_path: str, imports: list[str], adr: ADR, policy: Any, language: str - ) -> list[PolicyViolation]: - """Check for import policy violations.""" - violations = [] - - # Check disallowed imports - disallowed_imports = [] - if policy.imports and policy.imports.disallow: - disallowed_imports.extend(policy.imports.disallow) - if language == "python" and policy.python and policy.python.disallow_imports: - disallowed_imports.extend(policy.python.disallow_imports) - - for import_name in imports: - # Check against disallowed list - for disallowed in disallowed_imports: - if self._import_matches_pattern(import_name, disallowed): - violations.append( - PolicyViolation( - violation_type="import_disallowed", - severity="error", - message=f"Import '{import_name}' is disallowed by ADR {adr.front_matter.id}", - file_path=file_path, - adr_id=adr.front_matter.id, - adr_title=adr.front_matter.title, - suggested_fix=self._suggest_import_alternative( - import_name, policy - ), - context=f"Disallowed pattern: {disallowed}", - ) - ) - - # Check preferred imports (warning if not used) - if policy.imports and policy.imports.prefer: - for import_name in imports: - # Check if there's a preferred alternative - preferred_alternative = self._find_preferred_alternative( - import_name, policy.imports.prefer - ) - if preferred_alternative: - violations.append( - PolicyViolation( - violation_type="import_not_preferred", - severity="warning", - message=f"Consider using '{preferred_alternative}' instead of '{import_name}' (ADR {adr.front_matter.id})", - file_path=file_path, - adr_id=adr.front_matter.id, - adr_title=adr.front_matter.title, - suggested_fix=f"Replace with: {preferred_alternative}", - context="Preferred by ADR policy", - ) - ) - - return violations - - def _check_boundary_violations( - self, file_path: str, imports: list[str], adr: ADR, policy: Any - ) -> list[PolicyViolation]: - """Check for architectural boundary violations.""" - violations: list[PolicyViolation] = [] - - if not policy.boundaries or not policy.boundaries.rules: - return violations - - # Simplified boundary checking - can be expanded - for rule in policy.boundaries.rules: - if "cross-layer" in rule.forbid.lower() or "layer" in rule.forbid.lower(): - # Check for layer violations based on file path - file_layer = self._determine_file_layer(file_path) - for import_name in imports: - import_layer = self._determine_import_layer(import_name) - if ( - file_layer - and import_layer - and self._violates_layer_rule(file_layer, import_layer) - ): - violations.append( - PolicyViolation( - violation_type="boundary_violated", - severity="error", - message=f"Cross-layer import violation: {file_layer} → {import_layer} (ADR {adr.front_matter.id})", - file_path=file_path, - adr_id=adr.front_matter.id, - adr_title=adr.front_matter.title, - context=f"Rule: {rule.forbid}", - ) - ) - - return violations - - def _import_matches_pattern(self, import_name: str, pattern: str) -> bool: - """Check if import matches a disallow pattern.""" - # Support glob-like patterns - if "*" in pattern: - # Convert glob to regex - regex_pattern = pattern.replace("*", ".*") - return re.match(regex_pattern, import_name) is not None - else: - # Exact match or prefix match - return import_name == pattern or import_name.startswith(pattern + ".") - - def _suggest_import_alternative(self, import_name: str, policy: Any) -> str | None: - """Suggest an alternative import based on policy preferences.""" - if policy.imports and policy.imports.prefer: - # Find a preferred import that might replace this one - for preferred in policy.imports.prefer: - if self._are_similar_imports(import_name, preferred): - return str(preferred) - return None - - def _find_preferred_alternative( - self, import_name: str, preferred_imports: list[str] - ) -> str | None: - """Find if there's a preferred alternative to the current import.""" - for preferred in preferred_imports: - if self._are_similar_imports(import_name, preferred): - return preferred - return None - - def _are_similar_imports(self, import1: str, import2: str) -> bool: - """Check if two imports are functionally similar.""" - # Simplified similarity check - can be made more sophisticated - common_alternatives = { - "lodash": ["ramda", "underscore"], - "moment": ["dayjs", "date-fns"], - "axios": ["fetch", "node-fetch"], - "jquery": ["vanilla-js", "native-dom"], - } - - for base, alternatives in common_alternatives.items(): - if import1.startswith(base) and any( - import2.startswith(alt) for alt in alternatives - ): - return True - if import2.startswith(base) and any( - import1.startswith(alt) for alt in alternatives - ): - return True - - return False - - def _determine_file_layer(self, file_path: str) -> str | None: - """Determine architectural layer from file path.""" - path_parts = file_path.lower().split("/") - - layer_indicators = { - "controller": ["controller", "api", "route"], - "service": ["service", "business", "logic"], - "repository": ["repository", "data", "model", "db"], - "view": ["view", "template", "component", "ui"], - } - - for layer, indicators in layer_indicators.items(): - if any(indicator in path_parts for indicator in indicators): - return layer - - return None - - def _determine_import_layer(self, import_name: str) -> str | None: - """Determine architectural layer from import name.""" - import_lower = import_name.lower() - - if any(x in import_lower for x in ["express", "fastapi", "flask"]): - return "controller" - elif any(x in import_lower for x in ["mongoose", "sqlalchemy", "prisma"]): - return "repository" - elif any(x in import_lower for x in ["react", "vue", "angular"]): - return "view" - - return None - - def _violates_layer_rule(self, from_layer: str, to_layer: str) -> bool: - """Check if import between layers violates architecture rules.""" - # Simplified layer rule: views shouldn't import repositories directly - if from_layer == "view" and to_layer == "repository": - return True - - return False - - def _get_language_from_extension(self, ext: str) -> str: - """Map file extension to language.""" - ext_map = { - ".py": "python", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "typescript", - ".tsx": "typescript", - } - return ext_map.get(ext, "unknown") - - def _generate_summary( - self, - violations: list[PolicyViolation], - file_changes: dict[str, list[str]], - relevant_adrs: list[SemanticMatch], - ) -> str: - """Generate human-readable summary of the analysis.""" - if not violations: - return f"✅ No policy violations found in {len(file_changes)} files" - - error_count = sum(1 for v in violations if v.severity == "error") - warning_count = sum(1 for v in violations if v.severity == "warning") - - summary_parts = ["🛡️ Policy analysis complete:"] - - if error_count > 0: - summary_parts.append( - f"❌ {error_count} error{'s' if error_count != 1 else ''}" - ) - - if warning_count > 0: - summary_parts.append( - f"⚠️ {warning_count} warning{'s' if warning_count != 1 else ''}" - ) - - if relevant_adrs: - adr_ids = [adr.adr_id for adr in relevant_adrs[:3]] - summary_parts.append(f"📋 Relevant ADRs: {', '.join(adr_ids)}") - - return " | ".join(summary_parts) +from adr_kit.enforcement.detection.detector import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/__init__.py b/adr_kit/guardrail/__init__.py index cfcb24c..db06a68 100644 --- a/adr_kit/guardrail/__init__.py +++ b/adr_kit/guardrail/__init__.py @@ -1,20 +1,19 @@ -"""Automatic Guardrail Management System. +"""Guardrail package — shim for backward compatibility.""" -This module implements automatic application of configuration fragments -when ADR policies change, providing the "Guardrail Manager" component -from the architectural vision. -""" - -from .config_writer import ConfigFragment, ConfigWriter, SentinelBlock -from .file_monitor import ChangeEvent, ChangeType, FileMonitor -from .manager import GuardrailManager -from .models import ( +from adr_kit.enforcement.config.manager import GuardrailManager +from adr_kit.enforcement.config.models import ( ApplyResult, ConfigTemplate, FragmentTarget, FragmentType, GuardrailConfig, ) +from adr_kit.enforcement.config.monitor import ChangeEvent, ChangeType, FileMonitor +from adr_kit.enforcement.config.writer import ( + ConfigFragment, + ConfigWriter, + SentinelBlock, +) __all__ = [ "GuardrailManager", diff --git a/adr_kit/guardrail/config_writer.py b/adr_kit/guardrail/config_writer.py index c524c86..7b80b05 100644 --- a/adr_kit/guardrail/config_writer.py +++ b/adr_kit/guardrail/config_writer.py @@ -1,265 +1 @@ -"""Configuration file writer with sentinel block management.""" - -import json -import re -import shutil -from datetime import datetime -from pathlib import Path -from typing import Any - -import toml - -from .models import ( - ApplicationStatus, - ApplyResult, - ConfigFragment, - FragmentTarget, - FragmentType, - SentinelBlock, -) - - -class ConfigWriter: - """Writes configuration fragments to target files with sentinel block management.""" - - def __init__(self, backup_enabled: bool = True, backup_dir: Path | None = None): - self.backup_enabled = backup_enabled - self.backup_dir = backup_dir or Path(".adr-kit/backups") - - def apply_fragments( - self, target: FragmentTarget, fragments: list[ConfigFragment] - ) -> ApplyResult: - """Apply configuration fragments to a target file.""" - - result = ApplyResult( - target=target, - status=ApplicationStatus.SUCCESS, - message="Fragments applied successfully", - ) - - try: - # Ensure target file exists - if not target.file_path.exists(): - result.status = ApplicationStatus.FAILED - result.message = f"Target file does not exist: {target.file_path}" - return result - - # Create backup if enabled - if self.backup_enabled: - backup_path = self._create_backup(target.file_path) - result.backup_created = backup_path - - # Read current content - original_content = target.file_path.read_text(encoding="utf-8") - - # Apply fragments based on file type - if target.fragment_type in [FragmentType.ESLINT, FragmentType.PRETTIER]: - updated_content = self._apply_json_fragments( - original_content, fragments, target - ) - elif target.fragment_type in [ - FragmentType.RUFF, - FragmentType.MYPY, - FragmentType.IMPORT_LINTER, - ]: - updated_content = self._apply_toml_fragments( - original_content, fragments, target - ) - else: - updated_content = self._apply_text_fragments( - original_content, fragments, target - ) - - # Write updated content - target.file_path.write_text(updated_content, encoding="utf-8") - result.fragments_applied = len(fragments) - - except Exception as e: - result.status = ApplicationStatus.FAILED - result.message = f"Failed to apply fragments: {str(e)}" - result.errors.append(str(e)) - - # Restore from backup if available - if result.backup_created and result.backup_created.exists(): - try: - shutil.copy2(result.backup_created, target.file_path) - result.warnings.append("Restored from backup after failure") - except Exception as restore_error: - result.errors.append(f"Failed to restore backup: {restore_error}") - - return result - - def _create_backup(self, file_path: Path) -> Path: - """Create a backup of the target file.""" - self.backup_dir.mkdir(parents=True, exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_name = f"{file_path.name}_{timestamp}.backup" - backup_path = self.backup_dir / backup_name - - shutil.copy2(file_path, backup_path) - return backup_path - - def _apply_json_fragments( - self, content: str, fragments: list[ConfigFragment], target: FragmentTarget - ) -> str: - """Apply fragments to JSON configuration files (ESLint, Prettier).""" - - try: - config = json.loads(content) - except json.JSONDecodeError: - # If not valid JSON, treat as text - return self._apply_text_fragments(content, fragments, target) - - # Merge fragment content into config - for fragment in fragments: - try: - fragment_config = json.loads(fragment.content) - config = self._merge_json_configs(config, fragment_config) - except json.JSONDecodeError: - # Skip invalid JSON fragments - continue - - return json.dumps(config, indent=2) - - def _apply_toml_fragments( - self, content: str, fragments: list[ConfigFragment], target: FragmentTarget - ) -> str: - """Apply fragments to TOML configuration files (Ruff, Mypy).""" - - if toml is None: - # TOML library not available, fall back to text mode - return self._apply_text_fragments(content, fragments, target) - - try: - config = toml.loads(content) - except ( - Exception - ): # Broad exception handling since different TOML libs have different exceptions - # If not valid TOML, treat as text - return self._apply_text_fragments(content, fragments, target) - - # Merge fragment content into config - for fragment in fragments: - try: - fragment_config = toml.loads(fragment.content) - config = self._merge_dict_configs(config, fragment_config) - except Exception: # Broad exception handling - # Skip invalid TOML fragments - continue - - return toml.dumps(config) - - def _apply_text_fragments( - self, content: str, fragments: list[ConfigFragment], target: FragmentTarget - ) -> str: - """Apply fragments to plain text files using sentinel blocks.""" - - sentinel = SentinelBlock.for_fragment_type(target.fragment_type) - - # Combine all fragment content - fragment_content = "\n".join(fragment.content for fragment in fragments) - - # Create the managed section - managed_section = ( - f"{sentinel.start_marker}\n{fragment_content}\n{sentinel.end_marker}" - ) - - # Find and replace existing managed section - pattern = ( - re.escape(sentinel.start_marker) + r".*?" + re.escape(sentinel.end_marker) - ) - - if re.search(pattern, content, re.DOTALL): - # Replace existing section - updated_content = re.sub(pattern, managed_section, content, flags=re.DOTALL) - else: - # Append new section - updated_content = content.rstrip() + "\n\n" + managed_section + "\n" - - return updated_content - - def _merge_json_configs( - self, base: dict[str, Any], fragment: dict[str, Any] - ) -> dict[str, Any]: - """Merge JSON configuration objects.""" - return self._merge_dict_configs(base, fragment) - - def _merge_dict_configs( - self, base: dict[str, Any], fragment: dict[str, Any] - ) -> dict[str, Any]: - """Deep merge dictionary configurations.""" - - result = base.copy() - - for key, value in fragment.items(): - if key in result: - if isinstance(result[key], dict) and isinstance(value, dict): - result[key] = self._merge_dict_configs(result[key], value) - elif isinstance(result[key], list) and isinstance(value, list): - # Merge lists, removing duplicates while preserving order - seen = set(result[key]) - result[key] = result[key] + [ - item for item in value if item not in seen - ] - else: - # Fragment value overwrites base value - result[key] = value - else: - result[key] = value - - return result - - def remove_managed_sections(self, target: FragmentTarget) -> ApplyResult: - """Remove all managed sections from a target file.""" - - result = ApplyResult( - target=target, - status=ApplicationStatus.SUCCESS, - message="Managed sections removed successfully", - ) - - try: - if not target.file_path.exists(): - result.status = ApplicationStatus.SKIPPED - result.message = "Target file does not exist" - return result - - # Create backup if enabled - if self.backup_enabled: - backup_path = self._create_backup(target.file_path) - result.backup_created = backup_path - - content = target.file_path.read_text(encoding="utf-8") - sentinel = SentinelBlock.for_fragment_type(target.fragment_type) - - # Remove managed section - pattern = ( - re.escape(sentinel.start_marker) - + r".*?" - + re.escape(sentinel.end_marker) - ) - updated_content = re.sub(pattern, "", content, flags=re.DOTALL) - - # Clean up excessive blank lines - updated_content = re.sub(r"\n{3,}", "\n\n", updated_content) - - target.file_path.write_text(updated_content, encoding="utf-8") - - except Exception as e: - result.status = ApplicationStatus.FAILED - result.message = f"Failed to remove managed sections: {str(e)}" - result.errors.append(str(e)) - - return result - - def has_managed_section(self, target: FragmentTarget) -> bool: - """Check if target file has a managed section.""" - - if not target.file_path.exists(): - return False - - content = target.file_path.read_text(encoding="utf-8") - sentinel = SentinelBlock.for_fragment_type(target.fragment_type) - - return sentinel.start_marker in content and sentinel.end_marker in content +from adr_kit.enforcement.config.writer import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/file_monitor.py b/adr_kit/guardrail/file_monitor.py index 0fcccde..c3150e9 100644 --- a/adr_kit/guardrail/file_monitor.py +++ b/adr_kit/guardrail/file_monitor.py @@ -1,221 +1 @@ -"""File monitoring system for detecting ADR changes.""" - -import hashlib -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from pathlib import Path - -from ..core.model import ADR -from ..core.parse import ParseError, find_adr_files, parse_adr_file - - -class ChangeType(str, Enum): - """Types of file changes that can be detected.""" - - CREATED = "created" - MODIFIED = "modified" - DELETED = "deleted" - STATUS_CHANGED = "status_changed" # ADR status changed - POLICY_CHANGED = "policy_changed" # ADR policy changed - - -@dataclass -class ChangeEvent: - """Represents a detected change in an ADR file.""" - - file_path: Path - change_type: ChangeType - adr_id: str | None = None - old_status: str | None = None - new_status: str | None = None - detected_at: datetime | None = None - - def __post_init__(self) -> None: - if self.detected_at is None: - self.detected_at = datetime.now() - - -class FileMonitor: - """Monitors ADR directory for changes and detects policy-relevant events.""" - - def __init__(self, adr_dir: Path): - self.adr_dir = Path(adr_dir) - self._file_hashes: dict[Path, str] = {} - self._adr_statuses: dict[str, str] = {} - self._adr_policies: dict[str, str] = {} - - # Initialize baseline state - self._update_baseline() - - def _update_baseline(self) -> None: - """Update the baseline state of all ADR files.""" - - adr_files = find_adr_files(self.adr_dir) - - for file_path in adr_files: - try: - # Calculate file hash - file_hash = self._calculate_file_hash(file_path) - self._file_hashes[file_path] = file_hash - - # Parse ADR and store status/policy - adr = parse_adr_file(file_path, strict=False) - if adr: - status_value = ( - adr.front_matter.status.value - if hasattr(adr.front_matter.status, "value") - else str(adr.front_matter.status) - ) - self._adr_statuses[adr.id] = status_value - - # Create a simple hash of the policy for change detection - policy_hash = self._calculate_policy_hash(adr) - self._adr_policies[adr.id] = policy_hash - - except (OSError, ParseError): - # Skip files that can't be read or parsed - continue - - def detect_changes(self) -> list[ChangeEvent]: - """Detect changes since last check and update baseline.""" - - changes = [] - current_files = set(find_adr_files(self.adr_dir)) - previous_files = set(self._file_hashes.keys()) - - # Check for deleted files - for deleted_file in previous_files - current_files: - changes.append( - ChangeEvent(file_path=deleted_file, change_type=ChangeType.DELETED) - ) - # Clean up tracking - del self._file_hashes[deleted_file] - - # Check for new and modified files - for file_path in current_files: - current_hash = self._calculate_file_hash(file_path) - - if file_path not in self._file_hashes: - # New file - changes.append( - ChangeEvent(file_path=file_path, change_type=ChangeType.CREATED) - ) - elif self._file_hashes[file_path] != current_hash: - # Modified file - check what changed - try: - adr = parse_adr_file(file_path, strict=False) - if adr: - # Check for status changes - old_status = self._adr_statuses.get(adr.id) - new_status = ( - adr.front_matter.status.value - if hasattr(adr.front_matter.status, "value") - else str(adr.front_matter.status) - ) - - if old_status != new_status: - changes.append( - ChangeEvent( - file_path=file_path, - change_type=ChangeType.STATUS_CHANGED, - adr_id=adr.id, - old_status=old_status, - new_status=new_status, - ) - ) - - # Check for policy changes - old_policy_hash = self._adr_policies.get(adr.id, "") - new_policy_hash = self._calculate_policy_hash(adr) - - if old_policy_hash != new_policy_hash: - changes.append( - ChangeEvent( - file_path=file_path, - change_type=ChangeType.POLICY_CHANGED, - adr_id=adr.id, - ) - ) - - # Update tracking - self._adr_statuses[adr.id] = new_status - self._adr_policies[adr.id] = new_policy_hash - - except (OSError, ParseError): - # If we can't parse, just mark as modified - changes.append( - ChangeEvent( - file_path=file_path, change_type=ChangeType.MODIFIED - ) - ) - - # Update hash tracking - self._file_hashes[file_path] = current_hash - - return changes - - def _calculate_file_hash(self, file_path: Path) -> str: - """Calculate SHA-256 hash of file content.""" - try: - content = file_path.read_bytes() - return hashlib.sha256(content).hexdigest() - except OSError: - return "" - - def _calculate_policy_hash(self, adr: ADR) -> str: - """Calculate a hash representing the ADR's policy content.""" - - policy_parts = [] - - if adr.front_matter.policy: - policy = adr.front_matter.policy - - # Include import policies - if policy.imports: - if policy.imports.disallow: - policy_parts.extend(sorted(policy.imports.disallow)) - if policy.imports.prefer: - policy_parts.extend(sorted(policy.imports.prefer)) - - # Include boundary policies - if policy.boundaries and policy.boundaries.rules: - for rule in policy.boundaries.rules: - policy_parts.append(rule.forbid) - - # Include Python policies - if policy.python and policy.python.disallow_imports: - policy_parts.extend(sorted(policy.python.disallow_imports)) - - # Create hash from sorted policy parts - policy_text = "|".join(sorted(policy_parts)) - return hashlib.sha256(policy_text.encode("utf-8")).hexdigest() - - def get_policy_relevant_changes( - self, changes: list[ChangeEvent] - ) -> list[ChangeEvent]: - """Filter changes to only those that affect policy enforcement.""" - - policy_relevant = [] - - for change in changes: - if change.change_type in [ - ChangeType.STATUS_CHANGED, - ChangeType.POLICY_CHANGED, - ChangeType.CREATED, # New ADRs might have policies - ]: - policy_relevant.append(change) - - # Status changes to/from 'accepted' are always relevant - elif change.change_type == ChangeType.STATUS_CHANGED: - if change.old_status == "accepted" or change.new_status == "accepted": - policy_relevant.append(change) - - return policy_relevant - - def force_refresh(self) -> None: - """Force a complete refresh of the baseline state.""" - self._file_hashes.clear() - self._adr_statuses.clear() - self._adr_policies.clear() - self._update_baseline() +from adr_kit.enforcement.config.monitor import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/manager.py b/adr_kit/guardrail/manager.py index 82660c6..99d0fb9 100644 --- a/adr_kit/guardrail/manager.py +++ b/adr_kit/guardrail/manager.py @@ -1,394 +1 @@ -"""Main Guardrail Manager - orchestrates automatic configuration application.""" - -import json -from pathlib import Path -from typing import Any - -from ..contract import ConstraintsContractBuilder -from .config_writer import ConfigWriter -from .file_monitor import ChangeEvent, ChangeType, FileMonitor -from .models import ( - ApplicationStatus, - ApplyResult, - ConfigFragment, - ConfigTemplate, - FragmentTarget, - FragmentType, - GuardrailConfig, -) - - -class GuardrailManager: - """Main service for automatic guardrail management. - - Orchestrates the application of configuration fragments when - ADR policies change, implementing the "Guardrail Manager" component - from the architectural vision. - """ - - def __init__(self, adr_dir: Path, config: GuardrailConfig | None = None): - self.adr_dir = Path(adr_dir) - self.config = config or self._create_default_config() - - self.contract_builder = ConstraintsContractBuilder(adr_dir) - self.config_writer = ConfigWriter( - backup_enabled=self.config.backup_enabled, backup_dir=self.config.backup_dir - ) - self.file_monitor = FileMonitor(adr_dir) - - self._last_contract_hash = "" - - def _create_default_config(self) -> GuardrailConfig: - """Create default guardrail configuration.""" - - # Default targets for common configuration files - targets = [ - FragmentTarget( - file_path=Path(".eslintrc.adrs.json"), fragment_type=FragmentType.ESLINT - ), - FragmentTarget( - file_path=Path("pyproject.toml"), - fragment_type=FragmentType.RUFF, - section_name="tool.ruff", - ), - FragmentTarget( - file_path=Path(".import-linter.adrs.ini"), - fragment_type=FragmentType.IMPORT_LINTER, - ), - ] - - # Default templates - templates = [ - ConfigTemplate( - fragment_type=FragmentType.ESLINT, - template_content="""{ - "rules": { - "no-restricted-imports": [ - "error", - { - "paths": [ - {disallow_rules} - ] - } - ] - } -}""", - variables={"disallow_rules": "[]"}, - ), - ConfigTemplate( - fragment_type=FragmentType.RUFF, - template_content="""[tool.ruff.flake8-banned-api] -banned-api = [ -{disallow_imports} -]""", - variables={"disallow_imports": ""}, - ), - ] - - return GuardrailConfig(targets=targets, templates=templates) - - def apply_guardrails(self, force: bool = False) -> list[ApplyResult]: - """Apply guardrails based on current ADR policies.""" - - results: list[ApplyResult] = [] - - if not self.config.enabled: - return results - - # Build current constraints contract - try: - contract = self.contract_builder.build_contract(force_rebuild=force) - except Exception as e: - # If contract building fails, skip guardrail application - return [ - ApplyResult( - target=target, - status=ApplicationStatus.FAILED, - message=f"Failed to build constraints contract: {e}", - ) - for target in self.config.targets - ] - - # Check if contract has changed (optimization) - if not force and contract.metadata.hash == self._last_contract_hash: - return results # No changes needed - - # Generate fragments for each target type - fragment_map = self._generate_fragments(contract) - - # Apply fragments to each target - for target in self.config.targets: - if target.fragment_type in fragment_map: - fragments = fragment_map[target.fragment_type] - if fragments or self.config_writer.has_managed_section(target): - # Apply fragments (or remove section if no fragments) - result = self.config_writer.apply_fragments(target, fragments) - results.append(result) - - # Update contract hash - self._last_contract_hash = contract.metadata.hash - - return results - - def watch_and_apply(self) -> list[ApplyResult]: - """Watch for ADR changes and apply guardrails automatically.""" - - results: list[ApplyResult] = [] - - if not self.config.auto_apply: - return results - - # Detect changes - changes = self.file_monitor.detect_changes() - policy_changes = self.file_monitor.get_policy_relevant_changes(changes) - - if policy_changes: - # Apply guardrails due to policy changes - results = self.apply_guardrails(force=False) - - # Log changes for audit - self._log_policy_changes(policy_changes, results) - - return results - - def _generate_fragments( - self, contract: Any - ) -> dict[FragmentType, list[ConfigFragment]]: - """Generate configuration fragments from constraints contract.""" - - fragments: dict[FragmentType, list[ConfigFragment]] = { - FragmentType.ESLINT: [], - FragmentType.RUFF: [], - FragmentType.IMPORT_LINTER: [], - } - - if contract.constraints.is_empty(): - return fragments - - # Generate ESLint fragments - if contract.constraints.imports: - eslint_fragment = self._generate_eslint_fragment(contract) - if eslint_fragment: - fragments[FragmentType.ESLINT].append(eslint_fragment) - - # Generate Ruff fragments - if contract.constraints.python and contract.constraints.python.disallow_imports: - ruff_fragment = self._generate_ruff_fragment(contract) - if ruff_fragment: - fragments[FragmentType.RUFF].append(ruff_fragment) - - # Generate import-linter fragments - if contract.constraints.boundaries: - import_linter_fragment = self._generate_import_linter_fragment(contract) - if import_linter_fragment: - fragments[FragmentType.IMPORT_LINTER].append(import_linter_fragment) - - return fragments - - def _generate_eslint_fragment(self, contract: Any) -> ConfigFragment | None: - """Generate ESLint configuration fragment.""" - - if ( - not contract.constraints.imports - or not contract.constraints.imports.disallow - ): - return None - - # Build disallow rules - disallow_rules = [] - for item in contract.constraints.imports.disallow: - # Find source ADRs for this constraint by checking rule paths - source_adrs = [] - for adr_id, provenance in contract.provenance.items(): - # Check if this provenance rule matches our import constraint - if f"imports.disallow.{item}" in provenance.rule_path: - source_adrs.append(adr_id) - - adr_refs = f" ({', '.join(source_adrs)})" if source_adrs else "" - rule = { - "name": item, - "message": f"Use approved alternative instead{adr_refs}", - } - disallow_rules.append(json.dumps(rule)) - - # Use template to generate content - template = self.config.get_template_for_type(FragmentType.ESLINT) - if template: - content = template.render( - disallow_rules=",\n ".join(disallow_rules) - ) - else: - # Fallback content generation - content = f"""{{ - "rules": {{ - "no-restricted-imports": [ - "error", - {{ - "paths": [ - {",".join(disallow_rules)} - ] - }} - ] - }} -}}""" - - return ConfigFragment( - fragment_type=FragmentType.ESLINT, - content=content, - source_adr_ids=list(contract.provenance.keys()), - ) - - def _generate_ruff_fragment(self, contract: Any) -> ConfigFragment | None: - """Generate Ruff configuration fragment.""" - - if ( - not contract.constraints.python - or not contract.constraints.python.disallow_imports - ): - return None - - # Build banned import rules - banned_imports = [] - for item in contract.constraints.python.disallow_imports: - # Find source ADRs by checking rule paths - source_adrs = [] - for adr_id, provenance in contract.provenance.items(): - if f"python.disallow_imports.{item}" in provenance.rule_path: - source_adrs.append(adr_id) - - adr_refs = f" ({', '.join(source_adrs)})" if source_adrs else "" - rule = f' "{item} = Use approved alternative instead{adr_refs}"' - banned_imports.append(rule) - - # Use template to generate content - template = self.config.get_template_for_type(FragmentType.RUFF) - if template: - content = template.render(disallow_imports=",\n".join(banned_imports)) - else: - # Fallback content generation - content = f"""[tool.ruff.flake8-banned-api] -banned-api = [ -{",".join(banned_imports)} -]""" - - return ConfigFragment( - fragment_type=FragmentType.RUFF, - content=content, - source_adr_ids=list(contract.provenance.keys()), - ) - - def _generate_import_linter_fragment(self, contract: Any) -> ConfigFragment | None: - """Generate import-linter configuration fragment.""" - - if ( - not contract.constraints.boundaries - or not contract.constraints.boundaries.rules - ): - return None - - # Build import-linter contracts - contracts = [] - for i, rule in enumerate(contract.constraints.boundaries.rules): - contract_name = f"adr-boundary-{i+1}" - contracts.append( - f"""[contracts.{contract_name}] -name = "ADR Boundary Rule" -type = "forbidden" -source_modules = ["**"] -forbidden_modules = ["{rule.forbid}"]""" - ) - - content = "\n\n".join(contracts) - - return ConfigFragment( - fragment_type=FragmentType.IMPORT_LINTER, - content=content, - source_adr_ids=list(contract.provenance.keys()), - ) - - def _log_policy_changes( - self, changes: list[ChangeEvent], results: list[ApplyResult] - ) -> None: - """Log policy changes for audit purposes.""" - - if not self.config.notify_on_apply: - return - - # Simple logging - could be enhanced with structured logging - print(f"🔧 ADR-Kit: Applied guardrails due to {len(changes)} policy changes:") - - for change in changes: - if change.change_type == ChangeType.STATUS_CHANGED: - print(f" - {change.adr_id}: {change.old_status} → {change.new_status}") - elif change.change_type == ChangeType.POLICY_CHANGED: - print(f" - {change.adr_id}: Policy updated") - elif change.change_type == ChangeType.CREATED: - print(f" - New ADR: {change.file_path.name}") - - success_count = len( - [r for r in results if r.status == ApplicationStatus.SUCCESS] - ) - print(f" ✅ {success_count}/{len(results)} configurations updated") - - def remove_all_guardrails(self) -> list[ApplyResult]: - """Remove all managed guardrail sections from target files.""" - - results = [] - - for target in self.config.targets: - if self.config_writer.has_managed_section(target): - result = self.config_writer.remove_managed_sections(target) - results.append(result) - - return results - - def get_status(self) -> dict[str, Any]: - """Get current status of the guardrail management system.""" - - try: - contract = self.contract_builder.build_contract() - contract_valid = True - constraint_count = ( - len(contract.constraints.imports.disallow or []) - if contract.constraints.imports - else ( - 0 + len(contract.constraints.imports.prefer or []) - if contract.constraints.imports - else ( - 0 - + len(contract.constraints.architecture.layer_boundaries or []) - if contract.constraints.architecture - else ( - 0 + len(contract.constraints.python.disallow_imports or []) - if contract.constraints.python - else 0 - ) - ) - ) - ) - except Exception: - contract_valid = False - constraint_count = 0 - - # Check target file status - target_status = {} - for target in self.config.targets: - target_status[str(target.file_path)] = { - "exists": target.file_path.exists(), - "has_managed_section": ( - self.config_writer.has_managed_section(target) - if target.file_path.exists() - else False - ), - "fragment_type": target.fragment_type.value, - } - - return { - "enabled": self.config.enabled, - "auto_apply": self.config.auto_apply, - "contract_valid": contract_valid, - "active_constraints": constraint_count, - "target_count": len(self.config.targets), - "targets": target_status, - "last_contract_hash": self._last_contract_hash, - } +from adr_kit.enforcement.config.manager import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/models.py b/adr_kit/guardrail/models.py index 34143ae..06004ee 100644 --- a/adr_kit/guardrail/models.py +++ b/adr_kit/guardrail/models.py @@ -1,166 +1 @@ -"""Data models for the Automatic Guardrail Management System.""" - -from dataclasses import dataclass -from datetime import datetime, timezone -from enum import Enum -from pathlib import Path -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - - -class FragmentType(str, Enum): - """Types of configuration fragments.""" - - ESLINT = "eslint" - RUFF = "ruff" - IMPORT_LINTER = "import_linter" - PRETTIER = "prettier" - MYPY = "mypy" - CUSTOM = "custom" - - -class ApplicationStatus(str, Enum): - """Status of configuration fragment application.""" - - SUCCESS = "success" - FAILED = "failed" - SKIPPED = "skipped" - PARTIAL = "partial" - - -@dataclass -class FragmentTarget: - """Target configuration for applying a fragment.""" - - file_path: Path - fragment_type: FragmentType - section_name: str | None = None # For multi-section configs - backup_enabled: bool = True - - -class ConfigFragment(BaseModel): - """A configuration fragment to be applied to a target file.""" - - fragment_type: FragmentType - content: str = Field(..., description="The configuration content") - source_adr_ids: list[str] = Field(default_factory=list) - metadata: dict[str, Any] = Field(default_factory=dict) - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - - -class SentinelBlock(BaseModel): - """Sentinel block markers for tool-owned configuration sections.""" - - start_marker: str - end_marker: str - description: str | None = None - - @classmethod - def for_fragment_type( - cls, fragment_type: FragmentType, tool_name: str = "adr-kit" - ) -> "SentinelBlock": - """Create standard sentinel block for a fragment type.""" - markers = { - FragmentType.ESLINT: ( - f"/* === {tool_name.upper()} ADR RULES START === */", - f"/* === {tool_name.upper()} ADR RULES END === */", - ), - FragmentType.RUFF: ( - f"# === {tool_name.upper()} ADR RULES START ===", - f"# === {tool_name.upper()} ADR RULES END ===", - ), - FragmentType.IMPORT_LINTER: ( - f"# === {tool_name.upper()} ADR CONTRACTS START ===", - f"# === {tool_name.upper()} ADR CONTRACTS END ===", - ), - FragmentType.PRETTIER: ( - f"// === {tool_name.upper()} ADR RULES START ===", - f"// === {tool_name.upper()} ADR RULES END ===", - ), - FragmentType.MYPY: ( - f"# === {tool_name.upper()} ADR RULES START ===", - f"# === {tool_name.upper()} ADR RULES END ===", - ), - FragmentType.CUSTOM: ( - f"# === {tool_name.upper()} START ===", - f"# === {tool_name.upper()} END ===", - ), - } - - start_marker, end_marker = markers.get( - fragment_type, markers[FragmentType.CUSTOM] - ) - - return cls( - start_marker=start_marker, - end_marker=end_marker, - description=f"Auto-managed {fragment_type.value} rules from ADR policies", - ) - - -class ApplyResult(BaseModel): - """Result of applying configuration fragments.""" - - target: FragmentTarget - status: ApplicationStatus - message: str - fragments_applied: int = 0 - backup_created: Path | None = None - errors: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - - -class ConfigTemplate(BaseModel): - """Template for generating configuration fragments.""" - - fragment_type: FragmentType - template_content: str - variables: dict[str, Any] = Field(default_factory=dict) - - def render(self, **kwargs: Any) -> str: - """Render template with provided variables.""" - merged_vars = {**self.variables, **kwargs} - try: - return self.template_content.format(**merged_vars) - except KeyError as e: - raise ValueError(f"Missing template variable: {e}") from e - - -class GuardrailConfig(BaseModel): - """Configuration for the guardrail management system.""" - - enabled: bool = True - auto_apply: bool = True # Whether to automatically apply changes - backup_enabled: bool = True - backup_dir: Path | None = None - - # Target configurations - targets: list[FragmentTarget] = Field(default_factory=list) - - # Fragment type settings - fragment_settings: dict[FragmentType, dict[str, Any]] = Field(default_factory=dict) - - # Templates for different configuration types - templates: list[ConfigTemplate] = Field(default_factory=list) - - # Notification settings - notify_on_apply: bool = True - notify_on_error: bool = True - - model_config = ConfigDict(use_enum_values=True) - - def get_targets_for_type(self, fragment_type: FragmentType) -> list[FragmentTarget]: - """Get all targets for a specific fragment type.""" - return [ - target for target in self.targets if target.fragment_type == fragment_type - ] - - def get_template_for_type( - self, fragment_type: FragmentType - ) -> ConfigTemplate | None: - """Get template for a specific fragment type.""" - for template in self.templates: - if template.fragment_type == fragment_type: - return template - return None +from adr_kit.enforcement.config.models import * # noqa: F401,F403 diff --git a/adr_kit/workflows/__init__.py b/adr_kit/workflows/__init__.py index a807960..b9ae783 100644 --- a/adr_kit/workflows/__init__.py +++ b/adr_kit/workflows/__init__.py @@ -1,32 +1,24 @@ -"""Internal workflow orchestration system. +"""Workflow orchestration — shim for backward compatibility.""" -This module contains the internal workflow orchestrators that are triggered by MCP entry points. -These workflows handle all the complex automation and orchestration that was previously exposed -as separate MCP tools. +from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow +from adr_kit.decision.workflows.approval import ApprovalWorkflow +from adr_kit.decision.workflows.base import ( + BaseWorkflow, + WorkflowError, + WorkflowResult, + WorkflowStatus, +) +from adr_kit.decision.workflows.creation import CreationWorkflow +from adr_kit.decision.workflows.preflight import PreflightWorkflow +from adr_kit.decision.workflows.supersede import SupersedeWorkflow -Key Design Principles: -- Workflows are pure automation/orchestration (no intelligence) -- Intelligence comes only from agents calling entry points -- Each entry point triggers comprehensive internal workflows -- Workflows use existing components (contract, gate, context, guardrail systems) -- Rich status reporting guides agent next actions -""" - -from .analyze import AnalyzeProjectWorkflow -from .approval import ApprovalWorkflow -from .base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus -from .creation import CreationWorkflow from .planning import PlanningWorkflow -from .preflight import PreflightWorkflow -from .supersede import SupersedeWorkflow __all__ = [ - # Base classes "BaseWorkflow", "WorkflowResult", "WorkflowError", "WorkflowStatus", - # Workflow implementations "ApprovalWorkflow", "CreationWorkflow", "PreflightWorkflow", diff --git a/adr_kit/workflows/analyze.py b/adr_kit/workflows/analyze.py index 4623b4f..ae9e06e 100644 --- a/adr_kit/workflows/analyze.py +++ b/adr_kit/workflows/analyze.py @@ -1,580 +1 @@ -"""Analyze Project Workflow - For existing projects wanting to adopt ADR-Kit.""" - -import os -from collections import Counter -from pathlib import Path -from typing import Any - -from ..core.parse import find_adr_files -from .base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus - - -class AnalyzeProjectWorkflow(BaseWorkflow): - """Workflow for analyzing existing projects and generating agent prompts. - - This workflow is pure automation that: - 1. Scans project structure and detects technologies - 2. Checks for existing ADR setup - 3. Generates intelligent prompts for agents to analyze architectural decisions - - The agent provides ALL intelligence - this workflow just provides data and prompts. - """ - - def execute(self, **kwargs: Any) -> WorkflowResult: - """Execute project analysis workflow. - - Args: - **kwargs: Keyword arguments that should contain: - project_path: Path to project root (defaults to current directory) - focus_areas: Optional focus areas like ["dependencies", "patterns", "architecture"] - - Returns: - WorkflowResult with detected technologies and analysis prompt for agent - """ - # Extract parameters from kwargs - project_path = kwargs.get("project_path") - focus_areas = kwargs.get("focus_areas") - - self._start_workflow("Analyze Project") - - try: - # Step 1: Validate inputs and setup - project_root = self._execute_step( - "validate_inputs", self._validate_inputs, project_path - ) - - # Step 1.5: Validate ADR directory (but allow creation if needed) - self._execute_step( - "validate_adr_directory", self._validate_adr_directory_for_analysis - ) - - # Step 2: Scan project structure - project_structure = self._execute_step( - "scan_project_structure", self._scan_project_structure, project_root - ) - - # Step 3: Detect technologies - detected_technologies = self._execute_step( - "detect_technologies", - self._detect_technologies, - project_root, - project_structure, - ) - - # Step 4: Check existing ADR setup - existing_adr_info = self._execute_step( - "check_existing_adrs", self._check_existing_adrs, project_root - ) - - # Step 5: Generate analysis prompt for agent - analysis_prompt = self._execute_step( - "generate_analysis_prompt", - self._generate_analysis_prompt, - detected_technologies, - existing_adr_info, - focus_areas, - ) - - # Build project context summary - project_context = { - "technologies": detected_technologies["technologies"], - "confidence_scores": detected_technologies["confidence_scores"], - "project_structure": project_structure, - "existing_adrs": { - "count": existing_adr_info["adr_count"], - "directory": ( - str(existing_adr_info["adr_directory"]) - if existing_adr_info["adr_directory"] - else None - ), - "files": existing_adr_info["adr_files"], - }, - "suggested_focus": analysis_prompt["suggested_focus"], - } - - # Set workflow output data - self._set_workflow_data( - detected_technologies=detected_technologies["technologies"], - technology_confidence=detected_technologies["confidence_scores"], - existing_adr_count=existing_adr_info["adr_count"], - existing_adr_directory=( - str(existing_adr_info["adr_directory"]) - if existing_adr_info["adr_directory"] - else None - ), - analysis_prompt=analysis_prompt["prompt"], - suggested_focus=analysis_prompt["suggested_focus"], - project_context=project_context, - ) - - # Add agent guidance - if existing_adr_info["adr_count"] > 0: - guidance = f"Found {existing_adr_info['adr_count']} existing ADRs. Use the analysis prompt to identify missing architectural decisions and create additional ADRs as needed." - next_steps = [ - "Follow the analysis prompt to review existing architectural decisions", - "Identify architectural decisions not yet documented", - "Use adr_create() for each new architectural decision you identify", - "Consider updating existing ADRs if they're incomplete", - ] - else: - guidance = "No existing ADRs found. Use the analysis prompt to identify all architectural decisions and create initial ADR set." - next_steps = [ - "Follow the analysis prompt to analyze the entire project architecture", - "Identify all significant architectural decisions made in the project", - "Use adr_create() for each architectural decision you identify", - "Start with the most fundamental decisions (framework choices, data layer, etc.)", - ] - - self._add_agent_guidance(guidance, next_steps) - - self._complete_workflow( - success=True, - message=f"Project analysis completed - found {len(detected_technologies['technologies'])} technologies", - status=WorkflowStatus.SUCCESS, - ) - - except WorkflowError as e: - self.result.add_error(str(e)) - self._complete_workflow( - success=False, - message="Project analysis failed", - status=WorkflowStatus.FAILED, - ) - except Exception as e: - self.result.add_error(f"Unexpected error: {str(e)}") - self._complete_workflow( - success=False, - message=f"Project analysis failed: {str(e)}", - status=WorkflowStatus.FAILED, - ) - - return self.result - - def _validate_inputs(self, project_path: str | None) -> Path: - """Validate inputs and return project root path.""" - if project_path: - project_root = Path(project_path) - else: - project_root = Path.cwd() - - if not project_root.exists(): - raise WorkflowError(f"Project path does not exist: {project_root}") - - if not project_root.is_dir(): - raise WorkflowError(f"Project path is not a directory: {project_root}") - - return project_root - - def _validate_adr_directory_for_analysis(self) -> None: - """Validate ADR directory for analysis workflow.""" - # For analysis, we need the parent directory to exist so we can potentially create ADR directory - parent_dir = self.adr_dir.parent - if not parent_dir.exists(): - raise WorkflowError(f"ADR parent directory does not exist: {parent_dir}") - - # If ADR directory exists, it must be a directory and writable - if self.adr_dir.exists(): - if not self.adr_dir.is_dir(): - raise WorkflowError(f"ADR path is not a directory: {self.adr_dir}") - - # Check if we can write to the directory - try: - test_file = self.adr_dir / ".adr_kit_test" - test_file.touch() - test_file.unlink() - except Exception as e: - raise WorkflowError( - f"Cannot write to ADR directory: {self.adr_dir} - {e}" - ) from e - - def _scan_project_structure(self, project_root: Path) -> dict[str, Any]: - """Scan project structure to understand layout and files.""" - - structure: dict[str, Any] = { - "total_files": 0, - "directories": [], - "file_types": Counter(), - "config_files": [], - "package_managers": [], - "common_directories": [], - } - - # Define patterns to look for - config_patterns = [ - "package.json", - "package-lock.json", - "yarn.lock", - "requirements.txt", - "setup.py", - "pyproject.toml", - "Pipfile", - "Cargo.toml", - "go.mod", - "build.gradle", - "pom.xml", - ".eslintrc*", - "tsconfig.json", - "webpack.config.*", - "Dockerfile", - "docker-compose.yml", - ".env*", - ] - - common_dir_patterns = [ - "src", - "lib", - "app", - "components", - "services", - "utils", - "test", - "tests", - "spec", - "__tests__", - "docs", - "documentation", - "config", - "configs", - "settings", - ] - - try: - for root, dirs, files in os.walk(project_root): - # Skip hidden directories and common ignore patterns - dirs[:] = [ - d - for d in dirs - if not d.startswith(".") - and d - not in ["node_modules", "__pycache__", "target", "build", "dist"] - ] - - structure["directories"].extend([Path(root) / d for d in dirs]) - - for file in files: - if not file.startswith("."): - structure["total_files"] += 1 - - # Track file extensions - suffix = Path(file).suffix.lower() - if suffix: - structure["file_types"][suffix] += 1 - - # Check for config files - for pattern in config_patterns: - if file.startswith(pattern.replace("*", "")): - structure["config_files"].append(str(Path(root) / file)) - - # Check for common directories - for dir_name in dirs: - if dir_name.lower() in common_dir_patterns: - structure["common_directories"].append( - str(Path(root) / dir_name) - ) - - except Exception as e: - raise WorkflowError(f"Failed to scan project structure: {e}") from e - - return structure - - def _detect_technologies( - self, project_root: Path, structure: dict[str, Any] - ) -> dict[str, Any]: - """Detect technologies based on project structure and files.""" - - technologies = [] - confidence_scores = {} - - # Technology detection patterns - tech_patterns = { - # Frontend frameworks - "React": { - "files": ["package.json"], - "content_patterns": ['"react":', "import React", 'from "react"'], - "extensions": [".jsx", ".tsx"], - }, - "Vue": { - "files": ["package.json"], - "content_patterns": ['"vue":', "import Vue", ".vue"], - "extensions": [".vue"], - }, - "Angular": { - "files": ["package.json", "angular.json"], - "content_patterns": ['"@angular/', "import { Component }"], - "extensions": [".ts"], - }, - # Backend frameworks - "Express.js": { - "files": ["package.json"], - "content_patterns": [ - '"express":', - 'require("express")', - "import express", - ], - }, - "FastAPI": { - "files": ["requirements.txt", "pyproject.toml"], - "content_patterns": ["fastapi", "from fastapi import"], - }, - "Django": { - "files": ["requirements.txt", "manage.py"], - "content_patterns": ["django", "DJANGO_SETTINGS_MODULE"], - }, - "Flask": { - "files": ["requirements.txt"], - "content_patterns": ["flask", "from flask import"], - }, - # Languages - "TypeScript": { - "files": ["tsconfig.json", "package.json"], - "content_patterns": ['"typescript":', '"@types/'], - "extensions": [".ts", ".tsx"], - }, - "Python": { - "extensions": [".py"], - "files": ["requirements.txt", "setup.py", "pyproject.toml"], - }, - "JavaScript": {"extensions": [".js", ".jsx"], "files": ["package.json"]}, - "Rust": {"files": ["Cargo.toml"], "extensions": [".rs"]}, - "Go": {"files": ["go.mod", "go.sum"], "extensions": [".go"]}, - # Databases - "PostgreSQL": { - "content_patterns": ["postgresql", "psycopg2", "pg", "postgres"] - }, - "MySQL": {"content_patterns": ["mysql", "pymysql", "mysql2"]}, - "MongoDB": {"content_patterns": ["mongodb", "mongoose", "pymongo"]}, - "Redis": {"content_patterns": ["redis", "ioredis"]}, - # Tools - "Docker": { - "files": ["Dockerfile", "docker-compose.yml", "docker-compose.yaml"] - }, - "Webpack": { - "files": ["webpack.config.js", "webpack.config.ts"], - "content_patterns": ['"webpack":'], - }, - "Vite": { - "files": ["vite.config.js", "vite.config.ts"], - "content_patterns": ['"vite":'], - }, - } - - # Check each technology - for tech_name, patterns in tech_patterns.items(): - confidence: float = 0.0 - - # Check file extensions - if "extensions" in patterns: - for ext in patterns["extensions"]: - if ( - ext in structure["file_types"] - and structure["file_types"][ext] > 0 - ): - confidence += min(structure["file_types"][ext] * 0.1, 0.5) - - # Check specific files - if "files" in patterns: - for file_pattern in patterns["files"]: - for config_file in structure["config_files"]: - if file_pattern in config_file: - confidence += 0.3 - - # Check content patterns if available - if "content_patterns" in patterns: - try: - content = Path(config_file).read_text( - encoding="utf-8" - ) - for pattern in patterns["content_patterns"]: - if pattern in content: - confidence += 0.2 - except Exception: - pass # Skip file read errors - - # Check content patterns in all relevant files - if "content_patterns" in patterns and confidence == 0: - # Do a broader search if we haven't found anything yet - try: - for config_file in structure["config_files"][:10]: # Limit search - content = Path(config_file).read_text(encoding="utf-8") - for pattern in patterns["content_patterns"]: - if pattern in content: - confidence += 0.1 - break - except Exception: - pass - - # Add technology if confidence is high enough - if confidence >= 0.3: - technologies.append(tech_name) - confidence_scores[tech_name] = min(confidence, 1.0) - - return {"technologies": technologies, "confidence_scores": confidence_scores} - - def _check_existing_adrs(self, project_root: Path) -> dict[str, Any]: - """Check if project already has ADRs set up.""" - - # Common ADR directory locations (relative to project root) - possible_adr_dirs = [ - project_root / "docs" / "adr", - project_root / "docs" / "adrs", - project_root / "docs" / "decisions", - project_root / "adr", - project_root / "adrs", - project_root / "decisions", - project_root / "architecture" / "decisions", - ] - - # Also check the configured ADR directory - if self.adr_dir not in possible_adr_dirs: - possible_adr_dirs.append(self.adr_dir) - - existing_adr_info: dict[str, Any] = { - "adr_directory": None, - "adr_count": 0, - "adr_files": [], - } - - for adr_dir in possible_adr_dirs: - if adr_dir.exists() and adr_dir.is_dir(): - try: - adr_files = find_adr_files(adr_dir) - if adr_files: - existing_adr_info["adr_directory"] = str(adr_dir) - existing_adr_info["adr_count"] = len(adr_files) - existing_adr_info["adr_files"] = [str(f) for f in adr_files] - break - except Exception: - continue # Skip directories we can't read - - return existing_adr_info - - def _generate_analysis_prompt( - self, - detected_technologies: dict[str, Any], - existing_adr_info: dict[str, Any], - focus_areas: list[str] | None, - ) -> dict[str, Any]: - """Generate analysis prompt for the agent.""" - - technologies = detected_technologies["technologies"] - adr_count = existing_adr_info["adr_count"] - - # Build context-aware prompt - prompt_parts = [ - "Please analyze this project for architectural decisions that should be documented as ADRs.", - "", - "**Project Context:**", - f"- Detected technologies: {', '.join(technologies) if technologies else 'Unable to detect specific technologies'}", - ( - f"- Existing ADRs: {adr_count} found" - if adr_count > 0 - else "- No existing ADRs found" - ), - "", - ] - - if adr_count > 0: - prompt_parts.extend( - [ - "**Analysis Focus:**", - "1. Review existing ADRs to understand what's already documented", - "2. Identify architectural decisions that are missing from the ADR set", - "3. Look for inconsistencies between code and documented decisions", - "4. Propose new ADRs for undocumented architectural choices", - "", - ] - ) - else: - prompt_parts.extend( - [ - "**Analysis Focus:**", - "1. Identify all significant architectural decisions made in this project", - "2. Focus on framework choices, data architecture, API design, and deployment patterns", - "3. Look for established conventions and patterns in the codebase", - "4. Propose ADRs for each major architectural decision you identify", - "", - ] - ) - - # Add technology-specific guidance - if technologies: - prompt_parts.extend(["**Technology-Specific Areas to Examine:**"]) - - for tech in technologies: - if tech in ["React", "Vue", "Angular"]: - prompt_parts.append( - f"- {tech}: Component architecture, state management, routing decisions" - ) - elif tech in ["Express.js", "FastAPI", "Django", "Flask"]: - prompt_parts.append( - f"- {tech}: API design patterns, middleware choices, authentication strategy" - ) - elif tech in ["PostgreSQL", "MySQL", "MongoDB"]: - prompt_parts.append( - f"- {tech}: Database schema design, migration strategy, connection patterns" - ) - elif tech == "TypeScript": - prompt_parts.append( - f"- {tech}: Type system usage, configuration choices, strict mode settings" - ) - elif tech == "Docker": - prompt_parts.append( - f"- {tech}: Containerization strategy, multi-stage builds, orchestration" - ) - - prompt_parts.append("") - - # Add focus area guidance if provided - if focus_areas: - prompt_parts.extend( - [ - "**Specific Focus Areas Requested:**", - *[f"- {area.title()}" for area in focus_areas], - "", - ] - ) - - # Add action guidance - prompt_parts.extend( - [ - "**Instructions:**", - "1. Examine the codebase thoroughly for architectural patterns and decisions", - "2. For each significant architectural decision you identify:", - " - Consider the context that led to this decision", - " - Identify the alternatives that were likely considered", - " - Understand the consequences and trade-offs", - " - Draft an ADR using adr_create() with comprehensive rationale", - "", - "3. Focus on decisions that:", - " - Affect multiple parts of the system", - " - Have significant impact on development workflow", - " - Establish patterns other developers should follow", - " - Involve technology choices or architectural patterns", - "", - "4. Wait for human approval before accepting each proposed ADR", - "", - "Start your analysis now and propose ADRs for the architectural decisions you discover.", - ] - ) - - prompt = "\n".join(prompt_parts) - - # Generate suggested focus areas based on detected technologies - suggested_focus = [] - if any(tech in technologies for tech in ["React", "Vue", "Angular"]): - suggested_focus.append("frontend_architecture") - if any( - tech in technologies - for tech in ["Express.js", "FastAPI", "Django", "Flask"] - ): - suggested_focus.append("api_design") - if any( - tech in technologies for tech in ["PostgreSQL", "MySQL", "MongoDB", "Redis"] - ): - suggested_focus.append("data_architecture") - if "Docker" in technologies: - suggested_focus.append("deployment_strategy") - if "TypeScript" in technologies: - suggested_focus.append("type_system") - - return {"prompt": prompt, "suggested_focus": suggested_focus} +from adr_kit.decision.workflows.analyze import * # noqa: F401,F403 diff --git a/adr_kit/workflows/approval.py b/adr_kit/workflows/approval.py index 2c2861e..2d83e8e 100644 --- a/adr_kit/workflows/approval.py +++ b/adr_kit/workflows/approval.py @@ -1,746 +1 @@ -"""Approval Workflow - Approve ADR and trigger complete automation pipeline.""" - -import hashlib -import json -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any - -from ..contract.builder import ConstraintsContractBuilder -from ..core.model import ADR -from ..core.parse import find_adr_files, parse_adr_file -from ..core.validate import validate_adr -from ..enforce.eslint import generate_eslint_config -from ..enforce.ruff import generate_ruff_config -from ..guardrail.manager import GuardrailManager -from ..index.json_index import generate_adr_index -from .base import BaseWorkflow, WorkflowResult - - -@dataclass -class ApprovalInput: - """Input for ADR approval workflow.""" - - adr_id: str - digest_check: bool = True # Whether to verify content digest hasn't changed - force_approve: bool = False # Override conflicts and warnings - approval_notes: str | None = None # Human approval notes - - -@dataclass -class ApprovalResult: - """Result of ADR approval.""" - - adr_id: str - previous_status: str - new_status: str - content_digest: str # SHA-256 hash of approved content - automation_results: dict[str, Any] # Results from triggered automation - policy_rules_applied: int # Number of policy rules applied - configurations_updated: list[str] # List of config files updated - warnings: list[str] # Non-blocking warnings - next_steps: str # Guidance for what happens next - - -class ApprovalWorkflow(BaseWorkflow): - """ - Approval Workflow handles ADR approval and triggers comprehensive automation. - - This is the most complex workflow as it orchestrates the entire ADR ecosystem - when a decision is approved. All policy enforcement, configuration updates, - and validation happens here. - - Workflow Steps: - 1. Load and validate the ADR to be approved - 2. Verify content integrity (digest check) - 3. Update ADR status to 'accepted' - 4. Rebuild constraints contract with new ADR - 5. Apply guardrails and update configurations - 6. Generate enforcement rules (ESLint, Ruff, etc.) - 7. Update indexes and catalogs - 8. Validate codebase against new policies - 9. Generate comprehensive approval report - """ - - def execute(self, **kwargs: Any) -> WorkflowResult: - """Execute comprehensive ADR approval workflow.""" - # Extract input_data from kwargs - input_data = kwargs.get("input_data") - if not input_data or not isinstance(input_data, ApprovalInput): - raise ValueError("input_data must be provided as ApprovalInput instance") - - self._start_workflow("Approve ADR") - - try: - # Step 1: Load and validate ADR - adr, file_path = self._execute_step( - "load_adr", self._load_adr_for_approval, input_data.adr_id - ) - self._execute_step( - "validate_preconditions", - self._validate_approval_preconditions, - adr, - input_data, - ) - - previous_status = adr.status - - # Step 2: Content integrity check - if input_data.digest_check: - content_digest = self._execute_step( - "check_content_integrity", - self._calculate_content_digest, - str(file_path), - ) - else: - content_digest = "skipped" - - # Step 3: Update ADR status - updated_adr = self._execute_step( - "update_adr_status", - self._update_adr_status, - adr, - str(file_path), - input_data, - ) - - # Step 4: Rebuild constraints contract - contract_result = self._execute_step( - "rebuild_constraints_contract", self._rebuild_constraints_contract - ) - - # Step 5: Apply guardrails - guardrail_result = self._execute_step( - "apply_guardrails", self._apply_guardrails, updated_adr - ) - - # Step 6: Generate enforcement rules - enforcement_result = self._execute_step( - "generate_enforcement_rules", - self._generate_enforcement_rules, - updated_adr, - ) - - # Step 7: Update indexes - index_result = self._execute_step("update_indexes", self._update_indexes) - - # Step 8: Validate codebase (optional, can be time-consuming) - validation_result = self._execute_step( - "validate_codebase_compliance", - self._validate_codebase_compliance, - updated_adr, - ) - - # Step 9: Generate approval report - automation_results = { - "status_update": { - "success": True, - "old_status": previous_status, - "new_status": "accepted", - }, - "contract_rebuild": contract_result, - "guardrail_application": guardrail_result, - "enforcement_generation": enforcement_result, - "index_update": index_result, - "codebase_validation": validation_result, - } - - approval_report = self._execute_step( - "generate_approval_report", - self._generate_approval_report, - updated_adr, - automation_results, - content_digest, - input_data, - ) - - result = ApprovalResult( - adr_id=input_data.adr_id, - previous_status=previous_status, - new_status="accepted", - content_digest=content_digest, - automation_results=automation_results, - policy_rules_applied=self._count_policy_rules_applied( - automation_results - ), - configurations_updated=self._extract_updated_configurations( - automation_results - ), - warnings=approval_report.get("warnings", []), - next_steps=approval_report.get("next_steps", ""), - ) - - self._complete_workflow( - success=True, - message=f"ADR {input_data.adr_id} approved and automation completed", - ) - self.result.data = { - "approval_result": result, - "full_report": approval_report, - } - self.result.guidance = approval_report.get("guidance", "") - self.result.next_steps = approval_report.get( - "next_steps_list", - [ - f"ADR {input_data.adr_id} is now active", - "Review automation results for any issues", - "Monitor compliance with generated policy rules", - ], - ) - - except Exception as e: - self._complete_workflow( - success=False, - message=f"Approval workflow failed: {str(e)}", - ) - self.result.add_error(f"ApprovalError: {str(e)}") - - return self.result - - def _load_adr_for_approval(self, adr_id: str) -> tuple[ADR, Path]: - """Load the ADR that needs to be approved.""" - adr_files = find_adr_files(self.adr_dir) - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path) - if adr.id == adr_id: - return adr, file_path - except Exception: - continue - - raise ValueError(f"ADR {adr_id} not found in {self.adr_dir}") - - def _validate_approval_preconditions( - self, adr: ADR, input_data: ApprovalInput - ) -> None: - """Validate that ADR can be approved.""" - - # Check current status - if adr.status == "accepted": - if not input_data.force_approve: - raise ValueError(f"ADR {adr.id} is already approved") - - if adr.status == "superseded": - raise ValueError(f"ADR {adr.id} is superseded and cannot be approved") - - # Validate ADR structure - # Note: validate_adr signature is (adr, schema_path, project_root) - # Pass None for schema_path to use default, and self.adr_dir.parent as project_root - validation_result = validate_adr( - adr, - schema_path=None, - project_root=self.adr_dir.parent if self.adr_dir else None, - ) - if not validation_result.is_valid and not input_data.force_approve: - errors = [str(error) for error in validation_result.errors] - raise ValueError(f"ADR {adr.id} has validation errors: {', '.join(errors)}") - - def _calculate_content_digest(self, file_path: str) -> str: - """Calculate SHA-256 digest of ADR content for integrity checking.""" - with open(file_path, "rb") as f: - content = f.read() - return hashlib.sha256(content).hexdigest() - - def _update_adr_status( - self, adr: ADR, file_path: str, input_data: ApprovalInput - ) -> ADR: - """Update ADR status to accepted and write back to file.""" - - # Read current file content - with open(file_path, encoding="utf-8") as f: - content = f.read() - - # Update status in YAML front-matter - import re - - # Find status line and replace it - status_pattern = r"^status:\s*\w+$" - new_content = re.sub( - status_pattern, "status: accepted", content, flags=re.MULTILINE - ) - - # Add approval metadata if notes provided - if input_data.approval_notes: - # Find end of YAML front-matter - yaml_end = new_content.find("\n---\n") - if yaml_end != -1: - approval_metadata = ( - f'approval_date: {datetime.now().strftime("%Y-%m-%d")}\n' - ) - if input_data.approval_notes: - approval_metadata += ( - f'approval_notes: "{input_data.approval_notes}"\n' - ) - - # Insert before the closing --- - new_content = ( - new_content[:yaml_end] + approval_metadata + new_content[yaml_end:] - ) - - # Write updated content - with open(file_path, "w", encoding="utf-8") as f: - f.write(new_content) - - # Return updated ADR object - create a new one with updated status - from ..core.model import ADRStatus - - updated_front_matter = adr.front_matter.model_copy( - update={"status": ADRStatus.ACCEPTED} - ) - updated_adr = ADR( - front_matter=updated_front_matter, - content=adr.content, - file_path=adr.file_path, - ) - return updated_adr - - def _rebuild_constraints_contract(self) -> dict[str, Any]: - """Rebuild the constraints contract with all approved ADRs.""" - try: - builder = ConstraintsContractBuilder(adr_dir=self.adr_dir) - contract = builder.build() - - return { - "success": True, - "approved_adrs": len(contract.approved_adrs), - "constraints_exist": not contract.constraints.is_empty(), - "constraints": 1 if not contract.constraints.is_empty() else 0, - "message": "Constraints contract rebuilt successfully", - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Failed to rebuild constraints contract", - } - - def _apply_guardrails(self, adr: ADR) -> dict[str, Any]: - """Apply guardrails based on the approved ADR.""" - try: - # Apply guardrails using GuardrailManager - GuardrailManager(adr_dir=Path(self.adr_dir)) - - # This is a simplified implementation - would need to be enhanced - # to fully integrate with the GuardrailManager's apply methods - - return { - "success": True, - "guardrails_applied": 0, # Simplified for now - "configurations_updated": [], - "message": "Guardrails system initialized (simplified implementation)", - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Failed to apply guardrails", - } - - def _generate_enforcement_rules(self, adr: ADR) -> dict[str, Any]: - """Generate enforcement rules (ESLint, Ruff, git hooks) from ADR policies.""" - results = {} - - try: - # Generate ESLint rules if JavaScript/TypeScript policies exist - if self._has_javascript_policies(adr): - eslint_result = self._generate_eslint_rules(adr) - results["eslint"] = eslint_result - - # Generate Ruff rules if Python policies exist - if self._has_python_policies(adr): - ruff_result = self._generate_ruff_rules(adr) - results["ruff"] = ruff_result - - # Generate standalone validation scripts - scripts_result = self._generate_validation_scripts(adr) - results["scripts"] = scripts_result - - # Always update git hooks so staged enforcement reflects new rules - hooks_result = self._update_git_hooks() - results["hooks"] = hooks_result - - return { - "success": True, - "rule_generators": list(results.keys()), - "details": results, - "message": "Enforcement rules generated successfully", - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Failed to generate enforcement rules", - } - - def _generate_validation_scripts(self, adr: ADR) -> dict[str, Any]: - """Generate standalone validation scripts for an ADR's policies.""" - try: - from ..enforce.script_generator import ScriptGenerator - - generator = ScriptGenerator(adr_dir=self.adr_dir) - output_dir = Path.cwd() / "scripts" / "adr" - path = generator.generate_for_adr(adr, output_dir) - - if path: - return { - "success": True, - "script": str(path), - "message": f"Validation script generated: {path.name}", - } - return { - "success": True, - "script": None, - "message": "No enforceable policies — no script generated", - } - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Failed to generate validation script", - } - - def _update_git_hooks(self) -> dict[str, Any]: - """Update git hooks to run staged enforcement checks.""" - try: - from ..enforce.hooks import HookGenerator - - generator = HookGenerator() - hook_results = generator.generate(project_root=Path.cwd()) - - updated = [ - name - for name, action in hook_results.items() - if action not in ("unchanged", "skipped") - ] - skipped = [ - name for name, action in hook_results.items() if "skipped" in action - ] - - return { - "success": True, - "hooks_updated": updated, - "hooks_skipped": skipped, - "details": hook_results, - "message": f"Git hooks updated: {', '.join(updated) if updated else 'all unchanged'}", - } - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Failed to update git hooks (non-blocking)", - } - - def _has_javascript_policies(self, adr: ADR) -> bool: - """Check if ADR has JavaScript/TypeScript related policies.""" - if not adr.policy: - return False - - # Check for import restrictions, frontend policies, etc. - js_indicators = [] - - # Check if it has imports policy - if adr.policy.imports: - js_indicators.append(True) - - # Check for frontend-related terms in policy - policy_text = str(adr.policy.model_dump()).lower() - js_indicators.extend( - [ - "javascript" in policy_text, - "typescript" in policy_text, - "frontend" in policy_text, - "react" in policy_text, - "vue" in policy_text, - ] - ) - - return any(js_indicators) - - def _has_python_policies(self, adr: ADR) -> bool: - """Check if ADR has Python related policies.""" - if not adr.policy: - return False - - # Check for Python-specific policies - python_indicators = [] - - # Check for python-specific policy - if adr.policy.python: - python_indicators.append(True) - - # Check for imports policy - if adr.policy.imports: - python_indicators.append(True) - - # Check for Python-related terms in policy - policy_text = str(adr.policy.model_dump()).lower() - python_indicators.extend( - [ - "django" in policy_text, - "flask" in policy_text, - ] - ) - - return any(python_indicators) - - def _generate_eslint_rules(self, adr: ADR) -> dict[str, Any]: - """Generate ESLint rules from ADR policies.""" - try: - config = generate_eslint_config(self.adr_dir) - - # Write to .eslintrc.adrs.json - output_file = Path.cwd() / ".eslintrc.adrs.json" - with open(output_file, "w") as f: - f.write(config) - rules: list[dict[str, Any]] = [] # Simplified for now - - return { - "success": True, - "rules_generated": len(rules), - "output_file": str(output_file), - "rules": rules, - } - - except Exception as e: - return {"success": False, "error": str(e)} - - def _generate_ruff_rules(self, adr: ADR) -> dict[str, Any]: - """Generate Ruff configuration from ADR policies.""" - try: - config_content = generate_ruff_config(self.adr_dir) - - # Update pyproject.toml - output_file = Path.cwd() / "pyproject.toml" - # For now, just create a simple config file - with open(output_file, "a") as f: - f.write("\n" + config_content) - config: dict[str, Any] = {} # Simplified for now - - return { - "success": True, - "config_sections": len(config), - "output_file": str(output_file), - "config": config, - } - - except Exception as e: - return {"success": False, "error": str(e)} - - def _update_indexes(self) -> dict[str, Any]: - """Update JSON and other indexes after ADR approval.""" - try: - # Update JSON index - adr_index = generate_adr_index(self.adr_dir) - - # Write to standard location - index_file = Path(self.adr_dir) / "adr-index.json" - with open(index_file, "w") as f: - json.dump(adr_index.to_dict(), f, indent=2) - - return { - "success": True, - "total_adrs": len(adr_index.entries), - "approved_adrs": len( - [ - entry - for entry in adr_index.entries - if entry.adr.status == "accepted" - ] - ), - "index_file": str(index_file), - "message": "Indexes updated successfully", - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Failed to update indexes", - } - - def _validate_codebase_compliance(self, adr: ADR) -> dict[str, Any]: - """Validate existing codebase against new ADR policies (optional).""" - try: - # This is a lightweight validation - full validation might be expensive - # and should be run separately via CI/CD - - violations = [] - - # Check for obvious policy violations - if adr.policy and adr.policy.imports and adr.policy.imports.disallow: - disallowed = adr.policy.imports.disallow - if disallowed: - # Quick scan for disallowed imports in common files - violations.extend(self._quick_scan_for_violations(disallowed)) - - return { - "success": True, - "violations_found": len(violations), - "violations": violations[:10], # Limit to first 10 - "message": "Quick compliance check completed", - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "message": "Compliance validation failed", - } - - def _quick_scan_for_violations( - self, disallowed_imports: list[str] - ) -> list[dict[str, Any]]: - """Quick scan for obvious policy violations.""" - violations = [] - - # Scan common file types - file_patterns = ["**/*.js", "**/*.ts", "**/*.py", "**/*.jsx", "**/*.tsx"] - - for pattern in file_patterns: - try: - from pathlib import Path - - for file_path in Path.cwd().glob(pattern): - if ( - file_path.is_file() and file_path.stat().st_size < 1024 * 1024 - ): # Skip large files - try: - with open(file_path, encoding="utf-8") as f: - content = f.read() - - for disallowed in disallowed_imports: - if ( - f"import {disallowed}" in content - or f"from {disallowed}" in content - ): - violations.append( - { - "file": str(file_path), - "violation": f"Uses disallowed import: {disallowed}", - "type": "import_violation", - } - ) - except Exception: - continue # Skip problematic files - - except Exception: - continue # Skip problematic patterns - - return violations - - def _count_policy_rules_applied(self, automation_results: dict[str, Any]) -> int: - """Count total policy rules applied across all systems.""" - count = 0 - - if "enforcement_generation" in automation_results: - enforcement = automation_results["enforcement_generation"] - if enforcement.get("success"): - details = enforcement.get("details", {}) - for _system, result in details.items(): - if result.get("success"): - count += result.get("rules_generated", 0) - - return count - - def _extract_updated_configurations( - self, automation_results: dict[str, Any] - ) -> list[str]: - """Extract list of configuration files that were updated.""" - updated_files = [] - - # From guardrail application - if "guardrail_application" in automation_results: - guardrails = automation_results["guardrail_application"] - if guardrails.get("success"): - updated_files.extend(guardrails.get("configurations_updated", [])) - - # From enforcement rule generation - if "enforcement_generation" in automation_results: - enforcement = automation_results["enforcement_generation"] - if enforcement.get("success"): - details = enforcement.get("details", {}) - for _system, result in details.items(): - if result.get("success") and result.get("output_file"): - updated_files.append(result["output_file"]) - - # From index updates - if "index_update" in automation_results: - index = automation_results["index_update"] - if index.get("success") and index.get("index_file"): - updated_files.append(index["index_file"]) - - return list(set(updated_files)) # Remove duplicates - - def _generate_approval_report( - self, - adr: ADR, - automation_results: dict[str, Any], - content_digest: str, - input_data: ApprovalInput, - ) -> dict[str, Any]: - """Generate comprehensive approval report.""" - - # Count successes and failures - successes = sum( - 1 - for result in automation_results.values() - if isinstance(result, dict) and result.get("success") - ) - failures = len(automation_results) - successes - - warnings = [] - - # Check for automation failures - for step, result in automation_results.items(): - if isinstance(result, dict) and not result.get("success"): - warnings.append( - f"{step.replace('_', ' ').title()} failed: {result.get('message', 'Unknown error')}" - ) - - # Generate next steps - if failures > 0: - next_steps = ( - f"⚠️ ADR {adr.id} approved but {failures} automation step(s) failed. " - f"Review warnings and consider running manual enforcement. " - f"Use adr_validate() to check compliance." - ) - else: - policy_count = self._count_policy_rules_applied(automation_results) - config_count = len(self._extract_updated_configurations(automation_results)) - - next_steps = ( - f"✅ ADR {adr.id} fully approved and operational. " - f"{policy_count} policy rules applied, {config_count} configurations updated. " - f"All systems are enforcing this decision." - ) - - return { - "adr_id": adr.id, - "approval_timestamp": datetime.now().isoformat(), - "content_digest": content_digest, - "automation_summary": { - "total_steps": len(automation_results), - "successful_steps": successes, - "failed_steps": failures, - "success_rate": ( - f"{(successes/len(automation_results)*100):.1f}%" - if automation_results - else "0%" - ), - }, - "policy_enforcement": { - "rules_applied": self._count_policy_rules_applied(automation_results), - "configurations_updated": len( - self._extract_updated_configurations(automation_results) - ), - "enforcement_active": failures == 0, - }, - "warnings": warnings, - "next_steps": next_steps, - "full_automation_details": automation_results, - } +from adr_kit.decision.workflows.approval import * # noqa: F401,F403 diff --git a/adr_kit/workflows/base.py b/adr_kit/workflows/base.py index 8aa14e2..a30c2d3 100644 --- a/adr_kit/workflows/base.py +++ b/adr_kit/workflows/base.py @@ -1,252 +1 @@ -"""Base classes for internal workflow orchestration.""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum -from pathlib import Path -from typing import Any - - -class WorkflowStatus(str, Enum): - """Status of workflow execution.""" - - SUCCESS = "success" - PARTIAL_SUCCESS = "partial_success" # Some steps succeeded, some failed - FAILED = "failed" - VALIDATION_ERROR = "validation_error" - CONFLICT_ERROR = "conflict_error" - REQUIRES_ACTION = ( - "requires_action" # Quality gate or other check requires user action - ) - - -@dataclass -class WorkflowStep: - """Represents a single step in a workflow.""" - - name: str - status: WorkflowStatus - message: str - duration_ms: int | None = None - details: dict[str, Any] = field(default_factory=dict) - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - -@dataclass -class WorkflowResult: - """Result of workflow execution.""" - - success: bool - status: WorkflowStatus - message: str - - # Execution details - steps: list[WorkflowStep] = field(default_factory=list) - duration_ms: int = 0 - executed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - # Output data (workflow-specific) - data: dict[str, Any] = field(default_factory=dict) - - # Agent guidance - next_steps: list[str] = field(default_factory=list) - guidance: str = "" - - # Error handling - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - def add_step(self, step: WorkflowStep) -> None: - """Add a workflow step result.""" - self.steps.append(step) - - def add_error(self, error: str, step_name: str | None = None) -> None: - """Add an error to the result.""" - self.errors.append(error) - if step_name: - # Also add to the specific step if it exists - for step in self.steps: - if step.name == step_name: - step.errors.append(error) - break - - def add_warning(self, warning: str, step_name: str | None = None) -> None: - """Add a warning to the result.""" - self.warnings.append(warning) - if step_name: - for step in self.steps: - if step.name == step_name: - step.warnings.append(warning) - break - - def get_summary(self) -> str: - """Get human-readable summary of workflow execution.""" - if self.success: - successful_steps = len( - [s for s in self.steps if s.status == WorkflowStatus.SUCCESS] - ) - return f"✅ {self.message} ({successful_steps}/{len(self.steps)} steps completed)" - else: - failed_steps = len( - [s for s in self.steps if s.status == WorkflowStatus.FAILED] - ) - return f"❌ {self.message} ({failed_steps}/{len(self.steps)} steps failed)" - - def to_agent_response(self) -> dict[str, Any]: - """Convert to agent-friendly response format.""" - return { - "success": self.success, - "status": self.status.value, - "message": self.message, - "data": self.data, - "steps_completed": len( - [s for s in self.steps if s.status == WorkflowStatus.SUCCESS] - ), - "total_steps": len(self.steps), - "duration_ms": self.duration_ms, - "next_steps": self.next_steps, - "guidance": self.guidance, - "errors": self.errors, - "warnings": self.warnings, - "summary": self.get_summary(), - } - - -class WorkflowError(Exception): - """Exception raised during workflow execution.""" - - def __init__( - self, - message: str, - step_name: str | None = None, - details: dict | None = None, - ): - super().__init__(message) - self.message = message - self.step_name = step_name - self.details = details or {} - - -class BaseWorkflow(ABC): - """Base class for all internal workflows. - - Workflows are pure automation/orchestration that use existing components - to accomplish complex tasks triggered by agent entry points. - """ - - def __init__(self, adr_dir: Path | str): - self.adr_dir = Path(adr_dir) - self.result = WorkflowResult( - success=False, status=WorkflowStatus.FAILED, message="" - ) - self._start_time: datetime | None = None - - @abstractmethod - def execute(self, **kwargs: Any) -> WorkflowResult: - """Execute the workflow with given parameters. - - This is the main entry point that agents call through MCP tools. - Implementations should: - 1. Validate inputs - 2. Execute workflow steps in sequence - 3. Handle errors gracefully - 4. Return comprehensive results with agent guidance - """ - pass - - def _start_workflow(self, workflow_name: str) -> None: - """Initialize workflow execution.""" - self._start_time = datetime.now() - self.result = WorkflowResult( - success=False, - status=WorkflowStatus.FAILED, - message=f"{workflow_name} workflow started", - ) - - def _complete_workflow( - self, success: bool, message: str, status: WorkflowStatus | None = None - ) -> None: - """Complete workflow execution.""" - if self._start_time: - duration_ms = int( - (datetime.now() - self._start_time).total_seconds() * 1000 - ) - self.result.duration_ms = self._ensure_minimum_duration(duration_ms) - else: - # Fallback: use a minimal duration if start time wasn't set - self.result.duration_ms = 1 - - self.result.success = success - self.result.message = message - - if status: - self.result.status = status - else: - self.result.status = ( - WorkflowStatus.SUCCESS if success else WorkflowStatus.FAILED - ) - - def _ensure_minimum_duration(self, duration_ms: int) -> int: - """Ensure duration is at least 1ms for consistent timing.""" - return max(duration_ms, 1) - - def _execute_step( - self, step_name: str, step_func: Any, *args: Any, **kwargs: Any - ) -> Any: - """Execute a single workflow step with error handling.""" - start_time = datetime.now() - step = WorkflowStep( - name=step_name, status=WorkflowStatus.FAILED, message="Step started" - ) - - try: - result = step_func(*args, **kwargs) - - step.status = WorkflowStatus.SUCCESS - step.message = f"{step_name} completed successfully" - duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) - step.duration_ms = self._ensure_minimum_duration(duration_ms) - - self.result.add_step(step) - return result - - except Exception as e: - step.status = WorkflowStatus.FAILED - step.message = f"{step_name} failed: {str(e)}" - duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) - step.duration_ms = self._ensure_minimum_duration(duration_ms) - step.errors.append(str(e)) - - self.result.add_step(step) - raise WorkflowError( - f"{step_name} failed: {str(e)}", step_name, {"exception": str(e)} - ) from e - - def _validate_adr_directory(self) -> None: - """Validate that ADR directory exists and is accessible.""" - if not self.adr_dir.exists(): - raise WorkflowError(f"ADR directory does not exist: {self.adr_dir}") - - if not self.adr_dir.is_dir(): - raise WorkflowError(f"ADR path is not a directory: {self.adr_dir}") - - # Check if we can write to the directory - try: - test_file = self.adr_dir / ".adr_kit_test" - test_file.touch() - test_file.unlink() - except Exception as e: - raise WorkflowError( - f"Cannot write to ADR directory: {self.adr_dir} - {e}" - ) from e - - def _add_agent_guidance(self, guidance: str, next_steps: list[str]) -> None: - """Add guidance for the agent on what to do next.""" - self.result.guidance = guidance - self.result.next_steps = next_steps - - def _set_workflow_data(self, **data: Any) -> None: - """Set workflow-specific output data.""" - self.result.data.update(data) +from adr_kit.decision.workflows.base import * # noqa: F401,F403 diff --git a/adr_kit/workflows/creation.py b/adr_kit/workflows/creation.py index 5e48446..bde6e6e 100644 --- a/adr_kit/workflows/creation.py +++ b/adr_kit/workflows/creation.py @@ -1,1472 +1 @@ -"""Creation Workflow - Create new ADR proposals with conflict detection.""" - -import re -from dataclasses import dataclass -from datetime import date -from pathlib import Path -from typing import Any - -from ..contract.builder import ConstraintsContractBuilder -from ..core.model import ADR, ADRFrontMatter, ADRStatus, PolicyModel -from ..core.parse import find_adr_files, parse_adr_file -from ..core.validate import validate_adr -from .base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus - - -@dataclass -class CreationInput: - """Input for ADR creation workflow.""" - - title: str - context: str # The problem/situation that prompted this decision - decision: str # The architectural decision being made - consequences: str # Expected positive and negative consequences - status: str = "proposed" # Always start as proposed - deciders: list[str] | None = None - tags: list[str] | None = None - policy: dict[str, Any] | None = None # Structured policy block - alternatives: str | None = None # Alternative options considered - skip_quality_gate: bool = False # Skip quality gate (for testing or override) - - -@dataclass -class CreationResult: - """Result of ADR creation.""" - - adr_id: str - file_path: str - conflicts_detected: list[str] # ADR IDs that conflict with this proposal - related_adrs: list[str] # ADR IDs that are related but don't conflict - validation_warnings: list[str] # Non-blocking validation issues - next_steps: str # What agent should do next - review_required: bool # Whether human review is needed before approval - - -class CreationWorkflow(BaseWorkflow): - """ - Creation Workflow creates new ADR proposals with comprehensive validation. - - This workflow ensures new ADRs are properly structured, don't conflict with - existing decisions, and follow the project's ADR conventions. - - Workflow Steps: - 1. Generate next ADR ID and validate basic structure - 2. Query related ADRs using semantic search (if available) - 3. Detect conflicts with existing approved ADRs - 4. Validate ADR structure and policy format - 5. Generate ADR file in proposed status - 6. Return creation result with guidance for next steps - """ - - def execute( - self, input_data: CreationInput | None = None, **kwargs: Any - ) -> WorkflowResult: - """Execute ADR creation workflow with quality gate.""" - # Use positional input_data if provided, otherwise extract from kwargs - if input_data is None: - input_data = kwargs.get("input_data") - if not input_data or not isinstance(input_data, CreationInput): - raise ValueError("input_data must be provided as CreationInput instance") - - self._start_workflow("Create ADR") - - try: - # Step 1: Basic validation (minimum requirements) - self._execute_step( - "validate_input", self._validate_creation_input, input_data - ) - - # Step 2: Quality gate - run BEFORE any file operations (unless skipped) - quality_feedback = None - if not input_data.skip_quality_gate: - quality_feedback = self._execute_step( - "quality_gate", self._quick_quality_gate, input_data - ) - - # Step 3: Check quality threshold - if not quality_feedback.get("passes_threshold", True): - # Quality below threshold - BLOCK creation and return feedback - self._complete_workflow( - success=False, - message=f"Quality threshold not met (score: {quality_feedback['quality_score']}/{quality_feedback['threshold']})", - status=WorkflowStatus.REQUIRES_ACTION, - ) - self.result.data = { - "quality_feedback": quality_feedback, - "correction_prompt": ( - "Please address the quality issues identified above and resubmit. " - "Focus on high-priority issues first for maximum impact." - ), - } - self.result.next_steps = quality_feedback.get("next_steps", []) - return self.result - else: - # Quality gate skipped - generate basic feedback for backward compatibility - quality_feedback = { - "quality_score": None, - "grade": None, - "passes_threshold": True, - "summary": "Quality gate skipped (skip_quality_gate=True)", - "issues": [], - "strengths": [], - "recommendations": [], - "next_steps": [], - } - - # Quality passed threshold - proceed with ADR creation - # Step 4: Generate ADR ID - adr_id = self._execute_step("generate_adr_id", self._generate_adr_id) - - # Step 5: Check conflicts - related_adrs = self._execute_step( - "find_related_adrs", self._find_related_adrs, input_data - ) - conflicts = self._execute_step( - "check_conflicts", self._detect_conflicts, input_data, related_adrs - ) - - # Step 6: Create ADR content - adr = self._execute_step( - "create_adr_content", self._build_adr_structure, adr_id, input_data - ) - - # Step 7: Write ADR file (only happens if quality passed) - file_path = self._execute_step( - "write_adr_file", self._generate_adr_file, adr - ) - - # Additional processing - validation_result = self._validate_adr_structure(adr) - policy_warnings = self._validate_policy_completeness(adr, input_data) - validation_result["warnings"].extend(policy_warnings) - review_required = self._determine_review_requirements( - adr, conflicts, validation_result - ) - next_steps = self._generate_next_steps_guidance( - adr_id, conflicts, review_required - ) - - result = CreationResult( - adr_id=adr_id, - file_path=file_path, - conflicts_detected=[c["adr_id"] for c in conflicts], - related_adrs=[r["adr_id"] for r in related_adrs], - validation_warnings=validation_result.get("warnings", []), - next_steps=next_steps, - review_required=review_required, - ) - - # Generate policy suggestions if no policy was provided (Task 2) - policy_guidance = self._generate_policy_guidance(adr, input_data) - - self._complete_workflow( - success=True, message=f"ADR {adr_id} created successfully" - ) - self.result.data = { - "creation_result": result, - "quality_feedback": quality_feedback, # Task 1: Quality gate results - "policy_guidance": policy_guidance, # Task 2: Policy construction guidance - } - self.result.guidance = next_steps - self.result.next_steps = self._generate_next_steps_list( - adr_id, conflicts, review_required - ) - return self.result - - except WorkflowError as e: - # Check if this was a validation error - if "must be at least" in str(e) or "validation" in str(e).lower(): - self._complete_workflow( - success=False, - message=f"ADR creation failed: {str(e)}", - status=WorkflowStatus.VALIDATION_ERROR, - ) - else: - self._complete_workflow( - success=False, message=f"ADR creation failed: {str(e)}" - ) - self.result.errors = [f"CreationError: {str(e)}"] - return self.result - except Exception as e: - self._complete_workflow( - success=False, message=f"ADR creation failed: {str(e)}" - ) - self.result.errors = [f"CreationError: {str(e)}"] - return self.result - - def _generate_adr_id(self) -> str: - """Generate next available ADR ID.""" - # Scan directory for existing ADR files - adr_files = find_adr_files(self.adr_dir) - if not adr_files: - return "ADR-0001" - - # Extract numbers from existing ADR files - numbers = [] - for file_path in adr_files: - filename = Path(file_path).stem - match = re.search(r"ADR-(\d+)", filename) - if match: - numbers.append(int(match.group(1))) - - if not numbers: - return "ADR-0001" - - next_num = max(numbers) + 1 - return f"ADR-{next_num:04d}" - - def _validate_creation_input(self, input_data: CreationInput) -> None: - """Validate the input data for ADR creation with helpful error messages.""" - if not input_data.title or len(input_data.title.strip()) < 3: - raise ValueError( - "Title must be at least 3 characters. " - "Example: 'Use PostgreSQL for Primary Database' or 'Use React 18 with TypeScript'" - ) - - if not input_data.context or len(input_data.context.strip()) < 10: - raise ValueError( - "Context must be at least 10 characters. " - "Context should explain WHY this decision is needed - the problem or opportunity. " - "Example: 'We need ACID transactions for financial data integrity. Current SQLite " - "setup doesn't support concurrent writes from multiple services.'" - ) - - if not input_data.decision or len(input_data.decision.strip()) < 5: - raise ValueError( - "Decision must be at least 5 characters. " - "Decision should state WHAT specific technology/pattern/approach is chosen. " - "Example: 'Use PostgreSQL 15 as the primary database. Don't use MySQL or MongoDB.' " - "Be specific and include explicit constraints." - ) - - if not input_data.consequences or len(input_data.consequences.strip()) < 5: - raise ValueError( - "Consequences must be at least 5 characters. " - "Consequences should document BOTH positive and negative outcomes (trade-offs). " - "Example: '+ ACID compliance, + Rich features, - Higher resource usage, - Ops expertise required'" - ) - - if input_data.status and input_data.status not in [ - "proposed", - "accepted", - "superseded", - ]: - raise ValueError("Status must be one of: proposed, accepted, superseded") - - def _find_related_adrs(self, input_data: CreationInput) -> list[dict[str, Any]]: - """Find ADRs related to this proposal using various matching strategies.""" - related = [] - - try: - adr_files = find_adr_files(self.adr_dir) - - # Keywords from the proposal - proposal_text = ( - f"{input_data.title} {input_data.context} {input_data.decision}" - ).lower() - - # Extract key terms (simple approach - could be enhanced with NLP) - key_terms = self._extract_key_terms(proposal_text) - - for file_path in adr_files: - try: - existing_adr = parse_adr_file(file_path) - if existing_adr.status == "superseded": - continue # Skip superseded ADRs - - # Check for related content - existing_text = ( - f"{existing_adr.title} {existing_adr.context} {existing_adr.decision}" - ).lower() - - relevance_score = self._calculate_relevance( - key_terms, existing_text - ) - - if relevance_score > 0.3: # Threshold for relevance - related.append( - { - "adr_id": existing_adr.id, - "title": existing_adr.title, - "relevance_score": relevance_score, - "matching_terms": [ - term for term in key_terms if term in existing_text - ], - "tags_overlap": bool( - set(input_data.tags or []) - & set(existing_adr.front_matter.tags or []) - ), - } - ) - - except Exception: - continue # Skip problematic files - - # Sort by relevance - related.sort( - key=lambda x: ( - float(x["relevance_score"]) - if isinstance(x["relevance_score"], int | float | str) - else 0.0 - ), - reverse=True, - ) - return related[:10] # Return top 10 most relevant - - except Exception: - return [] # Return empty if search fails - - def _extract_key_terms(self, text: str) -> list[str]: - """Extract key technical terms from text.""" - # Common technology and architecture terms - tech_patterns = [ - r"\b\w*sql\w*\b", - r"\bmongo\w*\b", - r"\bredis\b", # Databases - r"\breact\b", - r"\bvue\b", - r"\bangular\b", - r"\bsvelte\b", # Frontend - r"\bexpress\b", - r"\bdjango\b", - r"\bflask\b", - r"\bspring\b", # Backend - r"\bmicroservice\w*\b", - r"\bmonolith\w*\b", - r"\bserverless\b", # Architecture - r"\bapi\b", - r"\brest\b", - r"\bgraphql\b", - r"\bgrpc\b", # APIs - r"\bdocker\b", - r"\bkubernetes\b", - r"\baws\b", - r"\bazure\b", # Infrastructure - r"\btypescript\b", - r"\bjavascript\b", - r"\bpython\b", - r"\bjava\b", # Languages - ] - - terms = [] - for pattern in tech_patterns: - matches = re.findall(pattern, text, re.IGNORECASE) - terms.extend([match.lower() for match in matches]) - - # Add important words (length > 5) - words = re.findall(r"\b\w{5,}\b", text.lower()) - terms.extend(words) - - return list(set(terms)) # Remove duplicates - - def _calculate_relevance(self, key_terms: list[str], existing_text: str) -> float: - """Calculate relevance score between proposal and existing ADR.""" - if not key_terms: - return 0.0 - - matching_terms = [term for term in key_terms if term in existing_text] - return len(matching_terms) / len(key_terms) - - def _detect_conflicts( - self, input_data: CreationInput, related_adrs: list[dict[str, Any]] - ) -> list[dict[str, Any]]: - """Detect conflicts between proposal and existing ADRs.""" - conflicts = [] - - try: - # Load constraints contract to check policy conflicts - builder = ConstraintsContractBuilder(adr_dir=self.adr_dir) - contract = builder.build() - - # Check policy conflicts - if input_data.policy: - policy_conflicts = self._detect_policy_conflicts( - input_data.policy, contract - ) - conflicts.extend(policy_conflicts) - - # Check direct contradictions in highly related ADRs - for related_adr in related_adrs: - if related_adr["relevance_score"] > 0.7: # High relevance threshold - contradiction = self._check_for_contradictions( - input_data, related_adr["adr_id"] - ) - if contradiction: - conflicts.append(contradiction) - - except Exception: - pass # Conflict detection is best-effort - - return conflicts - - def _detect_policy_conflicts( - self, proposed_policy: dict[str, Any], contract: Any - ) -> list[dict[str, Any]]: - """Detect conflicts between proposed policy and existing policies.""" - conflicts = [] - - # Check if proposed policy contradicts existing constraints - for constraint in contract.constraints: - if self._policies_conflict(proposed_policy, constraint.policy): - conflicts.append( - { - "adr_id": constraint.adr_id, - "conflict_type": "policy_contradiction", - "conflict_detail": f"Proposed policy conflicts with {constraint.adr_id} policy", - } - ) - - return conflicts - - def _policies_conflict( - self, policy1: dict[str, Any], policy2: dict[str, Any] - ) -> bool: - """Check if two policies contradict each other.""" - # Simple conflict detection - can be enhanced - - # Check import conflicts - if "imports" in policy1 and "imports" in policy2: - p1_disallow = set(policy1["imports"].get("disallow", [])) - p2_prefer = set(policy2["imports"].get("prefer", [])) - - if p1_disallow & p2_prefer: # Intersection means conflict - return True - - return False - - def _check_for_contradictions( - self, input_data: CreationInput, related_adr_id: str - ) -> dict[str, Any] | None: - """Check if proposal contradicts a specific ADR.""" - # This is a simplified version - could be enhanced with NLP - - # Load the related ADR - try: - adr_files = find_adr_files(self.adr_dir) - for file_path in adr_files: - adr = parse_adr_file(file_path) - if adr.id == related_adr_id: - # Simple keyword-based contradiction detection - proposal_decision = input_data.decision.lower() - existing_decision = adr.decision.lower() - - # Look for opposing terms - opposing_pairs = [ - ("use", "avoid"), - ("adopt", "reject"), - ("implement", "remove"), - ("enable", "disable"), - ("allow", "forbid"), - ] - - for word1, word2 in opposing_pairs: - if word1 in proposal_decision and word2 in existing_decision: - return { - "adr_id": related_adr_id, - "conflict_type": "decision_contradiction", - "conflict_detail": f"Proposal uses '{word1}' while {related_adr_id} uses '{word2}'", - } - break - except Exception: - pass - - return None - - def _build_adr_structure(self, adr_id: str, input_data: CreationInput) -> ADR: - """Build ADR data structure from input.""" - - # Build front matter - front_matter = ADRFrontMatter( - id=adr_id, - title=input_data.title.strip(), - status=ADRStatus(input_data.status), - date=date.today(), - deciders=input_data.deciders or [], - tags=input_data.tags or [], - supersedes=[], - superseded_by=[], - policy=( - PolicyModel.model_validate(input_data.policy) - if input_data.policy - else None - ), - ) - - # Build content sections - content_parts = [ - "## Context", - "", - input_data.context.strip(), - "", - "## Decision", - "", - input_data.decision.strip(), - "", - "## Consequences", - "", - input_data.consequences.strip(), - ] - - if input_data.alternatives: - content_parts.extend( - [ - "", - "## Alternatives", - "", - input_data.alternatives.strip(), - ] - ) - - content = "\n".join(content_parts) - - return ADR( - front_matter=front_matter, - content=content, - file_path=None, # Not loaded from disk - ) - - def _validate_adr_structure(self, adr: ADR) -> dict[str, Any]: - """Validate the ADR structure.""" - try: - # Use existing validation - validation_result = validate_adr(adr, self.adr_dir) - return { - "valid": validation_result.is_valid, - "errors": [str(error) for error in validation_result.errors], - "warnings": [str(warning) for warning in validation_result.warnings], - } - except Exception as e: - return { - "valid": False, - "errors": [f"Validation failed: {str(e)}"], - "warnings": [], - } - - def _validate_policy_completeness( - self, adr: ADR, creation_input: CreationInput - ) -> list[str]: - """Validate that ADR has extractable policy information. - - Returns list of warnings if policy is missing or insufficient. - - Note: This is a lightweight check. Policy construction guidance is provided - via the policy_guidance promptlet, which agents can use to construct policies. - """ - from ..core.policy_extractor import PolicyExtractor - - extractor = PolicyExtractor() - warnings = [] - - # Check if policy is extractable - if not extractor.has_extractable_policy(adr): - # Provide brief warning - detailed guidance is in policy_guidance promptlet - warnings.append( - "⚠️ No structured policy provided. Review the policy_guidance in the response " - "for instructions on constructing enforcement policies." - ) - - return warnings - - def _generate_adr_file(self, adr: ADR) -> str: - """Generate the ADR file.""" - # Create filename with slugified title - title_slug = re.sub(r"[^\w\s-]", "", adr.title.lower()) - title_slug = re.sub(r"[\s_-]+", "-", title_slug).strip("-") - file_path = Path(self.adr_dir) / f"{adr.id}-{title_slug}.md" - - # Ensure directory exists - Path(self.adr_dir).mkdir(parents=True, exist_ok=True) - - # Generate MADR format content - content = self._generate_madr_content(adr) - - # Write file - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - - return str(file_path) - - def _generate_madr_content(self, adr: ADR) -> str: - """Generate MADR format content for the ADR.""" - lines = [] - - # YAML front-matter - lines.append("---") - lines.append(f'id: "{adr.front_matter.id}"') - lines.append(f'title: "{adr.front_matter.title}"') - lines.append(f"status: {adr.front_matter.status}") - lines.append(f"date: {adr.front_matter.date}") - - if adr.front_matter.deciders: - lines.append(f"deciders: {adr.front_matter.deciders}") - - if adr.front_matter.tags: - lines.append(f"tags: {adr.front_matter.tags}") - - if adr.front_matter.supersedes: - lines.append(f"supersedes: {adr.front_matter.supersedes}") - - if adr.front_matter.superseded_by: - lines.append(f"superseded_by: {adr.front_matter.superseded_by}") - - if adr.front_matter.policy: - lines.append("policy:") - policy_dict = adr.front_matter.policy.model_dump(exclude_none=True) - for key, value in policy_dict.items(): - lines.append(f" {key}: {value}") - - lines.append("---") - lines.append("") - - # MADR content sections (already formatted in adr.content) - lines.append(adr.content) - - return "\n".join(lines) - - def _determine_review_requirements( - self, - adr: ADR, - conflicts: list[dict[str, Any]], - validation_result: dict[str, Any], - ) -> bool: - """Determine if human review is required before approval.""" - - # Always require review for conflicts - if conflicts: - return True - - # Require review for validation errors - if not validation_result.get("valid", True): - return True - - # Require review for significant architectural decisions - significant_terms = [ - "database", - "architecture", - "framework", - "security", - "performance", - "scalability", - "microservice", - "monolith", - ] - - adr_text = f"{adr.title} {adr.decision}".lower() - if any(term in adr_text for term in significant_terms): - return True - - # Default: minor decisions can be auto-approved if no conflicts - return False - - def _generate_next_steps_guidance( - self, adr_id: str, conflicts: list[dict[str, Any]], review_required: bool - ) -> str: - """Generate guidance for what the agent should do next.""" - - if conflicts: - conflict_ids = [c["adr_id"] for c in conflicts] - return ( - f"⚠️ {adr_id} has conflicts with {', '.join(conflict_ids)}. " - f"Review conflicts and consider using adr_supersede() if this decision should replace existing ones. " - f"Otherwise, revise the proposal to avoid conflicts." - ) - - if review_required: - return ( - f"📋 {adr_id} requires human review due to architectural significance. " - f"Have a human review the proposal, then use adr_approve() to activate it." - ) - - return ( - f"✅ {adr_id} is ready for approval. " - f"Use adr_approve('{adr_id}') to activate this decision and trigger policy enforcement." - ) - - def _generate_next_steps_list( - self, adr_id: str, conflicts: list[dict[str, Any]], review_required: bool - ) -> list[str]: - """Generate next steps as a list for the agent.""" - - if conflicts: - conflict_ids = [c["adr_id"] for c in conflicts] - return [ - f"Review conflicts with {', '.join(conflict_ids)}", - f"Consider using adr_supersede() if {adr_id} should replace existing decisions", - "Revise the proposal to avoid conflicts if superseding is not appropriate", - ] - - if review_required: - return [ - f"Have a human review {adr_id} due to architectural significance", - f"Use adr_approve('{adr_id}') after review to activate the decision", - ] - - return [ - f"Review the created ADR {adr_id}", - f"Use adr_approve('{adr_id}') to activate this decision", - "Trigger policy enforcement for the decision", - ] - - def _generate_policy_guidance( - self, adr: ADR, creation_input: CreationInput - ) -> dict[str, Any] | None: - """Generate policy guidance promptlet for agents. - - This method provides a structured promptlet that guides reasoning agents - through the process of constructing enforcement policies. Rather than - using regex to extract policies from text (which is fragile and redundant), - we provide the schema and let the agent reason about how to map their - architectural decision to the available policy capabilities. - - This follows the principle: "ADR Kit provides structure, agents provide intelligence." - - Returns: - Policy guidance dict with schema and reasoning prompts, or None if policy already provided - """ - # If policy was already provided, no guidance needed - if adr.front_matter.policy: - return { - "has_policy": True, - "message": "✅ Structured policy provided and validated", - } - - # No policy provided - guide the agent through policy construction - return { - "has_policy": False, - "message": ( - "📋 No policy provided. To enable automated enforcement, review your " - "architectural decision and construct a policy dict using the schema below." - ), - "agent_task": { - "role": "Policy Constructor", - "objective": ( - "Analyze your architectural decision and identify enforceable constraints " - "that can be automated. Map these constraints to the policy schema capabilities." - ), - "reasoning_steps": [ - "1. Review your decision text for enforceable rules (what you said 'yes' or 'no' to)", - "2. Identify which policy types apply (imports, patterns, architecture, config)", - "3. Map your constraints to the schema structures below", - "4. Construct a policy dict with only the relevant policy types", - "5. Call adr_create() again with the policy parameter", - ], - "focus": ( - "Look for explicit constraints in your decision: library choices, " - "code patterns, architectural boundaries, or configuration requirements." - ), - }, - "policy_capabilities": self._build_policy_reference(), - "example_workflow": { - "scenario": "Decision says: 'Use FastAPI. Don't use Flask or Django due to lack of async support.'", - "reasoning": "This is an import restriction - FastAPI is preferred, Flask/Django are disallowed.", - "constructed_policy": { - "imports": { - "disallow": ["flask", "django"], - "prefer": ["fastapi"], - }, - "rationales": ["Native async support required for I/O operations"], - }, - "next_call": "adr_create(..., policy={...})", - }, - "guidance": [ - "Only create policies for explicit constraints in your decision", - "Don't invent constraints that weren't in your decision", - "Multiple policy types can be combined in one policy dict", - "Rationales help explain why constraints exist", - ], - } - - def _build_policy_reference(self) -> dict[str, Any]: - """Build comprehensive policy structure reference documentation. - - This reference is provided just-in-time when agents need to construct - structured policies, avoiding context bloat in MCP tool docstrings. - """ - return { - "imports": { - "description": "Import/library restrictions", - "fields": { - "disallow": "List of banned libraries/modules", - "prefer": "List of recommended alternatives", - }, - "example": {"disallow": ["flask", "django"], "prefer": ["fastapi"]}, - }, - "patterns": { - "description": "Code pattern enforcement rules", - "fields": { - "patterns": "Dict of named pattern rules, each containing:", - " description": "Human-readable description", - " language": "Target language (python, typescript, etc.)", - " rule": "Regex pattern or structured query", - " severity": "error | warning | info", - " autofix": "Optional boolean for auto-fix support", - }, - "example": { - "patterns": { - "async_handlers": { - "description": "All FastAPI handlers must be async", - "language": "python", - "rule": r"def\s+\w+", - "severity": "error", - "autofix": False, - } - } - }, - }, - "architecture": { - "description": "Architecture policies (boundaries + required structure)", - "fields": { - "layer_boundaries": "List of forbidden dependencies", - " rule": "Format: 'source -> target' (e.g., 'frontend -> database')", - " action": "block | warn", - " message": "Error message to display", - " check": "Optional path pattern to scope the rule", - "required_structure": "List of required files/directories", - " path": "File or directory path (glob patterns supported)", - " description": "Why this structure is required", - }, - "example": { - "layer_boundaries": [ - { - "rule": "frontend -> database", - "action": "block", - "message": "Frontend must not access database directly", - "check": "src/frontend/**/*.py", - } - ], - "required_structure": [ - { - "path": "src/models/*.py", - "description": "Model layer required", - } - ], - }, - }, - "config_enforcement": { - "description": "Configuration enforcement for TypeScript/Python tools", - "fields": { - "typescript": "TypeScript config requirements", - " tsconfig": "Required tsconfig.json settings", - " eslintConfig": "Required ESLint config", - "python": "Python config requirements", - " ruff": "Required Ruff settings", - " mypy": "Required mypy settings", - }, - "example": { - "typescript": { - "tsconfig": { - "strict": True, - "compilerOptions": {"noImplicitAny": True}, - } - }, - "python": { - "ruff": {"lint": {"select": ["I"]}}, - "mypy": {"strict": True}, - }, - }, - }, - "rationales": { - "description": "List of reasons for the policies", - "example": [ - "FastAPI provides native async support", - "Better performance for I/O operations", - ], - }, - } - - def _quick_quality_gate(self, creation_input: CreationInput) -> dict[str, Any]: - """Quick quality gate that runs BEFORE ADR file creation. - - This pre-validation check runs deterministic quality checks on the input - to ensure decision quality meets the minimum threshold BEFORE creating - any files. This enables a correction loop without file pollution. - - Args: - creation_input: The input data for ADR creation - - Returns: - Quality assessment with passes_threshold boolean and feedback - """ - issues = [] - strengths = [] - score = 100 # Start perfect, deduct points for issues - QUALITY_THRESHOLD = 75 # B grade minimum (anything lower blocks creation) - - context_text = creation_input.context.lower() - decision_text = creation_input.decision.lower() - consequences_text = creation_input.consequences.lower() - - # Check 1: Specificity - detect generic/vague language - generic_terms = [ - "modern", - "good", - "best", - "framework", - "library", - "tool", - "better", - "nice", - ] - vague_count = sum( - 1 for term in generic_terms if term in decision_text or term in context_text - ) - - if vague_count >= 2: - score -= 15 - issues.append( - { - "category": "specificity", - "severity": "medium", - "issue": f"Decision uses {vague_count} generic terms ('{', '.join([t for t in generic_terms if t in decision_text or t in context_text][:3])}...')", - "suggestion": "Replace generic terms with specific technology names and versions", - "example_fix": "Instead of 'use a modern framework', write 'Use React 18 with TypeScript'", - } - ) - else: - strengths.append("Decision uses specific, concrete terminology") - - # Check 2: Balanced consequences - must have BOTH pros AND cons - positive_keywords = [ - "benefit", - "advantage", - "positive", - "improve", - "better", - "gain", - ] - negative_keywords = [ - "drawback", - "limitation", - "negative", - "cost", - "risk", - "challenge", - ] - - has_positives = any(kw in consequences_text for kw in positive_keywords) - has_negatives = any(kw in consequences_text for kw in negative_keywords) - - if not (has_positives and has_negatives): - score -= 25 - issues.append( - { - "category": "balance", - "severity": "high", - "issue": "Consequences are one-sided (only pros or only cons)", - "suggestion": "Document BOTH positive and negative consequences - every technical decision has trade-offs", - "example_fix": "Add '### Negative' section listing drawbacks, limitations, or risks", - "why_it_matters": "Balanced trade-off analysis enables informed decision-making", - } - ) - else: - strengths.append("Consequences show balanced trade-off analysis") - - # Check 3: Context quality - sufficient detail - context_length = len(creation_input.context) - - if context_length < 50: - score -= 20 - issues.append( - { - "category": "context", - "severity": "high", - "issue": f"Context is too brief ({context_length} characters)", - "suggestion": "Expand context to explain WHY this decision is needed: current state, requirements, drivers", - "example_fix": "Add: business requirements, technical constraints, user needs", - } - ) - elif context_length >= 150: - strengths.append("Context provides detailed problem background") - - # Check 4: Explicit constraints - policy-ready language - import re - - constraint_patterns = [ - r"\bdon[''']t\s+use\b", - r"\bmust\s+not\s+use\b", - r"\bavoid\b", - r"\bmust\s+(?:use|have|be)\b", - r"\ball\s+\w+\s+must\b", - ] - - has_explicit_constraints = any( - re.search(pattern, decision_text, re.IGNORECASE) - for pattern in constraint_patterns - ) - - if not has_explicit_constraints: - score -= 15 - issues.append( - { - "category": "policy_readiness", - "severity": "medium", - "issue": "Decision lacks explicit constraints (enables policy extraction)", - "suggestion": "Add explicit constraints using 'Don't use X', 'Must use Y', 'All Z must...'", - "example_fix": "Use FastAPI for APIs. **Don't use Flask** or Django.", - "why_it_matters": "Explicit constraints enable automated policy enforcement (Task 2)", - } - ) - else: - strengths.append( - "Decision includes explicit constraints ready for policy extraction" - ) - - # Check 5: Alternatives - critical for 'disallow' policies - if not creation_input.alternatives or len(creation_input.alternatives) < 20: - score -= 15 - issues.append( - { - "category": "alternatives", - "severity": "medium", - "issue": "Missing or minimal alternatives section", - "suggestion": "Document rejected alternatives with specific reasons", - "example_fix": "### MySQL\\n**Rejected**: Weaker JSON support\\n\\n### MongoDB\\n**Rejected**: Conflicts with ACID requirements", - "why_it_matters": "Alternatives section enables extraction of 'disallow' policies", - } - ) - else: - strengths.append("Alternatives documented (enables disallow policies)") - - # Check 6: Decision completeness - decision_length = len(creation_input.decision) - - if decision_length < 30: - score -= 10 - issues.append( - { - "category": "completeness", - "severity": "low", - "issue": f"Decision is very brief ({decision_length} characters)", - "suggestion": "Expand decision with: specific technology, scope, and constraints", - "example_fix": "Use PostgreSQL 15 for all application data. Deploy on AWS RDS with Multi-AZ.", - } - ) - - # Clamp score to valid range - score = max(0, min(100, score)) - - # Determine grade (A=90+, B=75+, C=60+, D=40+, F=<40) - if score >= 90: - grade = "A" - elif score >= 75: - grade = "B" - elif score >= 60: - grade = "C" - elif score >= 40: - grade = "D" - else: - grade = "F" - - passes_threshold = score >= QUALITY_THRESHOLD - - # Generate summary - if passes_threshold: - summary = f"Decision quality is acceptable (Grade {grade}, {score}/100). {len(issues)} minor improvements suggested." - else: - summary = f"Decision quality is below threshold (Grade {grade}, {score}/100). {len(issues)} issues must be addressed before ADR creation." - - # Generate prioritized recommendations - high_priority = [i for i in issues if i["severity"] == "high"] - medium_priority = [i for i in issues if i["severity"] == "medium"] - - recommendations = [] - if high_priority: - recommendations.append( - f"🔴 **High Priority**: Fix {len(high_priority)} critical issues first" - ) - for issue in high_priority[:2]: # Top 2 high priority - recommendations.append( - f" - {issue['category'].title()}: {issue['suggestion']}" - ) - - if medium_priority and score < QUALITY_THRESHOLD: - recommendations.append( - f"🟡 **Medium Priority**: Address {len(medium_priority)} quality issues" - ) - for issue in medium_priority[:2]: # Top 2 medium priority - recommendations.append( - f" - {issue['category'].title()}: {issue['suggestion']}" - ) - - # Next steps vary by quality score - next_steps = [] - if not passes_threshold: - next_steps.append( - "⛔ **ADR Creation Blocked**: Quality score below threshold" - ) - next_steps.append( - "📝 **Action Required**: Address the issues above and resubmit" - ) - next_steps.append( - "💡 **Tip**: Focus on high-priority issues first for maximum impact" - ) - else: - next_steps.append( - "✅ **Quality Gate Passed**: ADR will be created with this input" - ) - if issues: - next_steps.append( - f"💡 **Optional**: Consider addressing {len(issues)} suggestions for even higher quality" - ) - - return { - "quality_score": score, - "grade": grade, - "passes_threshold": passes_threshold, - "threshold": QUALITY_THRESHOLD, - "summary": summary, - "issues": issues, - "strengths": strengths, - "recommendations": recommendations, - "next_steps": next_steps, - } - - def _assess_decision_quality( - self, adr: ADR, creation_input: CreationInput - ) -> dict[str, Any]: - """Assess decision quality and provide targeted feedback. - - This implements Task 1 of the two-step ADR creation flow: - - Task 1 (this method): Assess decision quality and provide guidance - - Task 2 (_generate_policy_guidance): Extract enforceable policies - - The assessment identifies common quality issues and provides actionable - feedback to help agents improve their ADRs. It follows the principle: - "ADR Kit provides structure, agents provide intelligence." - - Args: - adr: The created ADR - creation_input: The input data used to create the ADR - - Returns: - Quality assessment with issues found and improvement suggestions - """ - issues = [] - strengths = [] - score = 100 # Start with perfect score, deduct for issues - - # Check 1: Specificity (are technology names specific?) - generic_terms = [ - "modern", - "good", - "best", - "framework", - "library", - "tool", - "system", - "platform", - ] - decision_lower = adr.decision.lower() - title_lower = adr.title.lower() - - vague_terms_found = [ - term - for term in generic_terms - if term in decision_lower or term in title_lower - ] - if vague_terms_found: - issues.append( - { - "category": "specificity", - "severity": "medium", - "issue": f"Decision uses generic terms: {', '.join(vague_terms_found)}", - "suggestion": ( - "Replace generic terms with specific technology names and versions. " - "Example: Instead of 'modern framework', use 'React 18' or 'FastAPI 0.104'." - ), - "example_fix": { - "bad": "Use a modern web framework", - "good": "Use React 18 with TypeScript for frontend development", - }, - } - ) - score -= 15 - else: - strengths.append("Decision is specific with clear technology choices") - - # Check 2: Balanced consequences (are there both pros AND cons?) - consequences_lower = adr.consequences.lower() - has_positives = any( - word in consequences_lower - for word in [ - "benefit", - "advantage", - "positive", - "+", - "pro:", - "pros:", - "good", - "better", - "improve", - ] - ) - has_negatives = any( - word in consequences_lower - for word in [ - "drawback", - "disadvantage", - "negative", - "-", - "con:", - "cons:", - "risk", - "limitation", - "downside", - "trade-off", - "tradeoff", - ] - ) - - if not (has_positives and has_negatives): - issues.append( - { - "category": "balance", - "severity": "high", - "issue": "Consequences appear one-sided (missing pros or cons)", - "suggestion": ( - "Every technical decision has trade-offs. Document BOTH positive outcomes " - "AND negative consequences honestly. Use structure like:\n" - "### Positive\n- Benefit 1\n- Benefit 2\n\n" - "### Negative\n- Drawback 1\n- Drawback 2" - ), - "why_it_matters": ( - "Balanced consequences help future decision-makers understand when to " - "reconsider this choice. Hiding drawbacks leads to technical debt." - ), - } - ) - score -= 25 - else: - strengths.append("Consequences document both benefits and drawbacks") - - # Check 3: Context quality (does it explain WHY?) - context_length = len(adr.context.strip()) - if context_length < 50: - issues.append( - { - "category": "context", - "severity": "high", - "issue": "Context is too brief (less than 50 characters)", - "suggestion": ( - "Context should explain WHY this decision is needed. Include:\n" - "- The problem or opportunity\n" - "- Current state and why it's insufficient\n" - "- Requirements that must be met\n" - "- Constraints or limitations" - ), - "example": ( - "Good Context: 'We need ACID transactions for financial data integrity. " - "Current SQLite setup doesn't support concurrent writes from multiple services. " - "Requires complex queries with joins and JSON document storage.'" - ), - } - ) - score -= 20 - else: - strengths.append("Context provides sufficient detail about the problem") - - # Check 4: Explicit constraints (for policy extraction) - constraint_patterns = [ - r"\bdon[''']t\s+use\b", - r"\bavoid\b.*\b(?:using|use)\b", - r"\bmust\s+(?:not\s+)?(?:use|have|be)\b", - r"\ball\s+\w+\s+must\b", - r"\brequired?\b", - r"\bprohibited?\b", - ] - - has_explicit_constraints = any( - re.search(pattern, decision_lower, re.IGNORECASE) - for pattern in constraint_patterns - ) - - if not has_explicit_constraints: - issues.append( - { - "category": "policy_readiness", - "severity": "medium", - "issue": "Decision lacks explicit constraints for policy extraction", - "suggestion": ( - "Use explicit constraint language to enable automated policy extraction:\n" - "- 'Don't use X' / 'Avoid X'\n" - "- 'Use Y instead of X'\n" - "- 'All X must have Y'\n" - "- 'Must not access'\n" - "Example: 'Use FastAPI. Don't use Flask or Django due to lack of async support.'" - ), - "why_it_matters": ( - "Explicit constraints enable Task 2 (policy extraction) to generate " - "enforceable rules automatically. Vague language can't be automated." - ), - } - ) - score -= 15 - else: - strengths.append( - "Decision includes explicit constraints ready for policy extraction" - ) - - # Check 5: Alternatives (critical for policy extraction) - if ( - not creation_input.alternatives - or len(creation_input.alternatives.strip()) < 20 - ): - issues.append( - { - "category": "alternatives", - "severity": "medium", - "issue": "Missing or insufficient alternatives documentation", - "suggestion": ( - "Document what alternatives you considered and WHY you rejected each one. " - "This is CRITICAL for policy extraction - rejected alternatives often become " - "'disallow' policies.\n\n" - "Structure:\n" - "### Alternative Name\n" - "**Rejected**: Specific reason for rejection\n" - "- Pros: ...\n" - "- Cons: ...\n" - "- Why not: ..." - ), - "example": ( - "### Flask\n" - "**Rejected**: Lacks native async support.\n" - "- Pros: Lightweight, huge ecosystem\n" - "- Cons: No native async, requires Quart\n" - "- Why not: Async support is critical for our use case" - ), - "why_it_matters": ( - "Alternatives with clear rejection reasons enable extraction of 'disallow' policies. " - "Example: 'Rejected Flask' becomes {'imports': {'disallow': ['flask']}}" - ), - } - ) - score -= 15 - else: - strengths.append( - "Alternatives documented with clear rejection reasons (enables 'disallow' policies)" - ) - - # Check 6: Decision length (too short is usually vague) - decision_length = len(adr.decision.strip()) - if decision_length < 30: - issues.append( - { - "category": "completeness", - "severity": "medium", - "issue": "Decision section is very brief (less than 30 characters)", - "suggestion": ( - "Decision should clearly state:\n" - "1. What technology/pattern/approach is chosen\n" - "2. Scope of applicability ('All new services', 'Frontend only')\n" - "3. Explicit constraints ('Don't use X', 'Must have Y')\n" - "4. Migration path if replacing existing technology" - ), - } - ) - score -= 10 - - # Determine overall quality grade - if score >= 90: - grade = "A" - summary = "Excellent ADR - ready for policy extraction" - elif score >= 75: - grade = "B" - summary = "Good ADR - minor improvements would help" - elif score >= 60: - grade = "C" - summary = "Acceptable ADR - several areas need improvement" - elif score >= 40: - grade = "D" - summary = ( - "Weak ADR - significant improvements needed before policy extraction" - ) - else: - grade = "F" - summary = "Poor ADR - needs major revision" - - return { - "quality_score": score, - "grade": grade, - "summary": summary, - "issues": issues, - "strengths": strengths, - "recommendations": self._generate_quality_recommendations(issues), - "next_steps": self._generate_quality_next_steps(issues, score), - } - - def _generate_quality_recommendations( - self, issues: list[dict[str, Any]] - ) -> list[str]: - """Generate prioritized recommendations based on quality issues. - - Args: - issues: List of quality issues found - - Returns: - Prioritized list of actionable recommendations - """ - if not issues: - return [ - "✅ Your ADR meets quality standards", - "Consider reviewing the policy_guidance to add automated enforcement", - ] - - recommendations = [] - - # Prioritize by severity - high_severity = [issue for issue in issues if issue["severity"] == "high"] - medium_severity = [issue for issue in issues if issue["severity"] == "medium"] - - if high_severity: - recommendations.append( - f"🔴 High Priority: Address {len(high_severity)} critical quality issue(s):" - ) - for issue in high_severity: - recommendations.append(f" - {issue['issue']}") - recommendations.append(f" → {issue['suggestion']}") - - if medium_severity: - recommendations.append( - f"🟡 Medium Priority: Improve {len(medium_severity)} quality aspect(s):" - ) - for issue in medium_severity: - recommendations.append(f" - {issue['issue']}") - - return recommendations - - def _generate_quality_next_steps( - self, issues: list[dict[str, Any]], score: int - ) -> list[str]: - """Generate next steps based on quality assessment. - - Args: - issues: List of quality issues found - score: Overall quality score - - Returns: - List of recommended next steps - """ - if score >= 80: - # High quality - ready to proceed - return [ - "Your ADR is high quality and ready for review", - "Review the policy_guidance to add automated enforcement policies", - "Use adr_approve() after human review to activate the decision", - ] - elif score >= 60: - # Acceptable but could improve - return [ - "ADR is acceptable but could be strengthened", - "Consider addressing the quality issues listed above", - "You can proceed with approval or revise for better policy extraction", - ] - else: - # Needs significant improvement - return [ - "⚠️ ADR quality is below recommended threshold", - "Strongly recommend revising before approval:", - " 1. Address high-priority issues (context, balance, specificity)", - " 2. Add alternatives with rejection reasons (enables policy extraction)", - " 3. Use explicit constraint language ('Don't use', 'Must have')", - "After revision, create a new ADR with improved content", - ] +from adr_kit.decision.workflows.creation import * # noqa: F401,F403 diff --git a/adr_kit/workflows/decision_guidance.py b/adr_kit/workflows/decision_guidance.py index 0135691..dbb5afa 100644 --- a/adr_kit/workflows/decision_guidance.py +++ b/adr_kit/workflows/decision_guidance.py @@ -1,491 +1,2 @@ -"""Decision Quality Guidance - Promptlets for high-quality ADR creation. - -This module provides comprehensive guidance for agents writing architectural decisions. -It follows the "ADR Kit provides structure, agents provide intelligence" principle by -offering focused promptlets that guide reasoning without prescribing exact outputs. -""" - -from typing import Any - - -def build_decision_guidance( - include_examples: bool = True, focus_area: str | None = None -) -> dict[str, Any]: - """Build comprehensive decision quality guidance promptlet for agents. - - This is Task 1 of the two-step ADR creation flow: - - Task 1 (this module): Guide agents to write high-quality decision content - - Task 2 (creation.py): Extract enforceable policies from the decision - - The guidance follows the reasoning-agent promptlet architecture pattern, - providing structure and letting the agent's intelligence fill in the details. - - Args: - include_examples: Whether to include good vs bad ADR examples - focus_area: Optional focus area for tailored examples (e.g., 'database', 'frontend') - - Returns: - Comprehensive promptlet with ADR structure, quality criteria, examples, and guidance - """ - guidance = { - "agent_task": { - "role": "Architectural Decision Documenter", - "objective": ( - "Document a significant technical decision with clarity, completeness, " - "and sufficient detail to enable automated policy extraction and future reasoning." - ), - "reasoning_steps": [ - "1. Understand the PROBLEM or OPPORTUNITY that prompted this decision (Context)", - "2. State the DECISION explicitly - what specific technology/pattern/approach are you choosing?", - "3. Analyze CONSEQUENCES - document both positive outcomes AND negative trade-offs", - "4. Document ALTERNATIVES - what did you consider and why did you reject each option?", - "5. Identify DECIDERS - who made or approved this choice?", - "6. Extract CONSTRAINTS - what enforceable rules emerge from this decision?", - ], - "focus": ( - "Create a decision document that is specific, actionable, complete, and " - "policy-extraction-ready. Good Task 1 output makes Task 2 (policy extraction) trivial." - ), - }, - "adr_structure": { - "overview": ( - "ADRs follow MADR (Markdown Architectural Decision Records) format with " - "four main sections. Each serves a distinct purpose in documenting architectural reasoning." - ), - "sections": { - "context": { - "purpose": "WHY this decision is needed - the problem or opportunity", - "required": True, - "what_to_include": [ - "The problem statement or opportunity being addressed", - "Current state and why it's insufficient", - "Requirements that must be met", - "Constraints or limitations to consider", - "Business or technical drivers", - ], - "what_to_avoid": [ - "Describing the solution (that's the Decision section)", - "Being too vague ('We need a database')", - "Skipping the 'why' - context must explain the need", - ], - "quality_bar": "After reading Context, someone should understand the problem without reading the Decision.", - }, - "decision": { - "purpose": "WHAT you're choosing - the specific technology, pattern, or approach", - "required": True, - "what_to_include": [ - "Explicit statement of what is being chosen", - "Specific technology names and versions if relevant", - "Explicit constraints ('Don't use X', 'Must have Y')", - "Scope of applicability ('All new services', 'Frontend only')", - ], - "what_to_avoid": [ - "Being generic ('Use a modern framework' → 'Use React 18')", - "Ambiguity about scope ('sometimes', 'maybe', 'consider')", - "Missing explicit constraints (makes policy extraction harder)", - ], - "quality_bar": "After reading Decision, it should be crystal clear what technology/approach was chosen and what's forbidden.", - }, - "consequences": { - "purpose": "Trade-offs - both POSITIVE and NEGATIVE outcomes of this decision", - "required": True, - "what_to_include": [ - "Positive consequences (benefits, improvements)", - "Negative consequences (drawbacks, limitations)", - "Risks and how they'll be mitigated", - "Impact on team, operations, or future flexibility", - "Known pitfalls or gotchas (AI-centric warnings)", - ], - "what_to_avoid": [ - "Only listing benefits (every decision has trade-offs)", - "Generic statements ('It will work well')", - "Hiding or minimizing negative consequences", - ], - "quality_bar": "Consequences should list both pros AND cons. If you see only positives, something's missing.", - "structure_tip": "Use subsections: ### Positive, ### Negative, ### Risks, ### Mitigation", - }, - "alternatives": { - "purpose": "What ELSE did you consider and WHY did you reject each option?", - "required": False, - "importance": "CRITICAL for policy extraction - rejected alternatives often become 'disallow' policies", - "what_to_include": [ - "Each alternative considered", - "Pros and cons of each", - "Specific reason for rejection", - "Under what conditions you might reconsider", - ], - "what_to_avoid": [ - "Saying 'We considered other options' without naming them", - "Not explaining WHY each was rejected", - "Unfairly dismissing alternatives", - ], - "quality_bar": "Each alternative should have a clear rejection reason that could become a policy.", - "example_structure": "### Flask\n**Rejected**: Lacks native async support.\n- Pros: ...\n- Cons: ...\n- Why not: ...", - }, - }, - }, - "quality_criteria": { - "specific": { - "description": "Use exact technology names, not generic categories", - "good": "Use PostgreSQL 15 as the primary database", - "bad": "Use a SQL database", - "why_it_matters": "Specific decisions enable precise policy extraction and clear implementation guidance", - }, - "actionable": { - "description": "Team can implement this decision immediately", - "good": "Use FastAPI for all new backend services. Migrate existing Flask services opportunistically.", - "bad": "Consider using FastAPI at some point", - "why_it_matters": "Vague decisions lead to inconsistent implementation and drift", - }, - "complete": { - "description": "All required fields filled with meaningful content", - "good": "Context explains the problem, Decision states the choice, Consequences list pros AND cons, Alternatives show what was rejected", - "bad": "Context: 'We need this.' Decision: 'Use X.' Consequences: 'It's good.'", - "why_it_matters": "Incomplete ADRs don't provide enough information for future reasoning or policy extraction", - }, - "policy_ready": { - "description": "Constraints are stated explicitly for automated extraction", - "good": "Use FastAPI. **Don't use Flask** or Django due to lack of native async support.", - "bad": "FastAPI is preferred in most cases", - "why_it_matters": "Explicit constraints ('Don't use X', 'Must have Y') enable Task 2 to extract enforceable policies", - }, - "balanced": { - "description": "Documents both benefits AND drawbacks honestly", - "good": "+ Native async support, + Auto docs, - Smaller ecosystem, - Team learning curve", - "bad": "FastAPI is perfect for everything", - "why_it_matters": "Unbalanced ADRs don't help future decision-makers understand when to reconsider", - }, - }, - "anti_patterns": { - "too_vague": { - "bad": "Use a modern web framework", - "good": "Use React 18 with TypeScript for frontend development", - "fix": "Replace generic categories with specific technology names and versions", - }, - "no_trade_offs": { - "bad": "PostgreSQL is the best database. It has ACID compliance and great performance.", - "good": "+ ACID compliance, + Great performance, + Rich features, - Higher resource usage than SQLite, - Requires operational expertise", - "fix": "Always list both positive AND negative consequences. Every decision has trade-offs.", - }, - "missing_context": { - "bad": "Decision: Use PostgreSQL", - "good": "Context: We need ACID transactions for financial data integrity and support for concurrent writes. Decision: Use PostgreSQL.", - "fix": "Explain WHY before stating WHAT. Context must justify the decision.", - }, - "no_alternatives": { - "bad": "(No alternatives section)", - "good": "### MySQL\nRejected: Weaker JSON support and extensibility vs PostgreSQL.\n### MongoDB\nRejected: Our data is highly relational, ACID compliance is critical.", - "fix": "Document what else you considered and specific reasons for rejection. This enables 'disallow' policy extraction.", - }, - "weak_constraints": { - "bad": "FastAPI is recommended for new services", - "good": "Use FastAPI for all new services. **Don't use Flask** or Django for new development.", - "fix": "Use explicit constraint language: 'Don't use', 'Must have', 'All X must Y'. This enables automated policy extraction.", - }, - }, - "example_workflow": { - "description": "How Task 1 (decision quality) enables Task 2 (policy extraction)", - "scenario": "Team needs to choose a web framework for a new API service", - "bad_adr": { - "title": "Use a web framework", - "context": "We need a framework for the API", - "decision": "Use a modern framework with good performance", - "consequences": "It will work well for our needs", - "alternatives": None, - "why_bad": [ - "Too vague - 'modern framework' could mean anything", - "No specific technology named", - "Consequences are generic platitudes", - "No alternatives documented", - "No explicit constraints for policy extraction", - ], - "task_2_result": "❌ Cannot extract any policies - no specific constraints stated", - }, - "good_adr": { - "title": "Use FastAPI for API Service", - "context": ( - "New API service requires async I/O for handling 1000+ concurrent connections. " - "Need automatic OpenAPI documentation for external partners. Team has Python experience." - ), - "decision": ( - "Use **FastAPI** as the web framework for all new backend API services. " - "**Don't use Flask or Django** for new services - they lack native async support. " - "Existing Flask services can be migrated opportunistically." - ), - "consequences": ( - "### Positive\n" - "- Native async/await support enables 10x higher concurrent connections\n" - "- Automatic OpenAPI/Swagger documentation reduces API maintenance burden\n" - "- Strong typing with Pydantic catches errors at API boundaries\n" - "- Modern Python features (3.10+) and excellent IDE support\n\n" - "### Negative\n" - "- Smaller plugin ecosystem compared to Django/Flask\n" - "- Team needs training on async/await patterns\n" - "- Async code can be harder to debug than synchronous code\n\n" - "### Risks\n" - "- Team unfamiliarity with async Python could cause subtle bugs\n\n" - "### Mitigation\n" - "- Provide async Python training (scheduled Q1 2026)\n" - "- Create internal FastAPI template with best practices" - ), - "alternatives": ( - "### Flask\n" - "**Rejected**: Lacks native async support.\n" - "- Pros: Lightweight, huge ecosystem, team familiarity\n" - "- Cons: No native async (requires Quart/ASGI), manual validation\n" - "- Why not: Async support is bolt-on, not native\n\n" - "### Django\n" - "**Rejected**: Too heavyweight for API-only services.\n" - "- Pros: Mature, batteries-included, excellent admin\n" - "- Cons: Synchronous by default, opinionated structure\n" - "- Why not: Don't need ORM or admin for API-only service" - ), - "why_good": [ - "Specific technology named (FastAPI)", - "Context explains requirements (async I/O, API docs)", - "Decision includes explicit constraints ('Don't use Flask or Django')", - "Consequences balanced (pros AND cons, risks AND mitigation)", - "Alternatives documented with clear rejection reasons", - "Policy-extraction-ready language", - ], - "task_2_result": ( - "✅ Can extract clear policies:\n" - "{'imports': {'disallow': ['flask', 'django'], 'prefer': ['fastapi']}, " - "'rationales': ['Native async support required', 'Automatic API documentation reduces maintenance']}" - ), - }, - "key_insight": ( - "Good Task 1 output (clear constraints + rejected alternatives) " - "makes Task 2 (policy extraction) trivial. The agent can directly " - "map 'Don't use Flask' to {'imports': {'disallow': ['flask']}}." - ), - }, - "connection_to_task_2": { - "overview": ( - "Task 1 (Decision Quality) and Task 2 (Policy Construction) work together. " - "The quality of your decision content directly impacts how easily policies can be extracted." - ), - "how_task_1_enables_task_2": [ - { - "decision_pattern": "Use FastAPI. Don't use Flask or Django.", - "extracted_policy": "{'imports': {'disallow': ['flask', 'django'], 'prefer': ['fastapi']}}", - "principle": "Explicit 'Don't use X' statements become 'disallow' policies", - }, - { - "decision_pattern": "All FastAPI handlers must be async functions", - "extracted_policy": "{'patterns': {'async_handlers': {'rule': 'async def', 'severity': 'error'}}}", - "principle": "'All X must be Y' statements become pattern policies", - }, - { - "decision_pattern": "Frontend must not access database directly", - "extracted_policy": "{'architecture': {'layer_boundaries': [{'rule': 'frontend -> database', 'action': 'block'}]}}", - "principle": "'X must not access Y' becomes architecture boundary", - }, - { - "decision_pattern": "TypeScript strict mode required for all frontend code", - "extracted_policy": "{'config_enforcement': {'typescript': {'tsconfig': {'strict': True}}}}", - "principle": "Config requirements become config enforcement policies", - }, - ], - "best_practices": [ - "Use explicit constraint language: 'Don't use', 'Must have', 'All X must Y'", - "Document alternatives with clear rejection reasons (enables 'disallow' extraction)", - "Be specific about technology names (not 'a modern framework', but 'React 18')", - "State scope clearly ('All new services', 'Frontend only')", - ], - }, - "dos_and_donts": { - "dos": [ - "✅ Use specific technology names and versions", - "✅ Document both positive AND negative consequences", - "✅ Explain WHY in Context before stating WHAT in Decision", - "✅ List alternatives with clear rejection reasons", - "✅ Use explicit constraint language ('Don't use', 'Must have')", - "✅ Include risks and mitigation strategies", - "✅ State scope of applicability clearly", - "✅ Identify who made the decision (deciders)", - ], - "donts": [ - "❌ Don't be vague or generic ('Use a modern framework')", - "❌ Don't only list benefits - every decision has trade-offs", - "❌ Don't skip Context - explain the problem first", - "❌ Don't forget Alternatives - they become 'disallow' policies", - "❌ Don't use weak language ('consider', 'maybe', 'sometimes')", - "❌ Don't hide negative consequences or risks", - "❌ Don't make decisions sound perfect - honest trade-offs matter", - ], - }, - "next_steps": [ - "1. Follow this guidance to draft your ADR content", - "2. Use adr_create() with your title, context, decision, consequences, and alternatives", - "3. Review the policy_guidance in the response to construct enforcement policies (Task 2)", - "4. Call adr_create() again with the policy parameter if you want automated enforcement", - ], - } - - # Add examples if requested - if include_examples: - guidance["examples"] = _build_examples(focus_area) - - return guidance - - -def _build_examples(focus_area: str | None = None) -> dict[str, Any]: - """Build good vs bad ADR examples. - - Args: - focus_area: Optional focus to tailor examples (e.g., 'database', 'frontend') - - Returns: - Dictionary with categorized examples - """ - examples = { - "database": { - "good": { - "title": "Use PostgreSQL for Primary Database", - "context": ( - "Application requires ACID transactions for financial data integrity. " - "Need support for complex queries with joins, concurrent writes from multiple services, " - "and JSON document storage for flexible user metadata. Team has SQL experience." - ), - "decision": ( - "Use **PostgreSQL 15** as the primary database for all application data. " - "**Don't use MySQL** (weaker JSON support) or **MongoDB** (eventual consistency conflicts with financial requirements). " - "Deploy on AWS RDS with Multi-AZ for high availability." - ), - "consequences": ( - "### Positive\n" - "- ACID compliance guarantees data consistency for transactions\n" - "- Rich feature set: JSON, full-text search, advanced indexing\n" - "- Excellent query planner handles complex joins efficiently\n" - "- Mature tooling and ecosystem\n\n" - "### Negative\n" - "- Higher resource usage (memory/CPU) than simpler databases\n" - "- Requires operational expertise for tuning and maintenance\n" - "- Vertical scaling limits (single-server architecture)\n\n" - "### Risks & Mitigation\n" - "- Risk: Poor indexing causes performance issues at scale\n" - "- Mitigation: Use connection pooling (PgBouncer), monitor with pg_stat_statements" - ), - "alternatives": ( - "### MySQL\n" - "**Rejected**: Weaker JSON support and extensibility compared to PostgreSQL.\n\n" - "### MongoDB\n" - "**Rejected**: Eventual consistency model conflicts with financial transaction requirements. " - "ACID transactions added in 4.0 but less mature than PostgreSQL." - ), - }, - "bad": { - "title": "Use a Database", - "context": "We need to store data", - "decision": "Use PostgreSQL", - "consequences": "PostgreSQL is good for data storage", - "alternatives": None, - }, - }, - "frontend": { - "good": { - "title": "Use React 18 with TypeScript for Frontend", - "context": ( - "Building complex interactive dashboard with real-time data updates. " - "Need component reusability, strong typing to catch errors early, and excellent developer tooling. " - "Team has JavaScript experience but new to TypeScript." - ), - "decision": ( - "Use **React 18** with **TypeScript** for all frontend development. " - "**Don't use Vue or Angular** - smaller ecosystems and steeper learning curves for our use case. " - "All new components must be written in TypeScript with strict mode enabled." - ), - "consequences": ( - "### Positive\n" - "- Huge ecosystem of components and libraries\n" - "- TypeScript catches errors at compile time, reducing runtime bugs\n" - "- Concurrent features in React 18 improve perceived performance\n" - "- Excellent IDE support and developer experience\n\n" - "### Negative\n" - "- TypeScript learning curve for team\n" - "- More boilerplate than plain JavaScript\n" - "- React hooks mental model takes time to master\n\n" - "### Risks & Mitigation\n" - "- Risk: Team struggles with TypeScript\n" - "- Mitigation: 2-week TypeScript training, pair programming on first components" - ), - "alternatives": ( - "### Vue 3\n" - "**Rejected**: Smaller ecosystem, less corporate backing than React.\n\n" - "### Angular\n" - "**Rejected**: Steep learning curve, very opinionated, our team has React experience not Angular." - ), - }, - "bad": { - "title": "Use a Frontend Framework", - "context": "We need to build a UI", - "decision": "Use React because it's popular", - "consequences": "React will work well", - "alternatives": None, - }, - }, - "generic": { - "good": { - "title": "Use FastAPI for Backend API Services", - "context": ( - "Building API service for mobile app with 1000+ concurrent users. " - "Need automatic API documentation for mobile team, async I/O for performance, " - "and strong typing for reliability. Team knows Python." - ), - "decision": ( - "Use **FastAPI** for all new backend API services. " - "**Don't use Flask** (no native async) or **Django** (too heavyweight for API-only). " - "Existing Flask services can migrate opportunistically." - ), - "consequences": ( - "### Positive\n" - "- Native async/await for 10x better concurrent performance\n" - "- Automatic OpenAPI docs reduce coordination overhead with mobile team\n" - "- Pydantic validation catches errors at API boundaries\n\n" - "### Negative\n" - "- Smaller ecosystem than Flask/Django\n" - "- Team needs async Python training\n" - "- Debugging async code is harder\n\n" - "### Mitigation\n" - "- Async Python training scheduled Q1 2026\n" - "- Internal template with best practices" - ), - "alternatives": ( - "### Flask\n" - "**Rejected**: No native async support, would require Quart/ASGI.\n\n" - "### Django\n" - "**Rejected**: Too heavyweight for API-only service, don't need ORM/admin." - ), - }, - "bad": { - "title": "Use Python Web Framework", - "context": "Need backend framework", - "decision": "Use FastAPI", - "consequences": "FastAPI is fast and modern", - "alternatives": None, - }, - }, - } - - # Return focused examples if specified - if focus_area and focus_area in examples: - return { - "focus": focus_area, - "good_example": examples[focus_area]["good"], - "bad_example": examples[focus_area]["bad"], - "comparison": ( - "Notice how the good example is specific, documents trade-offs, " - "includes alternatives with rejection reasons, and uses explicit constraint language." - ), - } - - # Return all examples - return { - "by_category": examples, - "comparison": ( - "Good examples are specific, document both pros and cons, explain context thoroughly, " - "list alternatives with clear rejection reasons, and use explicit constraint language. " - "Bad examples are vague, incomplete, and don't provide enough information for policy extraction." - ), - } +from adr_kit.decision.guidance.decision_guidance import * # noqa: F401,F403 +from adr_kit.decision.guidance.decision_guidance import _build_examples # noqa: F401 diff --git a/adr_kit/workflows/planning.py b/adr_kit/workflows/planning.py index 5441fff..adaf65f 100644 --- a/adr_kit/workflows/planning.py +++ b/adr_kit/workflows/planning.py @@ -4,10 +4,11 @@ from dataclasses import dataclass from typing import Any +from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowResult + from ..contract.builder import ConstraintsContractBuilder from ..contract.models import ConstraintsContract from ..core.model import ADR -from .base import BaseWorkflow, WorkflowResult @dataclass diff --git a/adr_kit/workflows/preflight.py b/adr_kit/workflows/preflight.py index a41e455..334dee1 100644 --- a/adr_kit/workflows/preflight.py +++ b/adr_kit/workflows/preflight.py @@ -1,528 +1 @@ -"""Preflight Workflow - Check if technical choice requires ADR before proceeding.""" - -from dataclasses import dataclass -from typing import Any - -from ..contract.builder import ConstraintsContractBuilder -from ..contract.models import ConstraintsContract -from .base import BaseWorkflow, WorkflowResult - - -@dataclass -class PreflightInput: - """Input for preflight workflow.""" - - choice: str # Technical choice being evaluated (e.g., "postgresql", "react", "microservices") - context: dict[str, Any] | None = None # Additional context about the choice - category: str | None = ( - None # Category hint (database, frontend, architecture, etc.) - ) - - -@dataclass -class PreflightDecision: - """Result of preflight evaluation.""" - - status: str # ALLOWED, REQUIRES_ADR, BLOCKED - reasoning: str # Human-readable explanation - conflicting_adrs: list[str] # ADR IDs that conflict with this choice - related_adrs: list[str] # ADR IDs that are related but don't conflict - required_policies: list[str] # Policies that would need to be addressed in ADR - next_steps: str # What the agent should do next - urgency: str # LOW, MEDIUM, HIGH - how important it is to create ADR - - -class PreflightWorkflow(BaseWorkflow): - """ - Preflight Workflow evaluates technical choices against existing ADRs. - - This is one of the most important entry points for agents - it prevents - architectural violations before they happen and guides agents toward - compliant technical choices. - - Workflow Steps: - 1. Load current constraints contract - 2. Categorize the technical choice - 3. Check against existing policy gates - 4. Identify conflicting and related ADRs - 5. Evaluate if choice requires new ADR - 6. Generate actionable guidance for agent - """ - - def execute(self, **kwargs: Any) -> WorkflowResult: - """Execute preflight evaluation workflow.""" - # Extract input_data from kwargs - input_data = kwargs.get("input_data") - if not input_data or not isinstance(input_data, PreflightInput): - raise ValueError("input_data must be provided as PreflightInput instance") - - self._start_workflow("Preflight Check") - - try: - # Step 1: Load constraints contract - contract = self._execute_step( - "load_constraints_contract", self._load_constraints_contract - ) - - # Step 2: Categorize and normalize choice - categorized_choice = self._execute_step( - "categorize_choice", self._categorize_choice, input_data - ) - - # Step 3: Check against policy gates - gate_result = self._execute_step( - "check_policy_gates", - self._check_policy_gates, - categorized_choice, - contract, - ) - - # Step 4: Find related and conflicting ADRs - related_adrs = self._execute_step( - "find_related_adrs", - self._find_related_adrs, - categorized_choice, - contract, - ) - conflicting_adrs = self._execute_step( - "find_conflicting_adrs", - self._find_conflicting_adrs, - categorized_choice, - contract, - ) - - # Step 5: Evaluate decision - decision = self._execute_step( - "make_preflight_decision", - self._make_preflight_decision, - categorized_choice, - gate_result, - related_adrs, - conflicting_adrs, - contract, - ) - - # Step 6: Generate guidance - guidance = self._execute_step( - "generate_agent_guidance", - self._generate_agent_guidance, - decision, - input_data, - ) - - result_data = { - "decision": decision, - "guidance": guidance, - "technical_choice": categorized_choice, - "evaluated_against": { - "total_adrs": len(contract.approved_adrs), - "constraints_exist": not contract.constraints.is_empty(), - "constraints": 1 if not contract.constraints.is_empty() else 0, - }, - } - - self._complete_workflow( - success=True, - message=f"Preflight check completed: {decision.status}", - ) - self.result.data = result_data - self.result.guidance = guidance - self.result.next_steps = ( - decision.next_steps.split(". ") - if hasattr(decision, "next_steps") and decision.next_steps - else [ - f"Technical choice {input_data.choice} evaluated: {decision.status}", - "Review preflight decision and proceed accordingly", - ] - ) - - except Exception as e: - self._complete_workflow( - success=False, - message=f"Preflight workflow failed: {str(e)}", - ) - self.result.add_error(f"PreflightError: {str(e)}") - - return self.result - - def _load_constraints_contract(self) -> ConstraintsContract: - """Load current constraints contract from approved ADRs.""" - try: - builder = ConstraintsContractBuilder(adr_dir=self.adr_dir) - return builder.build() - except Exception: - # If no contract exists, return empty contract - from pathlib import Path - - from ..contract.models import ConstraintsContract - - # Use the proper create_empty method instead of incorrect constructor - return ConstraintsContract.create_empty(Path(".")) - - def _categorize_choice(self, input_data: PreflightInput) -> dict[str, Any]: - """Categorize and normalize the technical choice.""" - choice = input_data.choice.lower().strip() - - # Common technology categories - database_terms = { - "postgresql", - "postgres", - "mysql", - "mongodb", - "redis", - "sqlite", - "cassandra", - "dynamodb", - "elasticsearch", - } - frontend_terms = { - "react", - "vue", - "angular", - "svelte", - "next.js", - "nuxt", - "gatsby", - "typescript", - "javascript", - "tailwind", - "bootstrap", - } - backend_terms = { - "express", - "fastapi", - "django", - "flask", - "spring", - "rails", - "node.js", - "python", - "java", - "go", - "rust", - } - architecture_terms = { - "microservices", - "monolith", - "serverless", - "event-driven", - "rest", - "graphql", - "grpc", - "kubernetes", - "docker", - } - - # Determine category - category = input_data.category - if not category: - if choice in database_terms: - category = "database" - elif choice in frontend_terms: - category = "frontend" - elif choice in backend_terms: - category = "backend" - elif choice in architecture_terms: - category = "architecture" - else: - category = "technology" - - return { - "original": input_data.choice, - "normalized": choice, - "category": category, - "context": input_data.context or {}, - "aliases": self._get_technology_aliases(choice), - } - - def _get_technology_aliases(self, choice: str) -> list[str]: - """Get common aliases for a technology choice.""" - alias_map = { - "postgres": ["postgresql", "pg"], - "postgresql": ["postgres", "pg"], - "javascript": ["js", "node", "node.js"], - "typescript": ["ts"], - "react": ["reactjs", "react.js"], - "vue": ["vuejs", "vue.js"], - "next.js": ["nextjs", "next"], - "nuxt": ["nuxtjs", "nuxt.js"], - } - return alias_map.get(choice, [choice]) - - def _check_policy_gates( - self, choice: dict[str, Any], contract: ConstraintsContract - ) -> dict[str, Any]: - """Check choice against existing policy gates.""" - # Simplified gate checking - will be enhanced later - # This would integrate with the actual PolicyGate system when fully implemented - - blocked = False - pre_approved = False - requirements: list[str] = [] - - # Basic implementation - check against contract constraints - if not contract.constraints.is_empty(): - # Simple check for blocked technologies in constraints - constraint_text = str(contract.constraints.model_dump()).lower() - if choice["normalized"] in constraint_text: - # This is a very basic check - real implementation would be more sophisticated - pass - - return { - "blocked": blocked, - "pre_approved": pre_approved, - "requirements": requirements, - "applicable_gates": [], - } - - def _find_related_adrs( - self, choice: dict[str, Any], contract: ConstraintsContract - ) -> list[dict[str, Any]]: - """Find ADRs related to this technical choice.""" - related = [] - - for adr in contract.approved_adrs: - # Check title and tags - tags_text = " ".join(adr.tags) if adr.tags else "" - adr_text = f"{adr.title.lower()} {tags_text.lower()}" - - # Check if choice or aliases appear in ADR - if choice["normalized"] in adr_text: - related.append( - { - "adr_id": adr.id, - "title": adr.title, - "relevance": "direct_mention", - "category_match": choice["category"] in (adr.tags or []), - } - ) - continue - - # Check aliases - for alias in choice["aliases"]: - if alias in adr_text: - related.append( - { - "adr_id": adr.id, - "title": adr.title, - "relevance": "alias_match", - "category_match": choice["category"] in (adr.tags or []), - } - ) - break - - return related - - def _find_conflicting_adrs( - self, choice: dict[str, Any], contract: ConstraintsContract - ) -> list[dict[str, Any]]: - """Find ADRs that conflict with this technical choice.""" - conflicts = [] - - for adr in contract.approved_adrs: - # Check policy blocks - if adr.policy: - policy = adr.policy - - # Check disallowed imports/technologies - disallowed = [] - if policy.imports and policy.imports.disallow: - disallowed.extend(policy.imports.disallow) - if policy.python and policy.python.disallow_imports: - disallowed.extend(policy.python.disallow_imports) - - # Check if choice conflicts - choice_terms = [choice["normalized"]] + choice["aliases"] - for term in choice_terms: - if term in [d.lower() for d in disallowed]: - conflicts.append( - { - "adr_id": adr.id, - "title": adr.title, - "conflict_type": "policy_disallow", - "conflict_detail": f"ADR disallows '{term}'", - } - ) - - return conflicts - - def _make_preflight_decision( - self, - choice: dict[str, Any], - gate_result: dict[str, Any], - related_adrs: list[dict[str, Any]], - conflicting_adrs: list[dict[str, Any]], - contract: ConstraintsContract, - ) -> PreflightDecision: - """Make the final preflight decision.""" - - # BLOCKED - explicit conflicts found - if conflicting_adrs: - return PreflightDecision( - status="BLOCKED", - reasoning=f"Choice '{choice['original']}' conflicts with existing ADRs", - conflicting_adrs=[c["adr_id"] for c in conflicting_adrs], - related_adrs=[r["adr_id"] for r in related_adrs], - required_policies=[], - next_steps="Review conflicting ADRs and consider superseding them if this choice is necessary", - urgency="HIGH", - ) - - # BLOCKED - policy gate blocks - if gate_result["blocked"]: - return PreflightDecision( - status="BLOCKED", - reasoning=f"Choice '{choice['original']}' is blocked by policy gates", - conflicting_adrs=[], - related_adrs=[r["adr_id"] for r in related_adrs], - required_policies=gate_result["requirements"], - next_steps="Review policy gates and consider updating them if this choice is necessary", - urgency="HIGH", - ) - - # ALLOWED - pre-approved choice - if gate_result["pre_approved"]: - return PreflightDecision( - status="ALLOWED", - reasoning=f"Choice '{choice['original']}' is pre-approved by existing ADRs", - conflicting_adrs=[], - related_adrs=[r["adr_id"] for r in related_adrs], - required_policies=[], - next_steps="Proceed with implementation", - urgency="LOW", - ) - - # REQUIRES_ADR - significant choice not covered - if self._is_significant_choice(choice, related_adrs, contract): - return PreflightDecision( - status="REQUIRES_ADR", - reasoning=f"Choice '{choice['original']}' is architecturally significant and requires ADR", - conflicting_adrs=[], - related_adrs=[r["adr_id"] for r in related_adrs], - required_policies=self._suggest_required_policies(choice), - next_steps="Create ADR proposal documenting this architectural decision", - urgency="MEDIUM", - ) - - # ALLOWED - minor choice, proceed - return PreflightDecision( - status="ALLOWED", - reasoning=f"Choice '{choice['original']}' is minor and doesn't require ADR", - conflicting_adrs=[], - related_adrs=[r["adr_id"] for r in related_adrs], - required_policies=[], - next_steps="Proceed with implementation", - urgency="LOW", - ) - - def _is_significant_choice( - self, - choice: dict[str, Any], - related_adrs: list[dict[str, Any]], - contract: ConstraintsContract, - ) -> bool: - """Determine if a technical choice is significant enough to require ADR.""" - - # Always significant categories - significant_categories = {"database", "architecture", "framework"} - if choice["category"] in significant_categories: - return True - - # Frontend frameworks are significant - frontend_frameworks = {"react", "vue", "angular", "svelte"} - if choice["normalized"] in frontend_frameworks: - return True - - # Backend frameworks are significant - backend_frameworks = {"express", "fastapi", "django", "flask", "spring"} - if choice["normalized"] in backend_frameworks: - return True - - # If no existing ADRs, even minor choices might be worth documenting - if len(contract.approved_adrs) == 0: - return True - - return False - - def _suggest_required_policies(self, choice: dict[str, Any]) -> list[str]: - """Suggest policies that should be included in ADR for this choice.""" - policies = [] - - category = choice["category"] - choice["normalized"] - - if category == "database": - policies.extend( - [ - "Database access patterns", - "Migration strategy", - "Backup and recovery approach", - "Connection pooling configuration", - ] - ) - elif category == "frontend": - policies.extend( - [ - "Component structure guidelines", - "State management approach", - "Styling methodology", - "Bundle size constraints", - ] - ) - elif category == "backend": - policies.extend( - [ - "API design principles", - "Error handling patterns", - "Logging and monitoring", - "Security considerations", - ] - ) - elif category == "architecture": - policies.extend( - [ - "Service boundaries", - "Communication patterns", - "Data consistency approach", - "Deployment strategy", - ] - ) - - return policies - - def _generate_agent_guidance( - self, decision: PreflightDecision, input_data: PreflightInput - ) -> str: - """Generate actionable guidance for the agent.""" - - if decision.status == "ALLOWED": - return ( - f"✅ You can proceed with '{input_data.choice}'. " - f"{decision.reasoning}. {decision.next_steps}." - ) - - elif decision.status == "BLOCKED": - conflicts_text = "" - if decision.conflicting_adrs: - conflicts_text = ( - f" (conflicts with {', '.join(decision.conflicting_adrs)})" - ) - - return ( - f"🚫 Cannot use '{input_data.choice}'{conflicts_text}. " - f"{decision.reasoning}. " - f"Next step: {decision.next_steps}" - ) - - elif decision.status == "REQUIRES_ADR": - related_text = "" - if decision.related_adrs: - related_text = f" (related: {', '.join(decision.related_adrs)})" - - return ( - f"📝 '{input_data.choice}' requires ADR{related_text}. " - f"{decision.reasoning}. " - f"Use adr_create() to document this decision before proceeding." - ) - - return f"Evaluation complete: {decision.reasoning}" +from adr_kit.decision.workflows.preflight import * # noqa: F401,F403 diff --git a/adr_kit/workflows/supersede.py b/adr_kit/workflows/supersede.py index 50e3ce0..a496cb9 100644 --- a/adr_kit/workflows/supersede.py +++ b/adr_kit/workflows/supersede.py @@ -1,380 +1 @@ -"""Supersede Workflow - Replace existing ADR with new decision.""" - -import re -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any - -from ..core.model import ADR -from ..core.parse import find_adr_files, parse_adr_file -from .approval import ApprovalInput, ApprovalWorkflow -from .base import BaseWorkflow, WorkflowResult, WorkflowStatus -from .creation import CreationInput, CreationWorkflow - - -@dataclass -class SupersedeInput: - """Input for ADR superseding workflow.""" - - old_adr_id: str # ADR to be superseded - new_proposal: CreationInput # New ADR proposal - supersede_reason: str # Why the old ADR is being replaced - auto_approve: bool = False # Whether to auto-approve the new ADR - preserve_history: bool = True # Whether to maintain bidirectional links - - -@dataclass -class SupersedeResult: - """Result of ADR superseding.""" - - old_adr_id: str - new_adr_id: str - old_adr_status: str # Previous status of old ADR - new_adr_status: str # Status of new ADR - relationships_updated: list[str] # ADR IDs that had relationships updated - automation_triggered: bool # Whether approval automation was triggered - conflicts_resolved: list[str] # Conflicts that were resolved by superseding - next_steps: str # Guidance for what happens next - - -class SupersedeWorkflow(BaseWorkflow): - """ - Supersede Workflow handles replacing existing ADRs with new decisions. - - This workflow manages the complex process of replacing an architectural - decision while maintaining proper relationships and triggering automation. - - Workflow Steps: - 1. Validate that old ADR exists and can be superseded - 2. Create new ADR proposal using CreationWorkflow - 3. Update old ADR status to 'superseded' - 4. Update bidirectional relationships (supersedes/superseded_by) - 5. Update any ADRs that referenced the old ADR - 6. Optionally approve new ADR (triggering ApprovalWorkflow) - 7. Generate comprehensive superseding report - """ - - def execute(self, **kwargs: Any) -> WorkflowResult: - """Execute ADR superseding workflow.""" - # Extract input_data from kwargs - input_data = kwargs.get("input_data") - if not input_data or not isinstance(input_data, SupersedeInput): - raise ValueError("input_data must be provided as SupersedeInput instance") - - self._start_workflow("Supersede ADR") - - try: - # Step 1: Validate superseding preconditions - old_adr, old_adr_file = self._execute_step( - "validate_supersede_preconditions", - self._validate_supersede_preconditions, - input_data.old_adr_id, - ) - old_status = old_adr.status - - # Step 2: Create new ADR proposal - creation_result = self._execute_step( - "create_new_adr", self._create_new_adr, input_data.new_proposal - ) - - new_adr_id = creation_result.data["creation_result"].adr_id - - # Step 3: Update old ADR to superseded status - self._execute_step( - "update_old_adr_status", - self._update_old_adr_status, - old_adr, - old_adr_file, - new_adr_id, - input_data.supersede_reason, - ) - - # Step 4: Update new ADR with supersedes relationship - self._execute_step( - "update_new_adr_relationships", - self._update_new_adr_relationships, - new_adr_id, - input_data.old_adr_id, - ) - - # Step 5: Update related ADRs - updated_relationships = self._execute_step( - "update_related_adr_relationships", - self._update_related_adr_relationships, - input_data.old_adr_id, - new_adr_id, - ) - - # Step 6: Resolve conflicts - resolved_conflicts = self._execute_step( - "resolve_conflicts", - self._resolve_conflicts_through_superseding, - input_data.old_adr_id, - creation_result.data["creation_result"].conflicts_detected, - ) - - # Step 7: Optionally approve new ADR - automation_triggered = False - new_adr_status = "proposed" - - if input_data.auto_approve: - approval_result = self._execute_step( - "auto_approve_new_adr", self._auto_approve_new_adr, new_adr_id - ) - automation_triggered = approval_result.get("success", False) - new_adr_status = "accepted" if automation_triggered else "proposed" - - # Step 8: Generate guidance - next_steps = self._execute_step( - "generate_supersede_guidance", - self._generate_supersede_guidance, - new_adr_id, - automation_triggered, - resolved_conflicts, - ) - - result = SupersedeResult( - old_adr_id=input_data.old_adr_id, - new_adr_id=new_adr_id, - old_adr_status=old_status, - new_adr_status=new_adr_status, - relationships_updated=updated_relationships, - automation_triggered=automation_triggered, - conflicts_resolved=resolved_conflicts, - next_steps=next_steps, - ) - - self._complete_workflow( - success=True, - message=f"ADR {input_data.old_adr_id} superseded by {new_adr_id}", - ) - self.result.data = {"supersede_result": result} - self.result.guidance = ( - f"ADR {input_data.old_adr_id} has been superseded by {new_adr_id}" - ) - self.result.next_steps = ( - next_steps.split(". ") - if isinstance(next_steps, str) - else [ - f"ADR {new_adr_id} has replaced {input_data.old_adr_id}", - "Review the new ADR and approve if ready", - "Update any dependent systems or documentation", - ] - ) - - except Exception as e: - self._complete_workflow( - success=False, - message=f"Supersede workflow failed: {str(e)}", - ) - self.result.add_error(f"SupersedeError: {str(e)}") - - return self.result - - def _create_new_adr(self, new_proposal: Any) -> WorkflowResult: - """Create new ADR using the creation workflow.""" - creation_workflow = CreationWorkflow(adr_dir=self.adr_dir) - creation_result = creation_workflow.execute(input_data=new_proposal) - - if creation_result.status != WorkflowStatus.SUCCESS: - raise Exception(f"Failed to create new ADR: {creation_result.message}") - - return creation_result - - def _auto_approve_new_adr(self, new_adr_id: str) -> dict: - """Auto-approve the new ADR if requested.""" - - approval_workflow = ApprovalWorkflow(adr_dir=self.adr_dir) - approval_input = ApprovalInput(adr_id=new_adr_id, force_approve=True) - approval_result = approval_workflow.execute(input_data=approval_input) - - return { - "success": approval_result.status == WorkflowStatus.SUCCESS, - "result": approval_result, - } - - def _validate_supersede_preconditions(self, old_adr_id: str) -> tuple[ADR, Path]: - """Validate that the old ADR exists and can be superseded.""" - adr_files = find_adr_files(self.adr_dir) - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path) - if adr.id == old_adr_id: - # Check if already superseded - if adr.status == "superseded": - raise ValueError(f"ADR {old_adr_id} is already superseded") - - return adr, file_path - except Exception: - continue - - raise ValueError(f"ADR {old_adr_id} not found in {self.adr_dir}") - - def _update_old_adr_status( - self, old_adr: ADR, old_adr_file: Path, new_adr_id: str, reason: str - ) -> None: - """Update old ADR status to superseded and add superseded_by relationship.""" - - # Read current file content - with open(old_adr_file, encoding="utf-8") as f: - content = f.read() - - # Update status - status_pattern = r"^status:\s*\w+$" - content = re.sub( - status_pattern, "status: superseded", content, flags=re.MULTILINE - ) - - # Update or add superseded_by field - superseded_by_pattern = r"^superseded_by:\s*.*$" - superseded_by_line = f'superseded_by: ["{new_adr_id}"]' - - if re.search(superseded_by_pattern, content, flags=re.MULTILINE): - # Replace existing superseded_by - content = re.sub( - superseded_by_pattern, superseded_by_line, content, flags=re.MULTILINE - ) - else: - # Add superseded_by before end of YAML front-matter - yaml_end = content.find("\n---\n") - if yaml_end != -1: - supersede_metadata = ( - f"{superseded_by_line}\n" - f'supersede_date: {datetime.now().strftime("%Y-%m-%d")}\n' - f'supersede_reason: "{reason}"\n' - ) - content = content[:yaml_end] + supersede_metadata + content[yaml_end:] - - # Write updated content - with open(old_adr_file, "w", encoding="utf-8") as f: - f.write(content) - - def _update_new_adr_relationships(self, new_adr_id: str, old_adr_id: str) -> None: - """Update new ADR to include supersedes relationship.""" - adr_files = find_adr_files(self.adr_dir) - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path) - if adr.id == new_adr_id: - # Read and update file - with open(file_path, encoding="utf-8") as f: - content = f.read() - - # Update or add supersedes field - supersedes_pattern = r"^supersedes:\s*.*$" - supersedes_line = f'supersedes: ["{old_adr_id}"]' - - if re.search(supersedes_pattern, content, flags=re.MULTILINE): - # Replace existing supersedes - content = re.sub( - supersedes_pattern, - supersedes_line, - content, - flags=re.MULTILINE, - ) - else: - # Add supersedes before end of YAML front-matter - yaml_end = content.find("\n---\n") - if yaml_end != -1: - content = ( - content[:yaml_end] - + supersedes_line - + "\n" - + content[yaml_end:] - ) - - # Write updated content - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - - break - except Exception: - continue - - def _update_related_adr_relationships( - self, old_adr_id: str, new_adr_id: str - ) -> list[str]: - """Update any ADRs that referenced the old ADR.""" - updated_relationships = [] - adr_files = find_adr_files(self.adr_dir) - - for file_path in adr_files: - try: - adr = parse_adr_file(file_path) - - # Skip the ADRs we're already updating - if adr.id in [old_adr_id, new_adr_id]: - continue - - # Check if this ADR references the old ADR - needs_update = False - - # Check supersedes relationships - if old_adr_id in (adr.supersedes or []): - needs_update = True - - # Check superseded_by relationships - if old_adr_id in (adr.superseded_by or []): - needs_update = True - - if needs_update: - # Read and update file - with open(file_path, encoding="utf-8") as f: - content = f.read() - - # Replace old ADR ID with new ADR ID in relationships - content = content.replace(f'"{old_adr_id}"', f'"{new_adr_id}"') - content = content.replace(f"'{old_adr_id}'", f"'{new_adr_id}'") - - # Write updated content - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - - updated_relationships.append(adr.id) - - except Exception: - continue # Skip problematic files - - return updated_relationships - - def _resolve_conflicts_through_superseding( - self, old_adr_id: str, detected_conflicts: list[str] - ) -> list[str]: - """Resolve conflicts that existed with the old ADR.""" - resolved_conflicts = [] - - # If the new ADR had conflicts with the old ADR, those are now resolved - if old_adr_id in detected_conflicts: - resolved_conflicts.append(old_adr_id) - - # Additional conflict resolution logic could be added here - # For example, checking if superseding resolves policy conflicts - - return resolved_conflicts - - def _generate_supersede_guidance( - self, new_adr_id: str, automation_triggered: bool, resolved_conflicts: list[str] - ) -> str: - """Generate guidance for what happens next after superseding.""" - - if automation_triggered: - conflicts_text = "" - if resolved_conflicts: - conflicts_text = ( - f" and resolved conflicts with {', '.join(resolved_conflicts)}" - ) - - return ( - f"✅ Superseding complete! {new_adr_id} is now active{conflicts_text}. " - f"All automation has been triggered and policies are being enforced. " - f"The old decision is superseded and no longer active." - ) - else: - return ( - f"📋 Superseding complete! {new_adr_id} created but requires approval. " - f"Use adr_approve('{new_adr_id}') to activate the new decision and " - f"trigger policy enforcement. The old ADR is marked as superseded." - ) +from adr_kit.decision.workflows.supersede import * # noqa: F401,F403 From dcd8b9718a50ca2e49360535aca34dc4ccc389e7 Mon Sep 17 00:00:00 2001 From: kschlt Date: Tue, 24 Mar 2026 10:03:40 +0100 Subject: [PATCH 2/5] refactor: update source imports to new decision/ and enforcement/ paths Update mcp/server.py and cli.py to import directly from new plane locations instead of going through backward-compat shims. --- adr_kit/cli.py | 32 ++++++++++++++++---------------- adr_kit/mcp/server.py | 10 +++++----- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/adr_kit/cli.py b/adr_kit/cli.py index cb08d3c..f40d9bd 100644 --- a/adr_kit/cli.py +++ b/adr_kit/cli.py @@ -280,10 +280,10 @@ def mcp_health() -> None: # Test workflow system (the real business logic) try: - from .workflows.analyze import AnalyzeProjectWorkflow # noqa: F401 - from .workflows.approval import ApprovalWorkflow # noqa: F401 - from .workflows.creation import CreationWorkflow # noqa: F401 - from .workflows.preflight import PreflightWorkflow # noqa: F401 + from .decision.workflows.analyze import AnalyzeProjectWorkflow # noqa: F401 + from .decision.workflows.approval import ApprovalWorkflow # noqa: F401 + from .decision.workflows.creation import CreationWorkflow # noqa: F401 + from .decision.workflows.preflight import PreflightWorkflow # noqa: F401 console.print("✅ Workflow backend system: OK") workflow_available = True @@ -652,7 +652,7 @@ def info() -> None: def _setup_enforcement_hooks() -> None: """Set up git hooks for staged ADR enforcement (called from init --with-enforcement).""" - from .enforce.hooks import HookGenerator + from .enforcement.generation.hooks import HookGenerator gen = HookGenerator() results = gen.generate() @@ -744,7 +744,7 @@ def setup_enforcement( .git/hooks/pre-push. Safe on existing hooks — appends only. Re-running is idempotent. """ - from .enforce.hooks import HookGenerator + from .enforcement.generation.hooks import HookGenerator try: gen = HookGenerator() @@ -775,7 +775,7 @@ def enforce_status( ), ) -> None: """Show status of ADR enforcement hooks.""" - from .enforce.hooks import HookGenerator + from .enforcement.generation.hooks import HookGenerator try: gen = HookGenerator() @@ -1010,7 +1010,7 @@ def preflight( before implementation, helping enforce architectural governance. """ try: - from .gate import PolicyGate, create_technical_choice + from .decision.gate import PolicyGate, create_technical_choice gate = PolicyGate(adr_dir) @@ -1083,7 +1083,7 @@ def gate_status( ) -> None: """Show current preflight gate status and configuration.""" try: - from .gate import PolicyGate + from .decision.gate import PolicyGate gate = PolicyGate(adr_dir) status = gate.get_gate_status() @@ -1146,7 +1146,7 @@ def guardrail_apply( """Apply automatic guardrails based on ADR policies.""" try: - from .guardrail import GuardrailManager + from .enforcement.config.manager import GuardrailManager adr_path = Path(adr_dir) manager = GuardrailManager(adr_path) @@ -1188,7 +1188,7 @@ def guardrail_status( """Show status of the automatic guardrail system.""" try: - from .guardrail import GuardrailManager + from .enforcement.config.manager import GuardrailManager adr_path = Path(adr_dir) manager = GuardrailManager(adr_path) @@ -1275,8 +1275,8 @@ def enforce( Exit codes: 0 = pass, 1 = violations found, 2 = warnings only, 3 = error """ - from .enforce.stages import EnforcementLevel - from .enforce.validator import StagedValidator + from .enforcement.validation.staged import StagedValidator + from .enforcement.validation.stages import EnforcementLevel try: try: @@ -1292,7 +1292,7 @@ def enforce( # JSON output mode — structured report for agents and CI if output_format.lower() == "json": - from .enforce.reporter import build_report + from .enforcement.reporter import build_report report = build_report(result) # Print to stdout (not via Rich console) so JSON is clean @@ -1368,7 +1368,7 @@ def generate_scripts( Each script supports --quick (staged files) and --full (all files) modes and outputs JSON matching the EnforcementReport schema. """ - from .enforce.script_generator import ScriptGenerator + from .enforcement.generation.scripts import ScriptGenerator try: generator = ScriptGenerator(adr_dir=adr_dir) @@ -1408,7 +1408,7 @@ def generate_ci( Safe to re-run — only overwrites files it previously generated. """ - from .enforce.ci import CIWorkflowGenerator + from .enforcement.generation.ci import CIWorkflowGenerator try: generator = CIWorkflowGenerator() diff --git a/adr_kit/mcp/server.py b/adr_kit/mcp/server.py index 297dc88..815147e 100644 --- a/adr_kit/mcp/server.py +++ b/adr_kit/mcp/server.py @@ -10,12 +10,12 @@ from fastmcp import FastMCP # Import the full workflow system (this is where the real business logic lives) -from ..workflows.analyze import AnalyzeProjectWorkflow -from ..workflows.approval import ApprovalInput, ApprovalWorkflow -from ..workflows.creation import CreationInput, CreationWorkflow +from ..decision.workflows.analyze import AnalyzeProjectWorkflow +from ..decision.workflows.approval import ApprovalInput, ApprovalWorkflow +from ..decision.workflows.creation import CreationInput, CreationWorkflow +from ..decision.workflows.preflight import PreflightInput, PreflightWorkflow +from ..decision.workflows.supersede import SupersedeInput, SupersedeWorkflow from ..workflows.planning import PlanningInput, PlanningWorkflow -from ..workflows.preflight import PreflightInput, PreflightWorkflow -from ..workflows.supersede import SupersedeInput, SupersedeWorkflow from .middleware import StringifiedParameterFixMiddleware from .models import ( AnalyzeProjectRequest, From 6fd115d4993e7fcceb77bea3ab03c5b26c11f4b8 Mon Sep 17 00:00:00 2001 From: kschlt Date: Tue, 24 Mar 2026 10:04:26 +0100 Subject: [PATCH 3/5] refactor: update test imports to new decision/ and enforcement/ paths --- .../test_comprehensive_scenarios.py | 8 ++--- .../test_decision_quality_assessment.py | 2 +- .../test_mcp_workflow_integration.py | 30 +++++++++++-------- tests/integration/test_workflow_analyze.py | 4 +-- tests/integration/test_workflow_creation.py | 4 +-- tests/unit/test_ci_generator.py | 2 +- tests/unit/test_decision_guidance.py | 5 +++- tests/unit/test_hook_generator.py | 2 +- tests/unit/test_policy_validation.py | 2 +- tests/unit/test_reporter.py | 6 ++-- tests/unit/test_script_generator.py | 2 +- tests/unit/test_staged_enforcement.py | 14 ++++----- tests/unit/test_workflow_base.py | 2 +- 13 files changed, 46 insertions(+), 37 deletions(-) diff --git a/tests/integration/test_comprehensive_scenarios.py b/tests/integration/test_comprehensive_scenarios.py index f1c2dda..5f5dfcf 100644 --- a/tests/integration/test_comprehensive_scenarios.py +++ b/tests/integration/test_comprehensive_scenarios.py @@ -8,10 +8,10 @@ import pytest -from adr_kit.workflows.analyze import AnalyzeProjectWorkflow -from adr_kit.workflows.base import WorkflowError, WorkflowStatus -from adr_kit.workflows.creation import CreationInput, CreationWorkflow -from adr_kit.workflows.preflight import PreflightInput, PreflightWorkflow +from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow +from adr_kit.decision.workflows.base import WorkflowError, WorkflowStatus +from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow +from adr_kit.decision.workflows.preflight import PreflightInput, PreflightWorkflow class TestErrorScenarios: diff --git a/tests/integration/test_decision_quality_assessment.py b/tests/integration/test_decision_quality_assessment.py index 9bce001..c53d74e 100644 --- a/tests/integration/test_decision_quality_assessment.py +++ b/tests/integration/test_decision_quality_assessment.py @@ -5,7 +5,7 @@ import pytest -from adr_kit.workflows.creation import CreationInput, CreationWorkflow +from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow class TestDecisionQualityAssessment: diff --git a/tests/integration/test_mcp_workflow_integration.py b/tests/integration/test_mcp_workflow_integration.py index adb86ae..d2a8922 100644 --- a/tests/integration/test_mcp_workflow_integration.py +++ b/tests/integration/test_mcp_workflow_integration.py @@ -49,8 +49,8 @@ def test_mcp_analyze_project_integration(self, temp_project_dir, temp_adr_dir): """Test MCP analyze project tool calls workflow correctly.""" # Import the actual MCP tool function # Note: We can't call the decorated function directly, so we test the workflow + from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow from adr_kit.mcp.server import logger - from adr_kit.workflows.analyze import AnalyzeProjectWorkflow # Test request model validation request = AnalyzeProjectRequest( @@ -79,7 +79,10 @@ def test_mcp_analyze_project_integration(self, temp_project_dir, temp_adr_dir): def test_mcp_preflight_integration(self, temp_adr_dir): """Test MCP preflight tool calls workflow correctly.""" - from adr_kit.workflows.preflight import PreflightInput, PreflightWorkflow + from adr_kit.decision.workflows.preflight import ( + PreflightInput, + PreflightWorkflow, + ) # Test request model request = PreflightCheckRequest( @@ -113,7 +116,7 @@ def test_mcp_preflight_integration(self, temp_adr_dir): def test_mcp_create_integration(self, temp_adr_dir): """Test MCP create tool calls workflow correctly.""" - from adr_kit.workflows.creation import CreationInput, CreationWorkflow + from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow # Test request model request = CreateADRRequest( @@ -168,8 +171,8 @@ def test_mcp_create_integration(self, temp_adr_dir): def test_mcp_approve_integration(self, temp_adr_dir): """Test MCP approve tool integration.""" - from adr_kit.workflows.approval import ApprovalInput, ApprovalWorkflow - from adr_kit.workflows.creation import CreationInput, CreationWorkflow + from adr_kit.decision.workflows.approval import ApprovalInput, ApprovalWorkflow + from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow # First create an ADR to approve creation_workflow = CreationWorkflow(adr_dir=temp_adr_dir) @@ -217,8 +220,11 @@ def test_mcp_approve_integration(self, temp_adr_dir): def test_mcp_supersede_integration(self, temp_adr_dir): """Test MCP supersede tool integration.""" - from adr_kit.workflows.creation import CreationInput, CreationWorkflow - from adr_kit.workflows.supersede import SupersedeInput, SupersedeWorkflow + from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow + from adr_kit.decision.workflows.supersede import ( + SupersedeInput, + SupersedeWorkflow, + ) # Create original ADR creation_workflow = CreationWorkflow(adr_dir=temp_adr_dir) @@ -322,8 +328,8 @@ def test_mcp_planning_context_integration(self, temp_adr_dir): def test_response_format_consistency(self, temp_project_dir, temp_adr_dir): """Test that all workflows return consistent response formats for MCP.""" + from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow from adr_kit.mcp.models import error_response, success_response - from adr_kit.workflows.analyze import AnalyzeProjectWorkflow # Test successful workflow response workflow = AnalyzeProjectWorkflow(adr_dir=temp_adr_dir) @@ -395,7 +401,7 @@ def test_request_model_validation(self): def test_error_propagation(self, temp_adr_dir): """Test that workflow errors are properly propagated to MCP responses.""" - from adr_kit.workflows.creation import CreationInput, CreationWorkflow + from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow # Create invalid input to trigger error invalid_input = CreationInput( @@ -419,9 +425,9 @@ def test_error_propagation(self, temp_adr_dir): def test_end_to_end_workflow_chain(self, temp_project_dir, temp_adr_dir): """Test complete workflow chain: analyze → create → approve.""" - from adr_kit.workflows.analyze import AnalyzeProjectWorkflow - from adr_kit.workflows.approval import ApprovalInput, ApprovalWorkflow - from adr_kit.workflows.creation import CreationInput, CreationWorkflow + from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow + from adr_kit.decision.workflows.approval import ApprovalInput, ApprovalWorkflow + from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow # Step 1: Analyze project analyze_workflow = AnalyzeProjectWorkflow(adr_dir=temp_adr_dir) diff --git a/tests/integration/test_workflow_analyze.py b/tests/integration/test_workflow_analyze.py index a364231..a43f232 100644 --- a/tests/integration/test_workflow_analyze.py +++ b/tests/integration/test_workflow_analyze.py @@ -7,8 +7,8 @@ import pytest -from adr_kit.workflows.analyze import AnalyzeProjectWorkflow -from adr_kit.workflows.base import WorkflowStatus +from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow +from adr_kit.decision.workflows.base import WorkflowStatus class TestAnalyzeProjectWorkflow: diff --git a/tests/integration/test_workflow_creation.py b/tests/integration/test_workflow_creation.py index 9c602f4..29c32a4 100644 --- a/tests/integration/test_workflow_creation.py +++ b/tests/integration/test_workflow_creation.py @@ -8,8 +8,8 @@ import pytest from adr_kit.core.model import ADRStatus -from adr_kit.workflows.base import WorkflowStatus -from adr_kit.workflows.creation import CreationInput, CreationWorkflow +from adr_kit.decision.workflows.base import WorkflowStatus +from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow class TestCreationWorkflow: diff --git a/tests/unit/test_ci_generator.py b/tests/unit/test_ci_generator.py index fefc94d..c41e55b 100644 --- a/tests/unit/test_ci_generator.py +++ b/tests/unit/test_ci_generator.py @@ -5,7 +5,7 @@ import pytest -from adr_kit.enforce.ci import _MANAGED_HEADER, CIWorkflowGenerator +from adr_kit.enforcement.generation.ci import _MANAGED_HEADER, CIWorkflowGenerator class TestCIWorkflowGenerator: diff --git a/tests/unit/test_decision_guidance.py b/tests/unit/test_decision_guidance.py index a25788e..07171d0 100644 --- a/tests/unit/test_decision_guidance.py +++ b/tests/unit/test_decision_guidance.py @@ -1,6 +1,9 @@ """Unit tests for decision quality guidance.""" -from adr_kit.workflows.decision_guidance import _build_examples, build_decision_guidance +from adr_kit.decision.guidance.decision_guidance import ( + _build_examples, + build_decision_guidance, +) class TestDecisionGuidance: diff --git a/tests/unit/test_hook_generator.py b/tests/unit/test_hook_generator.py index 95d8158..fc3bccf 100644 --- a/tests/unit/test_hook_generator.py +++ b/tests/unit/test_hook_generator.py @@ -6,7 +6,7 @@ import pytest -from adr_kit.enforce.hooks import ( +from adr_kit.enforcement.generation.hooks import ( MANAGED_END, MANAGED_START, HookGenerator, diff --git a/tests/unit/test_policy_validation.py b/tests/unit/test_policy_validation.py index 4cb0c0c..7020997 100644 --- a/tests/unit/test_policy_validation.py +++ b/tests/unit/test_policy_validation.py @@ -7,7 +7,7 @@ from adr_kit.core.model import ADR, ADRFrontMatter, ADRStatus from adr_kit.core.policy_extractor import PolicyExtractor -from adr_kit.workflows.creation import CreationInput, CreationWorkflow +from adr_kit.decision.workflows.creation import CreationInput, CreationWorkflow class TestPolicyValidation: diff --git a/tests/unit/test_reporter.py b/tests/unit/test_reporter.py index 2ce3a81..57c4b42 100644 --- a/tests/unit/test_reporter.py +++ b/tests/unit/test_reporter.py @@ -2,9 +2,9 @@ import json -from adr_kit.enforce.reporter import EnforcementReport, build_report -from adr_kit.enforce.stages import EnforcementLevel -from adr_kit.enforce.validator import ValidationResult, Violation +from adr_kit.enforcement.reporter import EnforcementReport, build_report +from adr_kit.enforcement.validation.staged import ValidationResult, Violation +from adr_kit.enforcement.validation.stages import EnforcementLevel # --------------------------------------------------------------------------- # build_report diff --git a/tests/unit/test_script_generator.py b/tests/unit/test_script_generator.py index dce6d4f..58f3da1 100644 --- a/tests/unit/test_script_generator.py +++ b/tests/unit/test_script_generator.py @@ -12,7 +12,7 @@ import pytest -from adr_kit.enforce.script_generator import ScriptGenerator +from adr_kit.enforcement.generation.scripts import ScriptGenerator # Reuse the _make_adr helper from test_staged_enforcement from tests.unit.test_staged_enforcement import _make_adr diff --git a/tests/unit/test_staged_enforcement.py b/tests/unit/test_staged_enforcement.py index 33fdc34..58d0148 100644 --- a/tests/unit/test_staged_enforcement.py +++ b/tests/unit/test_staged_enforcement.py @@ -6,13 +6,13 @@ import pytest -from adr_kit.enforce.stages import ( +from adr_kit.enforcement.validation.staged import StagedValidator, ValidationResult +from adr_kit.enforcement.validation.stages import ( EnforcementLevel, StagedCheck, checks_for_level, classify_adr_checks, ) -from adr_kit.enforce.validator import StagedValidator, ValidationResult # --------------------------------------------------------------------------- # Helpers @@ -440,8 +440,8 @@ def test_detects_pattern_violation(self): def test_invalid_regex_pattern_skipped_gracefully(self): """An invalid regex in an ADR policy should not crash the validator.""" - from adr_kit.enforce.stages import StagedCheck - from adr_kit.enforce.validator import StagedValidator + from adr_kit.enforcement.validation.staged import StagedValidator + from adr_kit.enforcement.validation.stages import StagedCheck check = StagedCheck( adr_id="ADR-0001", @@ -464,7 +464,7 @@ def test_passed_true_when_no_violations(self): assert result.passed def test_passed_false_when_error_violation_present(self): - from adr_kit.enforce.validator import Violation + from adr_kit.enforcement.validation.staged import Violation result = ValidationResult( level=EnforcementLevel.COMMIT, files_checked=5, checks_run=3 @@ -481,7 +481,7 @@ def test_passed_false_when_error_violation_present(self): assert not result.passed def test_passed_true_with_only_warnings(self): - from adr_kit.enforce.validator import Violation + from adr_kit.enforcement.validation.staged import Violation result = ValidationResult( level=EnforcementLevel.COMMIT, files_checked=5, checks_run=3 @@ -499,7 +499,7 @@ def test_passed_true_with_only_warnings(self): assert result.has_warnings def test_error_and_warning_counts(self): - from adr_kit.enforce.validator import Violation + from adr_kit.enforcement.validation.staged import Violation result = ValidationResult( level=EnforcementLevel.CI, files_checked=10, checks_run=5 diff --git a/tests/unit/test_workflow_base.py b/tests/unit/test_workflow_base.py index 2355515..24cbce0 100644 --- a/tests/unit/test_workflow_base.py +++ b/tests/unit/test_workflow_base.py @@ -7,7 +7,7 @@ import pytest -from adr_kit.workflows.base import ( +from adr_kit.decision.workflows.base import ( BaseWorkflow, WorkflowError, WorkflowResult, From 5ce6d4009d2ee323d3e594014b3286927e3ba686 Mon Sep 17 00:00:00 2001 From: kschlt Date: Tue, 24 Mar 2026 10:06:01 +0100 Subject: [PATCH 4/5] refactor: remove backward-compat shims and old directories Deleted adr_kit/gate/, adr_kit/enforce/, adr_kit/guardrail/, adr_kit/guard/. Updated workflows/__init__.py to final form (only PlanningWorkflow stays here). --- adr_kit/enforce/__init__.py | 6 ----- adr_kit/enforce/ci.py | 2 -- adr_kit/enforce/eslint.py | 1 - adr_kit/enforce/hooks.py | 2 -- adr_kit/enforce/reporter.py | 1 - adr_kit/enforce/ruff.py | 1 - adr_kit/enforce/script_generator.py | 1 - adr_kit/enforce/stages.py | 1 - adr_kit/enforce/validator.py | 1 - adr_kit/gate/__init__.py | 33 -------------------------- adr_kit/gate/models.py | 1 - adr_kit/gate/policy_engine.py | 1 - adr_kit/gate/policy_gate.py | 1 - adr_kit/gate/technical_choice.py | 1 - adr_kit/guard/__init__.py | 9 ------- adr_kit/guard/detector.py | 1 - adr_kit/guardrail/__init__.py | 31 ------------------------ adr_kit/guardrail/config_writer.py | 1 - adr_kit/guardrail/file_monitor.py | 1 - adr_kit/guardrail/manager.py | 1 - adr_kit/guardrail/models.py | 1 - adr_kit/workflows/__init__.py | 13 ++-------- adr_kit/workflows/analyze.py | 1 - adr_kit/workflows/approval.py | 1 - adr_kit/workflows/base.py | 1 - adr_kit/workflows/creation.py | 1 - adr_kit/workflows/decision_guidance.py | 2 -- adr_kit/workflows/preflight.py | 1 - adr_kit/workflows/supersede.py | 1 - 29 files changed, 2 insertions(+), 117 deletions(-) delete mode 100644 adr_kit/enforce/__init__.py delete mode 100644 adr_kit/enforce/ci.py delete mode 100644 adr_kit/enforce/eslint.py delete mode 100644 adr_kit/enforce/hooks.py delete mode 100644 adr_kit/enforce/reporter.py delete mode 100644 adr_kit/enforce/ruff.py delete mode 100644 adr_kit/enforce/script_generator.py delete mode 100644 adr_kit/enforce/stages.py delete mode 100644 adr_kit/enforce/validator.py delete mode 100644 adr_kit/gate/__init__.py delete mode 100644 adr_kit/gate/models.py delete mode 100644 adr_kit/gate/policy_engine.py delete mode 100644 adr_kit/gate/policy_gate.py delete mode 100644 adr_kit/gate/technical_choice.py delete mode 100644 adr_kit/guard/__init__.py delete mode 100644 adr_kit/guard/detector.py delete mode 100644 adr_kit/guardrail/__init__.py delete mode 100644 adr_kit/guardrail/config_writer.py delete mode 100644 adr_kit/guardrail/file_monitor.py delete mode 100644 adr_kit/guardrail/manager.py delete mode 100644 adr_kit/guardrail/models.py delete mode 100644 adr_kit/workflows/analyze.py delete mode 100644 adr_kit/workflows/approval.py delete mode 100644 adr_kit/workflows/base.py delete mode 100644 adr_kit/workflows/creation.py delete mode 100644 adr_kit/workflows/decision_guidance.py delete mode 100644 adr_kit/workflows/preflight.py delete mode 100644 adr_kit/workflows/supersede.py diff --git a/adr_kit/enforce/__init__.py b/adr_kit/enforce/__init__.py deleted file mode 100644 index 980b53e..0000000 --- a/adr_kit/enforce/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""ADR enforcement functionality — shim for backward compatibility.""" - -from adr_kit.enforcement.generation.ci import CIWorkflowGenerator -from adr_kit.enforcement.generation.scripts import ScriptGenerator - -__all__ = ["CIWorkflowGenerator", "ScriptGenerator"] diff --git a/adr_kit/enforce/ci.py b/adr_kit/enforce/ci.py deleted file mode 100644 index 021f8d0..0000000 --- a/adr_kit/enforce/ci.py +++ /dev/null @@ -1,2 +0,0 @@ -from adr_kit.enforcement.generation.ci import * # noqa: F401,F403 -from adr_kit.enforcement.generation.ci import _MANAGED_HEADER # noqa: F401 diff --git a/adr_kit/enforce/eslint.py b/adr_kit/enforce/eslint.py deleted file mode 100644 index c84ae81..0000000 --- a/adr_kit/enforce/eslint.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.adapters.eslint import * # noqa: F401,F403 diff --git a/adr_kit/enforce/hooks.py b/adr_kit/enforce/hooks.py deleted file mode 100644 index 6f090fd..0000000 --- a/adr_kit/enforce/hooks.py +++ /dev/null @@ -1,2 +0,0 @@ -from adr_kit.enforcement.generation.hooks import * # noqa: F401,F403 -from adr_kit.enforcement.generation.hooks import _apply_managed_section # noqa: F401 diff --git a/adr_kit/enforce/reporter.py b/adr_kit/enforce/reporter.py deleted file mode 100644 index abc5c42..0000000 --- a/adr_kit/enforce/reporter.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.reporter import * # noqa: F401,F403 diff --git a/adr_kit/enforce/ruff.py b/adr_kit/enforce/ruff.py deleted file mode 100644 index 966b0a8..0000000 --- a/adr_kit/enforce/ruff.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.adapters.ruff import * # noqa: F401,F403 diff --git a/adr_kit/enforce/script_generator.py b/adr_kit/enforce/script_generator.py deleted file mode 100644 index 347d83b..0000000 --- a/adr_kit/enforce/script_generator.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.generation.scripts import * # noqa: F401,F403 diff --git a/adr_kit/enforce/stages.py b/adr_kit/enforce/stages.py deleted file mode 100644 index 6f37b65..0000000 --- a/adr_kit/enforce/stages.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.validation.stages import * # noqa: F401,F403 diff --git a/adr_kit/enforce/validator.py b/adr_kit/enforce/validator.py deleted file mode 100644 index 1f3f473..0000000 --- a/adr_kit/enforce/validator.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.validation.staged import * # noqa: F401,F403 diff --git a/adr_kit/gate/__init__.py b/adr_kit/gate/__init__.py deleted file mode 100644 index 7cf9cf5..0000000 --- a/adr_kit/gate/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Gate package — shim for backward compatibility.""" - -from adr_kit.decision.gate.models import ( - CategoryRule, - GateConfig, - GateDecision, - NameMapping, -) -from adr_kit.decision.gate.policy_engine import PolicyConfig, PolicyEngine -from adr_kit.decision.gate.policy_gate import GateResult, PolicyGate -from adr_kit.decision.gate.technical_choice import ( - ChoiceType, - DependencyChoice, - FrameworkChoice, - TechnicalChoice, - create_technical_choice, -) - -__all__ = [ - "PolicyGate", - "GateDecision", - "GateResult", - "TechnicalChoice", - "ChoiceType", - "DependencyChoice", - "FrameworkChoice", - "create_technical_choice", - "PolicyEngine", - "PolicyConfig", - "GateConfig", - "CategoryRule", - "NameMapping", -] diff --git a/adr_kit/gate/models.py b/adr_kit/gate/models.py deleted file mode 100644 index 2b1d982..0000000 --- a/adr_kit/gate/models.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.gate.models import * # noqa: F401,F403 diff --git a/adr_kit/gate/policy_engine.py b/adr_kit/gate/policy_engine.py deleted file mode 100644 index 37f3fce..0000000 --- a/adr_kit/gate/policy_engine.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.gate.policy_engine import * # noqa: F401,F403 diff --git a/adr_kit/gate/policy_gate.py b/adr_kit/gate/policy_gate.py deleted file mode 100644 index 7942ba8..0000000 --- a/adr_kit/gate/policy_gate.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.gate.policy_gate import * # noqa: F401,F403 diff --git a/adr_kit/gate/technical_choice.py b/adr_kit/gate/technical_choice.py deleted file mode 100644 index 4c42d60..0000000 --- a/adr_kit/gate/technical_choice.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.gate.technical_choice import * # noqa: F401,F403 diff --git a/adr_kit/guard/__init__.py b/adr_kit/guard/__init__.py deleted file mode 100644 index dfed98e..0000000 --- a/adr_kit/guard/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Guard package — shim for backward compatibility.""" - -from adr_kit.enforcement.detection.detector import ( - CodeAnalysisResult, - GuardSystem, - PolicyViolation, -) - -__all__ = ["GuardSystem", "PolicyViolation", "CodeAnalysisResult"] diff --git a/adr_kit/guard/detector.py b/adr_kit/guard/detector.py deleted file mode 100644 index 22ae663..0000000 --- a/adr_kit/guard/detector.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.detection.detector import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/__init__.py b/adr_kit/guardrail/__init__.py deleted file mode 100644 index db06a68..0000000 --- a/adr_kit/guardrail/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Guardrail package — shim for backward compatibility.""" - -from adr_kit.enforcement.config.manager import GuardrailManager -from adr_kit.enforcement.config.models import ( - ApplyResult, - ConfigTemplate, - FragmentTarget, - FragmentType, - GuardrailConfig, -) -from adr_kit.enforcement.config.monitor import ChangeEvent, ChangeType, FileMonitor -from adr_kit.enforcement.config.writer import ( - ConfigFragment, - ConfigWriter, - SentinelBlock, -) - -__all__ = [ - "GuardrailManager", - "ConfigWriter", - "ConfigFragment", - "SentinelBlock", - "FileMonitor", - "ChangeEvent", - "ChangeType", - "GuardrailConfig", - "FragmentTarget", - "ApplyResult", - "ConfigTemplate", - "FragmentType", -] diff --git a/adr_kit/guardrail/config_writer.py b/adr_kit/guardrail/config_writer.py deleted file mode 100644 index 7b80b05..0000000 --- a/adr_kit/guardrail/config_writer.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.config.writer import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/file_monitor.py b/adr_kit/guardrail/file_monitor.py deleted file mode 100644 index c3150e9..0000000 --- a/adr_kit/guardrail/file_monitor.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.config.monitor import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/manager.py b/adr_kit/guardrail/manager.py deleted file mode 100644 index 99d0fb9..0000000 --- a/adr_kit/guardrail/manager.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.config.manager import * # noqa: F401,F403 diff --git a/adr_kit/guardrail/models.py b/adr_kit/guardrail/models.py deleted file mode 100644 index 06004ee..0000000 --- a/adr_kit/guardrail/models.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.enforcement.config.models import * # noqa: F401,F403 diff --git a/adr_kit/workflows/__init__.py b/adr_kit/workflows/__init__.py index b9ae783..7ac9e33 100644 --- a/adr_kit/workflows/__init__.py +++ b/adr_kit/workflows/__init__.py @@ -1,16 +1,12 @@ -"""Workflow orchestration — shim for backward compatibility.""" +"""Workflow orchestration. Planning workflow stays here (context plane). +All other workflows have moved to adr_kit.decision.workflows.""" -from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow -from adr_kit.decision.workflows.approval import ApprovalWorkflow from adr_kit.decision.workflows.base import ( BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus, ) -from adr_kit.decision.workflows.creation import CreationWorkflow -from adr_kit.decision.workflows.preflight import PreflightWorkflow -from adr_kit.decision.workflows.supersede import SupersedeWorkflow from .planning import PlanningWorkflow @@ -19,10 +15,5 @@ "WorkflowResult", "WorkflowError", "WorkflowStatus", - "ApprovalWorkflow", - "CreationWorkflow", - "PreflightWorkflow", "PlanningWorkflow", - "SupersedeWorkflow", - "AnalyzeProjectWorkflow", ] diff --git a/adr_kit/workflows/analyze.py b/adr_kit/workflows/analyze.py deleted file mode 100644 index ae9e06e..0000000 --- a/adr_kit/workflows/analyze.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.workflows.analyze import * # noqa: F401,F403 diff --git a/adr_kit/workflows/approval.py b/adr_kit/workflows/approval.py deleted file mode 100644 index 2d83e8e..0000000 --- a/adr_kit/workflows/approval.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.workflows.approval import * # noqa: F401,F403 diff --git a/adr_kit/workflows/base.py b/adr_kit/workflows/base.py deleted file mode 100644 index a30c2d3..0000000 --- a/adr_kit/workflows/base.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.workflows.base import * # noqa: F401,F403 diff --git a/adr_kit/workflows/creation.py b/adr_kit/workflows/creation.py deleted file mode 100644 index bde6e6e..0000000 --- a/adr_kit/workflows/creation.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.workflows.creation import * # noqa: F401,F403 diff --git a/adr_kit/workflows/decision_guidance.py b/adr_kit/workflows/decision_guidance.py deleted file mode 100644 index dbb5afa..0000000 --- a/adr_kit/workflows/decision_guidance.py +++ /dev/null @@ -1,2 +0,0 @@ -from adr_kit.decision.guidance.decision_guidance import * # noqa: F401,F403 -from adr_kit.decision.guidance.decision_guidance import _build_examples # noqa: F401 diff --git a/adr_kit/workflows/preflight.py b/adr_kit/workflows/preflight.py deleted file mode 100644 index 334dee1..0000000 --- a/adr_kit/workflows/preflight.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.workflows.preflight import * # noqa: F401,F403 diff --git a/adr_kit/workflows/supersede.py b/adr_kit/workflows/supersede.py deleted file mode 100644 index a496cb9..0000000 --- a/adr_kit/workflows/supersede.py +++ /dev/null @@ -1 +0,0 @@ -from adr_kit.decision.workflows.supersede import * # noqa: F401,F403 From 80ac0e97e620cb5c73c43d40844a62948121234d Mon Sep 17 00:00:00 2001 From: kschlt Date: Tue, 24 Mar 2026 10:26:01 +0100 Subject: [PATCH 5/5] chore: remove completed RST plan file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RST (module restructure by plane) is fully executed — all 309 tests pass, lint is clean, and the three-plane architecture is in place. The plan file is no longer needed and its presence would be misleading. --- .claude/plans/RST-module-restructure.md | 441 ------------------------ 1 file changed, 441 deletions(-) delete mode 100644 .claude/plans/RST-module-restructure.md diff --git a/.claude/plans/RST-module-restructure.md b/.claude/plans/RST-module-restructure.md deleted file mode 100644 index f512d5a..0000000 --- a/.claude/plans/RST-module-restructure.md +++ /dev/null @@ -1,441 +0,0 @@ -# RST — Module Restructure by Plane - -## Context - -Restructure `adr_kit/` into the three-plane architecture defined in `.agent/architecture.md`. Pure mechanical work — move files, update imports, no logic changes. Uses backward-compatible shims so tests pass at every intermediate step. - -**Strategy**: Move files → create shims at old locations → verify tests pass → update source imports → update test imports → remove shims → final verify. - ---- - -## Phase 0: Branch + Green Baseline - -1. Invoke `/branch` with args: `"RST Module Restructure by Plane — architecture realignment"` -2. Run `make test-all && make lint` — must pass. If not, stop. - ---- - -## Phase 1: Create New Directory Structure - -Create directories and empty `__init__.py` files: - -```bash -mkdir -p adr_kit/decision/workflows -mkdir -p adr_kit/decision/guidance -mkdir -p adr_kit/decision/gate -mkdir -p adr_kit/enforcement/adapters -mkdir -p adr_kit/enforcement/validation -mkdir -p adr_kit/enforcement/generation -mkdir -p adr_kit/enforcement/config -mkdir -p adr_kit/enforcement/detection -``` - -Create empty `__init__.py` in each new directory: -- `adr_kit/decision/__init__.py` -- `adr_kit/decision/workflows/__init__.py` -- `adr_kit/decision/guidance/__init__.py` -- `adr_kit/enforcement/__init__.py` -- `adr_kit/enforcement/adapters/__init__.py` -- `adr_kit/enforcement/validation/__init__.py` -- `adr_kit/enforcement/generation/__init__.py` -- `adr_kit/enforcement/config/__init__.py` - -(gate/ and detection/ `__init__.py` come from `git mv` in Phase 2) - ---- - -## Phase 2: Move Files with `git mv` - -### Decision Plane — workflows -``` -git mv adr_kit/workflows/base.py adr_kit/decision/workflows/base.py -git mv adr_kit/workflows/creation.py adr_kit/decision/workflows/creation.py -git mv adr_kit/workflows/approval.py adr_kit/decision/workflows/approval.py -git mv adr_kit/workflows/supersede.py adr_kit/decision/workflows/supersede.py -git mv adr_kit/workflows/preflight.py adr_kit/decision/workflows/preflight.py -git mv adr_kit/workflows/analyze.py adr_kit/decision/workflows/analyze.py -``` - -### Decision Plane — guidance -``` -git mv adr_kit/workflows/decision_guidance.py adr_kit/decision/guidance/decision_guidance.py -``` - -### Decision Plane — gate (entire package) -``` -git mv adr_kit/gate/__init__.py adr_kit/decision/gate/__init__.py -git mv adr_kit/gate/models.py adr_kit/decision/gate/models.py -git mv adr_kit/gate/policy_engine.py adr_kit/decision/gate/policy_engine.py -git mv adr_kit/gate/policy_gate.py adr_kit/decision/gate/policy_gate.py -git mv adr_kit/gate/technical_choice.py adr_kit/decision/gate/technical_choice.py -``` - -### Enforcement Plane — adapters -``` -git mv adr_kit/enforce/eslint.py adr_kit/enforcement/adapters/eslint.py -git mv adr_kit/enforce/ruff.py adr_kit/enforcement/adapters/ruff.py -``` - -### Enforcement Plane — validation -``` -git mv adr_kit/enforce/validator.py adr_kit/enforcement/validation/staged.py # RENAMED -git mv adr_kit/enforce/stages.py adr_kit/enforcement/validation/stages.py -``` - -### Enforcement Plane — reporter -``` -git mv adr_kit/enforce/reporter.py adr_kit/enforcement/reporter.py -``` - -### Enforcement Plane — generation -``` -git mv adr_kit/enforce/script_generator.py adr_kit/enforcement/generation/scripts.py # RENAMED -git mv adr_kit/enforce/ci.py adr_kit/enforcement/generation/ci.py -git mv adr_kit/enforce/hooks.py adr_kit/enforcement/generation/hooks.py -``` - -### Enforcement Plane — config (from guardrail/) -``` -git mv adr_kit/guardrail/config_writer.py adr_kit/enforcement/config/writer.py # RENAMED -git mv adr_kit/guardrail/file_monitor.py adr_kit/enforcement/config/monitor.py # RENAMED -git mv adr_kit/guardrail/manager.py adr_kit/enforcement/config/manager.py -git mv adr_kit/guardrail/models.py adr_kit/enforcement/config/models.py -``` - -### Enforcement Plane — detection (from guard/) -``` -git mv adr_kit/guard/__init__.py adr_kit/enforcement/detection/__init__.py -git mv adr_kit/guard/detector.py adr_kit/enforcement/detection/detector.py -``` - -### NOT moved -- `workflows/planning.py` — stays in `workflows/` (context plane, imported by mcp/server.py) -- `workflows/__init__.py` — stays, will be updated -- `knowledge/` — empty (only `__pycache__`), delete in Phase 7 - ---- - -## Phase 3: Fix Relative Imports in Moved Files - -Each moved file that uses `from ..X` imports needs depth adjusted (`..` → `...`) because it's now one level deeper. Also fix cross-module references for renamed files. - -### Decision Plane files - -**`adr_kit/decision/workflows/creation.py`** — 4 top-level + 1 lazy: -- `from ..contract.builder` → `from ...contract.builder` -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` -- `from ..core.validate` → `from ...core.validate` -- Line ~555 (lazy): `from ..core.policy_extractor` → `from ...core.policy_extractor` - -**`adr_kit/decision/workflows/approval.py`** — 7 top-level + 3 lazy: -- `from ..contract.builder` → `from ...contract.builder` -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` -- `from ..core.validate` → `from ...core.validate` -- `from ..enforce.eslint` → `from ...enforcement.adapters.eslint` -- `from ..enforce.ruff` → `from ...enforcement.adapters.ruff` -- `from ..guardrail.manager` → `from ...enforcement.config.manager` -- `from ..index.json_index` → `from ...index.json_index` -- Line ~287 (lazy): `from ..core.model` → `from ...core.model` -- Line ~383 (lazy): `from ..enforce.script_generator` → `from ...enforcement.generation.scripts` -- Line ~410 (lazy): `from ..enforce.hooks` → `from ...enforcement.generation.hooks` - -**`adr_kit/decision/workflows/supersede.py`** — 2 top-level: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` - -**`adr_kit/decision/workflows/preflight.py`** — 2 top-level: -- `from ..contract.builder` → `from ...contract.builder` -- `from ..contract.models` → `from ...contract.models` - -**`adr_kit/decision/workflows/analyze.py`** — 1 top-level: -- `from ..core.parse` → `from ...core.parse` - -**`adr_kit/decision/workflows/base.py`** — no `..` imports. No changes. - -**`adr_kit/decision/guidance/decision_guidance.py`** — no `..` imports. No changes. - -**`adr_kit/decision/gate/policy_engine.py`** — 1 top-level: -- `from ..contract` → `from ...contract` - -### Enforcement Plane files - -**`adr_kit/enforcement/adapters/eslint.py`** — 3 top-level: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` -- `from ..core.policy_extractor` → `from ...core.policy_extractor` - -**`adr_kit/enforcement/adapters/ruff.py`** — 2 top-level: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` - -**`adr_kit/enforcement/validation/staged.py`** (was validator.py) — 2 top-level: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` -- `from .stages import` — stays (sibling) - -**`adr_kit/enforcement/validation/stages.py`** — no `..` imports. No changes. - -**`adr_kit/enforcement/reporter.py`** — 1 internal: -- `from .validator import ValidationResult` → `from .validation.staged import ValidationResult` - -**`adr_kit/enforcement/generation/scripts.py`** (was script_generator.py) — 2 top-level + 1 sibling: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` -- `from .stages import` → `from ..validation.stages import` (stages moved to validation/) - -**`adr_kit/enforcement/generation/ci.py`** — no `..` imports. No changes. - -**`adr_kit/enforcement/generation/hooks.py`** — no `..` imports. No changes. - -**`adr_kit/enforcement/config/manager.py`** — 1 top-level + 2 sibling renames: -- `from ..contract` → `from ...contract` -- `from .config_writer` → `from .writer` (file renamed) -- `from .file_monitor` → `from .monitor` (file renamed) - -**`adr_kit/enforcement/config/monitor.py`** (was file_monitor.py) — 2 top-level: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` - -**`adr_kit/enforcement/config/writer.py`** (was config_writer.py) — no `..` imports. Only `.models` (stays). - -**`adr_kit/enforcement/detection/detector.py`** — 4 top-level: -- `from ..core.model` → `from ...core.model` -- `from ..core.parse` → `from ...core.parse` -- `from ..core.policy_extractor` → `from ...core.policy_extractor` -- `from ..semantic.retriever` → `from ...semantic.retriever` - ---- - -## Phase 4: Create Backward-Compatible Shims at Old Locations - -### `adr_kit/workflows/` shims - -Create these files (one-liner each): -``` -adr_kit/workflows/base.py: from adr_kit.decision.workflows.base import * # noqa: F401,F403 -adr_kit/workflows/creation.py: from adr_kit.decision.workflows.creation import * # noqa: F401,F403 -adr_kit/workflows/approval.py: from adr_kit.decision.workflows.approval import * # noqa: F401,F403 -adr_kit/workflows/supersede.py: from adr_kit.decision.workflows.supersede import * # noqa: F401,F403 -adr_kit/workflows/preflight.py: from adr_kit.decision.workflows.preflight import * # noqa: F401,F403 -adr_kit/workflows/analyze.py: from adr_kit.decision.workflows.analyze import * # noqa: F401,F403 -adr_kit/workflows/decision_guidance.py: from adr_kit.decision.guidance.decision_guidance import * # noqa: F401,F403 -``` - -**`adr_kit/workflows/__init__.py`** — replace content with: -```python -"""Workflow orchestration — shim for backward compatibility.""" -from .planning import PlanningWorkflow -from adr_kit.decision.workflows.analyze import AnalyzeProjectWorkflow -from adr_kit.decision.workflows.approval import ApprovalWorkflow -from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus -from adr_kit.decision.workflows.creation import CreationWorkflow -from adr_kit.decision.workflows.preflight import PreflightWorkflow -from adr_kit.decision.workflows.supersede import SupersedeWorkflow - -__all__ = [ - "BaseWorkflow", "WorkflowResult", "WorkflowError", "WorkflowStatus", - "ApprovalWorkflow", "CreationWorkflow", "PreflightWorkflow", - "PlanningWorkflow", "SupersedeWorkflow", "AnalyzeProjectWorkflow", -] -``` - -**`adr_kit/workflows/planning.py`** — fix broken sibling import: -- `from .base import BaseWorkflow, WorkflowResult` → `from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowResult` - -### `adr_kit/gate/` shims - -Recreate the directory and files: -``` -adr_kit/gate/__init__.py: (re-export all names from adr_kit.decision.gate — copy the original __all__ list, import each from new location) -adr_kit/gate/models.py: from adr_kit.decision.gate.models import * # noqa: F401,F403 -adr_kit/gate/policy_engine.py: from adr_kit.decision.gate.policy_engine import * # noqa: F401,F403 -adr_kit/gate/policy_gate.py: from adr_kit.decision.gate.policy_gate import * # noqa: F401,F403 -adr_kit/gate/technical_choice.py: from adr_kit.decision.gate.technical_choice import * # noqa: F401,F403 -``` - -### `adr_kit/enforce/` shims - -Replace all files (they were emptied by `git mv`): -``` -adr_kit/enforce/__init__.py: (re-export CIWorkflowGenerator from enforcement.generation.ci, ScriptGenerator from enforcement.generation.scripts) -adr_kit/enforce/eslint.py: from adr_kit.enforcement.adapters.eslint import * # noqa: F401,F403 -adr_kit/enforce/ruff.py: from adr_kit.enforcement.adapters.ruff import * # noqa: F401,F403 -adr_kit/enforce/validator.py: from adr_kit.enforcement.validation.staged import * # noqa: F401,F403 -adr_kit/enforce/stages.py: from adr_kit.enforcement.validation.stages import * # noqa: F401,F403 -adr_kit/enforce/reporter.py: from adr_kit.enforcement.reporter import * # noqa: F401,F403 -adr_kit/enforce/script_generator.py: from adr_kit.enforcement.generation.scripts import * # noqa: F401,F403 -adr_kit/enforce/ci.py: from adr_kit.enforcement.generation.ci import * # noqa: F401,F403 -adr_kit/enforce/hooks.py: from adr_kit.enforcement.generation.hooks import * # noqa: F401,F403 -``` - -### `adr_kit/guardrail/` shims - -Replace all files: -``` -adr_kit/guardrail/__init__.py: (re-export all 12 names from enforcement.config.* modules — copy original __all__) -adr_kit/guardrail/config_writer.py: from adr_kit.enforcement.config.writer import * # noqa: F401,F403 -adr_kit/guardrail/file_monitor.py: from adr_kit.enforcement.config.monitor import * # noqa: F401,F403 -adr_kit/guardrail/manager.py: from adr_kit.enforcement.config.manager import * # noqa: F401,F403 -adr_kit/guardrail/models.py: from adr_kit.enforcement.config.models import * # noqa: F401,F403 -``` - -### `adr_kit/guard/` shims - -Recreate: -``` -adr_kit/guard/__init__.py: (re-export GuardSystem, PolicyViolation, CodeAnalysisResult from enforcement.detection) -adr_kit/guard/detector.py: from adr_kit.enforcement.detection.detector import * # noqa: F401,F403 -``` - -### Verify: `make test-all && make lint` - -All 309+ tests must pass. If not, debug shims before proceeding. - -### Commit: `refactor: move files into decision/ and enforcement/ planes with backward-compat shims` - ---- - -## Phase 5: Update Source Imports - -### `adr_kit/mcp/server.py` (lines 13-18) - -| Old | New | -|-----|-----| -| `from ..workflows.analyze import ...` | `from ..decision.workflows.analyze import ...` | -| `from ..workflows.approval import ...` | `from ..decision.workflows.approval import ...` | -| `from ..workflows.creation import ...` | `from ..decision.workflows.creation import ...` | -| `from ..workflows.planning import ...` | **NO CHANGE** (stays in workflows/) | -| `from ..workflows.preflight import ...` | `from ..decision.workflows.preflight import ...` | -| `from ..workflows.supersede import ...` | `from ..decision.workflows.supersede import ...` | - -### `adr_kit/cli.py` (all lazy imports in function bodies) - -Find and replace each: -| Old | New | -|-----|-----| -| `from .workflows.analyze import` | `from .decision.workflows.analyze import` | -| `from .workflows.approval import` | `from .decision.workflows.approval import` | -| `from .workflows.creation import` | `from .decision.workflows.creation import` | -| `from .workflows.preflight import` | `from .decision.workflows.preflight import` | -| `from .enforce.hooks import` | `from .enforcement.generation.hooks import` | -| `from .gate import PolicyGate, create_technical_choice` | `from .decision.gate import PolicyGate, create_technical_choice` | -| `from .gate import PolicyGate` | `from .decision.gate import PolicyGate` | -| `from .guardrail import GuardrailManager` | `from .enforcement.config.manager import GuardrailManager` | -| `from .enforce.stages import` | `from .enforcement.validation.stages import` | -| `from .enforce.validator import` | `from .enforcement.validation.staged import` | -| `from .enforce.reporter import` | `from .enforcement.reporter import` | -| `from .enforce.script_generator import` | `from .enforcement.generation.scripts import` | -| `from .enforce.ci import` | `from .enforcement.generation.ci import` | - -### Verify: `make test-all && make lint` - -### Commit: `refactor: update source imports to new decision/ and enforcement/ paths` - ---- - -## Phase 6: Update Test Imports - -### Workflow test imports — replace `adr_kit.workflows.X` → `adr_kit.decision.workflows.X` - -Files (8): -- `tests/integration/test_mcp_workflow_integration.py` — many imports, but `adr_kit.workflows.planning` stays unchanged -- `tests/integration/test_comprehensive_scenarios.py` -- `tests/integration/test_workflow_creation.py` -- `tests/integration/test_workflow_analyze.py` -- `tests/unit/test_workflow_base.py` -- `tests/unit/test_policy_validation.py` -- `tests/integration/test_decision_quality_assessment.py` - -Special case: -- `tests/unit/test_decision_guidance.py`: `adr_kit.workflows.decision_guidance` → `adr_kit.decision.guidance.decision_guidance` - -### Enforcement test imports - -| File | Old | New | -|------|-----|-----| -| `tests/unit/test_staged_enforcement.py` | `adr_kit.enforce.stages` | `adr_kit.enforcement.validation.stages` | -| `tests/unit/test_staged_enforcement.py` | `adr_kit.enforce.validator` | `adr_kit.enforcement.validation.staged` | -| `tests/unit/test_reporter.py` | `adr_kit.enforce.reporter` | `adr_kit.enforcement.reporter` | -| `tests/unit/test_reporter.py` | `adr_kit.enforce.stages` | `adr_kit.enforcement.validation.stages` | -| `tests/unit/test_reporter.py` | `adr_kit.enforce.validator` | `adr_kit.enforcement.validation.staged` | -| `tests/unit/test_script_generator.py` | `adr_kit.enforce.script_generator` | `adr_kit.enforcement.generation.scripts` | -| `tests/unit/test_hook_generator.py` | `adr_kit.enforce.hooks` | `adr_kit.enforcement.generation.hooks` | -| `tests/unit/test_ci_generator.py` | `adr_kit.enforce.ci` | `adr_kit.enforcement.generation.ci` | - -**IMPORTANT**: `test_staged_enforcement.py` has dynamic imports inside test function bodies (not just at the top). Search the ENTIRE file for `from adr_kit.enforce.` patterns. - -### Verify: `make test-all && make lint` - -### Commit: `refactor: update test imports to new decision/ and enforcement/ paths` - ---- - -## Phase 7: Remove Shims and Clean Up - -### Delete workflow shims (keep `__init__.py` and `planning.py`) -```bash -rm adr_kit/workflows/base.py adr_kit/workflows/creation.py adr_kit/workflows/approval.py -rm adr_kit/workflows/supersede.py adr_kit/workflows/preflight.py adr_kit/workflows/analyze.py -rm adr_kit/workflows/decision_guidance.py -``` - -### Update `adr_kit/workflows/__init__.py` — final form: -```python -"""Workflow orchestration. Planning workflow stays here (context plane). -All other workflows have moved to adr_kit.decision.workflows.""" -from adr_kit.decision.workflows.base import BaseWorkflow, WorkflowError, WorkflowResult, WorkflowStatus -from .planning import PlanningWorkflow - -__all__ = [ - "BaseWorkflow", "WorkflowResult", "WorkflowError", "WorkflowStatus", - "PlanningWorkflow", -] -``` - -### Delete entire old directories -```bash -rm -rf adr_kit/gate/ -rm -rf adr_kit/enforce/ -rm -rf adr_kit/guardrail/ -rm -rf adr_kit/guard/ -rm -rf adr_kit/knowledge/ # empty, only __pycache__ -``` - -### Verify: `make test-all && make lint` - -### Commit: `refactor: remove backward-compat shims and old directories` - ---- - -## Phase 8: Final Verification - -1. `make test-all` — all 309+ tests pass -2. `make lint` — clean -3. Verify no stale imports remain: -```bash -grep -rn "from.*\.gate\." adr_kit/ --include="*.py" | grep -v __pycache__ | grep -v decision -grep -rn "from.*\.enforce\." adr_kit/ --include="*.py" | grep -v __pycache__ | grep -v enforcement -grep -rn "from.*\.guardrail\." adr_kit/ --include="*.py" | grep -v __pycache__ -grep -rn "from.*\.guard\." adr_kit/ --include="*.py" | grep -v __pycache__ | grep -v enforcement -grep -rn "adr_kit\.gate\." tests/ --include="*.py" -grep -rn "adr_kit\.enforce\." tests/ --include="*.py" -grep -rn "adr_kit\.guardrail\." tests/ --include="*.py" -grep -rn "adr_kit\.guard\." tests/ --include="*.py" -``` -All must return zero results. - -4. Verify directory structure: `find adr_kit/ -name "*.py" -not -path "*__pycache__*" | sort` - ---- - -## Critical Notes for Executor - -1. **Line numbers are approximate** — always use string matching (Edit tool's `old_string`), never line numbers alone. -2. **`approval.py` is the trickiest file** — 10 relative imports including 3 lazy ones in function bodies. -3. **`reporter.py`** has an internal import `from .validator` that changes to `from .validation.staged` (not a depth change, a path restructure). -4. **`scripts.py`** (was script_generator.py) has `from .stages` that changes to `from ..validation.stages`. -5. **`manager.py`** (in config/) has `from .config_writer` → `from .writer` and `from .file_monitor` → `from .monitor` (file renames). -6. **`planning.py`** stays but its `from .base` import breaks — fix it in Phase 4. -7. **After each phase, run `make test-all && make lint`** before proceeding.