From 492c0cecf92e29fc06e3aed49b64b35ff7fe66e0 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:59:17 -0700 Subject: [PATCH] Guarantee public APIs under caller-selected Bash options --- .github/workflows/tests.yml | 3 +- lib/bash/README.md | 42 +- lib/bash/arg/README.md | 10 +- lib/bash/arg/lib_arg.sh | 95 +++- lib/bash/arg/tests/lib_arg.bats | 138 +++++ lib/bash/file/README.md | 7 +- lib/bash/file/lib_file.sh | 6 +- lib/bash/file/tests/lib_file.bats | 72 ++- lib/bash/gh/README.md | 12 + lib/bash/gh/lib_gh.sh | 109 +++- lib/bash/gh/tests/lib_gh.bats | 157 ++++++ lib/bash/git/README.md | 8 + lib/bash/git/lib_git.sh | 72 ++- lib/bash/git/tests/lib_git.bats | 162 ++++++ lib/bash/list/README.md | 7 +- lib/bash/list/lib_list.sh | 86 +++- lib/bash/list/tests/lib_list.bats | 125 +++++ lib/bash/std/README.md | 15 +- lib/bash/std/lib_std.sh | 827 ++++++++++++++++++------------ lib/bash/std/tests/lib_std.bats | 299 ++++++++++- lib/bash/str/README.md | 2 + lib/bash/str/lib_str.sh | 49 +- lib/bash/str/tests/lib_str.bats | 131 +++++ tests/bash-option-contract.sh | 624 ++++++++++++++++++++++ tests/lint-warnings.sh | 1 + tests/validate.sh | 4 + 26 files changed, 2620 insertions(+), 443 deletions(-) create mode 100755 tests/bash-option-contract.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b0a609..9e0a2e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,7 +44,7 @@ jobs: check_bash_version ' bash "$PWD/lib/bash/std/lib_std.sh" - # The exact supported lower bound runs in the bash-42-logging job. This + # The exact supported lower bound runs in the compatibility job. This # macOS smoke exercises the known unsupported system Bash 3.2 path when # it is available. - name: Smoke unsupported macOS system Bash @@ -93,4 +93,5 @@ jobs: set -e bash tests/bash-42-logging-smoke.sh 4 2 53 bash tests/bash-42-release-smoke.sh 4 2 53 + bash tests/bash-option-contract.sh 4 2 53 ' diff --git a/lib/bash/README.md b/lib/bash/README.md index bfd17b2..ea3f072 100644 --- a/lib/bash/README.md +++ b/lib/bash/README.md @@ -26,9 +26,43 @@ The Base runtime shell files and Base version helpers remain in `basefoundry/base`. This repository carries only sourceable reusable library modules. +## Caller Runtime Contract + +All public modules support Bash 4.2 or newer with every combination of caller- +selected `errexit`, `nounset`, and `pipefail`. Sourcing a module does not change +those settings, any other `set` or `shopt` option, `IFS`, `OPTIND`, the working +directory, the umask, traps, or ordinary positional arguments. The stdlib's +documented wrapper flags are the exception: its initializer removes recognized +wrapper flags and publishes the filtered positional arguments. + +Public API calls preserve the same process state unless their documented +purpose is to change it. Examples of intentional mutation include PATH helpers, +`safe_cd`, caller-owned output variables, file-editing helpers, and cleanup +registrations while a hook or path remains active. Transient internal cleanup +registrations restore the caller's preexisting `EXIT` trap when the operation +finishes. + +Required arity is checked before a public helper expands a required positional +parameter, so a usage error remains diagnosable with caller `nounset` enabled. +Predicates and recoverable failures intentionally return nonzero; callers using +`errexit` should invoke expected nonzero results in `if`, `while`, `&&`, or +another Bash conditional context. + +The standalone `tests/bash-option-contract.sh` matrix sources every module and +exercises success, usage, predicate, and recoverable-failure paths in all eight +option combinations. CI runs that matrix on the current macOS and Ubuntu Bash +runtimes and in the digest-pinned, networkless Bash 4.2.53 compatibility image. + ## Naming Contract -Public helpers that write through caller-supplied variable or array names -reserve the `__` prefix for library-internal state. Passing an output name that -begins with `__` fails before the helper changes caller state. Use a regular -Bash variable name for public output values and arrays. +Public helpers that accept caller-supplied variable or array names reserve the +`__` prefix for library-internal state. Passing a caller-owned source or result +name that begins with `__` fails before the helper changes caller state. Use a +regular Bash variable name for public input and output values and arrays. +`assert_variable_name` is the syntax-only exception: it validates whether any +identifier is legal Bash syntax, including names in the reserved namespace, +without reading or writing the named variable. + +When one API accepts multiple caller-owned inputs or outputs, names that would +alias an input with an output are rejected before mutation. The module README +for that API documents the required distinct-name relationships. diff --git a/lib/bash/arg/README.md b/lib/bash/arg/README.md index 07a4eb9..6f4fc0f 100644 --- a/lib/bash/arg/README.md +++ b/lib/bash/arg/README.md @@ -11,9 +11,13 @@ helpers are available. - `arg_parse -- [args...]` Parse exact flag, value, and repeatable options into caller-owned arrays. - Returns `0` on success and `2` for malformed specs, unknown options, or - missing values; caller-owned outputs are published only after a successful - parse. + Returns `0` on success, `1` for invalid caller-owned variable contracts, and + `2` for malformed specs, unknown options, or missing values; caller-owned + outputs are published only after a successful parse. + +The options, positionals, and specs arrays must have distinct names. Every +repeatable option's output array must also be distinct from those three arrays. +Aliasing is rejected before any caller-owned output is changed. ## Usage diff --git a/lib/bash/arg/lib_arg.sh b/lib/bash/arg/lib_arg.sh index cabbaa6..b601f13 100644 --- a/lib/bash/arg/lib_arg.sh +++ b/lib/bash/arg/lib_arg.sh @@ -18,19 +18,52 @@ __arg_set_assoc_value__() { printf -v "$__arg_assoc_name[$__arg_assoc_key]" '%s' "$__arg_assoc_value" } +__arg_assert_distinct_names__() { + local -a __arg_distinct_names=("$@") + local __arg_left_index __arg_right_index + + for ((__arg_left_index = 0; __arg_left_index < ${#__arg_distinct_names[@]}; __arg_left_index++)); do + for ((__arg_right_index = __arg_left_index + 1; + __arg_right_index < ${#__arg_distinct_names[@]}; + __arg_right_index++)); do + if [[ "${__arg_distinct_names[__arg_left_index]}" == "${__arg_distinct_names[__arg_right_index]}" ]]; then + log_error -l base_bash_libs.arg \ + "arg_parse: caller-owned variables must be distinct; '${__arg_distinct_names[__arg_left_index]}' was provided more than once." + return 1 + fi + done + done + return 0 +} + +__arg_preflight_repeatable_names__() { + (($# == 1)) || return 0 + [[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 0 + eval "if [[ -n \"\${${1}[@]+set}\" ]]; then set -- \"\${${1}[@]}\"; else set --; fi" + + while (($#)); do + if [[ "${1#*|}" == repeatable\|* ]]; then + __std_assert_public_variable_names__ arg_parse "${1%%|*}" || return 1 + fi + shift + done + return 0 +} + __arg_parse_specs__() { local __arg_specs_name="$1" local __arg_token_kind_name="$2" __arg_token_name_name="$3" local __arg_repeatable_names_name="${4-}" + local __arg_options_name="${5-}" __arg_positionals_name="${6-}" __arg_caller_specs_name="${7-}" local -a __arg_specs=() __arg_tokens=() local __arg_spec __arg_remainder __arg_name __arg_kind __arg_tokens_part __arg_token local __arg_name_re='^[A-Za-z_][A-Za-z0-9_]*$' local __arg_token_re='^--?[[:alnum:]_][[:alnum:]_-]*$' local -A __arg_seen_names=() __arg_seen_tokens=() - eval "__arg_specs=(\"\${${__arg_specs_name}[@]}\")" + eval "if [[ -n \"\${${__arg_specs_name}[@]+set}\" ]]; then __arg_specs=(\"\${${__arg_specs_name}[@]}\"); fi" - for __arg_spec in "${__arg_specs[@]}"; do + for __arg_spec in "${__arg_specs[@]+"${__arg_specs[@]}"}"; do __arg_name="${__arg_spec%%|*}" __arg_remainder="${__arg_spec#*|}" __arg_kind="${__arg_remainder%%|*}" @@ -62,6 +95,9 @@ __arg_parse_specs__() { log_error -l base_bash_libs.arg "arg_parse: repeatable option spec '$__arg_name' requires an output array contract." return 2 fi + __std_assert_public_variable_names__ arg_parse "$__arg_name" || return 1 + __arg_assert_distinct_names__ \ + "$__arg_options_name" "$__arg_positionals_name" "$__arg_caller_specs_name" "$__arg_name" || return 1 if ! __std_declares_array_kind__ "$__arg_name" "a"; then log_error -l base_bash_libs.arg "arg_parse: repeatable option '$__arg_name' requires a caller-declared indexed array." return 2 @@ -76,7 +112,7 @@ __arg_parse_specs__() { return 2 fi IFS='|' read -r -a __arg_tokens <<<"$__arg_tokens_part" - for __arg_token in "${__arg_tokens[@]}"; do + for __arg_token in "${__arg_tokens[@]+"${__arg_tokens[@]}"}"; do if ! [[ "$__arg_token" =~ $__arg_token_re ]] || [[ "$__arg_token" == *"="* ]]; then log_error -l base_bash_libs.arg "arg_parse: option spec '$__arg_spec' has invalid option token '$__arg_token'." return 2 @@ -113,7 +149,14 @@ __arg_parse_specs__() { # arg_parse options positionals specs -- "$@" # arg_parse() { - local __arg_options_name="${1-}" __arg_positionals_name="${2-}" __arg_specs_name="${3-}" + if (($# < 4)) || [[ "${4-}" != "--" ]]; then + log_error -l base_bash_libs.arg "arg_parse: usage: arg_parse -- [args...]" + return 2 + fi + __std_assert_public_variable_names__ arg_parse "${1-}" "${2-}" "${3-}" || return 1 + __arg_preflight_repeatable_names__ "$3" || return 1 + + local __arg_options_name="$1" __arg_positionals_name="$2" __arg_specs_name="$3" local __arg_current __arg_option_token __arg_option_value __arg_option_name __arg_option_kind local __arg_repeatable_name __arg_repeatable_index __arg_repeatable_value local -a __arg_positionals=() __arg_repeatable_names=() __arg_repeatable_values=() @@ -121,17 +164,15 @@ arg_parse() { local -A __arg_options=() __arg_token_kind=() __arg_token_name=() local __arg_parse_options=1 - if (($# < 4)) || [[ "${4-}" != "--" ]]; then - log_error -l base_bash_libs.arg "arg_parse: usage: arg_parse -- [args...]" - return 2 - fi - + assert_variable_name "$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name" + __arg_assert_distinct_names__ "$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name" || return 1 assert_associative_array "$__arg_options_name" assert_indexed_array "$__arg_positionals_name" "$__arg_specs_name" __std_assert_writable_output__ arg_parse "$__arg_options_name" || return 1 __std_assert_writable_output__ arg_parse "$__arg_positionals_name" || return 1 - __arg_parse_specs__ "$__arg_specs_name" __arg_token_kind __arg_token_name __arg_repeatable_names || return $? + __arg_parse_specs__ "$__arg_specs_name" __arg_token_kind __arg_token_name __arg_repeatable_names \ + "$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name" || return $? shift 4 @@ -209,20 +250,34 @@ arg_parse() { done eval "$__arg_options_name=()" - for __arg_option_name in "${!__arg_options[@]}"; do - __arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "${__arg_options[$__arg_option_name]}" + # shellcheck disable=SC2199 # The + expansion safely detects Bash 4.2 empty arrays under nounset. + if [[ -n "${__arg_options[@]+set}" ]]; then + for __arg_option_name in "${!__arg_options[@]}"; do + __arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "${__arg_options[$__arg_option_name]}" + done + fi + eval "$__arg_positionals_name=()" + for __arg_current in "${__arg_positionals[@]+"${__arg_positionals[@]}"}"; do + eval "$__arg_positionals_name+=(\"\$__arg_current\")" done - eval "$__arg_positionals_name=(\"\${__arg_positionals[@]}\")" - for __arg_repeatable_name in "${__arg_repeatable_names[@]}"; do + for __arg_repeatable_name in "${__arg_repeatable_names[@]+"${__arg_repeatable_names[@]}"}"; do __arg_publish_values=() - for ((__arg_repeatable_index = 0; __arg_repeatable_index < ${#__arg_repeatable_values[@]}; __arg_repeatable_index += 2)); do - if [[ "${__arg_repeatable_values[__arg_repeatable_index]}" == "$__arg_repeatable_name" ]]; then - __arg_repeatable_value="${__arg_repeatable_values[__arg_repeatable_index + 1]}" - __arg_publish_values+=("$__arg_repeatable_value") - fi + # shellcheck disable=SC2199 # The + expansion safely detects Bash 4.2 empty arrays under nounset. + if [[ -n "${__arg_repeatable_values[@]+set}" ]]; then + for ((__arg_repeatable_index = 0; + __arg_repeatable_index < ${#__arg_repeatable_values[@]}; + __arg_repeatable_index += 2)); do + if [[ "${__arg_repeatable_values[__arg_repeatable_index]}" == "$__arg_repeatable_name" ]]; then + __arg_repeatable_value="${__arg_repeatable_values[__arg_repeatable_index + 1]}" + __arg_publish_values+=("$__arg_repeatable_value") + fi + done + fi + eval "$__arg_repeatable_name=()" + for __arg_repeatable_value in "${__arg_publish_values[@]+"${__arg_publish_values[@]}"}"; do + eval "$__arg_repeatable_name+=(\"\$__arg_repeatable_value\")" done - eval "$__arg_repeatable_name=(\"\${__arg_publish_values[@]}\")" done return 0 } diff --git a/lib/bash/arg/tests/lib_arg.bats b/lib/bash/arg/tests/lib_arg.bats index 5a0db27..0390cda 100644 --- a/lib/bash/arg/tests/lib_arg.bats +++ b/lib/bash/arg/tests/lib_arg.bats @@ -37,6 +37,27 @@ create_script() { [[ "$output" == *"source-rc=1"* ]] } +@test "arg_parse returns usage without nounset aborts under every caller option combination" { + local mode + + for mode in off e u p eu ep up eup; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + arg_parse + exit $? + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/arg/lib_arg.sh" + + [ "$status" -eq 2 ] + [[ "$output" == *"arg_parse: usage:"* ]] + [[ "$output" != *"unbound variable"* ]] + done +} + @test "arg_parse stores flags values and positionals" { local -a specs=( "verbose|flag|--verbose|-v" @@ -109,6 +130,102 @@ EOF [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] } +@test "arg_parse rejects exact internal holder and repeatable names before locals or mutation" { + local -r __arg_options_name=actual_options + local -A actual_options=([sentinel]="keep") + local -a positionals=(old) + local -a specs=("verbose|flag|--verbose") + local -ar __arg_repeatable_name=(saved) + local -a repeatable_specs=("__arg_repeatable_name|repeatable|--include") + local stderr_file="$TEST_TMPDIR/arg-internal-holder.err" + local rc + + if arg_parse __arg_options_name positionals specs -- --verbose 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${actual_options[sentinel]}" = "keep" ] + [ "${positionals[0]}" = "old" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] + + if arg_parse actual_options positionals repeatable_specs -- --include new 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${actual_options[sentinel]}" = "keep" ] + [ "${positionals[0]}" = "old" ] + [ "${__arg_repeatable_name[0]}" = "saved" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] +} + +@test "arg_parse rejects aliases among its primary caller-owned arrays before mutation" { + local -A options=([existing]="keep") + local -a positionals=(old) + local -a specs=("verbose|flag|--verbose|-v") + local rc + + if arg_parse options options specs -- --verbose 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${options[existing]}" = "keep" ] + + if arg_parse options positionals options -- --verbose 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${options[existing]}" = "keep" ] + [ "${positionals[0]}" = "old" ] + + if arg_parse options positionals positionals -- --verbose 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${options[existing]}" = "keep" ] + [ "${positionals[0]}" = "old" ] + [ "${specs[0]}" = "verbose|flag|--verbose|-v" ] +} + +@test "arg_parse rejects repeatable-output aliases before mutation" { + local -A options=([existing]="keep") + local -a positionals=(old) + local -a specs=("include|repeatable|--include") + local -a include=(saved) + local rc + + if arg_parse options include specs -- --include new 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${options[existing]}" = "keep" ] + [ "${include[0]}" = "saved" ] + + include=("include|repeatable|--include") + if arg_parse options positionals include -- --include new 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${options[existing]}" = "keep" ] + [ "${positionals[0]}" = "old" ] + [ "${include[0]}" = "include|repeatable|--include" ] +} + @test "arg_parse accepts long option equals values and repeated options" { local -a specs=( "verbose|flag|--verbose|-v" @@ -165,6 +282,27 @@ EOF [ -z "${options[include]+set}" ] } +@test "arg_parse handles declared-empty arrays under nounset" { + local script="$TEST_TMPDIR/arg-empty-nounset.sh" + + create_script "$script" < [-r|content...]` +- `update_file_section [-r] [content...]` Idempotently add, replace, or remove a marker-delimited block inside a file. It mutates the target or symlink referent and returns nonzero on validation or filesystem failure. @@ -59,6 +61,9 @@ fi - `file_section_needs_update` returns `0` when an add/update would change the target file, `1` when the first existing marked section already matches, and `2` when marker pairs are asymmetric or misordered. +- Invalid or incomplete arguments produce a usage diagnostic and return + nonzero without relying on unset positional parameters. Under `errexit`, use + a conditional context when a nonzero inspection result is expected. ## Tests diff --git a/lib/bash/file/lib_file.sh b/lib/bash/file/lib_file.sh index 6db24c5..d863b1b 100644 --- a/lib/bash/file/lib_file.sh +++ b/lib/bash/file/lib_file.sh @@ -322,7 +322,7 @@ update_file_section() { local remove_section=false local new_content_array=() - if [[ "$1" == "-r" ]]; then + if [[ "${1-}" == "-r" ]]; then remove_section=true shift # consume -r fi @@ -371,10 +371,10 @@ update_file_section() { local new_content_string="" if [[ "$remove_section" == false ]]; then - if [[ ${#new_content_array[@]} -gt 0 ]]; then + if [[ -n "${new_content_array[0]+set}" ]]; then # Use printf to join array elements with newlines, adding a final newline. # This ensures proper multi-line insertion. - printf -v new_content_string '%s\n' "${new_content_array[@]}" + printf -v new_content_string '%s\n' "${new_content_array[@]+"${new_content_array[@]}"}" fi fi diff --git a/lib/bash/file/tests/lib_file.bats b/lib/bash/file/tests/lib_file.bats index 85e9edb..49a6e72 100644 --- a/lib/bash/file/tests/lib_file.bats +++ b/lib/bash/file/tests/lib_file.bats @@ -96,6 +96,51 @@ EOF [[ "$output" == *"source-rc=1"* ]] } +@test "update_file_section reports zero-argument usage under strict options" { + local script="$TEST_TMPDIR/update-file-section-usage-strict.sh" + + cat > "$script" < "$target" + cat > "$script" < "$target" @@ -243,7 +288,7 @@ EOF update_file_section "$target" "# BEGIN" "# END" "new" - for cleanup_path in "${__std_cleanup_paths[@]}"; do + for cleanup_path in "${__std_cleanup_paths[@]+"${__std_cleanup_paths[@]}"}"; do if [[ "$cleanup_path" == *"base-file-section-new."* || "$cleanup_path" == *"base-file-section-current."* || "$cleanup_path" == *"config.txt."* ]]; then @@ -253,6 +298,31 @@ EOF done } +@test "update_file_section restores a preexisting EXIT trap after transient cleanup" { + local script="$TEST_TMPDIR/update-file-section-exit-trap.sh" + local target="$TEST_TMPDIR/update-file-section-exit-trap.txt" + local log_file="$TEST_TMPDIR/update-file-section-exit-trap.log" + + printf 'before\n' > "$target" + cat > "$script" <> "$log_file"' EXIT +before_trap="\$(trap -p EXIT)" +update_file_section "$target" '# BEGIN' '# END' 'managed' +after_trap="\$(trap -p EXIT)" +[[ "\$after_trap" == "\$before_trap" ]] +EOF + chmod +x "$script" + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$log_file")" = "caller" ] + [ "$(cat "$target")" = $'before\n# BEGIN\nmanaged\n# END' ] +} + @test "update_file_section skips unchanged existing section" { local before_inode local target="$TEST_TMPDIR/config.txt" diff --git a/lib/bash/gh/README.md b/lib/bash/gh/README.md index 948f27e..52c50d6 100644 --- a/lib/bash/gh/README.md +++ b/lib/bash/gh/README.md @@ -48,6 +48,18 @@ All GitHub helper failures return a nonzero status and preserve the underlying `gh` status where applicable. The remote parser and origin inference helpers leave caller-owned result variables unchanged on failure; use `--optional` with `gh_infer_repo_from_origin` when a missing or non-GitHub origin is expected. + +Public functions validate the documented argument count before expanding +required positional parameters. Invalid calls return `1`, including when the +caller has enabled `nounset`; optional flags such as `--optional` are rejected +when misspelled. The variadic `gh_run` and `gh_api_with_retry` helpers continue +to pass all arguments through to `gh` unchanged. + +The library does not change the caller's `errexit`, `nounset`, `pipefail`, +`shopt`, `IFS`, `OPTIND`, cwd, umask, traps, or positional parameters. Its +diagnostic parsing uses a command-scoped empty `IFS`, and failed `gh` commands +retain their original status from `1` through `255`. + ## Boundary This library is intentionally generic. It does not know about Base branch diff --git a/lib/bash/gh/lib_gh.sh b/lib/bash/gh/lib_gh.sh index 22c362a..a5603cc 100644 --- a/lib/bash/gh/lib_gh.sh +++ b/lib/bash/gh/lib_gh.sh @@ -10,7 +10,15 @@ if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then fi readonly __lib_gh_sourced__=1 +# Public callers may provide the optional install hint even though internal +# callers use the default. +# shellcheck disable=SC2120 gh_require_cli() { + if (($# > 1)); then + log_error -l base_bash_libs.gh "Usage: gh_require_cli [install_hint]" + return 1 + fi + local install_hint="${1:-}" command -v gh >/dev/null 2>&1 || { @@ -20,7 +28,15 @@ gh_require_cli() { } } +# Public callers may provide the optional login hint even though the internal +# failure reporter uses the default. +# shellcheck disable=SC2120 gh_auth_status_diagnostics() { + if (($# > 1)); then + log_error -l base_bash_libs.gh "Usage: gh_auth_status_diagnostics [login_hint]" + return 1 + fi + local login_hint="${1:-Run 'gh auth login -h github.com' and retry.}" local auth_output line @@ -36,10 +52,25 @@ gh_auth_status_diagnostics() { } gh_report_command_failure() { + if (($# < 1)); then + log_error -l base_bash_libs.gh "Usage: gh_report_command_failure [gh args...]" + return 1 + fi + local status="$1" shift local printable_args="" + if [[ ! "$status" =~ ^[0-9]{1,3}$ ]]; then + log_error -l base_bash_libs.gh "Usage: gh_report_command_failure [gh args...]" + return 1 + fi + status=$((10#$status)) + if ((status < 1 || status > 255)); then + log_error -l base_bash_libs.gh "Usage: gh_report_command_failure [gh args...]" + return 1 + fi + if (($#)); then printf -v printable_args '%q ' "$@" printable_args="${printable_args% }" @@ -60,49 +91,69 @@ gh_run() { gh_report_command_failure "$status" "$@" } -gh_repo_from_remote_url() { - local __gh_remote_url="$1" - local __gh_result_name="${2:-}" - local __gh_parsed_repo - - if [[ -z "$__gh_remote_url" || -z "$__gh_result_name" ]]; then - log_error -l base_bash_libs.gh "Usage: gh_repo_from_remote_url " - return 1 - fi - assert_variable_name "$__gh_result_name" - __std_assert_writable_output__ gh_repo_from_remote_url "$__gh_result_name" || return 1 +__gh_parse_repo_from_remote_url__() { + local __gh_parse_remote_url="${1-}" __gh_parse_result_name="${2-}" + local __gh_parse_repo - case "$__gh_remote_url" in + case "$__gh_parse_remote_url" in git@github.com:*) - __gh_parsed_repo="${__gh_remote_url#git@github.com:}" + __gh_parse_repo="${__gh_parse_remote_url#git@github.com:}" ;; ssh://git@github.com/*) - __gh_parsed_repo="${__gh_remote_url#ssh://git@github.com/}" + __gh_parse_repo="${__gh_parse_remote_url#ssh://git@github.com/}" ;; https://github.com/*) - __gh_parsed_repo="${__gh_remote_url#https://github.com/}" + __gh_parse_repo="${__gh_parse_remote_url#https://github.com/}" ;; *) return 1 ;; esac - __gh_parsed_repo="${__gh_parsed_repo%.git}" - [[ "$__gh_parsed_repo" =~ ^[^/[:space:]?#]+/[^/[:space:]?#]+$ ]] || return 1 + __gh_parse_repo="${__gh_parse_repo%.git}" + [[ "$__gh_parse_repo" =~ ^[^/[:space:]?#]+/[^/[:space:]?#]+$ ]] || return 1 + printf -v "$__gh_parse_result_name" '%s' "$__gh_parse_repo" +} + +gh_repo_from_remote_url() { + if (($# != 2)); then + log_error -l base_bash_libs.gh "Usage: gh_repo_from_remote_url " + return 1 + fi + __std_assert_public_variable_names__ gh_repo_from_remote_url "${2-}" || return 1 + + local __gh_remote_url="$1" + local __gh_result_name="$2" + local __gh_parsed_repo + + if [[ -z "$__gh_remote_url" || -z "$__gh_result_name" ]]; then + log_error -l base_bash_libs.gh "Usage: gh_repo_from_remote_url " + return 1 + fi + assert_variable_name "$__gh_result_name" || return 1 + __std_assert_writable_output__ gh_repo_from_remote_url "$__gh_result_name" || return 1 + + __gh_parse_repo_from_remote_url__ "$__gh_remote_url" __gh_parsed_repo || return 1 printf -v "$__gh_result_name" '%s' "$__gh_parsed_repo" } gh_infer_repo_from_origin() { + if (($# < 2 || $# > 3)) || { (($# == 3)) && [[ "$3" != "--optional" ]]; }; then + log_error -l base_bash_libs.gh "Usage: gh_infer_repo_from_origin [--optional]" + return 1 + fi + __std_assert_public_variable_names__ gh_infer_repo_from_origin "${2-}" || return 1 + local __gh_infer_repo_dir="$1" - local __gh_infer_result_name="${2:-}" + local __gh_infer_result_name="$2" local __gh_infer_optional=0 - local gh_infer_parsed_repo __gh_infer_remote_url + local __gh_infer_parsed_repo __gh_infer_remote_url if [[ -z "$__gh_infer_repo_dir" || -z "$__gh_infer_result_name" ]]; then log_error -l base_bash_libs.gh "Usage: gh_infer_repo_from_origin [--optional]" return 1 fi - assert_variable_name "$__gh_infer_result_name" + assert_variable_name "$__gh_infer_result_name" || return 1 __std_assert_writable_output__ gh_infer_repo_from_origin "$__gh_infer_result_name" || return 1 if [[ "${3:-}" == "--optional" ]]; then @@ -111,7 +162,7 @@ gh_infer_repo_from_origin() { __gh_infer_remote_url="$(git -C "$__gh_infer_repo_dir" remote get-url origin 2>/dev/null || true)" if [[ -z "$__gh_infer_remote_url" ]] || - ! gh_repo_from_remote_url "$__gh_infer_remote_url" gh_infer_parsed_repo; then + ! __gh_parse_repo_from_remote_url__ "$__gh_infer_remote_url" __gh_infer_parsed_repo; then if ((__gh_infer_optional)); then printf -v "$__gh_infer_result_name" '%s' "" return 0 @@ -120,19 +171,25 @@ gh_infer_repo_from_origin() { return 1 fi - printf -v "$__gh_infer_result_name" '%s' "$gh_infer_parsed_repo" + printf -v "$__gh_infer_result_name" '%s' "$__gh_infer_parsed_repo" } gh_repo_default_branch() { + if (($# != 2)); then + log_error -l base_bash_libs.gh "Usage: gh_repo_default_branch " + return 1 + fi + __std_assert_public_variable_names__ gh_repo_default_branch "${2-}" || return 1 + local __gh_repo="$1" - local __gh_repo_result_name="${2:-}" + local __gh_repo_result_name="$2" local __gh_repo_default_branch __gh_repo_status=0 if [[ -z "$__gh_repo" || -z "$__gh_repo_result_name" ]]; then log_error -l base_bash_libs.gh "Usage: gh_repo_default_branch " return 1 fi - assert_variable_name "$__gh_repo_result_name" + assert_variable_name "$__gh_repo_result_name" || return 1 __std_assert_writable_output__ gh_repo_default_branch "$__gh_repo_result_name" || return 1 gh_require_cli || return 1 @@ -150,6 +207,8 @@ gh_repo_default_branch() { } __gh_api_failure_retryable() { + (($# == 1)) || return 1 + local output="${1,,}" [[ "$output" == *"secondary rate limit"* || @@ -165,6 +224,8 @@ __gh_api_failure_retryable() { } __gh_api_retry_delay_seconds() { + (($# == 1)) || return 1 + local output="${1,,}" local configured_delay="${BASE_GH_API_RETRY_DELAY_SECONDS:-2}" diff --git a/lib/bash/gh/tests/lib_gh.bats b/lib/bash/gh/tests/lib_gh.bats index 5195f46..973cc80 100644 --- a/lib/bash/gh/tests/lib_gh.bats +++ b/lib/bash/gh/tests/lib_gh.bats @@ -40,6 +40,116 @@ create_fake_git() { [[ "$output" != *"command not found"* ]] } +@test "GitHub required-argument APIs return usage errors under every caller option combination" { + local function_name mode + + for mode in off e u p eu ep up eup; do + for function_name in \ + gh_report_command_failure \ + gh_repo_from_remote_url \ + gh_infer_repo_from_origin \ + gh_repo_default_branch; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + "$4" + rc=$? + exit "$rc" + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/gh/lib_gh.sh" "$function_name" + + [ "$status" -eq 1 ] + [[ "$output" == *"Usage:"* ]] + [[ "$output" != *"unbound variable"* ]] + done + done +} + +@test "GitHub optional forms reject excess arguments and invalid values" { + capture_command gh_require_cli one two + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: gh_require_cli [install_hint]"* ]] + + capture_command gh_auth_status_diagnostics one two + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: gh_auth_status_diagnostics [login_hint]"* ]] + + capture_command gh_infer_repo_from_origin repo result --required + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: gh_infer_repo_from_origin [--optional]"* ]] + + capture_command gh_report_command_failure invalid issue list + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: gh_report_command_failure [gh args...]"* ]] + + capture_command gh_report_command_failure 0 issue list + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: gh_report_command_failure [gh args...]"* ]] + + capture_command gh_report_command_failure 256 issue list + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: gh_report_command_failure [gh args...]"* ]] +} + +@test "GitHub diagnostics are independent of and preserve caller IFS" { + local output_file="$TEST_TMPDIR/auth-diagnostics.out" + local rc + + create_fake_gh <<'EOF' +#!/usr/bin/env bash +printf 'first diagnostic\nsecond diagnostic\n' >&2 +exit 4 +EOF + + IFS=: + if gh_auth_status_diagnostics >"$output_file" 2>&1; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [ "$IFS" = ":" ] + [[ "$(cat "$output_file")" == *"gh auth status: first diagnostic"* ]] + [[ "$(cat "$output_file")" == *"gh auth status: second diagnostic"* ]] +} + +@test "gh_run preserves command status under every caller option combination" { + local mode + + create_fake_gh <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "auth" && "${2:-}" == "status" ]]; then + printf 'not logged in\n' >&2 + exit 1 +fi +printf 'command failed\n' >&2 +exit 7 +EOF + + for mode in off e u p eu ep up eup; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + PATH="$4:$PATH" + gh_run issue list + rc=$? + exit "$rc" + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/gh/lib_gh.sh" "$TEST_TMPDIR/bin" + + [ "$status" -eq 7 ] + [[ "$output" == *"GitHub command failed: gh issue list"* ]] + [[ "$output" != *"unbound variable"* ]] + done +} + @test "gh_require_cli succeeds when gh is on PATH" { create_fake_gh <<'EOF' #!/usr/bin/env bash @@ -69,6 +179,41 @@ EOF [[ "$(cat "$stderr_file")" == *"result variable 'repo' is readonly"* ]] } +@test "GitHub result helpers reject exact internal holder names before locals or mutation" { + local -r __gh_result_name=parsed + local -r __gh_infer_result_name=inferred + local -r __gh_repo_result_name=defaulted + local parsed="keep-parsed" inferred="keep-inferred" defaulted="keep-defaulted" + local stderr_file="$TEST_TMPDIR/gh-internal-holder.err" + local rc + + if gh_repo_from_remote_url "https://github.com/owner/project.git" __gh_result_name 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$parsed" = "keep-parsed" ] + + if gh_infer_repo_from_origin . __gh_infer_result_name --optional 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$inferred" = "keep-inferred" ] + + if gh_repo_default_branch owner/project __gh_repo_result_name 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$defaulted" = "keep-defaulted" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] +} + @test "gh_require_cli reports missing gh with caller hint" { mkdir -p "$TEST_TMPDIR/no-gh-bin" @@ -291,6 +436,18 @@ EOF [ "$remote_url" = "owner/repo" ] } +@test "gh_infer_repo_from_origin supports its former internal parsed name as the result variable" { + local repo_dir="$TEST_TMPDIR/repo" + local gh_infer_parsed_repo="" + + init_git_repo "$repo_dir" + git -C "$repo_dir" remote add origin "git@github.com:owner/repo.git" + + gh_infer_repo_from_origin "$repo_dir" gh_infer_parsed_repo + + [ "$gh_infer_parsed_repo" = "owner/repo" ] +} + @test "gh_infer_repo_from_origin returns empty success for non-GitHub remotes when optional" { local repo_dir="$TEST_TMPDIR/repo" local repo="sentinel" diff --git a/lib/bash/git/README.md b/lib/bash/git/README.md index c02410a..bb975fd 100644 --- a/lib/bash/git/README.md +++ b/lib/bash/git/README.md @@ -48,6 +48,14 @@ log_info "Current branch: $branch" ## Behavior Notes +- Public functions validate the documented argument count before expanding + required positional parameters. Invalid calls return `1`, including when the + caller has enabled `nounset`; extra arguments are rejected unless the + signature explicitly accepts them. +- The library does not change the caller's `errexit`, `nounset`, `pipefail`, + `shopt`, `IFS`, `OPTIND`, cwd, umask, traps, or positional parameters. + Parsing that requires field splitting uses a command-scoped `IFS`, so a + caller-defined value is preserved. - `git_update_repo` only attempts updates when the checked-out branch is the detected default branch, or an explicit expected branch passed by the caller. - `git_update_repo` retries `git pull --ff-only` twice by default. Set `BASE_GIT_PULL_MAX_ATTEMPTS` to a positive integer to change the retry count. diff --git a/lib/bash/git/lib_git.sh b/lib/bash/git/lib_git.sh index bca0cfd..ace1955 100644 --- a/lib/bash/git/lib_git.sh +++ b/lib/bash/git/lib_git.sh @@ -11,15 +11,21 @@ fi readonly __lib_git_sourced__=1 git_detect_default_branch() { + if (($# != 2)); then + log_error -l base_bash_libs.git "Usage: git_detect_default_branch " + return 1 + fi + __std_assert_public_variable_names__ git_detect_default_branch "${2-}" || return 1 + local __git_detect_repo_dir="$1" - local __git_detect_result_name="${2:-}" + local __git_detect_result_name="$2" local __git_detect_branch if [[ -z "$__git_detect_repo_dir" || -z "$__git_detect_result_name" ]]; then log_error -l base_bash_libs.git "Usage: git_detect_default_branch " return 1 fi - assert_variable_name "$__git_detect_result_name" + assert_variable_name "$__git_detect_result_name" || return 1 __std_assert_writable_output__ git_detect_default_branch "$__git_detect_result_name" || return 1 if __git_detect_branch="$(git -C "$__git_detect_repo_dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)"; then @@ -46,6 +52,11 @@ git_detect_default_branch() { } git_worktree_path_for_branch() { + if (($# < 1 || $# > 2)); then + log_error -l base_bash_libs.git "Usage: git_worktree_path_for_branch [repo_dir]" + return 1 + fi + local branch="$1" local repo_dir="${2:-}" local target_ref="refs/heads/$branch" @@ -58,7 +69,7 @@ git_worktree_path_for_branch() { } [[ -z "$repo_dir" ]] || git_cmd=(git -C "$repo_dir") - if ! output="$("${git_cmd[@]}" worktree list --porcelain 2>&1)"; then + if ! output="$("${git_cmd[@]+"${git_cmd[@]}"}" worktree list --porcelain 2>&1)"; then log_error -l base_bash_libs.git "Unable to list Git worktrees." return 1 fi @@ -81,12 +92,17 @@ git_worktree_path_for_branch() { } git_list_worktree_branches() { + if (($# > 1)); then + log_error -l base_bash_libs.git "Usage: git_list_worktree_branches [repo_dir]" + return 1 + fi + local repo_dir="${1:-}" local line path="" branch="" output local -a git_cmd=(git) [[ -z "$repo_dir" ]] || git_cmd=(git -C "$repo_dir") - if ! output="$("${git_cmd[@]}" worktree list --porcelain 2>&1)"; then + if ! output="$("${git_cmd[@]+"${git_cmd[@]}"}" worktree list --porcelain 2>&1)"; then log_error -l base_bash_libs.git "Unable to list Git worktrees." return 1 fi @@ -113,6 +129,11 @@ git_list_worktree_branches() { } git_branch_upstream() { + if (($# != 2)); then + log_error -l base_bash_libs.git "Usage: git_branch_upstream " + return 1 + fi + local repo_dir="$1" local branch="$2" @@ -125,6 +146,11 @@ git_branch_upstream() { } git_branch_merged_to_ref() { + if (($# != 3)); then + log_error -l base_bash_libs.git "Usage: git_branch_merged_to_ref " + return 1 + fi + local repo_dir="$1" local branch="$2" local ref="$3" @@ -138,6 +164,11 @@ git_branch_merged_to_ref() { } git_list_remote_branches() { + if (($# > 1)); then + log_error -l base_bash_libs.git "Usage: git_list_remote_branches [repo_dir]" + return 1 + fi + local repo_dir="${1:-.}" local output ref _sha @@ -145,7 +176,7 @@ git_list_remote_branches() { log_error -l base_bash_libs.git "Unable to list remote branches from origin." return 1 fi - while read -r _sha ref; do + while IFS=$' \t' read -r _sha ref; do [[ "$ref" == refs/heads/* ]] || continue printf '%s\n' "${ref#refs/heads/}" done <<<"$output" @@ -157,12 +188,16 @@ git_list_remote_branches() { # @param $1 allowed_path Path in repository root that may be dirty (for example "shared"). # __git_path_matches_allowed_path__() { + (($# == 2)) || return 1 + local path="$1" allowed_path="$2" [[ "$path" == "$allowed_path" || "$path" == "$allowed_path/"* ]] } __git_only_path_dirty__() { + (($# == 1)) || return 1 + local allowed_path="$1" local status_file status_record status_code path related_path @@ -204,6 +239,8 @@ __git_only_path_dirty__() { } __git_expected_update_branch__() { + (($# <= 1)) || return 1 + local configured_branch="${1:-}" local default_branch @@ -265,6 +302,8 @@ __git_update_repo_finish__() { } __git_pull_with_retry__() { + (($# == 1)) || return 1 + local git_log="$1" local max_attempts="${BASE_GIT_PULL_MAX_ATTEMPTS:-2}" local attempt=1 @@ -308,6 +347,11 @@ __git_pull_with_retry__() { # BASE_GIT_PULL_MAX_ATTEMPTS Positive integer retry count for `git pull --ff-only`; defaults to 2. # git_update_repo() { + if (($# < 1 || $# > 3)); then + log_info -l base_bash_libs.git "Usage: git_update_repo /path/to/repo [allowed_dirty_path] [expected_branch]" + return 1 + fi + local git_repo="$1" local allowed_dirty_path="${2:-}" local expected_branch="${3:-}" @@ -412,8 +456,14 @@ git_update_repo() { # - The function itself returns an exit code of 0 on success, 1 on invalid usage. # git_get_current_branch() { + if (($# != 2)); then + log_error -l base_bash_libs.git "Usage: git_get_current_branch " + return 1 + fi + __std_assert_public_variable_names__ git_get_current_branch "${2-}" || return 1 + local __git_branch_target_dir="$1" - local __git_branch_result_name="${2:-}" + local __git_branch_result_name="$2" # --- Argument Validation --- if [[ -z "$__git_branch_target_dir" || -z "$__git_branch_result_name" ]]; then @@ -468,12 +518,14 @@ git_get_current_branch() { check_script_up_to_date() { local fetch_before_check=false script_path - if [[ "${1:-}" == "--fetch" ]]; then + if (($# == 2)); then + if [[ "$1" != "--fetch" ]]; then + log_error -l base_bash_libs.git "Usage: check_script_up_to_date [--fetch] " + return 1 + fi fetch_before_check=true shift - fi - - if (($# != 1)); then + elif (($# != 1)) || [[ "$1" == "--fetch" ]]; then log_error -l base_bash_libs.git "Usage: check_script_up_to_date [--fetch] " return 1 fi diff --git a/lib/bash/git/tests/lib_git.bats b/lib/bash/git/tests/lib_git.bats index 1c0e99d..1c083ef 100644 --- a/lib/bash/git/tests/lib_git.bats +++ b/lib/bash/git/tests/lib_git.bats @@ -31,6 +31,115 @@ setup() { [[ "$output" == *"source-rc=1"* ]] } +@test "git required-argument APIs return usage errors under every caller option combination" { + local function_name mode + + for mode in off e u p eu ep up eup; do + for function_name in \ + git_detect_default_branch \ + git_worktree_path_for_branch \ + git_branch_upstream \ + git_branch_merged_to_ref \ + git_update_repo \ + git_get_current_branch \ + check_script_up_to_date; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + "$4" + rc=$? + exit "$rc" + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/git/lib_git.sh" "$function_name" + + [ "$status" -eq 1 ] + [[ "$output" == *"Usage:"* ]] + [[ "$output" != *"unbound variable"* ]] + done + done +} + +@test "git optional forms reject excess arguments and unsupported option placement" { + capture_command git_list_worktree_branches one two + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: git_list_worktree_branches [repo_dir]"* ]] + + capture_command git_list_remote_branches one two + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: git_list_remote_branches [repo_dir]"* ]] + + capture_command git_worktree_path_for_branch branch repo extra + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: git_worktree_path_for_branch [repo_dir]"* ]] + + capture_command check_script_up_to_date --refresh script.sh + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: check_script_up_to_date [--fetch] "* ]] + + capture_command check_script_up_to_date --fetch + [ "$status" -eq 1 ] + [[ "$output" == *"Usage: check_script_up_to_date [--fetch] "* ]] +} + +@test "git remote parsing is independent of and preserves caller IFS" { + local output_file="$TEST_TMPDIR/remote-branches.out" + local rc + + git() { + if [[ "${1:-}" == "-C" && "${3:-}" == "ls-remote" ]]; then + printf 'abc123\trefs/heads/main\n' + printf 'def456\trefs/heads/feature/topic\n' + return 0 + fi + command git "$@" + } + + IFS=: + if git_list_remote_branches "$TEST_TMPDIR" >"$output_file"; then + rc=0 + else + rc=$? + fi + unset -f git + + [ "$rc" -eq 0 ] + [ "$IFS" = ":" ] + [ "$(cat "$output_file")" = $'main\nfeature/topic' ] +} + +@test "git predicate status survives every caller option combination" { + local mode + local repo="$TEST_TMPDIR/repo" + + init_git_repo "$repo" + printf 'base\n' > "$repo/data.txt" + commit_all "$repo" "Initial commit" + git -C "$repo" checkout -b feature >/dev/null 2>&1 + printf 'feature\n' > "$repo/feature.txt" + commit_all "$repo" "Feature commit" + git -C "$repo" checkout main >/dev/null 2>&1 + + for mode in off e u p eu ep up eup; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + git_branch_merged_to_ref "$4" feature main + rc=$? + exit "$rc" + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/git/lib_git.sh" "$repo" + + [ "$status" -eq 1 ] + [[ "$output" != *"unbound variable"* ]] + done +} + @test "git_detect_default_branch resolves origin HEAD and fallback branches" { local repo="$TEST_TMPDIR/repo" local branch="" @@ -93,6 +202,32 @@ EOF unset -f git } +@test "git worktree command arrays are Bash 4.2 nounset-safe" { + bats_run "$BASH" -c ' + set -u + source "$1" + source "$2" + git() { + printf "%s\n" \ + "worktree /tmp/main" \ + "HEAD abc123" \ + "branch refs/heads/main" \ + "" \ + "worktree /tmp/feature" \ + "HEAD def456" \ + "branch refs/heads/feature/test" + } + git_worktree_path_for_branch feature/test + git_list_worktree_branches /tmp/repo + ' bash "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/git/lib_git.sh" + + [ "$status" -eq 0 ] + [[ "$output" == *"/tmp/feature"* ]] + [[ "$output" == *$'/tmp/main\tmain'* ]] + [[ "$output" == *$'/tmp/feature\tfeature/test'* ]] + [[ "$output" != *"unbound variable"* ]] +} + @test "git branch and remote helpers use generic names" { local repo="$TEST_TMPDIR/repo" local branch_output remote_output @@ -148,6 +283,33 @@ EOF [[ "$(cat "$stderr_file")" == *"result variable 'branch' is readonly"* ]] } +@test "Git result helpers reject exact internal holder names before locals or mutation" { + local -r __git_detect_result_name=detected + local -r __git_branch_result_name=current + local detected="keep-detected" + local current="keep-current" + local stderr_file="$TEST_TMPDIR/git-internal-holder.err" + local rc + + if git_detect_default_branch . __git_detect_result_name 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$detected" = "keep-detected" ] + + if git_get_current_branch . __git_branch_result_name 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$current" = "keep-current" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] +} + @test "git_get_current_branch supports shadowing-prone output variable names" { local repo="$TEST_TMPDIR/repo" local result_var_name="" diff --git a/lib/bash/list/README.md b/lib/bash/list/README.md index 85215cd..3e9280a 100644 --- a/lib/bash/list/README.md +++ b/lib/bash/list/README.md @@ -9,9 +9,9 @@ helpers are available. ## Public API -- `list_append [value...]` +- `list_append [value...]` Append one or more values to a named indexed array. -- `list_prepend [value...]` +- `list_prepend [value...]` Prepend one or more values to a named indexed array. - `list_remove ` Remove all exact matches from a named indexed array. @@ -44,6 +44,9 @@ with `declare -a values=()`. Scalar result helpers accept the name of the output variable, validate it with `assert_variable_name`, and avoid stdout capture for caller state. +For `list_unique` and `list_length`, the result and source variable names must +be distinct. An alias is rejected before the source is changed. + ## Tests BATS coverage lives in `lib/bash/list/tests/lib_list.bats`. diff --git a/lib/bash/list/lib_list.sh b/lib/bash/list/lib_list.sh index 677b8dc..cfc57e0 100644 --- a/lib/bash/list/lib_list.sh +++ b/lib/bash/list/lib_list.sh @@ -10,13 +10,24 @@ if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then fi readonly __lib_list_sourced__=1 -list_append() { - local __list_array_name="${1-}" - local -a __list_values=() +__list_assert_distinct_names__() { + local __list_operation="${1-}" __list_result_name="${2-}" __list_source_name="${3-}" + + if [[ "$__list_result_name" == "$__list_source_name" ]]; then + log_error -l base_bash_libs.list \ + "$__list_operation: result and source variables must be distinct; '$__list_result_name' was provided for both." + return 1 + fi + return 0 +} +list_append() { if (($# < 2)); then fatal_error "list_append: usage: list_append [value...]" fi + __std_assert_public_variable_names__ list_append "${1-}" || return 1 + local __list_array_name="$1" + local -a __list_values=() assert_variable_name "$__list_array_name" assert_indexed_array "$__list_array_name" @@ -27,53 +38,64 @@ list_append() { } list_prepend() { - local __list_array_name="${1-}" - local -a __list_values=() __list_current=() - if (($# < 2)); then fatal_error "list_prepend: usage: list_prepend [value...]" fi + __std_assert_public_variable_names__ list_prepend "${1-}" || return 1 + local __list_array_name="$1" __list_item + local -a __list_values=() __list_current=() assert_variable_name "$__list_array_name" assert_indexed_array "$__list_array_name" __std_assert_writable_output__ list_prepend "$__list_array_name" || return 1 shift __list_values=("$@") - eval "__list_current=(\"\${${__list_array_name}[@]}\")" - eval "$__list_array_name=(\"\${__list_values[@]}\" \"\${__list_current[@]}\")" + eval "if [[ -n \"\${${__list_array_name}[@]+set}\" ]]; then __list_current=(\"\${${__list_array_name}[@]}\"); fi" + eval "$__list_array_name=()" + for __list_item in "${__list_values[@]+"${__list_values[@]}"}"; do + eval "$__list_array_name+=(\"\$__list_item\")" + done + for __list_item in "${__list_current[@]+"${__list_current[@]}"}"; do + eval "$__list_array_name+=(\"\$__list_item\")" + done } # # Removes every exact match from a caller-owned indexed array in place. # list_remove() { - local __list_array_name="${1-}" __list_needle="${2-}" __list_item + assert_arg_count "$#" 2 + __std_assert_public_variable_names__ list_remove "${1-}" || return 1 + local __list_array_name="$1" __list_needle="$2" __list_item local -a __list_current=() __list_filtered=() - assert_arg_count "$#" 2 assert_variable_name "$__list_array_name" assert_indexed_array "$__list_array_name" __std_assert_writable_output__ list_remove "$__list_array_name" || return 1 - eval "__list_current=(\"\${${__list_array_name}[@]}\")" - for __list_item in "${__list_current[@]}"; do + eval "if [[ -n \"\${${__list_array_name}[@]+set}\" ]]; then __list_current=(\"\${${__list_array_name}[@]}\"); fi" + for __list_item in "${__list_current[@]+"${__list_current[@]}"}"; do [[ "$__list_item" == "$__list_needle" ]] && continue __list_filtered+=("$__list_item") done - eval "$__list_array_name=(\"\${__list_filtered[@]}\")" + eval "$__list_array_name=()" + for __list_item in "${__list_filtered[@]+"${__list_filtered[@]}"}"; do + eval "$__list_array_name+=(\"\$__list_item\")" + done } list_contains() { - local __list_needle="${1-}" __list_array_name="${2-}" __list_item + assert_arg_count "$#" 2 + __std_assert_public_variable_names__ list_contains "${2-}" || return 1 + local __list_needle="$1" __list_array_name="$2" __list_item local -a __list_current=() - assert_arg_count "$#" 2 assert_variable_name "$__list_array_name" assert_indexed_array "$__list_array_name" - eval "__list_current=(\"\${${__list_array_name}[@]}\")" - for __list_item in "${__list_current[@]}"; do + eval "if [[ -n \"\${${__list_array_name}[@]+set}\" ]]; then __list_current=(\"\${${__list_array_name}[@]}\"); fi" + for __list_item in "${__list_current[@]+"${__list_current[@]}"}"; do [[ "$__list_item" == "$__list_needle" ]] && return 0 done @@ -81,35 +103,47 @@ list_contains() { } list_unique() { - local __list_result_name="${1-}" __list_array_name="${2-}" __list_item __list_key + assert_arg_count "$#" 2 + __std_assert_public_variable_names__ list_unique "${1-}" "${2-}" || return 1 + local __list_result_name="$1" __list_array_name="$2" __list_item __list_key local -a __list_current=() __list_unique=() local -A __list_seen=() - assert_arg_count "$#" 2 assert_variable_name "$__list_result_name" "$__list_array_name" + __list_assert_distinct_names__ list_unique "$__list_result_name" "$__list_array_name" || return 1 __std_assert_writable_output__ list_unique "$__list_result_name" || return 1 assert_indexed_array "$__list_result_name" "$__list_array_name" - eval "__list_current=(\"\${${__list_array_name}[@]}\")" - for __list_item in "${__list_current[@]}"; do + eval "if [[ -n \"\${${__list_array_name}[@]+set}\" ]]; then __list_current=(\"\${${__list_array_name}[@]}\"); fi" + for __list_item in "${__list_current[@]+"${__list_current[@]}"}"; do __list_key="v:$__list_item" [[ -n "${__list_seen[$__list_key]+set}" ]] && continue __list_seen["$__list_key"]=1 __list_unique+=("$__list_item") done - eval "$__list_result_name=(\"\${__list_unique[@]}\")" + eval "$__list_result_name=()" + for __list_item in "${__list_unique[@]+"${__list_unique[@]}"}"; do + eval "$__list_result_name+=(\"\$__list_item\")" + done } list_length() { - local __list_result_name="${1-}" __list_array_name="${2-}" + assert_arg_count "$#" 2 + __std_assert_public_variable_names__ list_length "${1-}" "${2-}" || return 1 + local __list_result_name="$1" __list_array_name="$2" + local __list_count=0 local -a __list_current=() - assert_arg_count "$#" 2 assert_variable_name "$__list_result_name" "$__list_array_name" + __list_assert_distinct_names__ list_length "$__list_result_name" "$__list_array_name" || return 1 __std_assert_writable_output__ list_length "$__list_result_name" || return 1 assert_indexed_array "$__list_array_name" - eval "__list_current=(\"\${${__list_array_name}[@]}\")" - printf -v "$__list_result_name" '%s' "${#__list_current[@]}" + eval "if [[ -n \"\${${__list_array_name}[@]+set}\" ]]; then __list_current=(\"\${${__list_array_name}[@]}\"); fi" + # shellcheck disable=SC2199 # The + expansion safely detects Bash 4.2 empty arrays under nounset. + if [[ -n "${__list_current[@]+set}" ]]; then + __list_count="${#__list_current[@]}" + fi + printf -v "$__list_result_name" '%s' "$__list_count" } diff --git a/lib/bash/list/tests/lib_list.bats b/lib/bash/list/tests/lib_list.bats index 697d6fd..5870ad1 100644 --- a/lib/bash/list/tests/lib_list.bats +++ b/lib/bash/list/tests/lib_list.bats @@ -37,6 +37,34 @@ create_script() { [[ "$output" == *"source-rc=1"* ]] } +@test "list APIs reject missing arguments under every caller option combination" { + local function_name mode + + for mode in off e u p eu ep up eup; do + for function_name in \ + list_append \ + list_prepend \ + list_remove \ + list_contains \ + list_unique \ + list_length; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + "$4" + exit $? + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/list/lib_list.sh" "$function_name" + + [ "$status" -eq 1 ] + [[ "$output" != *"unbound variable"* ]] + done + done +} + @test "list_append and list_prepend mutate caller arrays in place" { local -a values=("middle") @@ -92,6 +120,77 @@ create_script() { [ "${unique[2]}" = "" ] } +@test "list result helpers reject source aliases before mutation" { + local -a values=("alpha" "alpha" "beta") + local rc + + if list_unique values values 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${#values[@]}" -eq 3 ] + [ "${values[0]}" = "alpha" ] + [ "${values[1]}" = "alpha" ] + [ "${values[2]}" = "beta" ] + + if list_length values values 2>/dev/null; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${#values[@]}" -eq 3 ] + [ "${values[0]}" = "alpha" ] + [ "${values[1]}" = "alpha" ] + [ "${values[2]}" = "beta" ] +} + +@test "list helpers reject exact internal holder names before locals or mutation" { + local -r __list_array_name=actual + local -a actual=(keep) + local -ar __list_current=(alpha beta) + local -a result=(saved) + local count=saved + local stderr_file="$TEST_TMPDIR/list-internal-holder.err" + local rc + + if list_append __list_array_name new 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${actual[*]}" = "keep" ] + + if list_contains alpha __list_current 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + + if list_unique result __list_current 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "${result[*]}" = "saved" ] + + if list_length count __list_current 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$count" = "saved" ] + [ "${__list_current[*]}" = "alpha beta" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] +} + @test "list_length stores the array length in a named variable" { local -a values=("alpha" "beta gamma" "") local count="" @@ -101,6 +200,32 @@ create_script() { [ "$count" = "3" ] } +@test "list helpers handle declared-empty arrays under nounset" { + local script="$TEST_TMPDIR/list-empty-nounset.sh" + + create_script "$script" </dev/null && pwd -P -) +__script_source__="${BASE_BASH_BOOTSTRAP_SOURCE:-${BASH_SOURCE[1]-}}" +if [[ -n "$__script_source__" ]]; then + __SCRIPT_DIR__=$( + cd -- "$(dirname -- "$__script_source__")" &>/dev/null && pwd -P + ) || { + printf '%s\n' "Error: Unable to resolve caller directory from '$__script_source__'." >&2 + return 1 2>/dev/null || exit 1 + } +else + # An interactive shell or a top-level `bash -c` source has no outer + # BASH_SOURCE frame. In that case relative imports are anchored to the + # directory in which the caller sourced the library. + __SCRIPT_DIR__="$(pwd -P)" || { + printf '%s\n' "Error: Unable to resolve the current caller directory." >&2 + return 1 2>/dev/null || exit 1 + } +fi readonly __SCRIPT_DIR__ declare -ga __std_cleanup_hooks=() declare -ga __std_cleanup_paths=() declare -g __std_cleanup_dispatcher_installed=0 declare -g __std_original_exit_trap="" +declare -g __std_original_exit_trap_spec="" +declare -g __std_cleanup_dispatcher_trap_spec="" ############################################ BASH VERSION CHECKER ####################################################### @@ -282,7 +298,7 @@ __stdlib_init__() { # local arg parse_options=1 __color__=0 - for arg in "${__SCRIPT_ARGS__[@]}"; do + for arg in "${__SCRIPT_ARGS__[@]+"${__SCRIPT_ARGS__[@]}"}"; do if ((parse_options)) && [[ "$arg" == "--" ]]; then __new_args__+=("$arg") parse_options=0 @@ -371,7 +387,7 @@ import() { # -n : Do not check if the directory exists before adding it. # add_to_path() { - local dir path_dir prepend=0 opt strict=1 index in_path + local dir path_dir prepend=0 opt strict=1 index in_path directory_count local -a path_dirs directories=() local OPTIND=1 while getopts np opt; do @@ -387,13 +403,14 @@ add_to_path() { shift $((OPTIND-1)) directories=("$@") + directory_count=$# if ((prepend)); then - for ((index = ${#directories[@]} - 1; index >= 0; index--)); do + for ((index = directory_count - 1; index >= 0; index--)); do dir="${directories[index]}" ((strict)) && [[ ! -d $dir ]] && continue in_path=0 IFS=: read -ra path_dirs <<< "$PATH" - for path_dir in "${path_dirs[@]}"; do + for path_dir in "${path_dirs[@]+"${path_dirs[@]}"}"; do if [[ "$path_dir" == "$dir" ]]; then in_path=1 break @@ -404,11 +421,11 @@ add_to_path() { fi done else - for dir in "${directories[@]}"; do + for dir in "${directories[@]+"${directories[@]}"}"; do in_path=0 ((strict)) && [[ ! -d $dir ]] && continue IFS=: read -ra path_dirs <<< "$PATH" - for path_dir in "${path_dirs[@]}"; do + for path_dir in "${path_dirs[@]+"${path_dirs[@]}"}"; do if [[ "$path_dir" == "$dir" ]]; then in_path=1 break @@ -432,7 +449,7 @@ dedupe_path() { local -A seen local IFS=':' new_path dir for dir in $PATH; do - if [[ -n "$dir" && -z "${seen[$dir]}" ]]; then + if [[ -n "$dir" && -z "${seen[$dir]-}" ]]; then new_path="${new_path:+$new_path:}$dir" seen["$dir"]=1 fi @@ -446,7 +463,7 @@ dedupe_path() { print_path() { local IFS=':' dirs dir IFS=: read -ra dirs <<< "$PATH" - for dir in "${dirs[@]}"; do printf '%s\n' "$dir"; done + for dir in "${dirs[@]+"${dirs[@]}"}"; do printf '%s\n' "$dir"; done } #################################################### LOGGING ########################################################### @@ -476,20 +493,25 @@ __log_init__() { # __join_message__ - Join message fragments with a stable single-space separator. # __join_message__() { - local IFS=' ' - printf '%s' "$*" + local __std_join_message_result="" __std_join_message_fragment __std_join_message_separator="" + + for __std_join_message_fragment in "$@"; do + __std_join_message_result+="${__std_join_message_separator}${__std_join_message_fragment}" + __std_join_message_separator=" " + done + printf '%s' "$__std_join_message_result" } # # __log_timestamp__ - Store the current log timestamp in a named variable. # __log_timestamp__() { - local result_name="$1" + local __std_log_timestamp_result_name="$1" if [[ "${LOG_UTC:-}" == 1 ]]; then - TZ=UTC0 printf -v "$result_name" '%(%Y-%m-%d %H:%M:%S)T UTC' -1 + TZ=UTC0 printf -v "$__std_log_timestamp_result_name" '%(%Y-%m-%d %H:%M:%S)T UTC' -1 else - printf -v "$result_name" '%(%Y-%m-%d %H:%M:%S %z)T' -1 + printf -v "$__std_log_timestamp_result_name" '%(%Y-%m-%d %H:%M:%S %z)T' -1 fi } @@ -497,111 +519,120 @@ __log_timestamp__() { # __log_source_location__ - Store the first non-stdlib caller location. # __log_source_location__() { - local result_name="$1" - local fallback_path="${2:-}" fallback_line="${3:-0}" - local source_path="" source_line="" - local frame=1 max_caller_frames=20 caller_info caller_line _caller_func caller_file - - while ((frame <= max_caller_frames)) && caller_info=$(caller "$frame"); do - read -r caller_line _caller_func caller_file <<<"$caller_info" - if [[ -n "$caller_file" && "$caller_file" != "$__LIB_STD_PATH__" ]]; then - source_path="$caller_file" - source_line="$caller_line" + local __std_log_source_result_name="$1" + local __std_log_source_fallback_path="${2:-}" __std_log_source_fallback_line="${3:-0}" + local __std_log_source_path="" __std_log_source_line="" + local __std_log_source_frame=1 __std_log_source_max_frames=20 + local __std_log_source_caller_info __std_log_source_caller_rest + local __std_log_source_caller_line __std_log_source_caller_file + + while ((__std_log_source_frame <= __std_log_source_max_frames)) && + __std_log_source_caller_info=$(caller "$__std_log_source_frame"); do + __std_log_source_caller_line="${__std_log_source_caller_info%% *}" + __std_log_source_caller_rest="${__std_log_source_caller_info#* }" + __std_log_source_caller_file="${__std_log_source_caller_rest#* }" + if [[ -n "$__std_log_source_caller_file" && + "$__std_log_source_caller_file" != "$__LIB_STD_PATH__" ]]; then + __std_log_source_path="$__std_log_source_caller_file" + __std_log_source_line="$__std_log_source_caller_line" break fi - ((frame++)) + ((__std_log_source_frame++)) done - if [[ -z "$source_path" ]]; then - source_path="${fallback_path:-${BASH_SOURCE[2]:-${BASH_SOURCE[1]:-${BASH_SOURCE[0]:-unknown}}}}" - source_line="${fallback_line:-${BASH_LINENO[1]:-${BASH_LINENO[0]:-0}}}" + if [[ -z "$__std_log_source_path" ]]; then + __std_log_source_path="${__std_log_source_fallback_path:-${BASH_SOURCE[2]:-${BASH_SOURCE[1]:-${BASH_SOURCE[0]:-unknown}}}}" + __std_log_source_line="${__std_log_source_fallback_line:-${BASH_LINENO[1]:-${BASH_LINENO[0]:-0}}}" fi - source_path="${source_path#"$__SCRIPT_DIR__"/}" - source_path="${source_path#./}" - printf -v "$result_name" '%s:%s' "$source_path" "$source_line" + __std_log_source_path="${__std_log_source_path#"$__SCRIPT_DIR__"/}" + __std_log_source_path="${__std_log_source_path#./}" + printf -v "$__std_log_source_result_name" '%s:%s' "$__std_log_source_path" "$__std_log_source_line" } # # __log_primary_sink_is_usable__ - Check the primary sink without modifying it. # __log_primary_sink_is_usable__() { - local primary_log="${1-}" parent_dir + local __std_log_primary_usable_path="${1-}" __std_log_primary_usable_parent_dir - [[ -n "$primary_log" && "$primary_log" != */ ]] || return 1 - [[ -z "${_log_primary_sink_failed_paths[$primary_log]+set}" ]] || return 1 - [[ ! -L "$primary_log" ]] || return 1 + [[ -n "$__std_log_primary_usable_path" && "$__std_log_primary_usable_path" != */ ]] || return 1 + [[ -z "${_log_primary_sink_failed_paths[$__std_log_primary_usable_path]+set}" ]] || return 1 + [[ ! -L "$__std_log_primary_usable_path" ]] || return 1 - if [[ -e "$primary_log" ]]; then - if [[ -f "$primary_log" && -O "$primary_log" && -w "$primary_log" ]]; then + if [[ -e "$__std_log_primary_usable_path" ]]; then + if [[ -f "$__std_log_primary_usable_path" && -O "$__std_log_primary_usable_path" && + -w "$__std_log_primary_usable_path" ]]; then return 0 fi return 1 fi - if [[ "$primary_log" == */* ]]; then - parent_dir="${primary_log%/*}" - [[ -n "$parent_dir" ]] || parent_dir=/ + if [[ "$__std_log_primary_usable_path" == */* ]]; then + __std_log_primary_usable_parent_dir="${__std_log_primary_usable_path%/*}" + [[ -n "$__std_log_primary_usable_parent_dir" ]] || __std_log_primary_usable_parent_dir=/ else - parent_dir=. + __std_log_primary_usable_parent_dir=. fi - [[ -d "$parent_dir" && -w "$parent_dir" && -x "$parent_dir" ]] + [[ -d "$__std_log_primary_usable_parent_dir" && -w "$__std_log_primary_usable_parent_dir" && + -x "$__std_log_primary_usable_parent_dir" ]] } # # __log_primary_sink_prepare__ - Create or privately harden a usable sink. # __log_primary_sink_prepare__() { - local primary_log="$1" chmod_path + local __std_log_primary_prepare_path="$1" __std_log_primary_prepare_chmod_path - __log_primary_sink_is_usable__ "$primary_log" || return 1 + __log_primary_sink_is_usable__ "$__std_log_primary_prepare_path" || return 1 - if [[ ! -e "$primary_log" ]]; then + if [[ ! -e "$__std_log_primary_prepare_path" ]]; then # noclobber avoids truncating a target that appears after the # non-mutating eligibility check. - if ! (umask 077; set -o noclobber; : >"$primary_log") 2>/dev/null; then - [[ -e "$primary_log" && ! -L "$primary_log" ]] || return 1 + if ! (umask 077; set -o noclobber; : >"$__std_log_primary_prepare_path") 2>/dev/null; then + [[ -e "$__std_log_primary_prepare_path" && ! -L "$__std_log_primary_prepare_path" ]] || return 1 fi fi - [[ -f "$primary_log" && ! -L "$primary_log" && - -O "$primary_log" && -w "$primary_log" ]] || return 1 + [[ -f "$__std_log_primary_prepare_path" && ! -L "$__std_log_primary_prepare_path" && + -O "$__std_log_primary_prepare_path" && -w "$__std_log_primary_prepare_path" ]] || return 1 # macOS chmod does not accept "--"; prefix a bare option-like path instead. - chmod_path="$primary_log" - [[ "$chmod_path" == -* ]] && chmod_path="./$chmod_path" - command chmod 600 "$chmod_path" 2>/dev/null || return 1 + __std_log_primary_prepare_chmod_path="$__std_log_primary_prepare_path" + [[ "$__std_log_primary_prepare_chmod_path" == -* ]] && + __std_log_primary_prepare_chmod_path="./$__std_log_primary_prepare_chmod_path" + command chmod 600 "$__std_log_primary_prepare_chmod_path" 2>/dev/null || return 1 - [[ -f "$primary_log" && ! -L "$primary_log" && - -O "$primary_log" && -w "$primary_log" ]] + [[ -f "$__std_log_primary_prepare_path" && ! -L "$__std_log_primary_prepare_path" && + -O "$__std_log_primary_prepare_path" && -w "$__std_log_primary_prepare_path" ]] } # # __log_primary_sink_append__ - Append one record or file payload. # __log_primary_sink_append__() { - local payload_kind="${1-}" payload="${2-}" - local primary_log="${BASE_CLI_PRIMARY_LOG:-}" + local __std_log_primary_append_kind="${1-}" __std_log_primary_append_payload="${2-}" + local __std_log_primary_append_path="${BASE_CLI_PRIMARY_LOG:-}" - __log_primary_sink_is_usable__ "$primary_log" || return 1 + __log_primary_sink_is_usable__ "$__std_log_primary_append_path" || return 1 ( umask 077 - __log_primary_sink_prepare__ "$primary_log" || exit 1 + __log_primary_sink_prepare__ "$__std_log_primary_append_path" || exit 1 - case "$payload_kind" in + case "$__std_log_primary_append_kind" in record) - printf '%s\n' "$payload" + printf '%s\n' "$__std_log_primary_append_payload" ;; file) - command cat -- "$payload" || exit 1 + command cat -- "$__std_log_primary_append_payload" || exit 1 printf '\n' ;; *) exit 1 ;; - esac >>"$primary_log" + esac >>"$__std_log_primary_append_path" ) 2>/dev/null } @@ -609,11 +640,11 @@ __log_primary_sink_append__() { # __log_primary_sink_write__ - Keep sink failures best-effort and disable them. # __log_primary_sink_write__() { - local payload_kind="$1" payload="$2" - local primary_log="${BASE_CLI_PRIMARY_LOG:-}" + local __std_log_primary_write_kind="$1" __std_log_primary_write_payload="$2" + local __std_log_primary_write_path="${BASE_CLI_PRIMARY_LOG:-}" - if ! __log_primary_sink_append__ "$payload_kind" "$payload"; then - _log_primary_sink_failed_paths["$primary_log"]=1 + if ! __log_primary_sink_append__ "$__std_log_primary_write_kind" "$__std_log_primary_write_payload"; then + _log_primary_sink_failed_paths["$__std_log_primary_write_path"]=1 fi return 0 } @@ -622,19 +653,21 @@ __log_primary_sink_write__() { # __print_log_record__ - Compose and write a structured log record. # __print_log_record__() { - local color="$1" in_level="$2" source_location="$3" - local terminal_enabled="${4:-1}" persist_enabled="${5:-0}" + local __std_log_record_color="$1" __std_log_record_level="$2" __std_log_record_source="$3" + local __std_log_record_terminal_enabled="${4:-1}" __std_log_record_persist_enabled="${5:-0}" shift 5 - local message timestamp log_line + local __std_log_record_message __std_log_record_timestamp __std_log_record_line - message="$(__join_message__ "$@")" - __log_timestamp__ timestamp - printf -v log_line '%s %-7s %s %s' "$timestamp" "$in_level" "$source_location" "$message" - if ((terminal_enabled)); then - printf '%b%s%b\n' "$color" "$log_line" "$COLOR_OFF" >&2 + __std_log_record_message="$(__join_message__ "$@")" + __log_timestamp__ __std_log_record_timestamp + printf -v __std_log_record_line '%s %-7s %s %s' \ + "$__std_log_record_timestamp" "$__std_log_record_level" "$__std_log_record_source" \ + "$__std_log_record_message" + if ((__std_log_record_terminal_enabled)); then + printf '%b%s%b\n' "$__std_log_record_color" "$__std_log_record_line" "$COLOR_OFF" >&2 fi - if ((persist_enabled)); then - __log_primary_sink_write__ record "$log_line" + if ((__std_log_record_persist_enabled)); then + __log_primary_sink_write__ record "$__std_log_record_line" fi } @@ -676,31 +709,38 @@ __init_colors__() { # Invalid levels return 1 and leave the existing logger level unchanged. # set_log_level() { - local logger=default in_level l + local __std_set_log_logger=default __std_set_log_level __std_set_log_level_value + local __std_set_log_source_location if [[ "${1-}" == "-l" ]]; then if [[ -z "${2-}" ]]; then + __log_source_location__ __std_set_log_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + "$__std_set_log_source_location Option '-l' needs an argument" >&2 return 1 fi - logger=$2 + __std_set_log_logger=$2 shift 2 2>/dev/null fi - in_level="${1:-INFO}" - if [[ -z "$logger" ]]; then + __std_set_log_level="${1:-INFO}" + if [[ -z "$__std_set_log_logger" ]]; then + __log_source_location__ __std_set_log_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + "$__std_set_log_source_location Option '-l' needs an argument" >&2 return 1 fi - if [[ -n "${_log_levels[$in_level]+set}" ]]; then - l="${_log_levels[$in_level]}" - _loggers_level_map[$logger]=$l + if [[ -n "${_log_levels[$__std_set_log_level]+set}" ]]; then + __std_set_log_level_value="${_log_levels[$__std_set_log_level]}" + _loggers_level_map[$__std_set_log_logger]=$__std_set_log_level_value return 0 fi + __log_source_location__ __std_set_log_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Unknown log level '$in_level' for logger '$logger'" >&2 + "$__std_set_log_source_location Unknown log level '$__std_set_log_level' for logger '$__std_set_log_logger'" >&2 return 1 } @@ -715,24 +755,29 @@ set_log_level() { # Invalid arguments return 1 without changing the existing category level. # set_log_category_level() { - local category in_level l + local __std_set_category_name __std_set_category_level __std_set_category_level_value + local __std_set_category_source_location if [[ "$#" -ne 3 || "${1-}" != "-l" || -z "${2-}" || -z "${3-}" ]]; then + __log_source_location__ __std_set_category_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Usage: set_log_category_level -l " >&2 + "$__std_set_category_source_location Usage: set_log_category_level -l " >&2 return 1 fi - category=$2 - in_level=$3 - if [[ -n "${_log_levels[$in_level]+set}" ]]; then - l="${_log_levels[$in_level]}" - _log_category_level_map[$category]=$l + __std_set_category_name=$2 + __std_set_category_level=$3 + if [[ -n "${_log_levels[$__std_set_category_level]+set}" ]]; then + __std_set_category_level_value="${_log_levels[$__std_set_category_level]}" + _log_category_level_map[$__std_set_category_name]=$__std_set_category_level_value return 0 fi + __log_source_location__ __std_set_category_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Unknown log level '$in_level' for category '$category'" >&2 + "$__std_set_category_source_location Unknown log level '$__std_set_category_level' for category '$__std_set_category_name'" >&2 return 1 } @@ -740,43 +785,47 @@ set_log_category_level() { # __resolve_log_category_level__ - Resolve a category through its dotted parents. # __resolve_log_category_level__() { - local result_name="$1" category="${2:-default}" candidate - - candidate=$category - while [[ -n "$candidate" ]]; do - if [[ -n "${_log_category_level_map[$candidate]+set}" ]]; then - printf -v "$result_name" '%s' "${_log_category_level_map[$candidate]}" + local __std_log_category_result_name="$1" __std_log_category_name="${2:-default}" + local __std_log_category_candidate + + __std_log_category_candidate=$__std_log_category_name + while [[ -n "$__std_log_category_candidate" ]]; do + if [[ -n "${_log_category_level_map[$__std_log_category_candidate]+set}" ]]; then + printf -v "$__std_log_category_result_name" '%s' \ + "${_log_category_level_map[$__std_log_category_candidate]}" return 0 fi - [[ "$candidate" == *.* ]] || break - candidate="${candidate%.*}" + [[ "$__std_log_category_candidate" == *.* ]] || break + __std_log_category_candidate="${__std_log_category_candidate%.*}" done - printf -v "$result_name" '%s' "${_log_category_level_map[default]}" + printf -v "$__std_log_category_result_name" '%s' "${_log_category_level_map[default]}" } # # __log_sink_state__ - Store terminal and persistent-sink decisions. # __log_sink_state__() { - local category="$1" in_level="$2" terminal_result="$3" persist_result="$4" - local event_level category_level terminal_level terminal_state=0 persist_state=0 - - [[ -n "${_log_levels[$in_level]+set}" ]] || return 1 - event_level="${_log_levels[$in_level]}" - __resolve_log_category_level__ category_level "$category" - - if ((category_level >= event_level)); then - terminal_level="${_loggers_level_map[$category]:-${_loggers_level_map[default]}}" - ((terminal_level >= event_level)) && terminal_state=1 - if ((event_level <= _log_levels[DEBUG])) && + local __std_log_sink_category="$1" __std_log_sink_level="$2" + local __std_log_sink_terminal_result="$3" __std_log_sink_persist_result="$4" + local __std_log_sink_event_level __std_log_sink_category_level __std_log_sink_terminal_level + local __std_log_sink_terminal_state=0 __std_log_sink_persist_state=0 + + [[ -n "${_log_levels[$__std_log_sink_level]+set}" ]] || return 1 + __std_log_sink_event_level="${_log_levels[$__std_log_sink_level]}" + __resolve_log_category_level__ __std_log_sink_category_level "$__std_log_sink_category" + + if ((__std_log_sink_category_level >= __std_log_sink_event_level)); then + __std_log_sink_terminal_level="${_loggers_level_map[$__std_log_sink_category]:-${_loggers_level_map[default]}}" + ((__std_log_sink_terminal_level >= __std_log_sink_event_level)) && __std_log_sink_terminal_state=1 + if ((__std_log_sink_event_level <= _log_levels[DEBUG])) && __log_primary_sink_is_usable__ "${BASE_CLI_PRIMARY_LOG:-}"; then - persist_state=1 + __std_log_sink_persist_state=1 fi fi - printf -v "$terminal_result" '%s' "$terminal_state" - printf -v "$persist_result" '%s' "$persist_state" + printf -v "$__std_log_sink_terminal_result" '%s' "$__std_log_sink_terminal_state" + printf -v "$__std_log_sink_persist_result" '%s' "$__std_log_sink_persist_state" } # @@ -786,31 +835,39 @@ __log_sink_state__() { # log_is_enabled [-l category] level # log_is_enabled() { - local category=default in_level terminal_enabled persist_enabled + local __std_log_enabled_category=default __std_log_enabled_level + local __std_log_enabled_terminal __std_log_enabled_persist __std_log_enabled_source_location if [[ "${1-}" == "-l" ]]; then if [[ -z "${2-}" ]]; then + __log_source_location__ __std_log_enabled_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + "$__std_log_enabled_source_location Option '-l' needs an argument" >&2 return 1 fi - category=$2 + __std_log_enabled_category=$2 shift 2 fi if [[ "$#" -ne 1 || -z "${1-}" ]]; then + __log_source_location__ __std_log_enabled_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Usage: log_is_enabled [-l ] " >&2 + "$__std_log_enabled_source_location Usage: log_is_enabled [-l ] " >&2 return 1 fi - in_level=$1 - if [[ -z "${_log_levels[$in_level]+set}" ]]; then + __std_log_enabled_level=$1 + if [[ -z "${_log_levels[$__std_log_enabled_level]+set}" ]]; then + __log_source_location__ __std_log_enabled_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Unknown log level '$in_level' for category '$category'" >&2 + "$__std_log_enabled_source_location Unknown log level '$__std_log_enabled_level' for category '$__std_log_enabled_category'" >&2 return 1 fi - __log_sink_state__ "$category" "$in_level" terminal_enabled persist_enabled || return 1 - ((terminal_enabled || persist_enabled)) + __log_sink_state__ "$__std_log_enabled_category" "$__std_log_enabled_level" \ + __std_log_enabled_terminal __std_log_enabled_persist || return 1 + ((__std_log_enabled_terminal || __std_log_enabled_persist)) } # @@ -821,33 +878,40 @@ log_is_enabled() { # be called directly; use the `log_*` helper functions instead. # __print_log__() { - local in_level="${1-}" - [[ -n "$in_level" ]] || return 1 + local __std_print_log_level="${1-}" + [[ -n "$__std_print_log_level" ]] || return 1 shift - local logger=default color source_location - local terminal_enabled persist_enabled + local __std_print_log_logger=default __std_print_log_color __std_print_log_source_location + local __std_print_log_terminal_enabled __std_print_log_persist_enabled if [[ "${1-}" == "-l" ]]; then if [[ -z "${2-}" ]]; then - printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + __log_source_location__ __std_print_log_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" + printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 \ + "WARN $__std_print_log_source_location Option '-l' needs an argument" >&2 return 1 fi - logger=$2 + __std_print_log_logger=$2 shift 2 fi - __log_sink_state__ "$logger" "$in_level" terminal_enabled persist_enabled || return 1 + __log_sink_state__ "$__std_print_log_logger" "$__std_print_log_level" \ + __std_print_log_terminal_enabled __std_print_log_persist_enabled || return 1 - if ((terminal_enabled || persist_enabled)); then + if ((__std_print_log_terminal_enabled || __std_print_log_persist_enabled)); then # Select color based on log level - case "$in_level" in - FATAL|ERROR) color="$COLOR_RED";; - WARN) color="$COLOR_YELLOW";; - INFO) color="$COLOR_GREEN";; - DEBUG) color="$COLOR_BLUE";; - *) color="";; # No color for VERBOSE or others + case "$__std_print_log_level" in + FATAL|ERROR) __std_print_log_color="$COLOR_RED";; + WARN) __std_print_log_color="$COLOR_YELLOW";; + INFO) __std_print_log_color="$COLOR_GREEN";; + DEBUG) __std_print_log_color="$COLOR_BLUE";; + *) __std_print_log_color="";; # No color for VERBOSE or others esac - __log_source_location__ source_location "${BASH_SOURCE[2]:-}" "${BASH_LINENO[1]:-0}" - __print_log_record__ "$color" "$in_level" "$source_location" "$terminal_enabled" "$persist_enabled" "$@" + __log_source_location__ __std_print_log_source_location \ + "${BASH_SOURCE[2]:-}" "${BASH_LINENO[1]:-0}" + __print_log_record__ "$__std_print_log_color" "$__std_print_log_level" \ + "$__std_print_log_source_location" "$__std_print_log_terminal_enabled" \ + "$__std_print_log_persist_enabled" "$@" fi } @@ -857,31 +921,37 @@ __print_log__() { # Internal helper to be called by `log_info_file`, etc. # __print_log_file__() { - local in_level="${1-}" - [[ -n "$in_level" ]] || return 1 + local __std_print_file_level="${1-}" + [[ -n "$__std_print_file_level" ]] || return 1 shift - local logger=default file - local terminal_enabled persist_enabled + local __std_print_file_logger=default __std_print_file_path __std_print_file_source_location + local __std_print_file_terminal_enabled __std_print_file_persist_enabled if [[ "${1-}" == "-l" ]]; then if [[ -z "${2-}" ]]; then - printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + __log_source_location__ __std_print_file_source_location \ + "${BASH_SOURCE[1]:-${0:-unknown}}" "${BASH_LINENO[0]:-0}" + printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 \ + "WARN $__std_print_file_source_location Option '-l' needs an argument" >&2 return 1 fi - logger=$2 + __std_print_file_logger=$2 shift 2 fi - file="${1-}" - __log_sink_state__ "$logger" "$in_level" terminal_enabled persist_enabled || return 1 - if ((terminal_enabled || persist_enabled)) && [[ -f "$file" ]]; then - __print_log__ "$in_level" -l "$logger" "Contents of file '$file':" - if ((terminal_enabled)); then - cat -- "$file" >&2 + __std_print_file_path="${1-}" + __log_sink_state__ "$__std_print_file_logger" "$__std_print_file_level" \ + __std_print_file_terminal_enabled __std_print_file_persist_enabled || return 1 + if ((__std_print_file_terminal_enabled || __std_print_file_persist_enabled)) && + [[ -f "$__std_print_file_path" ]]; then + __print_log__ "$__std_print_file_level" -l "$__std_print_file_logger" \ + "Contents of file '$__std_print_file_path':" + if ((__std_print_file_terminal_enabled)); then + cat -- "$__std_print_file_path" >&2 # Keep the next structured record separate even when the file does # not end in a newline. A blank separator is harmless otherwise. printf '\n' >&2 fi - if ((persist_enabled)); then - __log_primary_sink_write__ file "$file" + if ((__std_print_file_persist_enabled)); then + __log_primary_sink_write__ file "$__std_print_file_path" fi fi } @@ -909,23 +979,23 @@ log_verbose_file() { __print_log_file__ VERBOSE "$@"; } # # Public functions for logging function entry and exit points. # -log_info_enter() { __print_log__ INFO "Entering function ${FUNCNAME[1]}"; } -log_debug_enter() { __print_log__ DEBUG "Entering function ${FUNCNAME[1]}"; } +log_info_enter() { __print_log__ INFO "Entering function ${FUNCNAME[1]:-main}"; } +log_debug_enter() { __print_log__ DEBUG "Entering function ${FUNCNAME[1]:-main}"; } # Deprecated compatibility helper; prefer log_debug_enter. -log_verbose_enter() { __print_log__ VERBOSE "Entering function ${FUNCNAME[1]}"; } -log_info_leave() { __print_log__ INFO "Leaving function ${FUNCNAME[1]}"; } -log_debug_leave() { __print_log__ DEBUG "Leaving function ${FUNCNAME[1]}"; } +log_verbose_enter() { __print_log__ VERBOSE "Entering function ${FUNCNAME[1]:-main}"; } +log_info_leave() { __print_log__ INFO "Leaving function ${FUNCNAME[1]:-main}"; } +log_debug_leave() { __print_log__ DEBUG "Leaving function ${FUNCNAME[1]:-main}"; } # Deprecated compatibility helper; prefer log_debug_leave. -log_verbose_leave() { __print_log__ VERBOSE "Leaving function ${FUNCNAME[1]}"; } +log_verbose_leave() { __print_log__ VERBOSE "Leaving function ${FUNCNAME[1]:-main}"; } # # Simple print routines that do not prefix messages with timestamps or levels. # -print_error() { local message; message="$(__join_message__ "$@")"; { printf '%bERROR: %s%b\n' "$COLOR_RED" "$message" "$COLOR_OFF"; } >&2; } -print_warn() { local message; message="$(__join_message__ "$@")"; { printf '%bWARN: %s%b\n' "$COLOR_YELLOW" "$message" "$COLOR_OFF"; } >&2; } -print_info() { local message; message="$(__join_message__ "$@")"; { printf '%b%s%b\n' "$COLOR_GREEN" "$message" "$COLOR_OFF"; } >&2; } -print_success() { local message; message="$(__join_message__ "$@")"; { printf '%bSUCCESS: %s%b\n' "$COLOR_GREEN" "$message" "$COLOR_OFF"; } >&2; } -print_bold() { local message; message="$(__join_message__ "$@")"; printf '%b%s%b\n' "$COLOR_BOLD" "$message" "$COLOR_OFF"; } +print_error() { local __std_print_error_message; __std_print_error_message="$(__join_message__ "$@")"; { printf '%bERROR: %s%b\n' "$COLOR_RED" "$__std_print_error_message" "$COLOR_OFF"; } >&2; } +print_warn() { local __std_print_warn_message; __std_print_warn_message="$(__join_message__ "$@")"; { printf '%bWARN: %s%b\n' "$COLOR_YELLOW" "$__std_print_warn_message" "$COLOR_OFF"; } >&2; } +print_info() { local __std_print_info_message; __std_print_info_message="$(__join_message__ "$@")"; { printf '%b%s%b\n' "$COLOR_GREEN" "$__std_print_info_message" "$COLOR_OFF"; } >&2; } +print_success() { local __std_print_success_message; __std_print_success_message="$(__join_message__ "$@")"; { printf '%bSUCCESS: %s%b\n' "$COLOR_GREEN" "$__std_print_success_message" "$COLOR_OFF"; } >&2; } +print_bold() { local __std_print_bold_message; __std_print_bold_message="$(__join_message__ "$@")"; printf '%b%s%b\n' "$COLOR_BOLD" "$__std_print_bold_message" "$COLOR_OFF"; } print_message() { printf '%s\n' "$@"; } # @@ -946,15 +1016,16 @@ print_tty() { # that led to an error. # dump_trace() { - local frame=0 line func source n=0 - while caller "$frame"; do - ((frame++)) - done | while read -r line func source; do - ((n++ == 0)) && { + local __std_trace_frame=0 __std_trace_line __std_trace_func __std_trace_source __std_trace_caller_info + while __std_trace_caller_info="$(caller "$__std_trace_frame")"; do + IFS=' ' read -r __std_trace_line __std_trace_func __std_trace_source <<<"$__std_trace_caller_info" + if ((__std_trace_frame == 0)); then printf 'Encountered a fatal error\n' - } - printf '%4s at %s\n' " " "$func ($source:$line)" + fi + printf '%4s at %s\n' " " "$__std_trace_func ($__std_trace_source:$__std_trace_line)" + ((__std_trace_frame += 1)) done >&2 + return 0 } # @@ -974,27 +1045,29 @@ dump_trace() { # exit_if_error() { (($#)) || return - local num_re='^[0-9]+$' - local rc=$1 normalized_rc; shift - local message + local __std_exit_number_re='^[0-9]+$' + local __std_exit_status=$1 __std_exit_normalized_status + shift + local __std_exit_message if (($#)); then - message="$(__join_message__ "$@")" + __std_exit_message="$(__join_message__ "$@")" else - message="No message specified" - fi - if ! [[ $rc =~ $num_re ]]; then - log_error -l base_bash_libs.std "'$rc' is not a valid exit code; it needs to be a number greater than zero. Treating it as 1." - rc=1 - elif ! __std_decimal_integer_value__ normalized_rc "$rc"; then - log_error -l base_bash_libs.std "'$rc' is not a valid decimal exit code. Treating it as 1." - rc=1 + __std_exit_message="No message specified" + fi + if ! [[ $__std_exit_status =~ $__std_exit_number_re ]]; then + log_error -l base_bash_libs.std \ + "'$__std_exit_status' is not a valid exit code; it needs to be a number greater than zero. Treating it as 1." + __std_exit_status=1 + elif ! __std_decimal_integer_value__ __std_exit_normalized_status "$__std_exit_status"; then + log_error -l base_bash_libs.std "'$__std_exit_status' is not a valid decimal exit code. Treating it as 1." + __std_exit_status=1 else - rc="$normalized_rc" + __std_exit_status="$__std_exit_normalized_status" fi - ((rc)) && { - log_fatal -l base_bash_libs.std "$message" + ((__std_exit_status)) && { + log_fatal -l base_bash_libs.std "$__std_exit_message" dump_trace - exit "$rc" + exit "$__std_exit_status" } return 0 } @@ -1009,9 +1082,9 @@ exit_if_error() { # [[ -f "$my_file" ]] || fatal_error "Required file '$my_file' not found." # fatal_error() { - local ec=$? # grab the current exit code - ((ec == 0)) && ec=1 # if it is zero, set exit code to 1 - exit_if_error "$ec" "$@" + local __std_fatal_status=$? # grab the current exit code + ((__std_fatal_status == 0)) && __std_fatal_status=1 # if it is zero, set exit code to 1 + exit_if_error "$__std_fatal_status" "$@" } #################################################### COMMAND EXECUTION ################################################# @@ -1023,10 +1096,10 @@ fatal_error() { # accept common truthy values so callers do not need to duplicate normalization. # is_dry_run() { - local value + local __std_dry_run_value - for value in "${DRY_RUN-}" "${dry_run-}"; do - case "${value,,}" in + for __std_dry_run_value in "${DRY_RUN-}" "${dry_run-}"; do + case "${__std_dry_run_value,,}" in true | 1 | yes | on) return 0 ;; @@ -1036,43 +1109,44 @@ is_dry_run() { } __std_decimal_integer_value__() { - local result_name="${1-}" value="${2-}" sign="" digits + local __std_decimal_result_name="${1-}" __std_decimal_value="${2-}" __std_decimal_sign="" + local __std_decimal_digits - [[ "$value" =~ ^[-+]?[0-9]+$ ]] || return 1 - case "$value" in + [[ "$__std_decimal_value" =~ ^[-+]?[0-9]+$ ]] || return 1 + case "$__std_decimal_value" in -*) - sign="-" - digits="${value#-}" + __std_decimal_sign="-" + __std_decimal_digits="${__std_decimal_value#-}" ;; +*) - digits="${value#+}" + __std_decimal_digits="${__std_decimal_value#+}" ;; *) - digits="$value" + __std_decimal_digits="$__std_decimal_value" ;; esac - while [[ "${#digits}" -gt 1 && "${digits:0:1}" == "0" ]]; do - digits="${digits:1}" + while [[ "${#__std_decimal_digits}" -gt 1 && "${__std_decimal_digits:0:1}" == "0" ]]; do + __std_decimal_digits="${__std_decimal_digits:1}" done - if [[ "$sign" == "-" && "$digits" != "0" ]]; then - printf -v "$result_name" '%s' "-$((10#$digits))" + if [[ "$__std_decimal_sign" == "-" && "$__std_decimal_digits" != "0" ]]; then + printf -v "$__std_decimal_result_name" '%s' "-$((10#$__std_decimal_digits))" else - printf -v "$result_name" '%s' "$((10#$digits))" + printf -v "$__std_decimal_result_name" '%s' "$((10#$__std_decimal_digits))" fi } __std_is_positive_integer__() { - local normalized - __std_decimal_integer_value__ normalized "${1-}" || return 1 - ((normalized > 0)) + local __std_positive_normalized + __std_decimal_integer_value__ __std_positive_normalized "${1-}" || return 1 + ((__std_positive_normalized > 0)) } __std_is_non_negative_integer__() { - local normalized - __std_decimal_integer_value__ normalized "${1-}" || return 1 - ((normalized >= 0)) + local __std_non_negative_normalized + __std_decimal_integer_value__ __std_non_negative_normalized "${1-}" || return 1 + ((__std_non_negative_normalized >= 0)) } __std_join_run_policy__() { @@ -1084,7 +1158,7 @@ __std_join_run_policy__() { ((max_attempts > 1)) && policies+=("${max_attempts} attempts") ((retry_delay > 0)) && policies+=("${retry_delay}s retry delay") - for policy in "${policies[@]}"; do + for policy in "${policies[@]+"${policies[@]}"}"; do if [[ -n "$joined_policy" ]]; then joined_policy+=", " fi @@ -1373,8 +1447,11 @@ __std_run_with_timeout_fallback__() { ) & timer_pid=$! - wait "$command_pid" 2>/dev/null - command_status=$? + if wait "$command_pid" 2>/dev/null; then + command_status=0 + else + command_status=$? + fi if kill -0 "$timer_pid" 2>/dev/null; then kill -KILL "$timer_pid" 2>/dev/null || true @@ -1420,11 +1497,11 @@ safe_mkdir() { for dir; do [[ -d "$dir" ]] && continue - if ! mkdir "${mkdir_args[@]}" -- "$dir"; then + if ! mkdir "${mkdir_args[@]+"${mkdir_args[@]}"}" -- "$dir"; then failed_dirs+=("$dir") fi done - ((${#failed_dirs[@]} > 0)) && exit_if_error 1 "Failed to create directories: ${failed_dirs[*]}" + [[ -z "${failed_dirs[0]+set}" ]] || exit_if_error 1 "Failed to create directories: ${failed_dirs[*]}" return 0 } @@ -1459,7 +1536,7 @@ safe_touch() { fi done - if ((${#failed_files[@]} > 0)); then + if [[ -n "${failed_files[0]+set}" ]]; then fatal_error "Failed to touch the following files: ${failed_files[*]}" fi @@ -1498,7 +1575,7 @@ safe_truncate() { fi done - if ((${#failed_files[@]} > 0)); then + if [[ -n "${failed_files[0]+set}" ]]; then fatal_error "Failed to truncate the following files: ${failed_files[*]}" fi @@ -1535,13 +1612,13 @@ __std_run_cleanup_hooks__() { ) || true fi - for hook in "${__std_cleanup_hooks[@]}"; do + for hook in "${__std_cleanup_hooks[@]+"${__std_cleanup_hooks[@]}"}"; do if ! "$hook"; then log_warn -l base_bash_libs.std "Cleanup hook '$hook' failed." fi done - for cleanup_path in "${__std_cleanup_paths[@]}"; do + for cleanup_path in "${__std_cleanup_paths[@]+"${__std_cleanup_paths[@]}"}"; do [[ -e "$cleanup_path" || -L "$cleanup_path" ]] || continue if ! rm -rf -- "$cleanup_path"; then log_warn -l base_bash_libs.std "Cleanup path '$cleanup_path' could not be removed." @@ -1556,12 +1633,37 @@ __std_install_cleanup_dispatcher__() { return 0 fi + __std_original_exit_trap_spec="$(trap -p EXIT || true)" __std_get_exit_trap_command__ __std_original_exit_trap trap '__std_run_cleanup_hooks__' EXIT + __std_cleanup_dispatcher_trap_spec="$(trap -p EXIT || true)" __std_cleanup_dispatcher_installed=1 return 0 } +__std_maybe_uninstall_cleanup_dispatcher__() { + local current_exit_trap_spec + + ((__std_cleanup_dispatcher_installed)) || return 0 + if [[ -n "${__std_cleanup_hooks[0]+set}" || -n "${__std_cleanup_paths[0]+set}" ]]; then + return 0 + fi + + current_exit_trap_spec="$(trap -p EXIT || true)" + if [[ "$current_exit_trap_spec" == "$__std_cleanup_dispatcher_trap_spec" ]]; then + trap - EXIT + if [[ -n "$__std_original_exit_trap_spec" ]]; then + eval "$__std_original_exit_trap_spec" + fi + fi + + __std_cleanup_dispatcher_installed=0 + __std_original_exit_trap="" + __std_original_exit_trap_spec="" + __std_cleanup_dispatcher_trap_spec="" + return 0 +} + # # std_register_cleanup_hook - Registers a function to run from the shared EXIT trap. # @@ -1584,7 +1686,7 @@ std_register_cleanup_hook() { return 1 fi - for existing_hook in "${__std_cleanup_hooks[@]}"; do + for existing_hook in "${__std_cleanup_hooks[@]+"${__std_cleanup_hooks[@]}"}"; do [[ "$existing_hook" == "$hook" ]] && return 0 done @@ -1608,11 +1710,12 @@ std_unregister_cleanup_hook() { return 1 fi - for existing_hook in "${__std_cleanup_hooks[@]}"; do + for existing_hook in "${__std_cleanup_hooks[@]+"${__std_cleanup_hooks[@]}"}"; do [[ "$existing_hook" == "$hook" ]] && continue remaining_hooks+=("$existing_hook") done - __std_cleanup_hooks=("${remaining_hooks[@]}") + __std_cleanup_hooks=("${remaining_hooks[@]+"${remaining_hooks[@]}"}") + __std_maybe_uninstall_cleanup_dispatcher__ return 0 } @@ -1662,7 +1765,7 @@ std_register_cleanup_path() { had_valid_path=1 already_registered=0 - for existing_path in "${__std_cleanup_paths[@]}"; do + for existing_path in "${__std_cleanup_paths[@]+"${__std_cleanup_paths[@]}"}"; do if [[ "$existing_path" == "$path" ]]; then already_registered=1 break @@ -1711,9 +1814,9 @@ std_unregister_cleanup_path() { done if ((had_valid_path)); then - for existing_path in "${__std_cleanup_paths[@]}"; do + for existing_path in "${__std_cleanup_paths[@]+"${__std_cleanup_paths[@]}"}"; do should_remove=0 - for path in "${paths_to_remove[@]}"; do + for path in "${paths_to_remove[@]+"${paths_to_remove[@]}"}"; do if [[ "$existing_path" == "$path" ]]; then should_remove=1 break @@ -1721,9 +1824,10 @@ std_unregister_cleanup_path() { done ((should_remove)) || remaining_paths+=("$existing_path") done - __std_cleanup_paths=("${remaining_paths[@]}") + __std_cleanup_paths=("${remaining_paths[@]+"${remaining_paths[@]}"}") fi + __std_maybe_uninstall_cleanup_dispatcher__ return "$status" } @@ -1820,6 +1924,7 @@ __std_make_temp_path__() { # std_make_temp_file [--keep] [prefix] # std_make_temp_file() { + __std_preflight_temp_result_name__ std_make_temp_file "$@" || return 1 __std_make_temp_path__ std_make_temp_file file "$@" } @@ -1832,35 +1937,76 @@ std_make_temp_file() { # std_make_temp_dir [--keep] [prefix] # std_make_temp_dir() { + __std_preflight_temp_result_name__ std_make_temp_dir "$@" || return 1 __std_make_temp_path__ std_make_temp_dir dir "$@" } ####################################################### ASSERTIONS #################################################### __is_valid_variable_name__() { - local var_name="${1-}" var_name_re='^[A-Za-z_][A-Za-z0-9_]*$' - [[ "$var_name" =~ $var_name_re ]] + local __std_variable_name="${1-}" + local __std_variable_name_re='^[A-Za-z_][A-Za-z0-9_]*$' + [[ "$__std_variable_name" =~ $__std_variable_name_re ]] } __std_assert_writable_output__() { - local function_name="${1-}" output_name="${2-}" declaration attributes + local __std_output_function_name="${1-}" __std_output_name="${2-}" + local __std_output_declaration __std_output_attributes - if [[ "$output_name" == __* ]]; then - log_error -l base_bash_libs.std "$function_name: result variable '$output_name' uses the reserved '__' internal namespace." + if [[ "$__std_output_name" == __* ]]; then + log_error -l base_bash_libs.std \ + "$__std_output_function_name: result variable '$__std_output_name' uses the reserved '__' internal namespace." return 1 fi - declaration="$(declare -p "$output_name" 2>/dev/null || true)" - [[ -n "$declaration" ]] || return 0 - attributes="${declaration#declare -}" - attributes="${attributes%% *}" - if [[ "$attributes" == *r* ]]; then - log_error -l base_bash_libs.std "$function_name: result variable '$output_name' is readonly." + __std_output_declaration="$(declare -p "$__std_output_name" 2>/dev/null || true)" + [[ -n "$__std_output_declaration" ]] || return 0 + __std_output_attributes="${__std_output_declaration#declare -}" + __std_output_attributes="${__std_output_attributes%% *}" + if [[ "$__std_output_attributes" == *r* ]]; then + log_error -l base_bash_libs.std \ + "$__std_output_function_name: result variable '$__std_output_name' is readonly." return 1 fi return 0 } +__std_assert_public_variable_names__() { + (($# >= 1)) || return 1 + set -- "${@:2}" "$1" + + while (($# > 1)); do + if [[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ && "${1-}" == __* ]]; then + log_error -l base_bash_libs.std \ + "${!#}: variable '${1-}' uses the reserved '__' internal namespace." + return 1 + fi + shift + done + return 0 +} + +__std_preflight_temp_result_name__() { + (($# >= 1)) || return 1 + shift + while (($#)); do + case "${1-}" in + --keep) + shift + ;; + --) + shift + break + ;; + *) + break + ;; + esac + done + (($# >= 1)) || return 0 + __std_assert_public_variable_names__ "${FUNCNAME[1]}" "${1-}" +} + # # assert_variable_name - Verifies that one or more arguments are valid Bash variable names. # @@ -1871,14 +2017,14 @@ __std_assert_writable_output__() { # assert_variable_name result_name array_name # assert_variable_name() { - local var_name + local __std_assert_variable_name if (($# == 0)); then fatal_error "assert_variable_name: No variable names provided for validation." fi - for var_name in "$@"; do - if ! __is_valid_variable_name__ "$var_name"; then + for __std_assert_variable_name in "$@"; do + if ! __is_valid_variable_name__ "$__std_assert_variable_name"; then fatal_error "assert_variable_name expects valid Bash variable names; one or more arguments are invalid." fi done @@ -1887,12 +2033,13 @@ assert_variable_name() { } __std_declares_array_kind__() { - local variable_name="${1-}" array_kind="${2-}" declaration attributes + local __std_array_variable_name="${1-}" __std_array_kind="${2-}" + local __std_array_declaration __std_array_attributes - declaration="$(declare -p "$variable_name" 2>/dev/null)" || return 1 - attributes="${declaration#declare -}" - attributes="${attributes%% *}" - [[ "$attributes" == *"$array_kind"* ]] + __std_array_declaration="$(declare -p "$__std_array_variable_name" 2>/dev/null)" || return 1 + __std_array_attributes="${__std_array_declaration#declare -}" + __std_array_attributes="${__std_array_attributes%% *}" + [[ "$__std_array_attributes" == *"$__std_array_kind"* ]] } # @@ -1906,16 +2053,16 @@ __std_declares_array_kind__() { # assert_indexed_array values # assert_indexed_array() { - local var_name - if (($# == 0)); then fatal_error "assert_indexed_array: No variable names provided for validation." fi + __std_assert_public_variable_names__ assert_indexed_array "$@" || return 1 + local __std_assert_indexed_name - for var_name in "$@"; do - assert_variable_name "$var_name" - if ! __std_declares_array_kind__ "$var_name" "a"; then - fatal_error "Variable '$var_name' must be an indexed array declared by the caller." + for __std_assert_indexed_name in "$@"; do + assert_variable_name "$__std_assert_indexed_name" + if ! __std_declares_array_kind__ "$__std_assert_indexed_name" "a"; then + fatal_error "Variable '$__std_assert_indexed_name' must be an indexed array declared by the caller." fi done @@ -1933,16 +2080,16 @@ assert_indexed_array() { # assert_associative_array options # assert_associative_array() { - local var_name - if (($# == 0)); then fatal_error "assert_associative_array: No variable names provided for validation." fi + __std_assert_public_variable_names__ assert_associative_array "$@" || return 1 + local __std_assert_associative_name - for var_name in "$@"; do - assert_variable_name "$var_name" - if ! __std_declares_array_kind__ "$var_name" "A"; then - fatal_error "Variable '$var_name' must be an associative array declared by the caller." + for __std_assert_associative_name in "$@"; do + assert_variable_name "$__std_assert_associative_name" + if ! __std_declares_array_kind__ "$__std_assert_associative_name" "A"; then + fatal_error "Variable '$__std_assert_associative_name' must be an associative array declared by the caller." fi done @@ -1960,12 +2107,13 @@ assert_associative_array() { # fi # std_command_path() { - local __std_command_result_name="${1-}" __std_command_name="${2-}" __std_command_resolved_path="" - if (($# != 2)); then log_error -l base_bash_libs.std "std_command_path: usage: std_command_path " return 1 fi + __std_assert_public_variable_names__ std_command_path "${1-}" || return 1 + local __std_command_result_name="$1" __std_command_name="$2" __std_command_resolved_path="" + if ! __is_valid_variable_name__ "$__std_command_result_name"; then log_error -l base_bash_libs.std "std_command_path: result variable name must be a valid Bash variable name." return 1 @@ -2012,7 +2160,7 @@ assert_function_exists() { fi done - if ((${#missing_functions[@]} > 0)); then + if [[ -n "${missing_functions[0]+set}" ]]; then fatal_error "Required functions are not defined: ${missing_functions[*]}" fi @@ -2038,25 +2186,27 @@ assert_function_exists() { # $@: One or more variable names to check. # assert_not_null() { - local unset_vars=() var_name if (($# == 0)); then fatal_error "assert_not_null: No variable names provided for validation." fi + __std_assert_public_variable_names__ assert_not_null "$@" || return 1 + local -a __std_assert_not_null_unset_names=() + local __std_assert_not_null_name - for var_name in "$@"; do - if ! __is_valid_variable_name__ "$var_name"; then + for __std_assert_not_null_name in "$@"; do + if ! __is_valid_variable_name__ "$__std_assert_not_null_name"; then fatal_error "assert_not_null expects variable names, not values; one or more arguments are not valid Bash variable names." fi # Use indirection to get the value of the variable whose name is stored in var_name. # The -v check is for unset variables, -z is for empty strings. # We check for empty string as per the request. - if [[ ! -v $var_name || -z "${!var_name-}" ]]; then - unset_vars+=("$var_name") + if [[ ! -v $__std_assert_not_null_name || -z "${!__std_assert_not_null_name-}" ]]; then + __std_assert_not_null_unset_names+=("$__std_assert_not_null_name") fi done - if ((${#unset_vars[@]} > 0)); then - fatal_error "These required variables are not set or are empty: ${unset_vars[*]}" + if [[ -n "${__std_assert_not_null_unset_names[0]+set}" ]]; then + fatal_error "These required variables are not set or are empty: ${__std_assert_not_null_unset_names[*]}" fi return 0 @@ -2065,19 +2215,26 @@ assert_not_null() { # # assert_integer - Checks if the values of one or more variables are valid integers. # -assert_integer() { - local var_name int_re='^[-+]?[0-9]+$' - (($# == 0)) && fatal_error "assert_integer: No variable names provided." - for var_name in "$@"; do - if ! __is_valid_variable_name__ "$var_name"; then +__std_assert_integer_names__() { + local __std_assert_integer_name __std_assert_integer_value + local __std_assert_integer_re='^[-+]?[0-9]+$' + for __std_assert_integer_name in "$@"; do + if ! __is_valid_variable_name__ "$__std_assert_integer_name"; then fatal_error "assert_integer expects variable names, not values; one or more arguments are not valid Bash variable names." fi - local value="${!var_name-}" - ! [[ "$value" =~ $int_re ]] && fatal_error "Variable '$var_name' with value '$value' is not a valid integer." + __std_assert_integer_value="${!__std_assert_integer_name-}" + ! [[ "$__std_assert_integer_value" =~ $__std_assert_integer_re ]] && + fatal_error "Variable '$__std_assert_integer_name' with value '$__std_assert_integer_value' is not a valid integer." done return 0 } +assert_integer() { + (($# == 0)) && fatal_error "assert_integer: No variable names provided." + __std_assert_public_variable_names__ assert_integer "$@" || return 1 + __std_assert_integer_names__ "$@" +} + # # assert_integer_range - Checks if a variable's value is an integer within a specified range. # @@ -2087,24 +2244,28 @@ assert_integer() { # $3: The maximum value. # assert_integer_range() { - local var_name="${1-}" min="${2-}" max="${3-}" (($# != 3)) && fatal_error "assert_integer_range: Expected 3 arguments, got $#." - if ! __is_valid_variable_name__ "$var_name"; then + __std_assert_public_variable_names__ assert_integer_range "${1-}" || return 1 + local __std_range_name="$1" __std_range_min="$2" __std_range_max="$3" + local __std_range_value __std_range_value_number __std_range_min_number __std_range_max_number + if ! __is_valid_variable_name__ "$__std_range_name"; then fatal_error "assert_integer_range expects a variable name as its first argument." fi - if ! [[ "$min" =~ ^[-+]?[0-9]+$ ]]; then - fatal_error "assert_integer_range minimum bound '$min' is not a valid integer." - fi - if ! [[ "$max" =~ ^[-+]?[0-9]+$ ]]; then - fatal_error "assert_integer_range maximum bound '$max' is not a valid integer." - fi - local value="${!var_name-}" value_number min_number max_number - assert_integer "$var_name" - __std_decimal_integer_value__ value_number "$value" - __std_decimal_integer_value__ min_number "$min" - __std_decimal_integer_value__ max_number "$max" - ((min_number > max_number)) && fatal_error "assert_integer_range minimum '$min' cannot exceed maximum '$max'." - ((value_number < min_number || value_number > max_number)) && fatal_error "Variable '$var_name' ($value) is out of range [$min, $max]." + if ! [[ "$__std_range_min" =~ ^[-+]?[0-9]+$ ]]; then + fatal_error "assert_integer_range minimum bound '$__std_range_min' is not a valid integer." + fi + if ! [[ "$__std_range_max" =~ ^[-+]?[0-9]+$ ]]; then + fatal_error "assert_integer_range maximum bound '$__std_range_max' is not a valid integer." + fi + __std_range_value="${!__std_range_name-}" + __std_assert_integer_names__ "$__std_range_name" + __std_decimal_integer_value__ __std_range_value_number "$__std_range_value" + __std_decimal_integer_value__ __std_range_min_number "$__std_range_min" + __std_decimal_integer_value__ __std_range_max_number "$__std_range_max" + ((__std_range_min_number > __std_range_max_number)) && + fatal_error "assert_integer_range minimum '$__std_range_min' cannot exceed maximum '$__std_range_max'." + ((__std_range_value_number < __std_range_min_number || __std_range_value_number > __std_range_max_number)) && + fatal_error "Variable '$__std_range_name' ($__std_range_value) is out of range [$__std_range_min, $__std_range_max]." return 0 } @@ -2121,39 +2282,42 @@ assert_integer_range() { # $3: (Optional) The maximum count for a range. # assert_arg_count() { - local arg_count="${1-}" count1="${2-}" count2="${3-}" argc=$# + local __std_arg_count_actual="${1-}" __std_arg_count_first="${2-}" __std_arg_count_second="${3-}" + local __std_arg_count_arity=$# # Check the number of arguments passed to this function itself. - if ((argc < 2 || argc > 3)); then - fatal_error "assert_arg_count: Incorrect usage. Expected 2 or 3 arguments, but got $argc." + if ((__std_arg_count_arity < 2 || __std_arg_count_arity > 3)); then + fatal_error "assert_arg_count: Incorrect usage. Expected 2 or 3 arguments, but got $__std_arg_count_arity." fi # Create temporary named variables for assert_integer to check - local __assert_arg_count_val="$arg_count" __assert_count1_val="$count1" - local arg_count_number count1_number count2_number - assert_integer __assert_arg_count_val __assert_count1_val + local __std_arg_count_actual_value="$__std_arg_count_actual" __std_arg_count_first_value="$__std_arg_count_first" + local __std_arg_count_actual_number __std_arg_count_first_number __std_arg_count_second_number + __std_assert_integer_names__ __std_arg_count_actual_value __std_arg_count_first_value - if [[ -n "$count2" ]]; then - local __assert_count2_val="$count2" - assert_integer __assert_count2_val + if [[ -n "$__std_arg_count_second" ]]; then + local __std_arg_count_second_value="$__std_arg_count_second" + __std_assert_integer_names__ __std_arg_count_second_value fi - __std_decimal_integer_value__ arg_count_number "$arg_count" - __std_decimal_integer_value__ count1_number "$count1" - if [[ -n "$count2" ]]; then - __std_decimal_integer_value__ count2_number "$count2" - ((count1_number > count2_number)) && fatal_error "assert_arg_count minimum '$count1' cannot exceed maximum '$count2'." + __std_decimal_integer_value__ __std_arg_count_actual_number "$__std_arg_count_actual" + __std_decimal_integer_value__ __std_arg_count_first_number "$__std_arg_count_first" + if [[ -n "$__std_arg_count_second" ]]; then + __std_decimal_integer_value__ __std_arg_count_second_number "$__std_arg_count_second" + ((__std_arg_count_first_number > __std_arg_count_second_number)) && + fatal_error "assert_arg_count minimum '$__std_arg_count_first' cannot exceed maximum '$__std_arg_count_second'." fi - if [[ -z "$count2" ]]; then + if [[ -z "$__std_arg_count_second" ]]; then # Exact match case - if ((arg_count_number != count1_number)); then - fatal_error "Argument count mismatch: expected $count1 but got $arg_count arguments" + if ((__std_arg_count_actual_number != __std_arg_count_first_number)); then + fatal_error "Argument count mismatch: expected $__std_arg_count_first but got $__std_arg_count_actual arguments" fi else # Range match case - if ((arg_count_number < count1_number || arg_count_number > count2_number)); then - fatal_error "Argument count mismatch: expected between $count1 and $count2 arguments, but got $arg_count" + if ((__std_arg_count_actual_number < __std_arg_count_first_number || + __std_arg_count_actual_number > __std_arg_count_second_number)); then + fatal_error "Argument count mismatch: expected between $__std_arg_count_first and $__std_arg_count_second arguments, but got $__std_arg_count_actual" fi fi return 0 @@ -2187,7 +2351,7 @@ assert_command_exists() { fi done - if ((${#missing_commands[@]} > 0)); then + if [[ -n "${missing_commands[0]+set}" ]]; then fatal_error "These required commands were not found in your PATH: ${missing_commands[*]}" fi @@ -2222,7 +2386,7 @@ assert_file_exists() { fi done - if ((${#missing_files[@]} > 0)); then + if [[ -n "${missing_files[0]+set}" ]]; then fatal_error "These required files do not exist or are not regular files: ${missing_files[*]}" fi @@ -2261,7 +2425,7 @@ assert_executable() { fi done - if ((${#missing_executables[@]} > 0)); then + if [[ -n "${missing_executables[0]+set}" ]]; then fatal_error "These required executable paths do not exist, are not regular files, or are not executable: ${missing_executables[*]}" fi @@ -2296,7 +2460,7 @@ assert_dir_exists() { fi done - if ((${#missing_dirs[@]} > 0)); then + if [[ -n "${missing_dirs[0]+set}" ]]; then fatal_error "These required directories do not exist: ${missing_dirs[*]}" fi @@ -2333,16 +2497,22 @@ safe_unalias() { # get_my_source_dir var_name # get_my_source_dir() { - local __std_source_result_name="${1-}" - [[ -n "$__std_source_result_name" ]] || fatal_error "get_my_source_dir: No result variable name provided." + [[ -n "${1-}" ]] || fatal_error "get_my_source_dir: No result variable name provided." + __std_assert_public_variable_names__ get_my_source_dir "${1-}" || return 1 + local __std_source_result_name="$1" + if ! __is_valid_variable_name__ "$__std_source_result_name"; then fatal_error "get_my_source_dir: result variable name must be a valid Bash variable name." fi __std_assert_writable_output__ get_my_source_dir "$__std_source_result_name" || return 1 - local __std_source_dir + local __std_source_dir __std_source_path="${BASH_SOURCE[1]-}" # Reference: https://stackoverflow.com/a/246128/6862601 - __std_source_dir="$(cd "$(dirname "${BASH_SOURCE[1]}")" >/dev/null 2>&1 && pwd -P)" || - fatal_error "get_my_source_dir: Unable to resolve source directory." + if [[ -n "$__std_source_path" ]]; then + __std_source_dir="$(cd "$(dirname -- "$__std_source_path")" >/dev/null 2>&1 && pwd -P)" || + fatal_error "get_my_source_dir: Unable to resolve source directory." + else + __std_source_dir="$(pwd -P)" || fatal_error "get_my_source_dir: Unable to resolve source directory." + fi printf -v "$__std_source_result_name" '%s' "$__std_source_dir" } @@ -2425,8 +2595,11 @@ wait_for_enter() { return 1 fi - read -r -s -p "$prompt" <&"$tty_fd" - read_status=$? + if read -r -s -p "$prompt" <&"$tty_fd"; then + read_status=0 + else + read_status=$? + fi exec {tty_fd}<&- if ((read_status != 0)); then @@ -2449,5 +2622,5 @@ readonly BASE_BASH_LIBS_STDLIB_LOADED=1 # This is the crucial step: it resets the positional parameters ($@, $1, etc.) # of the *calling script* to the new, filtered list of arguments. -set -- "${__new_args__[@]}" -unset __new_args__ __stdlib_init__ __log_init__ __init_colors__ +set -- "${__new_args__[@]+"${__new_args__[@]}"}" +unset __new_args__ __script_source__ __stdlib_init__ __log_init__ __init_colors__ diff --git a/lib/bash/std/tests/lib_std.bats b/lib/bash/std/tests/lib_std.bats index 44e6640..219528c 100644 --- a/lib/bash/std/tests/lib_std.bats +++ b/lib/bash/std/tests/lib_std.bats @@ -260,6 +260,52 @@ EOF [[ "$output" == *"script_dir=$expected_dir"* ]] } +@test "stdlib supports a top-level strict shell without an outer BASH_SOURCE frame" { + local expected_dir + + expected_dir="$(cd "$TEST_TMPDIR" && pwd -P)" + bats_run bash -c ' + set -euo pipefail + cd -- "$2" + source "$1" + source_dir="" + get_my_source_dir source_dir + [[ "$-" == *e* && "$-" == *u* ]] + shopt -qo pipefail + printf "script_dir=%s\nsource_dir=%s\nstrict=preserved\n" "$__SCRIPT_DIR__" "$source_dir" + ' bash "$STDLIB_PATH" "$TEST_TMPDIR" + + [ "$status" -eq 0 ] + [[ "$output" == *"script_dir=$expected_dir"* ]] + [[ "$output" == *"source_dir=$expected_dir"* ]] + [[ "$output" == *"strict=preserved"* ]] + [[ "$output" != *"unbound variable"* ]] +} + +@test "stdlib handles empty arrays in a strict child with no positional arguments" { + local script="$TEST_TMPDIR/strict-empty-arrays.sh" + local directory="$TEST_TMPDIR/strict-directory" + + create_script "$script" <\n' "\$IFS" +} +stack_with_custom_ifs +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"log-stack-ifs.sh:"*"custom IFS log"* ]] + [[ "$output" == *"stack_with_custom_ifs"* ]] + [[ "$output" == *"ifs=<:>"* ]] +} + @test "exit_if_error returns success for zero and empty input" { local rc @@ -1273,6 +1372,25 @@ EOF [[ "$output" != *"after"* ]] } +@test "exit_if_error preserves its requested status with errexit and pipefail" { + local script="$TEST_TMPDIR/exit-if-error-strict.sh" + + create_script "$script" <> "$log_file"' EXIT +before_trap="\$(trap -p EXIT)" +cleanup_transient() { printf 'unexpected\n' >> "$log_file"; } +std_register_cleanup_hook cleanup_transient +dispatcher_trap="\$(trap -p EXIT)" +[[ "\$dispatcher_trap" != "\$before_trap" ]] +std_unregister_cleanup_hook cleanup_transient +after_trap="\$(trap -p EXIT)" +[[ "\$after_trap" == "\$before_trap" ]] +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$log_file")" = "caller" ] +} + +@test "cleanup unregistration does not overwrite a caller-replaced EXIT trap" { + local script="$TEST_TMPDIR/cleanup-caller-replaced-trap.sh" + local log_file="$TEST_TMPDIR/cleanup-caller-replaced-trap.log" + + create_script "$script" <> "$log_file"' EXIT +cleanup_transient() { printf 'unexpected\n' >> "$log_file"; } +std_register_cleanup_hook cleanup_transient +trap 'printf "replacement\n" >> "$log_file"' EXIT +replacement_trap="\$(trap -p EXIT)" +std_unregister_cleanup_hook cleanup_transient +after_trap="\$(trap -p EXIT)" +[[ "\$after_trap" == "\$replacement_trap" ]] +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$log_file")" = "replacement" ] +} + @test "cleanup path registration removes files and directories on exit" { local script="$TEST_TMPDIR/cleanup-paths.sh" local target_file="$TEST_TMPDIR/cleanup-file.txt" @@ -2267,6 +2432,95 @@ EOF [[ "$(cat "$stderr_file")" == *"result variable 'output' is readonly"* ]] } +@test "readonly caller locals do not collide with logging diagnostics" { + local output="unchanged" + local stderr_file="$TEST_TMPDIR/readonly-logging-locals.err" + local rc + local -r logger="caller-logger" color="caller-color" message="caller-message" + local -r source_path="caller-source" + + readonly output + if std_command_path output bash 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [ "$output" = "unchanged" ] + [ "$logger" = "caller-logger" ] + [ "$color" = "caller-color" ] + [ "$message" = "caller-message" ] + [ "$source_path" = "caller-source" ] + [[ "$(cat "$stderr_file")" == *"std_command_path: result variable 'output' is readonly."* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] + [[ "$(cat "$stderr_file")" != *"local:"* ]] +} + +@test "named std helpers reject exact internal holder names before locals or side effects" { + local -r __std_command_result_name=command_target + local -r __std_temp_result_name=temp_target + local -r __std_source_result_name=source_target + local command_target="keep-command" temp_target="keep-temp" source_target="keep-source" + local temp_root="$TEST_TMPDIR/std-internal-holder" + local stderr_file="$TEST_TMPDIR/std-internal-holder.err" + local rc + + mkdir -p "$temp_root" + if std_command_path __std_command_result_name bash 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$command_target" = "keep-command" ] + + if TMPDIR="$temp_root" std_make_temp_file --keep __std_temp_result_name reserved 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$temp_target" = "keep-temp" ] + [ -z "$(find "$temp_root" -mindepth 1 -maxdepth 1 -print -quit)" ] + + if get_my_source_dir __std_source_result_name 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$source_target" = "keep-source" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] + [[ "$(cat "$stderr_file")" != *"local:"* ]] +} + +@test "readonly result names cannot collide with validation or diagnostic locals" { + local candidate + + for candidate in var_name var_name_re function_name output_name declaration attributes logger color message source_path; do + bats_run "$BASH" -c ' + source "$1" + printf -v "$2" %s unchanged + readonly "$2" + std_command_path "$2" bash + case $? in + 1) ;; + *) exit 99 ;; + esac + printf "value=%s\n" "${!2}" + exit 1 + ' bash "$STDLIB_PATH" "$candidate" + + [ "$status" -eq 1 ] + [[ "$output" == *"result variable '$candidate' is readonly"* ]] + [[ "$output" == *"value=unchanged"* ]] + [[ "$output" != *"readonly variable"* ]] + [[ "$output" != *"local:"* ]] + done +} + @test "std_command_path stores executable paths and returns nonzero for missing commands" { local command_path="" @@ -2358,7 +2612,48 @@ EOF } @test "assert_variable_name accepts valid Bash variable names" { - assert_variable_name value_name _value_name VALUE_NAME value_name_2 + assert_variable_name value_name _value_name VALUE_NAME value_name_2 __internal_syntax_name +} + +@test "named assertions reject reserved caller sources before local declarations" { + local -ar __std_assert_indexed_name=(alpha) + local -Ar __std_assert_associative_name=([alpha]=one) + local -r __std_assert_not_null_name=present + local -r __std_assert_integer_name=7 + local -r __std_range_name=5 + local stderr_file="$TEST_TMPDIR/assert-reserved-names.err" + assert_reserved_name_rejected() { + local assertion_status + if "$@" 2>"$stderr_file"; then + assertion_status=0 + else + assertion_status=$? + fi + [ "$assertion_status" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] + [[ "$(cat "$stderr_file")" != *"local:"* ]] + } + + assert_reserved_name_rejected assert_indexed_array __std_assert_indexed_name + assert_reserved_name_rejected assert_associative_array __std_assert_associative_name + assert_reserved_name_rejected assert_not_null __std_assert_not_null_name + assert_reserved_name_rejected assert_integer __std_assert_integer_name + assert_reserved_name_rejected assert_integer_range __std_range_name 1 10 +} + +@test "named assertions support historically shadowing-prone caller names" { + local -a var_name=(alpha) + local unset_vars=present + local value=7 + + assert_indexed_array var_name + unset var_name + local -A var_name=([alpha]=one) + assert_associative_array var_name + assert_not_null unset_vars + assert_integer value + assert_integer_range value 1 10 } @test "assert_variable_name exits for invalid variable names without echoing values" { diff --git a/lib/bash/str/README.md b/lib/bash/str/README.md index 39dd09a..f21671a 100644 --- a/lib/bash/str/README.md +++ b/lib/bash/str/README.md @@ -63,6 +63,8 @@ str_join joined "|" parts - `str_split` preserves a trailing empty field when the input ends with the separator. - `str_join` preserves empty array elements, including trailing empty elements. +- `str_join` requires distinct result and source variable names and rejects an + alias before changing caller state. - Use `list_contains` from `lib/bash/list/lib_list.sh` for indexed-array membership checks. - Named string, result, and array arguments must be valid Bash variable names. diff --git a/lib/bash/str/lib_str.sh b/lib/bash/str/lib_str.sh index 4596c7e..52d8954 100644 --- a/lib/bash/str/lib_str.sh +++ b/lib/bash/str/lib_str.sh @@ -11,9 +11,10 @@ fi readonly __lib_str_sourced__=1 str_lower() { - local __str_var_name="${1-}" __str_value - assert_arg_count "$#" 1 + __std_assert_public_variable_names__ str_lower "${1-}" || return 1 + local __str_var_name="$1" __str_value + assert_variable_name "$__str_var_name" __std_assert_writable_output__ str_lower "$__str_var_name" || return 1 __str_value="${!__str_var_name-}" @@ -21,9 +22,10 @@ str_lower() { } str_upper() { - local __str_var_name="${1-}" __str_value - assert_arg_count "$#" 1 + __std_assert_public_variable_names__ str_upper "${1-}" || return 1 + local __str_var_name="$1" __str_value + assert_variable_name "$__str_var_name" __std_assert_writable_output__ str_upper "$__str_var_name" || return 1 __str_value="${!__str_var_name-}" @@ -31,9 +33,10 @@ str_upper() { } str_ltrim() { - local __str_var_name="${1-}" __str_value - assert_arg_count "$#" 1 + __std_assert_public_variable_names__ str_ltrim "${1-}" || return 1 + local __str_var_name="$1" __str_value + assert_variable_name "$__str_var_name" __std_assert_writable_output__ str_ltrim "$__str_var_name" || return 1 __str_value="${!__str_var_name-}" @@ -42,9 +45,10 @@ str_ltrim() { } str_rtrim() { - local __str_var_name="${1-}" __str_value - assert_arg_count "$#" 1 + __std_assert_public_variable_names__ str_rtrim "${1-}" || return 1 + local __str_var_name="$1" __str_value + assert_variable_name "$__str_var_name" __std_assert_writable_output__ str_rtrim "$__str_var_name" || return 1 __str_value="${!__str_var_name-}" @@ -54,6 +58,7 @@ str_rtrim() { str_trim() { assert_arg_count "$#" 1 + __std_assert_public_variable_names__ str_trim "${1-}" || return 1 str_ltrim "$1" || return $? str_rtrim "$1" || return $? } @@ -82,9 +87,10 @@ str_ends_with() { # Splits a value into a caller-owned indexed array. Empty fields are preserved, # including the final empty field produced by a trailing separator. str_split() { - local __str_split_result_name="${1-}" __str_split_value="${2-}" __str_split_separator="${3-}" - assert_arg_count "$#" 3 + __std_assert_public_variable_names__ str_split "${1-}" || return 1 + local __str_split_result_name="$1" __str_split_value="$2" __str_split_separator="$3" + assert_variable_name "$__str_split_result_name" __std_assert_writable_output__ str_split "$__str_split_result_name" || return 1 assert_indexed_array "$__str_split_result_name" @@ -106,22 +112,29 @@ str_split() { } str_join() { - local __str_join_result_name="${1-}" __str_join_separator="${2-}" __str_join_array_name="${3-}" - assert_arg_count "$#" 3 + __std_assert_public_variable_names__ str_join "${1-}" "${3-}" || return 1 + local __str_join_result_name="$1" __str_join_separator="$2" __str_join_array_name="$3" + assert_variable_name "$__str_join_result_name" "$__str_join_array_name" + if [[ "$__str_join_result_name" == "$__str_join_array_name" ]]; then + log_error -l base_bash_libs.str \ + "str_join: result and source variables must be distinct; '$__str_join_result_name' was provided for both." + return 1 + fi __std_assert_writable_output__ str_join "$__str_join_result_name" || return 1 assert_indexed_array "$__str_join_array_name" - local __str_join_joined="" __str_join_index + local __str_join_joined="" __str_join_value __str_join_has_value=0 local -a __str_join_values=() - eval "__str_join_values=(\"\${${__str_join_array_name}[@]}\")" + eval "if [[ -n \"\${${__str_join_array_name}[@]+set}\" ]]; then __str_join_values=(\"\${${__str_join_array_name}[@]}\"); fi" - for __str_join_index in "${!__str_join_values[@]}"; do - if ((__str_join_index == 0)); then - __str_join_joined="${__str_join_values[$__str_join_index]}" + for __str_join_value in "${__str_join_values[@]+"${__str_join_values[@]}"}"; do + if ((__str_join_has_value == 0)); then + __str_join_joined="$__str_join_value" + __str_join_has_value=1 else - __str_join_joined+="$__str_join_separator${__str_join_values[$__str_join_index]}" + __str_join_joined+="$__str_join_separator$__str_join_value" fi done diff --git a/lib/bash/str/tests/lib_str.bats b/lib/bash/str/tests/lib_str.bats index 539e433..f0df2cb 100644 --- a/lib/bash/str/tests/lib_str.bats +++ b/lib/bash/str/tests/lib_str.bats @@ -37,6 +37,38 @@ create_script() { [[ "$output" == *"source-rc=1"* ]] } +@test "string APIs reject missing arguments under every caller option combination" { + local function_name mode + + for mode in off e u p eu ep up eup; do + for function_name in \ + str_lower \ + str_upper \ + str_ltrim \ + str_rtrim \ + str_trim \ + str_contains \ + str_starts_with \ + str_ends_with \ + str_split \ + str_join; do + bats_run "$BASH" -c ' + mode="$1" + case "$mode" in *e*) set -e ;; esac + case "$mode" in *u*) set -u ;; esac + case "$mode" in *p*) set -o pipefail ;; esac + source "$2" + source "$3" + "$4" + exit $? + ' bash "$mode" "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/str/lib_str.sh" "$function_name" + + [ "$status" -eq 1 ] + [[ "$output" != *"unbound variable"* ]] + done + done +} + @test "string case helpers transform text without changing other characters" { local value="Alpha BETA 123!?" local stdout_file="$TEST_TMPDIR/case.stdout" @@ -85,6 +117,32 @@ create_script() { [[ "$(cat "$stderr_file")" == *"result variable 'value' is readonly"* ]] } +@test "readonly string outputs cannot collide with argument-count decimal locals" { + local candidate + + for candidate in result_name value sign digits normalized; do + bats_run "$BASH" -c ' + source "$1" + source "$2" + printf -v "$3" %s MiXeD + readonly "$3" + str_lower "$3" + case $? in + 1) ;; + *) exit 99 ;; + esac + printf "value=%s\n" "${!3}" + exit 1 + ' bash "$BASE_BASH_DIR/std/lib_std.sh" "$BASE_BASH_DIR/str/lib_str.sh" "$candidate" + + [ "$status" -eq 1 ] + [[ "$output" == *"result variable '$candidate' is readonly"* ]] + [[ "$output" == *"value=MiXeD"* ]] + [[ "$output" != *"readonly variable"* ]] + [[ "$output" != *"local:"* ]] + done +} + @test "string transform helpers reject invalid variable names" { local script="$TEST_TMPDIR/string-transform-invalid.sh" @@ -216,6 +274,41 @@ EOF [ "$joined" = "left|right" ] } +@test "str_join rejects a source alias before mutation" { + local -a values=("alpha" "beta") + local rc + + if str_join values "," values 2>/dev/null; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [ "${#values[@]}" -eq 2 ] + [ "${values[0]}" = "alpha" ] + [ "${values[1]}" = "beta" ] +} + +@test "str_join handles a declared-empty array under nounset" { + local script="$TEST_TMPDIR/str-join-empty-nounset.sh" + + create_script "$script" <"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$actual" = "Mixed Case" ] + + if str_join __str_join_result_name , values 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$joined" = "keep" ] + + if str_join joined , __str_join_values 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [ "$joined" = "keep" ] + [ "${__str_join_values[*]}" = "alpha beta" ] + [[ "$(cat "$stderr_file")" == *"uses the reserved '__' internal namespace"* ]] + [[ "$(cat "$stderr_file")" != *"readonly variable"* ]] +} + @test "str_join rejects invalid variable names" { local script="$TEST_TMPDIR/str-join-invalid-array.sh" diff --git a/tests/bash-option-contract.sh b/tests/bash-option-contract.sh new file mode 100755 index 0000000..e1d6584 --- /dev/null +++ b/tests/bash-option-contract.sh @@ -0,0 +1,624 @@ +#!/usr/bin/env bash + +# Exercise the supported caller-runtime contract without depending on Bats so +# the same coverage can run inside the pinned, networkless Bash 4.2 image. + +contract_script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || exit 1 +contract_repo_root="$(cd -- "$contract_script_dir/.." && pwd -P)" || exit 1 + +contract_fail() { + printf 'Bash option contract failed (%s): %s\n' "${contract_mode:-harness}" "$*" >&2 + exit 1 +} + +contract_assert_equal() { + local label="${1-}" expected="${2-}" actual="${3-}" + + if [[ "$actual" != "$expected" ]]; then + contract_fail "$label: expected '$expected', got '$actual'" + fi +} + +contract_assert_file_equal() { + local label="${1-}" expected_file="${2-}" actual_file="${3-}" + + if ! cmp -s -- "$expected_file" "$actual_file"; then + printf 'State before %s:\n' "$label" >&2 + sed 's/^/ /' "$expected_file" >&2 + printf 'State after %s:\n' "$label" >&2 + sed 's/^/ /' "$actual_file" >&2 + contract_fail "$label changed caller state" + fi +} + +contract_expect_status() { + local label="${1-}" expected_status="${2-}" status + shift 2 + + if "$@"; then + status=0 + else + status=$? + fi + contract_assert_equal "$label status" "$expected_status" "$status" +} + +contract_quiet_call() { + "$@" >/dev/null 2>&1 +} + +contract_quiet_success() { + local label="${1-}" status + shift + + "$@" >/dev/null 2>&1 + status=$? + contract_assert_equal "$label status" 0 "$status" +} + +contract_quiet_subshell() { + ("$@") >/dev/null 2>&1 +} + +contract_enable_mode() { + case "$contract_mode" in + e | eu | ep | eup) builtin set -o errexit ;; + esac + case "$contract_mode" in + u | eu | up | eup) builtin set -o nounset ;; + esac + case "$contract_mode" in + p | ep | up | eup) builtin set -o pipefail ;; + esac +} + +contract_assert_mode_options() { + local expected_errexit=0 expected_nounset=0 expected_pipefail=0 + local actual_errexit=0 actual_nounset=0 actual_pipefail=0 + + case "$contract_mode" in + e | eu | ep | eup) expected_errexit=1 ;; + esac + case "$contract_mode" in + u | eu | up | eup) expected_nounset=1 ;; + esac + case "$contract_mode" in + p | ep | up | eup) expected_pipefail=1 ;; + esac + case "$-" in *e*) actual_errexit=1 ;; esac + case "$-" in *u*) actual_nounset=1 ;; esac + if [[ -o pipefail ]]; then + actual_pipefail=1 + fi + + contract_assert_equal "errexit mode selection" "$expected_errexit" "$actual_errexit" + contract_assert_equal "nounset mode selection" "$expected_nounset" "$actual_nounset" + contract_assert_equal "pipefail mode selection" "$expected_pipefail" "$actual_pipefail" +} + +contract_assert_version() { + if (($# == 0)); then + if ((BASH_VERSINFO[0] < 4 || + (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2))); then + contract_fail "requires Bash 4.2 or newer; running $BASH_VERSION" + fi + return 0 + fi + + if (($# != 3)); then + contract_fail "usage: bash-option-contract.sh [expected_major expected_minor expected_patch]" + fi + contract_assert_equal "Bash major version" "$1" "${BASH_VERSINFO[0]}" + contract_assert_equal "Bash minor version" "$2" "${BASH_VERSINFO[1]}" + contract_assert_equal "Bash patch version" "$3" "${BASH_VERSINFO[2]}" +} + +contract_snapshot_state() { + local prefix="${1-}" + + set +o > "$prefix.set" + shopt -p > "$prefix.shopt" + printf '%s' "$IFS" > "$prefix.ifs" + printf '%s\n' "$OPTIND" > "$prefix.optind" + pwd -P > "$prefix.cwd" + umask > "$prefix.umask" + trap -p > "$prefix.traps" +} + +contract_assert_state_unchanged() { + local label="${1-}" before_prefix="${2-}" after_prefix="${3-}" state + + for state in set shopt ifs optind cwd umask traps; do + contract_assert_file_equal "$label ($state)" \ + "$before_prefix.$state" "$after_prefix.$state" + done +} + +contract_source_module() { + local module_path="${1-}" module_name before_prefix after_prefix + module_name="${module_path##*/}" + contract_state_index=$((contract_state_index + 1)) + before_prefix="$contract_tmp/state-$contract_state_index-before" + after_prefix="$contract_tmp/state-$contract_state_index-after" + + set -- "argument with spaces" "" "literal-*" + contract_snapshot_state "$before_prefix" + # shellcheck disable=SC1090 # The matrix intentionally sources each absolute module path. + source "$module_path" + contract_snapshot_state "$after_prefix" + + contract_assert_state_unchanged "$module_name sourcing" "$before_prefix" "$after_prefix" + contract_assert_equal "$module_name positional count" 3 "$#" + contract_assert_equal "$module_name positional 1" "argument with spaces" "${1-}" + contract_assert_equal "$module_name positional 2" "" "${2-}" + contract_assert_equal "$module_name positional 3" "literal-*" "${3-}" +} + +contract_top_level_source_probe() { + local status + + "$BASH" -c ' + contract_mode=$1 + shift + contract_modules=("$@") + case "$contract_mode" in + e | eu | ep | eup) builtin set -o errexit ;; + esac + case "$contract_mode" in + u | eu | up | eup) builtin set -o nounset ;; + esac + case "$contract_mode" in + p | ep | up | eup) builtin set -o pipefail ;; + esac + IFS="| " + OPTIND=7 + umask 027 + shopt -s extglob nullglob nocasematch + trap ":" EXIT HUP INT TERM + for contract_module in "${contract_modules[@]}"; do + set -- "argument with spaces" "" "literal-*" + source "$contract_module" + [[ $# == 3 && ${1-} == "argument with spaces" && -z ${2-} && ${3-} == "literal-*" ]] || exit 1 + done + ' bash "$contract_mode" "$@" + status=$? + contract_assert_equal "top-level bash -c source probe status" 0 "$status" +} + +contract_run_api_smoke() { + local label="${1-}" smoke_function="${2-}" status + local before_prefix after_prefix + contract_state_index=$((contract_state_index + 1)) + before_prefix="$contract_tmp/state-$contract_state_index-before" + after_prefix="$contract_tmp/state-$contract_state_index-after" + + set -- "api argument with spaces" "" "api-literal-*" + contract_snapshot_state "$before_prefix" + "$smoke_function" + status=$? + contract_assert_equal "$label success" 0 "$status" + contract_snapshot_state "$after_prefix" + + contract_assert_state_unchanged "$label public API calls" "$before_prefix" "$after_prefix" + contract_assert_equal "$label API positional count" 3 "$#" + contract_assert_equal "$label API positional 1" "api argument with spaces" "${1-}" + contract_assert_equal "$label API positional 2" "" "${2-}" + contract_assert_equal "$label API positional 3" "api-literal-*" "${3-}" +} + +contract_std_api_smoke() { + local contract_path_output="" contract_source_dir="" contract_command_path="" + local contract_original_path="$PATH" + # shellcheck disable=SC2034 # Public APIs consume these variables by name. + local contract_temp_file="" contract_temp_dir="" contract_number=7 + local contract_log_file="$contract_tmp/log-input" + local contract_created_dir="$contract_tmp/created/child" + local contract_created_file="$contract_tmp/created/file" + + check_bash_version + base_bash_libs_require_version 0.0.0 + + PATH="/bin:/usr/bin:/bin" + dedupe_path + contract_assert_equal "dedupe_path" "/bin:/usr/bin" "$PATH" + add_to_path -n "$contract_tmp/tools" + contract_path_output="$(print_path)" + contract_assert_equal "print_path" $'/bin\n/usr/bin\n'"$contract_tmp/tools" "$contract_path_output" + PATH="$contract_original_path" + + set_log_level DEBUG + set_log_category_level -l contract DEBUG + log_is_enabled -l contract DEBUG + contract_expect_status "disabled log predicate" 1 log_is_enabled -l contract VERBOSE + printf 'line one\nline two\n' > "$contract_log_file" + contract_quiet_success "log_info" log_info "option contract" + contract_quiet_success "log_debug" log_debug -l contract "debug contract" + contract_quiet_success "log_info_file" log_info_file "$contract_log_file" + contract_quiet_success "log_info_enter" log_info_enter + contract_quiet_success "log_info_leave" log_info_leave + contract_quiet_success "dump_trace" dump_trace + contract_quiet_success "print_error" print_error "expected diagnostic" + contract_quiet_success "print_warn" print_warn "expected diagnostic" + contract_quiet_success "print_info" print_info "expected diagnostic" + contract_quiet_success "print_success" print_success "expected diagnostic" + contract_quiet_success "print_bold" print_bold "expected output" + contract_quiet_success "print_message" print_message "expected output" + print_tty "non-interactive output" + + std_run --no-exit --quiet "$BASH" -c 'exit 0' + contract_expect_status "std_run recoverable failure" 7 \ + contract_quiet_call std_run --no-exit --quiet "$BASH" -c 'exit 7' + contract_expect_status "std_run usage" 1 contract_quiet_call std_run + # shellcheck disable=SC2034 # is_dry_run reads the conventional global by name. + DRY_RUN=1 + contract_quiet_success "std_run dry run" \ + std_run --no-exit --quiet command-that-must-not-run + unset DRY_RUN + contract_expect_status "is_dry_run false predicate" 1 is_dry_run + # shellcheck disable=SC2034 # is_dry_run reads the compatibility global by name. + dry_run=yes + is_dry_run + unset dry_run + + safe_mkdir -p "$contract_created_dir" + safe_touch "$contract_created_file" + printf 'content\n' > "$contract_created_file" + safe_truncate "$contract_created_file" + contract_assert_equal "safe_truncate size" 0 "$(wc -c < "$contract_created_file" | tr -d ' ')" + + std_make_temp_file --keep contract_temp_file option-contract + std_make_temp_dir --keep contract_temp_dir option-contract + [[ -f "$contract_temp_file" ]] || contract_fail "std_make_temp_file did not create a file" + [[ -d "$contract_temp_dir" ]] || contract_fail "std_make_temp_dir did not create a directory" + + contract_cleanup_hook() { :; } + std_register_cleanup_hook contract_cleanup_hook + std_unregister_cleanup_hook contract_cleanup_hook + std_register_cleanup_path "$contract_temp_file" + std_unregister_cleanup_path "$contract_temp_file" + + std_command_path contract_command_path bash + [[ -n "$contract_command_path" ]] || contract_fail "std_command_path did not resolve bash" + std_function_exists contract_std_api_smoke + contract_expect_status "std_function_exists false predicate" 1 \ + std_function_exists contract_missing_function + assert_function_exists contract_std_api_smoke + assert_variable_name contract_number contract_source_dir + # shellcheck disable=SC2034 # Assertion APIs consume these declarations by name. + declare -a contract_indexed_array=() + # shellcheck disable=SC2034 # Assertion APIs consume these declarations by name. + declare -A contract_associative_array=() + assert_indexed_array contract_indexed_array + assert_associative_array contract_associative_array + assert_not_null contract_number + assert_integer contract_number + assert_integer_range contract_number 1 9 + assert_arg_count 2 1 3 + assert_command_exists bash + assert_file_exists "$contract_created_file" + assert_executable "$BASH" + assert_dir_exists "$contract_created_dir" + + get_my_source_dir contract_source_dir + [[ -n "$contract_source_dir" ]] || contract_fail "get_my_source_dir returned an empty path" + contract_source_dir="$(pwd -P)" + contract_created_dir="$(cd -- "$contract_created_dir" && pwd -P)" + safe_cd "$contract_created_dir" + contract_assert_equal "safe_cd destination" "$contract_created_dir" "$(pwd -P)" + safe_cd "$contract_source_dir" + alias contract_alias='printf alias' + safe_unalias contract_alias contract_missing_alias + contract_expect_status "ask_yes_no usage" 1 contract_quiet_call ask_yes_no + contract_expect_status "wait_for_enter usage" 1 contract_quiet_call wait_for_enter one two + contract_expect_status "exit_if_error status preservation" 7 \ + contract_quiet_subshell exit_if_error 7 "expected contract failure" + contract_expect_status "fatal_error status" 1 \ + contract_quiet_subshell fatal_error "expected contract failure" +} + +contract_str_api_smoke() { + local contract_value=" Mixed Case " contract_joined="" + # shellcheck disable=SC2034 # str_join consumes the empty array by name. + local -a contract_parts=() contract_empty_parts=() + + str_trim contract_value + contract_assert_equal "str_trim" "Mixed Case" "$contract_value" + str_lower contract_value + contract_assert_equal "str_lower" "mixed case" "$contract_value" + str_upper contract_value + contract_assert_equal "str_upper" "MIXED CASE" "$contract_value" + contract_value=" left" + str_ltrim contract_value + contract_assert_equal "str_ltrim" "left" "$contract_value" + contract_value="right " + str_rtrim contract_value + contract_assert_equal "str_rtrim" "right" "$contract_value" + str_contains "option-contract" "contract" + str_starts_with "option-contract" "option" + str_ends_with "option-contract" "contract" + contract_expect_status "str predicate false" 1 str_contains "option-contract" "missing" + + str_split contract_parts "alpha,,omega," "," + contract_assert_equal "str_split length" 4 "${#contract_parts[@]}" + contract_assert_equal "str_split empty field" "" "${contract_parts[1]-}" + contract_assert_equal "str_split trailing field" "" "${contract_parts[3]-}" + str_join contract_joined "|" contract_parts + contract_assert_equal "str_join" "alpha||omega|" "$contract_joined" + str_join contract_joined "|" contract_empty_parts + contract_assert_equal "str_join empty array" "" "$contract_joined" + contract_expect_status "str_lower usage" 1 contract_quiet_subshell str_lower +} + +contract_list_api_smoke() { + local contract_length="" + # shellcheck disable=SC2034 # List APIs consume these arrays by name. + local -a contract_values=(beta alpha beta) contract_unique=() contract_empty=() + + list_append contract_values omega + list_prepend contract_values zero + list_remove contract_values beta + list_contains alpha contract_values + contract_expect_status "list_contains false predicate" 1 list_contains missing contract_values + list_unique contract_unique contract_values + contract_assert_equal "list_unique length" 3 "${#contract_unique[@]}" + contract_assert_equal "list_unique first" zero "${contract_unique[0]-}" + contract_assert_equal "list_unique last" omega "${contract_unique[2]-}" + list_length contract_length contract_values + contract_assert_equal "list_length" 3 "$contract_length" + + list_unique contract_unique contract_empty + [[ -z "${contract_unique[0]+set}" ]] || contract_fail "list_unique did not publish an empty array" + list_length contract_length contract_empty + contract_assert_equal "list_length empty array" 0 "$contract_length" + contract_expect_status "list_append usage" 1 contract_quiet_subshell list_append +} + +contract_arg_api_smoke() { + local -A contract_options=() + local -a contract_positionals=() contract_includes=() + # shellcheck disable=SC2034 # arg_parse consumes the specification by name. + local -a contract_specs=( + "verbose|flag|--verbose|-v" + "output|value|--output|-o" + "contract_includes|repeatable|--include|-I" + ) + + arg_parse contract_options contract_positionals contract_specs -- \ + --verbose --output=result --include one --include=two "first positional" -- -x + contract_assert_equal "arg_parse flag" 1 "${contract_options[verbose]-}" + contract_assert_equal "arg_parse value" result "${contract_options[output]-}" + contract_assert_equal "arg_parse positional count" 2 "${#contract_positionals[@]}" + contract_assert_equal "arg_parse positional" "first positional" "${contract_positionals[0]-}" + contract_assert_equal "arg_parse option-like positional" -x "${contract_positionals[1]-}" + contract_assert_equal "arg_parse repeatable count" 2 "${#contract_includes[@]}" + contract_assert_equal "arg_parse repeatable last" two "${contract_includes[1]-}" + + contract_expect_status "arg_parse unknown option" 2 contract_quiet_call \ + arg_parse contract_options contract_positionals contract_specs -- --unknown + contract_expect_status "arg_parse usage" 2 contract_quiet_call arg_parse + + declare -A contract_empty_options=() + # shellcheck disable=SC2034 # arg_parse consumes the empty arrays by name. + declare -a contract_empty_positionals=() contract_empty_specs=() + arg_parse contract_empty_options contract_empty_positionals contract_empty_specs -- + [[ -z "${contract_empty_options[0]+set}" ]] || contract_fail "arg_parse did not publish empty options" + [[ -z "${contract_empty_positionals[0]+set}" ]] || contract_fail "arg_parse did not publish empty positionals" +} + +contract_file_api_smoke() { + local contract_target="$contract_tmp/section-file" + + printf 'prefix\n' > "$contract_target" + contract_quiet_success "update_file_section add" update_file_section \ + "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" "alpha" "beta" + file_section_exists "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" + contract_expect_status "file section unchanged predicate" 1 file_section_needs_update \ + "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" "alpha" "beta" + file_section_needs_update \ + "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" "replacement" + contract_quiet_success "update_file_section replace" update_file_section \ + "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" "replacement" + contract_quiet_success "update_file_section remove" update_file_section \ + -r "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" + contract_expect_status "file section absent predicate" 1 file_section_exists \ + "$contract_target" "# BEGIN CONTRACT" "# END CONTRACT" + contract_expect_status "file_section_exists usage" 2 contract_quiet_call file_section_exists + contract_expect_status "file_section_needs_update usage" 2 \ + contract_quiet_call file_section_needs_update + contract_expect_status "update_file_section usage" 1 contract_quiet_call update_file_section +} + +contract_git_stub() { + if [[ "${1-}" == "-C" ]]; then + shift 2 + fi + + case "${1-}" in + symbolic-ref) + if [[ "${4-}" == "refs/remotes/origin/HEAD" ]]; then + printf 'origin/main\n' + else + printf 'main\n' + fi + ;; + show-ref | merge-base) + return 0 + ;; + worktree) + printf 'worktree /contract/worktree\nHEAD 0123456789\nbranch refs/heads/main\n\n' + ;; + for-each-ref) + printf 'origin/main\n' + ;; + ls-remote) + printf '0123456789abcdef\trefs/heads/main\n' + printf 'fedcba9876543210\trefs/heads/topic/one\n' + ;; + remote) + printf 'git@github.com:basefoundry/base-bash-libs.git\n' + ;; + rev-parse) + case "${2-}" in + --is-inside-work-tree) printf 'true\n' ;; + --show-toplevel) printf '%s\n' "$contract_tmp" ;; + --show-prefix) printf '\n' ;; + *) return 1 ;; + esac + ;; + *) + return 0 + ;; + esac +} + +contract_gh_stub() { + local status="${CONTRACT_GH_STATUS:-0}" + + if ((status != 0)); then + return "$status" + fi + case "${1-}:${2-}" in + repo:view) printf 'main\n' ;; + api:*) printf '{"contract":true}\n' ;; + esac + return 0 +} + +contract_git_gh_api_smoke() { + local contract_result="" contract_output="" + + git() { contract_git_stub "$@"; } + gh() { contract_gh_stub "$@"; } + + git_detect_default_branch /contract/repo contract_result + contract_assert_equal "git_detect_default_branch" main "$contract_result" + contract_output="$(git_worktree_path_for_branch main /contract/repo)" + contract_assert_equal "git_worktree_path_for_branch" /contract/worktree "$contract_output" + contract_output="$(git_list_worktree_branches /contract/repo)" + contract_assert_equal "git_list_worktree_branches" $'/contract/worktree\tmain' "$contract_output" + contract_output="$(git_branch_upstream /contract/repo main)" + contract_assert_equal "git_branch_upstream" origin/main "$contract_output" + git_branch_merged_to_ref /contract/repo main origin/main + contract_output="$(git_list_remote_branches /contract/repo)" + contract_assert_equal "git_list_remote_branches" $'main\ntopic/one' "$contract_output" + git_get_current_branch "$contract_tmp" contract_result + contract_assert_equal "git_get_current_branch" main "$contract_result" + contract_quiet_success "check_script_up_to_date missing file" \ + check_script_up_to_date "$contract_tmp/missing-script" + + contract_expect_status "git_detect_default_branch usage" 1 contract_quiet_call git_detect_default_branch + contract_expect_status "git_worktree_path_for_branch usage" 1 contract_quiet_call git_worktree_path_for_branch + contract_expect_status "git_list_worktree_branches usage" 1 \ + contract_quiet_call git_list_worktree_branches one two + contract_expect_status "git_branch_upstream usage" 1 contract_quiet_call git_branch_upstream + contract_expect_status "git_branch_merged_to_ref usage" 1 contract_quiet_call git_branch_merged_to_ref + contract_expect_status "git_list_remote_branches usage" 1 \ + contract_quiet_call git_list_remote_branches one two + contract_expect_status "git_update_repo usage" 1 contract_quiet_call git_update_repo + contract_expect_status "git_get_current_branch usage" 1 contract_quiet_call git_get_current_branch + contract_expect_status "check_script_up_to_date usage" 1 contract_quiet_call check_script_up_to_date + + gh_require_cli + gh_auth_status_diagnostics + contract_quiet_success "gh_run" gh_run repo view basefoundry/base-bash-libs + gh_repo_from_remote_url git@github.com:basefoundry/base-bash-libs.git contract_result + contract_assert_equal "gh_repo_from_remote_url" basefoundry/base-bash-libs "$contract_result" + gh_infer_repo_from_origin /contract/repo contract_result + contract_assert_equal "gh_infer_repo_from_origin" basefoundry/base-bash-libs "$contract_result" + gh_repo_default_branch basefoundry/base-bash-libs contract_result + contract_assert_equal "gh_repo_default_branch" main "$contract_result" + contract_output="$(gh_api_with_retry repos/basefoundry/base-bash-libs)" + contract_assert_equal "gh_api_with_retry" '{"contract":true}' "$contract_output" + contract_expect_status "gh_report_command_failure status" 7 contract_quiet_call \ + gh_report_command_failure 7 api contract + CONTRACT_GH_STATUS=255 + contract_expect_status "gh_run failure status" 255 contract_quiet_call gh_run api contract + unset CONTRACT_GH_STATUS + + contract_expect_status "gh_require_cli usage" 1 contract_quiet_call gh_require_cli one two + contract_expect_status "gh_auth_status_diagnostics usage" 1 \ + contract_quiet_call gh_auth_status_diagnostics one two + contract_expect_status "gh_report_command_failure usage" 1 \ + contract_quiet_call gh_report_command_failure + contract_expect_status "gh_repo_from_remote_url usage" 1 \ + contract_quiet_call gh_repo_from_remote_url + contract_expect_status "gh_infer_repo_from_origin usage" 1 \ + contract_quiet_call gh_infer_repo_from_origin + contract_expect_status "gh_repo_default_branch usage" 1 \ + contract_quiet_call gh_repo_default_branch +} + +contract_run_mode() { + local module + local -a modules=( + "$contract_repo_root/lib/bash/std/lib_std.sh" + "$contract_repo_root/lib/bash/file/lib_file.sh" + "$contract_repo_root/lib/bash/git/lib_git.sh" + "$contract_repo_root/lib/bash/gh/lib_gh.sh" + "$contract_repo_root/lib/bash/str/lib_str.sh" + "$contract_repo_root/lib/bash/arg/lib_arg.sh" + "$contract_repo_root/lib/bash/list/lib_list.sh" + ) + + contract_assert_version "$@" + contract_tmp="$(mktemp -d "${TMPDIR:-/tmp}/base-bash-option-contract.XXXXXX")" || + contract_fail "unable to create temporary directory" + export TMPDIR="$contract_tmp" + contract_state_index=0 + trap 'rm -rf -- "$contract_tmp"' EXIT + trap ':' HUP INT TERM + IFS=$'| \t\n' + OPTIND=7 + umask 027 + shopt -s extglob nullglob nocasematch + cd -- "$contract_tmp" || contract_fail "unable to enter temporary directory" + + contract_enable_mode + contract_assert_mode_options + contract_top_level_source_probe "${modules[@]}" + for module in "${modules[@]}"; do + contract_source_module "$module" + done + + contract_run_api_smoke std contract_std_api_smoke + contract_run_api_smoke str contract_str_api_smoke + contract_run_api_smoke list contract_list_api_smoke + contract_run_api_smoke arg contract_arg_api_smoke + contract_run_api_smoke file contract_file_api_smoke + contract_run_api_smoke git-and-gh contract_git_gh_api_smoke + + printf 'Bash option contract passed: %s (%s)\n' "$contract_mode" "$BASH_VERSION" +} + +if [[ "${1-}" == "--mode" ]]; then + if (($# < 2)); then + contract_fail "--mode requires a value" + fi + contract_mode="$2" + shift 2 + case "$contract_mode" in + none | e | u | p | eu | ep | up | eup) ;; + *) contract_fail "unknown option mode '$contract_mode'" ;; + esac + contract_run_mode "$@" + exit 0 +fi + +if (($# != 0 && $# != 3)); then + contract_fail "usage: bash-option-contract.sh [expected_major expected_minor expected_patch]" +fi + +contract_modes=(none e u p eu ep up eup) +for contract_requested_mode in "${contract_modes[@]}"; do + if "$BASH" "$contract_script_dir/bash-option-contract.sh" \ + --mode "$contract_requested_mode" "$@"; then + : + else + contract_status=$? + printf 'Bash option mode %s failed with status %s.\n' \ + "$contract_requested_mode" "$contract_status" >&2 + exit "$contract_status" + fi +done + +printf 'All Bash option combinations passed (%s).\n' "$BASH_VERSION" diff --git a/tests/lint-warnings.sh b/tests/lint-warnings.sh index 4f468c2..4e96edd 100755 --- a/tests/lint-warnings.sh +++ b/tests/lint-warnings.sh @@ -24,6 +24,7 @@ lint_files=( tests/fixtures/basectl-release-stub tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh + tests/bash-option-contract.sh tests/validate.sh tests/lint-warnings.sh examples/std-usage.sh diff --git a/tests/validate.sh b/tests/validate.sh index 3879104..c241be0 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -20,6 +20,7 @@ required_files=( tests/fixtures/basectl-release-stub tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh + tests/bash-option-contract.sh examples/std-usage.sh examples/cookbook-cleanup-temp.sh examples/cookbook-args-lists-strings.sh @@ -73,6 +74,7 @@ check_no_strict_mode() { tests/fixtures/basectl-release-stub tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh + tests/bash-option-contract.sh tests/validate.sh tests/lint-warnings.sh examples/*.sh @@ -205,6 +207,7 @@ run_stage "ShellCheck error profile" shellcheck --severity=error \ tests/fixtures/basectl-release-stub \ tests/bash-42-release-smoke.sh \ tests/bash-42-logging-smoke.sh \ + tests/bash-option-contract.sh \ tests/validate.sh \ tests/lint-warnings.sh \ examples/std-usage.sh \ @@ -238,6 +241,7 @@ run_stage "BATS test suites" bats \ run_stage "Bash logging smoke" tests/bash-42-logging-smoke.sh || exit $? run_stage "Bash release guard smoke" tests/bash-42-release-smoke.sh || exit $? +run_stage "Bash caller-option contract" tests/bash-option-contract.sh || exit $? run_stage "examples/std-usage.sh" examples/std-usage.sh >/dev/null || exit $? run_stage "examples/cookbook-cleanup-temp.sh" examples/cookbook-cleanup-temp.sh >/dev/null || exit $? run_stage "examples/cookbook-args-lists-strings.sh" examples/cookbook-args-lists-strings.sh >/dev/null || exit $?