DEV Community

Cover image for SC2086 Is ShellCheck's Lowest-Severity Warning. It Let rm Delete Two Files I Never Named.
Anguishe
Anguishe

Posted on Originally published at bashsnippets.xyz

SC2086 Is ShellCheck's Lowest-Severity Warning. It Let rm Delete Two Files I Never Named.

ShellCheck files SC2086 at info, the lowest severity it shows by default, in the same tier as remarks about echo flags. It fires on every unquoted $var, so on a script that has never been linted it is often most of the output, and it is the rule people reach for first when they start a .shellcheckrc. It reads like a style nag. I wanted to know what treating it like one actually costs, so I built the smallest case I could on my own machine: three files and a four-line script.

The directory held quarterly, report.txt and quarterly report.txt. The script set report="quarterly report.txt" and ran rm $report, with set -euo pipefail at the top.

It deleted quarterly. It deleted report.txt. It left quarterly report.txt, the one file it was asked to remove, exactly where it was. And it exited 0, because as far as rm could tell it had been handed two names and both existed. Strict mode had nothing to catch; nothing failed. In a real cleanup job that is a wrong-file deletion with a green exit code, and the first sign is the next job looking for a file that is no longer there.

What bash does before rm ever runs

The mechanism is that an unquoted $report is not handed to rm as one thing. Bash expands the variable, splits the result on whitespace, expands any * or ? it finds against the current directory, and only then builds the argument list. rm $report with a space in the value has become rm quarterly report.txt — two arguments — by the time rm wakes up. Double quotes switch both steps off: rm "$report" is one argument, no globbing, done.

Then the lint run on the same four lines, ShellCheck 0.11.0 and bash 5.3.9:

$ shellcheck before.sh

In before.sh line 4:
rm $report
   ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
rm "$report"
Enter fullscreen mode Exit fullscreen mode

Info. The same severity ShellCheck gives a remark about echo flags. There is no severity level for "deletes files you did not name and reports success," so this is where it lives, and that label is the reason it is the first rule people exclude.

Where the same bug hides

The glob half is quieter. pattern="*.log" followed by echo Looking for $pattern prints the names of the log files in the directory instead of the pattern, because the star was expanded before echo saw it. Hand that unquoted pattern to find -name and it breaks the same way: one matching file and find searches for that literal name, two and it dies with paths must precede expression. ShellCheck flags it as SC2086 too; its separate code, SC2061, is for a glob typed straight into the command, find . -name *.log.

Inside [ ] the failure changes shape. [ is a command, so [ $name = "root" ] with an empty $name gives test two arguments instead of three. It complains about a unary operator on stderr, the condition evaluates false, and the script keeps walking. The branch you were guarding is skipped without an exit. Inside [[ ]] bash does not split at all, which is why ShellCheck stays quiet there and why "switch to double brackets" is a legitimate fix rather than a dodge.

And for f in $files — the one place where splitting is usually on purpose — ShellCheck 0.11.0 does not flag at all. It assumes you meant it. The loop still breaks on the first element containing a space. What you need is a list that can hold spaces, which in bash means an array, and an unquoted array expansion gets a different, louder code: SC2068, at error severity.

The honest case for turning it off

Here is the nuance a blanket disable throws away. There are two real situations where the split is the point: a flag string read from a config file — opts="-r -n" then grep $opts pattern . — and arguments passed through a thin wrapper. ShellCheck cannot know the split is intentional, so it warns. You have two honest exits. Make it an array, opts=(-r -n) and grep "${opts[@]}" pattern ., which ShellCheck accepts without comment. Or keep the string and put # shellcheck disable=SC2086 on the line above, with the reason after a second #, so the next reader knows it was a decision and not a leftover.

That directive covers one command. The repo-wide .shellcheckrc covers every command you will ever write in that repo, including the rm. That is the difference between disabling a check and disabling your own judgement.

There is also the opposite move, which most people who disable the rule never find: shellcheck --include=SC2086 script.sh reports this rule and nothing else, and still exits 1 when it finds one. That is how you enforce quoting as a CI gate on a repo that is still working through its other forty findings — the exact situation that makes people reach for disable= in the first place.

What it costs to filter by severity

Quoted, rm "$report" on the same directory deletes one file, the right one. ShellCheck's lowest severity holds the failure worth caring about most, and filtering by severity is exactly how it gets thrown away.

The full write-up, with every run pasted from ShellCheck 0.11.0 — the [ ] case, the for-loop that lints clean and still breaks, and the one-line, one-file and one-project ways to disable it: https://bashsnippets.xyz/shellcheck/sc2086

If the code on your screen is not SC2086, the ShellCheck Error Decoder explains any SC code with a before/after fix, SC2046 is the same trap for $(command) output, and the rest of the library is at https://bashsnippets.xyz

Top comments (0)