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