#!/usr/bin/env bash
#
# Every file a document names must exist.
#
# ## The failure this catches
#
# Documentation makes claims about code, and the claims rot silently because
# nothing executes them. Three real instances, all found by hand in one
# afternoon on one repository:
#
#   - A source comment stating that `tests/notice-security.test.ts` pinned a
#     security rule. **That file had never existed.** The rule was real and
#     enforced by nothing, and the sentence had been reassuring every reader who
#     checked for two batches.
#   - A reference manual whose migration table stopped at 0050 while the
#     repository was at 0056. Six migrations behind, and every reader in between
#     trusted it.
#   - A manual promising a watchdog the code structurally could not fire.
#
# The first two are mechanically checkable and this checks them. The third is
# not — a claim about behaviour needs a person or a test — which is worth
# knowing about this script's limits: **it proves a path exists, never that the
# sentence around it is true.**
#
# ## What it looks at
#
# Anything that looks like a repository path inside backticks or a markdown
# link, in the files you point it at. A path is checked when it looks like one:
# it contains a slash or a known source extension, and it is not a URL, not a
# glob, and not obviously prose.
#
#   bash scripts/doc-claims.sh                 # every tracked .md
#   bash scripts/doc-claims.sh docs/           # one tree
#   bash scripts/doc-claims.sh README.md       # one file
#   DOC_CLAIMS_ALSO_SRC=1 bash scripts/doc-claims.sh   # also scan source comments
#   DOC_CLAIMS_EXCLUDE='' bash scripts/doc-claims.sh   # include forward-looking specs
#
# ## The inverse: is everything that exists written down?
#
#   bash scripts/doc-claims.sh --covers src/db/migrations --in docs/MANUAL.md
#
# The check above asks whether every path a document *names* exists. This asks
# whether every file that exists is *named* — and it is the one that actually
# bit. A reference manual's migration table stopped at 0050 while the repository
# was at 0056: six rows missing, every path in the document perfectly valid, and
# no existence check can see an absent row.
#
# `docs/proposed/` is skipped by default: a specification naming the files it
# would create is not a claim that they exist.
#
# Exit codes: 0 every named path exists. 1 at least one does not. 2 nothing was
# scanned, which is not a pass — an empty run and a clean run must not look the
# same, for the same reason `verify.sh` refuses to report zero checks as green.
# ---------------------------------------------------------------------------

set -uo pipefail

# --covers <dir> --in <doc>: every file in <dir> must be mentioned in <doc>.
COVERS=""
COVERS_IN=""
ARGS=()

while [ $# -gt 0 ]; do
  case "$1" in
    --covers) COVERS="${2:-}"; shift 2 ;;
    --in)     COVERS_IN="${2:-}"; shift 2 ;;
    *)        ARGS+=("$1"); shift ;;
  esac
done

set -- "${ARGS[@]+"${ARGS[@]}"}"

cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
  printf 'doc-claims: not a git repository.\n' >&2
  exit 2
}

say()  { printf 'doc-claims: %s\n' "$*" >&2; }

if [ -n "$COVERS" ]; then
  [ -n "$COVERS_IN" ] || { say "--covers needs --in <document>"; exit 2; }
  [ -d "$COVERS" ]    || { say "--covers: $COVERS is not a directory"; exit 2; }
  [ -f "$COVERS_IN" ] || { say "--in: $COVERS_IN does not exist"; exit 2; }

  unmentioned=0
  total=0
  doc_body="$(cat "$COVERS_IN")"

  for entry in "$COVERS"/*; do
    [ -e "$entry" ] || continue

    total=$((total + 1))
    name="$(basename "$entry")"
    stem="${name%.*}"
    prefix="${stem%%_*}"

    # Three spellings, because documents legitimately use all of them and
    # demanding the longest reports a perfectly correct document as broken.
    # The first draft of this checked only the full name and the stem, and
    # reported 57 of 58 migrations missing from a table that lists every one —
    # because that table writes `0057`, not `0057_digest_recipients.sql`.
    #
    # The prefix is only accepted when it is distinctive: a bare `0057` is, a
    # bare `route` would not be, and matching on the latter would let a document
    # pass by coincidence.
    case "$doc_body" in
      *"$name"*|*"$stem"*) continue ;;
    esac

    case "$prefix" in
      "$stem") ;;                               # no underscore; nothing new to try
      [0-9][0-9][0-9]*|v[0-9]*)
        case "$doc_body" in *"$prefix"*) continue ;; esac ;;
    esac

    printf '%s: %s is not mentioned in %s\n' "$COVERS" "$name" "$COVERS_IN"
    unmentioned=$((unmentioned + 1))
  done

  if [ "$total" -eq 0 ]; then
    say "$COVERS is empty; nothing to cover."
    exit 2
  fi

  if [ "$unmentioned" -gt 0 ]; then
    say "$unmentioned of $total entr(ies) in $COVERS are absent from $COVERS_IN."
    say "A list that is missing rows reads as complete — that is the whole"
    say "problem with it."
    exit 1
  fi

  say "all $total entr(ies) in $COVERS are mentioned in $COVERS_IN."
  exit 0
fi

TARGETS=("$@")

if [ ${#TARGETS[@]} -eq 0 ]; then
  # Tracked files only. An untracked scratch document is not a claim this
  # repository is making.
  mapfile -t FILES < <(git ls-files '*.md')
else
  mapfile -t FILES < <(git ls-files "${TARGETS[@]}" | grep -E '\.md$')
fi

if [ -n "${DOC_CLAIMS_ALSO_SRC:-}" ]; then
  # Source comments make the same claims and rot the same way — the missing
  # test file above was named in a docblock, not in a document.
  mapfile -t -O "${#FILES[@]}" FILES < <(git ls-files '*.ts' '*.tsx' '*.mjs' '*.js' '*.sh')
fi

# Forward-looking documents are excluded, and this is the difference between a
# useful run and a noisy one. A specification naming the files it *would* create
# is not a rotted claim — it is the whole point of a specification. On the
# repository this was written against, every finding under `docs/proposed/` was
# of that kind and they outnumbered the real ones four to one.
# Two kinds of tree are excluded by default, and both for the same reason: the
# paths in them do not resolve against *this* repository.
#
#   docs/proposed/     a specification naming the files it would create
#   project-template/  a vendored copy of another project's docs, whose paths
#   vendor/            resolve against whatever scaffolds from it
#
# The template copy alone accounted for 14 of 33 findings on the repository this
# was written against — every one of them a README correctly describing scripts
# that live in the template folder rather than here.
EXCLUDE="${DOC_CLAIMS_EXCLUDE:-docs/proposed/|project-template/|vendor/}"

if [ -n "$EXCLUDE" ]; then
  mapfile -t FILES < <(printf '%s\n' "${FILES[@]}" | grep -vE "$EXCLUDE" || true)
fi

if [ ${#FILES[@]} -eq 0 ]; then
  say "no files to scan."
  exit 2
fi

# A token worth checking, and the filter matters more than the check.
#
# The first draft flagged 684 of 1142 tokens on a real repository — routes like
# `/agent/notices`, absolute paths like `~/.config/thing`, and bare filenames.
# A guard that is wrong six times in ten is one people learn to skip, so the
# rule is now deliberately narrow: **a token is only checked when its first
# segment is something that actually exists at the top of this repository.**
#
# That excludes URL routes (their first segment is empty), home-relative paths,
# and prose, and it means a genuinely missing path is reported against a
# background of near-silence.
#
# Bare filenames with a source extension — `api-handler.ts` — are resolved by
# basename anywhere in the tree, which is what a reader would do.
mapfile -t TOPLEVEL < <(git ls-tree --name-only HEAD)

is_toplevel() {
  local first="${1%%/*}"
  local entry
  for entry in "${TOPLEVEL[@]}"; do
    [ "$first" = "$entry" ] && return 0
  done
  return 1
}

worth_checking() {
  case "$1" in
    http://*|https://*|*@*) return 1 ;;      # links and addresses
    /*|~*|.*) return 1 ;;                    # routes, home paths, relative noise
    *\**|*\?*|*'<'*|*'>'*|*' '*) return 1 ;; # globs, placeholders, prose
    "") return 1 ;;
  esac

  # A path into this repository.
  is_toplevel "$1" && return 0

  # Or a bare source filename, resolved by basename below.
  case "$1" in
    */*) return 1 ;;
    *.ts|*.tsx|*.mjs|*.js|*.sh|*.py|*.sql|*.md) return 0 ;;
    *) return 1 ;;
  esac
}

resolves() {
  local token="$1" doc="$2"

  [ -e "$token" ] && return 0
  [ -e "$(dirname "$doc")/$token" ] && return 0

  # Bare filename: does anything in the repository carry that basename?
  case "$token" in
    */*) return 1 ;;
    *) git ls-files "*/$token" "$token" | grep -q . && return 0 ;;
  esac

  return 1
}

missing=0
checked=0
notes=0

for file in "${FILES[@]}"; do
  [ -f "$file" ] || continue

  # Backticked spans and markdown link targets. Line numbers and anchors are
  # trimmed: `src/lib/foo.ts:42` and `foo.md#heading` name a real file.
  while IFS= read -r raw; do
    token="${raw%%:*}"
    token="${token%%#*}"
    token="${token%/}"

    worth_checking "$token" || continue

    checked=$((checked + 1))

    resolves "$token" "$file" && continue

    # A bare filename is a weaker claim than a path, and is reported without
    # failing the run.
    #
    # `docs/qa/ClaudeReport.md` asserts something about THIS repository. But
    # `release.sh` in prose is usually a reference to a script the template
    # offers and this project may not have adopted yet -- scaffold.sh
    # deliberately ships no scripts, and TOOLS.md says so: "the table is a menu
    # rather than an inventory here". Treating those as failures made every
    # freshly scaffolded project start with a red guard, over documents that
    # were correct, and a gate that is red from day one is one nobody reads.
    #
    # The strictness that matters is untouched. The finding this script was
    # written for -- a comment claiming `tests/notice-security.test.ts` pinned a
    # security rule, for a file that had never existed -- is a path, and paths
    # still fail.
    case "$token" in
      */*) ;;
      *) printf '%s: mentions %s, which is not in this repository (yet)\n' "$file" "$token"
         notes=$((notes + 1))
         continue ;;
    esac

    printf '%s: names %s, which does not exist\n' "$file" "$token"
    missing=$((missing + 1))
  done < <(grep -oE '`[^`]+`|\]\([^)]+\)' "$file" 2>/dev/null \
             | sed -E 's/^`//; s/`$//; s/^\]\(//; s/\)$//')
done

if [ "$checked" -eq 0 ]; then
  say "scanned ${#FILES[@]} file(s) and found no paths to check."
  exit 2
fi

if [ "$notes" -gt 0 ]; then
  say "$notes bare filename(s) above are mentioned but not present. Not a"
  say "failure: a project adopts the scripts it needs one at a time, and the"
  say "documents naming them are a menu rather than an inventory."
fi

if [ "$missing" -gt 0 ]; then
  say "$missing claimed path(s) do not exist, of $checked checked."
  say "A document naming a file that is not there is worse than one saying"
  say "nothing: somebody checked, and was reassured."
  exit 1
fi

say "$checked claimed path(s), all present, across ${#FILES[@]} file(s)."
