|
| 1 | +#!/usr/bin/env bats |
| 2 | +# |
| 3 | +# Tests for hooks/write-time-quality.sh — pins the per-language quality |
| 4 | +# heuristics (Go fmt.Println in library code, Python bare except / eval / |
| 5 | +# missing return type hints, shell set -euo pipefail / unquoted vars), the |
| 6 | +# IS_TEST exemption logic, the tool-name filter, and JSON output shape. |
| 7 | +# The hook had zero coverage; a regression in any heuristic would silently |
| 8 | +# drop signal on every Edit/Write. |
| 9 | + |
| 10 | +setup() { |
| 11 | + load helpers/test_helper |
| 12 | + _helper_setup |
| 13 | + HOOK="$REPO_ROOT/hooks/write-time-quality.sh" |
| 14 | +} |
| 15 | + |
| 16 | +teardown() { |
| 17 | + _helper_teardown |
| 18 | +} |
| 19 | + |
| 20 | +# fire FILE_PATH TOOL_NAME — pipes a tool_use payload at the hook with the |
| 21 | +# given file_path and tool name, captures combined stdout+stderr in $output |
| 22 | +# and the exit status in $status. The hook prints JSON on stdout and a |
| 23 | +# human-readable digest on stderr; merging both lets warning-substring |
| 24 | +# assertions match either stream while JSON-shape tests use $stdout_only. |
| 25 | +fire() { |
| 26 | + local file="$1" |
| 27 | + local tool="${2:-Write}" |
| 28 | + local payload |
| 29 | + payload=$(jq -n --arg t "$tool" --arg f "$file" \ |
| 30 | + '{"tool_name":$t,"tool_input":{"file_path":$f}}') |
| 31 | + run bash -c 'printf "%s" "$1" | bash "$2" 2>&1' -- "$payload" "$HOOK" |
| 32 | +} |
| 33 | + |
| 34 | +# fire_stdout FILE_PATH TOOL_NAME — same as fire but captures only stdout |
| 35 | +# (the JSON envelope), used for JSON-shape assertions where the stderr |
| 36 | +# digest would corrupt jq parsing. |
| 37 | +fire_stdout() { |
| 38 | + local file="$1" |
| 39 | + local tool="${2:-Write}" |
| 40 | + local payload |
| 41 | + payload=$(jq -n --arg t "$tool" --arg f "$file" \ |
| 42 | + '{"tool_name":$t,"tool_input":{"file_path":$f}}') |
| 43 | + run bash -c 'printf "%s" "$1" | bash "$2" 2>/dev/null' -- "$payload" "$HOOK" |
| 44 | +} |
| 45 | + |
| 46 | +@test "non-Edit/Write tool is silently skipped" { |
| 47 | + local f="$TMP_TEST_DIR/x.go" |
| 48 | + cat > "$f" <<'EOF' |
| 49 | +package main |
| 50 | +func main() { fmt.Println("hi") } |
| 51 | +EOF |
| 52 | + fire "$f" Read |
| 53 | + [ "$status" -eq 0 ] |
| 54 | + [ -z "$output" ] |
| 55 | +} |
| 56 | + |
| 57 | +@test "missing FILE_PATH is silently skipped" { |
| 58 | + local payload='{"tool_name":"Write","tool_input":{}}' |
| 59 | + run bash -c 'printf "%s" "$1" | bash "$2" 2>&1' -- "$payload" "$HOOK" |
| 60 | + [ "$status" -eq 0 ] |
| 61 | + [ -z "$output" ] |
| 62 | +} |
| 63 | + |
| 64 | +@test "non-existent file is silently skipped" { |
| 65 | + fire "$TMP_TEST_DIR/does-not-exist.go" Write |
| 66 | + [ "$status" -eq 0 ] |
| 67 | + [ -z "$output" ] |
| 68 | +} |
| 69 | + |
| 70 | +@test "unsupported extension is silently skipped" { |
| 71 | + local f="$TMP_TEST_DIR/notes.txt" |
| 72 | + echo "hello" > "$f" |
| 73 | + fire "$f" |
| 74 | + [ "$status" -eq 0 ] |
| 75 | + [ -z "$output" ] |
| 76 | +} |
| 77 | + |
| 78 | +@test "AGENTOPS_HOOKS_DISABLED kill switch short-circuits" { |
| 79 | + local f="$TMP_TEST_DIR/lib.py" |
| 80 | + cat > "$f" <<'EOF' |
| 81 | +def f(x): |
| 82 | + try: |
| 83 | + return x |
| 84 | + except: |
| 85 | + pass |
| 86 | +EOF |
| 87 | + local payload |
| 88 | + payload=$(jq -n --arg t "Write" --arg f "$f" \ |
| 89 | + '{"tool_name":$t,"tool_input":{"file_path":$f}}') |
| 90 | + run bash -c 'printf "%s" "$1" | AGENTOPS_HOOKS_DISABLED=1 bash "$2" 2>&1' -- "$payload" "$HOOK" |
| 91 | + [ "$status" -eq 0 ] |
| 92 | + [ -z "$output" ] |
| 93 | +} |
| 94 | + |
| 95 | +@test "Go: fmt.Println in non-main package emits warning" { |
| 96 | + local f="$TMP_TEST_DIR/util.go" |
| 97 | + cat > "$f" <<'EOF' |
| 98 | +package util |
| 99 | +
|
| 100 | +import "fmt" |
| 101 | +
|
| 102 | +func Hello() { |
| 103 | + fmt.Println("noisy library code") |
| 104 | +} |
| 105 | +EOF |
| 106 | + fire "$f" |
| 107 | + [ "$status" -eq 0 ] |
| 108 | + [[ "$output" == *"fmt.Print call"* ]] |
| 109 | + [[ "$output" == *"library code"* ]] |
| 110 | + # JSON envelope must parse and report >0 warnings (stdout-only to skip stderr) |
| 111 | + fire_stdout "$f" |
| 112 | + echo "$output" | jq -e '.hookSpecificOutput.warning_count > 0' >/dev/null |
| 113 | + echo "$output" | jq -e '.hookSpecificOutput.language == "go"' >/dev/null |
| 114 | +} |
| 115 | + |
| 116 | +@test "Go: fmt.Println in package main produces no warning" { |
| 117 | + local f="$TMP_TEST_DIR/main.go" |
| 118 | + cat > "$f" <<'EOF' |
| 119 | +package main |
| 120 | +
|
| 121 | +import "fmt" |
| 122 | +
|
| 123 | +func main() { |
| 124 | + fmt.Println("hi") |
| 125 | +} |
| 126 | +EOF |
| 127 | + fire "$f" |
| 128 | + [[ "$output" != *"fmt.Print call"* ]] |
| 129 | +} |
| 130 | + |
| 131 | +@test "Go: fmt.Println in *_test.go produces no fmt warning" { |
| 132 | + local f="$TMP_TEST_DIR/util_test.go" |
| 133 | + cat > "$f" <<'EOF' |
| 134 | +package util_test |
| 135 | +
|
| 136 | +import ( |
| 137 | + "fmt" |
| 138 | + "testing" |
| 139 | +) |
| 140 | +
|
| 141 | +func TestX(t *testing.T) { |
| 142 | + fmt.Println("test diag") |
| 143 | +} |
| 144 | +EOF |
| 145 | + fire "$f" |
| 146 | + [[ "$output" != *"fmt.Print call"* ]] |
| 147 | +} |
| 148 | + |
| 149 | +@test "Python: bare except: emits warning" { |
| 150 | + local f="$TMP_TEST_DIR/lib.py" |
| 151 | + cat > "$f" <<'EOF' |
| 152 | +def f(x): |
| 153 | + try: |
| 154 | + return x / 0 |
| 155 | + except: |
| 156 | + return 0 |
| 157 | +EOF |
| 158 | + fire "$f" |
| 159 | + [[ "$output" == *"bare except"* ]] |
| 160 | +} |
| 161 | + |
| 162 | +@test "Python: eval() outside test file emits warning" { |
| 163 | + local f="$TMP_TEST_DIR/runner.py" |
| 164 | + cat > "$f" <<'EOF' |
| 165 | +def run(code): |
| 166 | + return eval(code) |
| 167 | +EOF |
| 168 | + fire "$f" |
| 169 | + [[ "$output" == *"eval/exec"* ]] |
| 170 | +} |
| 171 | + |
| 172 | +@test "Python: eval() in test_*.py is exempted" { |
| 173 | + local f="$TMP_TEST_DIR/test_runner.py" |
| 174 | + cat > "$f" <<'EOF' |
| 175 | +def test_eval(): |
| 176 | + assert eval("1+1") == 2 |
| 177 | +EOF |
| 178 | + fire "$f" |
| 179 | + [[ "$output" != *"eval/exec"* ]] |
| 180 | +} |
| 181 | + |
| 182 | +@test "Python: public function without return type hint warns" { |
| 183 | + local f="$TMP_TEST_DIR/api.py" |
| 184 | + cat > "$f" <<'EOF' |
| 185 | +def public_func(x): |
| 186 | + return x |
| 187 | +
|
| 188 | +def _private(x): |
| 189 | + return x |
| 190 | +EOF |
| 191 | + fire "$f" |
| 192 | + [[ "$output" == *"return type hint"* ]] |
| 193 | +} |
| 194 | + |
| 195 | +@test "Shell: missing 'set -euo pipefail' warns" { |
| 196 | + local f="$TMP_TEST_DIR/script.sh" |
| 197 | + cat > "$f" <<'EOF' |
| 198 | +#!/usr/bin/env bash |
| 199 | +echo hi |
| 200 | +EOF |
| 201 | + fire "$f" |
| 202 | + [[ "$output" == *"set -euo pipefail"* ]] |
| 203 | +} |
| 204 | + |
| 205 | +@test "Shell: header with 'set -euo pipefail' suppresses warning" { |
| 206 | + local f="$TMP_TEST_DIR/script.sh" |
| 207 | + cat > "$f" <<'EOF' |
| 208 | +#!/usr/bin/env bash |
| 209 | +set -euo pipefail |
| 210 | +echo "hi" |
| 211 | +EOF |
| 212 | + fire "$f" |
| 213 | + [[ "$output" != *"missing 'set -euo pipefail'"* ]] |
| 214 | +} |
| 215 | + |
| 216 | +@test "Output JSON envelope includes file, language, warning_count, warnings array" { |
| 217 | + local f="$TMP_TEST_DIR/lib.py" |
| 218 | + cat > "$f" <<'EOF' |
| 219 | +def f(x): |
| 220 | + try: |
| 221 | + return eval(x) |
| 222 | + except: |
| 223 | + return None |
| 224 | +EOF |
| 225 | + fire_stdout "$f" |
| 226 | + echo "$output" | jq -e '.hookSpecificOutput.hookEventName == "write_time_quality"' >/dev/null |
| 227 | + echo "$output" | jq -e '.hookSpecificOutput.file' >/dev/null |
| 228 | + echo "$output" | jq -e '.hookSpecificOutput.language == "python"' >/dev/null |
| 229 | + echo "$output" | jq -e '.hookSpecificOutput.warnings | type == "array"' >/dev/null |
| 230 | + count=$(echo "$output" | jq '.hookSpecificOutput.warning_count') |
| 231 | + [ "$count" -ge 2 ] |
| 232 | +} |
0 commit comments