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