From 9e8f8a8baa8a4f949e6a942e95822ec0832e7627 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 25 Mar 2026 09:12:55 +0100 Subject: [PATCH 1/3] feat: walk up directory tree to find JS lockfile in monorepos Add parent-traversal logic to Base_javascript.validateLockFile and _buildDependencyTree, matching the existing Cargo provider pattern. When a nested package.json has no lockfile in its directory, the provider now walks up looking for package-lock.json/yarn.lock/ pnpm-lock.yaml, stopping at a package.json with "workspaces" field (the workspace root boundary) or the filesystem root. This enables single-file stack analysis to work for modules in JS/TS monorepos where the lockfile lives at the workspace root. TRUSTIFY_DA_WORKSPACE_DIR still takes precedence as an explicit override (no walk-up when set). Assisted-by: Claude Code Ref: TC-3891 --- src/providers/base_javascript.js | 64 +++++++++++++++++-- test/providers/javascript.test.js | 30 +++++++++ .../package-lock.json | 4 ++ .../workspace_member_with_lock/package.json | 5 ++ .../packages/module-a/package.json | 4 ++ .../package.json | 5 ++ .../packages/module-a/package.json | 4 ++ 7 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json create mode 100644 test/providers/provider_manifests/npm/workspace_member_with_lock/package.json create mode 100644 test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json create mode 100644 test/providers/provider_manifests/npm/workspace_member_without_lock/package.json create mode 100644 test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index 64af7f22..1fecd6b8 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -112,7 +112,61 @@ export default class Base_javascript { } /** - * Checks if a required lock file exists in the manifest directory or at the workspace root. + * Walks up the directory tree from manifestDir looking for the lock file. + * Stops when the lock file is found, when a package.json with a "workspaces" + * field is encountered without a lock file (workspace root boundary), or + * when the filesystem root is reached. + * + * When TRUSTIFY_DA_WORKSPACE_DIR is set, checks only that directory (no walk-up). + * + * @param {string} manifestDir - The directory to start searching from + * @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR + * @returns {string|null} The directory containing the lock file, or null + * @protected + */ + _findLockFileDir(manifestDir, opts = {}) { + const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) + if (workspaceDir) { + const dir = path.resolve(workspaceDir) + return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null + } + + let dir = path.resolve(manifestDir) + let parent = dir + + do { + dir = parent + + if (fs.existsSync(path.join(dir, this._lockFileName()))) { + return dir + } + + // If this directory has a package.json with "workspaces", the lock + // file should have been here — stop searching (analogous to Cargo's + // [workspace] boundary). + const pkgJsonPath = path.join(dir, 'package.json') + if (fs.existsSync(pkgJsonPath)) { + try { + const content = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) + if (content.workspaces) { + return null + } + } catch (_) { + // ignore parse errors, keep searching + } + } + + parent = path.dirname(dir) + } while (parent !== dir) + + return null + } + + /** + * Checks if a required lock file exists in the manifest directory, a parent + * directory, or at the workspace root. Walks up the directory tree following + * the same pattern as the Cargo provider. + * * When TRUSTIFY_DA_WORKSPACE_DIR is provided (via env var or opts), * checks only that directory for the lock file. * @param {string} manifestDir - The base directory where the manifest is located @@ -120,10 +174,7 @@ export default class Base_javascript { * @returns {boolean} True if the lock file exists */ validateLockFile(manifestDir, opts = {}) { - const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) - const dirToCheck = workspaceDir ? path.resolve(workspaceDir) : manifestDir - const lock = path.join(dirToCheck, this._lockFileName()) - return fs.existsSync(lock) + return this._findLockFileDir(manifestDir, opts) !== null } /** @@ -188,8 +239,7 @@ export default class Base_javascript { _buildDependencyTree(includeTransitive, opts = {}) { this._version(); const manifestDir = path.dirname(this.#manifest.manifestPath); - const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) - const cmdDir = workspaceDir ? path.resolve(workspaceDir) : manifestDir; + const cmdDir = this._findLockFileDir(manifestDir, opts) || manifestDir; this.#createLockFile(cmdDir); let output = this.#executeListCmd(includeTransitive, cmdDir); diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 59708b08..ea1b7f0e 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -53,6 +53,8 @@ suite('testing the javascript-npm data provider', async () => { [ { name: 'npm/with_lock_file', validation: true }, { name: 'npm/without_lock_file', validation: false }, + { name: 'npm/workspace_member_with_lock/packages/module-a', validation: true }, + { name: 'npm/workspace_member_without_lock/packages/module-a', validation: false }, { name: 'pnpm/with_lock_file', validation: true }, { name: 'pnpm/without_lock_file', validation: false }, { name: 'yarn-classic/with_lock_file', validation: true }, @@ -168,4 +170,32 @@ suite('testing the javascript-npm data provider', async () => { expect(provider.isSupported('package.json')).to.be.true }) + test('verify workspace member walks up and finds lock file at workspace root', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' + const provider = match(manifest, availableProviders) + expect(provider).to.not.be.null + expect(provider.isSupported('package.json')).to.be.true + }) + + test('verify workspace member throws when workspace root has no lock file', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json' + expect(() => match(manifest, availableProviders)) + .to.throw('package.json requires a lock file') + }) + + test('verify match with opts.TRUSTIFY_DA_WORKSPACE_DIR overrides walk-up for workspace member', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' + const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_with_lock' } + const provider = match(manifest, availableProviders, opts) + expect(provider).to.not.be.null + expect(provider.isSupported('package.json')).to.be.true + }) + + test('verify match with wrong TRUSTIFY_DA_WORKSPACE_DIR fails even when walk-up would succeed', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' + const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_without_lock' } + expect(() => match(manifest, availableProviders, opts)) + .to.throw('package.json requires a lock file') + }) + }).beforeAll(() => clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z'))).afterAll(() => clock.restore()); diff --git a/test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json b/test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json new file mode 100644 index 00000000..268208f5 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace-root", + "lockfileVersion": 3 +} diff --git a/test/providers/provider_manifests/npm/workspace_member_with_lock/package.json b/test/providers/provider_manifests/npm/workspace_member_with_lock/package.json new file mode 100644 index 00000000..98101a90 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_with_lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-root", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json b/test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json new file mode 100644 index 00000000..05c16625 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-a", + "version": "1.0.0" +} diff --git a/test/providers/provider_manifests/npm/workspace_member_without_lock/package.json b/test/providers/provider_manifests/npm/workspace_member_without_lock/package.json new file mode 100644 index 00000000..98101a90 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_without_lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-root", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json b/test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json new file mode 100644 index 00000000..05c16625 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-a", + "version": "1.0.0" +} From fe3dd963ba9e6477f1df864a7a03967d11ed07aa Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 25 Mar 2026 13:22:11 +0100 Subject: [PATCH 2/3] fix: pass correct cwd to commands and trim unnecessary comments Address Qodo review: #executeListCmd and #createLockFile now pass { cwd: manifestDir } to #invokeCommand so commands run from the lockfile directory, not the manifest directory. Co-Authored-By: Claude Opus 4.6 Ref: TC-3891 --- src/providers/base_javascript.js | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index 1fecd6b8..c609e473 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -141,9 +141,6 @@ export default class Base_javascript { return dir } - // If this directory has a package.json with "workspaces", the lock - // file should have been here — stop searching (analogous to Cargo's - // [workspace] boundary). const pkgJsonPath = path.join(dir, 'package.json') if (fs.existsSync(pkgJsonPath)) { try { @@ -152,7 +149,7 @@ export default class Base_javascript { return null } } catch (_) { - // ignore parse errors, keep searching + // ignore parse errors } } @@ -163,14 +160,8 @@ export default class Base_javascript { } /** - * Checks if a required lock file exists in the manifest directory, a parent - * directory, or at the workspace root. Walks up the directory tree following - * the same pattern as the Cargo provider. - * - * When TRUSTIFY_DA_WORKSPACE_DIR is provided (via env var or opts), - * checks only that directory for the lock file. * @param {string} manifestDir - The base directory where the manifest is located - * @param {{TRUSTIFY_DA_WORKSPACE_DIR?: string}} [opts={}] - optional workspace root + * @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR * @returns {boolean} True if the lock file exists */ validateLockFile(manifestDir, opts = {}) { @@ -360,7 +351,7 @@ export default class Base_javascript { */ #executeListCmd(includeTransitive, manifestDir) { const listArgs = this._listCmdArgs(includeTransitive, manifestDir); - return this.#invokeCommand(listArgs); + return this.#invokeCommand(listArgs, { cwd: manifestDir }); } /** @@ -382,14 +373,12 @@ export default class Base_javascript { const isWindows = os.platform() === 'win32'; if (isWindows) { - // On Windows, --prefix flag doesn't work as expected - // Instead of installing from the prefix folder, it installs from current working directory process.chdir(manifestDir); } try { const args = this._updateLockFileCmdArgs(manifestDir); - this.#invokeCommand(args); + this.#invokeCommand(args, { cwd: manifestDir }); } finally { if (isWindows) { process.chdir(originalDir); From 5c9567762622750dccbd01a4bfbc4abc40d1f545 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 25 Mar 2026 14:24:02 +0100 Subject: [PATCH 3/3] fix: add pnpm-workspace.yaml as workspace root boundary Address Qodo review: _findLockFileDir now also stops at directories containing pnpm-workspace.yaml, preventing the walk-up from escaping pnpm workspace roots that don't use package.json#workspaces. Ref: TC-3891 Co-Authored-By: Claude Opus 4.6 --- src/providers/base_javascript.js | 30 ++++++++++++------- test/providers/javascript.test.js | 6 ++++ .../package.json | 4 +++ .../packages/module-a/package.json | 4 +++ .../pnpm-workspace.yaml | 2 ++ 5 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json create mode 100644 test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json create mode 100644 test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index c609e473..100c03fa 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -124,6 +124,24 @@ export default class Base_javascript { * @returns {string|null} The directory containing the lock file, or null * @protected */ + _isWorkspaceRoot(dir) { + if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + return true + } + const pkgJsonPath = path.join(dir, 'package.json') + if (fs.existsSync(pkgJsonPath)) { + try { + const content = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) + if (content.workspaces) { + return true + } + } catch (_) { + // ignore parse errors + } + } + return false + } + _findLockFileDir(manifestDir, opts = {}) { const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) if (workspaceDir) { @@ -141,16 +159,8 @@ export default class Base_javascript { return dir } - const pkgJsonPath = path.join(dir, 'package.json') - if (fs.existsSync(pkgJsonPath)) { - try { - const content = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) - if (content.workspaces) { - return null - } - } catch (_) { - // ignore parse errors - } + if (this._isWorkspaceRoot(dir)) { + return null } parent = path.dirname(dir) diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index ea1b7f0e..eec02da1 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -191,6 +191,12 @@ suite('testing the javascript-npm data provider', async () => { expect(provider.isSupported('package.json')).to.be.true }) + test('verify pnpm workspace member stops at pnpm-workspace.yaml boundary', () => { + const manifest = 'test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json' + expect(() => match(manifest, availableProviders)) + .to.throw('package.json requires a lock file') + }) + test('verify match with wrong TRUSTIFY_DA_WORKSPACE_DIR fails even when walk-up would succeed', () => { const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_without_lock' } diff --git a/test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json new file mode 100644 index 00000000..2266cc9d --- /dev/null +++ b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-pnpm-workspace-root", + "private": true +} diff --git a/test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json new file mode 100644 index 00000000..05c16625 --- /dev/null +++ b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-a", + "version": "1.0.0" +} diff --git a/test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*'