From 1c5e89342c0a05d813a072f8c408135965470497 Mon Sep 17 00:00:00 2001 From: Souptik Chakraborty <62941615+Souptik96@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:23:51 +0530 Subject: [PATCH] refactor(fail-on): share the gate-class labels between the two scan paths The single- and multi-folder scan paths each typed out the three --fail-on gate-class labels as string literals, so the wording for one class could change on one path without any test noticing. Export GATE_CLASS_LABELS next to failingGateSummary and build both call sites' gate input from it. Each path now has a test that trips all three classes and asserts the gate line against the shared labels, and a unit test pins the wording itself. No change to gate behaviour, exit codes or printed text. Closes #1274 --- src/scan/multi-folder-scan.ts | 8 ++--- src/scan/single-scan.ts | 8 ++--- src/utils/severity.ts | 9 +++++ tests/cli-integration.test.ts | 45 +++++++++++++++++++++++++ tests/multi-folder-scan.test.ts | 40 ++++++++++++++++++++++ tests/utils/failon-gate-summary.test.ts | 12 ++++++- 6 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index cce247e3..e1787f7e 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -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 { @@ -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( diff --git a/src/scan/single-scan.ts b/src/scan/single-scan.ts index 4e4f3e79..17868dc8 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 } 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"; @@ -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)); diff --git a/src/utils/severity.ts b/src/utils/severity.ts index 64c339e8..060d35b9 100644 --- a/src/utils/severity.ts +++ b/src/utils/severity.ts @@ -22,6 +22,15 @@ export function reachesFailOn( // 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, diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index 03166f4e..e3ace9ab 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -154,6 +154,12 @@ jest.unstable_mockModule("../src/scan/override-audit.js", () => ({ runOverrideAudit: runOverrideAuditMock, })); +const detectDM001Mock = jest.fn(() => Promise.resolve([])); + +jest.unstable_mockModule("../src/maintenance/dm001-maintenance-risk.js", () => ({ + detectDM001: detectDM001Mock, +})); + function createScanInput(overrides?: Partial): ScanInput { return { mode: "manifest-fallback", @@ -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({ diff --git a/tests/multi-folder-scan.test.ts b/tests/multi-folder-scan.test.ts index db82074b..f2c18e78 100644 --- a/tests/multi-folder-scan.test.ts +++ b/tests/multi-folder-scan.test.ts @@ -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; diff --git a/tests/utils/failon-gate-summary.test.ts b/tests/utils/failon-gate-summary.test.ts index d7d3a8bd..bbffabd1 100644 --- a/tests/utils/failon-gate-summary.test.ts +++ b/tests/utils/failon-gate-summary.test.ts @@ -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", () => {