run.sh 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. #!/usr/bin/env bash
  2. # Self-test for sqlite-ops: frontmatter contract, reference wiring, script behaviour.
  3. #
  4. # Fully offline and self-contained - the only external need is a working Python
  5. # (stdlib sqlite3), which every supported platform has. Fixtures are synthesized
  6. # in a temp dir, so no binary fixtures live in the repo.
  7. #
  8. # Usage: bash tests/run.sh
  9. # Exit: 0 all pass, 1 one or more failures
  10. set -uo pipefail
  11. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  12. SKILL="$(dirname "$HERE")"
  13. S="$SKILL/scripts"
  14. R="$SKILL/references"
  15. MD="$SKILL/SKILL.md"
  16. # Windows Store python3 is a stub that exits non-zero - probe for one that runs.
  17. PYTHON=""
  18. for c in python python3 py; do
  19. if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
  20. done
  21. if [[ -z "$PYTHON" ]]; then
  22. echo " SKIP no working python found - sqlite-ops suite not run" >&2
  23. exit 0
  24. fi
  25. SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
  26. PASS=0; FAIL=0
  27. ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; }
  28. no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; }
  29. expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
  30. expect_has() { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
  31. # Case-insensitive literal file search WITHOUT grep. GNU grep 3.0 (the build
  32. # shipped with Git Bash on Windows) ABORTS with SIGABRT on `-i` combined with
  33. # `-F` - it exits 134, which reads as "no match" and silently fails every
  34. # assertion. Pure-bash matching sidesteps the bug and is portable.
  35. file_has() { # $1=label $2=needle $3=file
  36. local hay needle
  37. hay="$(tr '[:upper:]' '[:lower:]' < "$3")"
  38. needle="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
  39. case "$hay" in *"$needle"*) ok "$1";; *) no "$1 (missing '$2')";; esac
  40. }
  41. echo "=== sqlite-ops self-test ==="
  42. # ── frontmatter contract ────────────────────────────────────────────────────
  43. # CONTRACT: these assertions police this skill's OWN frontmatter. The skill was
  44. # de-Pythonised on 2026-08-04 precisely because a Python-pinned description and
  45. # compatibility line suppressed it in TypeScript/Worker contexts. If you edit the
  46. # frontmatter, keep it engine-agnostic or these fail on purpose.
  47. echo "-- frontmatter --"
  48. fm="$(sed -n '2,/^---$/p' "$MD")"
  49. expect_has "name is sqlite-ops" "name: sqlite-ops" "$fm"
  50. expect_has "license MIT" "license: MIT" "$fm"
  51. expect_has "metadata.author claude-mods" "author: claude-mods" "$fm"
  52. # related-skills must be a comma-separated STRING under metadata, never an array
  53. # (naming-conventions.md + SKILL-SUBAGENT-REFERENCE.md). doc-drift.sh also checks
  54. # that every skill named here exists on disk.
  55. expect_has "related-skills present" "related-skills:" "$fm"
  56. case "$fm" in
  57. *"related-skills: \""*) ok "related-skills is a quoted string, not an array";;
  58. *"related-skills: ["*) no "related-skills is a YAML array (must be a comma-separated string)";;
  59. *) no "related-skills not in the expected string form";;
  60. esac
  61. for peer in sql-ops perf-ops cloudflare-ops postgres-ops; do
  62. expect_has "related-skills names $peer" "$peer" "$fm"
  63. done
  64. # The de-Pythonisation guard: description and compatibility must NOT scope the
  65. # skill to Python. A Python-only description hides this skill from Worker/TS work.
  66. case "$fm" in
  67. *"in Python projects"*) no "description still scopes the skill to Python projects";;
  68. *) ok "description is not scoped to Python projects";;
  69. esac
  70. case "$fm" in
  71. *"compatibility: \"Requires Python"*) no "compatibility still pins the skill to Python";;
  72. *) ok "compatibility does not pin the skill to Python";;
  73. esac
  74. expect_has "compatibility states engine-agnostic guidance" "engine-agnostic" "$fm"
  75. # Description budget: tests/validate.sh HARD-FAILS over 700 chars for
  76. # description + when_to_use combined. Catch it here rather than at the repo gate.
  77. budget="$("$PYTHON" - "$MD" <<'PY'
  78. import sys, pathlib
  79. lines = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8-sig").splitlines()
  80. marks = [i for i, l in enumerate(lines) if l.strip() == "---"]
  81. body = "\n".join(lines[marks[0] + 1:marks[1]])
  82. try:
  83. import yaml
  84. fm = yaml.safe_load(body) or {}
  85. total = len(str(fm.get("description") or "")) + len(str(fm.get("when_to_use") or ""))
  86. except ImportError:
  87. # Fallback: measure the raw single-line values without a YAML parser.
  88. total = 0
  89. for line in body.splitlines():
  90. for key in ("description:", "when_to_use:"):
  91. if line.startswith(key):
  92. total += len(line[len(key):].strip().strip('"').strip("'"))
  93. print(total)
  94. PY
  95. )"
  96. if [[ -n "$budget" && "$budget" -le 700 ]]; then
  97. ok "description budget ${budget}/700 chars"
  98. else
  99. no "description budget ${budget}/700 chars (validate.sh hard-fails over 700)"
  100. fi
  101. # ── trigger keywords ────────────────────────────────────────────────────────
  102. # These are the terms that must route a performance/D1 question here. They were
  103. # absent before 2026-08-04, which is why a live D1 investigation never loaded
  104. # this skill. Removing one silently un-routes that class of question.
  105. echo "-- description triggers --"
  106. desc_line="$(grep -m1 '^description:' "$MD")"
  107. for trigger in "EXPLAIN QUERY PLAN" "covering index" "rows_read" "sql_duration_ms" \
  108. "D1" "wrangler d1" "node:sqlite" "better-sqlite3" "fts5" "trigram" \
  109. "sqlite_stat1" "ANALYZE" "SQLITE_BUSY" "WAL" "STRICT tables"; do
  110. case "$desc_line" in
  111. *"$trigger"*) ok "trigger: $trigger";;
  112. *) no "trigger MISSING from description: $trigger";;
  113. esac
  114. done
  115. # ── references exist and are cited ──────────────────────────────────────────
  116. echo "-- references --"
  117. for ref in query-performance d1-edge d1-production-patterns concurrency-durability \
  118. schema-design schema-patterns migration-patterns feature-modules hosts \
  119. async-patterns operations testing; do
  120. if [[ -f "$R/$ref.md" ]]; then ok "reference exists: $ref.md"; else no "reference MISSING: $ref.md"; fi
  121. # SKILL-RESOURCE-PROTOCOL: an uncited reference is dead weight the router never finds.
  122. file_has "SKILL.md cites $ref.md" "references/$ref.md" "$MD"
  123. done
  124. # ── measured facts that must not silently vanish ────────────────────────────
  125. # These numbers come from a live D1 investigation (2026-08-04) and are the
  126. # evidence behind the skill's core lesson. They are labelled as one database's
  127. # worked example, NOT as constants - but if an edit drops them, the reasoning
  128. # loses its grounding, so they are pinned here.
  129. echo "-- worked-example facts --"
  130. file_has "covering-index before figure (171.83 ms)" "171.83" "$R/query-performance.md"
  131. file_has "covering-index after figure (6.75 ms)" "6.75" "$R/query-performance.md"
  132. file_has "invisible-aggregate figure (28.09 ms)" "28.09" "$R/query-performance.md"
  133. file_has "numbers labelled as a worked example" "worked example" "$R/query-performance.md"
  134. file_has "read-only proof technique documented" "read-only proof" "$R/query-performance.md"
  135. file_has "SCAN vs SEARCH distinction" "COVERING INDEX" "$R/query-performance.md"
  136. file_has "sqlite_stat1 with/without check" "sqlite_stat1" "$R/query-performance.md"
  137. echo "-- d1 facts --"
  138. file_has "rows_read documented" "rows_read" "$R/d1-edge.md"
  139. file_has "sql_duration_ms documented" "sql_duration_ms" "$R/d1-edge.md"
  140. file_has "one-line statement rule" "SQLITE_ERROR 7500" "$R/d1-edge.md"
  141. file_has "100 bound-parameter cap" "100" "$R/d1-edge.md"
  142. file_has "SQLITE_AUTH introspection block" "SQLITE_AUTH" "$R/d1-edge.md"
  143. # FTS5 on D1 was NOT confirmable read-only. Recording it as unknown is the honest
  144. # result; an edit that replaces this with a confident claim is a regression.
  145. file_has "FTS5-on-D1 recorded as unknown" "could not be confirmed read-only" "$R/d1-edge.md"
  146. file_has "d1 insights covered" "d1 insights" "$R/d1-edge.md"
  147. file_has "Sessions API / replication" "withSession" "$R/d1-edge.md"
  148. file_has "Time Travel covered" "time-travel" "$R/d1-edge.md"
  149. file_has "cold-run variance figure" "2,495" "$R/d1-edge.md"
  150. echo "-- engine-agnostic coverage --"
  151. file_has "concurrency: BUSY vs LOCKED" "SQLITE_LOCKED" "$R/concurrency-durability.md"
  152. file_has "concurrency: BEGIN IMMEDIATE" "BEGIN IMMEDIATE" "$R/concurrency-durability.md"
  153. file_has "schema: foreign_keys OFF by default" "OFF by default" "$R/schema-design.md"
  154. file_has "schema: STRICT tables" "STRICT" "$R/schema-design.md"
  155. file_has "migrations: 12-step dance" "12-step" "$R/migration-patterns.md"
  156. file_has "features: trigram tokenizer" "trigram" "$R/feature-modules.md"
  157. file_has "hosts: node:sqlite" "node:sqlite" "$R/hosts.md"
  158. file_has "hosts: bun:sqlite" "bun:sqlite" "$R/hosts.md"
  159. file_has "operations: VACUUM INTO" "VACUUM INTO" "$R/operations.md"
  160. file_has "testing: deterministic seeding" "deterministic" "$R/testing.md"
  161. # ── script contract (SKILL-RESOURCE-PROTOCOL) ───────────────────────────────
  162. echo "-- eqp-triage.py contract --"
  163. "$PYTHON" -m py_compile "$S/eqp-triage.py" 2>/dev/null && ok "py_compile eqp-triage.py" \
  164. || no "py_compile eqp-triage.py"
  165. "$PYTHON" "$S/eqp-triage.py" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
  166. help_out="$("$PYTHON" "$S/eqp-triage.py" --help 2>/dev/null)"
  167. expect_has "--help has EXAMPLES" "EXAMPLES" "$help_out"
  168. file_has "SKILL.md cites the script with a worked invocation" "eqp-triage.py --db" "$MD"
  169. echo "-- eqp-triage.py exit codes --"
  170. "$PYTHON" "$S/eqp-triage.py" --bogus-flag </dev/null >/dev/null 2>&1
  171. expect_exit "unknown flag -> 2" 2 $?
  172. "$PYTHON" "$S/eqp-triage.py" --sql "SELECT 1" </dev/null >/dev/null 2>&1
  173. expect_exit "--sql without --db -> 2" 2 $?
  174. "$PYTHON" "$S/eqp-triage.py" --db "$SB/nope.db" --sql "SELECT 1" </dev/null >/dev/null 2>&1
  175. expect_exit "missing database -> 3" 3 $?
  176. "$PYTHON" "$S/eqp-triage.py" --plan-file "$SB/nope.txt" </dev/null >/dev/null 2>&1
  177. expect_exit "missing plan file -> 3" 3 $?
  178. printf 'not a plan at all\n' | "$PYTHON" "$S/eqp-triage.py" >/dev/null 2>&1
  179. expect_exit "unparseable input -> 4" 4 $?
  180. # ── behavioural: real plans against a synthesized database ──────────────────
  181. echo "-- eqp-triage.py behaviour --"
  182. "$PYTHON" - "$SB/t.db" <<'PY'
  183. import sqlite3, sys
  184. conn = sqlite3.connect(sys.argv[1])
  185. conn.execute("CREATE TABLE q_product (id INTEGER PRIMARY KEY, org TEXT, "
  186. "product_id TEXT, pad TEXT)")
  187. conn.execute("CREATE INDEX q_product_org ON q_product(org)")
  188. conn.executemany("INSERT INTO q_product (org, product_id, pad) VALUES (?,?,?)",
  189. [("org%d" % (i % 50), "p%d" % i, "x" * 200) for i in range(2000)])
  190. conn.commit(); conn.close()
  191. PY
  192. [[ -f "$SB/t.db" ]] && ok "fixture database synthesized" || no "fixture database synthesized"
  193. # Leading-wildcard LIKE over a non-covering index: the exact shape from the
  194. # worked example. Must be flagged HIGH and exit 10.
  195. out="$("$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
  196. --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%org1%'" 2>/dev/null)"
  197. rc=$?
  198. expect_exit "non-covering scan -> 10" 10 "$rc"
  199. expect_has "flags the scan as HIGH" "HIGH" "$out"
  200. # Add the covering index; the scan must be reclassified as LOW (acceptable).
  201. "$PYTHON" -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); \
  202. c.execute('CREATE INDEX q_org_prod ON q_product(org, product_id)'); c.commit()" "$SB/t.db"
  203. out="$("$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
  204. --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%org1%'" 2>/dev/null)"
  205. expect_has "covering scan classified LOW" "LOW covering-scan" "$out"
  206. # An indexed equality seek is clean: no actionable findings, exit 0.
  207. "$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
  208. --sql "SELECT product_id FROM q_product WHERE org = 'x'" >/dev/null 2>&1
  209. expect_exit "indexed seek -> 0" 0 $?
  210. # Invalid SQL is a validation error, not a crash.
  211. "$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --sql "SELECT FROM WHERE" >/dev/null 2>&1
  212. expect_exit "bad SQL -> 4" 4 $?
  213. # The script must never be able to write to the database it analyses.
  214. "$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
  215. --sql "DELETE FROM q_product" >/dev/null 2>&1
  216. rc=$?
  217. rows="$("$PYTHON" -c "import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute(
  218. 'SELECT count(*) FROM q_product').fetchone()[0])" "$SB/t.db")"
  219. [[ "$rows" == "2000" ]] && ok "read-only guard: rows intact after a DELETE statement" \
  220. || no "read-only guard FAILED: row count now $rows (want 2000)"
  221. echo "-- eqp-triage.py input formats --"
  222. # Raw sqlite3 CLI text with tree-drawing characters
  223. out="$(printf 'QUERY PLAN\n|--SCAN q_product\n`--USE TEMP B-TREE FOR GROUP BY\n' \
  224. | "$PYTHON" "$S/eqp-triage.py" --quiet 2>/dev/null)"
  225. expect_has "parses raw sqlite3 tree output" "table-scan" "$out"
  226. expect_has "detects temp B-tree for GROUP BY" "temp-btree-group" "$out"
  227. # Legacy pipe-delimited format
  228. out="$(printf '0|0|0|SCAN TABLE q_product\n' | "$PYTHON" "$S/eqp-triage.py" --quiet 2>/dev/null)"
  229. expect_has "parses legacy 0|0|0| format" "table-scan" "$out"
  230. # wrangler d1 execute --json shape
  231. out="$(printf '[{"results":[{"id":2,"parent":0,"detail":"SCAN q_product USING INDEX q_product_org"}],"success":true,"meta":{"rows_read":58433}}]' \
  232. | "$PYTHON" "$S/eqp-triage.py" --json 2>/dev/null)"
  233. expect_has "parses wrangler --json output" "noncovering-scan" "$out"
  234. expect_has "--json emits the versioned schema" '"schema": "claude-mods.sqlite-ops.eqp/v1"' "$out"
  235. expect_has "--json envelope has data key" '"data"' "$out"
  236. expect_has "--json envelope has meta key" '"meta"' "$out"
  237. # --strict promotes low-severity findings to actionable
  238. printf 'SCAN t USING COVERING INDEX ix\n' | "$PYTHON" "$S/eqp-triage.py" --quiet >/dev/null 2>&1
  239. expect_exit "covering scan alone -> 0 by default" 0 $?
  240. printf 'SCAN t USING COVERING INDEX ix\n' | "$PYTHON" "$S/eqp-triage.py" --quiet --strict >/dev/null 2>&1
  241. expect_exit "covering scan -> 10 under --strict" 10 $?
  242. # Stream separation: with --quiet, stdout carries findings only.
  243. so="$(printf 'SCAN t\n' | "$PYTHON" "$S/eqp-triage.py" --quiet 2>/dev/null)"
  244. case "$so" in
  245. HIGH*) ok "stdout is data-only under --quiet";;
  246. *) no "stdout polluted with non-data output: $so";;
  247. esac
  248. echo ""
  249. echo "=== $PASS passed, $FAIL failed ==="
  250. [[ "$FAIL" -eq 0 ]] || exit 1
  251. exit 0