Source code for ralph.mcp.artifacts.plan._design_section

"""Design section aggregating the seven SE-opinionated sub-models.

The optional ``planning_profile`` field is a preset hint for agents. When
set, the @model_validator below bias-fills any None sub-section from a
class-level default dict. User-provided sub-section values always win; the
preset only fills in missing pieces. Sentinel ids (``PRESET-01``) cannot
collide with user-provided ``AC-XX`` / ``REF-XX`` entries because the prefix
is distinct.
"""

from __future__ import annotations

from pydantic import ConfigDict, Field, field_validator, model_validator

from ralph.mcp.artifacts.plan._acceptance_criteria import (
    AcceptanceCriteria,
    AcceptanceCriterion,
)
from ralph.mcp.artifacts.plan._dependency_injection import DependencyInjection
from ralph.mcp.artifacts.plan._design_constraints import DesignConstraints
from ralph.mcp.artifacts.plan._drift_detection import DriftDetection
from ralph.mcp.artifacts.plan._non_goals import NonGoals
from ralph.mcp.artifacts.plan._planning_profile import PlanningProfile
from ralph.mcp.artifacts.plan._refactor_strategy import RefactorStrategy
from ralph.mcp.artifacts.plan._testability import Testability
from ralph.pydantic_compat import RalphBaseModel

_STRICT_DEFAULTS: dict[str, object] = {
    "testability": Testability(
        must_be_black_box=True,
        forbidden_in_tests=["time.sleep", "subprocess.run-no-timeout"],
        required_test_layers=["unit"],
    ),
    "dependency_injection": DependencyInjection(
        required_for_testability=True,
        forbidden_patterns=["global-singleton", "module-level-mutable-state"],
    ),
    "refactor_strategy": RefactorStrategy(
        approach="incremental",
        dead_code_policy="delete-immediately",
        allow_temporary_hacks=False,
    ),
    "drift_detection": DriftDetection(
        guard_commands=["ruff check ralph/", "uv run python -m mypy ralph/"],
        sources=["ruff", "mypy"],
        on_drift_action="fail-verify",
    ),
    "acceptance_criteria": AcceptanceCriteria(
        criteria=[
            AcceptanceCriterion(
                id="PRESET-01",
                description=(
                    "Strict preset placeholder - executor should replace with a real "
                    "acceptance criterion matching ^[A-Z]+-\\d{2,}$"
                ),
            )
        ]
    ),
}

_BALANCED_DEFAULTS: dict[str, object] = {
    "testability": _STRICT_DEFAULTS["testability"],
    "dependency_injection": _STRICT_DEFAULTS["dependency_injection"],
    "refactor_strategy": _STRICT_DEFAULTS["refactor_strategy"],
}

_PRESET_DEFAULTS: dict[PlanningProfile, dict[str, object]] = {
    "strict": _STRICT_DEFAULTS,
    "balanced": _BALANCED_DEFAULTS,
}


[docs] class DesignSection(RalphBaseModel): """Design section aggregating SE-opinionated sub-models. Collects cross-cutting design choices: planning profile, constraints, non-goals, dependency-injection expectations, drift-detection guards, testability requirements, refactor strategy, and acceptance criteria. When ``planning_profile`` is set, the model bias-fills any missing sub-sections from preset defaults; user-provided values always win. """ model_config = ConfigDict(extra="forbid") planning_profile: PlanningProfile | None = None constraints: DesignConstraints | None = None non_goals: NonGoals | None = None dependency_injection: DependencyInjection | None = None drift_detection: DriftDetection | None = None testability: Testability | None = None refactor_strategy: RefactorStrategy | None = None acceptance_criteria: AcceptanceCriteria | None = None outcome: str | None = Field(default=None, max_length=1000) notes: str | None = Field(default=None, max_length=20000) @field_validator("outcome") @classmethod def _strip_outcome(cls, value: str | None) -> str | None: if value is None: return None stripped = value.strip() return stripped or None @model_validator(mode="after") def _bias_fill_from_profile(self) -> DesignSection: profile = self.planning_profile if profile is None: return self for key, default in _PRESET_DEFAULTS[profile].items(): current: object = getattr(self, key) if current is None: setattr(self, key, default) return self
__all__ = ["DesignSection"]