Skip to content

Commit 4e94fe4

Browse files
indexzeroclaude
andauthored
feat(flatcover) add --full and --list parameters, and accept ndjson via stdin (#12)
* feat(flatcover): Include integrity and resolved fields with --full --cover The --full flag was not passing integrity and resolved metadata through the coverage checking pipeline. The checkCoverage() function discarded these fields when grouping dependencies, and outputCoverage() did not accept or use the full parameter. - Preserve integrity/resolved in checkCoverage() by using Map instead of Set - Add full parameter to outputCoverage() function signature - Include fields conditionally in JSON, NDJSON, and CSV output formats - Update help text to document --full behavior with --cover - Add comprehensive tests for all output formats Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(flatcover): Add --list and stdin input sources for package lists Enable coverage checking for arbitrary package lists without requiring a lockfile. This supports two new input methods: --list <file>: Read packages from a JSON array file Example: flatcover --list packages.json --cover - (stdin): Read NDJSON packages from stdin, one per line Example: echo '{"name":"lodash","version":"4.17.21"}' | flatcover - --cover Both methods support optional integrity and resolved fields which are preserved when using --full. Input validation ensures each entry has required name and version fields. - Add -l/--list option to parseArgs for JSON file input - Add '-' positional argument convention for stdin input - Add readJsonList() and readStdinNdjson() helper functions - Validate mutually exclusive input sources - Restrict --workspace to lockfile input only - Update help text with new input source documentation - Add comprehensive tests for both input methods --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d5eb762 commit 4e94fe4

2 files changed

Lines changed: 586 additions & 42 deletions

File tree

bin/flatcover.js

Lines changed: 191 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414

1515
import { parseArgs } from 'node:util';
1616
import { readFileSync } from 'node:fs';
17+
import { createReadStream } from 'node:fs';
18+
import { createInterface } from 'node:readline';
1719
import { dirname, join } from 'node:path';
1820
import { Pool, RetryAgent } from 'undici';
1921
import { FlatlockSet } from '../src/set.js';
2022

2123
const { values, positionals } = parseArgs({
2224
options: {
2325
workspace: { type: 'string', short: 'w' },
26+
list: { type: 'string', short: 'l' },
2427
dev: { type: 'boolean', default: false },
2528
peer: { type: 'boolean', default: true },
2629
specs: { type: 'boolean', short: 's', default: false },
@@ -39,16 +42,28 @@ const { values, positionals } = parseArgs({
3942
allowPositionals: true
4043
});
4144

42-
if (values.help || positionals.length === 0) {
45+
// Check if stdin input is requested via '-' positional argument (Unix convention)
46+
const useStdin = positionals[0] === '-';
47+
48+
// Determine if we have a valid input source
49+
const hasInputSource = positionals.length > 0 || values.list;
50+
51+
if (values.help || !hasInputSource) {
4352
console.log(`flatcover - Check lockfile package coverage against a registry
4453
4554
Usage:
4655
flatcover <lockfile> --cover
47-
flatcover <lockfile> --cover --registry <url>
56+
flatcover --list packages.json --cover
57+
cat packages.ndjson | flatcover - --cover
4858
flatcover <lockfile> --cover --registry <url> --auth user:pass
4959
60+
Input sources (mutually exclusive):
61+
<lockfile> Parse lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock)
62+
-l, --list <file> Read JSON array of {name, version} objects from file
63+
- Read NDJSON {name, version} objects from stdin (one per line)
64+
5065
Options:
51-
-w, --workspace <path> Workspace path within monorepo
66+
-w, --workspace <path> Workspace path within monorepo (lockfile mode only)
5267
-s, --specs Include version (name@version or {name,version})
5368
--json Output as JSON array
5469
--ndjson Output as newline-delimited JSON (streaming)
@@ -68,14 +83,27 @@ Coverage options:
6883
6984
Output formats (with --cover):
7085
(default) CSV: package,version,present
86+
--full CSV: package,version,present,integrity,resolved
7187
--json [{"name":"...","version":"...","present":true}, ...]
88+
--full --json Adds "integrity" and "resolved" fields to JSON
7289
--ndjson {"name":"...","version":"...","present":true} per line
7390
7491
Examples:
92+
# From lockfile
7593
flatcover package-lock.json --cover
94+
flatcover package-lock.json --cover --full --json
95+
96+
# From JSON list file
97+
flatcover --list packages.json --cover --summary
98+
echo '[{"name":"lodash","version":"4.17.21"}]' > pkgs.json && flatcover -l pkgs.json --cover
99+
100+
# From stdin (NDJSON) - use '-' to read from stdin
101+
echo '{"name":"lodash","version":"4.17.21"}' | flatcover - --cover
102+
cat packages.ndjson | flatcover - --cover --json
103+
104+
# With custom registry
76105
flatcover package-lock.json --cover --registry https://npm.pkg.github.com --token ghp_xxx
77-
flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson
78-
flatcover pnpm-lock.yaml -w packages/core --cover --summary`);
106+
flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson`);
79107
process.exit(values.help ? 0 : 1);
80108
}
81109

@@ -89,6 +117,19 @@ if (values.auth && values.token) {
89117
process.exit(1);
90118
}
91119

120+
// Validate mutually exclusive input sources
121+
// Note: useStdin means positionals[0] === '-', so it's already counted in positionals.length
122+
if (positionals.length > 0 && values.list) {
123+
console.error('Error: Cannot use both lockfile/stdin and --list');
124+
process.exit(1);
125+
}
126+
127+
// --workspace only works with lockfile input (not stdin or --list)
128+
if (values.workspace && (useStdin || values.list || !positionals.length)) {
129+
console.error('Error: --workspace can only be used with lockfile input');
130+
process.exit(1);
131+
}
132+
92133
// --full implies --specs
93134
if (values.full) {
94135
values.specs = true;
@@ -102,6 +143,70 @@ if (values.cover) {
102143
const lockfilePath = positionals[0];
103144
const concurrency = Math.max(1, Math.min(50, Number.parseInt(values.concurrency, 10) || 20));
104145

146+
/**
147+
* Read packages from a JSON list file
148+
* @param {string} filePath - Path to JSON file containing [{name, version}, ...]
149+
* @returns {Array<{ name: string, version: string }>}
150+
*/
151+
function readJsonList(filePath) {
152+
const content = readFileSync(filePath, 'utf8');
153+
const data = JSON.parse(content);
154+
155+
if (!Array.isArray(data)) {
156+
throw new Error('--list file must contain a JSON array');
157+
}
158+
159+
const packages = [];
160+
for (const item of data) {
161+
if (!item.name || !item.version) {
162+
throw new Error('Each item in --list must have "name" and "version" fields');
163+
}
164+
packages.push({
165+
name: item.name,
166+
version: item.version,
167+
integrity: item.integrity,
168+
resolved: item.resolved
169+
});
170+
}
171+
172+
return packages;
173+
}
174+
175+
/**
176+
* Read packages from stdin as NDJSON
177+
* @returns {Promise<Array<{ name: string, version: string }>>}
178+
*/
179+
async function readStdinNdjson() {
180+
const packages = [];
181+
182+
const rl = createInterface({
183+
input: process.stdin,
184+
crlfDelay: Infinity
185+
});
186+
187+
for await (const line of rl) {
188+
const trimmed = line.trim();
189+
if (!trimmed) continue;
190+
191+
try {
192+
const item = JSON.parse(trimmed);
193+
if (!item.name || !item.version) {
194+
throw new Error('Each line must have "name" and "version" fields');
195+
}
196+
packages.push({
197+
name: item.name,
198+
version: item.version,
199+
integrity: item.integrity,
200+
resolved: item.resolved
201+
});
202+
} catch (err) {
203+
throw new Error(`Invalid JSON on stdin: ${err.message}`);
204+
}
205+
}
206+
207+
return packages;
208+
}
209+
105210
/**
106211
* Encode package name for URL (handle scoped packages)
107212
* @param {string} name - Package name like @babel/core
@@ -161,21 +266,22 @@ function createClient(registryUrl, { auth, token }) {
161266

162267
/**
163268
* Check coverage for all dependencies
164-
* @param {Array<{ name: string, version: string }>} deps
269+
* @param {Array<{ name: string, version: string, integrity?: string, resolved?: string }>} deps
165270
* @param {{ registry: string, auth?: string, token?: string, progress: boolean }} options
166-
* @returns {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }>}
271+
* @returns {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>}
167272
*/
168273
async function* checkCoverage(deps, { registry, auth, token, progress }) {
169274
const { client, headers, baseUrl } = createClient(registry, { auth, token });
170275

171276
// Group by package name to avoid duplicate requests
172-
/** @type {Map<string, Set<string>>} */
277+
// Store full dep info (including integrity/resolved) keyed by version
278+
/** @type {Map<string, Map<string, { name: string, version: string, integrity?: string, resolved?: string }>>} */
173279
const byPackage = new Map();
174280
for (const dep of deps) {
175281
if (!byPackage.has(dep.name)) {
176-
byPackage.set(dep.name, new Set());
282+
byPackage.set(dep.name, new Map());
177283
}
178-
byPackage.get(dep.name).add(dep.version);
284+
byPackage.get(dep.name).set(dep.version, dep);
179285
}
180286

181287
const packages = [...byPackage.entries()];
@@ -187,7 +293,7 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
187293
const batch = packages.slice(i, i + concurrency);
188294

189295
const results = await Promise.all(
190-
batch.map(async ([name, versions]) => {
296+
batch.map(async ([name, versionMap]) => {
191297
const encodedName = encodePackageName(name);
192298
const basePath = baseUrl.pathname.replace(/\/$/, '');
193299
const path = `${basePath}/${encodedName}`;
@@ -216,21 +322,29 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
216322
packumentVersions = packument.versions || {};
217323
}
218324

219-
// Check each version
325+
// Check each version, preserving integrity/resolved from original dep
220326
const versionResults = [];
221-
for (const version of versions) {
327+
for (const [version, dep] of versionMap) {
222328
const present = packumentVersions ? !!packumentVersions[version] : false;
223-
versionResults.push({ name, version, present });
329+
const result = { name, version, present };
330+
if (dep.integrity) result.integrity = dep.integrity;
331+
if (dep.resolved) result.resolved = dep.resolved;
332+
versionResults.push(result);
224333
}
225334
return versionResults;
226335
} catch (err) {
227336
// Return error for all versions of this package
228-
return [...versions].map(version => ({
229-
name,
230-
version,
231-
present: false,
232-
error: err.message
233-
}));
337+
return [...versionMap.values()].map(dep => {
338+
const result = {
339+
name: dep.name,
340+
version: dep.version,
341+
present: false,
342+
error: err.message
343+
};
344+
if (dep.integrity) result.integrity = dep.integrity;
345+
if (dep.resolved) result.resolved = dep.resolved;
346+
return result;
347+
});
234348
}
235349
})
236350
);
@@ -300,10 +414,10 @@ function outputDeps(deps, { specs, json, ndjson, full }) {
300414

301415
/**
302416
* Output coverage results
303-
* @param {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }>} results
304-
* @param {{ json: boolean, ndjson: boolean, summary: boolean }} options
417+
* @param {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>} results
418+
* @param {{ json: boolean, ndjson: boolean, summary: boolean, full: boolean }} options
305419
*/
306-
async function outputCoverage(results, { json, ndjson, summary }) {
420+
async function outputCoverage(results, { json, ndjson, summary, full }) {
307421
const all = [];
308422
let presentCount = 0;
309423
let missingCount = 0;
@@ -317,7 +431,10 @@ async function outputCoverage(results, { json, ndjson, summary }) {
317431

318432
if (ndjson) {
319433
// Stream immediately
320-
console.log(JSON.stringify({ name: result.name, version: result.version, present: result.present }));
434+
const obj = { name: result.name, version: result.version, present: result.present };
435+
if (full && result.integrity) obj.integrity = result.integrity;
436+
if (full && result.resolved) obj.resolved = result.resolved;
437+
console.log(JSON.stringify(obj));
321438
} else {
322439
all.push(result);
323440
}
@@ -328,13 +445,25 @@ async function outputCoverage(results, { json, ndjson, summary }) {
328445
all.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
329446

330447
if (json) {
331-
const data = all.map(r => ({ name: r.name, version: r.version, present: r.present }));
448+
const data = all.map(r => {
449+
const obj = { name: r.name, version: r.version, present: r.present };
450+
if (full && r.integrity) obj.integrity = r.integrity;
451+
if (full && r.resolved) obj.resolved = r.resolved;
452+
return obj;
453+
});
332454
console.log(JSON.stringify(data, null, 2));
333455
} else {
334456
// CSV output
335-
console.log('package,version,present');
336-
for (const r of all) {
337-
console.log(`${r.name},${r.version},${r.present}`);
457+
if (full) {
458+
console.log('package,version,present,integrity,resolved');
459+
for (const r of all) {
460+
console.log(`${r.name},${r.version},${r.present},${r.integrity || ''},${r.resolved || ''}`);
461+
}
462+
} else {
463+
console.log('package,version,present');
464+
for (const r of all) {
465+
console.log(`${r.name},${r.version},${r.present}`);
466+
}
338467
}
339468
}
340469
}
@@ -350,22 +479,41 @@ async function outputCoverage(results, { json, ndjson, summary }) {
350479
}
351480

352481
try {
353-
const lockfile = await FlatlockSet.fromPath(lockfilePath);
354482
let deps;
355483

356-
if (values.workspace) {
357-
const repoDir = dirname(lockfilePath);
358-
const workspacePkgPath = join(repoDir, values.workspace, 'package.json');
359-
const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, 'utf8'));
360-
361-
deps = await lockfile.dependenciesOf(workspacePkg, {
362-
workspacePath: values.workspace,
363-
repoDir,
364-
dev: values.dev,
365-
peer: values.peer
366-
});
484+
// Determine input source and load dependencies
485+
if (useStdin) {
486+
// Read from stdin (NDJSON)
487+
deps = await readStdinNdjson();
488+
if (deps.length === 0) {
489+
console.error('Error: No packages read from stdin');
490+
process.exit(1);
491+
}
492+
} else if (values.list) {
493+
// Read from JSON list file
494+
deps = readJsonList(values.list);
495+
if (deps.length === 0) {
496+
console.error('Error: No packages found in --list file');
497+
process.exit(1);
498+
}
367499
} else {
368-
deps = lockfile;
500+
// Read from lockfile (existing behavior)
501+
const lockfile = await FlatlockSet.fromPath(lockfilePath);
502+
503+
if (values.workspace) {
504+
const repoDir = dirname(lockfilePath);
505+
const workspacePkgPath = join(repoDir, values.workspace, 'package.json');
506+
const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, 'utf8'));
507+
508+
deps = await lockfile.dependenciesOf(workspacePkg, {
509+
workspacePath: values.workspace,
510+
repoDir,
511+
dev: values.dev,
512+
peer: values.peer
513+
});
514+
} else {
515+
deps = lockfile;
516+
}
369517
}
370518

371519
if (values.cover) {
@@ -381,7 +529,8 @@ try {
381529
await outputCoverage(results, {
382530
json: values.json,
383531
ndjson: values.ndjson,
384-
summary: values.summary
532+
summary: values.summary,
533+
full: values.full
385534
});
386535
} else {
387536
// Standard flatlock mode

0 commit comments

Comments
 (0)