Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/scan/multi-folder-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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 } from "../utils/severity.js";
import { GATE_CLASS_LABELS, failingGateSummary } from "../utils/severity.js";
import { readBaseline, writeBaseline, filterNewFindings, ratchetOutcome } from "../utils/baseline.js";
import { pluralize } from "../utils/string.js";
import {
Expand Down Expand Up @@ -453,9 +453,9 @@ export async function handleMultiFolderScan(params: {
// 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 },
{ label: GATE_CLASS_LABELS.vulnerability, findings: allSorted },
{ label: GATE_CLASS_LABELS.overrideHygiene, findings: allOverrideFindings },
{ label: GATE_CLASS_LABELS.maintenanceRisk, findings: allMaintenanceFindings },
], params.options.failOn);
const shouldFail = gateLine !== null;
const incompleteFailure = shouldFailForIncompleteScan(
Expand Down
8 changes: 4 additions & 4 deletions src/scan/single-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
printCacheSummary,
sortFindingsForOutput,
} from "../output/formatters.js";
import { countBySeverity, failingGateSummary } from "../utils/severity.js";
import { GATE_CLASS_LABELS, 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";
Expand Down Expand Up @@ -698,9 +698,9 @@ export async function handleSingleFolderScan(params: SingleFolderScanParams): Pr
// 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 },
{ label: GATE_CLASS_LABELS.vulnerability, findings: scanState.sorted },
{ label: GATE_CLASS_LABELS.overrideHygiene, findings: overrideFindings },
{ label: GATE_CLASS_LABELS.maintenanceRisk, findings: maintenanceFindings },
], options.failOn);
const shouldFail = gateLine !== null;
if (gateLine && !options.json && !options.fix) console.log(chalk.red(gateLine));
Expand Down
9 changes: 9 additions & 0 deletions src/utils/severity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export function reachesFailOn<T extends { severity: SeverityLabel }>(
// 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.
// The three finding classes the --fail-on gate ORs, as failingGateSummary
// names them. Both scan paths build their gate input from this one list, so
// the wording for a class cannot differ between a single- and multi-folder scan.
export const GATE_CLASS_LABELS = {
vulnerability: "vulnerability",
overrideHygiene: "override hygiene",
maintenanceRisk: "maintenance risk",
} as const;

export function failingGateSummary(
classes: ReadonlyArray<{ label: string; findings: ReadonlyArray<{ severity: SeverityLabel }> }>,
failOn: string,
Expand Down
45 changes: 45 additions & 0 deletions tests/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ jest.unstable_mockModule("../src/scan/override-audit.js", () => ({
runOverrideAudit: runOverrideAuditMock,
}));

const detectDM001Mock = jest.fn<any>(() => Promise.resolve([]));

jest.unstable_mockModule("../src/maintenance/dm001-maintenance-risk.js", () => ({
detectDM001: detectDM001Mock,
}));

function createScanInput(overrides?: Partial<ScanInput>): ScanInput {
return {
mode: "manifest-fallback",
Expand Down Expand Up @@ -637,6 +643,45 @@ describe("CLI integration", () => {
);
});

it("names all three gate classes with the shared labels on the single-folder path", async () => {
// Asserted against GATE_CLASS_LABELS rather than hand-typed copies, so a label
// changed only in single-scan.ts fails here instead of drifting from the
// multi-folder path (#1274).
const { GATE_CLASS_LABELS } = await import("../src/utils/severity.js");
const finding = createFinding({ severity: "high" });
parseArgsMock.mockReturnValue({
command: "scan",
options: {
failOn: "high",
checkOverrides: true,
checkMaintenance: true,
batchSize: "100",
searchDepth: "4",
minSeverity: "medium",
},
projectArg: ".",
});
loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] }));
scanPackagesMock.mockResolvedValue(createScanResult([finding]));
runOverrideAuditMock.mockResolvedValueOnce({
skipped: [],
findings: [
{ ruleId: "OA001", severity: "high", package: { name: "x" }, location: { file: "package.json" }, message: "orphan" },
],
});
detectDM001Mock.mockResolvedValueOnce([
{ ruleId: "DM001", severity: "high", package: { name: "gray-matter", version: "4.0.3" }, drag: [], message: "x" },
]);

const result = await runIndexModule();

expect(result.exitCode).toBe(1);
expect(result.stdout.map(line => stripAnsi(line))).toContain(
`Failing: 1 ${GATE_CLASS_LABELS.vulnerability} finding, 1 ${GATE_CLASS_LABELS.overrideHygiene} finding, ` +
`1 ${GATE_CLASS_LABELS.maintenanceRisk} 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({
Expand Down
40 changes: 40 additions & 0 deletions tests/multi-folder-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,46 @@ describe("handleMultiFolderScan - override hygiene fail-on", () => {
});
});

describe("handleMultiFolderScan - fail-on gate labels", () => {
it("names all three gate classes with the shared labels on the multi-folder path", async () => {
// Asserted against GATE_CLASS_LABELS rather than hand-typed copies, so a label
// changed only in multi-folder-scan.ts fails here instead of drifting from the
// single-folder path (#1274).
const { GATE_CLASS_LABELS } = await import("../src/utils/severity.js");
loadMultiplePackagesMock.mockReturnValue([
{ subfolder: "a", scanInput: makeScanInput() },
]);
scanPackagesMock.mockResolvedValueOnce({
findings: [{ pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, severity: "high" }],
completeness: scanCompleteness,
});
runOverrideAuditMock.mockResolvedValueOnce({
skipped: [],
findings: [
{ ruleId: "OA001", severity: "high", package: { name: "x" }, location: { file: "package.json" }, message: "orphan" },
],
});
detectDM001Mock.mockResolvedValueOnce([
{ 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, checkOverrides: true, 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.
const expected =
`Failing: 1 ${GATE_CLASS_LABELS.vulnerability} finding, 1 ${GATE_CLASS_LABELS.overrideHygiene} finding, ` +
`1 ${GATE_CLASS_LABELS.maintenanceRisk} finding at or above medium (--fail-on high).`;
expect(logged.some(line => line.includes(expected))).toBe(true);
});
});

describe("handleMultiFolderScan - maintenance risk JSON output", () => {
let tmpCwd: string;
let prevCwd: string;
Expand Down
12 changes: 11 additions & 1 deletion tests/utils/failon-gate-summary.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { failingGateSummary } from "../../src/utils/severity.js";
import { GATE_CLASS_LABELS, failingGateSummary } from "../../src/utils/severity.js";

describe("GATE_CLASS_LABELS", () => {
it("pins the wording both scan paths print for each gate class", () => {
expect(GATE_CLASS_LABELS).toEqual({
vulnerability: "vulnerability",
overrideHygiene: "override hygiene",
maintenanceRisk: "maintenance risk",
});
});
});

describe("failingGateSummary", () => {
it("names the class and threshold for the maintainer reproduction in issue #1000", () => {
Expand Down
Loading