121 lines
4.8 KiB
Python
121 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Independent verifier for sol-lane triage artifact.
|
|
|
|
Exit 0 iff the committed manifest re-derives from the frozen seed and every
|
|
frozen path still exists as a real, non-ignored file in the repo.
|
|
|
|
Design (independent of the generator):
|
|
* The generator (sol_lane_triage_2026_10_29.py) wrote the seed + manifest.
|
|
* This verifier re-reads only the seed and the committed manifest, applies
|
|
the rubric table below to each recorded extension, and compares.
|
|
* It also confirms that every frozen path resolves to a real file under
|
|
the repo and is not ignored by .gitignore.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent.parent
|
|
SRC = REPO / "wizard-work" / "sol"
|
|
SEED = REPO / "wizard-work" / "sol" / "sol-lane-triage-seed-2026-10-29.json"
|
|
MANIFEST = REPO / "wizard-work" / "sol" / "sol-lane-triage-2026-10-29.json"
|
|
|
|
# Rubric re-embedded here so the verifier does not import the generator.
|
|
RULER = {
|
|
"md": "text", "txt": "text", "rst": "text", "adoc": "text", "org": "text", "log": "text",
|
|
"json": "data", "csv": "data", "tsv": "data", "yaml": "data", "yml": "data", "toml": "data",
|
|
"sqlite": "data", "db": "data", "ipynb": "data",
|
|
"mp3": "media", "wav": "media", "mp4": "media", "webm": "media", "png": "media",
|
|
"jpg": "media", "jpeg": "media", "gif": "media", "webp": "media", "svg": "media",
|
|
"py": "script", "sh": "script", "bash": "script", "zsh": "script", "ps1": "script", "bat": "script",
|
|
"pdf": "doc", "doc": "doc", "docx": "doc", "ppt": "doc", "pptx": "doc", "xls": "doc", "xlsx": "doc",
|
|
}
|
|
# Known binary magic prefixes; used only for extensions the ruler has no entry.
|
|
KNOWN_MAGICS = {
|
|
"png": b"\x89PNG", "gif": b"GIF8", "jpg": b"\xff\xf0", "webp": b"WEBP", "svg": b"<?xml",
|
|
"zip/pptx": b"PK\x03\x04", "db": b"SQLite", "sqlite": b"SQLite",
|
|
}
|
|
|
|
def expected_category(rel: str, ext: str) -> str:
|
|
if ext in RULER:
|
|
return RULER[ext]
|
|
# Fall back to content sniff for an extension the ruler does not know.
|
|
p = SRC / rel
|
|
try:
|
|
with p.open("rb") as fh:
|
|
head = fh.read(1024)
|
|
except OSError:
|
|
return "other"
|
|
for key, magic in KNOWN_MAGICS.items():
|
|
if head.startswith(magic):
|
|
return {"png": "media", "gif": "media", "jpg": "media", "webp": "media",
|
|
"svg": "text", "zip/pptx": "doc", "db": "data", "sqlite": "data"}[key]
|
|
try:
|
|
head.decode("utf-8")
|
|
return "text"
|
|
except UnicodeDecodeError:
|
|
return "data"
|
|
|
|
def is_ignored(rel: str) -> bool:
|
|
res = subprocess.run(
|
|
["git", "-C", str(REPO), "check-ignore", "-q", "--", rel],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
return res.returncode == 0
|
|
|
|
def main() -> int:
|
|
problems = []
|
|
if not SEED.exists():
|
|
print(f"PROBLEM: seed missing: {SEED}")
|
|
return 1
|
|
if not MANIFEST.exists():
|
|
print(f"PROBLEM: manifest missing: {MANIFEST}")
|
|
return 1
|
|
|
|
seed = json.loads(SEED.read_text(encoding="utf-8"))
|
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
files = seed.get("files", [])
|
|
mfiles = manifest.get("files", [])
|
|
|
|
# 1. Manifest must match frozen seed exactly (same rel paths).
|
|
seed_paths = {f["rel"] for f in files}
|
|
m_paths = {f["rel"] for f in mfiles}
|
|
if seed_paths != m_paths:
|
|
problems.append(f"manifest path set != seed: seed={len(seed_paths)} manifest={len(m_paths)}")
|
|
|
|
# 2. Every category must re-derive by the rule from the recorded extension.
|
|
for f in files:
|
|
rel, ext, recorded = f["rel"], f["ext"], f["category"]
|
|
exp = expected_category(rel, ext)
|
|
if exp != recorded:
|
|
problems.append(f"category mismatch {rel}: ruler says {exp}, manifest says {recorded}")
|
|
|
|
# 3. Every frozen path must still exist as a real file and not be ignored.
|
|
for f in files:
|
|
p = SRC / f["rel"]
|
|
if not p.is_file():
|
|
problems.append(f"missing on disk: {f['rel']}")
|
|
elif is_ignored(f["rel"]):
|
|
problems.append(f"ignored by .gitignore: {f['rel']}")
|
|
|
|
# 4. Aggregate counts must be self-consistent.
|
|
counts = {}
|
|
for f in files:
|
|
counts[f["category"]] = counts.get(f["category"], 0) + 1
|
|
if manifest.get("total_files") != len(files):
|
|
problems.append(f"total_files {manifest.get('total_files')} != seed {len(files)}")
|
|
if counts != manifest.get("by_category"):
|
|
problems.append(f"by_category mismatch: seed={counts} manifest={manifest.get('by_category')}")
|
|
|
|
if problems:
|
|
print(f"VERIFY FAIL ({len(problems)} problems)")
|
|
for pb in problems:
|
|
print(f" - {pb}")
|
|
return 1
|
|
print(f"VERIFY OK ({len(files)} files, categories re-derived, all paths present)")
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|