stranger

A model suggests a package. You paste the name into package.json. The name is not real — nobody has ever published it — and the first person to notice registers it and ships whatever they like to everyone who runs npm install.

stranger reads a lockfile and tells you which names in it look invented, which of them run code when you install them, and which are installed at four different versions at once. It does not install anything, does not resolve anything, and never opens a socket.

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

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

  ⚠  HALLUCINATION RISK     3
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                35    (4.6% of third-party)

  ⚠  VERSION DRIFT          55    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 81/100    21ms    third-party deps used to compute this: 0

Three planted names, three findings, no false positives. The critical rule lists every hit; the other three collapse to a count until you ask for more with -v.

The empty manifest

The last field of that output is checkable. Cargo.toml has three dependency tables and all three are empty, so the lockfile of a lockfile auditor is one package long:

$ cargo tree
stranger v0.1.0 (/home/keir/stranger)

$ grep -c '^\[\[package\]\]' Cargo.lock
1

That is the whole dependency graph. No serde_json, no clap, no strsim, no toml, no owo-colors. The JSON reader, the TOML reader, the argument parser, the edit distance, the semver comparator, the terminal handling and the report writer are all in src/, and the test harness is the one Rust ships with, so there is no dev-dependency escape hatch in use either. STDLIB.md names each crate that was replaced and what was given up.

#![forbid(unsafe_code)] sits at the top of both src/lib.rs and src/main.rs, and CI fails the build if cargo tree ever prints a second line, if unsafe appears in src/, or if anything reaches for Command::new.

Rust's standard library has no TLS and no HTTP client. A binary with no dependencies therefore cannot make a network request — the guarantee comes from the empty manifest, not from anyone's restraint.

Reading files other tools produced

The hackathon rules forbid shelling out to installed tooling. Their FAQ rules this design in explicitly:

Parsing files those tools already produced is fine, because nothing third-party ends up in your artifact.

Two conditions attach. It has to be disclosed, and it has to degrade when the file is not there. Both hold. The corpus of known-real names and the fixture lockfiles are written up in corpus/PROVENANCE.md, fixtures/README.md and STDLIB.md, and pointing stranger at a directory with no lockfile prints what it looked for and exits 0:

$ 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

What it reads today

Seven formats across four ecosystems: package-lock.json (v2, v3), pnpm-lock.yaml (v9), Cargo.lock (v3, v4), poetry.lock, uv.lock, requirements.txt and go.mod. Five rules, one of which has an idea in it — the co-occurrence rule, which separates a hallucinated name from a legitimate sibling using something other than spelling. The rest of this book is what those things do, what they measure, and where they are wrong.

Start here:

$ make && ./target/release/stranger scan fixtures/poisoned.package-lock.json

Installing

There is nothing to install. Clone, type make, and a binary appears.

$ git clone https://github.com/keirsalterego/stranger
$ cd stranger
$ make
cargo build --release --locked
   Compiling stranger v0.1.0 (<your clone>)
    Finished `release` profile [optimized] target(s) in 2.18s

Under three seconds cold, because there is nothing to compile except this crate.

The --locked matters even with an empty manifest. It makes Cargo refuse to write Cargo.lock, so a build that somehow needed a dependency fails instead of quietly acquiring one.

The compiler is pinned in rust-toolchain.toml:

[toolchain]
channel = "1.98.0"
components = ["clippy", "rustfmt"]

Not stable. 1.98 is where str::substr_range and NumBuffer::format_into landed, and both are load-bearing — the first gives the JSON and TOML parsers their error positions, the second does digit grouping in the report without allocating. A floating stable would also make the build non-reproducible for no gain. See Reproducible builds.

Nothing is fetched. There is no cargo fetch step because there is nothing to fetch, which means the build works with the network cable out:

$ CARGO_NET_OFFLINE=true cargo build --release --locked --offline
    Finished `release` profile [optimized] target(s) in 0.01s

What you get

$ ls -l target/release/stranger
-rwxrwxr-x 2 keir keir 4064792 Aug 30 01:46 target/release/stranger

4,064,792 bytes, of which 2,960,053 is the corpus of known-real package names — 140,066 for npm, 15,000 for PyPI, 5,000 for crates.io — compiled in with include_str!. Nearly three quarters of the binary is corpus. That is the reason the tool works on a plane: no cache directory, no first-run download, no "corpus not found" failure mode.

Copy the binary wherever you like. It reads the file you point it at and nothing else; there is no config file and no state directory.

$ make && ./target/release/stranger --help

Your first scan

fixtures/poisoned.package-lock.json is a real 754-package lockfile with three fake names added by hand. Scan it:

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

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

  ⚠  HALLUCINATION RISK     3
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                35    (4.6% of third-party)

  ⚠  VERSION DRIFT          55    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 81/100    71ms    third-party deps used to compute this: 0

The header

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

The lead number counts third-party packages only. Direct is the count named by a manifest in this repository — the root package.json, and any workspace member's. Transitive is everything reached only through another package. A workspace member is neither, so it gets a third field of its own when there is one:

$ ./target/release/stranger scan fixtures/npm-xl.package-lock.json

  npm-xl.package-lock.json 1,376 packages   (150 direct · 1,226 transitive · 14 workspace)

  ⚠  INSTALL SCRIPTS        8     arbitrary code at install time

  ⚠  TRIVIAL                29    (2.1% of third-party)

  ⚠  VERSION DRIFT          76    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 62/100    382ms    third-party deps used to compute this: 0

The file holds 1,390 entries. 14 of them are your own code, so the tree you got from strangers is 1,376. Monorepos covers why that split is the same one the detection rule runs on.

Collapsed rules

Critical findings are always listed. Everything else reports a count and what the count means, because a 1,390-package tree produces 76 drift findings and 29 trivial ones, and printing all of them buries the three that matter under a hundred lines nobody scrolls back through.

-v prints the lot:

$ ./target/release/stranger scan -v fixtures/npm-xs.package-lock.json

  npm-xs.package-lock.json 37 packages   (1 direct · 36 transitive)

  ⚠  TRIVIAL                4     (10.8% of third-party)
     es-errors@1.3.0          one expression, one publisher · inlining it removes an account from your build
     gopd@1.2.0               one expression, one publisher · inlining it removes an account from your build
     has-symbols@1.1.0        predicate-shaped, resolves nothing · size not measured, see rule docs
     hasown@2.0.4             one expression, one publisher · inlining it removes an account from your build

  ·  UNPINNED               — no signal in this format

  risk 9/100    6ms    third-party deps used to compute this: 0

-q drops the header and the risk line and prints findings only, which is the form to pipe into something:

$ ./target/release/stranger scan -q fixtures/poisoned.package-lock.json

  ⚠  HALLUCINATION RISK     3
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                35    (4.6% of third-party)

  ⚠  VERSION DRIFT          55    same package at 2+ versions in one tree

A finding

     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

The three fields after the version are the three clauses of the rule, in the terms the rule actually used, so you can disagree with it:

  • not in corpuslodahs is not one of the 140,066 npm names compiled into the binary.
  • d=1 from "lodash" — its Damerau-Levenshtein distance to lodash is 1. One transposition. Under plain Levenshtein it would be 2, which is why the distance function is the one it is.
  • root-only, no parent — nothing else in these 757 packages depends on it.

All three have to hold. The co-occurrence rule is why, and stranger tree lodahs prints the in-edges the third one is about, so you do not have to take that line's word for it.

Every rule writes its own detail in its own terms. The other four are install scripts, version drift, trivial packages and unpinned requirements.

Colour

Findings are coloured by the worst severity in their block: red for critical, orange for high, yellow for medium, dim for low. Sixteen-colour SGR only, so it renders the way your theme intends rather than the way the author's monitor looked.

Four inputs decide whether escapes are emitted, highest priority first:

  1. --no-color — you said so, out loud, this run.
  2. NO_COLOR — off regardless of TTY.
  3. CLICOLOR_FORCE — on regardless of TTY.
  4. stdout is a TTY.

Off beats on at every tie, so a stray CLICOLOR_FORCE in a CI image cannot spray escapes into a log that asked for none. Both variables count only when present and non-empty, so NO_COLOR= means nothing was said rather than "off".

A pipe is not a TTY, so piped output is plain bytes you can grep:

$ ./target/release/stranger scan fixtures/poisoned.package-lock.json | cat -v | head -4

  poisoned.package-lock.json 757 packages   (35 direct M-BM-7 722 transitive)

  M-bM-^ZM-   HALLUCINATION RISK     3

The M-BM-7 is the UTF-8 middle dot, not an escape code. --format json never carries colour under any setting: a program that has to strip SGR codes out of a string field will not.

  risk 81/100    56ms    third-party deps used to compute this: 0

The risk number is a band for the worst severity present, plus position inside the band for how many findings share it:

worst findingband
critical75–98
high50–73
medium25–48
low1–24
nothing0

The band is the same question --fail-on asks, on purpose — the headline number and the gate should not disagree about what is serious. Volume saturates inside the band and never fills it, so a worse severity always outranks more of a lesser one, and there is always a worse tree than the one in front of you.

It is not calibrated against anything, because there is nothing honest to calibrate it against. Two projects are comparable at the band; two scans of the same project are comparable outright. The findings are the output; the score is a handle.

This was a sum of severity weights capped at 100 until the cap turned out to be doing all the work — nine of the sixteen fixtures scored exactly 100, including both poisoned.package-lock.json and the clean npm-l it was built from.

The milliseconds are wall time for that run, measured by the tool. It moves around. make bench runs the largest fixture fifty times:

Benchmark 1: target/release/stranger scan fixtures/npm-xl.package-lock.json
  Time (mean ± σ):     413.0 ms ±  82.5 ms    [User: 404.8 ms, System: 3.4 ms]
  Range (min … max):   371.2 ms … 660.2 ms    50 runs

Most of that is the nearest-neighbour scan for names that miss the corpus.

$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json

Looking at one package

stranger scan says a package has no parent. stranger tree shows you.

$ ./target/release/stranger tree <pkg> [path]

Same walk, same readers, same lockfiles — it just prints the graph around one name instead of the findings across a file. path defaults to . and can be a single lockfile or a directory to walk.

The one it was built for

The third clause of the co-occurrence rule is nothing in the lockfile depends on it. Clauses 1 and 2 you can check by hand: a name is in corpus/npm.txt or it is not, and an edit distance is arithmetic. Clause 3 is a claim about a graph, and until you can see the graph the only thing to do with it is believe the report.

$ ./target/release/stranger tree lodahs fixtures/poisoned.package-lock.json

  fixtures/poisoned.package-lock.json   npm · 757 packages

  lodahs@4.17.21   node_modules/lodahs

     depended on by   in-degree 0 · root-only, no parent
                      nothing in this lockfile depends on it. The only
                      reference to the name in the file is the manifest under
                      audit. That is clause 3 of the co-occurrence rule: a
                      hallucinated package is a root dependency, because
                      nothing real has ever heard of it.

     depends on       nothing

root-only, no parent is the same phrase the finding prints, on purpose. The report, this page and src/rules/slopsquat.rs should not have three ways of saying one thing.

A real package gives the other answer, out of the same reader and a tree the same size:

$ ./target/release/stranger tree accepts fixtures/npm-l.package-lock.json

  fixtures/npm-l.package-lock.json   npm · 754 packages

  accepts@2.0.0   node_modules/accepts
     dev-only

     depended on by   in-degree 1
                      express@5.2.1

     depends on       2 direct, to depth 3
     ├─ mime-types@3.0.2
     │  └─ mime-db@1.54.0
     └─ negotiator@1.0.0

The out-edge tree

--depth decides how far down it goes, three by default. The cut is announced on the line it happens at, because a tree that stops without saying so reads as a package that depends on less than it does:

$ ./target/release/stranger tree qs fixtures/npm-l.package-lock.json --depth 1

  fixtures/npm-l.package-lock.json   npm · 754 packages

  qs@6.15.3   node_modules/qs
     dev-only

     depended on by   in-degree 2
                      body-parser@2.3.0
                      express@5.2.1

     depends on       2 direct, to depth 1
     ├─ es-define-property@1.0.1
     └─ side-channel@1.1.1 · 5 more below, past --depth 1

--depth 0 removes the limit. That is safe to type because the walk expands each package once and not once per path: a lockfile is a directed graph with heavy sharing, and printing every path through one is exponential in the depth. A name whose dependencies were printed earlier comes back as a leaf marked (*), with a legend under the tree.

Real lockfiles also contain cycles — npm records peer dependencies in both directions often enough that a → b → a is ordinary — and a cycle is marked where it closes rather than followed:

$ ./target/release/stranger tree eslint fixtures/npm-xl.package-lock.json --depth 0 | grep -B 1 -A 1 cycle
     ├─ @eslint-community/eslint-utils@4.9.1
     │  ├─ eslint@9.39.4 · cycle, back to a name already above it
     │  └─ eslint-visitor-keys@3.4.3

One name, several versions

Version drift is one of the four things this tool reports, so the one thing tree must not do is pick a version. npm spells a duplicated package as a second entry under a nested install path, and every entry gets its own block:

$ ./target/release/stranger tree ansi-regex fixtures/npm-l.package-lock.json

  fixtures/npm-l.package-lock.json   npm · 754 packages

  ansi-regex@5.0.1   node_modules/ansi-regex
     dev-only

     depended on by   in-degree 1
                      strip-ansi@6.0.1

     depends on       nothing

  ansi-regex@6.2.2   node_modules/ora/node_modules/ansi-regex
     dev-only

     depended on by   in-degree 1
                      strip-ansi@7.2.0

     depends on       nothing

  ansi-regex@6.2.2   node_modules/string-width/node_modules/ansi-regex
     dev-only

     depended on by   in-degree 1
                      strip-ansi@7.2.0

     depends on       nothing

The install path is printed next to each version because it is what makes a second copy a second copy. It also shows up in the in-edge list when two parents would otherwise be indistinguishable — five packages depending on ms can be three separate entries called debug@3.2.7, each nested somewhere different, and a list that repeated the same label three times with no explanation reads as a rendering fault.

Flat formats have no graph

requirements.txt records a list, not a graph. There are no edges in the file to read, so in-degree 0 there is the format declining to say rather than a number anyone measured — which is the exact confusion clause 3 exists to avoid. It says so instead of printing a zero:

$ ./target/release/stranger tree tensorflow-gpu fixtures/reqs-xs.requirements.txt

  fixtures/reqs-xs.requirements.txt   pypi · 12 packages

  tensorflow-gpu

     flat format      no graph in this file
                      requirements.txt records no dependency edges at all, so
                      there is no in-degree here to read and no out-edges to
                      walk. Every package in it trivially has in-degree 0,
                      which is why clause 3 is vacuous on a flat file and the
                      rule falls back to two clauses. Point this at a
                      poetry.lock or a uv.lock and there is a graph to look at.

tensorflow-gpu is the false positive Limits uses to make the same point: a real, deprecated PyPI package that clauses 1 and 2 both fire on, with no clause 3 available to save it. The fix is a different file, and both of them are already readable:

$ ./target/release/stranger tree requests fixtures/poetry-s.poetry.lock

  fixtures/poetry-s.poetry.lock   pypi · 54 packages

  requests@2.32.5

     depended on by   in-degree 3
                      cachecontrol@0.14.3
                      poetry@1.8.5
                      requests-toolbelt@1.0.0

     depends on       4 direct, to depth 3
     ├─ certifi@2025.8.3
     ├─ charset-normalizer@3.4.3
     ├─ idna@3.10
     └─ urllib3@2.6.3

When it is not there

A name that is not in the tree is an answer, not a failure. Exit 0, and a list of what is close, measured with the same Damerau-Levenshtein function and the same threshold the rule uses. One name in five files is five lines, because the question tree answers is "where is it", and the real lodash is in all five while the planted lodahs is in two:

$ ./target/release/stranger tree lodashh fixtures/

  no package named `lodashh` in the 23 lockfiles under fixtures/

  close names that are there:
     lodash                   d=1 · fixtures/npm-s.package-lock.json
     lodash                   d=1 · fixtures/npm-xl.package-lock.json
     lodash                   d=1 · fixtures/pnpm-l.pnpm-lock.yaml
     lodash                   d=1 · fixtures/yarn-l.yarn.lock
     lodash                   d=1 · fixtures/yarn-m.yarn.lock
     lodahs                   d=2 · fixtures/hostile.package-lock.json
     lodahs                   d=2 · fixtures/poisoned.package-lock.json

$ echo $?
0

JSON

One object on one line, and it is one object rather than the newline-delimited stream scan emits: scan answers a question per file, tree answers one question about one name, and "it is in none of these files" is not something a stream of per-file objects can say.

$ ./target/release/stranger tree lodahs fixtures/poisoned.package-lock.json --format json
{"query":"lodahs","found":true,"lockfiles":1,"depth":3,"occurrences":[{"source":"fixtures/poisoned.package-lock.json","ecosystem":"npm","name":"lodahs","version":"4.17.21","key":"node_modules/lodahs","first_party":false,"direct":true,"records_edges":true,"in_degree":0,"parents":[],"dependencies":[]}],"near":[]}
fieldtypewhat it is
querystringthe name as you typed it
foundboolwhether occurrences is non-empty
lockfilesnumberhow many files were read to answer this
depthnumberthe --depth in force; 0 means no limit
occurrencesarrayone per entry that matched, in path then version order
neararraypopulated only when found is false

An occurrence:

fieldtypewhat it is
sourcestringthe path as you gave it, not canonicalised
ecosystemstringnpm, pypi or crates.io
name, version, keystringas the lockfile spelled them
first_partyboola workspace member — your own code
directboolnamed by a manifest in this repository
records_edgesboolfalse on requirements.txt, true everywhere else
in_degreenumber or nullnull when records_edges is false
parentsarray{name, version}, one per package with an edge in
dependenciesarraythe out-edge tree, nested

in_degree is null and not 0 on a flat format for the same reason the human output refuses to print a number there. Nobody measured 0.

A dependency node is {name, version, dependencies}, plus a stop when the walk stopped at it — "cycle", "repeat" or "depth", and depth carries hidden, the count of direct dependencies not shown:

$ ./target/release/stranger tree qs fixtures/npm-l.package-lock.json --depth 1 --format json | jq -c '.occurrences[0].dependencies'
[{"name":"es-define-property","version":"1.0.1","dependencies":[]},{"name":"side-channel","version":"1.1.1","stop":"depth","hidden":5,"dependencies":[]}]

Two runs over one tree produce the same bytes, human or JSON. There is no timing in this output at all, so unlike a scan there is nothing to strip before diffing two of them.

Flags

flageffect
--depth <n>levels of out-edges; default 3, 0 for no limit
--format <human|json>as scan
--no-coloras scan; also NO_COLOR and CLICOLOR_FORCE
-q, --quietdrop the file header and the explanatory prose, keep the numbers

--fail-on and -v are scan flags and tree says so rather than reporting an unknown option — tree produces no findings, so there is nothing to gate on and nothing collapsed to expand.

$ ./target/release/stranger tree express --fail-on high
stranger: `--fail-on` is a scan flag; tree reports no findings to gate on
$ echo $?
2

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]'

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 $?

Comparing two lockfiles

scan answers is this tree bad. A reviewer looking at a pull request has a narrower question, and it is not the same one: did this change make it worse.

$ ./target/release/stranger diff fixtures/npm-l.package-lock.json fixtures/poisoned.package-lock.json

  fixtures/npm-l.package-lock.json -> fixtures/poisoned.package-lock.json

  added       3
     chalck@5.3.0
     expres@4.18.2
     lodahs@4.17.21

  introduced  4 findings
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent
     lodahs@4.17.21           runs code at install time · lockfile records the flag, not the script

poisoned.package-lock.json is npm-l with three names planted in it. A scan of it also reports 55 version-drift findings and 35 trivial packages, every one of which was already in npm-l. The diff is the three that arrived.

Why the gate is not just two scans

--fail-on means something different here, deliberately. On scan it is the worst finding in the tree; on diff it is the worst finding the change introduced.

$ ./target/release/stranger diff fixtures/npm-l.package-lock.json fixtures/poisoned.package-lock.json --fail-on high -q
$ echo $?
1

Reverse the arguments and the same two files pass, because taking a problem out is not putting one in:

$ ./target/release/stranger diff fixtures/poisoned.package-lock.json fixtures/npm-l.package-lock.json --fail-on high -q
$ echo $?
0

While scan fails on that tree in both directions:

$ ./target/release/stranger scan fixtures/poisoned.package-lock.json --fail-on high -q
$ echo $?
1

That asymmetry is the reason the subcommand exists. A repository with 211 trivial packages fails a scan gate on every pull request until somebody turns the gate off; it passes a diff gate until a pull request adds something.

Findings are matched by rule and package, not by version

A bumped dependency keeps its findings. esbuild@0.20.0 with an install script becoming esbuild@0.21.0 with an install script is not one finding fixed and one introduced — it is a version change, which the changed block prints, and no change in risk.

The cost is real: a package already flagged for a rule can change version and the new version's finding is not called new.

A finding can move without a package moving

The two lockfiles do not have to be the same format — only the same ecosystem — and the formats do not all record the same things. npm records install scripts; yarn v1 has no field for them. So migrating a project between the two changes the finding set while added, removed and changed are all empty, and that is a real result rather than a glitch: the packages did not move, but what is now visible about them did.

diff prints those findings and gates on them. The alternative — deciding there is nothing to say because no package moved — is how it printed no change to the dependency tree and exited 1 at the same time, which in CI is a red build with nothing on screen to explain it.

In CI

$ git show HEAD~1:package-lock.json > /tmp/before.package-lock.json
$ stranger diff /tmp/before.package-lock.json package-lock.json --fail-on high

Exit 1 means this change introduced something at or above the threshold. See exit codes, and gate a pull request for the whole-tree version of the same job.

JSON

One object, not a stream — a diff is one comparison however many packages it touched. No timing field, for the same reason the scan object has none: two runs over the same pair have to produce the same bytes.

$ ./target/release/stranger diff --format json fixtures/npm-l.package-lock.json fixtures/poisoned.package-lock.json | head -c 120
{"old":"fixtures/npm-l.package-lock.json","new":"fixtures/poisoned.package-lock.json","added":["chalck@5.3.0","expres@4.

What it refuses

Two different ecosystems. A Cargo.lock against a package-lock.json parses fine and produces a diff in which every package was added and every package was removed — a confident, detailed, meaningless answer.

$ ./target/release/stranger diff fixtures/cargo-s.Cargo.lock fixtures/npm-xs.package-lock.json
stranger: fixtures/cargo-s.Cargo.lock and fixtures/npm-xs.package-lock.json are different ecosystems (crates.io and npm); there is nothing to compare

Two formats of one ecosystem are fine, and that diff is the one somebody migrating from npm to pnpm wants.

The co-occurrence rule

Edit distance on its own is not a rule.

http-proxy-agent and https-proxy-agent are both real npm packages, both depended on by other packages in npm-xl, and one edit apart. So are safe-buffer and safer-buffer. Take just the 1,077 distinct names the npm fixtures install: between them they have 9,453 neighbours within distance 2 in a 140,066-name corpus, every one a package that exists. Any threshold loose enough to catch a typo is loose enough to catch a legitimate sibling. Precision collapses and the tool becomes noise.

The clause that separates them is not about spelling at all:

A hallucinated package is a root dependency. Nothing depends on it, because nothing real has ever heard of it. A model put it in your manifest; no maintainer ever put it in theirs.

https-proxy-agent is depended on by other packages. lodahs is not, and that is the part that matters — the only reference to it in the tree under audit is the manifest under audit.

Not "because it does not exist", which is how this page put it for most of the weekend. A one-off registry check on 2026-08-30 says lodahs resolves on npm as 0.0.1-security: a holding package published by npm's security team for a name they took down. expres resolves too, as a genuine 2012 package at 0.0.5. Both of the versions the fixture pins — lodahs@4.17.21, expres@4.18.2 — are fabricated, mirroring the real lodash and express releases, and neither has ever been published. chalck does not resolve at all.

The rule is unaffected, and the check makes its premise sharper rather than softer. Clause 3 never asked whether a name is registered. It asks whether anything real depends on it, and for a squat that npm has already parked under a security hold the answer is emphatically no. See fixtures/README.md for the full check.

Three clauses

A name is reported when all three hold.

  1. It is not in the corpus of names known to exist.
  2. It is within edit distance 2 of a name that is.
  3. Nothing in the lockfile depends on it. Its in-degree is zero.

Clause 1 is a binary search over a sorted list compiled into the binary. Clause 2 is Damerau-Levenshtein, the unrestricted Lowrance-Wagner variant rather than the optimal-string-alignment version most libraries ship under that name, with the threshold at 2. Clause 3 is a lookup in a vector of in-degrees built from the lockfile's own edges.

They run in the order 1, 3, 2. Clause 1 first because it is a binary search and it eliminates all but a couple of dozen names. Clause 3 before clause 2 because it is one array index and clause 2 is a linear scan of 140,066 names — on the npm-xl fixture that ordering is most of the difference between 234 ms and something considerably worse.

Packages marked first-party are skipped before any of it. Somebody in this repository wrote them; they are not strangers.

Every hit is critical, and it is the only rule that is. It is also the only one whose findings are always listed rather than collapsed to a count.

Clause 3, demonstrated

Two lockfiles, the same fake package, one edge of difference. Write the first:

$ mkdir -p /tmp/a && cat > /tmp/a/package-lock.json <<'EOF'
{
  "name": "demo",
  "lockfileVersion": 3,
  "packages": {
    "": { "dependencies": { "expres": "^4.18.2" } },
    "node_modules/expres": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
    }
  }
}
EOF
$ ./target/release/stranger scan /tmp/a

  package-lock.json        1 packages   (1 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     1
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent

  ·  UNPINNED               — no signal in this format

  risk 77/100    34ms    third-party deps used to compute this: 0

Now the same name, reached through a real package that claims to depend on it:

$ mkdir -p /tmp/b && cat > /tmp/b/package-lock.json <<'EOF'
{
  "name": "demo",
  "lockfileVersion": 3,
  "packages": {
    "": { "dependencies": { "body-parser": "^1.20.2" } },
    "node_modules/body-parser": {
      "version": "1.20.2",
      "dependencies": { "expres": "^4.18.2" }
    },
    "node_modules/expres": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
    }
  }
}
EOF
$ ./target/release/stranger scan /tmp/b

  package-lock.json        2 packages   (1 direct · 1 transitive)

  no findings
  ·  UNPINNED               — no signal in this format

  risk 0/100    6ms    third-party deps used to compute this: 0

Clauses 1 and 2 are identical in both files. expres is absent from the corpus either way and one deletion from express either way. The only thing that changed is that a maintainer other than you wrote the name down, and the rule goes quiet.

That is the conservative direction on purpose. An extra in-edge suppresses a finding; it can never invent one. The same reasoning is why peerDependencies counts as an edge alongside dependencies, devDependencies and optionalDependencies — a peer dep is still a real maintainer writing down a real name, and counting it can only make the rule quieter.

Which edges count

Not all of them. An edge is evidence only if a stranger drew it.

Edges out of the root manifest do not count. That manifest is the thing under audit; "the file an LLM helped write lists this package" is not evidence that the package exists. Those go into a separate roots list, not into edges.

Neither do edges out of a workspace member. Same repository, same author, same absence of evidence. That refinement is doing real work: both monorepo fixtures here declare workspaces and keep almost nothing in the root manifest, so a reader that only looked at the root entry would report zero direct dependencies for a 576-package project. A hallucinated name added to apps/desktop/package.json would arrive with an in-edge and never be examined.

$ mkdir -p /tmp/c && cat > /tmp/c/package-lock.json <<'EOF'
{
  "name": "monorepo",
  "lockfileVersion": 3,
  "packages": {
    "": { "workspaces": ["apps/*"] },
    "apps/desktop": { "dependencies": { "expres": "^4.18.2" } },
    "node_modules/desktop": { "resolved": "apps/desktop", "link": true },
    "node_modules/expres": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
    }
  }
}
EOF
$ ./target/release/stranger scan /tmp/c

  package-lock.json        1 packages   (1 direct · 0 transitive · 2 workspace)

  ⚠  HALLUCINATION RISK     1
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent

  ·  UNPINNED               — no signal in this format

  risk 77/100    20ms    third-party deps used to compute this: 0

The workspace member declared it and the rule still fires. Compare with /tmp/b, where a third-party package declared it and the rule did not.

Why distance 2

At 2, lodash is within range of 6 corpus names. At 3 it is within range of 49 — lodash-es, lodash.eq, lodash.gt, lodash.lt, ldap, slash, soda and forty-two more, every one of them a real package — and precision on the fixtures falls off a cliff. Two still catches every single-character slip — deletion, insertion, substitution, transposition — which is the entire population of typos a model actually produces.

Transposition is why the distance function is Damerau and not plain Levenshtein. lodahs is one transposition from lodash: Damerau distance 1, Levenshtein distance 2.

This paragraph used to end "under Levenshtein it would sit in the same bucket as a large population of legitimate siblings, and the threshold that catches it catches half the registry with it". That is backwards, and the inequality says so: Levenshtein permits a strict subset of Damerau's edits, so Levenshtein-at-k is always the tighter filter, never the looser one. At the threshold that ships, plain Levenshtein returns one candidate for lodahs and Damerau returns three.

What Damerau buys is the reported distance, the tie-break below, and the threshold the rule could drop to — at k = 1 Damerau finds lodash and Levenshtein finds nothing. What it does not buy is a different set of findings at k = 2, where the two metrics agree on every name in every fixture. The full argument is in DECISIONS.md.

When several corpus names are within range, ties go to the shorter one. asn1s is one edit from both asn1 and asn1js, and the finding names asn1. That is a display choice and not a detection one: the finding fires on either neighbour, and which of them gets printed changes nothing about whether it fires.

Distance 2 is not a small net

requests-http was planted in fixtures/poisoned.requirements.txt as a name the rule was not expected to catch — a hallucinated name that is not a near-miss of anything real. It fires anyway:

$ ./target/release/stranger scan fixtures/poisoned.requirements.txt

  poisoned.requirements.txt 6 packages   (6 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     2
     python-dateutils@2.9.0   not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
     requests-http@1.0.2      not in corpus · d=2 from "requests-html" · no dependency graph in this format

  ⚠  UNPINNED               3     no exact version recorded

  ·  INSTALL SCRIPTS        — no signal in this format

  risk 79/100    6ms    third-party deps used to compute this: 0

requests-html is a real PyPI package two edits away. The finding is a true positive — requests-http does not exist — but the prediction about it was wrong, and it was wrong because nobody checked whether a real neighbour happened to exist. Two edits reaches further than intuition suggests.

Where clause 3 is vacuous

On requirements.txt there are no dependency edges, so every package trivially has in-degree 0, clause 3 eliminates nothing, and the rule degenerates to the two clauses the ablation was written precisely because nobody trusted on their own. pip covers the consequence and False positives shows it costing a real one. stranger tree refuses to print an in-degree on one of those files at all — Looking at one package.

Is clause 3 worth anything

Against the full corpus, measurably nothing. Against a corpus missing 10% of its names, it cuts false positives from 36 to 1 at no cost in recall. That is a number, not an assertion, and it is on the next page.

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

The ablation table

The third clause is the claim, so it gets measured rather than asserted.

Ground truth is the fixture set. poisoned.package-lock.json contains exactly three planted names — expres, lodahs, chalck — and the other five npm fixtures contain none, so any finding outside the planted set is a false positive by construction. 3,925 packages across six files.

$ make ablation

Against the full corpus, the clause is worth nothing

in-degree clauseTPFPFNprecisionrecall
on (shipped)3001.0001.000
off (ablated)3001.0001.000

Identical. Both configurations find all three planted names and flag nothing else across 3,925 packages.

That is a real result and it belongs at the top of this page rather than buried at the bottom. It also measures the wrong thing. The 140,066-name corpus contains every package in every fixture, so clause 1 alone is sufficient and no other clause can possibly show a difference. Perfect scores here say the corpus is good, not that the rule is.

No corpus is ever complete

npm accepts thousands of new names a day. This corpus is a snapshot taken on one afternoon — 2026-08-28, one curl run, written up in corpus/PROVENANCE.md. A package published the day after that snapshot is, to clause 1, indistinguishable from a package that does not exist.

So the question worth measuring is what happens as clause 1 degrades. Delete a fraction of the corpus and watch which clause is still holding the rule up. The thinning is a seeded xorshift, so the table is reproducible rather than different every run.

corpus keptin-degree clauseTPFPprecisionrecall
100% (140066)on301.0001.000
100% (140066)off301.0001.000
90% (126004)on310.7501.000
90% (126004)off3360.0771.000
70% (98197)on260.2500.667
70% (98197)off21270.0160.667
50% (69897)on180.1110.333
50% (69897)off11750.0060.333
25% (35134)on150.1670.333
25% (35134)off11770.0060.333

Read the 90% row first, because it is the realistic one. Ten percent of the corpus missing is roughly what a few months of registry growth looks like. The clause takes false positives from 36 down to 1 — a 36-fold cut — and recall stays at 1.000. Nothing was traded for it.

At 70% the ratio is 127 to 6, about 21-fold. At 25% it is 177 to 5, about 35-fold. The clause never makes precision worse at any level, and it never costs a true positive relative to running without it; the ablation test asserts both of those rather than leaving them to the reader's eye.

These numbers moved when the length budget landed, and one of them moved the wrong way

Every false-positive count on this table is roughly a third of what it was before distance::CHARS_PER_EDIT, because most of what a thinned corpus used to hand clause 3 were short names sitting near something by arithmetic. Those never reach clause 3 now. Precision improves at every level.

Recall got worse, and it is not a rounding artefact. At 70% the table used to read 3 true positives and now reads 2; at 50% and 25% it used to read 2 and now reads 1. That is the length budget refusing an edit the old threshold granted.

The honest reading is that the finding it lost was luck rather than detection. By 70% the thinning has deleted express itself, so expres no longer has its real parent in the corpus and what it was matching was espree — a name it has nothing to do with, as the section below works through. The budget declines to spend an edit reaching a stranger. But it is a recall change, it is a cost, and a page that only published the precision half of it would be doing the thing this repository keeps saying it will not do.

The verdict outlives the explanation

Something more interesting than the headline number falls out of the decay run, and it took reproducing the thinning by hand to see.

The thinning is deterministic — seed 0x5EED1234, the same xorshift, over corpus/npm.txt in order — so you can ask exactly which names it deleted. At 90% all three parents survive. At 70% express is gone. At 50% and 25% lodash is gone too, and only chalk is left.

Now look at what the tool reports at 70%. express has been deleted, and expres is still flagged — matching espree at distance 2. At 50% and 25% it matches rxpress. The finding is still correct, and its stated reason is not.

70%   expres -> espree   (d=2)
50%   expres -> rxpress  (d=2)
25%   expres -> rxpress  (d=2)

So the two halves of a finding decay at different rates. The verdict — this name has no evidence behind it — survives corpus loss much better than the explanation — this name is a typo of that one. By the time clause 1 has lost 30% of its coverage, the "nearest real name" printed in detail is a name the typo has nothing to do with.

The practical reading: treat d=1 from "chalk" as the rule showing its working, not as an identification. It is the closest surviving corpus entry, which is only the actual parent when the corpus still contains the actual parent.

The recall drop is not the clause

Recall falls from 1.000 to 0.667 at 50% and stays there at 25%, in both columns. That is worth being clear about, because a careless reading blames the in-degree clause for it.

It is clause 2 failing, not clause 3. Thinning at 50% deleted lodash itself. lodahs is still absent from the corpus and still has no parent, but there is no longer a real name within distance 2 for it to be a typo of, so clause 2 finds nothing and the rule stays quiet. Losing a planted name's parent loses the planted name.

expres kept firing through the same loss because a coincidental neighbour existed. lodahs had none. Which of the two happens is luck about the shape of the registry, not a property of the rule.

That is a corpus-coverage failure. It shows up identically whether the in-degree clause is on or off, which is exactly what you would expect from a failure that has nothing to do with it. A tool whose corpus is half gone has bigger problems than which clause is enabled.

Running it

The decay table scans the fixtures ten times against a 140,000-name corpus and took about two minutes before the prefilter and takes four seconds now, and it keeps its own target because it is still not something to pay on every cargo test:

$ make ablation
cargo test --release --test ablation -- --nocapture --include-ignored
...
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.03s

CI runs it on every push, because the README quotes its numbers. The fast table runs on every cargo test.

$ cargo test --test ablation -- --nocapture

False positives

On the npm fixture set with the full corpus, there are none. 3,925 packages across six lockfiles, three findings, and all three are the planted names.

That number is a property of the corpus and of the npm format. Here is where it breaks.

A real package published after the snapshot

This is the failure mode, and it is not hypothetical — the repository ships a fixture that triggers it.

$ ./target/release/stranger scan fixtures/reqs-xs.requirements.txt

  reqs-xs.requirements.txt 12 packages   (12 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     1
     tensorflow-gpu           not in corpus · d=1 from "tensorflow-cpu" · no dependency graph in this format

  ⚠  UNPINNED               12    no exact version recorded

  ·  INSTALL SCRIPTS        — no signal in this format

  risk 77/100    4ms    third-party deps used to compute this: 0

tensorflow-gpu is a real PyPI package. It is deprecated, which is why it is absent from a top-15,000 corpus, and it is one edit from tensorflow-cpu, which is present. Clauses 1 and 2 both fire. There is no clause 3 on a flat file to stop them.

You can check both halves yourself:

$ grep -x -E 'tensorflow(-gpu|-cpu)?' corpus/pypi.txt
tensorflow
tensorflow-cpu

Clause 1 asks whether a name is in a list fetched on 2026-08-28. npm accepts thousands of new names a day and PyPI is no slower. A package published after that date, or one that fell off a popularity ranking before it, fails clause 1 for a reason that has nothing to do with being fake.

The ablation table puts a number on the npm side: delete 10% of the corpus, roughly what a few months of registry growth costs you, and the false positive count goes from 0 to 3. Delete 30% and it goes to 16.

Short names, and the two false positives that are gone

Two findings were on this page until the last day of the window: ksni in cargo-m.Cargo.lock and taze in pnpm-l.pnpm-lock.yaml. Both were described as real packages that had fallen below a popularity cut — bad luck, and the corpus's fault.

That was the wrong diagnosis. Both names are four characters long, and length turns out to be the variable that matters.

The measurement is leave-one-out over each corpus: take a real name, pretend it is missing — which is exactly what a real package below the cut looks like to clause 1 — and ask whether the rest of the list offers it a neighbour. That is the false positive rate, as a function of length:

charsnpm k=1npm k=2pypi k=1pypi k=2crates k=1crates k=2
299.6%100.0%88.0%100.0%36.4%100.0%
398.6%100.0%69.9%100.0%60.4%100.0%
451.9%100.0%43.9%98.9%41.4%99.1%
540.5%97.5%34.0%93.8%18.7%78.9%
636.5%85.8%16.3%76.6%12.5%51.5%
830.0%63.0%11.3%35.6%7.0%26.6%
927.7%55.1%5.9%23.1%4.1%19.3%
1018.8%46.1%2.1%14.9%4.8%9.5%

At four characters, a name absent from npm finds a neighbour within two edits every single time. So clause 2 was not weighing evidence about ksni or taze; it was passing everything, and the rule was really running on two clauses: "not in the corpus" and "in-degree zero". For a real package that nobody depends on — a devDependency of the root manifest, say — that is a guaranteed CRITICAL.

The threshold is a function of length now: one edit per five characters, capped at two, which is distance::budget_for. Five is where the table points. Reading the npm column, a hit at k = 1 stops being the likelier outcome at five characters and a hit at k = 2 at ten characters — below a coin flip being the bar, because a clause that fires on most inputs is not evidence about any of them. That is one edit per five characters, twice.

Nine policies were swept against every fixture, with the seven planted names as ground truth and everything else counted as a false positive:

policyTPFPrecallprecision
2 — a flat threshold, what shipped751.0000.583
min(2, len / 3)741.0000.636
min(2, (len - 1) / 3)731.0000.700
min(2, len / 4)731.0000.700
min(2, len / 5) — ships711.0000.875
1 — a tighter flat threshold instead640.8570.600

Recall does not move. All seven planted names still fire, at the same distances and against the same parents.

Four policies tie at 0.875, so the fixtures do not pick between them — the leave-one-out table does. (len - 1) / 4 hands out two edits at nine characters, where npm still answers 55% of the time. (len - 1) / 5 and len / 6 refuse nunpy its edit at five characters, and nunpy is a true positive tests/pip.rs already holds the rule to.

The last row is there because "just lower the threshold" is the obvious alternative, and it is worse at both ends: it loses requests-http — a genuine hallucination two edits from requests-html — and still keeps four false positives.

Both tables are in the doc comment on distance::CHARS_PER_EDIT, and both are tests: tests/corpus.rs::length_is_the_false_positive_rate (ignored by default, about six minutes) and tests/ablation.rs::edit_budget_policy_sweep.

What it does not fix is the next section. tensorflow-gpu is fourteen characters, and at fourteen characters a near-miss really is evidence — no length policy reaches it, and it should not.

Flat formats lose the clause that would have saved it

The tensorflow-gpu finding is the pip case specifically. requirements.txt records no dependency edges, so every package has in-degree 0, clause 3 eliminates nothing, and the rule runs on two clauses instead of three.

On an npm tree the same package would have had a chance: something real depends on tensorflow-gpu, and that edge would have suppressed the finding. On a flat file there is no edge to find.

The fix is a different file rather than a better reader, and both of those files read today. poetry.lock and uv.lock record the resolved graph, so a Python project that keeps one of them gets three clauses where a requirements.txt project gets two. poetry-m scans as 233 packages, 75 direct and 158 transitive; uv-m as 249, 91 direct and 158 transitive. Those transitive counts are clause 3's raw material, and on reqs-xs above the same column reads 0.

This is not retroactive relief for tensorflow-gpu. That fixture is a requirements.txt and stays a two-clause scan; the point is that the format is the thing to change, not the reader.

The nearest name can be wrong even when the verdict is right

detail names the closest corpus entry within distance 2. When the corpus is complete that is nearly always the typo's actual parent. When it is not, the tool still picks the closest surviving name and prints it with the same confidence. In the 70% ablation row, expres is reported against espree rather than express, because express had been deleted.

Treat d=1 from "chalk" as the rule showing its working, not as an identification.

A brand-new direct dependency

The worst case for the rule is the case it is designed for, seen from the other side: you have added a genuinely new, genuinely real package to your manifest. It is a root dependency, nothing depends on it, and it is too recent for the corpus. All three clauses fire.

There is no way for the tool to tell that apart from a hallucination, because from inside the lockfile there is no difference. The evidence that would settle it — does this name exist on the registry — is on the network, and the tool does not go there.

The right reading of a finding is "no evidence this name is real", not "this name is fake".

The trivial rule is wrong more often than it is right

slopsquat gets the careful treatment because it is the rule with an idea in it. The noisiest rule is trivial, and it says so in its own documentation: its second clause looks for a predicate-shaped name that resolves no dependencies, and has no way to know how long the file behind that name is.

is-callable is dozens of lines of edge cases around one typeof. is-docker reads /proc and memoises the answer. Both are reported. Neither is a one-liner. That is not an occasional miss — it is a good share of what the clause finds on a real tree, which is why the rule is low and collapses to a count by default.

What it misses

Two categories, and they matter more than the false positives.

A typosquat that actually got registered. The corpus is a list of names that exist on npm and PyPI, harvested from npm and PyPI. It is not a list of names that are safe. If an attacker registered lodahs this morning, it would be in a corpus rebuilt this afternoon, clause 1 would pass, and the rule would never fire. The corpus makes the tool quiet about real names; whether a real name is malicious is a different question and this tool does not ask it.

A hallucinated name that is genuinely close to nothing. Clause 2 needs a neighbour within two edits. A name like api-client-utils has none and stays silent. Note that this net is wider than it looks — requests-http was planted as an example of exactly this and turned out to be two edits from the real requests-html, so the rule caught it after all.

What it deliberately ignores

Workspace members and link: true entries are first-party and skipped before any clause runs, by every rule. In npm-xl that is 14 of 1,390 entries. Without the exclusion every monorepo scan is mostly noise about the project scanning itself.

Checking a finding

You cannot do it from the lockfile — that is the whole point. Open the registry page for the name. If it does not exist, you have your answer. If it exists but was published last week by an account with no history, you have a different and more interesting answer.

$ ./target/release/stranger scan --format json fixtures/reqs-xs.requirements.txt | jq -r '.findings[] | select(.rule=="slopsquat") | .package'

Install scripts

npm install runs a dependency's preinstall, install and postinstall hooks as part of installing it. Before your test suite, before your own first line of code, with your environment and whatever your ssh agent is holding.

That is the whole argument for high. For these packages the gap between "a name appeared in the lockfile" and "that name's code ran on this machine" is one command, and no review step fits inside it.

$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json

  npm-m.package-lock.json  576 packages   (20 direct · 556 transitive · 6 workspace)

  ⚠  INSTALL SCRIPTS        4     arbitrary code at install time
     esbuild@0.27.7                         runs code at install time · lockfile records the flag, not the script
     fsevents@2.3.3                         runs code at install time · lockfile records the flag, not the script
     sharp@0.34.5                           runs code at install time · lockfile records the flag, not the script
     unrs-resolver@1.12.2                   runs code at install time · lockfile records the flag, not the script

The signal

One field:

$ jq '.packages["node_modules/esbuild"] | {version, hasInstallScript}' fixtures/npm-xl.package-lock.json
{
  "version": "0.28.1",
  "hasInstallScript": true
}

That is everything lockfileVersion 3 records. Not the body of the script, not which of the three hooks, not even the script's name. The body is in the tarball on the registry, and stranger does not fetch.

What it cannot see

esbuild unpacking a platform binary and a package curling a payload produce the identical line in this report. Reading that line as triage is the mistake the rule's own source comment exists to prevent, and the detail string is worded so it never implies otherwise: runs code at install time · lockfile records the flag, not the script.

A finding here is a list of packages to look at, ordered by nothing. It is not a verdict on any of them.

What is excluded

The root project's own hasInstallScript is your build, not a stranger's. So are a workspace member's and its link: true symlink. All three are dropped.

jq counts 9 flagged entries in npm-xl; the reader reports 8. The missing one is the root entry, deliberately.

Copies count separately

One finding per lockfile entry, not per name. Two entries for one name are two installs, and two installs run the hook twice, so both are reported. fsevents appears twice in npm-xl — once at the top level and once under a workspace member — and tests/rules.rs asserts both hits are there:

$ jq -r '.packages | to_entries[] | select(.key | endswith("node_modules/fsevents")) | "\(.key)  \(.value.version)"' fixtures/npm-xl.package-lock.json
apps/desktop/node_modules/fsevents  2.3.2
node_modules/fsevents  2.3.3

Both are reported:

$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json | head -12

  npm-xl.package-lock.json 1,376 packages   (150 direct · 1,226 transitive · 14 workspace)

  ⚠  INSTALL SCRIPTS        8     arbitrary code at install time
     agent-browser@0.26.0                   runs code at install time · lockfile records the flag, not the script
     electron@40.10.2                       runs code at install time · lockfile records the flag, not the script
     electron-winstaller@5.4.0              runs code at install time · lockfile records the flag, not the script
     esbuild@0.28.1                         runs code at install time · lockfile records the flag, not the script
     fsevents@2.3.2                         runs code at install time · lockfile records the flag, not the script
     fsevents@2.3.3                         runs code at install time · lockfile records the flag, not the script
     node-pty@1.1.0                         runs code at install time · lockfile records the flag, not the script
     unicode-animations@1.0.3               runs code at install time · lockfile records the flag, not the script

Version is part of the sort key, so the order is stable across scans.

It fires on npm only

requirements.txt records nothing equivalent. A pip source distribution can run whatever setup.py wants during installation and the file does not say that it will, which is a real blind spot rather than a rule that does not apply — see Limits.

Reading a hit

The poisoned fixture shows why this rule is worth having next to the hallucination rule rather than instead of it:

$ ./target/release/stranger scan --format json fixtures/poisoned.package-lock.json | jq -r '.findings[] | select(.rule=="install-script") | .package'
lodahs
sharp
unrs-resolver

lodahs appears under two rules that mean different things. One says the name has no evidence behind it. The other says that if you install it, it runs code. Both are true and neither implies the other.

$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json

Version drift

One name, installed at more than one version, in one tree.

$ ./target/release/stranger scan --format json fixtures/poisoned.package-lock.json | jq -r '.findings[] | select(.rule=="drift") | "\(.package)  \(.detail)"' | head -8
@types/unist  2 versions: 2.0.11, 3.0.3
ajv  2 versions: 6.12.6, 8.20.0
ajv-formats  2 versions: 2.1.1, 3.0.1
ansi-regex  2 versions: 5.0.1, 6.2.2
balanced-match  2 versions: 1.0.2, 4.0.4
brace-expansion  3 versions: 1.1.12, 2.0.2, 5.0.7
chalk  2 versions: 4.1.2, 5.6.2
commander  2 versions: 11.1.0, 14.0.3

Why the lockfile already knows

npm deduplicates what it can and nests what it cannot. When two packages want incompatible ranges of the same name, the loser gets its own copy at node_modules/parent/node_modules/name.

Those nested keys are not a quirk of the file format — they are how the format spells duplication. 184 of npm-xl's 1,390 entries are nested. So this rule needs no resolver, no registry and no node_modules on disk. It is reading the answer npm already wrote down.

Why medium

Nothing is exploitable because ansi-regex is installed at both 5.0.1 and 6.2.2. The argument is about the next advisory rather than today.

When a CVE lands on that name, the bump you make in your own manifest moves the copy your manifest reaches and leaves the other one pinned by whoever nested it. The fix reads as done while the vulnerable code is still on disk. Duplication is the thing that turns patching into a negotiation.

Not high, because there is no vulnerability here yet. Not low, because it decides how much tomorrow costs.

One finding per name

npm-xl has 76 drifted names behind 180 distinct versions:

$ ./target/release/stranger scan --format json fixtures/npm-xl.package-lock.json | jq '[.findings[] | select(.rule=="drift") | (.detail | capture("(?<n>[0-9]+) versions") | .n | tonumber)] | {names: length, versions: add}'
{
  "names": 76,
  "versions": 180
}

Reporting each of those 180 would be a wall; reporting the 76 names is something you read. So the finding carries the name, an empty version, and the full version list in detail:

{"rule":"drift","severity":"medium","package":"brace-expansion","version":"","detail":"3 versions: 1.1.16, 2.1.2, 5.0.7"}

That is npm-xl's brace-expansion. The block at the top of this page is the poisoned fixture's, which drifts the same name at 1.1.12, 2.0.2 and 5.0.7 — different tree, different versions, same shape of finding. Every block on this page names the fixture it came from in the command above it; the version lists only match across two blocks by coincidence.

An empty version field in the JSON is how a consumer tells this rule's findings apart from the others.

Versions are compared for equality, but listed in order

Whether two versions differ is the whole rule — it never asks which is newer. The list is sorted anyway, with src/semver.rs, because a human reads it:

$ ./target/release/stranger scan --format json fixtures/poisoned.package-lock.json | jq -r '.findings[] | select(.rule=="drift" and .package=="minimatch") | .detail'
3 versions: 3.1.2, 9.0.5, 10.2.5

This page said the opposite for most of the weekend, and was quoting 10.2.5, 3.1.2, 9.0.5 to prove it — byte order, where 1 sorts before 3 and the newest release leads the list. That was true when it was written. It stopped being true when drift.rs started sorting with the comparator, and nothing noticed until the checker started running the block.

So src/semver.rs is called: drift.rs imports Version and sorts with it. It implements precedence including the prerelease rules from section 11 that most implementations get wrong by accident, build metadata is ignored for ordering as the spec requires, and a version it cannot parse sorts last rather than throwing the finding away.

What it cannot see

Whether the two copies matter. Two versions of a type-definitions package is noise; two versions of a crypto library is not, and the lockfile records nothing that would tell them apart.

It also cannot see duplication that npm resolved away. If your tree happens to have deduplicated to one version today, a range in some transitive manifest can still float it apart tomorrow, and nothing in this file predicts that.

Not on pip

requirements.txt is a flat list of names, so a name appearing twice is a conflict pip would reject rather than drift it would nest. The rule technically runs on pip trees and cannot fire on a well-formed one.

$ ./target/release/stranger scan -v fixtures/npm-l.package-lock.json | tail -20

Trivial packages

Dependencies whose whole job is one expression. left-pad was one of these, and so was event-stream.

$ ./target/release/stranger scan -v fixtures/npm-xs.package-lock.json

  npm-xs.package-lock.json 37 packages   (1 direct · 36 transitive)

  ⚠  TRIVIAL                4     (10.8% of third-party)
     es-errors@1.3.0          one expression, one publisher · inlining it removes an account from your build
     gopd@1.2.0               one expression, one publisher · inlining it removes an account from your build
     has-symbols@1.1.0        predicate-shaped, resolves nothing · size not measured, see rule docs
     hasown@2.0.4             one expression, one publisher · inlining it removes an account from your build

  ·  UNPINNED               — no signal in this format

  risk 9/100    10ms    third-party deps used to compute this: 0

It does not measure triviality

Start here, because everything else on this page depends on it.

A package-lock.json entry holds version, resolved, integrity, license, engines and the dependency lists. There is no unpacked size, no file count, no export list, no line count anywhere in the format. All of that is in the tarball, the tarball is on the registry, and stranger does not fetch.

So this rule recognises names. Nothing it prints should be read as though it had measured anything.

Two clauses, and they are different in kind

A hand-written list. Two dozen packages whose published purpose is a single expression or a re-export of a builtin — isarray, is-number, left-pad, object-assign. Picked by hand, which makes the boundary somebody's judgement rather than a threshold. Against a registry holding millions of names, two dozen is nothing. That is the honest size of the clause, and there is no version of it that is not a list somebody wrote.

Shape. A name that reads as a predicate (is-…, has-…, scope stripped) and that resolves no dependencies of its own. Both halves come out of the lockfile. The second half is what stops it firing on is-glob and has-tostringtag, which turned out to need help.

The detail string keeps the two apart, because they deserve different amounts of trust:

one expression, one publisher · inlining it removes an account from your build
predicate-shaped, resolves nothing · size not measured, see rule docs

How clause 2 is wrong

It has no idea how long a file is.

is-callable is dozens of lines of edge cases around one typeof. is-docker reads /proc and memoises the answer. Both are predicate-shaped, both resolve nothing, both are reported, and neither is a one-liner. That is the false-positive mode, and it is not the exception — it is a good share of what clause 2 finds on a real tree.

A hit is worth twenty seconds of attention. It is not a verdict.

It under-reports at least as badly

function-bind, wrappy and util-deprecate are in the same weight class as anything on the list and are not on it, because nobody is going to claim to have read them all. Clause 2 is blind to any micro-package that depends on another micro-package — once needs wrappy — and to every one that is not named like a predicate, which is most of them.

Why low

None of this is a vulnerability. It is a count of publishers who can push straight into your build, for code you could have inlined. left-pad and event-stream were both packages this size, so the count is worth having. It is never urgent, and it collapses to a count unless you pass -v.

The percentage

  ⚠  TRIVIAL                29    (2.1% of third-party)

The denominator is the header's own package count — third-party entries only. The rule never looks at a first-party package, so a workspace member cannot be a hit, and counting one in the denominator would print a share of a population the numerator was never drawn from.

That was the bug for most of the weekend: the percentage divided by every entry in the lockfile while the header divided by third-party ones. On npm-m it read 17/582 where the header's own numbers say 17/576 — 2.9% against 3.0%. Under a tenth of a point on every fixture here, which is exactly why it survived so long. tests/cli.rs now derives both numbers from one line of output and fails if they ever disagree again.

Duplicates collapse

npm nests the same version of is-extendable under two different parents in npm-xl. Two install hooks would be two events, so install scripts reports both. Two copies of one expression are one fact, so this rule prints it once. Different versions still count separately — is-docker at 2.2.1 and 3.0.0 are two findings, and version drift is the rule that cares that both exist.

$ ./target/release/stranger scan --format json fixtures/npm-xl.package-lock.json | jq -r '.findings[] | select(.rule=="trivial") | .detail' | sort | uniq -c

Unpinned requirements

An unpinned requirement is not a vulnerability. It is the mechanism by which somebody else's vulnerability reaches you without anybody changing a file: the compromised release ships, pip install -r requirements.txt runs in CI, and the diff that introduced it is empty.

Every published pip supply-chain incident has that shape, and it is the reason a rule about punctuation is worth writing.

$ ./target/release/stranger scan -v fixtures/poisoned.requirements.txt

  poisoned.requirements.txt 6 packages   (6 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     2
     python-dateutils@2.9.0   not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
     requests-http@1.0.2      not in corpus · d=2 from "requests-html" · no dependency graph in this format

  ⚠  UNPINNED               3     no exact version recorded
     flask                    ~=3.0 · capped at the major, still floats below the cap
     numpy                    no bound in either direction · resolves to whatever is newest at install time
     urllib3                  >=1.26 · a range, so the file does not say what installs

  ·  INSTALL SCRIPTS        — no signal in this format

  risk 79/100    6ms    third-party deps used to compute this: 0

The file behind that:

$ cat fixtures/poisoned.requirements.txt
requests==2.31.0
urllib3>=1.26
python-dateutils==2.9.0
requests-http==1.0.2
flask~=3.0
numpy

requests==2.31.0 is exact and produces nothing.

Three severities, and the ranking is a ranking of how much future gets in

specifierpinseveritywhy
numpyunconstrainedhighno bound in either direction
>=1.26, <2, !=1.5rangemediumopen-ended in at least one direction
~=1.2, ==1.2.*compatiblelowcapped at the major, floats below the cap
==2.31.0, ===2.31.0exactnot a finding

Unconstrained is high. pip install numpy today and the same command in March install different programs, and there is nothing in the repository that records which one you tested.

A range is medium. >=1.0 is the common case and it is open above: every release the maintainer has not published yet already matches. <2 and !=1.5 are open below instead, which is a smaller window but the same class of answer — the file does not say what installs. One notch under unconstrained because at least one end is written down.

Compatible is low. ~=1.2 caps the major, so a hostile 2.0 cannot arrive. That is a real reduction, and it is not a pin: the compromised releases that actually happened were patch releases of a package people already trusted, and every one of those still matches.

Nothing here is critical. An unpinned dependency is a way to be compromised later, not evidence of being compromised now. Critical is reserved for slopsquat, where the finding is a name that should not exist.

It fires on PyPI only, and in practice on one PyPI format

npm, cargo and go all record a resolved version, so every entry those readers produce is Pin::Exact and there is nothing to say. Firing on them would mean either a rule that never triggers or a rule that has started guessing. The rule returns immediately on any non-PyPI tree.

poetry.lock and uv.lock are PyPI, so the rule does run over them — and finds nothing, because those two readers set Pin::Exact on every entry they build and Pin::Exact is the arm this rule skips. A lockfile records one resolved version per package; there is no specifier left to classify.

So the rule walks 233 poetry-m entries and 249 uv-m entries and returns an empty list from both. That is a rule doing nothing rather than a rule being switched off, and it is why the limits grid reads never for poetry and uv but not for requirements.txt.

A direct reference is unconstrained

pkg @ https://host/pkg.whl

A direct reference names bytes rather than a version, and the bytes at a URL are whatever the host serves next time. So it classifies as unconstrained, with no version recorded, which is the honest reading.

The finding has no version

version is empty in the JSON for every pinning finding except none — if a requirement had an exact version it would not be a finding. That is how a consumer tells these apart:

$ ./target/release/stranger scan --format json fixtures/poisoned.requirements.txt | jq -c '.findings[] | select(.rule=="pinning")'
{"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"}

The specifier is quoted verbatim in detail because the finding has to be arguable. >=1.26 is something you can check against the file; "unpinned" is something you have to take on trust.

What it cannot see

Whether the range is deliberate. A library publishing to PyPI is supposed to declare ranges; an application deploying from a requirements.txt is not. The file does not say which one it belongs to, and this rule does not guess.

It also cannot see the resolved versions that a real deployment used — not from this file. All three of the files that do record them are readable, so the answer is to point stranger at one of those instead.

pip freeze writes a requirements.txt with == on every line, which is the same reader and produces no pinning findings at all:

$ mkdir -p /tmp/freeze
$ printf 'flask==3.0.0\nnumpy==2.1.0\nurllib3==2.2.1\nrequests==2.31.0\n' > /tmp/freeze/requirements.txt
$ cat /tmp/freeze/requirements.txt
flask==3.0.0
numpy==2.1.0
urllib3==2.2.1
requests==2.31.0
$ ./target/release/stranger scan /tmp/freeze

  requirements.txt         4 packages   (4 direct · 0 transitive)

  no findings
  ·  INSTALL SCRIPTS        — no signal in this format

  risk 0/100    0ms    third-party deps used to compute this: 0

That still records no graph, so the detection rule stays on two clauses. poetry.lock and uv.lock record both the versions and the graph, and are the file to keep if you get to choose.

$ ./target/release/stranger scan -v fixtures/reqs-xs.requirements.txt

npm

package-lock.json, lockfileVersion 2 and 3.

$ ./target/release/stranger scan fixtures/npm-xl.package-lock.json

  npm-xl.package-lock.json 1,376 packages   (150 direct · 1,226 transitive · 14 workspace)

  ⚠  INSTALL SCRIPTS        8     arbitrary code at install time

  ⚠  TRIVIAL                29    (2.1% of third-party)

  ⚠  VERSION DRIFT          76    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 62/100    456ms    third-party deps used to compute this: 0

All five rules can fire on an npm tree, though pinning never will: every entry records a resolved version, so every entry is exactly pinned.

What it refuses

lockfileVersion 1, by name:

$ mkdir -p /tmp/v1
$ echo '{"lockfileVersion": 1, "dependencies": {}}' > /tmp/v1/package-lock.json
$ ./target/release/stranger scan /tmp/v1
stranger: /tmp/v1/package-lock.json: lockfileVersion 1 is not supported; stranger reads 2 and 3. Run `npm install` with npm 7 or newer to upgrade the file.

Version 1 kept the tree in a nested dependencies object and has no packages map at all. Refusing it beats mis-reading it: a reader that looked for packages and found nothing would report a clean project with zero dependencies, which is the worst possible output for an auditing tool.

A file with no lockfileVersion field is refused as not looking like a package-lock.json. A file with a lockfileVersion but no packages map is refused too.

A filename that is not one of the seven it knows:

$ 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

Cargo.lock was the example here until the Cargo reader landed and it started reading fine. The list in the message comes from the same constant discovery uses, so the message could not go stale — only this page could, and did.

Keys are install paths, not names

The awkward part of the format is that packages is keyed by where npm put the thing on disk:

"node_modules/@babel/core": { ... },
"node_modules/eslint/node_modules/semver": { ... }

The package name is the segment after the last node_modules/, with a scope slash after it counting as part of the name. 184 of npm-xl's 1,390 entries are nested like that, so it is not a rare path.

Resolving one package's dependency to another package's entry means reproducing npm's own lookup: try the nearest node_modules directory, then walk up. A dependency c of the package at node_modules/a/node_modules/b is the first of these that exists:

node_modules/a/node_modules/b/node_modules/c
node_modules/a/node_modules/c
node_modules/c

Getting that wrong does not produce a parse error. It produces a graph with the wrong edges, which silently corrupts in-degree, which is the clause the detection rule leans on hardest. tests/npm.rs asserts the nested count and that every nested entry keeps its own identity, including scoped ones.

Those same nested keys are what version drift reads: they are how the format spells duplication.

Which fields are read

fieldused for
lockfileVersionaccept or refuse the file
versionprinted beside the name; compared for equality by the drift rule
dev, optionalrecorded on each package; no rule uses them yet
linkmarks a symlink to a workspace member as first-party
hasInstallScriptthe install scripts rule
integritypresence recorded; never verified, see Limits
dependencies, devDependencies, optionalDependencies, peerDependenciesgraph edges

peerDependencies is in that list on purpose. A peer dep is a real maintainer writing down a real name, which is exactly the evidence the detection rule wants, and counting it can only make the rule quieter. devDependencies only ever appears on the root entry.

The empty-string key is the root project. It is in the map and is not one of the dependencies being counted, which is why npm-xl reports 1,390 entries from a 1,391-entry map. It is also why the root project's own hasInstallScript does not count as a supply-chain signal: that one is your build.

First-party entries

An entry is first-party when its key contains no node_modules/ at all — that is a workspace directory like apps/desktop — or when it carries "link": true, which is the symlink npm leaves in node_modules pointing at one. npm-xl has 14 of them, 7 of which are links.

First-party packages are skipped by every rule, never counted as direct dependencies of themselves, and reported separately in the header's workspace field. Monorepos covers why their outgoing edges are treated as manifest declarations rather than as evidence.

Discovery

stranger scan <dir> recurses, skipping node_modules and twelve other directories, and matches any filename ending in one of the seven names it knows.

Pointing at a file skips the walk, and the match there is the same suffix rule, so a lockfile you have renamed still reads:

$ ./target/release/stranger scan fixtures/npm-s.package-lock.json

  npm-s.package-lock.json  405 packages   (12 direct · 393 transitive)

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                10    (2.5% of third-party)

  ⚠  VERSION DRIFT          30    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 56/100    53ms    third-party deps used to compute this: 0

Fixture counts

Every entry count was measured with jq '.packages | length - 1' against the file, not copied from notes. The notes said npm-xl held 1,391 entries. It holds 1,390.

fixtureentriesthird-partydirectworkspace
npm-xs373710
npm-s405405120
npm-m582576206
npm-l754754320
npm-xl1,3901,37615014
poisoned757757350

poisoned is npm-l plus three planted names, all inserted as root dependencies with no parent. It added exactly three entries, three roots, and no edges — tests/npm.rs asserts all three, because nothing depends on a hallucination.

$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json

pnpm

pnpm-lock.yaml, lockfileVersion 9 and 6.

$ ./target/release/stranger scan fixtures/pnpm-l.pnpm-lock.yaml

  pnpm-l.pnpm-lock.yaml    850 packages   (29 direct · 821 transitive)

  ⚠  TRIVIAL                23    (2.7% of third-party)

  ⚠  VERSION DRIFT          58    same package at 2+ versions in one tree

  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 46/100    148ms    third-party deps used to compute this: 0

taze used to appear in that block, and was the npm half of a pair of false positives — ksni on cargo was the other. Both are four characters long, and that is why: on npm, a four-character name has a neighbour within two edits 100% of the time, so clause 2 was a formality rather than a filter. The length budget refuses it the edit and taze stops firing. False positives has the table.

It is still in the fixture. A fixture that stops demonstrating the bug it was kept for is still the file the fix was measured against.

pnpm packages are npm packages, so this reader shares npm's corpus and the trivial rule's name list works here unchanged. What it does not share is the file.

Three sections, and they are not interchangeable

sectionkeyed bywhat it carries
importersworkspace directory, . for a single-package repothe project's own manifests: name: {specifier, version}
packagesname@version850 distinct tarballs — resolution, engines, hasBin, peerDependencies, deprecated
snapshotsname@version plus a peer suffixthe installed instances. This is where the edges are.

Reading edges out of packages instead of snapshots gets you a package list and no graph, which is the failure mode that matters: the detection rule's third clause needs in-degree, and a tree with no edges makes every package look like a root.

deprecated is a block scalar

The one field in that table that is not a flow collection or a scalar on its own line. pnpm copies the registry's deprecation message verbatim, and those run to paragraphs, so it writes them as a YAML literal block:

  q@1.5.1:
    resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw…}
    engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
    deprecated: |-
      You or someone you depend on is using Q, the JavaScript Promise library
      that gave JavaScript developers strong feelings about promises.

      (For a CapTP with native promises, see @endo/eventual-send)

Nothing here reads the message. What matters is that the reader gets to the end of it and finds engines: again rather than treating an indented English sentence as structure — so the YAML subset parses literal block scalars with all three chomping modes (|, |-, |+) rather than refusing them. Refusing meant refusing the whole lockfile, and any tree holding one deprecated package holds one of these.

The folded form > is still refused by name, along with an explicit indentation indicator (|2). Neither appears in a lockfile, and a folded scalar that joins its lines slightly wrong still parses — which is the failure this subset is arranged against.

Peer suffixes

The same tarball can be installed more than once with different peers resolved, so a snapshot key carries a parenthesised suffix its packages entry does not:

packages:    astro@5.7.10
snapshots:   astro@5.7.10(@types/node@22.15.3)(jiti@2.4.2)

Dependency values carry it too — '@volar/kit': 2.4.23(typescript@5.8.3). Both ends of every edge are truncated at the first ( before lookup.

Splitting these on @ is how a naive reader loses every scoped package. @babel/core@7.27.1 has to split at the last @, and the @types/node@22.15.3 inside a suffix must not be split at all. tests/pnpm.rs asserts the 1,851 edges resolve and that scoped names survive.

What the format does not record

Everything in this section is about version 9. Version 6 records all three and is read for all three, which is the odd part: the newer file is the one that tells you less.

Install scripts. Version 9 dropped requiresBuild and did not replace it. hasBin: true appears 42 times in this fixture and is not a substitute — it means the package ships a bin to symlink, not that code runs at install time. Mapping one to the other would put 42 High findings in the report, every one invented. So install_script is false for every package in a v9 file and install scripts says nothing about that tree, and that silence means not measured rather than nothing found.

Dev-only packages. pnpm 9 records dev-ness on the importer's manifest, not on the package, and does not mark the transitive closure. dev is false throughout rather than guessed at with a graph walk.

First-party packages. A v9 workspace member lives in importers and never in packages, and pnpm writes its dependents' references to it as link:../name, which resolves to no package entry. So first_party is false everywhere in a v9 file for want of anything to be true about, and the header prints no workspace count — where the npm reader has to work for the same distinction.

Version 6 is the same file with two sections fused

pnpm 8 wrote it, and it is still on disk in a great many repositories — one of the four pnpm-lock.yaml files on the machine this was written on. The fixture is mongodb/mongo's own lockfile, taken as found:

$ ./target/release/stranger scan fixtures/pnpm-v6.pnpm-lock.yaml

  pnpm-v6.pnpm-lock.yaml   89 packages   (7 direct · 82 transitive · 1 workspace)

  ⚠  TRIVIAL                2     (2.2% of third-party)

  ⚠  VERSION DRIFT          2     same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 29/100    <ms>    third-party deps used to compute this: 0

Four differences, and the reader is otherwise the v9 reader:

  • No snapshots section. A packages entry carries its own resolved dependencies, so the tarball list and the instance list are one section.
  • Keys start with /, and the peer suffix rides on the packages key rather than on a separate snapshot key. Two entries for one tarball at different peers collapse to the single Package v9 would have written, which is what keeps a package count comparable across the two versions.
  • A single project writes its manifest at the top level and no importers at all, so the document itself is the one importer.
  • It records requiresBuild, dev, and local packages, all three of which v9 gave up.

That last one has teeth. A file: dependency gets a real packages entry keyed by its path, with its actual name in a name field because the key has no version to carry one:

  file:buildscripts/eslint-plugin-mongodb:
    resolution: {directory: buildscripts/eslint-plugin-mongodb, type: directory}
    name: eslint-plugin-mongodb
    dev: false

Read literally, that audits the project's own code as a stranger under the name file:buildscripts/eslint-plugin-mongodb, and stranger tree could not find it under the name its own repository uses. Taking the name field and setting first_party fixes both, and is the same call the npm reader makes about a workspace key — which is why the header above says 1 workspace:

$ ./target/release/stranger tree eslint-plugin-mongodb fixtures/pnpm-v6.pnpm-lock.yaml

  fixtures/pnpm-v6.pnpm-lock.yaml   npm · 89 packages

  eslint-plugin-mongodb   file:buildscripts/eslint-plugin-mongodb
     workspace member — your own code, not a stranger

What it refuses

$ mkdir -p /tmp/v5
$ printf "lockfileVersion: '5.4'\n" > /tmp/v5/pnpm-lock.yaml
$ ./target/release/stranger scan /tmp/v5/pnpm-lock.yaml
stranger: /tmp/v5/pnpm-lock.yaml: lockfileVersion 5.4 is not supported; stranger reads 9 and 6. Run `pnpm install` with pnpm 8 or newer to upgrade the file.

Version 5 and below key their packages /name/version rather than /name@version. The last @ in a scoped key is then part of the name and there is no version to find, so a reader that guessed would split every scoped name in the wrong place. Refusing by name beats that.

The version arrives as the string "9.0", because pnpm quotes it and the YAML subset types exactly two tokens — true and false — and leaves everything else a string. no and on are npm package names this repository's own corpus can confirm — y and off are registered too but sit below its popularity cut — and a reader that turned no@1.0.0 into a boolean would drop a package out of an audit without saying anything.

$ ./target/release/stranger scan -v fixtures/pnpm-l.pnpm-lock.yaml

cargo

Cargo.lock, version 3 and 4.

$ ./target/release/stranger scan fixtures/cargo-m.Cargo.lock

  cargo-m.Cargo.lock       708 packages   (34 direct · 674 transitive · 15 workspace)

  ⚠  VERSION DRIFT          70    same package at 2+ versions in one tree

  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 46/100    8ms    third-party deps used to compute this: 0

ksni used to appear in that block. It is a real crate below the top 5,000 and it was a false positive — and it was four characters long, which turned out to be the whole story: a name that short is within two edits of something in every registry, so clause 2 was not filtering it at all. The length budget (distance::CHARS_PER_EDIT) gives a four-character name no edits, and ksni stops firing. False positives has the measurement.

The crates.io corpus's size is still a published number rather than an implementation detail, because that is the limit underneath: 5,000 names is the top of crates.io and not the whole of it, and a longer crate below the cut would still be reported.

Structurally this is the easiest of the three formats: an array of [[package]] tables, no install paths to reproduce, no nesting. What makes it non-trivial is that its dependency strings are not names.

The three shapes of a dependency string

Cargo writes the shortest form that is unambiguous and promotes only when it must:

"bytes"                                  name
"winapi 0.3.9"                           name version
"qux 1.0.0 (registry+https://…)"         name version source

The second appears when two entries share a name — cargo-m has five hashbrowns and five windows-sys. The third appears when two entries share a name and a version and differ only in origin.

Counted across all three fixtures, 5,689 dependency strings in total:

fixturebarename+versionname+version+source
cargo-s25180
cargo-m1,7235000
cargo-l2,6105970

The third shape is not exercised by any real fixture here. It is implemented and tested against a hand-written file, and that is the honest status: handled, unmeasured.

The second shape is very much exercised — 1,105 strings, a fifth of the corpus — and reading one as a bare name is not a near miss. All 22 of cargo-m's windows-sys edges carry a version and there are five entries they could mean, so dropping the version collapses all 22 onto one entry. Fourteen of them point at 0.61.2, so the luckiest possible guess still lands 8 edges on the wrong package, and the unluckiest lands 21. Silently, too — no parse error, just a graph with wrong edges, which corrupts the in-degree the detection rule leans on.

The invariant that makes the bare form safe is Cargo's, not this reader's — a bare name is written only when exactly one entry carries it. That was checked rather than assumed: across all three fixtures, zero bare names refer to a duplicated package and zero dependency strings of any shape fail to resolve.

No source means somebody in this repo wrote it

sourcewhat it is
registry+https://…crates.io, or another registry
git+https://…#reva git dependency — not first-party
absenta workspace member or a path = "…" dependency

There is nowhere to fetch a path dependency from, so Cargo writes no source key at all. That is the whole first-party test, and it is npm's link: true rule wearing different clothes. It matters for the same reason: cargo-l is a 944-entry workspace with 93 members, and if their edges counted as evidence, a hallucinated crate added to any one of those 93 Cargo.toml files would arrive with in-degree 1 and never be looked at. Those edges become roots instead.

A git dependency is deliberately not first-party. Somebody outside this repo wrote it, and bypassing crates.io is if anything more interesting — but it also means the crates.io corpus was never asked about it, which is why the name rules stay quiet on anything whose origin is not the registry the corpus samples. That fix removed two of cargo-m's three original findings.

What the format does not record

Build scripts. Cargo runs build.rs at compile time — the same arbitrary-code-execution shape npm's hasInstallScript flags — and Cargo.lock records nothing about it. install_script is false on every package, so install scripts never fires on a Rust tree. Inventing a proxy (the -sys suffix, say) would produce a confident wrong answer, which is worse than a blank. A real answer needs the .crate archive or the index's metadata, and both mean fetching.

dev-dependencies, and optional ones. Cargo.lock distinguishes neither. A feature-gated dependency that was resolved is written exactly like any other, so dev and optional are false on every package — a limitation, not a measurement. Splitting them out needs Cargo.toml, the workspace's, and feature unification, which is a resolver.

Checksums on v1 files. checksum on the package table is v2-and-later; v1 kept them in a [metadata] table keyed "checksum bytes 1.0.0 (registry+…)". This reader does not look there, so a v1 file reads as having no integrity anywhere. There is no v1 file in the corpus and Cargo has rewritten them on every cargo update since 2019.

has_integrity is otherwise exact. 93 of cargo-l's 944 entries have no checksum and all 93 are the workspace members. cargo-m has 34 without one: 15 workspace members and 19 git dependencies, which have a source and no checksum because a git revision is its own integrity claim.

What it refuses

Two shapes, both of which mean the file is not the thing its name says it is.

No [[package]] array at all:

$ mkdir -p /tmp/refuse-cargo
$ printf 'x = 1\n' > /tmp/refuse-cargo/Cargo.lock
$ ./target/release/stranger scan /tmp/refuse-cargo/Cargo.lock
stranger: /tmp/refuse-cargo/Cargo.lock: no [[package]] entries; this does not look like a Cargo.lock

Refusing beats the alternative for the same reason it does in the npm reader: a reader that looked for package, found nothing and carried on would report a clean project with zero dependencies, and a clean report on an unread file is the worst output an auditing tool can produce.

An entry with no name or no version:

$ printf '[[package]]\nversion = "1.0"\n' > /tmp/refuse-cargo/x.Cargo.lock
$ ./target/release/stranger scan /tmp/refuse-cargo/x.Cargo.lock
stranger: /tmp/refuse-cargo/x.Cargo.lock: [[package]] #1 has no `name`

The ordinal rather than a line number, and that is a real limit rather than an oversight: positions die at the end of toml::parse, which hands back a value tree with no spans in it. [[package]] #1 is still something you can count to. Threading spans through the whole Value type to improve one message was not worth what it would cost every other user of the parser.

Everything the TOML subset refuses is refused here too, and decisions documents that subset. The important half is that it refuses rather than guesses: Cargo.lock is written by one program, so anything outside the subset means either a cargo change worth knowing about or a corrupt file, and both deserve an error rather than a partial tree.

Which rules can fire

Only two. slopsquat on registry crates, and drift — every entry records an exact version so pinning has nothing to say, install_script is never set, and the trivial rule's name list is npm micro-packages.

$ ./target/release/stranger scan fixtures/cargo-l.Cargo.lock

poetry and uv

poetry.lock and uv.lock — the two Python lockfiles that record a graph. One module reads both, because the piece worth writing exactly once is turning a dependency's name into an entry, and that is where the whole file can go quietly wrong.

$ ./target/release/stranger scan fixtures/poetry-m.poetry.lock

  poetry-m.poetry.lock     233 packages   (75 direct · 158 transitive)

  no findings
  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 0/100    16ms    third-party deps used to compute this: 0
$ ./target/release/stranger scan fixtures/uv-m.uv.lock

  uv-m.uv.lock             249 packages   (91 direct · 158 transitive · 1 workspace)

  ⚠  VERSION DRIFT          1     same package at 2+ versions in one tree

  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 27/100    20ms    third-party deps used to compute this: 0

A clean report on a 233-package tree is a real result and is published as one.

Why these exist when pip.rs already reads Python

requirements.txt records no edges, so every package in it has in-degree 0 and the detection rule's third clause eliminates nothing. That costs a real false positive on tensorflow-gpu.

Both formats here record the resolved graph, which is the entire reason to read them. A reader that produced packages and no edges would leave the rule exactly as weak as it already was — and it is measured rather than asserted: clause 3 removes 45.5% to 80.0% of candidates on poetry and uv, and structurally zero on requirements.txt, at every corpus size.

The source for those numbers is tests/pypi.rs, which prints the whole table under cargo test --test pypi -- --nocapture. It is a different measurement from the ablation, which is npm-only — this page used to cite the ablation for a PyPI number, which is the wrong table.

What each one records

poetry.lockuv.lock
entry[[package]][[package]]
edges[package.dependencies], keys are namesdependencies = [ { name = "…" } ]
optional edges[package.extras]not read[package.optional-dependencies] — read
hashesfiles array of {file, hash}sdist / wheels
dev splityes, per package, in poetry 2.x's groupsno
the root projectabsentsource = { editable = "." }

poetry-m has 112 dependency sub-tables, poetry-s has 20. uv-m has 141 dependencies arrays and 7 optional blocks, and no [[package.dependencies]] array-of-tables anywhere — that shape does not occur.

Why uv's optional block is read and poetry's extras are not

This looks inconsistent and is not.

uv's optional block holds resolved references. davey is in uv-m because somebody asked for discord-py[voice], so that is a real install edge.

poetry's extras block is the package's metadata copied verbatim whether anyone asked or not — 1,049 PEP 508 strings in poetry-m, of which 754 name packages that are not in the lock at all. Feeding the other 295 into edges would put non-install edges in the graph, and a package mentioned by somebody's unrequested docs extra would stop looking like a root when it is one.

That costs the detection rule 295 pieces of real "a maintainer has heard of this name" evidence, and losing evidence makes the rule fire more, not less. The upgrade is a separate mentioned set for clauses that want weaker evidence than an install edge — not a wider definition of edges.

poetry does not record the root project

There is no [[package]] for the thing being locked, and its direct dependencies live in pyproject.toml, which is not this file. uv does record it, and its dependency list is the answer poetry makes you infer — which is why the uv header prints 1 workspace and poetry's prints none.

What neither records

Install-time code. No field in either format says a package runs setup.py at install time, so install_script is false on every entry and install scripts never fires on a Python tree. There is no honest proxy — an sdist with no wheel is suggestive and is not the same claim — so nothing is invented. This is a real blind spot relative to npm.

A dev split, in uv's case. uv-m has no dev-dependencies at all, and group membership in uv attaches to the edge rather than the package, so dev is false on every uv entry.

What each one refuses

Both readers check a marker before they read anything, and the marker is the point. Cargo.lock, poetry.lock and uv.lock are all TOML with a [[package]] array, so the file name is the only thing that says which reader should get it — and a renamed file would otherwise be read by the wrong one and silently produce a tree with no edges in it, because cargo writes its dependencies as strings and these two write inline tables.

So poetry requires metadata.lock-version and uv requires requires-python, each of which appears in every file its own tool writes and in nothing else here:

$ mkdir -p /tmp/refuse-py
$ printf '[[package]]\nname = "a"\nversion = "1"\n' > /tmp/refuse-py/x.poetry.lock
$ ./target/release/stranger scan /tmp/refuse-py/x.poetry.lock
stranger: /tmp/refuse-py/x.poetry.lock: no `metadata.lock-version`; this is not the lockfile its name claims to be

An entry with no name is refused by ordinal, exactly as in cargo and for the same reason — the value tree carries no positions.

An absent package array is deliberately not an error. A project with no dependencies gets a lockfile that is a header and nothing else, and refusing that would be a false alarm about a file that is simply empty. The marker check above is what makes that safe: the file has already proved it is a poetry or uv lockfile before the empty array is accepted as meaning zero packages.

The TOML that makes this possible

Both files, plus Cargo.lock, go through one subset parser. It has no date type, and the only reason that is sufficient is that uv.lock stores timestamps as strings:

upload-time = "2026-03-26T01:21:00.379Z"

If uv wrote them as TOML datetimes the parser would refuse the file, loudly, with a line and column. The only bare integers across all six fixtures are version and revision.

poetry writes quoted keys containing dots — "jaraco.classes" = "*". That is one key whose name contains a dot, not a dotted key; quoting decides, not the dot. Conflating them silently invents a jaraco table.

$ ./target/release/stranger scan -v fixtures/uv-m.uv.lock

pip

requirements.txt. It is not a lockfile.

That is the first thing to say about it, because everything else follows. A requirements.txt is the input a resolver takes, not the answer it gives. There is no resolved version unless somebody typed ==, and there is no dependency information at all. It is read here because people commit it and then treat it as a lockfile.

$ ./target/release/stranger scan fixtures/poisoned.requirements.txt

  poisoned.requirements.txt 6 packages   (6 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     2
     python-dateutils@2.9.0   not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
     requests-http@1.0.2      not in corpus · d=2 from "requests-html" · no dependency graph in this format

  ⚠  UNPINNED               3     no exact version recorded

  ·  INSTALL SCRIPTS        — no signal in this format

  risk 79/100    6ms    third-party deps used to compute this: 0

The format is flat, and the detection rule pays for it

There are no transitive entries, no nesting, and nothing that says one line needs another. So every package is a root, roots is 0..packages.len(), and edges is left empty on purpose. That is not the reader giving up — the edges are not in the file to read.

Which makes the detection rule's third clause — nothing real depends on this namevacuous on this format. Every package trivially has in-degree 0, the clause eliminates nothing, and the rule degenerates to not-in-corpus AND near-a-real-name. Those two are exactly the half of the conjunction that the ablation table exists because nobody trusted on its own, so a pip scan is noisier than an npm scan by construction and no amount of parsing care changes it.

False positives has this costing a real one on a real fixture.

The upgrade is a different file, not a better reader, and both of those files read today. poetry.lock and uv.lock record the resolved graph:

$ ./target/release/stranger scan fixtures/poetry-m.poetry.lock

  poetry-m.poetry.lock     233 packages   (75 direct · 158 transitive)

  no findings
  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 0/100    10ms    third-party deps used to compute this: 0

158 of those 233 are transitive, and they can only be called transitive because the edges are in the file. That is the number requirements.txt cannot produce — it reports every package as direct — and clause 3 is the rule that spends it.

Which rules can fire

ruleon pip
slopsquatyes, with clause 3 doing nothing
pinningyes, and only here
install-scriptno — the format records nothing equivalent
drifttechnically, but a well-formed file cannot trigger it
trivialtechnically, but the name list is npm micro-packages

What it parses

PEP 508 requirements, which are more than a name and a version:

flask [async] >= 3.0 ; python_version < "3.10"
requests==2.31.0 \
    --hash=sha256:aaa \
    --hash=sha256:bbb
pkg @ https://host/pkg.whl

The order the pieces come off matters and is the reason the reader is longer than it looks.

Continuations join first, before anything else sees the text, which is also pip's order. The line number reported for a joined line is where the logical line started, so an editor gets sent to the right place.

Comments are (^|\s+)#.*$, not "cut at the first #". The difference is load-bearing: pkg @ https://host/x.zip#sha256=… puts a # in a URL fragment, and cutting at the first one truncates the URL into something that still parses.

Per-requirement options split by token, not by line, because continuations have already been joined. That runs before the marker is cut off — pip puts the options after the marker, and cutting at ; first would throw the hashes away.

The environment marker comes off before the version is read. A marker is expression syntax carrying the same operators a specifier does — ; python_version < "3.10" has a < in it — so a classifier that ran first would read the marker as a range and report a pinned requirement as unpinned. Order, not cleverness.

Extras come off after the name and before the specifier. flask[async]>=3.0 puts the bracket group between the two. Which extras were asked for is not recorded: an extra changes what gets installed alongside, never which version of this name gets installed, and the version is what this reader is for.

What it skips

-r base.txt and -c constraints.txt are not followed. An include means file IO, relative-path resolution and cycle detection, and it quietly turns one file's audit into a directory crawl. Point stranger at the other file.

-e editables are not packages.

--index-url and --extra-index-url are dropped, and that omission is worth naming rather than hiding. An extra index is the dependency-confusion vector, and a line adding one is more interesting than most of the packages under it. It is dropped because there is nowhere honest to put it: Tree holds packages, and this is a fact about the file. Minting a Package to carry it would put a lie in the package count and a fake name in the report. The upgrade is a field on Tree and a rule that reads it.

What it refuses

The line above is about what it skips — a line it can read and has nothing to say about. This is the other half: a line it cannot read at all. A requirements.txt is hand-edited, unlike every other format here, so the errors carry a position and quote the fragment.

$ mkdir -p /tmp/refuse-pip
$ printf '@ohno>=1\n' > /tmp/refuse-pip/a.requirements.txt
$ ./target/release/stranger scan /tmp/refuse-pip/a.requirements.txt
stranger: /tmp/refuse-pip/a.requirements.txt: `@ohno>=1` does not begin with a package name at 1:1
$ printf 'flask =!= 3\n' > /tmp/refuse-pip/b.requirements.txt
$ ./target/release/stranger scan /tmp/refuse-pip/b.requirements.txt
stranger: /tmp/refuse-pip/b.requirements.txt: `flask=!=3` is not a version specifier stranger understands at 1:1

An unclosed extras bracket is the third, and it has its own block further down under syntax errors.

The split between skipping and refusing is a judgement, and it is the one worth arguing with. A URL or a VCS reference is skipped because there is no project name in it for the corpus to be asked about, so the rules have nothing to say either way — and refusing the file over one of those used to take every other requirement in it down as well. A line that is meant to be a name and a specifier and is neither is refused, because reading it as anything would mean guessing what the author meant.

What the format does not record

  • A dependency graph. Covered above; it is the big one.
  • Install-time code execution. A source distribution can run whatever setup.py wants during installation, and this file does not say that it will.
  • A dev/test split. That lives in a second file whose name is not something to guess from.
  • Optionality. An environment marker is a condition, not npm's failure-is-tolerated optional, so optional is always false.
  • Integrity, usually. --hash=sha256:… is the only integrity this format has and it is opt-in. Presence is recorded; nothing verifies it.

Names are kept as written

Pillow, python-dateutil and python_dateutil are one project to PyPI under PEP 503, and three different strings in a file. Folding them at read time would have the report quote a name that is not in the file, so the reader keeps the spelling and the corpus normalises at comparison time instead:

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

Syntax errors carry a position

$ mkdir -p /tmp/bad
$ printf 'flask[async>=3.0\n' > /tmp/bad/requirements.txt
$ ./target/release/stranger scan /tmp/bad/requirements.txt
stranger: /tmp/bad/requirements.txt: `flask[async>=3.0` has an unclosed `[` in its extras at 1:1

Line and column are 1-based, counted on the logical line. For a joined line the column points at the start of the first piece, and the message quotes the fragment so the rest is findable.

$ ./target/release/stranger scan -v fixtures/reqs-xs.requirements.txt

go

go.mod. Like requirements.txt it is a manifest rather than a resolver's answer, and unlike requirements.txt that is almost fine.

$ ./target/release/stranger scan fixtures/gomod-m.go.mod

  gomod-m.go.mod           174 packages   (50 direct · 124 transitive)

  no findings
  ·  HALLUCINATION RISK     — no signal in this format
  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 0/100    0ms    third-party deps used to compute this: 0

No findings is the honest answer here rather than a clean bill of health, and the last section says why in a table.

The version on the line is a floor, and usually the ceiling too

Minimal version selection is what makes a manifest readable as a lockfile. Each require names a minimum, and the build picks the largest minimum anybody in the module graph named. Since Go 1.17 go mod tidy writes the whole build list into the file — every indirect module, at the version that was selected — so the file and the build agree unless somebody hand-edited one of them.

So every entry is Pin::Exact and unpinned requirements has nothing to say. Reporting these as ranges would fire that rule on all 174 entries above and be wrong 174 times.

The caveat is the go line. On a go 1.16 module the same file lists direct requirements only, and the rest of the tree lives in the module graph, which is not in this file and not reachable without the network. Both fixtures here are 1.17 or later.

// indirect is the entire graph

There are no edges in this format. A go.mod records that a module is needed transitively and never through what, so edges is empty, every package has in-degree 0, and the detection rule's third clause is vacuous here in exactly the way it is on a requirements.txt.

What the format does give up is the direct/transitive split, which is real and which a requirements.txt cannot produce at all: 50 direct against 124 // indirect in the fixture above.

What each directive does here

directivewhat stranger does with it
requireone Package, direct unless the line ends // indirect
modulethe module being audited — not a dependency, and its absence means this is not a go.mod
replacea target starting ./, ../ or / makes the module first-party; any other target takes its origin off the registry
excludeparsed, then dropped: it names a version that must not be selected, which is the opposite of a dependency
retract, go, toolchain, godebug, tool, ignoreconsumed, and nothing is read out of them
anything elsea syntax error with a line and column

The difference between consumed and skipped is the whole reason that fifth row exists. A retract block holds bare versions, and in the wild it holds [v1.11.0, v1.11.2] ranges as well:

retract (
	v1.4.1 // #218
	v1.4.0 // #218 panic on saveSessionTicket
)

A reader that skipped forward to the next line it recognised would read those as module paths and invent two packages out of a block that names none.

Refusing an unknown directive rather than skipping it is the same call the TOML subset makes. The go team keeps adding directives — toolchain in 1.21, godebug in 1.23, tool in 1.24 — and a reader that quietly ignores what it has not heard of will one day quietly ignore a require spelled slightly wrong.

Module paths may be quoted

The go grammar reads a module path as a Go string literal when it is written as one, in either spelling. This is not a corner of the format — gopkg.in/yaml.v3, one of the most depended-on modules in the ecosystem, ships a go.mod that quotes every path in it:

module "gopkg.in/yaml.v3"

require (
	"gopkg.in/check.v1" v0.0.0-20161208181325-20d25e280405
)

The quotes come off the path rather than travelling with it. That part is not cosmetic: a package named "gopkg.in/check.v1" — quotes included — is absent from every corpus and scores as a stranger on the strength of its own punctuation.

What stays refused is a path holding a Go string escape. The module proxy URL-encodes the path it fetches, so a path that needs an escape resolves to nothing, and reading \n as two characters would invent a package name no registry holds. Refusing it is the same call as the unknown directive above.

go.sum is not read

It was the obvious next file and it earns nothing. Three reasons, in ascending order of how much they settle it:

  • it holds a line for every module version in the graph, not the build list, because go mod tidy keeps hashes for versions that lost the selection — so counting packages out of it overstates the tree;
  • go mod tidy also guarantees a line for everything that is in the build, so has_integrity computed from it would be a constant true, and a constant is not a signal;
  • the field it would populate is presence and never correctness, because the standard library has no SHA-256 to check an h1: hash with. That is the same wall the npm reader hits on integrity, written up in Limits.

A second file, opened by a reader that is handed one string, for a column of true.

Which rules can fire

Effectively none, and the fixtures above report nothing at all.

ruleon go.modwhy
slopsquatnothere is no ranked list of module paths to be absent from
pinningnoMVS makes every entry exact
install-scriptnothe module system has no install-time hook to record
driftnoa module path appears once; two major versions are two paths
trivialeffectively neverthe list is npm micro-packages — but it matches the last path segment, so a module ending /is-foo would fire

Three of those are the format recording nothing the rule reads, one is the trivial list being about a different registry, and the first is a decision: proxy.golang.org publishes no ranked list of modules and a module path is a domain, so "not in the corpus" would mean "not in a list nobody publishes". corpus::names returns an empty slice for Go and slopsquat::scan stops on its first line, so the rule is off for this ecosystem by decision rather than by the arithmetic of an empty list. tests/gomod.rs hands it a corpus containing a one-edit neighbour of a real module in the tree and it still says nothing.

What is left is the tree, the split, and the package list:

$ ./target/release/stranger scan --format json fixtures/gomod-xs.go.mod
{"source":"fixtures/gomod-xs.go.mod","ecosystem":"go","packages":6,"direct":5,"transitive":1,"workspace":0,"integrity":0,"risk":0,"findings":[],"not_applicable":["slopsquat","install-script","pinning"]}

Syntax errors carry a position

An unterminated block is reported where it opened, not at end of file, because the opening line is the one worth going to:

$ mkdir -p /tmp/bad
$ printf 'module example.com/m\n\nrequire (\n\tgithub.com/pkg/errors v0.9.1\n' > /tmp/bad/go.mod
$ ./target/release/stranger scan /tmp/bad/go.mod
stranger: /tmp/bad/go.mod: `require (` is never closed at 3:1
$ ./target/release/stranger scan -v fixtures/gomod-xs.go.mod

yarn

yarn.lock, the v1 format.

$ ./target/release/stranger scan fixtures/yarn-l.yarn.lock

  yarn-l.yarn.lock         593 packages   (26 direct · 567 transitive)

  ⚠  TRIVIAL                23    (3.9% of third-party)

  ⚠  VERSION DRIFT          82    same package at 2+ versions in one tree

  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 46/100    <ms>    third-party deps used to compute this: 0

The fixtures are three real lockfiles: npm packages ship their own inside their tarballs, so pdf-lib supplies a 4,408-line one and uri-js a 2,558-line one. Neither was written for this reader.

Not YAML, despite reading like it

yarn v1 wrote its own format: entries at column 0, two-space fields under each, and quoting that is optional except where a value would otherwise break the line. yaml.rs is next door and deliberately unused — a YAML parser hands back lodash@^4.17.20, lodash@~4.17.0 as one scalar key that then has to be taken apart anyway.

The edges are specifiers, not versions

This is the one thing that makes yarn different from every other format here, and getting it wrong produces a tree with no edges rather than an error.

A dependencies line names a range:

"@babel/code-frame@^7.0.0":
  version "7.0.0"
  dependencies:
    "@babel/highlight" "^7.0.0"

The entry "@babel/highlight" "^7.0.0" points at is the one whose key list contains the literal specifier @babel/highlight@^7.0.0. Only the key list can answer that: ^7.0.0 never appears in the target's version field, which says 7.0.0. A reader that matched on the resolved version would find nothing, parse every entry successfully, and report a package count with an empty edge set — which reads as "nothing depends on anything" and hands every package an in-degree of zero, the exact shape the co-occurrence rule fires on.

One entry answers to several keys:

"@babel/generator@^7.9.0", "@babel/generator@^7.9.5":
  version "7.9.5"

75 of the 593 entries in yarn-l are written that way, and the file depends on that one through both keys. A reader that took only the first would silently lose every edge arriving through the second.

What it refuses

$ mkdir -p /tmp/berry
$ printf '__metadata:\n  version: 8\n' > /tmp/berry/yarn.lock
$ ./target/release/stranger scan /tmp/berry/yarn.lock
stranger: /tmp/berry/yarn.lock: this is a Yarn Berry (v2+) lockfile; stranger reads the v1 format

Berry is a real YAML document keyed name@npm:range with its own __metadata version counter. Same filename, different format.

An empty file is the other one, and it used to be the worst answer this tool could give:

$ mkdir -p /tmp/empty-yarn
$ printf '' > /tmp/empty-yarn/yarn.lock
$ ./target/release/stranger scan /tmp/empty-yarn/yarn.lock
stranger: /tmp/empty-yarn/yarn.lock: no entries and no `# yarn lockfile v1` header; this is not the lockfile its name claims to be

Every other reader here refuses a file that is not the thing its name claims — Cargo.lock wants a [[package]], go.mod wants a module, poetry.lock wants a metadata.lock-version. This one had no such check, because the v1 header is a comment and a hand-edited or concatenated lockfile can be missing it while its entries are perfectly good. That reasoning holds for a file with entries and quietly fails for a file without any: a truncated or zero-byte yarn.lock read as a clean tree of nothing, risk 0/100, and a --fail-on gate went green over a lockfile nobody had read.

So the header is required only when there is no other evidence. A project with no dependencies really does produce a header and nothing else, and that is still zero packages rather than an error.

Nested blocks

An entry's fields are key value pairs, except for the ones that are a bare name: opening a block indented under it. Six of them turn up:

blockread as
dependencies, optionalDependencies, peerDependenciesgraph edges
engines, os, cpu, dependenciesMeta, peerDependenciesMetaconsumed and dropped

peerDependencies is an edge for the same reason it is one in npm: a peer dep is a real maintainer writing down a real name, which is the evidence the detection rule wants, and an in-edge can only make that rule quieter. Both readers produce Ecosystem::Npm and stranger diff will put one against the other, so a name with in-degree 1 under npm must not have in-degree 0 under yarn — otherwise migrating a project between the two reports findings the migration did not introduce.

engines is the counter-case and the reason the block matters rather than the line: node ">=6" is the same two-token shape as a dependency line and names no package at all.

The bare header itself is the trap. peerDependencies: has no value on its line, so a field scanner that splits every line into key value has nothing to split and reports a syntax error — against a file that has none, and against most of the real yarn v1 lockfiles in existence, since peer dependencies are ordinary.

What the format does not record

The root manifest. Direct dependencies live in package.json, which is not this file, so roots is derived the way poetry's is: an entry nothing else depends on can only have arrived through the root. The derivation is wrong in one direction — a direct dependency something else also needs has an in-edge and drops out of the count.

Install scripts, dev-ness, and workspace membership. None of the three is in a v1 lockfile, so install scripts reports no signal in this format rather than nothing found.

Integrity. Present on all 593 entries of yarn-l and on none of yarn-xs, whose yarn predates the field. Reported as present or absent, never as checked — std has no crypto.

Gate a pull request

The lockfile is the artefact worth gating. It is the file a model's suggestion ends up in, it is the file nobody reads in a diff of 4,000 lines, and it is the file that decides what actually gets installed.

$ ./target/release/stranger scan --fail-on high .

Exit 1 if anything at or above high is found, 0 otherwise, 2 if the run itself was broken.

GitHub Actions

name: supply chain
on: [pull_request]

jobs:
  stranger:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/checkout@v4
        with:
          repository: keirsalterego/stranger
          path: .stranger
      - run: make -C .stranger
      - run: ./.stranger/target/release/stranger scan --fail-on high .

No toolchain setup step, no cache step, no registry credentials. The build has nothing to fetch, so the job works on a runner with the network firewalled off.

Pick a threshold and mean it

The five rules occupy four severities, so the threshold decides what the gate is actually for.

thresholdcatches
criticalhallucinated names only
highplus install scripts, plus unconstrained pip requirements
mediumplus version drift, plus pip ranges
lowplus trivial packages

--fail-on high is the reasonable default: hallucinated names and code that runs at install time, without failing a build over is-number.

npm-xl demonstrates the difference — it has install scripts and drift but no hallucinated names:

$ ./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

Note that --fail-on low will fail almost every real npm tree, because almost every real npm tree contains a micro-package. That is a statement about npm, not a bug, but it makes low unusable as a gate.

What the gate prints

$ ./target/release/stranger scan --fail-on high fixtures/poisoned.package-lock.json

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

  ⚠  HALLUCINATION RISK     3
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                35    (4.6% of third-party)

  ⚠  VERSION DRIFT          55    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 81/100    55ms    third-party deps used to compute this: 0
$ echo $?
1

The critical block is listed in full and the rest collapses to counts, which is the right shape for a CI log. Add -v if you want the whole thing in the job output; add -q to drop the header and risk line.

Colour is off automatically, because a CI log is not a TTY. If your runner renders ANSI and you want it, set CLICOLOR_FORCE=1.

Failing the build is not the only option

A finding is "no evidence this name is real", not proof of an attack. If your tree carries new packages often enough that a hard gate would cry wolf, post the report instead of blocking:

$ ./target/release/stranger scan --format json . > stranger.json

Exit code 0, machine-readable output, everything listed regardless of -v, and a human decides. The JSON output page has the field list, and False positives is worth reading before you choose which mode you want.

Do not gate on the risk score

risk is a band for the worst severity plus a saturating term for volume, and it is not calibrated against anything. Comparing today's score with yesterday's on the same project is meaningful; comparing two projects is meaningful only at the band, and a threshold on the number would be a number pretending to be a measurement.

--fail-on compares severities, which are at least defined, and it does not read risk at all. Gating on the band would be gating on --fail-on the long way round.

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

Audit a lockfile you did not write

Somebody sends you a repository. A contractor's handover, a candidate's take-home, a dependency you are about to vendor, an npm package you are considering. You want to know what is in the tree before you type npm install, because npm install is the part that runs other people's code.

$ ./target/release/stranger scan path/to/their/package-lock.json

Nothing is installed, nothing is resolved, no node_modules appears, and the registry is never contacted. The lockfile is read as text.

Why the order matters

The usual way to inspect a dependency tree is to install it first and then ask the installed tree questions. That is backwards for an unknown repository: the install is the risky operation.

That risk is measurable from the file itself, which is the point of the install scripts rule:

$ ./target/release/stranger scan -v fixtures/npm-m.package-lock.json | head -8

  npm-m.package-lock.json  576 packages   (20 direct · 556 transitive · 6 workspace)

  ⚠  INSTALL SCRIPTS        4     arbitrary code at install time
     esbuild@0.27.7                         runs code at install time · lockfile records the flag, not the script
     fsevents@2.3.3                         runs code at install time · lockfile records the flag, not the script
     sharp@0.34.5                           runs code at install time · lockfile records the flag, not the script
     unrs-resolver@1.12.2                   runs code at install time · lockfile records the flag, not the script

Four packages whose code runs before yours does, named before you install any of them. Reading the lockfile inverts the order: the tool never executes anything from the tree, and it has nothing of its own to execute either — an empty dependency manifest means auditing a hostile lockfile cannot pull a hostile parser.

On a file, not a directory

Point at the whole repository and discovery does the work: it walks six levels down, skipping node_modules and twelve other named directories, and picks up any of the seven filenames in lock::KNOWNpackage-lock.json, pnpm-lock.yaml, Cargo.lock, requirements.txt, poetry.lock, uv.lock, go.mod. On a handover that is what you want, because you do not yet know which ecosystems are in there.

Point at a single file and discovery is skipped entirely. The format is then chosen by the same suffix match, so an archived or renamed lockfile still reads:

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

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

  ⚠  HALLUCINATION RISK     3
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                35    (4.6% of third-party)

  ⚠  VERSION DRIFT          55    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 81/100    94ms    third-party deps used to compute this: 0

One path per run. A second positional argument is a usage error rather than a silently ignored one:

$ ./target/release/stranger scan a/package-lock.json b/package-lock.json
stranger: scan takes one path; got a second, `b/package-lock.json`

Loop in the shell if you have several:

$ for f in */package-lock.json; do ./target/release/stranger scan "$f"; done

Reading the result

The header tells you the size of what you were about to install. 757 packages (35 direct · 722 transitive) means 35 names somebody chose and 722 that came along.

Read the critical block first — it is listed in full because it is the answer. Then decide whether the counts underneath deserve a -v. On a tree you did not write, INSTALL SCRIPTS usually does.

A finding tells you a name has no evidence behind it, or that a package runs code, or that something is installed twice. None of that says the package is malicious, and a clean scan does not say the tree is safe. Limits is the honest list of what is not checked, and integrity hashes are at the top of it.

What it will not tell you

Whether the sha512-… integrity fields are correct. Whether an install script is benign. Whether a package that genuinely exists is nonetheless hostile. Those need either cryptography or the network, and this binary has neither.

$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json | head -14

Monorepos

A workspace member's package.json is a manifest, not evidence.

That sentence is the whole page, and it changes both numbers a monorepo scan produces.

The counting problem

npm records the whole workspace in one package-lock.json at the root. In a workspace layout the root manifest usually declares almost nothing — the actual dependencies live in apps/*/package.json and packages/*/package.json, and the lockfile carries them as entries keyed by directory.

A reader that took direct dependencies from the root entry alone would report zero direct dependencies for a 576-package project. Check the root entry yourself and the gap is stark:

$ jq '.packages[""] | {deps: (.dependencies|length), workspaces}' fixtures/npm-m.package-lock.json
{
  "deps": 0,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

What the reader actually reports:

$ ./target/release/stranger scan fixtures/npm-m.package-lock.json

  npm-m.package-lock.json  576 packages   (20 direct · 556 transitive · 6 workspace)

  ⚠  INSTALL SCRIPTS        4     arbitrary code at install time

  ⚠  TRIVIAL                17    (3.0% of third-party)

  ⚠  VERSION DRIFT          20    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 58/100    12ms    third-party deps used to compute this: 0

20 direct, because the dependencies a workspace member declares are dependencies this repository chose. npm-xl is the same story at larger scale: its root entry declares 2 dependencies across 5 workspace globs, and the reader reports 150 direct out of 1,376.

Three numbers, not two

576 packages (20 direct · 556 transitive · 6 workspace).

The lead count is third-party only. A workspace member is neither a direct dependency nor a transitive one — it is your own code — so it is excluded from both and reported separately. The file holds 582 entries; 6 of them are yours.

The workspace field only appears when there is one. npm-l has none and prints two numbers.

The detection problem

The counting is cosmetic. The consequence for the rules is not.

The detection rule fires only when nothing depends on a name. If an edge out of apps/desktop counted as a normal dependency edge, then a hallucinated name added to apps/desktop/package.json would arrive with in-degree 1, clause 3 would suppress it, and the tool would never look at it again. Exactly the scenario the tool exists for, hidden by exactly the layout most large JavaScript projects use.

So edges out of a first-party entry go into roots, not edges. Same manifest, same author, same absence of evidence as the root package.json.

$ mkdir -p /tmp/c && cat > /tmp/c/package-lock.json <<'EOF'
{
  "name": "monorepo",
  "lockfileVersion": 3,
  "packages": {
    "": { "workspaces": ["apps/*"] },
    "apps/desktop": { "dependencies": { "expres": "^4.18.2" } },
    "node_modules/desktop": { "resolved": "apps/desktop", "link": true },
    "node_modules/expres": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/expres/-/expres-4.18.2.tgz"
    }
  }
}
EOF
$ ./target/release/stranger scan /tmp/c

  package-lock.json        1 packages   (1 direct · 0 transitive · 2 workspace)

  ⚠  HALLUCINATION RISK     1
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent

  ·  UNPINNED               — no signal in this format

  risk 77/100    34ms    third-party deps used to compute this: 0

The workspace member declared the name and it is still reported. Put the same name behind a third-party package and it is not — that pair is on The co-occurrence rule.

Workspace members are never findings

Two kinds of entry are first-party. A key with no node_modules/ in it is a workspace directory. A "link": true entry is the symlink npm leaves in node_modules pointing at one.

Both are skipped before any rule runs, not only the detection rule. Your own build scripts are not INSTALL SCRIPTS findings, and your own package names are not TRIVIAL ones. npm-xl has 14 first-party entries, 7 links among them, and tests/rules.rs asserts none of them ever appears in a finding.

Discovery walks down, and the skip list is what makes that safe

stranger scan . at a workspace root does not read only that directory. It walks down from there, six levels deep, sorting as it goes and never following a symlink, and it refuses to enter node_modules, target, dist, vendor, nine other names and every dot-directory.

On a plain npm workspace the walk still ends up with the one lockfile npm actually wrote at the root, because the per-member package.json files are not lockfiles and have no resolved versions in them. The recursion earns its keep on the layouts where that is not true — a lockfile per app, or more than one ecosystem:

$ rm -rf /tmp/mono
$ mkdir -p /tmp/mono/apps/web /tmp/mono/services/api /tmp/mono/node_modules/vendored
$ cp fixtures/npm-xs.package-lock.json /tmp/mono/apps/web/package-lock.json
$ cp fixtures/npm-xs.package-lock.json /tmp/mono/node_modules/vendored/package-lock.json
$ cp fixtures/reqs-xs.requirements.txt /tmp/mono/services/api/requirements.txt
$ find /tmp/mono -type f | sort
/tmp/mono/apps/web/package-lock.json
/tmp/mono/node_modules/vendored/package-lock.json
/tmp/mono/services/api/requirements.txt
$ ./target/release/stranger scan --format json /tmp/mono | jq -r .source
/tmp/mono/apps/web/package-lock.json
/tmp/mono/services/api/requirements.txt

Three lockfiles on disk, two audited. The one under node_modules is somebody else's vendored copy, and a populated node_modules holds hundreds of them — walking into it turns one scan into four hundred irrelevant ones. tests/cli.rs::a_directory_scan_skips_vendored_lockfiles asserts the skip, and a_directory_scan_is_deterministic asserts the walk finds Cargo.lock, uv.lock and requirements.txt across the fixtures directory in sorted order.

Each file found produces its own report block:

$ 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 /tmp/mixed

  package-lock.json        1 packages   (1 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     1
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent

  ·  UNPINNED               — no signal in this format

  risk 77/100    17ms    third-party deps used to compute this: 0


  requirements.txt         6 packages   (6 direct · 0 transitive)

  ⚠  HALLUCINATION RISK     2
     python-dateutils@2.9.0   not in corpus · d=1 from "python-dateutil" · no dependency graph in this format
     requests-http@1.0.2      not in corpus · d=2 from "requests-html" · no dependency graph in this format

  ⚠  UNPINNED               3     no exact version recorded

  ·  INSTALL SCRIPTS        — no signal in this format

  risk 79/100    6ms    third-party deps used to compute this: 0

--fail-on compares against the worst severity across all of them. --format json emits one object per line rather than an array — see JSON output.

$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json | head -3

A project whose toolchain you do not have

You have a repository in a language you do not build. No node, no npm, no cargo, no python. You still want to know what its dependency tree contains.

$ ./target/release/stranger scan .

The lockfile is already on disk. Somebody's toolchain wrote it, and it records resolved versions, integrity fields, install-script flags and the dependency edges between them. Reading it needs a JSON parser, not a package manager.

Why this is allowed

The hackathon rules forbid shelling out to installed tooling. Their FAQ rules this design in explicitly:

Parsing files those tools already produced is fine, because nothing third-party ends up in your artifact.

Two conditions attach and both are met.

Disclosed. The corpus of known-real names and the fixture lockfiles are written up in corpus/PROVENANCE.md, fixtures/README.md and STDLIB.md, with dates, sources and counts. Both are data. Nothing there is compiled as source, nothing executes, and the corpus is embedded with include_str! and read as text.

Degrades gracefully. No lockfile is not an error. The tool says what it looked for and exits 0:

$ 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

That message names the formats rather than saying "nothing found", so the answer to "why did it not scan my project" is in the output instead of in the source. The list comes from the same constant discovery uses, so it cannot drift out of date.

The corpus degrades honestly too

If the compiled-in name list were empty, every name would fail clause 1, the nearest-neighbour search would find nothing, and the rule would report nothing. An absent corpus makes the tool quiet, not hysterical.

That is not hypothetical. Ecosystem::Go runs in exactly that state on purpose: go.mod reads, proxy.golang.org publishes no ranked list and module paths are domains, so there is no Go corpus and the detection rule can never fire on one. The rule does not even reach its first clause there — an empty corpus stops it — so it is off by decision rather than by arithmetic. Named in Limits and go rather than shipped as a rule that silently does nothing.

A filename it does not recognise

$ 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

The match is on the end of the filename and nothing reads the contents to second-guess it, so a file renamed at the front still works and one renamed at the back does not. It is a name it does not know rather than a format it cannot parse — that file is a perfectly ordinary requirements.txt under another name.

Pointing at a specific file you believe is a lockfile and being wrong is a usage error — exit 2. Scanning a directory that happens to hold nothing readable is not — exit 0. The difference is whether you asserted something.

All eight formats read, which is the point of this page: Cargo.lock needs no cargo, uv.lock needs no uv, pnpm-lock.yaml needs no pnpm or Node, yarn.lock needs no yarn, and go.mod needs no go. A semver comparator (src/semver.rs) exists and is tested and is not wired into the binary, because no rule has yet asked an ordering question.

No network, checkable

$ cargo tree
stranger v0.1.0 (/home/keir/stranger)

$ grep -c '^\[\[package\]\]' Cargo.lock
1

One package. Rust's standard library has no TLS and no HTTP client, so a binary with no dependencies cannot open a socket. CI enforces all three of those — the one-line cargo tree, no unsafe in src/, and no Command::new anywhere — so the claim fails the build rather than quietly rotting.

Run the scan with the cable out and it behaves identically. So does the build:

$ CARGO_NET_OFFLINE=true cargo build --release --locked --offline
    Finished `release` profile [optimized] target(s) in 0.01s
$ ./target/release/stranger scan /tmp/empty; echo $?

stranger, and the site serving this page

Two scans that are worth running because of what they are, not what they find.

The empty manifest, checked by the tool that argues for it

stranger's whole claim is that it has no dependencies. It can make that claim about itself, in its own output format, using the same reader it points at anybody else's project:

$ ./target/release/stranger scan Cargo.lock

  Cargo.lock               0 packages   (0 direct · 0 transitive · 1 workspace)

  no findings
  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 0/100    0ms    third-party deps used to compute this: 0

0 packages. The one workspace entry is stranger itself — a [[package]] with no source key, because there is nowhere to fetch it from, which is the same test the cargo reader applies to any path dependency.

That last line of the footer is fixed text, not a computation. It says the same thing on every scan, and this is the one run where you can check it against the line above it.

The other two ways to ask are in Reproducible builds and deps-proof.txt:

$ cargo tree | wc -l
1

$ grep -c '^\[\[package\]\]' Cargo.lock
1

Three different questions — the resolver's, the lockfile's, and the auditor's — and they agree.

The site that serves this book

This page is published at keir.is-a.dev/stranger/. The domain is served by an Astro site in a separate repository, and that repository has a pnpm-lock.yaml with 850 packages in it. Running stranger against the thing hosting stranger's documentation is the closing check, and it is published whatever it says:

$ ./target/release/stranger scan ~/keir.is-a.dev

  pnpm-lock.yaml           850 packages   (29 direct · 821 transitive)

  ⚠  TRIVIAL                23    (2.7% of third-party)

  ⚠  VERSION DRIFT          58    same package at 2+ versions in one tree

  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 46/100    146ms    third-party deps used to compute this: 0

There used to be a hallucination finding here, and it was wrong

This section said, for most of the weekend, that the scan above reported one HALLUCINATION RISK and that it was a false positive. It is gone from the block — not tuned away, and the difference matters.

taze is a real, maintained npm package for updating dependency ranges, it sits outside the most-downloaded 140,066 names, and clause 3 had no chance to save it: nothing depends on taze, because a devDependency of the root manifest genuinely has in-degree zero. The neighbour the tool named, gaze, is one deletion away and real. The page called that the rule working correctly on bad information, and left it in on the grounds that a better screenshot would have made a worse tool.

That reading was too generous to the rule. taze is four characters long, and a four-character name has a neighbour within two edits 100% of the time on npm — so clause 2 was not weighing evidence about taze, it was passing everything. The finding was not the corpus being incomplete. It was the threshold being a constant where it should have been a function of length. distance::CHARS_PER_EDIT is the fix and the measurement behind it.

So what this page now shows is a scan with no hallucination finding on it, which is a weaker screenshot and a truer one. The rule still gets tensorflow-gpu wrong, in a requirements.txt fixture, for a reason no length policy can fix — that one is fourteen characters, and at fourteen characters a near-miss really is evidence. False positives keeps it.

The lockfile is already a fixture

fixtures/pnpm-l.pnpm-lock.yaml is that file, byte for byte:

$ sha256sum ~/keir.is-a.dev/pnpm-lock.yaml fixtures/pnpm-l.pnpm-lock.yaml
a04b16fb54b274f40d9fef0dbad27616c1e6755409383c5a07e106075c23981a  …/keir.is-a.dev/pnpm-lock.yaml
a04b16fb54b274f40d9fef0dbad27616c1e6755409383c5a07e106075c23981a  fixtures/pnpm-l.pnpm-lock.yaml

Which means the pnpm reader was developed against this exact file and taze was a known false positive from before the reader shipped until the last day of the window. It was left in rather than removed from the fixture, and that is the only reason the length budget could be measured against it at all: a detector whose test data has had its inconvenient case deleted has nothing left to measure a fix with.

Try it on yours

$ ./target/release/stranger scan ~/some-project

No install, no resolve, no network, and no toolchain for the ecosystem you are auditing — see a project whose toolchain you do not have.

Decisions

Why the things are the way they are, written as they were decided rather than reconstructed afterwards.

The repository also carries DECISIONS.md, which is the submission's own copy and includes a written defence section. Where the two overlap, the root file is canonical — this page is the readable one, and the table at the bottom says which decisions live on other pages of this book instead.

One crate, not a workspace

stranger is twenty-five source files in one crate. It could be stranger-json, stranger-toml, stranger-lock and so on, and that would look more serious.

A workspace here would be an abstraction with one consumer. None of these modules are separately useful, none are separately versioned, and splitting them buys a longer build and a Cargo.lock with more [[package]] blocks in it. For a project whose central claim is that the file contains exactly one, that is an actively bad trade.

Modules give the same separation. cargo build already tells me if json.rs broke npm.rs.

Skipping the Single File bonus

There is a bonus for shipping as one file. Twenty-five files crushed into one main.rs trades a 25% criterion for a 5% bonus, and the 25% one is code quality judged by somebody who will not enjoy scrolling past a JSON parser to reach an argument parser.

Declining it with a reason reads as judgement. Declining it silently reads as an oversight.

The corpus is data, and it is compiled in

160,066 package names ship inside the binary through include_str! — 2,960,053 bytes of text in a 4,064,792-byte release binary. Nearly three quarters of the binary is corpus.

The alternative, fetching at runtime or reading a cache directory, would have made the central claim false. stranger works on a plane. There is no corpus not found failure mode, no first-run download, no stale-cache logic, and no code path where a network timeout changes the answer. Three megabytes deletes an entire category of failure.

Rust's standard library has no TLS, so a network request here is not merely against policy, it is unavailable. That is worth stating as a design property rather than apologising for as a limitation.

Why the in-degree clause exists

Edit distance is not a rule. http-proxy-agent is one edit from https-proxy-agent; both are real, both are in the corpus, and both are depended on by other packages in npm-xl. So are safe-buffer and safer-buffer, also one edit apart. Take just the 1,077 distinct names the npm fixtures install: between them they have 9,453 neighbours within distance 2 in the corpus, every one a package that exists. A threshold loose enough to catch a typo catches legitimate siblings, and precision collapses.

The observation that separates them is not about spelling:

A hallucinated package is a root dependency. Nothing depends on it, because nothing real has ever heard of it. A model put it in your manifest; no maintainer ever put it in theirs.

The only reference to lodahs — one transposition from lodash — anywhere in the world is the manifest being audited. Real packages, including the boring near-miss siblings, are depended upon by other packages.

So the rule is a conjunction: not in corpus, and within edit distance 2, and in-degree zero. The co-occurrence rule has the detail, including why the clauses are evaluated 1, 3, 2.

The refinement the fixtures forced

The first version counted every dependency edge as evidence. Both monorepo fixtures then reported zero direct dependencies for projects of 582 and 1,390 lockfile entries, because both declare workspaces and keep almost nothing in the root manifest.

Fixing that by "also read the workspace members" would have been wrong in an interesting way. An edge out of a workspace member is the same manifest, by the same author, as the root. If a model wrote apps/desktop/package.json, a hallucinated name in it arrives with an in-edge and clause 3 never looks at it.

Workspace-member edges are recorded as roots, not as evidence. Same author, same absence of independent confirmation. Monorepos covers what that looks like in use.

Measuring the idea instead of asserting it

Against the full corpus the clause is worth exactly nothing: 1.000 precision and 1.000 recall with it and without it. That result is at the top of the ablation table, because a measurement that undercuts my own idea is the one most worth publishing.

It measures nothing because the corpus contains every package in every fixture, so clause 1 alone suffices and nothing else can show a difference. No real corpus has that property. So the experiment thins the corpus and re-measures: at 90% coverage, the clause takes false positives from 36 to 1 at no cost to recall.

Making the corpus a parameter of the rule rather than a global is what made that measurable. An assumption you cannot vary is one you cannot measure.

A corpus can only speak about one registry

The Cargo reader landed and immediately produced three findings on cargo-m.Cargo.lock. Two were wrong in a way worth fixing rather than documenting: slint and sg are real crates fetched straight from git. They never went through crates.io, so a crates.io corpus cannot have heard of them, and only workspace members reference them, so nothing depends on them either. All three clauses fire on a package that is entirely legitimate.

The bug was treating "absent from a list" as evidence when the list was never asked. Packages now carry an origin, and the name rules stay quiet unless the lockfile says the package came from the registry the corpus samples.

What that does not fix is a real registry package outside the corpus. False positives has the three live ones.

The TOML subset

One parser reads Cargo.lock, poetry.lock and uv.lock — three formats for the price of one, which bought more per line written than anything after JSON.

Accepted: key = value, [table] and [dotted.table] headers, [[array.of.tables]], basic strings with the full escape set including \uXXXX and \UXXXXXXXX, literal strings, multi-line strings with the line-ending backslash fold, decimal integers with _ separators, booleans, arrays over as many lines as they like with a trailing comma allowed, single-line inline tables, and # comments.

Refused, each with a line and column: floats, dates, times and date-times as bare values; hex, octal and binary integers; dotted keys outside a table header; inline tables spread over several lines, which is TOML 1.1; duplicate keys; and a [table] header that reopens a table already defined.

Refusing beats guessing. A parser that improvises at a construct it does not know produces a plausible wrong answer, and a plausible wrong answer in a security tool is worse than an error.

Three things the real files taught that guessing would have missed:

  • The subset is only sufficient because uv.lock stores timestamps as stringsupload-time = "2026-03-26T01:21:00.379Z". If it wrote them as TOML datetimes, this parser would refuse the file, loudly. The only bare integers in all six fixtures are version and revision: 1, 3 and 4.
  • poetry writes quoted keys containing dots: "jaraco.classes" = "*". That is one key whose name contains a dot, not a dotted key. Quoting decides, not the dot — conflating them silently invents a jaraco table.
  • No triple-quoted string appears anywhere in the corpus. Not one, across six real lockfiles, contrary to what I assumed going in. They are implemented because a lockfile is allowed to contain one and mis-reading it would be worse than refusing it, but nothing here exercises them.

Two booleans, and everything else stays a string

YAML 1.1's implicit typing turns no, on, off and y into booleans. That is the Norway problem, and in a supply-chain tool it is not a curiosity.

no, on, y and off are all registered npm package names. A reader that turned the key no@1.0.0 into a boolean would drop a package out of an audit without saying anything. no and on are in corpus/npm.txt; y and off are registered on npm and are not, because the corpus is the top 140,066 names by download count and not the whole registry. Which is the same distinction the tool makes about Origin::Elsewhere: absence from a popularity sample is not evidence a package does not exist. So yaml.rs types exactly two tokens — lowercase true and lowercase false, which the fixture genuinely needs for hasBin and optional — and leaves null, ~, Yes, 010 and 1e3 as strings.

What the xorshift is for

Rust's standard library has no random number generator, and two things need one: property tests over random short strings, and deterministic corpus thinning for the ablation.

Five lines of xorshift64*, seeded from SystemTime nanoseconds in the property tests — and printed, so a failure replays — and from a fixed constant in the ablation, so the published table reproduces.

It has a short period and fails statistical tests a real generator passes. That is fine for four-letter strings out of a four-character alphabet. It would not be fine for anything security-sensitive, and nothing here is: stranger computes no hashes, verifies no signatures, holds no key material and makes no nonces. The rules forbid rolling your own crypto, and this is not crypto.

Reading files that other tools produced

The hackathon rules forbid shelling out to an installed tool, and the FAQ rules this design in explicitly:

Parsing files those tools already produced is fine, because nothing third-party ends up in your artifact.

Two conditions attach and both are honoured. It is disclosed, in STDLIB.md and corpus/PROVENANCE.md. And it degrades gracefully: a directory with no lockfile prints what it looked for and exits 0.

stranger never executes another program. Not npm, not pip, not cargo, not git. There is no std::process::Command anywhere in src/, and CI fails the build if one appears. A project whose toolchain you do not have demonstrates the point.

Decisions that live on other pages

Decisions about using the tool live where somebody meets them:

decisionpage
why exit 2 is separate from exit 1Exit codes
why non-critical rules collapse to a countYour first scan
the colour precedence orderYour first scan
why clause order is 1, 3, 2The co-occurrence rule
why distance 2 and not 3The co-occurrence rule
why each rule has the severity it hasits own page under the other four rules
why versions are compared for equality onlyVersion drift
why the trivial list is hand-writtenTrivial packages
why --index-url is droppedpip
the three reproducible-build settingsReproducible builds

What was traded away

The crate-by-crate accounting of what the standard library replaced — serde_json, clap, toml, serde_yaml, semver, strsim, walkdir, glob, owo-colors, comfy-table, is-terminal, rand, rayon, crossbeam-channel, itoa, once_cell, anyhow and thiserror — is in STDLIB.md, with download counts and an honest note on what each substitution gave up.

It also carries the disclosure of data that is not code: the name corpus and the fixture lockfiles. That disclosure is one of the two conditions attached to the FAQ ruling above.

Check the ones that are checkable

Several decisions on this page are assertions a reader can test rather than take on trust. These are the commands, and they need no network:

$ grep -rn 'Command::new' src/ | wc -l
0

$ cargo tree | wc -l
1

$ grep -c '^\[\[package\]\]' Cargo.lock
1

$ make ablation

The last one re-derives the corpus-decay table, which takes about four seconds.

Limits

What this tool does not do. A hidden limitation reads as an oversight; a named one reads as judgement.

The four that matter most are argued in full in the LIMITS section of the README, which is the canonical home for them. Restated here in one line each so this page is not misleading by omission, and then the rest, which the README does not carry.

The headline four

Integrity hashes are never verified. Rust's standard library has no cryptography of any kind, so the reader records whether an integrity field is present and never whether it is correct.

hasInstallScript is a bare boolean. Code runs at install time; what that code is lives in a tarball on the registry, and stranger does not fetch. See Install scripts.

No Go corpus. go.mod reads — 174 requirements out of gomod-m, 50 direct against 124 // indirect — and the name is the part that does not. proxy.golang.org publishes no ranked list and module paths are domains, so the corpus is empty on purpose and the detection rule can never fire on a Go module. tests/corpus.rs asserts that emptiness so it stays intentional, and tests/gomod.rs asserts the silence. See go.

Flat formats have no graph. requirements.txt and go.mod record no dependency edges, so clause 3 is vacuous and the detection rule runs on two clauses — on requirements.txt, at any rate, being the one of the two where it runs at all. See pip and, for it costing a real false positive, False positives. stranger tree says the file has no graph rather than reporting the in-degree 0 it would find there.

Seven formats, five rules, and most pairs are not real

package-lock.json (lockfileVersion 2 and 3), pnpm-lock.yaml (v9 and v6), Cargo.lock (v3 and v4), poetry.lock, uv.lock, requirements.txt, go.mod. Four ecosystems, three shared parsers — JSON, YAML, TOML — with requirements.txt and go.mod reading their own lines, and one graph model.

The grid below is the useful limit, because most of it is empty. A rule that cannot fire on your ecosystem is not protecting you from anything:

rulenpmpnpmcargopoetry / uvrequirements.txtgo.mod
slopsquatyesyesregistry crates onlyyesyes, weakenednever — no corpus
install-scriptyesnevernevernevernevernever
trivialyesyeseffectively nevereffectively nevereffectively nevereffectively never
driftyesyesyesyesnot on a well-formed filenever
pinningneverneverneverneveryesnever

Most of the install-script row is one fact repeated. install_script is a field npm has and nobody else records — pnpm does not carry it, Cargo.lock says nothing about build.rs, and neither poetry nor uv notes that a package runs setup.py. The reader sets the flag to false and each one says so in its module docs, so a quiet report on those four formats means not measured, never safe.

go.mod is the one cell in that row where false is a measurement: the module system has no install-time hook to record. go mod download fetches and unpacks a zip, and nothing in it runs until you build.

trivial is a hand-written list of npm micro-packages plus a predicate-shaped name heuristic. Nothing stops it running elsewhere and nothing makes it useful there; on pnpm-l it fires 23 times because pnpm packages are npm packages.

pinning is the mirror image: every other format on this list records an exact version for every entry, so the rule has nothing to say and never says it.

slopsquat on cargo is narrowed on purpose. A crates.io corpus can only speak about crates.io, so a package the lockfile marks as coming from git or a path is skipped rather than reported — why that matters. On go.mod it is not narrowed but switched off, because there is no ranked list of module paths for a name to be absent from and pretending otherwise would make every module a candidate.

A clean npm scan means: no name is absent-from-corpus-and-near-a-real-one-and- unvouched-for, nothing declares an install script, nothing appears at two versions, and nothing is a known micro-package. It is not an audit.

The corpus is a snapshot, and it lists existence, not safety

140,066 npm names, 15,000 PyPI, 5,000 crates.io, fetched on 2026-08-28 in one pass. A package published after that date is indistinguishable to clause 1 from a package that does not exist. The ablation table measures how fast that ages.

The corpus is also a list of names that exist, harvested from the registries. A typosquat that actually got registered is in the corpus, passes clause 1, and is never reported.

The trivial rule measures nothing

Named here because it is the loudest rule and the least trustworthy. It has no access to file sizes, line counts or export lists — none of that is in a lockfile — so it recognises names, using a hand-written list of two dozen and a shape heuristic. is-callable and is-docker are both reported and neither is a one-liner. Trivial packages has the argument.

Discovery matches names, not contents

stranger scan <dir> recurses, but it finds a lockfile by its filename. The seven names in lock::KNOWN are the whole list:

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

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

The match is on the end of the filename, so a prefixed copy is still found — poisoned.package-lock.json and npm-xl.package-lock.json both scan, which is why the fixtures directory in this repository works. A file renamed at the other end does not: requirements-dev.txt and deps.lock are invisible to a directory scan, and nothing inspects contents to second-guess that. Point at such a file directly and the reader is chosen by the same suffix rule, so it stays invisible there too.

Two of those cuts are guesses rather than facts, and both are now printed under -v as not descended into, with the reason beside each one — a policy that hides a lockfile hides a lockfile, whether or not the policy was reasonable.

dist is the guess. Most dist/ directories are build output and auditing them is noise; some are the directory a package is published from, and the skip list cannot tell those apart. The hidden-directory rule is the bigger cut by far — every name starting with . goes, not just the seven dotted names below — and .ci and .github are both places people keep a real lockfile.

Fourteen lockfile names are recognised and have no reader: Gemfile.lock, Pipfile.lock, Podfile.lock, bun.lock, bun.lockb, composer.lock, conan.lock, go.sum, gradle.lockfile, mix.lock, packages.lock.json, pdm.lock and pubspec.lock. Finding one prints found but not read: and exits 0. Naming a file it will not read is not the same as reading it, and it is a great deal better than what it replaces — eight lockfiles in a directory used to print "no lockfile", which tells somebody with a bun project that their repository has no lockfile.

walk::SKIP names thirteen directories the walk will not enter — node_modules, .git, target, vendor, .venv, venv, __pycache__, .tox, .mypy_cache, .pytest_cache, dist, .next, .svelte-kit — and every other dot-directory is skipped by a separate check, which is why the seven dotted names on that list are belt and braces. The walk caps at MAX_DEPTH = 6, sorts for determinism, and does not follow symlinks. Auditing four hundred vendored lockfiles belonging to other people is worse than auditing none, and tests/cli.rs::a_directory_scan_skips_vendored_lockfiles asserts a lockfile inside node_modules is not picked up.

Depth 6 counts directories below the one you named: a lockfile six levels down is found, and one seven levels down is not. A dist/ or .cache/ you actually wanted audited has to be named as the scan path directly.

Numbers that do not quite line up

The human report prints risk N/100 and 100 is not a score this tool can produce. The number is a band for the worst severity — critical is 75 — plus 24 * n / (n + 8) for the count at that severity, and that term is below 24 for every n. So the real ceiling is 98, it takes 184 critical findings in one tree to reach it, and the worst fixture in this repository sits at 81. The /100 is a denominator readers expect rather than one the arithmetic produces; JSON output documents the honest range, 0–98.

The workspace count used to be the entry here — the human report printed it and the JSON object did not. It does now, as workspace, so a consumer no longer has to parse the header line to tell a monorepo from a flat project of the same size.

The risk score is not a measurement

A band for the worst severity present — critical 75, high 50, medium 25, low 1 — plus a saturating term for how many findings share it. It is not calibrated against anything, and there is nothing honest to calibrate it against. Two projects are comparable at the band and not below it. Two scans of the same project are comparable outright.

Gate on --fail-on, which compares severities, and not on this number.

It used to be the weights summed and capped at 100, which saturated on any real tree: nine of the sixteen fixtures here scored exactly 100, including both poisoned.package-lock.json and the clean npm-l it was built from. That is fixed, and the number is still a handle rather than a measurement.

Parser details worth knowing

JSON numbers are parsed as f64. RFC 8259 puts no limit on magnitude or precision and f64 does; nothing in a lockfile is a number this tool does arithmetic on, so the lossy case is unreachable in practice rather than handled.

Duplicate JSON object keys resolve last-one-wins, which RFC 8259 declines to specify.

Nesting deeper than 128 levels is an error rather than a stack overflow, and it carries a position:

stranger: nesting deeper than 128 at 1:129

The margin is wider than that number suggests. Every package-lock.json fixture here nests to exactly 5 containers, npm-xl included — the root object, then packages, then one entry, then peerDependenciesMeta, then one peer name. npm writes a flat map keyed by install path rather than a tree, so the depth does not grow with the size of the project; npm-xs at 37 packages and npm-xl at 1,376 nest identically.

The pip reader does not follow -r includes and drops --index-url lines, which is the more interesting of the two omissions — an extra index is the dependency-confusion vector. pip explains why there is nowhere honest to put it yet.

Column widths assume ASCII names

Cell width is one column per Unicode scalar, which is wrong for East Asian wide forms, combining marks and emoji ZWJ sequences. Registry names are ASCII in practice — npm permits only URL-safe characters, PyPI normalises to [a-z0-9.-], crates.io to [A-Za-z0-9_-] — so it is exact for every name a lockfile can hand over, and a name with an accent in it still lines up. If a registry that permits CJK identifiers turns up, this needs a generated width table.

$ ./target/release/stranger scan -v fixtures/npm-xl.package-lock.json

Building

$ make
cargo build --release --locked
   Compiling stranger v0.1.0 (<your clone>)
    Finished `release` profile [optimized] target(s) in 2.18s

Under three seconds cold, because there is nothing to compile except this crate.

Targets

targetwhat it runs
makecargo build --release --locked
make testcargo test
make lintcargo clippy --all-targets -- -D warnings then cargo fmt --check
make fmtcargo fmt
make ablationthe decay table, four seconds since the prefilter landed
make bench50 timed runs on the largest fixture
make proofregenerate deps-proof.txt
make reprobuild twice, compare hashes
make demothe twelve beats of the submission video, run rather than typed
make sweepevery lockfile on this machine, read twice and compared
make cleancargo clean

Tests

$ make test

405 tests across 22 files, plus 15 unit tests inside src/. Five are #[ignore]d because they are slow — the corpus-decay ablation, the false-positive-by-length sweep, two deep fuzz campaigns and the JSON differential run — and each has a make target or a script beside it.

The counts below are grep -c '#\[test\]' tests/*.rs, so they are re-derivable rather than remembered. They went stale twice before that was written down.

filetestswhat it covers
tests/cli.rs47exit codes, -q, -v, blind spots, a flag borrowed from a sibling command, and no escapes down a pipe
tests/yaml.rs36the subset, literal block scalars, the flow indicators that used to invent a key, and a linearity bound on flow collections
tests/toml.rs34the accepted subset, every construct refused with a position, and the header depth that used to abort in Drop
tests/json_conformance.rs30RFC 8259 clause by clause, each test citing its section
tests/pip.rs29PEP 508 shapes, continuations, comments, markers, extras
tests/pnpm.rs25the three sections, v6 alongside v9, and that two legal spellings of one lockfile agree
tests/gomod.rs20require blocks, pseudo-versions, retract, replace, exclude, quoted paths
tests/walk.rs18the skip list, depth cap, sorted order, symlinks, and what it could not open
tests/term.rs18the four-input colour decision table, column widths, control-character replacement
tests/tree.rs17in-degree, out-edges, depth, near names, and the flags a reader set
tests/json.rs17malformed input, surrogate pairs, deep nesting, error positions
tests/yarn.rs19specifier-keyed edges, the nested blocks an entry carries, Berry and the headerless empty file refused by name
tests/pypi.rs16poetry and uv, and clause 3's share under corpus decay
tests/cargo.rs14the three shapes of a dependency string, workspace members, git origins
tests/rules.rs12all five rules against the fixtures
tests/corpus.rs12sortedness, PEP 503 normalisation, length bucketing, the false-positive-by-length table
tests/distance.rs11the OSA counterexample, Damerau against plain Levenshtein, three property tests
tests/semver.rs10precedence, including prerelease ordering
tests/npm.rs9fixture counts, nested entries, workspace members, refused versions
tests/fuzz.rs5mutation campaigns over every parser and all eight readers
tests/ablation.rs4the three tables
tests/fixtures.rs2every npm fixture parses and its count is what it should be

tests/cli.rs drives the built binary rather than the library, because exit codes and stdout are the actual contract with a CI job and neither is visible from inside main.

The distance property tests are seeded from the clock so repeated runs explore different inputs, and the seed is printed on failure so a bad case can be replayed. The corpus test asserting byte-order sortedness is not decoration: binary_search on an unsorted slice returns a wrong answer quietly, and shell sort is locale-dependent, so the files are generated with LC_ALL=C and checked rather than trusted.

The sweep

$ make sweep

The 23 fixtures were chosen partly because they are interesting: a v6 pnpm lockfile, a yarn entry answering to two specifiers, a retract block full of bare versions. That biases them. They are good at the hard case and say nothing about the ordinary one, because nobody picked an ordinary file to include.

A developer's disk is a few thousand lockfiles picked by nobody. make sweep runs every one it can find through the reader for it and separates two outcomes: a refusal — a lockfileVersion this tool does not read, a Berry file wearing yarn's name — is an answer and counts as a pass. A file the reader could not get through is a bug, and the script exits 1 on one.

On the machine this was written on, 1,484 lockfiles across all eight formats: 8 refused, 0 unread.

Getting there took four fixes, and this is the part worth stating plainly — every one of the four was in the ordinary case, and not one was reachable from the fixtures:

what refused a valid file
yarna bare peerDependencies: header
yamla deprecated: |- block scalar
go.moda quoted module path — gopkg.in/yaml.v3 ships one
diffprinted no change and exited 1 in the same breath

Two of them had a comment beside the bug asserting the case did not arise. The go.mod one said nothing in the wild does this, and the counterexample was already on the disk.

The sweep looks for one failure and the yarn reader had the opposite one, which no amount of sweeping would have found: an empty file read as a clean tree of nothing rather than being refused. A pass that only asks did the reader get through it scores that as success. It was caught by feeding every reader a zero-byte file of its own name and noticing that seven said this is not the lockfile its name claims and one said risk 0/100.

Getting through is not the same as being right

That first pass catches a refusal and misses the worse failure: a reader that gets through a file and returns the wrong number. The fixtures cannot catch it either, because the expected counts in tests/ were produced by this reader — a test written that way pins the behaviour, it does not check it.

So the second half of make sweep counts the same files again in Python, from each format's spec rather than from src/:

counted independently as
Cargo.lock[[package]] blocks; one with no source is a workspace member
package-lock.jsonentries under packages, minus the root, workspace directories and links
yarn.lockentry headers, the only lines at column 0
pnpm-lock.yamlkeys at one indent under packages:, counted by line rather than parsed
go.modpaths under require, in both spellings, and under no other directive
poetry.lock[[package]] blocks — poetry writes none for the root, so all of them
uv.lock[[package]] blocks, minus the one whose source is not a registry
the drift rulenames holding more than one version, recomputed from the raw file

Python's own json module doing the npm parse is the point of that second row: the hand-rolled src/json.rs and a mature implementation have to agree on a hundred real files, or one of them is wrong. The three TOML rows are the same argument aimed at src/toml.rs.

The pnpm row is line-based on purpose. src/yaml.rs is the thing under test, and an oracle that parsed the document with the same shape of parser could agree with it for the same reasons both were wrong. A pnpm lockfile puts its package keys at exactly one indent under a top-level packages: and nothing else there, which a line scan can see without a YAML parser at all.

1,373 lockfiles crosschecked, 0 mismatches: 1,058 Cargo.lock, 146 go.mod, 107 package-lock.json, 41 yarn.lock, 16 pnpm-lock.yaml, and four each of poetry.lock and uv.lock.

Seven of the eight readers have a second opinion. requirements.txt is the one that does not, and deliberately: a line in it is a requirement, so any oracle simple enough to be independent gets the answer wrong. A naive one counts 361 requirement lines across the 35 files on this machine where the reader counts 197, and the reader is right — 162 of the difference are --hash continuation lines, which belong to the requirement above them. An oracle that knew that would be a second copy of the reader, and two copies of one idea agree with each other for free.

Writing the oracles found a bug in an oracle rather than in a reader, which is the outcome that makes the exercise worth doing at all. Poetry puts source = ["Cython (>=3.0.11,<3.1.0)"] inside one lxml block's [package.extras], and the first draft — a regex over the whole block, the same shape the Cargo.lock row uses — read that as the package's own source and called lxml a local project. The reader was right and the second opinion was wrong. Blocks are cut at their first nested table header now.

The sweep is not in cargo test and cannot be: its corpus is whatever happens to be on the machine, so it is neither fixed nor portable, and a test that passes because you have no Go modules installed is not a test. It is a separate target for the same reason make bench is.

The ablation

$ make ablation
cargo test --release --test ablation -- --nocapture --include-ignored
...
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.03s

Prints both tables from The ablation table. Release mode is not optional — the decay run scans the fixtures ten times against 140,066 names, and a debug build makes it unpleasant.

Benchmarks

make bench writes bench.md. It reports p50 and p99, not a mean and a standard deviation: a scan is not normally distributed, the tail is where a CI job notices, and a mean with an 82 ms sigma — which is what this section used to quote — says less than either percentile does.

targetrunsp50 msp99 ms
stranger scan fixtures/npm-xl.package-lock.json (1,376 third-party packages)10037.239.4
500 names, all in the corpus1009.510.1
500 names, none in the corpus562.863.8

Measured on an Intel Core i5-10200H at 2.40 GHz, 8 cores, governor performance — the governor is in the file because a powersave reading is a measurement of the governor and not of the tool. One fresh process per sample, five warmup runs first, page cache warm, nearest-rank percentiles with no interpolation.

The third row is the point of the file. A name in the corpus is answered by a binary search; a name that is not costs a sweep, and the two 500-name rows are the same file shape at the same size differing only in whether the names hit.

That cliff used to be the headline here, and it was enormous: this table read 10,102 ms on the miss row against 9.5 on the hit row, a factor of a thousand, and the paragraph said so. corpus::ByLength had halved it and the comment on that type named the remaining ceiling — the widest length band — and deferred the fix to a deletion-neighbourhood index it could not afford the memory for.

There was a cheaper half it had not taken. Every candidate now carries a 64-bit map of which characters it contains, and a candidate whose map differs from the query's in more than 2k bits cannot be within k edits, because no single edit moves more than two characters in or out of the set. That is a bound rather than a heuristic, one XOR and a popcount rejects most of the band before the edit-distance table is allocated, and tests/corpus.rs holds the whole search to an exhaustive sweep so a bound off by one would fail rather than quietly stop finding slopsquats.

The cliff is 6.6× now instead of 1,080×, and the scan a judge actually runs got six times faster as a side effect. It is still a cliff, the widest band is still the ceiling, and the index is still the thing that would remove it.

bench.md is gitignored on purpose — it is a timing on one machine, not a claim — so make bench gives you your own. It uses hyperfine when it is installed and falls back to a plain timing loop when it is not, which also publishes its own floor, so make bench never answers with command not found.

Lint

$ make lint
cargo clippy --all-targets -- -D warnings
    Checking stranger v0.1.0 (...)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
cargo fmt --check

Clippy with -D warnings across all targets, then a formatting check. Both must be clean.

What CI enforces

.github/workflows/ci.yml runs the above plus three checks that guard the entry's central claim, and it runs them before anything else:

- name: The manifest is empty
  run: |
    test "$(cargo tree | wc -l)" -eq 1
    test "$(grep -c '^\[\[package\]\]' Cargo.lock)" -eq 1

Then no unsafe anywhere in src/ (excluding the forbid(unsafe_code) attribute itself, which the naive grep would otherwise match), and no Command::new anywhere — stranger reads files, it does not run programs. After that: format, clippy, tests, an offline build, the full ablation, and the reproducible-build check.

Reproducibility

rust-toolchain.toml pins the compiler to 1.98.0 rather than stable. Two reasons: str::substr_range and NumBuffer::format_into both landed in 1.98 and both are used, and a floating channel makes the binary non-reproducible for no benefit. Reproducible builds has the rest.

--locked makes Cargo refuse to write Cargo.lock. With three empty dependency tables there is nothing it could write, which is the point — a build that somehow acquired a dependency fails instead of quietly succeeding.

The book

$ make docs
checked 37 pages, no broken relative links
checked 37 pages: 127 commands reproduce, 48 not runnable here (`-v` lists them)

Two checks, both standard-library-only Python, neither of which ever enters Cargo.toml.

check-links.py resolves every relative markdown link against the file it appears in and exits non-zero on the first target that does not exist. mdBook turns a broken link into a 404 without complaining, so a rotted page looks exactly like a fine one until somebody clicks it. External URLs are skipped, because checking those needs the network and this is a repository whose whole argument is that it does not need the network.

check-output.py runs every $ stranger scan fixtures/... in the book and the README and compares what the tool actually prints against what the page says it prints. Elapsed milliseconds are the one thing allowed to differ, because they are a measurement rather than a claim.

The ones it reports as not runnable are the make, cargo, strings, jq and grep lines in the same blocks — a make ablation inside a check that runs on every push would repeat what make ablation already tells you. Every stranger invocation against a fixture is checked, and -v prints the rest by name so the gap is a list rather than a number.

It exists because two separate rots got through in one afternoon. The risk score changed from a capped sum to a band, which renumbered every published figure — on a branch that did not yet contain the four new format pages being written against the old score on another branch. Both merged green and three pages went out quoting a number the tool would not print. Separately, the co-occurrence rule's own three worked examples had stopped firing entirely when packages gained an origin, because their hand-written lockfiles carry no resolved field.

Neither was catchable by review. Both are caught now, in ci rather than in the docs workflow, because the check needs the release binary.

$ make test && make lint

Reproducible builds

Same commit, same toolchain, two different directories, one binary.

$ make repro
commit:  9d2e472a46bbac0c6b92b217539c03a84c56379f
rustc:   rustc 1.98.0 (88d9e12ae 2026-08-18)
epoch:   1787940000

build A  /tmp/stranger-repro.311450/a
         4400252c85f11eef5202d940140d9ef493e51d94fc97428fada7cad03c8c0d08
build B  /tmp/stranger-repro.311450/b-with-a-deliberately-longer-name
         4400252c85f11eef5202d940140d9ef493e51d94fc97428fada7cad03c8c0d08

MATCH — byte-identical across two directories

The hash is for whatever you have checked out, and it moves whenever src/ or Cargo.lock moves. The line carrying the claim is MATCH, not the hash.

Which is not a hypothetical caveat: this page and the README quoted two different runs, at two different commits, with two different hashes, for the same claim — which reads like one of them was invented. They are one run now, and re-synced from a single make repro at the freeze. CI runs scripts/repro.sh on every pull request and every push to main, so a commit that stops reproducing fails there rather than being discovered by a judge.

Two directories, not one

The hackathon's FAQ sets the bar at "same machine, same toolchain, build twice", which same-directory-twice already meets. This does more, because the absolute build path is the single thing most likely to leak into a binary — it ends up in panic messages.

So the second directory is deliberately longer than the first. If the path were leaking, the two binaries would differ in length before they differed in content, and the check would fail loudly instead of passing by luck.

The three settings

SOURCE_DATE_EPOCH=1787940000 CARGO_INCREMENTAL=0 \
RUSTFLAGS="--remap-path-prefix=$PWD=/build -C debuginfo=0" \
cargo build --release --locked --offline

SOURCE_DATE_EPOCH pins anything that would otherwise embed a build time. The value is the hackathon kickoff, 2026-08-28 18:00 UTC.

CARGO_INCREMENTAL=0 because incremental artifacts are not deterministic.

--remap-path-prefix rewrites the build directory out of anything that carries it, and -C debuginfo=0 drops the rest of the path leakage that lives in debug info.

Neither turns out to be load-bearing, and the honest thing is to say so rather than let the flag take credit. Two plain cargo build --release --locked runs in two directories of different lengths, with none of the three settings, produce the same binary and the same hash as make repro does. The reason is that Cargo compiles the local crate through a relative path, so panic! locations come out as src/main.rs and there is no absolute path for the remap to rewrite:

$ strings target/release/stranger | grep -E '^src/[a-z]+\.rs$' | head -3
src/tree.rs
src/main.rs
src/toml.rs

The absolute paths that are in the binary belong to the standard library, and they point into the rustup toolchain rather than into this repository:

$ strings target/release/stranger | grep -c "$HOME/.rustup"
20

Those are identical for every build on one machine and different on another, which is exactly the boundary the FAQ draws. The settings stay because they are what stops this from silently ceasing to be true — a include_str!(concat!(env!("..."))) or a build script would put the build directory in the binary tomorrow, and the flag is already there when it does.

How the check works

scripts/repro.sh does not copy the working tree. It runs git archive HEAD into each directory, so both builds see exactly the committed state and an untracked file cannot make the check pass or fail for the wrong reason.

Then it builds each with --locked --offline, hashes target/release/stranger with sha256sum, and compares.

When it fails

The script says where to start:

DIFFER
Bisect order: path leakage in panic messages, then build-id, then
incremental artifacts. Compare with:
  cmp -l A/target/release/stranger B/target/release/stranger | head

Path leakage first because it is the most common and the reason the two directories have different lengths.

Why an empty manifest makes this easy

Most of what breaks reproducible builds in Rust is dependencies: a build script that reads the clock or the hostname, a crate that embeds its own path, a proc-macro whose output depends on iteration order. There are none here. There is also no build.rs in this crate and no code generation, so the only inputs are the source, the corpus text files, and the compiler.

CI runs ./scripts/repro.sh on every push, so a change that breaks determinism fails the build rather than being discovered later.

$ make repro

JSON conformance

stranger nominates serde_json for the Package Killer bonus — 1,227,048,507 all-time downloads, 288,758,389 in the last ninety days, measured 2026-08-28. Almost every Rust program that reads a package-lock.json reaches for it.

The case against reaching for it here is src/json.rs, and "I wrote a JSON parser" is not that case. Anyone can write one that reads the happy path. Two things are on this page instead, and both are commands you can run.

Clause by clause

$ cargo test --test json_conformance

29 tests, each citing the RFC 8259 section it comes from. The grammar has few enough productions to walk exhaustively, so it is walked:

| §2 | the six structural characters, whitespace being exactly four characters | | §3 | the literal names, lowercase and whole — True and nul are errors | | §4 | objects, member names, trailing commas, and that a duplicate name takes the last | | §5 | arrays, trailing and missing commas | | §6 | leading zeros, leading +, a decimal point with digits on both sides, the exponent forms, and the numbers Rust's own float parser would have taken but JSON does not — inf, NaN, 1., .5 | | §7 | all eight two-character escapes, \uXXXX, surrogate pairs, unescaped control characters, unterminated strings | | §8 | a leading byte order mark, and lone or mispaired surrogates | | §9 | one value and no more |

Eight escapes, not six: " \ / b f n r t. This repository said six in two places until somebody counted.

Against a reference implementation

$ ./scripts/json-differential.sh

Two million generated and mutated inputs, fed to this parser and to CPython's json, comparing both the accept/reject decision and the parsed value.

The oracle is configured with parse_constant so it rejects NaN, Infinity and -Infinity. Python accepts all three by default and RFC 8259 has none of them, so leaving that alone would have been comparing against Python's dialect rather than against the grammar.

1,997,016 agreed. 2,984 disagreed, in four classes. None of the disagreements was about a value: every time both accepted, both built the same thing, down to the IEEE-754 bits.

nclasswho is right
1,093a leading BOM — we skip it, CPython raisesneither. §8.1 says a parser MAY ignore one
898lone high surrogate, "\ud83e"neither. §8.2 does not forbid unpaired surrogates
825lone low surrogate, "\udd80"same
168high surrogate then a non-surrogate escapesame

Every class is a place the RFC permits both answers, which is the honest reading and is less satisfying than "we were right four times". Both choices here are still choices, and both were made for a reason:

The BOM is skipped because a package-lock.json saved by a Windows editor starts with one, and the useful behaviour is to audit that file rather than to refuse it.

Unpaired surrogates are rejected because a Rust String cannot hold one. Accepting means either WTF-8 or substituting U+FFFD — and silently rewriting a character of a package name is exactly the bug class this tool exists to find. A corrupt name should stop the scan, not quietly become a different name. That is the same reasoning as term::sanitize, pointed the other way.

What the campaign did not find

No defect in src/json.rs. That is a result reported rather than a claim made, and it was checked past the edges the campaign reaches: a 100,000-digit integer, a 100,000-digit exponent, a \u escape truncated at every offset, one whose fourth byte lands mid-codepoint, the depth cap at 127, 128 and 129, and error columns counted in characters behind multi-byte text. All correct.

tests/fuzz.rs adds 1,366,400 mutants and 282,563 truncation prefixes across every parser and all eight readers, with no panic anywhere. It said seven until 2026-08-30, and it was right: READERS had seven entries and the tool had eight. yarn is the one it was missing, and it is the reader that then needed two fixes in a day. The array length is spelled out now, so a ninth reader will not compile until it is in the campaign too.

Python is dev-time tooling

python3 runs the oracle and nothing else. It never enters Cargo.toml, it is not linked, it does not ship, and the binary a judge builds has no idea it exists. The 29 clause tests run on a machine that does not have it; the differential run says so in a sentence and exits clean.

That is the same standing as the curl that fetched the name corpus, and it is disclosed the same way — in STDLIB.md, in corpus/PROVENANCE.md, and here.

The STDLIB log

Eighteen crates, and what the standard library did instead.

The canonical file is STDLIB.md, and it carries the part that matters: an honest what I gave up paragraph on every entry. This page is the index — what was replaced, by what, and where the code is. It deliberately does not repeat the rationale, because two copies of an argument drift and one of them starts lying.

Download counts are what the crates.io API returned on 2026-08-28, quoted in the two forms it actually reports. It has no weekly figure, so none is invented.

Package Killer nomination

serde_json — 1,227,048,507 all-time · 288,758,389 in 90 days

rand is bigger — 1,605,926,795 — and it is deliberately not the nomination. The bonus asks for one crate nailed, and five lines of xorshift is not a case for anything. The nomination goes to the substitution with the most work behind it, not to the largest number in the table.

src/json.rs is a complete RFC 8259 reader: escapes, \uXXXX with surrogate-pair recombination, the number grammar checked by hand before f64::from_str sees the slice, a nesting limit, and line/column on every error derived from a byte offset.

The position tracking is the part worth defending. The parser keeps the original input alongside the unconsumed remainder and asks the standard library where one sits inside the other — str::substr_range, stable in 1.98. No cursor struct threaded through thirty functions, no line counter to keep in sync, because the cursor is the remainder.

The other seventeen

crateall-timereplaced bywhat did the work
rand1,605,926,795src/distance.rs, tests/five lines of xorshift64*, seeded from SystemTime
thiserror1,377,720,340src/error.rsone enum, hand-written Display and Error::source
itoa1,265,455,201src/report.rscore::fmt::NumBuffer + format_into (1.98)
once_cell1,187,857,958src/corpus.rsstd::sync::LazyLock (1.80)
clap1,083,204,108src/cli.rsa match over std::env::args
strsim1,024,185,642src/distance.rsunrestricted Damerau-Levenshtein, written out
semver945,451,453src/semver.rsthe precedence rules from the spec's section 11
anyhow909,556,524src/error.rsthe same enum, with ?
toml855,052,855src/toml.rsa documented subset that refuses what it cannot read
walkdir588,096,443src/walk.rsstd::fs::read_dir and an explicit stack
glob575,867,559src/walk.rsstr::ends_with against seven known names
crossbeam-channel565,386,665src/main.rsstd::sync::mpsc
rayon516,056,344src/main.rsstd::thread::scope
serde_yaml383,697,832src/yaml.rsan indentation-driven subset, two booleans only
is-terminal324,499,410src/term.rsstd::io::IsTerminal (1.70)
owo-colors156,700,441src/term.rssixteen-colour SGR, written out
comfy-table94,630,283src/term.rscolumn widths measured from content

13,459,345,853 all-time downloads across those seventeen, and 14,686,394,360 with the nomination. The bonus asks for ten entries.

Three of these were free, and saying so is the point

itoaNumBuffer::format_into and once_cellLazyLock are substitutions the toolchain made for me. LazyLock landed in 1.80 and format_into in 1.98, eight days before the window opened. Neither cost an hour, and claiming them as craft would be claiming a stable release as personal work.

is-terminal is the third and it is the interesting one, because avoiding it avoided unsafe. The usual replacement is an FFI call to libc::isatty, which needs an unsafe block and would have broken #![forbid(unsafe_code)] at both crate roots. std::io::IsTerminal has done it safely since 1.70.

What is not claimed

indicatif. Nothing here draws a progress bar: the largest fixture scans in under half a second, and a bar that finishes before it renders is a dependency bought for one frame. Writing a spinner so that a spinner crate could be crossed off is padding rather than substitution.

The rule the log enforces on itself is that an unwritten module gets no entry. That is why src/semver.rs is in the table. It has ten tests, and for one day it was also the one module nothing called — which the log said out loud rather than implying it was load-bearing. Version drift sorts with it now.

Data that is not code, disclosed anyway

corpus/ holds 160,066 package names across three registries, fetched once with curl at development time on 2026-08-28. fixtures/ holds fourteen real lockfiles from public projects plus two poisoned by hand.

Neither is code — the corpora are embedded with include_str! and read as text, and the fixtures are test input. The hackathon's rule is that anything not written during the window is disclosed in STDLIB.md or it scores against you, and a name list is exactly the sort of thing it would be convenient to forget. Full provenance is in corpus/PROVENANCE.md and fixtures/README.md.

$ wc -l corpus/*.txt
    5000 corpus/crates-io.txt
  140066 corpus/npm.txt
   15000 corpus/pypi.txt
  160066 total

Troubleshooting

"no lockfile in ." on a project that has one

Discovery recurses from the directory you named and matches filenames ending in one of seven known names. A file renamed at the front still reads; one renamed at the back does not.

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

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

The usual cause is a name like requirements-dev.txt, which ends in .txt and not in requirements.txt. Nothing reads file contents to second-guess the name, so pointing straight at it does not help either — that is exit 2, "not a lockfile stranger knows". Copy or symlink it to a name in the list.

The other cause is a lockfile inside node_modules, target, .venv or one of the ten other skipped directories, or deeper than six levels down.

Point at a file to skip the walk:

$ ./target/release/stranger scan fixtures/npm-s.package-lock.json

  npm-s.package-lock.json  405 packages   (12 direct · 393 transitive)

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                10    (2.5% of third-party)

  ⚠  VERSION DRIFT          30    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 56/100    92ms    third-party deps used to compute this: 0

Matching on a file path is by suffix, so npm-s.package-lock.json and old.package-lock.json both read.

Exit code here is 0, not an error. A repository of mixed languages should not turn red on the directories the tool has nothing to say about.

"lockfileVersion 1 is not supported"

$ mkdir -p /tmp/v1
$ echo '{"lockfileVersion": 1, "dependencies": {}}' > /tmp/v1/package-lock.json
$ ./target/release/stranger scan /tmp/v1
stranger: /tmp/v1/package-lock.json: lockfileVersion 1 is not supported; stranger reads 2 and 3. Run `npm install` with npm 7 or newer to upgrade the file.

Do what it says. npm 7 and later write version 2 or 3, which have the packages map this reader needs. Version 1 kept the tree in a nested dependencies object and cannot be read the same way.

"not a lockfile stranger knows"

$ 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

Seven formats in this build, and the error lists them from the same constant discovery uses, so the message cannot drift out of date the way this page did.

You asserted the file was a lockfile and it does not match any name, so this is exit 2 rather than the exit 0 a directory scan gives. Renaming it to requirements.txt reads it — the name is the whole test, and nothing looks inside to confirm.

"no packages map"

The file parsed as JSON and had a lockfileVersion of 2 or more, but no packages object. Either it is truncated or it is not a package-lock.

A syntax error with a line and column

$ mkdir -p /tmp/bad
$ echo '{"lockfileVersion":3, "packages" 1}' > /tmp/bad/package-lock.json
$ ./target/release/stranger scan /tmp/bad/package-lock.json
stranger: /tmp/bad/package-lock.json: expected ':' at 1:34

pip errors quote the fragment as well as the position:

$ printf 'flask[async>=3.0\n' > /tmp/bad/requirements.txt
$ ./target/release/stranger scan /tmp/bad/requirements.txt
stranger: /tmp/bad/requirements.txt: `flask[async>=3.0` has an unclosed `[` in its extras at 1:1

Line and column are 1-based and the column counts characters, not bytes, so it lines up with what an editor shows you. A lockfile that fails to parse is usually a merge conflict marker or a truncated download.

Nothing found on a lockfile you expect findings in

Work through the three clauses of the detection rule in order. It fires only when all three hold.

  1. The name is in the corpus. 140,066 npm names and 15,000 PyPI names are compiled in; if the name is one of them the rule stops immediately. This is the case for a typosquat that actually got registered.
  2. No corpus name is within edit distance 2. A hallucinated name that is not a near-miss of a real one has no parent for clause 2 to find. Check the range is really empty before concluding this — requests-http was assumed to be in this category and turned out to be two edits from requests-html.
  3. Something depends on it. If any third-party package in the tree lists the name, the rule goes quiet by design. Edges out of the root manifest and out of workspace members do not count for this; edges out of any other package do. On a requirements.txt there are no edges at all, so this clause never suppresses anything.

Also check the package is not first-party. Workspace directories and link: true entries are skipped by every rule before any clause runs.

Only the critical block is listed

By design. Non-critical rules collapse to a count and a reason, because a 1,390-package tree produces 76 drift findings and 29 trivial ones. -v expands them:

$ ./target/release/stranger scan -v fixtures/npm-xs.package-lock.json

  npm-xs.package-lock.json 37 packages   (1 direct · 36 transitive)

  ⚠  TRIVIAL                4     (10.8% of third-party)
     es-errors@1.3.0          one expression, one publisher · inlining it removes an account from your build
     gopd@1.2.0               one expression, one publisher · inlining it removes an account from your build
     has-symbols@1.1.0        predicate-shaped, resolves nothing · size not measured, see rule docs
     hasown@2.0.4             one expression, one publisher · inlining it removes an account from your build

  ·  UNPINNED               — no signal in this format

  risk 9/100    9ms    third-party deps used to compute this: 0

--format json is never collapsed — it emits every finding whether or not you passed -v.

Findings you believe are wrong

Most likely the package is real and newer than the corpus snapshot of 2026-08-28, or it fell off a popularity ranking. tensorflow-gpu in fixtures/reqs-xs.requirements.txt is exactly that, and it is shipped as a fixture rather than hidden. False positives covers the shape of it and the ablation table puts numbers on it.

The nearest name in detail is the closest corpus entry, which is not always the name you would have guessed, and gets less reliable as the corpus ages. Treat it as the rule showing its working.

For TRIVIAL hits specifically: that rule cannot see how long a file is, and reports a good share of packages that are not one-liners. Trivial packages says which and why.

No colour

Colour is on when stdout is a TTY. Four inputs decide, highest priority first: --no-color, then NO_COLOR, then CLICOLOR_FORCE, then TTY. Off beats on at every tie.

If a CI log renders ANSI and you want colour there, set CLICOLOR_FORCE=1. If you are getting escape codes where you do not want them, --no-color beats everything else. --format json never carries colour under any setting.

A closed pipe prints nothing and exits 0

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

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

$ echo $?
0

EPIPE is the shell working correctly, not a failure, so it is silent. If a piped run reports an error, the error is real and came from somewhere else.

"scan takes one path"

$ ./target/release/stranger scan a/package-lock.json b/package-lock.json
stranger: scan takes one path; got a second, `b/package-lock.json`

One path per run, on purpose — a second argument being silently ignored is worse. Loop in the shell. A directory containing both a package-lock.json and a requirements.txt is one path and scans both.

$ ./target/release/stranger scan -v fixtures/poisoned.requirements.txt