diff --git a/scripts/check-queue-front-matter.sh b/scripts/check-queue-front-matter.sh new file mode 100755 index 0000000000..7c83791976 --- /dev/null +++ b/scripts/check-queue-front-matter.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Consumer-side front-matter validator for file-based markdown handoff queues +# (one item per file with YAML front matter). Detects malformed items that make +# a `grep '^status:'` reconciliation report a false all-clear (#1647). +# +# scripts/check-queue-front-matter.sh +# +# Not a CI gate — the queue lives outside the repository. Invoke at claim time +# when an agent decides whether work is present. +# +# Exit 0 = every item file conforms; 1 = one or more violations; 2 = usage error. +set -euo pipefail + +usage() { + printf 'usage: check-queue-front-matter.sh \n' >&2 + exit 2 +} + +[[ $# -eq 1 ]] || usage +QUEUE_DIR="$1" + +if [[ ! -d "$QUEUE_DIR" ]]; then + printf 'Error: queue directory not found: %s\n' "$QUEUE_DIR" >&2 + exit 2 +fi + +VALID_STATUSES='unclaimed|claimed|in-progress|blocked|done' +VALID_PRIORITIES='low|medium|high|urgent' +REQUIRED_KEYS=(id title status created producer) + +errors=0 +file_count=0 +parsed_count=0 + +report_violation() { + printf 'VIOLATION: %s — %s\n' "$1" "$2" + errors=$((errors + 1)) +} + +# extract_front_matter — prints front matter body or nothing. +extract_front_matter() { + awk ' + NR == 1 && $0 == "---" { in_fm = 1; next } + in_fm && $0 == "---" { exit } + in_fm { print } + ' "$1" +} + +# fm_value +fm_value() { + awk -v key="$2" ' + $1 == key ":" { + sub(/^[^:]*:[[:space:]]*/, "") + print + exit + } + ' <<<"$1" +} + +for item in "$QUEUE_DIR"/*.md; do + [[ -e "$item" ]] || continue + base="$(basename "$item")" + [[ "$base" == README.md ]] && continue + file_count=$((file_count + 1)) + stem="${base%.md}" + fm="$(extract_front_matter "$item")" + if [[ -z "${fm//[[:space:]]/}" ]]; then + report_violation "$item" 'missing or empty YAML front matter' + continue + fi + parsed_count=$((parsed_count + 1)) + + for key in "${REQUIRED_KEYS[@]}"; do + val="$(fm_value "$fm" "$key")" + if [[ -z "${val//[[:space:]]/}" ]]; then + report_violation "$item" "missing required key: $key" + fi + done + + status="$(fm_value "$fm" status)" + if [[ -n "$status" ]]; then + if ! grep -qE "^(${VALID_STATUSES})$" <<<"$status"; then + report_violation "$item" "status '$status' not in documented set (unclaimed|claimed|in-progress|blocked|done)" + fi + fi + + priority="$(fm_value "$fm" priority)" + if [[ -n "${priority//[[:space:]]/}" ]]; then + if ! grep -qE "^(${VALID_PRIORITIES})$" <<<"$priority"; then + report_violation "$item" "priority '$priority' not in documented set (low|medium|high|urgent)" + fi + fi + + id="$(fm_value "$fm" id)" + if [[ -n "$id" && "$id" != "$stem" ]]; then + report_violation "$item" "id '$id' does not match filename stem '$stem'" + fi +done + +printf 'Reconciliation: %d item file(s), %d with parseable front matter\n' \ + "$file_count" "$parsed_count" + +if ((file_count != parsed_count)); then + report_violation "$QUEUE_DIR" \ + "count gap — $((file_count - parsed_count)) file(s) lack parseable front matter (never reconcile by status grep alone)" +fi + +if ((errors > 0)); then + printf '\n%d violation(s).\n' "$errors" >&2 + exit 1 +fi + +echo "Queue front matter OK." +exit 0 diff --git a/scripts/check-queue-front-matter.test.sh b/scripts/check-queue-front-matter.test.sh new file mode 100755 index 0000000000..3fe66ce51e --- /dev/null +++ b/scripts/check-queue-front-matter.test.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Unit tests for check-queue-front-matter.sh. +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SELF_DIR/check-queue-front-matter.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +new_queue() { + mktemp -d +} + +run_check() ( + bash "$SCRIPT" "$1" +) + +# --- valid item passes ------------------------------------------------------- +q="$(new_queue)" +cat >"$q/20260812-sample.md" <<'EOF' +--- +id: 20260812-sample +title: Sample item +status: unclaimed +created: 2026-08-12T12:00:00Z +producer: test-fixture +--- +Body +EOF +if run_check "$q" >/dev/null 2>&1; then + ok "valid item passes" +else + fail "valid item should pass" +fi + +# --- missing front matter fails ---------------------------------------------- +q="$(new_queue)" +printf 'No front matter here\n' >"$q/20260812-bad.md" +if run_check "$q" >/dev/null 2>&1; then + fail "missing front matter should fail" +else + ok "missing front matter fails" +fi + +# --- invalid status fails ---------------------------------------------------- +q="$(new_queue)" +cat >"$q/20260812-open.md" <<'EOF' +--- +id: 20260812-open +title: Bad status +status: open +created: 2026-08-12T12:00:00Z +producer: test-fixture +--- +EOF +if run_check "$q" >/dev/null 2>&1; then + fail "invalid status should fail" +else + ok "invalid status fails" +fi + +# --- id stem mismatch fails -------------------------------------------------- +q="$(new_queue)" +cat >"$q/20260812-wrong.md" <<'EOF' +--- +id: other-id +title: Mismatch +status: unclaimed +created: 2026-08-12T12:00:00Z +producer: test-fixture +--- +EOF +if run_check "$q" >/dev/null 2>&1; then + fail "id/filename mismatch should fail" +else + ok "id stem mismatch fails" +fi + +# --- README.md is ignored ---------------------------------------------------- +q="$(new_queue)" +printf '# readme\n' >"$q/README.md" +cat >"$q/20260812-only.md" <<'EOF' +--- +id: 20260812-only +title: Only item +status: done +created: 2026-08-12T12:00:00Z +producer: test-fixture +--- +EOF +if run_check "$q" >/dev/null 2>&1; then + ok "README.md ignored" +else + fail "README should be ignored: valid sole item should pass" +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]]