adr-new.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. #!/usr/bin/env bash
  2. # Scaffold the next Architecture Decision Record from the canonical template.
  3. #
  4. # Usage: adr-new.sh --title "Title Text" [OPTIONS]
  5. # Input: argv flags only (no stdin).
  6. # Output: stdout = the created file path (or, under --dry-run, the path then the
  7. # full rendered content). Data only.
  8. # Stderr: headers, reminders (e.g. supersession flip), warnings, errors.
  9. # Exit: 0 created (or dry-run rendered), 2 usage, 3 dir not found,
  10. # 5 precondition (target already exists)
  11. #
  12. # Computes the next number as (highest existing ADR-NNN in --dir) + 1, zero-padded
  13. # to three digits, and writes ADR-NNN-slug.md with frontmatter filled in. Never
  14. # overwrites an existing file. Atomic write (tmp + mv).
  15. #
  16. # Examples:
  17. # adr-new.sh --title "OAuth-only auth"
  18. # adr-new.sh --dir docs/decisions --title "Per-trial container" --slug per-trial-container
  19. # adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede
  20. # adr-new.sh --title "Draft idea" --status proposed --dry-run
  21. set -uo pipefail
  22. # ── exit-code constants ────────────────────────────────────────────────────
  23. readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_PRECOND=5
  24. # Terminal design system (skills/_lib/term.sh). stdout = the created file path
  25. # (data); the creation summary + supersession reminders frame on stderr, so detect
  26. # color on fd 2. Degrade to plain stderr lines if the shared lib is unreachable.
  27. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  28. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  29. else
  30. term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
  31. term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; }
  32. term_alert() { shift; printf ' ! %s\n' "$*"; }
  33. term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
  34. fi
  35. # ── defaults ───────────────────────────────────────────────────────────────
  36. DIR="docs/adr"
  37. TITLE=""
  38. SLUG=""
  39. STATUS="accepted"
  40. SUPERSEDES=""
  41. APPLY_SUPERSEDE=0
  42. DATE=""
  43. NUMBER="" # explicit override; default is highest+1
  44. DRY_RUN=0
  45. # Resolve the bundled template relative to this script (works in repo + installed).
  46. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  47. TEMPLATE="$HERE/../assets/ADR-template.md"
  48. usage() {
  49. cat <<'EOF'
  50. adr-new.sh — scaffold the next ADR from the canonical template.
  51. Usage:
  52. adr-new.sh --title "Title Text" [OPTIONS]
  53. Options:
  54. --dir DIR ADR directory (default: docs/adr)
  55. --title TEXT ADR title (required). Used in the `# ADR-NNN: Title` line.
  56. --slug SLUG kebab-case slug for the filename. Derived from --title if omitted.
  57. --status STATUS proposed|accepted|superseded|deprecated (default: accepted)
  58. --number N Force the ADR number instead of computing highest+1
  59. (for backfilling or coordination). Sequential discipline
  60. is the default; use sparingly. The overwrite guard still
  61. applies.
  62. --supersedes ADR-NNN Mark this ADR as superseding ADR-NNN. Prints a reminder to
  63. flip the old record. Repeatable.
  64. --apply-supersede With --supersedes: also flip the OLD file's frontmatter
  65. (status: superseded, superseded-by: [this ADR]) in place.
  66. --date YYYY-MM-DD Decision date (default: today via `date +%F`).
  67. --dry-run Print the target path and full content; write nothing.
  68. -h, --help Show this help and exit 0.
  69. Exit codes:
  70. 0 created (or dry-run rendered) 2 usage 3 dir not found 5 target exists
  71. Examples:
  72. adr-new.sh --title "OAuth-only auth"
  73. adr-new.sh --dir docs/decisions --title "Per-trial container" --slug per-trial-container
  74. adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede
  75. adr-new.sh --title "Draft idea" --status proposed --dry-run
  76. EOF
  77. }
  78. die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
  79. # ── parse args ─────────────────────────────────────────────────────────────
  80. SUPERSEDES_LIST=()
  81. while [[ $# -gt 0 ]]; do
  82. case "$1" in
  83. --dir) [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;;
  84. --title) [[ $# -ge 2 ]] || die_usage "--title needs a value"; TITLE="$2"; shift 2 ;;
  85. --slug) [[ $# -ge 2 ]] || die_usage "--slug needs a value"; SLUG="$2"; shift 2 ;;
  86. --status) [[ $# -ge 2 ]] || die_usage "--status needs a value"; STATUS="$2"; shift 2 ;;
  87. --number) [[ $# -ge 2 ]] || die_usage "--number needs a value"; NUMBER="$2"; shift 2 ;;
  88. --supersedes) [[ $# -ge 2 ]] || die_usage "--supersedes needs a value"; SUPERSEDES_LIST+=("$2"); shift 2 ;;
  89. --apply-supersede) APPLY_SUPERSEDE=1; shift ;;
  90. --date) [[ $# -ge 2 ]] || die_usage "--date needs a value"; DATE="$2"; shift 2 ;;
  91. --dry-run) DRY_RUN=1; shift ;;
  92. -h|--help) usage; exit "$EX_OK" ;;
  93. -*) die_usage "unknown flag: $1" ;;
  94. *) die_usage "unexpected positional argument: $1" ;;
  95. esac
  96. done
  97. # ── validate ───────────────────────────────────────────────────────────────
  98. [[ -n "$TITLE" ]] || die_usage "--title is required"
  99. case "$STATUS" in
  100. proposed|accepted|superseded|deprecated) ;;
  101. *) die_usage "--status must be one of proposed|accepted|superseded|deprecated (got '$STATUS')" ;;
  102. esac
  103. if [[ -n "$DATE" ]]; then
  104. [[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die_usage "--date must be YYYY-MM-DD (got '$DATE')"
  105. else
  106. DATE="$(date +%F)"
  107. fi
  108. [[ -f "$TEMPLATE" ]] || { printf 'error: template not found at %s\n' "$TEMPLATE" >&2; exit "$EX_NOTFOUND"; }
  109. [[ -d "$DIR" ]] || { printf 'error: ADR directory not found: %s\n' "$DIR" >&2; exit "$EX_NOTFOUND"; }
  110. # Derive slug from title if not supplied: lowercase, non-alnum -> '-', squeeze, trim.
  111. if [[ -z "$SLUG" ]]; then
  112. SLUG="$(printf '%s' "$TITLE" \
  113. | tr '[:upper:]' '[:lower:]' \
  114. | sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//')"
  115. fi
  116. [[ -n "$SLUG" ]] || die_usage "could not derive a slug from --title; pass --slug explicitly"
  117. [[ "$SLUG" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--slug must be kebab-case (got '$SLUG')"
  118. # ── compute next number ────────────────────────────────────────────────────
  119. # Default: highest existing ADR-NNN in --dir, +1. Sequential, never reused/reordered.
  120. # --number N overrides this (for backfilling or cross-session coordination); the
  121. # overwrite guard below still protects against clobbering an existing record.
  122. if [[ -n "$NUMBER" ]]; then
  123. [[ "$NUMBER" =~ ^[0-9]+$ ]] || die_usage "--number must be a non-negative integer (got '$NUMBER')"
  124. next=$((10#$NUMBER))
  125. else
  126. highest=0
  127. shopt -s nullglob
  128. for f in "$DIR"/ADR-*.md; do
  129. base="$(basename "$f")"
  130. if [[ "$base" =~ ^ADR-([0-9]+) ]]; then
  131. n=$((10#${BASH_REMATCH[1]}))
  132. (( n > highest )) && highest=$n
  133. fi
  134. done
  135. shopt -u nullglob
  136. next=$((highest + 1))
  137. fi
  138. NNN="$(printf '%03d' "$next")"
  139. TARGET="$DIR/ADR-$NNN-$SLUG.md"
  140. # Never overwrite (precondition).
  141. if [[ -e "$TARGET" ]]; then
  142. printf 'error: target already exists: %s (refusing to overwrite)\n' "$TARGET" >&2
  143. exit "$EX_PRECOND"
  144. fi
  145. # ── render content from template ───────────────────────────────────────────
  146. # Build the supersedes YAML list value.
  147. supersedes_yaml="[]"
  148. if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then
  149. joined=""
  150. for s in "${SUPERSEDES_LIST[@]}"; do
  151. [[ "$s" =~ ^ADR-[0-9]+$ ]] || die_usage "--supersedes value must look like ADR-NNN (got '$s')"
  152. joined+="${joined:+, }$s"
  153. done
  154. supersedes_yaml="[$joined]"
  155. fi
  156. # Render from template via line-exact awk substitution (avoids bash ${//}
  157. # footguns: a leading '#' anchors the match, and a title with regex metachars
  158. # would otherwise need escaping). Each placeholder line is matched whole.
  159. content="$(awk \
  160. -v status="status: $STATUS" \
  161. -v date="date: $DATE" \
  162. -v supersedes="supersedes: $supersedes_yaml" \
  163. -v title="# ADR-$NNN: $TITLE" '
  164. $0 == "status: accepted" { print status; next }
  165. $0 == "date: YYYY-MM-DD" { print date; next }
  166. $0 == "supersedes: []" { print supersedes; next }
  167. $0 == "# ADR-NNN: Title in Title Case" { print title; next }
  168. { print }
  169. ' "$TEMPLATE")"
  170. # ── dry-run: print and stop ────────────────────────────────────────────────
  171. if [[ "$DRY_RUN" -eq 1 ]]; then
  172. printf '%s\n' "$TARGET"
  173. {
  174. term_panel_open adr "adr ${TERM_DOT} new (dry-run)" "ADR-$NNN"
  175. term_panel_vert
  176. term_status_row skip "would write $(basename "$TARGET")" "status: $STATUS ${TERM_DOT} $DATE"
  177. if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then
  178. term_alert warning "supersedes ${SUPERSEDES_LIST[*]} — flip status: superseded + superseded-by: [ADR-$NNN] in the same commit"
  179. fi
  180. term_panel_vert
  181. term_panel_close "nothing written" ""
  182. } >&2
  183. printf '%s\n' "$content"
  184. exit "$EX_OK"
  185. fi
  186. # ── atomic write ───────────────────────────────────────────────────────────
  187. tmp="$TARGET.tmp.$$"
  188. printf '%s\n' "$content" > "$tmp" || { printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; }
  189. mv -f "$tmp" "$TARGET" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$TARGET" >&2; exit 1; }
  190. printf '%s\n' "$TARGET"
  191. # ── supersession handling ──────────────────────────────────────────────────
  192. # Apply the flips first (collecting outcome rows), then frame the whole event as a
  193. # single status panel on stderr. stdout already carries the created path (data).
  194. sup_rows=() # "state\tlabel\tvalue" — rendered as term_status_row
  195. sup_alerts=() # plain text — rendered as term_alert warning
  196. if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then
  197. for old in "${SUPERSEDES_LIST[@]}"; do
  198. oldfile=""
  199. shopt -s nullglob
  200. for f in "$DIR/$old"-*.md; do oldfile="$f"; break; done
  201. shopt -u nullglob
  202. if [[ "$APPLY_SUPERSEDE" -eq 1 ]]; then
  203. if [[ -z "$oldfile" || ! -f "$oldfile" ]]; then
  204. sup_alerts+=("--apply-supersede: could not find $old in $DIR — flip it by hand")
  205. continue
  206. fi
  207. # Flip frontmatter ONLY: status -> superseded, superseded-by -> [ADR-NNN].
  208. otmp="$oldfile.tmp.$$"
  209. if sed -E \
  210. -e "0,/^status: .*/s//status: superseded/" \
  211. -e "0,/^superseded-by: .*/s//superseded-by: [ADR-$NNN]/" \
  212. "$oldfile" > "$otmp" && mv -f "$otmp" "$oldfile"; then
  213. sup_rows+=("ok"$'\t'"flipped $(basename "$oldfile")"$'\t'"-> superseded, superseded-by: [ADR-$NNN]")
  214. else
  215. rm -f "$otmp"
  216. sup_alerts+=("failed to flip $oldfile — do it by hand")
  217. fi
  218. else
  219. sup_alerts+=("supersedes $old — in the SAME commit flip ${oldfile:-$old} to status: superseded + superseded-by: [ADR-$NNN] (or re-run with --apply-supersede)")
  220. fi
  221. done
  222. fi
  223. {
  224. term_panel_open adr "adr ${TERM_DOT} new" "ADR-$NNN"
  225. term_panel_vert
  226. term_status_row ok "created $(basename "$TARGET")" "status: $STATUS ${TERM_DOT} $DATE"
  227. for row in "${sup_rows[@]:-}"; do
  228. [[ -z "$row" ]] && continue
  229. IFS=$'\t' read -r st lbl val <<<"$row"
  230. term_status_row "$st" "$lbl" "$val"
  231. done
  232. for a in "${sup_alerts[@]:-}"; do
  233. [[ -z "$a" ]] && continue
  234. term_alert warning "$a"
  235. done
  236. term_panel_vert
  237. term_panel_close "then: adr-lint before committing" ""
  238. } >&2
  239. exit "$EX_OK"