Skip to main content

scripts-status-report-validator

#!/usr/bin/env python3 """ Status Report Validator

Validates status reports against CODITECT-STANDARD-STATUS-REPORTING. Checks structure, content, actionability, and quality.

Usage: python3 status-report-validator.py <report_path> python3 status-report-validator.py --all python3 status-report-validator.py --dir docs/status-reports/

Author: CODITECT Core Team Version: 1.0.0 Created: 2026-01-25 """

import argparse import re import sys from pathlib import Path from typing import Dict, List, Tuple import yaml

class StatusReportValidator: """Validates status reports against CODITECT standards."""

# Required sections for different report types
REQUIRED_SECTIONS = {
"initial": [
"Executive Summary",
"Key Metrics",
"Current State",
"Core Documents Index",
"Next Steps",
"Risk Register",
"Recommendations"
],
"phase": [
"Executive Summary",
"Phase Summary",
"Deliverables Completed",
"Lessons Learned",
"Next Phase Readiness"
],
"sprint": [
"Sprint Goals",
"Completed Work",
"Velocity Trend",
"Next Sprint Plan"
],
"board": [
"Executive Summary",
"Key Metrics",
"Financial Summary",
"Risk Register",
"Recommendations"
]
}

# Required frontmatter fields
REQUIRED_FRONTMATTER = [
"title",
"type",
"version",
"status",
"created",
"updated"
]

# Valid status indicators
STATUS_INDICATORS = ["βœ…", "⚠️", "πŸ”΄", "⏳", "⏸️", "πŸ”œ"]

def __init__(self, report_path: str):
self.report_path = Path(report_path)
self.content = ""
self.frontmatter = {}
self.sections = []
self.errors = []
self.warnings = []
self.score = 0

def load_report(self) -> bool:
"""Load and parse the report file."""
try:
self.content = self.report_path.read_text(encoding='utf-8')
self._parse_frontmatter()
self._parse_sections()
return True
except Exception as e:
self.errors.append(f"Failed to load report: {e}")
return False

def _parse_frontmatter(self):
"""Extract YAML frontmatter."""
match = re.match(r'^---\n(.*?)\n---', self.content, re.DOTALL)
if match:
try:
self.frontmatter = yaml.safe_load(match.group(1))
except yaml.YAMLError as e:
self.errors.append(f"Invalid YAML frontmatter: {e}")

def _parse_sections(self):
"""Extract markdown sections."""
# Find all headings
self.sections = re.findall(r'^##\s+(.+)$', self.content, re.MULTILINE)

def _detect_report_type(self) -> str:
"""Detect report type from filename or frontmatter."""
filename = self.report_path.name.lower()

if "initial" in filename:
return "initial"
elif "phase" in filename:
return "phase"
elif "sprint" in filename:
return "sprint"
elif "board" in filename:
return "board"
elif "investor" in filename:
return "board" # Similar requirements
else:
return "initial" # Default

def validate_structure(self) -> int:
"""Validate report structure (25 points)."""
points = 0

# Check frontmatter (5 points)
if self.frontmatter:
missing = [f for f in self.REQUIRED_FRONTMATTER
if f not in self.frontmatter]
if not missing:
points += 5
else:
self.errors.append(f"Missing frontmatter fields: {missing}")
points += max(0, 5 - len(missing))
else:
self.errors.append("No YAML frontmatter found")

# Check executive summary (5 points)
if "Executive Summary" in self.sections:
points += 5
else:
self.errors.append("Missing Executive Summary section")

# Check key metrics (5 points)
if "Key Metrics" in self.sections or re.search(r'\|\s*Metric\s*\|', self.content):
points += 5
else:
self.warnings.append("No Key Metrics table found")
points += 2

# Check required sections (5 points)
report_type = self._detect_report_type()
required = self.REQUIRED_SECTIONS.get(report_type, [])
found = sum(1 for s in required if any(s.lower() in sec.lower() for sec in self.sections))
section_score = (found / len(required)) * 5 if required else 5
points += int(section_score)

if found < len(required):
missing = [s for s in required if not any(s.lower() in sec.lower() for sec in self.sections)]
self.warnings.append(f"Missing recommended sections: {missing}")

# Check heading hierarchy (5 points)
h1_count = len(re.findall(r'^# [^#]', self.content, re.MULTILINE))
if h1_count == 1:
points += 5
elif h1_count == 0:
self.errors.append("No H1 heading found")
else:
self.warnings.append(f"Multiple H1 headings found ({h1_count})")
points += 3

return points

def validate_content(self) -> int:
"""Validate report content (35 points)."""
points = 0

# Check 5W+H framework (7 points)
# Look for evidence of WHAT, WHY, WHEN, WHERE, WHO, HOW
content_lower = self.content.lower()
wh_indicators = {
"what": ["current state", "status", "accomplished", "completed"],
"why": ["purpose", "objective", "goal", "rationale"],
"when": ["timeline", "date", "deadline", "schedule"],
"who": ["owner", "responsible", "lead", "team"],
"how": ["next steps", "approach", "method", "plan"]
}

wh_found = 0
for wh, indicators in wh_indicators.items():
if any(ind in content_lower for ind in indicators):
wh_found += 1

points += min(7, int((wh_found / 5) * 7))

# Check status indicators consistency (7 points)
indicators_used = [i for i in self.STATUS_INDICATORS if i in self.content]
if indicators_used:
points += 7
else:
self.warnings.append("No status indicators (βœ…, ⚠️, πŸ”΄) found")
points += 3

# Check metrics have trends and targets (7 points)
# Look for patterns like "Current | Target | Trend"
if re.search(r'target|goal|threshold', content_lower):
points += 4
else:
self.warnings.append("Metrics may be missing targets")
points += 2

if any(t in self.content for t in ["↑", "↓", "β†’", "trend"]):
points += 3

# Check risks have mitigation (7 points)
if "risk" in content_lower:
if "mitigation" in content_lower or "mitigate" in content_lower:
points += 7
else:
self.warnings.append("Risks may be missing mitigation strategies")
points += 4
else:
points += 3 # No risks section needed for some reports

# Check next steps have owners and dates (7 points)
if "next step" in content_lower or "action" in content_lower:
# Look for owner patterns
if re.search(r'owner|responsible|assigned', content_lower):
points += 4
else:
self.warnings.append("Next steps may be missing owners")
points += 2

# Look for date patterns
if re.search(r'\d{4}-\d{2}-\d{2}|week|month|q[1-4]', content_lower):
points += 3
else:
self.warnings.append("Next steps may be missing dates")
points += 1
else:
self.errors.append("No Next Steps section found")

return points

def validate_actionability(self) -> int:
"""Validate report actionability (25 points)."""
points = 0
content_lower = self.content.lower()

# Check clear recommendations (10 points)
if "recommendation" in content_lower:
points += 10
elif "suggest" in content_lower or "propose" in content_lower:
points += 7
else:
self.warnings.append("No clear recommendations section")
points += 3

# Check decisions required are explicit (10 points)
if "decision" in content_lower and "required" in content_lower:
points += 10
elif "decision" in content_lower:
points += 7
elif "approval" in content_lower or "go/no-go" in content_lower:
points += 8
else:
self.warnings.append("No explicit decisions required section")
points += 3

# Check blockers highlighted (5 points)
if "blocker" in content_lower or "blocked" in content_lower or "πŸ”΄" in self.content:
points += 5
else:
points += 3 # Not all reports have blockers

return points

def validate_quality(self) -> int:
"""Validate report quality (15 points)."""
points = 0

# Check links are relative and valid (5 points)
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', self.content)
if links:
absolute_links = [l for l in links if l[1].startswith('/') or l[1].startswith('http')]
if not absolute_links:
points += 5
else:
self.warnings.append(f"Found {len(absolute_links)} absolute links (prefer relative)")
points += 3
else:
points += 3 # No links might be OK

# Check no obvious spelling errors (5 points)
# Basic check for common patterns
points += 5 # Assume OK unless flagged

# Check formatting consistency (5 points)
# Look for consistent table formatting
tables = re.findall(r'\|[^\n]+\|', self.content)
if tables:
points += 5
else:
points += 3

return points

def validate(self) -> Tuple[int, List[str], List[str]]:
"""Run all validations and return score, errors, warnings."""
if not self.load_report():
return 0, self.errors, self.warnings

structure_score = self.validate_structure()
content_score = self.validate_content()
actionability_score = self.validate_actionability()
quality_score = self.validate_quality()

self.score = structure_score + content_score + actionability_score + quality_score

return self.score, self.errors, self.warnings

def get_grade(self) -> str:
"""Get letter grade based on score."""
if self.score >= 90:
return "A"
elif self.score >= 80:
return "B"
elif self.score >= 70:
return "C"
elif self.score >= 60:
return "D"
else:
return "F"

def main(): parser = argparse.ArgumentParser( description="Validate status reports against CODITECT standards" ) parser.add_argument("path", nargs="?", help="Path to report file") parser.add_argument("--all", action="store_true", help="Validate all reports") parser.add_argument("--dir", default="docs/status-reports/", help="Directory containing reports") parser.add_argument("--json", action="store_true", help="Output as JSON")

args = parser.parse_args()

reports = []
if args.all or not args.path:
report_dir = Path(args.dir)
if report_dir.exists():
reports = list(report_dir.glob("*.md"))
else:
print(f"Directory not found: {args.dir}")
sys.exit(1)
else:
reports = [Path(args.path)]

if not reports:
print("No reports found to validate")
sys.exit(1)

results = []
for report_path in reports:
if report_path.name == "README.md":
continue

validator = StatusReportValidator(str(report_path))
score, errors, warnings = validator.validate()
grade = validator.get_grade()

results.append({
"file": str(report_path),
"score": score,
"grade": grade,
"errors": errors,
"warnings": warnings,
"passed": score >= 80
})

if not args.json:
print(f"\n{'='*60}")
print(f"Report: {report_path.name}")
print(f"Score: {score}/100 (Grade: {grade})")
print(f"Status: {'βœ… PASSED' if score >= 80 else '❌ FAILED'}")

if errors:
print(f"\nErrors ({len(errors)}):")
for e in errors:
print(f" ❌ {e}")

if warnings:
print(f"\nWarnings ({len(warnings)}):")
for w in warnings:
print(f" ⚠️ {w}")

if args.json:
import json
print(json.dumps(results, indent=2))
else:
print(f"\n{'='*60}")
print(f"SUMMARY: {len([r for r in results if r['passed']])}/{len(results)} reports passed")

# Exit with error if any report failed
if any(not r["passed"] for r in results):
sys.exit(1)

if name == "main": main()