advanced18 min

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-portable

Try 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.

Loading editor…

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.

Loading editor…

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.

Loading editor…

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

Knowledge check

1. What does ShellCheck actually do?
2. Why does cp $file /backup/ (unquoted) behave differently from cp "$file" /backup/ (quoted) when $file contains a space?
3. Why might a script using [[ ]] or arrays fail under /bin/sh in some environments (like certain CI systems or Docker images)?

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.