Files
Aperant/auto-build/validate_spec.py
T
2025-12-10 09:10:55 +01:00

527 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Spec Validation System
======================
Validates spec outputs at each checkpoint to ensure reliability.
This is the enforcement layer that catches errors before they propagate.
The spec creation process has mandatory checkpoints:
1. Prerequisites (project_index.json exists)
2. Context (context.json created with required fields)
3. Spec document (spec.md with required sections)
4. Implementation plan (implementation_plan.json with valid schema)
Usage:
python auto-build/validate_spec.py --spec-dir auto-build/specs/001-feature/ --checkpoint prereqs
python auto-build/validate_spec.py --spec-dir auto-build/specs/001-feature/ --checkpoint context
python auto-build/validate_spec.py --spec-dir auto-build/specs/001-feature/ --checkpoint spec
python auto-build/validate_spec.py --spec-dir auto-build/specs/001-feature/ --checkpoint plan
python auto-build/validate_spec.py --spec-dir auto-build/specs/001-feature/ --checkpoint all
"""
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
# JSON Schemas for validation
IMPLEMENTATION_PLAN_SCHEMA = {
"required_fields": ["feature", "workflow_type", "phases"],
"optional_fields": ["services_involved", "final_acceptance", "created_at", "updated_at", "spec_file", "qa_acceptance", "qa_signoff", "summary"],
"workflow_types": ["feature", "refactor", "investigation", "migration", "simple"],
"phase_schema": {
"required_fields": ["phase", "name", "chunks"],
"optional_fields": ["type", "depends_on", "parallel_safe"],
"phase_types": ["setup", "implementation", "investigation", "integration", "cleanup"],
},
"chunk_schema": {
"required_fields": ["id", "description", "status"],
"optional_fields": [
"service", "all_services", "files_to_modify", "files_to_create",
"patterns_from", "verification", "expected_output", "actual_output",
"started_at", "completed_at", "session_id", "critique_result"
],
"status_values": ["pending", "in_progress", "completed", "blocked", "failed"],
},
"verification_schema": {
"required_fields": ["type"],
"optional_fields": ["run", "url", "method", "expect_status", "expect_contains", "scenario"],
"verification_types": ["command", "api", "browser", "component", "manual", "none"],
},
}
CONTEXT_SCHEMA = {
"required_fields": ["task_description"],
"optional_fields": [
"scoped_services", "files_to_modify", "files_to_reference",
"patterns", "service_contexts", "created_at"
],
}
PROJECT_INDEX_SCHEMA = {
"required_fields": ["project_type"],
"optional_fields": [
"services", "infrastructure", "conventions", "root_path",
"created_at", "git_info"
],
"project_types": ["single", "monorepo"],
}
SPEC_REQUIRED_SECTIONS = [
"Overview",
"Workflow Type",
"Task Scope",
"Success Criteria",
]
SPEC_RECOMMENDED_SECTIONS = [
"Files to Modify",
"Files to Reference",
"Requirements",
"QA Acceptance Criteria",
]
@dataclass
class ValidationResult:
"""Result of a validation check."""
valid: bool
checkpoint: str
errors: list[str]
warnings: list[str]
fixes: list[str] # Suggested fixes
def __str__(self) -> str:
lines = [f"Checkpoint: {self.checkpoint}"]
lines.append(f"Status: {'PASS' if self.valid else 'FAIL'}")
if self.errors:
lines.append("\nErrors:")
for err in self.errors:
lines.append(f"{err}")
if self.warnings:
lines.append("\nWarnings:")
for warn in self.warnings:
lines.append(f"{warn}")
if self.fixes and not self.valid:
lines.append("\nSuggested Fixes:")
for fix in self.fixes:
lines.append(f"{fix}")
return "\n".join(lines)
class SpecValidator:
"""Validates spec outputs at each checkpoint."""
def __init__(self, spec_dir: Path):
self.spec_dir = Path(spec_dir)
def validate_all(self) -> list[ValidationResult]:
"""Run all validations."""
results = [
self.validate_prereqs(),
self.validate_context(),
self.validate_spec_document(),
self.validate_implementation_plan(),
]
return results
def validate_prereqs(self) -> ValidationResult:
"""Validate prerequisites exist."""
errors = []
warnings = []
fixes = []
# Check spec directory exists
if not self.spec_dir.exists():
errors.append(f"Spec directory does not exist: {self.spec_dir}")
fixes.append(f"Create directory: mkdir -p {self.spec_dir}")
return ValidationResult(False, "prereqs", errors, warnings, fixes)
# Check project_index.json
project_index = self.spec_dir / "project_index.json"
if not project_index.exists():
# Check if it exists at auto-build level
auto_build_index = self.spec_dir.parent.parent / "project_index.json"
if auto_build_index.exists():
warnings.append("project_index.json exists at auto-build/ but not in spec folder")
fixes.append(f"Copy: cp {auto_build_index} {project_index}")
else:
errors.append("project_index.json not found")
fixes.append("Run: python auto-build/analyzer.py --output auto-build/project_index.json")
return ValidationResult(
valid=len(errors) == 0,
checkpoint="prereqs",
errors=errors,
warnings=warnings,
fixes=fixes,
)
def validate_context(self) -> ValidationResult:
"""Validate context.json exists and has required structure."""
errors = []
warnings = []
fixes = []
context_file = self.spec_dir / "context.json"
if not context_file.exists():
errors.append("context.json not found")
fixes.append("Run: python auto-build/context.py --task '[task]' --services '[services]' --output context.json")
return ValidationResult(False, "context", errors, warnings, fixes)
try:
with open(context_file) as f:
context = json.load(f)
except json.JSONDecodeError as e:
errors.append(f"context.json is invalid JSON: {e}")
fixes.append("Regenerate context.json or fix JSON syntax")
return ValidationResult(False, "context", errors, warnings, fixes)
# Check required fields
for field in CONTEXT_SCHEMA["required_fields"]:
if field not in context:
errors.append(f"Missing required field: {field}")
fixes.append(f"Add '{field}' to context.json")
# Check optional but recommended fields
recommended = ["files_to_modify", "files_to_reference", "scoped_services"]
for field in recommended:
if field not in context or not context[field]:
warnings.append(f"Missing recommended field: {field}")
return ValidationResult(
valid=len(errors) == 0,
checkpoint="context",
errors=errors,
warnings=warnings,
fixes=fixes,
)
def validate_spec_document(self) -> ValidationResult:
"""Validate spec.md exists and has required sections."""
errors = []
warnings = []
fixes = []
spec_file = self.spec_dir / "spec.md"
if not spec_file.exists():
errors.append("spec.md not found")
fixes.append("Create spec.md with required sections")
return ValidationResult(False, "spec", errors, warnings, fixes)
content = spec_file.read_text()
# Check for required sections
for section in SPEC_REQUIRED_SECTIONS:
# Look for ## Section or # Section
pattern = rf"^##?\s+{re.escape(section)}"
if not re.search(pattern, content, re.MULTILINE | re.IGNORECASE):
errors.append(f"Missing required section: '{section}'")
fixes.append(f"Add '## {section}' section to spec.md")
# Check for recommended sections
for section in SPEC_RECOMMENDED_SECTIONS:
pattern = rf"^##?\s+{re.escape(section)}"
if not re.search(pattern, content, re.MULTILINE | re.IGNORECASE):
warnings.append(f"Missing recommended section: '{section}'")
# Check minimum content length
if len(content) < 500:
warnings.append("spec.md seems too short (< 500 chars)")
return ValidationResult(
valid=len(errors) == 0,
checkpoint="spec",
errors=errors,
warnings=warnings,
fixes=fixes,
)
def validate_implementation_plan(self) -> ValidationResult:
"""Validate implementation_plan.json exists and has valid schema."""
errors = []
warnings = []
fixes = []
plan_file = self.spec_dir / "implementation_plan.json"
if not plan_file.exists():
errors.append("implementation_plan.json not found")
fixes.append(f"Run: python auto-build/planner.py --spec-dir {self.spec_dir}")
return ValidationResult(False, "plan", errors, warnings, fixes)
try:
with open(plan_file) as f:
plan = json.load(f)
except json.JSONDecodeError as e:
errors.append(f"implementation_plan.json is invalid JSON: {e}")
fixes.append("Regenerate with: python auto-build/planner.py --spec-dir " + str(self.spec_dir))
return ValidationResult(False, "plan", errors, warnings, fixes)
# Validate top-level required fields
schema = IMPLEMENTATION_PLAN_SCHEMA
for field in schema["required_fields"]:
if field not in plan:
errors.append(f"Missing required field: {field}")
fixes.append(f"Add '{field}' to implementation_plan.json")
# Validate workflow_type
if "workflow_type" in plan:
if plan["workflow_type"] not in schema["workflow_types"]:
errors.append(f"Invalid workflow_type: {plan['workflow_type']}")
fixes.append(f"Use one of: {schema['workflow_types']}")
# Validate phases
phases = plan.get("phases", [])
if not phases:
errors.append("No phases defined")
fixes.append("Add at least one phase with chunks")
else:
for i, phase in enumerate(phases):
phase_errors = self._validate_phase(phase, i)
errors.extend(phase_errors)
# Check for at least one chunk
total_chunks = sum(len(p.get("chunks", [])) for p in phases)
if total_chunks == 0:
errors.append("No chunks defined in any phase")
fixes.append("Add chunks to phases")
# Validate dependencies don't create cycles
dep_errors = self._validate_dependencies(phases)
errors.extend(dep_errors)
return ValidationResult(
valid=len(errors) == 0,
checkpoint="plan",
errors=errors,
warnings=warnings,
fixes=fixes,
)
def _validate_phase(self, phase: dict, index: int) -> list[str]:
"""Validate a single phase."""
errors = []
schema = IMPLEMENTATION_PLAN_SCHEMA["phase_schema"]
for field in schema["required_fields"]:
if field not in phase:
errors.append(f"Phase {index + 1}: missing required field '{field}'")
if "type" in phase and phase["type"] not in schema["phase_types"]:
errors.append(f"Phase {index + 1}: invalid type '{phase['type']}'")
# Validate chunks
chunks = phase.get("chunks", [])
for j, chunk in enumerate(chunks):
chunk_errors = self._validate_chunk(chunk, index, j)
errors.extend(chunk_errors)
return errors
def _validate_chunk(self, chunk: dict, phase_idx: int, chunk_idx: int) -> list[str]:
"""Validate a single chunk."""
errors = []
schema = IMPLEMENTATION_PLAN_SCHEMA["chunk_schema"]
for field in schema["required_fields"]:
if field not in chunk:
errors.append(f"Phase {phase_idx + 1}, Chunk {chunk_idx + 1}: missing required field '{field}'")
if "status" in chunk and chunk["status"] not in schema["status_values"]:
errors.append(f"Phase {phase_idx + 1}, Chunk {chunk_idx + 1}: invalid status '{chunk['status']}'")
# Validate verification if present
if "verification" in chunk:
ver = chunk["verification"]
ver_schema = IMPLEMENTATION_PLAN_SCHEMA["verification_schema"]
if "type" not in ver:
errors.append(f"Phase {phase_idx + 1}, Chunk {chunk_idx + 1}: verification missing 'type'")
elif ver["type"] not in ver_schema["verification_types"]:
errors.append(f"Phase {phase_idx + 1}, Chunk {chunk_idx + 1}: invalid verification type '{ver['type']}'")
return errors
def _validate_dependencies(self, phases: list[dict]) -> list[str]:
"""Check for circular dependencies."""
errors = []
phase_nums = {p.get("phase", i + 1) for i, p in enumerate(phases)}
for phase in phases:
phase_num = phase.get("phase", 0)
depends_on = phase.get("depends_on", [])
for dep in depends_on:
if dep not in phase_nums:
errors.append(f"Phase {phase_num}: depends on non-existent phase {dep}")
if dep >= phase_num:
errors.append(f"Phase {phase_num}: cannot depend on phase {dep} (would create cycle)")
return errors
def auto_fix_plan(spec_dir: Path) -> bool:
"""Attempt to auto-fix common implementation_plan.json issues."""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return False
try:
with open(plan_file) as f:
plan = json.load(f)
except json.JSONDecodeError:
return False
fixed = False
# Fix missing top-level fields
if "feature" not in plan:
plan["feature"] = "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", [])):
if "phase" not in phase:
phase["phase"] = i + 1
fixed = True
if "name" not in phase:
phase["name"] = f"Phase {i + 1}"
fixed = True
if "chunks" not in phase:
phase["chunks"] = []
fixed = True
# Fix chunks
for j, chunk in enumerate(phase.get("chunks", [])):
if "id" not in chunk:
chunk["id"] = f"chunk-{i + 1}-{j + 1}"
fixed = True
if "description" not in chunk:
chunk["description"] = "No description"
fixed = True
if "status" not in chunk:
chunk["status"] = "pending"
fixed = True
if fixed:
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
print(f"Auto-fixed: {plan_file}")
return fixed
def main():
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(
description="Validate spec outputs at checkpoints"
)
parser.add_argument(
"--spec-dir",
type=Path,
required=True,
help="Directory containing spec files",
)
parser.add_argument(
"--checkpoint",
choices=["prereqs", "context", "spec", "plan", "all"],
default="all",
help="Which checkpoint to validate",
)
parser.add_argument(
"--auto-fix",
action="store_true",
help="Attempt to auto-fix common issues",
)
parser.add_argument(
"--json",
action="store_true",
help="Output results as JSON",
)
args = parser.parse_args()
validator = SpecValidator(args.spec_dir)
if args.auto_fix:
auto_fix_plan(args.spec_dir)
# Run validations
if args.checkpoint == "all":
results = validator.validate_all()
elif args.checkpoint == "prereqs":
results = [validator.validate_prereqs()]
elif args.checkpoint == "context":
results = [validator.validate_context()]
elif args.checkpoint == "spec":
results = [validator.validate_spec_document()]
elif args.checkpoint == "plan":
results = [validator.validate_implementation_plan()]
# Output
all_valid = all(r.valid for r in results)
if args.json:
output = {
"valid": all_valid,
"results": [
{
"checkpoint": r.checkpoint,
"valid": r.valid,
"errors": r.errors,
"warnings": r.warnings,
"fixes": r.fixes,
}
for r in results
],
}
print(json.dumps(output, indent=2))
else:
print("=" * 60)
print(" SPEC VALIDATION REPORT")
print("=" * 60)
print()
for result in results:
print(result)
print()
print("=" * 60)
if all_valid:
print(" ✓ ALL CHECKPOINTS PASSED")
else:
print(" ✗ VALIDATION FAILED - See errors above")
print("=" * 60)
sys.exit(0 if all_valid else 1)
if __name__ == "__main__":
main()