diff --git a/custom-skills/46-jamie-journal-editor/code/scripts/journal_validator.py b/custom-skills/46-jamie-journal-editor/code/scripts/journal_validator.py new file mode 100644 index 0000000..da30e81 --- /dev/null +++ b/custom-skills/46-jamie-journal-editor/code/scripts/journal_validator.py @@ -0,0 +1,268 @@ +"""journal_validator.py — Jamie Journal Editor validation tool. + +Usage: + python journal_validator.py --input article.md + python journal_validator.py --input article.md --check structure,compliance + python journal_validator.py --input article.md --output report.json --verbose + +Checks available: structure, compliance, spelling, links (default: all) + +Exit codes: + 0 — validation passed (no critical/high issues) + 1 — validation failed (critical or high issues found) + 2 — input error (file not found, bad arguments, etc.) +""" + +import re +import json +import sys +from typing import Dict, List, Tuple, Optional +from dataclasses import dataclass, asdict, field +import argparse + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class ValidationIssue: + checker: str + issue_type: str + severity: str # critical | high | medium | info + line_number: int + matched_text: str + message: str + suggestion: str + + +# --------------------------------------------------------------------------- +# Stub checker classes +# --------------------------------------------------------------------------- + +class StructureValidator: + """Validates document structure (headings, sections, word count, etc.).""" + + def check(self, content: str) -> List[ValidationIssue]: + return [] + + +class ComplianceChecker: + """Checks brand compliance rules for Jamie Clinic journal content.""" + + def check(self, content: str) -> List[ValidationIssue]: + return [] + + +class SpellingChecker: + """Checks for common spelling/terminology issues in medical/beauty content.""" + + def check(self, content: str) -> List[ValidationIssue]: + return [] + + +class LinkRecommender: + """Recommends internal/external links and detects broken or missing ones.""" + + def check(self, content: str) -> List[ValidationIssue]: + return [] + + +# --------------------------------------------------------------------------- +# Orchestrator +# --------------------------------------------------------------------------- + +VALID_CHECKS = {"structure", "compliance", "spelling", "links"} + +_CHECKER_MAP = { + "structure": StructureValidator, + "compliance": ComplianceChecker, + "spelling": SpellingChecker, + "links": LinkRecommender, +} + + +class JournalValidator: + """Orchestrates all validation checks and returns a consolidated report. + + Args: + checks: List of check names to run. Defaults to all valid checks. + Valid values: 'structure', 'compliance', 'spelling', 'links'. + """ + + def __init__(self, checks: Optional[List[str]] = None): + if checks is None: + self.checks = list(VALID_CHECKS) + else: + unknown = set(checks) - VALID_CHECKS + if unknown: + raise ValueError( + f"Unknown check(s): {', '.join(sorted(unknown))}. " + f"Valid options: {', '.join(sorted(VALID_CHECKS))}" + ) + self.checks = checks + + def validate(self, content: str) -> Dict: + """Run all enabled checkers against content and return a report dict. + + Returns: + { + "is_valid": bool, # False if any critical or high issue exists + "total_issues": int, + "issues_by_severity": { + "critical": int, + "high": int, + "medium": int, + "info": int, + }, + "issues": [dict, ...], # serialised ValidationIssue dicts + "summary": str, + } + """ + all_issues: List[ValidationIssue] = [] + + for check_name in self.checks: + checker_cls = _CHECKER_MAP[check_name] + checker = checker_cls() + issues = checker.check(content) + all_issues.extend(issues) + + counts: Dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "info": 0} + for issue in all_issues: + severity = issue.severity if issue.severity in counts else "info" + counts[severity] += 1 + + is_valid = counts["critical"] == 0 and counts["high"] == 0 + + if not all_issues: + summary = "All checks passed." + else: + parts = [] + if counts["critical"]: + parts.append(f"{counts['critical']} critical") + if counts["high"]: + parts.append(f"{counts['high']} high") + if counts["medium"]: + parts.append(f"{counts['medium']} medium") + if counts["info"]: + parts.append(f"{counts['info']} info") + summary = f"Found {len(all_issues)} issue(s): {', '.join(parts)}." + + return { + "is_valid": is_valid, + "total_issues": len(all_issues), + "issues_by_severity": counts, + "issues": [asdict(i) for i in all_issues], + "summary": summary, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="journal_validator", + description="Validate a Jamie Journal article against editorial rules.", + ) + parser.add_argument( + "--input", "-i", + required=True, + metavar="FILE", + help="Path to the markdown article to validate.", + ) + parser.add_argument( + "--check", "-c", + default="all", + metavar="CHECKS", + help=( + "Comma-separated list of checks to run " + "(structure, compliance, spelling, links). " + "Default: all" + ), + ) + parser.add_argument( + "--output", "-o", + metavar="FILE", + help="Optional path to write the JSON report.", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Print detailed issue list.", + ) + return parser + + +def _print_summary_table(report: Dict, verbose: bool = False) -> None: + sev = report["issues_by_severity"] + print("\n=== Journal Validator Report ===") + print(f" Status : {'PASS' if report['is_valid'] else 'FAIL'}") + print(f" Total : {report['total_issues']}") + print(f" Critical: {sev['critical']}") + print(f" High : {sev['high']}") + print(f" Medium : {sev['medium']}") + print(f" Info : {sev['info']}") + print(f"\n {report['summary']}") + + if verbose and report["issues"]: + print("\n--- Issues ---") + for i, issue in enumerate(report["issues"], start=1): + print( + f" [{i}] [{issue['severity'].upper()}] " + f"Line {issue['line_number']} | {issue['checker']} | " + f"{issue['message']}" + ) + if issue.get("suggestion"): + print(f" Suggestion: {issue['suggestion']}") + print() + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + + # --- Resolve checks --- + if args.check.strip().lower() == "all": + checks = list(VALID_CHECKS) + else: + checks = [c.strip() for c in args.check.split(",") if c.strip()] + + # --- Read input file --- + try: + with open(args.input, "r", encoding="utf-8") as fh: + content = fh.read() + except FileNotFoundError: + print(f"Error: file not found: {args.input}", file=sys.stderr) + sys.exit(2) + except OSError as exc: + print(f"Error reading file: {exc}", file=sys.stderr) + sys.exit(2) + + # --- Run validation --- + try: + validator = JournalValidator(checks=checks) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(2) + + report = validator.validate(content) + + # --- Output --- + _print_summary_table(report, verbose=args.verbose) + + if args.output: + try: + with open(args.output, "w", encoding="utf-8") as fh: + json.dump(report, fh, ensure_ascii=False, indent=2) + print(f"Report written to: {args.output}") + except OSError as exc: + print(f"Error writing report: {exc}", file=sys.stderr) + sys.exit(2) + + sys.exit(0 if report["is_valid"] else 1) + + +if __name__ == "__main__": + main()