diff --git a/DOCS.md b/DOCS.md index 272e7b65..22cb77ac 100644 --- a/DOCS.md +++ b/DOCS.md @@ -130,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) --- @@ -5374,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 63469a02..981cfa95 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 { 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'; @@ -281,6 +282,7 @@ export async function runBuild( attestation, timingMs: result.timing.totalMs, warnings: result.warnings, + ...(result.codedWarnings.length > 0 ? { codedWarnings: result.codedWarnings } : {}), activity: activity.toJSON(), }); return; @@ -339,6 +341,13 @@ export async function runBuild( .join(' '); info(` → ${artifactList}`); if (result.warnings.length) { + 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) { diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index 59806c89..a9e6820f 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -3,6 +3,7 @@ // 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'; /** Rows shown before the infrastructure-findings table is cut with an "… N more" line. */ @@ -173,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'); @@ -181,7 +190,7 @@ 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} | ${findingRuleLabel(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); } lines.push(''); } diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index a895d186..bc09d93c 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 { findingRuleLabel, formatDegradationLines } from '../warnings.js'; import { driftBar } from '../ui/bar.js'; import { titleBox, panelBox } from '../ui/box.js'; @@ -116,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'); @@ -132,7 +140,7 @@ 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(` ${findingRuleLabel(f.ruleId, f.details)} in ${f.location}`)); if (shown.hint) lines.push(chalk.dim(` ${shown.hint}`)); } lines.push(''); 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 9f512403..4ae6ee57 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -55,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'; /** @@ -762,15 +770,19 @@ export async function runCoreScan( progress.finish(); - const stuckPaths = fileCache.stuckPaths; - const skippedLarge = fileCache.skippedLargeFiles; + 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⚠ ${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}`)); + 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); @@ -782,14 +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⚠ ${skippedLarge.length} file${skippedLarge.length === 1 ? '' : 's'} skipped (>${sizeMB} MB):`), - ); - 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`, + ), + ); } } @@ -845,11 +856,24 @@ 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}`)); + degradations.push( + codedWarning( + WARNING_CODES.BASELINE_UNREADABLE, + `Could not read baseline file: ${warningPathLabel(path.basename(baselinePath))}`, + ), + ); } } } + 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'); await ensureDir(vibgrateDir); diff --git a/src/core-open/scoring/cvss.ts b/src/core-open/scoring/cvss.ts index 51823c55..d585faa0 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 { WARNING_CODES } from '../warnings.js'; /** * Minimal, dependency-free CVSS v3.0/v3.1 base-score calculator. @@ -64,7 +65,7 @@ function invalid(reason: string): CvssParseResult { ); return { status: 'invalid', - diagnostic: { code: CVSS_VECTOR_PARSE_FAILED, 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 80604f83..a27fb85b 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 { WARNING_CODES } from '../warnings.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: 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 74aad110..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'; @@ -477,6 +478,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 warning code. Stable across releases. + * `code` stays `cvss-vector-parse-failed`. + */ + 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. @@ -786,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. 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 99006736..9252c40f 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -50,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). */ @@ -144,6 +146,11 @@ export interface BuildResult { /** SQLite index write result. */ index?: { ok: boolean; path?: string; reason?: string }; warnings: string[]; + /** + * Same notices as `warnings`, split into a stable code and the prose. + * Sorted by code, then message. Empty when there are no warnings. + */ + codedWarnings: CodedWarning[]; /** Architecture role hits extracted during the parse already paid for. */ fileRoles: AstRoleHit[]; } @@ -228,8 +235,11 @@ export async function buildGraph(options: BuildOptions): Promise { }); oversizeRels.add(file.rel); buildWarnings.push( - `${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)`, + 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)`, + ), ); continue; } @@ -380,9 +390,12 @@ export async function buildGraph(options: BuildOptions): Promise { // 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 ` + - `${limits.tscMaxFiles.toLocaleString()}-file limit; calls use the heuristic resolver ` + - `(set VG_TSC_MAX_FILES to raise it, 0 to disable)`, + 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)`, + ), ); tsFiles = []; } @@ -533,6 +546,7 @@ export async function buildGraph(options: BuildOptions): Promise { } const toolchainResult = await extractToolchain(docs, { fileNodes: fileNodesByPath }); if (toolchainResult.nodes.length) nodes = [...nodes, ...toolchainResult.nodes]; + if (toolchainResult.warnings.length) warnings.push(...toolchainResult.warnings); timer.end('toolchain'); // Analyse → centrality/areas/surprise (test/coverage edges excluded from these). @@ -662,6 +676,7 @@ export async function buildGraph(options: BuildOptions): Promise { timer.end('total'); const stages = timer.snapshot(); + const assembled = assembleEngineWarnings(warnings); return { graph, @@ -676,7 +691,8 @@ export async function buildGraph(options: BuildOptions): Promise { tsc: tscStats, scip: scipStats, index: indexResult, - warnings, + warnings: assembled.warnings, + codedWarnings: assembled.codedWarnings, fileRoles: fileRolesFromParses(parses), }; } diff --git a/src/engine/parse-worker.ts b/src/engine/parse-worker.ts index 82ddd7d4..47904988 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 { stampWarning, WARNING_CODES } from '../core-open/warnings.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: [stampWarning(WARNING_CODES.PARSE_FAILED, `parse failed: ${(err as Error).message}`)], }); } } diff --git a/src/engine/pool.ts b/src/engine/pool.ts index 0b72f29c..9837770c 100644 --- a/src/engine/pool.ts +++ b/src/engine/pool.ts @@ -7,6 +7,7 @@ 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'; /** @@ -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, 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 6a57d713..5e18bbe4 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: 'warningCode=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..9c112ea0 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -8,6 +8,14 @@ import { fullDependencyGraph, type LockfileComponent, type LockfileGraph } from 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'; @@ -147,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 { @@ -383,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)); } @@ -765,10 +791,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: WARNING_CODE_PROPERTY, value: WARNING_CODES.LICENSE_UNPARSEABLE }, + ]), } : {}), }, @@ -788,15 +817,18 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco { name: PURL_STATUS_PROPERTY, value: PURL_STATUS_UNAVAILABLE }, { name: PURL_WARNING_PROPERTY, value: warning }, ); + pushWarningCode(properties, warning); } if (license.warning) { properties.push( { name: LICENSE_STATUS_PROPERTY, value: LICENSE_STATUS_UNREPRESENTABLE }, { name: LICENSE_WARNING_PROPERTY, value: license.warning }, ); + pushWarningCode(properties, license.warning); } - for (const warning of dep.mergeWarnings) { - properties.push({ name: 'vibgrate:mergeWarning', value: warning }); + for (const mergeWarning of dep.mergeWarnings) { + properties.push({ name: 'vibgrate:mergeWarning', value: mergeWarning }); + pushWarningCode(properties, mergeWarning); } return { type: 'library', @@ -841,30 +873,26 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record { annotations.push({ annotationType: 'OTHER', annotator: 'Tool: @vibgrate/cli', annotationDate: artifact.timestamp, - comment: warning, + comment: message, }); - } - if (license.warning) { - annotations.push({ - annotationType: 'OTHER', - annotator: 'Tool: @vibgrate/cli', - annotationDate: artifact.timestamp, - comment: license.warning, - }); - } - for (const warning of dep.mergeWarnings) { - annotations.push({ - annotationType: 'OTHER', - annotator: 'Tool: @vibgrate/cli', - annotationDate: artifact.timestamp, - comment: warning, - }); - } + 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}`, @@ -891,12 +919,20 @@ 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) => [ + { + 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}`, + }, + ]), } : {}), }; @@ -1022,13 +1058,21 @@ 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: ${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: ${warning}`)); + for (const warning of sortCodedWarnings(coded)) { + console.error(chalk.yellow(formatWarningLine(warning))); } - for (const warning of collectMergeWarnings(artifact, lockfileGraph)) { + 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 cc15e474..d6d2d0aa 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 { 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 { @@ -115,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'); @@ -123,7 +132,7 @@ 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} | ${findingRuleLabel(f.ruleId, f.details)} | ${f.message} | ${f.location} |`); } lines.push(''); } diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index fb50cad8..18d79ee2 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 { findingRuleLabel, formatDegradationLines } from '../../core-open/warnings.js'; import { driftBar } from '../../core-open/ui/bar.js'; import { titleBox } from '../../core-open/ui/box.js'; @@ -81,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'); @@ -97,7 +105,7 @@ 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(` ${findingRuleLabel(f.ruleId, f.details)} in ${f.location}`)); if (shown.hint) lines.push(chalk.dim(` ${shown.hint}`)); } lines.push(''); diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 5df43370..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. */ diff --git a/test/license-finding-source-path.test.ts b/test/license-finding-source-path.test.ts index c2280630..2ac32301 100644 --- a/test/license-finding-source-path.test.ts +++ b/test/license-finding-source-path.test.ts @@ -86,7 +86,7 @@ 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 +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' }); + 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 c7c9bbc5..7733d3c6 100644 --- a/test/sbom-lockfile-merge.test.ts +++ b/test/sbom-lockfile-merge.test.ts @@ -328,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: ${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 new file mode 100644 index 00000000..db06a4cb --- /dev/null +++ b/test/warning-codes.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildGraph } from '../src/engine/build.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[] = []; + +afterEach(() => { + while (dirs.length) cleanup(dirs.pop()!); +}); + +/** 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; + +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 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, + generatedAt: PIN, + inline: true, + noCache: true, + limits: { maxFileBytes: 256 }, + }); + 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]`); + }); +});