Skip to content

Commit b1f7919

Browse files
committed
feat(provider): add bun.lock support (#887)
Add a Javascript_bun provider that parses bun.lock directly using jsonc-parser, since bun does not implement `bun pm ls --json`. Expose base class _createLockFile so the bun provider can refresh the lock file via `bun install --lockfile-only` before parsing, matching the behavior of all other JS providers. Closes #887 Refs: TC-4412, TC-4409 Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8c3942 commit b1f7919

32 files changed

Lines changed: 842 additions & 5 deletions

File tree

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"help": "^3.0.2",
5656
"https-proxy-agent": "^7.0.6",
5757
"js-yaml": "^4.1.1",
58+
"jsonc-parser": "^3.3.1",
5859
"micromatch": "^4.0.8",
5960
"node-fetch": "^3.3.2",
6061
"p-limit": "^4.0.0",

src/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,8 @@ async function detectWorkspaceManifests(root, opts) {
370370
}
371371
}
372372

373-
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
373+
const hasJsLock = fs.existsSync(path.join(root, 'bun.lock'))
374+
|| fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
374375
|| fs.existsSync(path.join(root, 'yarn.lock'))
375376
|| fs.existsSync(path.join(root, 'package-lock.json'))
376377

src/provider.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import golangGomodulesProvider from './providers/golang_gomodules.js'
44
import Java_gradle_groovy from "./providers/java_gradle_groovy.js";
55
import Java_gradle_kotlin from "./providers/java_gradle_kotlin.js";
66
import Java_maven from "./providers/java_maven.js";
7+
import Javascript_bun from './providers/javascript_bun.js';
78
import Javascript_npm from './providers/javascript_npm.js';
89
import Javascript_pnpm from './providers/javascript_pnpm.js';
910
import Javascript_yarn from './providers/javascript_yarn.js';
@@ -24,6 +25,7 @@ export const availableProviders = [
2425
new Java_maven(),
2526
new Java_gradle_groovy(),
2627
new Java_gradle_kotlin(),
28+
new Javascript_bun(),
2729
new Javascript_pnpm(),
2830
new Javascript_yarn(),
2931
new Javascript_npm(),

src/providers/base_javascript.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export default class Base_javascript {
241241
this._version();
242242
const manifestDir = path.dirname(this.#manifest.manifestPath);
243243
const cmdDir = this._findLockFileDir(manifestDir, opts) || manifestDir;
244-
this.#createLockFile(cmdDir);
244+
this._createLockFile(cmdDir);
245245

246246
let output = this.#executeListCmd(includeTransitive, cmdDir);
247247
output = this._parseDepTreeOutput(output);
@@ -408,9 +408,9 @@ export default class Base_javascript {
408408
/**
409409
* Creates or updates the lock file for the package manager
410410
* @param {string} manifestDir - Directory containing the manifest file
411-
* @private
411+
* @protected
412412
*/
413-
#createLockFile(manifestDir) {
413+
_createLockFile(manifestDir) {
414414
const originalDir = process.cwd();
415415
const isWindows = os.platform() === 'win32';
416416

src/providers/javascript_bun.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
import { parse } from 'jsonc-parser';
5+
6+
import Base_javascript from './base_javascript.js';
7+
8+
export default class Javascript_bun extends Base_javascript {
9+
10+
_lockFileName() {
11+
return "bun.lock";
12+
}
13+
14+
_cmdName() {
15+
return "bun";
16+
}
17+
18+
_listCmdArgs() {
19+
return [];
20+
}
21+
22+
_updateLockFileCmdArgs() {
23+
return ['install', '--lockfile-only'];
24+
}
25+
26+
_buildDependencyTree(includeTransitive, opts = {}) {
27+
this._version();
28+
const manifestDir = path.dirname(this._getManifest().manifestPath);
29+
const lockDir = this._findLockFileDir(manifestDir, opts) || manifestDir;
30+
this._createLockFile(lockDir);
31+
32+
const lockContent = fs.readFileSync(path.join(lockDir, 'bun.lock'), 'utf-8');
33+
const lockData = parse(lockContent);
34+
35+
const packages = lockData.packages || {};
36+
const memberName = this._getManifest().name;
37+
const workspaceEntry = this.#findWorkspaceEntry(lockData, lockDir, manifestDir, memberName);
38+
39+
const directDeps = workspaceEntry?.dependencies || {};
40+
const tree = { name: memberName, version: this._getManifest().version, dependencies: {} };
41+
42+
const visited = new Set();
43+
for (const depName of Object.keys(directDeps)) {
44+
const resolved = this.#resolvePackage(depName, '', packages);
45+
if (resolved) {
46+
tree.dependencies[depName] = this.#buildNode(depName, resolved, packages, includeTransitive, visited);
47+
}
48+
}
49+
50+
return tree;
51+
}
52+
53+
#findWorkspaceEntry(lockData, lockDir, manifestDir, memberName) {
54+
const workspaces = lockData.workspaces || {};
55+
const relPath = path.relative(lockDir, path.resolve(manifestDir));
56+
if (!relPath || relPath === '.') {
57+
return workspaces[''] || {};
58+
}
59+
const normalised = relPath.split(path.sep).join('/');
60+
if (workspaces[normalised]) {
61+
return workspaces[normalised];
62+
}
63+
for (const [wsPath, entry] of Object.entries(workspaces)) {
64+
if (entry.name === memberName && wsPath !== '') {
65+
return entry;
66+
}
67+
}
68+
return workspaces[''] || {};
69+
}
70+
71+
#resolvePackage(depName, parentKey, packages) {
72+
if (parentKey) {
73+
const scopedKey = `${parentKey}/${depName}`;
74+
if (packages[scopedKey]) {
75+
return packages[scopedKey];
76+
}
77+
}
78+
return packages[depName] || null;
79+
}
80+
81+
#buildNode(depName, resolved, packages, includeTransitive, visited) {
82+
const resolvedId = Array.isArray(resolved) ? resolved[0] : resolved;
83+
const version = this.#extractVersion(resolvedId);
84+
const node = { version };
85+
86+
if (!includeTransitive) {
87+
return node;
88+
}
89+
90+
const metadata = Array.isArray(resolved) ? (resolved[2] || {}) : {};
91+
const subDeps = metadata.dependencies || {};
92+
93+
if (Object.keys(subDeps).length > 0) {
94+
const visitKey = `${depName}@${version}`;
95+
if (visited.has(visitKey)) {
96+
return node;
97+
}
98+
visited.add(visitKey);
99+
100+
node.dependencies = {};
101+
for (const subName of Object.keys(subDeps)) {
102+
const subResolved = this.#resolvePackage(subName, depName, packages);
103+
if (subResolved) {
104+
node.dependencies[subName] = this.#buildNode(subName, subResolved, packages, true, visited);
105+
}
106+
}
107+
}
108+
109+
return node;
110+
}
111+
112+
#extractVersion(resolvedId) {
113+
if (typeof resolvedId !== 'string') {
114+
return '0.0.0';
115+
}
116+
const atIdx = resolvedId.lastIndexOf('@');
117+
if (atIdx > 0) {
118+
return resolvedId.substring(atIdx + 1);
119+
}
120+
return '0.0.0';
121+
}
122+
}

test/providers/javascript.test.js

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ async function createMockProvider(providerName, listingOutput) {
4545
const Javascript_yarn = await mockProvider('yarn', listingOutput, '4.9.1');
4646
return new Javascript_yarn();
4747
}
48+
case 'bun': {
49+
const Javascript_bun = await mockProvider(providerName, listingOutput);
50+
return new Javascript_bun();
51+
}
4852
default: { fail('Not implemented'); }
4953
}
5054
}
@@ -60,7 +64,11 @@ suite('testing the javascript-npm data provider', async () => {
6064
{ name: 'yarn-classic/with_lock_file', validation: true },
6165
{ name: 'yarn-classic/without_lock_file', validation: false },
6266
{ name: 'yarn-berry/with_lock_file', validation: true },
63-
{ name: 'yarn-berry/without_lock_file', validation: false }
67+
{ name: 'yarn-berry/without_lock_file', validation: false },
68+
{ name: 'bun/with_lock_file', validation: true },
69+
{ name: 'bun/without_lock_file', validation: false },
70+
{ name: 'bun/workspace_member_with_lock/packages/module-a', validation: true },
71+
{ name: 'bun/workspace_member_without_lock/packages/module-a', validation: false }
6472
].forEach(testCase => {
6573
test(`verify isSupported returns ${testCase.expected} for ${testCase.name}`, () => {
6674
let manifest = `test/providers/provider_manifests/${testCase.name}/package.json`;
@@ -192,6 +200,35 @@ suite('testing the javascript-npm data provider', async () => {
192200
}).timeout(15000);
193201
});
194202

203+
['bun'].flatMap(providerName => [
204+
{ testCase: "package_json_deps_without_exhortignore_object", manifest: "package.json" },
205+
{ testCase: "package_json_deps_with_exhortignore_object", manifest: "package.json" },
206+
{ testCase: "package_json_deps_with_mixed_dep_types", manifest: "package.json" },
207+
{ testCase: "workspace_member", manifest: "packages/member-a/package.json" },
208+
].map(tc => ({ providerName, ...tc }))).forEach(({ providerName, testCase, manifest }) => {
209+
let scenario = testCase.replace('package_json_deps_', '').replaceAll('_', ' ')
210+
test(`verify package.json data provided for ${providerName} - stack analysis - ${scenario}`, async () => {
211+
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/stack_expected_sbom.json`).toString();
212+
213+
const provider = await createMockProvider(providerName, '');
214+
const manifestPath = `test/providers/tst_manifests/${providerName}/${testCase}/${manifest}`;
215+
let providedDataForStack = provider.provideStack(manifestPath);
216+
217+
compareSboms(providedDataForStack.content, expectedSbom);
218+
219+
}).timeout(30000);
220+
test(`verify package.json data provided for ${providerName} - component analysis - ${scenario}`, async () => {
221+
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/component_expected_sbom.json`).toString().trim();
222+
223+
const provider = await createMockProvider(providerName, '');
224+
const manifestPath = `test/providers/tst_manifests/${providerName}/${testCase}/${manifest}`;
225+
let providedDataForComponent = provider.provideComponent(manifestPath);
226+
227+
compareSboms(providedDataForComponent.content, expectedSbom);
228+
}).timeout(15000)
229+
230+
});
231+
195232
test('loads a valid manifest with ignored dependencies', () => {
196233
const testCase = 'package_json_deps_with_exhortignore_object';
197234
const manifestPath = `test/providers/tst_manifests/npm/${testCase}/package.json`;
@@ -306,4 +343,32 @@ suite('testing the javascript-npm data provider', async () => {
306343
.to.throw('package.json requires a lock file')
307344
})
308345

346+
test('verify match with opts.TRUSTIFY_DA_WORKSPACE_DIR finds bun provider when lock is at workspace root', () => {
347+
const manifest = 'test/providers/provider_manifests/bun/with_lock_file/package.json'
348+
const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/bun/with_lock_file' }
349+
const provider = match(manifest, availableProviders, opts)
350+
expect(provider).to.not.be.null
351+
expect(provider.isSupported('package.json')).to.be.true
352+
})
353+
354+
test('verify bun workspace member walks up and finds lock file at workspace root', () => {
355+
const manifest = 'test/providers/provider_manifests/bun/workspace_member_with_lock/packages/module-a/package.json'
356+
const provider = match(manifest, availableProviders)
357+
expect(provider).to.not.be.null
358+
expect(provider.isSupported('package.json')).to.be.true
359+
})
360+
361+
test('verify bun workspace member throws when workspace root has no lock file', () => {
362+
const manifest = 'test/providers/provider_manifests/bun/workspace_member_without_lock/packages/module-a/package.json'
363+
expect(() => match(manifest, availableProviders))
364+
.to.throw('package.json requires a lock file')
365+
})
366+
367+
test('verify match with wrong TRUSTIFY_DA_WORKSPACE_DIR fails for bun even when walk-up would succeed', () => {
368+
const manifest = 'test/providers/provider_manifests/bun/workspace_member_with_lock/packages/module-a/package.json'
369+
const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/bun/workspace_member_without_lock' }
370+
expect(() => match(manifest, availableProviders, opts))
371+
.to.throw('package.json requires a lock file')
372+
})
373+
309374
}).beforeAll(() => clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z'))).afterAll(() => clock.restore());

test/providers/provider_manifests/bun/with_lock_file/bun.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "test",
3+
"version": "1.0.0",
4+
"main": "index.js",
5+
"scripts": {
6+
"test": "echo \"Error: no test specified\" && exit 1"
7+
},
8+
"author": "",
9+
"license": "ISC",
10+
"description": ""
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "test",
3+
"version": "1.0.0",
4+
"main": "index.js",
5+
"scripts": {
6+
"test": "echo \"Error: no test specified\" && exit 1"
7+
},
8+
"author": "",
9+
"license": "ISC",
10+
"description": ""
11+
}

0 commit comments

Comments
 (0)