Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d1e7e77
feat(scanner,output): report incomplete scan data with diagnostics
luojiyin1987 Jul 25, 2026
46a4ca4
fix(scanner,output,multi-folder-scan): fix completeness propagation a…
luojiyin1987 Jul 25, 2026
a95e270
fix(scanner,output,html): propagate packument failure tracking throug…
luojiyin1987 Jul 25, 2026
22f4105
fix(scanner,npm-registry,output): use observer pattern for unified pa…
luojiyin1987 Jul 25, 2026
3928786
fix(npm-registry,multi-folder-scan): call onResolved on 404 too, use …
luojiyin1987 Jul 25, 2026
2f2e37d
refactor(scanner): use formatDiagnosticMessage in buildCompletenessDi…
luojiyin1987 Jul 25, 2026
e45e9ef
refactor: remove callback param threading, compress repetitive code
luojiyin1987 Jul 25, 2026
51a0b0f
style(scanner,printers): rename addDiagnostic, fix incomplete color p…
luojiyin1987 Jul 25, 2026
09be1d5
style(printers): expand printCompactOutput options type to multi-line
luojiyin1987 Jul 25, 2026
9825c77
test(write-outputs): restore accidentally deleted null-projectMeta test
luojiyin1987 Jul 25, 2026
095dedd
refactor(npm-registry): replace global mutable observer with AsyncLoc…
luojiyin1987 Jul 25, 2026
f494037
refactor(scanner): use runWithObserver to scope registry observer
luojiyin1987 Jul 25, 2026
15f0ed4
fix(printers): remove duplicate incomplete banner and fix compact footer
luojiyin1987 Jul 25, 2026
20fb7a1
test(output): update tests for printIncompleteDiagnostics rename
luojiyin1987 Jul 25, 2026
d2d6fcf
refactor(npm-registry): cache fetch results and notify observers per …
luojiyin1987 Jul 25, 2026
6b65d97
test(scanner-cache): cover concurrent observer isolation for shared f…
luojiyin1987 Jul 25, 2026
e543601
fix(printers): print diagnostics for incomplete compact scan with no …
luojiyin1987 Jul 25, 2026
5c23118
test(output): cover incomplete scan output across verbose and compact…
luojiyin1987 Jul 25, 2026
5f85435
fix(printers): use correct plural form for vulnerability count
luojiyin1987 Jul 25, 2026
84bd6ae
test(output): assert plural form in printFinalStatus
luojiyin1987 Jul 25, 2026
abcfd11
fix(scanner): block incomplete OSV scan baselines
luojiyin1987 Jul 25, 2026
59115c8
test(ratchet): cover incomplete scan handling
luojiyin1987 Jul 25, 2026
253f511
Merge branch 'main' into feat/output-report-incomplete-scan-data
luojiyin1987 Jul 25, 2026
85a4ef0
fix(scanner): refine incomplete scan diagnostics
luojiyin1987 Jul 26, 2026
ccaffc0
fix(scanner): report remediation package count
luojiyin1987 Jul 26, 2026
7278f92
style(printers): remove trailing blank line after diagnostics
luojiyin1987 Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
printCompactOutput,
printCoverage,
printFinalStatus,
printIncompleteDiagnostics,
printOverrideHint,
printSkippedDependencies,
printSuggestedFixCommands,
Expand Down Expand Up @@ -578,6 +579,27 @@ if (parsedArgs) {
// baseline. Re-saving here would silently absorb a new vulnerability into a
// fresh baseline, so a ratcheted CI job could never fail on a regression.
if (options.ratchet) {
// A baseline records detection results, so detection-impact gaps make it
// untrustworthy: refuse to save or evaluate one from partial data.
// Remediation-only gaps do not change the finding set, so they only warn.
if (!scanState.completeness.complete) {
printIncompleteDiagnostics(scanState.completeness);
}
const hasDetectionGaps = scanState.completeness.diagnostics.some(d => d.impact === "detection");
if (hasDetectionGaps) {
console.log(chalk.red("Scan data is incomplete. Refusing to create or evaluate a baseline from partial detection data. Resolve the issues above and re-run --ratchet."));
auditLogHandle.emit({
ts: new Date().toISOString(),
type: "scan.finished",
schemaVersion: 1,
durationMs: Date.now() - scanStartTime,
findingsCount: scanState.sorted.length,
exitCode: EXIT_ERROR,
});
auditLogHandle.close();
process.exit(EXIT_ERROR);
return;
}
const outcome = ratchetOutcome(baseline, scanState.sorted);
if (outcome.action === "save") {
writeBaseline(projectArg ?? ".", scanState.sorted);
Expand Down Expand Up @@ -666,6 +688,7 @@ if (parsedArgs) {
coverage: scanState.coverage,
minSeverity: scanState.minSeverity,
tableFindings: scanState.tableFindings,
completeness: scanState.completeness,
overrideFindings,
maintenanceFindings,
}, scanInput, projectPath);
Expand Down Expand Up @@ -702,13 +725,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 }));
}
Expand Down Expand Up @@ -740,6 +763,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,
Expand Down Expand Up @@ -826,7 +850,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,
Expand Down Expand Up @@ -887,6 +911,7 @@ async function scanProject(params: {
tableFindings,
suggestedFixCommands,
allPackages: params.scanInput.packages,
completeness,
};
}

42 changes: 41 additions & 1 deletion src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +40,7 @@ export type ReportData = {
skippedDependencies: string[];
overrideFindings?: ReadonlyArray<OverrideFinding>;
maintenanceFindings?: ReadonlyArray<MaintenanceFinding>;
completeness?: ScanCompleteness;
};

export function buildReportData(params: {
Expand All @@ -55,6 +56,7 @@ export function buildReportData(params: {
skippedDependencies: string[];
overrideFindings?: ReadonlyArray<OverrideFinding>;
maintenanceFindings?: ReadonlyArray<MaintenanceFinding>;
completeness?: ScanCompleteness;
}): ReportData {
return {
projectPath: params.projectPath,
Expand All @@ -72,6 +74,7 @@ export function buildReportData(params: {
skippedDependencies: params.skippedDependencies,
overrideFindings: params.overrideFindings,
maintenanceFindings: params.maintenanceFindings,
completeness: params.completeness,
};
}

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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.completeness);
const fixPlanHtml = renderFixPlan(data.suggestedFixCommands);
const skippedKeys = new Set(
(data.suggestedFixCommands?.skipped ?? []).map(s => `${s.package}@${s.version}`)
Expand Down Expand Up @@ -319,6 +329,7 @@ ${REPORT_STYLES}
<div class="sev-card total"><span class="count">${totalCVEs}</span><span class="label">CVEs</span></div>
</div>

${completenessBannerHtml}
${fixPlanHtml}

<div class="findings-section">
Expand Down Expand Up @@ -621,6 +632,35 @@ function renderTransitiveContextCol(finding: SerializedFinding): string {
</div>`;
}

export function renderCompletenessBanner(completeness: ScanCompleteness | null | undefined): string {
if (completeness == null || completeness.complete) return "";

const hasDetectionFailure = completeness.diagnostics.some(d => d.impact === "detection");
const hasRemediationFailure = 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 = completeness.diagnostics.map(d =>
`<li class="diag-item ${escapeHtml(d.severity)} ${escapeHtml(d.impact)}">${escapeHtml(d.message)}</li>`
).join("\n");

return `<div class="completeness-banner">
<div class="completeness-banner-icon">⚠</div>
<div class="completeness-banner-body">
<strong>Scan data is incomplete</strong>
<p>${bannerText}</p>
<ul>${diagRows}</ul>
</div>
</div>`;
}

function renderNotices(notes: string[], warnings: string[]): string {
if (notes.length === 0 && warnings.length === 0) return "";
const total = notes.length + warnings.length;
Expand Down
9 changes: 9 additions & 0 deletions src/output/multi-folder-html-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import type { MultiFolderScanResult } from "../scan/multi-folder-scan.js";
import { aggregateMultiFolderCompleteness } from "../scan/multi-folder-scan.js";
import {
REPORT_STYLES,
escapeHtml,
openInBrowser,
renderFindingRow,
renderFixPlan,
renderCompletenessBanner,
serializeHtmlFinding,
} from "./html-reporter.js";
import type { SerializedFinding } from "./html-reporter.js";
Expand Down Expand Up @@ -59,6 +61,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
? `<tr><td colspan="6" style="text-align:center;padding:24px;color:#8b949e">No findings</td></tr>`
Expand All @@ -71,6 +74,7 @@ function renderFolderSection(
<span class="folder-counts">${escapeHtml(summaryText)}</span>
</summary>
<div class="folder-body">
${completenessBannerHtml}
${fixPlanHtml}
${noticesHtml}
<div class="findings-section" style="margin:16px 32px 32px">
Expand Down Expand Up @@ -127,6 +131,9 @@ export async function writeMultiFolderHtmlReport(params: {
);
const folderCount = params.results.length;

const aggregateCompleteness = aggregateMultiFolderCompleteness(params.results);
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)),
Expand Down Expand Up @@ -221,6 +228,8 @@ details[open] .folder-summary::before{transform:rotate(90deg)}
<div class="sev-card total"><span class="count">${totalCVEs}</span><span class="label">CVEs</span></div>
</div>

${aggregateBannerHtml}

${folderSectionsHtml}

<div class="report-footer">
Expand Down
4 changes: 2 additions & 2 deletions src/output/multi-folder-printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
93 changes: 68 additions & 25 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -320,40 +320,49 @@ 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("────────────────────────────────"));

const incomplete = completeness?.complete === false;
const overrideSuffix = overrideCount > 0
? `, ${overrideCount} override hygiene ${pluralize(overrideCount, "issue")}`
: "";

if (findings.length === 0) {
if (overrideCount > 0) {
if (incomplete) {
console.log(chalk.yellow("⚠ No known vulnerabilities were resolved, but scan data is incomplete."));
} else if (overrideCount > 0) {
console.log(chalk.yellow(`▲ Scan complete. No known vulnerabilities found, but ${overrideCount} override hygiene ${pluralize(overrideCount, "issue")} detected.`));
} 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;

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;
} else {
const criticalCount = findings.filter(f => f.severity === "critical").length;
const highCount = findings.filter(f => f.severity === "high").length;
const urgent = criticalCount > 0 || highCount > 0;

const label = `${findings.length} ${pluralize(findings.length, "vulnerability", "vulnerabilities")}`;

if (incomplete) {
const severityNote = urgent ? ` (${criticalCount} critical, ${highCount} high)` : "";
const action = urgent ? "Start with the priority fixes above." : "Review the suggested fix plan above.";
const renderStatus = urgent ? chalk.redBright : chalk.yellow;
console.log(renderStatus(
`⚠ Partial scan: ${label} resolved${overrideSuffix}${severityNote}. ${action}`
));
} else if (urgent) {
console.log(chalk.redBright(
`✖ Scan complete. ${label}${overrideSuffix} found (${criticalCount} critical, ${highCount} high). Start with the priority fixes above.`
));
} else {
console.log(chalk.yellow(
`▲ Scan complete. ${label}${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.`
)
);
printIncompleteDiagnostics(completeness);
}

export function printOverrideHint(): void {
Expand Down Expand Up @@ -417,16 +426,27 @@ 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) {
console.log(chalk.gray("Package manager: ") + options.packageManager);
}

if (findings.length === 0) {
console.log(chalk.greenBright("✔ Scan complete. No known vulnerabilities found."));
console.log("");
if (options?.completeness?.complete === false) {
console.log(chalk.yellow("⚠ No known vulnerabilities were resolved, but scan data is incomplete."));
printIncompleteDiagnostics(options.completeness);
} else {
console.log(chalk.greenBright("✔ Scan complete. No known vulnerabilities found."));
console.log("");
}
return;
}

Expand Down Expand Up @@ -603,8 +623,18 @@ export function printCompactOutput(
}

// Footer
const incomplete = options?.completeness?.complete === false;
const urgentCount = counts.critical + counts.high;
if (urgentCount > 0) {
if (incomplete) {
const label = urgentCount > 0
? `${urgentCount} urgent ${pluralize(urgentCount, "issue")} resolved`
: `${findings.length} ${pluralize(findings.length, "issue")} resolved`;
console.log(
chalk.yellow(
`⚠ Partial scan: ${label}.`
)
);
} else if (urgentCount > 0) {
console.log(
chalk.redBright(
`✖ Scan complete. ${urgentCount} urgent ${pluralize(urgentCount, "issue")} found.`
Expand All @@ -620,7 +650,20 @@ export function printCompactOutput(
if (!options?.all) {
console.log(chalk.gray(`Run with ${chalk.whiteBright("--verbose")} for fix plan, paths, and full table.`));
}
printIncompleteDiagnostics(options?.completeness);
if (!incomplete) {
console.log("");
}
}

export function printIncompleteDiagnostics(completeness?: ScanCompleteness): void {
if (!completeness || completeness.complete) return;
console.log("");
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(" Resolve the issues above and re-run the scan."));
}

function renderSeverityTable(counts: { critical: number; high: number; medium: number; low: number; unknown: number }): string {
Expand Down
Loading