2026-09-22 23:21:59 +03:00
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
|
|
from pydantic import Field, model_validator
|
|
|
|
|
|
|
2026-09-26 12:50:19 +03:00
|
|
|
|
from contracts.CodeLanguages import Language
|
2026-09-22 23:21:59 +03:00
|
|
|
|
from contracts.base import _StrictModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LanguageSnapshot(_StrictModel):
|
2026-09-26 12:50:19 +03:00
|
|
|
|
name: Language = Field(description="Primary language the plan commits to.")
|
2026-09-22 23:21:59 +03:00
|
|
|
|
version: str | None = Field(
|
2026-09-26 12:50:19 +03:00
|
|
|
|
description="Language version if known (e.g. '3.12'), else null.",
|
2026-09-22 23:21:59 +03:00
|
|
|
|
)
|
2026-09-26 12:50:19 +03:00
|
|
|
|
test_runner: str = Field(min_length=1)
|
|
|
|
|
|
formatter: str | None = Field(description="Formatter command, or null.")
|
|
|
|
|
|
linter: str | None = Field(description="Linter command, or null.")
|
|
|
|
|
|
detected_from: list[str] = Field(min_length=1)
|
2026-09-22 23:21:59 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PlanStep(_StrictModel):
|
|
|
|
|
|
order: int = Field(ge=1, description="1-based ordering of this step.")
|
|
|
|
|
|
file: str = Field(
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
description="Repo-relative path (no leading '/', no '..').",
|
|
|
|
|
|
)
|
|
|
|
|
|
change_kind: Literal["create", "modify", "delete"]
|
|
|
|
|
|
description: str = Field(
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
description="What changes in this file and why, 1–3 sentences.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PlanOutput(_StrictModel):
|
|
|
|
|
|
summary: str = Field(
|
|
|
|
|
|
min_length=1, description="1–3 sentence summary of the whole change."
|
|
|
|
|
|
)
|
|
|
|
|
|
steps: list[PlanStep] = Field(
|
|
|
|
|
|
min_length=1, description="Ordered steps. Each step is one file."
|
|
|
|
|
|
)
|
|
|
|
|
|
target_files: list[str] = Field(
|
|
|
|
|
|
min_length=1, description="Unique repo-relative files this plan touches."
|
|
|
|
|
|
)
|
|
|
|
|
|
target_tests: list[str] = Field(
|
|
|
|
|
|
description="Tests that must pass. May be empty for non-code changes."
|
|
|
|
|
|
)
|
|
|
|
|
|
language: LanguageSnapshot
|
|
|
|
|
|
breaks_existing: bool = Field(
|
|
|
|
|
|
description="True if this changes existing behavior a user or caller "
|
|
|
|
|
|
"could rely on.",
|
|
|
|
|
|
)
|
|
|
|
|
|
risk_notes: list[str] = Field(
|
|
|
|
|
|
description="Reasons this might be risky. Non-empty if breaks_existing.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@model_validator(mode="after")
|
|
|
|
|
|
def _consistency(self) -> "PlanOutput":
|
|
|
|
|
|
# 1. target_files must equal the set of files in steps.
|
|
|
|
|
|
step_files = {s.file for s in self.steps}
|
|
|
|
|
|
if set(self.target_files) != step_files:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"target_files {sorted(self.target_files)} must equal "
|
|
|
|
|
|
f"the set of step files {sorted(step_files)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. breaks_existing implies risk_notes non-empty.
|
|
|
|
|
|
if self.breaks_existing and not self.risk_notes:
|
|
|
|
|
|
raise ValueError("breaks_existing=True requires at least one risk note")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. No absolute paths, no traversal.
|
|
|
|
|
|
for f in self.target_files:
|
|
|
|
|
|
if f.startswith("/") or ".." in f.split("/"):
|
|
|
|
|
|
raise ValueError(f"unsafe path: {f!r}")
|
|
|
|
|
|
|
2026-09-26 12:50:19 +03:00
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def wrap_ref_siblings(node):
|
|
|
|
|
|
if isinstance(node, dict):
|
|
|
|
|
|
if "$ref" in node and len(node) > 1:
|
|
|
|
|
|
ref = node["$ref"]
|
|
|
|
|
|
siblings = {k: wrap_ref_siblings(v) for k, v in node.items() if k != "$ref"}
|
|
|
|
|
|
return {"anyOf": [{"$ref": ref}], **siblings}
|
|
|
|
|
|
return {k: wrap_ref_siblings(v) for k, v in node.items()}
|
|
|
|
|
|
if isinstance(node, list):
|
|
|
|
|
|
return [wrap_ref_siblings(x) for x in node]
|
|
|
|
|
|
return node
|