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('