#!/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 `.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 < "$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