Skip to content

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

Open
amitturgemancycode wants to merge 2 commits into
cycodehq:mainfrom
amitturgemancycode:CM-73389-skip-npm-workspace-member-lockfiles
Open

amitturgemancycode wants to merge 2 commits into
cycodehq:mainfrom
amitturgemancycode:CM-73389-skip-npm-workspace-member-lockfiles

Conversation

@amitturgemancycode

@amitturgemancycode amitturgemancycode commented Sep 28, 2026 •

Copy link
Copy Markdown

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

Both conditions must hold:

  1. an ancestor package.json declares a workspaces pattern matching the member, and
  2. that ancestor ships a lockfile (package-lock.json or npm-shrinkwrap.json) listing the member

Condition 1 is not optional, and a lockfile entry alone is not a substitute. npm records a file: dependency exactly like a workspace member:

workspace member   'packages/member' -> {"name": "@scope/member", "dependencies": {...}}
file: target       'local-lib'       -> {"name": "local-lib",     "dependencies": {...}}

A file: target is not resolved through the root lockfile, so it still needs its own. The only signal that separates the two is the root manifest's workspaces field — which is also what the backend keys on, so both sides now agree by construction.

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

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>
@omer-roth

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

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>
Comment on lines +18 to +20
# These lockfiles indicate another package manager owns the project — NPM should not run.
# bun.lockb is deliberately absent: Bun only restores from a text bun.lock, so excluding it
# here would leave a Bun <1.2 project with no handler at all.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten comment and remove Bun reference

Comment on lines +17 to +30
MANIFEST_FILE_NAME = 'package.json'
PNPM_WORKSPACE_FILE_NAME = 'pnpm-workspace.yaml'

NPM_PACKAGE_MANAGER = 'npm'
YARN_PACKAGE_MANAGER = 'yarn'
PNPM_PACKAGE_MANAGER = 'pnpm'
BUN_PACKAGE_MANAGER = 'bun'
DENO_PACKAGE_MANAGER = 'deno'

NPM_LOCK_FILE_NAME = 'package-lock.json'
NPM_SHRINKWRAP_FILE_NAME = 'npm-shrinkwrap.json'

MANIFEST_DECLARED = 'manifest'
PNPM_WORKSPACE_DECLARED = 'pnpm-workspace'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check if you have these values in other files of the same module to avoid stale variables over time

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