Exit codes

Three of them, and the split between 1 and 2 is the point.

codemeaning
0clean, findings below the --fail-on threshold, or no lockfile found
1a finding at or above the threshold
2bad usage, or a file that could not be read or parsed

A CI gate that cannot tell a finding from a broken invocation is a CI gate somebody turns off. So a typo in a flag exits 2, a missing file exits 2, a lockfile in a format the reader refuses exits 2, and only an actual finding exits 1.

Without --fail-on

Nothing ever exits 1. The scan reports and returns 0.

$ ./target/release/stranger scan fixtures/poisoned.package-lock.json > /dev/null
$ echo $?
0

That is deliberate. stranger scan on its own is a thing you read; making it fail by default would mean everyone learns to write || true.

With --fail-on

The threshold is compared against the worst severity seen. Levels order low < medium < high < critical, and the five rules occupy four different levels, so the choice matters.

ruleseverity
slopsquatcritical
install-scripthigh
pinninghigh, medium or low, depending on the specifier
driftmedium
triviallow

npm-xl has install scripts, drift and trivial findings but no hallucinated names, which makes it a clean demonstration of the threshold actually doing something:

$ ./target/release/stranger scan --fail-on critical fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
0

$ ./target/release/stranger scan --fail-on high fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
1

npm-xs has nothing but four trivial findings, so it separates low from medium:

$ ./target/release/stranger scan --fail-on low fixtures/npm-xs.package-lock.json > /dev/null
$ echo $?
1

$ ./target/release/stranger scan --fail-on medium fixtures/npm-xs.package-lock.json > /dev/null
$ echo $?
0

Exit 2

$ ./target/release/stranger scan fixtures/nope.json
stranger: fixtures/nope.json: no such file or directory
$ echo $?
2

$ ./target/release/stranger scan --format yaml fixtures/poisoned.package-lock.json
stranger: --format takes `human` or `json`, not `yaml`
$ echo $?
2

$ mkdir -p /tmp/renametest
$ printf 'flask==3.0.0\n' > /tmp/renametest/requirements-dev.txt
$ ./target/release/stranger scan /tmp/renametest/requirements-dev.txt
stranger: requirements-dev.txt: not a lockfile stranger knows. It reads: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock
$ echo $?
2

Errors go to stderr, findings to stdout. Because the reader parses a lockfile before anything is written, a --format json run that exits 2 has printed nothing to stdout — a downstream parser gets an empty stream and a non-zero status rather than half an object.

A closed pipe is not an error

$ ./target/release/stranger scan fixtures/poisoned.package-lock.json | head -3

  poisoned.package-lock.json 757 packages   (35 direct · 722 transitive)

$ echo $?
0

head closes the pipe as soon as it has what it wants, and every write after that fails with EPIPE. That is the shell working correctly, so it exits 0 and says nothing. The alternative is an error message on every piped invocation.

A directory it could not open is an error

$ rm -rf /tmp/shut && mkdir -p /tmp/shut/proj
$ cp fixtures/poisoned.package-lock.json /tmp/shut/proj/
$ chmod 000 /tmp/shut/proj
$ ./target/release/stranger scan /tmp/shut --fail-on critical

  could not look inside 1 path — this scan is incomplete
     /tmp/shut/proj

$ echo $?
2
$ chmod 755 /tmp/shut/proj

Exit 2, and it outranks the findings — a directory that will not open is not a bump, it is stranger being unable to do its job. --fail-on asks "is there a finding at or above this level", and over a list of lockfiles that is short by an unknown number the honest answer is neither 0 nor 1.

The cost is real and it is worth naming. One 0700 directory anywhere under the scan root turns the gate red, whether or not it had a lockfile in it. That is the trade: the alternative is the bug this replaced, where chmod 000 over a directory holding a poisoned lockfile printed no lockfile and exited 0. A green tick over a directory nobody could open is worse than a red one over a directory that turned out to be empty, because only one of those two failures is silent.

A directory that is absent is not a blind spot and is not counted — a path that is not there hides nothing.

No lockfile is not an error

$ rm -rf /tmp/empty && mkdir -p /tmp/empty
$ ./target/release/stranger scan /tmp/empty

  no lockfile stranger reads in /tmp/empty
  looked for: package-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod, yarn.lock

$ echo $?
0

Exit 0. Running the tool across a repository of mixed languages should not turn red on the directories it has nothing to say about. See A project whose toolchain you do not have.

$ ./target/release/stranger scan --fail-on high fixtures/npm-xl.package-lock.json; echo $?