flatlock 1.0.1

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.
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, readdir, stat, mkdir, writeFile, rm } from 'node:fs/promises';
4
+ import { join, dirname } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { fileURLToPath } from 'node:url';
7
+ import crypto from 'node:crypto';
8
+ import * as flatlock from '../src/index.js';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ // Unique run ID for this script execution (7 char hash of timestamp)
13
+ const RUN_ID = Date.now().toString(36).slice(-7);
14
+ const TMP_BASE = join(__dirname, 'tmp', RUN_ID);
15
+ let tmpDirCreated = false;
16
+
17
+ // Comparison parsers (lazy loaded)
18
+ let Arborist, yarnLockfile, parseSyml, yaml;
19
+
20
+ async function loadArborist() {
21
+ if (!Arborist) {
22
+ const mod = await import('@npmcli/arborist');
23
+ Arborist = mod.default;
24
+ }
25
+ return Arborist;
26
+ }
27
+
28
+ async function loadYarnClassic() {
29
+ if (!yarnLockfile) {
30
+ const mod = await import('@yarnpkg/lockfile');
31
+ yarnLockfile = mod.default || mod;
32
+ }
33
+ return yarnLockfile;
34
+ }
35
+
36
+ async function loadYarnBerry() {
37
+ if (!parseSyml) {
38
+ const mod = await import('@yarnpkg/parsers');
39
+ parseSyml = mod.parseSyml;
40
+ }
41
+ return parseSyml;
42
+ }
43
+
44
+ async function loadYaml() {
45
+ if (!yaml) {
46
+ const mod = await import('js-yaml');
47
+ yaml = mod.default;
48
+ }
49
+ return yaml;
50
+ }
51
+
52
+ /**
53
+ * Ensure the temp directory for this run exists
54
+ */
55
+ async function ensureTmpDir() {
56
+ if (!tmpDirCreated) {
57
+ await mkdir(TMP_BASE, { recursive: true });
58
+ tmpDirCreated = true;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Cleanup the temp directory for this run
64
+ */
65
+ async function cleanup() {
66
+ if (tmpDirCreated) {
67
+ try {
68
+ await rm(TMP_BASE, { recursive: true, force: true });
69
+ } catch {
70
+ // Ignore cleanup errors
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Get packages set using @npmcli/arborist
77
+ *
78
+ * Arborist requires a directory with package-lock.json (and optionally package.json).
79
+ * We create a temp directory structure for each lockfile:
80
+ * bin/tmp/<run-id>/<file-hash>/package-lock.json
81
+ */
82
+ async function getPackagesFromNpm(content, filepath) {
83
+ const ArboristClass = await loadArborist();
84
+ await ensureTmpDir();
85
+
86
+ // Create unique subdir for this lockfile
87
+ const fileId = crypto.createHash('md5').update(filepath).digest('hex').slice(0, 7);
88
+ const tmpDir = join(TMP_BASE, fileId);
89
+
90
+ await mkdir(tmpDir, { recursive: true });
91
+ await writeFile(join(tmpDir, 'package-lock.json'), content);
92
+
93
+ // Create minimal package.json from lockfile root entry
94
+ const lockfile = JSON.parse(content);
95
+ const root = lockfile.packages?.[''] || {};
96
+ const pkg = {
97
+ name: root.name || 'arborist-temp',
98
+ version: root.version || '1.0.0'
99
+ };
100
+ await writeFile(join(tmpDir, 'package.json'), JSON.stringify(pkg));
101
+
102
+ try {
103
+ const arb = new ArboristClass({ path: tmpDir });
104
+ const tree = await arb.loadVirtual();
105
+
106
+ const result = new Set();
107
+ let workspaceCount = 0;
108
+ for (const node of tree.inventory.values()) {
109
+ if (node.isRoot) continue;
110
+ // Skip workspace symlinks (link:true, no version in raw lockfile)
111
+ if (node.isLink) {
112
+ workspaceCount++;
113
+ continue;
114
+ }
115
+ // Skip workspace package definitions (not in node_modules)
116
+ // Flatlock only yields packages from node_modules/ paths
117
+ if (node.location && !node.location.includes('node_modules')) {
118
+ workspaceCount++;
119
+ continue;
120
+ }
121
+ if (node.name && node.version) {
122
+ result.add(`${node.name}@${node.version}`);
123
+ }
124
+ }
125
+ return { packages: result, workspaceCount };
126
+ } finally {
127
+ // Cleanup this specific lockfile's temp dir
128
+ await rm(tmpDir, { recursive: true, force: true });
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get packages set using @yarnpkg/lockfile (classic)
134
+ */
135
+ async function getPackagesFromYarnClassic(content) {
136
+ const yarnLock = await loadYarnClassic();
137
+ const parse = yarnLock.parse || yarnLock.default?.parse;
138
+ const { object: lockfile } = parse(content);
139
+
140
+ const result = new Set();
141
+ let workspaceCount = 0;
142
+ for (const [key, pkg] of Object.entries(lockfile)) {
143
+ if (key === '__metadata') continue;
144
+
145
+ // Skip workspace/link entries - flatlock only cares about external dependencies
146
+ const resolved = pkg.resolved || '';
147
+ if (resolved.startsWith('file:') || resolved.startsWith('link:')) {
148
+ workspaceCount++;
149
+ continue;
150
+ }
151
+
152
+ let name;
153
+ if (key.startsWith('@')) {
154
+ const idx = key.indexOf('@', 1);
155
+ name = key.slice(0, idx);
156
+ } else {
157
+ name = key.split('@')[0];
158
+ }
159
+ if (name && pkg.version) result.add(`${name}@${pkg.version}`);
160
+ }
161
+
162
+ return { packages: result, workspaceCount };
163
+ }
164
+
165
+ /**
166
+ * Get packages set using @yarnpkg/parsers (berry)
167
+ */
168
+ async function getPackagesFromYarnBerry(content) {
169
+ const parse = await loadYarnBerry();
170
+ const lockfile = parse(content);
171
+
172
+ const result = new Set();
173
+ let workspaceCount = 0;
174
+ for (const [key, pkg] of Object.entries(lockfile)) {
175
+ if (key === '__metadata') continue;
176
+
177
+ // Skip workspace/link entries - flatlock only cares about external dependencies
178
+ const resolution = pkg.resolution || '';
179
+ if (resolution.startsWith('workspace:') ||
180
+ resolution.startsWith('portal:') ||
181
+ resolution.startsWith('link:')) {
182
+ workspaceCount++;
183
+ continue;
184
+ }
185
+
186
+ let name;
187
+ if (key.startsWith('@')) {
188
+ const idx = key.indexOf('@', 1);
189
+ name = key.slice(0, idx);
190
+ } else {
191
+ name = key.split('@')[0];
192
+ }
193
+ if (name && pkg.version) result.add(`${name}@${pkg.version}`);
194
+ }
195
+
196
+ return { packages: result, workspaceCount };
197
+ }
198
+
199
+ /**
200
+ * Get packages set using js-yaml (pnpm)
201
+ */
202
+ async function getPackagesFromPnpm(content) {
203
+ const y = await loadYaml();
204
+ const lockfile = y.load(content);
205
+ const packages = lockfile.packages || {};
206
+
207
+ const result = new Set();
208
+ let workspaceCount = 0;
209
+ for (const [key, pkg] of Object.entries(packages)) {
210
+ // Skip link/file entries - flatlock only cares about external dependencies
211
+ // Keys can be: link:path, file:path, or @pkg@file:path
212
+ if (key.startsWith('link:') || key.startsWith('file:') ||
213
+ key.includes('@link:') || key.includes('@file:')) {
214
+ workspaceCount++;
215
+ continue;
216
+ }
217
+ // Also skip if resolution.type is 'directory' (workspace)
218
+ if (pkg.resolution?.type === 'directory') {
219
+ workspaceCount++;
220
+ continue;
221
+ }
222
+ // pnpm keys are like /lodash@4.17.21 or /@babel/core@7.0.0
223
+ const match = key.match(/^\/?(@?[^@]+)@(.+)$/);
224
+ if (match) result.add(`${match[1]}@${match[2]}`);
225
+ }
226
+
227
+ return { packages: result, workspaceCount };
228
+ }
229
+
230
+ /**
231
+ * Get packages set with flatlock
232
+ */
233
+ async function getPackagesFromFlatlock(filepath) {
234
+ const result = new Set();
235
+ for await (const dep of flatlock.fromPath(filepath)) {
236
+ if (dep.name && dep.version) result.add(`${dep.name}@${dep.version}`);
237
+ }
238
+ return result;
239
+ }
240
+
241
+ /**
242
+ * Get comparison parser name for type
243
+ */
244
+ function getComparisonName(type) {
245
+ switch (type) {
246
+ case 'npm': return '@npmcli/arborist';
247
+ case 'yarn-classic': return '@yarnpkg/lockfile';
248
+ case 'yarn-berry': return '@yarnpkg/parsers';
249
+ case 'pnpm': return 'js-yaml';
250
+ default: return 'unknown';
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Get packages with comparison parser based on type
256
+ */
257
+ async function getPackagesFromComparison(type, content, filepath) {
258
+ switch (type) {
259
+ case 'npm': return getPackagesFromNpm(content, filepath);
260
+ case 'yarn-classic': return getPackagesFromYarnClassic(content);
261
+ case 'yarn-berry': return getPackagesFromYarnBerry(content);
262
+ case 'pnpm': return getPackagesFromPnpm(content);
263
+ default: return null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Compare two sets and return differences
269
+ */
270
+ function compareSets(setA, setB) {
271
+ const onlyInA = new Set([...setA].filter(x => !setB.has(x)));
272
+ const onlyInB = new Set([...setB].filter(x => !setA.has(x)));
273
+ return { onlyInA, onlyInB };
274
+ }
275
+
276
+ /**
277
+ * Convert glob pattern to regex
278
+ */
279
+ function globToRegex(pattern) {
280
+ let regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
281
+ regex = regex.replace(/\*\*/g, '__DOUBLESTAR__');
282
+ regex = regex.replace(/\*/g, '[^/]*');
283
+ regex = regex.replace(/__DOUBLESTAR__/g, '.*');
284
+ return new RegExp(`^${regex}$`);
285
+ }
286
+
287
+ /**
288
+ * Find files in directory matching glob pattern
289
+ */
290
+ async function findFiles(dir, pattern) {
291
+ const entries = await readdir(dir, { recursive: true, encoding: 'utf8' });
292
+ const regex = pattern ? globToRegex(pattern) : null;
293
+
294
+ const files = [];
295
+ for (const entry of entries) {
296
+ const fullPath = join(dir, entry);
297
+ if (regex && !regex.test(entry)) continue;
298
+
299
+ try {
300
+ const stats = await stat(fullPath);
301
+ if (stats.isFile()) files.push(fullPath);
302
+ } catch {
303
+ continue;
304
+ }
305
+ }
306
+
307
+ return files.sort();
308
+ }
309
+
310
+ /**
311
+ * Process a single lockfile - compare sets, not just counts
312
+ */
313
+ async function processFile(filepath, baseDir) {
314
+ try {
315
+ const content = await readFile(filepath, 'utf8');
316
+ const type = flatlock.detectType({ path: filepath, content });
317
+ const rel = baseDir ? filepath.replace(baseDir + '/', '') : filepath;
318
+ const comparisonName = getComparisonName(type);
319
+
320
+ const flatlockSet = await getPackagesFromFlatlock(filepath);
321
+ let comparisonResult;
322
+
323
+ try {
324
+ comparisonResult = await getPackagesFromComparison(type, content, filepath);
325
+ } catch (err) {
326
+ comparisonResult = null;
327
+ }
328
+
329
+ if (!comparisonResult) {
330
+ return {
331
+ type,
332
+ path: rel,
333
+ comparisonName,
334
+ flatlockCount: flatlockSet.size,
335
+ comparisonCount: null,
336
+ workspaceCount: 0,
337
+ identical: null,
338
+ onlyInFlatlock: null,
339
+ onlyInComparison: null
340
+ };
341
+ }
342
+
343
+ const { packages: comparisonSet, workspaceCount } = comparisonResult;
344
+ const { onlyInA: onlyInFlatlock, onlyInB: onlyInComparison } = compareSets(flatlockSet, comparisonSet);
345
+ const identical = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
346
+
347
+ return {
348
+ type,
349
+ path: rel,
350
+ comparisonName,
351
+ flatlockCount: flatlockSet.size,
352
+ comparisonCount: comparisonSet.size,
353
+ workspaceCount,
354
+ identical,
355
+ onlyInFlatlock,
356
+ onlyInComparison
357
+ };
358
+ } catch (err) {
359
+ const rel = baseDir ? filepath.replace(baseDir + '/', '') : filepath;
360
+ return { error: err.message, path: rel };
361
+ }
362
+ }
363
+
364
+ async function main() {
365
+ const { values, positionals } = parseArgs({
366
+ options: {
367
+ dir: { type: 'string', short: 'd' },
368
+ glob: { type: 'string', short: 'g' },
369
+ quiet: { type: 'boolean', short: 'q', default: false },
370
+ help: { type: 'boolean', short: 'h' }
371
+ },
372
+ allowPositionals: true
373
+ });
374
+
375
+ if (values.help) {
376
+ console.log(`flatlock-cmp - Compare flatlock against established parsers
377
+
378
+ Usage:
379
+ flatlock-cmp [files...]
380
+ flatlock-cmp --dir <dir> [--glob <pattern>]
381
+
382
+ Options:
383
+ -d, --dir <path> Directory to scan
384
+ -g, --glob <pattern> Glob pattern for filtering
385
+ -q, --quiet Only show mismatches and summary
386
+ -h, --help Show this help
387
+
388
+ Comparison parsers (workspace/link entries excluded from all):
389
+ npm: @npmcli/arborist (loadVirtual)
390
+ yarn-classic: @yarnpkg/lockfile
391
+ yarn-berry: @yarnpkg/parsers
392
+ pnpm: js-yaml
393
+
394
+ Examples:
395
+ flatlock-cmp package-lock.json
396
+ flatlock-cmp --dir path/to/your/locker-room --glob "**/*package-lock*"
397
+ flatlock-cmp --dir path/to/your/locker-room --glob "**/*yarn.lock*"
398
+ flatlock-cmp --dir path/to/your/locker-room --glob "**/*pnpm-lock*"`);
399
+ process.exit(0);
400
+ }
401
+
402
+ let files = [];
403
+ const baseDir = values.dir;
404
+
405
+ if (baseDir) {
406
+ files = await findFiles(baseDir, values.glob);
407
+ if (!files.length) {
408
+ console.error(`No files found in ${baseDir}${values.glob ? ` matching ${values.glob}` : ''}`);
409
+ process.exit(1);
410
+ }
411
+ } else if (positionals.length > 0) {
412
+ files = positionals;
413
+ } else {
414
+ console.error('No files specified. Use --help for usage.');
415
+ process.exit(1);
416
+ }
417
+
418
+ let totalFlatlock = 0;
419
+ let totalComparison = 0;
420
+ let totalWorkspaces = 0;
421
+ let fileCount = 0;
422
+ let errorCount = 0;
423
+ let matchCount = 0;
424
+ let mismatchCount = 0;
425
+
426
+ try {
427
+ for (const file of files) {
428
+ const result = await processFile(file, baseDir);
429
+
430
+ if (result.error) {
431
+ errorCount++;
432
+ if (!values.quiet) {
433
+ console.log(`\n❌ ERROR: ${result.path}`);
434
+ console.log(` ${result.error}`);
435
+ }
436
+ continue;
437
+ }
438
+
439
+ fileCount++;
440
+ totalFlatlock += result.flatlockCount;
441
+ totalWorkspaces += result.workspaceCount || 0;
442
+
443
+ if (result.comparisonCount === null) {
444
+ if (!values.quiet) {
445
+ console.log(`\n⚠️ ${result.path}`);
446
+ console.log(` flatlock: ${result.flatlockCount} packages`);
447
+ console.log(` ${result.comparisonName}: unavailable`);
448
+ }
449
+ continue;
450
+ }
451
+
452
+ totalComparison += result.comparisonCount;
453
+
454
+ if (result.identical) {
455
+ matchCount++;
456
+ if (!values.quiet) {
457
+ const wsNote = result.workspaceCount > 0 ? ` (${result.workspaceCount} workspaces excluded)` : '';
458
+ console.log(`✓ ${result.path}${wsNote}`);
459
+ console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
460
+ console.log(` sets: identical`);
461
+ }
462
+ } else {
463
+ mismatchCount++;
464
+ console.log(`\n❌ ${result.path}`);
465
+ console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
466
+ console.log(` sets: MISMATCH`);
467
+
468
+ if (result.onlyInFlatlock.size > 0) {
469
+ console.log(` only in flatlock (${result.onlyInFlatlock.size}):`);
470
+ for (const pkg of [...result.onlyInFlatlock].slice(0, 10)) {
471
+ console.log(` + ${pkg}`);
472
+ }
473
+ if (result.onlyInFlatlock.size > 10) {
474
+ console.log(` ... and ${result.onlyInFlatlock.size - 10} more`);
475
+ }
476
+ }
477
+
478
+ if (result.onlyInComparison.size > 0) {
479
+ console.log(` only in ${result.comparisonName} (${result.onlyInComparison.size}):`);
480
+ for (const pkg of [...result.onlyInComparison].slice(0, 10)) {
481
+ console.log(` - ${pkg}`);
482
+ }
483
+ if (result.onlyInComparison.size > 10) {
484
+ console.log(` ... and ${result.onlyInComparison.size - 10} more`);
485
+ }
486
+ }
487
+ }
488
+ }
489
+
490
+ // Summary
491
+ console.log('\n' + '='.repeat(70));
492
+ console.log(`SUMMARY: ${fileCount} files, ${matchCount} identical, ${mismatchCount} mismatches, ${errorCount} errors`);
493
+ console.log(` flatlock total: ${totalFlatlock.toString().padStart(8)} packages`);
494
+ if (totalComparison > 0) {
495
+ console.log(` comparison total: ${totalComparison.toString().padStart(8)} packages`);
496
+ }
497
+ if (totalWorkspaces > 0) {
498
+ console.log(` workspaces: ${totalWorkspaces.toString().padStart(8)} excluded (local/workspace refs)`);
499
+ }
500
+
501
+ // Exit with error if any mismatches
502
+ if (mismatchCount > 0) {
503
+ process.exit(1);
504
+ }
505
+ } finally {
506
+ await cleanup();
507
+ }
508
+ }
509
+
510
+ main().catch(async err => {
511
+ await cleanup();
512
+ console.error('Fatal error:', err.message);
513
+ process.exit(1);
514
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @typedef {'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry'} LockfileType
3
+ */
4
+ /**
5
+ * @typedef {Object} ParsedLockfile
6
+ * @property {LockfileType} type - The type of lockfile
7
+ * @property {string} path - Path to the lockfile
8
+ * @property {Record<string, unknown>} packages - Parsed package entries
9
+ */
10
+ /**
11
+ * Parse a lockfile from the given path
12
+ * @param {string} lockfilePath - Path to the lockfile
13
+ * @returns {Promise<ParsedLockfile>}
14
+ */
15
+ export function parseLockfile(lockfilePath: string): Promise<ParsedLockfile>;
16
+ export default parseLockfile;
17
+ export type LockfileType = "npm" | "pnpm" | "yarn-classic" | "yarn-berry";
18
+ export type ParsedLockfile = {
19
+ /**
20
+ * - The type of lockfile
21
+ */
22
+ type: LockfileType;
23
+ /**
24
+ * - Path to the lockfile
25
+ */
26
+ path: string;
27
+ /**
28
+ * - Parsed package entries
29
+ */
30
+ packages: Record<string, unknown>;
31
+ };
32
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;GAKG;AAEH;;;;GAIG;AACH,4CAHW,MAAM,GACJ,OAAO,CAAC,cAAc,CAAC,CAKnC;;2BAlBY,KAAK,GAAG,MAAM,GAAG,cAAc,GAAG,YAAY;;;;;UAK7C,YAAY;;;;UACZ,MAAM;;;;cACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC"}
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "flatlock",
3
+ "version": "1.0.1",
4
+ "description": "The Matlock of lockfile parsers - extracts packages without building dependency graphs",
5
+ "keywords": [
6
+ "lockfile",
7
+ "lockfiles",
8
+ "shrinkwrap",
9
+ "npm",
10
+ "pnpm",
11
+ "yarn",
12
+ "yarn-berry"
13
+ ],
14
+ "homepage": "https://github.com/indexzero/flatlock#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/indexzero/flatlock/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/indexzero/flatlock.git"
21
+ },
22
+ "license": "Apache-2.0",
23
+ "author": "Charlie Robbins <npm@charlie.dev>",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./src/index.js"
29
+ }
30
+ },
31
+ "main": "src/index.js",
32
+ "bin": {
33
+ "flatlock-cmp": "./bin/flatlock-cmp.js"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "dist",
38
+ "bin"
39
+ ],
40
+ "scripts": {
41
+ "test": "node --test ./test/*.test.js",
42
+ "test:coverage": "c8 node --test ./test/*.test.js",
43
+ "build:types": "tsc",
44
+ "lint": "biome lint src test",
45
+ "lint:fix": "biome lint --write src test",
46
+ "lint:md": "markdownlint-cli2 \"README.md\"",
47
+ "format": "biome format --write src test",
48
+ "format:check": "biome format src test",
49
+ "check": "biome check src test",
50
+ "check:fix": "biome check --write src test",
51
+ "prepublishOnly": "npm run build:types"
52
+ },
53
+ "dependencies": {
54
+ "@yarnpkg/lockfile": "^1.1.0",
55
+ "@yarnpkg/parsers": "^3.0.3",
56
+ "js-yaml": "^4.1.1"
57
+ },
58
+ "devDependencies": {
59
+ "@biomejs/biome": "^2.3.8",
60
+ "@npmcli/arborist": "^9.1.9",
61
+ "@pnpm/lockfile-file": "^9.0.0",
62
+ "c8": "^10.1.3",
63
+ "markdownlint-cli2": "^0.17.2",
64
+ "snyk-nodejs-lockfile-parser": "^1.55.0",
65
+ "typescript": "^5.7.2"
66
+ },
67
+ "packageManager": "pnpm@10.25.0",
68
+ "engines": {
69
+ "node": ">=20"
70
+ },
71
+ "c8": {
72
+ "all": true,
73
+ "include": [
74
+ "src/**/*.js"
75
+ ],
76
+ "exclude": [
77
+ "test/**"
78
+ ],
79
+ "reporter": [
80
+ "text",
81
+ "lcov",
82
+ "html"
83
+ ]
84
+ }
85
+ }