Codifying a Bash Style Guide as ShellCheck Plugins
A style guide is just text. An enforced check is a tool that catches mistakes.
I have a bash style guide that I keep in a repo and re-read when I forget which way around the *List convention goes. I also have a shellcheck fork with a plugin system. The natural next step is to translate the guide into checks. That’s shellcheck-convention-plugin, and it ships nine checks codifying nine rules.
This post is the catalog plus two lessons from building it. The lessons are the value; the catalog is reference.
The catalog
| Check | Rule | Guide section |
|---|---|---|
| SC9001 | Taint flows from unquoted parameter expansion to test/cmdsub contexts | §5 quoting |
| SC9002 | Command substitution result is tainted; quote it before using | §5 quoting |
| SC9003 | Quoting an already-quoted-by-context value is noise | §5 quoting |
| SC9004 | A variable cannot end in both _ and List (the two mutually exclusive suffixes) |
§3 naming |
| SC9005 | Numeric variables don’t belong inside [[ ... ]] — use (( ... )) |
§11 conditionals |
| SC9006 | Inclusive language in identifiers and comments | §3 naming |
| SC9007 | Function docstring shape: first body statement is a # description comment |
§6 functions |
| SC9008 | *List is an IFS-newline-serialized string, not an array — disallow array operations on it |
§3 naming + §7 arrays |
| SC9009 | A local declaration without initialization followed by an append (x+=..., printf -v x, read x) reads from outer scope |
§6 functions + §15 FP-style |
Each check has positive (should fire) and negative (should not fire) test fixtures. The plugin ships as one .so and reports Loaded plugin: libconvention-checks.so (9 check(s)) at startup. Each check has its own SC code so users can disable individuals with --disable=SC9008.
The codes are in the SC9xxx range. Upstream uses SC1xxx (parser), SC2xxx (analytics), SC3xxx (shell-dialect). SC9xxx is a convention I picked for plugins — it doesn’t collide with anything upstream is likely to issue, and a future reader can tell at a glance that an SC9xxx warning is from a plugin, not from shellcheck core.
Lesson 1: when the task and the guide disagree, the guide wins
SC9008 shipped backwards.
The task description said “warn on array operations applied to *List variables.” I read that, wrote the check, shipped it. The fixtures passed. The check fired on octopiList[0] and didn’t fire on octopi[0]. Looked correct.
It was inverted.
*List in my style guide means an IFS-serialized string — newline-separated values you read with while IFS= read -r line. Arrays use plural names: octopi, requestedTests, filenames. The task had been filed months earlier, when the convention was still in flux, and the wording reflected the older form where *List meant “array.” By the time I implemented it, the convention had inverted. The clarification lived in a separate task I didn’t read. I followed the task wording, not the guide.
The lesson: when implementing a rule, read the guide section, not the task description. Tasks describe what to do; guides describe what’s true. If they disagree, the guide wins, because the guide is what users will be checked against.
The fix: git revert, file a corrected task, re-implement against the guide, write a process retro. The retro is the part that mattered — it’s the reason I’ll catch this class of mistake next time.
Lesson 2: scope-aware checks are hard, and they’re worth the trouble
SC9009 is the only check in the catalog that requires reasoning about variable scope and order of operations within a function. Everything else can be decided from the AST node in isolation.
The rule sounds simple:
A
local xdeclaration followed by an append (x+=...,printf -v x ...,read x,(( x += ... ))) without an intervening initialization is a bug. The append reads from outer scope before assigning, so the function silently captures and mutates a global.
Implementing it took 7 grade/improve cycles past the plan’s approval, each finding a new defect class:
read -p prompt var— the-pvalue got treated as a write target. Fix: extract aextractReadTargetshelper that knows whichreadflags take values.mapfile -t arr— same flag-value bug formapfile. Fix: sharedextractFlagAwareTargetshelper.declare -p name— the-pform is a query, not a declaration. Fix: skipdeclarewhen-p/-f/-Fis present.declare -n alias=...— the-nform is a nameref, not a value. Fix: skip when-nis present.(( x ))—TA_VariableLHS of an arithmetic expression was being indexed as a read. Fix: track arith LHS IDs in a separate set, exclude from read positions.(( x = y = 1 ))— chained arithmetic only registered the outer write. Fix: recurse into matchedTA_Assignmentfor chained writes.printf -v var fmt— the-vform is a write, but only when the flag is actually present. Fix: detect the-vflag explicitly rather than assuming anyprintfinvocation with a variable arg is a write.
Each of these passed the previous round’s fixtures. Each surfaced when I added one more real-world script to the negative-fixture set.
The check is still not CFG-path-sensitive. It’s a lexical heuristic: walk the AST in order, build a per-scope index of (variable, first-write-kind, first-read-or-write-position), flag when the first write is an append and there’s no preceding initialization. A real CFG analysis would handle conditional initialization — if foo; then x=1; fi; x+=more — without flagging it. The lexical version flags it. That’s a known false positive and it’s documented in the check.
I shipped the lexical version because it catches the bug class — uninitialized-then-appended — without the implementation cost of a CFG. If I see real false positives in real scripts, I’ll revisit. So far, the rate is low enough that the lexical heuristic is the right cost/benefit point.
What this experiment proved
Before this work, my bash style guide was a document. People who read it (mostly me) tried to apply it; mistakes were caught in code review, when caught at all.
After this work, the guide is a tool. The same shellcheck I already run on save now refuses to let me declare userList=( inky blinky ), refuses to let me write local count; count+=1, refuses to let me write a function whose first body statement isn’t a docstring comment.
The translation isn’t perfect. SC9009 has known false positives. SC9007 fires on section-header comments that aren’t intended as docstrings. SC9006 can’t tell that master as a git branch context is allowed where master as a deployment role isn’t. These are tradeoffs — false positives are cheaper to suppress than false negatives are to find by hand.
The repo: binaryphile/shellcheck-convention-plugin. The catalog with full per-check rationale: docs/design.md in that repo. The host fork: binaryphile/shellcheck, covered in the previous post.
If you’ve written a style guide for any language and wish it were enforced, write a plugin for whichever linter your team already runs. The ROI is real. The first check costs a day; the second costs an hour.