Configuration and CI

  1. Limits by class of code
  2. Freezing the debt you have
  3. In zig build
  4. In a pull request
  5. One gnarl here and in CI
  6. In GitHub Actions

Limits by class of code

Code is hot, normal or cold, by globs in complexity.zon in the directory gnarl runs in (or the file --config names); with no file, every file is normal. Default limits, warning then critical; nesting and params have a warning limit only, so they mark a row ! but never fail --check:

  cyclomatic cognitive lines nesting params
hot 8, 12 10, 15 40, 80 3 4
normal 10, 15 15, 25 50, 100 4 4
cold critical 20 critical 35      
tests critical 20 critical 35      

A flat dispatch is held to no cyclomatic limit: a function over a cyclomatic limit whose cognitive is over none of its own (within its warning, or its critical where it has no warning). A switch whose every prong is a call has as many paths as prongs but reads as one decision, and cognitive complexity is what tells the two apart. The table marks its cyclomatic dispatch rather than ! or !!, the summary counts the dispatches, and --check does not fail one on cyclomatic, so a new prong never fails it. Its lines, nesting and cognitive are held as any function’s are. So cyclomatic’s limits bite only where cognitive is high too.

.{
    .hot = .{ "src/parse/**" },
    .cold = .{ "build.zig", "tools/**" },
    .exclude = .{ "test/fixtures" },
    .thresholds = .{
        .hot = .{ .cognitive = .{ .warning = 8, .critical = 12 } },
        .tests = .{ .lines = .{ .warning = 150 } },
    },
}
  • * matches within a path segment, ** across them, ? one character. A path both a hot and a cold glob match is hot.
  • A metric named under a class replaces both its limits there; the rest keep their defaults.
  • Test code is held to tests wherever it lives: a test block, what is declared in one, and a function neither pub nor export that only test code in its file uses. Zig keeps tests beside the code, so no glob could tell them apart, and a long test is usually a table of cases.
  • .exclude leaves out what a glob matches, or what is inside it, as what git ignores is: not read under a directory named, read when named outright.

Freezing the debt you have

On a codebase with debt already, write a baseline and commit it:

$ gnarl --update-baseline .
gnarl: complexity.baseline: note: wrote 1 function over a critical threshold

complexity.baseline is sorted plain text, a function a line, so a change to it reads as a diff. From then on gnarl --check . holds to it: a function not in it over a critical limit fails, and so does one above its number there. Within its limits, a function may change freely.

A pass prints nothing. A failure lists only what fails, then says on stderr what each failing function is over and the --explain to run:

$ gnarl --check .
cyclomatic  cognitive  lines  nesting  params  weighted  location     function
      12!        34!!   23        5!      4         4    tally.zig:7  tally

1 function in 1 file: 1 over a critical threshold (!!), 0 more over a warning one (!).
Thresholds for normal code (warning, critical): cyclomatic 10, 15; cognitive 15, 25; lines 50, 100; nesting 4; params 4.
gnarl: --check: 1 function fails against complexity.baseline:
  tally.zig:7  tally: cognitive 34, above its baseline 33
gnarl --explain tally.zig:7 says why the first scores what it does

What the baseline holds is not listed: it is not news to a check. --all lists every function.

When a frozen function gets better, --check says so, and gnarl --update-baseline . tightens the ratchet; when it is within every limit again, the update drops it. Debt can only shrink.

A function is known by its path and its name, so a rename or a move to another file is a new function: update the baseline in the same change.

In zig build

A step in build.zig runs the check with zig build test:

const complexity = b.addSystemCommand(&.{ "gnarl", "--check", "src", "build.zig" });
complexity.setCwd(b.path(".")); // where complexity.zon and complexity.baseline are
complexity.has_side_effects = true; // run every time: the build does not know what gnarl reads
b.step("complexity", "Hold the code to complexity.baseline").dependOn(&complexity.step);
test_step.dependOn(&complexity.step);

setCwd matters: gnarl reads complexity.zon and complexity.baseline from the directory it runs in. A pass prints nothing, so there is no output to capture. If the step does capture it, or checks the exit code with expectExitCode, has_side_effects is what keeps the build from caching the step and skipping it on the next run.

In a pull request

gnarl --check .                        # the gate
gnarl --diff origin/main --format md   # what the change did, for the review

--diff lists only the functions the change touched, with their numbers before and after, says plainly when nothing got worse, and ends by saying whether any of them is over a critical limit now.

One gnarl here and in CI

Releases of gnarl that count differently disagree on what passes: 0.4.0 left a function’s return type out of its numbers, 0.5.0 held a flat dispatch to no cyclomatic limit, 0.6.0 held what only test code uses as tests. A repo whose CI installs one release and whose machines have another passes in one place and fails in the other. complexity.zon names the release the code is judged by:

.{
    .gnarl = "0.7.0",
    .hot = .{ "src/parse/**" },
}

A gnarl that counts otherwise will not run --check: it exits 2, names both releases, and says how to agree. Every other run warns and goes on. A release that counts the same as the one named, such as a fix to --diff, says nothing; a release newer than the gnarl running always counts otherwise to it, since it cannot know. Moving to a new release is one change: .gnarl, and gnarl --update-baseline . run with that release.

In GitHub Actions

A released binary, the one complexity.zon names, checked against its release’s checksums:

- name: install gnarl
  run: |
    GNARL="v$(sed -n 's/.*\.gnarl = "\([^"]*\)".*/\1/p' complexity.zon)"
    dir="$RUNNER_TEMP/gnarl" && mkdir -p "$dir" && cd "$dir"
    base="https://github.com/kmoneil/gnarl/releases/download/$GNARL"
    curl -fsSL --remote-name-all "$base/gnarl-linux-amd64" "$base/SHA256SUMS"
    grep ' gnarl-linux-amd64$' SHA256SUMS | sha256sum -c -
    mv gnarl-linux-amd64 gnarl && chmod +x gnarl
    echo "$dir" >> "$GITHUB_PATH"
- name: complexity
  run: gnarl --check .

On a runner with Zig 0.17 it can be built instead: git clone --depth 1 https://github.com/kmoneil/gnarl, then zig build -Doptimize=ReleaseFast in it, and its zig-out/bin on the PATH.