aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests/spec-sort.bats
blob: 583e458dbf475c53cefb5e21598d743dc31721d4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
#!/usr/bin/env bats
#
# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile
# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside
# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an
# Implementation phases heading), show an evidence panel, and on --apply
# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the
# status heading (:ID:, dated history line), rewrite the keyword header to
# the two-sequence form, relink file: links across the rewritten roots,
# stamp :LAST_SPEC_SORT: in .ai/notes.org.
#
# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org,
# "The retrofit"):
#   - dry-run report is the default; --apply writes
#   - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty
#   - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1
#     otherwise); terminal keywords need --reason REL=TEXT
#   - plan validated before the first write; destination collisions block
#   - bare-path mentions in rewritten roots block --apply until
#     --acknowledge-bare waives them (reported, never rewritten)
#   - mid-apply failure names applied/not-applied + git restore recovery
#   - idempotent: a sorted project yields no candidates, no changes
#
# Strategy: each test builds a throwaway git project fixture and runs the
# real script against it. Mid-apply failure is forced via the test-only
# SPEC_SORT_INJECT_FAIL_AFTER env hook.

SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort"

setup() {
  TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)"
  PROJ="$TEST_DIR/proj"
  mkdir -p "$PROJ"
}

teardown() {
  rm -rf "$TEST_DIR"
}

# Standard fixture: one spec candidate, one note, a stray root spec with a
# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org,
# a sibling note, a session archive (report-only surface), and .ai/notes.org
# with a Workflow State section.
make_project() {
  cd "$PROJ"
  git init -q
  git config user.email test@test
  git config user.name test
  mkdir -p docs/design .ai/sessions

  cat > docs/design/widget.org <<'EOF'
#+TITLE: Widget Feature
#+DATE: 2026-05-01
#+TODO: DRAFT REVIEW | SHIPPED

* Metadata
| Status | draft |
| Owner  | Craig |

* Summary
The widget feature. See [[file:scratch-note.org][the note]].

* Decisions [1/2]
** DONE Pick the widget shape
** TODO Pick the color

* Implementation phases
** Phase 1 — build =src/widget.py=
EOF

  cat > docs/design/scratch-note.org <<'EOF'
#+TITLE: Scratch Note

* Metadata
| Status | n/a |

* Thoughts
See [[file:widget.org][the widget spec]].
EOF

  cat > docs/rooty-spec.org <<'EOF'
#+TITLE: Rooty

* Decisions
** DONE Only decision

* Implementation phases
** Phase 1 — nothing
EOF

  cat > docs/lonely-spec.org <<'EOF'
#+TITLE: Lonely
Just prose, no spine.
EOF

  cat > todo.org <<'EOF'
* Open Work
** DOING [#B] Widget feature
Spec: [[file:docs/design/widget.org][widget spec]].
Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]].
EOF

  cat > .ai/notes.org <<'EOF'
* Active Reminders

* Workflow State
:LAST_AUDIT: 2026-06-28
EOF

  cat > .ai/sessions/2026-06-01-old.org <<'EOF'
Old log: [[file:../../docs/design/widget.org][widget]]
EOF

  git add -A
  git commit -qm init
}

# Confirm flags that satisfy the gate for the standard fixture's candidates.
CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT)

# ---- Classification (dry-run) ----------------------------------------

@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]]
}

@test "spec-sort: a Metadata table alone does not qualify — note stays a note" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]]
  [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]]
}

@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]]
  [[ "$output" != *"rooty-spec-spec.org"* ]]
}

@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]]
  [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]]
}

@test "spec-sort: docs/specs/ contents are excluded from classification" {
  make_project
  mkdir -p docs/specs
  cp docs/design/widget.org docs/specs/sorted-spec.org
  git add -A && git commit -qm more
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]]
}

@test "spec-sort: no docs/ directory is a silent no-op" {
  cd "$PROJ"
  git init -q
  git config user.email test@test
  git config user.name test
  echo x > README.md
  git add -A && git commit -qm init
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [ -z "$output" ]
}

# ---- Evidence panel ---------------------------------------------------

@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"status field: draft"* ]]
  [[ "$output" == *"Decisions [1/2]"* ]]
  [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]]
}

@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  # status field says draft, but the linking todo.org task is DOING — the
  # panel proposes the state the strongest evidence supports
  [[ "$output" == *"proposed keyword: DOING"* ]]
}

@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" {
  make_project
  sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org
  git add -A && git commit -qm status
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]]
}

# ---- Confirm gate -----------------------------------------------------

@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" {
  make_project
  run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT
  [ "$status" -eq 1 ]
  [[ "$output" == *"unconfirmed"* ]]
  [[ "$output" == *"docs/rooty-spec.org"* ]]
  [ -f docs/design/widget.org ]   # nothing moved
}

@test "spec-sort --apply: a terminal keyword without --reason refuses" {
  make_project
  run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org
  [ "$status" -eq 1 ]
  [[ "$output" == *"--reason"* ]]
  [ -f docs/design/widget.org ]
}

@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" {
  make_project
  run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \
    --reason "docs/design/widget.org=shipped in v2, confirmed against src" \
    --skip docs/rooty-spec.org
  [ "$status" -eq 0 ]
  grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org
  grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org
}

@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" {
  make_project
  run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org
  [ "$status" -eq 0 ]
  [ -f docs/design/widget.org ]
  grep -q ':LAST_SPEC_SORT:' .ai/notes.org
}

# ---- Preflight --------------------------------------------------------

@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" {
  make_project
  echo "drift" >> todo.org
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 2 ]
  [[ "$output" == *"dirty"* ]]
  [ -f docs/design/widget.org ]
}

@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" {
  make_project
  echo "drift" >> todo.org
  git add todo.org && git commit -qm drift   # keep the link intact; dirty a different file
  echo "scratch" > untracked-note.txt
  echo "local edit" >> .ai/notes.org
  run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  [[ "$output" == *"pre-existing"* ]]
  [[ "$output" == *".ai/notes.org"* ]]
  [ -f docs/specs/widget-spec.org ]
}

# ---- Move + rename + rewrite ------------------------------------------

@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  [ -f docs/specs/widget-spec.org ]
  [ ! -f docs/design/widget.org ]
  grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org
  grep -q ':ID:' docs/specs/widget-spec.org
  grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org
}

@test "spec-sort --apply: keyword header rewritten to the two-sequence form" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org
  grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org
  ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org
}

@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" {
  make_project
  run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org
  [ "$status" -eq 0 ]
  grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org
  grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org
}

# ---- Relink -----------------------------------------------------------

@test "spec-sort --apply: rewrites the todo.org link, preserving the description" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org
  ! grep -q 'docs/design/widget.org' todo.org
}

@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org
}

@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org
}

@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org
}

@test "spec-sort: session archives are reported, never rewritten" {
  make_project
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]]
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org
}

@test "spec-sort: a synced template path report names the canonical rulesets file" {
  make_project
  mkdir -p .ai/workflows
  echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org
  git add -A && git commit -qm wf
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]]
  [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]]
}

# ---- Bare-path mentions -----------------------------------------------

@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" {
  make_project
  echo "raw mention: docs/design/widget.org needs review" >> todo.org
  git add -A && git commit -qm bare
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 1 ]
  [[ "$output" == *"BARE"* ]]
  [ -f docs/design/widget.org ]   # nothing moved
  run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q 'raw mention: docs/design/widget.org' todo.org   # reported, never rewritten
}

@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" {
  make_project
  echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org
  git add -A && git commit -qm selfmention
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 1 ]
  [[ "$output" == *"BARE"* ]]
  run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]   # the acknowledged mention rides along to docs/specs/; not residue
  grep -q ':LAST_SPEC_SORT:' .ai/notes.org
}

# ---- Plan validation ---------------------------------------------------

@test "spec-sort --apply: a destination collision blocks validation, nothing moved" {
  make_project
  mkdir -p docs/specs
  echo "occupied" > docs/specs/widget-spec.org
  git add -A && git commit -qm occupy
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 1 ]
  [[ "$output" == *"destination exists"* ]]
  [ -f docs/design/widget.org ]
  [ "$(cat docs/specs/widget-spec.org)" = "occupied" ]
}

@test "spec-sort --apply: writes the plan file before executing" {
  make_project
  run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  [ -f "$TEST_DIR/plan.json" ]
  grep -q 'widget-spec.org' "$TEST_DIR/plan.json"
}

# ---- Mid-apply failure recovery ----------------------------------------

@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" {
  make_project
  run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 1 ]
  [[ "$output" == *"RECOVERY"* ]]
  [[ "$output" == *"git restore"* ]]
  [[ "$output" == *"applied"* ]]
  [[ "$output" == *"not applied"* ]]
  ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org   # no stamp on a failed apply
}

# ---- Idempotence + marker ----------------------------------------------

@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
  # lands inside the Workflow State section, alongside the existing marker
  awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org
}

@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" {
  make_project
  printf '* Active Reminders\n' > .ai/notes.org
  git add -A && git commit -qm notes
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  grep -q '^\* Workflow State' .ai/notes.org
  grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
}

@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" {
  make_project
  rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org
  git add -A && git commit -qm notes-only
  run "$SCRIPT" --apply
  [ "$status" -eq 0 ]
  grep -q ':LAST_SPEC_SORT:' .ai/notes.org
}

@test "spec-sort: a second run after a successful apply finds nothing to do" {
  make_project
  run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
  [ "$status" -eq 0 ]
  git add -A && git commit -qm sorted
  run "$SCRIPT"
  [ "$status" -eq 0 ]
  [[ "$output" != *"CANDIDATE"* ]]
  run "$SCRIPT" --apply
  [ "$status" -eq 0 ]
  run git status --porcelain
  # only the re-stamped marker (same date) may differ — tree stays clean
  [ -z "$(git status --porcelain -- docs todo.org)" ]
}