#3537·shellcheck

SC2218 false positives when built against fgl < 5.8.1.0, which the declared bound permits

Author: etalCreated Sep 18, 2026Updated Sep 21, 2026

For bugs with existing features

  • Rule Id (if any, e.g. SC2218): SC2218
  • My shellcheck version (shellcheck --version or "online"): 0.11.0
  • The rule's wiki page does not already cover this
  • I tried on https://www.shellcheck.net/ and verified that this is still a problem on the latest commit — it is not a problem there, and that is the point of this report: the defect is a property of the build rather than of the source.

Summary

SC2218 emits false positives when ShellCheck is linked against fgl earlier than 5.8.1.0. ShellCheck.cabal permits fgl >= 5.7.0, and the tree's own stack.yaml pins resolver: lts-18.15, whose snapshot carries fgl 5.7.0.3, so stack build from the release tarball produces such a binary. The released binaries carry fgl 5.8.3.0 and are unaffected, which is why reports of this shape cannot be reproduced on shellcheck.net or with a downloaded release.

Two open reports reproduce exactly on a fgl 5.7.0.3 build and not at all on the released 0.11.0 binary: #3290 (both findings, at the same lines and columns the reporter quoted) and #3331 (the single finding at line 11).

Minimal reproducer

bash
#!/bin/sh
f() { return; }
return
[ "$(f)" = x ] || r=1
[ "$(f)" = y ] || r=1

fgl 5.7.0.3 build: 4:6: error: This function is only defined later. Move the definition up. [SC2218]. Released 0.11.0 binary, and a cabal build with fgl 5.8.3.0: no SC2218. Both calls and the top-level return are required, which follows from the mechanism below.

Mechanism

Since 0.11.0, checkUseBeforeDefinition accepts a finding when the definition post-dominates the invocation, via CF.doesPostDominate. In 0.10.0 the check was a syntactic reverse scan with no control-flow input, which is consistent with the reports that 0.10.0 was clean on the same code.

findPostDominators in src/ShellCheck/CFG.hs reverses the control-flow graph and calls fgl's dom. Through fgl 5.8.0.0, dom ended with

haskell
    rest = I.keys (I.filter (-1 ==) fromNode)
  in
    [(toNode ! i, dom' ! i) | i <- range (bounds dom')] ++
    [(n, nodes') | n <- rest]

so every node unreachable from the root was reported as dominated by every node in the graph. With that table, doesPostDominate is true for arbitrary pairs whenever the call site cannot reach the graph's exit, and the first guard in checkUseBeforeDefinition passes on correctly ordered code. fgl 5.8.1.0 changed fromNode's default set from nodes g to reachable root g, which leaves rest empty and omits those nodes; 5.8.1.1 then repaired the partiality that change introduced in preds, recorded in fgl's changelog as "Data.Graph.Inductive.Query.Dominators.{dom,iDom} could fail for some graphs (issue #109, regression in 5.8.1.0)".

The existing bound already excludes 5.8.1.0, from #2677. Only its lower half is at issue here.

Measurements

Three osx-arm64 binaries, each reporting version: 0.11.0, each built from the v0.11.0 source. Dependency versions were read from the package paths embedded in each binary.

build GHC fgl minimal reproducer #3290 #3331 one real 1113-line script
stack with the tree's stack.yaml 8.10.7 5.7.0.3 SC2218 2 findings, as reported 1 finding, as reported 57 findings
released darwin.aarch64 8.10.7 5.8.3.0 none none none none
cabal against a current Hackage index 9.12.2 5.8.3.0 none none none none

GHC 8.10.7 is common to one correct build and the incorrect one, so the compiler is not the variable. All three builds agree on a genuine use-before-definition (f; f() { true; }), so the old-fgl build over-reports rather than reporting differently.

Not every open SC2218 report is this defect. #3509 reproduces on all three builds and is unrelated to fgl; #3421's reproducer fires on none of them.

Suggested change

Raise the lower bound to fgl >= 5.8.1.1, which collapses the current disjunction to a single range, and move stack.yaml to a snapshot that satisfies it, so that a build from the source tree matches the releases. As it stands, the declared bound admits a dependency version under which one of the analyses is unsound, and a packager who follows the tree's own build configuration gets that version.

One such package exists in the wild: conda-forge's osx builds are made with stack from this tree, and are reported separately as conda-forge/shellcheck-feedstock#19.