doc-drift.sh 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. #!/usr/bin/env bash
  2. # Doc-drift gate: documentation must describe what is actually on disk.
  3. #
  4. # Checks:
  5. # 1. Component counts on disk vs claims in README.md header, AGENTS.md
  6. # overview bullets, docs/PLAN.md inventory table, and selected README prose
  7. # 2. Every skill directory has a row in a README skill table
  8. # 3. Every repo-relative markdown link in README.md / AGENTS.md resolves
  9. # to an existing file or directory (no ghost references)
  10. # 4. Skill frontmatter references only skills that exist on disk
  11. #
  12. # Exit 0 = clean, exit 1 = drift detected.
  13. set -u
  14. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
  15. cd "$ROOT" || exit 1
  16. errors=0
  17. err() { echo "DRIFT: $*"; errors=$((errors + 1)); }
  18. # --- 1. Counts on disk ------------------------------------------------------
  19. skills_disk=0
  20. for d in skills/*/; do
  21. [ -f "$d/SKILL.md" ] && skills_disk=$((skills_disk + 1))
  22. done
  23. agents_disk=$(find agents -maxdepth 1 -name '*.md' | wc -l)
  24. hooks_disk=$(find hooks -maxdepth 1 -name '*.sh' | wc -l)
  25. rules_disk=$(find rules -maxdepth 1 -name '*.md' | wc -l)
  26. styles_disk=$(find output-styles -maxdepth 1 -name '*.md' | wc -l)
  27. commands_disk=$(find commands -maxdepth 1 -name '*.md' | wc -l)
  28. echo "Disk: agents=$agents_disk skills=$skills_disk styles=$styles_disk hooks=$hooks_disk rules=$rules_disk commands=$commands_disk"
  29. # --- README header claim: "**N agents. N skills. N styles. N hooks. N rules. ...**"
  30. header="$(grep -oE '\*\*[0-9]+ agents\. [0-9]+ skills\. [0-9]+ styles\. [0-9]+ hooks\. [0-9]+ rules\.' README.md | head -1)"
  31. if [ -z "$header" ]; then
  32. err "README.md: count header line not found (expected '**N agents. N skills. ...**')"
  33. else
  34. read -r r_agents r_skills r_styles r_hooks r_rules <<< \
  35. "$(echo "$header" | grep -oE '[0-9]+' | tr '\n' ' ')"
  36. [ "$r_agents" = "$agents_disk" ] || err "README header: $r_agents agents claimed, $agents_disk on disk"
  37. [ "$r_skills" = "$skills_disk" ] || err "README header: $r_skills skills claimed, $skills_disk on disk"
  38. [ "$r_styles" = "$styles_disk" ] || err "README header: $r_styles styles claimed, $styles_disk on disk"
  39. [ "$r_hooks" = "$hooks_disk" ] || err "README header: $r_hooks hooks claimed, $hooks_disk on disk"
  40. [ "$r_rules" = "$rules_disk" ] || err "README header: $r_rules rules claimed, $rules_disk on disk"
  41. fi
  42. # --- Selected README prose count claims -------------------------------------
  43. # These five known count-bearing patterns are intentionally exhaustive; newly
  44. # introduced prose patterns must be added explicitly if they should be gated.
  45. check_readme_prose() { # $1=regex $2=disk-count $3=label
  46. local line claim matched=0
  47. while IFS= read -r line; do
  48. [ -n "$line" ] || continue
  49. matched=$((matched + 1))
  50. claim="$(echo "$line" | grep -oE '[0-9]+' | head -1)"
  51. [ "$claim" = "$2" ] || err "README.md: $claim $3 claimed, $2 on disk"
  52. done < <(grep -E "$1" README.md || true)
  53. # Zero matches = the count-bearing line was deleted or reworded, which is
  54. # drift too — a guard that finds nothing to check must not stay silent.
  55. [ "$matched" -ge 1 ] || err "README.md: prose pattern for '$3' matched no lines (deleted/reworded?)"
  56. }
  57. check_readme_prose '[0-9]+ specialized skills' "$skills_disk" "specialized skills"
  58. check_readme_prose '[0-9]+ on-demand skills' "$skills_disk" "on-demand skills"
  59. check_readme_prose 'Custom skills \([0-9]+\)' "$skills_disk" "custom skills"
  60. check_readme_prose 'Slash commands \([0-9]+\)' "$commands_disk" "slash commands"
  61. check_readme_prose 'Expert subagents \([0-9]+\)' "$agents_disk" "expert subagents"
  62. # --- AGENTS.md overview bullets ---------------------------------------------
  63. check_agents_md() { # $1=regex $2=disk-count $3=label
  64. local claim
  65. claim="$(grep -oE "$1" AGENTS.md | head -1 | grep -oE '[0-9]+')"
  66. if [ -z "$claim" ]; then
  67. err "AGENTS.md: no '$3' count bullet found"
  68. elif [ "$claim" != "$2" ]; then
  69. err "AGENTS.md: $claim $3 claimed, $2 on disk"
  70. fi
  71. }
  72. check_agents_md '\*\*[0-9]+ expert agents\*\*' "$agents_disk" "agents"
  73. check_agents_md '\*\*[0-9]+ skills\*\*' "$skills_disk" "skills"
  74. check_agents_md '\*\*[0-9]+ output styles\*\*' "$styles_disk" "output styles"
  75. check_agents_md '\*\*[0-9]+ hooks\*\*' "$hooks_disk" "hooks"
  76. check_agents_md '\*\*[0-9]+ commands\*\*' "$commands_disk" "commands"
  77. # --- docs/PLAN.md inventory table -------------------------------------------
  78. check_plan() { # $1=row-label $2=disk-count
  79. local claim
  80. claim="$(grep -E "^\| $1 \|" docs/PLAN.md | head -1 | awk -F'|' '{gsub(/ /,"",$3); print $3}')"
  81. if [ -n "$claim" ] && [ "$claim" != "$2" ]; then
  82. err "docs/PLAN.md: $1 = $claim claimed, $2 on disk"
  83. fi
  84. }
  85. check_plan "Agents" "$agents_disk"
  86. check_plan "Skills" "$skills_disk"
  87. check_plan "Commands" "$commands_disk"
  88. check_plan "Rules" "$rules_disk"
  89. check_plan "Output Styles" "$styles_disk"
  90. check_plan "Hooks" "$hooks_disk"
  91. # --- 2. Every skill has a README row ----------------------------------------
  92. for d in skills/*/; do
  93. n="$(basename "$d")"
  94. [ -f "$d/SKILL.md" ] || continue
  95. grep -q "skills/$n/" README.md || err "README.md: skill '$n' has no table row"
  96. done
  97. # --- 3. Ghost-link check (README.md + AGENTS.md) ----------------------------
  98. for doc in README.md AGENTS.md; do
  99. while IFS= read -r path; do
  100. path="${path%%#*}" # strip anchors
  101. [ -z "$path" ] && continue
  102. [ -e "$path" ] || err "$doc: link target does not exist: $path"
  103. done < <(grep -oE '\]\((skills|agents|hooks|rules|output-styles|commands|docs|tools|tests|scripts)/[^)]*\)' "$doc" \
  104. | sed -E 's/^\]\(//; s/\)$//')
  105. done
  106. # --- 4. Skill frontmatter ghost references ---------------------------------
  107. # These are Claude-Code-bundled skills, not skills shipped by this repo.
  108. # Keep this allowlist narrow: every other unknown reference is a failure.
  109. external_skills="frontend-design"
  110. for skill_file in skills/*/SKILL.md; do
  111. while IFS= read -r refs; do
  112. refs="${refs#*:}"
  113. refs="${refs#"${refs%%[![:space:]]*}"}"
  114. refs="${refs%"${refs##*[![:space:]]}"}"
  115. refs="${refs#\"}"; refs="${refs%\"}"
  116. IFS=',' read -ra names <<< "$refs"
  117. for name in "${names[@]}"; do
  118. name="${name#"${name%%[![:space:]]*}"}"
  119. name="${name%"${name##*[![:space:]]}"}"
  120. [ -z "$name" ] && continue
  121. case " $external_skills " in *" $name "*) continue ;; esac
  122. [ -d "skills/$name" ] || err "$skill_file: unknown skill reference '$name'"
  123. done
  124. done < <(awk '
  125. NR == 1 && $0 == "---" { frontmatter=1; next }
  126. frontmatter && $0 == "---" { exit }
  127. frontmatter && /^metadata:/ { metadata=1; next }
  128. metadata && /^[^ ]/ { metadata=0 }
  129. metadata && /^ (related-skills|depends-on):/ { print }
  130. ' "$skill_file")
  131. done
  132. echo
  133. if [ "$errors" -eq 0 ]; then
  134. echo "doc-drift: clean"
  135. exit 0
  136. else
  137. echo "doc-drift: $errors issue(s) found"
  138. exit 1
  139. fi