Files
heros_web/scripts/check-unit-sprite-workflow-consistency.py

260 lines
9.7 KiB
Python

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())