Eslint-plugin-sonarjs: pnpm catalogs unresolved when linting from a workspace package

eslint-plugin-sonarjs: pnpm catalog: lookup stops at the ESLint cwd, so linting a workspace package from its own directory prints Dependency "x" could not be resolved for catalog "y" to stdout

Category: Report a bug (eslint-plugin-sonarjs, standalone ESLint usage)

Environment

  • eslint-plugin-sonarjs 4.2.0 (also reproduced on the current master code path, see “Root cause”)
  • eslint 10.10.0 (flat config)
  • pnpm 10.33.4 and 11.26.0, nodeLinker: hoisted — irrelevant to the bug, the manifests are read from disk
  • Node.js 24.18.0, Windows 11 (the lookup is path-based, nothing platform-specific)

Summary

In a pnpm workspace that uses catalogs, running ESLint from inside a workspace package (the normal "lint": "eslint ." script executed by pnpm --filter <pkg> run lint, or just pnpm lint in the package folder) makes eslint-plugin-sonarjs print one line per catalog: dependency:

Dependency "zod" could not be resolved for catalog "ts"

The plugin never finds pnpm-workspace.yaml because its upward search for the catalog source is bounded by context.cwd, which in this setup is the package directory — the workspace root, where pnpm-workspace.yaml lives, is above it. The same command executed from the workspace root (eslint packages/app) resolves everything and prints nothing.

Steps to reproduce

repro/
├── pnpm-workspace.yaml
├── package.json
└── packages/app/
    ├── package.json
    ├── eslint.config.mjs
    └── src/index.js

pnpm-workspace.yaml

packages:
  - packages/*

catalogs:
  ts:
    zod: 4.6.2

package.json

{
  "name": "sonarjs-catalog-repro",
  "private": true,
  "devDependencies": {
    "eslint": "10.10.0",
    "eslint-plugin-sonarjs": "4.2.0"
  }
}

packages/app/package.json

{
  "name": "app",
  "private": true,
  "scripts": {
    "lint": "eslint ."
  },
  "dependencies": {
    "zod": "catalog:ts"
  }
}

packages/app/eslint.config.mjs

import sonarjs from 'eslint-plugin-sonarjs';

export default [sonarjs.configs.recommended];

packages/app/src/index.js

export const answer = 42;

Then:

$ pnpm install
$ cd packages/app && pnpm lint
> eslint .
Dependency "zod" could not be resolved for catalog "ts"

$ cd ../.. && npx eslint packages/app      # same files, same config, cwd = workspace root
(no output)

Observed

  • One Dependency "<name>" could not be resolved for catalog "<catalog>" line per catalog dependency of the package (11 lines for one of our real packages), on every lint run from the package directory.
  • The message is emitted with console.debug, i.e. on stdout, so it corrupts machine-readable output: eslint . --format json prints the debug lines before the JSON array, and a consumer doing JSON.parse on stdout fails.
  • Because the version never resolves, the dependency map keeps the literal catalog:ts string as the version. parseReactVersion('catalog:react') returns null, so every version-gated React rule loses the version signal for any React app that declares react: catalog:react; the Node.js/TypeScript version signals are affected the same way.

Expected

The catalog reference resolves against the workspace’s pnpm-workspace.yaml regardless of which directory ESLint happens to be started from, exactly like pnpm itself resolves it. At the very least the diagnostic should not go to stdout.

Root cause

packages/analysis/src/jsts/rules/helpers/dependency-manifests/resolvers/package-json.ts, packageJsonManifestResolver.resolve(dir, topDir, fileSystem):

  1. findClosestParentPackageJsonWithCatalogs(dir, topDir, fileSystem) returns undefined right away when dir === topDir, and otherwise stops climbing at topDir.
  2. The fallback closestPatternCache.get(PNPM_WORKSPACE_YAML, fileSystem).get(topDir).get(dir) also stops at topDir (from !== topDir guard in find-up/closest.ts).
  3. With no catalog source, resolveCatalogReferences keeps the catalog: string and calls console.debug(...).

topDir is normalizeToAbsolutePath(context.cwd) (dependency-manifests/dependencies.ts → getDependenciesSanitizePaths, getReactVersion; all-in-parent-dirs.ts → getPackageJsonManifests). Under the SonarQube scanner context.cwd is the project base dir, which is normally the repository root, so the boundary is harmless there. Under plain ESLint context.cwd is process.cwd(), which in a monorepo is most often the package being linted — and by definition the workspace root is one or more levels above it, i.e. outside the search range. ESLint’s CLI has no --cwd flag, and the plugin exposes no setting to override the boundary, so there is no way to fix it from configuration.

The lookup in question was introduced by JS-1315 / PR #6904; JS-1728 / PR #7892 (Bun root catalogs) keeps the same topDir boundary, so master behaves the same way for this layout.

Suggested fix

Any of these would solve it; the first is the most faithful to pnpm’s own behaviour:

  1. Search for the catalog source past topDir. A pnpm workspace root is found by walking up from the package until a directory containing pnpm-workspace.yaml is hit (that is what @pnpm/find-workspace-dir does); a Bun/npm/yarn workspace root is the nearest ancestor package.json with a workspaces field. Neither is ever below the package, so bounding the search by context.cwd cannot be correct for that particular lookup — topDir still makes sense for which manifests contribute dependencies, just not for where the catalog definitions live.
  2. Expose a setting, e.g. settings: { sonarjs: { workspaceRoot: '<abs path>' } }, used as the upper bound instead of context.cwd when present. Shared ESLint configs in monorepos can then pin it once.
  3. Independently of 1/2: emit the unresolved-catalog diagnostic through the plugin’s debug logging (or console.error/stderr) rather than console.debug on stdout, so --format json and other stdout consumers keep working.

Workaround we use today

Run ESLint with the workspace root as cwd and point it at the package: "lint": "pnpm -w exec eslint packages/app" (pnpm -w = --workspace-root). ESLint 10 looks the flat config up from each linted file’s directory, so the package’s own eslint.config.mjs still applies. This works but has to be applied to every package’s lint script and every other plugin that defaults to cwd has to be pinned to the package separately (eslint-import-resolver-typescript project, @next/eslint-plugin-next settings.next.rootDir).

Hello @daomtthuan,

thanks for reporting this. I’ve created a ticket to fix it. It will be included in the next release of the ESLint plugin.

Cheers