Things Zig compiles that are wrong.
zigvet reads your .zig files with std.zig.Ast, the compiler's own
parser, and reports code the compiler accepts in every build mode that is
almost certainly a bug: a slice of a dead stack frame handed back to the
caller, a double free on an error path, a lock still held after a failure,
a buffered writer never flushed, tests that never run. go vet is the
closest analog: not style, which zig fmt and the existing linters cover,
but silent wrongness.
- Twenty-two rules. Eighteen have found real bugs: in Zig's own standard library and compiler, in well-known open-source Zig projects, and in application code.
- Syntax only. No build, no configuration. It reads Zig's standard library and compiler, 650,000 lines, in under a second.
- Quiet when there is nothing to say, as
zig fmt --checkis, and fails the build when there is.
pub fn scopesFor(scope: Scope, asked: []const []const u8) []const []const u8 {
return if (scope == .monitoring) &.{Scope.monitoring.url()} else asked;
}$ zigvet src
src/auth.zig:14:38: escape: anonymous array made here leaves scopesFor through return
hint: make it comptime-known (`comptime` on the runtime elements) or a container-level constScope.monitoring.url() is a call, so the array is built at run time, in
scopesFor's own frame, and the slice it returns points into memory the
next call writes over. Zig 0.17.0 compiles this without a diagnostic in
every mode. This exact code shipped, passed its tests for two days, and
failed only when an unrelated std.debug.print happened to overwrite the
stack slot. The fix is one word: &.{comptime Scope.monitoring.url()}.
| Where | How |
|---|---|
| macOS, Linux | brew install kmoneil/tap/zigvet |
| Windows | scoop bucket add kmoneil https://github.com/kmoneil/scoop-bucket then scoop install kmoneil/zigvet |
| Anywhere | a binary from the latest release |
| From source | zig build -Doptimize=ReleaseSafe with Zig 0.17.1, which leaves it in zig-out/bin/ |
Homebrew and Scoop move to each release by themselves, after checking every
file's build provenance. Each release carries a binary for Linux, macOS and
Windows on x86-64 and arm64, named
zigvet-<linux|darwin|windows>-<amd64|arm64> (.exe on Windows), with
SHA256SUMS and a build-provenance attestation for every file.
Verify a download, or rebuild a release yourself
On an Apple-silicon Mac, for example:
curl -fLO https://github.com/kmoneil/zigvet/releases/latest/download/zigvet-darwin-arm64
curl -fLO https://github.com/kmoneil/zigvet/releases/latest/download/SHA256SUMS
shasum -a 256 --check --ignore-missing SHA256SUMS
gh attestation verify zigvet-darwin-arm64 --repo kmoneil/zigvet
chmod +x zigvet-darwin-arm64 && mv zigvet-darwin-arm64 /usr/local/bin/zigvetA binary downloaded with a browser on macOS is quarantined; clear it with
xattr -d com.apple.quarantine zigvet. curl does not set the flag.
From v0.2.1 on, releases are also reproducible: a tag built with the Zig
its build.zig.zon names as minimum_zig_version, from ziglang.org, gives
the same bytes wherever it is built, so a release can be checked without
trusting the workflow that made it. That is the Zig its CI and release ran
(0.17.0 up to v0.24.0, 0.17.1 after); another Zig gives other bytes. CI
builds every change on Linux and on macOS and compares them. To check one
yourself:
git clone --depth 1 --branch <tag> https://github.com/kmoneil/zigvet && cd zigvet
grep minimum_zig_version build.zig.zon # the Zig to build it with
zig build release && cd zig-out/release
curl -fLO https://github.com/kmoneil/zigvet/releases/download/<tag>/SHA256SUMS
shasum -a 256 --check SHA256SUMSIt takes Zig as ziglang.org ships it: a Zig built against another LLVM, such as Homebrew's, writes its linker's name into the Linux binaries, so those two differ while the macOS and Windows ones still match. The release binaries carry no debug info, since it holds the paths of the machine that built them, so a panic prints its message but not a symbolized stack trace.
zigvet . # every .zig file under here
zigvet src build.zig # some directories and files
zigvet --summary src # and say what was checked
zigvet --format github src # annotate a pull request in CIzigvet [--exclude <path>]... [--format <text|github|json>] [--summary] <path>...
zigvet skill [--install <dir>]
zigvet --version | --help
A file is checked whatever its extension. A directory is walked for .zig
files, skipping hidden files and directories, zig-out and zig-pkg.
Paths print cleaned (./src/a.zig as src/a.zig), and a file reached
more than one way is checked once. A file that does not parse is reported
with the error zig ast-check gives for it.
| Option | Does |
|---|---|
--exclude <path> |
Skips a file or directory; may be given more than once. It is matched against paths as zigvet prints them, so name it relative to the current directory: zigvet . --exclude _tmp leaves out a scratch directory, or a second worktree checked out inside it. |
--format text |
The default: path:line:col: rule: message, then the hint on its own line. |
--format github |
GitHub Actions workflow commands, so each report lands on the pull request's diff. |
--format json |
One JSON array, an object per report, for other tools. |
--summary |
Ends the run with one line on stderr, in every format, saying how many files were checked and what each rule reported. |
--summary is off by default, so a clean run prints nothing and a lint
step stays quiet. Turn it on where a zero has to be trusted, such as CI
after an upgrade: it tells a run over 354 files from a run over 3.
zigvet: checked 354 files, no findings
zigvet: checked 41 files, 3 findings (escape 1, refill 2)
Exit status: 0 if nothing was reported, 1 if a finding or parse error
was, 2 on a usage error or a path that could not be read or holds no
.zig file to check. So a lint step aimed at a directory whose Zig has
moved fails rather than passing with nothing checked.
What the github and json formats look like
::error file=src/kms/fake_kms.zig,line=1297,col=54,title=zigvet escape::result of a call made here leaves rawOf through return. hint: return the value itself, have the caller pass in the memory, or allocate it
[
{"path":"src/a.zig","line":2,"column":12,"rule":"escape","message":"anonymous array made here leaves f through return","hint":"make it comptime-known (`comptime` on the runtime elements) or a container-level const"}
]rule is the rule's name, allow for an allow comment that suppresses
nothing, or error for a parse error, which has no hint (null). The
reports, their order and the exit status are the same in every format.
Findings go to stdout, in path order and then source order; a message
about the run itself, such as a path that could not be read, goes to
stderr as text.
Each rule is one word for what it catches. Its page shows the bug, why it goes wrong, exactly what the rule reports and leaves alone, and what it found in real code.
| Rule | Catches | What goes wrong |
|---|---|---|
| escape | A pointer or slice into stack memory that leaves the function that made it | The caller reads memory the next call writes over |
| freed | A function that returns what its own defer frees, or a std.json value parsed out of it |
The caller reads freed memory |
| grow | A pointer into a list's items, kept across a call that can move them | The pointer reads freed memory once the list grows |
| copy | A std.Io.Writer or Reader interface copied out of the struct it belongs to |
The first flush reads whatever sits next to the copy as its parent, and crashes |
These all go wrong only when something fails part way, the path tests seldom take.
| Rule | Catches | What goes wrong |
|---|---|---|
| refill | A place freed, then given new memory by something that can fail first | The place still holds the freed memory, and deinit frees it again |
| owner | One allocation freed by two errdefers: the caller's, and the one in the function it hands it to | Freed twice |
| kept | An errdefer that frees what the function has already stored where its caller keeps it |
The caller's deinit frees it again |
| torn | An optional or union built in place by an initializer that can fail | It is left set over what was never made, and deinit frees that |
| twice | An errdefer's free also written out by hand on a path that then fails | Freed twice |
| sweep | A loop that frees what a list holds, can fail part way, and empties the list only after it | The list still holds what was freed |
| unlock | A lock taken, then something that can fail before the unlock, with no defer |
The lock stays held, and the next lock waits for good |
| leak | A local released by hand later in its block, with something that can fail between | The release never runs |
| Rule | Catches | What goes wrong |
|---|---|---|
| cancel | In a task's loop, an Io call whose error is dropped with catch {} or catch continue |
error.Canceled comes once, so the loop runs on and the cancel waits for good |
| fit | A number printed into a buffer too small for it, under catch unreachable |
A panic in Debug and ReleaseSafe, undefined behavior in ReleaseFast |
| Rule | Catches | What goes wrong |
|---|---|---|
| alias | An anonymous initializer assigned to a place that it reads after storing to it | ext = .{ ext[1], ext[0] } gives { ext[1], ext[1] }, and e = .{ .pair = f(e) } hands f an e already tagged .pair |
| flush | A buffered file or stream writer, written to and never flushed | What is left in the buffer never arrives |
| positional | A writer on stdout or stderr, or a reader on stdin, made positional (writer, File.Writer.init, reader) |
Redirected to a file it writes from byte 0, over what else is written there: prog > log 2>&1 comes out garbled; a reader reads again what was read before it |
| wipe | A local wiped with @memset as the last thing done with it |
Release builds drop the store, and the secret stays in memory |
| Rule | Catches | What goes wrong |
|---|---|---|
| race | A container-level optional tested by value (g == null, if (g) |v|) when what it holds has a lock or atomics in it |
Debug copies all of it to read the tag, a plain read racing other threads' atomic writes, and ThreadSanitizer reports it |
| Rule | Catches | What goes wrong |
|---|---|---|
| cached | A build script that lists a directory to decide what to build, and never tells Zig's configure cache | A file added there is not built until something else changes the build |
| unrun | A file whose tests never run, because nothing the test build analyses refers to it | zig build test passes without them |
| hoard | A fuzz target that keeps memory past one input, from an allocator the test runner does not check | It grows with every input until the run runs out of memory, and the leak check passes |
For a report zigvet gets wrong, say so in the comment lines directly above the reported line, naming the rule and giving a reason:
// zigvet: allow escape only called at comptime, from the build script
return &.{Scope.monitoring.url()};The reason is required; an allow comment without one suppresses nothing.
Every allow comment has to suppress something. One that does not is reported itself, with why: nothing on the line below breaks its rule any more, it names no rule zigvet has, it gives no reason, or it sits at the end of the line it means instead of above it. It counts toward the exit status like a finding, so an allow cannot outlive the code it excused and then excuse whatever is written there next.
Add zigvet to build.zig.zon, pinned to a release tag, with zig fetch --save git+https://github.com/kmoneil/zigvet#v0.26.1, then give build.zig
a lint step, the way zig fmt --check is used:
const zigvet = b.dependency("zigvet", .{
.target = b.graph.host,
.optimize = .ReleaseSafe,
});
const vet = b.addRunArtifact(zigvet.artifact("zigvet"));
vet.setCwd(b.path("."));
vet.addArgs(&.{"src"});
b.step("lint", "Report code that compiles but is wrong").dependOn(&vet.step);zig build lint then fails when zigvet reports anything. zigvet's own
zig build lint is the same step run over its src/.
A function that returns a slice of its own frame passes a test that reads
the slice straight away, because nothing has written over that memory yet.
That is how the bug at the top passed its test for two days.
clobberStack() writes over the stack the returned frame used, so the same
test fails at once:
const zigvet = @import("zigvet");
test "scopesFor asks for read-only Monitoring" {
const got = Auth.scopesFor(.monitoring, &asked);
zigvet.clobberStack();
try std.testing.expectEqualStrings(monitoring_read_url, got[0]);
}Give the test module the import in build.zig, from the same dependency as
the lint step:
tests.root_module.addImport("zigvet", zigvet.module("zigvet"));Since v0.2.0. It finds the bug only in a function a test calls, and is
dependable only in a Debug build (the default for zig build test): an
optimized build may turn a read of a dead frame into anything. The escape
rule finds the shapes it knows in any function, so the two are worth
having together.
zigvet skill prints what an AI agent needs to work with zigvet: the
loop, each rule with the smallest code that trips it and the fix, how to
add it to a build, and what to do with a finding that looks wrong. It is
built into the binary, and zigvet's tests run every example in it.
zigvet skill # print it
zigvet skill --install ~/.skills # write it to ~/.skills/zigvet/SKILL.mdTo check a directory named skill, name it ./skill.
The code compiles clean in every build mode and is still wrong. If the
compiler, zig fmt or an existing linter already catches it, it is not a
zigvet rule, and nor is code that is right but larger or slower than it
could be. CONTRIBUTING.md has the rest: the tests, the
case corpus, and how a rule is measured on real code before it lands.
Apache-2.0; see LICENSE.