#!/usr/bin/env python3
"""
repo_lint.py -- static hygiene lint for this 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/archive/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).
  L8  guide<->skill guide-to-skill coupling (skill-lag guard): the sha256 of
                    clientdocs/jenkins-kubernetes-guide.md is pinned in
                    clientdocs/guide-skill-coupling.txt. When the guide changes,
                    lint FAILS until the tenant AI skill (tenant-skill/references/
                    kubernetes.md + troubleshooting.md) is reviewed for the same
                    update and the pin is re-recorded: bash scripts/repo-lint.sh
                    --record-guide-skill-coupling. Skipped when the guide is absent.
  L9  self-ref      repo-agnostic guard (DOCFIX-141): a script/RUN-block/skill
                    file must never hardcode the CURRENT repo's own directory
                    name (a fork or rename breaks every hardcoded copy at once --
                    this repo IS itself the second such rename). Scope: whole
                    file for scripts/**/*.{sh,py}, .claude/hooks/*.{sh,py}, and
                    .claude/skills/**/*.md (agent-operational, not incidental
                    prose); fenced code blocks only for runbooks/*.md (prose
                    naming a repo, incl. a SIBLING repo by name, is fine -- only
                    an executed literal path/URL is the hazard). Opt out with
                    'repo-lint: allow-selfref' in the file for a genuine,
                    reviewed exception. Self-locate instead: BASH_SOURCE-derived
                    $HERE in scripts (see scripts/repo-lint.sh), $REPO set once
                    per session (via git rev-parse --show-toplevel or an
                    explicit export) threaded through runbook RUN blocks.
  L10 status-couple GA-R1 rule 8 (amendment C1, ratified 2026-07-18 --
                    docs/audit/ga-rulings.md): a change set that modifies a
                    state-bearing surface -- anything under docs/audit/
                    (captures, ga-rulings.md), a **Status: line in
                    docs/design-decisions.md, or a gate row (| G<n> |) in
                    docs/session-ledger.md -- without touching
                    docs/CURRENT-STATE.md is "defective by construction".
                    Scans the PENDING change set (working tree vs HEAD) when
                    the tree is dirty, else the HEAD commit -- so the flag
                    fires BEFORE the commit in the delivery loop, and at
                    session bootstrap for an already-landed one. Skipped when
                    git or a HEAD commit is absent (static fixtures).
                    Severity: WARN during sweep Batch 0, then FAIL -- flip
                    L10_FAIL (phase5-sweep-plan Batch 0: "WARN for one
                    batch").
  L11 caveman-resid caveman-compress residue (SEC-017): /caveman-compress
  L12 stage-couple  creds-manifests/stages-reached is a DERIVED projection of
                    CURRENT-STATE consumed by the blocking preflight P5 gate; editing it
                    without CURRENT-STATE in the same change set lets the projection drift
                    from its authority and the gate silently defer real absences (D-137)
                    overwrites a prose file IN PLACE and drops a
                    <name>.original.md backup beside it -- the signature of a
                    lossy in-place rewrite having run against a repo-tracked
                    file, incompatible with the verbatim-record discipline
                    (GA-R1/R5). FAILS on any *.original.md in the tree
                    (.git / .claude worktrees / .terraform / __pycache__
                    excluded). Opt out with 'repo-lint: allow-original-md' in
                    the file for a genuine, reviewed exception.

Exit: 0 clean | 1 FAIL findings | 2 warnings only.  ASCII + LF.
Usage: python3 scripts/repo_lint.py [--record-clientdocs-sweep] [--record-guide-skill-coupling] [repo-root]
(marker: repo-lint: allow-stale-tokens -- this file names them by necessity)
"""
import re, sys, pathlib, collections, hashlib, shutil, subprocess

# L10 severity gate: False = WARN, True = FAIL. Ran as WARN through sweep
# Batch 0; FLIPPED to FAIL at Batch 1 open 2026-07-19 per
# docs/audit/phase5-sweep-plan.md Batch 0 ("the C1 check is a WARN for one
# batch before becoming FAIL").
L10_FAIL = True

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
"""

COUPLING_FLAG = "--record-guide-skill-coupling"
COUPLING_CMD = "bash scripts/repo-lint.sh " + COUPLING_FLAG
COUPLING_HEADER = """\
# clientdocs/guide-skill-coupling.txt -- guide<->skill coupling pin (L8).
# INTERNAL FILE: never shipped to a client.
# Pins the sha256 of the Jenkins + Kubernetes Implementation Guide. When the guide
# changes, repo lint FAILS until the tenant AI skill (tenant-skill/references/
# kubernetes.md + troubleshooting.md) has been reviewed for the SAME update and this
# pin is re-recorded with:
#   bash scripts/repo-lint.sh --record-guide-skill-coupling
# Rationale: guide edits repeatedly landed without mirroring into the skill
# (DOCFIX-135/136 skill-lag). This makes "sweep the skill" a hard gate, not a note.
"""

# ---- R15(1): the gate must not be able to pass over nothing --------------------
# Ruled 2026-07-27 ("All three, with a harness MANIFEST rather than a count"), OPS.
# MEASURED DEFECT: this function stripped only the two KNOWN flags, so ANY other
# --flag fell through to argv[0] and became the REPO ROOT. With no is_dir() check
# and no floor, rglob then yielded nothing and the script printed
#   PASS: repo lint (0 fail, 0 warn)
# with exit 0. Reproduced on `--recordd` and on a nonexistent path. This is the
# gate whose "0-fail" every GA-R6 stage close in this project's history cites, and
# it could not distinguish a clean repo from an unexamined one.
MIN_FILES_SCANNED = 100  # floor, not a pin. ~596 in scope at the 2026-07-27 fix,
                         # so a ~6x margin: catches zero/catastrophic-mis-root
                         # without going brittle as the repo grows or shrinks.
                         # Exact-set pinning is the harness MANIFEST's job (R15(2)),
                         # deliberately not this floor's.
# The floor SCALES to the tree, because a flat floor is wrong: this lint is also run
# against deliberately minimal fixture trees (tests/repo-lint builds ~7-file repos to
# plant one violation each), and those are legitimately small. A flat 100 failed 20 of
# them on first run. So the STRONG floor applies only to a full checkout of THIS repo,
# detected by files no fixture creates; any other tree still must scan at least one
# file, which is what catches "the walk filters emptied the scan".
FULL_REPO_MARKERS = ("scripts/preflight.sh", "docs/security-ledger.md")


def main():
    argv = [a for a in sys.argv[1:]]
    record = RECORD_FLAG in argv
    coupling_record = COUPLING_FLAG in argv
    argv = [a for a in argv if a not in (RECORD_FLAG, COUPLING_FLAG)]

    # R15(1)a -- reject UNRECOGNISED options instead of treating them as the root.
    # This is the actual root cause of the zero-file false green: a one-character
    # typo of a flag silently became the directory to scan.
    unknown = [a for a in argv if a.startswith("-")]
    if unknown:
        print("FAIL: repo lint -- unrecognised option(s): %s" % " ".join(unknown))
        print("      known options: %s | %s" % (RECORD_FLAG, COUPLING_FLAG))
        print("      (an unrecognised flag used to be taken as the REPO ROOT, which "
              "resolved to a nonexistent directory and reported PASS over ZERO files)")
        return 1
    if len(argv) > 1:
        print("FAIL: repo lint -- expected at most one repo-root path, got %d: %s"
              % (len(argv), " ".join(argv)))
        return 1

    R = pathlib.Path(argv[0] if argv else ".").resolve()

    # R15(1)b -- the root must exist, be a directory, and actually look like THIS
    # repo. "Could not look" must never read as "nothing there".
    if not R.is_dir():
        print("FAIL: repo lint -- repo root is not a directory: %s" % R)
        print("      nothing was scanned; this is NOT a clean result")
        return 1
    missing_markers = [m for m in ("docs", "scripts") if not (R / m).is_dir()]
    if missing_markers:
        print("FAIL: repo lint -- %s does not look like this repo (missing: %s)"
              % (R, ", ".join(missing_markers + [])))
        print("      refusing to report a verdict over a tree this lint does not "
              "understand -- an unrecognised state REFUSES rather than defaulting "
              "to success")
        return 1

    fails, warns = [], []

    # ---- L7 helpers (clientdocs sweep-on-change, DOCFIX-104) ----
    cdir = R / "clientdocs"
    receipt = cdir / "sweep-receipt.txt"
    # ---- L8: guide<->skill coupling (skill-lag guard) ----
    guide = cdir / "jenkins-kubernetes-guide.md"
    coupling = cdir / "guide-skill-coupling.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)
            # write_bytes + explicit ascii encode, not write_text: write_text's
            # newline translation emits CRLF on Windows (this repo is LF-only;
            # .gitattributes eol=lf cleans it up at `git add` time regardless,
            # but this script should not depend on git to fix its own output).
            receipt.write_bytes((RECEIPT_HEADER + "\n".join(body) + "\n").encode("ascii"))
            print("  [info] L7 recorded clientdocs sweep receipt (%d files)" % len(body))

    if coupling_record:
        if guide.is_file():
            coupling.write_bytes(
                (COUPLING_HEADER + "%s  %s\n"
                 % (sweep_sha(guide), guide.relative_to(R).as_posix())).encode("ascii")
            )
            print("  [info] L8 recorded guide->skill coupling pin")
        else:
            print("  [info] L8 record: no jenkins-kubernetes-guide.md -- nothing to record")
            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", ".tf", ".tmpl", ""}
        skip_names = {"overrides.zip"}
        out = []
        for p in R.rglob("*"):
            # .claude/worktrees/ 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) -- session-local, not
            # repo content (DOCFIX-111). The rest of .claude/ (hooks, skills) IS
            # checked-in repo content and stays in scope for L1/L9.
            # .terraform/ is OpenTofu's downloaded provider-plugin cache (created
            # by `tofu init`, gitignored per DOCFIX-175). Its third-party provider
            # docs carry non-ASCII bytes and are NOT repo content -- skip like .git
            # (DOCFIX-179: surfaced on the first real `tofu init` in opentofu/).
            if not p.is_file() or ".git" in p.parts or "__pycache__" in p.parts \
               or ".terraform" in p.parts \
               or (".claude" in p.parts and "worktrees" 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 = p.relative_to(R).as_posix()
        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 = p.relative_to(R).as_posix()
        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 / L9 self-ref (runbooks) ----
    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")
    reponame = R.name
    selfref_rx = re.compile(r"\b" + re.escape(reponame) + r"\b")
    SELFREF_OPTOUT = "repo-lint: allow-selfref"
    rb_dir = R / "runbooks"
    for p in (sorted(rb_dir.glob("*.md")) if rb_dir.is_dir() else []):
        rel = p.relative_to(R).as_posix()
        txt = p.read_text(errors="replace")
        opted_out = SELFREF_OPTOUT in txt
        in_code = False
        for i, ln in enumerate(txt.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]))
            if in_code and not opted_out and selfref_rx.search(ln):
                fails.append("L9 %s:%d RUN block hardcodes the repo's own name %r -- "
                             "use $REPO (session bootstrap), never a literal clone "
                             "path/URL: %s" % (rel, i, reponame, ln.strip()[:70]))

    # ---- L9 self-ref: scripts + hooks (whole file) ----
    selfref_script_dirs = []
    if (R / "scripts").is_dir():
        selfref_script_dirs += list((R / "scripts").rglob("*.sh")) + list((R / "scripts").rglob("*.py"))
    if (R / ".claude" / "hooks").is_dir():
        selfref_script_dirs += list((R / ".claude" / "hooks").glob("*.sh")) \
                             + list((R / ".claude" / "hooks").glob("*.py"))
    for p in selfref_script_dirs:
        rel = p.relative_to(R).as_posix()
        txt = p.read_text(errors="replace")
        if SELFREF_OPTOUT in txt:
            continue
        for i, ln in enumerate(txt.splitlines(), 1):
            if selfref_rx.search(ln):
                fails.append("L9 %s:%d hardcodes the repo's own name %r -- self-locate "
                             "instead (BASH_SOURCE-derived $HERE, git rev-parse "
                             "--show-toplevel): %s" % (rel, i, reponame, ln.strip()[:70]))

    # ---- L9 self-ref: .claude/skills/ (whole file -- agent-operational, not incidental prose) ----
    skill_dir = R / ".claude" / "skills"
    if skill_dir.is_dir():
        for p in skill_dir.rglob("*.md"):
            rel = p.relative_to(R).as_posix()
            txt = p.read_text(errors="replace")
            if SELFREF_OPTOUT in txt:
                continue
            for i, ln in enumerate(txt.splitlines(), 1):
                if selfref_rx.search(ln):
                    fails.append("L9 %s:%d skill hardcodes the repo's own name %r -- "
                                 "describe 'this repository' / discover via git remote, "
                                 "never bake in the name: %s" % (rel, i, reponame, 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.

    # ---- L10 status-coupling (GA-R1 rule 8 / amendment C1) ----
    # State-bearing surfaces per the ratified C1 utterance: docs/audit/
    # captures + ga-rulings, design-decisions Status lines, ledger gate rows.
    # (The gate-row TABLE currently lives in CURRENT-STATE.md itself, where a
    # row edit satisfies the rule by construction; the session-ledger pattern
    # below guards gate rows reappearing there.) A triggering change set must
    # include docs/CURRENT-STATE.md, same commit.
    def git(*args):
        return subprocess.run(["git", "-C", str(R)] + list(args),
                              capture_output=True, text=True)
    if shutil.which("git") and \
       git("rev-parse", "--is-inside-work-tree").stdout.strip() == "true" and \
       git("rev-parse", "--verify", "-q", "HEAD").returncode == 0:
        if git("status", "--porcelain").stdout.strip():
            scope = "pending change set"
            changed = set(git("diff", "--name-only", "HEAD").stdout.splitlines())
            changed |= set(git("ls-files", "--others", "--exclude-standard")
                           .stdout.splitlines())
            def l10_diff(path):
                return git("diff", "HEAD", "--", path).stdout
        else:
            scope = "HEAD commit"
            changed = set(git("diff-tree", "-r", "--no-commit-id", "--name-only",
                              "--root", "HEAD").stdout.splitlines())
            def l10_diff(path):
                return git("diff-tree", "-p", "--no-commit-id", "--root",
                           "HEAD", "--", path).stdout
        def diff_hits(path, rx):  # changed +/- lines matching rx (headers excluded)
            return any(rx.match(ln) for ln in l10_diff(path).splitlines())
        triggers = []
        audit_hits = sorted(p for p in changed if p.startswith("docs/audit/"))
        if audit_hits:
            triggers.append("docs/audit/: " + ", ".join(audit_hits[:3])
                            + (" (+%d more)" % (len(audit_hits) - 3)
                               if len(audit_hits) > 3 else ""))
        if "docs/design-decisions.md" in changed and diff_hits(
                "docs/design-decisions.md",
                re.compile(r"^[+-](?!\+\+|--).*\*\*Status:")):
            triggers.append("design-decisions.md Status line")
        if "docs/session-ledger.md" in changed and diff_hits(
                "docs/session-ledger.md",
                re.compile(r"^[+-](?!\+\+|--)\s*\|\s*G\d+\s*\|")):
            triggers.append("session-ledger.md gate row")
        if triggers and "docs/CURRENT-STATE.md" not in changed:
            (fails if L10_FAIL else warns).append(
                "L10 %s modifies state-bearing surface(s) [%s] without touching "
                "docs/CURRENT-STATE.md -- GA-R1 rule 8 (C1): a status-changing "
                "commit updates CURRENT-STATE.md in the SAME commit"
                % (scope, "; ".join(triggers)))

    # ---- 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))

    # ---- L8 guide<->skill coupling (skill-lag guard) ----
    if guide.is_file():
        if not coupling.exists():
            fails.append("L8 clientdocs/guide-skill-coupling.txt missing -- review the "
                         "tenant skill vs the guide, then: %s" % COUPLING_CMD)
        else:
            pinned = None
            for ln in coupling.read_text(errors="replace").splitlines():
                ln = ln.strip()
                if ln and not ln.startswith("#"):
                    pinned = ln.split(None, 1)[0]
                    break
            if pinned != sweep_sha(guide):
                fails.append("L8 jenkins-kubernetes-guide.md changed since the last "
                             "guide->skill review -- review clientdocs/tenant-skill/"
                             "references/kubernetes.md + troubleshooting.md for the SAME "
                             "update, then re-record: %s" % COUPLING_CMD)

    # ---- L12 stage-declaration coupling (D-137) ----
    # creds-manifests/stages-reached is a machine-readable PROJECTION of CURRENT-STATE's
    # stage reach, consumed by the BLOCKING preflight P5 gate: it decides which missing
    # credentials are "not minted yet" rather than "missing". Editing it without its
    # authority in the same change set lets the projection drift silently, and a drifted
    # projection makes the gate DEFER real absences -- a false green. Same coupling L10
    # applies to state-bearing surfaces, stated as its own rule so each has its own id.
    if shutil.which("git") and \
       git("rev-parse", "--is-inside-work-tree").stdout.strip() == "true" and \
       git("rev-parse", "--verify", "-q", "HEAD").returncode == 0:
        if git("status", "--porcelain").stdout.strip():
            l12_scope = "pending change set"
            l12_changed = set(git("diff", "--name-only", "HEAD").stdout.splitlines())
            l12_changed |= set(git("ls-files", "--others", "--exclude-standard")
                               .stdout.splitlines())
        else:
            l12_scope = "HEAD commit"
            l12_changed = set(git("diff-tree", "-r", "--no-commit-id", "--name-only",
                                  "--root", "HEAD").stdout.splitlines())
        if ("creds-manifests/stages-reached" in l12_changed
                and "docs/CURRENT-STATE.md" not in l12_changed):
            fails.append("L12 %s edits creds-manifests/stages-reached (the D-137 stage "
                         "projection consumed by the blocking preflight P5 gate) without "
                         "touching docs/CURRENT-STATE.md -- the projection must not drift "
                         "from its authority" % l12_scope)

    # ---- L11 caveman-compress residue (SEC-017) ----
    # /caveman-compress rewrites a prose file IN PLACE and drops a
    # <name>.original.md backup beside it. That backup is the residue signature
    # of a lossy in-place rewrite having run against a repo-tracked file --
    # incompatible with this repo's verbatim-record discipline (GA-R1/R5). FAIL
    # on any *.original.md in the tree; reuse the all_text() skip set. Escape
    # hatch: a file carrying a `repo-lint: allow-original-md` marker line is a
    # reviewed exception (L9 opt-out convention).
    for p in R.rglob("*.original.md"):
        if not p.is_file() or ".git" in p.parts or "__pycache__" in p.parts \
           or ".terraform" in p.parts \
           or (".claude" in p.parts and "worktrees" in p.parts):
            continue
        if "repo-lint: allow-original-md" in p.read_text(errors="replace"):
            continue
        rel = p.relative_to(R).as_posix()
        fails.append("L11 %s: caveman-compress residue (a *.original.md backup = "
                     "a lossy in-place rewrite ran against a repo-tracked file). "
                     "Restore the original and delete the backup; /caveman-compress "
                     "must NEVER run against repo-tracked prose (CLAUDE.md; SEC-017)."
                     % rel)

    # R15(1)c -- files-scanned FLOOR. Even with a valid root, a future refactor of
    # the walk filters could silently empty the scan; a verdict over ~nothing must
    # not read as clean. The count is also PRINTED: before this fix the script
    # emitted no files-scanned figure at all, so a reader had no way to tell a
    # thorough pass from a vacuous one (found again during the Stage-5 Phase 0 runs).
    scanned = len(all_text())
    full_repo = all((R / m).is_file() for m in FULL_REPO_MARKERS)
    floor = MIN_FILES_SCANNED if full_repo else 1
    if scanned < floor:
        fails.append("L0 files-scanned floor: only %d file(s) in scope under %s "
                     "(floor %d, %s). A lint verdict over ~nothing is not a clean "
                     "repo -- investigate the root or the walk filters before "
                     "trusting any result above."
                     % (scanned, R, floor,
                        "full repo checkout" if full_repo else "minimal tree"))

    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, %d files scanned)"
          % (verdict, len(fails), len(warns), scanned))
    return 1 if fails else (2 if warns else 0)

if __name__ == "__main__":
    sys.exit(main())
