test-sdd-workspace.sh 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. #!/usr/bin/env bash
  2. # Tests for the SDD workspace: scripts/sdd-workspace resolves a self-ignoring,
  3. # PER-PLAN working-tree directory for SDD artifacts, and the SDD scripts write
  4. # into their plan's directory.
  5. set -euo pipefail
  6. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  7. REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
  8. SDD_SCRIPTS="$REPO_ROOT/skills/subagent-driven-development/scripts"
  9. FAILURES=0
  10. TEST_ROOT=""
  11. pass() { echo " [PASS] $1"; }
  12. fail() {
  13. echo " [FAIL] $1"
  14. FAILURES=$((FAILURES + 1))
  15. }
  16. cleanup() {
  17. if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" ]]; then
  18. rm -rf "$TEST_ROOT"
  19. fi
  20. }
  21. main() {
  22. echo "=== Test: sdd-workspace ==="
  23. TEST_ROOT="$(mktemp -d)"
  24. trap cleanup EXIT
  25. # Resolve repo to its physical path so string comparisons match the
  26. # helper's output (git rev-parse --show-toplevel resolves symlinks; on
  27. # macOS mktemp lives under /var -> /private/var).
  28. git init -q -b main "$TEST_ROOT/repo"
  29. local repo
  30. repo="$(cd "$TEST_ROOT/repo" && git rev-parse --show-toplevel)"
  31. cat > "$repo/plan-a.md" <<'PLAN'
  32. # Plan A
  33. ## Task 1: First thing
  34. Do the first thing.
  35. PLAN
  36. cat > "$repo/plan-b.md" <<'PLAN'
  37. # Plan B
  38. ## Task 1: Other thing
  39. Do the other thing.
  40. PLAN
  41. # --- argument validation ---
  42. local rc=0
  43. (cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" >/dev/null 2>&1) || rc=$?
  44. if [[ "$rc" -eq 2 ]]; then
  45. pass "sdd-workspace without a plan errors with exit 2"
  46. else
  47. fail "sdd-workspace without a plan errors with exit 2"
  48. echo " exit: $rc"
  49. fi
  50. rc=0
  51. (cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" no-such-plan.md >/dev/null 2>&1) || rc=$?
  52. if [[ "$rc" -eq 2 ]]; then
  53. pass "sdd-workspace with a missing plan file errors with exit 2"
  54. else
  55. fail "sdd-workspace with a missing plan file errors with exit 2"
  56. echo " exit: $rc"
  57. fi
  58. # --- per-plan resolution ---
  59. local dir_a dir_b
  60. dir_a="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" plan-a.md)"
  61. dir_b="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" plan-b.md)"
  62. if [[ "$dir_a" == "$repo/.superpowers/sdd/plan-a" ]]; then
  63. pass "prints <repo-root>/.superpowers/sdd/<plan-basename>"
  64. else
  65. fail "prints <repo-root>/.superpowers/sdd/<plan-basename>"
  66. echo " got: $dir_a"
  67. fi
  68. if [[ "$dir_a" != "$dir_b" && -d "$dir_a" && -d "$dir_b" ]]; then
  69. pass "two plans resolve to two distinct directories"
  70. else
  71. fail "two plans resolve to two distinct directories"
  72. echo " a: $dir_a"
  73. echo " b: $dir_b"
  74. fi
  75. if [[ -f "$repo/.superpowers/sdd/.gitignore" && "$(cat "$repo/.superpowers/sdd/.gitignore")" == "*" ]]; then
  76. pass "self-ignoring .gitignore created at .superpowers/sdd/ with '*'"
  77. else
  78. fail "self-ignoring .gitignore created at .superpowers/sdd/ with '*'"
  79. fi
  80. printf 'x\n' > "$dir_a/artifact.md"
  81. local status
  82. status="$(cd "$repo" && git status --porcelain)"
  83. # plan-a.md/plan-b.md are intentionally untracked fixture files; only the
  84. # workspace must be invisible.
  85. if [[ "$status" != *".superpowers"* ]]; then
  86. pass "workspace invisible to git status"
  87. else
  88. fail "workspace invisible to git status"
  89. echo " status: $status"
  90. fi
  91. ( cd "$repo" && git add -A )
  92. local staged
  93. staged="$(cd "$repo" && git diff --cached --name-only)"
  94. if [[ "$staged" != *".superpowers"* ]]; then
  95. pass "git add -A does not stage the workspace"
  96. else
  97. fail "git add -A does not stage the workspace"
  98. echo " staged: $staged"
  99. fi
  100. # --- task-brief lands in its plan's directory ---
  101. local brief_out brief_path
  102. brief_out="$(cd "$repo" && "$SDD_SCRIPTS/task-brief" plan-a.md 1)"
  103. brief_path="$(printf '%s\n' "$brief_out" | sed -n 's/^wrote \(.*\): [0-9][0-9]* lines$/\1/p')"
  104. if [[ "$brief_path" == "$repo/.superpowers/sdd/plan-a/task-1-brief.md" ]]; then
  105. pass "task-brief writes its brief under the plan's workspace"
  106. else
  107. fail "task-brief writes its brief under the plan's workspace"
  108. echo " got: $brief_path"
  109. fi
  110. # --- review-package takes the plan first and lands in its directory ---
  111. local git_id=(-c user.email=t@example.com -c user.name=t -c commit.gpgsign=false)
  112. ( cd "$repo" \
  113. && git "${git_id[@]}" commit -qm c1 \
  114. && printf 'y\n' > f && git add f \
  115. && git "${git_id[@]}" commit -qm c2 )
  116. local rp_out rp_path
  117. rp_out="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md HEAD~1 HEAD)"
  118. rp_path="$(printf '%s\n' "$rp_out" | sed -n 's/^wrote \(.*\): [0-9].*$/\1/p')"
  119. case "$rp_path" in
  120. "$repo/.superpowers/sdd/plan-a/review-"*.diff)
  121. pass "review-package writes its diff under the plan's workspace" ;;
  122. *)
  123. fail "review-package writes its diff under the plan's workspace"
  124. echo " got: $rp_path"
  125. ;;
  126. esac
  127. rc=0
  128. (cd "$repo" && "$SDD_SCRIPTS/review-package" HEAD~1 HEAD >/dev/null 2>&1) || rc=$?
  129. if [[ "$rc" -eq 2 ]]; then
  130. pass "review-package without a plan errors with exit 2"
  131. else
  132. fail "review-package without a plan errors with exit 2"
  133. echo " exit: $rc"
  134. fi
  135. local rp_explicit
  136. rp_explicit="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md HEAD~1 HEAD "$TEST_ROOT/explicit.diff")"
  137. if [[ -s "$TEST_ROOT/explicit.diff" && "$rp_explicit" == *"$TEST_ROOT/explicit.diff"* ]]; then
  138. pass "review-package honors an explicit OUTFILE"
  139. else
  140. fail "review-package honors an explicit OUTFILE"
  141. echo " got: $rp_explicit"
  142. fi
  143. # --- range guards: BASE must be an ancestor of HEAD, range must be non-empty ---
  144. local divergent
  145. divergent="$(cd "$repo" && git "${git_id[@]}" commit-tree 'HEAD~1^{tree}' -p 'HEAD~1' -m divergent)"
  146. rc=0
  147. local guard_err
  148. guard_err="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md "$divergent" HEAD 2>&1 >/dev/null)" || rc=$?
  149. if [[ "$rc" -eq 3 && "$guard_err" == *"not a descendant"* ]]; then
  150. pass "review-package rejects a BASE that is not an ancestor of HEAD with exit 3"
  151. else
  152. fail "review-package rejects a BASE that is not an ancestor of HEAD with exit 3"
  153. echo " exit: $rc"
  154. echo " stderr: $guard_err"
  155. fi
  156. rc=0
  157. guard_err="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md HEAD HEAD 2>&1 >/dev/null)" || rc=$?
  158. if [[ "$rc" -eq 3 && "$guard_err" == *"empty commit range"* ]]; then
  159. pass "review-package rejects an empty BASE..HEAD range with exit 3"
  160. else
  161. fail "review-package rejects an empty BASE..HEAD range with exit 3"
  162. echo " exit: $rc"
  163. echo " stderr: $guard_err"
  164. fi
  165. # --- Worktree isolation: a linked worktree resolves its own workspace ---
  166. local wt="$TEST_ROOT/wt"
  167. ( cd "$repo" && git worktree add -q "$wt" -b wt-feature )
  168. local wt_root wt_dir
  169. wt_root="$(cd "$wt" && git rev-parse --show-toplevel)"
  170. wt_dir="$(cd "$wt" && "$SDD_SCRIPTS/sdd-workspace" plan-a.md)"
  171. if [[ "$wt_dir" == "$wt_root/.superpowers/sdd/plan-a" && "$wt_dir" != "$dir_a" ]]; then
  172. pass "linked worktree resolves its own distinct workspace"
  173. else
  174. fail "linked worktree resolves its own distinct workspace"
  175. echo " main: $dir_a"
  176. echo " wt: $wt_dir"
  177. fi
  178. printf 'y\n' > "$wt_dir/artifact.md"
  179. local wt_status
  180. wt_status="$(cd "$wt" && git status --porcelain)"
  181. if [[ "$wt_status" != *".superpowers"* ]]; then
  182. pass "worktree workspace invisible to git status"
  183. else
  184. fail "worktree workspace invisible to git status"
  185. echo " status: $wt_status"
  186. fi
  187. # --- helpers survive a mode-stripping extractor dropping exec bits (#2040) ---
  188. local stripped="$TEST_ROOT/stripped-scripts"
  189. mkdir -p "$stripped"
  190. cp "$SDD_SCRIPTS/sdd-workspace" "$SDD_SCRIPTS/task-brief" "$SDD_SCRIPTS/review-package" "$stripped/"
  191. chmod -x "$stripped"/*
  192. local noexec_out noexec_rc=0
  193. noexec_out="$(cd "$repo" && bash "$stripped/task-brief" plan-b.md 1 2>&1)" || noexec_rc=$?
  194. if [[ "$noexec_rc" -eq 0 && -f "$repo/.superpowers/sdd/plan-b/task-1-brief.md" ]]; then
  195. pass "task-brief works with no exec bit on sdd-workspace"
  196. else
  197. fail "task-brief works with no exec bit on sdd-workspace"
  198. echo " rc: $noexec_rc"
  199. echo " output: $noexec_out"
  200. fi
  201. # --- Ownership markers: two plans with the same basename (#2045) ---
  202. mkdir -p "$repo/docs/alpha" "$repo/docs/beta"
  203. cat > "$repo/docs/alpha/plan.md" <<'PLAN'
  204. # Alpha Plan
  205. ## Task 1: Alpha work
  206. Alpha-only requirement text.
  207. PLAN
  208. cat > "$repo/docs/beta/plan.md" <<'PLAN'
  209. # Beta Plan
  210. ## Task 1: Beta work
  211. Beta-only requirement text.
  212. PLAN
  213. local dir_alpha dir_beta
  214. dir_alpha="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" docs/alpha/plan.md)"
  215. dir_beta="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" docs/beta/plan.md)"
  216. if [[ "$dir_alpha" != "$dir_beta" ]]; then
  217. pass "same-basename plans resolve to distinct workspaces"
  218. else
  219. fail "same-basename plans resolve to distinct workspaces"
  220. echo " alpha: $dir_alpha"
  221. echo " beta: $dir_beta"
  222. fi
  223. ( cd "$repo" && "$SDD_SCRIPTS/task-brief" docs/alpha/plan.md 1 >/dev/null )
  224. ( cd "$repo" && "$SDD_SCRIPTS/task-brief" docs/beta/plan.md 1 >/dev/null )
  225. if grep -q "Alpha-only requirement text." "$dir_alpha/task-1-brief.md" 2>/dev/null \
  226. && grep -q "Beta-only requirement text." "$dir_beta/task-1-brief.md" 2>/dev/null; then
  227. pass "same-basename plans keep both task briefs intact"
  228. else
  229. fail "same-basename plans keep both task briefs intact"
  230. echo " alpha brief: $(cat "$dir_alpha/task-1-brief.md" 2>/dev/null)"
  231. echo " beta brief: $(cat "$dir_beta/task-1-brief.md" 2>/dev/null)"
  232. fi
  233. # --- Legacy adoption: pre-existing workspace without a marker ---
  234. printf '# Foo\n\n## Task 1: Foo\n\nFoo.\n' > "$repo/foo.md"
  235. mkdir -p "$repo/.superpowers/sdd/foo"
  236. printf 'ledger\n' > "$repo/.superpowers/sdd/foo/progress.md"
  237. local dir_foo
  238. dir_foo="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" foo.md)"
  239. if [[ "$dir_foo" == "$repo/.superpowers/sdd/foo" \
  240. && -f "$dir_foo/progress.md" \
  241. && "$(cat "$dir_foo/plan-path" 2>/dev/null)" == "foo.md" ]]; then
  242. pass "legacy markerless workspace is adopted in place and marked"
  243. else
  244. fail "legacy markerless workspace is adopted in place and marked"
  245. echo " dir: $dir_foo"
  246. echo " marker: $(cat "$dir_foo/plan-path" 2>/dev/null)"
  247. fi
  248. # --- Ownership conflict: marker names a different plan ---
  249. printf '# Bar\n\n## Task 1: Bar\n\nBar.\n' > "$repo/bar.md"
  250. mkdir -p "$repo/.superpowers/sdd/bar"
  251. printf 'somewhere-else/bar.md\n' > "$repo/.superpowers/sdd/bar/plan-path"
  252. printf 'other ledger\n' > "$repo/.superpowers/sdd/bar/progress.md"
  253. local dir_bar
  254. dir_bar="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" bar.md)"
  255. if [[ "$dir_bar" == "$repo/.superpowers/sdd/bar-repo" \
  256. && "$(cat "$dir_bar/plan-path" 2>/dev/null)" == "bar.md" ]]; then
  257. pass "owned workspace disambiguates with parent-dir suffix"
  258. else
  259. fail "owned workspace disambiguates with parent-dir suffix"
  260. echo " got: $dir_bar"
  261. fi
  262. if [[ "$(cat "$repo/.superpowers/sdd/bar/plan-path")" == "somewhere-else/bar.md" \
  263. && "$(cat "$repo/.superpowers/sdd/bar/progress.md")" == "other ledger" ]]; then
  264. pass "conflicting plan leaves the original workspace untouched"
  265. else
  266. fail "conflicting plan leaves the original workspace untouched"
  267. fi
  268. # --- Counter fallback: parent-suffixed workspace is owned too ---
  269. printf '# Baz\n\n## Task 1: Baz\n\nBaz.\n' > "$repo/baz.md"
  270. mkdir -p "$repo/.superpowers/sdd/baz" "$repo/.superpowers/sdd/baz-repo"
  271. printf 'one/baz.md\n' > "$repo/.superpowers/sdd/baz/plan-path"
  272. printf 'two/baz.md\n' > "$repo/.superpowers/sdd/baz-repo/plan-path"
  273. local dir_baz
  274. dir_baz="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" baz.md)"
  275. if [[ "$dir_baz" == "$repo/.superpowers/sdd/baz-repo-2" \
  276. && "$(cat "$dir_baz/plan-path" 2>/dev/null)" == "baz.md" ]]; then
  277. pass "double conflict falls back to a counter suffix"
  278. else
  279. fail "double conflict falls back to a counter suffix"
  280. echo " got: $dir_baz"
  281. fi
  282. # --- Same plan spelled differently resolves to one workspace ---
  283. local dir_rel dir_abs dir_dotdot
  284. dir_rel="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" docs/alpha/plan.md)"
  285. dir_abs="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" "$repo/docs/alpha/plan.md")"
  286. dir_dotdot="$(cd "$repo/docs/beta" && "$SDD_SCRIPTS/sdd-workspace" ../alpha/plan.md)"
  287. if [[ "$dir_rel" == "$dir_abs" && "$dir_rel" == "$dir_dotdot" \
  288. && "$(cat "$dir_rel/plan-path" 2>/dev/null)" == "docs/alpha/plan.md" ]]; then
  289. pass "relative, absolute, and ../ spellings share one workspace and marker"
  290. else
  291. fail "relative, absolute, and ../ spellings share one workspace and marker"
  292. echo " rel: $dir_rel"
  293. echo " abs: $dir_abs"
  294. echo " dotdot: $dir_dotdot"
  295. echo " marker: $(cat "$dir_rel/plan-path" 2>/dev/null)"
  296. fi
  297. # --- Out-of-repo plans keep working, marker holds the absolute path ---
  298. mkdir -p "$TEST_ROOT/outside"
  299. printf '# Remote\n\n## Task 1: Remote\n\nRemote.\n' > "$TEST_ROOT/outside/remote-plan.md"
  300. local outside_abs dir_out
  301. outside_abs="$(cd "$TEST_ROOT/outside" && pwd -P)/remote-plan.md"
  302. dir_out="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" "$TEST_ROOT/outside/remote-plan.md")"
  303. if [[ "$dir_out" == "$repo/.superpowers/sdd/remote-plan" \
  304. && "$(cat "$dir_out/plan-path" 2>/dev/null)" == "$outside_abs" ]]; then
  305. pass "out-of-repo plan gets a basename slug and an absolute-path marker"
  306. else
  307. fail "out-of-repo plan gets a basename slug and an absolute-path marker"
  308. echo " dir: $dir_out"
  309. echo " marker: $(cat "$dir_out/plan-path" 2>/dev/null)"
  310. fi
  311. echo ""
  312. if [[ "$FAILURES" -ne 0 ]]; then
  313. echo "FAILED: $FAILURES assertion(s)."
  314. exit 1
  315. fi
  316. echo "PASS"
  317. }
  318. main "$@"