You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@skywalking.apache.org by wu...@apache.org on 2026/08/24 06:22:07 UTC
(skywalking-eyes) branch main updated: feat: add a pnpm resolver for Node.js dependencies (#282)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-eyes.git
The following commit(s) were added to refs/heads/main by this push:
new ab60c97 feat: add a pnpm resolver for Node.js dependencies (#282)
ab60c97 is described below
commit ab60c97524d4fe73142b27055a262cf1520f66a4
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Aug 24 14:22:02 2026 +0800
feat: add a pnpm resolver for Node.js dependencies (#282)
`package.json` was always resolved with npm, so a pnpm project resolved
nothing: `npm ls --parseable` prints paths under `node_modules/<name>`, which
in a pnpm install is a symlink farm into a content-addressed store, and every
package came back Unknown. Worse, the run began with `npm ci`, which rewrites
`node_modules` and leaves the workspace broken for whatever runs next.
PnpmResolver lives in its own file and claims a `package.json` only when the
project is pnpm-managed, so NpmResolver keeps handling exactly what it handles
today and a yarn resolver can be added the same way later. It embeds
NpmResolver for the shared half — licenses are read from a package's own
`package.json` or its license file, identically for both managers — and
replaces only what genuinely differs:
- Detection reads corepack's `packageManager` field, which is decisive in both
directions, then falls back to the lock file, climbing from the
`package.json` upward so a workspace member is governed by its root.
- Installation runs `pnpm ci`, the counterpart of `npm ci`, falling back to
`pnpm install --frozen-lockfile` on pnpm 7 and older, which have no `ci`.
- Listing uses `pnpm licenses list --prod --json` for install locations, since
pnpm has no `--parseable` and `pnpm ls` reports the dependency graph rather
than paths. Every path of every entry is emitted: one entry can carry
several versions of a package, and a version whose terms differ from its
sibling's is exactly what a license scan exists to catch. A workspace is
listed once however many members `dependency.files` names.
- Names are recovered as the segment after the LAST `node_modules` in the
path, which holds for npm's `node_modules/<name>` and pnpm's
`node_modules/.pnpm/<name>@<version>/node_modules/<name>` alike, where the
relative path would yield pnpm's store key.
Registration order is load-bearing, since both resolvers answer to
package.json and the first match wins; a test asserts it.
Node's `license` field is also no longer reported verbatim when it holds the
documented `"SEE LICENSE IN <file>"` form. That names a file rather than
stating terms, so returning it made the sentence the package's LicenseSpdxID,
nothing could identify it, and the package had to be declared by hand in
`dependency.licenses` — while the terms sat in the file it names, in a form
the resolver already reads. Declining the field lets the existing
license-file fallback run. This applies to npm projects too.
Verified against a four-member pnpm workspace: 364 packages across all
versions, all resolved with no `dependency.licenses` configuration, 350
license files collected, and `node_modules` intact after the run. An npm
fixture resolves byte-identically to `main`.
---
README.md | 13 +-
examples/nodejs/Dockerfile | 5 +-
pkg/deps/npm.go | 22 +-
pkg/deps/{resolve.go => npm_license_field_test.go} | 48 ++--
pkg/deps/pnpm.go | 278 +++++++++++++++++++++
pkg/deps/pnpm_test.go | 170 +++++++++++++
pkg/deps/resolve.go | 4 +
7 files changed, 503 insertions(+), 37 deletions(-)
diff --git a/README.md b/README.md
index de938af..515685e 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ dependency:
files:
- pom.xml # If this is a maven project.
- Cargo.toml # If this is a rust project.
- - package.json # If this is a npm project.
+ - package.json # If this is a Node.js project (npm or pnpm).
- go.mod # If this is a Go project.
- Gemfile.lock # If this is a Ruby project (Bundler). Ensure Gemfile.lock is committed.
```
@@ -730,6 +730,17 @@ Example using weak-compatible mode:
license-eye -c test/testdata/.licenserc_for_test_check.yaml dep check -w
```
+##### Node.js projects: npm and pnpm
+
+`package.json` is resolved with npm by default. A project is resolved with pnpm instead when either of the following says so, checked in the directory of the `package.json` and then in each directory above it, so that a workspace member is governed by its root:
+
+- the [`packageManager`](https://nodejs.org/api/corepack.html) field of a `package.json` names `pnpm`, or
+- a `pnpm-lock.yaml` is present.
+
+Both managers are used the same way: the packages installed for **production** are the ones checked, and each package's own `package.json` and `LICENSE` file are what the license is read from. In a pnpm workspace the whole workspace is resolved once, however many members the `files` configuration names.
+
+Whichever manager applies must be on `PATH`, and the packages must be installed — the tool offers to install them for you at the start of a run (`npm ci`, or `pnpm ci`, falling back to `pnpm install --frozen-lockfile` on pnpm 7 and older), which you can skip with `s` + ENTER if they are already in place.
+
<details>
<summary>Dependency Check Result</summary>
diff --git a/examples/nodejs/Dockerfile b/examples/nodejs/Dockerfile
index 58fefb8..b6bb3d6 100644
--- a/examples/nodejs/Dockerfile
+++ b/examples/nodejs/Dockerfile
@@ -20,7 +20,8 @@ FROM apache/skywalking-eyes:latest
ARG NODE_VERSION=22.13.1
ARG TARGETARCH
-# Install Node.js
-RUN apk add --no-cache --update nodejs=${NODE_VERSION}-r0 npm
+# Install Node.js. pnpm is included so that pnpm-managed projects resolve too;
+# drop it if yours is an npm project.
+RUN apk add --no-cache --update nodejs=${NODE_VERSION}-r0 npm && npm install -g pnpm
WORKDIR /github/workspace
diff --git a/pkg/deps/npm.go b/pkg/deps/npm.go
index 0579158..9625e52 100644
--- a/pkg/deps/npm.go
+++ b/pkg/deps/npm.go
@@ -138,6 +138,15 @@ func (resolver *NpmResolver) Resolve(pkgFile string, config *ConfigDeps, report
// Walk through each package's root directory to resolve licenses
// Resolve from a package's package.json file or its license file
+ resolver.ResolvePackages(pkgs, config, report)
+ return nil
+}
+
+// ResolvePackages reads the license of every listed package and records it in
+// the report. Only the LISTING of packages differs between Node package
+// managers, so this half is shared: see PnpmResolver, which lists differently
+// and then calls into here.
+func (resolver *NpmResolver) ResolvePackages(pkgs []*Package, config *ConfigDeps, report *Report) {
for _, pkg := range pkgs {
if result := resolver.ResolvePackageLicense(pkg.Name, pkg.Path, config); result.LicenseSpdxID != "" {
report.Resolve(result)
@@ -149,7 +158,6 @@ func (resolver *NpmResolver) Resolve(pkgFile string, config *ConfigDeps, report
logger.Log.Warnln("Failed to resolve the license of dependency:", pkg.Name, result.ResolveErrors)
}
}
- return nil
}
// NeedSkipInstallPkgs queries whether to skip the procedure of installing or updating packages
@@ -300,6 +308,13 @@ func (resolver *NpmResolver) ResolvePkgFile(result *Result, pkgPath string, conf
return fmt.Errorf(`cannot parse the "license"/"licenses" field`)
}
+// isLicenseFileReference reports whether an npm `license` string is the
+// "SEE LICENSE IN <file>" form, which names a file rather than stating terms.
+// https://docs.npmjs.com/cli/configuring-npm/package-json#license
+func isLicenseFileReference(value string) bool {
+ return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(value)), "SEE LICENSE IN")
+}
+
// ResolveLicenseField parses and validates the "license" field in package.json file
func (resolver *NpmResolver) ResolveLicenseField(rawData []byte) (string, bool) {
if len(rawData) > 0 {
@@ -307,7 +322,10 @@ func (resolver *NpmResolver) ResolveLicenseField(rawData []byte) (string, bool)
case '"':
var lcs string
_ = json.Unmarshal(rawData, &lcs)
- if lcs != "" {
+ // Returning "SEE LICENSE IN LICENSE" verbatim reports the pointer as
+ // though it were the license, and nothing can identify it. The terms
+ // are in the file it names, so decline and let that file be read.
+ if lcs != "" && !isLicenseFileReference(lcs) {
return lcs, true
}
case '{':
diff --git a/pkg/deps/resolve.go b/pkg/deps/npm_license_field_test.go
similarity index 53%
copy from pkg/deps/resolve.go
copy to pkg/deps/npm_license_field_test.go
index fe1a672..5fe1afc 100644
--- a/pkg/deps/resolve.go
+++ b/pkg/deps/npm_license_field_test.go
@@ -17,39 +17,23 @@
package deps
-import (
- "fmt"
-)
+import "testing"
-type Resolver interface {
- CanResolve(string) bool
- Resolve(string, *ConfigDeps, *Report) error
-}
-
-var Resolvers = []Resolver{
- new(GoModResolver),
- new(NpmResolver),
- new(MavenPomResolver),
- new(JarResolver),
- new(CargoTomlResolver),
- new(GemfileLockResolver),
- new(GemspecResolver),
-}
-
-func Resolve(config *ConfigDeps, report *Report) error {
-resolveFile:
- for _, file := range config.Files {
- for _, resolver := range Resolvers {
- if !resolver.CanResolve(file) {
- continue
- }
- if err := resolver.Resolve(file, config, report); err != nil {
- return err
- }
- continue resolveFile
+func TestIsLicenseFileReference(t *testing.T) {
+ for _, c := range []struct {
+ value string
+ want bool
+ }{
+ {"SEE LICENSE IN LICENSE", true},
+ {"See license in LICENSE.md", true},
+ {" SEE LICENSE IN ./legal/terms.txt", true},
+ {"MIT", false},
+ {"Apache-2.0", false},
+ {"MIT OR Apache-2.0", false},
+ {"", false},
+ } {
+ if got := isLicenseFileReference(c.value); got != c.want {
+ t.Errorf("isLicenseFileReference(%q) = %v, want %v", c.value, got, c.want)
}
- return fmt.Errorf("unable to find a resolver to resolve dependency declaration file: %v", file)
}
-
- return nil
}
diff --git a/pkg/deps/pnpm.go b/pkg/deps/pnpm.go
new file mode 100644
index 0000000..d2864ff
--- /dev/null
+++ b/pkg/deps/pnpm.go
@@ -0,0 +1,278 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package deps
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/apache/skywalking-eyes/pkg/logger"
+)
+
+// PnpmLockFileName identifies a pnpm-managed project.
+const PnpmLockFileName = "pnpm-lock.yaml"
+
+// pnpmCleanInstallMajor is the first pnpm major with a `pnpm ci` command
+// (aliased clean-install / install-clean). Below it, the lock-pinned install
+// has to be spelled out as flags.
+const pnpmCleanInstallMajor = 8
+
+// PnpmResolver resolves the licenses of a pnpm-managed project's dependencies.
+//
+// It handles the same `package.json` as NpmResolver and reads licenses exactly
+// the same way — a package resolves identically whichever manager installed it.
+// What differs is finding the packages: pnpm links dependencies out of a
+// content-addressed store rather than laying them out under
+// `node_modules/<name>`, so neither `npm ls --parseable` nor the name npm
+// infers from an install path applies. NpmResolver is embedded for the shared
+// half, and only the listing is replaced.
+type PnpmResolver struct {
+ NpmResolver
+
+ // Workspaces already listed. `pnpm licenses list` reports the whole
+ // workspace whatever member it runs from, so a config naming several
+ // package.json files would otherwise report every package once per member.
+ listedWorkspaces map[string]bool
+}
+
+// CanResolve is deliberately narrower than NpmResolver's: it claims a
+// package.json only when the project is pnpm-managed. Registration order in
+// Resolvers therefore matters — this resolver must be offered the file first,
+// or NpmResolver will claim every package.json and pnpm projects resolve as
+// nothing.
+func (resolver *PnpmResolver) CanResolve(file string) bool {
+ if filepath.Base(file) != PkgFileName {
+ return false
+ }
+ return isPnpmProject(filepath.Dir(file))
+}
+
+// isPnpmProject reports whether the project rooted at or above dir is managed
+// by pnpm.
+//
+// The corepack `packageManager` field is the project's own statement of what it
+// is built with, so it is believed first; a project that predates corepack, or
+// simply omits the field, is identified by its lock file. Both live at the
+// workspace ROOT while resolution runs from each MEMBER, so this climbs.
+func isPnpmProject(dir string) bool {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return false
+ }
+ for {
+ if content, err := os.ReadFile(filepath.Join(abs, PkgFileName)); err == nil {
+ var manifest struct {
+ PackageManager string `json:"packageManager"`
+ }
+ if err := json.Unmarshal(content, &manifest); err == nil && manifest.PackageManager != "" {
+ name, _, _ := strings.Cut(manifest.PackageManager, "@")
+ // A declared manager is decisive in BOTH directions: a project
+ // naming yarn is not pnpm's to claim, whatever lock files a
+ // stale working tree happens to contain.
+ return name == "pnpm"
+ }
+ }
+ if _, err := os.Stat(filepath.Join(abs, PnpmLockFileName)); err == nil {
+ return true
+ }
+ parent := filepath.Dir(abs)
+ if parent == abs {
+ return false
+ }
+ abs = parent
+ }
+}
+
+// pnpmWorkspaceRoot walks up from dir to the directory holding the pnpm lock
+// file, which is the workspace a member belongs to. Empty when there is none.
+func pnpmWorkspaceRoot(dir string) string {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return ""
+ }
+ for {
+ if _, err := os.Stat(filepath.Join(abs, PnpmLockFileName)); err == nil {
+ return abs
+ }
+ parent := filepath.Dir(abs)
+ if parent == abs {
+ return ""
+ }
+ abs = parent
+ }
+}
+
+// Resolve resolves licenses of all dependencies declared in the package.json file.
+func (resolver *PnpmResolver) Resolve(pkgFile string, config *ConfigDeps, report *Report) error {
+ workDir := filepath.Dir(pkgFile)
+ if err := os.Chdir(workDir); err != nil {
+ return err
+ }
+
+ if needSkip := resolver.NeedSkipInstallPkgs(); !needSkip {
+ resolver.InstallPkgs()
+ }
+
+ pkgs := resolver.GetInstalledPkgs(filepath.Join(workDir, "node_modules"))
+ resolver.ResolvePackages(pkgs, config, report)
+ return nil
+}
+
+// InstallPkgs runs `pnpm ci` to install node packages, the counterpart of
+// `npm ci`: a clean, lock-pinned install, for reproducible builds.
+func (resolver *PnpmResolver) InstallPkgs() {
+ cmd := exec.Command("pnpm", "ci")
+ if !pnpmSupportsCleanInstall() {
+ cmd = exec.Command("pnpm", "install", "--frozen-lockfile")
+ }
+ logger.Log.Println(fmt.Sprintf("Run command: %v, please wait", cmd.String()))
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ logger.Log.Errorln(err)
+ }
+}
+
+// pnpmSupportsCleanInstall reports whether the pnpm on PATH has `pnpm ci`.
+// An unreadable or unparseable version is taken as "no", so the explicit flags
+// are used rather than a command that may not exist.
+func pnpmSupportsCleanInstall() bool {
+ out, err := exec.Command("pnpm", "--version").Output()
+ if err != nil {
+ return false
+ }
+ major, _, _ := strings.Cut(strings.TrimSpace(string(out)), ".")
+ n, err := strconv.Atoi(major)
+ if err != nil {
+ return false
+ }
+ return n >= pnpmCleanInstallMajor
+}
+
+// ListPkgPaths lists every production package pnpm has installed, one absolute
+// path per line, matching what `npm ls --parseable` produces for npm.
+//
+// pnpm has no `--parseable`, and `pnpm ls` reports the dependency GRAPH rather
+// than install locations. `pnpm licenses list` reports the locations directly,
+// which is what this needs — the license it also reports is left to the normal
+// resolution path, so a package is read the same way whichever manager
+// installed it.
+func (resolver *PnpmResolver) ListPkgPaths() (io.Reader, error) {
+ // Keyed on the workspace ROOT, not the working directory: resolution runs
+ // from each member in turn, so the cwd differs every time while the
+ // workspace — and the package set — is the same one.
+ cwd, err := os.Getwd()
+ if err != nil {
+ return nil, err
+ }
+ root := pnpmWorkspaceRoot(cwd)
+ if root == "" {
+ root = cwd
+ }
+ if resolver.listedWorkspaces[root] {
+ return &bytes.Buffer{}, nil
+ }
+
+ buffer := &bytes.Buffer{}
+ cmd := exec.Command("pnpm", "licenses", "list", "--prod", "--json")
+ cmd.Stdout = buffer
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("failed to list pnpm packages: %w", err)
+ }
+
+ var grouped map[string][]struct {
+ Paths []string `json:"paths"`
+ }
+ if err := json.Unmarshal(buffer.Bytes(), &grouped); err != nil {
+ return nil, fmt.Errorf("failed to parse `pnpm licenses list --json` output: %w", err)
+ }
+
+ // Every path, not one per entry: entries are grouped by the license PNPM
+ // read, so one can carry several VERSIONS of a package, each at its own
+ // path. Keeping only the first would leave a version unread — and a version
+ // whose terms differ from its sibling's is exactly what a license scan
+ // exists to catch.
+ paths := &bytes.Buffer{}
+ for _, entries := range grouped {
+ for _, entry := range entries {
+ for _, path := range entry.Paths {
+ paths.WriteString(path)
+ paths.WriteByte('\n')
+ }
+ }
+ }
+ if resolver.listedWorkspaces == nil {
+ resolver.listedWorkspaces = make(map[string]bool)
+ }
+ resolver.listedWorkspaces[root] = true
+ return paths, nil
+}
+
+// GetInstalledPkgs gathers all the installed packages' names and paths.
+//
+// The node_modules directory NpmResolver is handed is ignored: pnpm reports
+// absolute paths, and its packages live under `node_modules/.pnpm` rather than
+// directly beneath it, so there is nothing to resolve them against. The
+// parameter is kept so this shadows the embedded method — a caller reaching
+// GetInstalledPkgs on a pnpm project must not silently get npm's listing.
+func (resolver *PnpmResolver) GetInstalledPkgs(_ string) []*Package {
+ buffer, err := resolver.ListPkgPaths()
+ if err != nil {
+ logger.Log.Errorln(err)
+ return nil
+ }
+ pkgs := make([]*Package, 0)
+ sc := bufio.NewScanner(buffer)
+ for sc.Scan() {
+ absPath := sc.Text()
+ name := pnpmPkgName(absPath)
+ if name == "" {
+ continue
+ }
+ pkgs = append(pkgs, &Package{
+ Name: name,
+ Path: absPath,
+ })
+ }
+ return pkgs
+}
+
+// pnpmPkgName recovers a package's name from where pnpm installed it.
+//
+// npm installs to `node_modules/<name>`, so npm infers the name from the path
+// relative to the node_modules directory. pnpm links out of its store, to
+// `node_modules/.pnpm/<name>@<version>/node_modules/<name>` — where that same
+// relative path yields the store key instead. The name is what follows the LAST
+// node_modules segment, which is true of both layouts.
+func pnpmPkgName(absPath string) string {
+ normalized := filepath.ToSlash(absPath)
+ const marker = "/node_modules/"
+ if i := strings.LastIndex(normalized, marker); i >= 0 {
+ return normalized[i+len(marker):]
+ }
+ return ""
+}
diff --git a/pkg/deps/pnpm_test.go b/pkg/deps/pnpm_test.go
new file mode 100644
index 0000000..260ee41
--- /dev/null
+++ b/pkg/deps/pnpm_test.go
@@ -0,0 +1,170 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Internal, because what needs testing here — which projects the resolver
+// claims, and how a name is recovered from an install path — is not exported.
+
+package deps
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// writeFile creates dir/name containing content, making the directory first.
+func writeFile(t *testing.T, dir, name, content string) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPnpmResolverCanResolve(t *testing.T) {
+ resolver := new(PnpmResolver)
+
+ t.Run("declines a file that is not package.json", func(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, PnpmLockFileName, "lockfileVersion: '9.0'\n")
+ if resolver.CanResolve(filepath.Join(dir, "go.mod")) {
+ t.Error("claimed go.mod")
+ }
+ })
+
+ t.Run("declines an npm project, leaving it to NpmResolver", func(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, PkgFileName, `{"name":"plain"}`)
+ writeFile(t, dir, "package-lock.json", `{}`)
+ if resolver.CanResolve(filepath.Join(dir, PkgFileName)) {
+ t.Error("claimed an npm project")
+ }
+ })
+
+ t.Run("claims a project with a pnpm lock file", func(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, PkgFileName, `{"name":"root"}`)
+ writeFile(t, dir, PnpmLockFileName, "lockfileVersion: '9.0'\n")
+ if !resolver.CanResolve(filepath.Join(dir, PkgFileName)) {
+ t.Error("did not claim a pnpm project")
+ }
+ })
+
+ t.Run("claims a project declaring pnpm without a lock file", func(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, PkgFileName, `{"name":"root","packageManager":"pnpm@9.12.0"}`)
+ if !resolver.CanResolve(filepath.Join(dir, PkgFileName)) {
+ t.Error("did not honor the packageManager field")
+ }
+ })
+
+ // A declared manager is decisive in both directions: a yarn project with a
+ // leftover pnpm lock file in the tree is not pnpm's to claim.
+ t.Run("declines a project declaring another manager", func(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, PkgFileName, `{"name":"root","packageManager":"yarn@4.1.0"}`)
+ writeFile(t, dir, PnpmLockFileName, "lockfileVersion: '9.0'\n")
+ if resolver.CanResolve(filepath.Join(dir, PkgFileName)) {
+ t.Error("claimed a project that declares yarn")
+ }
+ })
+
+ // The case that matters in practice: resolution runs from each workspace
+ // member in turn, and a member declares neither the field nor the lock file.
+ t.Run("claims a workspace member via its root", func(t *testing.T) {
+ root := t.TempDir()
+ writeFile(t, root, PkgFileName, `{"name":"root"}`)
+ writeFile(t, root, PnpmLockFileName, "lockfileVersion: '9.0'\n")
+ member := filepath.Join(root, "apps", "bff")
+ writeFile(t, member, PkgFileName, `{"name":"@scope/bff"}`)
+
+ if !resolver.CanResolve(filepath.Join(member, PkgFileName)) {
+ t.Error("did not claim a workspace member")
+ }
+ })
+}
+
+// The two Node resolvers both answer to package.json and Resolve() takes the
+// first match, so this ordering is load-bearing, not cosmetic.
+func TestPnpmResolverIsOfferedPackageJSONFirst(t *testing.T) {
+ pnpmAt, npmAt := -1, -1
+ for i, r := range Resolvers {
+ switch r.(type) {
+ case *PnpmResolver:
+ pnpmAt = i
+ case *NpmResolver:
+ npmAt = i
+ }
+ }
+ if pnpmAt < 0 || npmAt < 0 {
+ t.Fatalf("both Node resolvers must be registered: pnpm=%d npm=%d", pnpmAt, npmAt)
+ }
+ if pnpmAt > npmAt {
+ t.Errorf("PnpmResolver (%d) must precede NpmResolver (%d), or NpmResolver claims every package.json", pnpmAt, npmAt)
+ }
+}
+
+func TestPnpmWorkspaceRoot(t *testing.T) {
+ root := t.TempDir()
+ writeFile(t, root, PnpmLockFileName, "lockfileVersion: '9.0'\n")
+ member := filepath.Join(root, "packages", "api-client")
+ if err := os.MkdirAll(member, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Every member of one workspace must answer with the same root — that is
+ // what stops the same package set being listed once per member.
+ want, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, dir := range []string{root, member} {
+ got, err := filepath.EvalSymlinks(pnpmWorkspaceRoot(dir))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Errorf("from %q: got %q, want %q", dir, got, want)
+ }
+ }
+
+ if got := pnpmWorkspaceRoot(t.TempDir()); got != "" {
+ t.Errorf("outside any workspace: got %q, want empty", got)
+ }
+}
+
+func TestPnpmPkgName(t *testing.T) {
+ for _, c := range []struct {
+ path string
+ want string
+ }{
+ // pnpm links out of its store, so the name follows the LAST
+ // node_modules segment, not the first.
+ {"/w/node_modules/.pnpm/vue@3.5.13/node_modules/vue", "vue"},
+ {"/w/node_modules/.pnpm/@vue+shared@3.5.13/node_modules/@vue/shared", "@vue/shared"},
+ // A direct link, as a non-hoisted install also produces.
+ {"/w/node_modules/vue", "vue"},
+ // No node_modules in the path at all: not a dependency, so not listed.
+ {"/w/apps/bff", ""},
+ } {
+ if got := pnpmPkgName(c.path); got != c.want {
+ t.Errorf("pnpmPkgName(%q) = %q, want %q", c.path, got, c.want)
+ }
+ }
+}
diff --git a/pkg/deps/resolve.go b/pkg/deps/resolve.go
index fe1a672..d916a7f 100644
--- a/pkg/deps/resolve.go
+++ b/pkg/deps/resolve.go
@@ -28,6 +28,10 @@ type Resolver interface {
var Resolvers = []Resolver{
new(GoModResolver),
+ // PnpmResolver before NpmResolver: both answer to package.json, and the
+ // first match wins, so the narrower predicate has to be offered the file
+ // first. Reordering these two silently stops pnpm projects resolving.
+ new(PnpmResolver),
new(NpmResolver),
new(MavenPomResolver),
new(JarResolver),