From 4495d450c069b759a5c22039deb24795c128400e Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 16:09:07 -0500 Subject: [PATCH 1/3] Add cve-validate CLI Adds a `cve-validate` bin for validating CVE Records and CNA Containers from the shell or a CI pipeline, wrapping the existing Validate API. - src/cli.ts: parse args, read one or more JSON files (or stdin via `-` / a pipe), validate as record (default) or published-cna / rejected-cna via --type, and print human-readable or --json output. Exit 0 (all valid), 1 (any invalid or unreadable), 2 (usage error). Supports --help/--version. - package.json: add the `bin`, build src/cli.ts alongside src/index.ts, add a `test:cli` script. - scripts/test-cli.mjs: spawn the built CLI and assert output + exit codes across valid/invalid/mixed inputs, --json, stdin, --quiet, --type, --help, --version, unknown option, bad type, missing file, and malformed JSON. - README: document the CLI. Co-Authored-By: Claude Opus 4.8 --- README.md | 35 ++++++ package.json | 6 +- scripts/test-cli.mjs | 128 +++++++++++++++++++ src/cli.ts | 286 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 scripts/test-cli.mjs create mode 100644 src/cli.ts diff --git a/README.md b/README.md index 8ebe504..bf08ef6 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,41 @@ npm install -D tsx npx tsx main.ts ``` +## CLI + +Installing the package provides a `cve-validate` command for validating CVE +Records and CNA Containers from the shell or a CI pipeline. + +Validate one or more files: + +```sh +npx cve-validate record.json +npx cve-validate record1.json record2.json +``` + +Read from stdin (use `-` explicitly, or pipe with no arguments): + +```sh +cat record.json | cve-validate +``` + +Validate a CNA Container instead of a full CVE Record: + +```sh +cve-validate --type published-cna published-container.json +cve-validate --type rejected-cna rejected-container.json +``` + +Emit machine-readable JSON for scripting: + +```sh +cve-validate --json record.json +``` + +The command exits `0` when every input is valid, `1` when any input is invalid +or unreadable, and `2` on a usage error. Run `cve-validate --help` for the full +list of options. + ## Validation Methods ```ts diff --git a/package.json b/package.json index caa6ba8..544cf0d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "import": "./dist/index.js" } }, + "bin": { + "cve-validate": "dist/cli.js" + }, "files": [ "dist", "src", @@ -37,12 +40,13 @@ "LICENSE" ], "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean", + "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean", "dev": "tsx watch main.js", "typecheck": "tsc --noEmit", "prepare": "npm run build", "test": "npm run typecheck", "test:local": "npm run build && node scripts/test-local.mjs", + "test:cli": "npm run build && node scripts/test-cli.mjs", "warning:stub": "npm run build && node scripts/create-stub-warning.mjs" }, "devDependencies": { diff --git a/scripts/test-cli.mjs b/scripts/test-cli.mjs new file mode 100644 index 0000000..8c55d3a --- /dev/null +++ b/scripts/test-cli.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const fromRoot = (relativePath) => fileURLToPath(new URL(relativePath, import.meta.url)); + +const cli = fromRoot("../dist/cli.js"); +const validRecord = fromRoot("../scripts/test-data/cve-record-valid.json"); +const invalidRecord = fromRoot("../scripts/test-data/cve-record-missing-cna-container.json"); +const validPublishedContainer = fromRoot( + "../scripts/test-data/cna-container/published-cna-container-valid.json", +); +const packageVersion = JSON.parse(readFileSync(fromRoot("../package.json"), "utf8")).version; + +function runCli(args, input) { + return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", input }); +} + +function runValidFile() { + const result = runCli([validRecord]); + assert.equal(result.status, 0); + assert.match(result.stdout, /valid/); + assert.match(result.stdout, /1 file\(s\): 1 valid, 0 invalid/); +} + +function runInvalidFile() { + const result = runCli([invalidRecord]); + assert.equal(result.status, 1); + assert.match(result.stdout, /FAILED_JSON_SCHEMA_VALIDATION/); + assert.match(result.stdout, /1 file\(s\): 0 valid, 1 invalid/); +} + +function runMixedFiles() { + const result = runCli([validRecord, invalidRecord]); + assert.equal(result.status, 1); + assert.match(result.stdout, /2 file\(s\): 1 valid, 1 invalid/); +} + +function runJsonOutput() { + const result = runCli(["--json", validRecord, invalidRecord]); + assert.equal(result.status, 1); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.length, 2); + assert.equal(parsed[0].valid, true); + assert.equal(parsed[0].error, null); + assert.ok(Array.isArray(parsed[0].warnings)); + assert.equal(parsed[1].valid, false); + assert.equal(parsed[1].error, "FAILED_JSON_SCHEMA_VALIDATION"); +} + +function runStdin() { + const result = runCli([], readFileSync(validRecord, "utf8")); + assert.equal(result.status, 0); + assert.match(result.stdout, / — valid/); +} + +function runStdinDash() { + const result = runCli(["-"], readFileSync(invalidRecord, "utf8")); + assert.equal(result.status, 1); + assert.match(result.stdout, //); +} + +function runQuiet() { + const result = runCli(["--quiet", validRecord]); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /✓/); + assert.match(result.stdout, /1 valid, 0 invalid/); +} + +function runVersion() { + const result = runCli(["--version"]); + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), packageVersion); +} + +function runHelp() { + const result = runCli(["--help"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage: cve-validate/); +} + +function runUnknownOption() { + const result = runCli(["--bogus"]); + assert.equal(result.status, 2); + assert.match(result.stderr, /Unknown option: --bogus/); +} + +function runBadType() { + const result = runCli(["--type", "nope", validRecord]); + assert.equal(result.status, 2); + assert.match(result.stderr, /--type must be one of/); +} + +function runPublishedContainerType() { + const result = runCli(["--type", "published-cna", validPublishedContainer]); + assert.equal(result.status, 0); + assert.match(result.stdout, /1 valid, 0 invalid/); +} + +function runMissingFile() { + const result = runCli(["does-not-exist.json"]); + assert.equal(result.status, 1); + assert.match(result.stdout, /INPUT_ERROR/); +} + +function runMalformedJsonStdin() { + const result = runCli(["-"], "{ not valid json"); + assert.equal(result.status, 1); + assert.match(result.stdout, /INPUT_ERROR/); +} + +runValidFile(); +runInvalidFile(); +runMixedFiles(); +runJsonOutput(); +runStdin(); +runStdinDash(); +runQuiet(); +runVersion(); +runHelp(); +runUnknownOption(); +runBadType(); +runPublishedContainerType(); +runMissingFile(); +runMalformedJsonStdin(); + +console.log("CLI tests passed."); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..3cede8f --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,286 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import process from 'node:process'; + +import { Validate } from './core/validate.js'; +import { type Diagnostics } from './diagnostic/diagnostics.js'; +import { type JsonObject } from './utils/json.js'; + +/** Exit codes returned by the CLI. */ +const EXIT = { + /** Every input was valid. */ + SUCCESS: 0, + /** At least one input was invalid or could not be read. */ + VALIDATION_FAILED: 1, + /** The command was invoked incorrectly (bad option, no input). */ + USAGE: 2, +} as const; + +/** Supported input types, mapped to the Validate method that checks them. */ +const VALIDATORS = { + 'record': (validator: Validate, data: JsonObject) => validator.validateCveRecord(data), + 'published-cna': (validator: Validate, data: JsonObject) => validator.validatePublishedCveCnaContainer(data), + 'rejected-cna': (validator: Validate, data: JsonObject) => validator.validateRejectedCveCnaContainer(data), +} as const; + +type InputType = keyof typeof VALIDATORS; + +const INPUT_TYPES = Object.keys(VALIDATORS) as InputType[]; + +/** A single input's result, shaped for both human and JSON output. */ +type CliResult = { + readonly file: string; + readonly valid: boolean; + readonly error: string | null; + readonly message: string; + readonly details: Diagnostics['details'] | null; + readonly warnings: Diagnostics['warnings']; +}; + +type CliOptions = { + paths: string[]; + type: InputType; + json: boolean; + quiet: boolean; + help: boolean; + version: boolean; +}; + +/** Raised for invalid command-line usage; produces exit code 2. */ +class CliUsageError extends Error {} + +const USAGE = `Usage: cve-validate [options] + +Validate one or more CVE Records (or CNA Containers) against the CVE JSON +schema and CVE business rules. + +Arguments: + file Path to a JSON file to validate. Use "-" to read from + stdin. When no file is given and stdin is piped, stdin + is read. + +Options: + --type Input type: ${INPUT_TYPES.join(', ')} (default: record). + --json Output results as JSON instead of human-readable text. + -q, --quiet Do not print a line for each valid input. + -v, --version Print the library version and exit. + -h, --help Print this help and exit. + +Exit codes: + 0 all inputs valid + 1 at least one input invalid or unreadable + 2 usage error`; + +/** + * Parses CLI arguments into options. + * + * @param argv - Arguments after the node executable and script path. + * @returns Parsed options. + * @throws CliUsageError on an unknown option or invalid value. + */ +function parseArgs(argv: readonly string[]): CliOptions { + const options: CliOptions = { + paths: [], + type: 'record', + json: false, + quiet: false, + help: false, + version: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--') { + options.paths.push(...argv.slice(index + 1)); + break; + } + + switch (arg) { + case '-h': + case '--help': + options.help = true; + break; + case '-v': + case '--version': + options.version = true; + break; + case '--json': + options.json = true; + break; + case '-q': + case '--quiet': + options.quiet = true; + break; + case '--type': { + const value = argv[index + 1]; + index += 1; + options.type = parseType(value); + break; + } + default: + if (arg.startsWith('--type=')) { + options.type = parseType(arg.slice('--type='.length)); + } else if (arg !== '-' && arg.startsWith('-')) { + throw new CliUsageError(`Unknown option: ${arg}`); + } else { + options.paths.push(arg); + } + } + } + + return options; +} + +function parseType(value: string | undefined): InputType { + if (value !== undefined && (INPUT_TYPES as string[]).includes(value)) { + return value as InputType; + } + + throw new CliUsageError(`--type must be one of: ${INPUT_TYPES.join(', ')}`); +} + +/** Reads all of stdin as a UTF-8 string. */ +async function readStdin(): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +async function readJsonInput(path: string): Promise { + const raw = path === '-' ? await readStdin() : await readFile(path, 'utf8'); + return JSON.parse(raw) as JsonObject; +} + +/** Reads the library version from the package manifest. */ +async function readVersion(): Promise { + const manifestUrl = new URL('../package.json', import.meta.url); + const manifest = JSON.parse(await readFile(manifestUrl, 'utf8')) as { version?: string }; + return manifest.version ?? '0.0.0'; +} + +function displayName(path: string): string { + return path === '-' ? '' : path; +} + +/** Formats a diagnostics `details` payload into concise, one-per-line strings. */ +function formatDetails(details: Diagnostics['details']): string[] { + if (!details) { + return []; + } + + const errors = 'errors' in details ? details.errors : details; + + return errors.map((error) => { + if (typeof error === 'string') { + return error; + } + + if ('msg' in error) { + return `${error.param || '(root)'}: ${error.msg}`; + } + + return `${error.instancePath || '(root)'} ${error.message ?? ''}`.trim(); + }); +} + +function printHumanResults(results: readonly CliResult[], options: CliOptions): void { + for (const result of results) { + if (result.valid) { + if (!options.quiet) { + process.stdout.write(`✓ ${result.file} — valid\n`); + } + continue; + } + + process.stdout.write(`✗ ${result.file} — ${result.error}: ${result.message}\n`); + for (const line of formatDetails(result.details)) { + process.stdout.write(` ${line}\n`); + } + } + + const invalid = results.filter((result) => !result.valid).length; + const valid = results.length - invalid; + process.stdout.write(`\n${results.length} file(s): ${valid} valid, ${invalid} invalid\n`); +} + +/** + * Runs the CLI. + * + * @param argv - Arguments after the node executable and script path. + * @returns The process exit code. + */ +export async function run(argv: readonly string[]): Promise { + let options: CliOptions; + + try { + options = parseArgs(argv); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n\n${USAGE}\n`); + return EXIT.USAGE; + } + + if (options.help) { + process.stdout.write(`${USAGE}\n`); + return EXIT.SUCCESS; + } + + if (options.version) { + process.stdout.write(`${await readVersion()}\n`); + return EXIT.SUCCESS; + } + + if (options.paths.length === 0) { + if (process.stdin.isTTY) { + process.stderr.write(`No input files provided.\n\n${USAGE}\n`); + return EXIT.USAGE; + } + + // Piped input with no explicit path: read the record from stdin. + options.paths.push('-'); + } + + const validate = VALIDATORS[options.type]; + const validator = new Validate(); + const results: CliResult[] = []; + + for (const path of options.paths) { + try { + const data = await readJsonInput(path); + const diagnostics = await validate(validator, data); + results.push({ + file: displayName(path), + valid: diagnostics.valid, + error: diagnostics.error, + message: diagnostics.message, + details: diagnostics.details, + warnings: diagnostics.warnings, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.push({ + file: displayName(path), + valid: false, + error: 'INPUT_ERROR', + message, + details: null, + warnings: [], + }); + } + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + } else { + printHumanResults(results, options); + } + + return results.some((result) => !result.valid) ? EXIT.VALIDATION_FAILED : EXIT.SUCCESS; +} + +process.exit(await run(process.argv.slice(2))); From 47cfa05231742d6610239f20f54d0e45eaa7cadd Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 16:23:22 -0500 Subject: [PATCH 2/3] Address CLI review findings - Surface registered warnings in default human output (previously only in --json): print each warning under its result and include a count in the summary; document it in the README. - ASCII fallback for the status glyphs on non-UTF-8 (legacy Windows) consoles. - Guard the entrypoint with a top-level catch so any unexpected failure (e.g. an unreadable manifest in --version) exits with a code instead of an unhandled rejection. - Tests: lock stdout/stderr separation (empty stderr on success/--json, empty stdout on usage errors), assert warnings are populated (length + messageId) in both JSON and human output, make --version non-vacuous, and cover short flags, --type= form, rejected-cna, the '--' separator, detail lines, and a packaging guard that src/ and dist/ stay in the published files. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 ++-- scripts/test-cli.mjs | 83 +++++++++++++++++++++++++++++++++++++++++--- src/cli.ts | 54 ++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bf08ef6..07c8d73 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,10 @@ Emit machine-readable JSON for scripting: cve-validate --json record.json ``` -The command exits `0` when every input is valid, `1` when any input is invalid -or unreadable, and `2` on a usage error. Run `cve-validate --help` for the full -list of options. +Any registered warnings are printed beneath each result (and included in the +`--json` output). The command exits `0` when every input is valid, `1` when any +input is invalid or unreadable, and `2` on a usage error. Run +`cve-validate --help` for the full list of options. ## Validation Methods diff --git a/scripts/test-cli.mjs b/scripts/test-cli.mjs index 8c55d3a..2822fd4 100644 --- a/scripts/test-cli.mjs +++ b/scripts/test-cli.mjs @@ -11,7 +11,11 @@ const invalidRecord = fromRoot("../scripts/test-data/cve-record-missing-cna-cont const validPublishedContainer = fromRoot( "../scripts/test-data/cna-container/published-cna-container-valid.json", ); -const packageVersion = JSON.parse(readFileSync(fromRoot("../package.json"), "utf8")).version; +const validRejectedContainer = fromRoot( + "../scripts/test-data/cna-container/rejected-cna-container-valid.json", +); +const manifest = JSON.parse(readFileSync(fromRoot("../package.json"), "utf8")); +const packageVersion = manifest.version; function runCli(args, input) { return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", input }); @@ -20,6 +24,7 @@ function runCli(args, input) { function runValidFile() { const result = runCli([validRecord]); assert.equal(result.status, 0); + assert.equal(result.stderr, "", "success must not write to stderr"); assert.match(result.stdout, /valid/); assert.match(result.stdout, /1 file\(s\): 1 valid, 0 invalid/); } @@ -27,7 +32,10 @@ function runValidFile() { function runInvalidFile() { const result = runCli([invalidRecord]); assert.equal(result.status, 1); + assert.equal(result.stderr, ""); assert.match(result.stdout, /FAILED_JSON_SCHEMA_VALIDATION/); + // A per-error detail line must be printed under the failing file. + assert.match(result.stdout, /must have required property 'cna'/); assert.match(result.stdout, /1 file\(s\): 0 valid, 1 invalid/); } @@ -37,14 +45,26 @@ function runMixedFiles() { assert.match(result.stdout, /2 file\(s\): 1 valid, 1 invalid/); } +function runWarningsInHumanOutput() { + // The default registry ships a STUB_WARNING; it must be surfaced (not only in + // --json) and reflected in the summary count. + const result = runCli([validRecord]); + assert.equal(result.status, 0); + assert.match(result.stdout, /STUB_WARNING/); + assert.match(result.stdout, /\d+ warning\(s\)/); +} + function runJsonOutput() { const result = runCli(["--json", validRecord, invalidRecord]); assert.equal(result.status, 1); + assert.equal(result.stderr, "", "--json must not pollute stderr"); const parsed = JSON.parse(result.stdout); assert.equal(parsed.length, 2); assert.equal(parsed[0].valid, true); assert.equal(parsed[0].error, null); - assert.ok(Array.isArray(parsed[0].warnings)); + // Warnings must be present and populated, not merely an array. + assert.ok(parsed[0].warnings.length >= 1); + assert.equal(parsed[0].warnings[0].messageId, "STUB_WARNING"); assert.equal(parsed[1].valid, false); assert.equal(parsed[1].error, "FAILED_JSON_SCHEMA_VALIDATION"); } @@ -52,7 +72,7 @@ function runJsonOutput() { function runStdin() { const result = runCli([], readFileSync(validRecord, "utf8")); assert.equal(result.status, 0); - assert.match(result.stdout, / — valid/); + assert.match(result.stdout, //); } function runStdinDash() { @@ -64,31 +84,51 @@ function runStdinDash() { function runQuiet() { const result = runCli(["--quiet", validRecord]); assert.equal(result.status, 0); - assert.doesNotMatch(result.stdout, /✓/); + assert.doesNotMatch(result.stdout, /valid$/m); assert.match(result.stdout, /1 valid, 0 invalid/); } +function runShortFlags() { + const quiet = runCli(["-q", validRecord]); + assert.equal(quiet.status, 0); + assert.doesNotMatch(quiet.stdout, /— valid/); + + const help = runCli(["-h"]); + assert.equal(help.status, 0); + assert.match(help.stdout, /Usage: cve-validate/); + + const version = runCli(["-v"]); + assert.equal(version.status, 0); + assert.equal(version.stdout.trim(), packageVersion); +} + function runVersion() { const result = runCli(["--version"]); assert.equal(result.status, 0); + assert.equal(result.stderr, ""); assert.equal(result.stdout.trim(), packageVersion); + // Non-vacuous shape check (meaningful once version advances past 0.0.0). + assert.match(result.stdout.trim(), /^\d+\.\d+\.\d+/); } function runHelp() { const result = runCli(["--help"]); assert.equal(result.status, 0); + assert.equal(result.stderr, ""); assert.match(result.stdout, /Usage: cve-validate/); } function runUnknownOption() { const result = runCli(["--bogus"]); assert.equal(result.status, 2); + assert.equal(result.stdout, "", "usage errors must not write to stdout"); assert.match(result.stderr, /Unknown option: --bogus/); } function runBadType() { const result = runCli(["--type", "nope", validRecord]); assert.equal(result.status, 2); + assert.equal(result.stdout, ""); assert.match(result.stderr, /--type must be one of/); } @@ -98,6 +138,26 @@ function runPublishedContainerType() { assert.match(result.stdout, /1 valid, 0 invalid/); } +function runTypeEqualsForm() { + const result = runCli(["--type=published-cna", validPublishedContainer]); + assert.equal(result.status, 0); + assert.match(result.stdout, /1 valid, 0 invalid/); +} + +function runRejectedContainerType() { + const result = runCli(["--type", "rejected-cna", validRejectedContainer]); + assert.equal(result.status, 0); + assert.match(result.stdout, /1 valid, 0 invalid/); +} + +function runEndOfOptions() { + // After '--', a leading-dash argument is treated as a path, not an option. + const result = runCli(["--", "--version"]); + assert.equal(result.status, 1); + assert.match(result.stdout, /INPUT_ERROR/); + assert.match(result.stdout, /--version/); +} + function runMissingFile() { const result = runCli(["does-not-exist.json"]); assert.equal(result.status, 1); @@ -110,19 +170,34 @@ function runMalformedJsonStdin() { assert.match(result.stdout, /INPUT_ERROR/); } +function runPackagingGuard() { + // The published bin resolves its schema/registry JSON assets from src/ at + // runtime, so src/ (and dist/) must stay in the published file set. + assert.ok(Array.isArray(manifest.files)); + assert.ok(manifest.files.includes("src"), '"files" must include src for the bin to resolve assets'); + assert.ok(manifest.files.includes("dist"), '"files" must include dist for the bin'); + assert.equal(manifest.bin["cve-validate"], "dist/cli.js"); +} + runValidFile(); runInvalidFile(); runMixedFiles(); +runWarningsInHumanOutput(); runJsonOutput(); runStdin(); runStdinDash(); runQuiet(); +runShortFlags(); runVersion(); runHelp(); runUnknownOption(); runBadType(); runPublishedContainerType(); +runTypeEqualsForm(); +runRejectedContainerType(); +runEndOfOptions(); runMissingFile(); runMalformedJsonStdin(); +runPackagingGuard(); console.log("CLI tests passed."); diff --git a/src/cli.ts b/src/cli.ts index 3cede8f..f1008f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -188,24 +188,54 @@ function formatDetails(details: Diagnostics['details']): string[] { }); } +/** + * Whether the environment can render the Unicode status glyphs. Falls back to + * ASCII on Windows legacy consoles that do not advertise UTF-8. + */ +function supportsUnicode(): boolean { + if (process.platform !== 'win32') { + return true; + } + + return Boolean(process.env.WT_SESSION) + || Boolean(process.env.CI) + || process.env.TERM_PROGRAM === 'vscode' + || /utf-?8/i.test(process.env.LANG ?? ''); +} + +const MARKS = supportsUnicode() + ? { valid: '✓', invalid: '✗', warning: '⚠', separator: '—' } + : { valid: 'PASS', invalid: 'FAIL', warning: 'WARN', separator: '-' }; + function printHumanResults(results: readonly CliResult[], options: CliOptions): void { + let totalWarnings = 0; + for (const result of results) { + const suppressed = result.valid && options.quiet; + if (result.valid) { if (!options.quiet) { - process.stdout.write(`✓ ${result.file} — valid\n`); + process.stdout.write(`${MARKS.valid} ${result.file} ${MARKS.separator} valid\n`); + } + } else { + process.stdout.write(`${MARKS.invalid} ${result.file} ${MARKS.separator} ${result.error}: ${result.message}\n`); + for (const line of formatDetails(result.details)) { + process.stdout.write(` ${line}\n`); } - continue; } - process.stdout.write(`✗ ${result.file} — ${result.error}: ${result.message}\n`); - for (const line of formatDetails(result.details)) { - process.stdout.write(` ${line}\n`); + for (const warning of result.warnings) { + totalWarnings += 1; + if (!suppressed) { + process.stdout.write(` ${MARKS.warning} ${warning.messageId}: ${warning.notificationMessage}\n`); + } } } const invalid = results.filter((result) => !result.valid).length; const valid = results.length - invalid; - process.stdout.write(`\n${results.length} file(s): ${valid} valid, ${invalid} invalid\n`); + const warningSuffix = totalWarnings > 0 ? `, ${totalWarnings} warning(s)` : ''; + process.stdout.write(`\n${results.length} file(s): ${valid} valid, ${invalid} invalid${warningSuffix}\n`); } /** @@ -283,4 +313,14 @@ export async function run(argv: readonly string[]): Promise { return results.some((result) => !result.valid) ? EXIT.VALIDATION_FAILED : EXIT.SUCCESS; } -process.exit(await run(process.argv.slice(2))); +run(process.argv.slice(2)) + .then((code) => { + process.exit(code); + }) + .catch((error: unknown) => { + // Last-resort guard so any unexpected failure exits with a code instead of + // an unhandled rejection / stack trace. + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`cve-validate: ${message}\n`); + process.exit(EXIT.VALIDATION_FAILED); + }); From 0ef36c67239f3a141d302e65cda48e51f3ec9600 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 18:43:07 -0500 Subject: [PATCH 3/3] Fix piped-output truncation and quiet-mode warning count Round-2 review found a high-severity defect: the entrypoint called process.exit(code) as soon as run() resolved, terminating before async stdout writes to a pipe had drained. Large output (e.g. --json over many files) was truncated at the ~64KB OS pipe buffer, making piped JSON unparseable and silently dropping validation results. - Set process.exitCode instead of calling process.exit(), so the event loop drains stdout before exiting (both the .then and .catch branches). - Add a slow-pipe regression test that produces >64KB of output, consumes it with backpressure, and asserts the full payload parses (proven to fail on the old process.exit path). - Count only warnings that are actually printed toward the summary, so the --quiet count matches visible output. - Make the quiet assertion match the per-file marker directly instead of a fragile /valid$/m that also matches "invalid". Co-Authored-By: Claude Opus 4.8 --- scripts/test-cli.mjs | 49 ++++++++++++++++++++++++++++++++++++++++++-- src/cli.ts | 15 +++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/scripts/test-cli.mjs b/scripts/test-cli.mjs index 2822fd4..41cdd91 100644 --- a/scripts/test-cli.mjs +++ b/scripts/test-cli.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -21,6 +21,30 @@ function runCli(args, input) { return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", input }); } +// Runs the CLI and consumes stdout SLOWLY, pausing on each chunk. This creates +// pipe backpressure so the OS pipe buffer fills, exposing any premature exit +// that would truncate buffered output before it drains. +function runCliSlowPipe(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, ...args], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + child.stdout.pause(); + setTimeout(() => child.stdout.resume(), 5); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }); +} + function runValidFile() { const result = runCli([validRecord]); assert.equal(result.status, 0); @@ -84,7 +108,9 @@ function runStdinDash() { function runQuiet() { const result = runCli(["--quiet", validRecord]); assert.equal(result.status, 0); - assert.doesNotMatch(result.stdout, /valid$/m); + // No per-file "… — valid" line in quiet mode. Match the marker directly so the + // assertion does not accidentally match the "invalid" count in the summary. + assert.doesNotMatch(result.stdout, /— valid/); assert.match(result.stdout, /1 valid, 0 invalid/); } @@ -170,6 +196,24 @@ function runMalformedJsonStdin() { assert.match(result.stdout, /INPUT_ERROR/); } +async function runNoPipeTruncation() { + // Produce output far larger than the ~64KB OS pipe buffer, consumed by a slow + // reader. The whole payload must arrive intact; process.exit() would truncate + // it and make the --json output unparseable. + const count = 500; + const args = ["--json", ...Array.from({ length: count }, () => invalidRecord)]; + const result = await runCliSlowPipe(args); + + assert.equal(result.status, 1); + assert.ok(result.stdout.length > 65536, "test must generate more than one pipe buffer of output"); + + let parsed; + assert.doesNotThrow(() => { + parsed = JSON.parse(result.stdout); + }, "piped --json output must be complete and parseable (no truncation on exit)"); + assert.equal(parsed.length, count, "no results may be dropped over a slow pipe"); +} + function runPackagingGuard() { // The published bin resolves its schema/registry JSON assets from src/ at // runtime, so src/ (and dist/) must stay in the published file set. @@ -198,6 +242,7 @@ runRejectedContainerType(); runEndOfOptions(); runMissingFile(); runMalformedJsonStdin(); +await runNoPipeTruncation(); runPackagingGuard(); console.log("CLI tests passed."); diff --git a/src/cli.ts b/src/cli.ts index f1008f2..b1827ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -224,9 +224,9 @@ function printHumanResults(results: readonly CliResult[], options: CliOptions): } } - for (const warning of result.warnings) { - totalWarnings += 1; - if (!suppressed) { + if (!suppressed) { + for (const warning of result.warnings) { + totalWarnings += 1; process.stdout.write(` ${MARKS.warning} ${warning.messageId}: ${warning.notificationMessage}\n`); } } @@ -313,14 +313,19 @@ export async function run(argv: readonly string[]): Promise { return results.some((result) => !result.valid) ? EXIT.VALIDATION_FAILED : EXIT.SUCCESS; } +// Set process.exitCode rather than calling process.exit(): process.exit() +// terminates before asynchronous stdout writes to a pipe have drained, which +// truncates large output (e.g. --json over many files) at the OS pipe buffer. +// Letting the event loop empty naturally flushes stdout first, then exits with +// this code. run(process.argv.slice(2)) .then((code) => { - process.exit(code); + process.exitCode = code; }) .catch((error: unknown) => { // Last-resort guard so any unexpected failure exits with a code instead of // an unhandled rejection / stack trace. const message = error instanceof Error ? error.message : String(error); process.stderr.write(`cve-validate: ${message}\n`); - process.exit(EXIT.VALIDATION_FAILED); + process.exitCode = EXIT.VALIDATION_FAILED; });