check-divisions.sh 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. #!/usr/bin/env bash
  2. #
  3. # check-divisions.sh — enforce a single source of truth for the division set.
  4. #
  5. # divisions.json (repo root) is canonical. This script fails if any of the
  6. # following disagree with it:
  7. # 1. The actual top-level agent directories on disk
  8. # 2. AGENT_DIRS in scripts/convert.sh
  9. # 3. AGENT_DIRS in scripts/lint-agents.sh
  10. # 4. The path filters in .github/workflows/lint-agents.yml
  11. # 5. Every divisions.json entry has label, icon, and color
  12. #
  13. # Add a division: create its directory, add an entry to divisions.json, then
  14. # this script tells you every other place that must be updated. No deps beyond
  15. # bash 3.2 + coreutils (no jq) so it runs the same on macOS and CI.
  16. #
  17. # Usage: ./scripts/check-divisions.sh
  18. set -euo pipefail
  19. cd "$(dirname "$0")/.."
  20. JSON="divisions.json"
  21. # Top-level directories that are NOT divisions. Everything else at the repo
  22. # root that is a directory is treated as a division (so a new division dir is
  23. # caught even if nobody remembered to register it).
  24. # integrations/ is convert.sh's OUTPUT tree (per-tool conversions written back
  25. # into the repo), not a source-agent category. strategy/ holds playbooks and
  26. # runbooks (no agent frontmatter), not agents. Neither is a division — they must
  27. # never be scanned as source-agent categories.
  28. NON_DIVISION_DIRS=(examples scripts integrations strategy)
  29. errors=0
  30. fail() { echo "ERROR $*"; errors=$((errors + 1)); }
  31. # --- sorted, newline-delimited helpers -------------------------------------
  32. # Canonical set: object-valued keys inside the "divisions" object. Scoping to
  33. # lines after the `"divisions": {` opener excludes both the wrapper key itself
  34. # and the string-valued "_note" key.
  35. canonical() {
  36. awk '/"divisions"[[:space:]]*:[[:space:]]*\{/{f=1; next} f' "$JSON" \
  37. | grep -oE '"[a-z0-9-]+"[[:space:]]*:[[:space:]]*\{' \
  38. | sed -E 's/"([a-z0-9-]+)".*/\1/' | sort -u
  39. }
  40. # Actual division directories: top-level dirs that contain at least one
  41. # git-TRACKED file, minus the excludes and anything dot-prefixed. Using
  42. # `git ls-files` (not a filesystem glob) keeps this in lockstep with what CI's
  43. # clean checkout sees, so a local gitignored scratch dir (e.g. notes/) can't
  44. # produce a false failure.
  45. actual_dirs() {
  46. local base
  47. git ls-files | awk -F/ 'NF>1{print $1}' | sort -u | while IFS= read -r base; do
  48. [[ "$base" == .* ]] && continue
  49. case " ${NON_DIVISION_DIRS[*]} " in *" $base "*) continue ;; esac
  50. echo "$base"
  51. done
  52. }
  53. # Contents of a bash AGENT_DIRS=( ... ) array in the given file, one per line.
  54. agent_dirs_array() {
  55. awk '/AGENT_DIRS=\(/{f=1; next} f && /^\)/{exit} f{print}' "$1" \
  56. | tr ' \t' '\n\n' | grep -E '^[a-z0-9-]+$' | sort -u
  57. }
  58. # Compare canonical vs a candidate set; report both directions.
  59. compare() {
  60. local label="$1" candidate="$2" canon
  61. canon="$(canonical)"
  62. local missing extra
  63. missing="$(comm -23 <(echo "$canon") <(echo "$candidate"))"
  64. extra="$(comm -13 <(echo "$canon") <(echo "$candidate"))"
  65. if [[ -n "$missing" ]]; then
  66. fail "$label is missing division(s) present in $JSON: $(echo "$missing" | tr '\n' ' ')"
  67. fi
  68. if [[ -n "$extra" ]]; then
  69. fail "$label has division(s) not in $JSON: $(echo "$extra" | tr '\n' ' ')"
  70. fi
  71. }
  72. # --- checks ----------------------------------------------------------------
  73. [[ -f "$JSON" ]] || { echo "ERROR $JSON not found at repo root"; exit 1; }
  74. compare "the agent directories on disk" "$(actual_dirs)"
  75. compare "scripts/convert.sh AGENT_DIRS" "$(agent_dirs_array scripts/convert.sh)"
  76. compare "scripts/lint-agents.sh AGENT_DIRS" "$(agent_dirs_array scripts/lint-agents.sh)"
  77. # Workflow path filters: every canonical division must appear as `<div>/` in
  78. # the lint workflow, or new divisions silently skip CI.
  79. WF=".github/workflows/lint-agents.yml"
  80. if [[ -f "$WF" ]]; then
  81. while IFS= read -r div; do
  82. grep -qE "\b${div}/" "$WF" || fail "$WF has no path filter for division '$div'"
  83. done < <(canonical)
  84. else
  85. fail "$WF not found"
  86. fi
  87. # Every entry must have label, icon, and color.
  88. while IFS= read -r div; do
  89. block="$(awk -v d="\"$div\"" '$0 ~ d"[[:space:]]*:[[:space:]]*\\{" {print; found=1; next} found && /\}/ {print; exit} found {print}' "$JSON")"
  90. for field in label icon color; do
  91. echo "$block" | grep -qE "\"$field\"[[:space:]]*:" \
  92. || fail "division '$div' in $JSON is missing \"$field\""
  93. done
  94. done < <(canonical)
  95. # Every division must contain at least one agent file: a .md whose first line is
  96. # '---' frontmatter. This is the content-derived backstop that keeps a docs or
  97. # playbook directory (e.g. strategy/, all of whose files are frontmatter-less)
  98. # from being registered as an empty agent division.
  99. has_agent_file() {
  100. local f first
  101. while IFS= read -r f; do
  102. first="$(head -1 "$f" | tr -d '\r')"
  103. [[ "$first" == "---" ]] && return 0
  104. done < <(find "$1" -name '*.md' -type f 2>/dev/null)
  105. return 1
  106. }
  107. while IFS= read -r div; do
  108. if [[ ! -d "$div" ]]; then
  109. fail "division '$div' has no directory on disk"
  110. elif ! has_agent_file "$div"; then
  111. fail "division '$div' has no agent files (.md with '---' frontmatter) — not a real division"
  112. fi
  113. done < <(canonical)
  114. # --- result ----------------------------------------------------------------
  115. count="$(canonical | wc -l | tr -d ' ')"
  116. if [[ $errors -gt 0 ]]; then
  117. echo ""
  118. echo "FAILED: $errors divisions consistency error(s). $JSON is the source of truth."
  119. exit 1
  120. fi
  121. echo "PASSED: $count divisions consistent across $JSON, directories, scripts, and CI."