Newer
Older
openstack-caracal-ipv4 / scripts / repo_lint.py
#!/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())