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),