diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..1bbca52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,15 @@ backward compatible. ### Fixed +- **An unscored DriftScore no longer prints as 0.** A scan that measured no + runtime, framework, dependency, or end-of-life signal used to report + DriftScore 0 and low risk — the same result as a fully current tree. + Unmeasured components, and an overall score with nothing to measure, are + now null in `vg scan --format json` and `n/a` in the text and Markdown + summary. A score that was actually computed, including a real 0, is + unchanged. `--drift-budget` skips the comparison when the score is absent + instead of treating that absence as 0. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..4d43ade 100644 --- a/DOCS.md +++ b/DOCS.md @@ -2816,6 +2816,8 @@ The DriftScore is a deterministic, versioned metric (0–100) that represents ho **Lower score = healthier upgrade posture.** 0 means no drift (fully current); 100 means maximum drift. Higher is worse. +A number is reported only when something was measured. If a scan has no runtime, framework, dependency, or end-of-life signal, the DriftScore is absent: `null` in JSON and `n/a` in the text and Markdown report. That is not a score of 0. `--drift-budget` skips the comparison when the score is absent and does not fail the scan for it. + The methodology is published: see the [public scoring specification](./docs/public/SCORING-METHODOLOGY-PUBLIC.md) in this repository and the overview at [vibgrate.com/driftscore](https://vibgrate.com/driftscore). ### Risk Levels diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index be83d21..16c8a49 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -28,8 +28,9 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Metric | Value |`); lines.push(`|--------|-------|`); - lines.push(`| **DriftScore** | ${artifact.drift.score}/100 |`); - lines.push(`| **Risk Level** | ${artifact.drift.riskLevel.toUpperCase()} |`); + const score = artifact.drift.score; + lines.push(`| **DriftScore** | ${typeof score === 'number' ? `${score}/100` : 'n/a'} |`); + lines.push(`| **Risk Level** | ${typeof score === 'number' ? artifact.drift.riskLevel.toUpperCase() : 'n/a'} |`); lines.push(`| **Projects** | ${artifact.projects.length} |`); if (billing) { // Per-size billable contribution (count ÷ ratio) to 1–2 dp, so tiny projects @@ -64,10 +65,11 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Component | Score |`); lines.push(`|-----------|-------|`); - lines.push(`| Runtime | ${artifact.drift.components.runtimeScore} |`); - lines.push(`| Frameworks | ${artifact.drift.components.frameworkScore} |`); - lines.push(`| Dependencies | ${artifact.drift.components.dependencyScore} |`); - lines.push(`| EOL Risk | ${artifact.drift.components.eolScore} |`); + const cell = (value: number | null): string => (typeof value === 'number' ? String(value) : 'n/a'); + lines.push(`| Runtime | ${cell(artifact.drift.components.runtimeScore)} |`); + lines.push(`| Frameworks | ${cell(artifact.drift.components.frameworkScore)} |`); + lines.push(`| Dependencies | ${cell(artifact.drift.components.dependencyScore)} |`); + lines.push(`| EOL Risk | ${cell(artifact.drift.components.eolScore)} |`); lines.push(''); // Per project diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index 9f1ed28..bee25ce 100644 --- a/src/core-open/formatters/text.ts +++ b/src/core-open/formatters/text.ts @@ -156,14 +156,16 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) lines.push(''); } - // Score summary - const scoreColor = artifact.drift.score <= 30 ? chalk.green : - artifact.drift.score <= 60 ? chalk.yellow : chalk.red; + // Score summary. A null score was not computed; it must not render as 0. + const score = artifact.drift.score; + const scoreColor = typeof score === 'number' + ? (score <= 30 ? chalk.green : score <= 60 ? chalk.yellow : chalk.red) + : chalk.dim; lines.push(...titleBox('DriftScore Summary')); lines.push(''); - lines.push(chalk.bold(' DriftScore: ') + scoreColor.bold(`${artifact.drift.score}/100`)); - lines.push(chalk.bold(' Risk Level: ') + riskBadge(artifact.drift.riskLevel)); + lines.push(chalk.bold(' DriftScore: ') + (typeof score === 'number' ? scoreColor.bold(`${score}/100`) : chalk.dim('n/a'))); + lines.push(chalk.bold(' Risk Level: ') + (typeof score === 'number' ? riskBadge(artifact.drift.riskLevel) : chalk.dim('n/a'))); lines.push(chalk.bold(' Projects: ') + `${artifact.projects.length}`); // Project classification breakdown + billable projects ("micro-project pricing"). @@ -217,13 +219,17 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) lines.push(''); - // Score breakdown + // Score breakdown. A missing `measured` list is a legacy artifact that + // stored a number for every component; a null component is unmeasured + // even when that list names it. const m = new Set(artifact.drift.measured ?? ['runtime', 'framework', 'dependency', 'eol']); + const componentCell = (key: 'runtime' | 'framework' | 'dependency' | 'eol', value: number | null): string => + m.has(key) && typeof value === 'number' ? scoreBar(value) : chalk.dim('n/a'); lines.push(' ' + chalk.bold.underline('Score Breakdown')); - lines.push(` Runtime: ${m.has('runtime') ? scoreBar(artifact.drift.components.runtimeScore) : chalk.dim('n/a')}`); - lines.push(` Frameworks: ${m.has('framework') ? scoreBar(artifact.drift.components.frameworkScore) : chalk.dim('n/a')}`); - lines.push(` Dependencies: ${m.has('dependency') ? scoreBar(artifact.drift.components.dependencyScore) : chalk.dim('n/a')}`); - lines.push(` EOL Risk: ${m.has('eol') ? scoreBar(artifact.drift.components.eolScore) : chalk.dim('n/a')}`); + lines.push(` Runtime: ${componentCell('runtime', artifact.drift.components.runtimeScore)}`); + lines.push(` Frameworks: ${componentCell('framework', artifact.drift.components.frameworkScore)}`); + lines.push(` Dependencies: ${componentCell('dependency', artifact.drift.components.dependencyScore)}`); + lines.push(` EOL Risk: ${componentCell('eol', artifact.drift.components.eolScore)}`); lines.push(''); const scannedParts: string[] = [`Scanned at ${artifact.timestamp}`]; diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 5375ff9..c204b46 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -718,7 +718,10 @@ export async function runCoreScan( // ── Step: Drift score ── progress.startStep('drift'); const drift = computeDriftScore(allProjects); - progress.completeStep('drift', `${drift.score}/100 — ${drift.riskLevel} risk`); + progress.completeStep( + 'drift', + drift.score === null ? 'absent' : `${drift.score}/100 — ${drift.riskLevel} risk`, + ); // ── Step: Findings ── progress.startStep('findings'); @@ -823,7 +826,9 @@ export async function runCoreScan( // baseline outside the repo degrades to its basename for the same reason. const relBaseline = path.relative(rootDir, baselinePath); artifact.baseline = !relBaseline || relBaseline.startsWith('..') ? path.basename(baselinePath) : relBaseline; - artifact.delta = artifact.drift.score - baseline.drift.score; + if (typeof artifact.drift.score === 'number' && typeof baseline.drift.score === 'number') { + artifact.delta = artifact.drift.score - baseline.drift.score; + } } catch { console.error(chalk.yellow(`Warning: Could not read baseline file: ${baselinePath}`)); } diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index 9ee6bff..7bc8ba5 100644 --- a/src/core-open/scoring/drift-score.ts +++ b/src/core-open/scoring/drift-score.ts @@ -202,14 +202,19 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { // DriftScore v2 convention: 0 = no drift (best), 100 = maximum drift (worst). // Components are computed internally on a "health" scale (higher = healthier) // and inverted here so every emitted number reads as drift. + // Null health is unmeasured and stays null. Filling it with 100 inverted to + // drift 0, which reads as "no drift". A real health of 0 (runtime lag of 4 + // or more) is measured and inverts to drift 100. const toDrift = (health: number) => 100 - health; + const componentDrift = (health: number | null): number | null => + health === null ? null : toDrift(Math.round(health)); const buildComponents = (): DriftScore['components'] => { const c: DriftScore['components'] = { - runtimeScore: toDrift(Math.round(rs ?? 100)), - frameworkScore: toDrift(Math.round(fs ?? 100)), - dependencyScore: toDrift(Math.round(ds ?? 100)), - eolScore: toDrift(Math.round(es ?? 100)), + runtimeScore: componentDrift(rs), + frameworkScore: componentDrift(fs), + dependencyScore: componentDrift(ds), + eolScore: componentDrift(es), }; return c; }; @@ -238,11 +243,12 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { const active = components.filter((c) => c.score !== null); if (active.length === 0) { - // No data at all — neutral score (no measurable drift) + // Nothing was measured. The score is absent — not zero, and not low risk. return { - score: 0, - riskLevel: 'low', + score: null, + riskLevel: 'none', components: buildComponents(), + measured: [], methodologyVersion: DRIFT_SCORE_METHODOLOGY_VERSION, ...(confidence !== undefined ? { confidence } : {}), ...envelope, diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..6fa3b9d 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -286,17 +286,18 @@ export interface MermaidDiagram { export interface DriftScore { /** * DriftScore (`driftscore-2.0`): 0–100 where **0 = no drift (best)** and - * **100 = maximum drift (worst)**. Higher is worse — consistent with - * RiskScore and the "drift budget" model. Components below are also drift - * (0 = fully current). + * **100 = maximum drift (worst)**, or null when nothing was measured. + * Higher is worse — consistent with RiskScore and the "drift budget" model. + * Null is absent, not a perfect score. Components below are also drift + * (0 = fully current); a null component was not measured. */ - score: number; + score: number | null; riskLevel: RiskLevel; components: { - runtimeScore: number; - frameworkScore: number; - dependencyScore: number; - eolScore: number; + runtimeScore: number | null; + frameworkScore: number | null; + dependencyScore: number | null; + eolScore: number | null; /** * Libyear-based dependency-freshness sub-score as drift (0–100, 0 = fresh). * Optional/additive: only present when release-date data was available, so diff --git a/src/core-open/utils/mermaid.ts b/src/core-open/utils/mermaid.ts index b035e62..7ca660b 100644 --- a/src/core-open/utils/mermaid.ts +++ b/src/core-open/utils/mermaid.ts @@ -11,8 +11,8 @@ function escapeLabel(input: string): string { return input.replace(/"/g, '\\"'); } -function scoreClass(score: number | undefined): 'scoreHigh' | 'scoreModerate' | 'scoreLow' | 'scoreUnknown' { - if (score === undefined || Number.isNaN(score)) return 'scoreUnknown'; +function scoreClass(score: number | null | undefined): 'scoreHigh' | 'scoreModerate' | 'scoreLow' | 'scoreUnknown' { + if (typeof score !== 'number' || Number.isNaN(score)) return 'scoreUnknown'; // Match dashboard thresholds: >= 80 green, >= 50 amber, < 50 red if (score >= 80) return 'scoreHigh'; if (score >= 50) return 'scoreModerate'; diff --git a/src/lsp/server.ts b/src/lsp/server.ts index f59d95f..fdd3b9b 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -118,9 +118,11 @@ export interface ScanArtifactNotification { /** `vibgrate/score` — pushed whenever the score changes. Drives the status bar. */ export interface ScoreNotification { - score: number; - /** DRIFTSCORE-V3-SPEC §5. Clients map band → theme colour; we never send one. */ - band: Band; + /** DriftScore, or null when the scan measured nothing. Never 0 for an absent score. */ + score: number | null; + /** DRIFTSCORE-V3-SPEC §5. Clients map band → theme colour; we never send one. + * `none` means the score was not measured. */ + band: Band | 'none'; /** `estimated` renders with a leading `~` (v3 §2.4). Offline is NOT estimated. */ mode: 'verified' | 'estimated'; /** Clients must break trend lines across a change here (v3 version-tag note). */ @@ -991,19 +993,29 @@ export class VibgrateLanguageServer { // `riskLevel` is what `driftscore-2.0` ships; v3 renames it `band` (§5 // envelope). We normalise to `band` on the wire so clients are already // speaking v3 and need no change when the engine catches up. - const band = (a.drift.riskLevel ?? 'low') as Band; + // `none` stays `none` — an unscored scan is not low risk. + const band: Band | 'none' = + a.drift.riskLevel === 'moderate' || a.drift.riskLevel === 'high' || a.drift.riskLevel === 'low' + ? a.drift.riskLevel + : 'none'; + const numericScore = typeof a.drift.score === 'number' ? a.drift.score : null; // History + drift diff (plan §5.6/§5.8): diff against the last recorded // entry, then record this one. Both engine-side — clients only render. - const historyEntry: ScoreHistoryEntry = { - ts: a.timestamp, - score: a.drift.score, - band, - mode: a.drift.mode ?? (hasReleaseDates(a) ? 'verified' : 'estimated'), - methodology: a.drift.methodologyVersion ?? 'unknown', - }; - const delta = deltaFrom(lastEntry(this.opts.root), historyEntry); - recordScore(this.opts.root, historyEntry); + // An absent score is not recorded; a 0 on the sparkline would look measured. + const mode = a.drift.mode ?? (hasReleaseDates(a) ? 'verified' : 'estimated'); + let delta: number | undefined; + if (numericScore !== null && band !== 'none') { + const historyEntry: ScoreHistoryEntry = { + ts: a.timestamp, + score: numericScore, + band, + mode, + methodology: a.drift.methodologyVersion ?? 'unknown', + }; + delta = deltaFrom(lastEntry(this.opts.root), historyEntry); + recordScore(this.opts.root, historyEntry); + } // Per-dependency state for the inline/hover surfaces — all O(deps), once // per scan, never in a hover or decoration hot path (coverage plan §5). @@ -1016,19 +1028,19 @@ export class VibgrateLanguageServer { } } } - const snapshot = { ts: a.timestamp, methodology: historyEntry.methodology, drifted: driftedKeys }; + const snapshot = { ts: a.timestamp, methodology: a.drift.methodologyVersion ?? 'unknown', drifted: driftedKeys }; this.newDrift = newlyDrifted(readInventory(this.opts.root), snapshot); recordInventory(this.opts.root, snapshot); const payload: ScoreNotification = { - score: a.drift.score, + score: numericScore, band, // v3 §2.4: `estimated` means "no timestamps at all" — it is NOT an // offline marker. An air-gapped scan against a dated snapshot is Verified. // The score now carries the authoritative provenance (driftscore-3.0 // envelope); fall back to the artifact heuristic for pre-v3 engines. - mode: historyEntry.mode, - methodology: historyEntry.methodology, + mode, + methodology: a.drift.methodologyVersion ?? 'unknown', scale: '0 best, 100 worst', counts: { behind, eol, unmaintained, total }, rootPath: a.rootPath, @@ -1246,9 +1258,11 @@ export class VibgrateLanguageServer { (project.drift?.mode ?? a.drift.mode ?? (hasReleaseDates(a) ? 'verified' : 'estimated')) === 'estimated'; const mode = estimated ? '~' : ''; - const score = project.drift?.score ?? a.drift.score; - const band = project.drift?.riskLevel ?? a.drift.riskLevel; - const title = `Vibgrate · drift ${mode}${score} (${band}) · ${behind} behind · ${eol} EOL`; + const rawScore = project.drift ? project.drift.score : a.drift.score; + const rawBand = project.drift ? project.drift.riskLevel : a.drift.riskLevel; + const scoreText = typeof rawScore === 'number' ? `${mode}${rawScore}` : 'n/a'; + const bandText = typeof rawScore === 'number' ? rawBand : 'n/a'; + const title = `Vibgrate · drift ${scoreText} (${bandText}) · ${behind} behind · ${eol} EOL`; return [ { @@ -2151,8 +2165,10 @@ function buildProjectRefs(rootDir: string, projects: ProjectScan[]): ProjectRef[ name: rel, manifestPath: manifestRelativePath(p), ...(lockfilePath ? { lockfilePath } : {}), - score: p.drift.score, - band: (p.drift.riskLevel ?? 'low') as Band, + ...(typeof p.drift.score === 'number' ? { score: p.drift.score } : {}), + ...(p.drift.riskLevel === 'low' || p.drift.riskLevel === 'moderate' || p.drift.riskLevel === 'high' + ? { band: p.drift.riskLevel } + : {}), mode: (p.drift.mode ?? 'verified') as 'verified' | 'estimated', }); } @@ -2201,9 +2217,14 @@ function scoreForProject(rootDir: string, a: ScanArtifact, proj: ProjectScan): S ).length; const hasDates = (proj.dependencies ?? []).some((d) => d.ageDays !== null && d.ageDays !== undefined); + const score = typeof proj.drift.score === 'number' ? proj.drift.score : null; + const band: Band | 'none' = + proj.drift.riskLevel === 'low' || proj.drift.riskLevel === 'moderate' || proj.drift.riskLevel === 'high' + ? proj.drift.riskLevel + : 'none'; return { - score: proj.drift.score, - band: (proj.drift.riskLevel ?? 'low') as Band, + score, + band, mode: proj.drift.mode ?? (hasDates ? 'verified' : 'estimated'), methodology: proj.drift.methodologyVersion ?? a.drift.methodologyVersion ?? 'unknown', scale: '0 best, 100 worst', diff --git a/src/reporting/commands/baseline.ts b/src/reporting/commands/baseline.ts index c944422..95fe994 100644 --- a/src/reporting/commands/baseline.ts +++ b/src/reporting/commands/baseline.ts @@ -17,7 +17,8 @@ export async function runBaseline(rootDir: string): Promise { const baselinePath = path.join(rootDir, '.vibgrate', 'baseline.json'); await writeJsonFile(baselinePath, artifact); console.log(chalk.green('✔') + ` Baseline saved to ${chalk.bold('.vibgrate/baseline.json')}`); - console.log(chalk.dim(` Baseline score: ${artifact.drift.score}/100`)); + const score = artifact.drift.score; + console.log(chalk.dim(` Baseline score: ${typeof score === 'number' ? `${score}/100` : 'n/a'}`)); } export const baselineCommand = new Command('baseline') diff --git a/src/reporting/commands/fix-e2e.test.ts b/src/reporting/commands/fix-e2e.test.ts index 1e0e0dc..711f7cb 100644 --- a/src/reporting/commands/fix-e2e.test.ts +++ b/src/reporting/commands/fix-e2e.test.ts @@ -187,7 +187,8 @@ describe('vg fix — end to end on real repos', () => { expect(rendered.currentDriftScore).toBe(artifact.drift.score); const safe = rendered.plans.find((p) => p.tier === 'safe')!; expect(safe.expectedDriftScore).toBe(0); // upgrading lodash to current clears all drift - expect(safe.driftDelta).toBe(-artifact.drift.score); // strictly better + expect(artifact.drift.score).not.toBeNull(); + expect(safe.driftDelta).toBe(artifact.drift.score === null ? undefined : -artifact.drift.score); // strictly better }); it('picks a plan non-interactively and previews the exact upgrade command (--plan --dry-run)', async () => { @@ -316,7 +317,8 @@ describe('vg fix — end to end on real repos', () => { // …and a fresh scan proves the drift is gone, matching the pre-apply estimate. const after = await scan(root); - expect(after.drift.score).toBeLessThan(before.drift.score); + expect(before.drift.score).not.toBeNull(); + expect(after.drift.score).toBeLessThan(before.drift.score ?? 101); expect(after.drift.score).toBe(0); expect(after.projects[0].dependencyAgeBuckets).toMatchObject({ current: 2, twoPlusBehind: 0 }); }); diff --git a/src/reporting/commands/fix.ts b/src/reporting/commands/fix.ts index 23fa9b4..8b2cdfc 100644 --- a/src/reporting/commands/fix.ts +++ b/src/reporting/commands/fix.ts @@ -340,8 +340,10 @@ export const fixCommand = new Command('fix') for (const plan of response.plans) { const upgraded = new Set(plan.upgrades.map((u) => u.package)); const expected = estimateDriftScore(artifact, upgraded); - plan.expectedDriftScore = expected; - plan.driftDelta = expected - currentDrift; + if (typeof expected === 'number') { + plan.expectedDriftScore = expected; + plan.driftDelta = expected - currentDrift; + } } } diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c560d2f..509fd57 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -468,7 +468,11 @@ export function formatDeltaText(base: ScanArtifact, current: ScanArtifact): stri '===================', `Baseline: ${base.timestamp}`, `Current: ${current.timestamp}`, - `DriftScore delta: ${(current.drift.score - base.drift.score).toFixed(2)} points`, + `DriftScore delta: ${ + typeof current.drift.score === 'number' && typeof base.drift.score === 'number' + ? `${(current.drift.score - base.drift.score).toFixed(2)} points` + : 'n/a' + }`, '', `Added dependencies (${added.length})`, ...added.map((d) => ` + ${d}`), diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index 76a4e3b..9f2d1e0 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -18,7 +18,7 @@ import { loadConfig, findConfigFile, } from '../../core-open/index.js'; -import { evaluateConfigDriftBudget } from '../drift-budget-gate.js'; +import { driftBudgetFlagDecision, evaluateConfigDriftBudget } from '../drift-budget-gate.js'; import type { ScanOptions, ScanArtifact } from '../../core-open/index.js'; import { analyzeReachability, collectPreflightDependencies } from '../reachability.js'; import type { VgGraph } from '../../schema.js'; @@ -889,18 +889,24 @@ export const scanCommand = new Command('scan') if (!opts.quiet) console.error(chalk.dim(`\niac gate: no findings at or above ${threshold} (${securityPacksLabel(section)}).`)); } - if (scanOpts.driftBudget !== undefined && artifact.drift.score > scanOpts.driftBudget) { - console.error(chalk.red(`\nFailing fitness function: DriftScore ${artifact.drift.score}/100 exceeds budget ${scanOpts.driftBudget}.`)); - process.exit(2); + if (scanOpts.driftBudget !== undefined) { + const decision = driftBudgetFlagDecision(artifact.drift.score, scanOpts.driftBudget); + if (decision.exitCode === 2 && decision.message) { + console.error(chalk.red(`\n${decision.message}`)); + process.exit(2); + } + if (decision.message) { + console.error(chalk.yellow(`\n${decision.message}`)); + } } if (scanOpts.driftWorseningPercent !== undefined) { - if (artifact.delta === undefined) { + if (artifact.drift.score === null) { + console.error(chalk.yellow('\nDriftScore is absent; --drift-worsening was not applied.')); + } else if (artifact.delta === undefined) { console.error(chalk.red('\nFailing fitness function: --drift-worsening requires --baseline to compare against previous drift.')); process.exit(2); - } - - if (artifact.delta > 0) { + } else if (artifact.delta > 0) { const baselineScore = artifact.drift.score - artifact.delta; const denominator = Math.max(Math.abs(baselineScore), 0.0001); const worseningPercent = (artifact.delta / denominator) * 100; @@ -920,7 +926,9 @@ export const scanCommand = new Command('scan') raw: projectConfig.driftBudget, configFile: findConfigFile(rootDir), headScore: artifact.drift.score, - baseScore: artifact.delta === undefined ? null : artifact.drift.score - artifact.delta, + baseScore: artifact.delta === undefined || artifact.drift.score === null + ? null + : artifact.drift.score - artifact.delta, }); for (const line of gate.lines) { if (line.level === 'error') console.error(chalk.red(line.text)); diff --git a/src/reporting/drift-budget-gate.test.ts b/src/reporting/drift-budget-gate.test.ts index 541f6f0..3d727eb 100644 --- a/src/reporting/drift-budget-gate.test.ts +++ b/src/reporting/drift-budget-gate.test.ts @@ -1,9 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { evaluateConfigDriftBudget } from './drift-budget-gate.js'; +import { driftBudgetFlagDecision, evaluateConfigDriftBudget } from './drift-budget-gate.js'; const file = '.vibgrate/config.yml'; +describe('driftBudgetFlagDecision', () => { + it('does not compare an absent score, even against a budget of 0', () => { + expect(driftBudgetFlagDecision(null, 0)).toEqual({ + exitCode: 0, + message: 'DriftScore is absent; --drift-budget was not applied.', + }); + }); + + it('keeps a measured zero inside the budget and fails a real overrun', () => { + expect(driftBudgetFlagDecision(0, 0)).toEqual({ exitCode: 0, message: null }); + expect(driftBudgetFlagDecision(41, 40).exitCode).toBe(2); + expect(driftBudgetFlagDecision(41, 40).message).toContain('41/100'); + }); +}); + describe('evaluateConfigDriftBudget', () => { + it('does not apply the budget when DriftScore is absent', () => { + const gate = evaluateConfigDriftBudget({ + raw: { mode: 'enforce', maxScore: 0 }, + configFile: file, + headScore: null, + baseScore: null, + }); + expect(gate.exitCode).toBe(0); + expect(gate.verdict).toBeNull(); + expect(gate.lines[0]?.text).toContain('DriftScore is absent'); + }); + it('does nothing when the config has no driftBudget', () => { expect(evaluateConfigDriftBudget({ raw: undefined, configFile: file, headScore: 90, baseScore: null })).toEqual({ exitCode: 0, diff --git a/src/reporting/drift-budget-gate.ts b/src/reporting/drift-budget-gate.ts index 5b1497b..de134af 100644 --- a/src/reporting/drift-budget-gate.ts +++ b/src/reporting/drift-budget-gate.ts @@ -16,11 +16,32 @@ export interface DriftBudgetGateInput { raw: unknown; /** Where the config came from, for messages. */ configFile: string | null; - headScore: number; + /** Null when the scan did not measure a DriftScore. Never treat that as 0. */ + headScore: number | null; /** Score before this change: `headScore - delta` when `--baseline` was used. */ baseScore: number | null; } +/** + * Compare a DriftScore to `--drift-budget`. + * An absent score is not compared and does not fail the scan. + */ +export function driftBudgetFlagDecision( + score: number | null, + budget: number, +): { exitCode: 0 | 2; message: string | null } { + if (score === null) { + return { exitCode: 0, message: 'DriftScore is absent; --drift-budget was not applied.' }; + } + if (score > budget) { + return { + exitCode: 2, + message: `Failing fitness function: DriftScore ${score}/100 exceeds budget ${budget}.`, + }; + } + return { exitCode: 0, message: null }; +} + export type GateLineLevel = 'info' | 'warn' | 'error'; export interface DriftBudgetGateResult { @@ -45,6 +66,14 @@ export function evaluateConfigDriftBudget(input: DriftBudgetGateInput): DriftBud }; } + if (input.headScore === null) { + return { + exitCode: 0, + verdict: null, + lines: [{ level: 'info', text: 'DriftScore is absent; the drift budget was not applied.' }], + }; + } + const verdict = evaluateDriftBudget({ headScore: input.headScore, baseScore: input.baseScore, diff --git a/src/reporting/formatters/formatters.test.ts b/src/reporting/formatters/formatters.test.ts index 9019427..3d211f2 100644 --- a/src/reporting/formatters/formatters.test.ts +++ b/src/reporting/formatters/formatters.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect } from 'vitest'; import { formatSarif as formatSarifCore } from '../../core-open/formatters/sarif.js'; import { formatMarkdown } from '../formatters/markdown.js'; import { formatText } from '../formatters/text.js'; +import { formatMarkdown as formatScanMarkdown } from '../../core-open/formatters/markdown.js'; +import { formatText as formatScanText } from '../../core-open/formatters/text.js'; import type { ScanArtifact } from '../types.js'; // The reporting-side artifact type has drifted from core-open's (a stale @@ -372,4 +374,66 @@ describe('formatText', () => { const text = formatText(makeArtifact({ projects: [], findings: [] })); expect(text).toContain('Vibgrate Drift Report'); }); + + it('renders an absent DriftScore as n/a and a measured zero as 0', () => { + const absent = makeArtifact({ + projects: [], + findings: [], + drift: { + score: null, + riskLevel: 'none', + components: { + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + measured: [], + }, + }); + const text = formatText(absent); + expect(text).toContain('n/a'); + expect(text).not.toContain('0/100'); + const md = formatMarkdown(absent); + expect(md).toContain('| **DriftScore** | n/a'); + expect(md).toContain('| Runtime | n/a |'); + expect(md).not.toContain('0/100'); + + const zero = formatText(makeArtifact({ + drift: { + score: 0, + riskLevel: 'low', + components: { + runtimeScore: 0, + frameworkScore: 0, + dependencyScore: 0, + eolScore: 0, + }, + measured: ['runtime', 'framework', 'dependency', 'eol'], + }, + })); + expect(zero).toContain('0/100'); + }); + + it('scan summary keeps an absent DriftScore absent in text, markdown, and JSON', () => { + const drift = { + score: null, + riskLevel: 'none' as const, + components: { + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + measured: [] as const, + }; + const artifact = { ...makeArtifact({ projects: [], findings: [] }), drift }; + const text = formatScanText(artifact as never); + const md = formatScanMarkdown(artifact as never); + expect(text).toContain('n/a'); + expect(text).not.toContain('0/100'); + expect(md).toContain('| **DriftScore** | n/a |'); + expect(md).toContain('| Runtime | n/a |'); + expect(JSON.parse(JSON.stringify(drift)).score).toBeNull(); + }); }); diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index 5778589..e6ad1ed 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -8,8 +8,9 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Metric | Value |`); lines.push(`|--------|-------|`); - lines.push(`| **DriftScore** | ${artifact.drift.score}/100 _(lower is better; 0 = no drift)_ |`); - lines.push(`| **Risk Level** | ${artifact.drift.riskLevel.toUpperCase()} |`); + const score = artifact.drift.score; + lines.push(`| **DriftScore** | ${typeof score === 'number' ? `${score}/100 _(lower is better; 0 = no drift)_` : 'n/a _(not measured)_'} |`); + lines.push(`| **Risk Level** | ${typeof score === 'number' ? artifact.drift.riskLevel.toUpperCase() : 'n/a'} |`); lines.push(`| **Projects** | ${artifact.projects.length} |`); const scannedMeta: string[] = [artifact.timestamp]; if (artifact.durationMs !== undefined) scannedMeta.push(`${(artifact.durationMs / 1000).toFixed(1)}s`); @@ -28,10 +29,11 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Component | Score |`); lines.push(`|-----------|-------|`); - lines.push(`| Runtime | ${artifact.drift.components.runtimeScore} |`); - lines.push(`| Frameworks | ${artifact.drift.components.frameworkScore} |`); - lines.push(`| Dependencies | ${artifact.drift.components.dependencyScore} |`); - lines.push(`| EOL Risk | ${artifact.drift.components.eolScore} |`); + const cell = (value: number | null): string => (typeof value === 'number' ? String(value) : 'n/a'); + lines.push(`| Runtime | ${cell(artifact.drift.components.runtimeScore)} |`); + lines.push(`| Frameworks | ${cell(artifact.drift.components.frameworkScore)} |`); + lines.push(`| Dependencies | ${cell(artifact.drift.components.dependencyScore)} |`); + lines.push(`| EOL Risk | ${cell(artifact.drift.components.eolScore)} |`); lines.push(''); // Per project diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index 5275e34..6e4b2d1 100644 --- a/src/reporting/formatters/text.ts +++ b/src/reporting/formatters/text.ts @@ -128,13 +128,16 @@ export function formatText(artifact: ScanArtifact): string { } // Score summary — drift score is lower-is-better (0 = no drift). - const scoreColor = artifact.drift.score <= 30 ? chalk.green : - artifact.drift.score <= 60 ? chalk.yellow : chalk.red; + // A null score was not computed; it must not render as 0. + const score = artifact.drift.score; + const scoreColor = typeof score === 'number' + ? (score <= 30 ? chalk.green : score <= 60 ? chalk.yellow : chalk.red) + : chalk.dim; lines.push(...titleBox('DriftScore Summary')); lines.push(''); - lines.push(chalk.bold(' DriftScore: ') + scoreColor.bold(`${artifact.drift.score}/100`)); - lines.push(chalk.bold(' Risk Level: ') + riskBadge(artifact.drift.riskLevel)); + lines.push(chalk.bold(' DriftScore: ') + (typeof score === 'number' ? scoreColor.bold(`${score}/100`) : chalk.dim('n/a'))); + lines.push(chalk.bold(' Risk Level: ') + (typeof score === 'number' ? riskBadge(artifact.drift.riskLevel) : chalk.dim('n/a'))); lines.push(chalk.bold(' Projects: ') + `${artifact.projects.length}`); if (artifact.vcs) { @@ -146,13 +149,17 @@ export function formatText(artifact: ScanArtifact): string { lines.push(''); - // Score breakdown + // Score breakdown. A missing `measured` list is a legacy artifact that + // stored a number for every component; a null component is unmeasured + // even when that list names it. const m = new Set(artifact.drift.measured ?? ['runtime', 'framework', 'dependency', 'eol']); + const componentCell = (key: 'runtime' | 'framework' | 'dependency' | 'eol', value: number | null): string => + m.has(key) && typeof value === 'number' ? scoreBar(value) : chalk.dim('n/a'); lines.push(' ' + chalk.bold.underline('Score Breakdown')); - lines.push(` Runtime: ${m.has('runtime') ? scoreBar(artifact.drift.components.runtimeScore) : chalk.dim('n/a')}`); - lines.push(` Frameworks: ${m.has('framework') ? scoreBar(artifact.drift.components.frameworkScore) : chalk.dim('n/a')}`); - lines.push(` Dependencies: ${m.has('dependency') ? scoreBar(artifact.drift.components.dependencyScore) : chalk.dim('n/a')}`); - lines.push(` EOL Risk: ${m.has('eol') ? scoreBar(artifact.drift.components.eolScore) : chalk.dim('n/a')}`); + lines.push(` Runtime: ${componentCell('runtime', artifact.drift.components.runtimeScore)}`); + lines.push(` Frameworks: ${componentCell('framework', artifact.drift.components.frameworkScore)}`); + lines.push(` Dependencies: ${componentCell('dependency', artifact.drift.components.dependencyScore)}`); + lines.push(` EOL Risk: ${componentCell('eol', artifact.drift.components.eolScore)}`); lines.push(''); const scannedParts: string[] = [`Scanned at ${artifact.timestamp}`]; diff --git a/src/reporting/planning/expected-drift.test.ts b/src/reporting/planning/expected-drift.test.ts index ce7e1c8..7d63240 100644 --- a/src/reporting/planning/expected-drift.test.ts +++ b/src/reporting/planning/expected-drift.test.ts @@ -44,8 +44,11 @@ describe('estimateDriftScore', () => { const before = estimateDriftScore(a, new Set()); const afterOne = estimateDriftScore(a, new Set(['b'])); // moves 1 out of oneBehind const afterAll = estimateDriftScore(a, new Set(['a', 'b'])); - expect(afterOne).toBeLessThanOrEqual(before); - expect(afterAll).toBeLessThanOrEqual(afterOne); + expect(afterOne).not.toBeNull(); + expect(afterAll).not.toBeNull(); + expect(before).not.toBeNull(); + expect(afterOne ?? 0).toBeLessThanOrEqual(before ?? 0); + expect(afterAll ?? 0).toBeLessThanOrEqual(afterOne ?? 0); }); it('does not mutate the input artifact', () => { diff --git a/src/reporting/planning/expected-drift.ts b/src/reporting/planning/expected-drift.ts index 63fa322..d3eb2e9 100644 --- a/src/reporting/planning/expected-drift.ts +++ b/src/reporting/planning/expected-drift.ts @@ -21,7 +21,7 @@ import type { ScanArtifact, ProjectScan } from '../../core-open/index.js'; */ /** Recompute the DriftScore assuming every package named in `upgraded` lands at latest. */ -export function estimateDriftScore(artifact: ScanArtifact, upgraded: Set): number { +export function estimateDriftScore(artifact: ScanArtifact, upgraded: Set): number | null { const projects: ProjectScan[] = JSON.parse(JSON.stringify(artifact.projects ?? [])); for (const p of projects) { const buckets = p.dependencyAgeBuckets; diff --git a/src/reporting/scoring/drift-score.test.ts b/src/reporting/scoring/drift-score.test.ts index 043d2a4..fd84e91 100644 --- a/src/reporting/scoring/drift-score.test.ts +++ b/src/reporting/scoring/drift-score.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { computeDriftScore, generateFindings, computeProjectId } from '../scoring/drift-score.js'; +import { computeDriftScore as computeScanDriftScore } from '../../core-open/scoring/drift-score.js'; import type { ProjectScan, VibgrateConfig } from '../types.js'; // ── Helpers ── @@ -46,16 +47,39 @@ describe('computeDriftScore', () => { const result = computeDriftScore([project]); expect(result.score).toBe(0); expect(result.riskLevel).toBe('low'); + expect(result.components.runtimeScore).toBe(0); }); - it('returns 0 drift for empty projects array', () => { + it('leaves the score absent for an empty project list', () => { const result = computeDriftScore([]); - expect(result.score).toBe(0); - expect(result.riskLevel).toBe('low'); - expect(result.components.runtimeScore).toBe(0); - expect(result.components.frameworkScore).toBe(0); - expect(result.components.dependencyScore).toBe(0); - expect(result.components.eolScore).toBe(0); + expect(result.score).toBeNull(); + expect(result.riskLevel).not.toBe('low'); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + expect(result.measured).toEqual([]); + const json = JSON.stringify(result); + expect(JSON.parse(json).score).toBeNull(); + expect(json).not.toContain('"score":0'); + expect(json).not.toContain('"runtimeScore":0'); + }); + + it('leaves the score absent when a project has no runtime and empty dependency buckets', () => { + const project = makeNodeProject({ + runtime: undefined, + runtimeMajorsBehind: undefined, + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }); + const result = computeDriftScore([project]); + expect(result.score).toBeNull(); + expect(result.riskLevel).toBe('none'); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); }); it('penalises runtime 1 major behind', () => { @@ -82,19 +106,22 @@ describe('computeDriftScore', () => { expect(result.components.runtimeScore).toBe(100); }); - it('returns runtimeScore 0 (no drift) when no runtime info', () => { + it('leaves runtimeScore absent when there is no runtime info', () => { const project = makeNodeProject({ runtimeMajorsBehind: undefined, runtime: undefined, }); const result = computeDriftScore([project]); - expect(result.components.runtimeScore).toBe(0); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + // Dependencies are still measured, so the overall score stays a number. + expect(result.score).toBe(0); }); - it('computes frameworkScore 0 (no drift) when no frameworks', () => { + it('leaves frameworkScore absent when there are no frameworks', () => { const project = makeNodeProject({ frameworks: [] }); const result = computeDriftScore([project]); - expect(result.components.frameworkScore).toBe(0); + expect(result.components.frameworkScore).toBeNull(); }); it('penalises frameworks with major lag', () => { @@ -114,7 +141,7 @@ describe('computeDriftScore', () => { ], }); const result = computeDriftScore([project]); - expect(result.components.frameworkScore).toBe(0); + expect(result.components.frameworkScore).toBeNull(); }); it('computes dependencyScore 0 (no drift) when all current', () => { @@ -133,12 +160,13 @@ describe('computeDriftScore', () => { expect(result.components.dependencyScore).toBeGreaterThan(50); }); - it('dependencyScore 0 (no drift) when no deps at all', () => { + it('leaves dependencyScore absent when there are no deps', () => { const project = makeNodeProject({ dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, }); const result = computeDriftScore([project]); - expect(result.components.dependencyScore).toBe(0); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.runtimeScore).toBe(50); }); it('eolScore penalises node 2 majors behind', () => { @@ -454,8 +482,8 @@ describe('per-project drift scores', () => { expect(score2.score).toBeGreaterThan(70); // Aggregate should be between the two (pulled up by p2's drift) - expect(aggregate.score).toBeLessThan(score2.score); - expect(aggregate.score).toBeGreaterThan(score1.score); + expect(aggregate.score).toBeLessThan(score2.score ?? 0); + expect(aggregate.score).toBeGreaterThan(score1.score ?? 0); }); it('individual project score matches single-project aggregate', () => { @@ -474,3 +502,76 @@ describe('per-project drift scores', () => { expect(singleProjectScore.components).toBeDefined(); }); }); + +describe('scan DriftScore absence', () => { + it('is null for an empty project list and stays null in JSON', () => { + const result = computeScanDriftScore([]); + expect(result.score).toBeNull(); + expect(result.riskLevel).not.toBe('low'); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + expect(JSON.parse(JSON.stringify(result)).score).toBeNull(); + }); + + it('is null when a project has no runtime and empty dependency buckets', () => { + const result = computeScanDriftScore([ + { + type: 'node', + path: '/test/project', + name: 'empty', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ]); + expect(result.score).toBeNull(); + expect(result.riskLevel).toBe('none'); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + }); + + it('keeps a measured zero and leaves unscored components null', () => { + const result = computeScanDriftScore([ + { + type: 'node', + path: '/test/project', + name: 'current', + runtime: '22.0.0', + runtimeLatest: '22.0.0', + runtimeMajorsBehind: 0, + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ]); + expect(result.score).toBe(0); + expect(result.riskLevel).toBe('low'); + expect(result.components.runtimeScore).toBe(0); + expect(result.components.eolScore).toBe(0); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + }); + + it('treats runtime health 0 (lag of 4 or more) as measured maximum drift', () => { + const result = computeScanDriftScore([ + { + type: 'node', + path: '/test/project', + name: 'old', + runtime: '10.0.0', + runtimeLatest: '22.0.0', + runtimeMajorsBehind: 4, + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ]); + expect(result.components.runtimeScore).toBe(100); + expect(result.measured).toContain('runtime'); + expect(result.score).not.toBeNull(); + }); +}); diff --git a/src/reporting/scoring/drift-score.ts b/src/reporting/scoring/drift-score.ts index e39d70b..80cff5b 100644 --- a/src/reporting/scoring/drift-score.ts +++ b/src/reporting/scoring/drift-score.ts @@ -138,21 +138,27 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { // DriftScore v2 convention: 0 = no drift (best), 100 = maximum drift (worst). // Components are computed on a "health" scale and inverted to drift here. + // Null health is unmeasured and stays null. Filling it with 100 inverted to + // drift 0, which reads as "no drift". A real health of 0 (runtime lag of 4 + // or more) is measured and inverts to drift 100. const toDrift = (health: number) => 100 - health; + const componentDrift = (health: number | null): number | null => + health === null ? null : toDrift(Math.round(health)); const buildComponents = (): DriftScore['components'] => ({ - runtimeScore: toDrift(Math.round(rs ?? 100)), - frameworkScore: toDrift(Math.round(fs ?? 100)), - dependencyScore: toDrift(Math.round(ds ?? 100)), - eolScore: toDrift(Math.round(es ?? 100)), + runtimeScore: componentDrift(rs), + frameworkScore: componentDrift(fs), + dependencyScore: componentDrift(ds), + eolScore: componentDrift(es), }); const active = components.filter((c) => c.score !== null); if (active.length === 0) { - // No data at all — neutral score (no measurable drift) + // Nothing was measured. The score is absent — not zero, and not low risk. return { - score: 0, - riskLevel: 'low', + score: null, + riskLevel: 'none', components: buildComponents(), + measured: [], methodologyVersion: DRIFT_SCORE_METHODOLOGY_VERSION, }; } diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 0ac11bf..c3f7cc5 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -170,17 +170,19 @@ export interface MermaidDiagram { export interface DriftScore { /** - * Aggregate drift score, 0–100. Lower is better: 0 = no drift, 100 = maximum drift. - * Risk bands: 0–30 = low, 31–60 = moderate, 61–100 = high. + * Aggregate drift score, 0–100, or null when nothing was measured. + * Lower is better: 0 = no drift, 100 = maximum drift. Null is absent, not + * a perfect score. Risk bands: 0–30 = low, 31–60 = moderate, 61–100 = high. + * `none` means the score was not computed. */ - score: number; + score: number | null; riskLevel: RiskLevel; - /** Per-component drift scores (0 = no drift, 100 = maximum drift). */ + /** Per-component drift scores (0 = no drift, 100 = maximum drift). Null means that component was not measured. */ components: { - runtimeScore: number; - frameworkScore: number; - dependencyScore: number; - eolScore: number; + runtimeScore: number | null; + frameworkScore: number | null; + dependencyScore: number | null; + eolScore: number | null; }; /** Which components had sufficient data to score. Missing = no data available. */ measured?: ('runtime' | 'framework' | 'dependency' | 'eol')[]; diff --git a/src/reporting/utils/ingest-id-output.test.ts b/src/reporting/utils/ingest-id-output.test.ts index e134b32..613f60f 100644 --- a/src/reporting/utils/ingest-id-output.test.ts +++ b/src/reporting/utils/ingest-id-output.test.ts @@ -37,4 +37,14 @@ describe('emitDriftScoreLine', () => { emitDriftScoreLine(42); expect(log).toHaveBeenCalledWith('VIBGRATE_DRIFT_SCORE=42'); }); + + it('prints a measured zero and omits an absent score', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + process.env.VIBGRATE_EMIT_MARKERS = '1'; + emitDriftScoreLine(0); + expect(log).toHaveBeenCalledWith('VIBGRATE_DRIFT_SCORE=0'); + log.mockClear(); + emitDriftScoreLine(null); + expect(log).not.toHaveBeenCalled(); + }); }); diff --git a/src/reporting/utils/ingest-id-output.ts b/src/reporting/utils/ingest-id-output.ts index 0c2ee3f..11b42f4 100644 --- a/src/reporting/utils/ingest-id-output.ts +++ b/src/reporting/utils/ingest-id-output.ts @@ -13,7 +13,9 @@ export function emitIngestIdLine(ingestId: string, options?: { unchanged?: boole * (VIBGRATE_EMIT_MARKERS=1, set by the migration agent) so normal CLI output is * unchanged. */ -export function emitDriftScoreLine(score: number): void { +export function emitDriftScoreLine(score: number | null): void { if (process.env.VIBGRATE_EMIT_MARKERS !== '1') return; + // An absent score is omitted. Printing 0 would look like a real DriftScore. + if (typeof score !== 'number') return; console.log(`VIBGRATE_DRIFT_SCORE=${score}`); }