aboutsummaryrefslogtreecommitdiff
path: root/working/hook-fail-open/validate-el.sh.superseded
blob: 045c93f9bedf8557f2bad223973c2fc4c13066a6 (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
#!/usr/bin/env bash
# Validate and test .el files after Edit/Write/MultiEdit.
# PostToolUse hook: receives tool-call JSON on stdin.
#
# On success: exit 0 silent.
# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
# sees a structured error in its context, THEN exit 2 to block the tool
# pipeline. stderr still echoes the error for terminal visibility.
#
# Phase 1: check-parens + byte-compile
# Phase 2: for non-test .el files, run matching tests/test-<stem>*.el

set -u

# Emit a JSON failure payload and exit 2. Arguments:
#   $1 — short failure type (e.g. "PAREN CHECK FAILED")
#   $2 — file path
#   $3 — emacs output (error body), always sent to Claude in additionalContext
#   $4 — optional compact terminal echo; when set, the terminal shows this
#        instead of the full $3 (Claude still gets the full $3). Used by the
#        test runner so a failing suite prints a short summary to the pane
#        rather than dumping every ERT backtrace.
fail_json() {
    local ctx
    ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
        | jq -Rs .)"
    cat <<EOF
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
EOF
    printf '%s: %s\n%s\n' "$1" "$2" "${4:-$3}" >&2
    exit 2
}

# Report something without failing the edit.  Used when the hook declines to do
# work it would normally do: a silent decline is indistinguishable from a clean
# run, which is how the over-cap skip below hid missing test runs for months.
#   $1 — message for both the terminal and Claude's context
notice_json() {
    local ctx
    ctx="$(printf '%s' "$1" | jq -Rs .)"
    cat <<EOF
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
EOF
    printf '%s\n' "$1" >&2
}

# Portable project root: prefer Claude Code's env var, fall back to deriving
# from this script's location ($project/.claude/hooks/validate-el.sh).
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"

f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
[ -z "$f" ] && exit 0
[ "${f##*.}" = "el" ] || exit 0

MAX_AUTO_TEST_FILES=20  # skip if more matches than this (large test suites)

# --- Phase 1: syntax + byte-compile ---
case "$f" in
  */init.el|*/early-init.el)
    # Byte-compile here would load the full package graph. Parens only.
    if ! output="$(emacs --batch --no-site-file --no-site-lisp "$f" \
                     --eval '(check-parens)' 2>&1)"; then
      fail_json "PAREN CHECK FAILED" "$f" "$output"
    fi
    ;;
  *.el)
    # -L the file's own directory (and a sibling project root for files
    # under a tests/ subdir) so cross-project edits compile against their
    # own modules, not just this project's.
    if ! output="$(emacs --batch --no-site-file --no-site-lisp \
                     --eval '(setq load-prefer-newer t)' \
                     -L "$(dirname "$f")" \
                     -L "$(dirname "$f")/.." \
                     -L "$PROJECT_ROOT" \
                     -L "$PROJECT_ROOT/modules" \
                     -L "$PROJECT_ROOT/tests" \
                     -L "$PROJECT_ROOT/themes" \
                     --eval '(package-initialize)' \
                     "$f" \
                     --eval '(check-parens)' \
                     --eval "(or (byte-compile-file \"$f\") (kill-emacs 1))" 2>&1)"; then
      fail_json "VALIDATION FAILED" "$f" "$output"
    fi
    ;;
esac

# --- Phase 2: test runner ---
# Determine which tests (if any) apply to this edit. Works for projects with
# source at root, in modules/, or elsewhere — stem-based test lookup is the
# common pattern.
tests=()
case "$f" in
  */init.el|*/early-init.el)
    : # Phase 1 handled it; skip test runner
    ;;
  "$PROJECT_ROOT/tests/testutil-"*.el)
    stem="$(basename "${f%.el}")"
    stem="${stem#testutil-}"
    mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort)
    ;;
  "$PROJECT_ROOT/tests/test-"*.el)
    tests=("$f")
    ;;
  *.el)
    # Any other .el under the project — find matching tests by stem
    stem="$(basename "${f%.el}")"
    mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort)
    ;;
esac

count="${#tests[@]}"
if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then
  load_args=()
  for t in "${tests[@]}"; do load_args+=("-l" "$t"); done
  if ! output="$(emacs --batch --no-site-file --no-site-lisp \
                   --eval '(setq load-prefer-newer t)' \
                   -L "$PROJECT_ROOT" \
                   -L "$PROJECT_ROOT/modules" \
                   -L "$PROJECT_ROOT/tests" \
                   -L "$PROJECT_ROOT/themes" \
                   --eval '(package-initialize)' \
                   --eval "(cd \"$PROJECT_ROOT/tests\")" \
                   -l ert "${load_args[@]}" \
                   --eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then
    # Terminal gets a compact summary (the run tally + the failing test names);
    # Claude still gets the full backtrace via additionalContext.  Keeps the
    # pane from drowning in ERT stack frames on every red test.
    summary="$(printf '%s\n' "$output" \
        | grep -E '^Ran [0-9]+ tests|unexpected results:|^[[:space:]]+FAILED' || true)"
    [ -n "$summary" ] && summary="${summary}"$'\n'"(full backtrace in Claude's context)"
    fail_json "TESTS FAILED ($count test file(s))" "$f" "$output" "$summary"
  fi
elif [ "$count" -gt "$MAX_AUTO_TEST_FILES" ]; then
  # Say so.  Running this many files on every keystroke is not wanted, but a
  # silent skip reads exactly like a passing run, so an edit to one of the big
  # families looked verified when nothing had been tested.
  notice_json "TESTS SKIPPED: $count test files match $(basename "$f") — over the \
auto-run cap of $MAX_AUTO_TEST_FILES, so none ran. This is NOT a pass. Run them \
explicitly, e.g. make test-file FILE=$(basename "${tests[0]}")"
fi

exit 0