260 lines
9.7 KiB
Python
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())
|