security-review/checkers/doc-drift.sh

483 lines
25 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
# doc-drift.sh — Plane-1 / Tier-1 checker for the R720 agent-team.
#
# Design refs: docs/r720-agent-team-design.md §4 (Tier 1 roster: doc-drift —
# "Flags repos whose architecture moved but Confluence/README did not") and §7 Phase 3
# ("doc-drift + step-ca/Roles Anywhere + aws-posture"). This is the THIRD Plane-1 checker
# built on the Phase-0 shared substrate (lib/sweep_substrate.sh); it mirrors
# compliance-drift.sh / dependency-cve.sh conventions VERBATIM so the coordinator (§5) can
# drive all of them identically. doc-drift is UNGATED (only aws-posture in this phase is
# hard-gated behind the GPT-4.1 IAM cross-review; that checker is NOT built here).
#
# WHAT IT DOES (read-only):
# Scans the SAME shallow clean clones nightly_sweep.sh already produced in $MIRROR_DIR — it
# does NOT re-clone (mirrors-first; an optional --refresh re-runs discovery+mirror via the
# shared substrate). In each mirror it flags repos whose ARCHITECTURE MOVED but the README
# DID NOT — i.e. documentation drift. The checklist is DETERMINISTIC and GROUNDED in the
# global CLAUDE.md README obligation; it does NOT invent fuzzy judgments. See "CHECKLIST".
#
# This phase is the deterministic core ONLY. The design's "Gemini (large context)" judge
# layer (§4) is a LATER enhancement: a clearly-marked inert stub hook (maybe_judge) marks
# the future seam; it does NOTHING offline and NOTHING in this phase.
#
# REPORTING (matches secrev sweep conventions):
# - Writes a per-run JSON + text report under $REPORT_ROOT/<UTC-date>/, mode 600 (umask 077).
# - Slack ALARM-ONLY: a clean run (no confirmed drift) posts NOTHING (memory
# feedback_cloudwatch_alarms). Secret-shaped values are redacted from the Slack string.
# - Reuses the substrate's redact() + post_slack_alarm() verbatim.
#
# SUBSTRATE REUSE (lib/sweep_substrate.sh, sourced — bash dynamic scoping):
# redact, post_slack_alarm -> Slack delivery (reads SLACK_WEBHOOK_URL, REPORT_DIR, SWEEP_LOG)
# discover_repos, mirror_repo-> ONLY on --refresh (reads GH_TOKEN, GH_ORG, MIRROR_DIR, REPORT_DIR)
# Default path enumerates EXISTING $MIRROR_DIR/*/.git dirs — zero clones, zero network.
#
# CANARY / DRY-RUN (offline, no network, no token):
# --canary runs the checklist against a planted-drift fixture (checkers/fixtures/doc-drift/)
# and asserts the known drift count. This is the anti-complacency floor (design §6.4) AND the
# routing dry-run (§7 Phase 3): with --dry-run, the Slack alarm is composed + printed but NOT
# POSTed. Fully offline-smoke-testable (the checks are filesystem + `git log`, no network).
#
# SCOPE / SAFETY:
# Read-only. All checks are filesystem + local `git log`; NO network, NO token, NO GitHub API
# (doc-drift has no API-only checks — it is purely tree+history). Fixtures ship git metadata as
# dotgit/ (renamed to .git/ at run time) so they commit into THIS repo without becoming
# submodules — the SAME trick compliance-drift / dependency-cve use. A repo with NO README is
# SKIPPED (compliance-drift owns readme-present); doc-drift never double-flags a missing README.
#
# This script does NOT touch agent_team/ or agent-team/, is NOT wired into systemd, and does NOT
# stand up step-ca / Roles Anywhere / aws-posture — that is Phase-3/6 provisioning (gated). See
# the "PROVISIONING (NOT DONE HERE)" note at the bottom.
#
# Exit: 0 = ran (whether or not it alarmed); 2 = setup/usage error; 3 = canary assertion FAILED.
set -euo pipefail
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
log() { echo "[doc-drift] $*" >&2; }
die() { echo "[doc-drift] FATAL: $*" >&2; exit 2; }
# --- Shared substrate ---------------------------------------------------------
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBSTRATE="$HERE/../lib/sweep_substrate.sh"
[ -f "$SUBSTRATE" ] || die "shared substrate not found: $SUBSTRATE"
# shellcheck source=../lib/sweep_substrate.sh
. "$SUBSTRATE"
# --- Config + defaults (env, all optional) ------------------------------------
GH_ORG="${GH_ORG:-Sea-Haven-Industries}"
MIRROR_DIR="${MIRROR_DIR:-$HOME/repo-mirrors}"
REPORT_ROOT="${REPORT_ROOT:-$HOME/sweep-reports/doc-drift}"
# Docs-only repos describe themselves differently (a handbook is its own doc); skip the
# architecture-omission scan for them. They still get the staleness check.
DOCS_ONLY_REPOS="${DOCS_ONLY_REPOS:-engineering-handbook}"
# Staleness thresholds: README must lag the newest code by BOTH at least this many days AND
# this many substantial code commits before we call it drift (two-factor = no false alarm on a
# single quick fix landed after a doc commit; memory feedback_cloudwatch_alarms).
DOC_DRIFT_STALE_DAYS="${DOC_DRIFT_STALE_DAYS:-60}"
DOC_DRIFT_STALE_COMMITS="${DOC_DRIFT_STALE_COMMITS:-3}"
REFRESH=0 # --refresh: re-run discovery+mirror via substrate (network). Default: reuse mirrors.
DO_API=1 # --no-api: accepted for interface-parity with the other checkers; doc-drift makes
# NO API calls, so this flag is a documented no-op (kept so the coordinator
# can pass a uniform flag set to every Tier-1 checker).
DRY_RUN=0 # --dry-run: compose the Slack alarm but DO NOT post it (routing dry-run).
CANARY=0 # --canary: run against the planted-drift fixture + assert the known count.
TARGETS_OVERRIDE="" # --targets "p1 p2": scan explicit dirs instead of the mirror set.
usage() {
cat >&2 <<EOF
doc-drift.sh — Plane-1 Tier-1 documentation-drift checker (read-only)
--canary run against the planted-drift fixture and assert the known drift count
(implies --dry-run; fully offline smoke test — no network, no token)
--dry-run compose the Slack alarm but DO NOT post it (routing dry-run)
--no-api accepted for parity with the other Tier-1 checkers; doc-drift makes NO
API calls, so this is a documented no-op
--refresh re-discover + re-mirror via the shared substrate before scanning (network)
--targets "a b" scan these explicit repo dirs instead of \$MIRROR_DIR/* (no clone)
-h|--help this help
Env: GH_ORG MIRROR_DIR REPORT_ROOT GH_TOKEN SLACK_WEBHOOK_URL DOCS_ONLY_REPOS
DOC_DRIFT_STALE_DAYS DOC_DRIFT_STALE_COMMITS
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--canary) CANARY=1; DRY_RUN=1 ;;
--dry-run) DRY_RUN=1 ;;
--no-api) DO_API=0 ;;
--refresh) REFRESH=1 ;;
--targets) shift; TARGETS_OVERRIDE="${1:-}" ;;
-h|--help) usage; exit 0 ;;
*) die "unknown arg: $1 (see --help)" ;;
esac
shift
done
command -v jq >/dev/null || die "jq is required"
command -v git >/dev/null || die "git is required"
# --- Report dir (mode 600 reports; matches sweep conventions) -----------------
umask 077
UTC_DATE="$(date -u +%Y-%m-%d)"
UTC_STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
REPORT_DIR="$REPORT_ROOT/$UTC_DATE"
mkdir -p "$REPORT_DIR"; chmod 700 "$REPORT_ROOT" "$REPORT_DIR" 2>/dev/null || true
# shellcheck disable=SC2034 # read by the sourced substrate (post_slack_alarm) via dynamic scope
SWEEP_LOG="$REPORT_DIR/doc-drift.log" # name the substrate's post_slack_alarm() references
REPORT_JSON="$REPORT_DIR/doc-drift.json"
REPORT_TXT="$REPORT_DIR/doc-drift.txt"
# doc-drift makes NO API calls, so DO_API is a documented no-op kept only for coordinator
# flag-parity; surface it in the run banner so the chosen value is auditable (and used).
log "=== doc-drift $UTC_STAMP (canary=$CANARY dry_run=$DRY_RUN refresh=$REFRESH api=${DO_API}[no-op] stale_days=$DOC_DRIFT_STALE_DAYS stale_commits=$DOC_DRIFT_STALE_COMMITS) ==="
# ------------------------------------------------------------------------------
# CHECKLIST (grounded — every item cites the README obligation; nothing invented):
#
# readme-omits-component README exists but omits a major existing component
# present in the tree (top-level service dir, SAM/CDK stack,
# Lambda handler dir, openapi/docs API spec)
# -> global CLAUDE.md: "README must accurately describe
# architecture, services, data flow, and configuration"
# readme-stale-vs-code README last-touched commit far older than the newest code
# commit (>= DOC_DRIFT_STALE_DAYS) AND >= DOC_DRIFT_STALE_COMMITS
# substantial code commits landed after the README was touched
# -> global CLAUDE.md: "update the README in the same commit"
#
# A repo with NO README is SKIPPED (compliance-drift owns readme-present; double-flagging would
# be a false alarm). Each emitted finding follows the spirit of finding.schema.json
# (id/title/severity/category/proof/status) so the coordinator can route it like an agentic
# finding. category="other" (doc drift is not one of the schema's security categories).
# status="confirmed" only for deterministic filesystem/git-history facts. The future Gemini
# judge (design §4) is an inert stub (maybe_judge) — never invoked offline / in this phase.
# ------------------------------------------------------------------------------
# Drift accumulator: one JSON object per finding, appended to a bash array.
declare -a FINDINGS=()
add_finding() { # repo id title severity check proof
local repo="$1" id="$2" title="$3" sev="$4" check="$5" proof="$6"
FINDINGS+=( "$(jq -n \
--arg repo "$repo" --arg id "$id" --arg title "$title" --arg sev "$sev" \
--arg check "$check" --arg proof "$proof" \
'{repo:$repo, id:($repo+"-"+$id), title:$title, severity:$sev, category:"other",
check:$check, status:"confirmed", proof:{outcome:$proof}}')" )
}
declare -a SKIPPED_CHECKS=() # (repo:check) checks skipped on missing data — reported, never alarmed
note_skip() { SKIPPED_CHECKS+=( "$1" ); }
in_csv() { # needle csv -> 0 if present
local n="$1" csv="$2"; case ",$csv," in *",$n,"*) return 0 ;; *) return 1 ;; esac
}
# Inert future seam (design §4 "Gemini (large context)" judge): in LIVE mode an ambiguous
# omission ("is this component material enough to require a README mention?") could be escalated
# to a large-context judge. This phase keeps the deterministic core ONLY — the stub does nothing
# and is never reached offline / in canary / dry-run.
maybe_judge() { # candidate_json (no-op stub; Phase-3 intentionally inert)
return 0
}
# --- Does a README mention a component name? (case-insensitive, word-ish, deterministic) ----
# Matches the bare name OR the name with a trailing slash (how a dir is usually cited). Strips
# a leading "the " never matters; we test the literal token. Pure grep, no fuzzy matching.
readme_mentions() { # readme_file name
local rf="$1" name="$2"
# Escape regex metacharacters in the component name (defensive; dir names are usually plain).
local esc; esc="$(printf '%s' "$name" | sed -E 's/[][(){}.*+?^$|\\/]/\\&/g')"
grep -qiE "(^|[^A-Za-z0-9_-])${esc}([^A-Za-z0-9_-]|/|$)" "$rf" 2>/dev/null
}
# --- Enumerate the major components present in a repo tree (deterministic) ------
# Emits "TYPE<TAB>label<TAB>mention_token" lines. mention_token is what the README must contain.
# service-dir a top-level directory whose name ends in -service or -api, or named api/web/worker
# sam-cdk-stack a SAM/CDK stack root (template.yaml | app.py at a stack root | cdk.json)
# lambda-dir a Lambda handler dir (a dir named handlers/ or containing handler.* / app.py under handlers/)
# api-spec an openapi/ or docs/ directory or an openapi.* / swagger.* spec file
enumerate_components() { # repo_dir -> TSV lines
local dir="$1" d nm
# 1) top-level service-ish directories (the unit a README is expected to name)
for d in "$dir"/*/; do
[ -d "$d" ] || continue
nm="$(basename "$d")"
case "$nm" in
.git|.github|node_modules|dist|build|vendor|__pycache__|.venv) continue ;;
esac
case "$nm" in
*-service|*-api|api|web|worker|backend|frontend)
printf 'service-dir\t%s\t%s\n' "$nm" "$nm" ;;
esac
done
# 2) SAM / CDK stack roots
if [ -f "$dir/template.yaml" ] || [ -f "$dir/template.yml" ]; then
printf 'sam-cdk-stack\t%s\t%s\n' "template.yaml (SAM stack)" "template.yaml"
fi
if [ -f "$dir/cdk.json" ]; then
printf 'sam-cdk-stack\t%s\t%s\n' "cdk.json (CDK app)" "cdk.json"
fi
# 3) Lambda handler dirs: a top-level/handlers-rooted dir literally named "handlers"
while IFS= read -r d; do
[ -n "$d" ] || continue
printf 'lambda-dir\t%s\t%s\n' "handlers/ (Lambda handlers)" "handlers"
break # one mention requirement for the handlers tree is enough
done < <(find "$dir" -maxdepth 2 -type d -name handlers -not -path '*/.git/*' 2>/dev/null)
# 4) API spec: an openapi/ or docs/ dir, or an openapi.*/swagger.* file
if [ -d "$dir/openapi" ]; then
printf 'api-spec\t%s\t%s\n' "openapi/ (API spec)" "openapi"
elif find "$dir" -maxdepth 2 \( -iname 'openapi.*' -o -iname 'swagger.*' \) -not -path '*/.git/*' -print -quit 2>/dev/null | grep -q .; then
printf 'api-spec\t%s\t%s\n' "openapi/swagger spec" "openapi"
fi
}
# --- README last-touch epoch vs newest code commit (staleness, deterministic git log) -------
# Returns the staleness facts on stdout as TSV "readme_epoch<TAB>newest_code_epoch<TAB>commits_after".
# commits_after = count of commits that touched code (non-doc) files AFTER the README's last touch.
# Code = anything that is NOT a README/markdown/LICENSE/.gitignore/docs file. Prints nothing if
# the repo has no git history or no README in history (caller treats that as "cannot assess").
readme_staleness_facts() { # repo_dir
local dir="$1"
command -v git >/dev/null || return 0
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || return 0
# README last-touch (committer epoch of the most recent commit touching README.md).
local rd_epoch
rd_epoch="$(git -C "$dir" log -1 --format='%ct' -- README.md 2>/dev/null || true)"
[ -n "$rd_epoch" ] || return 0 # README not in history -> cannot assess staleness
# Newest commit touching a CODE path (exclude docs/markdown/license/config-noise).
local code_epoch
code_epoch="$(git -C "$dir" log -1 --format='%ct' -- \
':(exclude)README.md' ':(exclude)*.md' ':(exclude)docs/**' \
':(exclude)LICENSE' ':(exclude).gitignore' ':(exclude).github/**' \
2>/dev/null || true)"
[ -n "$code_epoch" ] || return 0 # no code commits -> nothing to be stale against
# Count CODE commits strictly AFTER the README's last touch.
local commits_after
commits_after="$(git -C "$dir" rev-list --count "--since=@${rd_epoch}" HEAD -- \
':(exclude)README.md' ':(exclude)*.md' ':(exclude)docs/**' \
':(exclude)LICENSE' ':(exclude).gitignore' ':(exclude).github/**' \
2>/dev/null || echo 0)"
printf '%s\t%s\t%s\n' "$rd_epoch" "$code_epoch" "${commits_after:-0}"
}
# ==============================================================================
# PER-REPO CHECK (offline; filesystem + local git log only)
# ==============================================================================
check_repo() { # repo_name repo_dir
local repo="$1" dir="$2"
local docs_only=0; in_csv "$repo" "$DOCS_ONLY_REPOS" && docs_only=1
# No README -> doc-drift cannot assess drift; compliance-drift owns readme-present. SKIP.
if [ ! -f "$dir/README.md" ]; then
note_skip "$repo:doc-drift(no-readme — compliance-drift owns readme-present)"
return
fi
local readme="$dir/README.md"
# --- readme-omits-component (skip for docs-only repos: they document differently) ---
if [ "$docs_only" -eq 0 ]; then
local type label token
while IFS=$'\t' read -r type label token; do
[ -n "$token" ] || continue
if ! readme_mentions "$readme" "$token"; then
add_finding "$repo" "readme-omits-$(printf '%s' "$type-$token" | tr -c 'A-Za-z0-9-' '-')" \
"README omits existing component: $label" "medium" "readme-omits-component" \
"global CLAUDE.md: README must accurately describe architecture/services (present in tree, absent from README: $label)"
fi
done < <(enumerate_components "$dir")
else
note_skip "$repo:readme-omits-component(docs-only)"
fi
# --- readme-stale-vs-code (two-factor: age in days AND code-commits-after) ---
local facts; facts="$(readme_staleness_facts "$dir")"
if [ -z "$facts" ]; then
note_skip "$repo:readme-stale-vs-code(no-history-or-no-readme-in-history)"
else
local rd_epoch code_epoch commits_after age_days
IFS=$'\t' read -r rd_epoch code_epoch commits_after <<< "$facts"
age_days=$(( (code_epoch - rd_epoch) / 86400 ))
[ "$age_days" -lt 0 ] && age_days=0
if [ "$age_days" -ge "$DOC_DRIFT_STALE_DAYS" ] && [ "$commits_after" -ge "$DOC_DRIFT_STALE_COMMITS" ]; then
add_finding "$repo" "readme-stale" \
"README is stale: ${age_days}d behind newest code, ${commits_after} code commit(s) since last README touch" \
"medium" "readme-stale-vs-code" \
"global CLAUDE.md: update the README in the same commit as functionality changes (thresholds: >=${DOC_DRIFT_STALE_DAYS}d AND >=${DOC_DRIFT_STALE_COMMITS} code commits)"
fi
fi
maybe_judge "" # inert in this phase (future Gemini large-context seam)
}
# ==============================================================================
# TARGET RESOLUTION
# ==============================================================================
declare -a REPO_NAMES=(); declare -A REPO_DIR=()
if [ "$CANARY" -eq 1 ]; then
FIXTURE_ROOT="$HERE/fixtures/doc-drift"
[ -d "$FIXTURE_ROOT" ] || die "canary fixture missing: $FIXTURE_ROOT"
# Pin the exception lists + thresholds the fixtures were authored against, so the canary is
# self-contained and deterministic regardless of the operator's env.
DOCS_ONLY_REPOS=""
DOC_DRIFT_STALE_DAYS=60
DOC_DRIFT_STALE_COMMITS=3
# Fixtures ship their git metadata as `dotgit/` (not `.git/`) so they are committable into THIS
# repo without becoming nested submodules. Materialize them into a temp work area — copy each
# fixture and rename dotgit -> .git — so the README/git-log checks run against a real git
# checkout. The temp area is mode 700 and removed on exit (same trick as compliance-drift.sh).
FIXTURE_WORK="$(mktemp -d "${TMPDIR:-/tmp}/doc-drift-canary.XXXXXX")"
trap 'rm -rf "$FIXTURE_WORK"' EXIT
log "canary: materializing planted-drift fixtures from $FIXTURE_ROOT into $FIXTURE_WORK"
for d in "$FIXTURE_ROOT"/*/; do
[ -d "$d/dotgit" ] || continue # only fixture repos (skip README.md, EXPECTED_* etc.)
nm="$(basename "$d")"
cp -R "$d" "$FIXTURE_WORK/$nm"
mv "$FIXTURE_WORK/$nm/dotgit" "$FIXTURE_WORK/$nm/.git"
REPO_NAMES+=( "$nm" ); REPO_DIR["$nm"]="$FIXTURE_WORK/$nm"
done
elif [ -n "$TARGETS_OVERRIDE" ]; then
# shellcheck disable=SC2206 # intentional word-split of the space-separated --targets list
arr=( $TARGETS_OVERRIDE )
for p in "${arr[@]}"; do p="${p/#\~/$HOME}"; nm="$(basename "$p")"; REPO_NAMES+=( "$nm" ); REPO_DIR["$nm"]="$p"; done
log "explicit targets: ${REPO_NAMES[*]}"
else
if [ "$REFRESH" -eq 1 ]; then
[ -n "${GH_TOKEN:-}" ] || die "--refresh needs GH_TOKEN"
command -v curl >/dev/null || die "--refresh needs curl"
mkdir -p "$MIRROR_DIR"
log "refresh: re-discovering + mirroring via shared substrate (no separate clone path)"
DISCOVERED="$REPORT_DIR/discovered.tsv"
if discover_repos > "$DISCOVERED" 2>>"$REPORT_DIR/discover.log" && [ -s "$DISCOVERED" ]; then
while IFS=$'\t' read -r name url branch; do
[ -n "$name" ] || continue
mirror_repo "$name" "$url" "$branch" || log " mirror FAILED: $name (will use stale mirror if present)"
done < "$DISCOVERED"
else
log "discovery failed — falling back to existing mirrors (coverage may be stale)"
fi
fi
# Default + post-refresh: enumerate EXISTING mirrors. No clone here — reuse the sweep's clones.
[ -d "$MIRROR_DIR" ] || die "mirror dir not found: $MIRROR_DIR (run nightly_sweep.sh first, or use --refresh/--targets)"
for d in "$MIRROR_DIR"/*/; do
[ -d "$d/.git" ] || continue
nm="$(basename "$d")"; REPO_NAMES+=( "$nm" ); REPO_DIR["$nm"]="${d%/}"
done
log "reusing ${#REPO_NAMES[@]} existing mirror(s) in $MIRROR_DIR (no re-clone)"
fi
[ "${#REPO_NAMES[@]}" -gt 0 ] || die "no repos to scan"
# ==============================================================================
# RUN CHECKS
# ==============================================================================
for nm in "${REPO_NAMES[@]}"; do
check_repo "$nm" "${REPO_DIR[$nm]}"
done
# ==============================================================================
# ASSEMBLE REPORT (JSON + text), mode 600 (identical shape to compliance-drift)
# ==============================================================================
if [ "${#FINDINGS[@]}" -gt 0 ]; then
FINDINGS_JSON="$(printf '%s\n' "${FINDINGS[@]}" | jq -cs .)"
else
FINDINGS_JSON="[]"
fi
if [ "${#SKIPPED_CHECKS[@]}" -gt 0 ]; then
SKIPPED_JSON="$(printf '%s\n' "${SKIPPED_CHECKS[@]}" | jq -R . | jq -cs .)"
else
SKIPPED_JSON="[]"
fi
N_DRIFT="$(echo "$FINDINGS_JSON" | jq 'length')"
N_HIGH="$(echo "$FINDINGS_JSON" | jq '[.[]|select(.severity=="high")] | length')"
N_REPOS_DRIFTED="$(echo "$FINDINGS_JSON" | jq '[.[].repo] | unique | length')"
jq -n \
--arg checker "doc-drift" --arg ts "$UTC_STAMP" --arg org "$GH_ORG" \
--argjson scanned "${#REPO_NAMES[@]}" \
--argjson findings "$FINDINGS_JSON" --argjson skipped "$SKIPPED_JSON" \
'{checker:$checker, generated:$ts, org:$org,
repos_scanned:$scanned, drift_count:($findings|length),
repos_with_drift:([$findings[].repo]|unique|length),
findings:$findings, skipped_checks:$skipped}' > "$REPORT_JSON"
{
echo "doc-drift report — $UTC_STAMP"
echo "org=$GH_ORG repos_scanned=${#REPO_NAMES[@]} stale_thresholds=${DOC_DRIFT_STALE_DAYS}d/${DOC_DRIFT_STALE_COMMITS}commits"
echo "drift findings: $N_DRIFT ($N_HIGH high) across $N_REPOS_DRIFTED repo(s)"
echo
echo "$FINDINGS_JSON" | jq -r '.[] | "• [\(.severity)] \(.repo): \(.title)\n rule: \(.proof.outcome)"'
if [ "$(echo "$SKIPPED_JSON" | jq 'length')" -gt 0 ]; then
echo; echo "skipped checks (missing data / not doc-drift's job — NOT counted as drift):"
echo "$SKIPPED_JSON" | jq -r '.[] | " - \(.)"'
fi
} > "$REPORT_TXT"
chmod 600 "$REPORT_JSON" "$REPORT_TXT" 2>/dev/null || true
log "report: $REPORT_JSON ($N_DRIFT drift finding(s), $N_REPOS_DRIFTED repo(s))"
# ==============================================================================
# CANARY ASSERTION (anti-complacency floor, design §6.4)
# ==============================================================================
if [ "$CANARY" -eq 1 ]; then
EXPECT_FILE="$HERE/fixtures/doc-drift/EXPECTED_DRIFT_COUNT"
[ -f "$EXPECT_FILE" ] || die "canary expected-count file missing: $EXPECT_FILE"
EXPECTED="$(tr -dc '0-9' < "$EXPECT_FILE")"
log "canary assertion: expected drift=$EXPECTED, got=$N_DRIFT"
if [ "$N_DRIFT" -ne "$EXPECTED" ]; then
echo "[doc-drift] CANARY FAIL: planted-drift count mismatch (expected $EXPECTED, got $N_DRIFT)" >&2
echo " -> the checklist regressed (a check stopped firing) or the fixture changed. See $REPORT_TXT." >&2
exit 3
fi
log "canary PASS: all $EXPECTED planted drifts detected."
fi
# ==============================================================================
# ALARM-ONLY ROUTING (clean = silent; memory feedback_cloudwatch_alarms)
# ==============================================================================
if [ "$N_DRIFT" -eq 0 ]; then
log "no confirmed drift — posting NOTHING to Slack (ALARM-only policy)."
exit 0
fi
ALARM_BODY="$(echo "$FINDINGS_JSON" | jq -r '
group_by(.repo)[] | "*\(.[0].repo)*: " + ([.[] | "[\(.severity)] \(.title)"] | join("; "))' | sed 's/^/• /')"
SLACK_TEXT=":memo: *Sea Haven doc-drift — ALARM* ($UTC_STAMP)
$N_DRIFT documentation-drift finding(s) across $N_REPOS_DRIFTED repo(s) ($N_HIGH high):
$ALARM_BODY
Checks: README-omits-component · README-stale-vs-code (architecture moved, docs did not)
Report (mode 600): \`$REPORT_JSON\` (on R720)"
SLACK_TEXT="$(echo "$SLACK_TEXT" | redact)"
echo "$SLACK_TEXT" >&2
if [ "$DRY_RUN" -eq 1 ]; then
log "DRY-RUN: alarm composed but NOT posted (routing dry-run, design §7 Phase 3)."
exit 0
fi
post_slack_alarm "$SLACK_TEXT"
exit 0
# ==============================================================================
# PROVISIONING (NOT DONE HERE — gated, Phase 3 / Phase 6):
# - No systemd unit / timer is installed by this script. Wiring it into the live
# sea-haven-secrev schedule (or a sibling timer) is provisioning and is gated.
# - This script is NOT registered in checker_coordinator.sh; the coordinator registry is
# integrated centrally (separate change), so doc-drift is not yet driven by the coordinator.
# - step-ca / IAM Roles Anywhere / the read-only AWS role / aws-posture are NOT stood up or
# built here. The IAM artifacts authored alongside this checker (security-review/iam/) are
# FILES for the mandatory GPT-4.1 cross-review; aws-posture itself is hard-gated behind that
# review and is built only after it is recorded (design §7, B3).
# - The LIVE "Gemini (large context)" doc-drift judge (design §4) is the only LLM seam; it is
# an inert stub here (maybe_judge) and stays off in canary / dry-run / offline.
# - Confluence + project_r720_agent_team memory updates are docs-as-you-go obligations for the
# build session, tracked outside this script.
# ==============================================================================