From d1e7e77db91a50da3e5cdbac24d0be82d8153149 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 25 Jul 2026 11:28:53 +0800 Subject: [PATCH 01/25] feat(scanner,output): report incomplete scan data with diagnostics Track scan completeness by capturing transient OSV detail failures, offline missing records, packument fetch errors, remediation failures, and chain resolution failures. Surface diagnostic warnings in CLI output, HTML report banner, and JSON output when scan data may be incomplete. --- src/index.ts | 8 +-- src/output/html-reporter.ts | 30 ++++++++++- src/output/printers.ts | 52 +++++++++++------- src/output/write-outputs.ts | 6 ++- src/scan/multi-folder-scan.ts | 2 +- src/scanner.ts | 95 +++++++++++++++++++++++++++++++-- src/types.ts | 20 +++++++ tests/cli-integration.test.ts | 15 +++--- tests/html-reporter.test.ts | 31 +++++++++++ tests/multi-folder-scan.test.ts | 2 +- tests/output.test.ts | 42 +++++++++++++++ tests/scanner-cache.test.ts | 56 +++++++++---------- tests/write-outputs.test.ts | 49 ++++++++++++++--- 13 files changed, 335 insertions(+), 73 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5b4f17fc..362e9489 100644 --- a/src/index.ts +++ b/src/index.ts @@ -702,13 +702,13 @@ if (parsedArgs) { console.log("\n" + renderMaintenanceFindings(maintenanceFindings, { verbose: true })); } printCoverage([...scanInput.notes, ...scanState.coverage]); - printFinalStatus(scanState.sorted, overrideCount); + printFinalStatus(scanState.sorted, overrideCount, scanState.completeness); if (showOverrideHint) printOverrideHint(); } else { const compactPmLabel = scanState.suggestedFixCommands ? `${chalk.cyan(scanState.suggestedFixCommands.packageManager)} ${chalk.gray(`(${scanState.suggestedFixCommands.sourceLabel})`)}` : undefined; - printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all, packageManager: compactPmLabel }); + printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all, packageManager: compactPmLabel, completeness: scanState.completeness }); if (options.checkOverrides) { console.log("\n" + renderOverrideFindings(overrideFindings, { verbose: false, projectPath })); } @@ -740,6 +740,7 @@ if (parsedArgs) { overrideFindings: options.checkOverrides ? overrideFindings : undefined, // Same undefined-vs-[] convention as overrideFindings above. maintenanceFindings: options.checkMaintenance ? maintenanceFindings : undefined, + completeness: scanState.completeness, }); const { reportPath } = await writeHtmlReport({ outputDir, @@ -826,7 +827,7 @@ async function scanProject(params: { fetchImpl?: typeof fetch; }) { const directDependencyNames = readDirectDependencyNames(params.projectPath, !!params.options.prodOnly); - const findings = await scanPackages(params.scanInput.packages, params.batchSize, params.options, { + const { findings, completeness } = await scanPackages(params.scanInput.packages, params.batchSize, params.options, { directDependencyNames, scanSource: params.scanInput.source, scanFilePath: params.scanInput.filePath, @@ -887,6 +888,7 @@ async function scanProject(params: { tableFindings, suggestedFixCommands, allPackages: params.scanInput.packages, + completeness, }; } diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 33857e7a..042775b7 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -8,7 +8,7 @@ import { pluralize } from "../utils/string.js"; import { MAL_GIT_SOURCE_PINNED_DISPLAY, MAL_GIT_SOURCE_FLOATING_DISPLAY } from "../constants.js"; import { renderOverrideFindingsHtml } from "./override-findings-html.js"; import { renderMaintenanceFindingsHtml } from "./maintenance-findings-html.js"; -import type { Finding } from "../types.js"; +import type { Finding, ScanCompleteness } from "../types.js"; import type { OverrideFinding } from "../overrides/types.js"; import type { MaintenanceFinding } from "../maintenance/types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; @@ -40,6 +40,7 @@ export type ReportData = { skippedDependencies: string[]; overrideFindings?: ReadonlyArray; maintenanceFindings?: ReadonlyArray; + completeness?: ScanCompleteness; }; export function buildReportData(params: { @@ -55,6 +56,7 @@ export function buildReportData(params: { skippedDependencies: string[]; overrideFindings?: ReadonlyArray; maintenanceFindings?: ReadonlyArray; + completeness?: ScanCompleteness; }): ReportData { return { projectPath: params.projectPath, @@ -72,6 +74,7 @@ export function buildReportData(params: { skippedDependencies: params.skippedDependencies, overrideFindings: params.overrideFindings, maintenanceFindings: params.maintenanceFindings, + completeness: params.completeness, }; } @@ -208,6 +211,12 @@ button.header-link:hover{color:var(--link);border-color:var(--link)} .fix-target-note{color:var(--text-3);font-style:italic;font-size:10px} .fix-target-coverage{flex-basis:100%;color:var(--text-3);font-size:10px;line-height:1.45} .chain-proof{font-size:0.78rem;color:var(--text-2);margin-left:6px;font-family:monospace} +.completeness-banner{margin:16px 32px 0;background:#e3b34111;border:1px solid #e3b34144;border-radius:8px;padding:14px 18px;display:flex;gap:14px;align-items:flex-start} +.completeness-banner-icon{font-size:22px;flex-shrink:0;line-height:1} +.completeness-banner-body strong{font-size:13px;color:var(--text);display:block;margin-bottom:4px} +.completeness-banner-body p{font-size:12px;color:var(--text-2);margin:0} +.completeness-banner-body ul{margin:8px 0 0;padding:0 0 0 18px;display:flex;flex-direction:column;gap:3px} +.completeness-banner-body li{font-size:11px;color:var(--text-2);line-height:1.5} .break-badge{font-size:10px;font-weight:700;background:#fb850022;color:#fb8500;border:1px solid #fb850066;border-radius:4px;padding:1px 5px} .tier-ok{display:inline-flex;align-items:center;gap:4px;font-size:10px;padding:2px 8px;border-radius:4px;background:#3fb95022;color:#3fb950;border:1px solid #3fb95033;margin-bottom:6px} .tier-warn{display:inline-flex;align-items:center;gap:4px;font-size:10px;padding:2px 8px;border-radius:4px;background:#e3b34122;color:#e3b341;border:1px solid #e3b34133;margin-bottom:6px} @@ -245,6 +254,7 @@ export function renderHtmlReport(data: ReportData): string { const totalCVEs = new Set(data.findings.flatMap(f => f.vulnerabilities.map(v => v.id))).size; const noticesHtml = renderNotices(data.notes, data.warnings); + const completenessBannerHtml = renderCompletenessBanner(data); const fixPlanHtml = renderFixPlan(data.suggestedFixCommands); const skippedKeys = new Set( (data.suggestedFixCommands?.skipped ?? []).map(s => `${s.package}@${s.version}`) @@ -319,6 +329,7 @@ ${REPORT_STYLES}
${totalCVEs}CVEs
+${completenessBannerHtml} ${fixPlanHtml}
@@ -621,6 +632,23 @@ function renderTransitiveContextCol(finding: SerializedFinding): string {
`; } +function renderCompletenessBanner(data: ReportData): string { + if (data.completeness == null || data.completeness.complete) return ""; + + const diagRows = data.completeness.diagnostics.map(d => + `
  • ${escapeHtml(d.message)}
  • ` + ).join("\\n"); + + return `
    +
    +
    + Scan data is incomplete +

    The scan result may be missing some findings because the following issues occurred:

    +
      ${diagRows}
    +
    +
    `; +} + function renderNotices(notes: string[], warnings: string[]): string { if (notes.length === 0 && warnings.length === 0) return ""; const total = notes.length + warnings.length; diff --git a/src/output/printers.ts b/src/output/printers.ts index 184b1137..ce01a2fd 100644 --- a/src/output/printers.ts +++ b/src/output/printers.ts @@ -1,4 +1,4 @@ -import type { Finding, ScanInput, SeverityLabel } from "../types.js"; +import type { Finding, ScanCompleteness, ScanInput, SeverityLabel } from "../types.js"; import { chalk, stripAnsi } from "../utils/chalk.js"; import { buildSuggestedFixCommandPlan, type SuggestedFixTarget } from "../remediation/fix-commands.js"; import { isMajorVersionBump } from "../utils/version.js"; @@ -320,7 +320,7 @@ export function printTable(findings: Finding[], threshold: SeverityLabel | null, } } -export function printFinalStatus(findings: Finding[], overrideCount = 0) { +export function printFinalStatus(findings: Finding[], overrideCount = 0, completeness?: ScanCompleteness) { console.log(""); console.log(chalk.gray("────────────────────────────────")); @@ -334,26 +334,26 @@ export function printFinalStatus(findings: Finding[], overrideCount = 0) { } else { console.log(chalk.greenBright("✔ Scan complete. No known vulnerabilities found.")); } - return; - } - - const criticalCount = findings.filter(f => f.severity === "critical").length; - const highCount = findings.filter(f => f.severity === "high").length; + } else { + const criticalCount = findings.filter(f => f.severity === "critical").length; + const highCount = findings.filter(f => f.severity === "high").length; - if (criticalCount > 0 || highCount > 0) { - console.log( - chalk.redBright( - `✖ Scan complete. ${findings.length} ${pluralize(findings.length, "vulnerability")}${overrideSuffix} found (${criticalCount} critical, ${highCount} high). Start with the priority fixes above.` - ) - ); - return; + if (criticalCount > 0 || highCount > 0) { + console.log( + chalk.redBright( + `✖ Scan complete. ${findings.length} ${pluralize(findings.length, "vulnerability")}${overrideSuffix} found (${criticalCount} critical, ${highCount} high). Start with the priority fixes above.` + ) + ); + } else { + console.log( + chalk.yellow( + `▲ Scan complete. ${findings.length} ${pluralize(findings.length, "vulnerability")}${overrideSuffix} found. Review the suggested fix plan above.` + ) + ); + } } - console.log( - chalk.yellow( - `▲ Scan complete. ${findings.length} ${pluralize(findings.length, "vulnerability")}${overrideSuffix} found. Review the suggested fix plan above.` - ) - ); + printIncompleteWarning(completeness); } export function printOverrideHint(): void { @@ -417,7 +417,7 @@ function wrapCell(value: string, width: number): string[] { export function printCompactOutput( findings: Finding[], scanInput?: ScanInput, - options?: { offline?: boolean; all?: boolean; subfolder?: string; packageManager?: string }, + options?: { offline?: boolean; all?: boolean; subfolder?: string; packageManager?: string; completeness?: ScanCompleteness }, ) { console.log(""); if (options?.packageManager) { @@ -620,9 +620,21 @@ export function printCompactOutput( if (!options?.all) { console.log(chalk.gray(`Run with ${chalk.whiteBright("--verbose")} for fix plan, paths, and full table.`)); } + printIncompleteWarning(options?.completeness); console.log(""); } +export function printIncompleteWarning(completeness?: ScanCompleteness): void { + if (!completeness || completeness.complete) return; + console.log(""); + console.log(chalk.yellow("⚠ Scan data is incomplete.")); + for (const diag of completeness.diagnostics) { + const prefix = diag.impact === "detection" ? chalk.yellow(" ·") : chalk.gray(" ·"); + console.log(prefix + " " + chalk.gray(diag.message)); + } + console.log(chalk.gray(" Re-run the scan to get a complete result.")); +} + function renderSeverityTable(counts: { critical: number; high: number; medium: number; low: number; unknown: number }): string { const labels = ["Critical", "High", "Medium", "Low", "Unknown"]; const coloredValues = [ diff --git a/src/output/write-outputs.ts b/src/output/write-outputs.ts index 17ed9e90..f2abc99e 100644 --- a/src/output/write-outputs.ts +++ b/src/output/write-outputs.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { chalk } from "../utils/chalk.js"; -import type { ParsedOptions, ScanInput, Finding, PackageRef } from "../types.js"; +import type { ParsedOptions, ScanInput, Finding, PackageRef, ScanCompleteness } from "../types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import type { OverrideFinding } from "../overrides/types.js"; import type { MaintenanceFinding } from "../maintenance/types.js"; @@ -19,6 +19,7 @@ export type ScanState = { coverage: string[]; minSeverity: string; tableFindings: Finding[]; + completeness?: ScanCompleteness; }; function readProjectMeta(projectPath: string): ProjectMeta { @@ -58,6 +59,9 @@ export async function writeOutputs( source: scanInput.source, packageCount: scanInput.packages.length, findingCount: scanState.sorted.length, + status: scanState.completeness?.complete !== false ? "ok" : "partial", + complete: scanState.completeness?.complete ?? true, + diagnostics: scanState.completeness?.diagnostics ?? [], suggestedFixCommands: scanState.suggestedFixCommands, notes: [...scanInput.notes, ...scanState.coverage], warnings: scanInput.warnings, diff --git a/src/scan/multi-folder-scan.ts b/src/scan/multi-folder-scan.ts index 569b9b91..7b3308f9 100644 --- a/src/scan/multi-folder-scan.ts +++ b/src/scan/multi-folder-scan.ts @@ -59,7 +59,7 @@ export async function runMultiFolderScan(params: { const subfolderAbs = path.join(params.projectRoot, subfolder); const directDependencyNames = readDirectDependencyNames(subfolderAbs, !!params.options.prodOnly); - const findings = await scanPackages(scanInput.packages, params.batchSize, params.options, { + const { findings } = await scanPackages(scanInput.packages, params.batchSize, params.options, { directDependencyNames, scanSource: scanInput.source, scanFilePath: scanInput.filePath, diff --git a/src/scanner.ts b/src/scanner.ts index ece063ca..926c5e70 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -1,4 +1,4 @@ -import type { Finding, NpmTransitiveGraph, OsvVuln, PackageRef, ParsedOptions, ScanInput } from "./types.js"; +import type { Finding, NpmTransitiveGraph, OsvVuln, PackageRef, ParsedOptions, ScanCompleteness, ScanDiagnostic, ScanInput } from "./types.js"; import type { PublishedFixVersionResolution } from "./remediation/npm-registry.js"; import { chunk, unique, runWithConcurrency } from "./utils/array.js"; import { isPrivateRegistrySource, isGitSource, hasCommitShaPinning } from "./utils/advisory.js"; @@ -107,7 +107,7 @@ export async function scanPackages( context?: ScanClassificationContext, debugLog?: DebugLogger, fetchImpl?: typeof fetch, -): Promise { +): Promise<{ findings: Finding[]; completeness: ScanCompleteness }> { const log: DebugLogger = debugLog ?? (() => {}); const sourceContext = createAdvisorySource({ osvUrl: options.osvUrl, @@ -428,6 +428,10 @@ export async function scanPackages( log("Workspace map", { type: "bun", workspaces: bunWorkspaceMap.size }); } const lockfileWorkspaceMap = pnpmWorkspaceMap ?? bunWorkspaceMap ?? null; + const packumentFailures = new Set(); + const remediationFailures = new Map(); + let chainResolutionFailureCount = 0; + const npmRemediationCache = new Map(); const parentUpgradeCache = new Map(); const directValidationCount = offline ? 0 : findings.length; @@ -476,7 +480,10 @@ export async function scanPackages( } if (packumentsToPrewarm.size > 0) { await runWithConcurrency([...packumentsToPrewarm], 8, async (name) => { - try { await fetchPackument(name); } catch { /* loop will handle missing packuments */ } + try { await fetchPackument(name); } catch { + packumentFailures.add(name); + log("Packument pre-warm failed", { package: name }); + } }); } } @@ -568,8 +575,10 @@ export async function scanPackages( parentUpgradeCache.set(remediationCacheKey, finding.recommendedParentUpgrade ?? null); } } catch (error) { + const packageName = finding.pkg.name; + remediationFailures.set(packageName, (remediationFailures.get(packageName) ?? 0) + 1); log("Remediation failed", { - package: finding.pkg.name, + package: packageName, version: finding.pkg.version, error: error instanceof Error ? error.message : String(error), }); @@ -596,6 +605,7 @@ export async function scanPackages( { offline }, ); } catch (error) { + chainResolutionFailureCount += 1; log("Chain resolution failed", { package: finding.pkg.name, version: finding.pkg.version, @@ -633,7 +643,19 @@ export async function scanPackages( unknown: severityCounts.unknown, }); - return findings; + const diagnostics = buildCompletenessDiagnostics({ + transientDetailIds, + offlineMissingDetailIds, + packumentFailures, + remediationFailures, + chainResolutionFailureCount, + }); + const completeness: ScanCompleteness = { + complete: diagnostics.length === 0, + diagnostics, + }; + + return { findings, completeness }; } catch (error) { spinner.fail("Scan failed"); throw error; @@ -847,6 +869,69 @@ function findFirstFixedVersion(vulns: OsvVuln[]): string | null { return fixedVersions.sort(compareVersions)[0]; } +function buildCompletenessDiagnostics(params: { + transientDetailIds: Set; + offlineMissingDetailIds: Set; + packumentFailures: Set; + remediationFailures: Map; + chainResolutionFailureCount: number; +}): ScanDiagnostic[] { + const diagnostics: ScanDiagnostic[] = []; + + if (params.transientDetailIds.size > 0) { + diagnostics.push({ + code: "OSV_DETAIL_TRANSIENT_FAILURE", + severity: "warning", + message: `${params.transientDetailIds.size} advisory detail ${pluralize(params.transientDetailIds.size, "lookup")} failed with a transient error — re-run to retry.`, + impact: "detection", + count: params.transientDetailIds.size, + }); + } + + if (params.offlineMissingDetailIds.size > 0) { + diagnostics.push({ + code: "OSV_DETAIL_OFFLINE_MISSING", + severity: "warning", + message: `${params.offlineMissingDetailIds.size} advisory detail ${pluralize(params.offlineMissingDetailIds.size, "record")} ${pluralize(params.offlineMissingDetailIds.size, "is", "are")} missing from the local advisory database — refresh to sync.`, + impact: "detection", + count: params.offlineMissingDetailIds.size, + }); + } + + if (params.packumentFailures.size > 0) { + diagnostics.push({ + code: "PACKUMENT_FETCH_FAILURE", + severity: "warning", + message: `${params.packumentFailures.size} packument ${pluralize(params.packumentFailures.size, "fetch")} failed — transitive remediation may be incomplete.`, + impact: "remediation", + count: params.packumentFailures.size, + }); + } + + if (params.remediationFailures.size > 0) { + const total = [...params.remediationFailures.values()].reduce((sum, c) => sum + c, 0); + diagnostics.push({ + code: "REMEDIATION_FAILURE", + severity: "warning", + message: `${total} transitive remediation ${pluralize(total, "attempt")} failed — some fix recommendations may be missing.`, + impact: "remediation", + count: total, + }); + } + + if (params.chainResolutionFailureCount > 0) { + diagnostics.push({ + code: "CHAIN_RESOLUTION_FAILURE", + severity: "warning", + message: `${params.chainResolutionFailureCount} chain resolution ${pluralize(params.chainResolutionFailureCount, "attempt")} failed — some validated upgrade chains may be missing.`, + impact: "remediation", + count: params.chainResolutionFailureCount, + }); + } + + return diagnostics; +} + export function buildCoverageNotes(scanInput: ScanInput, offline: boolean): string[] { const notes = [ "CVE Lite CLI checks package versions against OSV advisories. It does not prove exploitability or runtime reachability.", diff --git a/src/types.ts b/src/types.ts index efe06d78..96ef9e35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -200,6 +200,26 @@ export type Baseline = { findings: BaselineEntry[]; }; +export type ScanDiagnosticCode = + | "OSV_DETAIL_TRANSIENT_FAILURE" + | "OSV_DETAIL_OFFLINE_MISSING" + | "PACKUMENT_FETCH_FAILURE" + | "REMEDIATION_FAILURE" + | "CHAIN_RESOLUTION_FAILURE"; + +export type ScanDiagnostic = { + code: ScanDiagnosticCode; + severity: "warning" | "error"; + message: string; + impact: "detection" | "remediation"; + count: number; +}; + +export type ScanCompleteness = { + complete: boolean; + diagnostics: ScanDiagnostic[]; +}; + export type CliCommand = "scan" | "advisories-sync" | "advisories-init" | "install-skill" | "config" | "overrides"; export type ParsedOptions = { diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index f1aae86f..eace47ba 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -13,6 +13,7 @@ const parseArgsMock = jest.fn(); const loadPackagesMock = jest.fn(); const buildNoPackagesMessageMock = jest.fn(); const scanPackagesMock = jest.fn(); +const emptyCompleteness = { complete: true, diagnostics: [] }; const syncOsvAdvisoriesMock = jest.fn(); const printCacheSummaryMock = jest.fn(); const logInfoMock = jest.fn(); @@ -225,7 +226,7 @@ function setupFixModeWithNpmDirectTarget() { packages: [multerDirectFixFinding.pkg], }), ); - scanPackagesMock.mockResolvedValue([multerDirectFixFinding]); + scanPackagesMock.mockResolvedValue({ findings: [multerDirectFixFinding], completeness: emptyCompleteness }); buildSuggestedFixCommandPlanMock.mockReturnValue(npmFixPlan); } @@ -292,7 +293,7 @@ describe("CLI integration", () => { }); buildNoPackagesMessageMock.mockReturnValue("No scannable packages were found."); loadPackagesMock.mockReturnValue(createScanInput()); - scanPackagesMock.mockResolvedValue([]); + scanPackagesMock.mockResolvedValue({ findings: [], completeness: emptyCompleteness }); syncOsvAdvisoriesMock.mockResolvedValue({ advisoryCount: 0, dbPath: "/tmp/advisories.db", @@ -479,7 +480,7 @@ describe("CLI integration", () => { projectArg: ".", }); loadPackagesMock.mockReturnValue(createScanInput({ packages: [finding.pkg] })); - scanPackagesMock.mockResolvedValue([finding]); + scanPackagesMock.mockResolvedValue({ findings: [finding], completeness: emptyCompleteness }); const result = await runIndexModule(); @@ -646,7 +647,7 @@ describe("CLI integration", () => { skippedDependencies: ["dependencies:debug@^4.3.0"], }), ); - scanPackagesMock.mockResolvedValue([finding]); + scanPackagesMock.mockResolvedValue({ findings: [finding], completeness: emptyCompleteness }); const result = await runIndexModule(); @@ -862,7 +863,7 @@ describe("CLI integration", () => { loadPackagesMock .mockReturnValueOnce(createScanInput({ source: "package-lock", filePath: "/tmp/project/package-lock.json", packages: [finding.pkg] })) .mockReturnValueOnce(createScanInput({ source: "package-lock", filePath: "/tmp/project/package-lock.json", packages: [finding.pkg] })); - scanPackagesMock.mockResolvedValueOnce([finding]).mockResolvedValueOnce([]); + scanPackagesMock.mockResolvedValueOnce({ findings: [finding], completeness: emptyCompleteness }).mockResolvedValueOnce({ findings: [], completeness: emptyCompleteness }); buildSuggestedFixCommandPlanMock.mockReturnValueOnce({ packageManager: "npm", sourceLabel: "package-lock.json", @@ -1015,7 +1016,7 @@ describe("CLI integration", () => { { name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] }, ]; loadPackagesMock.mockReturnValue(createScanInput({ packages })); - scanPackagesMock.mockResolvedValue([]); + scanPackagesMock.mockResolvedValue({ findings: [], completeness: emptyCompleteness }); parseArgsMock.mockReturnValue({ command: "scan", options: { @@ -1065,7 +1066,7 @@ describe("CLI integration", () => { { name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] }, ]; loadPackagesMock.mockReturnValue(createScanInput({ packages })); - scanPackagesMock.mockResolvedValue([]); + scanPackagesMock.mockResolvedValue({ findings: [], completeness: emptyCompleteness }); parseArgsMock.mockReturnValue({ command: "scan", options: { diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index 17339392..e36929e9 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -827,3 +827,34 @@ describe("writeHtmlReport", () => { } }); }); + +describe("scan completeness HTML rendering", () => { + it("does not render a banner when completeness is not set (undefined)", () => { + const data = buildReportData(BASE_PARAMS); + const html = renderHtmlReport(data); + expect(html).not.toContain("Scan data is incomplete"); + }); + + it("does not render a banner for a complete scan", () => { + const data = buildReportData({ ...BASE_PARAMS, completeness: { complete: true, diagnostics: [] } }); + const html = renderHtmlReport(data); + expect(html).not.toContain("Scan data is incomplete"); + }); + + it("renders an incomplete-scan banner with diagnostics when complete is false", () => { + const data = buildReportData({ + ...BASE_PARAMS, + completeness: { + complete: false, + diagnostics: [ + { code: "OSV_DETAIL_TRANSIENT_FAILURE", severity: "warning", message: "3 lookups failed", impact: "detection", count: 3 }, + { code: "PACKUMENT_FETCH_FAILURE", severity: "warning", message: "1 packument fetch failed", impact: "remediation", count: 1 }, + ], + }, + }); + const html = renderHtmlReport(data); + expect(html).toContain("Scan data is incomplete"); + expect(html).toContain("3 lookups failed"); + expect(html).toContain("1 packument fetch failed"); + }); +}); diff --git a/tests/multi-folder-scan.test.ts b/tests/multi-folder-scan.test.ts index 62c6dca8..f65e26f5 100644 --- a/tests/multi-folder-scan.test.ts +++ b/tests/multi-folder-scan.test.ts @@ -1,7 +1,7 @@ import { jest } from "@jest/globals"; const loadMultiplePackagesMock = jest.fn(); -const scanPackagesMock = jest.fn(() => Promise.resolve([])); +const scanPackagesMock = jest.fn(() => Promise.resolve({ findings: [], completeness: { complete: true, diagnostics: [] } })); const buildCoverageNotesMock = jest.fn(() => []); const sortFindingsForOutputMock = jest.fn((f: any[]) => f); const normalizeSeverityMock = jest.fn(() => "medium"); diff --git a/tests/output.test.ts b/tests/output.test.ts index 4f151753..6731ba23 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -25,6 +25,7 @@ import { printActionSummary, printCompactOutput, printFinalStatus, + printIncompleteWarning, printOverrideHint, printSuggestedFixCommands, printSuggestedFixCommandSkips, @@ -2733,3 +2734,44 @@ describe("printSuggestedFixCommands - cooldown warning", () => { expect(output).not.toContain("release cooldown"); }); }); + +describe("printIncompleteWarning", () => { + it("prints nothing when completeness is undefined", () => { + const lines = captureLogs(() => { + printIncompleteWarning(undefined); + }); + expect(lines.join("\n")).not.toContain("Scan data is incomplete"); + }); + + it("prints nothing for a complete scan", () => { + const lines = captureLogs(() => { + printIncompleteWarning({ complete: true, diagnostics: [] }); + }); + expect(lines.join("\n")).not.toContain("Scan data is incomplete"); + }); + + it("prints a warning banner for an incomplete scan", () => { + const lines = captureLogs(() => { + printIncompleteWarning({ + complete: false, + diagnostics: [ + { code: "OSV_DETAIL_TRANSIENT_FAILURE", severity: "warning", message: "3 lookups failed", impact: "detection", count: 3 }, + { code: "PACKUMENT_FETCH_FAILURE", severity: "warning", message: "1 packument fetch failed", impact: "remediation", count: 1 }, + ], + }); + }); + const output = lines.join("\n"); + expect(output).toContain("Scan data is incomplete"); + expect(output).toContain("3 lookups failed"); + expect(output).toContain("1 packument fetch failed"); + expect(output).toContain("Re-run the scan to get a complete result."); + }); + + it("does not print for a complete scan with empty diagnostics", () => { + const lines = captureLogs(() => { + printIncompleteWarning({ complete: true, diagnostics: [] }); + }); + const output = lines.join("\n"); + expect(output).toBe(""); + }); +}); diff --git a/tests/scanner-cache.test.ts b/tests/scanner-cache.test.ts index 57ef1d6f..735f2272 100644 --- a/tests/scanner-cache.test.ts +++ b/tests/scanner-cache.test.ts @@ -104,7 +104,7 @@ describe("scanPackages cache behavior", () => { getVulnMock.mockResolvedValue(detail); try { - const firstFindings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings: firstFindings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(firstFindings).toHaveLength(1); expect(firstFindings[0]?.vulnerabilities).toEqual([detail]); expect(queryBatchMock).toHaveBeenCalledTimes(1); @@ -113,7 +113,7 @@ describe("scanPackages cache behavior", () => { queryBatchMock.mockClear(); getVulnMock.mockClear(); - const secondFindings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings: secondFindings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(secondFindings).toHaveLength(1); expect(secondFindings[0]?.vulnerabilities).toEqual([detail]); expect(queryBatchMock).not.toHaveBeenCalled(); @@ -145,7 +145,7 @@ describe("scanPackages cache behavior", () => { ); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.vulnerabilities).toEqual([]); @@ -246,7 +246,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages( + const { findings } = await scanPackages( [mochaPkg, transitivePkg], 100, { @@ -302,7 +302,7 @@ describe("scanPackages cache behavior", () => { db.close(); try { - const findings = await scanPackages([pkg], 100, { + const { findings } = await scanPackages([pkg], 100, { ...createOptions(tempDir), offline: true, offlineDb: dbPath, @@ -365,7 +365,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.firstFixedVersion).toBe("1.0.2"); @@ -400,7 +400,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir), { + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir), { directDependencyNames: new Set(["typescript", "jest"]), }); @@ -433,7 +433,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir), { + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir), { directDependencyNames: new Set(["diff", "typescript"]), }); @@ -489,7 +489,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.firstFixedVersion).toBe("2.0.1"); @@ -515,7 +515,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); // OSV-GOOD detail should be present even though OSV-BAD failed expect(findings).toHaveLength(1); expect(findings[0]?.vulnerabilities).toEqual([goodDetail]); @@ -551,7 +551,7 @@ describe("scanPackages cache behavior", () => { getVulnMock.mockResolvedValue(freshDetail); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); // Both details should appear in the finding expect(findings[0]?.vulnerabilities).toEqual(expect.arrayContaining([cachedDetail, freshDetail])); @@ -618,7 +618,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir), { + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir), { directDependencyNames: new Set(["js-yaml"]), }); @@ -789,7 +789,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.validatedFirstFixedVersion).toBe("4.17.21"); @@ -829,7 +829,7 @@ describe("scanPackages cache behavior", () => { }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.validatedFirstFixedVersion).toBe("1.0.1"); @@ -860,7 +860,7 @@ describe("scanPackages cache behavior", () => { fetchMock.mockResolvedValue({ ok: false, status: 404 }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.maliciousUnverifiable).toBe(true); } finally { @@ -889,7 +889,7 @@ describe("scanPackages cache behavior", () => { fetchMock.mockResolvedValue({ ok: false, status: 404 }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.maliciousUnverifiable).toBeUndefined(); } finally { @@ -917,7 +917,7 @@ describe("scanPackages cache behavior", () => { fetchMock.mockResolvedValue({ ok: false, status: 404 }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.maliciousUnverifiable).toBeUndefined(); } finally { @@ -946,7 +946,7 @@ describe("scanPackages cache behavior", () => { fetchMock.mockResolvedValue({ ok: false, status: 404 }); try { - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.maliciousUnverifiable).toBeUndefined(); } finally { @@ -973,7 +973,7 @@ describe("scanPackages cache behavior", () => { try { (console.log as jest.Mock).mockClear(); - const findings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(findings).toHaveLength(1); expect(findings[0]?.vulnerabilities).toEqual([goodDetail]); expect(findings[0]?.unresolvedAdvisoryIds).toEqual(["OSV-TRANSIENT"]); @@ -1003,14 +1003,14 @@ describe("scanPackages cache behavior", () => { }); try { - const firstFindings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings: firstFindings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(firstFindings[0]?.unresolvedAdvisoryIds).toEqual(["OSV-RETRY"]); expect(callCount).toBe(1); queryBatchMock.mockClear(); getVulnMock.mockClear(); - const secondFindings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings: secondFindings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(secondFindings[0]?.vulnerabilities).toEqual([detail]); expect(getVulnMock).toHaveBeenCalledWith("OSV-RETRY"); } finally { @@ -1032,7 +1032,7 @@ describe("scanPackages cache behavior", () => { getVulnMock.mockRejectedValue(new Error("OSV vuln fetch failed for OSV-404-MISSING: 404 Not Found")); try { - const firstFindings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings: firstFindings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(firstFindings).toHaveLength(1); expect(firstFindings[0]?.vulnerabilities).toEqual([]); expect(firstFindings[0]?.unresolvedAdvisoryIds).toEqual(["OSV-404-MISSING"]); @@ -1040,7 +1040,7 @@ describe("scanPackages cache behavior", () => { queryBatchMock.mockClear(); getVulnMock.mockClear(); - const secondFindings = await scanPackages([pkg], 100, createOptions(cacheDir)); + const { findings: secondFindings } = await scanPackages([pkg], 100, createOptions(cacheDir)); expect(getVulnMock).not.toHaveBeenCalled(); expect(secondFindings[0]?.vulnerabilities).toEqual([]); expect(secondFindings[0]?.unresolvedAdvisoryIds).toEqual(["OSV-404-MISSING"]); @@ -1074,7 +1074,7 @@ describe("scanPackages cache behavior", () => { try { (console.log as jest.Mock).mockClear(); - const findings = await scanPackages([pkg1, pkg2], 100, { + const { findings } = await scanPackages([pkg1, pkg2], 100, { ...createOptions(cacheDir), json: false, }); @@ -1127,7 +1127,7 @@ describe("scanPackages cache behavior", () => { const debugLog = jest.fn(); try { - const findings = await scanPackages([pkgA, pkgB], 100, createOptions(cacheDir), { + const { findings } = await scanPackages([pkgA, pkgB], 100, createOptions(cacheDir), { directDependencyNames: new Set(["pkg-a"]), scanSource: "package-lock", scanFilePath: lockfilePath, @@ -1182,7 +1182,7 @@ describe("scanPackages cache behavior", () => { const debugLog = jest.fn(); try { - const findings = await scanPackages([pkgA, pkgC], 100, createOptions(cacheDir), { + const { findings } = await scanPackages([pkgA, pkgC], 100, createOptions(cacheDir), { directDependencyNames: new Set(["pkg-a"]), }, debugLog); @@ -1246,7 +1246,7 @@ describe("scanPackages cache behavior", () => { try { (console.log as jest.Mock).mockClear(); - const findings = await scanPackages([pkg], 100, { + const { findings } = await scanPackages([pkg], 100, { ...createOptions(cacheDir), json: false, }); @@ -1290,7 +1290,7 @@ describe("scanPackages cache behavior", () => { db.close(); try { - const findings = await scanPackages([pkg], 100, { + const { findings } = await scanPackages([pkg], 100, { ...createOptions(tempDir), offline: true, offlineDb: dbPath, diff --git a/tests/write-outputs.test.ts b/tests/write-outputs.test.ts index 95361bd6..9664d9ec 100644 --- a/tests/write-outputs.test.ts +++ b/tests/write-outputs.test.ts @@ -142,13 +142,50 @@ describe("writeOutputs", () => { const [,, planArg, metaArg] = writeCycloneDxReportMock.mock.calls[0] as any[]; expect(metaArg).toBeNull(); }); + }); - it("passes null projectMeta when package.json has no name field", async () => { - existsSyncMock.mockReturnValueOnce(true); - readFileSyncMock.mockReturnValueOnce(JSON.stringify({ version: "1.0.0" })); - await writeOutputs(makeOptions({ cdx: true }), mockScanState, mockScanInput, "/project"); - const [,, planArg, metaArg] = writeCycloneDxReportMock.mock.calls[0] as any[]; - expect(metaArg).toBeNull(); + describe("scan completeness JSON fields", () => { + it("includes complete:true and empty diagnostics for a healthy scan", async () => { + await writeOutputs(makeOptions({ json: true }), mockScanState, mockScanInput, "/tmp/project"); + const [, jsonContent] = writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1] as [string, string]; + const parsed = JSON.parse(jsonContent); + expect(parsed.status).toBe("ok"); + expect(parsed.complete).toBe(true); + expect(parsed.diagnostics).toEqual([]); + }); + + it("includes complete:false and diagnostics when completeness indicates a partial scan", async () => { + const partialState = { + ...mockScanState, + completeness: { + complete: false, + diagnostics: [ + { + code: "OSV_DETAIL_TRANSIENT_FAILURE" as const, + severity: "warning" as const, + message: "5 advisory detail lookups failed with a transient error", + impact: "detection" as const, + count: 5, + }, + { + code: "PACKUMENT_FETCH_FAILURE" as const, + severity: "warning" as const, + message: "2 packument fetches failed", + impact: "remediation" as const, + count: 2, + }, + ], + }, + }; + await writeOutputs(makeOptions({ json: true }), partialState, mockScanInput, "/tmp/project"); + const [, jsonContent] = writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1] as [string, string]; + const parsed = JSON.parse(jsonContent); + expect(parsed.status).toBe("partial"); + expect(parsed.complete).toBe(false); + expect(parsed.diagnostics).toEqual([ + expect.objectContaining({ code: "OSV_DETAIL_TRANSIENT_FAILURE", impact: "detection" }), + expect.objectContaining({ code: "PACKUMENT_FETCH_FAILURE", impact: "remediation" }), + ]); }); }); }); From 46a4ca4652f7c9ab0bcff290d05d718a1367f68d Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 25 Jul 2026 11:48:37 +0800 Subject: [PATCH 02/25] fix(scanner,output,multi-folder-scan): fix completeness propagation and reporting - Pass completeness to writeOutputs call site so JSON output receives real scan completeness instead of always reporting ok/complete - Add onTransientFailure callback to fetchPackument so scanner can track packument failures that were previously silently caught - Add completeness field to MultiFolderScanResult and propagate through multi-folder printer, JSON output, and HTML reports - Add aggregateMultiFolderCompleteness to deduplicate diagnostics across subfolders when aggregating counts - Fix compact mode zero-findings early return that skipped the incomplete scan warning - Differentiate HTML completeness banner text by diagnostic impact: detection failures vs remediation-only failures - Fix HTML banner diagRows join using literal backslash-n - Make ScanState.completeness required to prevent silent downgrades --- src/index.ts | 1 + src/output/html-reporter.ts | 16 ++++++++++++++-- src/output/multi-folder-printer.ts | 4 ++-- src/output/printers.ts | 7 ++++++- src/output/write-outputs.ts | 8 ++++---- src/remediation/npm-registry.ts | 7 ++++++- src/scan/multi-folder-scan.ts | 28 ++++++++++++++++++++++++++-- src/scanner.ts | 8 ++++---- tests/write-outputs.test.ts | 1 + 9 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 362e9489..3204738e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -666,6 +666,7 @@ if (parsedArgs) { coverage: scanState.coverage, minSeverity: scanState.minSeverity, tableFindings: scanState.tableFindings, + completeness: scanState.completeness, overrideFindings, maintenanceFindings, }, scanInput, projectPath); diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 042775b7..0906e509 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -635,15 +635,27 @@ function renderTransitiveContextCol(finding: SerializedFinding): string { function renderCompletenessBanner(data: ReportData): string { if (data.completeness == null || data.completeness.complete) return ""; + const hasDetectionFailure = data.completeness.diagnostics.some(d => d.impact === "detection"); + const hasRemediationFailure = data.completeness.diagnostics.some(d => d.impact === "remediation"); + + let bannerText: string; + if (hasDetectionFailure) { + bannerText = "Some vulnerability findings may be incomplete because the following issues occurred:"; + } else if (hasRemediationFailure) { + bannerText = "Vulnerability findings are available, but remediation guidance may be incomplete because the following issues occurred:"; + } else { + bannerText = "The scan result may be incomplete because the following issues occurred:"; + } + const diagRows = data.completeness.diagnostics.map(d => `
  • ${escapeHtml(d.message)}
  • ` - ).join("\\n"); + ).join("\n"); return `
    Scan data is incomplete -

    The scan result may be missing some findings because the following issues occurred:

    +

    ${bannerText}

      ${diagRows}
    `; diff --git a/src/output/multi-folder-printer.ts b/src/output/multi-folder-printer.ts index b7964707..739c1e90 100644 --- a/src/output/multi-folder-printer.ts +++ b/src/output/multi-folder-printer.ts @@ -49,9 +49,9 @@ export function printMultiFolderResults( } } printCoverage([...result.scanInput.notes, ...result.coverage]); - printFinalStatus(result.sorted); + printFinalStatus(result.sorted, 0, result.completeness); } else { - printCompactOutput(result.sorted, result.scanInput, { offline, all: !!options.all, subfolder: result.subfolder }); + printCompactOutput(result.sorted, result.scanInput, { offline, all: !!options.all, subfolder: result.subfolder, completeness: result.completeness }); } if (i < results.length - 1) { diff --git a/src/output/printers.ts b/src/output/printers.ts index ce01a2fd..5a69a434 100644 --- a/src/output/printers.ts +++ b/src/output/printers.ts @@ -425,7 +425,12 @@ export function printCompactOutput( } if (findings.length === 0) { - console.log(chalk.greenBright("✔ Scan complete. No known vulnerabilities found.")); + if (options?.completeness?.complete === false) { + console.log(chalk.yellow("⚠ No known vulnerabilities were resolved, but scan data is incomplete.")); + printIncompleteWarning(options.completeness); + } else { + console.log(chalk.greenBright("✔ Scan complete. No known vulnerabilities found.")); + } console.log(""); return; } diff --git a/src/output/write-outputs.ts b/src/output/write-outputs.ts index f2abc99e..953f0e1f 100644 --- a/src/output/write-outputs.ts +++ b/src/output/write-outputs.ts @@ -19,7 +19,7 @@ export type ScanState = { coverage: string[]; minSeverity: string; tableFindings: Finding[]; - completeness?: ScanCompleteness; + completeness: ScanCompleteness; }; function readProjectMeta(projectPath: string): ProjectMeta { @@ -59,9 +59,9 @@ export async function writeOutputs( source: scanInput.source, packageCount: scanInput.packages.length, findingCount: scanState.sorted.length, - status: scanState.completeness?.complete !== false ? "ok" : "partial", - complete: scanState.completeness?.complete ?? true, - diagnostics: scanState.completeness?.diagnostics ?? [], + status: scanState.completeness.complete ? "ok" : "partial", + complete: scanState.completeness.complete, + diagnostics: scanState.completeness.diagnostics, suggestedFixCommands: scanState.suggestedFixCommands, notes: [...scanInput.notes, ...scanState.coverage], warnings: scanInput.warnings, diff --git a/src/remediation/npm-registry.ts b/src/remediation/npm-registry.ts index 9e2fb7e7..1d25f24d 100644 --- a/src/remediation/npm-registry.ts +++ b/src/remediation/npm-registry.ts @@ -42,7 +42,10 @@ export function clearPackumentCache(): void { packumentCache.clear(); } -export async function fetchPackument(packageName: string): Promise { +export async function fetchPackument( + packageName: string, + onTransientFailure?: (packageName: string) => void, +): Promise { const cached = packumentCache.get(packageName); if (cached) return cached; @@ -74,6 +77,7 @@ export async function fetchPackument(packageName: string): Promise r.completeness?.diagnostics ?? []).map((d, _i, arr) => { + const sameCode = arr.filter(x => x.code === d.code); + if (sameCode.length > 1) { + const total = sameCode.reduce((sum, x) => sum + x.count, 0); + return { ...d, count: total, message: d.message.replace(/\d+/, String(total)) }; + } + return d; + }); + const seen = new Set(); + const deduped = allDiagnostics.filter(d => { + const key = d.code; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return { complete: deduped.length === 0, diagnostics: deduped }; +} + export async function handleMultiFolderScan(params: { projectRoot: string; batchSize: number; @@ -138,6 +161,7 @@ export async function handleMultiFolderScan(params: { multiFolder: true, folders: results.map(r => r.subfolder), findings: allFindings, + completeness: aggregateMultiFolderCompleteness(results), ...(params.options.checkOverrides ? { overrideFindings: results.flatMap(r => diff --git a/src/scanner.ts b/src/scanner.ts index 926c5e70..e6192957 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -480,10 +480,10 @@ export async function scanPackages( } if (packumentsToPrewarm.size > 0) { await runWithConcurrency([...packumentsToPrewarm], 8, async (name) => { - try { await fetchPackument(name); } catch { - packumentFailures.add(name); - log("Packument pre-warm failed", { package: name }); - } + await fetchPackument(name, (pkg) => { + packumentFailures.add(pkg); + log("Packument pre-warm failed", { package: pkg }); + }); }); } } diff --git a/tests/write-outputs.test.ts b/tests/write-outputs.test.ts index 9664d9ec..41d6d9c5 100644 --- a/tests/write-outputs.test.ts +++ b/tests/write-outputs.test.ts @@ -58,6 +58,7 @@ const mockScanState = { coverage: [], minSeverity: "medium" as const, tableFindings: [], + completeness: { complete: true, diagnostics: [] }, }; const mockScanInput: ScanInput = { From a95e270f6b835476a3ef985f73d610c1a8e848be Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 25 Jul 2026 12:20:14 +0800 Subject: [PATCH 03/25] fix(scanner,output,html): propagate packument failure tracking through all paths - Pass onTransientFailure callback through resolvePublishedFixVersion, resolveLowestKnownNonVulnerableVersion, validateDirectFixTargets, and resolveChainFix so direct-only scans also track registry failures - Fix printFinalStatus zero-findings path to show incomplete warning instead of conflicting green success when scan data is incomplete - Export renderCompletenessBanner and add per-folder + aggregate completeness banners to multi-folder HTML reports - Update footer text from 'Re-run the scan' to 'Resolve the issues above and re-run the scan' --- src/output/html-reporter.ts | 12 ++++----- src/output/multi-folder-html-reporter.ts | 27 +++++++++++++++++++- src/output/printers.ts | 6 +++-- src/remediation/npm-registry.ts | 10 +++++--- src/remediation/transitive-chain-resolver.ts | 11 +++++--- src/scanner.ts | 11 +++++--- tests/output.test.ts | 2 +- 7 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 0906e509..9c7f6c1f 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -254,7 +254,7 @@ export function renderHtmlReport(data: ReportData): string { const totalCVEs = new Set(data.findings.flatMap(f => f.vulnerabilities.map(v => v.id))).size; const noticesHtml = renderNotices(data.notes, data.warnings); - const completenessBannerHtml = renderCompletenessBanner(data); + const completenessBannerHtml = renderCompletenessBanner(data.completeness); const fixPlanHtml = renderFixPlan(data.suggestedFixCommands); const skippedKeys = new Set( (data.suggestedFixCommands?.skipped ?? []).map(s => `${s.package}@${s.version}`) @@ -632,11 +632,11 @@ function renderTransitiveContextCol(finding: SerializedFinding): string { `; } -function renderCompletenessBanner(data: ReportData): string { - if (data.completeness == null || data.completeness.complete) return ""; +export function renderCompletenessBanner(completeness: ScanCompleteness | null | undefined): string { + if (completeness == null || completeness.complete) return ""; - const hasDetectionFailure = data.completeness.diagnostics.some(d => d.impact === "detection"); - const hasRemediationFailure = data.completeness.diagnostics.some(d => d.impact === "remediation"); + const hasDetectionFailure = completeness.diagnostics.some(d => d.impact === "detection"); + const hasRemediationFailure = completeness.diagnostics.some(d => d.impact === "remediation"); let bannerText: string; if (hasDetectionFailure) { @@ -647,7 +647,7 @@ function renderCompletenessBanner(data: ReportData): string { bannerText = "The scan result may be incomplete because the following issues occurred:"; } - const diagRows = data.completeness.diagnostics.map(d => + const diagRows = completeness.diagnostics.map(d => `
  • ${escapeHtml(d.message)}
  • ` ).join("\n"); diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index c07655cd..f236528d 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -7,12 +7,13 @@ import { openInBrowser, renderFindingRow, renderFixPlan, + renderCompletenessBanner, serializeHtmlFinding, } from "./html-reporter.js"; import type { SerializedFinding } from "./html-reporter.js"; import { loadLogoDataUri } from "./logos.js"; import { pluralize } from "../utils/string.js"; -import type { SeverityLabel } from "../types.js"; +import type { SeverityLabel, ScanCompleteness } from "../types.js"; type SevCounts = Record; @@ -59,6 +60,7 @@ function renderFolderSection( const findingRowsHtml = serialized.map((f, i) => renderFindingRow(f, idxOffset + i)).join("\n"); const fixPlanHtml = renderFixPlan(result.suggestedFixCommands); const noticesHtml = renderFolderNotices(result.coverage); + const completenessBannerHtml = renderCompletenessBanner(result.completeness); const emptyRow = result.sorted.length === 0 ? `No findings` @@ -71,6 +73,7 @@ function renderFolderSection( ${escapeHtml(summaryText)}
    + ${completenessBannerHtml} ${fixPlanHtml} ${noticesHtml}
    @@ -127,6 +130,26 @@ export async function writeMultiFolderHtmlReport(params: { ); const folderCount = params.results.length; + const aggregateCompleteness = params.results + .reduce( + (acc, r) => { + if (r.completeness && !r.completeness.complete) { + acc.complete = false; + for (const d of r.completeness.diagnostics) { + const existing = acc.diagnostics.find(x => x.code === d.code); + if (existing) { + existing.count += d.count; + } else { + acc.diagnostics.push({ ...d }); + } + } + } + return acc; + }, + { complete: true, diagnostics: [] }, + ); + const aggregateBannerHtml = renderCompletenessBanner(aggregateCompleteness); + // Pre-serialize findings once per folder for both HTML rendering and JS filter/sort const allSerialized: SerializedFinding[][] = params.results.map(r => r.sorted.map(f => serializeHtmlFinding(f, r.suggestedFixCommands)), @@ -221,6 +244,8 @@ details[open] .folder-summary::before{transform:rotate(90deg)}
    ${totalCVEs}CVEs
    +${aggregateBannerHtml} + ${folderSectionsHtml}