run.sh 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. #!/usr/bin/env bash
  2. # Self-test for the summon skill (scripts/summon.py).
  3. #
  4. # Offline-deterministic: builds a throwaway Claude Desktop dir tree in a temp
  5. # sandbox (HOME/USERPROFILE/APPDATA redirected), so no real account data is
  6. # read or written. Covers the selection/confirmation flow (--yes, --select,
  7. # piped stdin), the cp1252 UnicodeEncodeError regression, the toolbox modes
  8. # (rebind/recover/pick/doctor, incl. the worktree-repair hint), and the
  9. # distilled-handover flow (extraction skips tool blobs, cache hit/miss on
  10. # mtime, --no-distill, degrade paths via a PATH-shimmed fake `claude` —
  11. # no real LLM call is ever made by this suite), the pick --json inventory
  12. # envelope, and the in-chat picker asset (present + cited from SKILL.md).
  13. #
  14. # The behavioural checks live in test_summon.py — its pass/fail summary is the
  15. # primary signal. One shell-level check also runs after it (below): a
  16. # section-map drift gate that pins the docstring 'Sections:' list against the
  17. # body's `# ===` banner headers, so the deliberately-single-file script's map
  18. # cannot silently rot as it grows.
  19. #
  20. # Usage: bash tests/run.sh
  21. # Exit: 0 all pass, 1 one or more failures
  22. set -uo pipefail
  23. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  24. # Pick a python that actually executes — skips the Windows Store python3 stub.
  25. PYTHON=""
  26. for c in python python3 py; do
  27. if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
  28. done
  29. [[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
  30. # Run the full behavioural suite, then fall through to the shell-level
  31. # section-map drift gate (we deliberately do NOT `exec` the python here — the
  32. # gate must run afterwards and contribute to the combined exit code).
  33. SUMMON_PY_RC=0
  34. "$PYTHON" "$HERE/test_summon.py" || SUMMON_PY_RC=$?
  35. # --- section-map drift gate (summon.py docstring 'Sections:' ↔ # === banners) ---
  36. # summon.py is deliberately a single multi-thousand-line file
  37. # (docs/SKILL-RESOURCE-PROTOCOL.md: skill scripts ship as self-contained
  38. # portable units — do not split). Its module docstring carries a `Sections:`
  39. # map of the `# ===` banner headers so the file stays navigable. This gate
  40. # pins BOTH the docstring section count and the body banner count, so a
  41. # section added or removed on either side fails the build until the map is
  42. # reconciled. An empty parse on either side is a hard FAIL (never a silent
  43. # pass) — that is the rot mode this guard exists to catch: a docstring/map
  44. # format change that yields zero names.
  45. #
  46. # Matching is STRICT on alias-resolved first-token keys (upgraded from a
  47. # count gate per adversarial review — counts stay 14/13 under a rename, so
  48. # structural drift slipped by). The docstring and banners carry different
  49. # labels for a few sections ("DESIGN(term)"↔"DESIGN:", "Modes (…)"↔"Toolbox
  50. # modes:", "CLI entry"↔"Main"), so those are an explicit alias table, and
  51. # "Transcript/Distill" (implemented inline, banner-less) is an explicit
  52. # exception. Anything else that diverges — including a rename on either
  53. # side — is drift.
  54. GP=0; GF=0
  55. ok(){ GP=$((GP+1)); printf ' PASS %s\n' "$1"; }
  56. no(){ GF=$((GF+1)); printf ' FAIL %s\n' "$1"; }
  57. SRC="$HERE/../scripts/summon.py"
  58. # docstring section names: from the first `Sections:` line to the next `"""`.
  59. doc_sections="$(awk '
  60. !done && /Sections:/ { cap=1; sub(/.*Sections:[[:space:]]*/,"",$0); blob=blob $0 " "; next }
  61. cap { if (/"""/) { done=1; cap=0; next } blob=blob $0 " " }
  62. END { gsub(/·/,"\n",blob); n=split(blob,a,"\n");
  63. for (i=1;i<=n;i++){ s=a[i]; sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); sub(/\.$/,"",s); if (s!="") print s } }
  64. ' "$SRC")"
  65. dc="$(printf '%s\n' "$doc_sections" | grep -c . || true)"
  66. # body banner sections: the `# ===…===` header pairs — the name line that
  67. # sits between each opening banner and its closing banner.
  68. ban_sections="$(awk '
  69. /^# ={20,}$/ { saw=1; next }
  70. saw && /^# / { t=$0; sub(/^# +/,"",t); sub(/[[:space:]]+$/,"",t); print t }
  71. { saw=0 }
  72. ' "$SRC")"
  73. bc="$(printf '%s\n' "$ban_sections" | grep -c . || true)"
  74. # Normalize a section label to its comparison key: first token, with any
  75. # "(...)" suffix and trailing ":" stripped ("DESIGN(term)" -> "DESIGN",
  76. # "Toolbox modes: rebind…" -> "Toolbox").
  77. norm_key() { awk '{ t=$1; sub(/\(.*$/,"",t); sub(/:$/,"",t); print t }'; }
  78. doc_keys="$(printf '%s\n' "$doc_sections" | norm_key | sed \
  79. -e 's/^Modes$/Toolbox/' \
  80. -e 's/^CLI$/Main/' \
  81. | grep -v '^Transcript' | sort -u)"
  82. ban_keys="$(printf '%s\n' "$ban_sections" | norm_key | sort -u)"
  83. if [[ "$dc" -eq 0 ]]; then
  84. no "section-map (docstring) EMPTY PARSE: 0 sections — 'Sections:' line missing or unparseable"
  85. elif [[ "$bc" -eq 0 ]]; then
  86. no "section-map (body) EMPTY PARSE: 0 banners — banner format changed"
  87. elif [[ "$doc_keys" == "$ban_keys" ]]; then
  88. ok "section-map: docstring ($dc) <-> banners ($bc) match after alias resolution"
  89. else
  90. diverged="$(comm -3 <(printf '%s\n' "$doc_keys") <(printf '%s\n' "$ban_keys") | tr -d '\t' | paste -sd', ' -)"
  91. no "section-map DRIFT — docstring and banners disagree on: ${diverged:-<unknown>}"
  92. fi
  93. # Boxed-banner parity: each banner is a 3-line box (=== / title / ===); an
  94. # odd ruler-line count means a box's closing line was deleted.
  95. rulers="$(grep -cE '^# ={20,}$' "$SRC" || true)"
  96. if (( rulers > 0 && rulers % 2 == 0 )); then
  97. ok "section-map: $rulers banner ruler lines (even — boxes intact)"
  98. else
  99. no "section-map: ruler-line count $rulers (odd or zero) — a banner box is broken"
  100. fi
  101. echo "=== section-map drift gate: $GP passed, $GF failed ==="
  102. # Combine: the Python behavioural suite AND the shell section-map gate pass.
  103. [[ "$SUMMON_PY_RC" -eq 0 && "$GF" -eq 0 ]] || exit 1
  104. exit 0