From ddf48dc7ac780da1aacdff4e03f1d7da255b8f39 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 31 May 2026 12:19:34 -0500 Subject: feat: add rename-ai-artifact tool and rename the drill-deck family to flashcard Renaming an .ai artifact by hand is the kind of mechanical job that gets done incompletely: the canonical copy moves but the mirror doesn't, a reference in the INDEX is missed, a trigger phrase points at the old name. I'd also assumed a rename was costly because references scatter, when the index update is trivial and the drift check already guards it. So I built the discipline into a script instead of re-deriving it each time. scripts/rename-ai-artifact.sh takes old and new basenames, moves the file in both the canonical and mirror trees, and rewrites every reference repo-wide on a token boundary so renaming "foo" can't corrupt "foobar" or "foo-bar". It rewrites the underscore module-name variant too (a hyphenated script imported as foo_bar via importlib), leaves the archived session records under sessions/ alone because they're history, and runs workflow-integrity + sync-check at the end to prove no drift. rename-artifact.org documents it and indexes the triggers. Then I used the tool to do the rename that prompted it: the org-drill deck workflow and its helpers are now flashcard-named, since "flashcard" is the word you'd actually search for. The renamed set is flashcard-review.org plus flashcard-stats.py, flashcard-sync, flashcard-to-anki.py, and flashcard-diff-ids.py, with their tests, every reference, and the INDEX entry updated. The deck is still an org-drill deck under the hood, so the ":drill:" tag handling and the "drill deck" trigger phrases stay. I added "review/update the flashcards" alongside them. Tests: 9 bats for the rename tool (including the prefix-collision and history-preservation edges), and the renamed script suites all pass under make test. --- scripts/rename-ai-artifact.sh | 129 ++++++++++++++++++++++++++++++++++ scripts/tests/rename-ai-artifact.bats | 119 +++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100755 scripts/rename-ai-artifact.sh create mode 100644 scripts/tests/rename-ai-artifact.bats (limited to 'scripts') diff --git a/scripts/rename-ai-artifact.sh b/scripts/rename-ai-artifact.sh new file mode 100755 index 0000000..9af6326 --- /dev/null +++ b/scripts/rename-ai-artifact.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Rename an .ai artifact (a workflow or a script) across the canonical + mirror +# trees, rewriting every reference to it and leaving archived session records +# alone. Encodes the rename discipline so it isn't re-derived by hand each time: +# +# - canonical (claude-templates/.ai/) and mirror (.ai/) move in lockstep +# - every reference to the artifact's stem is rewritten, repo-wide +# - .ai/sessions/ (both trees) is history — never edited +# - references match on a token boundary, so renaming `foo` can't corrupt +# `foobar` or `foo-bar` +# - the underscore module-name variant is rewritten too (a hyphenated script +# imported as `foo_bar` via importlib), not just the hyphenated path +# - workflow-integrity.py + sync-check.sh run at the end to prove no drift +# +# Usage: rename-ai-artifact.sh OLD-BASENAME NEW-BASENAME +# e.g. rename-ai-artifact.sh old-workflow.org new-workflow.org +# rename-ai-artifact.sh old-helper.py new-helper.py +# +# Renames ONE artifact per call; run it once per file in a family. Order within +# a family doesn't matter — token-boundary matching keeps shared prefixes apart. +# +# Exit: 0 renamed (verify may still warn); 1 usage / not-found / target-exists. + +set -euo pipefail + +OLD="${1:-}" +NEW="${2:-}" + +if [ -z "$OLD" ] || [ -z "$NEW" ]; then + echo "usage: rename-ai-artifact.sh OLD-BASENAME NEW-BASENAME" >&2 + exit 1 +fi +if [ "$OLD" = "$NEW" ]; then + echo "rename: OLD and NEW are identical ($OLD)" >&2 + exit 1 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$REPO_ROOT" ]; then + echo "rename: not inside a git repository" >&2 + exit 1 +fi +CANON="$REPO_ROOT/claude-templates/.ai" +MIRROR="$REPO_ROOT/.ai" + +# Locate OLD under the canonical tree (the source of truth). Exactly one match. +mapfile -t hits < <(find "$CANON" -type f -name "$OLD" 2>/dev/null) +if [ "${#hits[@]}" -eq 0 ]; then + echo "rename: artifact not found under canonical tree: $OLD" >&2 + exit 1 +fi +if [ "${#hits[@]}" -gt 1 ]; then + echo "rename: $OLD is ambiguous (${#hits[@]} matches under $CANON):" >&2 + printf ' %s\n' "${hits[@]}" >&2 + exit 1 +fi +canon_old="${hits[0]}" +relpath="${canon_old#"$CANON"/}" # e.g. workflows/foo.org +reldir="$(dirname "$relpath")" +canon_new="$CANON/$reldir/$NEW" +mirror_old="$MIRROR/$relpath" +mirror_new="$MIRROR/$reldir/$NEW" + +if [ ! -f "$mirror_old" ]; then + echo "rename: canonical has $relpath but the mirror copy is missing: $mirror_old" >&2 + exit 1 +fi +for n in "$canon_new" "$mirror_new"; do + if [ -e "$n" ]; then + echo "rename: target already exists: $n" >&2 + exit 1 + fi +done + +# Stems drive reference rewriting: foo.org -> foo, foo-helper.py -> foo-helper, +# a no-extension script keeps its whole name. The stem replacement also covers +# the extensioned form (foo.org), since the "." after the stem is a boundary. +old_stem="${OLD%.*}" +new_stem="${NEW%.*}" +# Python imports a hyphenated script under an underscored module name +# (importlib.spec_from_file_location("foo_bar", "foo-bar.py")). Rewrite that +# variant too so the module-name reference isn't left behind. +old_us="${old_stem//-/_}" +new_us="${new_stem//-/_}" + +echo "rename: $relpath" +echo " $OLD -> $NEW (stem: $old_stem -> $new_stem)" + +git -C "$REPO_ROOT" mv "$canon_old" "$canon_new" +git -C "$REPO_ROOT" mv "$mirror_old" "$mirror_new" +echo " moved in canonical and mirror" + +# Rewrite references repo-wide across tracked files, except archived session +# records (history) and the two files just renamed (already carry the new name +# in their path; their bodies get the same stem rewrite as everything else). +rewritten=0 +while IFS= read -r f; do + case "$f" in + */sessions/*) continue ;; # history — never edit + esac + # Token-boundary replace: not preceded/followed by an identifier char or '-'. + # Rewrite the hyphenated stem, then the underscored variant when it differs. + PERL_OLD="$old_stem" PERL_NEW="$new_stem" perl -i -pe \ + 's/(?/dev/null || true + if [ "$old_us" != "$old_stem" ]; then + PERL_OLD="$old_us" PERL_NEW="$new_us" perl -i -pe \ + 's/(?/dev/null || true + fi +done < <(git -C "$REPO_ROOT" grep -lI --untracked -e "$old_stem" -e "$old_us" 2>/dev/null || true) + +# Count files that still changed (git sees them as modified). +rewritten="$(git -C "$REPO_ROOT" status --porcelain | grep -c '^ *M' || true)" +echo " rewrote references (${rewritten} file(s) modified, sessions/ left as history)" + +# Verify — best-effort; skips cleanly when the checkers aren't present (tests). +status=0 +if [ -f "$REPO_ROOT/scripts/workflow-integrity.py" ]; then + echo " verify: workflow-integrity" + python3 "$REPO_ROOT/scripts/workflow-integrity.py" || status=$? +fi +if [ -f "$REPO_ROOT/scripts/sync-check.sh" ]; then + echo " verify: sync-check" + bash "$REPO_ROOT/scripts/sync-check.sh" || status=$? +fi +if [ "$status" -ne 0 ]; then + echo "rename: done, but a verify step reported drift — review before committing." >&2 +fi +echo "rename: done." +exit 0 diff --git a/scripts/tests/rename-ai-artifact.bats b/scripts/tests/rename-ai-artifact.bats new file mode 100644 index 0000000..f00c92f --- /dev/null +++ b/scripts/tests/rename-ai-artifact.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats +# +# Tests for scripts/rename-ai-artifact.sh — rename an .ai artifact (workflow or +# script) across the canonical + mirror trees, rewriting every reference and +# leaving archived session records (history) untouched. +# +# Strategy: build a synthetic git repo mirroring the canonical/mirror layout in +# a temp dir, copy the real script in, and run it there. The script resolves +# the repo root from git, so it operates entirely on the fixture. The verify +# step (workflow-integrity / sync-check) is best-effort and skips when those +# scripts are absent, which they are in the fixture. + +REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" +SCRIPT_SRC="$REAL_REPO/scripts/rename-ai-artifact.sh" + +setup() { + REPO="$(mktemp -d -t rename-bats.XXXXXX)" + mkdir -p "$REPO/scripts" + cp "$SCRIPT_SRC" "$REPO/scripts/rename-ai-artifact.sh" + # Canonical + mirror trees. + for base in "$REPO/claude-templates/.ai" "$REPO/.ai"; do + mkdir -p "$base/workflows" "$base/scripts" "$base/sessions" + printf '* Summary\nThe foo workflow.\nTriggers: "run the foo workflow"\n' > "$base/workflows/foo.org" + printf '#+TITLE: Index\n- =foo.org= — the foo workflow.\n- =foobar.org= — unrelated, must survive.\n' > "$base/workflows/INDEX.org" + printf '* Summary\nThe foobar workflow (must not be touched by a foo rename).\n' > "$base/workflows/foobar.org" + printf '#!/usr/bin/env python3\n# foo-helper for the foo workflow\nprint("foo-helper")\n' > "$base/scripts/foo-helper.py" + printf 'Old session mentioning foo and foo.org — this is history.\n' > "$base/sessions/2026-01-01-old.org" + done + printf 'See foo.org and foo-helper.py. Also foobar.org stays.\n' > "$REPO/notes.org" + ( cd "$REPO" && git init -q && git add -A && git -c user.email=t@t -c user.name=t commit -qm init ) +} + +teardown() { + rm -rf "$REPO" +} + +run_rename() { # OLD NEW + ( cd "$REPO" && bash scripts/rename-ai-artifact.sh "$1" "$2" ) +} + +# --- Normal: workflow rename across both trees --- + +@test "rename: moves a workflow in both canonical and mirror" { + run run_rename foo.org bar.org + [ "$status" -eq 0 ] + [ -f "$REPO/claude-templates/.ai/workflows/bar.org" ] + [ -f "$REPO/.ai/workflows/bar.org" ] + [ ! -e "$REPO/claude-templates/.ai/workflows/foo.org" ] + [ ! -e "$REPO/.ai/workflows/foo.org" ] +} + +@test "rename: rewrites references in the index and prose" { + run_rename foo.org bar.org + grep -q "=bar.org=" "$REPO/.ai/workflows/INDEX.org" + grep -q "run the bar workflow" "$REPO/.ai/workflows/bar.org" + grep -q "bar.org" "$REPO/notes.org" + ! grep -q "foo.org" "$REPO/notes.org" +} + +# --- The kernel: history is untouched, near-miss names survive --- + +@test "rename: archived session records are left as history" { + run_rename foo.org bar.org + grep -q "mentioning foo and foo.org" "$REPO/.ai/sessions/2026-01-01-old.org" + grep -q "mentioning foo and foo.org" "$REPO/claude-templates/.ai/sessions/2026-01-01-old.org" +} + +@test "rename: a longer name sharing the prefix is not corrupted" { + run_rename foo.org bar.org + # foobar.org and its index row must be intact — token-boundary matching. + [ -f "$REPO/.ai/workflows/foobar.org" ] + grep -q "=foobar.org=" "$REPO/.ai/workflows/INDEX.org" + grep -q "foobar.org stays" "$REPO/notes.org" +} + +# --- Script artifact (no .org extension to strip cleanly) --- + +@test "rename: renames a script artifact and its references" { + run run_rename foo-helper.py baz-helper.py + [ "$status" -eq 0 ] + [ -f "$REPO/.ai/scripts/baz-helper.py" ] + [ -f "$REPO/claude-templates/.ai/scripts/baz-helper.py" ] + grep -q "baz-helper.py" "$REPO/notes.org" + ! grep -q "foo-helper" "$REPO/notes.org" +} + +@test "rename: also rewrites the underscore module-name variant" { + # A python test imports the hyphenated script under an underscored module + # name, the way importlib.spec_from_file_location does. Renaming the script + # must update both the hyphen path and the underscore module name. + for base in "$REPO/claude-templates/.ai" "$REPO/.ai"; do + printf 'spec_from_file_location("foo_helper", "foo-helper.py")\n' \ + > "$base/scripts/uses-helper.py" + done + ( cd "$REPO" && git add -A && git -c user.email=t@t -c user.name=t commit -qm add ) + run run_rename foo-helper.py baz-helper.py + [ "$status" -eq 0 ] + grep -q 'spec_from_file_location("baz_helper", "baz-helper.py")' "$REPO/.ai/scripts/uses-helper.py" + ! grep -q "foo_helper" "$REPO/.ai/scripts/uses-helper.py" +} + +# --- Errors --- + +@test "rename: refuses when the source artifact does not exist" { + run run_rename nope.org bar.org + [ "$status" -ne 0 ] + [[ "$output" == *"not found"* ]] +} + +@test "rename: refuses when the target already exists" { + run run_rename foo.org foobar.org + [ "$status" -ne 0 ] + [[ "$output" == *"exists"* ]] +} + +@test "rename: refuses bad usage (missing args)" { + run bash "$REPO/scripts/rename-ai-artifact.sh" only-one-arg + [ "$status" -ne 0 ] +} -- cgit v1.2.3