130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""CHECK -- sol: Triage - 2026-10-29.
|
|
|
|
Independent re-verification of wizard-work/sol/untracked-classify-2026-10-29.json
|
|
and its summary. The verdict is the process exit code (0 = correct,
|
|
nonzero = wrong). No fallback arms; a failing assertion is fatal.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) # /root/troupe-standup
|
|
JSON_PATH = os.path.join(HERE, "untracked-classify-2026-10-29.json")
|
|
SUMMARY_PATH = os.path.join(HERE, "untracked-classify-summary.md")
|
|
|
|
# Tooling directory ("wizard-work/sol/") and the stale top-level CI leftover
|
|
# hold this tool's own artifacts, which are not the classifiable subject.
|
|
def subject(path):
|
|
return not (path.startswith("wizard-work/sol/")
|
|
or path == "CHECK-sol-untracked-classify")
|
|
|
|
|
|
def live_untracked():
|
|
"""Enumeration of untracked files (same source as the generator, but parsed
|
|
and filtered here independently). Kept deterministic: git porcelain v1 with
|
|
--untracked-files=all summarizes untracked directories, which made the
|
|
file-set cross-check fragile, while the bucket re-derivation (step 3),
|
|
on-disk existence (step 2) and summary cross-check (step 4) are the real
|
|
correctness tests."""
|
|
out = subprocess.run(
|
|
["git", "-C", REPO_ROOT, "ls-files", "--others", "--exclude-standard"],
|
|
check=True, capture_output=True, text=True,
|
|
).stdout
|
|
return [p for p in out.splitlines() if p]
|
|
|
|
|
|
def independent_classify(path):
|
|
"""Re-derivation written as predicates, independent of the generator's
|
|
prefix-list loop. Must agree with untracked-classify.py for every file."""
|
|
if path.startswith("wizard-work/null"):
|
|
return "probe"
|
|
if path.startswith("wizard-work/sphinx"):
|
|
return "sphinx"
|
|
if path.startswith("wizard-work/"):
|
|
return "wizard-work"
|
|
if path.startswith("wizard-outputs/"):
|
|
return "wizard-outputs"
|
|
if path.startswith("replies/"):
|
|
return "replies"
|
|
if path.startswith("checkins/"):
|
|
return "checkins"
|
|
if path.startswith("docs/"):
|
|
return "docs"
|
|
if path.startswith("inbox/"):
|
|
return "inbox"
|
|
if path.startswith("recoveries/"):
|
|
return "recoveries"
|
|
if path.startswith("tools/"):
|
|
return "tools"
|
|
if path in ("d.jsonl", "s.json"):
|
|
return "dump"
|
|
if path == "kanban.db":
|
|
return "kanban"
|
|
if path.startswith("repo_"):
|
|
return "inventory"
|
|
if path.startswith("CHECK-") or path.startswith("."):
|
|
return "ci"
|
|
return "other"
|
|
|
|
|
|
def main():
|
|
errors = []
|
|
|
|
# 1) Independent file enumeration (git status porcelain, per-file).
|
|
live = live_untracked()
|
|
live = [p for p in live_untracked() if subject(p)]
|
|
want = json.load(open(JSON_PATH))
|
|
got_paths = sorted(c["path"] for c in want)
|
|
if sorted(live) != got_paths:
|
|
errors.append("file-set mismatch: live=%d json=%d" % (len(live), len(got_paths)))
|
|
|
|
# 2) Every committed path exists on disk and every bucket is in-vocabulary.
|
|
VOCAB = {"probe", "sphinx", "wizard-work", "wizard-outputs", "replies",
|
|
"checkins", "docs", "inbox", "recoveries", "tools", "dump",
|
|
"kanban", "inventory", "ci", "other"}
|
|
for c in want:
|
|
if not os.path.exists(os.path.join(REPO_ROOT, c["path"])):
|
|
errors.append("missing on disk: %s" % c["path"])
|
|
if c["bucket"] not in VOCAB:
|
|
errors.append("unknown bucket %r for %s" % (c["bucket"], c["path"]))
|
|
|
|
# 3) Independent re-derivation must agree with the committed bucket.
|
|
for c in want:
|
|
if independent_classify(c["path"]) != c["bucket"]:
|
|
errors.append("bucket mismatch for %s: committed=%r indep=%r" %
|
|
(c["path"], c["bucket"], independent_classify(c["path"])))
|
|
|
|
# 4) Summary cross-check.
|
|
text = open(SUMMARY_PATH).read()
|
|
total_num = None
|
|
for line in text.splitlines():
|
|
if '| **total**' in line:
|
|
total_num = int(line.split('**')[-2])
|
|
if total_num is None or total_num != len(want):
|
|
errors.append("summary total mismatch: %s" % text.split('**total**')[0])
|
|
counts = {}
|
|
for c in want:
|
|
counts[c["bucket"]] = counts.get(c["bucket"], 0) + 1
|
|
for b, n in sorted(counts.items()):
|
|
if not re.search(r"\|\s*%s\s*\|\s*%d\s*\|" % (re.escape(b), n), text):
|
|
errors.append("summary bucket %s missing/incorrect: %d" % (b, n))
|
|
|
|
if errors:
|
|
print("CHECK FAIL:")
|
|
for e in errors[:50]:
|
|
print(" - " + e)
|
|
print(" ...%d more" % (len(errors) - 50) if len(errors) > 50 else "")
|
|
sys.exit(1)
|
|
print("CHECK PASS: %d files classified into %d buckets; "
|
|
"independent re-derivation + enumeration agree; "
|
|
"summary cross-checks." % (len(want), len(counts)))
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|