From 27d5eb6c784d85ba17dd474d29c8c84da3e8d403 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 22:12:47 +0000 Subject: [PATCH 1/3] feat: emit stable warning codes on degrade-and-continue paths Build, scan, and SBOM export warnings now carry a VG_WARN_* code in the human line and in existing JSON channels, defined once in a registry. Success, failure, and exit codes are unchanged. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- ARCHITECTURE.md | 3 + DOCS.md | 49 +++++- src/commands/build.ts | 5 +- src/core-open/formatters/markdown.ts | 11 +- src/core-open/formatters/text.ts | 10 +- src/core-open/run-core-scan.ts | 35 ++++- src/core-open/scanners/dart-scanner.ts | 5 +- src/core-open/scanners/docker-scanner.ts | 3 +- src/core-open/scanners/dotnet-scanner.ts | 5 +- src/core-open/scanners/elixir-scanner.ts | 3 +- src/core-open/scanners/go-scanner.ts | 5 +- src/core-open/scanners/helm-scanner.ts | 3 +- src/core-open/scanners/java-scanner.ts | 5 +- src/core-open/scanners/node-scanner.ts | 5 +- src/core-open/scanners/php-scanner.ts | 5 +- src/core-open/scanners/python-scanner.ts | 5 +- src/core-open/scanners/ruby-scanner.ts | 5 +- src/core-open/scanners/rust-scanner.ts | 5 +- src/core-open/scanners/swift-scanner.ts | 5 +- src/core-open/scanners/terraform-scanner.ts | 3 +- src/core-open/scoring/cvss.ts | 5 +- src/core-open/scoring/drift-score.ts | 5 +- src/core-open/types.ts | 11 ++ src/engine/build.ts | 59 +++++-- src/engine/discover.ts | 17 +- src/engine/export.ts | 2 + src/engine/parse-worker.ts | 3 +- src/engine/pool.ts | 3 +- src/reporting/commands/sbom.test.ts | 7 + src/reporting/commands/sbom.ts | 79 ++++++---- src/reporting/formatters/markdown.ts | 11 +- src/reporting/formatters/text.ts | 10 +- src/reporting/types.ts | 5 + src/warnings/codes.ts | 166 ++++++++++++++++++++ test/sbom-lockfile-merge.test.ts | 3 +- test/warning-codes.test.ts | 153 ++++++++++++++++++ 36 files changed, 625 insertions(+), 89 deletions(-) create mode 100644 src/warnings/codes.ts create mode 100644 test/warning-codes.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 475150fb..38d329c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,6 +87,9 @@ src/ │ ├── config.ts, credentials.ts, regions.ts, version.ts │ └── utils/ │ +├── warnings/ Stable degrade-and-continue warning codes +│ └── codes.ts One registry (`VG_WARN_…`); published strings stay stable +│ └── util/ Shared helpers ├── output.ts, progress.ts, logo.ts, exit.ts ``` diff --git a/DOCS.md b/DOCS.md index f31fd5c0..1bc3f4e4 100644 --- a/DOCS.md +++ b/DOCS.md @@ -98,6 +98,7 @@ For a quick overview, see the [README](./README.md). This document covers everyt - [Advisories with several ids](#advisories-with-several-ids) - [Markdown](#markdown) - [JUnit](#junit) + - [Degrade warning codes](#degrade-warning-codes) - [Configuration](#configuration) - [vibgrate.config.ts](#vibgrateconfigts) - [Thresholds](#thresholds) @@ -1527,9 +1528,12 @@ separator), that component stays in the document and the purl is omitted. CycloneDX sets `vibgrate:purlStatus` to `unavailable` and records the reason on `vibgrate:purlWarning`. SPDX omits the purl externalRef, records `purlStatus=unavailable` on the package annotation, and repeats the -reason in a second annotation. `vg sbom export` prints the same warning on -stderr. The warning names the package and its ecosystem. The purl rules above -are the identity a scanner should store. +reason in a second annotation. CycloneDX also sets `vibgrate:purlWarningCode` +to `VG_WARN_PURL_UNAVAILABLE`. SPDX adds an annotation whose comment is that +same code. `vg sbom export` prints the code and the reason on stderr. The +warning names the package and its ecosystem. The purl rules above +are the identity a scanner should store. The full code list is in +[Degrade warning codes](#degrade-warning-codes). #### Component identity @@ -1661,8 +1665,10 @@ A value that cannot be represented, such as `LicenseRef-has space`, stays visible. The component remains in the document. CycloneDX sets `vibgrate:licenseStatus` to `unrepresentable` and records the reason on `vibgrate:licenseWarning`. SPDX adds `licenseStatus=unrepresentable` to the -package annotation and repeats the reason in a second annotation. `vg sbom -export` prints the same warning on stderr. The warning names the ecosystem, +package annotation and repeats the reason in a second annotation. Both formats +also record `VG_WARN_LICENSE_UNPARSEABLE` (`vibgrate:licenseWarningCode` on +CycloneDX; an SPDX annotation whose comment is that code). `vg sbom +export` prints the code and the same warning on stderr. The warning names the ecosystem, the package, and the version. No license field is left as an empty string, and no license is filled in from a guess. @@ -1704,8 +1710,9 @@ wins. A later copy is not an error. `vibgrate:projects` (SPDX: `projects=` on the package annotation) lists every project that contributed the identity, sorted. When a later manifest row carries different drift, spec, or license metadata, that metadata is dropped, the component stays, and -`vibgrate:mergeWarning` records the drop. The same warning is an additional SPDX -annotation, and `vg sbom export` prints it on stderr. +`vibgrate:mergeWarning` records the drop. CycloneDX sets `vibgrate:mergeWarningCode` +to `VG_WARN_SBOM_MERGE`. The same warning is an additional SPDX +annotation, followed by an annotation whose comment is that code, and `vg sbom export` prints the code and the warning on stderr. `vibgrate:scope` (SPDX: `scope=` on the package annotation) is `direct` when a scanned manifest declared that exact identity, and `transitive` when the @@ -5338,6 +5345,34 @@ have. --- +## Degrade warning codes + +`vg build`, `vg scan`, and `vg sbom export` keep going when one file, path, or field cannot be used. Those warnings carry a short code so a CI check or an assistant can match the code instead of the prose. + +A published code does not change. A new warning gets a new code. Codes are fixed tokens: they do not include a path, a file body, or a secret. The message next to the code is the human detail. When several warnings fire they are ordered by code, then by message, and an identical pair is emitted once. + +On `vg build`, the human line on stderr names the codes that fired. `vg build --json` keeps `warnings` as an array of strings, each `CODE: message`, and adds `warningRecords`: an array of `{ "code", "message" }` in that same order. Exit codes are unchanged. + +On `vg scan`, the same strings are printed when a path times out, a file is over the scan size limit, or a baseline file cannot be read. `vg scan --format json` and `.vibgrate/scan_result.json` add `degradeWarnings` (omitted when nothing fired) with the same `{ "code", "message" }` objects. A license that does not resolve still uses rule id `vibgrate/license-parse-failed` and adds `details.warnCode`. A CVSS vector that does not parse still uses `cvss-vector-parse-failed` and adds `warnCode` on `cvssDiagnostic`. `vg report` prints `degradeWarnings` and any warn code stored on a finding. + +On `vg sbom export`, stderr lines are `warning: CODE: message`. CycloneDX keeps the existing warning properties and adds a code property beside each one (`vibgrate:purlWarningCode`, `vibgrate:licenseWarningCode`, `vibgrate:mergeWarningCode`, and `vibgrate:warningCode` on a document-level license-parse note). SPDX keeps the existing annotation and adds one whose comment is the code. The document id does not include these new fields. + +| Code | When it fires | +| --- | --- | +| `VG_WARN_PARSE_DEGRADE` | A source file threw during parse. The build keeps the other files. | +| `VG_WARN_SKIPPED_FILE` | A file was over the build per-file cap or the scan size limit and was not read. | +| `VG_WARN_SKIPPED_SUBTREE` | A directory could not be read during `vg build`, or a scan path timed out and was skipped. | +| `VG_WARN_TSC_SKIPPED` | The TypeScript resolver was skipped because the file count was over `VG_TSC_MAX_FILES`. The heuristic resolver still ran. | +| `VG_WARN_TOOLCHAIN_DEGRADE` | Infrastructure or CI extraction degraded for one file. The rest of the map was still written. | +| `VG_WARN_PURL_UNAVAILABLE` | An SBOM component was kept and its Package URL was omitted. | +| `VG_WARN_LICENSE_UNPARSEABLE` | A declared license did not resolve to SPDX, or could not be written on the SBOM. | +| `VG_WARN_CVSS_UNPARSEABLE` | A CVSS vector was present and did not parse. The advisory is kept. | +| `VG_WARN_SBOM_MERGE` | An SBOM merge dropped or guessed a fact and still wrote the component. | +| `VG_WARN_BASELINE_UNREADABLE` | A baseline file could not be read. The scan continued without a delta. | +| `VG_WARN_SCAN_DEGRADE` | One project scanner failed and that project was skipped. | + +An unknown optional lockfile field is not one of these codes. When that warning exists it gets its own code in the same registry, rather than reusing one of the rows above. + ## Exit Codes CI and agents branch on these, so they are a stable contract. diff --git a/src/commands/build.ts b/src/commands/build.ts index 63469a02..b12c425d 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -26,6 +26,7 @@ import { UnsafeRootError } from '../core-open/utils/root-safety.js'; import { CliError, ExitCode, usageError } from '../util/exit.js'; import { resolveSelfJsEntry } from '../util/cli-invocation.js'; import { c, info, out, json } from '../util/output.js'; +import { warningCodeSummary } from '../warnings/codes.js'; import { printLogo } from '../util/logo.js'; import { ProgressBar } from '../util/progress.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; @@ -281,6 +282,7 @@ export async function runBuild( attestation, timingMs: result.timing.totalMs, warnings: result.warnings, + warningRecords: result.warningRecords, activity: activity.toJSON(), }); return; @@ -339,7 +341,8 @@ export async function runBuild( .join(' '); info(` → ${artifactList}`); if (result.warnings.length) { - info(c.yellow(` ${result.warnings.length} parse warning(s) — run with --json for detail`)); + const codes = warningCodeSummary(result.warningRecords); + info(c.yellow(` ${result.warnings.length} parse warning(s) [${codes}] — run with --json for detail`)); } if (attestation) { for (const n of attestNotices) info(c.yellow(` ${n}`)); diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index 59806c89..1f283d62 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -4,6 +4,7 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; import { securityPacksLabel } from './text.js'; +import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; /** Rows shown before the infrastructure-findings table is cut with an "… N more" line. */ const SECURITY_ROWS_MAX = 50; @@ -181,11 +182,19 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(`|-------|------|---------|----------|`); for (const f of artifact.findings) { const emoji = f.level === 'error' ? '🔴' : f.level === 'warning' ? '🟡' : '🔵'; - lines.push(`| ${emoji} ${f.level} | ${f.ruleId} | ${f.message} | ${f.location} |`); + lines.push(`| ${emoji} ${f.level} | ${ruleWithWarnCode(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); } lines.push(''); } + const degradeLines = degradeWarningBlock(artifact.degradeWarnings); + if (degradeLines.length) { + lines.push(`## ${degradeLines[0]}`); + lines.push(''); + for (const line of degradeLines.slice(1)) lines.push(`- ${line.trim()}`); + lines.push(''); + } + if (artifact.delta !== undefined) { const dir = artifact.delta > 0 ? '📈' : artifact.delta < 0 ? '📉' : '➡️'; lines.push(`## Drift Delta: ${dir} ${artifact.delta > 0 ? '+' : ''}${artifact.delta} vs baseline`); diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index a895d186..1d529d15 100644 --- a/src/core-open/formatters/text.ts +++ b/src/core-open/formatters/text.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import type { ScanArtifact, BillingSummary, ExtendedScanResults, InventoryItem, ServiceDependencyItem, ArchitectureResult, SecurityFinding, SecuritySection } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; import { humanFindingText } from './fix-hint.js'; +import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; import { driftBar } from '../ui/bar.js'; import { titleBox, panelBox } from '../ui/box.js'; @@ -132,12 +133,19 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) const icon = f.level === 'error' ? chalk.red('✖') : f.level === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ'); const shown = humanFindingText(f); lines.push(` ${icon} ${shown.message}`); - lines.push(chalk.dim(` ${f.ruleId} in ${f.location}`)); + lines.push(chalk.dim(` ${ruleWithWarnCode(f.ruleId, f.details)} in ${f.location}`)); if (shown.hint) lines.push(chalk.dim(` ${shown.hint}`)); } lines.push(''); } + const degradeLines = degradeWarningBlock(artifact.degradeWarnings); + if (degradeLines.length) { + lines.push(chalk.bold.underline(` ${degradeLines[0]}`)); + for (const line of degradeLines.slice(1)) lines.push(chalk.yellow(line)); + lines.push(''); + } + // Priority actions const actions = generatePriorityActions(artifact); if (actions.length > 0) { diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 9f512403..b5086c0f 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -38,6 +38,7 @@ import { formatMarkdown } from './formatters/markdown.js'; import { loadConfig, appendExcludePatterns } from './config.js'; import { pathExists, readJsonFile, writeJsonFile, writeTextFile, ensureDir, FileCache, quickTreeCount } from './utils/fs.js'; import { assertSafeWalkRoot } from './utils/root-safety.js'; +import { WarningCodes, degradeLine, stableWarningRecords, type WarningRecord } from '../warnings/codes.js'; import { detectVcs } from './utils/vcs.js'; import { isCiEnvironment, hasVibgrateWorkflow } from './utils/ci-env.js'; import { resolveRepositoryName } from './utils/repository-name.js'; @@ -762,15 +763,20 @@ export async function runCoreScan( progress.finish(); - const stuckPaths = fileCache.stuckPaths; - const skippedLarge = fileCache.skippedLargeFiles; + const stuckPaths = [...fileCache.stuckPaths].sort(); + const skippedLarge = [...fileCache.skippedLargeFiles].sort(); + const degradeWarnings: WarningRecord[] = []; if (stuckPaths.length > 0) { console.log( - chalk.yellow(`\n⚠ ${stuckPaths.length} path${stuckPaths.length === 1 ? '' : 's'} timed out (>${Math.round(projectScanTimeoutMs / 1000)}s) and ${stuckPaths.length === 1 ? 'was' : 'were'} skipped:`), + chalk.yellow(`\n⚠ ${degradeLine(WarningCodes.SKIPPED_SUBTREE, `${stuckPaths.length} path${stuckPaths.length === 1 ? '' : 's'} timed out (>${Math.round(projectScanTimeoutMs / 1000)}s) and ${stuckPaths.length === 1 ? 'was' : 'were'} skipped`)}:`), ); for (const d of stuckPaths) { console.log(chalk.dim(` → ${d}`)); + degradeWarnings.push({ + code: WarningCodes.SKIPPED_SUBTREE, + message: `${d}: skipped — scan timed out`, + }); } const newExcludes = stuckPaths.map((d) => `${d}/**`); const updated = await appendExcludePatterns(rootDir, newExcludes); @@ -783,8 +789,14 @@ export async function runCoreScan( const sizeLimit = config.maxFileSizeToScan ?? 5_242_880; const sizeMB = (sizeLimit / 1_048_576).toFixed(0); console.log( - chalk.yellow(`\n⚠ ${skippedLarge.length} file${skippedLarge.length === 1 ? '' : 's'} skipped (>${sizeMB} MB):`), + chalk.yellow(`\n⚠ ${degradeLine(WarningCodes.SKIPPED_FILE, `${skippedLarge.length} file${skippedLarge.length === 1 ? '' : 's'} skipped (>${sizeMB} MB)`)}:`), ); + for (const f of skippedLarge) { + degradeWarnings.push({ + code: WarningCodes.SKIPPED_FILE, + message: `${f}: skipped — file exceeds the scan size limit`, + }); + } for (const f of skippedLarge.slice(0, 10)) { console.log(chalk.dim(` → ${f}`)); } @@ -845,11 +857,17 @@ export async function runCoreScan( const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : []; artifact.baselineComparison = compareBaselineFindings(artifact.findings, baselineFindings); } catch { - console.error(chalk.yellow(`Warning: Could not read baseline file: ${baselinePath}`)); + const where = baselineWarningPath(rootDir, baselinePath); + const message = `Could not read baseline file: ${where}`; + console.error(chalk.yellow(`Warning: ${degradeLine(WarningCodes.BASELINE_UNREADABLE, message)}`)); + degradeWarnings.push({ code: WarningCodes.BASELINE_UNREADABLE, message }); } } } + const stableDegrade = stableWarningRecords(degradeWarnings); + if (stableDegrade.length) artifact.degradeWarnings = stableDegrade; + if (!opts.noLocalArtifacts && !maxPrivacyMode) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); @@ -993,3 +1011,10 @@ async function buildRepositoryInfo(rootDir: string, remoteUrl: string | undefine ...(remoteUrl ? { remoteUrl } : {}), }; } + +/** Baseline path safe to print and store: repo-relative, or the basename when it sits outside the repo. */ +function baselineWarningPath(rootDir: string, baselinePath: string): string { + const rel = path.relative(rootDir, baselinePath); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return path.basename(baselinePath); + return rel.split('\\').join('/'); +} diff --git a/src/core-open/scanners/dart-scanner.ts b/src/core-open/scanners/dart-scanner.ts index f8a4d67b..1d51b6b1 100644 --- a/src/core-open/scanners/dart-scanner.ts +++ b/src/core-open/scanners/dart-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; @@ -264,7 +265,7 @@ export async function scanDartProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Dart project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Dart project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -272,7 +273,7 @@ export async function scanDartProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Dart project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Dart project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/docker-scanner.ts b/src/core-open/scanners/docker-scanner.ts index 3315801b..a663f986 100644 --- a/src/core-open/scanners/docker-scanner.ts +++ b/src/core-open/scanners/docker-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchDockerVersionsBulk, parseDockerImage } from './docker-cache.js'; import { gt, SemVer } from 'semver'; import type { ProjectScan, DependencyRow } from '../types.js'; @@ -260,7 +261,7 @@ export async function scanDockerProjects( } } catch (error) { // Skip projects that fail to scan - console.error(`Error scanning Docker project at ${dir}:`, error); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Docker project at ${dir}:`), error); } } diff --git a/src/core-open/scanners/dotnet-scanner.ts b/src/core-open/scanners/dotnet-scanner.ts index 3a3461d2..d8cda0f3 100644 --- a/src/core-open/scanners/dotnet-scanner.ts +++ b/src/core-open/scanners/dotnet-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; @@ -450,14 +451,14 @@ export async function scanDotnetProjects(rootDir: string, nugetCache?: NuGetCach if (cache) { cache.addStuckPath(relPath || '.'); } - console.error(`Timeout scanning ${csprojPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning ${csprojPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning ${csprojPath}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning ${csprojPath}: ${msg}`)); } } diff --git a/src/core-open/scanners/elixir-scanner.ts b/src/core-open/scanners/elixir-scanner.ts index 9c34079e..0cefed87 100644 --- a/src/core-open/scanners/elixir-scanner.ts +++ b/src/core-open/scanners/elixir-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchHexVersionsBulk } from './hex-cache.js'; import { gt, minVersion, validRange } from 'semver'; import type { ProjectScan, DependencyRow, DetectedFramework } from '../types.js'; @@ -320,7 +321,7 @@ export async function scanElixirProjects( } } catch (error) { // Skip projects that fail to scan - console.error(`Error scanning Elixir project at ${dir}:`, error); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Elixir project at ${dir}:`), error); } } diff --git a/src/core-open/scanners/go-scanner.ts b/src/core-open/scanners/go-scanner.ts index aad284bf..84bf1933 100644 --- a/src/core-open/scanners/go-scanner.ts +++ b/src/core-open/scanners/go-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; @@ -221,14 +222,14 @@ export async function scanGoProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Go project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Go project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Go project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Go project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/helm-scanner.ts b/src/core-open/scanners/helm-scanner.ts index 46051e6a..65c68b5d 100644 --- a/src/core-open/scanners/helm-scanner.ts +++ b/src/core-open/scanners/helm-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchHelmVersionsBulk } from './helm-cache.js'; import { gt, minVersion, validRange } from 'semver'; import type { ProjectScan, DependencyRow } from '../types.js'; @@ -311,7 +312,7 @@ export async function scanHelmProjects( } } catch (error) { // Skip projects that fail to scan - console.error(`Error scanning Helm chart at ${dir}:`, error); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Helm chart at ${dir}:`), error); } } diff --git a/src/core-open/scanners/java-scanner.ts b/src/core-open/scanners/java-scanner.ts index 4000c683..20a44ec3 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; @@ -382,7 +383,7 @@ export async function scanJavaProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Java project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Java project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -390,7 +391,7 @@ export async function scanJavaProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Java project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Java project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/node-scanner.ts b/src/core-open/scanners/node-scanner.ts index 1d110109..7827da6f 100644 --- a/src/core-open/scanners/node-scanner.ts +++ b/src/core-open/scanners/node-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as os from 'node:os'; import * as path from 'node:path'; import * as semver from 'semver'; @@ -244,7 +245,7 @@ export async function scanNodeProjects( if (cache) { cache.addStuckPath(relPath || '.'); } - console.error(`Timeout scanning ${pjPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning ${pjPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -252,7 +253,7 @@ export async function scanNodeProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning ${pjPath}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning ${pjPath}: ${msg}`)); return null; } }))); diff --git a/src/core-open/scanners/php-scanner.ts b/src/core-open/scanners/php-scanner.ts index e5993ca3..6773afaa 100644 --- a/src/core-open/scanners/php-scanner.ts +++ b/src/core-open/scanners/php-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { FileCache, readJsonFile } from '../utils/fs.js'; @@ -212,7 +213,7 @@ export async function scanPhpProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning PHP project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning PHP project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -220,7 +221,7 @@ export async function scanPhpProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning PHP project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning PHP project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/python-scanner.ts b/src/core-open/scanners/python-scanner.ts index e421fe40..272a3e3f 100644 --- a/src/core-open/scanners/python-scanner.ts +++ b/src/core-open/scanners/python-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; @@ -373,7 +374,7 @@ export async function scanPythonProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Python project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Python project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -381,7 +382,7 @@ export async function scanPythonProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Python project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Python project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/ruby-scanner.ts b/src/core-open/scanners/ruby-scanner.ts index e5927bf8..bbe612c7 100644 --- a/src/core-open/scanners/ruby-scanner.ts +++ b/src/core-open/scanners/ruby-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; @@ -360,7 +361,7 @@ export async function scanRubyProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Ruby project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Ruby project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -368,7 +369,7 @@ export async function scanRubyProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Ruby project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Ruby project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/rust-scanner.ts b/src/core-open/scanners/rust-scanner.ts index 1f11642f..a7dd313b 100644 --- a/src/core-open/scanners/rust-scanner.ts +++ b/src/core-open/scanners/rust-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; @@ -211,7 +212,7 @@ export async function scanRustProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Rust project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Rust project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -219,7 +220,7 @@ export async function scanRustProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Rust project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Rust project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/swift-scanner.ts b/src/core-open/scanners/swift-scanner.ts index 9c4aec6f..5b30d2b9 100644 --- a/src/core-open/scanners/swift-scanner.ts +++ b/src/core-open/scanners/swift-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; @@ -188,7 +189,7 @@ export async function scanSwiftProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(`Timeout scanning Swift project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); + console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Swift project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -196,7 +197,7 @@ export async function scanSwiftProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(`Error scanning Swift project ${dir}: ${msg}`); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Swift project ${dir}: ${msg}`)); } } diff --git a/src/core-open/scanners/terraform-scanner.ts b/src/core-open/scanners/terraform-scanner.ts index 387ea5e9..e96bf126 100644 --- a/src/core-open/scanners/terraform-scanner.ts +++ b/src/core-open/scanners/terraform-scanner.ts @@ -1,6 +1,7 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchTerraformVersionsBulk } from './terraform-cache.js'; import { gt, minVersion, validRange } from 'semver'; import type { ProjectScan, DependencyRow } from '../types.js'; @@ -399,7 +400,7 @@ export async function scanTerraformProjects( } } catch (error) { // Skip projects that fail to scan - console.error(`Error scanning Terraform project at ${dir}:`, error); + console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Terraform project at ${dir}:`), error); } } diff --git a/src/core-open/scoring/cvss.ts b/src/core-open/scoring/cvss.ts index 51823c55..296ab9f1 100644 --- a/src/core-open/scoring/cvss.ts +++ b/src/core-open/scoring/cvss.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import type { CvssDiagnostic, VulnSeverity } from '../types.js'; import { redactSecrets } from '../utils/redact.js'; +import { WarningCodes } from '../../warnings/codes.js'; /** * Minimal, dependency-free CVSS v3.0/v3.1 base-score calculator. @@ -60,11 +61,11 @@ function preview(vector: string): string { function invalid(reason: string): CvssParseResult { const message = redactSecrets( - `CVSS vector failed to parse (${CVSS_VECTOR_PARSE_FAILED}): ${reason}. ${NEXT_STEP}`, + `${WarningCodes.CVSS_UNPARSEABLE}: CVSS vector failed to parse (${CVSS_VECTOR_PARSE_FAILED}): ${reason}. ${NEXT_STEP}`, ); return { status: 'invalid', - diagnostic: { code: CVSS_VECTOR_PARSE_FAILED, message }, + diagnostic: { code: CVSS_VECTOR_PARSE_FAILED, warnCode: WarningCodes.CVSS_UNPARSEABLE, message }, }; } diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index 80604f83..b48c1bc6 100644 --- a/src/core-open/scoring/drift-score.ts +++ b/src/core-open/scoring/drift-score.ts @@ -4,6 +4,7 @@ import * as crypto from 'node:crypto'; import type { ProjectScan, DriftScore, Finding, RiskLevel, VibgrateConfig } from '../types.js'; import { licenseParseDiagnostic } from '../licenses/diagnostic.js'; +import { WarningCodes } from '../../warnings/codes.js'; import { normalizeLicenseSourcePath } from '../licenses/dependency-license.js'; import { aggregateDependencyDrift } from './dependency-drift-v3.js'; @@ -409,7 +410,9 @@ export function generateFindings( level: 'warning', message: diag.message, location, - details: sourcePath ? { raw: diag.raw, sourcePath } : { raw: diag.raw }, + details: sourcePath + ? { raw: diag.raw, sourcePath, warnCode: WarningCodes.LICENSE_UNPARSEABLE } + : { raw: diag.raw, warnCode: WarningCodes.LICENSE_UNPARSEABLE }, }); } findings.push(...licenseFindings); diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 74aad110..5456ea01 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -477,6 +477,11 @@ export type VulnSeverity = 'low' | 'moderate' | 'high' | 'critical' | 'unknown'; export interface CvssDiagnostic { /** Stable code, identical for every unparseable vector. */ code: 'cvss-vector-parse-failed'; + /** + * Degrade-and-continue code (`VG_WARN_CVSS_UNPARSEABLE`). Present when this + * CLI built the diagnostic. The `code` field above is unchanged. + */ + warnCode?: 'VG_WARN_CVSS_UNPARSEABLE'; /** * What failed and what to do next. Deterministic for a given vector. * Credential-shaped text from the vector is redacted. @@ -809,6 +814,12 @@ export interface ScanArtifact { relationshipDiagram?: MermaidDiagram; /** Billing roll-up derived from per-project function/project classifications */ billing?: BillingSummary; + /** + * Degrade-and-continue warnings from this scan (skipped paths, an unreadable + * baseline). Omitted when none fired. Sorted by code, then message. Does + * not change whether the scan succeeded. + */ + degradeWarnings?: Array<{ code: string; message: string }>; /** * Vulnerable-symbol reachability results from the LOCAL code-graph query * (`vg scan` with a DSN): the risky-symbol manifest from the scan symbols diff --git a/src/engine/build.ts b/src/engine/build.ts index 99006736..faca105d 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -1,6 +1,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { discover, mergeExcludes, type DiscoveredFile } from './discover.js'; +import { + WarningCodes, + isParseFailureWarning, + parseRenderedWarning, + renderWarning, + stableWarningRecords, + type WarningRecord, +} from '../warnings/codes.js'; import { parseFiles } from './pool.js'; import { resolve } from './resolve.js'; import { inheritDuties } from './duties-inherit.js'; @@ -143,7 +151,13 @@ export interface BuildResult { scip?: { documents: number; references: number; resolved: number; tool?: string }; /** SQLite index write result. */ index?: { ok: boolean; path?: string; reason?: string }; + /** + * Degrade-and-continue warnings. Each string is `CODE: message`. + * Same order as {@link warningRecords}. + */ warnings: string[]; + /** Structured form of {@link warnings}. Sorted by code, then message. */ + warningRecords: WarningRecord[]; /** Architecture role hits extracted during the parse already paid for. */ fileRoles: AstRoleHit[]; } @@ -157,14 +171,20 @@ export async function buildGraph(options: BuildOptions): Promise { // instead of reading a filesystem root or an enormous unpack first. const limits = resolveLimits(options.limits); timer.start('discover'); + const skippedSubtrees: string[] = []; const files = discover({ root, only: options.only, exclude, paths: options.paths, maxEntries: limits.maxFiles, + skippedSubtrees, }); timer.end('discover'); + const warningRecordsAcc: WarningRecord[] = skippedSubtrees.map((rel) => ({ + code: WarningCodes.SKIPPED_SUBTREE, + message: `${rel}: skipped — directory could not be read`, + })); // Resource safeguards (see limits.ts): stop a pathological corpus before it // OOM-kills the process. Skips are deterministic functions of the input. @@ -201,7 +221,7 @@ export async function buildGraph(options: BuildOptions): Promise { const fileStats: FileStat[] = []; const toParse: DiscoveredFile[] = []; const reused: FileParse[] = []; - const buildWarnings: string[] = []; + const buildWarningRecords: WarningRecord[] = []; /** Files skipped for size — in fileStats under a sentinel hash, never in the manifest. */ const oversizeRels = new Set(); let statHits = 0; @@ -227,10 +247,12 @@ export async function buildGraph(options: BuildOptions): Promise { hash: hashString(`vg:oversize:${stat.size}`), }); oversizeRels.add(file.rel); - buildWarnings.push( - `${file.rel}: skipped — ${formatBytes(stat.size)} exceeds the ` + + buildWarningRecords.push({ + code: WarningCodes.SKIPPED_FILE, + message: + `${file.rel}: skipped — ${formatBytes(stat.size)} exceeds the ` + `${formatBytes(limits.maxFileBytes)} per-file limit (set VG_MAX_FILE_BYTES to raise it, 0 to disable)`, - ); + }); continue; } @@ -299,7 +321,7 @@ export async function buildGraph(options: BuildOptions): Promise { // corrupted wasm heap) would otherwise poison the cache for that content // hash and every later build would reuse the empty parse instead of // re-parsing the file. - if (p.defs.length === 0 && p.warnings?.some((w) => w.startsWith('parse failed:'))) continue; + if (p.defs.length === 0 && p.warnings?.some((w) => isParseFailureWarning(w))) continue; const st = fileStats.find((f) => f.rel === p.rel); cache.set(p.rel, p, st ? { mtimeMs: st.mtimeMs, size: st.size } : undefined); } @@ -313,7 +335,18 @@ export async function buildGraph(options: BuildOptions): Promise { a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0, ); - const warnings = [...buildWarnings, ...parses.flatMap((p) => p.warnings ?? [])]; + warningRecordsAcc.push(...buildWarningRecords); + for (const p of parses) { + for (const w of p.warnings ?? []) { + const parsed = parseRenderedWarning(w); + const message = parsed?.message ?? w; + const withPath = message.startsWith(`${p.rel}:`) ? message : `${p.rel}: ${message}`; + warningRecordsAcc.push({ + code: parsed?.code ?? WarningCodes.PARSE_DEGRADE, + message: withPath, + }); + } + } // Resolve → nodes/edges. The module resolver follows relative imports plus // tsconfig path aliases and workspace-package names (so monorepo cross-package @@ -379,11 +412,13 @@ export async function buildGraph(options: BuildOptions): Promise { // A ts.Program over the whole corpus is the largest single memory consumer // in the build. Past the cap, fall back to the heuristic floor (still a // complete graph, just less precise call resolution). - warnings.push( - `typescript resolver skipped — ${tsFiles.length.toLocaleString()} TS/JS files exceed the ` + + warningRecordsAcc.push({ + code: WarningCodes.TSC_SKIPPED, + message: + `typescript resolver skipped — ${tsFiles.length.toLocaleString()} TS/JS files exceed the ` + `${limits.tscMaxFiles.toLocaleString()}-file limit; calls use the heuristic resolver ` + `(set VG_TSC_MAX_FILES to raise it, 0 to disable)`, - ); + }); tsFiles = []; } timer.start('tsc'); @@ -533,6 +568,11 @@ export async function buildGraph(options: BuildOptions): Promise { } const toolchainResult = await extractToolchain(docs, { fileNodes: fileNodesByPath }); if (toolchainResult.nodes.length) nodes = [...nodes, ...toolchainResult.nodes]; + for (const message of toolchainResult.warnings) { + warningRecordsAcc.push({ code: WarningCodes.TOOLCHAIN_DEGRADE, message }); + } + const warningRecords = stableWarningRecords(warningRecordsAcc); + const warnings = warningRecords.map(renderWarning); timer.end('toolchain'); // Analyse → centrality/areas/surprise (test/coverage edges excluded from these). @@ -677,6 +717,7 @@ export async function buildGraph(options: BuildOptions): Promise { scip: scipStats, index: indexResult, warnings, + warningRecords, fileRoles: fileRolesFromParses(parses), }; } diff --git a/src/engine/discover.ts b/src/engine/discover.ts index 2f523416..3c1ac5d3 100644 --- a/src/engine/discover.ts +++ b/src/engine/discover.ts @@ -166,6 +166,12 @@ export interface DiscoverOptions { * Default: `VG_MAX_FILES`, else 100000. */ maxEntries?: number; + /** + * Repo-relative directories that could not be read. Appended in sorted + * order. The walk continues. The OS error is not copied — it can name a + * path outside the message we want to keep. + */ + skippedSubtrees?: string[]; } export interface DiscoveredFile { @@ -248,6 +254,7 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { const found = new Map(); const budget = createWalkBudget(root, options.maxEntries); + const skippedSubtrees: string[] = []; const considerFile = (abs: string): void => { const rel = toPosix(path.relative(root, abs)); @@ -269,7 +276,11 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { - return; // unreadable dir — skip rather than crash the build + // Unreadable dir — skip rather than crash the build. The relative path + // is the whole notice; the OS error is not copied. + const rel = toPosix(path.relative(root, dir)); + skippedSubtrees.push(rel === '' ? '.' : rel); + return; } for (const entry of entries) { const abs = path.join(dir, entry.name); @@ -297,6 +308,10 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { } else if (stat.isFile()) considerFile(scope); } + if (options.skippedSubtrees) { + for (const rel of [...new Set(skippedSubtrees)].sort()) options.skippedSubtrees.push(rel); + } + return [...found.values()].sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0)); } diff --git a/src/engine/export.ts b/src/engine/export.ts index f6ac8d51..25cf6d77 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -3,6 +3,7 @@ import { renderReport } from './report.js'; import { renderHtml } from './html.js'; import type { DepRecord } from './drift.js'; import { resolvePurl } from '../reporting/commands/sbom.js'; +import { WarningCodes } from '../warnings/codes.js'; import type { LocalModel } from './models.js'; import type { VgGraph } from '../schema.js'; @@ -266,6 +267,7 @@ function cyclonedx(ctx: ExportContext): string { properties: [ { name: 'vibgrate:purlStatus', value: 'unavailable' }, { name: 'vibgrate:purlWarning', value: resolved.warning }, + { name: 'vibgrate:purlWarningCode', value: WarningCodes.PURL_UNAVAILABLE }, ], }); } diff --git a/src/engine/parse-worker.ts b/src/engine/parse-worker.ts index 82ddd7d4..df5d9287 100644 --- a/src/engine/parse-worker.ts +++ b/src/engine/parse-worker.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import { parseSource } from './parse.js'; import { setGrammarsOverride, resetParser } from './grammars.js'; import type { FileParse } from './types.js'; +import { parseDegradeWarning } from '../warnings/codes.js'; /** * tinypool worker entry. Receives a chunk of files, reads and parses each, and @@ -46,7 +47,7 @@ export default async function run(payload: ParsePayload): Promise { heritage: [], typeRefs: [], guards: [], - warnings: [`parse failed: ${(err as Error).message}`], + warnings: [parseDegradeWarning((err as Error).message)], }); } } diff --git a/src/engine/pool.ts b/src/engine/pool.ts index 0b72f29c..9dc00112 100644 --- a/src/engine/pool.ts +++ b/src/engine/pool.ts @@ -8,6 +8,7 @@ import { checkMemoryBudget, envJobs, envWorkerHeapMb, ResourceLimitError } from import type { DiscoveredFile } from './discover.js'; import type { FileParse } from './types.js'; import type { ParseTask } from './parse-worker.js'; +import { parseDegradeWarning } from '../warnings/codes.js'; /** * Parse a set of discovered files into FileParse tables. @@ -81,7 +82,7 @@ async function parseInline(files: DiscoveredFile[], options: ParseOptions): Prom // A wasm-level parse crash can leave the language's reused parser // mid-state; drop it so the failure stays contained to this file. resetParser(file.lang.id); - out.push(emptyParse(file, `parse failed: ${(err as Error).message}`)); + out.push(emptyParse(file, parseDegradeWarning((err as Error).message))); } onProgress?.(out.length, files.length); if (out.length % MEM_CHECK_EVERY === 0) checkMemoryBudget('parse', memoryBudgetMb); diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 6a57d713..fb328088 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -449,6 +449,12 @@ describe('sbom helpers', () => { annotationDate: '2026-02-19T00:00:00.000Z', comment: `${LICENSE_PARSE_FAILED}: ${message}`, }, + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: '2026-02-19T00:00:00.000Z', + comment: 'VG_WARN_LICENSE_UNPARSEABLE', + }, ]); expect(JSON.stringify(spdx)).not.toContain('unrelated'); @@ -458,6 +464,7 @@ describe('sbom helpers', () => { }; expect(cdx.metadata.properties).toEqual([ { name: LICENSE_PARSE_FAILED, value: `apps/web: ${message}` }, + { name: 'vibgrate:warningCode', value: 'VG_WARN_LICENSE_UNPARSEABLE' }, ]); expect(toCycloneDx(withFailure)).toEqual(cdx); expect(cdx.serialNumber).not.toBe((toCycloneDx(plain) as { serialNumber: string }).serialNumber); diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index cef3700f..852cc72d 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { pathExists, readJsonFile, writeTextFile } from '../utils/fs.js'; import type { DependencyRow, Finding, ProjectScan, ScanArtifact } from '../types.js'; import { LICENSE_PARSE_FAILED } from '../../core-open/licenses/diagnostic.js'; +import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fullDependencyGraph, type LockfileComponent, type LockfileGraph } from '../../engine/lockfile.js'; import { ECOSYSTEMS, type Ecosystem } from '../../engine/drift.js'; import { componentLicense, extractedLicensingInfos, type ComponentLicense } from './sbom-license.js'; @@ -765,10 +766,13 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco }, ...(licenseNotes.length ? { - properties: licenseNotes.map((f) => ({ - name: LICENSE_PARSE_FAILED, - value: `${f.location}: ${f.message}`, - })), + properties: licenseNotes.flatMap((f) => [ + { + name: LICENSE_PARSE_FAILED, + value: `${f.location}: ${f.message}`, + }, + { name: 'vibgrate:warningCode', value: WarningCodes.LICENSE_UNPARSEABLE }, + ]), } : {}), }, @@ -787,16 +791,21 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco properties.push( { name: PURL_STATUS_PROPERTY, value: PURL_STATUS_UNAVAILABLE }, { name: PURL_WARNING_PROPERTY, value: warning }, + { name: `${PURL_WARNING_PROPERTY}Code`, value: WarningCodes.PURL_UNAVAILABLE }, ); } if (license.warning) { properties.push( { name: LICENSE_STATUS_PROPERTY, value: LICENSE_STATUS_UNREPRESENTABLE }, { name: LICENSE_WARNING_PROPERTY, value: license.warning }, + { name: `${LICENSE_WARNING_PROPERTY}Code`, value: WarningCodes.LICENSE_UNPARSEABLE }, ); } for (const warning of dep.mergeWarnings) { - properties.push({ name: 'vibgrate:mergeWarning', value: warning }); + properties.push( + { name: 'vibgrate:mergeWarning', value: warning }, + { name: 'vibgrate:mergeWarningCode', value: WarningCodes.SBOM_MERGE }, + ); } return { type: 'library', @@ -812,6 +821,20 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco }; } +function spdxNote(artifact: ScanArtifact, comment: string): { + annotationType: string; + annotator: string; + annotationDate: string; + comment: string; +} { + return { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment, + }; +} + export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record { const dependencies = flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem); const relationships = spdxRelationships(dependencies, graph); @@ -842,28 +865,22 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record ({ - annotationType: 'OTHER', - annotator: 'Tool: @vibgrate/cli', - annotationDate: artifact.timestamp, - comment: `${f.ruleId}: ${f.message}`, - })), + annotations: licenseNotes.flatMap((f) => [ + spdxNote(artifact, `${f.ruleId}: ${f.message}`), + spdxNote(artifact, WarningCodes.LICENSE_UNPARSEABLE), + ]), } : {}), }; @@ -1023,13 +1038,13 @@ const exportCommand = new Command('export') const sbom = format === 'cyclonedx' ? toCycloneDx(artifact, lockfileGraph) : toSpdx(artifact, lockfileGraph); for (const warning of collectPurlWarnings(artifact, lockfileGraph)) { - console.error(chalk.yellow(`warning: ${warning}`)); + console.error(chalk.yellow(`warning: ${degradeLine(WarningCodes.PURL_UNAVAILABLE, warning)}`)); } for (const warning of collectLicenseWarnings(artifact, lockfileGraph)) { - console.error(chalk.yellow(`warning: ${warning}`)); + console.error(chalk.yellow(`warning: ${degradeLine(WarningCodes.LICENSE_UNPARSEABLE, warning)}`)); } for (const warning of collectMergeWarnings(artifact, lockfileGraph)) { - console.error(chalk.yellow(`warning: ${warning}`)); + console.error(chalk.yellow(`warning: ${degradeLine(WarningCodes.SBOM_MERGE, warning)}`)); } const body = JSON.stringify(sbom, null, 2); diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index cc15e474..4dc117ba 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -1,5 +1,6 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; +import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; /** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ function markdownDriftCell(score: number | null): string { @@ -123,11 +124,19 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(`|-------|------|---------|----------|`); for (const f of artifact.findings) { const emoji = f.level === 'error' ? '🔴' : f.level === 'warning' ? '🟡' : '🔵'; - lines.push(`| ${emoji} ${f.level} | ${f.ruleId} | ${f.message} | ${f.location} |`); + lines.push(`| ${emoji} ${f.level} | ${ruleWithWarnCode(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); } lines.push(''); } + const degradeLines = degradeWarningBlock(artifact.degradeWarnings); + if (degradeLines.length) { + lines.push(`## ${degradeLines[0]}`); + lines.push(''); + for (const line of degradeLines.slice(1)) lines.push(`- ${line.trim()}`); + lines.push(''); + } + if (artifact.delta !== undefined) { // Lower is better: a rise (positive delta) means drift worsened; a fall means it improved. const dir = artifact.delta > 0 ? '📈' : artifact.delta < 0 ? '📉' : '➡️'; diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index fb50cad8..d95beefe 100644 --- a/src/reporting/formatters/text.ts +++ b/src/reporting/formatters/text.ts @@ -3,6 +3,7 @@ import type { ScanArtifact, ExtendedScanResults, InventoryItem, ServiceDependenc import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; import { VERSION } from '../version.js'; import { humanFindingText } from '../../core-open/formatters/fix-hint.js'; +import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; import { driftBar } from '../../core-open/ui/bar.js'; import { titleBox } from '../../core-open/ui/box.js'; @@ -97,12 +98,19 @@ export function formatText(artifact: ScanArtifact): string { const icon = f.level === 'error' ? chalk.red('✖') : f.level === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ'); const shown = humanFindingText(f); lines.push(` ${icon} ${shown.message}`); - lines.push(chalk.dim(` ${f.ruleId} in ${f.location}`)); + lines.push(chalk.dim(` ${ruleWithWarnCode(f.ruleId, f.details)} in ${f.location}`)); if (shown.hint) lines.push(chalk.dim(` ${shown.hint}`)); } lines.push(''); } + const degradeLines = degradeWarningBlock(artifact.degradeWarnings); + if (degradeLines.length) { + lines.push(chalk.bold.underline(` ${degradeLines[0]}`)); + for (const line of degradeLines.slice(1)) lines.push(chalk.yellow(line)); + lines.push(''); + } + // Priority actions const actions = generatePriorityActions(artifact); if (actions.length > 0) { diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 5df43370..73c7547b 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -285,6 +285,11 @@ export interface ScanArtifact { treeSummary?: TreeCount; /** Workspace-level relationship diagram */ relationshipDiagram?: MermaidDiagram; + /** + * Degrade-and-continue warnings from this scan. Omitted when none fired. + * Sorted by code, then message. + */ + degradeWarnings?: Array<{ code: string; message: string }>; } // ── CLI option types ── diff --git a/src/warnings/codes.ts b/src/warnings/codes.ts new file mode 100644 index 00000000..d19a5d00 --- /dev/null +++ b/src/warnings/codes.ts @@ -0,0 +1,166 @@ +/** + * Stable codes for degrade-and-continue warnings in build, scan, and report. + * + * A published code string does not change. A new site adds a constant in this + * module — for example an unknown optional lockfile field — and does not reuse + * a code that already means something else. Codes are fixed tokens: they never + * include a path, file contents, or a secret. Those details stay in the + * message, which the caller keeps free of credentials. + * + * When several warnings fire, {@link stableWarningRecords} orders them by code + * then message so the same inputs always emit the same sequence. + */ + +export const WarningCodes = { + /** A source file threw during parse. The build keeps the other files. */ + PARSE_DEGRADE: 'VG_WARN_PARSE_DEGRADE', + /** A file was over the size cap and was not read. */ + SKIPPED_FILE: 'VG_WARN_SKIPPED_FILE', + /** A directory or scan path was skipped and the rest of the tree continued. */ + SKIPPED_SUBTREE: 'VG_WARN_SKIPPED_SUBTREE', + /** The TypeScript resolver was skipped; the heuristic resolver still ran. */ + TSC_SKIPPED: 'VG_WARN_TSC_SKIPPED', + /** Infrastructure or CI extraction degraded for one file. */ + TOOLCHAIN_DEGRADE: 'VG_WARN_TOOLCHAIN_DEGRADE', + /** A component stayed in the SBOM and its Package URL was omitted. */ + PURL_UNAVAILABLE: 'VG_WARN_PURL_UNAVAILABLE', + /** A declared license did not resolve, or could not be written on an SBOM. */ + LICENSE_UNPARSEABLE: 'VG_WARN_LICENSE_UNPARSEABLE', + /** A CVSS vector was present and did not parse. The advisory is kept. */ + CVSS_UNPARSEABLE: 'VG_WARN_CVSS_UNPARSEABLE', + /** An SBOM merge dropped or guessed a fact and still wrote the component. */ + SBOM_MERGE: 'VG_WARN_SBOM_MERGE', + /** A baseline file could not be read. The scan continued without a delta. */ + BASELINE_UNREADABLE: 'VG_WARN_BASELINE_UNREADABLE', + /** One project scanner failed and that project was skipped. */ + SCAN_DEGRADE: 'VG_WARN_SCAN_DEGRADE', +} as const; + +export type WarningCode = (typeof WarningCodes)[keyof typeof WarningCodes]; + +const CODE_SET: ReadonlySet = new Set(Object.values(WarningCodes)); + +export function isWarningCode(value: string): value is WarningCode { + return CODE_SET.has(value); +} + +/** Every published code, in sorted order. This module is the only definition. */ +export function publishedWarningCodes(): WarningCode[] { + return (Object.values(WarningCodes) as WarningCode[]).slice().sort(); +} + +export interface WarningRecord { + code: WarningCode; + /** Actionable prose. The code is not repeated in this field. */ + message: string; +} + +/** `VG_WARN_…: message` — stderr lines and string warning channels. */ +export function renderWarning(record: WarningRecord): string { + return `${record.code}: ${record.message}`; +} + +/** Same as {@link renderWarning}, for call sites that have the two parts. */ +export function degradeLine(code: WarningCode, message: string): string { + return renderWarning({ code, message }); +} + +/** Parse-pool / worker warning. The code is on the string; the message stays `parse failed:`. */ +export function parseDegradeWarning(errMessage: string): string { + return renderWarning({ + code: WarningCodes.PARSE_DEGRADE, + message: `parse failed: ${errMessage}`, + }); +} + +/** + * Split a rendered warning. A legacy line that is not `CODE: message` + * returns null so the caller can classify it. + */ +export function parseRenderedWarning(line: string): WarningRecord | null { + const sep = line.indexOf(': '); + if (sep <= 0) return null; + const code = line.slice(0, sep); + if (!isWarningCode(code)) return null; + return { code, message: line.slice(sep + 2) }; +} + +/** True for a failed parse, including a warning already prefixed with its code. */ +export function isParseFailureWarning(warning: string): boolean { + const parsed = parseRenderedWarning(warning); + const message = parsed?.code === WarningCodes.PARSE_DEGRADE ? parsed.message : warning; + return message.startsWith('parse failed:'); +} + +/** Stable order: code, then message. Identical pairs collapse to one. */ +export function stableWarningRecords(records: readonly WarningRecord[]): WarningRecord[] { + const sorted = records.slice().sort((a, b) => { + if (a.code < b.code) return -1; + if (a.code > b.code) return 1; + if (a.message < b.message) return -1; + if (a.message > b.message) return 1; + return 0; + }); + const out: WarningRecord[] = []; + for (const record of sorted) { + const prev = out[out.length - 1]; + if (prev && prev.code === record.code && prev.message === record.message) continue; + out.push(record); + } + return out; +} + +/** Unique codes that fired, sorted. Empty when nothing fired. */ +export function warningCodeSummary(records: readonly WarningRecord[]): string { + const codes: WarningCode[] = []; + for (const record of records) { + if (!codes.includes(record.code)) codes.push(record.code); + } + codes.sort(); + return codes.join(', '); +} + +/** Warn code stored on a finding, or on a nested CVSS diagnostic. */ +export function findingWarnCode(details: Record | null | undefined): WarningCode | null { + if (!details) return null; + const direct = details.warnCode; + if (typeof direct === 'string' && isWarningCode(direct)) return direct; + const diag = details.cvssDiagnostic; + if (diag && typeof diag === 'object') { + const nested = (diag as { warnCode?: unknown }).warnCode; + if (typeof nested === 'string' && isWarningCode(nested)) return nested; + } + return null; +} + +/** Rule id for a human line, with the warn code when the finding carries one. */ +export function ruleWithWarnCode(ruleId: string, details?: Record | null): string { + const code = findingWarnCode(details); + return code ? `${ruleId} ${code}` : ruleId; +} + +/** + * Plain lines for a report. Already-stable input stays in that order; this + * sorts again so a hand-written artifact cannot reshuffle the block. + */ +export function degradeWarningBlock( + records: ReadonlyArray<{ code: string; message: string }> | undefined, +): string[] { + if (!records?.length) return []; + const sorted = records.slice().sort((a, b) => { + if (a.code < b.code) return -1; + if (a.code > b.code) return 1; + if (a.message < b.message) return -1; + if (a.message > b.message) return 1; + return 0; + }); + const lines = ['Degrade warnings']; + const seen = new Set(); + for (const record of sorted) { + const line = ` ${record.code}: ${record.message}`; + if (seen.has(line)) continue; + seen.add(line); + lines.push(line); + } + return lines; +} diff --git a/test/sbom-lockfile-merge.test.ts b/test/sbom-lockfile-merge.test.ts index c7c9bbc5..637e2d6f 100644 --- a/test/sbom-lockfile-merge.test.ts +++ b/test/sbom-lockfile-merge.test.ts @@ -125,6 +125,7 @@ describe('sbom export: multi-project lockfile merge', () => { expect(prop(left.properties, 'vibgrate:projects')).toBe('docs, web'); expect(prop(left.properties, 'vibgrate:scope')).toBe('direct'); expect(props(left.properties, 'vibgrate:mergeWarning')).toEqual([LOSSY_EDGE_WARNING]); + expect(props(left.properties, 'vibgrate:mergeWarningCode')).toEqual(['VG_WARN_SBOM_MERGE']); const widgetOld = cdx.components.find((c) => c.purl === 'pkg:npm/widget@1.0.0')!; const widgetNew = cdx.components.find((c) => c.purl === 'pkg:npm/widget@2.0.0')!; @@ -328,7 +329,7 @@ describe('sbom export: multi-project lockfile merge', () => { expect(stdout).toHaveLength(2); expect(stdout[0]).toBe(stdout[1]); expect(stdout[0]).toContain('"bomFormat": "CycloneDX"'); - expect(stderr.join('\n')).toContain(`warning: ${LOSSY_EDGE_WARNING}`); + expect(stderr.join('\n')).toContain(`warning: VG_WARN_SBOM_MERGE: ${LOSSY_EDGE_WARNING}`); expect(stderr.join('\n')).not.toContain('http'); }); }); diff --git a/test/warning-codes.test.ts b/test/warning-codes.test.ts new file mode 100644 index 00000000..bf1d95fb --- /dev/null +++ b/test/warning-codes.test.ts @@ -0,0 +1,153 @@ +/** + * Stable degrade-and-continue warning codes. + * + * Two command paths must carry the exact code string: a build that skips an + * oversized file, and a parse that throws and continues. The registry itself + * must not publish two names for one string. + */ +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildGraph } from '../src/engine/build.js'; +import runParseWorker from '../src/engine/parse-worker.js'; +import { parseCvssVector } from '../src/core-open/scoring/cvss.js'; +import { toCycloneDx } from '../src/reporting/commands/sbom.js'; +import type { ScanArtifact } from '../src/reporting/types.js'; +import { + WarningCodes, + publishedWarningCodes, + stableWarningRecords, +} from '../src/warnings/codes.js'; +import { cleanup, makeProject } from './helpers.js'; + +const PIN = '2020-01-01T00:00:00.000Z'; +const dirs: string[] = []; + +function project(files: Record): string { + const dir = makeProject(files); + dirs.push(dir); + return dir; +} + +afterEach(() => { + while (dirs.length) cleanup(dirs.pop()!); +}); + +describe('warning code registry', () => { + it('publishes each code string once', () => { + const values = Object.values(WarningCodes); + expect(new Set(values).size).toBe(values.length); + expect(publishedWarningCodes()).toEqual([...values].sort()); + expect(publishedWarningCodes()).toContain('VG_WARN_PARSE_DEGRADE'); + expect(publishedWarningCodes()).toContain('VG_WARN_SKIPPED_FILE'); + }); + + it('orders several warnings by code, then message, and collapses exact duplicates', () => { + const records = stableWarningRecords([ + { code: WarningCodes.SKIPPED_FILE, message: 'b.ts: skipped' }, + { code: WarningCodes.PARSE_DEGRADE, message: 'a.ts: parse failed: boom' }, + { code: WarningCodes.SKIPPED_FILE, message: 'a.ts: skipped' }, + { code: WarningCodes.SKIPPED_FILE, message: 'b.ts: skipped' }, + ]); + expect(records.map((r) => `${r.code} ${r.message}`)).toEqual([ + 'VG_WARN_PARSE_DEGRADE a.ts: parse failed: boom', + 'VG_WARN_SKIPPED_FILE a.ts: skipped', + 'VG_WARN_SKIPPED_FILE b.ts: skipped', + ]); + }); +}); + +describe('build degrade warnings', () => { + const big = `// ${'x'.repeat(400)}\nexport function huge(){ return 1; }\n`; + + it('puts VG_WARN_SKIPPED_FILE on an oversized file and keeps that order', async () => { + const result = await buildGraph({ + root: project({ + 'ok.ts': 'export const n = 1;\n', + 'b-big.ts': big, + 'a-big.ts': big, + }), + generatedAt: PIN, + inline: true, + noCache: true, + limits: { maxFileBytes: 256 }, + }); + const skipped = result.warningRecords.filter((r) => r.code === 'VG_WARN_SKIPPED_FILE'); + expect(skipped.map((r) => r.message.split(':')[0])).toEqual(['a-big.ts', 'b-big.ts']); + expect(result.warnings.filter((w) => w.startsWith('VG_WARN_SKIPPED_FILE:'))).toEqual( + skipped.map((r) => `VG_WARN_SKIPPED_FILE: ${r.message}`), + ); + expect(result.warnings.some((w) => w.includes('VG_MAX_FILE_BYTES'))).toBe(true); + }); +}); + +describe('parse degrade warnings', () => { + it('puts VG_WARN_PARSE_DEGRADE on a file that cannot be read and continues', async () => { + const missing = path.join(os.tmpdir(), 'vg-warn-missing-does-not-exist.ts'); + const parsed = await runParseWorker({ + tasks: [ + { rel: 'z.ts', abs: missing, lang: 'ts' }, + { rel: 'a.ts', abs: missing, lang: 'ts' }, + ], + }); + expect(parsed).toHaveLength(2); + for (const file of parsed) { + expect(file.defs).toEqual([]); + expect(file.warnings).toHaveLength(1); + expect(file.warnings?.[0]).toMatch(/^VG_WARN_PARSE_DEGRADE: parse failed: /); + } + expect(parsed[0]?.rel).toBe('z.ts'); + expect(parsed[1]?.rel).toBe('a.ts'); + }); +}); + +describe('report degrade warnings', () => { + it('puts VG_WARN_PURL_UNAVAILABLE beside an omitted package URL', () => { + const artifact = { + schemaVersion: '1.0', + timestamp: '2026-02-19T00:00:00.000Z', + vibgrateVersion: '0.0.1', + rootPath: 'repo', + drift: { + score: 10, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: [], + projects: [ + { + type: 'node', + path: '.', + name: 'app', + frameworks: [], + dependencies: [ + { + package: 'foo bar', + section: 'dependencies', + currentSpec: '1.0.0', + resolvedVersion: '1.0.0', + latestStable: '1.0.0', + majorsBehind: 0, + drift: 'current', + }, + ], + }, + ], + } as ScanArtifact; + const cdx = toCycloneDx(artifact) as { + components: Array<{ properties: Array<{ name: string; value: string }> }>; + }; + const props = cdx.components[0]?.properties ?? []; + expect(props.find((p) => p.name === 'vibgrate:purlWarningCode')?.value).toBe('VG_WARN_PURL_UNAVAILABLE'); + expect(props.find((p) => p.name === 'vibgrate:purlWarning')?.value).toContain('foo bar'); + }); + + it('puts VG_WARN_CVSS_UNPARSEABLE on a vector that does not parse', () => { + const parsed = parseCvssVector('not-a-vector'); + expect(parsed.status).toBe('invalid'); + if (parsed.status !== 'invalid') return; + expect(parsed.diagnostic.warnCode).toBe('VG_WARN_CVSS_UNPARSEABLE'); + expect(parsed.diagnostic.code).toBe('cvss-vector-parse-failed'); + expect(parsed.diagnostic.message.startsWith('VG_WARN_CVSS_UNPARSEABLE: ')).toBe(true); + }); +}); From 2208f0f0c4c145d731242369eb2cfa6ee1bd586a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 22:19:26 +0000 Subject: [PATCH 2/3] fix: keep the CVSS diagnostic message and record its warn code The existing parse-failure prose stays the message. VG_WARN_CVSS_UNPARSEABLE is the added warnCode field. License findings assert that same additive field. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- src/core-open/scoring/cvss.ts | 2 +- test/license-finding-source-path.test.ts | 10 ++++++++-- test/warning-codes.test.ts | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/core-open/scoring/cvss.ts b/src/core-open/scoring/cvss.ts index 296ab9f1..6a5014dc 100644 --- a/src/core-open/scoring/cvss.ts +++ b/src/core-open/scoring/cvss.ts @@ -61,7 +61,7 @@ function preview(vector: string): string { function invalid(reason: string): CvssParseResult { const message = redactSecrets( - `${WarningCodes.CVSS_UNPARSEABLE}: CVSS vector failed to parse (${CVSS_VECTOR_PARSE_FAILED}): ${reason}. ${NEXT_STEP}`, + `CVSS vector failed to parse (${CVSS_VECTOR_PARSE_FAILED}): ${reason}. ${NEXT_STEP}`, ); return { status: 'invalid', diff --git a/test/license-finding-source-path.test.ts b/test/license-finding-source-path.test.ts index c2280630..45525f96 100644 --- a/test/license-finding-source-path.test.ts +++ b/test/license-finding-source-path.test.ts @@ -86,7 +86,10 @@ describe('license findings in machine-readable scan output', () => { 'apps/api', 'package.json', ]); - expect(licenseFindings[1]?.details).toEqual({ raw: 'Also-Not-SPDX' }); + expect(licenseFindings[1]?.details).toEqual({ + raw: 'Also-Not-SPDX', + warnCode: 'VG_WARN_LICENSE_UNPARSEABLE', + }); expect(JSON.stringify(scan.findings)).not.toContain('beta-ok'); const json = JSON.stringify(scan, null, 2); @@ -114,7 +117,10 @@ describe('license findings in machine-readable scan output', () => { ]); expect(results[0]?.properties?.sourcePath).toBe('apps/web/package.json'); expect(results[2]?.properties?.sourcePath).toBe('package.json'); - expect(results[1]?.properties).toEqual({ raw: 'Also-Not-SPDX' }); + expect(results[1]?.properties).toEqual({ + raw: 'Also-Not-SPDX', + warnCode: 'VG_WARN_LICENSE_UNPARSEABLE', + }); expect(results[2]?.locations).toHaveLength(1); expect(JSON.stringify(sarif)).not.toContain('beta-ok'); }); diff --git a/test/warning-codes.test.ts b/test/warning-codes.test.ts index bf1d95fb..1e79e441 100644 --- a/test/warning-codes.test.ts +++ b/test/warning-codes.test.ts @@ -148,6 +148,8 @@ describe('report degrade warnings', () => { if (parsed.status !== 'invalid') return; expect(parsed.diagnostic.warnCode).toBe('VG_WARN_CVSS_UNPARSEABLE'); expect(parsed.diagnostic.code).toBe('cvss-vector-parse-failed'); - expect(parsed.diagnostic.message.startsWith('VG_WARN_CVSS_UNPARSEABLE: ')).toBe(true); + expect(parsed.diagnostic.message).toBe( + 'CVSS vector failed to parse (cvss-vector-parse-failed): "not-a-vector" is not a CVSS:3.0 or CVSS:3.1 base vector. Replace it with a CVSS:3.0 or CVSS:3.1 base vector, or omit the vector and supply a numeric base score.', + ); }); }); From 296a3137d45c7c462d455c49b534637492f06e6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 05:01:38 +0000 Subject: [PATCH 3/3] refactor(warnings): align VG_WARN codes with canonical registry Replace the earlier warning-code shape with the 23-code registry. Build JSON adds codedWarnings, scan JSON adds degradations, and SBOM documents add vibgrate:warningCode. Exit codes are unchanged. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- ARCHITECTURE.md | 3 - DOCS.md | 94 +++++----- src/commands/build.ts | 14 +- src/core-open/formatters/markdown.ts | 20 +-- src/core-open/formatters/text.ts | 18 +- src/core-open/licenses/diagnostic.ts | 4 + src/core-open/run-core-scan.ts | 79 +++++---- src/core-open/scanners/dart-scanner.ts | 5 +- src/core-open/scanners/docker-scanner.ts | 3 +- src/core-open/scanners/dotnet-scanner.ts | 5 +- src/core-open/scanners/elixir-scanner.ts | 3 +- src/core-open/scanners/go-scanner.ts | 5 +- src/core-open/scanners/helm-scanner.ts | 3 +- src/core-open/scanners/java-scanner.ts | 5 +- src/core-open/scanners/node-scanner.ts | 5 +- src/core-open/scanners/php-scanner.ts | 5 +- src/core-open/scanners/python-scanner.ts | 5 +- src/core-open/scanners/ruby-scanner.ts | 5 +- src/core-open/scanners/rust-scanner.ts | 5 +- src/core-open/scanners/swift-scanner.ts | 5 +- src/core-open/scanners/terraform-scanner.ts | 3 +- src/core-open/scoring/cvss.ts | 4 +- src/core-open/scoring/drift-score.ts | 6 +- src/core-open/types.ts | 19 +- src/core-open/warnings.ts | 159 +++++++++++++++++ src/engine/build.ts | 77 +++----- src/engine/discover.ts | 17 +- src/engine/export.ts | 2 - src/engine/parse-worker.ts | 4 +- src/engine/pool.ts | 4 +- src/engine/toolchain/compose.ts | 3 +- src/engine/toolchain/dockerfile.ts | 3 +- src/engine/toolchain/index.ts | 3 +- src/engine/toolchain/kubernetes.ts | 3 +- src/engine/toolchain/terraform.ts | 9 +- src/engine/toolchain/workflows.ts | 7 +- src/engine/toolchain/yaml.ts | 7 +- src/engine/warning-codes.ts | 33 ++++ src/reporting/commands/sbom.test.ts | 2 +- src/reporting/commands/sbom.ts | 127 ++++++++------ src/reporting/formatters/markdown.ts | 20 +-- src/reporting/formatters/text.ts | 18 +- src/reporting/types.ts | 11 +- src/warnings/codes.ts | 166 ------------------ test/license-finding-source-path.test.ts | 10 +- test/sbom-lockfile-merge.test.ts | 3 +- test/vulnerabilities.test.ts | 1 + test/warning-codes.test.ts | 185 ++++++-------------- 48 files changed, 568 insertions(+), 629 deletions(-) create mode 100644 src/core-open/warnings.ts create mode 100644 src/engine/warning-codes.ts delete mode 100644 src/warnings/codes.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 38d329c3..475150fb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,9 +87,6 @@ src/ │ ├── config.ts, credentials.ts, regions.ts, version.ts │ └── utils/ │ -├── warnings/ Stable degrade-and-continue warning codes -│ └── codes.ts One registry (`VG_WARN_…`); published strings stay stable -│ └── util/ Shared helpers ├── output.ts, progress.ts, logo.ts, exit.ts ``` diff --git a/DOCS.md b/DOCS.md index aaf7b032..22cb77ac 100644 --- a/DOCS.md +++ b/DOCS.md @@ -98,7 +98,6 @@ For a quick overview, see the [README](./README.md). This document covers everyt - [Advisories with several ids](#advisories-with-several-ids) - [Markdown](#markdown) - [JUnit](#junit) - - [Degrade warning codes](#degrade-warning-codes) - [Configuration](#configuration) - [vibgrate.config.ts](#vibgrateconfigts) - [Thresholds](#thresholds) @@ -131,6 +130,7 @@ For a quick overview, see the [README](./README.md). This document covers everyt - [Troubleshooting: registry, auth, and network](#troubleshooting-registry-auth-and-network) - [Privacy & Security](#privacy--security) - [Exit Codes](#exit-codes) +- [Warning codes](#warning-codes) - [Programmatic API](#programmatic-api) --- @@ -1528,12 +1528,9 @@ separator), that component stays in the document and the purl is omitted. CycloneDX sets `vibgrate:purlStatus` to `unavailable` and records the reason on `vibgrate:purlWarning`. SPDX omits the purl externalRef, records `purlStatus=unavailable` on the package annotation, and repeats the -reason in a second annotation. CycloneDX also sets `vibgrate:purlWarningCode` -to `VG_WARN_PURL_UNAVAILABLE`. SPDX adds an annotation whose comment is that -same code. `vg sbom export` prints the code and the reason on stderr. The -warning names the package and its ecosystem. The purl rules above -are the identity a scanner should store. The full code list is in -[Degrade warning codes](#degrade-warning-codes). +reason in a second annotation. `vg sbom export` prints the same warning on +stderr. The warning names the package and its ecosystem. The purl rules above +are the identity a scanner should store. #### Component identity @@ -1665,10 +1662,8 @@ A value that cannot be represented, such as `LicenseRef-has space`, stays visible. The component remains in the document. CycloneDX sets `vibgrate:licenseStatus` to `unrepresentable` and records the reason on `vibgrate:licenseWarning`. SPDX adds `licenseStatus=unrepresentable` to the -package annotation and repeats the reason in a second annotation. Both formats -also record `VG_WARN_LICENSE_UNPARSEABLE` (`vibgrate:licenseWarningCode` on -CycloneDX; an SPDX annotation whose comment is that code). `vg sbom -export` prints the code and the same warning on stderr. The warning names the ecosystem, +package annotation and repeats the reason in a second annotation. `vg sbom +export` prints the same warning on stderr. The warning names the ecosystem, the package, and the version. No license field is left as an empty string, and no license is filled in from a guess. @@ -1710,9 +1705,8 @@ wins. A later copy is not an error. `vibgrate:projects` (SPDX: `projects=` on the package annotation) lists every project that contributed the identity, sorted. When a later manifest row carries different drift, spec, or license metadata, that metadata is dropped, the component stays, and -`vibgrate:mergeWarning` records the drop. CycloneDX sets `vibgrate:mergeWarningCode` -to `VG_WARN_SBOM_MERGE`. The same warning is an additional SPDX -annotation, followed by an annotation whose comment is that code, and `vg sbom export` prints the code and the warning on stderr. +`vibgrate:mergeWarning` records the drop. The same warning is an additional SPDX +annotation, and `vg sbom export` prints it on stderr. `vibgrate:scope` (SPDX: `scope=` on the package annotation) is `direct` when a scanned manifest declared that exact identity, and `transitive` when the @@ -5359,34 +5353,6 @@ have. --- -## Degrade warning codes - -`vg build`, `vg scan`, and `vg sbom export` keep going when one file, path, or field cannot be used. Those warnings carry a short code so a CI check or an assistant can match the code instead of the prose. - -A published code does not change. A new warning gets a new code. Codes are fixed tokens: they do not include a path, a file body, or a secret. The message next to the code is the human detail. When several warnings fire they are ordered by code, then by message, and an identical pair is emitted once. - -On `vg build`, the human line on stderr names the codes that fired. `vg build --json` keeps `warnings` as an array of strings, each `CODE: message`, and adds `warningRecords`: an array of `{ "code", "message" }` in that same order. Exit codes are unchanged. - -On `vg scan`, the same strings are printed when a path times out, a file is over the scan size limit, or a baseline file cannot be read. `vg scan --format json` and `.vibgrate/scan_result.json` add `degradeWarnings` (omitted when nothing fired) with the same `{ "code", "message" }` objects. A license that does not resolve still uses rule id `vibgrate/license-parse-failed` and adds `details.warnCode`. A CVSS vector that does not parse still uses `cvss-vector-parse-failed` and adds `warnCode` on `cvssDiagnostic`. `vg report` prints `degradeWarnings` and any warn code stored on a finding. - -On `vg sbom export`, stderr lines are `warning: CODE: message`. CycloneDX keeps the existing warning properties and adds a code property beside each one (`vibgrate:purlWarningCode`, `vibgrate:licenseWarningCode`, `vibgrate:mergeWarningCode`, and `vibgrate:warningCode` on a document-level license-parse note). SPDX keeps the existing annotation and adds one whose comment is the code. The document id does not include these new fields. - -| Code | When it fires | -| --- | --- | -| `VG_WARN_PARSE_DEGRADE` | A source file threw during parse. The build keeps the other files. | -| `VG_WARN_SKIPPED_FILE` | A file was over the build per-file cap or the scan size limit and was not read. | -| `VG_WARN_SKIPPED_SUBTREE` | A directory could not be read during `vg build`, or a scan path timed out and was skipped. | -| `VG_WARN_TSC_SKIPPED` | The TypeScript resolver was skipped because the file count was over `VG_TSC_MAX_FILES`. The heuristic resolver still ran. | -| `VG_WARN_TOOLCHAIN_DEGRADE` | Infrastructure or CI extraction degraded for one file. The rest of the map was still written. | -| `VG_WARN_PURL_UNAVAILABLE` | An SBOM component was kept and its Package URL was omitted. | -| `VG_WARN_LICENSE_UNPARSEABLE` | A declared license did not resolve to SPDX, or could not be written on the SBOM. | -| `VG_WARN_CVSS_UNPARSEABLE` | A CVSS vector was present and did not parse. The advisory is kept. | -| `VG_WARN_SBOM_MERGE` | An SBOM merge dropped or guessed a fact and still wrote the component. | -| `VG_WARN_BASELINE_UNREADABLE` | A baseline file could not be read. The scan continued without a delta. | -| `VG_WARN_SCAN_DEGRADE` | One project scanner failed and that project was skipped. | - -An unknown optional lockfile field is not one of these codes. When that warning exists it gets its own code in the same registry, rather than reusing one of the rows above. - ## Exit Codes CI and agents branch on these, so they are a stable contract. @@ -5409,6 +5375,50 @@ exits `6` rather than quietly producing a review no model contributed to. --- +## Warning codes + +A warning means the command continued. The process exit code stays the one in [Exit Codes](#exit-codes). Each published code keeps its meaning. A later release may add a code. It does not rename or reuse a code that has shipped. A code never contains a path, a secret, or file contents. When several warnings fire, JSON lists them by code, then by message. + +Stderr lines look like `warning [VG_WARN_PARSE_FAILED]: …`. The code is the token a CI check or an assistant asserts on. The sentence after the code can be reworded. + +`vg build --json` keeps `warnings` as strings and adds `codedWarnings`: an array of `{ code, message }` in that same order. Each string in `warnings` ends with ` [CODE]`. `codedWarnings` is left out when there are no warnings. + +`vg scan --format json` adds `degradations` when a path was skipped or a baseline file could not be read. The field is left out when there are none. `vg report` prints those same rows. + +`vg sbom export` prints the code on stderr. CycloneDX adds a `vibgrate:warningCode` property next to the existing warning property. SPDX adds an annotation whose comment is `warningCode=VG_WARN_…`. The warning sentences already stored on the document stay as they are. + +License findings keep the rule id `vibgrate/license-parse-failed` and also carry `warnCode`. CVSS findings keep `cvss-vector-parse-failed` and also carry `warnCode`. + +A truncated or invalid lockfile stops the command. Warning codes cover conditions that let the command continue. + +| Code | Command | Meaning | +| --- | --- | --- | +| `VG_WARN_PARSE_FAILED` | `vg build` | A source file failed to parse. The map continues without its symbols. | +| `VG_WARN_BUILD_FILE_OVERSIZE` | `vg build` | A file exceeded the per-file size cap and was left out of the map. | +| `VG_WARN_TSC_RESOLVER_SKIPPED` | `vg build` | The TypeScript resolver was skipped because the corpus exceeded its file cap. | +| `VG_WARN_YAML_PARSE_FAILED` | `vg build` | YAML for an infrastructure file failed to parse. | +| `VG_WARN_YAML_DOCUMENT_SKIPPED` | `vg build` | A YAML document in a stream had errors and was skipped. | +| `VG_WARN_YAML_DOCUMENT_UNMATERIALISED` | `vg build` | A YAML document could not be materialised. | +| `VG_WARN_TOOLCHAIN_EXTRACTION_FAILED` | `vg build` | An infrastructure extractor failed. That file contributes no structure. | +| `VG_WARN_TOOLCHAIN_NODE_CAP` | `vg build` | An extractor stopped at the per-file node cap. | +| `VG_WARN_HCL_GRAMMAR_UNAVAILABLE` | `vg build` | The HCL grammar was unavailable, so no Terraform structure was extracted. | +| `VG_WARN_HCL_NO_TREE` | `vg build` | HCL parse produced no tree. | +| `VG_WARN_HCL_PARTIAL` | `vg build` | HCL parse recovered from a syntax error. Extraction may be partial. | +| `VG_WARN_WORKFLOW_STEP_CAP` | `vg build` | A workflow job listed more steps than the extractor enumerates. | +| `VG_WARN_SCAN_PATH_SKIPPED` | `vg scan` | A scan path timed out and was skipped. | +| `VG_WARN_SCAN_FILE_OVERSIZE` | `vg scan` | A scan file exceeded the size cap and was skipped. | +| `VG_WARN_BASELINE_UNREADABLE` | `vg scan` | A baseline file could not be read. The scan continues without a comparison. | +| `VG_WARN_LICENSE_UNPARSEABLE` | `vg scan`, `vg sbom` | A declared license string could not be resolved to SPDX. | +| `VG_WARN_LICENSE_UNREPRESENTABLE` | `vg sbom` | A declared license cannot be represented in an SBOM. | +| `VG_WARN_CVSS_UNPARSEABLE` | `vg scan` | A CVSS vector was present and could not be parsed. | +| `VG_WARN_PURL_UNAVAILABLE` | `vg sbom` | A package URL could not be formed. The component is included without a purl. | +| `VG_WARN_SBOM_LOSSY_EDGES` | `vg sbom` | Merge dropped a different dependency list for one package. | +| `VG_WARN_SBOM_LOSSY_MANIFEST` | `vg sbom` | Merge dropped differing manifest metadata for one package. | +| `VG_WARN_SBOM_UNKNOWN_ECOSYSTEM` | `vg sbom` | Merge recorded an unknown ecosystem as npm. | +| `VG_WARN_SBOM_UNTRACKED_EDGES` | `vg sbom` | A lockfile format does not record dependency edges. | + +--- + ## Programmatic API The package exports its core types for programmatic use: diff --git a/src/commands/build.ts b/src/commands/build.ts index b12c425d..981cfa95 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -26,7 +26,7 @@ import { UnsafeRootError } from '../core-open/utils/root-safety.js'; import { CliError, ExitCode, usageError } from '../util/exit.js'; import { resolveSelfJsEntry } from '../util/cli-invocation.js'; import { c, info, out, json } from '../util/output.js'; -import { warningCodeSummary } from '../warnings/codes.js'; +import { formatWarningLine } from '../core-open/warnings.js'; import { printLogo } from '../util/logo.js'; import { ProgressBar } from '../util/progress.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; @@ -282,7 +282,7 @@ export async function runBuild( attestation, timingMs: result.timing.totalMs, warnings: result.warnings, - warningRecords: result.warningRecords, + ...(result.codedWarnings.length > 0 ? { codedWarnings: result.codedWarnings } : {}), activity: activity.toJSON(), }); return; @@ -341,8 +341,14 @@ export async function runBuild( .join(' '); info(` → ${artifactList}`); if (result.warnings.length) { - const codes = warningCodeSummary(result.warningRecords); - info(c.yellow(` ${result.warnings.length} parse warning(s) [${codes}] — run with --json for detail`)); + for (const warning of result.codedWarnings) { + info(c.yellow(` ${formatWarningLine(warning)}`)); + } + const stamped = new Set(result.codedWarnings.map((warning) => `${warning.message} [${warning.code}]`)); + for (const line of result.warnings) { + if (!stamped.has(line)) info(c.yellow(` warning: ${line}`)); + } + info(c.yellow(` ${result.warnings.length} parse warning(s) — run with --json for detail`)); } if (attestation) { for (const n of attestNotices) info(c.yellow(` ${n}`)); diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index 1f283d62..a9e6820f 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; +import { findingRuleLabel, formatDegradationLines } from '../warnings.js'; import { securityPacksLabel } from './text.js'; -import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; /** Rows shown before the infrastructure-findings table is cut with an "… N more" line. */ const SECURITY_ROWS_MAX = 50; @@ -174,6 +174,14 @@ export function formatMarkdown(artifact: ScanArtifact): string { } lines.push(''); } + const degradationLines = formatDegradationLines(artifact.degradations); + if (degradationLines.length > 0) { + lines.push('## Degradations'); + lines.push(''); + for (const line of degradationLines) lines.push(`- ${line}`); + lines.push(''); + } + // Findings if (artifact.findings.length > 0) { lines.push('## Findings'); @@ -182,19 +190,11 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(`|-------|------|---------|----------|`); for (const f of artifact.findings) { const emoji = f.level === 'error' ? '🔴' : f.level === 'warning' ? '🟡' : '🔵'; - lines.push(`| ${emoji} ${f.level} | ${ruleWithWarnCode(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); + lines.push(`| ${emoji} ${f.level} | ${findingRuleLabel(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); } lines.push(''); } - const degradeLines = degradeWarningBlock(artifact.degradeWarnings); - if (degradeLines.length) { - lines.push(`## ${degradeLines[0]}`); - lines.push(''); - for (const line of degradeLines.slice(1)) lines.push(`- ${line.trim()}`); - lines.push(''); - } - if (artifact.delta !== undefined) { const dir = artifact.delta > 0 ? '📈' : artifact.delta < 0 ? '📉' : '➡️'; lines.push(`## Drift Delta: ${dir} ${artifact.delta > 0 ? '+' : ''}${artifact.delta} vs baseline`); diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index 1d529d15..bc09d93c 100644 --- a/src/core-open/formatters/text.ts +++ b/src/core-open/formatters/text.ts @@ -5,7 +5,7 @@ import chalk from 'chalk'; import type { ScanArtifact, BillingSummary, ExtendedScanResults, InventoryItem, ServiceDependencyItem, ArchitectureResult, SecurityFinding, SecuritySection } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; import { humanFindingText } from './fix-hint.js'; -import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; +import { findingRuleLabel, formatDegradationLines } from '../warnings.js'; import { driftBar } from '../ui/bar.js'; import { titleBox, panelBox } from '../ui/box.js'; @@ -117,6 +117,13 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) lines.push(...formatExtended(artifact.extended)); } + const degradationLines = formatDegradationLines(artifact.degradations); + if (degradationLines.length > 0) { + lines.push(chalk.bold.underline(' Degradations')); + for (const line of degradationLines) lines.push(chalk.yellow(` ${line}`)); + lines.push(''); + } + // Findings if (artifact.findings.length > 0) { const errors = artifact.findings.filter((f) => f.level === 'error'); @@ -133,19 +140,12 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) const icon = f.level === 'error' ? chalk.red('✖') : f.level === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ'); const shown = humanFindingText(f); lines.push(` ${icon} ${shown.message}`); - lines.push(chalk.dim(` ${ruleWithWarnCode(f.ruleId, f.details)} in ${f.location}`)); + lines.push(chalk.dim(` ${findingRuleLabel(f.ruleId, f.details)} in ${f.location}`)); if (shown.hint) lines.push(chalk.dim(` ${shown.hint}`)); } lines.push(''); } - const degradeLines = degradeWarningBlock(artifact.degradeWarnings); - if (degradeLines.length) { - lines.push(chalk.bold.underline(` ${degradeLines[0]}`)); - for (const line of degradeLines.slice(1)) lines.push(chalk.yellow(line)); - lines.push(''); - } - // Priority actions const actions = generatePriorityActions(artifact); if (actions.length > 0) { diff --git a/src/core-open/licenses/diagnostic.ts b/src/core-open/licenses/diagnostic.ts index ef7fc3a3..87582588 100644 --- a/src/core-open/licenses/diagnostic.ts +++ b/src/core-open/licenses/diagnostic.ts @@ -19,6 +19,7 @@ import { isExplicitUnknownLicense, normalizeLicense } from './normalize.js'; import { parseLicenseExpression } from './spdx-expression.js'; +import { WARNING_CODES, type WarningCode } from '../warnings.js'; /** Stable code for scan, report, SARIF, and SBOM. Identical for every failure. */ export const LICENSE_PARSE_FAILED = 'vibgrate/license-parse-failed'; @@ -28,6 +29,8 @@ export const LICENSE_RAW_LIMIT = 120; export interface LicenseParseDiagnostic { code: typeof LICENSE_PARSE_FAILED; + /** Degrade-and-continue warning code. `code` stays the finding rule id. */ + warnCode: WarningCode; /** Actionable, deterministic text. No file body and no credential. */ message: string; /** Manifest-relative path the declaration was read from. */ @@ -109,6 +112,7 @@ export function licenseParseDiagnostic( 'Replace it with a canonical SPDX id or expression, or NOASSERTION if the license is intentionally unknown.'; return { code: LICENSE_PARSE_FAILED, + warnCode: WARNING_CODES.LICENSE_UNPARSEABLE, message, location: manifestPath, raw: shown, diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index b5086c0f..4ae6ee57 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -38,7 +38,6 @@ import { formatMarkdown } from './formatters/markdown.js'; import { loadConfig, appendExcludePatterns } from './config.js'; import { pathExists, readJsonFile, writeJsonFile, writeTextFile, ensureDir, FileCache, quickTreeCount } from './utils/fs.js'; import { assertSafeWalkRoot } from './utils/root-safety.js'; -import { WarningCodes, degradeLine, stableWarningRecords, type WarningRecord } from '../warnings/codes.js'; import { detectVcs } from './utils/vcs.js'; import { isCiEnvironment, hasVibgrateWorkflow } from './utils/ci-env.js'; import { resolveRepositoryName } from './utils/repository-name.js'; @@ -56,6 +55,14 @@ import type { ScanArtifact, ScanOptions, ProjectScan, ExtendedScanResults, RepositoryInfo, SolutionScan, VibgrateConfig, Finding, } from './types.js'; +import { + codedWarning, + formatWarningLine, + sortCodedWarnings, + warningPathLabel, + WARNING_CODES, + type CodedWarning, +} from './warnings.js'; import type { RuntimeCatalog } from './runtimes/types.js'; /** @@ -763,20 +770,19 @@ export async function runCoreScan( progress.finish(); - const stuckPaths = [...fileCache.stuckPaths].sort(); - const skippedLarge = [...fileCache.skippedLargeFiles].sort(); - const degradeWarnings: WarningRecord[] = []; + const degradations: CodedWarning[] = []; + const stuckPaths = [...fileCache.stuckPaths].sort((a, b) => a.localeCompare(b)); + const skippedLarge = [...fileCache.skippedLargeFiles].sort((a, b) => a.localeCompare(b)); + const timeoutSeconds = Math.round(projectScanTimeoutMs / 1000); if (stuckPaths.length > 0) { - console.log( - chalk.yellow(`\n⚠ ${degradeLine(WarningCodes.SKIPPED_SUBTREE, `${stuckPaths.length} path${stuckPaths.length === 1 ? '' : 's'} timed out (>${Math.round(projectScanTimeoutMs / 1000)}s) and ${stuckPaths.length === 1 ? 'was' : 'were'} skipped`)}:`), - ); - for (const d of stuckPaths) { - console.log(chalk.dim(` → ${d}`)); - degradeWarnings.push({ - code: WarningCodes.SKIPPED_SUBTREE, - message: `${d}: skipped — scan timed out`, - }); + for (const rel of stuckPaths) { + degradations.push( + codedWarning( + WARNING_CODES.SCAN_PATH_SKIPPED, + `${warningPathLabel(rel)} timed out (>${timeoutSeconds}s) and was skipped`, + ), + ); } const newExcludes = stuckPaths.map((d) => `${d}/**`); const updated = await appendExcludePatterns(rootDir, newExcludes); @@ -788,20 +794,13 @@ export async function runCoreScan( if (skippedLarge.length > 0) { const sizeLimit = config.maxFileSizeToScan ?? 5_242_880; const sizeMB = (sizeLimit / 1_048_576).toFixed(0); - console.log( - chalk.yellow(`\n⚠ ${degradeLine(WarningCodes.SKIPPED_FILE, `${skippedLarge.length} file${skippedLarge.length === 1 ? '' : 's'} skipped (>${sizeMB} MB)`)}:`), - ); - for (const f of skippedLarge) { - degradeWarnings.push({ - code: WarningCodes.SKIPPED_FILE, - message: `${f}: skipped — file exceeds the scan size limit`, - }); - } - for (const f of skippedLarge.slice(0, 10)) { - console.log(chalk.dim(` → ${f}`)); - } - if (skippedLarge.length > 10) { - console.log(chalk.dim(` … and ${skippedLarge.length - 10} more`)); + for (const rel of skippedLarge) { + degradations.push( + codedWarning( + WARNING_CODES.SCAN_FILE_OVERSIZE, + `${warningPathLabel(rel)} skipped — larger than ${sizeMB} MB`, + ), + ); } } @@ -857,16 +856,23 @@ export async function runCoreScan( const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : []; artifact.baselineComparison = compareBaselineFindings(artifact.findings, baselineFindings); } catch { - const where = baselineWarningPath(rootDir, baselinePath); - const message = `Could not read baseline file: ${where}`; - console.error(chalk.yellow(`Warning: ${degradeLine(WarningCodes.BASELINE_UNREADABLE, message)}`)); - degradeWarnings.push({ code: WarningCodes.BASELINE_UNREADABLE, message }); + degradations.push( + codedWarning( + WARNING_CODES.BASELINE_UNREADABLE, + `Could not read baseline file: ${warningPathLabel(path.basename(baselinePath))}`, + ), + ); } } } - const stableDegrade = stableWarningRecords(degradeWarnings); - if (stableDegrade.length) artifact.degradeWarnings = stableDegrade; + const sortedDegradations = sortCodedWarnings(degradations); + if (sortedDegradations.length > 0) { + artifact.degradations = sortedDegradations; + for (const warning of sortedDegradations) { + console.error(chalk.yellow(formatWarningLine(warning))); + } + } if (!opts.noLocalArtifacts && !maxPrivacyMode) { const vibgrateDir = path.join(rootDir, '.vibgrate'); @@ -1011,10 +1017,3 @@ async function buildRepositoryInfo(rootDir: string, remoteUrl: string | undefine ...(remoteUrl ? { remoteUrl } : {}), }; } - -/** Baseline path safe to print and store: repo-relative, or the basename when it sits outside the repo. */ -function baselineWarningPath(rootDir: string, baselinePath: string): string { - const rel = path.relative(rootDir, baselinePath); - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return path.basename(baselinePath); - return rel.split('\\').join('/'); -} diff --git a/src/core-open/scanners/dart-scanner.ts b/src/core-open/scanners/dart-scanner.ts index 1d51b6b1..f8a4d67b 100644 --- a/src/core-open/scanners/dart-scanner.ts +++ b/src/core-open/scanners/dart-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; @@ -265,7 +264,7 @@ export async function scanDartProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Dart project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Dart project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -273,7 +272,7 @@ export async function scanDartProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Dart project ${dir}: ${msg}`)); + console.error(`Error scanning Dart project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/docker-scanner.ts b/src/core-open/scanners/docker-scanner.ts index a663f986..3315801b 100644 --- a/src/core-open/scanners/docker-scanner.ts +++ b/src/core-open/scanners/docker-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchDockerVersionsBulk, parseDockerImage } from './docker-cache.js'; import { gt, SemVer } from 'semver'; import type { ProjectScan, DependencyRow } from '../types.js'; @@ -261,7 +260,7 @@ export async function scanDockerProjects( } } catch (error) { // Skip projects that fail to scan - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Docker project at ${dir}:`), error); + console.error(`Error scanning Docker project at ${dir}:`, error); } } diff --git a/src/core-open/scanners/dotnet-scanner.ts b/src/core-open/scanners/dotnet-scanner.ts index d8cda0f3..3a3461d2 100644 --- a/src/core-open/scanners/dotnet-scanner.ts +++ b/src/core-open/scanners/dotnet-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; @@ -451,14 +450,14 @@ export async function scanDotnetProjects(rootDir: string, nugetCache?: NuGetCach if (cache) { cache.addStuckPath(relPath || '.'); } - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning ${csprojPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning ${csprojPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning ${csprojPath}: ${msg}`)); + console.error(`Error scanning ${csprojPath}: ${msg}`); } } diff --git a/src/core-open/scanners/elixir-scanner.ts b/src/core-open/scanners/elixir-scanner.ts index 0cefed87..9c34079e 100644 --- a/src/core-open/scanners/elixir-scanner.ts +++ b/src/core-open/scanners/elixir-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchHexVersionsBulk } from './hex-cache.js'; import { gt, minVersion, validRange } from 'semver'; import type { ProjectScan, DependencyRow, DetectedFramework } from '../types.js'; @@ -321,7 +320,7 @@ export async function scanElixirProjects( } } catch (error) { // Skip projects that fail to scan - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Elixir project at ${dir}:`), error); + console.error(`Error scanning Elixir project at ${dir}:`, error); } } diff --git a/src/core-open/scanners/go-scanner.ts b/src/core-open/scanners/go-scanner.ts index 84bf1933..aad284bf 100644 --- a/src/core-open/scanners/go-scanner.ts +++ b/src/core-open/scanners/go-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; @@ -222,14 +221,14 @@ export async function scanGoProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Go project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Go project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Go project ${dir}: ${msg}`)); + console.error(`Error scanning Go project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/helm-scanner.ts b/src/core-open/scanners/helm-scanner.ts index 65c68b5d..46051e6a 100644 --- a/src/core-open/scanners/helm-scanner.ts +++ b/src/core-open/scanners/helm-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchHelmVersionsBulk } from './helm-cache.js'; import { gt, minVersion, validRange } from 'semver'; import type { ProjectScan, DependencyRow } from '../types.js'; @@ -312,7 +311,7 @@ export async function scanHelmProjects( } } catch (error) { // Skip projects that fail to scan - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Helm chart at ${dir}:`), error); + console.error(`Error scanning Helm chart at ${dir}:`, error); } } diff --git a/src/core-open/scanners/java-scanner.ts b/src/core-open/scanners/java-scanner.ts index 20a44ec3..4000c683 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; @@ -383,7 +382,7 @@ export async function scanJavaProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Java project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Java project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -391,7 +390,7 @@ export async function scanJavaProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Java project ${dir}: ${msg}`)); + console.error(`Error scanning Java project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/node-scanner.ts b/src/core-open/scanners/node-scanner.ts index 7827da6f..1d110109 100644 --- a/src/core-open/scanners/node-scanner.ts +++ b/src/core-open/scanners/node-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as os from 'node:os'; import * as path from 'node:path'; import * as semver from 'semver'; @@ -245,7 +244,7 @@ export async function scanNodeProjects( if (cache) { cache.addStuckPath(relPath || '.'); } - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning ${pjPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning ${pjPath} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -253,7 +252,7 @@ export async function scanNodeProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning ${pjPath}: ${msg}`)); + console.error(`Error scanning ${pjPath}: ${msg}`); return null; } }))); diff --git a/src/core-open/scanners/php-scanner.ts b/src/core-open/scanners/php-scanner.ts index 6773afaa..e5993ca3 100644 --- a/src/core-open/scanners/php-scanner.ts +++ b/src/core-open/scanners/php-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { FileCache, readJsonFile } from '../utils/fs.js'; @@ -213,7 +212,7 @@ export async function scanPhpProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning PHP project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning PHP project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -221,7 +220,7 @@ export async function scanPhpProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning PHP project ${dir}: ${msg}`)); + console.error(`Error scanning PHP project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/python-scanner.ts b/src/core-open/scanners/python-scanner.ts index 272a3e3f..e421fe40 100644 --- a/src/core-open/scanners/python-scanner.ts +++ b/src/core-open/scanners/python-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; @@ -374,7 +373,7 @@ export async function scanPythonProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Python project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Python project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -382,7 +381,7 @@ export async function scanPythonProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Python project ${dir}: ${msg}`)); + console.error(`Error scanning Python project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/ruby-scanner.ts b/src/core-open/scanners/ruby-scanner.ts index bbe612c7..e5927bf8 100644 --- a/src/core-open/scanners/ruby-scanner.ts +++ b/src/core-open/scanners/ruby-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; @@ -361,7 +360,7 @@ export async function scanRubyProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Ruby project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Ruby project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -369,7 +368,7 @@ export async function scanRubyProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Ruby project ${dir}: ${msg}`)); + console.error(`Error scanning Ruby project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/rust-scanner.ts b/src/core-open/scanners/rust-scanner.ts index a7dd313b..1f11642f 100644 --- a/src/core-open/scanners/rust-scanner.ts +++ b/src/core-open/scanners/rust-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; @@ -212,7 +211,7 @@ export async function scanRustProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Rust project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Rust project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -220,7 +219,7 @@ export async function scanRustProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Rust project ${dir}: ${msg}`)); + console.error(`Error scanning Rust project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/swift-scanner.ts b/src/core-open/scanners/swift-scanner.ts index 5b30d2b9..9c4aec6f 100644 --- a/src/core-open/scanners/swift-scanner.ts +++ b/src/core-open/scanners/swift-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; @@ -189,7 +188,7 @@ export async function scanSwiftProjects( } else { const relPath = path.relative(rootDir, dir); if (cache) cache.addStuckPath(relPath || '.'); - console.error(degradeLine(WarningCodes.SKIPPED_SUBTREE, `Timeout scanning Swift project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`)); + console.error(`Timeout scanning Swift project ${dir} (>${STUCK_TIMEOUT_MS / 1000}s) — skipped`); if (cache?.shouldShowTimeoutHint()) { console.error(` Tip: increase projectScanTimeout in vibgrate.config.ts (or --project-scan-timeout ) for large projects`); } @@ -197,7 +196,7 @@ export async function scanSwiftProjects( } catch (e: unknown) { rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Swift project ${dir}: ${msg}`)); + console.error(`Error scanning Swift project ${dir}: ${msg}`); } } diff --git a/src/core-open/scanners/terraform-scanner.ts b/src/core-open/scanners/terraform-scanner.ts index e96bf126..387ea5e9 100644 --- a/src/core-open/scanners/terraform-scanner.ts +++ b/src/core-open/scanners/terraform-scanner.ts @@ -1,7 +1,6 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fetchTerraformVersionsBulk } from './terraform-cache.js'; import { gt, minVersion, validRange } from 'semver'; import type { ProjectScan, DependencyRow } from '../types.js'; @@ -400,7 +399,7 @@ export async function scanTerraformProjects( } } catch (error) { // Skip projects that fail to scan - console.error(degradeLine(WarningCodes.SCAN_DEGRADE, `Error scanning Terraform project at ${dir}:`), error); + console.error(`Error scanning Terraform project at ${dir}:`, error); } } diff --git a/src/core-open/scoring/cvss.ts b/src/core-open/scoring/cvss.ts index 6a5014dc..d585faa0 100644 --- a/src/core-open/scoring/cvss.ts +++ b/src/core-open/scoring/cvss.ts @@ -3,7 +3,7 @@ // and re-run the vendor script. Apache-2.0. import type { CvssDiagnostic, VulnSeverity } from '../types.js'; import { redactSecrets } from '../utils/redact.js'; -import { WarningCodes } from '../../warnings/codes.js'; +import { WARNING_CODES } from '../warnings.js'; /** * Minimal, dependency-free CVSS v3.0/v3.1 base-score calculator. @@ -65,7 +65,7 @@ function invalid(reason: string): CvssParseResult { ); return { status: 'invalid', - diagnostic: { code: CVSS_VECTOR_PARSE_FAILED, warnCode: WarningCodes.CVSS_UNPARSEABLE, message }, + diagnostic: { code: CVSS_VECTOR_PARSE_FAILED, warnCode: WARNING_CODES.CVSS_UNPARSEABLE, message }, }; } diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index b48c1bc6..a27fb85b 100644 --- a/src/core-open/scoring/drift-score.ts +++ b/src/core-open/scoring/drift-score.ts @@ -4,7 +4,7 @@ import * as crypto from 'node:crypto'; import type { ProjectScan, DriftScore, Finding, RiskLevel, VibgrateConfig } from '../types.js'; import { licenseParseDiagnostic } from '../licenses/diagnostic.js'; -import { WarningCodes } from '../../warnings/codes.js'; +import { WARNING_CODES } from '../warnings.js'; import { normalizeLicenseSourcePath } from '../licenses/dependency-license.js'; import { aggregateDependencyDrift } from './dependency-drift-v3.js'; @@ -411,8 +411,8 @@ export function generateFindings( message: diag.message, location, details: sourcePath - ? { raw: diag.raw, sourcePath, warnCode: WarningCodes.LICENSE_UNPARSEABLE } - : { raw: diag.raw, warnCode: WarningCodes.LICENSE_UNPARSEABLE }, + ? { raw: diag.raw, sourcePath, warnCode: WARNING_CODES.LICENSE_UNPARSEABLE } + : { raw: diag.raw, warnCode: WARNING_CODES.LICENSE_UNPARSEABLE }, }); } findings.push(...licenseFindings); diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 5456ea01..ac0390d0 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -4,6 +4,7 @@ // ── Core types for Vibgrate CLI ── import type { DriftBudgetConfig } from './drift-budget.js'; +import type { CodedWarning } from './warnings.js'; export type DepSection = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies'; @@ -478,10 +479,10 @@ export interface CvssDiagnostic { /** Stable code, identical for every unparseable vector. */ code: 'cvss-vector-parse-failed'; /** - * Degrade-and-continue code (`VG_WARN_CVSS_UNPARSEABLE`). Present when this - * CLI built the diagnostic. The `code` field above is unchanged. + * Degrade-and-continue warning code. Stable across releases. + * `code` stays `cvss-vector-parse-failed`. */ - warnCode?: 'VG_WARN_CVSS_UNPARSEABLE'; + warnCode: 'VG_WARN_CVSS_UNPARSEABLE'; /** * What failed and what to do next. Deterministic for a given vector. * Credential-shaped text from the vector is redacted. @@ -791,6 +792,12 @@ export interface ScanArtifact { solutions?: SolutionScan[]; drift: DriftScore; findings: Finding[]; + /** + * Degrade-and-continue notices from this scan (skipped paths, an unreadable + * baseline). Omitted when there are none — absent is not an empty list. + * Sorted by code, then by message. Does not change the exit code. + */ + degradations?: CodedWarning[]; /** * Repo-relative path (or basename, when the file is outside the repo) of the * `--baseline` snapshot this scan compared against. @@ -814,12 +821,6 @@ export interface ScanArtifact { relationshipDiagram?: MermaidDiagram; /** Billing roll-up derived from per-project function/project classifications */ billing?: BillingSummary; - /** - * Degrade-and-continue warnings from this scan (skipped paths, an unreadable - * baseline). Omitted when none fired. Sorted by code, then message. Does - * not change whether the scan succeeded. - */ - degradeWarnings?: Array<{ code: string; message: string }>; /** * Vulnerable-symbol reachability results from the LOCAL code-graph query * (`vg scan` with a DSN): the risky-symbol manifest from the scan symbols diff --git a/src/core-open/warnings.ts b/src/core-open/warnings.ts new file mode 100644 index 00000000..f2b950bc --- /dev/null +++ b/src/core-open/warnings.ts @@ -0,0 +1,159 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Stable codes for degrade-and-continue warnings on `vg build`, `vg scan`, + * `vg report`, and `vg sbom`. + * + * A published code keeps its meaning. Add a new entry here for a new + * condition, then emit it from the call site. Do not reuse or rewrite a + * code that has shipped. Codes are identifiers only: they never embed a + * path, a secret, or file contents. + * + * A truncated or invalid lockfile stops the command. It is not a warning. + * A later warn-and-continue lockfile condition adds one entry to this + * object and one emit site. + */ + +export const WARNING_CODES = { + /** A source file failed to parse. The build continues without its symbols. */ + PARSE_FAILED: 'VG_WARN_PARSE_FAILED', + /** A file exceeded the per-file size cap and was left out of the map. */ + BUILD_FILE_OVERSIZE: 'VG_WARN_BUILD_FILE_OVERSIZE', + /** The TypeScript resolver was skipped because the corpus exceeded its file cap. */ + TSC_RESOLVER_SKIPPED: 'VG_WARN_TSC_RESOLVER_SKIPPED', + /** YAML for an infrastructure file failed to parse. */ + YAML_PARSE_FAILED: 'VG_WARN_YAML_PARSE_FAILED', + /** A YAML document in a stream had errors and was skipped. */ + YAML_DOCUMENT_SKIPPED: 'VG_WARN_YAML_DOCUMENT_SKIPPED', + /** A YAML document could not be materialised. */ + YAML_DOCUMENT_UNMATERIALISED: 'VG_WARN_YAML_DOCUMENT_UNMATERIALISED', + /** An infrastructure extractor threw. That file contributes no structure. */ + TOOLCHAIN_EXTRACTION_FAILED: 'VG_WARN_TOOLCHAIN_EXTRACTION_FAILED', + /** An extractor stopped at the per-file node cap. */ + TOOLCHAIN_NODE_CAP: 'VG_WARN_TOOLCHAIN_NODE_CAP', + /** The HCL grammar was unavailable, so no Terraform structure was extracted. */ + HCL_GRAMMAR_UNAVAILABLE: 'VG_WARN_HCL_GRAMMAR_UNAVAILABLE', + /** HCL parse produced no tree. */ + HCL_NO_TREE: 'VG_WARN_HCL_NO_TREE', + /** HCL parse recovered from a syntax error. Extraction may be partial. */ + HCL_PARTIAL: 'VG_WARN_HCL_PARTIAL', + /** A workflow job listed more steps than the extractor enumerates. */ + WORKFLOW_STEP_CAP: 'VG_WARN_WORKFLOW_STEP_CAP', + /** A scan path timed out and was skipped. */ + SCAN_PATH_SKIPPED: 'VG_WARN_SCAN_PATH_SKIPPED', + /** A scan file exceeded the size cap and was skipped. */ + SCAN_FILE_OVERSIZE: 'VG_WARN_SCAN_FILE_OVERSIZE', + /** A baseline file could not be read. The scan continues without a comparison. */ + BASELINE_UNREADABLE: 'VG_WARN_BASELINE_UNREADABLE', + /** A declared license string could not be resolved to SPDX. */ + LICENSE_UNPARSEABLE: 'VG_WARN_LICENSE_UNPARSEABLE', + /** A declared license cannot be represented in an SBOM. */ + LICENSE_UNREPRESENTABLE: 'VG_WARN_LICENSE_UNREPRESENTABLE', + /** A CVSS vector was present and could not be parsed. */ + CVSS_UNPARSEABLE: 'VG_WARN_CVSS_UNPARSEABLE', + /** A package URL could not be formed. The component is included without a purl. */ + PURL_UNAVAILABLE: 'VG_WARN_PURL_UNAVAILABLE', + /** SBOM merge dropped a different dependency list for one package. */ + SBOM_LOSSY_EDGES: 'VG_WARN_SBOM_LOSSY_EDGES', + /** SBOM merge dropped differing manifest metadata for one package. */ + SBOM_LOSSY_MANIFEST: 'VG_WARN_SBOM_LOSSY_MANIFEST', + /** SBOM merge recorded an unknown ecosystem as npm. */ + SBOM_UNKNOWN_ECOSYSTEM: 'VG_WARN_SBOM_UNKNOWN_ECOSYSTEM', + /** A lockfile format does not record dependency edges. */ + SBOM_UNTRACKED_EDGES: 'VG_WARN_SBOM_UNTRACKED_EDGES', +} as const; + +export type WarningCode = (typeof WARNING_CODES)[keyof typeof WARNING_CODES]; + +export interface CodedWarning { + code: WarningCode; + /** Human prose. The code lives in {@link CodedWarning.code}, not in this string. */ + message: string; +} + +const CODE_LIST: readonly WarningCode[] = Object.values(WARNING_CODES); +const CODE_SET: ReadonlySet = new Set(CODE_LIST); + +/** Every published code, in registry order. */ +export function warningCodeList(): readonly WarningCode[] { + return CODE_LIST; +} + +export function isWarningCode(value: unknown): value is WarningCode { + return typeof value === 'string' && CODE_SET.has(value); +} + +export function codedWarning(code: WarningCode, message: string): CodedWarning { + return { code, message }; +} + +/** + * Human line. The code is a stable token at the front so a search for + * `VG_WARN_` does not depend on the prose. + */ +export function formatWarningLine(warning: CodedWarning): string { + return `warning [${warning.code}]: ${warning.message}`; +} + +/** + * Store a code on a string channel that already carries prose. + * The prose stays a prefix so existing substring checks still match. + */ +export function stampWarning(code: WarningCode, message: string): string { + return `${message} [${code}]`; +} + +const STAMP_RE = / \[VG_WARN_[A-Z0-9_]+\]$/; + +/** Split a stamped string. Returns undefined when the line has no known code. */ +export function splitStampedWarning(stored: string): CodedWarning | undefined { + const match = STAMP_RE.exec(stored); + if (!match) return undefined; + const code = match[0].slice(2, -1); + if (!isWarningCode(code)) return undefined; + return { code, message: stored.slice(0, match.index) }; +} + +/** + * Order by code, then by message. Equal pairs keep their input order + * (the runtime sort is stable). + */ +export function sortCodedWarnings(warnings: readonly CodedWarning[]): CodedWarning[] { + return [...warnings].sort((a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message)); +} + +/** + * Path text safe to put in a warning message: a repo-relative path, or the + * file name when the path would leave the repo or is absolute. Never file + * contents. + */ +export function warningPathLabel(rel: string): string { + const posix = rel.split('\\').join('/'); + if (posix.startsWith('/') || /^[A-Za-z]:/.test(posix) || posix.startsWith('..') || posix === '') { + const base = posix.split('/').pop(); + return base && base.length > 0 ? base : 'path'; + } + return posix; +} + +export function findingWarningCode(details: unknown): WarningCode | undefined { + if (!details || typeof details !== 'object') return undefined; + const record = details as { warnCode?: unknown; cvssDiagnostic?: { warnCode?: unknown } | null }; + if (isWarningCode(record.warnCode)) return record.warnCode; + const nested = record.cvssDiagnostic?.warnCode; + if (isWarningCode(nested)) return nested; + return undefined; +} + +/** Rule id for a text or markdown row, with the warning code when one is set. */ +export function findingRuleLabel(ruleId: string, details: unknown): string { + const code = findingWarningCode(details); + return code ? `${ruleId} [${code}]` : ruleId; +} + +/** Plain warning lines for a report. Empty when the field is absent. */ +export function formatDegradationLines(degradations: readonly CodedWarning[] | undefined): string[] { + if (!degradations || degradations.length === 0) return []; + return degradations.map((warning) => formatWarningLine(warning)); +} diff --git a/src/engine/build.ts b/src/engine/build.ts index faca105d..9252c40f 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -1,14 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { discover, mergeExcludes, type DiscoveredFile } from './discover.js'; -import { - WarningCodes, - isParseFailureWarning, - parseRenderedWarning, - renderWarning, - stableWarningRecords, - type WarningRecord, -} from '../warnings/codes.js'; import { parseFiles } from './pool.js'; import { resolve } from './resolve.js'; import { inheritDuties } from './duties-inherit.js'; @@ -58,6 +50,8 @@ import type { FileParse } from './types.js'; import type { ResolveResult } from './resolve.js'; import { fileRolesFromParses } from './ast-roles.js'; import type { AstRoleHit } from '../core-open/scanners/architecture/ast-roles.js'; +import { stampWarning, WARNING_CODES, type CodedWarning } from '../core-open/warnings.js'; +import { assembleEngineWarnings } from './warning-codes.js'; export interface BuildOptions { /** Directory to build (default cwd). */ @@ -151,13 +145,12 @@ export interface BuildResult { scip?: { documents: number; references: number; resolved: number; tool?: string }; /** SQLite index write result. */ index?: { ok: boolean; path?: string; reason?: string }; + warnings: string[]; /** - * Degrade-and-continue warnings. Each string is `CODE: message`. - * Same order as {@link warningRecords}. + * Same notices as `warnings`, split into a stable code and the prose. + * Sorted by code, then message. Empty when there are no warnings. */ - warnings: string[]; - /** Structured form of {@link warnings}. Sorted by code, then message. */ - warningRecords: WarningRecord[]; + codedWarnings: CodedWarning[]; /** Architecture role hits extracted during the parse already paid for. */ fileRoles: AstRoleHit[]; } @@ -171,20 +164,14 @@ export async function buildGraph(options: BuildOptions): Promise { // instead of reading a filesystem root or an enormous unpack first. const limits = resolveLimits(options.limits); timer.start('discover'); - const skippedSubtrees: string[] = []; const files = discover({ root, only: options.only, exclude, paths: options.paths, maxEntries: limits.maxFiles, - skippedSubtrees, }); timer.end('discover'); - const warningRecordsAcc: WarningRecord[] = skippedSubtrees.map((rel) => ({ - code: WarningCodes.SKIPPED_SUBTREE, - message: `${rel}: skipped — directory could not be read`, - })); // Resource safeguards (see limits.ts): stop a pathological corpus before it // OOM-kills the process. Skips are deterministic functions of the input. @@ -221,7 +208,7 @@ export async function buildGraph(options: BuildOptions): Promise { const fileStats: FileStat[] = []; const toParse: DiscoveredFile[] = []; const reused: FileParse[] = []; - const buildWarningRecords: WarningRecord[] = []; + const buildWarnings: string[] = []; /** Files skipped for size — in fileStats under a sentinel hash, never in the manifest. */ const oversizeRels = new Set(); let statHits = 0; @@ -247,12 +234,13 @@ export async function buildGraph(options: BuildOptions): Promise { hash: hashString(`vg:oversize:${stat.size}`), }); oversizeRels.add(file.rel); - buildWarningRecords.push({ - code: WarningCodes.SKIPPED_FILE, - message: + buildWarnings.push( + stampWarning( + WARNING_CODES.BUILD_FILE_OVERSIZE, `${file.rel}: skipped — ${formatBytes(stat.size)} exceeds the ` + - `${formatBytes(limits.maxFileBytes)} per-file limit (set VG_MAX_FILE_BYTES to raise it, 0 to disable)`, - }); + `${formatBytes(limits.maxFileBytes)} per-file limit (set VG_MAX_FILE_BYTES to raise it, 0 to disable)`, + ), + ); continue; } @@ -321,7 +309,7 @@ export async function buildGraph(options: BuildOptions): Promise { // corrupted wasm heap) would otherwise poison the cache for that content // hash and every later build would reuse the empty parse instead of // re-parsing the file. - if (p.defs.length === 0 && p.warnings?.some((w) => isParseFailureWarning(w))) continue; + if (p.defs.length === 0 && p.warnings?.some((w) => w.startsWith('parse failed:'))) continue; const st = fileStats.find((f) => f.rel === p.rel); cache.set(p.rel, p, st ? { mtimeMs: st.mtimeMs, size: st.size } : undefined); } @@ -335,18 +323,7 @@ export async function buildGraph(options: BuildOptions): Promise { a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0, ); - warningRecordsAcc.push(...buildWarningRecords); - for (const p of parses) { - for (const w of p.warnings ?? []) { - const parsed = parseRenderedWarning(w); - const message = parsed?.message ?? w; - const withPath = message.startsWith(`${p.rel}:`) ? message : `${p.rel}: ${message}`; - warningRecordsAcc.push({ - code: parsed?.code ?? WarningCodes.PARSE_DEGRADE, - message: withPath, - }); - } - } + const warnings = [...buildWarnings, ...parses.flatMap((p) => p.warnings ?? [])]; // Resolve → nodes/edges. The module resolver follows relative imports plus // tsconfig path aliases and workspace-package names (so monorepo cross-package @@ -412,13 +389,14 @@ export async function buildGraph(options: BuildOptions): Promise { // A ts.Program over the whole corpus is the largest single memory consumer // in the build. Past the cap, fall back to the heuristic floor (still a // complete graph, just less precise call resolution). - warningRecordsAcc.push({ - code: WarningCodes.TSC_SKIPPED, - message: + warnings.push( + stampWarning( + WARNING_CODES.TSC_RESOLVER_SKIPPED, `typescript resolver skipped — ${tsFiles.length.toLocaleString()} TS/JS files exceed the ` + - `${limits.tscMaxFiles.toLocaleString()}-file limit; calls use the heuristic resolver ` + - `(set VG_TSC_MAX_FILES to raise it, 0 to disable)`, - }); + `${limits.tscMaxFiles.toLocaleString()}-file limit; calls use the heuristic resolver ` + + `(set VG_TSC_MAX_FILES to raise it, 0 to disable)`, + ), + ); tsFiles = []; } timer.start('tsc'); @@ -568,11 +546,7 @@ export async function buildGraph(options: BuildOptions): Promise { } const toolchainResult = await extractToolchain(docs, { fileNodes: fileNodesByPath }); if (toolchainResult.nodes.length) nodes = [...nodes, ...toolchainResult.nodes]; - for (const message of toolchainResult.warnings) { - warningRecordsAcc.push({ code: WarningCodes.TOOLCHAIN_DEGRADE, message }); - } - const warningRecords = stableWarningRecords(warningRecordsAcc); - const warnings = warningRecords.map(renderWarning); + if (toolchainResult.warnings.length) warnings.push(...toolchainResult.warnings); timer.end('toolchain'); // Analyse → centrality/areas/surprise (test/coverage edges excluded from these). @@ -702,6 +676,7 @@ export async function buildGraph(options: BuildOptions): Promise { timer.end('total'); const stages = timer.snapshot(); + const assembled = assembleEngineWarnings(warnings); return { graph, @@ -716,8 +691,8 @@ export async function buildGraph(options: BuildOptions): Promise { tsc: tscStats, scip: scipStats, index: indexResult, - warnings, - warningRecords, + warnings: assembled.warnings, + codedWarnings: assembled.codedWarnings, fileRoles: fileRolesFromParses(parses), }; } diff --git a/src/engine/discover.ts b/src/engine/discover.ts index 3c1ac5d3..2f523416 100644 --- a/src/engine/discover.ts +++ b/src/engine/discover.ts @@ -166,12 +166,6 @@ export interface DiscoverOptions { * Default: `VG_MAX_FILES`, else 100000. */ maxEntries?: number; - /** - * Repo-relative directories that could not be read. Appended in sorted - * order. The walk continues. The OS error is not copied — it can name a - * path outside the message we want to keep. - */ - skippedSubtrees?: string[]; } export interface DiscoveredFile { @@ -254,7 +248,6 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { const found = new Map(); const budget = createWalkBudget(root, options.maxEntries); - const skippedSubtrees: string[] = []; const considerFile = (abs: string): void => { const rel = toPosix(path.relative(root, abs)); @@ -276,11 +269,7 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { - // Unreadable dir — skip rather than crash the build. The relative path - // is the whole notice; the OS error is not copied. - const rel = toPosix(path.relative(root, dir)); - skippedSubtrees.push(rel === '' ? '.' : rel); - return; + return; // unreadable dir — skip rather than crash the build } for (const entry of entries) { const abs = path.join(dir, entry.name); @@ -308,10 +297,6 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { } else if (stat.isFile()) considerFile(scope); } - if (options.skippedSubtrees) { - for (const rel of [...new Set(skippedSubtrees)].sort()) options.skippedSubtrees.push(rel); - } - return [...found.values()].sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0)); } diff --git a/src/engine/export.ts b/src/engine/export.ts index 25cf6d77..f6ac8d51 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -3,7 +3,6 @@ import { renderReport } from './report.js'; import { renderHtml } from './html.js'; import type { DepRecord } from './drift.js'; import { resolvePurl } from '../reporting/commands/sbom.js'; -import { WarningCodes } from '../warnings/codes.js'; import type { LocalModel } from './models.js'; import type { VgGraph } from '../schema.js'; @@ -267,7 +266,6 @@ function cyclonedx(ctx: ExportContext): string { properties: [ { name: 'vibgrate:purlStatus', value: 'unavailable' }, { name: 'vibgrate:purlWarning', value: resolved.warning }, - { name: 'vibgrate:purlWarningCode', value: WarningCodes.PURL_UNAVAILABLE }, ], }); } diff --git a/src/engine/parse-worker.ts b/src/engine/parse-worker.ts index df5d9287..47904988 100644 --- a/src/engine/parse-worker.ts +++ b/src/engine/parse-worker.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import { parseSource } from './parse.js'; import { setGrammarsOverride, resetParser } from './grammars.js'; import type { FileParse } from './types.js'; -import { parseDegradeWarning } from '../warnings/codes.js'; +import { stampWarning, WARNING_CODES } from '../core-open/warnings.js'; /** * tinypool worker entry. Receives a chunk of files, reads and parses each, and @@ -47,7 +47,7 @@ export default async function run(payload: ParsePayload): Promise { heritage: [], typeRefs: [], guards: [], - warnings: [parseDegradeWarning((err as Error).message)], + warnings: [stampWarning(WARNING_CODES.PARSE_FAILED, `parse failed: ${(err as Error).message}`)], }); } } diff --git a/src/engine/pool.ts b/src/engine/pool.ts index 9dc00112..9837770c 100644 --- a/src/engine/pool.ts +++ b/src/engine/pool.ts @@ -7,8 +7,8 @@ import { setGrammarsOverride, resetParser } from './grammars.js'; import { checkMemoryBudget, envJobs, envWorkerHeapMb, ResourceLimitError } from './limits.js'; import type { DiscoveredFile } from './discover.js'; import type { FileParse } from './types.js'; +import { stampWarning, WARNING_CODES } from '../core-open/warnings.js'; import type { ParseTask } from './parse-worker.js'; -import { parseDegradeWarning } from '../warnings/codes.js'; /** * Parse a set of discovered files into FileParse tables. @@ -82,7 +82,7 @@ async function parseInline(files: DiscoveredFile[], options: ParseOptions): Prom // A wasm-level parse crash can leave the language's reused parser // mid-state; drop it so the failure stays contained to this file. resetParser(file.lang.id); - out.push(emptyParse(file, parseDegradeWarning((err as Error).message))); + out.push(emptyParse(file, stampWarning(WARNING_CODES.PARSE_FAILED, `parse failed: ${(err as Error).message}`))); } onProgress?.(out.length, files.length); if (out.length % MEM_CHECK_EVERY === 0) checkMemoryBudget('parse', memoryBudgetMb); diff --git a/src/engine/toolchain/compose.ts b/src/engine/toolchain/compose.ts index 6d91c9fc..8685c5bf 100644 --- a/src/engine/toolchain/compose.ts +++ b/src/engine/toolchain/compose.ts @@ -12,6 +12,7 @@ import type { ToolchainExtractor, ToolchainNodeDraft, } from './types.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; /** * Docker Compose extraction. @@ -51,7 +52,7 @@ export const composeExtractor: ToolchainExtractor = { for (const [name, service] of services) { if (nodes.length >= TOOLCHAIN_NODES_PER_FILE_MAX) { - warnings.push(`${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_NODE_CAP, `${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`)); break; } const address = `service:${name}`; diff --git a/src/engine/toolchain/dockerfile.ts b/src/engine/toolchain/dockerfile.ts index 10760353..ff9078a1 100644 --- a/src/engine/toolchain/dockerfile.ts +++ b/src/engine/toolchain/dockerfile.ts @@ -12,6 +12,7 @@ import type { ToolchainExtractor, ToolchainNodeDraft, } from './types.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; /** * Dockerfile extraction — a deterministic scanner, no grammar. @@ -357,7 +358,7 @@ export const dockerfileExtractor: ToolchainExtractor = { for (const line of logicalLines(source)) { if (nodes.length >= TOOLCHAIN_NODES_PER_FILE_MAX) { - warnings.push(`${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_NODE_CAP, `${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`)); break; } diff --git a/src/engine/toolchain/index.ts b/src/engine/toolchain/index.ts index 5b839e2a..8f3b1aa0 100644 --- a/src/engine/toolchain/index.ts +++ b/src/engine/toolchain/index.ts @@ -10,6 +10,7 @@ import { terraformExtractor } from './terraform.js'; import { githubActionsExtractor, gitlabCiExtractor } from './workflows.js'; import { linkToolchain } from './link.js'; import { safeDoc, TOOLCHAIN_FILE_MAX_BYTES, toPosix } from './util.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; import type { ToolchainExtraction, ToolchainExtractor, @@ -151,7 +152,7 @@ async function readExtractions(docs: DiscoveredDoc[]): Promise<{ files: FileExtr try { files.push({ rel, format: extractor.format, extraction: await extractor.extract(rel, source) }); } catch (err) { - warnings.push(`${rel}: ${extractor.format} extraction failed (${(err as Error).message})`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_EXTRACTION_FAILED, `${rel}: ${extractor.format} extraction failed (${(err as Error).message})`)); } } diff --git a/src/engine/toolchain/kubernetes.ts b/src/engine/toolchain/kubernetes.ts index 2caa3836..5b7f4f19 100644 --- a/src/engine/toolchain/kubernetes.ts +++ b/src/engine/toolchain/kubernetes.ts @@ -15,6 +15,7 @@ import type { ToolchainExtractor, ToolchainNodeDraft, } from './types.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; /** * Kubernetes manifest extraction. @@ -91,7 +92,7 @@ export const kubernetesExtractor: ToolchainExtractor = { for (const doc of docs) { if (nodes.length >= TOOLCHAIN_NODES_PER_FILE_MAX) { - warnings.push(`${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_NODE_CAP, `${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`)); break; } if (!isKubernetesDoc(doc.value)) continue; diff --git a/src/engine/toolchain/terraform.ts b/src/engine/toolchain/terraform.ts index 40a47909..210a297d 100644 --- a/src/engine/toolchain/terraform.ts +++ b/src/engine/toolchain/terraform.ts @@ -9,6 +9,7 @@ import type { ToolchainExtractor, ToolchainNodeDraft, } from './types.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; /** * Terraform / OpenTofu extraction over the HCL concrete syntax tree. @@ -614,12 +615,12 @@ export const terraformExtractor: ToolchainExtractor = { return { nodes: [], edges: [], - warnings: [`${rel}: HCL grammar unavailable — no Terraform structure extracted`], + warnings: [stampWarning(WARNING_CODES.HCL_GRAMMAR_UNAVAILABLE, `${rel}: HCL grammar unavailable — no Terraform structure extracted`)], }; } const tree = parser.parse(source); - if (!tree) return { nodes: [], edges: [], warnings: [`${rel}: HCL parse produced no tree`] }; + if (!tree) return { nodes: [], edges: [], warnings: [stampWarning(WARNING_CODES.HCL_NO_TREE, `${rel}: HCL parse produced no tree`)] }; const lines = new LineIndex(source); const nodes: ToolchainNodeDraft[] = []; @@ -632,12 +633,12 @@ export const terraformExtractor: ToolchainExtractor = { const pendingEdges: ToolchainEdgeDraft[] = []; if (tree.rootNode.hasError) { - warnings.push(`${rel}: HCL parse recovered from a syntax error — extraction may be partial`); + warnings.push(stampWarning(WARNING_CODES.HCL_PARTIAL, `${rel}: HCL parse recovered from a syntax error — extraction may be partial`)); } for (const block of topLevelBlocks(tree.rootNode)) { if (nodes.length >= TOOLCHAIN_NODES_PER_FILE_MAX) { - warnings.push(`${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_NODE_CAP, `${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`)); break; } const { type, labels } = blockHead(block); diff --git a/src/engine/toolchain/workflows.ts b/src/engine/toolchain/workflows.ts index 7e224720..7109a4d0 100644 --- a/src/engine/toolchain/workflows.ts +++ b/src/engine/toolchain/workflows.ts @@ -12,6 +12,7 @@ import type { ToolchainExtractor, ToolchainNodeDraft, } from './types.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; /** * CI workflow extraction — GitHub Actions and GitLab CI. @@ -168,7 +169,7 @@ export const githubActionsExtractor: ToolchainExtractor = { for (const [jobId, job] of jobs) { if (nodes.length >= TOOLCHAIN_NODES_PER_FILE_MAX) { - warnings.push(`${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_NODE_CAP, `${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`)); break; } const jobAddress = `job:${jobId}`; @@ -244,7 +245,7 @@ export const githubActionsExtractor: ToolchainExtractor = { }); if (steps.length > MAX_STEPS_PER_JOB) { - warnings.push(`${rel}: job ${jobId} has ${steps.length} steps — enumerated the first ${MAX_STEPS_PER_JOB}`); + warnings.push(stampWarning(WARNING_CODES.WORKFLOW_STEP_CAP, `${rel}: job ${jobId} has ${steps.length} steps — enumerated the first ${MAX_STEPS_PER_JOB}`)); } // Secret *names* referenced anywhere in the job. @@ -306,7 +307,7 @@ export const gitlabCiExtractor: ToolchainExtractor = { for (const [jobId, job] of jobs) { if (nodes.length >= TOOLCHAIN_NODES_PER_FILE_MAX) { - warnings.push(`${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`); + warnings.push(stampWarning(WARNING_CODES.TOOLCHAIN_NODE_CAP, `${rel}: stopped at ${TOOLCHAIN_NODES_PER_FILE_MAX} nodes`)); break; } const jobAddress = `job:${jobId}`; diff --git a/src/engine/toolchain/yaml.ts b/src/engine/toolchain/yaml.ts index 032feda2..1980e2b0 100644 --- a/src/engine/toolchain/yaml.ts +++ b/src/engine/toolchain/yaml.ts @@ -1,6 +1,7 @@ import YAML from 'yaml'; import type { LineIndex } from './util.js'; import type { ToolchainSpan } from './types.js'; +import { stampWarning, WARNING_CODES } from '../../core-open/warnings.js'; /** * Shared YAML access for the toolchain extractors. @@ -55,21 +56,21 @@ export function parseYamlStream(rel: string, source: string): YamlParseResult { uniqueKeys: false, }); } catch (err) { - warnings.push(`${rel}: YAML parse failed (${(err as Error).message})`); + warnings.push(stampWarning(WARNING_CODES.YAML_PARSE_FAILED, `${rel}: YAML parse failed (${(err as Error).message})`)); return { docs: [], warnings }; } const docs: YamlDoc[] = []; parsed.forEach((ast, index) => { if (ast.errors.length) { - warnings.push(`${rel}: YAML document ${index + 1} has errors — skipped`); + warnings.push(stampWarning(WARNING_CODES.YAML_DOCUMENT_SKIPPED, `${rel}: YAML document ${index + 1} has errors — skipped`)); return; } let value: unknown; try { value = ast.toJS({ maxAliasCount: 100 }); } catch (err) { - warnings.push(`${rel}: YAML document ${index + 1} could not be materialised (${(err as Error).message})`); + warnings.push(stampWarning(WARNING_CODES.YAML_DOCUMENT_UNMATERIALISED, `${rel}: YAML document ${index + 1} could not be materialised (${(err as Error).message})`)); return; } if (value === null || value === undefined) return; diff --git a/src/engine/warning-codes.ts b/src/engine/warning-codes.ts new file mode 100644 index 00000000..faf99880 --- /dev/null +++ b/src/engine/warning-codes.ts @@ -0,0 +1,33 @@ +import { + sortCodedWarnings, + splitStampedWarning, + stampWarning, + type CodedWarning, +} from '../core-open/warnings.js'; + +/** + * Turn stored warning strings into the human list and the coded list. + * + * A stamped line is `{message} [VG_WARN_...]`. Lines with no known code + * (for example a cache entry written by an older build) stay in `warnings` + * and are omitted from `codedWarnings`. Coded rows are ordered by code, + * then by message. + */ +export function assembleEngineWarnings(stored: readonly string[]): { + warnings: string[]; + codedWarnings: CodedWarning[]; +} { + const coded: CodedWarning[] = []; + const plain: string[] = []; + for (const line of stored) { + const split = splitStampedWarning(line); + if (split) coded.push(split); + else plain.push(line); + } + const sorted = sortCodedWarnings(coded); + const plainSorted = [...plain].sort((a, b) => a.localeCompare(b)); + return { + warnings: [...sorted.map((warning) => stampWarning(warning.code, warning.message)), ...plainSorted], + codedWarnings: sorted, + }; +} diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index fb328088..5e18bbe4 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -453,7 +453,7 @@ describe('sbom helpers', () => { annotationType: 'OTHER', annotator: 'Tool: @vibgrate/cli', annotationDate: '2026-02-19T00:00:00.000Z', - comment: 'VG_WARN_LICENSE_UNPARSEABLE', + comment: 'warningCode=VG_WARN_LICENSE_UNPARSEABLE', }, ]); expect(JSON.stringify(spdx)).not.toContain('unrelated'); diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index 852cc72d..9c112ea0 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -4,11 +4,18 @@ import chalk from 'chalk'; import { pathExists, readJsonFile, writeTextFile } from '../utils/fs.js'; import type { DependencyRow, Finding, ProjectScan, ScanArtifact } from '../types.js'; import { LICENSE_PARSE_FAILED } from '../../core-open/licenses/diagnostic.js'; -import { WarningCodes, degradeLine } from '../../warnings/codes.js'; import { fullDependencyGraph, type LockfileComponent, type LockfileGraph } from '../../engine/lockfile.js'; import { ECOSYSTEMS, type Ecosystem } from '../../engine/drift.js'; import { componentLicense, extractedLicensingInfos, type ComponentLicense } from './sbom-license.js'; import { vexCommand } from './vex.js'; +import { + codedWarning, + formatWarningLine, + sortCodedWarnings, + WARNING_CODES, + type CodedWarning, + type WarningCode, +} from '../../core-open/warnings.js'; export { describeUnrepresentableLicense } from './sbom-license.js'; @@ -148,6 +155,8 @@ const PURL_STATUS_UNAVAILABLE = 'unavailable'; const LICENSE_STATUS_PROPERTY = 'vibgrate:licenseStatus'; const LICENSE_WARNING_PROPERTY = 'vibgrate:licenseWarning'; const LICENSE_STATUS_UNREPRESENTABLE = 'unrepresentable'; +/** CycloneDX property that carries the stable warning code beside a prose warning. */ +const WARNING_CODE_PROPERTY = 'vibgrate:warningCode'; /** The purl type/namespace/name portion, without a version — shared by every ecosystem branch of `purlFor`. */ function purlPath(ecosystem: Ecosystem, name: string): string | null { @@ -384,6 +393,22 @@ function describeUntrackedEdges(ecosystem: string, packageName: string, version: return `Dependency edges are not recorded for ${ecosystem} package "${packageName}@${version}" from ${projectPhrase(projects)}. An empty dependsOn is not a claim that the package has no dependencies.`; } +/** Map a known SBOM warning sentence to its code. Unknown prose stays uncoded. */ +function sbomWarningCode(message: string): WarningCode | undefined { + if (message.startsWith('Package URL unavailable')) return WARNING_CODES.PURL_UNAVAILABLE; + if (message.startsWith('Declared license')) return WARNING_CODES.LICENSE_UNREPRESENTABLE; + if (message.startsWith('Dropped a different dependency list')) return WARNING_CODES.SBOM_LOSSY_EDGES; + if (message.startsWith('Dropped differing manifest metadata')) return WARNING_CODES.SBOM_LOSSY_MANIFEST; + if (message.startsWith('Ecosystem unknown')) return WARNING_CODES.SBOM_UNKNOWN_ECOSYSTEM; + if (message.startsWith('Dependency edges are not recorded')) return WARNING_CODES.SBOM_UNTRACKED_EDGES; + return undefined; +} + +function pushWarningCode(properties: Array<{ name: string; value: string }>, message: string): void { + const code = sbomWarningCode(message); + if (code) properties.push({ name: WARNING_CODE_PROPERTY, value: code }); +} + function sortedUnique(names: Iterable): string[] { return [...new Set(names)].sort((a, b) => a.localeCompare(b)); } @@ -771,7 +796,7 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco name: LICENSE_PARSE_FAILED, value: `${f.location}: ${f.message}`, }, - { name: 'vibgrate:warningCode', value: WarningCodes.LICENSE_UNPARSEABLE }, + { name: WARNING_CODE_PROPERTY, value: WARNING_CODES.LICENSE_UNPARSEABLE }, ]), } : {}), @@ -791,21 +816,19 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco properties.push( { name: PURL_STATUS_PROPERTY, value: PURL_STATUS_UNAVAILABLE }, { name: PURL_WARNING_PROPERTY, value: warning }, - { name: `${PURL_WARNING_PROPERTY}Code`, value: WarningCodes.PURL_UNAVAILABLE }, ); + pushWarningCode(properties, warning); } if (license.warning) { properties.push( { name: LICENSE_STATUS_PROPERTY, value: LICENSE_STATUS_UNREPRESENTABLE }, { name: LICENSE_WARNING_PROPERTY, value: license.warning }, - { name: `${LICENSE_WARNING_PROPERTY}Code`, value: WarningCodes.LICENSE_UNPARSEABLE }, ); + pushWarningCode(properties, license.warning); } - for (const warning of dep.mergeWarnings) { - properties.push( - { name: 'vibgrate:mergeWarning', value: warning }, - { name: 'vibgrate:mergeWarningCode', value: WarningCodes.SBOM_MERGE }, - ); + for (const mergeWarning of dep.mergeWarnings) { + properties.push({ name: 'vibgrate:mergeWarning', value: mergeWarning }); + pushWarningCode(properties, mergeWarning); } return { type: 'library', @@ -821,20 +844,6 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco }; } -function spdxNote(artifact: ScanArtifact, comment: string): { - annotationType: string; - annotator: string; - annotationDate: string; - comment: string; -} { - return { - annotationType: 'OTHER', - annotator: 'Tool: @vibgrate/cli', - annotationDate: artifact.timestamp, - comment, - }; -} - export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record { const dependencies = flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem); const relationships = spdxRelationships(dependencies, graph); @@ -864,24 +873,26 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record { + annotations.push({ + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: message, + }); + const code = sbomWarningCode(message); + if (code) { + annotations.push({ + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: `warningCode=${code}`, + }); + } + }; + if (warning) pushSpdxWarning(warning); + if (license.warning) pushSpdxWarning(license.warning); + for (const mergeWarning of dep.mergeWarnings) pushSpdxWarning(mergeWarning); return { name: dep.package, SPDXID: `SPDXRef-Package-${i + 1}`, @@ -909,8 +920,18 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record [ - spdxNote(artifact, `${f.ruleId}: ${f.message}`), - spdxNote(artifact, WarningCodes.LICENSE_UNPARSEABLE), + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: `${f.ruleId}: ${f.message}`, + }, + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: `warningCode=${WARNING_CODES.LICENSE_UNPARSEABLE}`, + }, ]), } : {}), @@ -1037,14 +1058,22 @@ const exportCommand = new Command('export') const lockfileGraph = opts.transitive ? collectLockfileGraph(artifact, path.resolve(opts.root)) : undefined; const sbom = format === 'cyclonedx' ? toCycloneDx(artifact, lockfileGraph) : toSpdx(artifact, lockfileGraph); - for (const warning of collectPurlWarnings(artifact, lockfileGraph)) { - console.error(chalk.yellow(`warning: ${degradeLine(WarningCodes.PURL_UNAVAILABLE, warning)}`)); + const coded: CodedWarning[] = []; + const plain: string[] = []; + for (const warning of [ + ...collectPurlWarnings(artifact, lockfileGraph), + ...collectLicenseWarnings(artifact, lockfileGraph), + ...collectMergeWarnings(artifact, lockfileGraph), + ]) { + const code = sbomWarningCode(warning); + if (code) coded.push(codedWarning(code, warning)); + else plain.push(warning); } - for (const warning of collectLicenseWarnings(artifact, lockfileGraph)) { - console.error(chalk.yellow(`warning: ${degradeLine(WarningCodes.LICENSE_UNPARSEABLE, warning)}`)); + for (const warning of sortCodedWarnings(coded)) { + console.error(chalk.yellow(formatWarningLine(warning))); } - for (const warning of collectMergeWarnings(artifact, lockfileGraph)) { - console.error(chalk.yellow(`warning: ${degradeLine(WarningCodes.SBOM_MERGE, warning)}`)); + for (const warning of plain) { + console.error(chalk.yellow(`warning: ${warning}`)); } const body = JSON.stringify(sbom, null, 2); diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index 4dc117ba..d6d2d0aa 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -1,6 +1,6 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; -import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; +import { findingRuleLabel, formatDegradationLines } from '../../core-open/warnings.js'; /** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ function markdownDriftCell(score: number | null): string { @@ -116,6 +116,14 @@ export function formatMarkdown(artifact: ScanArtifact): string { } lines.push(''); } + const degradationLines = formatDegradationLines(artifact.degradations); + if (degradationLines.length > 0) { + lines.push('## Degradations'); + lines.push(''); + for (const line of degradationLines) lines.push(`- ${line}`); + lines.push(''); + } + // Findings if (artifact.findings.length > 0) { lines.push('## Findings'); @@ -124,19 +132,11 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(`|-------|------|---------|----------|`); for (const f of artifact.findings) { const emoji = f.level === 'error' ? '🔴' : f.level === 'warning' ? '🟡' : '🔵'; - lines.push(`| ${emoji} ${f.level} | ${ruleWithWarnCode(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); + lines.push(`| ${emoji} ${f.level} | ${findingRuleLabel(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); } lines.push(''); } - const degradeLines = degradeWarningBlock(artifact.degradeWarnings); - if (degradeLines.length) { - lines.push(`## ${degradeLines[0]}`); - lines.push(''); - for (const line of degradeLines.slice(1)) lines.push(`- ${line.trim()}`); - lines.push(''); - } - if (artifact.delta !== undefined) { // Lower is better: a rise (positive delta) means drift worsened; a fall means it improved. const dir = artifact.delta > 0 ? '📈' : artifact.delta < 0 ? '📉' : '➡️'; diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index d95beefe..18d79ee2 100644 --- a/src/reporting/formatters/text.ts +++ b/src/reporting/formatters/text.ts @@ -3,7 +3,7 @@ import type { ScanArtifact, ExtendedScanResults, InventoryItem, ServiceDependenc import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; import { VERSION } from '../version.js'; import { humanFindingText } from '../../core-open/formatters/fix-hint.js'; -import { degradeWarningBlock, ruleWithWarnCode } from '../../warnings/codes.js'; +import { findingRuleLabel, formatDegradationLines } from '../../core-open/warnings.js'; import { driftBar } from '../../core-open/ui/bar.js'; import { titleBox } from '../../core-open/ui/box.js'; @@ -82,6 +82,13 @@ export function formatText(artifact: ScanArtifact): string { lines.push(...formatExtended(artifact.extended)); } + const degradationLines = formatDegradationLines(artifact.degradations); + if (degradationLines.length > 0) { + lines.push(chalk.bold.underline(' Degradations')); + for (const line of degradationLines) lines.push(chalk.yellow(` ${line}`)); + lines.push(''); + } + // Findings if (artifact.findings.length > 0) { const errors = artifact.findings.filter((f) => f.level === 'error'); @@ -98,19 +105,12 @@ export function formatText(artifact: ScanArtifact): string { const icon = f.level === 'error' ? chalk.red('✖') : f.level === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ'); const shown = humanFindingText(f); lines.push(` ${icon} ${shown.message}`); - lines.push(chalk.dim(` ${ruleWithWarnCode(f.ruleId, f.details)} in ${f.location}`)); + lines.push(chalk.dim(` ${findingRuleLabel(f.ruleId, f.details)} in ${f.location}`)); if (shown.hint) lines.push(chalk.dim(` ${shown.hint}`)); } lines.push(''); } - const degradeLines = degradeWarningBlock(artifact.degradeWarnings); - if (degradeLines.length) { - lines.push(chalk.bold.underline(` ${degradeLines[0]}`)); - for (const line of degradeLines.slice(1)) lines.push(chalk.yellow(line)); - lines.push(''); - } - // Priority actions const actions = generatePriorityActions(artifact); if (actions.length > 0) { diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 73c7547b..59e466d7 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -1,4 +1,5 @@ import type { DependencyLicense } from '../core-open/types.js'; +import type { CodedWarning } from '../core-open/warnings.js'; // ── Core types for Vibgrate CLI ── @@ -271,6 +272,11 @@ export interface ScanArtifact { solutions?: SolutionScan[]; drift: DriftScore; findings: Finding[]; + /** + * Degrade-and-continue scan notices (skipped paths, unreadable baseline). + * Omitted when there are none. Sorted by code, then by message. + */ + degradations?: CodedWarning[]; /** Repo-relative path of the `--baseline` file, when one was compared. */ baseline?: string; /** Present only after a baseline file was read. Absent means no comparison. */ @@ -285,11 +291,6 @@ export interface ScanArtifact { treeSummary?: TreeCount; /** Workspace-level relationship diagram */ relationshipDiagram?: MermaidDiagram; - /** - * Degrade-and-continue warnings from this scan. Omitted when none fired. - * Sorted by code, then message. - */ - degradeWarnings?: Array<{ code: string; message: string }>; } // ── CLI option types ── diff --git a/src/warnings/codes.ts b/src/warnings/codes.ts deleted file mode 100644 index d19a5d00..00000000 --- a/src/warnings/codes.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Stable codes for degrade-and-continue warnings in build, scan, and report. - * - * A published code string does not change. A new site adds a constant in this - * module — for example an unknown optional lockfile field — and does not reuse - * a code that already means something else. Codes are fixed tokens: they never - * include a path, file contents, or a secret. Those details stay in the - * message, which the caller keeps free of credentials. - * - * When several warnings fire, {@link stableWarningRecords} orders them by code - * then message so the same inputs always emit the same sequence. - */ - -export const WarningCodes = { - /** A source file threw during parse. The build keeps the other files. */ - PARSE_DEGRADE: 'VG_WARN_PARSE_DEGRADE', - /** A file was over the size cap and was not read. */ - SKIPPED_FILE: 'VG_WARN_SKIPPED_FILE', - /** A directory or scan path was skipped and the rest of the tree continued. */ - SKIPPED_SUBTREE: 'VG_WARN_SKIPPED_SUBTREE', - /** The TypeScript resolver was skipped; the heuristic resolver still ran. */ - TSC_SKIPPED: 'VG_WARN_TSC_SKIPPED', - /** Infrastructure or CI extraction degraded for one file. */ - TOOLCHAIN_DEGRADE: 'VG_WARN_TOOLCHAIN_DEGRADE', - /** A component stayed in the SBOM and its Package URL was omitted. */ - PURL_UNAVAILABLE: 'VG_WARN_PURL_UNAVAILABLE', - /** A declared license did not resolve, or could not be written on an SBOM. */ - LICENSE_UNPARSEABLE: 'VG_WARN_LICENSE_UNPARSEABLE', - /** A CVSS vector was present and did not parse. The advisory is kept. */ - CVSS_UNPARSEABLE: 'VG_WARN_CVSS_UNPARSEABLE', - /** An SBOM merge dropped or guessed a fact and still wrote the component. */ - SBOM_MERGE: 'VG_WARN_SBOM_MERGE', - /** A baseline file could not be read. The scan continued without a delta. */ - BASELINE_UNREADABLE: 'VG_WARN_BASELINE_UNREADABLE', - /** One project scanner failed and that project was skipped. */ - SCAN_DEGRADE: 'VG_WARN_SCAN_DEGRADE', -} as const; - -export type WarningCode = (typeof WarningCodes)[keyof typeof WarningCodes]; - -const CODE_SET: ReadonlySet = new Set(Object.values(WarningCodes)); - -export function isWarningCode(value: string): value is WarningCode { - return CODE_SET.has(value); -} - -/** Every published code, in sorted order. This module is the only definition. */ -export function publishedWarningCodes(): WarningCode[] { - return (Object.values(WarningCodes) as WarningCode[]).slice().sort(); -} - -export interface WarningRecord { - code: WarningCode; - /** Actionable prose. The code is not repeated in this field. */ - message: string; -} - -/** `VG_WARN_…: message` — stderr lines and string warning channels. */ -export function renderWarning(record: WarningRecord): string { - return `${record.code}: ${record.message}`; -} - -/** Same as {@link renderWarning}, for call sites that have the two parts. */ -export function degradeLine(code: WarningCode, message: string): string { - return renderWarning({ code, message }); -} - -/** Parse-pool / worker warning. The code is on the string; the message stays `parse failed:`. */ -export function parseDegradeWarning(errMessage: string): string { - return renderWarning({ - code: WarningCodes.PARSE_DEGRADE, - message: `parse failed: ${errMessage}`, - }); -} - -/** - * Split a rendered warning. A legacy line that is not `CODE: message` - * returns null so the caller can classify it. - */ -export function parseRenderedWarning(line: string): WarningRecord | null { - const sep = line.indexOf(': '); - if (sep <= 0) return null; - const code = line.slice(0, sep); - if (!isWarningCode(code)) return null; - return { code, message: line.slice(sep + 2) }; -} - -/** True for a failed parse, including a warning already prefixed with its code. */ -export function isParseFailureWarning(warning: string): boolean { - const parsed = parseRenderedWarning(warning); - const message = parsed?.code === WarningCodes.PARSE_DEGRADE ? parsed.message : warning; - return message.startsWith('parse failed:'); -} - -/** Stable order: code, then message. Identical pairs collapse to one. */ -export function stableWarningRecords(records: readonly WarningRecord[]): WarningRecord[] { - const sorted = records.slice().sort((a, b) => { - if (a.code < b.code) return -1; - if (a.code > b.code) return 1; - if (a.message < b.message) return -1; - if (a.message > b.message) return 1; - return 0; - }); - const out: WarningRecord[] = []; - for (const record of sorted) { - const prev = out[out.length - 1]; - if (prev && prev.code === record.code && prev.message === record.message) continue; - out.push(record); - } - return out; -} - -/** Unique codes that fired, sorted. Empty when nothing fired. */ -export function warningCodeSummary(records: readonly WarningRecord[]): string { - const codes: WarningCode[] = []; - for (const record of records) { - if (!codes.includes(record.code)) codes.push(record.code); - } - codes.sort(); - return codes.join(', '); -} - -/** Warn code stored on a finding, or on a nested CVSS diagnostic. */ -export function findingWarnCode(details: Record | null | undefined): WarningCode | null { - if (!details) return null; - const direct = details.warnCode; - if (typeof direct === 'string' && isWarningCode(direct)) return direct; - const diag = details.cvssDiagnostic; - if (diag && typeof diag === 'object') { - const nested = (diag as { warnCode?: unknown }).warnCode; - if (typeof nested === 'string' && isWarningCode(nested)) return nested; - } - return null; -} - -/** Rule id for a human line, with the warn code when the finding carries one. */ -export function ruleWithWarnCode(ruleId: string, details?: Record | null): string { - const code = findingWarnCode(details); - return code ? `${ruleId} ${code}` : ruleId; -} - -/** - * Plain lines for a report. Already-stable input stays in that order; this - * sorts again so a hand-written artifact cannot reshuffle the block. - */ -export function degradeWarningBlock( - records: ReadonlyArray<{ code: string; message: string }> | undefined, -): string[] { - if (!records?.length) return []; - const sorted = records.slice().sort((a, b) => { - if (a.code < b.code) return -1; - if (a.code > b.code) return 1; - if (a.message < b.message) return -1; - if (a.message > b.message) return 1; - return 0; - }); - const lines = ['Degrade warnings']; - const seen = new Set(); - for (const record of sorted) { - const line = ` ${record.code}: ${record.message}`; - if (seen.has(line)) continue; - seen.add(line); - lines.push(line); - } - return lines; -} diff --git a/test/license-finding-source-path.test.ts b/test/license-finding-source-path.test.ts index 45525f96..2ac32301 100644 --- a/test/license-finding-source-path.test.ts +++ b/test/license-finding-source-path.test.ts @@ -86,10 +86,7 @@ describe('license findings in machine-readable scan output', () => { 'apps/api', 'package.json', ]); - expect(licenseFindings[1]?.details).toEqual({ - raw: 'Also-Not-SPDX', - warnCode: 'VG_WARN_LICENSE_UNPARSEABLE', - }); + expect(licenseFindings[1]?.details).toEqual({ raw: 'Also-Not-SPDX', warnCode: 'VG_WARN_LICENSE_UNPARSEABLE' }); expect(JSON.stringify(scan.findings)).not.toContain('beta-ok'); const json = JSON.stringify(scan, null, 2); @@ -117,10 +114,7 @@ describe('license findings in machine-readable scan output', () => { ]); expect(results[0]?.properties?.sourcePath).toBe('apps/web/package.json'); expect(results[2]?.properties?.sourcePath).toBe('package.json'); - expect(results[1]?.properties).toEqual({ - raw: 'Also-Not-SPDX', - warnCode: 'VG_WARN_LICENSE_UNPARSEABLE', - }); + expect(results[1]?.properties).toEqual({ raw: 'Also-Not-SPDX', warnCode: 'VG_WARN_LICENSE_UNPARSEABLE' }); expect(results[2]?.locations).toHaveLength(1); expect(JSON.stringify(sarif)).not.toContain('beta-ok'); }); diff --git a/test/sbom-lockfile-merge.test.ts b/test/sbom-lockfile-merge.test.ts index 637e2d6f..7733d3c6 100644 --- a/test/sbom-lockfile-merge.test.ts +++ b/test/sbom-lockfile-merge.test.ts @@ -125,7 +125,6 @@ describe('sbom export: multi-project lockfile merge', () => { expect(prop(left.properties, 'vibgrate:projects')).toBe('docs, web'); expect(prop(left.properties, 'vibgrate:scope')).toBe('direct'); expect(props(left.properties, 'vibgrate:mergeWarning')).toEqual([LOSSY_EDGE_WARNING]); - expect(props(left.properties, 'vibgrate:mergeWarningCode')).toEqual(['VG_WARN_SBOM_MERGE']); const widgetOld = cdx.components.find((c) => c.purl === 'pkg:npm/widget@1.0.0')!; const widgetNew = cdx.components.find((c) => c.purl === 'pkg:npm/widget@2.0.0')!; @@ -329,7 +328,7 @@ describe('sbom export: multi-project lockfile merge', () => { expect(stdout).toHaveLength(2); expect(stdout[0]).toBe(stdout[1]); expect(stdout[0]).toContain('"bomFormat": "CycloneDX"'); - expect(stderr.join('\n')).toContain(`warning: VG_WARN_SBOM_MERGE: ${LOSSY_EDGE_WARNING}`); + expect(stderr.join('\n')).toContain(`warning [VG_WARN_SBOM_LOSSY_EDGES]: ${LOSSY_EDGE_WARNING}`); expect(stderr.join('\n')).not.toContain('http'); }); }); diff --git a/test/vulnerabilities.test.ts b/test/vulnerabilities.test.ts index c8d488e2..80f3fca9 100644 --- a/test/vulnerabilities.test.ts +++ b/test/vulnerabilities.test.ts @@ -45,6 +45,7 @@ const stubGraph = {} as VgGraph; const PARSE_FAILED = { code: 'cvss-vector-parse-failed' as const, + warnCode: 'VG_WARN_CVSS_UNPARSEABLE' as const, message: 'CVSS vector failed to parse (cvss-vector-parse-failed): "not-a-vector" is not a CVSS:3.0 or CVSS:3.1 base vector. Replace it with a CVSS:3.0 or CVSS:3.1 base vector, or omit the vector and supply a numeric base score.', }; diff --git a/test/warning-codes.test.ts b/test/warning-codes.test.ts index 1e79e441..db06a4cb 100644 --- a/test/warning-codes.test.ts +++ b/test/warning-codes.test.ts @@ -1,155 +1,76 @@ -/** - * Stable degrade-and-continue warning codes. - * - * Two command paths must carry the exact code string: a build that skips an - * oversized file, and a parse that throws and continues. The registry itself - * must not publish two names for one string. - */ -import * as os from 'node:os'; -import * as path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { buildGraph } from '../src/engine/build.js'; -import runParseWorker from '../src/engine/parse-worker.js'; -import { parseCvssVector } from '../src/core-open/scoring/cvss.js'; -import { toCycloneDx } from '../src/reporting/commands/sbom.js'; -import type { ScanArtifact } from '../src/reporting/types.js'; -import { - WarningCodes, - publishedWarningCodes, - stableWarningRecords, -} from '../src/warnings/codes.js'; +import { warningCodeList } from '../src/core-open/warnings.js'; import { cleanup, makeProject } from './helpers.js'; const PIN = '2020-01-01T00:00:00.000Z'; const dirs: string[] = []; -function project(files: Record): string { - const dir = makeProject(files); - dirs.push(dir); - return dir; -} - afterEach(() => { while (dirs.length) cleanup(dirs.pop()!); }); -describe('warning code registry', () => { - it('publishes each code string once', () => { - const values = Object.values(WarningCodes); - expect(new Set(values).size).toBe(values.length); - expect(publishedWarningCodes()).toEqual([...values].sort()); - expect(publishedWarningCodes()).toContain('VG_WARN_PARSE_DEGRADE'); - expect(publishedWarningCodes()).toContain('VG_WARN_SKIPPED_FILE'); - }); +/** Published codes. A rename or a duplicate fails this list. */ +const PUBLISHED_CODES = [ + 'VG_WARN_BASELINE_UNREADABLE', + 'VG_WARN_BUILD_FILE_OVERSIZE', + 'VG_WARN_CVSS_UNPARSEABLE', + 'VG_WARN_HCL_GRAMMAR_UNAVAILABLE', + 'VG_WARN_HCL_NO_TREE', + 'VG_WARN_HCL_PARTIAL', + 'VG_WARN_LICENSE_UNPARSEABLE', + 'VG_WARN_LICENSE_UNREPRESENTABLE', + 'VG_WARN_PARSE_FAILED', + 'VG_WARN_PURL_UNAVAILABLE', + 'VG_WARN_SBOM_LOSSY_EDGES', + 'VG_WARN_SBOM_LOSSY_MANIFEST', + 'VG_WARN_SBOM_UNKNOWN_ECOSYSTEM', + 'VG_WARN_SBOM_UNTRACKED_EDGES', + 'VG_WARN_SCAN_FILE_OVERSIZE', + 'VG_WARN_SCAN_PATH_SKIPPED', + 'VG_WARN_TOOLCHAIN_EXTRACTION_FAILED', + 'VG_WARN_TOOLCHAIN_NODE_CAP', + 'VG_WARN_TSC_RESOLVER_SKIPPED', + 'VG_WARN_WORKFLOW_STEP_CAP', + 'VG_WARN_YAML_DOCUMENT_SKIPPED', + 'VG_WARN_YAML_DOCUMENT_UNMATERIALISED', + 'VG_WARN_YAML_PARSE_FAILED', +] as const; - it('orders several warnings by code, then message, and collapses exact duplicates', () => { - const records = stableWarningRecords([ - { code: WarningCodes.SKIPPED_FILE, message: 'b.ts: skipped' }, - { code: WarningCodes.PARSE_DEGRADE, message: 'a.ts: parse failed: boom' }, - { code: WarningCodes.SKIPPED_FILE, message: 'a.ts: skipped' }, - { code: WarningCodes.SKIPPED_FILE, message: 'b.ts: skipped' }, - ]); - expect(records.map((r) => `${r.code} ${r.message}`)).toEqual([ - 'VG_WARN_PARSE_DEGRADE a.ts: parse failed: boom', - 'VG_WARN_SKIPPED_FILE a.ts: skipped', - 'VG_WARN_SKIPPED_FILE b.ts: skipped', - ]); +describe('warning code registry', () => { + it('publishes each code once', () => { + const codes = warningCodeList(); + expect(new Set(codes).size).toBe(codes.length); + expect(codes.every((code) => /^VG_WARN_[A-Z0-9_]+$/.test(code))).toBe(true); + expect([...codes].sort((a, b) => a.localeCompare(b))).toEqual([...PUBLISHED_CODES]); }); }); -describe('build degrade warnings', () => { - const big = `// ${'x'.repeat(400)}\nexport function huge(){ return 1; }\n`; - - it('puts VG_WARN_SKIPPED_FILE on an oversized file and keeps that order', async () => { +describe('build oversize warnings', () => { + it('stamps VG_WARN_BUILD_FILE_OVERSIZE in order', async () => { + const big = 'x'.repeat(400); + const root = makeProject({ + 'small.ts': 'export const a = 1;\n', + 'aaa-generated.ts': big, + 'zzz-generated.ts': big, + }); + dirs.push(root); const result = await buildGraph({ - root: project({ - 'ok.ts': 'export const n = 1;\n', - 'b-big.ts': big, - 'a-big.ts': big, - }), + root, generatedAt: PIN, inline: true, noCache: true, limits: { maxFileBytes: 256 }, }); - const skipped = result.warningRecords.filter((r) => r.code === 'VG_WARN_SKIPPED_FILE'); - expect(skipped.map((r) => r.message.split(':')[0])).toEqual(['a-big.ts', 'b-big.ts']); - expect(result.warnings.filter((w) => w.startsWith('VG_WARN_SKIPPED_FILE:'))).toEqual( - skipped.map((r) => `VG_WARN_SKIPPED_FILE: ${r.message}`), - ); - expect(result.warnings.some((w) => w.includes('VG_MAX_FILE_BYTES'))).toBe(true); - }); -}); - -describe('parse degrade warnings', () => { - it('puts VG_WARN_PARSE_DEGRADE on a file that cannot be read and continues', async () => { - const missing = path.join(os.tmpdir(), 'vg-warn-missing-does-not-exist.ts'); - const parsed = await runParseWorker({ - tasks: [ - { rel: 'z.ts', abs: missing, lang: 'ts' }, - { rel: 'a.ts', abs: missing, lang: 'ts' }, - ], - }); - expect(parsed).toHaveLength(2); - for (const file of parsed) { - expect(file.defs).toEqual([]); - expect(file.warnings).toHaveLength(1); - expect(file.warnings?.[0]).toMatch(/^VG_WARN_PARSE_DEGRADE: parse failed: /); - } - expect(parsed[0]?.rel).toBe('z.ts'); - expect(parsed[1]?.rel).toBe('a.ts'); - }); -}); - -describe('report degrade warnings', () => { - it('puts VG_WARN_PURL_UNAVAILABLE beside an omitted package URL', () => { - const artifact = { - schemaVersion: '1.0', - timestamp: '2026-02-19T00:00:00.000Z', - vibgrateVersion: '0.0.1', - rootPath: 'repo', - drift: { - score: 10, - riskLevel: 'low', - components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, - }, - findings: [], - projects: [ - { - type: 'node', - path: '.', - name: 'app', - frameworks: [], - dependencies: [ - { - package: 'foo bar', - section: 'dependencies', - currentSpec: '1.0.0', - resolvedVersion: '1.0.0', - latestStable: '1.0.0', - majorsBehind: 0, - drift: 'current', - }, - ], - }, - ], - } as ScanArtifact; - const cdx = toCycloneDx(artifact) as { - components: Array<{ properties: Array<{ name: string; value: string }> }>; - }; - const props = cdx.components[0]?.properties ?? []; - expect(props.find((p) => p.name === 'vibgrate:purlWarningCode')?.value).toBe('VG_WARN_PURL_UNAVAILABLE'); - expect(props.find((p) => p.name === 'vibgrate:purlWarning')?.value).toContain('foo bar'); - }); - - it('puts VG_WARN_CVSS_UNPARSEABLE on a vector that does not parse', () => { - const parsed = parseCvssVector('not-a-vector'); - expect(parsed.status).toBe('invalid'); - if (parsed.status !== 'invalid') return; - expect(parsed.diagnostic.warnCode).toBe('VG_WARN_CVSS_UNPARSEABLE'); - expect(parsed.diagnostic.code).toBe('cvss-vector-parse-failed'); - expect(parsed.diagnostic.message).toBe( - 'CVSS vector failed to parse (cvss-vector-parse-failed): "not-a-vector" is not a CVSS:3.0 or CVSS:3.1 base vector. Replace it with a CVSS:3.0 or CVSS:3.1 base vector, or omit the vector and supply a numeric base score.', - ); + const oversize = result.codedWarnings.filter((warning) => warning.code === 'VG_WARN_BUILD_FILE_OVERSIZE'); + expect(oversize.map((warning) => warning.code)).toEqual([ + 'VG_WARN_BUILD_FILE_OVERSIZE', + 'VG_WARN_BUILD_FILE_OVERSIZE', + ]); + expect(oversize[0]?.message).toContain('aaa-generated.ts'); + expect(oversize[1]?.message).toContain('zzz-generated.ts'); + expect(oversize[0]!.message.localeCompare(oversize[1]!.message)).toBeLessThan(0); + expect(result.warnings).toContain(`${oversize[0]!.message} [VG_WARN_BUILD_FILE_OVERSIZE]`); + expect(result.warnings).toContain(`${oversize[1]!.message} [VG_WARN_BUILD_FILE_OVERSIZE]`); }); });