Catching Bugs Early: ShellCheck and Portability
How a static analyzer catches real shell scripting bugs before a script ever runs, and the specific portability traps that make a script behave differently across sh, bash, and different operating systems.
What you'll learn
- Explain what class of bugs a static shell analyzer like ShellCheck catches before execution
- Identify at least three common portability traps between bash and a stricter POSIX sh
- Explain why an unquoted variable expansion is a genuinely common, real source of bugs
Prerequisites
Explanation
Every real command below runs only in your own terminal — this lesson's exercises model these findings as data, never executing a real script or a real ShellCheck scan.
ShellCheck is a static analyzer for shell scripts — it reads a script's source without running it and flags real, specific classes of bugs: an unquoted variable expansion that will break on a filename containing a space, a variable that's referenced before it's ever assigned, a comparison operator used in the wrong context, and dozens of other well-documented, numbered rules (each with a stable ID like SC2086, so a finding is easy to look up and understand). This is genuinely valuable because many shell bugs are silent in ordinary testing — a script that works perfectly against every filename you happened to test during development can still break the very first time a real file with a space, or a filename starting with a dash, shows up.
The single most common real-world finding is an unquoted variable expansion: writing cp $file /backup/ instead of cp "$file" /backup/. Without quotes, the shell performs word splitting and globbing on the expanded value — meaning a filename like my report.txt silently becomes two separate arguments (my and report.txt), and a filename containing a * can silently expand into a list of unrelated matching files. Quoting the expansion ("$file") disables both of these behaviors for that expansion, making the variable's value used exactly as-is, as a single argument — this is why "$var" (double-quoted) is the standard, defensive default for referencing a variable that might contain spaces or glob-special characters.
Portability is a separate, related concern: a script written assuming bash-specific features (like [[ ]], arrays, or local) will fail, sometimes silently with different behavior rather than a clear error, if it's ever run with a stricter POSIX sh instead — this can happen unexpectedly in some CI systems, some Docker base images, or when a script's shebang doesn't match the shell actually invoking it. Being deliberate about a script's shebang (#!/usr/bin/env bash specifically, if bash features are used) and knowing which features are bash-only is what prevents a script that "worked on my machine" from silently misbehaving somewhere else.
Example
Modeling the unquoted-variable word-splitting bug and a simple bash-only-feature portability check, as data.
function simulateWordSplitting(value, quoted) {
if (quoted) {
return [value]; // quoted: the whole value is ONE argument, exactly as-is
}
return value.split(/\s+/).filter(Boolean); // unquoted: word-split on whitespace into MULTIPLE arguments
}
console.log(simulateWordSplitting("my report.txt", true)); // ["my report.txt"] -- one argument, correct
console.log(simulateWordSplitting("my report.txt", false)); // ["my","report.txt"] -- silently TWO arguments, a real bug
function usesBashOnlyFeature(scriptSource) {
const bashOnlyPatterns = ["[[", "local ", "declare -a", "readarray"];
return bashOnlyPatterns.some((pattern) => scriptSource.includes(pattern));
}
console.log(usesBashOnlyFeature("if [[ -f \"$file\" ]]; then echo found; fi")); // true -- [[ is bash-specific, not POSIX sh
console.log(usesBashOnlyFeature("if [ -f \"$file\" ]; then echo found; fi")); // false -- [ is POSIX-portableTry it yourself
Call simulateWordSplitting with 'report*.txt' and quoted=false, and observe that a glob-special character is present in the (incorrectly) split result.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
This models detecting the unquoted-variable finding only -- no real ShellCheck scan runs. Write findUnquotedVar(line), returning true if line contains a bare $ followed by a variable name that is NOT immediately preceded by a double quote (a simplified model of ShellCheck's SC2086). Use the regex /(?<!")\$\w+/ to test the line.
Checks: correctly flags an unquoted variable expansion · correctly does NOT flag a properly quoted expansion · correctly ignores a line with no variable expansion at all
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
This models a simplified bash-vs-POSIX-sh portability check only -- no real script is analyzed. Write portabilityIssues(scriptSource), returning an array of any bash-only feature names found among ['[[', 'local ', 'declare -a', 'readarray'] that appear in scriptSource (in the order listed).
Checks: correctly finds multiple bash-only features in one script · correctly reports no issues for a POSIX-portable script · correctly finds a single specific bash-only feature
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Common mistakes
- Writing cp $file /backup/ instead of cp "$file" /backup/ -- the unquoted expansion undergoes word splitting and globbing, silently turning one filename with a space into two arguments (or a filename with a * into a list of matches).
- Assuming a script that works with bash will behave identically under sh -- bash-only features like [[ ]], arrays, and local can fail or behave differently under a stricter POSIX sh, which some CI systems and Docker images use as /bin/sh.
- Treating a ShellCheck warning as automatically wrong to fix, or automatically safe to ignore, without reading WHY that specific rule exists -- each rule corresponds to a real, specific, well-documented bug pattern worth actually understanding.
Knowledge check
Takeaway
Run scripts through ShellCheck to catch well-documented, real bug patterns before execution -- especially unquoted variable expansions, which silently undergo word splitting and globbing. Be deliberate about which shell features are bash-specific (like [[ ]], arrays, local) if a script might ever run under a stricter POSIX sh.
Summary
ShellCheck statically analyzes shell scripts and flags well-documented bug patterns without running them. Unquoted variable expansions undergo word splitting and globbing, a genuinely common real bug source -- quoting an expansion ("$var") prevents both. Bash-specific features like [[ ]], arrays, and local can fail under a stricter POSIX sh, which is why portability matters for any script that might run in an environment where /bin/sh isn't bash.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.