#!/usr/bin/env bash
# check-tools-index.sh — assert every shipped tool is discoverable from the
# resident documentation an agent actually has in context.
#
# WHY THIS GATE EXISTS
# --------------------
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
# undocumented ones was `pr-review.sh` — the wrapper that carries the
# APPROVED/APPROVE provider-dialect split.
#
# The observable consequence, on a live fleet host: an agent needing to place a
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
# host, and got HTTP 200 with the review silently filed PENDING — three times,
# because nothing about the failure pointed at the wrapper that already handled
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
# an index that said the tool did not exist.
#
# That is not a discipline problem and no amount of prose fixes it. A wrapper
# that is not in the resident index is, from inside a session, indistinguishable
# from a wrapper that was never written. So the invariant is mechanical:
#
#     shipping a tool and documenting it are the same commit, or CI fails.
#
# WHAT IT CHECKS
# --------------
#   forward  every non-excluded tool in an ENFORCED suite is named in at least
#            one index document (missing tool -> undiscoverable -> FAIL)
#   reverse  every `<name>.sh` an index document attributes to an enforced
#            suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
#
# Suites outside the enforced set are reported with a coverage percentage but do
# not fail the build, so the ratchet can be tightened one suite per PR instead of
# landing as one unreviewable sweep. `--strict` fails on those too.
#
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
# -------------------------------------------------------------------
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
# would let an operator silence this gate by editing their own copy — the gate
# would then be strongest exactly where it is least needed and absent where it
# is needed most. The list is framework-owned and changes only through a
# reviewed PR.
#
# Usage:
#   check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
#
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage

set -euo pipefail

# Suites whose coverage is a HARD requirement. Add a suite here only together
# with the doc changes that make it pass.
#
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
# an undiscoverable git wrapper converts a hard gate into a coin flip.
ENFORCED_SUITES=(git)

# Files that are not agent-callable tools and must not be required in an index.
EXCLUDE_GLOBS=(
  'test-*'        # hermetic regression scripts, invoked by CI not by agents
  '_*'            # private helpers (_lib, _scripts internals)
  '*.bak'         # editor/installer debris
  '*.pre-*'       # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
  'README.md'
)

STRICT=0
SELF_TEST=0
TOOLS_DIR=""
DOCS=()

die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }

while [ $# -gt 0 ]; do
  case "$1" in
    --tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
    --doc)       DOCS+=("${2:-}");   shift 2 ;;
    --strict)    STRICT=1; shift ;;
    --self-test) SELF_TEST=1; shift ;;
    -h|--help)   sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *)           die "unknown argument: $1" ;;
  esac
done

# ---- location resolution ---------------------------------------------------
# Runs from two places with different layouts, and must not silently check the
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
resolve_locations() {
  local here framework
  here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
  # .../framework/tools/quality/scripts -> .../framework
  framework="$(cd -- "$here/../../.." && pwd)"

  if [ -z "$TOOLS_DIR" ]; then
    if [ -d "$framework/tools" ]; then
      TOOLS_DIR="$framework/tools"
    else
      TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
    fi
  fi

  if [ ${#DOCS[@]} -eq 0 ]; then
    # The two layouts are mutually exclusive on purpose. Unioning them would let
    # a well-maintained operator TOOLS.md on the developer's own machine mask a
    # gap in the shipped defaults — the check would pass locally and the defect
    # would still install on every other host. Repo layout wins when present.
    if [ -f "$framework/defaults/TOOLS.md" ]; then
      DOCS+=("$framework/defaults/TOOLS.md")
      [ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
    else
      local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
      [ -f "$mosaic_home/TOOLS.md" ]                  && DOCS+=("$mosaic_home/TOOLS.md")
      [ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
    fi
  fi

  [ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
  [ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
}

is_excluded() {
  local name="$1" glob
  for glob in "${EXCLUDE_GLOBS[@]}"; do
    # shellcheck disable=SC2254  # glob is intentionally a pattern
    case "$name" in $glob) return 0 ;; esac
  done
  return 1
}

# A tool counts as documented when its basename appears anywhere in the corpus.
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
# about *presence*: the gate's job is "an agent can find it", not house style.
documented() { grep -qF -- "$1" "$CORPUS"; }

# ---- the check -------------------------------------------------------------
run_check() {
  local rc=0 suite dir tool base enforced

  CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
  cat "${DOCS[@]}" > "$CORPUS"

  printf 'tools:  %s\n' "$TOOLS_DIR"
  for d in "${DOCS[@]}"; do printf 'index:  %s\n' "$d"; done
  printf '\n'

  for dir in "$TOOLS_DIR"/*/; do
    [ -d "$dir" ] || continue
    suite="$(basename -- "$dir")"
    case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
    [ "$STRICT" -eq 1 ] && enforced=1
    case "$suite" in _*) continue ;; esac

    local total=0 found=0
    local -a suite_missing=() suite_noexec=()
    for tool in "$dir"*.sh; do
      [ -e "$tool" ] || continue
      base="$(basename -- "$tool")"
      is_excluded "$base" && continue
      total=$((total + 1))
      if documented "$base"; then
        found=$((found + 1))
        # Documented AND present is not enough. The index presents these as
        # commands to run, and every caller — the wrapper guard included —
        # decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
        # documented, present, and dead: it reads as absent to every check that
        # matters while scoring 100% here. That is a false green, which is worse
        # than a red, so it fails rather than warns.
        [ -x "$tool" ] || suite_noexec+=("$base")
      else
        suite_missing+=("$base")
      fi
    done
    [ "$total" -eq 0 ] && continue

    local pct=$(( found * 100 / total ))
    if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
      printf 'FAIL  %-12s %3d%% (%d/%d)  documented but not executable: %s\n' \
        "$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
      rc=1
    fi
    if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
      printf 'FAIL  %-12s %3d%% (%d/%d)  undocumented: %s\n' \
        "$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
      rc=1
    elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
      printf 'ok    %-12s %3d%% (%d/%d)  [enforced]\n' "$suite" "$pct" "$found" "$total"
    else
      printf 'info  %-12s %3d%% (%d/%d)  not yet enforced\n' "$suite" "$pct" "$found" "$total"
    fi

    # Reverse: an index that names a tool this suite does not have sends agents
    # after something that cannot run. Only checked for enforced suites, where
    # the naming is unambiguous enough to attribute.
    if [ "$enforced" -eq 1 ]; then
      local -a stale=()
      local ref
      while read -r ref; do
        [ -n "$ref" ] || continue
        is_excluded "$ref" && continue
        [ -e "$dir$ref" ] || stale+=("$ref")
      done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
                 | sed "s|^$suite/||" | sort -u)
      if [ ${#stale[@]} -gt 0 ]; then
        printf 'FAIL  %-12s stale index references (no such file): %s\n' \
          "$suite" "${stale[*]}"
        rc=1
      fi
    fi
  done

  printf '\n'
  if [ "$rc" -ne 0 ]; then
    cat <<EOF
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
tells it to use a wrapper it has no way to learn exists — it will reach for raw
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.

Fix by naming each tool above in one of the index documents listed at the top,
in the same commit that ships it.
EOF
  else
    printf 'every enforced suite is fully discoverable.\n'
  fi
  return "$rc"
}

# ---- self-test -------------------------------------------------------------
# Proves the gate can actually fail. A checker that only ever passes is
# indistinguishable from one that is not running, which is the failure mode this
# whole file exists to prevent — so it must demonstrate a red on demand.
self_test() {
  local tmp rc
  tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
  mkdir -p "$tmp/tools/git"
  printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
  printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
  chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"

  # run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
  # command-prefix assignment, so point the globals at the fixture directly.
  TOOLS_DIR="$tmp/tools"
  DOCS=("$tmp/doc.md")

  # Case 1: fully documented -> pass.
  printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
  if run_check >/dev/null; then
    printf 'self-test 1/4 ok  (complete index passes)\n'
  else
    printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
  fi

  # Case 2: an undocumented tool -> fail.
  printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
  rc=0; run_check >/dev/null || rc=$?
  if [ "$rc" -eq 1 ]; then
    printf 'self-test 2/4 ok  (undocumented tool fails the gate)\n'
  else
    printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
  fi

  # Case 3: a stale index reference -> fail.
  rm "$tmp/tools/git/undocumented-tool.sh"
  printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
  rc=0; run_check >/dev/null || rc=$?
  if [ "$rc" -eq 1 ]; then
    printf 'self-test 3/4 ok  (stale index reference fails the gate)\n'
  else
    printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
  fi

  # Case 4: documented, present, and NOT executable -> fail. Found by an
  # independent reviewer: a 0644 wrapper scored 100% here while reading as
  # absent to every `[ -x ]` in the fleet, including the wrapper guard's.
  sed -i '/deleted-tool/d' "$tmp/doc.md"
  printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
  chmod 0644 "$tmp/tools/git/noexec-tool.sh"
  printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
  rc=0; run_check >/dev/null || rc=$?
  if [ "$rc" -eq 1 ]; then
    printf 'self-test 4/4 ok  (documented but non-executable tool fails the gate)\n'
  else
    printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
  fi

  printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
}

if [ "$SELF_TEST" -eq 1 ]; then
  self_test
else
  resolve_locations
  run_check
fi
