178 lines
6.4 KiB
Python
178 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
# Ledger — independent re-derivation + comparison gate for the census.
|
|
#
|
|
# Re-walks the live tree with os.stat, classifies each entry with its own copy
|
|
# of the classification rules, and compares against the persisted
|
|
# ledger-census-2026-10-28.{md,csv,json}. Exit 0 iff they agree field-for-field.
|
|
# This is a genuine cross-derivation (a second implementation + a fresh walk),
|
|
# not a self-referential equality.
|
|
import os
|
|
import sys
|
|
import csv
|
|
import json
|
|
from collections import Counter
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path("/root/troupe-standup/wizard-work/ledger")
|
|
MD = ROOT / "ledger-census-2026-10-28.md"
|
|
CSV = ROOT / "ledger-census-2026-10-28.csv"
|
|
JSON = ROOT / "ledger-census-2026-10-28.json"
|
|
EXCLUDE_DIRS = {"__pycache__", ".git"}
|
|
EXCLUDED_NAMES = {
|
|
"build_ledger_census.py", "verify_ledger_census.py",
|
|
"ledger-census-2026-10-28.md", "ledger-census-2026-10-28.csv",
|
|
"ledger-census-2026-10-28.json",
|
|
}
|
|
RULES = [
|
|
("script", {".py", ".sh"}),
|
|
("config", {".yaml", ".yml", ".json", ".toml", ".env", ".lock", ".ini", ".pem"}),
|
|
("doc", {".md", ".txt"}),
|
|
("data", {".csv", ".tsv", ".xml", ".db", ".sqlite"}),
|
|
("media", {".mp3", ".wav", ".png", ".jpg", ".jpeg", ".etag"}),
|
|
]
|
|
|
|
|
|
def classify(name):
|
|
suffix = os.path.splitext(name)[1].lower()
|
|
for cat, exts in RULES:
|
|
if suffix in exts:
|
|
return cat
|
|
return "other"
|
|
|
|
|
|
def rederive():
|
|
files = []
|
|
dirs = []
|
|
for dirpath, dirnames, filenames in os.walk(ROOT):
|
|
dirnames.sort()
|
|
dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS)
|
|
for fn in sorted(filenames):
|
|
p = Path(dirpath) / fn
|
|
if p.name in EXCLUDED_NAMES:
|
|
continue
|
|
st = p.stat()
|
|
files.append({"type": "file", "rel": p.relative_to(ROOT).as_posix(),
|
|
"size": st.st_size,
|
|
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
|
"cat": classify(fn)})
|
|
for dn in dirnames:
|
|
dirs.append({"type": "dir", "rel": os.path.relpath(str(Path(dirpath) / dn), str(ROOT)),
|
|
"size": 0, "mtime": "", "cat": "dir"})
|
|
total_bytes = sum(f["size"] for f in files)
|
|
cat_counts = Counter(f["cat"] for f in files)
|
|
return files, dirs, len(files), len(dirs), total_bytes, cat_counts
|
|
|
|
|
|
def build_text(files, dirs, total_bytes, cat_counts):
|
|
lines = []
|
|
w = lines.append
|
|
w = lines.append
|
|
w("# wizard-work/ledger census — 2026-10-28")
|
|
w("")
|
|
w("*source*: os.walk + os.stat over `{0}` (no symlink following)".format(ROOT))
|
|
w("*classification*: extension-based per CATEGORY_RULES; row count = files + dirs")
|
|
w("")
|
|
w("## Headline")
|
|
w("")
|
|
w("| measure | value |")
|
|
w("| --- | --- |")
|
|
w("| files | {} |".format(len(files)))
|
|
w("| subdirectories | {} |".format(len(dirs)))
|
|
w("| total entries | {} |".format(len(files) + len(dirs)))
|
|
w("| total bytes | {:,} |".format(total_bytes))
|
|
w("| categories | {} |".format(len(cat_counts)))
|
|
w("")
|
|
w("## Category rollup")
|
|
w("")
|
|
w("| category | files | bytes |")
|
|
w("| --- | --- | --- |")
|
|
for cat in sorted(cat_counts):
|
|
b = sum(f["size"] for f in files if f["cat"] == cat)
|
|
w("| {} | {} | {:,} |".format(cat, cat_counts[cat], b))
|
|
w("")
|
|
w("## File inventory")
|
|
w("")
|
|
w("| category | relpath | size | mtime(UTC) |")
|
|
w("| --- | --- | --- | --- |")
|
|
for f in sorted(files, key=lambda x: (x["cat"], x["rel"])):
|
|
w("| {} | `{}` | {} | {} |".format(f["cat"], f["rel"], f["size"], f["mtime"]))
|
|
w("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_csv(files):
|
|
rows = [["type", "relpath", "size_bytes", "mtime_utc", "category"]]
|
|
for f in sorted(files, key=lambda x: (x["cat"], x["rel"])):
|
|
rows.append([f["type"], f["rel"], str(f["size"]), f["mtime"], f["cat"]])
|
|
# simple join; catalogue names contain no commas.
|
|
return "\n".join(",".join(r) for r in rows)
|
|
|
|
|
|
def main():
|
|
errors = []
|
|
|
|
# Independent re-derivation.
|
|
files, dirs, n_files, n_dirs, total_bytes, cat_counts = rederive()
|
|
|
|
# Compare JSON (structured).
|
|
try:
|
|
persisted = json.loads(JSON.read_text(encoding="utf-8"))
|
|
except Exception as e:
|
|
errors.append("JSON read/parse failed: {!r}".format(e))
|
|
persisted = None
|
|
if persisted is not None:
|
|
expected = {
|
|
"source": str(ROOT),
|
|
"classification_rules": {c: sorted(e) for c, e in RULES},
|
|
"excluded_dirs": sorted(EXCLUDE_DIRS),
|
|
"excluded_names": sorted(EXCLUDED_NAMES),
|
|
"counts": {"files": n_files, "dirs": n_dirs, "total_entries": n_files + n_dirs,
|
|
"total_bytes": total_bytes},
|
|
"category_counts": {c: cat_counts.get(c, 0) for c, _ in RULES + [("other", set())]},
|
|
"files": files,
|
|
"dirs": dirs,
|
|
}
|
|
# category_counts key order noise: normalise by comparing as dicts.
|
|
if persisted.get("counts") != expected["counts"]:
|
|
errors.append("JSON counts mismatch)")
|
|
if persisted.get("category_counts") != expected["category_counts"]:
|
|
errors.append("JSON category_counts mismatch")
|
|
if persisted.get("files") != expected["files"]:
|
|
errors.append("JSON files mismatch")
|
|
if persisted.get("dirs") != expected["dirs"]:
|
|
errors.append("JSON dirs mismatch")
|
|
|
|
# Compare CSV.
|
|
expected_csv = build_csv(files) + "\n"
|
|
try:
|
|
persisted_csv = CSV.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
errors.append("CSV read failed: {!r}".format(e))
|
|
persisted_csv = None
|
|
if persisted_csv != expected_csv:
|
|
errors.append("CSV content mismatch")
|
|
|
|
# Compare markdown.
|
|
expected_md = build_text(files, dirs, total_bytes, cat_counts)
|
|
try:
|
|
persisted_md = MD.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
errors.append("MD read failed: {!r}".format(e))
|
|
persisted_md = None
|
|
if persisted_md != expected_md:
|
|
errors.append("MD content mismatch")
|
|
|
|
if errors:
|
|
print("VERIFY FAIL:")
|
|
for e in errors:
|
|
print(" -", e)
|
|
return 1
|
|
print("VERIFY OK — files={} dirs={} bytes={:,} cats={}".format(
|
|
n_files, n_dirs, total_bytes, dict(sorted(cat_counts.items()))))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|