#!/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)" ] }