From c45e8a38f6d1aa4c99feb6686891c4a02d5c0f9a Mon Sep 17 00:00:00 2001 From: Michael Lip <51033404+theluckystrike@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:27:08 +0700 Subject: [PATCH 1/6] scan explain what tripped the fail-on gate on failure --- src/utils/severity.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/utils/severity.ts b/src/utils/severity.ts index 03abd34e..0bfe9350 100644 --- a/src/utils/severity.ts +++ b/src/utils/severity.ts @@ -14,6 +14,26 @@ export function reachesFailOn( return findings.some((f) => severityOrder[f.severity] >= severityOrder[failLevel]); } +// One line that names what tripped the --fail-on gate, so a red CI job says why +// it is red. The gate ORs three finding classes (CVE, override hygiene, +// maintenance risk) and none of the rendered output mentioned the gate before, +// which read as a broken exit code. Returns null when the gate would not fire, +// so callers can log unconditionally without changing clean-run output. +export function failingGateSummary( + classes: ReadonlyArray<{ label: string; findings: ReadonlyArray<{ severity: SeverityLabel }> }>, + failOn: string, +): string | null { + if (!failOn) return null; + const failLevel = normalizeSeverity(failOn); + const parts: string[] = []; + for (const { label, findings } of classes) { + const count = findings.filter((f) => severityOrder[f.severity] >= severityOrder[failLevel]).length; + if (count > 0) parts.push(`${count} ${label} ${count === 1 ? "finding" : "findings"}`); + } + if (parts.length === 0) return null; + return `Failing: ${parts.join(", ")} at or above ${failLevel} (--fail-on ${failLevel}).`; +} + export function formatSeverityLabel(severity: string): string { const lower = severity.toLowerCase(); if (lower === "critical") return chalk.redBright(severity); From b83eb9d21dcb4b54da1bf15804fe12b0d56abdd4 Mon Sep 17 00:00:00 2001 From: Michael Lip <51033404+theluckystrike@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:27:12 +0700 Subject: [PATCH 2/6] scan explain what tripped the fail-on gate on failure --- src/scan/single-scan.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index e44be39e..3774cf95 100644 --- a/src/scan/single-scan.ts +++ b/src/scan/single-scan.ts @@ -20,7 +20,7 @@ import { printCacheSummary, sortFindingsForOutput, } from "../output/formatters.js"; -import { countBySeverity, reachesFailOn } from "../utils/severity.js"; +import { countBySeverity, failingGateSummary, reachesFailOn } from "../utils/severity.js"; import { buildReportData, writeHtmlReport } from "../output/html-reporter.js"; import { writeOutputs } from "../output/write-outputs.js"; import { selectFindingsForTable } from "../output/finding-display.js"; @@ -658,6 +658,14 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr reachesFailOn(scanState.sorted, options.failOn) || reachesFailOn(overrideFindings, options.failOn) || reachesFailOn(maintenanceFindings, options.failOn); + if (!options.json && !options.fix) { + const gateLine = failingGateSummary([ + { label: "vulnerability", findings: scanState.sorted }, + { label: "override hygiene", findings: overrideFindings }, + { label: "maintenance risk", findings: maintenanceFindings }, + ], options.failOn); + if (gateLine) console.log(chalk.red(gateLine)); + } // In fix mode, remaining transitive findings cannot be auto-fixed. // Exiting non-zero would prevent the Action PR step from running. const incompleteFailure = shouldFailForIncompleteScan( From 71f51408568a5548911e8790354e03871d477a2f Mon Sep 17 00:00:00 2001 From: Michael Lip <51033404+theluckystrike@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:27:15 +0700 Subject: [PATCH 3/6] scan explain what tripped the fail-on gate on failure --- src/scan/multi-folder-scan.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index e455e088..93b89bb5 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -27,7 +27,7 @@ import type { MaintenanceFinding } from "../maintenance/types.js"; import { detectLicenseIssues } from "../licenses/license-check.js"; import { renderLicenseFindings } from "../output/license-terminal.js"; import type { LicenseFinding } from "../licenses/types.js"; -import { reachesFailOn } from "../utils/severity.js"; +import { failingGateSummary, reachesFailOn } from "../utils/severity.js"; import { readBaseline, writeBaseline, filterNewFindings, ratchetOutcome } from "../utils/baseline.js"; import { pluralize } from "../utils/string.js"; import { @@ -463,6 +463,14 @@ export async function handleMultiFolderScan(params: { : shouldFail ? EXIT_FINDINGS : EXIT_OK; + if (!params.options.json) { + const gateLine = failingGateSummary([ + { label: "vulnerability", findings: allSorted }, + { label: "override hygiene", findings: allOverrideFindings }, + { label: "maintenance risk", findings: allMaintenanceFindings }, + ], params.options.failOn); + if (gateLine) console.log(chalk.red(gateLine)); + } auditLog.emit({ ts: new Date(scanFinishedAt).toISOString(), From 53f34f24666a719d2f01edef92d860d13b586be2 Mon Sep 17 00:00:00 2001 From: Michael Lip <51033404+theluckystrike@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:27:17 +0700 Subject: [PATCH 4/6] docs scope only-used to vulnerability findings --- website/docs/cli-reference.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index d98a47d5..ed3be423 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -114,14 +114,14 @@ See [Corporate SSL Proxy](./corporate-proxy.md) for the full setup workflow. | Flag | Default | Description | Example | |---|---|---|---| -| `--fail-on` | `critical` | Exit with code `1` if any finding meets or exceeds this severity (`critical`, `high`, `medium`, `low`); exit `0` otherwise | `cve-lite . --fail-on high` | +| `--fail-on` | `critical` | Exit with code `1` if any finding meets or exceeds this severity (`critical`, `high`, `medium`, `low`); exit `0` otherwise. When the gate trips, the CLI prints one line naming the finding class and the threshold, for example `Failing: 1 override hygiene finding at or above low (--fail-on low).` | `cve-lite . --fail-on high` | | `--incomplete-policy` | `warn` | How to handle incomplete scan data: `warn` (default) prints diagnostics but exits based on findings; `error` exits with code `3` when detection data is incomplete | `cve-lite . --incomplete-policy error` | | `--ratchet` | off | Save current CVE findings as a baseline, or if a baseline exists, only fail on findings above it. In multi-folder mode each subfolder gets its own `.cve-lite/baseline.json` | `cve-lite . --ratchet` | | `--fix` | off | Auto-apply direct-dependency fix commands (direct deps only, v1); cannot be used with `--json`, `--sarif`, or `--sbom`/`--cdx` | `cve-lite . --fix` | | `--check-overrides` | off | Audit `overrides` and `resolutions` entries as part of the scan (OA001-OA008); results appear in the scan output | `cve-lite . --check-overrides` | | `--check-maintenance` | off | Run maintenance risk checks alongside the CVE scan (DM001); surfaces dependency drag and checks for deprecated packages | `cve-lite . --check-maintenance` | | `--usage` | off | Scan source files to detect which packages are actually imported | `cve-lite . --usage` | -| `--only-used` | off | Show only findings for packages that are imported in source code (implies `--usage`) | `cve-lite . --only-used` | +| `--only-used` | off | Show only findings for packages that are imported in source code (implies `--usage`). This scopes vulnerability findings only. Override hygiene and maintenance risk findings still count toward `--fail-on` unfiltered, because neither is a reachability question. | `cve-lite . --only-used` | **Note:** `--usage-hints` is a deprecated alias for `--usage`. From a9c7b614738c2e7b5de0d1eca9c718b318dc4a27 Mon Sep 17 00:00:00 2001 From: Michael Lip <51033404+theluckystrike@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:27:20 +0700 Subject: [PATCH 5/6] scan explain what tripped the fail-on gate on failure --- tests/utils/failon-gate-summary.test.ts | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/utils/failon-gate-summary.test.ts diff --git a/tests/utils/failon-gate-summary.test.ts b/tests/utils/failon-gate-summary.test.ts new file mode 100644 index 00000000..ad87e930 --- /dev/null +++ b/tests/utils/failon-gate-summary.test.ts @@ -0,0 +1,55 @@ +import { failingGateSummary } from "../../src/utils/severity.js"; + +describe("failingGateSummary", () => { + it("names the class and threshold for the maintainer reproduction in issue #1000", () => { + const line = failingGateSummary( + [{ label: "override hygiene", findings: [{ severity: "low" }] }], + "low", + ); + expect(line).toBe( + "Failing: 1 override hygiene finding at or above low (--fail-on low).", + ); + }); + + it("counts only findings that meet or exceed the threshold", () => { + const line = failingGateSummary( + [ + { label: "vulnerability", findings: [{ severity: "critical" }, { severity: "low" }] }, + { label: "override hygiene", findings: [{ severity: "low" }] }, + ], + "high", + ); + expect(line).toBe( + "Failing: 1 vulnerability finding at or above high (--fail-on high).", + ); + }); + + it("joins multiple tripped classes with correct pluralization", () => { + const line = failingGateSummary( + [ + { label: "vulnerability", findings: [{ severity: "high" }, { severity: "high" }] }, + { label: "maintenance risk", findings: [{ severity: "high" }] }, + ], + "medium", + ); + expect(line).toBe( + "Failing: 2 vulnerability findings, 1 maintenance risk finding at or above medium (--fail-on medium).", + ); + }); + + it("returns null on a clean run so output is unchanged", () => { + expect( + failingGateSummary([{ label: "vulnerability", findings: [{ severity: "low" }] }], "high"), + ).toBeNull(); + }); + + it("returns null when --fail-on is not set", () => { + expect( + failingGateSummary([{ label: "vulnerability", findings: [{ severity: "critical" }] }], ""), + ).toBeNull(); + }); + + it("returns null for an empty class list", () => { + expect(failingGateSummary([], "low")).toBeNull(); + }); +}); From 90ffa3d3acd60772d57abf82f9e7c424f3aeb341 Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:05 +0700 Subject: [PATCH 6/6] scan derive the fail-on exit from the gate line and pin it with tests shouldFail is now gateLine !== null on both the single and multi-folder paths, so the exit code and the printed reason come from one computation and the third copy of the severity comparison is gone. The parenthetical echoes the raw --fail-on value. normalizeSeverity falls back to critical for an unknown value, so echoing the normalized level told the user they passed a value they never typed. New tests cover the line on a run that trips the gate and its absence under --json, for both paths, plus the raw echo in the unit tests. --- src/scan/multi-folder-scan.ts | 30 +++++++---------- src/scan/single-scan.ts | 27 ++++++--------- src/utils/severity.ts | 7 ++-- tests/cli-integration.test.ts | 45 +++++++++++++++++++++++++ tests/multi-folder-scan.test.ts | 35 +++++++++++++++++++ tests/utils/failon-gate-summary.test.ts | 12 +++++++ 6 files changed, 120 insertions(+), 36 deletions(-) diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index c7926128..9917b059 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -14,7 +14,7 @@ import { normalizeSeverity } from "../osv/severity.js"; import { selectFindingsForTable } from "../output/finding-display.js"; import { buildSuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import { readDirectDependencyNames, hasOverrideEntries } from "../utils/package-json.js"; -import { DEFAULT_BATCH_SIZE, DEFAULT_SEARCH_DEPTH, severityOrder, OVERRIDES_COMMAND } from "../constants.js"; +import { DEFAULT_BATCH_SIZE, DEFAULT_SEARCH_DEPTH, OVERRIDES_COMMAND } from "../constants.js"; import { printMultiFolderResults } from "../output/multi-folder-printer.js"; import { printOverrideHint } from "../output/printers.js"; import { writeMultiFolderHtmlReport } from "../output/multi-folder-html-reporter.js"; @@ -28,7 +28,7 @@ import type { MaintenanceFinding } from "../maintenance/types.js"; import { detectLicenseIssues } from "../licenses/license-check.js"; import { renderLicenseFindings } from "../output/license-terminal.js"; import type { LicenseFinding } from "../licenses/types.js"; -import { failingGateSummary, reachesFailOn } from "../utils/severity.js"; +import { failingGateSummary } from "../utils/severity.js"; import { readBaseline, writeBaseline, filterNewFindings, ratchetOutcome } from "../utils/baseline.js"; import { pluralize } from "../utils/string.js"; import { @@ -433,17 +433,18 @@ export async function handleMultiFolderScan(params: { console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`); } - const failLevel = normalizeSeverity(params.options.failOn); const allSorted = results.flatMap(r => r.sorted); const allOverrideFindings = results.flatMap(r => r.overrideFindings); const allMaintenanceFindings = results.flatMap(r => r.maintenanceFindings); - // Mirror single-folder exit policy in src/index.ts: CVE + override + maintenance - // all count toward --fail-on. Without overrides here, multi-folder CI using - // --check-overrides --fail-on high would exit 0 despite high OA findings. - const shouldFail = - allSorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]) || - reachesFailOn(allOverrideFindings, params.options.failOn) || - reachesFailOn(allMaintenanceFindings, params.options.failOn); + // Mirror single-folder exit policy in src/scan/single-scan.ts: CVE + override + + // maintenance all count toward --fail-on. Without overrides here, multi-folder CI + // using --check-overrides --fail-on high would exit 0 despite high OA findings. + const gateLine = failingGateSummary([ + { label: "vulnerability", findings: allSorted }, + { label: "override hygiene", findings: allOverrideFindings }, + { label: "maintenance risk", findings: allMaintenanceFindings }, + ], params.options.failOn); + const shouldFail = gateLine !== null; const incompleteFailure = shouldFailForIncompleteScan( aggregatedCompleteness, params.options.incompletePolicy, @@ -453,14 +454,7 @@ export async function handleMultiFolderScan(params: { : shouldFail ? EXIT_FINDINGS : EXIT_OK; - if (!params.options.json) { - const gateLine = failingGateSummary([ - { label: "vulnerability", findings: allSorted }, - { label: "override hygiene", findings: allOverrideFindings }, - { label: "maintenance risk", findings: allMaintenanceFindings }, - ], params.options.failOn); - if (gateLine) console.log(chalk.red(gateLine)); - } + if (gateLine && !params.options.json) console.log(chalk.red(gateLine)); auditLog.emit({ ts: new Date(scanFinishedAt).toISOString(), diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index 2da90905..a916d49e 100644 --- a/src/scan/single-scan.ts +++ b/src/scan/single-scan.ts @@ -20,7 +20,7 @@ import { printCacheSummary, sortFindingsForOutput, } from "../output/formatters.js"; -import { countBySeverity, failingGateSummary, reachesFailOn } from "../utils/severity.js"; +import { countBySeverity, failingGateSummary } from "../utils/severity.js"; import { buildReportData, writeHtmlReport } from "../output/html-reporter.js"; import { buildScanJson } from "../output/scan-json.js"; import { writeOutputs } from "../output/write-outputs.js"; @@ -670,24 +670,19 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr } } - // Override hygiene findings count toward --fail-on too, using the shared - // reachesFailOn helper from src/utils/severity.ts, the same logic used by - // the standalone `overrides` command. Without this, a CI run + // Override hygiene findings count toward --fail-on too. Without this, a CI run // of `cve-lite . --check-overrides --fail-on high` would exit 0 despite high-severity // override findings, giving a false sense of protection. overrideFindings is empty // unless --check-overrides (and non-ratchet), so this is a no-op otherwise. - const shouldFail = - reachesFailOn(scanState.sorted, options.failOn) || - reachesFailOn(overrideFindings, options.failOn) || - reachesFailOn(maintenanceFindings, options.failOn); - if (!options.json && !options.fix) { - const gateLine = failingGateSummary([ - { label: "vulnerability", findings: scanState.sorted }, - { label: "override hygiene", findings: overrideFindings }, - { label: "maintenance risk", findings: maintenanceFindings }, - ], options.failOn); - if (gateLine) console.log(chalk.red(gateLine)); - } + // The gate and the line that explains it come from one call, so the exit code + // and the printed reason cannot drift apart. + const gateLine = failingGateSummary([ + { label: "vulnerability", findings: scanState.sorted }, + { label: "override hygiene", findings: overrideFindings }, + { label: "maintenance risk", findings: maintenanceFindings }, + ], options.failOn); + const shouldFail = gateLine !== null; + if (gateLine && !options.json && !options.fix) console.log(chalk.red(gateLine)); // In fix mode, remaining transitive findings cannot be auto-fixed. // Exiting non-zero would prevent the Action PR step from running. const incompleteFailure = shouldFailForIncompleteScan( diff --git a/src/utils/severity.ts b/src/utils/severity.ts index 0bfe9350..64c339e8 100644 --- a/src/utils/severity.ts +++ b/src/utils/severity.ts @@ -18,7 +18,10 @@ export function reachesFailOn( // it is red. The gate ORs three finding classes (CVE, override hygiene, // maintenance risk) and none of the rendered output mentioned the gate before, // which read as a broken exit code. Returns null when the gate would not fire, -// so callers can log unconditionally without changing clean-run output. +// so callers derive the exit decision from it (gateLine !== null) and the two +// cannot disagree. The parenthetical echoes the raw flag value: --fail-on is not +// validated and normalizeSeverity falls back to critical, so echoing the +// normalized level would show the user a value they never typed. export function failingGateSummary( classes: ReadonlyArray<{ label: string; findings: ReadonlyArray<{ severity: SeverityLabel }> }>, failOn: string, @@ -31,7 +34,7 @@ export function failingGateSummary( if (count > 0) parts.push(`${count} ${label} ${count === 1 ? "finding" : "findings"}`); } if (parts.length === 0) return null; - return `Failing: ${parts.join(", ")} at or above ${failLevel} (--fail-on ${failLevel}).`; + return `Failing: ${parts.join(", ")} at or above ${failLevel} (--fail-on ${failOn}).`; } export function formatSeverityLabel(severity: string): string { diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index b758afe1..58084d4f 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -609,6 +609,51 @@ describe("CLI integration", () => { ); }); + it("prints the fail-on gate line when a terminal run trips the gate", async () => { + const finding = createFinding(); + parseArgsMock.mockReturnValue({ + command: "scan", + options: { + failOn: "high", + batchSize: "100", + searchDepth: "4", + minSeverity: "medium", + }, + projectArg: ".", + }); + loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] })); + scanPackagesMock.mockResolvedValue(createScanResult([finding])); + + const result = await runIndexModule(); + + expect(result.exitCode).toBe(1); + expect(result.stdout.map(line => stripAnsi(line))).toContain( + "Failing: 1 vulnerability finding at or above high (--fail-on high).", + ); + }); + + it("does not print the fail-on gate line under --json", async () => { + const finding = createFinding(); + parseArgsMock.mockReturnValue({ + command: "scan", + options: { + json: true, + failOn: "high", + batchSize: "100", + searchDepth: "4", + minSeverity: "medium", + }, + projectArg: ".", + }); + loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] })); + scanPackagesMock.mockResolvedValue(createScanResult([finding])); + + const result = await runIndexModule(); + + expect(result.exitCode).toBe(1); + expect(result.stdout.some(line => stripAnsi(line).includes("Failing:"))).toBe(false); + }); + it("warns and exits cleanly when no scannable packages are found", async () => { loadPackagesMock.mockReturnValue(createScanInput({ packages: [] })); diff --git a/tests/multi-folder-scan.test.ts b/tests/multi-folder-scan.test.ts index 23b6210d..b803f12c 100644 --- a/tests/multi-folder-scan.test.ts +++ b/tests/multi-folder-scan.test.ts @@ -760,6 +760,41 @@ describe("handleMultiFolderScan - maintenance risk fail-on", () => { expect(exitCode).toBe(EXIT_FINDINGS); }); + it("prints the fail-on gate line when the multi-folder gate trips", async () => { + detectDM001Mock.mockResolvedValue([ + { ruleId: "DM001", severity: "high", package: { name: "gray-matter", version: "4.0.3" }, drag: [], message: "x" }, + ]); + + const { EXIT_FINDINGS } = await import("../src/types.js"); + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, checkMaintenance: true, failOn: "high" }, + }); + + expect(exitCode).toBe(EXIT_FINDINGS); + const logged = consoleLogMock.mock.calls.map(call => String(call[0] ?? "")); + // normalizeSeverity is mocked to "medium" in this file; the flag value is echoed raw. + expect(logged.some(line => line.includes("Failing: 1 maintenance risk finding at or above medium (--fail-on high)."))).toBe(true); + }); + + it("does not print the fail-on gate line in multi-folder JSON mode", async () => { + detectDM001Mock.mockResolvedValue([ + { ruleId: "DM001", severity: "high", package: { name: "gray-matter", version: "4.0.3" }, drag: [], message: "x" }, + ]); + + const { EXIT_FINDINGS } = await import("../src/types.js"); + const exitCode = await handleMultiFolderScan({ + projectRoot: "/project", + batchSize: 100, + options: { ...baseOptions, checkMaintenance: true, failOn: "high", json: true }, + }); + + expect(exitCode).toBe(EXIT_FINDINGS); + const logged = consoleLogMock.mock.calls.map(call => String(call[0] ?? "")); + expect(logged.some(line => line.includes("Failing:"))).toBe(false); + }); + it("does not fail when maintenance findings exist but --check-maintenance was not requested", async () => { const { EXIT_OK } = await import("../src/types.js"); const exitCode = await handleMultiFolderScan({ diff --git a/tests/utils/failon-gate-summary.test.ts b/tests/utils/failon-gate-summary.test.ts index ad87e930..d7d3a8bd 100644 --- a/tests/utils/failon-gate-summary.test.ts +++ b/tests/utils/failon-gate-summary.test.ts @@ -52,4 +52,16 @@ describe("failingGateSummary", () => { it("returns null for an empty class list", () => { expect(failingGateSummary([], "low")).toBeNull(); }); + + it("echoes the raw --fail-on value instead of the normalized level", () => { + // normalizeSeverity falls back to critical for an unknown value, so the + // parenthetical must show what the user typed, not a value they did not. + const line = failingGateSummary( + [{ label: "vulnerability", findings: [{ severity: "critical" }] }], + "hgih", + ); + expect(line).toBe( + "Failing: 1 vulnerability finding at or above critical (--fail-on hgih).", + ); + }); });