Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,6 @@ repos:
language: system
types: [shell]
pass_filenames: true
# Intentional bare captures for SUR-1936 hook tests; see
# tests/check-no-bare-ret.bats.
exclude: ^tests/fixtures/check-no-bare-ret/
44 changes: 44 additions & 0 deletions tests/check-no-bare-ret.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bats
# SUR-1936: brace depth in check-no-bare-ret must not reset function scope on
# inner `}` lines inside nested `{ ... }` groups.

setup() {
load 'helpers.bash'
helpers::isolate_home
HOOK="$REPO_ROOT/tests/check-no-bare-ret.sh"
FIXTURES="$REPO_ROOT/tests/fixtures/check-no-bare-ret"
export HOOK FIXTURES
}

@test "nested brace group: bare capture after inner } is reported (SUR-1936)" {
run bash "$HOOK" "$FIXTURES/nested-brace-bare-capture.sh"
[ "$status" -eq 1 ]
[[ "$output" == *"bare capture"* ]]
[[ "$output" == *"ret=\$?"* ]] || [[ "$output" == *'ret=$?'* ]]
}

@test "nested brace group: local ret before capture passes (SUR-1936)" {
run bash "$HOOK" "$FIXTURES/nested-brace-local-capture.sh"
[ "$status" -eq 0 ]
}

@test "trivial function with bare exit-code capture fails (regression)" {
run bash "$HOOK" "$FIXTURES/trivial-bare-capture.sh"
[ "$status" -eq 1 ]
[[ "$output" == *"bare capture"* ]]
}

@test "function ends correctly after nested groups (SUR-1936)" {
run bash "$HOOK" "$FIXTURES/function-end-after-nested.sh"
[ "$status" -eq 0 ]
}

@test "'#' inside parameter expansion does not drift function scope" {
run bash "$HOOK" "$FIXTURES/param-expansion-hash.sh"
[ "$status" -eq 0 ]
}

@test "live bash/*.sh tree is still clean under the hook" {
run bash "$HOOK" "$REPO_ROOT"/bash/*.sh
[ "$status" -eq 0 ]
}
105 changes: 93 additions & 12 deletions tests/check-no-bare-ret.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
# Prevents the SUR-1874 anti-pattern (bare exit-code capture that leaks the
# variable into the caller's scope) from being re-introduced. Individual
# fixes were applied in SUR-1859, SUR-1860, SUR-1868, SUR-1871, and SUR-1883;
# this hook makes future violations impossible without a deliberate bypass.
# this hook catches typical violations without requiring shellcheck-level
# parsing. Brace depth is tracked so a closing `}` from an inner `{ ... }`
# group does not end function scope (SUR-1936); `{`/`}` inside comments and
# basic quoted spans are skipped. `#` is only treated as a comment marker
# at start-of-token so parameter expansions like `${var#…}` and `${#var}`
# do not abort line parsing. Heredocs and `$'…'` ANSI-C quoting are not
# fully tracked — a pre-existing limitation.
#
# Usage: check-no-bare-ret.sh [files...]
#
Expand Down Expand Up @@ -37,49 +43,124 @@ LOCAL_LINE_PAT='^[[:space:]]*local[[:space:]]'
# name() (POSIX parens style)
FUNC_DEF_PAT='^[[:space:]]*(function[[:space:]]+[a-zA-Z_][a-zA-Z0-9_:]*|[a-zA-Z_][a-zA-Z0-9_:]*[[:space:]]*[(][)])'

# Update fn_body_depth / fn_body_pending from one line of source (comment and
# simple quote rules only). Uses globals: fn_body_depth, fn_body_pending.
apply_line_braces() {
local line=$1
local i len c state
len=${#line}
i=0
state=norm
while ((i < len)); do
c=${line:i:1}
case "$state" in
norm)
case "$c" in
'#')
# '#' starts a comment only at start-of-token (start of line
# or after whitespace). Inside ${var#…} / ${var##…} / ${#var}
# the '#' is part of parameter expansion; aborting here would
# leave the matching '}' unmatched and drift fn_body_depth.
if ((i == 0)) || [[ ${line:i-1:1} =~ [[:space:]] ]]; then
break
fi
;;
"'")
state=sq
;;
'"')
state=dq
;;
'{')
if [ "$fn_body_pending" -eq 1 ]; then
fn_body_pending=0
fi
fn_body_depth=$((fn_body_depth + 1))
;;
'}')
if [ "$fn_body_pending" -eq 0 ]; then
fn_body_depth=$((fn_body_depth - 1))
if [ "$fn_body_depth" -lt 0 ]; then
fn_body_depth=0
fi
fi
;;
esac
;;
sq)
if [ "$c" = "'" ]; then
state=norm
fi
;;
dq)
case "$c" in
$'\\')
if ((i + 1 < len)); then
((i++))
fi
;;
'"')
state=norm
;;
esac
;;
esac
((i++))
done
}

found=0

for file in "$@"; do
in_function=0
locals=""
fn_body_pending=0
fn_body_depth=0

while IFS= read -r line; do
# Detect function entry.
if [[ "$line" =~ $FUNC_DEF_PAT ]]; then
in_function=1
locals=""
continue
fi

# Detect function end: a lone `}` (possibly indented) on its own line.
if [[ "$line" =~ ^[[:space:]]*[}][[:space:]]*$ ]] && [ "$in_function" -eq 1 ]; then
in_function=0
locals=""
fn_body_pending=1
fn_body_depth=0
apply_line_braces "$line"
if [ "$fn_body_pending" -eq 0 ] && [ "$fn_body_depth" -eq 0 ]; then
in_function=0
locals=""
fi
continue
fi

[ "$in_function" -eq 0 ] && continue

# Record `local` declarations for tracked variable names.
if [[ "$line" =~ $LOCAL_LINE_PAT ]]; then
for varname in ret exit_code exit_status rc; do
# Match `local varname`, `local varname=...`, or `local a varname b`.
if [[ "$line" =~ (^|[[:space:]])local([[:space:]]+-[a-zA-Z])*[[:space:]]${varname}([[:space:]]|=|$) ]] ||
[[ "$line" =~ (^|[[:space:]])local([[:space:]]+-[a-zA-Z])*[[:space:]].*[[:space:]]${varname}([[:space:]]|=|$) ]]; then
locals="$locals $varname "
fi
done
apply_line_braces "$line"
if [ "$fn_body_pending" -eq 0 ] && [ "$fn_body_depth" -eq 0 ]; then
in_function=0
locals=""
fi
continue
fi

# Check for a bare capture on this line.
if [[ "$line" =~ $CAPTURE_PAT ]]; then
varname="${BASH_REMATCH[1]}"
if [[ " $locals " != *" $varname "* ]]; then
echo "$file: bare capture (add 'local $varname'): $line" >&2
found=1
fi
fi

apply_line_braces "$line"
if [ "$fn_body_pending" -eq 0 ] && [ "$fn_body_depth" -eq 0 ]; then
in_function=0
locals=""
fi
done <"$file"

done
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/check-no-bare-ret/function-end-after-nested.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Fixture: true function-ending } after nested groups — must not leave hook stuck in-function.

outer() {
{
true
}
local ret
ret=$?
: "${ret}"
}
10 changes: 10 additions & 0 deletions tests/fixtures/check-no-bare-ret/nested-brace-bare-capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Fixture: nested { } inside a function; bare ret=$? after inner } must be flagged (SUR-1936).

outer() {
{
true
}
ret=$?
: "${ret}"
}
11 changes: 11 additions & 0 deletions tests/fixtures/check-no-bare-ret/nested-brace-local-capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Fixture: same shape as nested-brace-bare-capture but local ret — hook must pass.

outer() {
{
true
}
local ret
ret=$?
: "${ret}"
}
22 changes: 22 additions & 0 deletions tests/fixtures/check-no-bare-ret/param-expansion-hash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Fixture: '#' inside parameter expansion (${var#…} / ${var##…} / ${#var})
# must not be treated as a comment-start by the hook. If it were, the
# matching '}' would be skipped, fn_body_depth would drift, and the
# top-level `ret=$?` below would be wrongly flagged as inside the function.
#
# These expansions are intentionally *unquoted* so the parser sees the '#'
# in the bare `norm` state — the buggy code path. (Inside double-quotes,
# '#' is already skipped by the dq state and the bug does not trigger.)

strip_prefix() {
local input=$1
local stripped len
stripped=${input#prefix-}
stripped=${stripped##old/}
len=${#input}
echo "$stripped $len"
}

strip_prefix "$@"
ret=$?
exit "$ret"
7 changes: 7 additions & 0 deletions tests/fixtures/check-no-bare-ret/trivial-bare-capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Fixture: simple function, bare capture (regression / baseline failure path).

f() {
ret=$?
: "${ret}"
}
Loading