diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts
index 7432d0b3..d322af7d 100644
--- a/src/output/multi-folder-html-reporter.ts
+++ b/src/output/multi-folder-html-reporter.ts
@@ -12,9 +12,63 @@ 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[];
+ serializedByFolder: SerializedFinding[][];
+ projectPath: string;
+ cliVersion: string;
+ includeOverrides?: boolean;
+ includeMaintenance?: boolean;
+}): MultiFolderReportData {
+ const findings = params.results.flatMap((r, i) =>
+ params.serializedByFolder[i].map(f => ({
+ ...f,
+ 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 +97,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 +119,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 +175,8 @@ function renderFolderSection(
+ ${overrideHtml}
+ ${maintenanceHtml}
`;
}
@@ -121,6 +187,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 +212,15 @@ export async function writeMultiFolderHtmlReport(params: {
r.sorted.map(f => serializeHtmlFinding(f, r.suggestedFixCommands)),
);
+ const reportData = buildMultiFolderReportData({
+ results: params.results,
+ serializedByFolder: allSerialized,
+ 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 +241,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 +275,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 +458,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 b4e79d9d..2ad2cded 100644
--- a/src/scan/multi-folder-scan.ts
+++ b/src/scan/multi-folder-scan.ts
@@ -351,6 +351,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..81bcd038 100644
--- a/tests/multi-folder-html-reporter.test.ts
+++ b/tests/multi-folder-html-reporter.test.ts
@@ -1,10 +1,17 @@
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 { serializeHtmlFinding } from "../src/output/html-reporter.js";
+import {
+ buildMultiFolderReportData,
+ writeMultiFolderHtmlReport,
+} from "../src/output/multi-folder-html-reporter.js";
+import type { SerializedFinding } from "../src/output/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,28 +21,65 @@ 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 completeness: ScanCompleteness = overrides.completeness ?? { complete: true, diagnostics: [] };
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: [makeFinding()],
suggestedFixCommands: null,
coverage: [],
minSeverity: "medium",
- tableFindings: [],
- allPackages: [],
+ tableFindings: [makeFinding()],
+ allPackages: [{ name: "lodash", version: "4.17.20", ecosystem: "npm" }],
completeness,
overrideFindings: [],
maintenanceFindings: [],
suppressedCount: 0,
+ ...overrides,
};
}
@@ -53,24 +97,32 @@ describe("multi-folder completeness HTML", () => {
autoOpen: false,
results: [
makeResult("api", {
- complete: false,
- diagnostics: [{
- code: "OSV_DETAIL_TRANSIENT_FAILURE",
- severity: "warning",
- message: detectionMessage,
- impact: "detection",
- count: 1,
- }],
+ sorted: [],
+ tableFindings: [],
+ completeness: {
+ complete: false,
+ diagnostics: [{
+ code: "OSV_DETAIL_TRANSIENT_FAILURE",
+ severity: "warning",
+ message: detectionMessage,
+ impact: "detection",
+ count: 1,
+ }],
+ },
}),
makeResult("web", {
- complete: false,
- diagnostics: [{
- code: "PACKUMENT_FETCH_FAILURE",
- severity: "warning",
- message: remediationMessage,
- impact: "remediation",
- count: 1,
- }],
+ sorted: [],
+ tableFindings: [],
+ completeness: {
+ complete: false,
+ diagnostics: [{
+ code: "PACKUMENT_FETCH_FAILURE",
+ severity: "warning",
+ message: remediationMessage,
+ impact: "remediation",
+ count: 1,
+ }],
+ },
}),
],
});
@@ -94,3 +146,160 @@ describe("multi-folder completeness HTML", () => {
expect(html).not.toContain('