From 712029e30272916758a4fa36af63f31178d265a5 Mon Sep 17 00:00:00 2001 From: boxp Date: Tue, 14 Apr 2026 14:15:29 +0900 Subject: [PATCH 1/2] Add agent hook lint checks --- .claude/settings.json | 17 ++ .codex/hooks.json | 17 ++ Makefile | 7 +- README.ja.md | 9 + README.md | 9 + scripts/agent-hooks/lint_format_check_hook.py | 133 ++++++++++++++ test/agent_hook_test.sh | 173 ++++++++++++++++++ 7 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .codex/hooks.json create mode 100644 scripts/agent-hooks/lint_format_check_hook.py create mode 100644 test/agent_hook_test.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..54f8b5a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "/usr/bin/env python3 \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/lint_format_check_hook.py", + "async": true, + "timeout": 300 + } + ] + } + ] + } +} diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..19939fd --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "/usr/bin/env python3 \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/lint_format_check_hook.py\"", + "statusMessage": "Running ceeker format-check and lint", + "timeout": 300 + } + ] + } + ] + } +} diff --git a/Makefile b/Makefile index 11a2512..6fb7635 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: test test-install-script lint format-check format ci run clean uber +.PHONY: test test-install-script test-agent-hooks lint format-check format ci run clean uber -test: test-install-script +test: test-install-script test-agent-hooks mkdir -p target/test-runtime mkdir -p target/test-runtime/tmux TMUX= TMUX_PANE= TMUX_TMPDIR=$(CURDIR)/target/test-runtime/tmux XDG_RUNTIME_DIR=$(CURDIR)/target/test-runtime clojure -M:test @@ -8,6 +8,9 @@ test: test-install-script test-install-script: sh test/install_script_test.sh +test-agent-hooks: + sh test/agent_hook_test.sh + lint: clojure -M:lint diff --git a/README.ja.md b/README.ja.md index 6161355..5aa1a04 100644 --- a/README.ja.md +++ b/README.ja.md @@ -418,6 +418,15 @@ make lint make format ``` +ceeker repo には、Claude Code と Codex 向けの repo-local hook 設定を同梱しています。 + +- `.claude/settings.json`: `Write|Edit|MultiEdit|Bash` の `PostToolUse` 後に `scripts/agent-hooks/lint_format_check_hook.py` を非同期実行 +- `.codex/hooks.json`: `PostToolUse` 後に同じスクリプトを実行 + +この hook は `make format-check` と `make lint` を順に実行し、結果を agent に返します。Claude Code は repo を開くだけで有効です。Codex は feature flag が必要なので、未設定なら `~/.codex/config.toml` に `[features] codex_hooks = true` を追加するか `codex features enable codex_hooks` を実行してください。 + +補足: Codex の `PostToolUse` は 2026-04-14 時点の公式仕様では `Bash` のみが発火対象です。そのため ceeker の Codex hook も、ファイル変更の可能性がある Bash 実行に対してのみ `format-check` / `lint` を走らせます。 + ## CI GitHub Actions で以下のジョブが PR / main push 時に実行されます: diff --git a/README.md b/README.md index acca111..b6a3342 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,15 @@ make lint make format ``` +The ceeker repo ships repo-local hooks for both Claude Code and Codex. + +- `.claude/settings.json`: runs `scripts/agent-hooks/lint_format_check_hook.py` asynchronously after `PostToolUse` for `Write|Edit|MultiEdit|Bash` +- `.codex/hooks.json`: runs the same script after `PostToolUse` + +The hook runs `make format-check` and `make lint` in sequence and reports the result back to the agent. Claude Code picks this up automatically when you open the repo. For Codex, ensure the feature flag is enabled by adding `[features] codex_hooks = true` to `~/.codex/config.toml` or by running `codex features enable codex_hooks`. + +Note: per the official Codex hooks documentation as of April 14, 2026, `PostToolUse` currently fires only for `Bash`. ceeker therefore limits the Codex hook to Bash commands that are likely to have modified the workspace before running `format-check` and `lint`. + ## CI GitHub Actions runs the following jobs on PRs and pushes to main: diff --git a/scripts/agent-hooks/lint_format_check_hook.py b/scripts/agent-hooks/lint_format_check_hook.py new file mode 100644 index 0000000..f37eb58 --- /dev/null +++ b/scripts/agent-hooks/lint_format_check_hook.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Run ceeker repo validation after mutating agent tool calls.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from typing import Any + +EDIT_TOOLS = {"Write", "Edit", "MultiEdit"} +VALIDATION_COMMANDS = { + "make lint", + "make format-check", + "make ci", + "clojure -M:lint", + "clojure -M:format-check", + "clojure -M:test", +} +MUTATING_BASH_PATTERNS = [ + r"\bapply_patch\b", + r"\bgit\s+apply\b", + r"\bpatch\b", + r"\bsed\s+-i\b", + r"\bperl\s+-pi\b", + r"\bmv\b", + r"\bcp\b", + r"\brm\b", + r"\bmkdir\b", + r"\btouch\b", + r"\btee\b", + r"\bchmod\b", + r"\bchown\b", + r">>", + r"(^|[^>])>([^>]|$)", +] + + +def read_payload() -> dict[str, Any]: + raw = sys.stdin.read().strip() + if not raw: + return {} + return json.loads(raw) + + +def normalize_command(command: str) -> str: + return " ".join(command.strip().split()) + + +def should_run(payload: dict[str, Any]) -> tuple[bool, str]: + if payload.get("hook_event_name") != "PostToolUse": + return False, "" + + tool_name = str(payload.get("tool_name") or "") + if tool_name in EDIT_TOOLS: + return True, tool_name + + if tool_name != "Bash": + return False, "" + + command = str((payload.get("tool_input") or {}).get("command") or "") + normalized_command = normalize_command(command) + if normalized_command in VALIDATION_COMMANDS: + return False, "" + + for pattern in MUTATING_BASH_PATTERNS: + if re.search(pattern, command): + return True, "Bash command" + + return False, "" + + +def summarize_output(output: str) -> str: + stripped = output.strip() + if not stripped: + return "" + + lines = stripped.splitlines()[:20] + summary = "\n".join(lines) + if len(summary) > 2000: + summary = summary[:2000] + "\n..." + return summary + + +def run_target(target: str) -> tuple[bool, str]: + result = subprocess.run( + ["make", target], + check=False, + capture_output=True, + text=True, + ) + combined = "\n".join( + part for part in [result.stdout.strip(), result.stderr.strip()] if part + ) + return result.returncode == 0, combined + + +def emit_message(message: str) -> None: + json.dump({"systemMessage": message}, sys.stdout) + sys.stdout.write("\n") + + +def main() -> int: + payload = read_payload() + should_validate, trigger = should_run(payload) + if not should_validate: + return 0 + + format_ok, format_output = run_target("format-check") + if not format_ok: + summary = summarize_output(format_output) + message = f"format-check failed after {trigger}." + if summary: + message = f"{message}\n{summary}" + emit_message(message) + return 0 + + lint_ok, lint_output = run_target("lint") + if not lint_ok: + summary = summarize_output(lint_output) + message = f"lint failed after {trigger}." + if summary: + message = f"{message}\n{summary}" + emit_message(message) + return 0 + + emit_message(f"format-check and lint passed after {trigger}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/agent_hook_test.sh b/test/agent_hook_test.sh new file mode 100644 index 0000000..2542059 --- /dev/null +++ b/test/agent_hook_test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +HOOK_SCRIPT="${REPO_ROOT}/scripts/agent-hooks/lint_format_check_hook.py" +TEST_TMP_ROOT=$(mktemp -d) +PASS_COUNT=0 +FAIL_COUNT=0 + +cleanup() { + rm -rf "${TEST_TMP_ROOT}" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf 'PASS %s\n' "$1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf 'FAIL %s: %s\n' "$1" "$2" >&2 +} + +assert_contains() { + file_path="$1" + expected="$2" + label="$3" + if ! grep -F "$expected" "$file_path" >/dev/null 2>&1; then + fail "$label" "expected '$expected' in $file_path" + return 1 + fi + return 0 +} + +assert_empty() { + file_path="$1" + label="$2" + if [ -s "$file_path" ]; then + fail "$label" "expected empty file: $file_path" + return 1 + fi + return 0 +} + +assert_line_count() { + file_path="$1" + expected="$2" + label="$3" + actual=$(wc -l <"$file_path" | tr -d ' ') + if [ "$actual" != "$expected" ]; then + fail "$label" "expected $expected lines in $file_path, got $actual" + return 1 + fi + return 0 +} + +setup_case() { + CASE_DIR="${TEST_TMP_ROOT}/$1" + STUB_DIR="${CASE_DIR}/bin" + LOG_FILE="${CASE_DIR}/make.log" + OUTPUT_FILE="${CASE_DIR}/stdout.json" + INPUT_FILE="${CASE_DIR}/input.json" + MAKE_BEHAVIOR="${CASE_DIR}/make-behavior" + + mkdir -p "${STUB_DIR}" + : >"${LOG_FILE}" + : >"${OUTPUT_FILE}" + : >"${MAKE_BEHAVIOR}" + + cat >"${STUB_DIR}/make" <<'EOF' +#!/usr/bin/env sh +set -eu +printf '%s\n' "$*" >> "${HOOK_TEST_MAKE_LOG}" + +if [ -f "${HOOK_TEST_MAKE_BEHAVIOR}" ]; then + while IFS='=' read -r key value; do + [ -n "${key}" ] || continue + if [ "$1" = "${key}" ]; then + if [ "${value}" = "0" ]; then + printf '%s ok\n' "$1" + exit 0 + fi + printf '%s failed\n' "$1" >&2 + exit "${value}" + fi + done < "${HOOK_TEST_MAKE_BEHAVIOR}" +fi + +printf '%s ok\n' "$1" +EOF + chmod +x "${STUB_DIR}/make" +} + +run_hook() { + payload="$1" + printf '%s\n' "$payload" >"${INPUT_FILE}" + HOOK_TEST_MAKE_LOG="${LOG_FILE}" \ + HOOK_TEST_MAKE_BEHAVIOR="${MAKE_BEHAVIOR}" \ + PATH="${STUB_DIR}:${PATH}" \ + python3 "${HOOK_SCRIPT}" <"${INPUT_FILE}" >"${OUTPUT_FILE}" +} + +test_write_runs_format_and_lint() { + setup_case "write-runs-checks" + + if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"src/ceeker/core.clj"}}'; then + assert_line_count "${LOG_FILE}" 2 "write runs both make targets" || return 1 + assert_contains "${LOG_FILE}" "format-check" "write runs format-check first" || return 1 + assert_contains "${LOG_FILE}" "lint" "write runs lint second" || return 1 + assert_contains "${OUTPUT_FILE}" "format-check and lint passed after Write" "write pass message" || return 1 + pass "Write tool triggers format-check and lint" + return 0 + fi + + fail "Write tool triggers format-check and lint" "hook command failed" + return 1 +} + +test_read_only_bash_skips_checks() { + setup_case "bash-skip" + + if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"rg hook README.md"}}'; then + assert_empty "${LOG_FILE}" "read-only bash should skip make" || return 1 + assert_empty "${OUTPUT_FILE}" "read-only bash should not emit message" || return 1 + pass "read-only Bash command skips checks" + return 0 + fi + + fail "read-only Bash command skips checks" "hook command failed" + return 1 +} + +test_mutating_bash_runs_checks() { + setup_case "bash-runs-checks" + + if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git apply /tmp/change.patch"}}'; then + assert_line_count "${LOG_FILE}" 2 "mutating bash runs both make targets" || return 1 + assert_contains "${OUTPUT_FILE}" "format-check and lint passed after Bash command" "bash pass message" || return 1 + pass "mutating Bash command triggers checks" + return 0 + fi + + fail "mutating Bash command triggers checks" "hook command failed" + return 1 +} + +test_format_check_failure_reports_message() { + setup_case "format-failure" + printf 'format-check=1\n' >"${MAKE_BEHAVIOR}" + + if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"src/ceeker/core.clj"}}'; then + assert_line_count "${LOG_FILE}" 1 "format-check failure should stop before lint" || return 1 + assert_contains "${OUTPUT_FILE}" "format-check failed after Edit" "format failure message" || return 1 + assert_contains "${OUTPUT_FILE}" "format-check failed" "format failure output" || return 1 + pass "format-check failure is reported" + return 0 + fi + + fail "format-check failure is reported" "hook command failed" + return 1 +} + +test_write_runs_format_and_lint +test_read_only_bash_skips_checks +test_mutating_bash_runs_checks +test_format_check_failure_reports_message + +if [ "${FAIL_COUNT}" -ne 0 ]; then + printf '%s test(s) failed\n' "${FAIL_COUNT}" >&2 + exit 1 +fi + +printf '%s test(s) passed\n' "${PASS_COUNT}" From dda71c00db22e9e0864d7515c39901a8b0fe5e94 Mon Sep 17 00:00:00 2001 From: boxp Date: Tue, 14 Apr 2026 14:43:41 +0900 Subject: [PATCH 2/2] Use babashka for agent hook checks --- .claude/settings.json | 2 +- .codex/hooks.json | 2 +- README.ja.md | 4 +- README.md | 4 +- .../agent-hooks/lint_format_check_hook.clj | 110 +++++++++++++++ scripts/agent-hooks/lint_format_check_hook.py | 133 ------------------ test/agent_hook_test.sh | 4 +- 7 files changed, 118 insertions(+), 141 deletions(-) create mode 100644 scripts/agent-hooks/lint_format_check_hook.clj delete mode 100644 scripts/agent-hooks/lint_format_check_hook.py diff --git a/.claude/settings.json b/.claude/settings.json index 54f8b5a..ccd6125 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "/usr/bin/env python3 \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/lint_format_check_hook.py", + "command": "/usr/bin/env bb \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/lint_format_check_hook.clj", "async": true, "timeout": 300 } diff --git a/.codex/hooks.json b/.codex/hooks.json index 19939fd..5f23d89 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "/usr/bin/env python3 \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/lint_format_check_hook.py\"", + "command": "/usr/bin/env bb \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/lint_format_check_hook.clj\"", "statusMessage": "Running ceeker format-check and lint", "timeout": 300 } diff --git a/README.ja.md b/README.ja.md index 5aa1a04..8c4f890 100644 --- a/README.ja.md +++ b/README.ja.md @@ -420,10 +420,10 @@ make format ceeker repo には、Claude Code と Codex 向けの repo-local hook 設定を同梱しています。 -- `.claude/settings.json`: `Write|Edit|MultiEdit|Bash` の `PostToolUse` 後に `scripts/agent-hooks/lint_format_check_hook.py` を非同期実行 +- `.claude/settings.json`: `Write|Edit|MultiEdit|Bash` の `PostToolUse` 後に `scripts/agent-hooks/lint_format_check_hook.clj` を非同期実行 - `.codex/hooks.json`: `PostToolUse` 後に同じスクリプトを実行 -この hook は `make format-check` と `make lint` を順に実行し、結果を agent に返します。Claude Code は repo を開くだけで有効です。Codex は feature flag が必要なので、未設定なら `~/.codex/config.toml` に `[features] codex_hooks = true` を追加するか `codex features enable codex_hooks` を実行してください。 +この hook は babashka (`bb`) で動作し、`make format-check` と `make lint` を順に実行して結果を agent に返します。Claude Code は repo を開くだけで有効です。Codex は feature flag が必要なので、未設定なら `~/.codex/config.toml` に `[features] codex_hooks = true` を追加するか `codex features enable codex_hooks` を実行してください。 補足: Codex の `PostToolUse` は 2026-04-14 時点の公式仕様では `Bash` のみが発火対象です。そのため ceeker の Codex hook も、ファイル変更の可能性がある Bash 実行に対してのみ `format-check` / `lint` を走らせます。 diff --git a/README.md b/README.md index b6a3342..eed0108 100644 --- a/README.md +++ b/README.md @@ -420,10 +420,10 @@ make format The ceeker repo ships repo-local hooks for both Claude Code and Codex. -- `.claude/settings.json`: runs `scripts/agent-hooks/lint_format_check_hook.py` asynchronously after `PostToolUse` for `Write|Edit|MultiEdit|Bash` +- `.claude/settings.json`: runs `scripts/agent-hooks/lint_format_check_hook.clj` asynchronously after `PostToolUse` for `Write|Edit|MultiEdit|Bash` - `.codex/hooks.json`: runs the same script after `PostToolUse` -The hook runs `make format-check` and `make lint` in sequence and reports the result back to the agent. Claude Code picks this up automatically when you open the repo. For Codex, ensure the feature flag is enabled by adding `[features] codex_hooks = true` to `~/.codex/config.toml` or by running `codex features enable codex_hooks`. +The hook is implemented as a babashka (`bb`) script. It runs `make format-check` and `make lint` in sequence and reports the result back to the agent. Claude Code picks this up automatically when you open the repo. For Codex, ensure the feature flag is enabled by adding `[features] codex_hooks = true` to `~/.codex/config.toml` or by running `codex features enable codex_hooks`. Note: per the official Codex hooks documentation as of April 14, 2026, `PostToolUse` currently fires only for `Bash`. ceeker therefore limits the Codex hook to Bash commands that are likely to have modified the workspace before running `format-check` and `lint`. diff --git a/scripts/agent-hooks/lint_format_check_hook.clj b/scripts/agent-hooks/lint_format_check_hook.clj new file mode 100644 index 0000000..8c91034 --- /dev/null +++ b/scripts/agent-hooks/lint_format_check_hook.clj @@ -0,0 +1,110 @@ +#!/usr/bin/env bb + +(ns lint-format-check-hook + (:require [cheshire.core :as json] + [clojure.java.shell :as sh] + [clojure.string :as str])) + +(def edit-tools #{"Write" "Edit" "MultiEdit"}) + +(def validation-commands + #{"make lint" + "make format-check" + "make ci" + "clojure -M:lint" + "clojure -M:format-check" + "clojure -M:test"}) + +(def mutating-bash-patterns + [#"\bapply_patch\b" + #"\bgit\s+apply\b" + #"\bpatch\b" + #"\bsed\s+-i\b" + #"\bperl\s+-pi\b" + #"\bmv\b" + #"\bcp\b" + #"\brm\b" + #"\bmkdir\b" + #"\btouch\b" + #"\btee\b" + #"\bchmod\b" + #"\bchown\b" + #">>" + #"(^|[^>])>([^>]|$)"]) + +(defn read-payload [] + (let [raw (str/trim (slurp *in*))] + (if (str/blank? raw) + {} + (json/parse-string raw true)))) + +(defn normalize-command [command] + (->> (str/split (str/trim command) #"\s+") + (remove str/blank?) + (str/join " "))) + +(defn should-run [payload] + (if (not= "PostToolUse" (:hook_event_name payload)) + [false nil] + (let [tool-name (str (:tool_name payload)) + command (str (get-in payload [:tool_input :command] "")) + normalized-command (normalize-command command)] + (cond + (contains? edit-tools tool-name) + [true tool-name] + + (not= "Bash" tool-name) + [false nil] + + (contains? validation-commands normalized-command) + [false nil] + + (some #(re-find % command) mutating-bash-patterns) + [true "Bash command"] + + :else + [false nil])))) + +(defn summarize-output [output] + (let [trimmed (str/trim output)] + (if (str/blank? trimmed) + "" + (let [summary (->> (str/split-lines trimmed) + (take 20) + (str/join "\n"))] + (if (> (count summary) 2000) + (str (subs summary 0 2000) "\n...") + summary))))) + +(defn run-target [target] + (let [{:keys [exit out err]} (sh/sh "make" target) + combined (->> [out err] + (map str/trim) + (remove str/blank?) + (str/join "\n"))] + [(zero? exit) combined])) + +(defn emit-message [message] + (println (json/generate-string {:systemMessage message}))) + +(defn failure-message [target trigger output] + (let [summary (summarize-output output) + message (str target " failed after " trigger ".")] + (if (str/blank? summary) + message + (str message "\n" summary)))) + +(defn -main [] + (let [payload (read-payload) + [should-validate trigger] (should-run payload)] + (when should-validate + (let [[format-ok format-output] (run-target "format-check")] + (if-not format-ok + (emit-message (failure-message "format-check" trigger format-output)) + (let [[lint-ok lint-output] (run-target "lint")] + (if lint-ok + (emit-message (str "format-check and lint passed after " trigger ".")) + (emit-message (failure-message "lint" trigger lint-output)))))))) + 0) + +(System/exit (-main)) diff --git a/scripts/agent-hooks/lint_format_check_hook.py b/scripts/agent-hooks/lint_format_check_hook.py deleted file mode 100644 index f37eb58..0000000 --- a/scripts/agent-hooks/lint_format_check_hook.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -"""Run ceeker repo validation after mutating agent tool calls.""" - -from __future__ import annotations - -import json -import re -import subprocess -import sys -from typing import Any - -EDIT_TOOLS = {"Write", "Edit", "MultiEdit"} -VALIDATION_COMMANDS = { - "make lint", - "make format-check", - "make ci", - "clojure -M:lint", - "clojure -M:format-check", - "clojure -M:test", -} -MUTATING_BASH_PATTERNS = [ - r"\bapply_patch\b", - r"\bgit\s+apply\b", - r"\bpatch\b", - r"\bsed\s+-i\b", - r"\bperl\s+-pi\b", - r"\bmv\b", - r"\bcp\b", - r"\brm\b", - r"\bmkdir\b", - r"\btouch\b", - r"\btee\b", - r"\bchmod\b", - r"\bchown\b", - r">>", - r"(^|[^>])>([^>]|$)", -] - - -def read_payload() -> dict[str, Any]: - raw = sys.stdin.read().strip() - if not raw: - return {} - return json.loads(raw) - - -def normalize_command(command: str) -> str: - return " ".join(command.strip().split()) - - -def should_run(payload: dict[str, Any]) -> tuple[bool, str]: - if payload.get("hook_event_name") != "PostToolUse": - return False, "" - - tool_name = str(payload.get("tool_name") or "") - if tool_name in EDIT_TOOLS: - return True, tool_name - - if tool_name != "Bash": - return False, "" - - command = str((payload.get("tool_input") or {}).get("command") or "") - normalized_command = normalize_command(command) - if normalized_command in VALIDATION_COMMANDS: - return False, "" - - for pattern in MUTATING_BASH_PATTERNS: - if re.search(pattern, command): - return True, "Bash command" - - return False, "" - - -def summarize_output(output: str) -> str: - stripped = output.strip() - if not stripped: - return "" - - lines = stripped.splitlines()[:20] - summary = "\n".join(lines) - if len(summary) > 2000: - summary = summary[:2000] + "\n..." - return summary - - -def run_target(target: str) -> tuple[bool, str]: - result = subprocess.run( - ["make", target], - check=False, - capture_output=True, - text=True, - ) - combined = "\n".join( - part for part in [result.stdout.strip(), result.stderr.strip()] if part - ) - return result.returncode == 0, combined - - -def emit_message(message: str) -> None: - json.dump({"systemMessage": message}, sys.stdout) - sys.stdout.write("\n") - - -def main() -> int: - payload = read_payload() - should_validate, trigger = should_run(payload) - if not should_validate: - return 0 - - format_ok, format_output = run_target("format-check") - if not format_ok: - summary = summarize_output(format_output) - message = f"format-check failed after {trigger}." - if summary: - message = f"{message}\n{summary}" - emit_message(message) - return 0 - - lint_ok, lint_output = run_target("lint") - if not lint_ok: - summary = summarize_output(lint_output) - message = f"lint failed after {trigger}." - if summary: - message = f"{message}\n{summary}" - emit_message(message) - return 0 - - emit_message(f"format-check and lint passed after {trigger}.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/test/agent_hook_test.sh b/test/agent_hook_test.sh index 2542059..7a649fa 100644 --- a/test/agent_hook_test.sh +++ b/test/agent_hook_test.sh @@ -2,7 +2,7 @@ set -eu REPO_ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -HOOK_SCRIPT="${REPO_ROOT}/scripts/agent-hooks/lint_format_check_hook.py" +HOOK_SCRIPT="${REPO_ROOT}/scripts/agent-hooks/lint_format_check_hook.clj" TEST_TMP_ROOT=$(mktemp -d) PASS_COUNT=0 FAIL_COUNT=0 @@ -97,7 +97,7 @@ run_hook() { HOOK_TEST_MAKE_LOG="${LOG_FILE}" \ HOOK_TEST_MAKE_BEHAVIOR="${MAKE_BEHAVIOR}" \ PATH="${STUB_DIR}:${PATH}" \ - python3 "${HOOK_SCRIPT}" <"${INPUT_FILE}" >"${OUTPUT_FILE}" + bb "${HOOK_SCRIPT}" <"${INPUT_FILE}" >"${OUTPUT_FILE}" } test_write_runs_format_and_lint() {