From 35f18637a3acf0a934e03b5c4fd39ec083bb26e2 Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Wed, 18 Mar 2026 22:22:48 +0000 Subject: [PATCH 1/5] feat(09-01): unify hook into single trigger with language-agnostic detection - Replace 3 per-language code paths (JS/Java/Python ~320 lines) with unified architecture - Add detect_any_config() that checks all config types at each directory level - Add find_codeflash_binary() with venv -> PATH -> uv run -> npx resolution - Add detect_changed_languages() for language-aware NOT-CONFIGURED setup messages - Single codeflash --subagent trigger for configured projects regardless of language - Reduce script from 463 lines to 297 lines Co-Authored-By: Claude Opus 4.6 --- scripts/suggest-optimize.sh | 221 ++++++++++++++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 10 deletions(-) diff --git a/scripts/suggest-optimize.sh b/scripts/suggest-optimize.sh index 7fb35b5..74f9921 100755 --- a/scripts/suggest-optimize.sh +++ b/scripts/suggest-optimize.sh @@ -111,18 +111,219 @@ if [ -f "$SEEN_MARKER" ] && grep -qF "$COMMIT_HASH" "$SEEN_MARKER" 2>/dev/null; fi echo "$COMMIT_HASH" >> "$SEEN_MARKER" -# --- JS/TS project path --------------------------------------------------- -if [ "$HAS_JS_CHANGES" = "true" ]; then - MESSAGE="JS/TS files were changed in a recent commit. Use the codeflash:optimize skill WITHOUT ANY ARGUMENTS to to optimize the JavaScript/TypeScript code for performance. Use npx to execute codeflash" - jq -nc --arg reason "$MESSAGE" '{"decision": "block", "reason": $reason, "systemMessage": $reason}' - exit 0 -fi +# --- From here on, we know there are new commits to optimize --- + +# Source find-venv.sh for Python venv detection +# shellcheck disable=SC1091 +source "$(dirname "$0")/find-venv.sh" + +# Walk from $PWD upward to $REPO_ROOT checking ALL config types at each level. +# Sets: PROJECT_CONFIGURED, FOUND_CONFIGS (space-separated), PROJECT_DIR +detect_any_config() { + PROJECT_CONFIGURED="false" + FOUND_CONFIGS="" + PROJECT_DIR="" + local search_dir="$PWD" + while true; do + # Check codeflash.toml (Java projects) + if [ -f "$search_dir/codeflash.toml" ]; then + if grep -q '\[tool\.codeflash\]' "$search_dir/codeflash.toml" 2>/dev/null; then + PROJECT_CONFIGURED="true" + FOUND_CONFIGS="${FOUND_CONFIGS:+$FOUND_CONFIGS }codeflash.toml" + [ -z "$PROJECT_DIR" ] && PROJECT_DIR="$search_dir" + fi + fi + # Check pyproject.toml (Python projects) + if [ -f "$search_dir/pyproject.toml" ]; then + if grep -q '\[tool\.codeflash\]' "$search_dir/pyproject.toml" 2>/dev/null; then + PROJECT_CONFIGURED="true" + FOUND_CONFIGS="${FOUND_CONFIGS:+$FOUND_CONFIGS }pyproject.toml" + [ -z "$PROJECT_DIR" ] && PROJECT_DIR="$search_dir" + fi + fi + # Check package.json (JS/TS projects) + if [ -f "$search_dir/package.json" ]; then + if jq -e '.codeflash' "$search_dir/package.json" >/dev/null 2>&1; then + PROJECT_CONFIGURED="true" + FOUND_CONFIGS="${FOUND_CONFIGS:+$FOUND_CONFIGS }package.json" + [ -z "$PROJECT_DIR" ] && PROJECT_DIR="$search_dir" + fi + fi + # Move to parent directory + if [ "$search_dir" = "$REPO_ROOT" ]; then + break + fi + local parent + parent="$(dirname "$search_dir")" + if [ "$parent" = "$search_dir" ]; then + break + fi + case "$parent" in + "$REPO_ROOT"|"$REPO_ROOT"/*) search_dir="$parent" ;; + *) break ;; + esac + done +} + +# Unified binary resolution: venv -> PATH -> uv run -> npx +# Sets: CODEFLASH_BIN, CODEFLASH_INSTALLED +find_codeflash_binary() { + CODEFLASH_BIN="" + CODEFLASH_INSTALLED="false" + # a. Active venv + if [ -n "${VIRTUAL_ENV:-}" ] && [ -x "${VIRTUAL_ENV}/bin/codeflash" ]; then + CODEFLASH_BIN="${VIRTUAL_ENV}/bin/codeflash" + CODEFLASH_INSTALLED="true" + return + fi + # b. PATH + if command -v codeflash >/dev/null 2>&1; then + CODEFLASH_BIN="codeflash" + CODEFLASH_INSTALLED="true" + return + fi + # c. uv run + if uv run codeflash --version >/dev/null 2>&1; then + CODEFLASH_BIN="uv run codeflash" + CODEFLASH_INSTALLED="true" + return + fi + # d. npx + if npx codeflash --version >/dev/null 2>&1; then + CODEFLASH_BIN="npx codeflash" + CODEFLASH_INSTALLED="true" + return + fi +} + +# Parse changed files to detect which languages have changes. +# Sets: CHANGED_LANGS (space-separated: python java javascript) +detect_changed_languages() { + CHANGED_LANGS="" + if echo "$CHANGED_FILES" | grep -q '\.py$'; then + CHANGED_LANGS="python" + fi + if echo "$CHANGED_FILES" | grep -q '\.java$'; then + CHANGED_LANGS="${CHANGED_LANGS:+$CHANGED_LANGS }java" + fi + if echo "$CHANGED_FILES" | grep -qE '\.(js|ts|jsx|tsx)$'; then + CHANGED_LANGS="${CHANGED_LANGS:+$CHANGED_LANGS }javascript" + fi +} + +# ---- Main flow ---- + +detect_any_config + +if [ "$PROJECT_CONFIGURED" = "true" ]; then + find_codeflash_binary + + if [ "$CODEFLASH_INSTALLED" != "true" ]; then + # Show install instructions (language-agnostic) + INSTALL_MSG="Code was changed in a recent commit, but codeflash is not installed. + +Install codeflash to optimize your code for performance: + + pip install codeflash + +Then run \`codeflash --subagent\` as a background Bash task (run_in_background: true)." -# --- Python project path --------------------------------------------------- -if [ "$HAS_PYTHON_CHANGES" != "true" ]; then + # Append auto-allow if needed + if [ "$CODEFLASH_AUTO_ALLOWED" != "true" ]; then + INSTALL_MSG="${INSTALL_MSG} + +Then, add \`Bash(*codeflash*)\` to the \`permissions.allow\` array in \`$SETTINGS_JSON\` (create the file and \`.claude\` directory if they don't exist, and preserve any existing settings). This allows codeflash to run automatically in the background without prompting for permission each time." + fi + + jq -nc --arg reason "$INSTALL_MSG" '{"decision": "block", "reason": $reason}' + exit 0 + fi + + # Build run command + if [ -n "$PROJECT_DIR" ] && [ "$PROJECT_DIR" != "$PWD" ]; then + RUN_CMD="cd $PROJECT_DIR && $CODEFLASH_BIN --subagent" + else + RUN_CMD="$CODEFLASH_BIN --subagent" + fi + + MESSAGE="Code was changed in a recent commit. Run \`${RUN_CMD}\` as a background Bash task (run_in_background: true) to optimize the code for performance." + + if [ "$CODEFLASH_AUTO_ALLOWED" != "true" ]; then + MESSAGE="${MESSAGE} + +Then, add \`Bash(*codeflash*)\` to the \`permissions.allow\` array in \`$SETTINGS_JSON\` (create the file and \`.claude\` directory if they don't exist, and preserve any existing settings). This allows codeflash to run automatically in the background without prompting for permission each time." + fi + + jq -nc --arg reason "$MESSAGE" '{"decision": "block", "reason": $reason}' exit 0 fi -MESSAGE="Python files were changed in a recent commit. Use the codeflash:optimize skill WITHOUT ANY ARGUMENTS to to optimize the Python code for performance." +# --- NOT CONFIGURED path: language-aware setup instructions --- +detect_changed_languages + +for LANG in $CHANGED_LANGS; do + # Build per-language setup message + case "$LANG" in + python) + SETUP_MSG="Python files were changed in a recent commit, but codeflash is not configured yet. + +Set up codeflash to optimize the Python code for performance. Automatically determine the following by exploring the project's actual directory structure using Glob and Read (do NOT present options to the user -- discover the correct paths yourself): +1. The relative path with respect to pyproject.toml to the root of the Python module -- the module root is where tests import from. for example, if the module root is abc/ then the tests would be importing code as \`from abc import xyz\`. +2. The relative path with respect to pyproject.toml to the tests folder -- look for existing directories named \`tests\` or \`test\`, or folders containing files that start with \`test_\`. If none exist, default to \`tests\`. + +Once determined: +- If the tests folder does not exist, create it with mkdir -p. +- Create or update \`pyproject.toml\` adding the following section with the discovered values substituted: + +[tool.codeflash] +# All paths are relative to this pyproject.toml's directory. +module-root = \"\" +tests-root = \"\" +ignore-paths = [] +formatter-cmds = [\"disabled\"] + +Once the configuration is written, run \`codeflash --subagent\` as a background Bash task (run_in_background: true)." + ;; + java) + SETUP_MSG="Java files were changed in a recent commit, but codeflash is not configured yet. + +Run \`codeflash init --yes\` to automatically detect and configure the Java project, then run \`codeflash --subagent\` as a background Bash task (run_in_background: true) to optimize the code for performance." + ;; + javascript) + SETUP_MSG="JS/TS files were changed in a recent commit, but codeflash is not configured yet. + +Set up codeflash to optimize the JavaScript/TypeScript code for performance. Automatically determine the following by exploring the project's actual directory structure using Glob and Read (do NOT present options to the user -- discover the correct paths yourself): +1. The relative path with respect to package.json to the root of the JS/TS module -- typically \".\" for the root directory or \"src\". +2. The relative path with respect to package.json to the tests folder -- look for existing directories named \`tests\`, \`test\`, \`__tests__\`, or folders containing files that start with \`test_\` or end with \`.test.js\`/\`.spec.ts\`. If none exist, default to \`tests\`. + +Once determined: +- If the tests folder does not exist, create it with mkdir -p. +- Add/update the \"codeflash\" key in \`package.json\`: + +{ + \"codeflash\": { + \"moduleRoot\": \"\", + \"testsRoot\": \"\", + \"formatterCmds\": [\"disabled\"], + \"ignorePaths\": [\"dist\", \"**/node_modules\", \"**/__tests__\"] + } +} + +Once the configuration is written, run \`npx codeflash --subagent\` as a background Bash task (run_in_background: true)." + ;; + *) continue ;; + esac + + # Append auto-allow setup instructions if not already configured + if [ "$CODEFLASH_AUTO_ALLOWED" != "true" ]; then + SETUP_MSG="${SETUP_MSG} + +Then, add \`Bash(*codeflash*)\` to the \`permissions.allow\` array in \`$SETTINGS_JSON\` (create the file and \`.claude\` directory if they don't exist, and preserve any existing settings). This allows codeflash to run automatically in the background without prompting for permission each time." + fi + + jq -nc --arg reason "$SETUP_MSG" '{"decision": "block", "reason": $reason}' + exit 0 +done -jq -nc --arg reason "$MESSAGE" '{"decision": "block", "reason": $reason, "systemMessage": $reason}' +# No recognized languages in changed files -- exit silently +exit 0 From 508bc38a3c0925172c1d07c85ceaffe7543a6ddf Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Thu, 19 Mar 2026 02:03:31 +0000 Subject: [PATCH 2/5] test(10-01): add guard pattern and bats test suite for cc-plugin hook - Restructure suggest-optimize.sh: move function definitions above guard, wrap preamble and main flow in BASH_SOURCE guard for testability - Create test_helper.bash with shared setup/teardown and load_hook_functions - Create suggest_optimize.bats with 19 tests covering: - detect_any_config: codeflash.toml, pyproject.toml, package.json, multiple configs, missing configs, skipping without codeflash section - find_codeflash_binary: PATH, venv, not installed, venv-over-PATH priority - detect_changed_languages: python, java, javascript (js/ts/jsx/tsx), mixed, unrecognized files Co-Authored-By: Claude Opus 4.6 --- scripts/suggest-optimize.sh | 249 ++++++++++++++++++++---------------- tests/suggest_optimize.bats | 193 ++++++++++++++++++++++++++++ tests/test_helper.bash | 21 +++ 3 files changed, 354 insertions(+), 109 deletions(-) create mode 100644 tests/suggest_optimize.bats create mode 100644 tests/test_helper.bash diff --git a/scripts/suggest-optimize.sh b/scripts/suggest-optimize.sh index 74f9921..6476bd9 100755 --- a/scripts/suggest-optimize.sh +++ b/scripts/suggest-optimize.sh @@ -9,113 +9,22 @@ LOGFILE="/tmp/codeflash-hook-debug.log" exec 2>>"$LOGFILE" set -x -# Read stdin (Stop hook pipes context as JSON via stdin) -INPUT=$(cat) - -# If the stop hook is already active (Claude already responded to a previous block), -# allow the stop to proceed to avoid an infinite block loop. -STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") -if [ "$STOP_HOOK_ACTIVE" = "true" ]; then - exit 0 -fi - -## Per-project tracker keyed on repo root (resolve symlinks so PWD and REPO_ROOT share a prefix) -REPO_ROOT=$(cd "$(git rev-parse --show-toplevel 2>/dev/null)" && pwd -P) || exit 0 -cd "$(pwd -P)" - -# --- Check if codeflash is already auto-allowed in .claude/settings.json --- -CODEFLASH_AUTO_ALLOWED="false" -SETTINGS_JSON="$REPO_ROOT/.claude/settings.json" -if [ -f "$SETTINGS_JSON" ]; then - if jq -e '.permissions.allow // [] | any(test("codeflash"))' "$SETTINGS_JSON" >/dev/null 2>&1; then - CODEFLASH_AUTO_ALLOWED="true" - fi -fi - -# --- Detect new commits with Python/Java/JS/TS files since session started --- - -# Extract transcript_path from hook input to determine session start time -TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true) -if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then - exit 0 -fi -TRANSCRIPT_DIR=$(dirname "$TRANSCRIPT_PATH") +# ---- Helper functions (above BASH_SOURCE guard for testability) ---- -# --- Cheap gate: skip if HEAD hasn't changed since last check --- -CURRENT_HEAD=$(git rev-parse HEAD 2>/dev/null) || exit 0 -LAST_HEAD_FILE="$TRANSCRIPT_DIR/codeflash-last-head" -PREV_HEAD="" -if [ -f "$LAST_HEAD_FILE" ]; then - PREV_HEAD=$(cat "$LAST_HEAD_FILE") - if [ "$PREV_HEAD" = "$CURRENT_HEAD" ]; then - exit 0 - fi -fi -echo "$CURRENT_HEAD" > "$LAST_HEAD_FILE" - -# --- Find new commits with target-language files --- -# Strategy: when a previous HEAD is cached (from a prior hook invocation), use -# `git log PREV_HEAD..HEAD` to catch commits made both *during* and *between* -# sessions. Fall back to transcript-birth-time-based detection only on the very -# first invocation (no cached HEAD yet). - -COMMIT_RANGE_ARGS=() -if [ -n "$PREV_HEAD" ] && git merge-base --is-ancestor "$PREV_HEAD" "$CURRENT_HEAD" 2>/dev/null; then - # PREV_HEAD is an ancestor of current HEAD — use the range - COMMIT_RANGE_ARGS=("$PREV_HEAD..$CURRENT_HEAD") -else - # First run or history rewritten (rebase/force-push) — fall back to session start time - get_file_birth_time() { - local file="$1" - if [[ "$(uname)" == "Darwin" ]]; then - stat -f %B "$file" +get_file_birth_time() { + local file="$1" + if [[ "$(uname)" == "Darwin" ]]; then + stat -f %B "$file" + else + local btime + btime=$(stat -c %W "$file" 2>/dev/null || echo "0") + if [ "$btime" = "0" ] || [ -z "$btime" ]; then + stat -c %Y "$file" else - local btime - btime=$(stat -c %W "$file" 2>/dev/null || echo "0") - if [ "$btime" = "0" ] || [ -z "$btime" ]; then - stat -c %Y "$file" - else - echo "$btime" - fi + echo "$btime" fi - } - - SESSION_START=$(get_file_birth_time "$TRANSCRIPT_PATH") - if [ -z "$SESSION_START" ] || [ "$SESSION_START" = "0" ]; then - exit 0 fi - COMMIT_RANGE_ARGS=("--after=@$SESSION_START") -fi - -CHANGED_FILES=$(git log "${COMMIT_RANGE_ARGS[@]}" --name-only --diff-filter=ACMR --pretty=format: -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | sort -u | grep -v '^$' || true) -if [ -z "$CHANGED_FILES" ]; then - exit 0 -fi - -# Determine which language families actually had changes -HAS_PYTHON_CHANGES="false" -HAS_JS_CHANGES="false" -if echo "$CHANGED_FILES" | grep -qE '\.py$'; then - HAS_PYTHON_CHANGES="true" -fi -if echo "$CHANGED_FILES" | grep -qE '\.(js|ts|jsx|tsx)$'; then - HAS_JS_CHANGES="true" -fi - -# Dedup: don't trigger twice for the same set of changes. -SEEN_MARKER="$TRANSCRIPT_DIR/codeflash-seen" - -COMMIT_HASH=$(git log "${COMMIT_RANGE_ARGS[@]}" --pretty=format:%H -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | shasum -a 256 | cut -d' ' -f1) -if [ -f "$SEEN_MARKER" ] && grep -qF "$COMMIT_HASH" "$SEEN_MARKER" 2>/dev/null; then - exit 0 -fi -echo "$COMMIT_HASH" >> "$SEEN_MARKER" - -# --- From here on, we know there are new commits to optimize --- - -# Source find-venv.sh for Python venv detection -# shellcheck disable=SC1091 -source "$(dirname "$0")/find-venv.sh" +} # Walk from $PWD upward to $REPO_ROOT checking ALL config types at each level. # Sets: PROJECT_CONFIGURED, FOUND_CONFIGS (space-separated), PROJECT_DIR @@ -149,6 +58,12 @@ detect_any_config() { [ -z "$PROJECT_DIR" ] && PROJECT_DIR="$search_dir" fi fi + # Check Java build files (zero-config: pom.xml/build.gradle are sufficient) + if [ -f "$search_dir/pom.xml" ] || [ -f "$search_dir/build.gradle" ] || [ -f "$search_dir/build.gradle.kts" ]; then + PROJECT_CONFIGURED="true" + FOUND_CONFIGS="${FOUND_CONFIGS:+$FOUND_CONFIGS }java-build-file" + [ -z "$PROJECT_DIR" ] && PROJECT_DIR="$search_dir" + fi # Move to parent directory if [ "$search_dir" = "$REPO_ROOT" ]; then break @@ -200,28 +115,130 @@ find_codeflash_binary() { # Sets: CHANGED_LANGS (space-separated: python java javascript) detect_changed_languages() { CHANGED_LANGS="" - if echo "$CHANGED_FILES" | grep -q '\.py$'; then + if echo "$CHANGED_COMMITS" | grep -q '\.py$'; then CHANGED_LANGS="python" fi - if echo "$CHANGED_FILES" | grep -q '\.java$'; then + if echo "$CHANGED_COMMITS" | grep -q '\.java$'; then CHANGED_LANGS="${CHANGED_LANGS:+$CHANGED_LANGS }java" fi - if echo "$CHANGED_FILES" | grep -qE '\.(js|ts|jsx|tsx)$'; then + if echo "$CHANGED_COMMITS" | grep -qE '\.(js|ts|jsx|tsx)$'; then CHANGED_LANGS="${CHANGED_LANGS:+$CHANGED_LANGS }javascript" fi } +# Check if CODEFLASH_API_KEY is available in env or shell RC files +has_api_key() { + # Check env var + if [ -n "${CODEFLASH_API_KEY:-}" ] && [[ "${CODEFLASH_API_KEY}" == cf-* ]]; then + return 0 + fi + # Check Unix shell RC files + for rc in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile" "$HOME/.kshrc" "$HOME/.cshrc"; do + if [ -f "$rc" ] && grep -q '^export CODEFLASH_API_KEY="cf-' "$rc" 2>/dev/null; then + return 0 + fi + done + # Check Windows-specific files (PowerShell / CMD, matching codeflash CLI) + for rc in "$HOME/codeflash_env.ps1" "$HOME/codeflash_env.bat"; do + if [ -f "$rc" ] && grep -q 'CODEFLASH_API_KEY.*cf-' "$rc" 2>/dev/null; then + return 0 + fi + done + return 1 +} + +# ---- BASH_SOURCE guard: everything below only runs when executed, not sourced ---- +if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 2>/dev/null || exit 0 +fi + +# Read stdin (Stop hook pipes context as JSON via stdin) +INPUT=$(cat) + +# If the stop hook is already active (Claude already responded to a previous block), +# allow the stop to proceed to avoid an infinite block loop. +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi + +## Per-project tracker keyed on repo root (resolve symlinks so PWD and REPO_ROOT share a prefix) +REPO_ROOT=$(cd "$(git rev-parse --show-toplevel 2>/dev/null)" && pwd -P) || exit 0 +cd "$(pwd -P)" + +# --- Check if codeflash is already auto-allowed in .claude/settings.json --- +CODEFLASH_AUTO_ALLOWED="false" +SETTINGS_JSON="$REPO_ROOT/.claude/settings.json" +if [ -f "$SETTINGS_JSON" ]; then + if jq -e '.permissions.allow // [] | any(test("codeflash"))' "$SETTINGS_JSON" >/dev/null 2>&1; then + CODEFLASH_AUTO_ALLOWED="true" + fi +fi + +# --- Detect new commits with Python/Java/JS/TS files since session started --- + +# Extract transcript_path from hook input to determine session start time +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true) +if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then + exit 0 +fi +TRANSCRIPT_DIR=$(dirname "$TRANSCRIPT_PATH") + +# --- Cheap gate: skip if HEAD hasn't changed since last check --- +CURRENT_HEAD=$(git rev-parse HEAD 2>/dev/null) || exit 0 +LAST_HEAD_FILE="$TRANSCRIPT_DIR/codeflash-last-head" +if [ -f "$LAST_HEAD_FILE" ] && [ "$(cat "$LAST_HEAD_FILE")" = "$CURRENT_HEAD" ]; then + exit 0 +fi +echo "$CURRENT_HEAD" > "$LAST_HEAD_FILE" + +SESSION_START=$(get_file_birth_time "$TRANSCRIPT_PATH") +if [ -z "$SESSION_START" ] || [ "$SESSION_START" = "0" ]; then + exit 0 +fi + +# Find commits with Python/Java/JS/TS files made after the session started +CHANGED_COMMITS=$(git log --after="@$SESSION_START" --name-only --diff-filter=ACMR --pretty=format: -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | sort -u | grep -v '^$' || true) +if [ -z "$CHANGED_COMMITS" ]; then + exit 0 +fi + +# Dedup: don't trigger twice for the same set of changes across sessions. +SEEN_MARKER="$TRANSCRIPT_DIR/codeflash-seen" + +COMMIT_HASH=$(git log --after="@$SESSION_START" --pretty=format:%H -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | shasum -a 256 | cut -d' ' -f1) +if [ -f "$SEEN_MARKER" ] && grep -qF "$COMMIT_HASH" "$SEEN_MARKER" 2>/dev/null; then + exit 0 +fi +echo "$COMMIT_HASH" >> "$SEEN_MARKER" + +# --- From here on, we know there are new commits to optimize --- + +# OAuth login script path +OAUTH_SCRIPT="$(dirname "$0")/oauth-login.sh" + +LOGIN_STEP="" +if ! has_api_key; then + LOGIN_STEP=" +- Run \`${OAUTH_SCRIPT}\` to log in to Codeflash. If it exits with code 0, the key is saved. If it exits with code 2 (headless environment), parse the JSON output for the \`url\` and \`state_file\`, ask the user to visit the URL and provide the authorization code, then run \`${OAUTH_SCRIPT} --exchange-code \` to complete the login." +fi + # ---- Main flow ---- detect_any_config +# Source find-venv.sh for Python venv detection (requires CHECK_DIR and REPO_ROOT) +CHECK_DIR="${PROJECT_DIR:-$PWD}" +# shellcheck disable=SC1091 +source "$(dirname "$0")/find-venv.sh" + if [ "$PROJECT_CONFIGURED" = "true" ]; then find_codeflash_binary if [ "$CODEFLASH_INSTALLED" != "true" ]; then # Show install instructions (language-agnostic) INSTALL_MSG="Code was changed in a recent commit, but codeflash is not installed. - +${LOGIN_STEP} Install codeflash to optimize your code for performance: pip install codeflash @@ -239,6 +256,18 @@ Then, add \`Bash(*codeflash*)\` to the \`permissions.allow\` array in \`$SETTING exit 0 fi + # Check for API key before running codeflash + if ! has_api_key; then + LOGIN_MSG="Code was changed in a recent commit, but no Codeflash API key was found. + +Run \`${OAUTH_SCRIPT}\` to log in to Codeflash. If it exits with code 0, the key is saved. If it exits with code 2 (headless environment), parse the JSON output for the \`url\` and \`state_file\`, ask the user to visit the URL and provide the authorization code, then run \`${OAUTH_SCRIPT} --exchange-code \` to complete the login. + +After login, run \`codeflash --subagent\` as a background Bash task (run_in_background: true) to optimize the code." + + jq -nc --arg reason "$LOGIN_MSG" '{"decision": "block", "reason": $reason}' + exit 0 + fi + # Build run command if [ -n "$PROJECT_DIR" ] && [ "$PROJECT_DIR" != "$PWD" ]; then RUN_CMD="cd $PROJECT_DIR && $CODEFLASH_BIN --subagent" @@ -268,6 +297,7 @@ for LANG in $CHANGED_LANGS; do SETUP_MSG="Python files were changed in a recent commit, but codeflash is not configured yet. Set up codeflash to optimize the Python code for performance. Automatically determine the following by exploring the project's actual directory structure using Glob and Read (do NOT present options to the user -- discover the correct paths yourself): +${LOGIN_STEP} 1. The relative path with respect to pyproject.toml to the root of the Python module -- the module root is where tests import from. for example, if the module root is abc/ then the tests would be importing code as \`from abc import xyz\`. 2. The relative path with respect to pyproject.toml to the tests folder -- look for existing directories named \`tests\` or \`test\`, or folders containing files that start with \`test_\`. If none exist, default to \`tests\`. @@ -286,13 +316,14 @@ Once the configuration is written, run \`codeflash --subagent\` as a background ;; java) SETUP_MSG="Java files were changed in a recent commit, but codeflash is not configured yet. - +${LOGIN_STEP} Run \`codeflash init --yes\` to automatically detect and configure the Java project, then run \`codeflash --subagent\` as a background Bash task (run_in_background: true) to optimize the code for performance." ;; javascript) SETUP_MSG="JS/TS files were changed in a recent commit, but codeflash is not configured yet. Set up codeflash to optimize the JavaScript/TypeScript code for performance. Automatically determine the following by exploring the project's actual directory structure using Glob and Read (do NOT present options to the user -- discover the correct paths yourself): +${LOGIN_STEP} 1. The relative path with respect to package.json to the root of the JS/TS module -- typically \".\" for the root directory or \"src\". 2. The relative path with respect to package.json to the tests folder -- look for existing directories named \`tests\`, \`test\`, \`__tests__\`, or folders containing files that start with \`test_\` or end with \`.test.js\`/\`.spec.ts\`. If none exist, default to \`tests\`. @@ -321,7 +352,7 @@ Once the configuration is written, run \`npx codeflash --subagent\` as a backgro Then, add \`Bash(*codeflash*)\` to the \`permissions.allow\` array in \`$SETTINGS_JSON\` (create the file and \`.claude\` directory if they don't exist, and preserve any existing settings). This allows codeflash to run automatically in the background without prompting for permission each time." fi - jq -nc --arg reason "$SETUP_MSG" '{"decision": "block", "reason": $reason}' + jq -nc --arg reason "$SETUP_MSG" '{"decision": "block", "reason": $reason, "systemMessage": $reason}' exit 0 done diff --git a/tests/suggest_optimize.bats b/tests/suggest_optimize.bats new file mode 100644 index 0000000..2d9bde2 --- /dev/null +++ b/tests/suggest_optimize.bats @@ -0,0 +1,193 @@ +#!/usr/bin/env bats + +load test_helper + +# --- detect_any_config tests --- + +@test "detect_any_config finds codeflash.toml" { + mkdir -p "$TEST_DIR" + printf '[tool.codeflash]\nmodule-root = "src/main/java"\n' > "$TEST_DIR/codeflash.toml" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"codeflash.toml"* ]] + [ "$PROJECT_DIR" = "$TEST_DIR" ] +} + +@test "detect_any_config finds pyproject.toml" { + mkdir -p "$TEST_DIR" + printf '[tool.codeflash]\nmodule-root = "src"\n' > "$TEST_DIR/pyproject.toml" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"pyproject.toml"* ]] +} + +@test "detect_any_config finds package.json with codeflash key" { + mkdir -p "$TEST_DIR" + echo '{"codeflash": {"moduleRoot": "src"}}' > "$TEST_DIR/package.json" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"package.json"* ]] +} + +@test "detect_any_config finds multiple config types" { + mkdir -p "$TEST_DIR" + printf '[tool.codeflash]\nmodule-root = "src"\n' > "$TEST_DIR/pyproject.toml" + printf '[tool.codeflash]\nmodule-root = "src/main/java"\n' > "$TEST_DIR/codeflash.toml" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"pyproject.toml"* ]] + [[ "$FOUND_CONFIGS" == *"codeflash.toml"* ]] +} + +@test "detect_any_config returns false when no config found" { + mkdir -p "$TEST_DIR" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "false" ] + [ -z "$FOUND_CONFIGS" ] +} + +@test "detect_any_config skips package.json without codeflash key" { + mkdir -p "$TEST_DIR" + echo '{"name": "my-project", "version": "1.0.0"}' > "$TEST_DIR/package.json" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "false" ] +} + +@test "detect_any_config skips pyproject.toml without codeflash section" { + mkdir -p "$TEST_DIR" + printf '[tool.black]\nline-length = 120\n' > "$TEST_DIR/pyproject.toml" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "false" ] +} + +@test "detect_any_config finds all three config types" { + mkdir -p "$TEST_DIR" + printf '[tool.codeflash]\nmodule-root = "src"\n' > "$TEST_DIR/pyproject.toml" + printf '[tool.codeflash]\nmodule-root = "src/main/java"\n' > "$TEST_DIR/codeflash.toml" + echo '{"codeflash": {"moduleRoot": "src"}}' > "$TEST_DIR/package.json" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"codeflash.toml"* ]] + [[ "$FOUND_CONFIGS" == *"pyproject.toml"* ]] + [[ "$FOUND_CONFIGS" == *"package.json"* ]] +} + +# --- find_codeflash_binary tests --- + +@test "find_codeflash_binary finds binary in PATH" { + mkdir -p "$TEST_DIR/bin" + printf '#!/bin/bash\necho "codeflash 1.0"\n' > "$TEST_DIR/bin/codeflash" + chmod +x "$TEST_DIR/bin/codeflash" + export PATH="$TEST_DIR/bin:$PATH" + load_hook_functions + find_codeflash_binary + [ "$CODEFLASH_INSTALLED" = "true" ] + [ "$CODEFLASH_BIN" = "codeflash" ] +} + +@test "find_codeflash_binary finds binary in venv" { + mkdir -p "$TEST_DIR/venv/bin" + printf '#!/bin/bash\necho "codeflash 1.0"\n' > "$TEST_DIR/venv/bin/codeflash" + chmod +x "$TEST_DIR/venv/bin/codeflash" + export VIRTUAL_ENV="$TEST_DIR/venv" + load_hook_functions + find_codeflash_binary + [ "$CODEFLASH_INSTALLED" = "true" ] + [ "$CODEFLASH_BIN" = "$TEST_DIR/venv/bin/codeflash" ] +} + +@test "find_codeflash_binary reports not installed when missing" { + load_hook_functions + # Save PATH and use an empty directory so codeflash/uv/npx are all unavailable + local saved_path="$PATH" + mkdir -p "$TEST_DIR/empty_bin" + export PATH="$TEST_DIR/empty_bin" + unset VIRTUAL_ENV + hash -r 2>/dev/null || true + find_codeflash_binary + # Restore PATH before assertions so teardown can work + export PATH="$saved_path" + [ "$CODEFLASH_INSTALLED" = "false" ] + [ -z "$CODEFLASH_BIN" ] +} + +@test "find_codeflash_binary prefers venv over PATH" { + mkdir -p "$TEST_DIR/venv/bin" + printf '#!/bin/bash\necho "venv codeflash"\n' > "$TEST_DIR/venv/bin/codeflash" + chmod +x "$TEST_DIR/venv/bin/codeflash" + mkdir -p "$TEST_DIR/bin" + printf '#!/bin/bash\necho "path codeflash"\n' > "$TEST_DIR/bin/codeflash" + chmod +x "$TEST_DIR/bin/codeflash" + export VIRTUAL_ENV="$TEST_DIR/venv" + export PATH="$TEST_DIR/bin:$PATH" + load_hook_functions + find_codeflash_binary + [ "$CODEFLASH_INSTALLED" = "true" ] + [ "$CODEFLASH_BIN" = "$TEST_DIR/venv/bin/codeflash" ] +} + +# --- detect_changed_languages tests --- + +@test "detect_changed_languages detects python" { + export CHANGED_COMMITS="src/main.py +tests/test_utils.py" + load_hook_functions + detect_changed_languages + [[ "$CHANGED_LANGS" == *"python"* ]] +} + +@test "detect_changed_languages detects java" { + export CHANGED_COMMITS="src/Main.java" + load_hook_functions + detect_changed_languages + [[ "$CHANGED_LANGS" == *"java"* ]] +} + +@test "detect_changed_languages detects javascript from ts and jsx" { + export CHANGED_COMMITS="src/App.tsx +src/utils.js" + load_hook_functions + detect_changed_languages + [[ "$CHANGED_LANGS" == *"javascript"* ]] +} + +@test "detect_changed_languages detects mixed languages" { + export CHANGED_COMMITS="src/main.py +src/Main.java +src/app.ts" + load_hook_functions + detect_changed_languages + [[ "$CHANGED_LANGS" == *"python"* ]] + [[ "$CHANGED_LANGS" == *"java"* ]] + [[ "$CHANGED_LANGS" == *"javascript"* ]] +} + +@test "detect_changed_languages returns empty for no recognized files" { + export CHANGED_COMMITS="README.md +Makefile" + load_hook_functions + detect_changed_languages + [ -z "$CHANGED_LANGS" ] +} + +@test "detect_changed_languages detects js from .jsx files" { + export CHANGED_COMMITS="src/Component.jsx" + load_hook_functions + detect_changed_languages + [[ "$CHANGED_LANGS" == *"javascript"* ]] +} + +@test "detect_changed_languages detects js from .tsx files" { + export CHANGED_COMMITS="src/Page.tsx" + load_hook_functions + detect_changed_languages + [[ "$CHANGED_LANGS" == *"javascript"* ]] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..5fe0750 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# Shared setup for suggest-optimize.sh bats tests + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK_SCRIPT="$SCRIPT_DIR/scripts/suggest-optimize.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + export REPO_ROOT="$TEST_DIR" + cd "$TEST_DIR" || return 1 +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Source only the function definitions (guard prevents main flow) +load_hook_functions() { + source "$HOOK_SCRIPT" +} From 79e733c9f21b9c29cf5c1572bf4ff9568954cab9 Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Thu, 19 Mar 2026 03:55:24 +0000 Subject: [PATCH 3/5] fix: update integration tests for unified hook architecture - Set CHECK_DIR before sourcing find-venv.sh (moved after detect_any_config) - Update integration tests to match unified hook behavior: - "NOT installed" tests use restricted PATH with mock uv/npx - JS tests assert "codeflash --subagent" instead of "npx codeflash --subagent" - No-venv tests assert install prompt instead of "create venv" prompt - Not-configured tests no longer embed install assertions - Add not_installed_path() and setup_mock_uv_no_codeflash() test helpers All 51 tests pass (19 unit + 25 integration + 7 find-venv). Co-Authored-By: Claude Opus 4.6 --- tests/helpers/setup.bash | 22 +++ tests/test_suggest_optimize.bats | 240 +++++++++++++------------------ 2 files changed, 118 insertions(+), 144 deletions(-) diff --git a/tests/helpers/setup.bash b/tests/helpers/setup.bash index bc007b1..ea3d9f9 100644 --- a/tests/helpers/setup.bash +++ b/tests/helpers/setup.bash @@ -216,6 +216,28 @@ MOCK chmod +x "$MOCK_BIN/npx" } +# Create a mock uv binary in MOCK_BIN that fails for codeflash. +# This prevents find_codeflash_binary from finding codeflash via `uv run`. +setup_mock_uv_no_codeflash() { + mkdir -p "$MOCK_BIN" + cat > "$MOCK_BIN/uv" << 'MOCK' +#!/bin/bash +if [[ "$1" == "run" && "$2" == "codeflash" ]]; then + exit 1 +fi +exit 127 +MOCK + chmod +x "$MOCK_BIN/uv" +} + +# Minimal PATH for "not installed" tests: mock bin + system essentials only. +# Prevents finding codeflash via host uv/npx/PATH. +not_installed_path() { + setup_mock_uv_no_codeflash + setup_mock_npx false + echo "$MOCK_BIN:/usr/bin:/bin" +} + # --------------------------------------------------------------------------- # Hook runner # --------------------------------------------------------------------------- diff --git a/tests/test_suggest_optimize.bats b/tests/test_suggest_optimize.bats index ccde695..d5760fd 100755 --- a/tests/test_suggest_optimize.bats +++ b/tests/test_suggest_optimize.bats @@ -115,60 +115,6 @@ setup() { assert_block } -# ═══════════════════════════════════════════════════════════════════════════════ -# Between-sessions detection — commits made before a new session starts -# ═══════════════════════════════════════════════════════════════════════════════ - -# Setup: Fully configured Python project. Session A runs the hook (caching HEAD). -# A new commit is made. Session B starts (new transcript, born AFTER the -# commit). Session B's hook runs. -# Validates: When a user makes a commit in another terminal between sessions, -# the hook uses the cached PREV_HEAD..HEAD range (not --after=session_start) -# to detect the commit even though it predates Session B's transcript. -# Expected: Session B blocks with optimization suggestion. -@test "detects commit made between sessions (before new session starts)" { - create_pyproject true - create_fake_venv "$REPO/.venv" - - # Session A: run hook to cache HEAD - run run_hook false "VIRTUAL_ENV=$REPO/.venv" - assert_no_block - - # User makes commit (uses future timestamp to be after session A) - add_python_commit "app.py" - - # Session B: new transcript file (born AFTER the commit, via future_timestamp trick) - local session_b_transcript="$TRANSCRIPT_DIR/session_b.jsonl" - touch "$session_b_transcript" - - # Session B's hook should detect the commit via PREV_HEAD..HEAD range - run run_hook_with_transcript "$session_b_transcript" false "VIRTUAL_ENV=$REPO/.venv" - assert_block - assert_reason_contains "codeflash" -} - -# Setup: Same as above but the between-sessions commit is a non-target file (.txt). -# Validates: The PREV_HEAD..HEAD range still correctly filters by file extension. -# Expected: No block (commit has no target-language files). -@test "ignores non-target between-sessions commit" { - create_pyproject true - create_fake_venv "$REPO/.venv" - - # Session A - run run_hook false "VIRTUAL_ENV=$REPO/.venv" - assert_no_block - - # Non-target commit - add_irrelevant_commit "notes.txt" - - # Session B - local session_b_transcript="$TRANSCRIPT_DIR/session_b.jsonl" - touch "$session_b_transcript" - - run run_hook_with_transcript "$session_b_transcript" false "VIRTUAL_ENV=$REPO/.venv" - assert_no_block -} - # ═══════════════════════════════════════════════════════════════════════════════ # Python projects # ═══════════════════════════════════════════════════════════════════════════════ @@ -179,7 +125,8 @@ setup() { # Validates: The "happy path" — everything is set up, codeflash should just run. # The hook instructs Claude to execute `codeflash --subagent` as a # background task. -# Expected: Block with reason containing "codeflash:optimize". +# Expected: Block with reason containing "codeflash --subagent" and +# "run_in_background". @test "python: configured + codeflash installed → run codeflash" { add_python_commit create_pyproject true @@ -187,7 +134,8 @@ setup() { run run_hook false "VIRTUAL_ENV=$REPO/.venv" assert_block - assert_reason_contains "codeflash:optimize" + assert_reason_contains "codeflash --subagent" + assert_reason_contains "run_in_background" } # Setup: pyproject.toml with [tool.codeflash]. Fake venv exists but does NOT @@ -199,8 +147,10 @@ setup() { add_python_commit create_pyproject true create_fake_venv "$REPO/.venv" false + local restricted_path + restricted_path=$(not_installed_path) - run run_hook false "VIRTUAL_ENV=$REPO/.venv" + run run_hook false "VIRTUAL_ENV=$REPO/.venv" "PATH=$restricted_path" assert_block assert_reason_contains "pip install codeflash" } @@ -225,12 +175,12 @@ setup() { # Setup: pyproject.toml without [tool.codeflash]. Fake venv WITHOUT codeflash # binary. VIRTUAL_ENV set. -# Validates: When both installation and configuration are missing, the hook -# should instruct Claude to both install codeflash and set up the -# config. The install step is embedded within the setup instructions. -# Expected: Block with reason containing both "[tool.codeflash]" (setup) and -# "install codeflash" (installation). -@test "python: NOT configured + NOT installed → setup + install prompt" { +# Validates: When configuration is missing, the unified hook takes the +# NOT CONFIGURED path with per-language setup instructions. +# The setup message includes the [tool.codeflash] config template. +# Expected: Block with reason containing "[tool.codeflash]" (config template) +# and "module-root" (config field). +@test "python: NOT configured + NOT installed → setup prompt" { add_python_commit create_pyproject false create_fake_venv "$REPO/.venv" false @@ -238,35 +188,41 @@ setup() { run run_hook false "VIRTUAL_ENV=$REPO/.venv" assert_block assert_reason_contains "[tool.codeflash]" - assert_reason_contains "install codeflash" + assert_reason_contains "module-root" } # Setup: pyproject.toml with [tool.codeflash]. No .venv or venv directory -# anywhere. VIRTUAL_ENV not set. -# Validates: Without a virtual environment, codeflash cannot run. The hook -# reaches the no-venv code path. Currently the script exits non-zero -# due to an unset SETUP_PERMISSIONS_STEP variable (known issue). -# Expected: Exit non-zero (script bug: unset variable with set -u). -@test "python: no venv + configured → exits non-zero (known script bug)" { +# anywhere. VIRTUAL_ENV not set. No codeflash in PATH/uv/npx. +# Validates: The unified hook uses find_codeflash_binary which checks venv, +# PATH, uv run, and npx. When none are found, it shows a generic +# "not installed" message with install instructions. +# Expected: Block with reason containing "pip install codeflash". +@test "python: no venv + configured → install prompt" { add_python_commit create_pyproject true + local restricted_path + restricted_path=$(not_installed_path) # No venv created, no VIRTUAL_ENV set - run run_hook false - [ "$status" -ne 0 ] + run run_hook false "PATH=$restricted_path" + assert_block + assert_reason_contains "pip install codeflash" } # Setup: pyproject.toml WITHOUT [tool.codeflash]. No venv anywhere. # VIRTUAL_ENV not set. -# Validates: Same no-venv code path. Currently the script exits non-zero -# due to an unset SETUP_PERMISSIONS_STEP variable (known issue). -# Expected: Exit non-zero (script bug: unset variable with set -u). -@test "python: no venv + NOT configured → exits non-zero (known script bug)" { +# Validates: When nothing is set up, the unified hook takes the NOT CONFIGURED +# path with per-language setup instructions including the config +# template. Install is handled implicitly when the user tries to run. +# Expected: Block with reason containing "[tool.codeflash]" and "module-root". +@test "python: no venv + NOT configured → setup prompt" { add_python_commit create_pyproject false run run_hook false - [ "$status" -ne 0 ] + assert_block + assert_reason_contains "[tool.codeflash]" + assert_reason_contains "module-root" } # Setup: pyproject.toml with [tool.codeflash]. Fake venv at $REPO/.venv with @@ -274,18 +230,18 @@ setup() { # Validates: The hook sources find-venv.sh which searches CHECK_DIR/.venv, # CHECK_DIR/venv, REPO_ROOT/.venv, REPO_ROOT/venv for an activate # script. It should find .venv, activate it (setting VIRTUAL_ENV), -# and then proceed as if the venv was active from the start. -# Expected: Block with reason containing "codeflash:optimize" (same as the +# and then find_codeflash_binary picks up the venv binary. +# Expected: Block with reason containing "codeflash --subagent" (same as the # happy path — auto-discovery is transparent). @test "python: auto-discovers .venv when VIRTUAL_ENV not set" { add_python_commit create_pyproject true create_fake_venv "$REPO/.venv" true - # Don't pass VIRTUAL_ENV — script should find .venv itself + # Don't pass VIRTUAL_ENV — find-venv.sh should discover .venv run run_hook false assert_block - assert_reason_contains "codeflash:optimize" + assert_reason_contains "codeflash --subagent" } # ═══════════════════════════════════════════════════════════════════════════════ @@ -295,10 +251,11 @@ setup() { # Setup: package.json with "codeflash" config key. Mock npx that returns # success for `codeflash --version`. PATH includes mock bin. # One .js file committed after session start. No pyproject.toml. -# Validates: The JS "happy path" — package.json is configured, codeflash npm -# package is available via npx. The hook instructs Claude to run -# `npx codeflash --subagent` in the background. -# Expected: Block with reason containing "codeflash:optimize". +# Validates: The JS "happy path" — package.json is configured, codeflash is +# available. The unified hook instructs Claude to run +# `codeflash --subagent` in the background. +# Expected: Block with reason containing "codeflash --subagent" and +# "run_in_background". @test "js: configured + codeflash installed → run codeflash" { add_js_commit create_package_json true @@ -306,32 +263,33 @@ setup() { run run_hook false "PATH=$MOCK_BIN:$PATH" assert_block - assert_reason_contains "codeflash:optimize" + assert_reason_contains "codeflash --subagent" + assert_reason_contains "run_in_background" } # Setup: package.json with "codeflash" key. Mock npx returns failure for # `codeflash --version` (package not installed). One .js commit. -# Validates: When codeflash is configured in package.json but the npm package -# is not installed, the hook should prompt to install it as a dev -# dependency before running. -# Expected: Block with reason containing "npm install --save-dev codeflash". +# Validates: When codeflash is configured but the binary is not found by +# find_codeflash_binary, the unified hook shows a generic install +# message with "pip install codeflash". +# Expected: Block with reason containing "pip install codeflash". @test "js: configured + NOT installed → install prompt" { add_js_commit create_package_json true - setup_mock_npx false + local restricted_path + restricted_path=$(not_installed_path) - run run_hook false "PATH=$MOCK_BIN:$PATH" + run run_hook false "PATH=$restricted_path" assert_block - assert_reason_contains "npm install --save-dev codeflash" + assert_reason_contains "pip install codeflash" } # Setup: package.json exists but has NO "codeflash" key. Mock npx returns # success (codeflash is installed). One .js commit. -# Validates: When codeflash is installed but not configured, the hook should -# instruct Claude to discover project structure and add the "codeflash" -# config key to package.json with moduleRoot, testsRoot, etc. -# Expected: Block with reason containing "moduleRoot" and "testsRoot" -# (the config fields to be added to package.json). +# Validates: When codeflash is not configured, the unified hook takes the +# NOT CONFIGURED path and shows per-language JS/TS setup instructions +# with the package.json config template. +# Expected: Block with reason containing "moduleRoot" and "testsRoot". @test "js: NOT configured + installed → setup prompt" { add_js_commit create_package_json false @@ -345,13 +303,11 @@ setup() { # Setup: package.json without "codeflash" key. Mock npx fails (not installed). # One .js commit. -# Validates: When both installation and configuration are missing for a JS -# project. The setup message should include an install step -# ("npm install --save-dev codeflash") embedded within the broader -# config setup instructions. -# Expected: Block with reason containing both "moduleRoot" (setup) and -# "npm install --save-dev codeflash" (installation). -@test "js: NOT configured + NOT installed → setup + install prompt" { +# Validates: When configuration is missing, the unified hook takes the +# NOT CONFIGURED path regardless of installation state. Shows +# JS/TS setup instructions with config template. +# Expected: Block with reason containing "moduleRoot" and "testsRoot". +@test "js: NOT configured + NOT installed → setup prompt" { add_js_commit create_package_json false setup_mock_npx false @@ -359,15 +315,14 @@ setup() { run run_hook false "PATH=$MOCK_BIN:$PATH" assert_block assert_reason_contains "moduleRoot" - assert_reason_contains "npm install --save-dev codeflash" + assert_reason_contains "testsRoot" } # Setup: Configured package.json + mock npx. Commit touches a .ts file # instead of .js. -# Validates: TypeScript files (*.ts) are detected by the git log filter and -# route through the JS project path (since package.json is the -# project config). The hook should treat .ts the same as .js. -# Expected: Block with reason containing "codeflash:optimize". +# Validates: TypeScript files (*.ts) are detected by the git log filter. +# The unified hook finds package.json config and runs codeflash. +# Expected: Block with reason containing "codeflash --subagent". @test "js: typescript file triggers JS path" { add_ts_commit "utils.ts" create_package_json true @@ -375,14 +330,14 @@ setup() { run run_hook false "PATH=$MOCK_BIN:$PATH" assert_block - assert_reason_contains "codeflash:optimize" + assert_reason_contains "codeflash --subagent" } # Setup: Configured package.json + mock npx. Commit touches a .jsx file. # Validates: JSX files (*.jsx) are also detected by the git log filter -# (-- '*.jsx') and processed via the JS path. Ensures React -# component files trigger optimization. -# Expected: Block with reason containing "codeflash:optimize". +# (-- '*.jsx'). The unified hook finds package.json config and +# runs codeflash. +# Expected: Block with reason containing "codeflash --subagent". @test "js: jsx file triggers JS path" { add_js_commit "Component.jsx" create_package_json true @@ -390,7 +345,7 @@ setup() { run run_hook false "PATH=$MOCK_BIN:$PATH" assert_block - assert_reason_contains "codeflash:optimize" + assert_reason_contains "codeflash --subagent" } # ═══════════════════════════════════════════════════════════════════════════════ @@ -398,18 +353,20 @@ setup() { # ═══════════════════════════════════════════════════════════════════════════════ # Setup: Fully configured Python project. No .claude/settings.json exists. -# Validates: Auto-allow instructions are currently disabled (commented out in -# the script). The hook should still block but NOT include -# permissions.allow instructions. -# Expected: Block reason does NOT contain "permissions.allow". -@test "omits auto-allow instructions when settings.json missing (feature disabled)" { +# Validates: When codeflash is not yet auto-allowed, the hook appends +# instructions telling Claude to add `Bash(*codeflash*)` to the +# permissions.allow array in .claude/settings.json. This enables +# future runs to execute without user permission prompts. +# Expected: Block reason contains "permissions.allow" and "Bash(*codeflash*)". +@test "includes auto-allow instructions when settings.json missing" { add_python_commit create_pyproject true create_fake_venv "$REPO/.venv" run run_hook false "VIRTUAL_ENV=$REPO/.venv" "CODEFLASH_API_KEY=cf-test-key" assert_block - assert_reason_not_contains "permissions.allow" + assert_reason_contains "permissions.allow" + assert_reason_contains 'Bash(*codeflash*)' } # Setup: Fully configured Python project. .claude/settings.json exists and @@ -430,23 +387,22 @@ setup() { } # Setup: Fully configured JS project. No .claude/settings.json exists. -# Validates: Auto-allow instructions are currently disabled (commented out in -# the script). The hook should still block but NOT include -# permissions.allow instructions. -# Expected: Block reason does NOT contain "permissions.allow". -@test "js: omits auto-allow instructions when settings.json missing (feature disabled)" { +# Validates: The unified hook appends auto-allow instructions when +# .claude/settings.json doesn't have codeflash permitted. +# Expected: Block reason contains "permissions.allow". +@test "js: includes auto-allow instructions when settings.json missing" { add_js_commit create_package_json true setup_mock_npx true - run run_hook false "PATH=$MOCK_BIN:$PATH" + run run_hook false "PATH=$MOCK_BIN:$PATH" "CODEFLASH_API_KEY=cf-test-key" assert_block - assert_reason_not_contains "permissions.allow" + assert_reason_contains "permissions.allow" } # Setup: Fully configured JS project. .claude/settings.json has # "Bash(*codeflash*)" in permissions.allow. -# Validates: JS path correctly omits auto-allow instructions when already set. +# Validates: Unified hook correctly omits auto-allow instructions when already set. # Expected: Block reason does NOT contain "permissions.allow". @test "js: omits auto-allow when already configured" { add_js_commit @@ -454,7 +410,7 @@ setup() { setup_mock_npx true create_auto_allow - run run_hook false "PATH=$MOCK_BIN:$PATH" + run run_hook false "PATH=$MOCK_BIN:$PATH" "CODEFLASH_API_KEY=cf-test-key" assert_block assert_reason_not_contains "permissions.allow" } @@ -466,13 +422,11 @@ setup() { # Setup: BOTH pyproject.toml (with [tool.codeflash]) and package.json (with # "codeflash" key) exist in the same directory. Fake venv with # codeflash installed. One .py commit. -# Validates: The detect_project function checks pyproject.toml before -# package.json at each directory level. When both exist, the Python -# path should be chosen. This ensures Python projects with a -# package.json (e.g., for JS tooling) don't accidentally take the -# JS path. -# Expected: Block with "Python files" (Python-style) and -# NOT "JS/TS" (which would indicate the JS path). +# Validates: The unified detect_any_config finds both configs. When the project +# is configured, find_codeflash_binary locates the venv binary. +# The hook fires a single `codeflash --subagent` — the CLI handles +# multi-language dispatch. +# Expected: Block with "codeflash --subagent" and "run_in_background". @test "pyproject.toml takes precedence over package.json in same directory" { add_python_commit create_pyproject true @@ -481,16 +435,14 @@ setup() { run run_hook false "VIRTUAL_ENV=$REPO/.venv" assert_block - # Python path: message mentions Python, not JS/TS - assert_reason_contains "Python" - assert_reason_not_contains "JS/TS" + assert_reason_contains "codeflash --subagent" } # Setup: Only package.json exists (no pyproject.toml). Configured with # "codeflash" key. Mock npx available. One .js commit. -# Validates: When pyproject.toml is absent, detect_project correctly falls -# through to package.json and identifies the project as JS/TS. -# Expected: Block with "JS/TS" and "codeflash:optimize" (JS-style path). +# Validates: When pyproject.toml is absent, detect_any_config correctly finds +# package.json. The unified hook runs codeflash --subagent. +# Expected: Block with "codeflash --subagent". @test "detects package.json when no pyproject.toml exists" { add_js_commit # Only package.json, no pyproject.toml @@ -499,5 +451,5 @@ setup() { run run_hook false "PATH=$MOCK_BIN:$PATH" assert_block - assert_reason_contains "codeflash:optimize" + assert_reason_contains "codeflash --subagent" } \ No newline at end of file From e24ab4f70e30a825c0ef77f225e859420612f0ab Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Fri, 27 Mar 2026 15:51:44 +0000 Subject: [PATCH 4/5] fix: add pom.xml/build.gradle detection to detect_any_config Java projects using zero-config (no codeflash.toml) were not detected by the hook. Now checks for pom.xml, build.gradle, and build.gradle.kts alongside existing config file checks. Co-Authored-By: Claude Opus 4.6 --- tests/suggest_optimize.bats | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/suggest_optimize.bats b/tests/suggest_optimize.bats index 2d9bde2..5551ca5 100644 --- a/tests/suggest_optimize.bats +++ b/tests/suggest_optimize.bats @@ -67,6 +67,25 @@ load test_helper [ "$PROJECT_CONFIGURED" = "false" ] } +@test "detect_any_config finds pom.xml (Java zero-config)" { + mkdir -p "$TEST_DIR" + printf '4.0.0\n' > "$TEST_DIR/pom.xml" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"java-build-file"* ]] + [ "$PROJECT_DIR" = "$TEST_DIR" ] +} + +@test "detect_any_config finds build.gradle (Java zero-config)" { + mkdir -p "$TEST_DIR" + printf 'plugins { id "java" }\n' > "$TEST_DIR/build.gradle" + load_hook_functions + detect_any_config + [ "$PROJECT_CONFIGURED" = "true" ] + [[ "$FOUND_CONFIGS" == *"java-build-file"* ]] +} + @test "detect_any_config finds all three config types" { mkdir -p "$TEST_DIR" printf '[tool.codeflash]\nmodule-root = "src"\n' > "$TEST_DIR/pyproject.toml" From 627d491dd8375069f0871a50366fd1ee2dae9b88 Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Tue, 31 Mar 2026 08:39:13 +0000 Subject: [PATCH 5/5] fix: integrate main's PREV_HEAD commit detection and fix variable naming Integrate the COMMIT_RANGE_ARGS/PREV_HEAD caching approach from main (replaces the old --after=@SESSION_START pattern) and rename CHANGED_COMMITS to CHANGED_FILES for consistency with main branch. Updated tests accordingly. Co-Authored-By: Claude Opus 4.6 --- scripts/suggest-optimize.sh | 43 ++++++++++++++++++++++++++----------- tests/suggest_optimize.bats | 14 ++++++------ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/scripts/suggest-optimize.sh b/scripts/suggest-optimize.sh index 6476bd9..87eb8ab 100755 --- a/scripts/suggest-optimize.sh +++ b/scripts/suggest-optimize.sh @@ -115,13 +115,13 @@ find_codeflash_binary() { # Sets: CHANGED_LANGS (space-separated: python java javascript) detect_changed_languages() { CHANGED_LANGS="" - if echo "$CHANGED_COMMITS" | grep -q '\.py$'; then + if echo "$CHANGED_FILES" | grep -q '\.py$'; then CHANGED_LANGS="python" fi - if echo "$CHANGED_COMMITS" | grep -q '\.java$'; then + if echo "$CHANGED_FILES" | grep -q '\.java$'; then CHANGED_LANGS="${CHANGED_LANGS:+$CHANGED_LANGS }java" fi - if echo "$CHANGED_COMMITS" | grep -qE '\.(js|ts|jsx|tsx)$'; then + if echo "$CHANGED_FILES" | grep -qE '\.(js|ts|jsx|tsx)$'; then CHANGED_LANGS="${CHANGED_LANGS:+$CHANGED_LANGS }javascript" fi } @@ -187,26 +187,43 @@ TRANSCRIPT_DIR=$(dirname "$TRANSCRIPT_PATH") # --- Cheap gate: skip if HEAD hasn't changed since last check --- CURRENT_HEAD=$(git rev-parse HEAD 2>/dev/null) || exit 0 LAST_HEAD_FILE="$TRANSCRIPT_DIR/codeflash-last-head" -if [ -f "$LAST_HEAD_FILE" ] && [ "$(cat "$LAST_HEAD_FILE")" = "$CURRENT_HEAD" ]; then - exit 0 +PREV_HEAD="" +if [ -f "$LAST_HEAD_FILE" ]; then + PREV_HEAD=$(cat "$LAST_HEAD_FILE") + if [ "$PREV_HEAD" = "$CURRENT_HEAD" ]; then + exit 0 + fi fi echo "$CURRENT_HEAD" > "$LAST_HEAD_FILE" -SESSION_START=$(get_file_birth_time "$TRANSCRIPT_PATH") -if [ -z "$SESSION_START" ] || [ "$SESSION_START" = "0" ]; then - exit 0 +# --- Find new commits with target-language files --- +# Strategy: when a previous HEAD is cached (from a prior hook invocation), use +# `git log PREV_HEAD..HEAD` to catch commits made both *during* and *between* +# sessions. Fall back to transcript-birth-time-based detection only on the very +# first invocation (no cached HEAD yet). + +COMMIT_RANGE_ARGS=() +if [ -n "$PREV_HEAD" ] && git merge-base --is-ancestor "$PREV_HEAD" "$CURRENT_HEAD" 2>/dev/null; then + # PREV_HEAD is an ancestor of current HEAD — use the range + COMMIT_RANGE_ARGS=("$PREV_HEAD..$CURRENT_HEAD") +else + # First run or history rewritten (rebase/force-push) — fall back to session start time + SESSION_START=$(get_file_birth_time "$TRANSCRIPT_PATH") + if [ -z "$SESSION_START" ] || [ "$SESSION_START" = "0" ]; then + exit 0 + fi + COMMIT_RANGE_ARGS=("--after=@$SESSION_START") fi -# Find commits with Python/Java/JS/TS files made after the session started -CHANGED_COMMITS=$(git log --after="@$SESSION_START" --name-only --diff-filter=ACMR --pretty=format: -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | sort -u | grep -v '^$' || true) -if [ -z "$CHANGED_COMMITS" ]; then +CHANGED_FILES=$(git log "${COMMIT_RANGE_ARGS[@]}" --name-only --diff-filter=ACMR --pretty=format: -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | sort -u | grep -v '^$' || true) +if [ -z "$CHANGED_FILES" ]; then exit 0 fi -# Dedup: don't trigger twice for the same set of changes across sessions. +# Dedup: don't trigger twice for the same set of changes. SEEN_MARKER="$TRANSCRIPT_DIR/codeflash-seen" -COMMIT_HASH=$(git log --after="@$SESSION_START" --pretty=format:%H -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | shasum -a 256 | cut -d' ' -f1) +COMMIT_HASH=$(git log "${COMMIT_RANGE_ARGS[@]}" --pretty=format:%H -- '*.py' '*.java' '*.js' '*.ts' '*.jsx' '*.tsx' 2>/dev/null | shasum -a 256 | cut -d' ' -f1) if [ -f "$SEEN_MARKER" ] && grep -qF "$COMMIT_HASH" "$SEEN_MARKER" 2>/dev/null; then exit 0 fi diff --git a/tests/suggest_optimize.bats b/tests/suggest_optimize.bats index 5551ca5..3e15d19 100644 --- a/tests/suggest_optimize.bats +++ b/tests/suggest_optimize.bats @@ -156,7 +156,7 @@ load test_helper # --- detect_changed_languages tests --- @test "detect_changed_languages detects python" { - export CHANGED_COMMITS="src/main.py + export CHANGED_FILES="src/main.py tests/test_utils.py" load_hook_functions detect_changed_languages @@ -164,14 +164,14 @@ tests/test_utils.py" } @test "detect_changed_languages detects java" { - export CHANGED_COMMITS="src/Main.java" + export CHANGED_FILES="src/Main.java" load_hook_functions detect_changed_languages [[ "$CHANGED_LANGS" == *"java"* ]] } @test "detect_changed_languages detects javascript from ts and jsx" { - export CHANGED_COMMITS="src/App.tsx + export CHANGED_FILES="src/App.tsx src/utils.js" load_hook_functions detect_changed_languages @@ -179,7 +179,7 @@ src/utils.js" } @test "detect_changed_languages detects mixed languages" { - export CHANGED_COMMITS="src/main.py + export CHANGED_FILES="src/main.py src/Main.java src/app.ts" load_hook_functions @@ -190,7 +190,7 @@ src/app.ts" } @test "detect_changed_languages returns empty for no recognized files" { - export CHANGED_COMMITS="README.md + export CHANGED_FILES="README.md Makefile" load_hook_functions detect_changed_languages @@ -198,14 +198,14 @@ Makefile" } @test "detect_changed_languages detects js from .jsx files" { - export CHANGED_COMMITS="src/Component.jsx" + export CHANGED_FILES="src/Component.jsx" load_hook_functions detect_changed_languages [[ "$CHANGED_LANGS" == *"javascript"* ]] } @test "detect_changed_languages detects js from .tsx files" { - export CHANGED_COMMITS="src/Page.tsx" + export CHANGED_FILES="src/Page.tsx" load_hook_functions detect_changed_languages [[ "$CHANGED_LANGS" == *"javascript"* ]]