flatlock 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/bin/flatlock-cmp.js +71 -45
- package/dist/compare.d.ts +25 -3
- package/dist/compare.d.ts.map +1 -1
- package/dist/detect.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/parsers/index.d.ts +2 -2
- package/dist/parsers/npm.d.ts +64 -37
- package/dist/parsers/npm.d.ts.map +1 -1
- package/dist/parsers/pnpm/detect.d.ts +136 -0
- package/dist/parsers/pnpm/detect.d.ts.map +1 -0
- package/dist/parsers/pnpm/index.d.ts +120 -0
- package/dist/parsers/pnpm/index.d.ts.map +1 -0
- package/dist/parsers/pnpm/internal.d.ts +5 -0
- package/dist/parsers/pnpm/internal.d.ts.map +1 -0
- package/dist/parsers/pnpm/shrinkwrap.d.ts +129 -0
- package/dist/parsers/pnpm/shrinkwrap.d.ts.map +1 -0
- package/dist/parsers/pnpm/v5.d.ts +139 -0
- package/dist/parsers/pnpm/v5.d.ts.map +1 -0
- package/dist/parsers/pnpm/v6plus.d.ts +212 -0
- package/dist/parsers/pnpm/v6plus.d.ts.map +1 -0
- package/dist/parsers/pnpm.d.ts +1 -59
- package/dist/parsers/pnpm.d.ts.map +1 -1
- package/dist/parsers/types.d.ts +23 -0
- package/dist/parsers/types.d.ts.map +1 -0
- package/dist/parsers/yarn-berry.d.ts +141 -52
- package/dist/parsers/yarn-berry.d.ts.map +1 -1
- package/dist/parsers/yarn-classic.d.ts +79 -33
- package/dist/parsers/yarn-classic.d.ts.map +1 -1
- package/dist/set.d.ts +189 -0
- package/dist/set.d.ts.map +1 -0
- package/package.json +7 -5
- package/src/compare.js +385 -28
- package/src/detect.js +3 -4
- package/src/index.js +9 -2
- package/src/parsers/index.js +10 -2
- package/src/parsers/npm.js +64 -16
- package/src/parsers/pnpm/detect.js +198 -0
- package/src/parsers/pnpm/index.js +289 -0
- package/src/parsers/pnpm/internal.js +41 -0
- package/src/parsers/pnpm/shrinkwrap.js +241 -0
- package/src/parsers/pnpm/v5.js +225 -0
- package/src/parsers/pnpm/v6plus.js +290 -0
- package/src/parsers/pnpm.js +11 -89
- package/src/parsers/types.js +10 -0
- package/src/parsers/yarn-berry.js +183 -36
- package/src/parsers/yarn-classic.js +81 -21
- package/src/set.js +618 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flatlock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "The Matlock of lockfile parsers - extracts packages without building dependency graphs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lockfile",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"bin"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"test": "node --test ./test/*.test.js",
|
|
46
|
+
"test": "node --test ./test/*.test.js ./test/**/*.test.js",
|
|
47
47
|
"test:coverage": "c8 node --test ./test/*.test.js",
|
|
48
48
|
"build:types": "tsc",
|
|
49
49
|
"lint": "biome lint src test",
|
|
@@ -62,7 +62,6 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@biomejs/biome": "^2.3.8",
|
|
65
|
-
"@pnpm/lockfile-file": "^9.0.0",
|
|
66
65
|
"@types/js-yaml": "^4.0.9",
|
|
67
66
|
"@types/node": "^22.10.2",
|
|
68
67
|
"c8": "^10.1.3",
|
|
@@ -71,11 +70,14 @@
|
|
|
71
70
|
"typescript": "^5.7.2"
|
|
72
71
|
},
|
|
73
72
|
"optionalDependencies": {
|
|
74
|
-
"@
|
|
73
|
+
"@cyclonedx/cyclonedx-npm": "^4.1.2",
|
|
74
|
+
"@npmcli/arborist": "^9.1.9",
|
|
75
|
+
"@pnpm/lockfile.fs": "^1001.0.0",
|
|
76
|
+
"@yarnpkg/core": "^4.5.0"
|
|
75
77
|
},
|
|
76
78
|
"packageManager": "pnpm@10.25.0",
|
|
77
79
|
"engines": {
|
|
78
|
-
"node": ">=
|
|
80
|
+
"node": ">=22"
|
|
79
81
|
},
|
|
80
82
|
"c8": {
|
|
81
83
|
"all": true,
|
package/src/compare.js
CHANGED
|
@@ -1,23 +1,100 @@
|
|
|
1
|
+
import { constants } from 'node:buffer';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
1
3
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
2
5
|
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import Arborist from '@npmcli/arborist';
|
|
5
|
-
import yarnLockfile from '@yarnpkg/lockfile';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
6
7
|
import { parseSyml } from '@yarnpkg/parsers';
|
|
7
8
|
import yaml from 'js-yaml';
|
|
8
9
|
import { detectType, fromPath, Type } from './index.js';
|
|
9
|
-
import { parseYarnBerryKey, parseYarnClassicKey } from './parsers/index.js';
|
|
10
|
+
import { parseYarnBerryKey, parseYarnClassic, parseYarnClassicKey } from './parsers/index.js';
|
|
10
11
|
import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
|
|
11
12
|
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
// Lazy-loaded optional dependencies
|
|
16
|
+
/** @type {typeof import('@npmcli/arborist') | false | null} */
|
|
17
|
+
let Arborist = null;
|
|
18
|
+
/** @type {typeof import('@pnpm/lockfile.fs').readWantedLockfile | false | null} */
|
|
19
|
+
let readWantedLockfile = null;
|
|
20
|
+
/** @type {string | false | null} */
|
|
21
|
+
let cyclonedxCliPath = null;
|
|
22
|
+
/** @type {typeof import('@yarnpkg/core') | false | null} */
|
|
23
|
+
let yarnCore = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Try to load @npmcli/arborist (optional dependency)
|
|
27
|
+
* @returns {Promise<typeof import('@npmcli/arborist') | null>}
|
|
28
|
+
*/
|
|
29
|
+
async function loadArborist() {
|
|
30
|
+
if (Arborist === null) {
|
|
31
|
+
try {
|
|
32
|
+
const mod = await import('@npmcli/arborist');
|
|
33
|
+
Arborist = mod.default;
|
|
34
|
+
} catch {
|
|
35
|
+
Arborist = false; // Mark as unavailable
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Arborist || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Try to load @pnpm/lockfile.fs (optional dependency)
|
|
43
|
+
* @returns {Promise<typeof import('@pnpm/lockfile.fs').readWantedLockfile | null>}
|
|
44
|
+
*/
|
|
45
|
+
async function loadPnpmLockfileFs() {
|
|
46
|
+
if (readWantedLockfile === null) {
|
|
47
|
+
try {
|
|
48
|
+
const mod = await import('@pnpm/lockfile.fs');
|
|
49
|
+
readWantedLockfile = mod.readWantedLockfile;
|
|
50
|
+
} catch {
|
|
51
|
+
readWantedLockfile = false; // Mark as unavailable
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return readWantedLockfile || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Try to resolve @cyclonedx/cyclonedx-npm CLI path (optional dependency)
|
|
59
|
+
* @returns {string | null}
|
|
60
|
+
*/
|
|
61
|
+
function loadCycloneDxCliPath() {
|
|
62
|
+
if (cyclonedxCliPath === null) {
|
|
63
|
+
try {
|
|
64
|
+
cyclonedxCliPath = require.resolve('@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js');
|
|
65
|
+
} catch {
|
|
66
|
+
cyclonedxCliPath = false; // Mark as unavailable
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return cyclonedxCliPath || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Try to load @yarnpkg/core (optional dependency)
|
|
74
|
+
* @returns {Promise<typeof import('@yarnpkg/core') | null>}
|
|
75
|
+
*/
|
|
76
|
+
async function loadYarnCore() {
|
|
77
|
+
if (yarnCore === null) {
|
|
78
|
+
try {
|
|
79
|
+
yarnCore = await import('@yarnpkg/core');
|
|
80
|
+
} catch {
|
|
81
|
+
yarnCore = false; // Mark as unavailable
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return yarnCore || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
12
87
|
/**
|
|
13
88
|
* @typedef {Object} CompareOptions
|
|
14
|
-
* @property {string} [tmpDir] - Temp directory for Arborist (npm only)
|
|
89
|
+
* @property {string} [tmpDir] - Temp directory for Arborist/CycloneDX (npm only)
|
|
90
|
+
* @property {string[]} [workspace] - Workspace paths for CycloneDX (-w flag)
|
|
15
91
|
*/
|
|
16
92
|
|
|
17
93
|
/**
|
|
18
94
|
* @typedef {Object} ComparisonResult
|
|
19
95
|
* @property {string} type - Lockfile type
|
|
20
|
-
* @property {
|
|
96
|
+
* @property {string} [source] - Comparison source used (e.g., '@npmcli/arborist', '@cyclonedx/cyclonedx-npm')
|
|
97
|
+
* @property {boolean | null} equinumerous - Whether flatlock and comparison have same cardinality
|
|
21
98
|
* @property {number} flatlockCount - Number of packages found by flatlock
|
|
22
99
|
* @property {number} [comparisonCount] - Number of packages found by comparison parser
|
|
23
100
|
* @property {number} [workspaceCount] - Number of workspace packages skipped
|
|
@@ -29,6 +106,7 @@ import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
|
|
|
29
106
|
* @typedef {Object} PackagesResult
|
|
30
107
|
* @property {Set<string>} packages - Set of package@version strings
|
|
31
108
|
* @property {number} workspaceCount - Number of workspace packages skipped
|
|
109
|
+
* @property {string} source - Comparison source used
|
|
32
110
|
*/
|
|
33
111
|
|
|
34
112
|
/**
|
|
@@ -36,9 +114,12 @@ import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
|
|
|
36
114
|
* @param {string} content - Lockfile content
|
|
37
115
|
* @param {string} _filepath - Path to lockfile (unused)
|
|
38
116
|
* @param {CompareOptions} [options] - Options
|
|
39
|
-
* @returns {Promise<PackagesResult>}
|
|
117
|
+
* @returns {Promise<PackagesResult | null>}
|
|
40
118
|
*/
|
|
41
|
-
async function
|
|
119
|
+
async function getPackagesFromArborist(content, _filepath, options = {}) {
|
|
120
|
+
const Arb = await loadArborist();
|
|
121
|
+
if (!Arb) return null;
|
|
122
|
+
|
|
42
123
|
// Arborist needs a directory with package-lock.json
|
|
43
124
|
const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-cmp-')));
|
|
44
125
|
const lockPath = join(tmpDir, 'package-lock.json');
|
|
@@ -56,7 +137,7 @@ async function getPackagesFromNpm(content, _filepath, options = {}) {
|
|
|
56
137
|
};
|
|
57
138
|
await writeFile(pkgPath, JSON.stringify(pkg));
|
|
58
139
|
|
|
59
|
-
const arb = new
|
|
140
|
+
const arb = new Arb({ path: tmpDir });
|
|
60
141
|
const tree = await arb.loadVirtual();
|
|
61
142
|
|
|
62
143
|
const packages = new Set();
|
|
@@ -80,7 +161,75 @@ async function getPackagesFromNpm(content, _filepath, options = {}) {
|
|
|
80
161
|
}
|
|
81
162
|
}
|
|
82
163
|
|
|
83
|
-
return { packages, workspaceCount };
|
|
164
|
+
return { packages, workspaceCount, source: '@npmcli/arborist' };
|
|
165
|
+
} finally {
|
|
166
|
+
if (!options.tmpDir) {
|
|
167
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get packages from npm lockfile using CycloneDX SBOM generation
|
|
174
|
+
* @param {string} content - Lockfile content
|
|
175
|
+
* @param {string} _filepath - Path to lockfile (unused)
|
|
176
|
+
* @param {CompareOptions} [options] - Options
|
|
177
|
+
* @returns {Promise<PackagesResult | null>}
|
|
178
|
+
*/
|
|
179
|
+
async function getPackagesFromCycloneDX(content, _filepath, options = {}) {
|
|
180
|
+
const cliPath = loadCycloneDxCliPath();
|
|
181
|
+
if (!cliPath) return null;
|
|
182
|
+
|
|
183
|
+
// CycloneDX needs a directory with package-lock.json and package.json
|
|
184
|
+
const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-cdx-')));
|
|
185
|
+
const lockPath = join(tmpDir, 'package-lock.json');
|
|
186
|
+
const pkgPath = join(tmpDir, 'package.json');
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await writeFile(lockPath, content);
|
|
190
|
+
|
|
191
|
+
// Create minimal package.json from lockfile root entry
|
|
192
|
+
const lockfile = JSON.parse(content);
|
|
193
|
+
const root = lockfile.packages?.[''] || {};
|
|
194
|
+
const pkg = {
|
|
195
|
+
name: root.name || 'cyclonedx-temp',
|
|
196
|
+
version: root.version || '1.0.0'
|
|
197
|
+
};
|
|
198
|
+
await writeFile(pkgPath, JSON.stringify(pkg));
|
|
199
|
+
|
|
200
|
+
const args = [
|
|
201
|
+
cliPath,
|
|
202
|
+
'--output-format',
|
|
203
|
+
'JSON',
|
|
204
|
+
'--output-file',
|
|
205
|
+
'-',
|
|
206
|
+
'--package-lock-only' // Don't require node_modules
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
// Add workspace flags if specified
|
|
210
|
+
if (options.workspace) {
|
|
211
|
+
for (const ws of options.workspace) {
|
|
212
|
+
args.push('-w', ws);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sbomBuffer = execFileSync(process.execPath, args, {
|
|
217
|
+
cwd: tmpDir,
|
|
218
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
219
|
+
encoding: 'buffer',
|
|
220
|
+
maxBuffer: constants.MAX_LENGTH
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const sbom = JSON.parse(sbomBuffer.toString('utf8'));
|
|
224
|
+
const packages = new Set();
|
|
225
|
+
|
|
226
|
+
for (const component of sbom.components || []) {
|
|
227
|
+
if (component.name && component.version) {
|
|
228
|
+
packages.add(`${component.name}@${component.version}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { packages, workspaceCount: 0, source: '@cyclonedx/cyclonedx-npm' };
|
|
84
233
|
} finally {
|
|
85
234
|
if (!options.tmpDir) {
|
|
86
235
|
await rm(tmpDir, { recursive: true, force: true });
|
|
@@ -88,14 +237,34 @@ async function getPackagesFromNpm(content, _filepath, options = {}) {
|
|
|
88
237
|
}
|
|
89
238
|
}
|
|
90
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Get packages from npm lockfile - tries Arborist first, falls back to CycloneDX
|
|
242
|
+
* @param {string} content - Lockfile content
|
|
243
|
+
* @param {string} filepath - Path to lockfile
|
|
244
|
+
* @param {CompareOptions} [options] - Options
|
|
245
|
+
* @returns {Promise<PackagesResult>}
|
|
246
|
+
*/
|
|
247
|
+
async function getPackagesFromNpm(content, filepath, options = {}) {
|
|
248
|
+
// Try Arborist first (faster, more accurate)
|
|
249
|
+
const arboristResult = await getPackagesFromArborist(content, filepath, options);
|
|
250
|
+
if (arboristResult) return arboristResult;
|
|
251
|
+
|
|
252
|
+
// Fall back to CycloneDX
|
|
253
|
+
const cyclonedxResult = await getPackagesFromCycloneDX(content, filepath, options);
|
|
254
|
+
if (cyclonedxResult) return cyclonedxResult;
|
|
255
|
+
|
|
256
|
+
throw new Error(
|
|
257
|
+
'No npm comparison parser available. Install @npmcli/arborist or @cyclonedx/cyclonedx-npm'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
91
261
|
/**
|
|
92
262
|
* Get packages from yarn classic lockfile
|
|
93
263
|
* @param {string} content - Lockfile content
|
|
94
264
|
* @returns {Promise<PackagesResult>}
|
|
95
265
|
*/
|
|
96
266
|
async function getPackagesFromYarnClassic(content) {
|
|
97
|
-
const
|
|
98
|
-
const parsed = parse(content);
|
|
267
|
+
const parsed = parseYarnClassic(content);
|
|
99
268
|
|
|
100
269
|
if (parsed.type !== 'success' && parsed.type !== 'merge') {
|
|
101
270
|
throw new Error('Failed to parse yarn.lock');
|
|
@@ -121,15 +290,92 @@ async function getPackagesFromYarnClassic(content) {
|
|
|
121
290
|
}
|
|
122
291
|
}
|
|
123
292
|
|
|
124
|
-
return { packages, workspaceCount };
|
|
293
|
+
return { packages, workspaceCount, source: '@yarnpkg/lockfile' };
|
|
125
294
|
}
|
|
126
295
|
|
|
127
296
|
/**
|
|
128
|
-
* Get packages from yarn berry lockfile
|
|
297
|
+
* Get packages from yarn berry lockfile using @yarnpkg/core Project.originalPackages
|
|
298
|
+
*
|
|
299
|
+
* This is the equivalent of Arborist.loadVirtual() - loads yarn's internal representation.
|
|
300
|
+
*
|
|
301
|
+
* Key insight: By calling setupResolutions() directly (a private method), we can populate
|
|
302
|
+
* originalPackages from the lockfile WITHOUT requiring a valid project setup. This gives
|
|
303
|
+
* us yarn's ground truth package data.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} content - Lockfile content
|
|
306
|
+
* @param {CompareOptions} [options] - Options
|
|
307
|
+
* @returns {Promise<PackagesResult | null>}
|
|
308
|
+
*/
|
|
309
|
+
async function getPackagesFromYarnBerryCore(content, options = {}) {
|
|
310
|
+
const core = await loadYarnCore();
|
|
311
|
+
if (!core) return null;
|
|
312
|
+
|
|
313
|
+
const { Configuration, Project, structUtils } = core;
|
|
314
|
+
|
|
315
|
+
// Create temp directory with yarn.lock and minimal package.json
|
|
316
|
+
const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-yarn-')));
|
|
317
|
+
const lockPath = join(tmpDir, 'yarn.lock');
|
|
318
|
+
const pkgPath = join(tmpDir, 'package.json');
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await writeFile(lockPath, content);
|
|
322
|
+
// Minimal package.json - only needed for Configuration.find
|
|
323
|
+
await writeFile(
|
|
324
|
+
pkgPath,
|
|
325
|
+
JSON.stringify({ name: 'flatlock-temp', version: '0.0.0', private: true })
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Load configuration
|
|
329
|
+
const configuration = await Configuration.find(/** @type {any} */ (tmpDir), null);
|
|
330
|
+
|
|
331
|
+
// Create project manually (don't use Project.find which calls setupWorkspaces)
|
|
332
|
+
const project = new Project(/** @type {any} */ (tmpDir), { configuration });
|
|
333
|
+
|
|
334
|
+
// Call setupResolutions directly - this parses the lockfile and populates originalPackages
|
|
335
|
+
// This is a private method but it's the only way to get ground truth without a full project
|
|
336
|
+
await /** @type {any} */ (project).setupResolutions();
|
|
337
|
+
|
|
338
|
+
const packages = new Set();
|
|
339
|
+
let workspaceCount = 0;
|
|
340
|
+
|
|
341
|
+
// Iterate over originalPackages - this is yarn's ground truth from the lockfile
|
|
342
|
+
for (const pkg of project.originalPackages.values()) {
|
|
343
|
+
const ref = pkg.reference;
|
|
344
|
+
|
|
345
|
+
// Check for workspace/link/portal protocols
|
|
346
|
+
if (ref.startsWith('workspace:') || ref.startsWith('link:') || ref.startsWith('portal:')) {
|
|
347
|
+
workspaceCount++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Skip virtual packages (peer dependency variants)
|
|
352
|
+
if (structUtils.isVirtualLocator(pkg)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const name = structUtils.stringifyIdent(pkg);
|
|
357
|
+
if (name && pkg.version) {
|
|
358
|
+
packages.add(`${name}@${pkg.version}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { packages, workspaceCount, source: '@yarnpkg/core' };
|
|
363
|
+
} catch (_err) {
|
|
364
|
+
// If @yarnpkg/core fails (e.g., incompatible lockfile), return null to fall back
|
|
365
|
+
return null;
|
|
366
|
+
} finally {
|
|
367
|
+
if (!options.tmpDir) {
|
|
368
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Get packages from yarn berry lockfile using @yarnpkg/parsers (fallback)
|
|
129
375
|
* @param {string} content - Lockfile content
|
|
130
376
|
* @returns {Promise<PackagesResult>}
|
|
131
377
|
*/
|
|
132
|
-
async function
|
|
378
|
+
async function getPackagesFromYarnBerryParsers(content) {
|
|
133
379
|
const parsed = parseSyml(content);
|
|
134
380
|
|
|
135
381
|
const packages = new Set();
|
|
@@ -139,11 +385,16 @@ async function getPackagesFromYarnBerry(content) {
|
|
|
139
385
|
if (key === '__metadata') continue;
|
|
140
386
|
|
|
141
387
|
// Skip workspace/link entries - flatlock only cares about external dependencies
|
|
388
|
+
// Keys look like: "@pkg@workspace:path" or "pkg@workspace:path"
|
|
389
|
+
// Resolutions look like: "@pkg@workspace:path" or "pkg@npm:1.0.0"
|
|
142
390
|
const resolution = value.resolution || '';
|
|
143
391
|
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
392
|
+
key.includes('@workspace:') ||
|
|
393
|
+
key.includes('@portal:') ||
|
|
394
|
+
key.includes('@link:') ||
|
|
395
|
+
resolution.includes('@workspace:') ||
|
|
396
|
+
resolution.includes('@portal:') ||
|
|
397
|
+
resolution.includes('@link:')
|
|
147
398
|
) {
|
|
148
399
|
workspaceCount++;
|
|
149
400
|
continue;
|
|
@@ -156,15 +407,84 @@ async function getPackagesFromYarnBerry(content) {
|
|
|
156
407
|
}
|
|
157
408
|
}
|
|
158
409
|
|
|
159
|
-
return { packages, workspaceCount };
|
|
410
|
+
return { packages, workspaceCount, source: '@yarnpkg/parsers' };
|
|
160
411
|
}
|
|
161
412
|
|
|
162
413
|
/**
|
|
163
|
-
* Get packages from
|
|
414
|
+
* Get packages from yarn berry lockfile - tries @yarnpkg/core first, falls back to @yarnpkg/parsers
|
|
164
415
|
* @param {string} content - Lockfile content
|
|
416
|
+
* @param {CompareOptions} [options] - Options
|
|
165
417
|
* @returns {Promise<PackagesResult>}
|
|
166
418
|
*/
|
|
167
|
-
async function
|
|
419
|
+
async function getPackagesFromYarnBerry(content, options = {}) {
|
|
420
|
+
// Try @yarnpkg/core first (official yarn implementation with Project.restoreInstallState)
|
|
421
|
+
const coreResult = await getPackagesFromYarnBerryCore(content, options);
|
|
422
|
+
if (coreResult) return coreResult;
|
|
423
|
+
|
|
424
|
+
// Fall back to @yarnpkg/parsers with manual filtering
|
|
425
|
+
return getPackagesFromYarnBerryParsers(content);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get packages from pnpm lockfile using @pnpm/lockfile.fs (official parser)
|
|
430
|
+
* @param {string} _content - Lockfile content (unused, reads from disk)
|
|
431
|
+
* @param {string} filepath - Path to lockfile
|
|
432
|
+
* @param {CompareOptions} [_options] - Options (unused)
|
|
433
|
+
* @returns {Promise<PackagesResult | null>}
|
|
434
|
+
*/
|
|
435
|
+
async function getPackagesFromPnpmOfficial(_content, filepath, _options = {}) {
|
|
436
|
+
const readLockfile = await loadPnpmLockfileFs();
|
|
437
|
+
if (!readLockfile) return null;
|
|
438
|
+
|
|
439
|
+
const projectDir = dirname(filepath);
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const lockfile = await readLockfile(projectDir, {
|
|
443
|
+
ignoreIncompatible: true
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
if (!lockfile) return null;
|
|
447
|
+
|
|
448
|
+
const packages = new Set();
|
|
449
|
+
let workspaceCount = 0;
|
|
450
|
+
const pkgs = lockfile.packages || {};
|
|
451
|
+
|
|
452
|
+
// If no packages found, likely version incompatibility - fall back to js-yaml
|
|
453
|
+
if (Object.keys(pkgs).length === 0) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const [key, _value] of Object.entries(pkgs)) {
|
|
458
|
+
// Skip link/file entries
|
|
459
|
+
if (
|
|
460
|
+
key.startsWith('link:') ||
|
|
461
|
+
key.startsWith('file:') ||
|
|
462
|
+
key.includes('@link:') ||
|
|
463
|
+
key.includes('@file:')
|
|
464
|
+
) {
|
|
465
|
+
workspaceCount++;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const { name, version } = parsePnpmSpec(key);
|
|
470
|
+
if (name && version) {
|
|
471
|
+
packages.add(`${name}@${version}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { packages, workspaceCount, source: '@pnpm/lockfile.fs' };
|
|
476
|
+
} catch {
|
|
477
|
+
// Fall back to js-yaml if official parser fails (version incompatibility)
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get packages from pnpm lockfile using js-yaml (fallback)
|
|
484
|
+
* @param {string} content - Lockfile content
|
|
485
|
+
* @returns {Promise<PackagesResult>}
|
|
486
|
+
*/
|
|
487
|
+
async function getPackagesFromPnpmYaml(content) {
|
|
168
488
|
const parsed = /** @type {{ packages?: Record<string, any> }} */ (yaml.load(content));
|
|
169
489
|
|
|
170
490
|
const packages = new Set();
|
|
@@ -196,7 +516,23 @@ async function getPackagesFromPnpm(content) {
|
|
|
196
516
|
}
|
|
197
517
|
}
|
|
198
518
|
|
|
199
|
-
return { packages, workspaceCount };
|
|
519
|
+
return { packages, workspaceCount, source: 'js-yaml' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Get packages from pnpm lockfile - tries official parser first, falls back to js-yaml
|
|
524
|
+
* @param {string} content - Lockfile content
|
|
525
|
+
* @param {string} filepath - Path to lockfile
|
|
526
|
+
* @param {CompareOptions} [options] - Options
|
|
527
|
+
* @returns {Promise<PackagesResult>}
|
|
528
|
+
*/
|
|
529
|
+
async function getPackagesFromPnpm(content, filepath, options = {}) {
|
|
530
|
+
// Try official pnpm parser first
|
|
531
|
+
const officialResult = await getPackagesFromPnpmOfficial(content, filepath, options);
|
|
532
|
+
if (officialResult) return officialResult;
|
|
533
|
+
|
|
534
|
+
// Fall back to js-yaml
|
|
535
|
+
return getPackagesFromPnpmYaml(content);
|
|
200
536
|
}
|
|
201
537
|
|
|
202
538
|
/**
|
|
@@ -225,23 +561,24 @@ export async function compare(filepath, options = {}) {
|
|
|
225
561
|
comparisonResult = await getPackagesFromYarnClassic(content);
|
|
226
562
|
break;
|
|
227
563
|
case Type.YARN_BERRY:
|
|
228
|
-
comparisonResult = await getPackagesFromYarnBerry(content);
|
|
564
|
+
comparisonResult = await getPackagesFromYarnBerry(content, options);
|
|
229
565
|
break;
|
|
230
566
|
case Type.PNPM:
|
|
231
|
-
comparisonResult = await getPackagesFromPnpm(content);
|
|
567
|
+
comparisonResult = await getPackagesFromPnpm(content, filepath, options);
|
|
232
568
|
break;
|
|
233
569
|
default:
|
|
234
|
-
return { type,
|
|
570
|
+
return { type, equinumerous: null, flatlockCount: flatlockSet.size };
|
|
235
571
|
}
|
|
236
572
|
|
|
237
|
-
const { packages: comparisonSet, workspaceCount } = comparisonResult;
|
|
573
|
+
const { packages: comparisonSet, workspaceCount, source } = comparisonResult;
|
|
238
574
|
const onlyInFlatlock = new Set([...flatlockSet].filter(x => !comparisonSet.has(x)));
|
|
239
575
|
const onlyInComparison = new Set([...comparisonSet].filter(x => !flatlockSet.has(x)));
|
|
240
|
-
const
|
|
576
|
+
const equinumerous = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
|
|
241
577
|
|
|
242
578
|
return {
|
|
243
579
|
type,
|
|
244
|
-
|
|
580
|
+
source,
|
|
581
|
+
equinumerous,
|
|
245
582
|
flatlockCount: flatlockSet.size,
|
|
246
583
|
comparisonCount: comparisonSet.size,
|
|
247
584
|
workspaceCount,
|
|
@@ -261,3 +598,23 @@ export async function* compareAll(filepaths, options = {}) {
|
|
|
261
598
|
yield { filepath, ...(await compare(filepath, options)) };
|
|
262
599
|
}
|
|
263
600
|
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Check which optional comparison parsers are available
|
|
604
|
+
* @returns {Promise<{ arborist: boolean, cyclonedx: boolean, pnpmLockfileFs: boolean, yarnCore: boolean }>}
|
|
605
|
+
*/
|
|
606
|
+
export async function getAvailableParsers() {
|
|
607
|
+
const [arborist, pnpmLockfileFs, yarnCoreModule] = await Promise.all([
|
|
608
|
+
loadArborist(),
|
|
609
|
+
loadPnpmLockfileFs(),
|
|
610
|
+
loadYarnCore()
|
|
611
|
+
]);
|
|
612
|
+
const cyclonedx = loadCycloneDxCliPath();
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
arborist: !!arborist,
|
|
616
|
+
cyclonedx: !!cyclonedx,
|
|
617
|
+
pnpmLockfileFs: !!pnpmLockfileFs,
|
|
618
|
+
yarnCore: !!yarnCoreModule
|
|
619
|
+
};
|
|
620
|
+
}
|
package/src/detect.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import yarnLockfile from '@yarnpkg/lockfile';
|
|
2
1
|
import { parseSyml } from '@yarnpkg/parsers';
|
|
3
2
|
import yaml from 'js-yaml';
|
|
3
|
+
import { parseYarnClassic } from './parsers/yarn-classic.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @typedef {'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry'} LockfileType
|
|
@@ -57,10 +57,9 @@ function tryParseYarnBerry(content) {
|
|
|
57
57
|
*/
|
|
58
58
|
function tryParseYarnClassic(content) {
|
|
59
59
|
try {
|
|
60
|
-
|
|
61
|
-
if (!parse) return false;
|
|
60
|
+
if (!parseYarnClassic) return false;
|
|
62
61
|
|
|
63
|
-
const result =
|
|
62
|
+
const result = parseYarnClassic(content);
|
|
64
63
|
// Must parse successfully and NOT have __metadata (that's berry)
|
|
65
64
|
// Must have at least one package entry (not empty object)
|
|
66
65
|
const isValidResult = result.type === 'success' || result.type === 'merge';
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fromYarnClassicLock
|
|
8
8
|
} from './parsers/index.js';
|
|
9
9
|
import { Err, Ok } from './result.js';
|
|
10
|
+
import { FlatlockSet } from './set.js';
|
|
10
11
|
|
|
11
12
|
/** @typedef {import('./detect.js').LockfileType} LockfileType */
|
|
12
13
|
/** @typedef {import('./parsers/npm.js').Dependency} Dependency */
|
|
@@ -26,6 +27,9 @@ export { Ok, Err };
|
|
|
26
27
|
// Re-export individual parsers
|
|
27
28
|
export { fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock };
|
|
28
29
|
|
|
30
|
+
// Re-export FlatlockSet class
|
|
31
|
+
export { FlatlockSet };
|
|
32
|
+
|
|
29
33
|
/**
|
|
30
34
|
* Parse lockfile from path (auto-detect type)
|
|
31
35
|
* @param {string} path - Path to lockfile
|
|
@@ -130,8 +134,8 @@ export function* fromYarnLock(content, options = {}) {
|
|
|
130
134
|
export async function collect(pathOrContent, options = {}) {
|
|
131
135
|
const deps = [];
|
|
132
136
|
|
|
133
|
-
//
|
|
134
|
-
const isPath = !pathOrContent.includes('\n') &&
|
|
137
|
+
// Better heuristic: paths don't contain newlines and are reasonably short
|
|
138
|
+
const isPath = !pathOrContent.includes('\n') && pathOrContent.length < 1000;
|
|
135
139
|
|
|
136
140
|
if (isPath) {
|
|
137
141
|
for await (const dep of fromPath(pathOrContent, options)) {
|
|
@@ -146,6 +150,9 @@ export async function collect(pathOrContent, options = {}) {
|
|
|
146
150
|
return deps;
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
// Re-export compare API
|
|
154
|
+
export { compare, compareAll, getAvailableParsers } from './compare.js';
|
|
155
|
+
|
|
149
156
|
// Re-export lockfile key parsing utilities
|
|
150
157
|
export {
|
|
151
158
|
parseNpmKey,
|
package/src/parsers/index.js
CHANGED
|
@@ -4,5 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
export { fromPackageLock, parseLockfileKey as parseNpmKey } from './npm.js';
|
|
6
6
|
export { fromPnpmLock, parseLockfileKey as parsePnpmKey } from './pnpm.js';
|
|
7
|
-
export {
|
|
8
|
-
|
|
7
|
+
export {
|
|
8
|
+
fromYarnBerryLock,
|
|
9
|
+
parseLockfileKey as parseYarnBerryKey,
|
|
10
|
+
parseResolution as parseYarnBerryResolution
|
|
11
|
+
} from './yarn-berry.js';
|
|
12
|
+
export {
|
|
13
|
+
fromYarnClassicLock,
|
|
14
|
+
parseLockfileKey as parseYarnClassicKey,
|
|
15
|
+
parseYarnClassic
|
|
16
|
+
} from './yarn-classic.js';
|