Template
Apply answers to questions-v1.md (Q1–Q5):
- Q4: Remove crush.json and hooks/ entirely. All enforcement is now portable
via git hooks (pre-commit/pre-push) + check-rules.sh + AGENTS.md prose.
Works under Crush, OpenWebUI, Hermes, or any agent framework.
- Q5: Remove docs/JOURNAL.md. Redmine is the system of record for work;
Discourse for docs. JOURNAL.md was a stopgap.
- Q3: Add mandatory Discourse pointer-header check to check-rules.sh. Any
non-exempt tracked .md without a Discourse URL FAILs. All projects, no
exceptions.
- Q2: Reference real CLI container invocation paths
(KNEL-AIMiddleware/{redmine,discourse}-cli/) in AGENTS.md instead of the
missing bin/ shortcuts.
- Q1: Note tea + docker login are preconfigured on TSYS workstations.
Also: make test default is now no-op pass so the template self-validates;
make validate now passes clean on the repo itself (17 PASS / 0 FAIL).
💘 Generated with Crush
Assisted-by: Crush via Crush <crush@charm.land>
246 lines
11 KiB
Bash
Executable File
246 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# check-rules.sh — project rule audit engine.
|
|
#
|
|
# Usage:
|
|
# bash scripts/check-rules.sh # full audit (verbose, includes slow checks)
|
|
# bash scripts/check-rules.sh --fast # fast audit (quiet, skips slow checks) — for pre-commit
|
|
# bash scripts/check-rules.sh --quiet # full audit, only prints failures
|
|
#
|
|
# Exit code: 0 = all rules pass (warnings are non-fatal), 1 = one or more FAILED.
|
|
#
|
|
# This is a generalized version of the rules engine proven in the
|
|
# RCEO-PersonalAssistant project. Add project-specific checks by appending
|
|
# `check "<desc>" "<pass|warn|fail>"` calls below.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
# shellcheck disable=SC1091
|
|
source "$HERE/lib/common.sh"
|
|
REPO_ROOT="$(repo_root)"
|
|
cd "$REPO_ROOT"
|
|
|
|
# --- argument parsing ---
|
|
RULE_FAST=false
|
|
RULE_VERBOSE=true
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--fast) RULE_FAST=true; RULE_VERBOSE=false ;;
|
|
--quiet) RULE_VERBOSE=false ;;
|
|
*) die "check-rules.sh: unknown argument '$arg'" ;;
|
|
esac
|
|
done
|
|
export RULE_FAST RULE_VERBOSE
|
|
|
|
init_counters
|
|
$RULE_VERBOSE && echo "=== Project Rule Audit ==="
|
|
|
|
TODAY="$(date +%Y-%m-%d)"
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 1. Shellcheck — every .sh/.bash must pass (zero warnings, incl. info-level).
|
|
# Runs in Docker so the host stays clean (no native shellcheck required).
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Shell scripts (shellcheck)"
|
|
mapfile -d '' SH_FILES < <(find . -path ./.git -prune -o -path ./.tmp -prune -o -path ./vendor -prune -o -path ./node_modules -prune -o \( -name '*.sh' -o -name '*.bash' \) -print0 2>/dev/null)
|
|
if [ "${#SH_FILES[@]}" -gt 0 ]; then
|
|
if have shellcheck; then
|
|
if shellcheck "${SH_FILES[@]}" >/dev/null 2>&1; then
|
|
check "All shell scripts pass shellcheck (host)" "pass"
|
|
else
|
|
check "shellcheck reports violations — run: shellcheck <file>" "fail"
|
|
fi
|
|
elif have docker; then
|
|
MNT_FILES=()
|
|
for f in "${SH_FILES[@]}"; do MNT_FILES+=("/mnt/${f#./}"); done
|
|
if docker run --rm -v "$REPO_ROOT:/mnt" koalaman/shellcheck:stable "${MNT_FILES[@]}" >/dev/null 2>&1; then
|
|
check "All shell scripts pass shellcheck (docker)" "pass"
|
|
else
|
|
check "shellcheck (docker) reports violations" "fail"
|
|
fi
|
|
else
|
|
check "No shellcheck or docker available to lint scripts" "warn"
|
|
fi
|
|
else
|
|
check "No shell scripts to lint" "pass"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 2. Docker image pinning — no ':latest' tags in compose or Dockerfiles.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Docker image pinning"
|
|
if grep -rqE '(image:|FROM).*:latest' --include='docker-compose*.y*ml' --include='Dockerfile*' . 2>/dev/null; then
|
|
check "No ':latest' image tags (pin everything)" "fail"
|
|
else
|
|
check "No ':latest' image tags" "pass"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 2b. Container naming — every service in a docker-compose file MUST set an
|
|
# explicit container_name (never rely on Docker's default <dir>_<n>).
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Container naming"
|
|
COMPOSE_FILES="$(find . -path ./.git -prune -o \( -name 'docker-compose*.yml' -o -name 'docker-compose*.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) -print 2>/dev/null || true)"
|
|
if [ -n "$COMPOSE_FILES" ]; then
|
|
BAD=0
|
|
while IFS= read -r cf; do
|
|
[ -n "$cf" ] || continue
|
|
# Count top-level service keys (2-space indent under services:) and
|
|
# compare against the number of container_name: declarations.
|
|
svc_count=$(awk '/^services:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]{2}[[:alnum:]_-]+:[[:space:]]*$/{c++} END{print c+0}' "$cf")
|
|
cn_count=$(grep -cE '^[[:space:]]*container_name:' "$cf" 2>/dev/null || echo 0)
|
|
if [ "${svc_count:-0}" -gt 0 ] && [ "$cn_count" -lt "$svc_count" ]; then
|
|
BAD=$((BAD + 1))
|
|
fi
|
|
done <<EOF
|
|
$COMPOSE_FILES
|
|
EOF
|
|
if [ "$BAD" -eq 0 ]; then
|
|
check "All compose services set container_name" "pass"
|
|
else
|
|
check "$BAD compose file(s) with services missing container_name" "fail"
|
|
fi
|
|
else
|
|
check "No compose files (container-name check skipped)" "pass"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 3. Required-files manifest — the files every project using this template owns.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Required files"
|
|
REQUIRED_FILES="AGENTS.md STATUS.md questions-v1.md .env.example Makefile scripts/check-rules.sh scripts/setup-hooks.sh"
|
|
REQUIRED_FILES="$REQUIRED_FILES ${PROJECT_REQUIRED_FILES:-}"
|
|
for f in $REQUIRED_FILES; do
|
|
if [ -f "$f" ]; then check "$f exists" "pass"; else check "$f MISSING" "fail"; fi
|
|
done
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 4. Doc freshness — STATUS.md touched today.
|
|
# Warning (not failure): staleness is a signal, not a break.
|
|
# Redmine is the system of record for work; Discourse for docs. STATUS.md is
|
|
# a scratchpad only — see BASELINE-PROMPT.md §3, §8.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Doc freshness"
|
|
if [ -f STATUS.md ]; then
|
|
STATUS_DATE="$(grep -oE 'Last updated: [0-9]{4}-[0-9]{2}-[0-9]{2}' STATUS.md | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' || echo unknown)"
|
|
if [ "$STATUS_DATE" = "$TODAY" ]; then
|
|
check "STATUS.md updated today ($STATUS_DATE)" "pass"
|
|
else
|
|
check "STATUS.md is stale (last: $STATUS_DATE, today: $TODAY) — update it" "warn"
|
|
fi
|
|
else
|
|
check "STATUS.md MISSING" "fail"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 4b. Discourse pointer-header policy (MANDATORY).
|
|
# Discourse is the system of record for documentation. In-repo .md files are
|
|
# stubs that point to a Discourse topic URL. Operational files exempt.
|
|
# Override exemptions via PROJECT_DOC_EXEMPT (space-separated globs of
|
|
# basenames) and the Discourse host via PROJECT_DISCOURSE_HOST.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Discourse pointer-header"
|
|
DISCOURSE_HOST="${PROJECT_DISCOURSE_HOST:-community.turnsys.com}"
|
|
DOC_EXEMPT="${PROJECT_DOC_EXEMPT:-AGENTS.md STATUS.md WORKING.md README.md LICENSE .env.example questions-v*.md BASELINE-PROMPT.md PATTERNS.md}"
|
|
POINTER_MISSING=0
|
|
while IFS= read -r -d '' f; do
|
|
base="$(basename "$f")"
|
|
exempt=false
|
|
for pat in $DOC_EXEMPT; do
|
|
# shellcheck disable=SC2254
|
|
case "$base" in $pat) exempt=true; break ;; esac
|
|
done
|
|
[ "$exempt" = true ] && continue
|
|
if ! grep -qF "$DISCOURSE_HOST" "$f" 2>/dev/null; then
|
|
if [ "$POINTER_MISSING" -eq 0 ]; then
|
|
$RULE_VERBOSE && printf ' %s\n' "Missing $DISCOURSE_HOST URL in:"
|
|
fi
|
|
POINTER_MISSING=$((POINTER_MISSING + 1))
|
|
$RULE_VERBOSE && printf ' %s\n' "$f"
|
|
fi
|
|
done < <(find . -path ./.git -prune -o -path ./.tmp -prune -o -name '*.md' -print0 2>/dev/null)
|
|
if [ "$POINTER_MISSING" -eq 0 ]; then
|
|
check "All non-exempt .md cite Discourse ($DISCOURSE_HOST)" "pass"
|
|
else
|
|
check "$POINTER_MISSING .md file(s) missing Discourse pointer (see BASELINE-PROMPT.md §3)" "fail"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 5. Git state — uncommitted changes are a warning (the pre-push hook hardens
|
|
# this where it matters).
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Git state"
|
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
if git diff --quiet && git diff --cached --quiet; then
|
|
check "Working tree clean" "pass"
|
|
else
|
|
check "Uncommitted changes present" "warn"
|
|
fi
|
|
else
|
|
check "Not a git repo (git checks skipped)" "pass"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 6. Hooks installed — self-check that git hooks were set up.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Git hooks"
|
|
if [ -f .git/hooks/pre-commit ]; then
|
|
check "pre-commit hook installed" "pass"
|
|
else
|
|
check "pre-commit NOT installed (run: bash scripts/setup-hooks.sh)" "warn"
|
|
fi
|
|
if [ -f .git/hooks/pre-push ]; then
|
|
check "pre-push hook installed" "pass"
|
|
else
|
|
check "pre-push NOT installed (run: bash scripts/setup-hooks.sh)" "warn"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 7. WORKING.md completion — no unchecked tasks may remain at commit time.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Task completion"
|
|
if [ -f WORKING.md ]; then
|
|
UNCHECKED="$(grep -cF -- '- [ ]' WORKING.md || true)"
|
|
if [ "$UNCHECKED" -eq 0 ]; then
|
|
check "WORKING.md has no unchecked tasks" "pass"
|
|
else
|
|
check "WORKING.md has ${UNCHECKED} unchecked task(s) — finish them before committing" "fail"
|
|
fi
|
|
else
|
|
check "WORKING.md absent (no active task tracker)" "pass"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 8. CNW markers — empty `CNW:` markers flag unresolved questions for the human.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "Unresolved questions"
|
|
EMPTY_CNW="$(grep -rn 'CNW:$' . --include='*.md' 2>/dev/null | head -20 || true)"
|
|
if [ -z "$EMPTY_CNW" ]; then
|
|
check "No empty CNW: markers (unresolved questions)" "pass"
|
|
else
|
|
CNW_COUNT="$(printf '%s\n' "$EMPTY_CNW" | grep -c . || true)"
|
|
check "${CNW_COUNT} unresolved CNW: marker(s) — needs user input" "warn"
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 9. Hygiene — merge-conflict markers and trailing whitespace must never land.
|
|
# ----------------------------------------------------------------------------
|
|
$RULE_VERBOSE && log_step "File hygiene"
|
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
CONFLICT="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | xargs -r grep -lE '^(<<<<<<<|=======|>>>>>>>)' 2>/dev/null || true)"
|
|
if [ -z "$CONFLICT" ]; then check "No merge-conflict markers staged" "pass"; else check "Merge-conflict markers staged: $CONFLICT" "fail"; fi
|
|
fi
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 10. (slow, skipped in --fast) Project test suite via `make test` if present.
|
|
# ----------------------------------------------------------------------------
|
|
if [ "$RULE_FAST" = false ] && [ -f Makefile ] && grep -qE '^test:' Makefile; then
|
|
$RULE_VERBOSE && log_step "Test suite (make test)"
|
|
if make test >/dev/null 2>&1; then
|
|
check "make test passes" "pass"
|
|
else
|
|
check "make test FAILS" "fail"
|
|
fi
|
|
fi
|
|
|
|
print_summary_and_exit
|