Files
Aperant/apps/backend/spec/validate_pkg/auto_fix.py
T
Umaru 29d28bf0a2 fix(planner): enforce implementation_plan schema (issue #884) (#912)
* fix(planner): enforce implementation_plan schema

* fix(logs): sync planning->coding phase to source

* style(backend): ruff format

* fix(progress): backfill empty description from title

* fix(planner): prevent post-processing during planning

* fix(planner): normalize phase_id and reuse alias mapping

* fix(planner): address review suggestions

* fix(progress): prevent phase field overrides

* test(planner): avoid hardcoded planner.md path

* fix(auto-fix): normalize phase_id and depends_on

* fix(planner): harden plan normalization edge cases

* fix(auto-fix): handle file I/O errors
2026-01-11 14:07:25 +01:00

181 lines
5.8 KiB
Python

"""
Auto-Fix Utilities
==================
Automated fixes for common implementation plan issues.
"""
import json
from pathlib import Path
from core.plan_normalization import normalize_subtask_aliases
def _normalize_status(value: object) -> str:
"""Normalize common status variants to schema-compliant values."""
if not isinstance(value, str):
return "pending"
normalized = value.strip().lower()
if normalized in {"pending", "in_progress", "completed", "blocked", "failed"}:
return normalized
# Common non-standard variants produced by LLMs or legacy tooling
if normalized in {"not_started", "not started", "todo", "to_do", "backlog"}:
return "pending"
if normalized in {"in-progress", "inprogress", "working"}:
return "in_progress"
if normalized in {"done", "complete", "completed_successfully"}:
return "completed"
# Unknown values fall back to pending to prevent deadlocks in execution
return "pending"
def auto_fix_plan(spec_dir: Path) -> bool:
"""Attempt to auto-fix common implementation_plan.json issues.
Args:
spec_dir: Path to the spec directory
Returns:
True if fixes were applied, False otherwise
"""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return False
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
except (OSError, json.JSONDecodeError):
return False
fixed = False
# Support older/simple plans that use top-level "subtasks" (or "chunks")
if "phases" not in plan and (
isinstance(plan.get("subtasks"), list) or isinstance(plan.get("chunks"), list)
):
subtasks = plan.get("subtasks") or plan.get("chunks") or []
plan["phases"] = [
{
"id": "1",
"phase": 1,
"name": "Phase 1",
"subtasks": subtasks,
}
]
plan.pop("subtasks", None)
plan.pop("chunks", None)
fixed = True
# Fix missing top-level fields
if "feature" not in plan:
plan["feature"] = plan.get("title") or plan.get("spec_id") or "Unnamed Feature"
fixed = True
if "workflow_type" not in plan:
plan["workflow_type"] = "feature"
fixed = True
if "phases" not in plan:
plan["phases"] = []
fixed = True
# Fix phases
for i, phase in enumerate(plan.get("phases", [])):
# Normalize common phase field aliases
if "name" not in phase and "title" in phase:
phase["name"] = phase.get("title")
fixed = True
if "phase" not in phase and "phase_id" in phase:
phase_id = phase.get("phase_id")
phase_id_str = str(phase_id).strip() if phase_id is not None else ""
phase_num: int | None = None
if isinstance(phase_id, int) and not isinstance(phase_id, bool):
phase_num = phase_id
elif (
isinstance(phase_id, float)
and not isinstance(phase_id, bool)
and phase_id.is_integer()
):
phase_num = int(phase_id)
elif isinstance(phase_id, str) and phase_id_str.isdigit():
phase_num = int(phase_id_str)
if phase_num is not None:
if "id" not in phase:
phase["id"] = str(phase_num)
fixed = True
phase["phase"] = phase_num
fixed = True
elif "id" not in phase and phase_id is not None:
phase["id"] = phase_id_str
fixed = True
if "phase" not in phase:
phase["phase"] = i + 1
fixed = True
depends_on_raw = phase.get("depends_on", [])
if isinstance(depends_on_raw, list):
normalized_depends_on = [
str(d).strip() for d in depends_on_raw if d is not None
]
elif depends_on_raw is None:
normalized_depends_on = []
else:
normalized_depends_on = [str(depends_on_raw).strip()]
if normalized_depends_on != depends_on_raw:
phase["depends_on"] = normalized_depends_on
fixed = True
if "name" not in phase:
phase["name"] = f"Phase {i + 1}"
fixed = True
if "subtasks" not in phase:
phase["subtasks"] = phase.get("chunks", [])
fixed = True
elif "chunks" in phase and not phase.get("subtasks"):
# If subtasks exists but is empty, fall back to chunks if present
phase["subtasks"] = phase.get("chunks", [])
fixed = True
# Fix subtasks
for j, subtask in enumerate(phase.get("subtasks", [])):
normalized, changed = normalize_subtask_aliases(subtask)
if changed:
subtask.update(normalized)
fixed = True
if "id" not in subtask:
subtask["id"] = f"subtask-{i + 1}-{j + 1}"
fixed = True
if "description" not in subtask:
subtask["description"] = "No description"
fixed = True
if "status" not in subtask:
subtask["status"] = "pending"
fixed = True
else:
normalized_status = _normalize_status(subtask.get("status"))
if subtask.get("status") != normalized_status:
subtask["status"] = normalized_status
fixed = True
if fixed:
try:
with open(plan_file, "w", encoding="utf-8") as f:
json.dump(plan, f, indent=2, ensure_ascii=False)
except OSError:
return False
print(f"Auto-fixed: {plan_file}")
return fixed