#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AGENT_OPS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$AGENT_OPS_DIR/.." && pwd)" WORKSPACE_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)" LOCKS_FILE="${AGENT_ROADMAP_LOCKS_FILE:-$WORKSPACE_ROOT/.agent-roadmap-sync/locks.yaml}" usage() { cat <<'EOF' Usage: roadmap-dependency-checker.sh [locks.yaml] roadmap-dependency-checker.sh --find-milestone [locked|rely-on|both] [locks.yaml] Validates locked, rely-on.target, and rely-on.status for . Prints true when every rely-on.status is enable. Prints false when at least one rely-on.status is disable. With --find-milestone, prints matching lock ids as locked: or rely-on:, or none when the milestone identity is not referenced. Exit codes: 0 all dependencies are enabled 1 at least one dependency is still disabled 2 configuration, input, or parse error For --find-milestone, exit code 0 means the lookup completed; stdout is either matching entries or none. EOF } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi MODE="check" LOCK_ID="" FIND_IDENTITY="" FIND_DIRECTION="both" if [[ "${1:-}" == "--find-milestone" ]]; then MODE="find" if [[ $# -lt 2 || $# -gt 4 ]]; then usage >&2 exit 2 fi FIND_IDENTITY="$2" if [[ -z "$FIND_IDENTITY" ]]; then echo "error: milestone identity is required" >&2 exit 2 fi if [[ $# -ge 3 ]]; then case "$3" in locked|rely-on|both) FIND_DIRECTION="$3" if [[ $# -eq 4 ]]; then LOCKS_FILE="$4" fi ;; *) if [[ $# -eq 3 ]]; then LOCKS_FILE="$3" else usage >&2 exit 2 fi ;; esac fi elif [[ $# -lt 1 || $# -gt 2 ]]; then usage >&2 exit 2 else LOCK_ID="$1" if [[ -n "${2:-}" ]]; then LOCKS_FILE="$2" fi fi if [[ "$MODE" == "check" && -z "$LOCK_ID" ]]; then echo "error: lock id is required" >&2 exit 2 fi if [[ ! -r "$LOCKS_FILE" ]]; then echo "error: locks file not readable: $LOCKS_FILE" >&2 exit 2 fi if [[ "$MODE" == "find" ]]; then awk -v identity="$FIND_IDENTITY" -v direction="$FIND_DIRECTION" ' function trim(value) { gsub(/\r/, "", value) sub(/^[[:space:]]+/, "", value) sub(/[[:space:]]+$/, "", value) return value } function unquote(value) { value = trim(value) sub(/[[:space:]]+#.*$/, "", value) value = trim(value) if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { return substr(value, 2, length(value) - 2) } return trim(value) } function reset_entry() { entry_id = "" locked = "" in_rely_on = 0 rely_count = 0 delete rely_targets } function finish_entry( i) { if (entry_id == "") { return } if ((direction == "both" || direction == "locked") && locked == identity) { print "locked:" entry_id found = 1 } if (direction == "both" || direction == "rely-on") { for (i = 1; i <= rely_count; i++) { if (rely_targets[i] == identity) { print "rely-on:" entry_id found = 1 break } } } } BEGIN { found = 0 reset_entry() } /^[[:space:]]*($|#)/ { next } /^[[:space:]]*-[[:space:]]*id:[[:space:]]*/ { finish_entry() reset_entry() value = $0 sub(/^[[:space:]]*-[[:space:]]*id:[[:space:]]*/, "", value) entry_id = unquote(value) next } entry_id != "" && /^[[:space:]]*locked:[[:space:]]*/ { value = $0 sub(/^[[:space:]]*locked:[[:space:]]*/, "", value) locked = unquote(value) next } entry_id != "" && /^[[:space:]]*rely-on:[[:space:]]*/ { in_rely_on = 1 next } entry_id != "" && in_rely_on && /^[[:space:]]*-[[:space:]]*target:[[:space:]]*/ { value = $0 sub(/^[[:space:]]*-[[:space:]]*target:[[:space:]]*/, "", value) rely_targets[++rely_count] = unquote(value) next } entry_id != "" && in_rely_on && /^[[:space:]]*target:[[:space:]]*/ { value = $0 sub(/^[[:space:]]*target:[[:space:]]*/, "", value) rely_targets[++rely_count] = unquote(value) next } END { finish_entry() if (!found) { print "none" } } ' "$LOCKS_FILE" exit 0 fi awk -v wanted="$LOCK_ID" ' function trim(value) { gsub(/\r/, "", value) sub(/^[[:space:]]+/, "", value) sub(/[[:space:]]+$/, "", value) return value } function unquote(value) { value = trim(value) sub(/[[:space:]]+#.*$/, "", value) value = trim(value) if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { return substr(value, 2, length(value) - 2) } return trim(value) } function fail(message) { error = message printf "error: %s\n", error > "/dev/stderr" exit 2 } function finish_dependency() { if (!selected || !in_dependency) { return } if (!dependency_has_target) { fail("missing rely-on.target for lock id: " wanted) } if (!dependency_has_status) { fail("missing rely-on.status for lock id: " wanted) } in_dependency = 0 } function finish_entry() { if (!selected) { return } finish_dependency() if (!entry_has_locked) { fail("missing locked for lock id: " wanted) } if (total == 0) { fail("no rely-on.status entries for lock id: " wanted) } selected = 0 } BEGIN { selected = 0 matches = 0 total = 0 enabled = 0 error = "" entry_has_locked = 0 in_rely_on = 0 in_dependency = 0 dependency_has_target = 0 dependency_has_status = 0 } /^[[:space:]]*($|#)/ { next } /^[[:space:]]*-[[:space:]]*id:[[:space:]]*/ { finish_entry() value = $0 sub(/^[[:space:]]*-[[:space:]]*id:[[:space:]]*/, "", value) id = unquote(value) if (id == wanted) { if (matches > 0) { fail("duplicate lock id: " wanted) } selected = 1 matches++ total = 0 enabled = 0 entry_has_locked = 0 in_rely_on = 0 in_dependency = 0 dependency_has_target = 0 dependency_has_status = 0 } next } selected && /^[[:space:]]*locked:[[:space:]]*/ { value = $0 sub(/^[[:space:]]*locked:[[:space:]]*/, "", value) locked = unquote(value) if (locked == "") { fail("empty locked for lock id: " wanted) } entry_has_locked = 1 next } selected && /^[[:space:]]*rely-on:[[:space:]]*/ { in_rely_on = 1 next } selected && in_rely_on && /^[[:space:]]*-[[:space:]]*target:[[:space:]]*/ { finish_dependency() in_dependency = 1 dependency_has_target = 0 dependency_has_status = 0 value = $0 sub(/^[[:space:]]*-[[:space:]]*target:[[:space:]]*/, "", value) target = unquote(value) if (target == "") { fail("empty rely-on.target for lock id: " wanted) } dependency_has_target = 1 next } selected && in_rely_on && /^[[:space:]]*target:[[:space:]]*/ { if (!in_dependency) { fail("rely-on.target outside dependency item for lock id: " wanted) } value = $0 sub(/^[[:space:]]*target:[[:space:]]*/, "", value) target = unquote(value) if (target == "") { fail("empty rely-on.target for lock id: " wanted) } dependency_has_target = 1 next } selected && in_rely_on && /^[[:space:]]*status:[[:space:]]*/ { if (!in_dependency) { fail("rely-on.status outside dependency item for lock id: " wanted) } value = $0 sub(/^[[:space:]]*status:[[:space:]]*/, "", value) status = unquote(value) if (status != "enable" && status != "disable") { fail("invalid rely-on.status for " wanted ": " status) } if (dependency_has_status) { fail("duplicate rely-on.status for lock id: " wanted) } dependency_has_status = 1 total++ if (status == "enable") { enabled++ } next } selected && in_rely_on && /^[[:space:]]*-[[:space:]]*/ { finish_dependency() fail("missing rely-on.target for lock id: " wanted) } selected && /^[[:space:]]*status:[[:space:]]*/ { fail("status outside rely-on for lock id: " wanted) } END { if (error != "") { exit 2 } if (matches == 0) { printf "error: lock id not found: %s\n", wanted > "/dev/stderr" exit 2 } finish_entry() if (total == enabled) { print "true" exit 0 } print "false" exit 1 } ' "$LOCKS_FILE"