Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions hooks/ix-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ parse_json() {
# Requires: ix_capture_async (from ix-errors.sh, no-op if absent)
ix_run_text_locate() {
local _pattern="$1" _path_arg="${2:-}" _lang_arg="${3:-}"
local _text_tmp _loc_tmp _text_err _loc_err _TEXT_PID _LOC_PID _is_plain
local _text_tmp _loc_tmp _text_err _loc_err _TEXT_PID _LOC_PID _is_plain _loc_status
local _TEXT_ARGS=("$_pattern" "--limit" "15" "--format" "json")
[ -n "$_path_arg" ] && _TEXT_ARGS+=("--path" "$_path_arg")
[ -n "$_lang_arg" ] && _TEXT_ARGS+=("--language" "$_lang_arg")
Expand Down Expand Up @@ -308,8 +308,25 @@ ix_run_text_locate() {
wait "$_TEXT_PID" || ix_capture_async "ix" "ix-text" "text search failed" "$?" \
"ix text '${_pattern}'" "$(head -3 "$_text_err")"
[ -n "$_LOC_PID" ] && {
wait "$_LOC_PID" || ix_capture_async "ix" "ix-locate" "locate failed" "$?" \
"ix locate '${_pattern}'" "$(head -3 "$_loc_err")"
# `_loc_status=0; wait || _loc_status=$?` rather than a bare `wait`: these
# hooks run under `set -euo pipefail`, so an unguarded non-zero `wait` kills
# the hook outright. The `||` also keeps $? intact, which `if ! wait` would
# have replaced with the negation's own result.
_loc_status=0
wait "$_LOC_PID" || _loc_status=$?
if [ "$_loc_status" -ne 0 ]; then
# An unresolved target exits non-zero but still prints its JSON body
# (Ix#539). That is an answer, not a failure -- filing it would record an
# error for every symbol a user asks about that does not happen to exist,
# and bury real locate failures in the ledger. Only an empty body is a
# genuine failure.
if [ -s "$_loc_tmp" ]; then
ix_log "MISS ix locate exited ${_loc_status} with a body; treated as no-match"
else
ix_capture_async "ix" "ix-locate" "locate failed" "$_loc_status" \
"ix locate '${_pattern}'" "$(head -3 "$_loc_err")"
fi
fi
}

_TEXT_RAW=$(cat "$_text_tmp")
Expand Down
14 changes: 14 additions & 0 deletions tests/mock-ix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
# IX_MOCK_EXPECT_INVENTORY_KIND — expected `--kind` arg for `ix inventory`
# IX_MOCK_BRIEFING_FILE — path to fixture for `ix briefing` (default: briefing.json)
# IX_MOCK_FAIL=1 — exit 1 for all data-returning commands (simulates ix failure)
# IX_MOCK_LOCATE_EXIT=N — `ix locate` exits N *after* printing its body
# (Ix#539: an unresolved target is a non-zero exit
# with a usable payload, not an absent one)

SUBCOMMAND="${1:-}"
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -32,6 +35,17 @@ case "$SUBCOMMAND" in
;;
locate)
cat "${IX_MOCK_LOCATE_FILE:-${FX}/locate_resolved.json}"
# Ix#539 makes an unresolved target exit non-zero while still printing its
# body. Simulated separately from IX_MOCK_FAIL, which suppresses output too:
# the whole point is a failing exit code *with* usable output.
#
# `if`, never `[ -n "$V" ] && exit "$V"`. That form evaluates to status 1
# when V is unset, and as the last command in this branch it becomes the
# mock's own exit status -- so `ix locate` would exit 1 on every call, in
# every test. It is invisible precisely because the fix in this PR makes the
# hook tolerate a non-zero exit with a body, so the suite stays green while
# no longer testing what it claims to.
if [ -n "${IX_MOCK_LOCATE_EXIT:-}" ]; then exit "${IX_MOCK_LOCATE_EXIT}"; fi
;;
overview)
cat "${IX_MOCK_OVERVIEW_FILE:-${FX}/overview_normal.json}"
Expand Down
35 changes: 35 additions & 0 deletions tests/test_hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ assert_log_contains() {
pass "${_name}"
}

assert_log_not_contains() {
local _name="$1" _needle="$2"
if [ ! -f "${_IX_DEBUG_LOG:-}" ]; then
fail "${_name}" "debug log missing at ${_IX_DEBUG_LOG:-<unset>}"; return
fi
if grep -Fq -- "$_needle" "${_IX_DEBUG_LOG}"; then
fail "${_name}" "debug log unexpectedly contains '${_needle}'"; return
fi
pass "${_name}"
}

run_ix_hook_decide() {
local _mode="$1" _content="$2"; shift 2
_RC=0
Expand Down Expand Up @@ -546,6 +557,30 @@ run_hook ix-intercept.sh "${FX_IN}/grep_plain.json" \
IX_MOCK_LOCATE_FILE="${FX_IX}/locate_candidates.json"
assert_additional_context "intercept/medium confidence candidates augment" "[ix text + ix locate]"

# ── Ix#539: `ix locate` exiting non-zero while still printing a body ──────────
# An unresolved target is about to exit non-zero with its JSON payload intact.
# The hook must keep using that payload, and must not record it as a locate
# failure -- otherwise every symbol a user asks about that does not exist files
# an error, burying the real failures.
run_hook ix-intercept.sh "${FX_IN}/grep_plain.json" \
IX_MOCK_LOCATE_FILE="${FX_IX}/locate_candidates.json" \
IX_MOCK_LOCATE_EXIT=1
assert_additional_context "intercept/non-zero locate still augments" "[ix text + ix locate]"

run_hook_with_debug_log ix-intercept.sh "${FX_IN}/grep_plain.json" \
IX_MOCK_LOCATE_FILE="${FX_IX}/locate_candidates.json" \
IX_MOCK_LOCATE_EXIT=1
assert_log_contains "intercept/non-zero locate with a body is a miss, not a failure" \
"MISS ix locate exited 1 with a body"

# An empty body with a non-zero exit is still a genuine failure and must not be
# swallowed by the same branch.
run_hook_with_debug_log ix-intercept.sh "${FX_IN}/grep_plain.json" \
IX_MOCK_LOCATE_FILE=/dev/null \
IX_MOCK_LOCATE_EXIT=1
assert_log_not_contains "intercept/non-zero locate with no body stays a failure" \
"MISS ix locate exited"

# Glob → inventory → block when result set is manageable
run_hook ix-intercept.sh "${FX_IN}/glob_path.json"
assert_block_decision "intercept/glob pattern blocks" "Next: ix overview AuthService"
Expand Down