Skip to content

Commit 76ffa18

Browse files
committed
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
1 parent 7144952 commit 76ffa18

7 files changed

Lines changed: 109 additions & 7 deletions

File tree

src/providers/base_javascript.js

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,69 @@ export default class Base_javascript {
112112
}
113113

114114
/**
115-
* Checks if a required lock file exists in the manifest directory or at the workspace root.
115+
* Walks up the directory tree from manifestDir looking for the lock file.
116+
* Stops when the lock file is found, when a package.json with a "workspaces"
117+
* field is encountered without a lock file (workspace root boundary), or
118+
* when the filesystem root is reached.
119+
*
120+
* When TRUSTIFY_DA_WORKSPACE_DIR is set, checks only that directory (no walk-up).
121+
*
122+
* @param {string} manifestDir - The directory to start searching from
123+
* @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR
124+
* @returns {string|null} The directory containing the lock file, or null
125+
* @protected
126+
*/
127+
_findLockFileDir(manifestDir, opts = {}) {
128+
const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts)
129+
if (workspaceDir) {
130+
const dir = path.resolve(workspaceDir)
131+
return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null
132+
}
133+
134+
let dir = path.resolve(manifestDir)
135+
let parent = dir
136+
137+
do {
138+
dir = parent
139+
140+
if (fs.existsSync(path.join(dir, this._lockFileName()))) {
141+
return dir
142+
}
143+
144+
// If this directory has a package.json with "workspaces", the lock
145+
// file should have been here — stop searching (analogous to Cargo's
146+
// [workspace] boundary).
147+
const pkgJsonPath = path.join(dir, 'package.json')
148+
if (fs.existsSync(pkgJsonPath)) {
149+
try {
150+
const content = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'))
151+
if (content.workspaces) {
152+
return null
153+
}
154+
} catch (_) {
155+
// ignore parse errors, keep searching
156+
}
157+
}
158+
159+
parent = path.dirname(dir)
160+
} while (parent !== dir)
161+
162+
return null
163+
}
164+
165+
/**
166+
* Checks if a required lock file exists in the manifest directory, a parent
167+
* directory, or at the workspace root. Walks up the directory tree following
168+
* the same pattern as the Cargo provider.
169+
*
116170
* When TRUSTIFY_DA_WORKSPACE_DIR is provided (via env var or opts),
117171
* checks only that directory for the lock file.
118172
* @param {string} manifestDir - The base directory where the manifest is located
119173
* @param {{TRUSTIFY_DA_WORKSPACE_DIR?: string}} [opts={}] - optional workspace root
120174
* @returns {boolean} True if the lock file exists
121175
*/
122176
validateLockFile(manifestDir, opts = {}) {
123-
const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts)
124-
const dirToCheck = workspaceDir ? path.resolve(workspaceDir) : manifestDir
125-
const lock = path.join(dirToCheck, this._lockFileName())
126-
return fs.existsSync(lock)
177+
return this._findLockFileDir(manifestDir, opts) !== null
127178
}
128179

129180
/**
@@ -188,8 +239,7 @@ export default class Base_javascript {
188239
_buildDependencyTree(includeTransitive, opts = {}) {
189240
this._version();
190241
const manifestDir = path.dirname(this.#manifest.manifestPath);
191-
const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts)
192-
const cmdDir = workspaceDir ? path.resolve(workspaceDir) : manifestDir;
242+
const cmdDir = this._findLockFileDir(manifestDir, opts) || manifestDir;
193243
this.#createLockFile(cmdDir);
194244

195245
let output = this.#executeListCmd(includeTransitive, cmdDir);

test/providers/javascript.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ suite('testing the javascript-npm data provider', async () => {
5353
[
5454
{ name: 'npm/with_lock_file', validation: true },
5555
{ name: 'npm/without_lock_file', validation: false },
56+
{ name: 'npm/workspace_member_with_lock/packages/module-a', validation: true },
57+
{ name: 'npm/workspace_member_without_lock/packages/module-a', validation: false },
5658
{ name: 'pnpm/with_lock_file', validation: true },
5759
{ name: 'pnpm/without_lock_file', validation: false },
5860
{ name: 'yarn-classic/with_lock_file', validation: true },
@@ -168,4 +170,32 @@ suite('testing the javascript-npm data provider', async () => {
168170
expect(provider.isSupported('package.json')).to.be.true
169171
})
170172

173+
test('verify workspace member walks up and finds lock file at workspace root', () => {
174+
const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json'
175+
const provider = match(manifest, availableProviders)
176+
expect(provider).to.not.be.null
177+
expect(provider.isSupported('package.json')).to.be.true
178+
})
179+
180+
test('verify workspace member throws when workspace root has no lock file', () => {
181+
const manifest = 'test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json'
182+
expect(() => match(manifest, availableProviders))
183+
.to.throw('package.json requires a lock file')
184+
})
185+
186+
test('verify match with opts.TRUSTIFY_DA_WORKSPACE_DIR overrides walk-up for workspace member', () => {
187+
const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json'
188+
const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_with_lock' }
189+
const provider = match(manifest, availableProviders, opts)
190+
expect(provider).to.not.be.null
191+
expect(provider.isSupported('package.json')).to.be.true
192+
})
193+
194+
test('verify match with wrong TRUSTIFY_DA_WORKSPACE_DIR fails even when walk-up would succeed', () => {
195+
const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json'
196+
const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_without_lock' }
197+
expect(() => match(manifest, availableProviders, opts))
198+
.to.throw('package.json requires a lock file')
199+
})
200+
171201
}).beforeAll(() => clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z'))).afterAll(() => clock.restore());

test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "test-workspace-root",
3+
"private": true,
4+
"workspaces": ["packages/*"]
5+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "module-a",
3+
"version": "1.0.0"
4+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "test-workspace-root",
3+
"private": true,
4+
"workspaces": ["packages/*"]
5+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "module-a",
3+
"version": "1.0.0"
4+
}

0 commit comments

Comments
 (0)