Skip to content

CM-73389: Stop generating a lockfile for an npm workspace member - #552

Merged
omer-roth merged 11 commits into
cycodehq:mainfrom
amit-turgeman:CM-73389-skip-npm-workspace-member-lockfiles
Oct 5, 2026
Merged

omer-roth merged 11 commits into
cycodehq:mainfrom
amit-turgeman:CM-73389-skip-npm-workspace-member-lockfiles

Conversation

@amit-turgeman

@amit-turgeman amit-turgeman commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The CLI no longer generates a package-lock.json for an npm workspace member. npm installs a workspace from the root lockfile and ignores any lockfile inside a member, so the file we were generating was uploaded and never used.

Worse than wasted work: generating it re-resolves the member's transitive ranges against the registry, so the scan sees versions the repository does not install. A committed root lockfile pinning y18n 5.0.0 / tough-cookie 2.3.0 became 5.0.8 / 2.3.4 in the generated member lockfile — patched versions, hence no findings.

When a member is skipped

A member is skipped when a lockfile above it already resolves its dependencies. The lockfile
itself is the authority on that, because it names the directories it resolved:

lockfile where the members are named
package-lock.json / npm-shrinkwrap.json (v2+) keys of packages, excluding "" and node_modules/...
pnpm-lock.yaml keys of importers
yarn.lock (berry) the resolution of each <name>@workspace:<path> entry

The root manifest's workspaces globs are consulted only for lockfiles that cannot name
their members: classic yarn.lock, which is flat, and the binary bun.lockb. A v1
package-lock.json is different again — workspaces arrived with npm 7 and lockfileVersion 2,
so a v1 lockfile is simply not a workspace root and the globs must not rescue it.

This matches how dependency-collector resolves the same question, which derives member
attribution purely from lockfile content.

Correction to an earlier revision of this description

This description previously claimed that a file: dependency target "is not resolved through
the root lockfile, so it still needs its own", and used that to justify requiring a matching
workspaces glob. That claim was wrong. Checked against npm 11, a file: target and a
workspace member produce the same lockfile shape, and the target's ranges are pinned in the
root just like a member's:

file: dependency    key 'local-lib'        resolved y18n = 5.0.8
workspace member    key 'packages/member'  resolved y18n = 5.0.8

Generating a second lockfile inside a file: target can therefore only drift from the root.
Such targets are now skipped like any other directory the root resolves.

Coverage

  • lockfileVersion 2 and 3 — the only versions that can describe a workspace
  • lockfileVersion 1 has no packages map and predates workspaces (npm 7), so it keeps generating as before
  • workspaces array form and object form ({"packages": [...]})
  • npm-shrinkwrap.json, which the backend already treats as equivalent
  • npm glob semantics: * stops at a path separator, ** does not, so packages/* does not claim packages/a/b

Testing

30 unit tests, ruff check and ruff format --check clean on the pinned 0.15.20.

Run against sca-benchmark, all 33 npm scenarios, comparing collected documents before and after:

scenarios changed:                     2 / 33
scenarios identical:                  31
scenarios that lost a committed file:  0
scenario change
npm/v3/18-combo-workspace-optional 4 → 3 docs, dropped the generated member lockfile
npm/v3/20-workspaces-object-form 4 → 3 docs, dropped the generated member lockfile

npm/v3/09-peer-bundle-link-flags and npm/v3/10-file-directory also have nested manifests with no lockfile and are unchanged — they are the control, and 10-file-directory is the scenario that caught the original predicate being wrong.

End to end: the resulting 3-document upload scanned against dependency-collector at main yields 2 detections at the versions the committed lockfile pins.

Jira

CM-73389

🤖 Generated with Claude Code

@omer-roth

Copy link
Copy Markdown
Collaborator
  1. Add coverage for pnpm and yarn too
  2. sign your commits

Comment thread cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py Outdated
Comment thread cycode/cli/files_collector/sca/npm/workspace.py Outdated
omer-roth
omer-roth previously approved these changes Oct 1, 2026
amit-turgeman and others added 10 commits October 3, 2026 15:21
npm installs a workspace from the root lockfile and ignores any lockfile inside a
member, so the one we generate here is uploaded and never used. Worse, generating
it re-resolves the member's transitive ranges against the registry, so the scan
sees versions the repository does not install.

A directory only counts as the workspace root when its package.json declares a
"workspaces" pattern matching the member and its lockfile lists that member. A
lockfile entry on its own is not enough: npm records a file: dependency exactly
like a workspace member, and a file: target is not resolved through the root
lockfile, so it still needs a lockfile of its own.

Covers both lockfile shapes that can describe a workspace, the array and object
forms of "workspaces", and npm-shrinkwrap.json. A lockfileVersion 1 root has no
"packages" map and predates workspaces, so it keeps generating as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The npm fallback generated a package-lock.json for a member of a yarn,
pnpm or bun workspace. The dedicated handlers only look for their lockfile
in the member's own folder, and a workspace keeps it solely at the root, so
nobody claimed the member and npm took it — wrong package manager, and
versions re-resolved against the registry rather than the ones installed.

Coverage detection moves into a shared module that all four handlers
consult. npm still requires the member to appear in the root lockfile, so a
stale root cannot hide it; yarn, pnpm and bun cannot be enumerated cheaply
or uniformly, so for those the root lockfile's presence is the signal.
pnpm declares its members in pnpm-workspace.yaml, and each package manager
resolves membership only against the declaration file it actually reads —
a stale workspaces array left behind by a migration does not make a pnpm
member. Negated globs are honoured, so an excluded directory still gets its
own lockfile instead of being silently dropped.

The root lockfile is parsed once per scan rather than once per member. Only
the derived member-name set is retained, not the parsed document: for a
24 MB lockfile that is 8 KB instead of 98 MB. Deciding what to skip across
200 members drops from 19.4s to 0.1s.

A committed npm-shrinkwrap.json is now used instead of being regenerated,
and the collected document keeps that name rather than being reported as a
package-lock.json.

The ancestor walk stops at the git repository root, falling back to the
scanned path when there is none, so a manifest outside the scanned tree
cannot suppress a project inside it. Scanning only a member folder still
declines, and says so once with the root it found.

bun.lockb counts as workspace coverage but is deliberately not treated as
an alternative lockfile in the member's own folder: Bun restores only from
a text bun.lock, so excluding it there would leave a Bun <1.2 project with
no handler at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
workspace.py declared its own copies of the file names the handlers already
had, so the coverage table and the handler consuming it could drift apart
without anything failing. package.json existed five times, and yarn.lock,
pnpm-lock.yaml, bun.lock and deno.lock three times each.

workspace.py now owns them and the handlers import from it, keeping their
existing constant names so callers are unaffected. A test scans the module
for a bare file-name literal and names the offending file, so the convention
is enforced rather than remembered.

The npm alternative-lockfile list is built from those constants, which makes
the absence of the binary bun lockfile visible in the code instead of
needing a comment to explain it, and a test pins that exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found in review.

scan_roots_from_context kept only str entries, but typer declares these
arguments as Path with resolve_path=True, so every real scan root was
discarded and the tuple was always empty. The containment check short
circuits on an empty tuple, so the "workspace root is outside the scanned
path" warning could never fire in production and the boundary fallback for
a tree without .git never applied. Scan roots are now taken from any
PathLike, and both the single path and the list of paths are read. The
previous tests passed a str, which is the one shape typer never produces.

Containment compared os.path.abspath, which does not resolve symlinks,
while typer hands back a resolved path. A scan root reached through a
symlink therefore looked as if it sat outside itself, which would have
produced a spurious warning as soon as the above was fixed. On macOS /tmp
and /var are symlinks, so this is the ordinary case. Both sides are now
compared as real paths.

pnpm was treated as presence-only on the grounds that its lockfile cannot
be enumerated, which is true of yarn and bun but not of pnpm: pnpm-lock.yaml
carries a top-level importers map keyed by member directory, as checkable as
npm's packages. A member declared in pnpm-workspace.yaml but missing from a
stale lockfile was silently dropped. pnpm now requires lockfile membership.
Only the importers block is parsed, which on a 2.4 MB lockfile costs 164 ms
instead of 1369 ms for the whole document.

The reasoning in the previous commit message holds for yarn and bun only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-root lookup

The pnpm importers block is sliced by line, and any line starting at column
zero ended it — including a comment. A hand-written comment inside importers
therefore truncated the slice to nothing, and with no members found npm took
the project and regenerated a package-lock.json for a pnpm workspace. Lines
beginning with a hash are now skipped. pnpm does not emit such comments
itself, so this was reachable only through hand editing.

The scan-root lookup existed twice. get_path_from_context read the same two
parameters as the newer function but kept whatever typer produced, so it
returned a Path despite being annotated Optional[str], and it raised
IndexError when paths was an empty list. The lookup now lives in path_utils,
get_path_from_context returns its first entry, and the npm handlers call it
there. Tests cover both the shapes typer produces and the empty list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Treating a root lockfile as coverage when that lockfile is outside the
scanned paths produced no dependency documents at all. Running
cycode scan path ./packages/app inside a monorepo found the root through
the .git boundary, skipped the member, and then never collected the root
lockfile, so the scan reported clean. Before this branch the same command
produced detections. Wrong versions are bad; no data is worse. Such a root
is no longer treated as coverage, the member is restored on its own, and
the warning says which root to scan for the versions actually installed.

The walk could also leave the scanned tree entirely. A scanned path may be
a file rather than a directory, and comparing a file against a directory
never matched, so no scan root appeared to contain the member and the walk
ran to the filesystem root, reading manifests and lockfiles above it. An
unrelated ancestor declaring workspaces ["**"] was accepted as the covering
root and suppressed the restore. A scanned path now stands for its
directory, and when the scanned paths are known the walk never passes them.

Reading the pnpm importers block no longer loads the whole lockfile. The
slice existed to avoid parsing a large document, but the file was still
read and split in full: 44.6 ms and 37.5 MB for a 13.2 MB lockfile, against
0.3 ms and 0.1 MB when the read stops at the packages block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st globs

A lockfile names the directories it resolved, so it can answer directly whether
a member is already covered: package-lock.json and npm-shrinkwrap.json in the
keys of "packages", pnpm-lock.yaml in "importers", and a berry yarn.lock in the
resolution of each "<name>@workspace:<path>" entry. Asking it removes a whole
layer of inference. dependency-collector resolves the same question the same
way, so the two now agree by construction rather than by coincidence.

The globs were introduced to separate a workspace member from a file:
dependency target, on the grounds that the root lockfile does not resolve the
latter. Checked against npm 11, that is not so: a file: target gets a packages
entry and has its ranges pinned under the root node_modules exactly as a member
does. A second lockfile inside such a target can only drift from the root, so
these are now skipped like any other directory the root resolves.

The manifest globs remain for the two formats that cannot name their members:
a classic yarn.lock, which is flat, and the binary bun.lockb. A v1
package-lock.json is a third case and must not reach them - workspaces arrived
with npm 7 and lockfileVersion 2, so a v1 root is simply not a workspace.

Each format is now a resolver class behind one abstract base, which caches and
leaves the subclass to parse. Reading members and deciding what that format's
silence means live together per format, so the yarn classic and berry split is
stated in the class that handles it.

This also closes two gaps the globs left. A pnpm root declaring src/app/** did
not match src/app itself, though pnpm's own importers list it, and a berry
member missing from the lockfile was skipped on the strength of the glob alone,
collecting nothing for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module had grown to five concerns in one file: per-format lockfile reading,
glob matching, path and walk-boundary handling, caching, and the public entry
points. Each is now its own module, with the dependencies running one way.

  names.py      file names and package-manager identifiers
  files.py      file identity for caching, and tolerant reading
  globs.py      the manifest globs, reached only by the blind formats
  resolvers.py  the abstract reader and one implementation per format
  coverage.py   scan roots, the walk, and the two public functions
  __init__.py   the public surface, re-exported

Nothing outside the package changed: __init__ re-exports every name the
handlers already imported, so no import statement elsewhere moved. The three
caches now live beside the code that fills them, and clear_cache fans out to
them, which keeps a caller from needing to know how many there are.

Tests reach for the module that owns a helper rather than for one module that
owned everything. Note that resolvers binds read_json_object at import, so a
test patches the name there rather than on files.

No behaviour change: the decision is identical across all 41 manifests of
sca-npm-examples, and against the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The glob matcher built a regular expression a character at a time, with the
trailing /** handled as a special case so that src/app/** would match src/app.
Matching segment by segment says the same thing in a quarter of the code: a
separator cannot occur inside a segment, so fnmatch is exactly right there and
brings character classes with it, while ** is the only construct that spans
segments. The directory matching its own /** pattern then follows from **
spanning zero segments, rather than from a special case. Checked against yarn
1.22, which is what the globs exist to mirror: *, **, ?, literals and character
classes all agree. Brace expansion and extglobs are still unsupported and fail
as a miss, which restores the member rather than dropping it.

Each ** tries every split of the remaining path, so several in one pattern
multiplied out: six globstars against a deep tree took 67ms, and twenty would
not have finished. The pattern comes from a manifest inside the repository
being scanned, so that was reachable from a crafted package.json. The match is
now memoised on its two tuples of segments, which are plain values and cannot
go stale, and twenty globstars take 0.35ms.

bun.lock names its members under "workspaces", so bun no longer depends on the
globs at all. It is JSON with trailing commas, which strict parsing rejects, so
a failed parse retries once with those removed. The binary bun.lockb cannot be
read and still falls back to the globs.

deno.lock is no longer treated as covering a member. The fallback reads the
manifest's workspaces field, which deno does not use - it declares members in
deno.json - so a match there was meaningless. A deno.lock beside a manifest
still stops the npm fallback through the alternative-lockfile guard.

The globs are now reached by classic yarn.lock and bun.lockb alone. Across
sca-npm-examples, fourteen of the nineteen skipped members are decided by a
lockfile and five by the globs, all of them yarn classic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The package exported five package-manager identifiers that only its own
resolvers use, so they advertised an API with no caller. They stay in names.py
and leave the public surface. DENO_PACKAGE_MANAGER had no use anywhere once
deno stopped resolving members, so it goes entirely. WorkspaceCoverage keeps
its place: find_covering_workspace returns it, so it belongs in a signature a
caller can read.

The npm handler's is_project described only its first half, the guard against
an alternative lockfile beside the manifest, and said nothing about the
workspace coverage that the rest of this branch added. It now says both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amit-turgeman
amit-turgeman force-pushed the CM-73389-skip-npm-workspace-member-lockfiles branch from a706f78 to 44d3b87 Compare October 3, 2026 12:29
@omer-roth
omer-roth merged commit 338f081 into cycodehq:main Oct 5, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants