Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, + / −).
Expand Down
2 changes: 2 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/core-open/formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 16 additions & 10 deletions src/core-open/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -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}`];
Expand Down
9 changes: 7 additions & 2 deletions src/core-open/run-core-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}`));
}
Expand Down
20 changes: 13 additions & 7 deletions src/core-open/scoring/drift-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 9 additions & 8 deletions src/core-open/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/core-open/utils/mermaid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
69 changes: 45 additions & 24 deletions src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand Down Expand Up @@ -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 [
{
Expand Down Expand Up @@ -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',
});
}
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion src/reporting/commands/baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export async function runBaseline(rootDir: string): Promise<void> {
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')
Expand Down
6 changes: 4 additions & 2 deletions src/reporting/commands/fix-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 });
});
Expand Down
6 changes: 4 additions & 2 deletions src/reporting/commands/fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/reporting/commands/sbom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
Expand Down
Loading
Loading