flatlock 1.0.1 → 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 +55 -2
- package/bin/flatlock-cmp.js +109 -356
- package/dist/compare.d.ts +85 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/detect.d.ts +33 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/index.d.ts +60 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/parsers/index.d.ts +5 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/npm.d.ts +109 -0
- package/dist/parsers/npm.d.ts.map +1 -0
- 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 +2 -0
- package/dist/parsers/pnpm.d.ts.map +1 -0
- package/dist/parsers/types.d.ts +23 -0
- package/dist/parsers/types.d.ts.map +1 -0
- package/dist/parsers/yarn-berry.d.ts +154 -0
- package/dist/parsers/yarn-berry.d.ts.map +1 -0
- package/dist/parsers/yarn-classic.d.ts +110 -0
- package/dist/parsers/yarn-classic.d.ts.map +1 -0
- package/dist/result.d.ts +12 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/set.d.ts +189 -0
- package/dist/set.d.ts.map +1 -0
- package/package.json +18 -7
- package/src/compare.js +620 -0
- package/src/detect.js +8 -7
- package/src/index.js +33 -15
- package/src/parsers/index.js +12 -4
- package/src/parsers/npm.js +70 -23
- 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 +12 -77
- package/src/parsers/types.js +10 -0
- package/src/parsers/yarn-berry.js +187 -38
- package/src/parsers/yarn-classic.js +85 -24
- package/src/result.js +2 -2
- package/src/set.js +618 -0
- package/src/types.d.ts +54 -0
package/src/compare.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { constants } from 'node:buffer';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { parseSyml } from '@yarnpkg/parsers';
|
|
8
|
+
import yaml from 'js-yaml';
|
|
9
|
+
import { detectType, fromPath, Type } from './index.js';
|
|
10
|
+
import { parseYarnBerryKey, parseYarnClassic, parseYarnClassicKey } from './parsers/index.js';
|
|
11
|
+
import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
|
|
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
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {Object} CompareOptions
|
|
89
|
+
* @property {string} [tmpDir] - Temp directory for Arborist/CycloneDX (npm only)
|
|
90
|
+
* @property {string[]} [workspace] - Workspace paths for CycloneDX (-w flag)
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @typedef {Object} ComparisonResult
|
|
95
|
+
* @property {string} type - Lockfile type
|
|
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
|
|
98
|
+
* @property {number} flatlockCount - Number of packages found by flatlock
|
|
99
|
+
* @property {number} [comparisonCount] - Number of packages found by comparison parser
|
|
100
|
+
* @property {number} [workspaceCount] - Number of workspace packages skipped
|
|
101
|
+
* @property {string[]} [onlyInFlatlock] - Packages only found by flatlock
|
|
102
|
+
* @property {string[]} [onlyInComparison] - Packages only found by comparison parser
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {Object} PackagesResult
|
|
107
|
+
* @property {Set<string>} packages - Set of package@version strings
|
|
108
|
+
* @property {number} workspaceCount - Number of workspace packages skipped
|
|
109
|
+
* @property {string} source - Comparison source used
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get packages from npm lockfile using Arborist (ground truth)
|
|
114
|
+
* @param {string} content - Lockfile content
|
|
115
|
+
* @param {string} _filepath - Path to lockfile (unused)
|
|
116
|
+
* @param {CompareOptions} [options] - Options
|
|
117
|
+
* @returns {Promise<PackagesResult | null>}
|
|
118
|
+
*/
|
|
119
|
+
async function getPackagesFromArborist(content, _filepath, options = {}) {
|
|
120
|
+
const Arb = await loadArborist();
|
|
121
|
+
if (!Arb) return null;
|
|
122
|
+
|
|
123
|
+
// Arborist needs a directory with package-lock.json
|
|
124
|
+
const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-cmp-')));
|
|
125
|
+
const lockPath = join(tmpDir, 'package-lock.json');
|
|
126
|
+
const pkgPath = join(tmpDir, 'package.json');
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await writeFile(lockPath, content);
|
|
130
|
+
|
|
131
|
+
// Create minimal package.json from lockfile root entry
|
|
132
|
+
const lockfile = JSON.parse(content);
|
|
133
|
+
const root = lockfile.packages?.[''] || {};
|
|
134
|
+
const pkg = {
|
|
135
|
+
name: root.name || 'arborist-temp',
|
|
136
|
+
version: root.version || '1.0.0'
|
|
137
|
+
};
|
|
138
|
+
await writeFile(pkgPath, JSON.stringify(pkg));
|
|
139
|
+
|
|
140
|
+
const arb = new Arb({ path: tmpDir });
|
|
141
|
+
const tree = await arb.loadVirtual();
|
|
142
|
+
|
|
143
|
+
const packages = new Set();
|
|
144
|
+
let workspaceCount = 0;
|
|
145
|
+
|
|
146
|
+
for (const node of tree.inventory.values()) {
|
|
147
|
+
if (node.isRoot) continue;
|
|
148
|
+
// Skip workspace symlinks (link:true, no version in raw lockfile)
|
|
149
|
+
if (node.isLink) {
|
|
150
|
+
workspaceCount++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Skip workspace package definitions (not in node_modules)
|
|
154
|
+
// Flatlock only yields packages from node_modules/ paths
|
|
155
|
+
if (node.location && !node.location.includes('node_modules')) {
|
|
156
|
+
workspaceCount++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (node.name && node.version) {
|
|
160
|
+
packages.add(`${node.name}@${node.version}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
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' };
|
|
233
|
+
} finally {
|
|
234
|
+
if (!options.tmpDir) {
|
|
235
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
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
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get packages from yarn classic lockfile
|
|
263
|
+
* @param {string} content - Lockfile content
|
|
264
|
+
* @returns {Promise<PackagesResult>}
|
|
265
|
+
*/
|
|
266
|
+
async function getPackagesFromYarnClassic(content) {
|
|
267
|
+
const parsed = parseYarnClassic(content);
|
|
268
|
+
|
|
269
|
+
if (parsed.type !== 'success' && parsed.type !== 'merge') {
|
|
270
|
+
throw new Error('Failed to parse yarn.lock');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const packages = new Set();
|
|
274
|
+
let workspaceCount = 0;
|
|
275
|
+
|
|
276
|
+
for (const [key, value] of Object.entries(parsed.object)) {
|
|
277
|
+
if (key === '__metadata') continue;
|
|
278
|
+
|
|
279
|
+
// Skip workspace/link entries - flatlock only cares about external dependencies
|
|
280
|
+
const resolved = value.resolved || '';
|
|
281
|
+
if (resolved.startsWith('file:') || resolved.startsWith('link:')) {
|
|
282
|
+
workspaceCount++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Extract package name from lockfile key
|
|
287
|
+
const name = parseYarnClassicKey(key);
|
|
288
|
+
if (name && value.version) {
|
|
289
|
+
packages.add(`${name}@${value.version}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { packages, workspaceCount, source: '@yarnpkg/lockfile' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
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)
|
|
375
|
+
* @param {string} content - Lockfile content
|
|
376
|
+
* @returns {Promise<PackagesResult>}
|
|
377
|
+
*/
|
|
378
|
+
async function getPackagesFromYarnBerryParsers(content) {
|
|
379
|
+
const parsed = parseSyml(content);
|
|
380
|
+
|
|
381
|
+
const packages = new Set();
|
|
382
|
+
let workspaceCount = 0;
|
|
383
|
+
|
|
384
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
385
|
+
if (key === '__metadata') continue;
|
|
386
|
+
|
|
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"
|
|
390
|
+
const resolution = value.resolution || '';
|
|
391
|
+
if (
|
|
392
|
+
key.includes('@workspace:') ||
|
|
393
|
+
key.includes('@portal:') ||
|
|
394
|
+
key.includes('@link:') ||
|
|
395
|
+
resolution.includes('@workspace:') ||
|
|
396
|
+
resolution.includes('@portal:') ||
|
|
397
|
+
resolution.includes('@link:')
|
|
398
|
+
) {
|
|
399
|
+
workspaceCount++;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Extract package name from lockfile key
|
|
404
|
+
const name = parseYarnBerryKey(key);
|
|
405
|
+
if (name && value.version) {
|
|
406
|
+
packages.add(`${name}@${value.version}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { packages, workspaceCount, source: '@yarnpkg/parsers' };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get packages from yarn berry lockfile - tries @yarnpkg/core first, falls back to @yarnpkg/parsers
|
|
415
|
+
* @param {string} content - Lockfile content
|
|
416
|
+
* @param {CompareOptions} [options] - Options
|
|
417
|
+
* @returns {Promise<PackagesResult>}
|
|
418
|
+
*/
|
|
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) {
|
|
488
|
+
const parsed = /** @type {{ packages?: Record<string, any> }} */ (yaml.load(content));
|
|
489
|
+
|
|
490
|
+
const packages = new Set();
|
|
491
|
+
let workspaceCount = 0;
|
|
492
|
+
const pkgs = parsed.packages || {};
|
|
493
|
+
|
|
494
|
+
for (const [key, value] of Object.entries(pkgs)) {
|
|
495
|
+
// Skip link/file entries - flatlock only cares about external dependencies
|
|
496
|
+
// Keys can be: link:path, file:path, or @pkg@file:path
|
|
497
|
+
if (
|
|
498
|
+
key.startsWith('link:') ||
|
|
499
|
+
key.startsWith('file:') ||
|
|
500
|
+
key.includes('@link:') ||
|
|
501
|
+
key.includes('@file:')
|
|
502
|
+
) {
|
|
503
|
+
workspaceCount++;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
// Also skip if resolution.type is 'directory' (workspace)
|
|
507
|
+
if (value.resolution?.type === 'directory') {
|
|
508
|
+
workspaceCount++;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Extract name and version from pnpm lockfile key
|
|
513
|
+
const { name, version } = parsePnpmSpec(key);
|
|
514
|
+
if (name && version) {
|
|
515
|
+
packages.add(`${name}@${version}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
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);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Compare flatlock output against established parser for a lockfile
|
|
540
|
+
* @param {string} filepath - Path to lockfile
|
|
541
|
+
* @param {CompareOptions} [options] - Options
|
|
542
|
+
* @returns {Promise<ComparisonResult>}
|
|
543
|
+
*/
|
|
544
|
+
export async function compare(filepath, options = {}) {
|
|
545
|
+
const content = await readFile(filepath, 'utf8');
|
|
546
|
+
const type = detectType({ path: filepath, content });
|
|
547
|
+
|
|
548
|
+
const flatlockSet = new Set();
|
|
549
|
+
for await (const dep of fromPath(filepath)) {
|
|
550
|
+
if (dep.name && dep.version) {
|
|
551
|
+
flatlockSet.add(`${dep.name}@${dep.version}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let comparisonResult;
|
|
556
|
+
switch (type) {
|
|
557
|
+
case Type.NPM:
|
|
558
|
+
comparisonResult = await getPackagesFromNpm(content, filepath, options);
|
|
559
|
+
break;
|
|
560
|
+
case Type.YARN_CLASSIC:
|
|
561
|
+
comparisonResult = await getPackagesFromYarnClassic(content);
|
|
562
|
+
break;
|
|
563
|
+
case Type.YARN_BERRY:
|
|
564
|
+
comparisonResult = await getPackagesFromYarnBerry(content, options);
|
|
565
|
+
break;
|
|
566
|
+
case Type.PNPM:
|
|
567
|
+
comparisonResult = await getPackagesFromPnpm(content, filepath, options);
|
|
568
|
+
break;
|
|
569
|
+
default:
|
|
570
|
+
return { type, equinumerous: null, flatlockCount: flatlockSet.size };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const { packages: comparisonSet, workspaceCount, source } = comparisonResult;
|
|
574
|
+
const onlyInFlatlock = new Set([...flatlockSet].filter(x => !comparisonSet.has(x)));
|
|
575
|
+
const onlyInComparison = new Set([...comparisonSet].filter(x => !flatlockSet.has(x)));
|
|
576
|
+
const equinumerous = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
type,
|
|
580
|
+
source,
|
|
581
|
+
equinumerous,
|
|
582
|
+
flatlockCount: flatlockSet.size,
|
|
583
|
+
comparisonCount: comparisonSet.size,
|
|
584
|
+
workspaceCount,
|
|
585
|
+
onlyInFlatlock: [...onlyInFlatlock],
|
|
586
|
+
onlyInComparison: [...onlyInComparison]
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Compare multiple lockfiles
|
|
592
|
+
* @param {string[]} filepaths - Paths to lockfiles
|
|
593
|
+
* @param {CompareOptions} [options] - Options
|
|
594
|
+
* @returns {AsyncGenerator<ComparisonResult & { filepath: string }>}
|
|
595
|
+
*/
|
|
596
|
+
export async function* compareAll(filepaths, options = {}) {
|
|
597
|
+
for (const filepath of filepaths) {
|
|
598
|
+
yield { filepath, ...(await compare(filepath, options)) };
|
|
599
|
+
}
|
|
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 yaml from 'js-yaml';
|
|
2
1
|
import { parseSyml } from '@yarnpkg/parsers';
|
|
3
|
-
import
|
|
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
|
|
@@ -40,6 +40,7 @@ function tryParseYarnBerry(content) {
|
|
|
40
40
|
try {
|
|
41
41
|
const parsed = parseSyml(content);
|
|
42
42
|
// Must have __metadata object at root with version property
|
|
43
|
+
// biome-ignore format: preserve multiline logical expression
|
|
43
44
|
return parsed
|
|
44
45
|
&& typeof parsed.__metadata === 'object'
|
|
45
46
|
&& parsed.__metadata !== null
|
|
@@ -56,10 +57,9 @@ function tryParseYarnBerry(content) {
|
|
|
56
57
|
*/
|
|
57
58
|
function tryParseYarnClassic(content) {
|
|
58
59
|
try {
|
|
59
|
-
|
|
60
|
-
if (!parse) return false;
|
|
60
|
+
if (!parseYarnClassic) return false;
|
|
61
61
|
|
|
62
|
-
const result =
|
|
62
|
+
const result = parseYarnClassic(content);
|
|
63
63
|
// Must parse successfully and NOT have __metadata (that's berry)
|
|
64
64
|
// Must have at least one package entry (not empty object)
|
|
65
65
|
const isValidResult = result.type === 'success' || result.type === 'merge';
|
|
@@ -81,10 +81,11 @@ function tryParsePnpm(content) {
|
|
|
81
81
|
try {
|
|
82
82
|
const parsed = yaml.load(content);
|
|
83
83
|
// Must have lockfileVersion at root and NOT have __metadata
|
|
84
|
-
|
|
84
|
+
// biome-ignore format: preserve multiline logical expression
|
|
85
|
+
return !!(parsed
|
|
85
86
|
&& typeof parsed === 'object'
|
|
86
87
|
&& 'lockfileVersion' in parsed
|
|
87
|
-
&& !('__metadata' in parsed);
|
|
88
|
+
&& !('__metadata' in parsed));
|
|
88
89
|
} catch {
|
|
89
90
|
return false;
|
|
90
91
|
}
|