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-sonarjs4.2.0 (also reproduced on the currentmastercode path, see “Root cause”)eslint10.10.0 (flat config)pnpm10.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 jsonprints the debug lines before the JSON array, and a consumer doingJSON.parseon stdout fails. - Because the version never resolves, the dependency map keeps the literal
catalog:tsstring as the version.parseReactVersion('catalog:react')returnsnull, so every version-gated React rule loses the version signal for any React app that declaresreact: 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):
findClosestParentPackageJsonWithCatalogs(dir, topDir, fileSystem)returnsundefinedright away whendir === topDir, and otherwise stops climbing attopDir.- The fallback
closestPatternCache.get(PNPM_WORKSPACE_YAML, fileSystem).get(topDir).get(dir)also stops attopDir(from !== topDirguard infind-up/closest.ts). - With no catalog source,
resolveCatalogReferenceskeeps thecatalog:string and callsconsole.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:
- Search for the catalog source past
topDir. A pnpm workspace root is found by walking up from the package until a directory containingpnpm-workspace.yamlis hit (that is what@pnpm/find-workspace-dirdoes); a Bun/npm/yarn workspace root is the nearest ancestorpackage.jsonwith aworkspacesfield. Neither is ever below the package, so bounding the search bycontext.cwdcannot be correct for that particular lookup —topDirstill makes sense for which manifests contribute dependencies, just not for where the catalog definitions live. - Expose a setting, e.g.
settings: { sonarjs: { workspaceRoot: '<abs path>' } }, used as the upper bound instead ofcontext.cwdwhen present. Shared ESLint configs in monorepos can then pin it once. - Independently of 1/2: emit the unresolved-catalog diagnostic through the plugin’s debug logging (or
console.error/stderr) rather thanconsole.debugon stdout, so--format jsonand 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).