from __future__ import annotations import json import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DOCS_DIR = ROOT / "docs" MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json" AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json" NEXT_STEPS_PATH = DOCS_DIR / "unit-sprite-next-steps.md" PIPELINE_PATH = DOCS_DIR / "unit-sprite-handpaint-pipeline-guide.md" CHECKLIST_PATH = DOCS_DIR / "unit-sprite-batch-output-checklist.md" PLAN_PATH = DOCS_DIR / "unit-sprite-rework-plan.md" CURRENT_BOARD_PATH = DOCS_DIR / "unit-sprite-current-approval-board.png" ART_GATE_PATH = DOCS_DIR / "unit-sprite-art-quality-gate.md" REPORT_PATH = DOCS_DIR / "unit-sprite-workflow-consistency.md" REQUIRED_BEFORE_APPROVAL_COMMANDS = [ "python scripts\\audit-unit-sprite-sheets.py", "python scripts\\render-unit-sprite-review-boards.py", "python scripts\\render-current-sprite-approval-board.py", "python scripts\\check-unit-sprite-art-quality.py", "python scripts\\check-unit-sprite-approval-gate.py --mode sample", "python scripts\\write-unit-sprite-next-steps.py", "python scripts\\check-unit-sprite-workflow-consistency.py", ] REQUIRED_AFTER_APPROVAL_COMMANDS = [ "python scripts\\audit-unit-sprite-sheets.py", "python scripts\\render-unit-sprite-review-boards.py", "python scripts\\check-unit-sprite-approval-gate.py --mode release", "pnpm build", ] FORBIDDEN_AFTER_APPROVAL_COMMANDS = [ "python scripts\\render-current-sprite-approval-board.py", "python scripts\\check-unit-sprite-art-quality.py", ] def rel(path: Path) -> str: return path.relative_to(ROOT).as_posix() def load_json(path: Path) -> dict[str, Any]: if not path.exists(): raise FileNotFoundError(f"missing required file: {rel(path)}") return json.loads(path.read_text(encoding="utf-8")) def read_text(path: Path) -> str: if not path.exists(): raise FileNotFoundError(f"missing required file: {rel(path)}") return path.read_text(encoding="utf-8") def rows_by_stem(audit: dict[str, Any]) -> dict[str, dict[str, Any]]: return {row["stem"]: row for row in audit.get("rows", [])} def unique_issues(label: str, values: list[str]) -> list[str]: issues: list[str] = [] duplicates = sorted({value for value in values if values.count(value) > 1}) for value in duplicates: issues.append(f"{label} contains duplicate entry: {value}") return issues def validate_manifest(manifest: dict[str, Any], audit: dict[str, Any]) -> list[str]: issues: list[str] = [] rows = rows_by_stem(audit) approved = manifest.get("approvedBaseline", []) pending = manifest.get("pendingApproval", []) next_after = manifest.get("nextAfterApproval", []) candidates = manifest.get("currentApprovalCandidate", {}) for label, values in ( ("approvedBaseline", approved), ("pendingApproval", pending), ("nextAfterApproval", next_after), ): if not isinstance(values, list): issues.append(f"{label} must be a list") continue issues.extend(unique_issues(label, values)) overlap_pairs = [ ("approvedBaseline", "pendingApproval", set(approved) & set(pending)), ("approvedBaseline", "nextAfterApproval", set(approved) & set(next_after)), ("pendingApproval", "nextAfterApproval", set(pending) & set(next_after)), ] for left, right, overlap in overlap_pairs: if overlap: issues.append(f"{left} and {right} overlap: {', '.join(sorted(overlap))}") for stem in approved + pending + next_after: if stem not in rows: issues.append(f"manifest references unknown unit sheet: {stem}") if set(candidates) != set(pending): issues.append( "currentApprovalCandidate keys must exactly match pendingApproval " f"({sorted(candidates)} != {sorted(pending)})" ) for stem in pending: candidate = candidates.get(stem) if not isinstance(candidate, dict): issues.append(f"{stem}: currentApprovalCandidate must be an object") continue for field in ("version", "source", "artifactPrefix", "approvalPacket"): if not str(candidate.get(field, "")).strip(): issues.append(f"{stem}: candidate missing `{field}`") for field in ("source", "approvalPacket"): value = str(candidate.get(field, "")).strip() if value and not (ROOT / value).exists(): issues.append(f"{stem}: candidate `{field}` does not exist: {value}") return issues def validate_docs(manifest: dict[str, Any]) -> list[str]: issues: list[str] = [] next_steps = read_text(NEXT_STEPS_PATH) pipeline = read_text(PIPELINE_PATH) checklist = read_text(CHECKLIST_PATH) plan = read_text(PLAN_PATH) combined_core_docs = "\n".join([next_steps, pipeline, checklist, plan]) for command in REQUIRED_BEFORE_APPROVAL_COMMANDS: if command not in next_steps: issues.append(f"next steps missing before-approval command: {command}") pending = manifest.get("pendingApproval", []) candidates = manifest.get("currentApprovalCandidate", {}) dynamic_after_commands = REQUIRED_AFTER_APPROVAL_COMMANDS.copy() for stem in pending: candidate = candidates.get(stem, {}) if isinstance(candidates, dict) else {} version = candidate.get("version") if isinstance(candidate, dict) else None command = f"python scripts\\promote-unit-sprite-approval.py --unit {stem}" if version: command += f" --expected-version {version}" dynamic_after_commands.insert(0, command) for command in dynamic_after_commands: if command not in next_steps: issues.append(f"next steps missing after-approval command: {command}") after_section = next_steps.split("## Commands After Approval", 1)[-1].split("## First Task", 1)[0] for command in FORBIDDEN_AFTER_APPROVAL_COMMANDS: if command in after_section: issues.append(f"after-approval commands still include pending-only command: {command}") stale_patterns = [ "unit-shu-infantry` v2", "Shu infantry v2", "approved Shu infantry v2", "v2 restart sample", ] for pattern in stale_patterns: if pattern in combined_core_docs: issues.append(f"core docs contain stale reference: {pattern}") for stem in pending: if f"debugSpritePreview={stem}" not in combined_core_docs and stem == "unit-shu-infantry": issues.append(f"core docs missing debug preview reference for current pending unit: {stem}") if "scripts/check-unit-sprite-art-quality.py" not in checklist: issues.append("batch output checklist does not include art quality gate") if "scripts/check-unit-sprite-art-quality.py" not in plan: issues.append("rework plan does not include art quality gate") return issues def validate_artifact_freshness(manifest: dict[str, Any]) -> list[str]: issues: list[str] = [] generated_paths = [ART_GATE_PATH] if manifest.get("pendingApproval", []): generated_paths.append(CURRENT_BOARD_PATH) for path in generated_paths: if not path.exists(): issues.append(f"missing generated workflow artifact: {rel(path)}") continue for source in (MANIFEST_PATH, AUDIT_PATH): if source.exists() and path.stat().st_mtime < source.stat().st_mtime: issues.append(f"{rel(path)} is older than {rel(source)}") return issues def write_report(issues: list[str], manifest: dict[str, Any] | None = None) -> None: pending = manifest.get("pendingApproval", []) if manifest else [] lines = [ "# Unit Sprite Workflow Consistency", "", "This file is generated by `scripts/check-unit-sprite-workflow-consistency.py`.", "", "The check validates that manifest state, approval docs, and gate command lists describe the same workflow.", "", "## Result", "", "FAILED" if issues else "OK", "", ] if issues: lines.extend(["## Issues", ""]) lines.extend(f"- {issue}" for issue in issues) lines.append("") else: lines.extend( [ "## Checked", "", f"- Manifest: `{rel(MANIFEST_PATH)}`", f"- Audit: `{rel(AUDIT_PATH)}`", f"- Next steps: `{rel(NEXT_STEPS_PATH)}`", f"- Pipeline guide: `{rel(PIPELINE_PATH)}`", f"- Checklist: `{rel(CHECKLIST_PATH)}`", f"- Rework plan: `{rel(PLAN_PATH)}`", f"- Art quality gate: `{rel(ART_GATE_PATH)}`", "", ] ) if pending: lines.insert(-2, f"- Current board: `{rel(CURRENT_BOARD_PATH)}`") else: lines.insert(-2, "- Current board: skipped because there is no pending approval unit.") REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") def main() -> int: issues: list[str] = [] manifest: dict[str, Any] | None = None try: manifest = load_json(MANIFEST_PATH) audit = load_json(AUDIT_PATH) issues.extend(validate_manifest(manifest, audit)) issues.extend(validate_docs(manifest)) issues.extend(validate_artifact_freshness(manifest)) except Exception as exc: issues.append(str(exc)) write_report(issues, manifest) if issues: print("Unit sprite workflow consistency: FAILED") for issue in issues: print(f"- {issue}") print(f"Wrote {REPORT_PATH}") return 1 print("Unit sprite workflow consistency: OK") print(f"Wrote {REPORT_PATH}") return 0 if __name__ == "__main__": sys.exit(main())