From 9a3a51ba681bbb69b633356d30bba2e37c3e31a7 Mon Sep 17 00:00:00 2001 From: Rohith Pariki Date: Wed, 5 Aug 2026 06:12:55 +0530 Subject: [PATCH] fix(cli): exit 3 when incomplete-policy=error fails (closes #898) --- src/cli/args.ts | 12 ++++++++++++ src/index.ts | 10 +++++++--- src/scan/multi-folder-scan.ts | 6 +++++- src/types.ts | 1 + tests/e2e/commands-and-exit-codes.test.ts | 13 ++++++++----- tests/e2e/detectors.test.ts | 4 ++-- tests/e2e/global-setup.mjs | 2 +- tests/e2e/harness.ts | 2 +- tests/multi-folder-scan.test.ts | 4 ++-- 9 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/cli/args.ts b/src/cli/args.ts index 48f5999c..fd287738 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -381,6 +381,18 @@ export function parseArgs(argv: string[]): { options.checkMaintenance = true; continue; } + if (arg === "--incomplete-policy") { + const val = argv[++i]; + if (val !== "warn" && val !== "error") throw new Error("--incomplete-policy requires 'warn' or 'error'"); + options.incompletePolicy = val; + continue; + } + if (arg.startsWith("--incomplete-policy=")) { + const val = arg.slice("--incomplete-policy=".length); + if (val !== "warn" && val !== "error") throw new Error("--incomplete-policy requires 'warn' or 'error'"); + options.incompletePolicy = val; + continue; + } if (arg.startsWith("-")) { throw new Error(`Unknown option: ${arg}`); } diff --git a/src/index.ts b/src/index.ts index c5a9bc53..75064089 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,8 @@ import { getCliVersion } from "./utils/version-info.js"; import { getNetworkErrorHint, offlineDbSyncHint, createCertAwareFetch } from "./utils/network.js"; import { formatAdvisoryDbFreshness } from "./utils/time.js"; import { pluralize, nearestCommand } from "./utils/string.js"; -import type { FetchLike, ParsedOptions } from "./types.js"; -import { EXIT_ERROR } from "./types.js"; +import type { FetchLike, ParsedOptions, ExitCode } from "./types.js"; +import { EXIT_ERROR, EXIT_FINDINGS, EXIT_OK } from "./types.js"; import { formatAdvisorySourceLine, formatHintLines, @@ -781,7 +781,11 @@ if (parsedArgs) { reachesFailOn(maintenanceFindings, options.failOn); // In fix mode, remaining transitive findings cannot be auto-fixed. // Exiting non-zero would prevent the Action PR step from running. - const exitCode = shouldFail && !options.fix ? 1 : 0; + let exitCode: ExitCode = shouldFail && !options.fix ? EXIT_FINDINGS : EXIT_OK; + + if (options.incompletePolicy === "error" && !scanState.completeness.complete) { + exitCode = EXIT_ERROR; + } // Emit scan.finished event and close audit-log auditLogHandle.emit({ diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index cedc00cd..2c916cf8 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -358,7 +358,11 @@ export async function handleMultiFolderScan(params: { allSorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]) || reachesFailOn(allOverrideFindings, params.options.failOn) || reachesFailOn(allMaintenanceFindings, params.options.failOn); - const exitCode = shouldFail ? EXIT_FINDINGS : EXIT_OK; + let exitCode: ExitCode = shouldFail ? EXIT_FINDINGS : EXIT_OK; + + if (params.options.incompletePolicy === "error" && !results.every(r => r.completeness.complete)) { + exitCode = EXIT_ERROR; + } auditLog.emit({ ts: new Date(scanFinishedAt).toISOString(), diff --git a/src/types.ts b/src/types.ts index a60e258e..5ab0996a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -270,6 +270,7 @@ export type ParsedOptions = { rule?: string; /** --allow-private-osv-url - allow --osv-url to resolve to private/reserved IPs. */ allowPrivateOsvUrl?: boolean; + incompletePolicy?: string; }; /** diff --git a/tests/e2e/commands-and-exit-codes.test.ts b/tests/e2e/commands-and-exit-codes.test.ts index c3a7090f..d4776b96 100644 --- a/tests/e2e/commands-and-exit-codes.test.ts +++ b/tests/e2e/commands-and-exit-codes.test.ts @@ -228,7 +228,7 @@ describe("commands + meta", () => { it("config set/show/unset round-trips under a temp HOME", () => { // getConfigDir() = path.join(os.homedir(), ".cve-lite-cli"); on Linux - // os.homedir() honors $HOME, so a temp HOME isolates this from the real + // os.homedir() honors $HOME (and USERPROFILE on Windows), so a temp HOME isolates this from the real // user config. Verified that nothing lands under the real ~/.cve-lite-cli. const home = scratch(); const certDir = mkProject({ @@ -236,18 +236,19 @@ describe("commands + meta", () => { }); const certPath = join(certDir, "cert.pem"); try { - const set = runCli(["config", "set", "ca-cert", certPath], { env: { HOME: home } }); + const env = { HOME: home, USERPROFILE: home }; + const set = runCli(["config", "set", "ca-cert", certPath], { env }); expect(set.status).toBe(0); expect(existsSync(join(home, ".cve-lite-cli", "config.json"))).toBe(true); - const show = runCli(["config", "show"], { env: { HOME: home } }); + const show = runCli(["config", "show"], { env }); expect(show.status).toBe(0); expect(show.stdout).toContain(certPath); - const unset = runCli(["config", "unset", "ca-cert"], { env: { HOME: home } }); + const unset = runCli(["config", "unset", "ca-cert"], { env }); expect(unset.status).toBe(0); - const showAfter = runCli(["config", "show"], { env: { HOME: home } }); + const showAfter = runCli(["config", "show"], { env }); expect(showAfter.status).toBe(0); expect(showAfter.stdout).toMatch(/No configuration set/); } finally { @@ -268,6 +269,8 @@ describe("exit codes", () => { } }); + + it("1: orphan override (OA001) above the fail-on threshold", () => { // package.json declares an override for `gone`, but the lockfile resolves a // different package, so OA001 (override target not in resolved tree) fires diff --git a/tests/e2e/detectors.test.ts b/tests/e2e/detectors.test.ts index 60ed64f5..90b17373 100644 --- a/tests/e2e/detectors.test.ts +++ b/tests/e2e/detectors.test.ts @@ -299,7 +299,7 @@ describe("e2e detectors OA001-OA008 fire through the real binary", () => { const pd001 = findings.find((f: any) => f.ruleId === "PD001"); expect(pd001.package.name).toBe("js-yaml"); expect(pd001.severity).toBe("high"); - expect(pd001.details).toContain("src/index.ts"); + expect(pd001.details.replace(/\\/g, "/")).toContain("src/index.ts"); }); it("PD002 transitive-only phantom: import backed only by transitive dep fires PD002", () => { @@ -312,6 +312,6 @@ describe("e2e detectors OA001-OA008 fire through the real binary", () => { const pd002 = findings.find((f: any) => f.ruleId === "PD002"); expect(pd002.package.name).toBe("semver"); expect(pd002.severity).toBe("medium"); - expect(pd002.details).toContain("src/index.ts"); + expect(pd002.details.replace(/\\/g, "/")).toContain("src/index.ts"); }); }); diff --git a/tests/e2e/global-setup.mjs b/tests/e2e/global-setup.mjs index 034c7730..155faa0c 100644 --- a/tests/e2e/global-setup.mjs +++ b/tests/e2e/global-setup.mjs @@ -40,7 +40,7 @@ export default function build() { existsSync(distEntry) && statSync(distEntry).mtimeMs >= newestMtime(join(repoRoot, "src")); if (!fresh) { - execFileSync("npm", ["run", "build"], { cwd: repoRoot, stdio: "inherit" }); + execFileSync("npm", ["run", "build"], { cwd: repoRoot, stdio: "inherit", shell: true }); } const seededDb = join(repoRoot, "tests", "fixtures", "advisories", "seeded.db"); diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index d758ad10..3520042c 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -105,5 +105,5 @@ export function installedManifest(dir: string, name: string, manifest: Record { }); expect(writeBaselineMock).toHaveBeenCalledTimes(2); - expect(writeBaselineMock).toHaveBeenCalledWith("/project/a", expect.any(Array)); - expect(writeBaselineMock).toHaveBeenCalledWith("/project/b", expect.any(Array)); + expect(writeBaselineMock).toHaveBeenCalledWith(path.join("/project", "a"), expect.any(Array)); + expect(writeBaselineMock).toHaveBeenCalledWith(path.join("/project", "b"), expect.any(Array)); expect(exitCode).toBe(EXIT_OK); const output = consoleLogMock.mock.calls.flat().join("\n"); expect(output).toMatch(/a\/: Baseline saved/i);