JSON output

--format json writes one object per lockfile, on one line, followed by a newline. A directory holding both a package-lock.json and a requirements.txt produces two lines rather than an array, so the stream is newline-delimited JSON and a consumer reads it a line at a time:

$ rm -rf /tmp/mixed && mkdir -p /tmp/mixed
$ cp fixtures/poisoned.requirements.txt /tmp/mixed/requirements.txt
$ cat > /tmp/mixed/package-lock.json <<'EOF'
{
  "name": "mixed",
  "lockfileVersion": 3,
  "packages": {
    "": { "name": "mixed", "dependencies": { "expres": "4.18.2" } },
    "node_modules/expres": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz",
      "integrity": "sha512-AA"
    }
  }
}
EOF
$ ./target/release/stranger scan --format json /tmp/mixed | jq -c '{source, packages, findings: (.findings|length)}'
{"source":"/tmp/mixed/package-lock.json","packages":1,"findings":1}
{"source":"/tmp/mixed/requirements.txt","packages":6,"findings":5}
$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt
{"source":"fixtures/poisoned.requirements.txt","ecosystem":"pypi","packages":6,"direct":6,"transitive":0,"workspace":0,"integrity":0,"risk":79,"findings":[{"rule":"slopsquat","severity":"critical","package":"python-dateutils","version":"2.9.0","detail":"not in corpus · d=1 from \"python-dateutil\" · no dependency graph in this format"},{"rule":"slopsquat","severity":"critical","package":"requests-http","version":"1.0.2","detail":"not in corpus · d=2 from \"requests-html\" · no dependency graph in this format"},{"rule":"pinning","severity":"low","package":"flask","version":"","detail":"~=3.0 · capped at the major, still floats below the cap"},{"rule":"pinning","severity":"high","package":"numpy","version":"","detail":"no bound in either direction · resolves to whatever is newest at install time"},{"rule":"pinning","severity":"medium","package":"urllib3","version":"","detail":">=1.26 · a range, so the file does not say what installs"}],"not_applicable":["install-script"]}

The blind-spot object

A scan that could not see everything leads with one extra object, and it is the one with no source key — which is how a consumer tells the two apart without guessing:

$ rm -rf /tmp/blind && mkdir -p /tmp/blind/ok /tmp/blind/locked
$ cp fixtures/npm-xs.package-lock.json /tmp/blind/ok/package-lock.json
$ touch /tmp/blind/bun.lock
$ chmod 000 /tmp/blind/locked
$ ./target/release/stranger scan --format json /tmp/blind | head -1
{"unreadable":["/tmp/blind/locked"],"unsupported":["bun.lock"]}
$ chmod 755 /tmp/blind/locked

unreadable is directories that exist and would not open. Each one removes an unknown number of lockfiles from the answer, so the scan exits 2 rather than reporting on a list it knows is short. unsupported is lockfile names with no reader behind them, which is information rather than a failure and does not change the exit code.

Prose on this stream is the one thing a consumer reading a line at a time cannot survive. A second object shape is not, which is why the blind spots are JSON here and a sentence in the human report.

not_applicable, and why it is not the same as clean

A rule that cannot fire in a format is not a rule that found nothing. poetry.lock records no install-script flag at all, so the scripts rule is mute there — and a CI gate that only sees "findings":[] would read that as a Python project with no install-time code execution, which is not a claim this tool can make about any Python project.

$ ./target/release/stranger scan --format json fixtures/poetry-m.poetry.lock | jq -c '{findings: (.findings|length), not_applicable}'
{"findings":0,"not_applicable":["install-script","pinning"]}

The human report says the same thing with a · instead of a .

The object

fieldtypewhat it is
sourcestringthe path as you gave it, not canonicalised
ecosystemstringnpm, pypi, crates.io or go. All four appear — go.mod reads; what Go has no corpus for is the detection rule
packagesnumberthird-party packages; workspace members are excluded
directnumbernamed by a manifest in this repository
transitivenumberpackages - direct
workspacenumberfirst-party entries set aside; 0 on a non-monorepo
integritynumberthird-party entries that recorded an integrity field. Presence, never correctness — std ships no crypto, so no hash is ever computed. See Limits
not_applicablearrayrules that cannot fire in this format, by id. Absent from the array is not the same as clean — see below
risknumber0–98; a band for the worst severity plus a term for volume
findingsarrayworst rule first, then alphabetical by package within a rule

workspace is the one header number that cannot be rebuilt from the others. packages, direct and transitive are all third-party counts, so without it a monorepo and a flat project of the same dependency count read identically.

A finding

fieldtypewhat it is
rulestringslopsquat, install-script, trivial, drift or pinning
severitystringlow, medium, high or critical
packagestringthe name as the lockfile spelled it
versionstringmay be empty
detailstringwhy this fired, in the rule's own terms

version is empty in two cases that are worth knowing about. A drift finding is one per name across all its versions, so there is no single version to put there and the versions are in detail instead. A pinning finding on a requirement with no == has no resolved version to record at all — that is the finding.

Findings are ordered by rules::ORDER — slopsquat, install-script, trivial, drift, pinning — and alphabetically by package inside each rule. The order is stable across runs, so a diff between two scans is a diff and not a reshuffle.

detail is prose meant for a person. Its shape is stable enough to read and not stable enough to parse — if you need the edit distance, the nearest name or the drifted version list as data, say so and they can become fields.

Everything is listed

--format json ignores the collapsing that the human report does. A rule that prints as VERSION DRIFT 76 on a terminal emits all 76 objects here, whether or not you passed -v. Same for -q: it changes nothing about the JSON.

$ ./target/release/stranger scan --format json fixtures/npm-xl.package-lock.json | jq '.findings | length'
113

Escaping and colour

The writer escapes what RFC 8259 section 7 requires — ", \, the three whitespace shorthands, and anything below U+0020 as \uXXXX — and nothing else. The · separators in detail go out as literal UTF-8, which is legal JSON and what every parser expects. Note the \" around "python-dateutil" above: that is a quoted name inside detail, correctly escaped.

JSON is never coloured, under any combination of CLICOLOR_FORCE, NO_COLOR and TTY. It goes to a program, and a program that has to strip SGR codes out of a string field will not.

No pretty-printer

There is none, and adding one would mean writing a second serialiser to check. Pipe it:

$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt | jq '.findings[0]'