#!/usr/bin/env bash
# tests/repo-lint/run-tests.sh -- offline harness for repo_lint.py (DOCFIX-074).
# Builds throwaway fixture repos with planted violations; asserts each lint class
# fires, and that a clean fixture passes. Mutates nothing outside $TMP.
# Exit: 0 all pass | 1 any case failed.  ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0

mkrepo() { # mkrepo <name>  -> minimal clean fixture repo
  local d="$TMP/$1"; mkdir -p "$d/runbooks" "$d/scripts" "$d/docs"
  printf '# readme\n' > "$d/README.md"
  printf 'applications: {}\n' > "$d/bundle.yaml"
  printf '## D-001\ntext\n' > "$d/docs/design-decisions.md"
  printf '#!/usr/bin/env bash\necho ok\n' > "$d/scripts/real.sh"
  printf '# phase\n```bash\nbash scripts/real.sh\n```\n' > "$d/runbooks/phase-x.md"
  echo "$d"
}
run() { # run <want_rc> <regex> <label> <dir>
  local out rc
  out="$(python3 "$REPO/scripts/repo_lint.py" "$4" 2>&1)"; rc=$?
  if [[ "$rc" == "$1" ]] && grep -qE "$2" <<<"$out"; then
    echo "  PASS  $3"; PASS=$((PASS+1))
  else
    echo "  FAIL  $3 (rc=$rc want=$1)"; echo "$out" | sed 's/^/        /' | head -5; FAIL=$((FAIL+1))
  fi
}

D=$(mkrepo clean);            run 0 'PASS: repo lint' "T1 clean fixture passes" "$D"
D=$(mkrepo l1); printf 'line with \xe2\x80\x94 dash\n' >> "$D/runbooks/phase-x.md"
                              run 1 'L1 .*non-ASCII' "T2 L1 non-ASCII FAILS" "$D"
D=$(mkrepo l1b); printf 'crlf line\r\n' >> "$D/scripts/real.sh"
                              run 1 'L1 .*CR byte' "T3 L1 CRLF FAILS" "$D"
D=$(mkrepo l1d); printf 'line with \xe2\x80\x94 dash\n' >> "$D/docs/design-decisions.md"
                              run 2 'L1 docs/design-decisions.md.*non-ASCII.*legacy D-001\.\.018 carve-out' \
                                    "T3b L1 design-decisions.md carve-out WARNs (not FAILs)" "$D"
D=$(mkrepo l2); printf 'bind to provider-vip here\n' >> "$D/runbooks/phase-x.md"
                              run 1 'L2 .*retired space' "T4 L2 stale space FAILS" "$D"
D=$(mkrepo l2b); printf 'the provider-vip plane is retired (D-060)\n' >> "$D/runbooks/phase-x.md"
                              run 0 'PASS' "T5 L2 historical context EXEMPT" "$D"
D=$(mkrepo l3); printf 'run scripts/ghost.sh now\n' >> "$D/runbooks/phase-x.md"
                              run 1 'L3 .*missing scripts/ghost.sh' "T6 L3 ghost ref FAILS" "$D"
D=$(mkrepo l4); printf 'use phase-00-teardown.sh --apply\n' >> "$D/runbooks/phase-x.md"
                              run 1 'L4 .*deprecated' "T7 L4 deprecated ref FAILS" "$D"
D=$(mkrepo l5); printf '## D-001\ndupe\n' >> "$D/docs/design-decisions.md"
                              run 1 'L5 D-001 defined 2' "T8 L5 numbering collision FAILS" "$D"
D=$(mkrepo l6); printf '```bash\nscripts/real.sh --apply\n```\n' >> "$D/runbooks/phase-x.md"
                              run 1 'L6 .*bare script invocation' "T9 L6 bare invoke FAILS" "$D"

# ---- L9 self-reference (repo-agnostic guard, DOCFIX-141) ----
D=$(mkrepo l9a); printf 'REPO="$HOME/l9a"\n' >> "$D/scripts/real.sh"
                              run 1 'L9 scripts/real.sh.*hardcodes the repo.s own name' \
                                    "T29 L9 script hardcodes own repo name FAILS" "$D"
D=$(mkrepo l9b); printf 'REPO="$HOME/l9b"  # repo-lint: allow-selfref\n' >> "$D/scripts/real.sh"
                              run 0 'PASS: repo lint' "T30 L9 opt-out marker suppresses script self-ref" "$D"
D=$(mkrepo l9c); printf 'This runbook applies to the l9c repository.\n' >> "$D/runbooks/phase-x.md"
                              run 0 'PASS: repo lint' "T31 L9 runbook prose mention (outside code block) does NOT fire" "$D"
D=$(mkrepo l9d); printf '\n```bash\ncd ~/l9d\n```\n' >> "$D/runbooks/phase-x.md"
                              run 1 'L9 runbooks/phase-x.md.*RUN block hardcodes the repo.s own name' \
                                    "T32 L9 runbook code-block hardcode FAILS" "$D"
D=$(mkrepo l9e); mkdir -p "$D/.claude/skills/foo"
printf '# skill\nThe repo l9e is authoritative.\n' > "$D/.claude/skills/foo/SKILL.md"
                              run 1 'L9 .claude/skills/foo/SKILL.md.*skill hardcodes the repo.s own name' \
                                    "T33 L9 skill file hardcodes own repo name FAILS" "$D"

# ---- L7 clientdocs sweep-on-change receipt (DOCFIX-104) ----
mkcd() { # mkcd <name> -> clean fixture WITH clientdocs + tenant-facing files
  local d; d=$(mkrepo "$1")
  mkdir -p "$d/clientdocs"
  printf '# welcome\n' > "$d/clientdocs/welcome.md"
  printf '#!/usr/bin/env bash\necho onboard\n' > "$d/scripts/tenant-onboard.sh"
  printf '# contract\n' > "$d/docs/tenant-onboarding-contract.md"
  echo "$d"
}
rec() { python3 "$REPO/scripts/repo_lint.py" --record-clientdocs-sweep "$1" >/dev/null 2>&1; }
mkguide() { # mkcd + a Jenkins guide + skill refs (for the L8 guide<->skill coupling tests)
  local d; d=$(mkcd "$1")
  printf '# guide v1\n' > "$d/clientdocs/jenkins-kubernetes-guide.md"
  mkdir -p "$d/clientdocs/tenant-skill/references"
  printf '# k8s\n' > "$d/clientdocs/tenant-skill/references/kubernetes.md"
  printf '# trbl\n' > "$d/clientdocs/tenant-skill/references/troubleshooting.md"
  echo "$d"
}
reccpl() { python3 "$REPO/scripts/repo_lint.py" --record-guide-skill-coupling "$1" >/dev/null 2>&1; }

D=$(mkcd l7a);                run 1 'L7 clientdocs/ exists without sweep-receipt' \
                                    "T10 L7 missing receipt FAILS" "$D"
D=$(mkcd l7b); rec "$D";      run 0 'PASS: repo lint' "T11 L7 recorded receipt PASSES" "$D"
D=$(mkcd l7c); rec "$D"; printf 'echo changed\n' >> "$D/scripts/tenant-onboard.sh"
                              run 1 'L7 scripts/tenant-onboard.sh changed since the recorded clientdocs sweep' \
                                    "T12 L7 stale marker FAILS" "$D"
D=$(mkcd l7d); rec "$D"; printf 'echo changed\n' >> "$D/scripts/tenant-onboard.sh"; rec "$D"
                              run 0 'PASS: repo lint' "T13 L7 marker bump PASSES again" "$D"
D=$(mkcd l7e); rec "$D"; printf 'unrelated\n' >> "$D/README.md"
                              run 0 'PASS: repo lint' "T14 L7 unrelated change does NOT fire" "$D"
D=$(mkcd l7f); rec "$D"; printf '# pricing\n' > "$D/clientdocs/pricing.md"
                              run 1 'L7 tenant-facing clientdocs/pricing.md is not in the sweep receipt' \
                                    "T15 L7 uncovered new tenant-facing file FAILS" "$D"
D=$(mkcd l7g); rec "$D"; rm "$D/scripts/tenant-onboard.sh"
                              run 1 'L7 sweep receipt pins missing file scripts/tenant-onboard.sh' \
                                    "T16 L7 pinned file removed FAILS" "$D"
D=$(mkcd l7h); rec "$D"; printf 'not-a-hash-line\n' >> "$D/clientdocs/sweep-receipt.txt"
                              run 1 'L7 .*malformed line' "T17 L7 malformed receipt line FAILS" "$D"

# clientdocs/scripts/* is auto-covered like clientdocs/*.md (starter-kit files)
D=$(mkcd l7i); rec "$D"; mkdir -p "$D/clientdocs/scripts"
printf '#!/usr/bin/env bash\necho smoke\n' > "$D/clientdocs/scripts/smoke.sh"
                              run 1 'L7 tenant-facing clientdocs/scripts/smoke.sh is not in the sweep receipt' \
                                    "T18 L7 uncovered clientdocs/scripts file FAILS" "$D"
D=$(mkcd l7j); mkdir -p "$D/clientdocs/scripts"
printf '#!/usr/bin/env bash\necho smoke\n' > "$D/clientdocs/scripts/smoke.sh"; rec "$D"
                              run 0 'PASS: repo lint' "T19 L7 recorded clientdocs/scripts file PASSES" "$D"
D=$(mkcd l7k); mkdir -p "$D/clientdocs/scripts"
printf '#!/usr/bin/env bash\necho smoke\n' > "$D/clientdocs/scripts/smoke.sh"; rec "$D"
printf 'echo changed\n' >> "$D/clientdocs/scripts/smoke.sh"
                              run 1 'L7 clientdocs/scripts/smoke.sh changed since the recorded clientdocs sweep' \
                                    "T20 L7 clientdocs/scripts drift FAILS" "$D"
# *.md in ANY clientdocs subdir is auto-covered (tenant-skill/ package)
D=$(mkcd l7l); rec "$D"; mkdir -p "$D/clientdocs/tenant-skill/references"
printf '# skill ref\n' > "$D/clientdocs/tenant-skill/references/ops.md"
                              run 1 'L7 tenant-facing clientdocs/tenant-skill/references/ops.md is not in the sweep receipt' \
                                    "T21 L7 uncovered clientdocs subdir .md FAILS" "$D"
D=$(mkcd l7m); mkdir -p "$D/clientdocs/tenant-skill/references"
printf '# skill ref\n' > "$D/clientdocs/tenant-skill/references/ops.md"; rec "$D"
                              run 0 'PASS: repo lint' "T22 L7 recorded subdir .md PASSES" "$D"
# .claude/ (agent worktrees, hooks) is session-local, never linted
D=$(mkcd l1c); rec "$D"; mkdir -p "$D/.claude/worktrees/agent-x/docs"
printf '# em\xe2\x80\x94dash noise\n' > "$D/.claude/worktrees/agent-x/docs/design-decisions.md"
                              run 0 'PASS: repo lint' "T23 .claude/ contents excluded from lint" "$D"

# ---- L8 guide<->skill coupling (skill-lag guard) ----
# guide present + L7 recorded, but no coupling pin -> L8 FAILS
D=$(mkguide l8a); rec "$D"
                              run 1 'L8 clientdocs/guide-skill-coupling.txt missing' \
                                    "T24 L8 missing coupling pin FAILS" "$D"
# pin recorded -> PASSES
D=$(mkguide l8b); rec "$D"; reccpl "$D"
                              run 0 'PASS: repo lint' "T25 L8 recorded coupling PASSES" "$D"
# guide edited, L7 re-recorded (so L7 is clean) but skill NOT re-reviewed -> L8 FAILS alone
D=$(mkguide l8c); rec "$D"; reccpl "$D"
printf '# guide v2 (new topic)\n' >> "$D/clientdocs/jenkins-kubernetes-guide.md"; rec "$D"
                              run 1 'L8 jenkins-kubernetes-guide.md changed since the last' \
                                    "T26 L8 guide drift FAILS when skill not re-reviewed" "$D"
# after re-recording the coupling (the review ack) -> PASSES again
D=$(mkguide l8d); rec "$D"; reccpl "$D"
printf '# guide v2\n' >> "$D/clientdocs/jenkins-kubernetes-guide.md"; rec "$D"; reccpl "$D"
                              run 0 'PASS: repo lint' "T27 L8 re-record after review PASSES" "$D"
# no guide in the fixture -> L8 is skipped entirely (clean repo still passes)
D=$(mkcd l8e); rec "$D";      run 0 'PASS: repo lint' "T28 L8 skipped when no guide present" "$D"

# ---- L10 status-coupling (GA-R1 rule 8 / amendment C1) ----
mkgit() { # mkrepo + state-bearing surfaces + git history (for L10)
  local d; d=$(mkrepo "$1")
  mkdir -p "$d/docs/audit"
  printf '# rulings\n' > "$d/docs/audit/ga-rulings.md"
  printf '# current state\n' > "$d/docs/CURRENT-STATE.md"
  printf '# ledger\n| G4 | gate | closes it | owner | TO RUN |\n' > "$d/docs/session-ledger.md"
  printf '## D-001\n**Status: PROPOSED.**\n' > "$d/docs/design-decisions.md"
  git -C "$d" init -q -b main
  git -C "$d" add -A >/dev/null 2>&1
  git -C "$d" -c user.email=t@t -c user.name=t commit -qm base >/dev/null 2>&1
  echo "$d"
}
# non-git fixtures skip L10 entirely: every earlier T-case above already
# asserts its exact rc with L10 present, so no separate skip test is needed.
D=$(mkgit l10a); printf 'amended\n' >> "$D/docs/audit/ga-rulings.md"
                              run 1 'L10 pending change set.*docs/audit/' \
                                    "T34 L10 dirty audit-surface edit w/o CURRENT-STATE FAILS" "$D"
D=$(mkgit l10b); printf 'amended\n' >> "$D/docs/audit/ga-rulings.md"
printf 'updated\n' >> "$D/docs/CURRENT-STATE.md"
                              run 0 'PASS: repo lint' \
                                    "T35 L10 same change set touching CURRENT-STATE PASSES" "$D"
D=$(mkgit l10c); printf '## D-001\n**Status: ADOPTED.**\n' > "$D/docs/design-decisions.md"
                              run 1 'L10 .*design-decisions.md Status line' \
                                    "T36 L10 Status-line change w/o CURRENT-STATE FAILS" "$D"
D=$(mkgit l10d); printf 'non-status prose line\n' >> "$D/docs/design-decisions.md"
                              run 0 'PASS: repo lint' \
                                    "T37 L10 non-Status design-decisions edit does NOT fire" "$D"
D=$(mkgit l10e); printf 'amended\n' >> "$D/docs/audit/ga-rulings.md"
git -C "$D" add -A >/dev/null 2>&1
git -C "$D" -c user.email=t@t -c user.name=t commit -qm drift >/dev/null 2>&1
                              run 1 'L10 HEAD commit.*docs/audit/' \
                                    "T38 L10 clean tree flags the HEAD commit" "$D"
D=$(mkgit l10f); printf '# ledger\n| G4 | gate | closes it | owner | OPEN |\n' > "$D/docs/session-ledger.md"
                              run 1 'L10 .*session-ledger.md gate row' \
                                    "T39 L10 ledger gate-row change w/o CURRENT-STATE FAILS" "$D"
D=$(mkgit l10g); printf 'ledger prose, no gate row touched\n' >> "$D/docs/session-ledger.md"
                              run 0 'PASS: repo lint' \
                                    "T40 L10 non-gate-row ledger edit does NOT fire" "$D"
D=$(mkgit l10h); printf 'plan output\n' > "$D/docs/audit/outer-plan-x.txt"
                              run 1 'L10 pending change set.*docs/audit/' \
                                    "T41 L10 untracked new capture file FAILS" "$D"

# ---- L11 caveman-compress residue (SEC-017) ----
D=$(mkrepo l11a); printf '# stale in-place copy\n' > "$D/docs/CLAUDE.original.md"
                              run 1 'L11 docs/CLAUDE.original.md.*caveman-compress residue' \
                                    "T42 L11 *.original.md residue FAILS" "$D"
D=$(mkrepo l11b); printf '# reviewed exception -- repo-lint: allow-original-md\n' > "$D/docs/keep.original.md"
                              run 0 'PASS: repo lint' \
                                    "T43 L11 opt-out marker suppresses residue FAIL" "$D"
D=$(mkrepo l11c); mkdir -p "$D/.claude/worktrees/agent-y/docs"
printf '# worktree copy\n' > "$D/.claude/worktrees/agent-y/docs/x.original.md"
                              run 0 'PASS: repo lint' \
                                    "T44 L11 excludes .claude worktrees" "$D"

# T45/T46 -- L12: creds-manifests/stages-reached is a DERIVED projection of CURRENT-STATE
# that the BLOCKING preflight P5 gate consumes to decide which missing credentials are
# merely not-minted-yet. A drifted projection makes the gate DEFER REAL ABSENCES -- a false
# green, the exact class D-137 exists to prevent. L12 couples the two the way L10 couples
# state-bearing surfaces.
mkl12() { local d; d="$(mkrepo "$1")"; mkdir -p "$d/creds-manifests"
  printf 'stage2 reached\n' > "$d/creds-manifests/stages-reached"
  printf '# state\n' > "$d/docs/CURRENT-STATE.md"
  ( cd "$d" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t \
      commit -qm base ) >/dev/null 2>&1
  printf 'stage2 reached\nstage3 reached\n' > "$d/creds-manifests/stages-reached"
  echo "$d"; }
D=$(mkl12 l12a); run 1 'L12 .*stages-reached' "T45 L12 stage projection edited alone FAILS" "$D"
D=$(mkl12 l12b); printf 'moved\n' >> "$D/docs/CURRENT-STATE.md"
run 0 'PASS: repo lint' "T46 L12 does NOT fire when CURRENT-STATE moves with it" "$D"

# ---- T48-T54 -- R15(1): THE GATE MUST NOT BE ABLE TO PASS OVER NOTHING ----
# Ruled 2026-07-27. MEASURED defect: only the two known flags were stripped, so any
# OTHER --flag fell through to argv[0] and became the REPO ROOT; with no is_dir()
# check and no floor, rglob yielded nothing and this printed
#   PASS: repo lint (0 fail, 0 warn)   exit 0
# over ZERO files. This is the gate whose "0-fail" every GA-R6 stage close cites.
# These cases are the regression lock: each was reproduced BEFORE the fix.
runargv() { # runargv <want_rc> <regex> <label> [args...]
  local want="$1" re="$2" label="$3"; shift 3
  local out rc
  out="$(python3 "$REPO/scripts/repo_lint.py" "$@" 2>&1)"; rc=$?
  if [[ "$rc" == "$want" ]] && grep -qE "$re" <<<"$out"; then
    echo "  PASS  $label"; PASS=$((PASS+1))
  else
    echo "  FAIL  $label (rc=$rc want=$want)"; echo "$out" | sed 's/^/        /' | head -4; FAIL=$((FAIL+1))
  fi
}

runargv 1 'unrecognised option' \
  "T48 R15(1) a one-character typo of a flag FAILS (was: PASS over zero files)" --recordd
runargv 1 'unrecognised option' \
  "T49 R15(1) an unknown flag alongside a valid root still FAILS" --nope "$TMP"
runargv 1 'repo root is not a directory' \
  "T50 R15(1) a nonexistent root FAILS (was: PASS over zero files)" "$TMP/no-such-dir"
D=$(mkrepo t51); runargv 1 'expected at most one repo-root path' \
  "T51 R15(1) two positional roots FAIL rather than silently using the first" "$D" "$D"

# A directory that exists but is not this repo must REFUSE, not report clean.
mkdir -p "$TMP/notrepo"; printf 'hi\n' > "$TMP/notrepo/x.md"
runargv 1 'does not look like this repo' \
  "T52 R15(1) a valid dir lacking repo markers REFUSES (never defaults to success)" "$TMP/notrepo"

# The STRONG floor: full-repo markers present but the tree is nearly empty. This is
# the walk-filters-broke case the marker check cannot catch.
D=$(mkrepo t53); printf '#!/usr/bin/env bash\n' > "$D/scripts/preflight.sh"
printf '# ledger\n' > "$D/docs/security-ledger.md"
runargv 1 'files-scanned floor.*full repo checkout' \
  "T53 R15(1) full-repo markers + near-empty tree trips the floor" "$D"

# The floor must NOT fire on a legitimate minimal fixture -- a flat floor failed 20
# existing cases on first implementation, which is why it scales to the tree.
D=$(mkrepo t54); runargv 0 'files scanned' \
  "T54 R15(1) floor does NOT punish a legitimate minimal tree, and the count PRINTS" "$D"

echo; echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1
