Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

Shell says “bad substitution”? Prove the interpreter and syntax family before rewriting the script

Freeze the exact launcher, identify the parser and version that actually read the file, classify the expansion, then choose Bash or portable sh and replay the original route.

Original diagram mapping direct execution, explicit interpreter, sourcing and wrappers to the parser that actually reads a shell script.
Original illustration: a shebang matters only on the direct-execution route. Watch at Ease

“Bad substitution” does not prove that the expression is wrong. A valid Bash expansion can fail because sh, dash, an older Bash, a sourced parent shell or a wrapper parsed it. A malformed ${command ...} can fail even under the intended Bash.

Freeze the exact command and stderr, prove the execution route and effective interpreter, classify the token against the declared shell contract, change one branch, and replay the same wrapper or scheduler with a fixed fixture.

1. Freeze the exact launch route and first error

Record the complete command, wrapper or scheduler configuration, working directory, environment names, file path, first stderr line and exit status. Preserve whether the file was run as ./script, bash script, sh script, sourced with . or source, piped to a shell, or embedded in another program.

Do not begin by changing /bin/sh, deleting the shebang or replacing every expansion. Each edit can hide the route that selected the wrong parser, and globally relinking sh can break unrelated system scripts that intentionally require POSIX behavior.

2. Prove the interpreter and feature level that parsed the line

For a controlled reproduction, capture the parent and child process command lines, the wrapper shell setting and an interpreter-specific version probe. Treat $SHELL as account metadata, not proof of the current parser. A shebang applies when the operating system directly executes an executable script; sh script and bash script explicitly choose their own interpreters.

If the file is sourced, the caller shell owns parsing. If a hook, CI runner, cron entry, SSH command or application uses sh -c, that launcher owns the contract even when the nested file begins with #!/usr/bin/env bash. Record the exact resolved bash path and version when a Bash-only feature is intentional.

3. Classify the expression instead of guessing

Compare the failing token with the declared contract. Portable parameter operations include ordinary expansion and common prefix/suffix removal. Bash adds pattern replacement, substring operations, case conversion, indirect expansion and arrays, with some operators introduced in later versions. Verify the exact operator in the GNU Bash manual rather than assuming every Bash release supports it.

If braces contain a command such as ${ls ...}, the category is wrong: ${...} denotes parameter expansion, while $(...) performs command substitution. Quote the resulting expansion unless deliberate splitting is required. Do not use eval to make a copied expression parse; it adds a second parsing pass and turns data into code.

4. Choose one explicit contract and repair one branch

If the project requires Bash, keep a valid Bash shebang, make the file executable for direct launch, and configure every wrapper, scheduler and CI step to invoke the intended Bash path or version. Add an early version/feature check when a newer operator is essential.

If the contract is POSIX sh, replace Bash-only syntax with a portable construction supported by the factual specifications and test target shells. For malformed substitution, correct the expression category. Do not mix a Bash body with a promise that any sh can parse it.

5. Run a parse-and-execution matrix through the original launcher

Use a small fixed fixture and test syntax plus execution under each relevant parser: for example dash -n, bash -n, the resolved production Bash, and the exact wrapper or scheduler entry. Capture interpreter path/version, exit status, stdout and stderr in one ledger. Static success is not enough when the failing branch depends on input.

Accept the repair only when the original route succeeds with the expected output and failure cases still fail safely. Also test direct execution versus explicit interpreter and execute versus source when both are supported. If results differ, declare separate contracts instead of adding more compatibility guesses.

Original diagram separating portable parameter expansion, Bash-only expansion, command substitution and verification.
Original illustration: classify the token, select one shell contract, then replay the original launcher. Watch at Ease