flatlock 1.4.0 → 1.5.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.
Files changed (2) hide show
  1. package/bin/flatcover.js +135 -20
  2. package/package.json +1 -1
package/bin/flatcover.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { parseArgs } from 'node:util';
16
16
  import { readFileSync } from 'node:fs';
17
+ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
17
18
  import { createReadStream } from 'node:fs';
18
19
  import { createInterface } from 'node:readline';
19
20
  import { dirname, join } from 'node:path';
@@ -37,6 +38,8 @@ const { values, positionals } = parseArgs({
37
38
  concurrency: { type: 'string', default: '20' },
38
39
  progress: { type: 'boolean', default: false },
39
40
  summary: { type: 'boolean', default: false },
41
+ before: { type: 'string', short: 'b' },
42
+ cache: { type: 'string', short: 'c' },
40
43
  help: { type: 'boolean', short: 'h' }
41
44
  },
42
45
  allowPositionals: true
@@ -67,7 +70,7 @@ Options:
67
70
  -s, --specs Include version (name@version or {name,version})
68
71
  --json Output as JSON array
69
72
  --ndjson Output as newline-delimited JSON (streaming)
70
- --full Include all metadata (integrity, resolved)
73
+ --full Include all metadata (integrity, resolved, time)
71
74
  --dev Include dev dependencies (default: false)
72
75
  --peer Include peer dependencies (default: true)
73
76
  -h, --help Show this help
@@ -80,13 +83,17 @@ Coverage options:
80
83
  --concurrency <n> Concurrent requests (default: 20)
81
84
  --progress Show progress on stderr
82
85
  --summary Show coverage summary on stderr
86
+ --before <date> Only count versions published before this ISO date
87
+ -c, --cache <dir> Cache packuments to disk for faster subsequent runs
83
88
 
84
89
  Output formats (with --cover):
85
- (default) CSV: package,version,present
86
- --full CSV: package,version,present,integrity,resolved
87
- --json [{"name":"...","version":"...","present":true}, ...]
88
- --full --json Adds "integrity" and "resolved" fields to JSON
89
- --ndjson {"name":"...","version":"...","present":true} per line
90
+ (default) CSV format (sorted by name, version)
91
+ --json JSON array (sorted by name, version)
92
+ --ndjson Newline-delimited JSON (streaming, unsorted)
93
+
94
+ Output fields:
95
+ (default) name, version, present
96
+ --full Adds: spec, integrity, resolved, time (works with all formats)
90
97
 
91
98
  Examples:
92
99
  # From lockfile
@@ -97,6 +104,10 @@ Examples:
97
104
  flatcover --list packages.json --cover --summary
98
105
  echo '[{"name":"lodash","version":"4.17.21"}]' > pkgs.json && flatcover -l pkgs.json --cover
99
106
 
107
+ # Time-travel reanalysis: capture full output with timestamps
108
+ flatcover package-lock.json --cover --full --json > coverage.json
109
+ # Later, filter locally by publication date without re-fetching registry
110
+
100
111
  # From stdin (NDJSON) - use '-' to read from stdin
101
112
  echo '{"name":"lodash","version":"4.17.21"}' | flatcover - --cover
102
113
  cat packages.ndjson | flatcover - --cover --json
@@ -217,6 +228,68 @@ function encodePackageName(name) {
217
228
  return name.replace('/', '%2f');
218
229
  }
219
230
 
231
+ /**
232
+ * Read cached packument metadata (etag, lastModified)
233
+ * @param {string} cacheDir - Cache directory path
234
+ * @param {string} encodedName - URL-encoded package name
235
+ * @returns {Promise<{ etag?: string, lastModified?: string } | null>}
236
+ */
237
+ async function readCacheMeta(cacheDir, encodedName) {
238
+ try {
239
+ const metaPath = join(cacheDir, `${encodedName}.meta.json`);
240
+ const content = await readFile(metaPath, 'utf8');
241
+ return JSON.parse(content);
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Read cached packument from disk
249
+ * @param {string} cacheDir - Cache directory path
250
+ * @param {string} encodedName - URL-encoded package name
251
+ * @returns {Promise<object | null>}
252
+ */
253
+ async function readCachedPackument(cacheDir, encodedName) {
254
+ try {
255
+ const cachePath = join(cacheDir, `${encodedName}.json`);
256
+ const content = await readFile(cachePath, 'utf8');
257
+ return JSON.parse(content);
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Write packument and metadata to cache atomically
265
+ * @param {string} cacheDir - Cache directory path
266
+ * @param {string} encodedName - URL-encoded package name
267
+ * @param {string} body - Raw packument JSON string
268
+ * @param {{ etag?: string, lastModified?: string }} meta - Cache metadata
269
+ */
270
+ async function writeCache(cacheDir, encodedName, body, meta) {
271
+ await mkdir(cacheDir, { recursive: true });
272
+
273
+ const cachePath = join(cacheDir, `${encodedName}.json`);
274
+ const metaPath = join(cacheDir, `${encodedName}.meta.json`);
275
+ const pid = process.pid;
276
+
277
+ // Write packument atomically
278
+ const tmpCachePath = `${cachePath}.${pid}.tmp`;
279
+ await writeFile(tmpCachePath, body);
280
+ await rename(tmpCachePath, cachePath);
281
+
282
+ // Write metadata atomically
283
+ const metaObj = {
284
+ etag: meta.etag,
285
+ lastModified: meta.lastModified,
286
+ fetchedAt: new Date().toISOString()
287
+ };
288
+ const tmpMetaPath = `${metaPath}.${pid}.tmp`;
289
+ await writeFile(tmpMetaPath, JSON.stringify(metaObj));
290
+ await rename(tmpMetaPath, metaPath);
291
+ }
292
+
220
293
  /**
221
294
  * Create undici client with retry support
222
295
  * @param {string} registryUrl
@@ -267,10 +340,10 @@ function createClient(registryUrl, { auth, token }) {
267
340
  /**
268
341
  * Check coverage for all dependencies
269
342
  * @param {Array<{ name: string, version: string, integrity?: string, resolved?: string }>} deps
270
- * @param {{ registry: string, auth?: string, token?: string, progress: boolean }} options
343
+ * @param {{ registry: string, auth?: string, token?: string, progress: boolean, before?: string, cache?: string }} options
271
344
  * @returns {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>}
272
345
  */
273
- async function* checkCoverage(deps, { registry, auth, token, progress }) {
346
+ async function* checkCoverage(deps, { registry, auth, token, progress, before, cache }) {
274
347
  const { client, headers, baseUrl } = createClient(registry, { auth, token });
275
348
 
276
349
  // Group by package name to avoid duplicate requests
@@ -286,7 +359,7 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
286
359
 
287
360
  const packages = [...byPackage.entries()];
288
361
  let completed = 0;
289
- const total = packages.length;
362
+ const total = deps.length;
290
363
 
291
364
  // Process in batches for bounded concurrency
292
365
  for (let i = 0; i < packages.length; i += concurrency) {
@@ -299,10 +372,22 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
299
372
  const path = `${basePath}/${encodedName}`;
300
373
 
301
374
  try {
375
+ // Build request headers, adding conditional request headers if cached
376
+ const reqHeaders = { ...headers };
377
+ let cacheMeta = null;
378
+ if (cache) {
379
+ cacheMeta = await readCacheMeta(cache, encodedName);
380
+ if (cacheMeta?.etag) {
381
+ reqHeaders['If-None-Match'] = cacheMeta.etag;
382
+ } else if (cacheMeta?.lastModified) {
383
+ reqHeaders['If-Modified-Since'] = cacheMeta.lastModified;
384
+ }
385
+ }
386
+
302
387
  const response = await client.request({
303
388
  method: 'GET',
304
389
  path,
305
- headers
390
+ headers: reqHeaders
306
391
  });
307
392
 
308
393
  const chunks = [];
@@ -316,19 +401,43 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
316
401
  }
317
402
 
318
403
  let packumentVersions = null;
319
- if (response.statusCode === 200) {
404
+ let packumentTime = null;
405
+
406
+ if (response.statusCode === 304 && cache) {
407
+ // Cache hit - read from disk
408
+ const cachedPackument = await readCachedPackument(cache, encodedName);
409
+ if (cachedPackument) {
410
+ packumentVersions = cachedPackument.versions || {};
411
+ packumentTime = cachedPackument.time || {};
412
+ }
413
+ } else if (response.statusCode === 200) {
320
414
  const body = Buffer.concat(chunks).toString('utf8');
321
415
  const packument = JSON.parse(body);
322
416
  packumentVersions = packument.versions || {};
417
+ packumentTime = packument.time || {};
418
+
419
+ // Write to cache if enabled
420
+ if (cache) {
421
+ await writeCache(cache, encodedName, body, {
422
+ etag: response.headers.etag,
423
+ lastModified: response.headers['last-modified']
424
+ });
425
+ }
323
426
  }
324
427
 
325
428
  // Check each version, preserving integrity/resolved from original dep
326
429
  const versionResults = [];
327
430
  for (const [version, dep] of versionMap) {
328
- const present = packumentVersions ? !!packumentVersions[version] : false;
431
+ let present = packumentVersions ? !!packumentVersions[version] : false;
432
+
433
+ // Time travel: if --before set, only count if published before that date
434
+ if (present && before && packumentTime[version] >= before) {
435
+ present = false;
436
+ }
329
437
  const result = { name, version, present };
330
438
  if (dep.integrity) result.integrity = dep.integrity;
331
439
  if (dep.resolved) result.resolved = dep.resolved;
440
+ if (packumentTime && packumentTime[version]) result.time = packumentTime[version];
332
441
  versionResults.push(result);
333
442
  }
334
443
  return versionResults;
@@ -353,10 +462,10 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
353
462
  for (const packageResults of results) {
354
463
  for (const result of packageResults) {
355
464
  yield result;
356
- }
357
- completed++;
358
- if (progress) {
359
- process.stderr.write(`\r Checking: ${completed}/${total} packages`);
465
+ completed++;
466
+ if (progress) {
467
+ process.stderr.write(`\r Checking: ${completed}/${total} package specs`);
468
+ }
360
469
  }
361
470
  }
362
471
  }
@@ -374,7 +483,7 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
374
483
  */
375
484
  function formatDep(dep, { specs, full }) {
376
485
  if (full) {
377
- const obj = { name: dep.name, version: dep.version };
486
+ const obj = { name: dep.name, version: dep.version, spec: `${dep.name}@${dep.version}` };
378
487
  if (dep.integrity) obj.integrity = dep.integrity;
379
488
  if (dep.resolved) obj.resolved = dep.resolved;
380
489
  return obj;
@@ -432,8 +541,10 @@ async function outputCoverage(results, { json, ndjson, summary, full }) {
432
541
  if (ndjson) {
433
542
  // Stream immediately
434
543
  const obj = { name: result.name, version: result.version, present: result.present };
544
+ if (full) obj.spec = `${result.name}@${result.version}`;
435
545
  if (full && result.integrity) obj.integrity = result.integrity;
436
546
  if (full && result.resolved) obj.resolved = result.resolved;
547
+ if (full && result.time) obj.time = result.time;
437
548
  console.log(JSON.stringify(obj));
438
549
  } else {
439
550
  all.push(result);
@@ -447,17 +558,19 @@ async function outputCoverage(results, { json, ndjson, summary, full }) {
447
558
  if (json) {
448
559
  const data = all.map(r => {
449
560
  const obj = { name: r.name, version: r.version, present: r.present };
561
+ if (full) obj.spec = `${r.name}@${r.version}`;
450
562
  if (full && r.integrity) obj.integrity = r.integrity;
451
563
  if (full && r.resolved) obj.resolved = r.resolved;
564
+ if (full && r.time) obj.time = r.time;
452
565
  return obj;
453
566
  });
454
567
  console.log(JSON.stringify(data, null, 2));
455
568
  } else {
456
569
  // CSV output
457
570
  if (full) {
458
- console.log('package,version,present,integrity,resolved');
571
+ console.log('package,version,spec,present,integrity,resolved,time');
459
572
  for (const r of all) {
460
- console.log(`${r.name},${r.version},${r.present},${r.integrity || ''},${r.resolved || ''}`);
573
+ console.log(`${r.name},${r.version},${r.name}@${r.version},${r.present},${r.integrity || ''},${r.resolved || ''},${r.time || ''}`);
461
574
  }
462
575
  } else {
463
576
  console.log('package,version,present');
@@ -523,7 +636,9 @@ try {
523
636
  registry: values.registry,
524
637
  auth: values.auth,
525
638
  token: values.token,
526
- progress: values.progress
639
+ progress: values.progress,
640
+ before: values.before,
641
+ cache: values.cache
527
642
  });
528
643
 
529
644
  await outputCoverage(results, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flatlock",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "The Matlock of lockfile parsers - extracts packages without building dependency graphs",
5
5
  "keywords": [
6
6
  "lockfile",