Newer
Older
openstack-caracal-dc-dc / scripts / record-audit.py
#!/usr/bin/env python3
"""scripts/record-audit.py -- Phase-1 deterministic record inventory (grounding audit).

Implements Phase 1 items 1, 2 and 4 of docs/audit/grounding-audit-charter.md as a
SCRIPT, not judgment: doc census, status-claim extraction (plan counts, stage
statuses, version pins, gate assertions), and reference integrity (path refs,
D-/DOCFIX-/BUNDLEFIX-/SEC-/R3-F number refs, commit hashes, file:line refs).
Scope per the operator's 2026-07-18 amendment: docs/ + runbooks/ + README.md +
CLAUDE.md + the .claude/skills/openstack-cloud-ops/ tree + (via --memory-dir) the
auto-memory directory, read as on-disk evidence.

Mutates NOTHING except writing the single report file (--out, default
docs/audit/record-inventory.md under --repo).

Deterministic counting rules (zero judgment, stated so re-runs are comparable):
  - Files under docs/audit/ and the memory dir are INVENTORIED but their rows do
    NOT count toward the contradiction-pair exit signal -- the audit's own record
    of a defect must not keep the defect alive (charter Phase 6 criterion 1).
  - A "contradiction group" is a claim key (the global plan-count set; one
    version product; one stage key) holding >1 distinct value across counted
    surfaces. The report lists every row; adjudication happens in the findings
    register, not here.
  - A second pair count EXCLUDING changelog-class files is reported alongside
    (changelogs are historical by nature; GA-R2 owns their consolidation).

Usage:
    python3 scripts/record-audit.py [--repo DIR] [--memory-dir DIR]
        [--out FILE] [--stamp STRING] [--no-git]

Exit: 0 = zero counted contradiction groups (PROCEED), 1 = contradictions found
(HOLD), 2 = precondition missing (repo absent/unreadable). ASCII + LF.
"""
import argparse
import os
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path

TEXT_EXT = {".md", ".sh", ".py", ".tf", ".yaml", ".yml", ".txt", ".cfg", ".conf",
            ".json", ".tfvars", ".php", ""}
SKIP_DIRS = {".git", ".terraform", "__pycache__", "node_modules"}
MAX_TEXT = 4 * 1024 * 1024

STATUS_TOKENS = r"(DEPLOY-READY|READY|BLOCKED|CLOSED|COMPLETED|COMPLETE|HELD|STOPPED)"
GATE_WORDS = r"(non-blocking|blocking|unblocked|unexercised|unverified|imported|verified)"
PRODUCTS = [
    ("tofu", r"(?:OpenTofu|opentofu|tofu)"),
    ("maas", r"(?:MAAS|maas)"),
    ("juju", r"(?:Juju|juju)"),
    ("opnsense", r"(?:OPNsense|opnsense)"),
    ("netbox", r"(?:NetBox|netbox)"),
    ("lxd", r"(?:LXD|lxd)"),
]
VER_RE = r"v?(\d+\.\d+(?:\.\d+)?)"


def die(msg, code):
    print(f"record-audit: {msg}", file=sys.stderr)
    sys.exit(code)


def run_git(repo, args):
    try:
        out = subprocess.run(["git", "-C", str(repo)] + args, capture_output=True,
                             text=True, timeout=60)
        return out.returncode, out.stdout.strip()
    except Exception:
        return 1, ""


def is_text_candidate(p: Path):
    return p.suffix.lower() in TEXT_EXT and p.is_file() and p.stat().st_size <= MAX_TEXT


def read_lines(p: Path):
    try:
        return p.read_text(encoding="utf-8", errors="replace").splitlines()
    except Exception:
        return []


def classify(rel: str):
    b = os.path.basename(rel)
    if rel.startswith("docs/audit/"):
        return "audit"
    if b.startswith("changelog-"):
        return "changelog"
    if b == "design-decisions.md":
        return "decision"
    if "ledger" in b:
        return "ledger"
    if "readiness" in b:
        return "readiness"
    if "finding" in b:
        return "finding"
    if "patch" in b or rel.endswith(".patch"):
        return "patch"
    if "workflow" in b:
        return "workflow"
    if rel.startswith("runbooks/"):
        return "runbook"
    return "reference"


def trim(line, n=110):
    s = line.strip().replace("|", "\\|")
    return s[:n] + ("..." if len(s) > n else "")


class Row:
    __slots__ = ("kind", "key", "value", "loc", "line", "counted", "census_class")

    def __init__(self, kind, key, value, loc, line, counted, census_class):
        self.kind, self.key, self.value = kind, key, value
        self.loc, self.line = loc, line
        self.counted, self.census_class = counted, census_class


def extract_claims(rel, lines, counted, census_class):
    rows = []

    def add(kind, key, value, i, line):
        rows.append(Row(kind, key, value, f"{rel}:{i}", line, counted, census_class))

    plan_wordy = re.compile(
        r"(\d+)\s*(?:add|create[sd]?)\s*/\s*(\d+(?:-or-\d+)?)\s*"
        r"(?:change[sd]?|in-place|update[sd]?)\s*/\s*(\d+)\s*(?:destroy|delete[sd]?)", re.I)
    plan_hyphen = re.compile(
        r"(\d+)-add/\(?(\d+(?:-or-\d+)?)\)?-change/(\d+)-destroy", re.I)
    plan_tofu = re.compile(r"(\d+) to add, (\d+) to change, (\d+) to destroy")
    plan_compact = re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{1,2})\b")
    plan_context = re.compile(r"\b(plan|apply|add|destroy|outer|inner)\b", re.I)
    stage_tok = re.compile(r"\b" + STATUS_TOKENS + r"\b")
    stage_key = re.compile(r"[Ss]tage[- ]?(\d+)")
    gate_tok = re.compile(r"\b" + GATE_WORDS + r"\b", re.I)

    # Version attribution: the version string must follow the product token with
    # no digits and no OTHER product/noise token in between (kills cross-product
    # bleed, Ubuntu/Django/kernel versions, URL/section numbers).
    prod_words = [(k, re.compile(r"(?<![A-Za-z])" + pat + r"(?![A-Za-z])"))
                  for k, pat in PRODUCTS]
    ver_only = re.compile(VER_RE)
    ver_noise = re.compile(
        r"ubuntu|django|freebsd|postgres|python|pynetbox|kernel|kea|topic|"
        r"section|vault|mysql|ceph|caracal|noble|raft|charm", re.I)

    for i, line in enumerate(lines, 1):
        seen_span = []
        for m in plan_wordy.finditer(line):
            add("plan", "plan", "/".join(m.groups()), i, line)
            seen_span.append(m.span())
        for m in plan_hyphen.finditer(line):
            add("plan", "plan", "/".join(m.groups()), i, line)
            seen_span.append(m.span())
        for m in plan_tofu.finditer(line):
            add("plan", "plan", "/".join(m.groups()), i, line)
            seen_span.append(m.span())
        if plan_context.search(line):
            for m in plan_compact.finditer(line):
                if any(a <= m.start() < b for a, b in seen_span):
                    continue
                # not part of a longer x/y/z/w chain, date range, or decimal
                tail = line[m.end():]
                head = line[:m.start()]
                if tail[:1] in "/-." or head[-1:] in "/-.":
                    continue
                add("plan", "plan", "/".join(m.groups()), i, line)

        for m in stage_tok.finditer(line):
            tok = m.group(1)
            norm = {"DEPLOY-READY": "READY", "COMPLETED": "COMPLETE"}.get(tok, tok)
            km = stage_key.search(line)
            key = f"stage-{km.group(1)}" if km else "-"
            add("status", key, norm, i, line)

        for prod, pre in prod_words:
            for pm in pre.finditer(line):
                seg = line[pm.end():pm.end() + 46]
                vm = ver_only.search(seg)
                if not vm:
                    continue
                between = seg[:vm.start()]
                if re.search(r"\d", between) or ver_noise.search(between):
                    continue
                if vm.start() > 0 and (seg[vm.start() - 1] == "." or
                                       seg[vm.start() - 1].isalpha()):
                    continue  # '.8.20' octet fragment / 'C2.3' checklist id
                if any(k2 != prod and p2.search(between) for k2, p2 in prod_words):
                    continue
                after = seg[vm.end():]
                if (after[:1] == "." and after[1:2].isdigit()) or after[:1] == "%":
                    continue  # IP fragment / percentage, not a version
                add("version", prod, vm.group(1), i, line)

        for m in gate_tok.finditer(line):
            add("gate", "gate", m.group(1).lower(), i, line)
    return rows


PATH_RE = re.compile(
    r"\b((?:docs|runbooks|scripts|tests|opentofu|netbox|policies|clientdocs|"
    r"creds-manifests|references)/[A-Za-z0-9._@/-]*[A-Za-z0-9])")
NUM_RE = re.compile(r"\b((?:D|DOCFIX|BUNDLEFIX|SEC)-\d{1,3}|GA-[FR]\d{1,2}|R3-F\d{2})\b")
HASH_RE = re.compile(r"\b[0-9a-f]{7,12}\b")
FILELINE_RE = re.compile(
    r"\b((?:docs|runbooks|scripts|opentofu|tests)/[A-Za-z0-9._/-]+\."
    r"(?:md|sh|py|tf)):(\d+)\b")


def extract_refs(rel, lines, skill_base):
    paths, nums, hashes, filelines = [], [], [], []
    for i, line in enumerate(lines, 1):
        for m in PATH_RE.finditer(line):
            p = m.group(1).rstrip(".,;:")
            if any(c in p for c in "*<>{}$") or "YYYY" in p or "NN" in p:
                continue
            paths.append((p, f"{rel}:{i}"))
        for m in NUM_RE.finditer(line):
            nums.append((m.group(1), f"{rel}:{i}"))
        for m in HASH_RE.finditer(line):
            h = m.group(0)
            if re.search(r"[a-f]", h) and re.search(r"[0-9]", h):
                hashes.append((h, f"{rel}:{i}"))
        for m in FILELINE_RE.finditer(line):
            filelines.append((m.group(1), int(m.group(2)), f"{rel}:{i}"))
    return paths, nums, hashes, filelines


def main():
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--repo", default=None)
    ap.add_argument("--memory-dir", default=None)
    ap.add_argument("--out", default=None)
    ap.add_argument("--stamp", default="")
    ap.add_argument("--no-git", action="store_true")
    a = ap.parse_args()

    here = Path(__file__).resolve().parent
    repo = Path(a.repo).resolve() if a.repo else here.parent
    if not repo.is_dir() or not (repo / "docs").is_dir():
        die(f"repo dir absent or has no docs/: {repo}", 2)
    out = Path(a.out) if a.out else repo / "docs" / "audit" / "record-inventory.md"
    memdir = Path(a.memory_dir).resolve() if a.memory_dir else None
    if memdir and not memdir.is_dir():
        die(f"--memory-dir not a directory: {memdir}", 2)
    use_git = not a.no_git
    head = run_git(repo, ["rev-parse", "--short", "HEAD"])[1] if use_git else "(no-git)"

    skill_base = repo / ".claude" / "skills" / "openstack-cloud-ops"

    # ---- collect scan targets: (label, path, counted, surface) ---------------
    targets = []
    for base, surface in (("docs", "repo"), ("runbooks", "repo")):
        d = repo / base
        if d.is_dir():
            for p in sorted(d.rglob("*")):
                if p.is_file() and not any(s in p.parts for s in SKIP_DIRS):
                    rel = p.relative_to(repo).as_posix()
                    targets.append((rel, p, not rel.startswith("docs/audit/"), surface))
    for extra in ("README.md", "CLAUDE.md"):
        p = repo / extra
        if p.is_file():
            targets.append((extra, p, True, "repo"))
    if skill_base.is_dir():
        for p in sorted(skill_base.rglob("*.md")):
            rel = p.relative_to(repo).as_posix()
            targets.append((rel, p, True, "skill"))
    if memdir:
        for p in sorted(memdir.iterdir()):
            if p.is_file():
                targets.append((f"memory/{p.name}", p, False, "memory"))
    out_rel = out.resolve()
    targets = [t for t in targets if t[1].resolve() != out_rel]

    # ---- census + claims + refs ---------------------------------------------
    census, claims = [], []
    all_paths, all_nums, all_hashes, all_filelines = [], [], [], []
    contents = {}
    for rel, p, counted, surface in targets:
        lines = read_lines(p)
        contents[rel] = lines
        cls = classify(rel) if surface == "repo" else surface
        if use_git and surface == "repo":
            _, cdate = run_git(repo, ["log", "-1", "--format=%as", "--", rel])
            cdate = cdate or "uncommitted"
        else:
            cdate = "n/a"
        census.append((rel, cls, p.stat().st_size, len(lines), cdate))
        claims.extend(extract_claims(rel, lines, counted, cls))
        pa, nu, ha, fl = extract_refs(rel, lines, skill_base)
        all_paths.extend(pa)
        all_nums.extend(nu)
        all_hashes.extend(ha)
        all_filelines.extend(fl)

    # ---- inbound-reference corpus (whole repo text) -------------------------
    corpus = {}
    for p in sorted(repo.rglob("*")):
        if any(s in p.parts for s in SKIP_DIRS):
            continue
        if is_text_candidate(p):
            rel = p.relative_to(repo).as_posix()
            corpus[rel] = "\n".join(contents.get(rel) or read_lines(p))
    inbound = defaultdict(list)
    doc_basenames = {os.path.basename(rel): rel for rel, _, _, s in targets if s == "repo"}
    for b, target_rel in sorted(doc_basenames.items()):
        for rel, text in corpus.items():
            if rel == target_rel:
                continue
            if b in text:
                inbound[target_rel].append(rel)

    # ---- reference integrity checks -----------------------------------------
    dd_text = corpus.get("docs/design-decisions.md", "")
    sec_text = corpus.get("docs/security-ledger.md", "")
    broken_paths = sorted({(p, loc) for p, loc in all_paths
                           if not (repo / p).exists() and not (skill_base / p).exists()})
    orphan_nums = []
    for n, loc in sorted(set(all_nums)):
        canon = n if "-" in n else n
        if n.startswith("D-") and len(n) >= 4:
            if canon not in dd_text:
                orphan_nums.append((n, loc, "not in design-decisions.md"))
        elif n.startswith("SEC-"):
            if canon not in sec_text:
                orphan_nums.append((n, loc, "not in security-ledger.md"))
        else:
            hits = sum(1 for t in corpus.values() if canon in t)
            if hits <= 1:
                orphan_nums.append((n, loc, "referenced nowhere else"))
    unknown_hashes = []
    if use_git:
        for h, loc in sorted(set(all_hashes)):
            rc, _ = run_git(repo, ["cat-file", "-e", f"{h}^{{commit}}"])
            if rc != 0:
                unknown_hashes.append((h, loc))
    bad_filelines = []
    for f, ln, loc in sorted(set(all_filelines)):
        tgt = repo / f
        if not tgt.exists():
            bad_filelines.append((f, ln, loc, "target absent"))
        elif ln > len(read_lines(tgt)):
            bad_filelines.append((f, ln, loc, f"only {len(read_lines(tgt))} lines"))

    # ---- contradiction groups -----------------------------------------------
    def groups(kind):
        g = defaultdict(list)
        for r in claims:
            if r.kind == kind:
                g[r.key].append(r)
        return g

    contra, contra_nochg = [], []
    for kind in ("plan", "version", "status"):
        for key, rows in sorted(groups(kind).items()):
            if kind == "status" and key == "-":
                continue
            counted_rows = [r for r in rows if r.counted]
            vals = sorted({r.value for r in counted_rows})
            if len(vals) > 1:
                contra.append((kind, key, vals, counted_rows))
                v2 = sorted({r.value for r in counted_rows if r.census_class != "changelog"})
                if len(v2) > 1:
                    contra_nochg.append((kind, key, v2))

    # ---- render -------------------------------------------------------------
    L = []
    w = L.append
    w("# Record inventory -- grounding audit Phase 1 (deterministic)")
    w("")
    w(f"Generated by `scripts/record-audit.py` at repo HEAD `{head}`"
      + (f" ({a.stamp})" if a.stamp else "") + ".")
    w("Read-only extraction; zero judgment. Counting rules are in the script header:")
    w("docs/audit/* and memory-surface rows are inventoried but NOT counted toward")
    w("the contradiction signal; a changelog-excluded pair count is shown alongside.")
    w("")
    w("## Summary")
    w("")
    w(f"- files scanned: {len(targets)}")
    w(f"- status-claim rows: {len(claims)} "
      f"(plan {sum(1 for r in claims if r.kind == 'plan')}, "
      f"status {sum(1 for r in claims if r.kind == 'status')}, "
      f"version {sum(1 for r in claims if r.kind == 'version')}, "
      f"gate {sum(1 for r in claims if r.kind == 'gate')})")
    w(f"- CONTRADICTION GROUPS (counted surfaces): {len(contra)}")
    w(f"- contradiction groups excluding changelog-class files: {len(contra_nochg)}")
    w(f"- broken path refs: {len(broken_paths)}; orphan number refs: {len(orphan_nums)}; "
      f"unknown commit hashes: {len(unknown_hashes)}; bad file:line refs: {len(bad_filelines)}")
    w("")

    w("## 1. Doc census")
    w("")
    w("| file | class | bytes | lines | last commit | inbound refs |")
    w("|---|---|---|---|---|---|")
    for rel, cls, size, nlines, cdate in census:
        n_in = len(inbound.get(rel, []))
        w(f"| {rel} | {cls} | {size} | {nlines} | {cdate} | {n_in} |")
    w("")
    unref = [c for c in census if c[1] == "changelog" and not inbound.get(c[0])]
    w(f"Unreferenced changelogs (consolidation candidates by default): {len(unref)}")
    for rel, *_ in unref:
        w(f"- {rel}")
    w("")

    w("## 2. Contradiction groups (mechanical)")
    w("")
    if not contra:
        w("NONE.")
    for kind, key, vals, rows in contra:
        w(f"### CONTRADICTION: {kind} `{key}` -> {len(vals)} distinct values: "
          + ", ".join(f"`{v}`" for v in vals))
        w("")
        w("| value | where | line |")
        w("|---|---|---|")
        for r in sorted(rows, key=lambda r: (r.value, r.loc)):
            w(f"| {r.value} | {r.loc} | {trim(r.line)} |")
        w("")

    w("## 3. Status-claim rows (full table)")
    w("")
    w("| kind | key | value | where | line |")
    w("|---|---|---|---|---|")
    for r in sorted((r for r in claims if r.kind != "gate"),
                    key=lambda r: (r.kind, r.key, r.loc)):
        flag = "" if r.counted else " (not counted)"
        w(f"| {r.kind}{flag} | {r.key} | {r.value} | {r.loc} | {trim(r.line, 80)} |")
    w("")

    w("## 4. Gate-assertion rows")
    w("")
    w("| word | where | line |")
    w("|---|---|---|")
    for r in sorted((r for r in claims if r.kind == "gate"), key=lambda r: r.loc):
        w(f"| {r.value} | {r.loc} | {trim(r.line, 80)} |")
    w("")

    w("## 5. Reference integrity")
    w("")
    w("### Broken path references")
    w("| ref | where |")
    w("|---|---|")
    for p, loc in broken_paths:
        w(f"| {p} | {loc} |")
    w("")
    w("### Orphan / unregistered number references")
    w("| ref | where | why |")
    w("|---|---|---|")
    for n, loc, why in orphan_nums:
        w(f"| {n} | {loc} | {why} |")
    w("")
    w("### Unknown commit hashes")
    w("| hash | where |")
    w("|---|---|")
    for h, loc in unknown_hashes:
        w(f"| {h} | {loc} |")
    w("")
    w("### file:line references out of range or absent")
    w("| ref | line | where | why |")
    w("|---|---|---|---|")
    for f, ln, loc, why in bad_filelines:
        w(f"| {f} | {ln} | {loc} | {why} |")
    w("")

    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text("\n".join(L) + "\n", encoding="ascii", errors="replace")
    print(f"record-audit: {len(targets)} files, {len(claims)} claim rows, "
          f"{len(contra)} contradiction groups -> {out}")
    sys.exit(1 if contra else 0)


if __name__ == "__main__":
    main()