#!/usr/bin/env python3
"""
repo_lint.py -- static hygiene lint for the openstack-caracal-ipv4 repo (DOCFIX-074).
Read-only. Catches the drift classes that accumulated silently between the D-052
rebuild and the 2026-07-02 redeploy-readiness sweep (46 findings), so they are
caught at commit time instead of at the next redeploy:
L1 encoding non-ASCII / CR bytes in committed text (repo rule: ASCII+LF).
Carve-out: docs/design-decisions.md legacy D-001..018 region
(em-dash style documented) -> WARN with count, never FAIL.
L2 stale tokens retired space names / CIDRs / VIP band in LIVE docs
(runbooks/, scripts/, bundle.yaml, README.md). Lines that
are explicitly historical (retired/STALE/superseded/D-058/
D-060/DOCFIX context) are exempt.
L3 ghost refs scripts/<name>.(sh|py) referenced in a runbook must exist.
L4 deprecated invoking a deprecated/retired script outside a deprecation
or historical context.
L5 numbering duplicate D-NNN definition headings in design-decisions.md
(collision guard). Next-free numbering is ledger-scan's job
ALONE (DOCFIX-107: the old L5 next-free print disagreed with
it via three independent defects; see
docs/repo-lint-nextfree-bug-FINDING.md).
L6 bare invoke runbook lines executing scripts/*.sh without a bash/source
prefix (repo carries NO exec bits -- DOCFIX-069; bare form
fails "Permission denied" on a fresh clone).
L7 clientdocs client-docs sweep-on-change (DOCFIX-104): tenant-facing
files are hash-pinned in clientdocs/sweep-receipt.txt (the
path list IS the manifest; scripts/tenant-*.sh, clientdocs/
*.md, clientdocs/scripts/* and docs/tenant-onboarding-
contract.md are auto-covered). Any pinned file drifting from its recorded hash
FAILS until the operator reviews clientdocs/ for staleness
and re-records: bash scripts/repo-lint.sh
--record-clientdocs-sweep (the ONLY thing this flag
mutates is the receipt file). Skipped when clientdocs/ is
absent. NOTE: lint L-numbers are their own namespace,
distinct from appendix-A lesson numbers (both have an L7).
Exit: 0 clean | 1 FAIL findings | 2 warnings only. ASCII + LF.
Usage: python3 scripts/repo_lint.py [--record-clientdocs-sweep] [repo-root]
(marker: repo-lint: allow-stale-tokens -- this file names them by necessity)
"""
import re, sys, pathlib, collections, hashlib
RECORD_FLAG = "--record-clientdocs-sweep"
RECORD_CMD = "bash scripts/repo-lint.sh " + RECORD_FLAG
RECEIPT_HEADER = """\
# clientdocs/sweep-receipt.txt -- client-docs sweep-on-change receipt.
# INTERNAL FILE: never shipped to a client; not part of the client package.
# Each entry pins the reviewed state of one tenant-facing file:
# <sha256> <repo-relative-path>
# The path list IS the tenant-facing manifest (tenant scripts, clientdocs/
# incl. its scripts/ subdir, and the onboarding contract are auto-covered;
# runbooks/policy files are manual lines -- add a line with any hash and
# recording fixes it).
# Repo lint fails when any pinned file drifts, until clientdocs/ has been
# reviewed for staleness and this receipt is re-recorded with:
# bash scripts/repo-lint.sh --record-clientdocs-sweep
"""
def main():
argv = [a for a in sys.argv[1:]]
record = RECORD_FLAG in argv
argv = [a for a in argv if a != RECORD_FLAG]
R = pathlib.Path(argv[0] if argv else ".").resolve()
fails, warns = [], []
# ---- L7 helpers (clientdocs sweep-on-change, DOCFIX-104) ----
cdir = R / "clientdocs"
receipt = cdir / "sweep-receipt.txt"
def sweep_auto(): # files the manifest MUST cover (existence-driven, no guesses)
# recursive over clientdocs/: *.md anywhere (incl. tenant-skill/) and
# everything under scripts/ (kit .sh + .template/.example deliverables)
cd = set(cdir.rglob("*.md")) | set(cdir.rglob("*.sh")) | set(cdir.glob("scripts/*"))
cd.discard(receipt)
out = sorted(cd) + sorted((R / "scripts").glob("tenant-*.sh"))
contract = R / "docs" / "tenant-onboarding-contract.md"
if contract.is_file():
out.append(contract)
return [p for p in out if p.is_file()]
def sweep_sha(p):
return hashlib.sha256(p.read_bytes()).hexdigest()
def receipt_entries(): # {repo-relative-path: recorded-hash}; None if unreadable
out = {}
for i, ln in enumerate(receipt.read_text(errors="replace").splitlines(), 1):
ln = ln.strip()
if not ln or ln.startswith("#"):
continue
parts = ln.split(None, 1)
if len(parts) != 2:
fails.append("L7 clientdocs/sweep-receipt.txt:%d malformed line "
"(want: <sha256> <path>) -- fix or re-record: %s" % (i, RECORD_CMD))
continue
out[parts[1].strip()] = parts[0]
return out
if record:
if not cdir.is_dir():
print(" [info] L7 record: no clientdocs/ -- nothing to record")
else:
listed = list(receipt_entries()) if receipt.exists() else []
paths = sorted(set(listed) | {p.relative_to(R).as_posix() for p in sweep_auto()})
body = []
for rp in paths:
p = R / rp
if p.is_file():
body.append("%s %s" % (sweep_sha(p), rp))
else:
print(" [info] L7 record: dropped missing %s from receipt" % rp)
receipt.write_text(RECEIPT_HEADER + "\n".join(body) + "\n")
print(" [info] L7 recorded clientdocs sweep receipt (%d files)" % len(body))
fails = [] # malformed lines were just rewritten; lint below re-reads
def live_docs():
out = [R / "README.md", R / "bundle.yaml"]
out += sorted((R / "runbooks").glob("*.md")) if (R / "runbooks").is_dir() else []
out += sorted((R / "scripts").iterdir()) if (R / "scripts").is_dir() else []
return [p for p in out if p.is_file()]
def all_text():
exts = {".md", ".sh", ".py", ".yaml", ".yml", ""}
skip_names = {"overrides.zip"}
out = []
for p in R.rglob("*"):
# .claude/ holds session-local agent worktrees (full repo copies:
# their design-decisions.md mirror trips the L1 carve-out, which
# keys on the exact repo-relative path) plus hooks config -- all
# non-repo-content; lint the repo, not the session (DOCFIX-111)
if not p.is_file() or ".git" in p.parts or "__pycache__" in p.parts \
or ".claude" in p.parts:
continue
if p.suffix.lower() == ".zip" or p.name in skip_names:
continue
if p.suffix in exts or p.name == ".gitattributes":
out.append(p)
return out
# ---- L1 encoding ----
for p in all_text():
data = p.read_bytes()
rel = str(p.relative_to(R))
cr = data.count(b"\x0d")
na = sum(1 for b in data if b > 127)
if cr:
fails.append("L1 %s: %d CR byte(s) (repo is LF-only)" % (rel, cr))
if na:
if rel == "docs/design-decisions.md":
warns.append("L1 %s: %d non-ASCII byte(s) (legacy D-001..018 carve-out; "
"NEW entries must be ASCII)" % (rel, na))
else:
fails.append("L1 %s: %d non-ASCII byte(s) (repo rule: ASCII only)" % (rel, na))
# ---- L2 stale tokens in live docs ----
STALE = [
(re.compile(r"\b(provider-vip|fabric-data)\b"), "retired space name"),
(re.compile(r"\b(?<![\w.])lbaas\b(?!-)"), "retired lbaas space"),
(re.compile(r"10\.12\.(20|24|60)\."), "D-058-era CIDR (never deployed)"),
(re.compile(r"10\.12\.4\.2(2[4-9]|3[0-6])\b"), "pre-R14 VIP band"),
(re.compile(r"jesse\.austin/openstack-caracal-ipv4"), "dead repo path"),
]
EXEMPT = re.compile(r"retired|stale|supersed|historical|deprecat|D-05[3-9]|D-060|DOCFIX|"
r"must[- ]be[- ]absent|no provider-vip|renam|8_lbaas|ex-lbaas|old",
re.IGNORECASE)
for p in live_docs():
try:
txt = p.read_text(errors="replace")
except Exception:
continue
# explicit per-file opt-out for guard scripts whose PURPOSE is naming
# stale tokens (fail-closed checks, deprecation lists):
if "repo-lint: allow-stale-tokens" in txt:
continue
lines = txt.splitlines()
rel = str(p.relative_to(R))
for i, ln in enumerate(lines, 1):
if EXEMPT.search(ln):
continue
for rx, why in STALE:
if rx.search(ln):
fails.append("L2 %s:%d %s: %s" % (rel, i, why, ln.strip()[:80]))
# ---- L3 ghost script refs / L4 deprecated / L6 bare invocation ----
DEPRECATED = ["phase-00-teardown.sh", "phase-00-maas-recidr.sh",
"provider-vip-standup.sh", "d057-bundle-check.py",
"review-bundle.py",
"04a-capi-bootstrap-cluster", "05-magnum-capi-driver"]
DEP_EXEMPT = re.compile(r"deprecat|retired|historical|git rm|DO NOT USE|absorbed|replac",
re.IGNORECASE)
ref_rx = re.compile(r"scripts/([a-z0-9_\-]+\.(?:sh|py))")
bare_rx = re.compile(r"^(?!.*\b(?:bash|source|python3?)\b)[^#]*(?<![\w/.])(?:\./)?scripts/[a-z0-9_\-]+\.sh\b")
rb_dir = R / "runbooks"
for p in (sorted(rb_dir.glob("*.md")) if rb_dir.is_dir() else []):
rel = str(p.relative_to(R))
in_code = False
for i, ln in enumerate(p.read_text(errors="replace").splitlines(), 1):
if ln.strip().startswith("```"):
in_code = not in_code
continue
for m in ref_rx.finditer(ln):
if not (R / "scripts" / m.group(1)).exists():
fails.append("L3 %s:%d references missing scripts/%s" % (rel, i, m.group(1)))
if not DEP_EXEMPT.search(ln):
for d in DEPRECATED:
if d in ln:
fails.append("L4 %s:%d references deprecated %s" % (rel, i, d))
if in_code and bare_rx.search(ln):
fails.append("L6 %s:%d bare script invocation (no exec bits in repo; "
"use 'bash scripts/...'): %s" % (rel, i, ln.strip()[:70]))
# ---- L5 identifier numbering ----
heads = collections.Counter()
for name in ("docs/design-decisions.md",):
p = R / name
if not p.exists():
continue
# \d{3,} not 0\d{2}: the band-limited form goes blind past 099
# (the same defect fixed in ledger-scan as DOCFIX-105)
for m in re.finditer(r"(?m)^##+\s+(D-\d{3,})\b(?!.*AMENDMENT|.*RESOLVED)",
p.read_text(errors="replace")):
heads[m.group(1)] += 1
for ident, n in sorted(heads.items()):
if n > 1:
fails.append("L5 %s defined %d times in design-decisions.md (collision)" % (ident, n))
# next-free print REMOVED (DOCFIX-107): it was wrong on all three counters
# (band-limited regex, mention-counting incl. pointer lines, tests/ fixture
# ingestion) and collided with ledger-scan, the single numbering authority.
# ---- L7 clientdocs sweep-on-change receipt ----
if cdir.is_dir():
if not receipt.exists():
fails.append("L7 clientdocs/ exists without sweep-receipt.txt -- "
"review clientdocs/ for staleness, then: %s" % RECORD_CMD)
else:
entries = receipt_entries()
for rp, h in sorted(entries.items()):
p = R / rp
if not p.is_file():
fails.append("L7 sweep receipt pins missing file %s -- "
"review clientdocs/, then: %s" % (rp, RECORD_CMD))
elif sweep_sha(p) != h:
fails.append("L7 %s changed since the recorded clientdocs sweep -- "
"review clientdocs/ for staleness, then: %s" % (rp, RECORD_CMD))
for p in sweep_auto():
rp = p.relative_to(R).as_posix()
if rp not in entries:
fails.append("L7 tenant-facing %s is not in the sweep receipt -- "
"review clientdocs/, then: %s" % (rp, RECORD_CMD))
for w in warns:
print(" [WARN] %s" % w)
for f in fails:
print(" [FAIL] %s" % f)
verdict = "FAIL" if fails else ("WARN" if warns else "PASS")
print("\n%s: repo lint (%d fail, %d warn)" % (verdict, len(fails), len(warns)))
return 1 if fails else (2 if warns else 0)
if __name__ == "__main__":
sys.exit(main())