Configuration and CI
- Limits by class of code
- Freezing the debt you have
- In zig build
- In a pull request
- One gnarl here and in CI
- 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
testswherever it lives: a test block, what is declared in one, and a function neitherpubnorexportthat 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. .excludeleaves 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.