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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/v2-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,20 @@ platform-specific utility's exact status.
- Diagnostics, warnings, logs, usage text, traces, and failure explanations
go to standard error. They never contaminate command-substitution data.
- APIs that return a value use one pass-by-name result as their first argument,
followed by inputs. Result arrays are caller-declared indexed arrays and
scalar results are caller-owned variables.
followed by inputs. Each API documents whether its result is a scalar,
integer, indexed array, or associative array. Arrays must be declared with
the required kind before the call; ordinary scalar results accept an
untyped/exported scalar, and numeric results also accept an integer (`-i`)
scalar.
- Before any side effect, an output name is checked for valid Bash identifier
syntax, the reserved `__` prefix, readonly status, correct array kind, and
aliases with an input or another output. On failure, outputs remain
unchanged unless an API explicitly documents partial mutation.
aliases with an input or another output. Integer (`-i`) and case-converting
(`-l`/`-u`) attributes are rejected for string and array results because
Bash would silently coerce published values. Readonly variables and
namerefs are rejected; namerefs are never followed, so readonly targets,
cycles, and reserved targets fail safely on Bash versions that support them.
On failure, outputs remain unchanged unless an API explicitly documents
partial mutation.
- Named-output APIs are preferred over command substitution for values that
may contain newlines, whitespace, or leading dashes.

Expand Down
4 changes: 3 additions & 1 deletion lib/bash/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ model's complete effective snapshot.

Supported types are `string`, `path`, `bool`, `integer`, and `enum`. Optional
`validator=FUNCTION` callbacks receive the candidate value and must return
zero. No configuration value is evaluated as shell code.
zero. `base_app_config_get` writes integer-typed values to either an ordinary
scalar or a caller-declared integer (`-i`) scalar; other configuration types
require an ordinary scalar. No configuration value is evaluated as shell code.

## Standard options and prompts

Expand Down
11 changes: 8 additions & 3 deletions lib/bash/app/lib_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -617,15 +617,20 @@ base_app_config_get() {
local __base_bash_libs_app_config_get_model="${1-}"
local __base_bash_libs_app_config_get_key="${2-}"
local __base_bash_libs_app_config_get_result_name="${3-}"
local __base_bash_libs_app_config_get_output_kind=scalar

(($# == 3)) || {
__base_bash_libs_app_error__ 'base_app_config_get: usage: base_app_config_get MODEL KEY RESULT_VARIABLE'
return 2
}
__base_bash_libs_std_validate_variable_names__ base_app_config_get \
"$__base_bash_libs_app_config_get_result_name" || return 2
if [[ "${__base_bash_libs_app_config["$__base_bash_libs_app_config_get_model|$__base_bash_libs_app_config_get_key|type"]-}" == integer ]]; then
__base_bash_libs_app_config_get_output_kind=integer
fi
__base_bash_libs_std_assert_writable_output__ base_app_config_get \
"$__base_bash_libs_app_config_get_result_name" || return 2
"$__base_bash_libs_app_config_get_result_name" \
"$__base_bash_libs_app_config_get_output_kind" || return 2
[[ -n "${__base_bash_libs_app_values["$__base_bash_libs_app_config_get_model|$__base_bash_libs_app_config_get_key"]+set}" ]] || return 1
printf -v "$__base_bash_libs_app_config_get_result_name" '%s' \
"${__base_bash_libs_app_values["$__base_bash_libs_app_config_get_model|$__base_bash_libs_app_config_get_key"]}"
Expand All @@ -644,7 +649,7 @@ base_app_config_provenance() {
__base_bash_libs_std_validate_variable_names__ base_app_config_provenance \
"$__base_bash_libs_app_config_provenance_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_app_config_provenance \
"$__base_bash_libs_app_config_provenance_result_name" || return 2
"$__base_bash_libs_app_config_provenance_result_name" scalar || return 2
[[ -n "${__base_bash_libs_app_provenance["$__base_bash_libs_app_config_provenance_model|$__base_bash_libs_app_config_provenance_key"]+set}" ]] || return 1
printf -v "$__base_bash_libs_app_config_provenance_result_name" '%s' \
"${__base_bash_libs_app_provenance["$__base_bash_libs_app_config_provenance_model|$__base_bash_libs_app_config_provenance_key"]}"
Expand Down Expand Up @@ -852,7 +857,7 @@ base_app_status() {
__base_bash_libs_std_validate_variable_names__ base_app_status \
"$__base_bash_libs_app_status_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_app_status \
"$__base_bash_libs_app_status_result_name" || return 2
"$__base_bash_libs_app_status_result_name" integer || return 2
__base_bash_libs_app_model_exists__ "$__base_bash_libs_app_status_model" || return 1
printf -v "$__base_bash_libs_app_status_result_name" '%s' \
"${__base_bash_libs_app_models["$__base_bash_libs_app_status_model|last-status"]-0}"
Expand Down
67 changes: 67 additions & 0 deletions lib/bash/app/tests/lib_app.bats
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,73 @@ assert_demo_snapshot() {
[ "$value" = cli-secret ]
}

@test "string configuration outputs reject integer and case-converting variables" {
local stderr_file="$TEST_TMPDIR/config-typed-output.err"
local rc output_name
local -i integer_output=42
local -u uppercase_output=MiXeD
local -a app_args=()

base_init app_args --source "${BASH_SOURCE[0]}" --
base_app_init typed_output name=typed-output
base_app_config_define typed_output greeting string default=hello

if base_app_config_get typed_output greeting integer_output 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "$integer_output" -eq 42 ]
[[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]]

if base_app_config_get typed_output greeting uppercase_output 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "$uppercase_output" = MIXED ]

if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3))); then
local readonly_target=unchanged
local __private_target=private
readonly readonly_target
local -n readonly_alias=readonly_target
local -n reserved_alias=__private_target
local cycle_left cycle_right
local -n cycle_left=cycle_right
local -n cycle_right=cycle_left

for output_name in readonly_alias reserved_alias cycle_left; do
if base_app_config_get typed_output greeting "$output_name" 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[[ "$(<"$stderr_file")" == *"is a nameref; named outputs require a direct variable"* ]]
done
[ "$readonly_target" = unchanged ]
[ "$__private_target" = private ]
fi
}

@test "integer configuration supports integer caller-owned output variables" {
local scalar_output=unchanged
local -i integer_output=42

base_app_init typed_integer_output name=typed-integer-output
base_app_config_define typed_integer_output timeout integer default=30
base_app_config_load typed_integer_output

base_app_config_get typed_integer_output timeout integer_output
[ "$integer_output" -eq 30 ]

base_app_config_get typed_integer_output timeout scalar_output
[ "$scalar_output" = 30 ]
}

@test "empty environment bindings do not override file configuration" {
local project_file="$TEST_TMPDIR/project.conf" value source

Expand Down
4 changes: 4 additions & 0 deletions lib/bash/arg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ helpers are available.
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.
The options output must be a caller-declared associative array; positionals
and repeatable option outputs must be caller-declared indexed arrays. Arrays
with integer or case-converting attributes, readonly outputs, and nameref
outputs are rejected before parsing or publication.

## Usage

Expand Down
6 changes: 3 additions & 3 deletions lib/bash/arg/lib_arg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ __base_bash_libs_arg_parse_specs__() {
base_std_log_error -l base_bash_libs.arg "base_arg_parse: repeatable option '$__base_bash_libs_arg_name' requires a caller-declared indexed array."
return 2
fi
__base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_name" indexed-array || return 2
eval "$__base_bash_libs_arg_repeatable_names_name+=(\"\$__base_bash_libs_arg_name\")"
fi

Expand Down Expand Up @@ -167,8 +167,8 @@ base_arg_parse() {
__base_bash_libs_arg_assert_distinct_names__ "$__base_bash_libs_arg_options_name" "$__base_bash_libs_arg_positionals_name" "$__base_bash_libs_arg_specs_name" || return 2
__base_bash_libs_std_validate_array_kind__ base_arg_parse A "$__base_bash_libs_arg_options_name" || return 2
__base_bash_libs_std_validate_array_kind__ base_arg_parse a "$__base_bash_libs_arg_positionals_name" "$__base_bash_libs_arg_specs_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_options_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_positionals_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_options_name" associative-array || return 2
__base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_positionals_name" indexed-array || return 2

__base_bash_libs_arg_parse_specs__ "$__base_bash_libs_arg_specs_name" __base_bash_libs_arg_token_kind __base_bash_libs_arg_token_name __base_bash_libs_arg_repeatable_names \
"$__base_bash_libs_arg_options_name" "$__base_bash_libs_arg_positionals_name" "$__base_bash_libs_arg_specs_name" || return $?
Expand Down
64 changes: 64 additions & 0 deletions lib/bash/arg/tests/lib_arg.bats
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,70 @@ create_script() {
[ "${positionals[2]}" = "gamma" ]
}

@test "base_arg_parse rejects coercing attributes on its output arrays before publication" {
local stderr_file="$TEST_TMPDIR/arg-typed-output.err"
local rc
local -Au options=([existing]=sentinel)
local -ai positionals=(23)
local -a specs=("verbose|flag|--verbose|-v")

if base_arg_parse options positionals specs -- --verbose new 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "${options[existing]}" = SENTINEL ]
[ "${positionals[0]}" -eq 23 ]
[[ "$(<"$stderr_file")" == *"attributes incompatible with the associative-array output contract"* ]]

local -A plain_options=([existing]=keep)
if base_arg_parse plain_options positionals specs -- --verbose new 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "${plain_options[existing]}" = keep ]
[ "${positionals[0]}" -eq 23 ]
[[ "$(<"$stderr_file")" == *"attributes incompatible with the indexed-array output contract"* ]]

local -A repeatable_options=([existing]=keep)
local -a repeatable_positionals=(old)
local -ai tag_values=(23)
local -a repeatable_specs=("tag_values|repeatable|--tag")
if base_arg_parse repeatable_options repeatable_positionals repeatable_specs -- \
--tag new 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "${repeatable_options[existing]}" = keep ]
[ "${repeatable_positionals[0]}" = old ]
[ "${tag_values[0]}" -eq 23 ]
[[ "$(<"$stderr_file")" == *"attributes incompatible with the indexed-array output contract"* ]]
}

@test "base_arg_parse rejects an associative specs input when nocasematch is enabled" {
local -A options=()
local -a positionals=()
local -A specs=([verbose]="verbose|flag|--verbose")
local status

shopt -s nocasematch
if base_arg_parse options positionals specs -- --verbose 2>/dev/null; then
status=0
else
status=$?
fi

[ "$status" -eq 2 ]
[ "${#options[@]}" -eq 0 ]
[ "${#positionals[@]}" -eq 0 ]
shopt -q nocasematch
}

@test "base_arg_parse rejects readonly output arrays before parsing" {
local script="$TEST_TMPDIR/arg-readonly-output.sh"

Expand Down
6 changes: 3 additions & 3 deletions lib/bash/cli/lib_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1593,7 +1593,7 @@ base_cli_result_get() {
__base_bash_libs_std_validate_variable_names__ base_cli_result_get \
"$__base_bash_libs_cli_result_get_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_cli_result_get \
"$__base_bash_libs_cli_result_get_result_name" || return 2
"$__base_bash_libs_cli_result_get_result_name" scalar || return 2
[[ -n "${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[$__base_bash_libs_cli_result_get_key]+set}" ]] || return 1
printf -v "$__base_bash_libs_cli_result_get_result_name" '%s' \
"${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[$__base_bash_libs_cli_result_get_key]}"
Expand All @@ -1614,7 +1614,7 @@ base_cli_result_get_positional() {
__base_bash_libs_std_validate_variable_names__ base_cli_result_get_positional \
"$__base_bash_libs_cli_result_get_positional_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_cli_result_get_positional \
"$__base_bash_libs_cli_result_get_positional_result_name" || return 2
"$__base_bash_libs_cli_result_get_positional_result_name" scalar || return 2
((${#BASE_BASH_LIBS_CLI_RESULT_POSITIONALS[@]} > 0)) || return 1
__base_bash_libs_cli_result_get_positional_normalized="${__base_bash_libs_cli_result_get_positional_index#"${__base_bash_libs_cli_result_get_positional_index%%[!0]*}"}"
[[ -n "$__base_bash_libs_cli_result_get_positional_normalized" ]] ||
Expand Down Expand Up @@ -1649,7 +1649,7 @@ base_cli_result_count() {
__base_bash_libs_std_validate_variable_names__ base_cli_result_count \
"$__base_bash_libs_cli_result_count_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_cli_result_count \
"$__base_bash_libs_cli_result_count_result_name" || return 2
"$__base_bash_libs_cli_result_count_result_name" integer || return 2
printf -v "$__base_bash_libs_cli_result_count_result_name" '%s' \
"${BASE_BASH_LIBS_CLI_RESULT_REPEATABLE_COUNTS[$__base_bash_libs_cli_result_count_key]-0}"
}
22 changes: 22 additions & 0 deletions lib/bash/cli/tests/lib_cli.bats
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,28 @@ EOF
[ "$count" -eq 2 ]
}

@test "CLI named outputs enforce scalar and integer attributes" {
local stderr_file="$TEST_TMPDIR/cli-typed-output.err"
local rc
local -u color_value=sentinel
local -i repeatable_count=99

declare_demo_model
base_cli_parse demo -- manage u target-name --color green --tag one

if base_cli_result_get color color_value 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "$color_value" = SENTINEL ]
[[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]]

base_cli_result_count tag repeatable_count
[ "$repeatable_count" -eq 1 ]
}

@test "help is deterministic and includes aliases, defaults, and nested usage" {
declare_demo_model

Expand Down
6 changes: 3 additions & 3 deletions lib/bash/gh/lib_gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ base_gh_repo_from_remote_url() {
base_std_log_error -l base_bash_libs.gh "Usage: base_gh_repo_from_remote_url <remote_url> <result_variable_name>"
return 2
fi
__base_bash_libs_std_assert_writable_output__ base_gh_repo_from_remote_url "$__base_bash_libs_gh_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_gh_repo_from_remote_url "$__base_bash_libs_gh_result_name" scalar || return 2

__base_bash_libs_gh_parse_repo_from_remote_url__ "$__base_bash_libs_gh_remote_url" __base_bash_libs_gh_parsed_repo || return 2
printf -v "$__base_bash_libs_gh_result_name" '%s' "$__base_bash_libs_gh_parsed_repo"
Expand All @@ -305,7 +305,7 @@ base_gh_infer_repo_from_origin() {
base_std_log_error -l base_bash_libs.gh "Usage: base_gh_infer_repo_from_origin <repo_dir> <result_variable_name> [--optional]"
return 2
fi
__base_bash_libs_std_assert_writable_output__ base_gh_infer_repo_from_origin "$__base_bash_libs_gh_infer_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_gh_infer_repo_from_origin "$__base_bash_libs_gh_infer_result_name" scalar || return 2

if [[ "${3:-}" == "--optional" ]]; then
__base_bash_libs_gh_infer_optional=1
Expand Down Expand Up @@ -340,7 +340,7 @@ base_gh_repo_default_branch() {
base_std_log_error -l base_bash_libs.gh "Usage: base_gh_repo_default_branch <owner/repo> <result_variable_name>"
return 2
fi
__base_bash_libs_std_assert_writable_output__ base_gh_repo_default_branch "$__base_bash_libs_gh_repo_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_gh_repo_default_branch "$__base_bash_libs_gh_repo_result_name" scalar || return 2

base_gh_require_cli || return 1
__base_bash_libs_gh_repo_default_branch="$(gh repo view "$__base_bash_libs_gh_repo" --json defaultBranchRef --jq .defaultBranchRef.name 2> /dev/null)" || __base_bash_libs_gh_repo_status=$?
Expand Down
15 changes: 15 additions & 0 deletions lib/bash/gh/tests/lib_gh.bats
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,21 @@ EOF
[[ "$(cat "$stderr_file")" == *"result variable 'repo' is readonly"* ]]
}

@test "GitHub result parser rejects coercing scalar output before publishing" {
local stderr_file="$TEST_TMPDIR/gh-typed-output.err"
local rc
local -i repo=42

if base_gh_repo_from_remote_url "https://github.com/basefoundry/base-bash-libs.git" repo 2>"$stderr_file"; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ]
[ "$repo" -eq 42 ]
[[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]]
}

@test "GitHub result helpers reject exact internal holder names before locals or mutation" {
local -r __base_bash_libs_gh_result_name=parsed
local -r __base_bash_libs_gh_infer_result_name=inferred
Expand Down
6 changes: 3 additions & 3 deletions lib/bash/git/lib_git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ base_git_detect_default_branch() {
base_std_log_error -l base_bash_libs.git "Usage: base_git_detect_default_branch <repo_dir> <result_variable_name>"
return 2
fi
__base_bash_libs_std_assert_writable_output__ base_git_detect_default_branch "$__base_bash_libs_git_detect_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_git_detect_default_branch "$__base_bash_libs_git_detect_result_name" scalar || return 2

if __base_bash_libs_git_detect_branch="$(__base_bash_libs_git_detect_default_branch__ "$__base_bash_libs_git_detect_repo_dir")"; then
printf -v "$__base_bash_libs_git_detect_result_name" '%s' "$__base_bash_libs_git_detect_branch"
Expand Down Expand Up @@ -128,7 +128,7 @@ base_git_worktree_path_for_branch() {
}
if [[ -n "$__base_bash_libs_git_worktree_result_name" ]]; then
__base_bash_libs_std_validate_variable_names__ base_git_worktree_path_for_branch "$__base_bash_libs_git_worktree_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_git_worktree_path_for_branch "$__base_bash_libs_git_worktree_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_git_worktree_path_for_branch "$__base_bash_libs_git_worktree_result_name" scalar || return 2
fi

if ! __base_bash_libs_git_capture_worktree_records__ \
Expand Down Expand Up @@ -521,7 +521,7 @@ base_git_get_current_branch() {
base_std_log_error -l base_bash_libs.git "base_git_get_current_branch: result variable name must be a valid Bash variable name."
return 2
fi
__base_bash_libs_std_assert_writable_output__ base_git_get_current_branch "$__base_bash_libs_git_branch_result_name" || return 2
__base_bash_libs_std_assert_writable_output__ base_git_get_current_branch "$__base_bash_libs_git_branch_result_name" scalar || return 2

printf -v "$__base_bash_libs_git_branch_result_name" '%s' ""

Expand Down
Loading
Loading