diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index 7432d0b3..2677d4ca 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -12,9 +12,62 @@ import { serializeHtmlFinding, } from "./html-reporter.js"; import type { SerializedFinding } from "./html-reporter.js"; +import { renderOverrideFindingsHtml } from "./override-findings-html.js"; +import { renderMaintenanceFindingsHtml } from "./maintenance-findings-html.js"; import { loadLogoDataUri } from "./logos.js"; import { pluralize } from "../utils/string.js"; import type { SeverityLabel } from "../types.js"; +import type { OverrideFinding } from "../overrides/types.js"; +import type { MaintenanceFinding } from "../maintenance/types.js"; + +export type MultiFolderReportData = { + multiFolder: true; + projectPath: string; + scannedAt: string; + cliVersion: string; + folders: string[]; + findings: Array; + overrideFindings?: Array; + maintenanceFindings?: Array; +}; + +export function buildMultiFolderReportData(params: { + results: MultiFolderScanResult[]; + projectPath: string; + cliVersion: string; + includeOverrides?: boolean; + includeMaintenance?: boolean; +}): MultiFolderReportData { + const findings = params.results.flatMap(r => + r.sorted.map(f => ({ + ...serializeHtmlFinding(f, r.suggestedFixCommands), + subfolder: r.subfolder, + })), + ); + + return { + multiFolder: true, + projectPath: params.projectPath, + scannedAt: new Date().toISOString(), + cliVersion: params.cliVersion, + folders: params.results.map(r => r.subfolder), + findings, + ...(params.includeOverrides + ? { + overrideFindings: params.results.flatMap(r => + r.overrideFindings.map(f => ({ ...f, subfolder: r.subfolder })), + ), + } + : {}), + ...(params.includeMaintenance + ? { + maintenanceFindings: params.results.flatMap(r => + r.maintenanceFindings.map(f => ({ ...f, subfolder: r.subfolder })), + ), + } + : {}), + }; +} type SevCounts = Record; @@ -43,6 +96,9 @@ function renderFolderSection( folderIdx: number, idxOffset: number, serialized: SerializedFinding[], + projectRoot: string, + includeOverrides?: boolean, + includeMaintenance?: boolean, ): string { const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }; for (const f of result.sorted) { @@ -62,6 +118,13 @@ function renderFolderSection( const fixPlanHtml = renderFixPlan(result.suggestedFixCommands); const noticesHtml = renderFolderNotices(result.coverage); const completenessBannerHtml = renderCompletenessBanner(result.completeness); + const folderAbs = path.join(projectRoot, result.subfolder); + const overrideHtml = includeOverrides + ? renderOverrideFindingsHtml(result.overrideFindings, folderAbs) + : ""; + const maintenanceHtml = includeMaintenance + ? renderMaintenanceFindingsHtml(result.maintenanceFindings) + : ""; const emptyRow = result.sorted.length === 0 ? `No findings` @@ -111,6 +174,8 @@ function renderFolderSection( + ${overrideHtml} + ${maintenanceHtml} `; } @@ -121,6 +186,10 @@ export async function writeMultiFolderHtmlReport(params: { projectPath: string; cliVersion: string; autoOpen: boolean; + /** When true, render override hygiene panels (even if empty). */ + includeOverrides?: boolean; + /** When true, render maintenance risk panels (even if empty). */ + includeMaintenance?: boolean; }): Promise<{ reportPath: string }> { fs.mkdirSync(params.outputDir, { recursive: true }); @@ -142,6 +211,14 @@ export async function writeMultiFolderHtmlReport(params: { r.sorted.map(f => serializeHtmlFinding(f, r.suggestedFixCommands)), ); + const reportData = buildMultiFolderReportData({ + results: params.results, + projectPath: params.projectPath, + cliVersion: params.cliVersion, + includeOverrides: params.includeOverrides, + includeMaintenance: params.includeMaintenance, + }); + // Compact per-folder findings for JavaScript (only fields needed for filter/sort/search) const folderFindingsJson = JSON.stringify( allSerialized.map(findings => @@ -162,7 +239,15 @@ export async function writeMultiFolderHtmlReport(params: { let idxOffset = 0; const folderSectionsHtml = params.results .map((r, fi) => { - const html = renderFolderSection(r, fi, idxOffset, allSerialized[fi]); + const html = renderFolderSection( + r, + fi, + idxOffset, + allSerialized[fi], + params.projectPath, + params.includeOverrides, + params.includeMaintenance, + ); idxOffset += r.sorted.length; return html; }) @@ -188,6 +273,7 @@ details[open] > .folder-summary .chevron{transform:rotate(90deg)} .folder-counts{font-size:11px;color:#8b949e;margin-left:auto} .folder-body{padding:0} .folder-body .fix-plan{margin:24px 32px 0} +.folder-body .override-hygiene,.folder-body .maintenance-risk{margin:0 32px 24px} @@ -370,6 +456,8 @@ document.addEventListener('click', function(e) { `; const indexPath = path.join(params.outputDir, "index.html"); + const jsonPath = path.join(params.outputDir, "report.json"); + fs.writeFileSync(jsonPath, JSON.stringify(reportData, null, 2), "utf8"); fs.writeFileSync(indexPath, html, "utf8"); if (params.autoOpen) openInBrowser(indexPath); diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index d62d78eb..59d5e2bd 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -349,6 +349,10 @@ export async function handleMultiFolderScan(params: { projectPath: params.projectRoot, cliVersion, autoOpen: !params.options.noOpen, + // Same undefined-vs-[] contract as single-folder buildReportData: pass the + // flag so empty panels render when the check ran with no hits. + includeOverrides: !!params.options.checkOverrides, + includeMaintenance: !!params.options.checkMaintenance, }); console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`); } diff --git a/tests/multi-folder-html-reporter.test.ts b/tests/multi-folder-html-reporter.test.ts index f4cc1a33..af8d2ea6 100644 --- a/tests/multi-folder-html-reporter.test.ts +++ b/tests/multi-folder-html-reporter.test.ts @@ -1,10 +1,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "@jest/globals"; -import { writeMultiFolderHtmlReport } from "../src/output/multi-folder-html-reporter.js"; +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { + buildMultiFolderReportData, + writeMultiFolderHtmlReport, +} from "../src/output/multi-folder-html-reporter.js"; import type { MultiFolderScanResult } from "../src/scan/multi-folder-scan.js"; -import type { ScanCompleteness } from "../src/types.js"; +import type { Finding, ScanCompleteness } from "../src/types.js"; +import type { OverrideFinding } from "../src/overrides/types.js"; +import type { MaintenanceFinding } from "../src/maintenance/types.js"; const tempDirs: string[] = []; @@ -14,31 +19,86 @@ afterEach(() => { } }); -function makeResult(subfolder: string, completeness: ScanCompleteness): MultiFolderScanResult { +function makeFinding(name = "lodash"): Finding { + return { + pkg: { name, version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-xxx", aliases: ["CVE-2021-23337"], summary: "test" }], + severity: "high", + cveAliases: ["CVE-2021-23337"], + dependencyPaths: [["project", name]], + relationship: "direct", + firstFixedVersion: "4.17.21", + }; +} + +function makeOverride(): OverrideFinding { + return { + ruleId: "OA001", + severity: "high", + package: { name: "gone" }, + location: { file: "package.json", jsonPath: "/overrides/gone" }, + message: "Override target not in resolved tree", + }; +} + +function makeMaintenance(): MaintenanceFinding { + return { + ruleId: "DM001", + severity: "high", + package: { name: "gray-matter", version: "4.0.3" }, + drag: [], + message: "maintenance risk", + }; +} + +function makeResult( + subfolder: string, + overrides: Partial = {}, +): MultiFolderScanResult { + const finding = makeFinding(); return { subfolder, scanInput: { mode: "resolved-lockfile", source: "package-lock", filePath: `/project/${subfolder}/package-lock.json`, - packages: [], + packages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }], notes: [], warnings: [], skippedDependencies: [], }, - sorted: [], + sorted: [finding], suggestedFixCommands: null, coverage: [], minSeverity: "medium", - tableFindings: [], - allPackages: [], - completeness, + tableFindings: [finding], + allPackages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }], + completeness: { complete: true, diagnostics: [] }, overrideFindings: [], maintenanceFindings: [], suppressedCount: 0, + ...overrides, }; } +function makeCompletenessResult(subfolder: string, completeness: ScanCompleteness): MultiFolderScanResult { + return makeResult(subfolder, { + completeness, + sorted: [], + tableFindings: [], + allPackages: [], + scanInput: { + mode: "resolved-lockfile", + source: "package-lock", + filePath: `/project/${subfolder}/package-lock.json`, + packages: [], + notes: [], + warnings: [], + skippedDependencies: [], + }, + }); +} + describe("multi-folder completeness HTML", () => { it("renders the aggregate banner and each folder banner", async () => { const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-multi-html-")); @@ -52,7 +112,7 @@ describe("multi-folder completeness HTML", () => { cliVersion: "1.18.1", autoOpen: false, results: [ - makeResult("api", { + makeCompletenessResult("api", { complete: false, diagnostics: [{ code: "OSV_DETAIL_TRANSIENT_FAILURE", @@ -62,7 +122,7 @@ describe("multi-folder completeness HTML", () => { count: 1, }], }), - makeResult("web", { + makeCompletenessResult("web", { complete: false, diagnostics: [{ code: "PACKUMENT_FETCH_FAILURE", @@ -94,3 +154,150 @@ describe("multi-folder completeness HTML", () => { expect(html).not.toContain('