From 27b4e9ef4d0c10fb55efb1c730b28fc045f9f891 Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Mon, 1 Jun 2026 08:31:13 +0300 Subject: [PATCH 1/2] feat(headless): cross-reference advisories against upgrade targets Add fixedByRange and fixedByLatest fields to indicate whether upgrading within range or to latest resolves each advisory. Include schemaVersion in the report payload for consumer pinning. Refactor vulnerability types to support the new cross-referencing data. --- README.md | 10 +- src/core/upgrade-runner.ts | 128 ++++++++++++++++++------- src/types/domain.ts | 30 +++++- test/unit/core/headless-runner.test.ts | 48 ++++++++-- 4 files changed, 171 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 56df61c..26a8435 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,15 @@ inup | cat # plain line-based report when piped to a log Each reported package carries its health signals: `deprecated` (npm deprecation message), `enginesNode` (declared `engines.node`), and `vulnerability` (known advisories on the currently-installed version, -from one bulk `npm audit`-style request). The summary includes a `vulnerable` count. +from one bulk `npm audit`-style request). Every advisory is **cross-referenced against the upgrade +targets**, so you know whether the upgrade actually fixes it: + +- `vulnerability.advisories[].fixedByRange` / `fixedByLatest` — does the in-range / latest target escape + this advisory's affected range? +- `vulnerability.fixedByRange` / `fixedByLatest` — does the target clear **every** advisory? + +The summary includes a `vulnerable` count, and the payload carries a `schemaVersion` so scripts and +agents can pin to a known shape. Output hygiene: with `--json`, stdout carries **only** the JSON document; all progress and warnings go to stderr. Exit codes: `0` up to date, `1` updates exist (`--check`), `2` error. diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index be6b0e7..59b55bf 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -1,21 +1,26 @@ import chalk from 'chalk' +import * as semver from 'semver' import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' import { + HeadlessAdvisory, HeadlessOptions, HeadlessReport, HeadlessReportEntry, + HeadlessVulnerability, + HEADLESS_SCHEMA_VERSION, PackageInfo, PackageLoadProgress, PackageSelectionState, PackageUpgradeChoice, UpgradeOptions, PackageManagerInfo, + VulnerabilitySeverity, } from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' -import { fetchVulnerabilities } from '../services' -import { createVulnerabilitySummary } from '../ui/presenters/vulnerability' +import { fetchVulnerabilities, VulnerabilityInfo } from '../services' +import { toComparableVersion } from '../utils' import { ConsoleUtils } from '../ui/utils' import { getPerformanceTracker } from '../features/debug' @@ -202,15 +207,17 @@ export class UpgradeRunner { const packages = await this.detector.getOutdatedPackages() const outdated = this.detector.getOutdatedPackagesOnly(packages) - // Enrich with security advisories (one bulk request, best-effort) — the same audit the - // TUI runs on `s`, so --json/--check carry the vuln signal CI cares about. - await this.enrichWithVulnerabilities(outdated) + // Audit the current versions (one bulk request, best-effort) and cross-reference each + // advisory against the upgrade targets, so the report says whether upgrading *fixes* it. + const vulnerabilities = await this.auditVulnerabilities(outdated) if (options.json) { // stdout is reserved for the JSON document only. - console.log(JSON.stringify(this.buildHeadlessReport(packages, outdated), null, 2)) + console.log( + JSON.stringify(this.buildHeadlessReport(packages, outdated, vulnerabilities), null, 2) + ) } else { - this.printPlainReport(outdated) + this.printPlainReport(outdated, vulnerabilities) } // Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors. @@ -223,13 +230,18 @@ export class UpgradeRunner { } } - private buildHeadlessReport(all: PackageInfo[], outdated: PackageInfo[]): HeadlessReport { + private buildHeadlessReport( + all: PackageInfo[], + outdated: PackageInfo[], + vulnerabilities: Map + ): HeadlessReport { return { + schemaVersion: HEADLESS_SCHEMA_VERSION, summary: { total: all.length, outdated: outdated.length, major: outdated.filter((pkg) => pkg.hasMajorUpdate).length, - vulnerable: outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length, + vulnerable: vulnerabilities.size, }, outdated: outdated.map((pkg) => { const entry: HeadlessReportEntry = { @@ -243,20 +255,26 @@ export class UpgradeRunner { } if (pkg.deprecated) entry.deprecated = pkg.deprecated if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode - if (pkg.vulnerability) entry.vulnerability = pkg.vulnerability + const vulnerability = vulnerabilities.get(pkg) + if (vulnerability) entry.vulnerability = vulnerability return entry }), } } /** - * Attach known security advisories to the outdated packages in place. Audits each package's - * currently-installed specifier (matching the interactive audit) via one bulk request. + * Audit the outdated packages' currently-installed versions (one bulk request, matching the + * interactive audit) and, for each advisory, cross-reference its affected range against the + * upgrade targets — so the report states whether upgrading actually *fixes* the issue. + * * Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a - * failed audit never blocks the report. + * failed audit never blocks the report. Returns only the vulnerable packages, keyed by package. */ - private async enrichWithVulnerabilities(outdated: PackageInfo[]): Promise { - if (outdated.length === 0) return + private async auditVulnerabilities( + outdated: PackageInfo[] + ): Promise> { + const result = new Map() + if (outdated.length === 0) return result // The bulk advisory API is keyed by package name (one version per name), so dedupe by name. const versions = new Map() @@ -265,25 +283,61 @@ export class UpgradeRunner { } const advisories = await fetchVulnerabilities(versions) - if (advisories.size === 0) return + if (advisories.size === 0) return result for (const pkg of outdated) { const found = advisories.get(pkg.name) if (!found || found.vulnerabilities.length === 0 || !found.highestSeverity) continue - pkg.vulnerability = createVulnerabilitySummary( - undefined, - found.vulnerabilities.map((item) => ({ - id: item.id, - title: item.title, - severity: item.severity, - url: item.url, - })), - found.highestSeverity + result.set( + pkg, + this.summarizeVulnerability(pkg, found.vulnerabilities, found.highestSeverity) ) } + return result } - private printPlainReport(outdated: PackageInfo[]): void { + private summarizeVulnerability( + pkg: PackageInfo, + vulnerabilities: VulnerabilityInfo[], + highestSeverity: VulnerabilitySeverity + ): HeadlessVulnerability { + const advisories: HeadlessAdvisory[] = vulnerabilities.map((vuln) => ({ + id: vuln.id, + title: vuln.title, + severity: vuln.severity, + url: vuln.url, + vulnerableVersions: vuln.vulnerable_versions, + fixedByRange: this.upgradeClears(pkg.rangeVersion, vuln.vulnerable_versions), + fixedByLatest: this.upgradeClears(pkg.latestVersion, vuln.vulnerable_versions), + })) + + return { + count: advisories.length, + highestSeverity, + fixedByRange: advisories.every((advisory) => advisory.fixedByRange), + fixedByLatest: advisories.every((advisory) => advisory.fixedByLatest), + advisories, + } + } + + /** + * True when upgrading to `target` escapes an advisory's affected range. Conservative: if either + * the target or the advisory range can't be parsed, we do NOT claim a fix. + */ + private upgradeClears(target: string, vulnerableVersions: string): boolean { + const comparable = toComparableVersion(target) + if (!comparable) return false + try { + return !semver.satisfies(comparable, vulnerableVersions, { includePrerelease: true }) + } catch { + return false + } + } + + private printPlainReport( + outdated: PackageInfo[], + vulnerabilities: Map + ): void { if (outdated.length === 0) { console.log('All dependencies are up to date — no upgrades needed.') return @@ -291,22 +345,30 @@ export class UpgradeRunner { for (const pkg of outdated) { const major = pkg.hasMajorUpdate ? ' (major)' : '' - const vuln = - pkg.vulnerability && pkg.vulnerability.count > 0 - ? ` [vuln: ${pkg.vulnerability.count} ${pkg.vulnerability.highestSeverity}]` - : '' const deprecated = pkg.deprecated ? ' [deprecated]' : '' console.log( - `${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${vuln}${deprecated}` + `${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${this.vulnMarker(vulnerabilities.get(pkg))}${deprecated}` ) } const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size - const vulnerable = outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length - const vulnNote = vulnerable > 0 ? ` — ${vulnerable} with known vulnerabilities` : '' + const vulnNote = + vulnerabilities.size > 0 ? ` — ${vulnerabilities.size} with known vulnerabilities` : '' console.log(`\n${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`) } + /** A compact `[vuln: N sev → verdict]` tag for the plain report; '' when there are none. */ + private vulnMarker(vulnerability: HeadlessVulnerability | undefined): string { + if (!vulnerability) return '' + // Prefer the cheaper fix: if the in-range bump already clears it, that's the safer action. + const verdict = vulnerability.fixedByRange + ? 'fixed by range upgrade' + : vulnerability.fixedByLatest + ? 'fixed by latest only' + : 'not fixed by upgrade' + return ` [vuln: ${vulnerability.count} ${vulnerability.highestSeverity} → ${verdict}]` + } + private checkPrerequisites(): void { // Check if package.json exists if (!this.detector.hasPackageJson()) { diff --git a/src/types/domain.ts b/src/types/domain.ts index ad6903d..b4ec049 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -1,13 +1,15 @@ import type { ChalkInstance } from 'chalk' +export type VulnerabilitySeverity = 'info' | 'low' | 'moderate' | 'high' | 'critical' + export interface VulnerabilitySummary { count: number - highestSeverity: 'info' | 'low' | 'moderate' | 'high' | 'critical' + highestSeverity: VulnerabilitySeverity detailsUrl?: string advisories: Array<{ id: number title: string - severity: 'info' | 'low' | 'moderate' | 'high' | 'critical' + severity: VulnerabilitySeverity url: string }> } @@ -90,6 +92,27 @@ export interface HeadlessOptions { check?: boolean // Exit non-zero when updates exist (CI gate) } +/** Bump when the `--json` shape changes in a way consumers (scripts, agents) must adapt to. */ +export const HEADLESS_SCHEMA_VERSION = 1 + +export interface HeadlessAdvisory { + id: number + title: string + severity: VulnerabilitySeverity + url: string + vulnerableVersions: string // The advisory's affected semver range, verbatim from npm + fixedByRange: boolean // The in-range target (`range`) is no longer affected + fixedByLatest: boolean // The latest target (`latest`) is no longer affected +} + +export interface HeadlessVulnerability { + count: number + highestSeverity: VulnerabilitySeverity + fixedByRange: boolean // Every advisory is cleared by upgrading within the current range + fixedByLatest: boolean // Every advisory is cleared by upgrading to latest + advisories: HeadlessAdvisory[] +} + export interface HeadlessReportEntry { name: string current: string // Raw specifier from package.json (with ^/~ prefix) @@ -100,10 +123,11 @@ export interface HeadlessReportEntry { hasMajorUpdate: boolean deprecated?: string // npm deprecation message for the latest version, if any enginesNode?: string // declared engines.node range for the latest version, if any - vulnerability?: VulnerabilitySummary + vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them } export interface HeadlessReport { + schemaVersion: number // HEADLESS_SCHEMA_VERSION — lets agents pin to a known shape summary: { total: number // Packages scanned outdated: number // Packages with an available update diff --git a/test/unit/core/headless-runner.test.ts b/test/unit/core/headless-runner.test.ts index 3a7d96e..97a7810 100644 --- a/test/unit/core/headless-runner.test.ts +++ b/test/unit/core/headless-runner.test.ts @@ -99,6 +99,7 @@ describe('UpgradeRunner.runHeadless', () => { expect(logSpy).toHaveBeenCalledTimes(1) const report = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(report.schemaVersion).toBe(1) expect(report.summary).toEqual({ total: 2, outdated: 1, major: 1, vulnerable: 0 }) expect(report.outdated).toHaveLength(1) expect(report.outdated[0]).toMatchObject({ @@ -118,7 +119,10 @@ describe('UpgradeRunner.runHeadless', () => { logSpy.mockRestore() }) - it('--json includes security advisories from the audit and counts them in the summary', async () => { + it('--json cross-references advisories against the upgrade targets (fixed-by verdict)', async () => { + // axios fixture: current ^0.27.0, range 0.27.2, latest 1.16.1. + // Advisory A (<1.0.0): the latest (1.16.1) escapes it, the in-range bump (0.27.2) does not. + // Advisory B (>=0.0.1): affects everything, so neither target fixes it. mocks.fetchVulnerabilities.mockResolvedValue( new Map([ [ @@ -128,12 +132,19 @@ describe('UpgradeRunner.runHeadless', () => { highestSeverity: 'high', vulnerabilities: [ { - id: 1234, - title: 'Server-Side Request Forgery', + id: 1, + title: 'SSRF', severity: 'high', - url: 'https://github.com/advisories/GHSA-test', + url: 'https://github.com/advisories/GHSA-a', vulnerable_versions: '<1.0.0', }, + { + id: 2, + title: 'Prototype pollution', + severity: 'moderate', + url: 'https://github.com/advisories/GHSA-b', + vulnerable_versions: '>=0.0.1', + }, ], }, ], @@ -148,11 +159,32 @@ describe('UpgradeRunner.runHeadless', () => { const report = JSON.parse(logSpy.mock.calls[0][0] as string) expect(report.summary.vulnerable).toBe(1) - expect(report.outdated[0].vulnerability).toMatchObject({ - count: 1, + expect(report.outdated[0].vulnerability).toEqual({ + count: 2, highestSeverity: 'high', - detailsUrl: 'https://github.com/advisories/GHSA-test', - advisories: [{ id: 1234, severity: 'high' }], + // Aggregates use AND across advisories: B blocks both, so neither target clears everything. + fixedByRange: false, + fixedByLatest: false, + advisories: [ + { + id: 1, + title: 'SSRF', + severity: 'high', + url: 'https://github.com/advisories/GHSA-a', + vulnerableVersions: '<1.0.0', + fixedByRange: false, + fixedByLatest: true, + }, + { + id: 2, + title: 'Prototype pollution', + severity: 'moderate', + url: 'https://github.com/advisories/GHSA-b', + vulnerableVersions: '>=0.0.1', + fixedByRange: false, + fixedByLatest: false, + }, + ], }) logSpy.mockRestore() From cd0aa63f6b4552cd43509c36fcb265d568e1b8b1 Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Mon, 1 Jun 2026 08:45:29 +0300 Subject: [PATCH 2/2] refactor(headless): extract headless runner into dedicated feature module Move non-interactive CLI logic from UpgradeRunner into a new HeadlessRunner class under src/features/headless/. This separates the CI/read-only path from the interactive TUI runner and co-locates vulnerability auditing and report generation with the headless feature. --- src/cli.ts | 17 +- src/core/upgrade-runner.ts | 185 -------------- src/features/headless/headless-runner.ts | 52 ++++ src/features/headless/index.ts | 4 + src/features/headless/report.ts | 73 ++++++ src/features/headless/types.ts | 51 ++++ src/features/headless/vulnerability-audit.ts | 76 ++++++ src/types/domain.ts | 50 ---- test/unit/cli.test.ts | 19 +- test/unit/core/headless-runner.test.ts | 239 ------------------ .../features/headless/headless-runner.test.ts | 163 ++++++++++++ .../headless/vulnerability-audit.test.ts | 87 +++++++ 12 files changed, 528 insertions(+), 488 deletions(-) create mode 100644 src/features/headless/headless-runner.ts create mode 100644 src/features/headless/index.ts create mode 100644 src/features/headless/report.ts create mode 100644 src/features/headless/types.ts create mode 100644 src/features/headless/vulnerability-audit.ts delete mode 100644 test/unit/core/headless-runner.test.ts create mode 100644 test/unit/features/headless/headless-runner.test.ts create mode 100644 test/unit/features/headless/vulnerability-audit.test.ts diff --git a/src/cli.ts b/src/cli.ts index 5a4ab57..e564164 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,9 +4,10 @@ import { Command } from 'commander' import chalk from 'chalk' import { resolve } from 'path' import { UpgradeRunner } from './index' +import { HeadlessRunner } from './features/headless' import { checkForUpdateAsync } from './services' import { loadProjectConfig, PACKAGE_NAME, PACKAGE_VERSION } from './config' -import { PackageManager } from './types' +import { PackageManager, UpgradeOptions } from './types' import { enableDebugLogging, applyColorSetting } from './utils' import { getGitWorkingTreeState } from './utils/git' import { TerminalInput } from './ui' @@ -101,26 +102,28 @@ export async function runCli(options: CliOptions): Promise { packageManager = options.packageManager as PackageManager } - const upgrader = new UpgradeRunner({ + const runnerOptions: UpgradeOptions = { cwd, excludePatterns, scanDirs: projectConfig.scanDirs, maxDepth, ignorePackages, packageManager, - showPeerDependencyVulnerabilities: - projectConfig.showPeerDependencyVulnerabilities ?? false, + showPeerDependencyVulnerabilities: projectConfig.showPeerDependencyVulnerabilities ?? false, showOptionalDependencyVulnerabilities: projectConfig.showOptionalDependencyVulnerabilities ?? false, debug: options.debug || process.env.INUP_DEBUG === '1', saveExact: options.saveExact ?? false, - }) + } + + // Non-interactive (piped / CI / --json / --check) routes to the read-only headless feature; + // only the interactive path builds the full TUI runner. if (!interactive) { - await upgrader.runHeadless({ json: options.json, check: options.check }) + await new HeadlessRunner(runnerOptions).run({ json: options.json, check: options.check }) return } - await upgrader.run() + await new UpgradeRunner(runnerOptions).run() // After the main flow completes, check if there's an update available const updateCheck = await updateCheckPromise diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index 59b55bf..bd0f064 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -1,26 +1,16 @@ import chalk from 'chalk' -import * as semver from 'semver' import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' import { - HeadlessAdvisory, - HeadlessOptions, - HeadlessReport, - HeadlessReportEntry, - HeadlessVulnerability, - HEADLESS_SCHEMA_VERSION, PackageInfo, PackageLoadProgress, PackageSelectionState, PackageUpgradeChoice, UpgradeOptions, PackageManagerInfo, - VulnerabilitySeverity, } from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' -import { fetchVulnerabilities, VulnerabilityInfo } from '../services' -import { toComparableVersion } from '../utils' import { ConsoleUtils } from '../ui/utils' import { getPerformanceTracker } from '../features/debug' @@ -194,181 +184,6 @@ export class UpgradeRunner { } } - /** - * Non-interactive entry point: resolve the outdated list without rendering the - * TUI, then either emit a JSON report (--json) or a plain line-based report. - * With --check, sets a non-zero exit code when updates exist so CI can gate on it. - * Read-only: never writes package.json or installs. - */ - public async runHeadless(options: HeadlessOptions): Promise { - try { - this.checkPrerequisites() - - const packages = await this.detector.getOutdatedPackages() - const outdated = this.detector.getOutdatedPackagesOnly(packages) - - // Audit the current versions (one bulk request, best-effort) and cross-reference each - // advisory against the upgrade targets, so the report says whether upgrading *fixes* it. - const vulnerabilities = await this.auditVulnerabilities(outdated) - - if (options.json) { - // stdout is reserved for the JSON document only. - console.log( - JSON.stringify(this.buildHeadlessReport(packages, outdated, vulnerabilities), null, 2) - ) - } else { - this.printPlainReport(outdated, vulnerabilities) - } - - // Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors. - if (options.check && outdated.length > 0) { - process.exitCode = 1 - } - } catch (error) { - console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)) - process.exit(2) - } - } - - private buildHeadlessReport( - all: PackageInfo[], - outdated: PackageInfo[], - vulnerabilities: Map - ): HeadlessReport { - return { - schemaVersion: HEADLESS_SCHEMA_VERSION, - summary: { - total: all.length, - outdated: outdated.length, - major: outdated.filter((pkg) => pkg.hasMajorUpdate).length, - vulnerable: vulnerabilities.size, - }, - outdated: outdated.map((pkg) => { - const entry: HeadlessReportEntry = { - name: pkg.name, - current: pkg.currentVersion, - range: pkg.rangeVersion, - latest: pkg.latestVersion, - type: pkg.type, - packageJsonPath: pkg.packageJsonPath, - hasMajorUpdate: pkg.hasMajorUpdate, - } - if (pkg.deprecated) entry.deprecated = pkg.deprecated - if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode - const vulnerability = vulnerabilities.get(pkg) - if (vulnerability) entry.vulnerability = vulnerability - return entry - }), - } - } - - /** - * Audit the outdated packages' currently-installed versions (one bulk request, matching the - * interactive audit) and, for each advisory, cross-reference its affected range against the - * upgrade targets — so the report states whether upgrading actually *fixes* the issue. - * - * Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a - * failed audit never blocks the report. Returns only the vulnerable packages, keyed by package. - */ - private async auditVulnerabilities( - outdated: PackageInfo[] - ): Promise> { - const result = new Map() - if (outdated.length === 0) return result - - // The bulk advisory API is keyed by package name (one version per name), so dedupe by name. - const versions = new Map() - for (const pkg of outdated) { - if (!versions.has(pkg.name)) versions.set(pkg.name, pkg.currentVersion) - } - - const advisories = await fetchVulnerabilities(versions) - if (advisories.size === 0) return result - - for (const pkg of outdated) { - const found = advisories.get(pkg.name) - if (!found || found.vulnerabilities.length === 0 || !found.highestSeverity) continue - result.set( - pkg, - this.summarizeVulnerability(pkg, found.vulnerabilities, found.highestSeverity) - ) - } - return result - } - - private summarizeVulnerability( - pkg: PackageInfo, - vulnerabilities: VulnerabilityInfo[], - highestSeverity: VulnerabilitySeverity - ): HeadlessVulnerability { - const advisories: HeadlessAdvisory[] = vulnerabilities.map((vuln) => ({ - id: vuln.id, - title: vuln.title, - severity: vuln.severity, - url: vuln.url, - vulnerableVersions: vuln.vulnerable_versions, - fixedByRange: this.upgradeClears(pkg.rangeVersion, vuln.vulnerable_versions), - fixedByLatest: this.upgradeClears(pkg.latestVersion, vuln.vulnerable_versions), - })) - - return { - count: advisories.length, - highestSeverity, - fixedByRange: advisories.every((advisory) => advisory.fixedByRange), - fixedByLatest: advisories.every((advisory) => advisory.fixedByLatest), - advisories, - } - } - - /** - * True when upgrading to `target` escapes an advisory's affected range. Conservative: if either - * the target or the advisory range can't be parsed, we do NOT claim a fix. - */ - private upgradeClears(target: string, vulnerableVersions: string): boolean { - const comparable = toComparableVersion(target) - if (!comparable) return false - try { - return !semver.satisfies(comparable, vulnerableVersions, { includePrerelease: true }) - } catch { - return false - } - } - - private printPlainReport( - outdated: PackageInfo[], - vulnerabilities: Map - ): void { - if (outdated.length === 0) { - console.log('All dependencies are up to date — no upgrades needed.') - return - } - - for (const pkg of outdated) { - const major = pkg.hasMajorUpdate ? ' (major)' : '' - const deprecated = pkg.deprecated ? ' [deprecated]' : '' - console.log( - `${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${this.vulnMarker(vulnerabilities.get(pkg))}${deprecated}` - ) - } - - const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size - const vulnNote = - vulnerabilities.size > 0 ? ` — ${vulnerabilities.size} with known vulnerabilities` : '' - console.log(`\n${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`) - } - - /** A compact `[vuln: N sev → verdict]` tag for the plain report; '' when there are none. */ - private vulnMarker(vulnerability: HeadlessVulnerability | undefined): string { - if (!vulnerability) return '' - // Prefer the cheaper fix: if the in-range bump already clears it, that's the safer action. - const verdict = vulnerability.fixedByRange - ? 'fixed by range upgrade' - : vulnerability.fixedByLatest - ? 'fixed by latest only' - : 'not fixed by upgrade' - return ` [vuln: ${vulnerability.count} ${vulnerability.highestSeverity} → ${verdict}]` - } - private checkPrerequisites(): void { // Check if package.json exists if (!this.detector.hasPackageJson()) { diff --git a/src/features/headless/headless-runner.ts b/src/features/headless/headless-runner.ts new file mode 100644 index 0000000..dd0115d --- /dev/null +++ b/src/features/headless/headless-runner.ts @@ -0,0 +1,52 @@ +import chalk from 'chalk' +import { PackageDetector } from '../../core/package-detector' +import type { UpgradeOptions } from '../../types' +import { auditVulnerabilities } from './vulnerability-audit' +import { buildHeadlessReport, renderPlainReport } from './report' +import { HeadlessOptions } from './types' + +/** + * Non-interactive entry point. Resolves the outdated list without rendering the TUI, then either + * emits a JSON report (--json) or a plain line-based report. With --check, sets a non-zero exit + * code when updates exist so CI can gate on it. Read-only: never writes package.json or installs. + * + * This is the headless counterpart to the interactive `UpgradeRunner`; it shares only the + * `PackageDetector` (the scan/resolve layer), not the UI/upgrade machinery. + */ +export class HeadlessRunner { + private detector: PackageDetector + + constructor(options?: UpgradeOptions) { + this.detector = new PackageDetector(options) + } + + async run(options: HeadlessOptions): Promise { + try { + if (!this.detector.hasPackageJson()) { + throw new Error('No package.json found in current directory') + } + + const packages = await this.detector.getOutdatedPackages() + const outdated = this.detector.getOutdatedPackagesOnly(packages) + + // Audit the current versions (one bulk request, best-effort) and cross-reference each + // advisory against the upgrade targets, so the report says whether upgrading *fixes* it. + const vulnerabilities = await auditVulnerabilities(outdated) + + if (options.json) { + // stdout is reserved for the JSON document only. + console.log(JSON.stringify(buildHeadlessReport(packages, outdated, vulnerabilities), null, 2)) + } else { + console.log(renderPlainReport(outdated, vulnerabilities)) + } + + // Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors. + if (options.check && outdated.length > 0) { + process.exitCode = 1 + } + } catch (error) { + console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)) + process.exit(2) + } + } +} diff --git a/src/features/headless/index.ts b/src/features/headless/index.ts new file mode 100644 index 0000000..5561710 --- /dev/null +++ b/src/features/headless/index.ts @@ -0,0 +1,4 @@ +export * from './types' +export { HeadlessRunner } from './headless-runner' +export { auditVulnerabilities, upgradeClears } from './vulnerability-audit' +export { buildHeadlessReport, renderPlainReport } from './report' diff --git a/src/features/headless/report.ts b/src/features/headless/report.ts new file mode 100644 index 0000000..6bea03e --- /dev/null +++ b/src/features/headless/report.ts @@ -0,0 +1,73 @@ +import type { PackageInfo } from '../../types' +import { + HEADLESS_SCHEMA_VERSION, + HeadlessReport, + HeadlessReportEntry, + HeadlessVulnerability, +} from './types' + +type VulnerabilityMap = Map + +/** Build the machine-readable `--json` payload from the scanned + outdated package sets. */ +export function buildHeadlessReport( + all: PackageInfo[], + outdated: PackageInfo[], + vulnerabilities: VulnerabilityMap +): HeadlessReport { + return { + schemaVersion: HEADLESS_SCHEMA_VERSION, + summary: { + total: all.length, + outdated: outdated.length, + major: outdated.filter((pkg) => pkg.hasMajorUpdate).length, + vulnerable: vulnerabilities.size, + }, + outdated: outdated.map((pkg) => { + const entry: HeadlessReportEntry = { + name: pkg.name, + current: pkg.currentVersion, + range: pkg.rangeVersion, + latest: pkg.latestVersion, + type: pkg.type, + packageJsonPath: pkg.packageJsonPath, + hasMajorUpdate: pkg.hasMajorUpdate, + } + if (pkg.deprecated) entry.deprecated = pkg.deprecated + if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode + const vulnerability = vulnerabilities.get(pkg) + if (vulnerability) entry.vulnerability = vulnerability + return entry + }), + } +} + +/** Render the plain, line-based report (one line per package + a recap) as a single string. */ +export function renderPlainReport(outdated: PackageInfo[], vulnerabilities: VulnerabilityMap): string { + if (outdated.length === 0) { + return 'All dependencies are up to date — no upgrades needed.' + } + + const lines = outdated.map((pkg) => { + const major = pkg.hasMajorUpdate ? ' (major)' : '' + const deprecated = pkg.deprecated ? ' [deprecated]' : '' + return `${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${vulnMarker(vulnerabilities.get(pkg))}${deprecated}` + }) + + const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size + const vulnNote = + vulnerabilities.size > 0 ? ` — ${vulnerabilities.size} with known vulnerabilities` : '' + lines.push('', `${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`) + return lines.join('\n') +} + +/** A compact `[vuln: N sev → verdict]` tag for the plain report; '' when there are none. */ +function vulnMarker(vulnerability: HeadlessVulnerability | undefined): string { + if (!vulnerability) return '' + // Prefer the cheaper fix: if the in-range bump already clears it, that's the safer action. + const verdict = vulnerability.fixedByRange + ? 'fixed by range upgrade' + : vulnerability.fixedByLatest + ? 'fixed by latest only' + : 'not fixed by upgrade' + return ` [vuln: ${vulnerability.count} ${vulnerability.highestSeverity} → ${verdict}]` +} diff --git a/src/features/headless/types.ts b/src/features/headless/types.ts new file mode 100644 index 0000000..bcf6ef5 --- /dev/null +++ b/src/features/headless/types.ts @@ -0,0 +1,51 @@ +import type { DependencyType, VulnerabilitySeverity } from '../../types' + +export interface HeadlessOptions { + json?: boolean // Emit a machine-readable JSON report on stdout + check?: boolean // Exit non-zero when updates exist (CI gate) +} + +/** Bump when the `--json` shape changes in a way consumers (scripts, agents) must adapt to. */ +export const HEADLESS_SCHEMA_VERSION = 1 + +export interface HeadlessAdvisory { + id: number + title: string + severity: VulnerabilitySeverity + url: string + vulnerableVersions: string // The advisory's affected semver range, verbatim from npm + fixedByRange: boolean // The in-range target (`range`) is no longer affected + fixedByLatest: boolean // The latest target (`latest`) is no longer affected +} + +export interface HeadlessVulnerability { + count: number + highestSeverity: VulnerabilitySeverity + fixedByRange: boolean // Every advisory is cleared by upgrading within the current range + fixedByLatest: boolean // Every advisory is cleared by upgrading to latest + advisories: HeadlessAdvisory[] +} + +export interface HeadlessReportEntry { + name: string + current: string // Raw specifier from package.json (with ^/~ prefix) + range: string // Latest version satisfying the current range + latest: string // Absolute latest version + type: DependencyType + packageJsonPath: string + hasMajorUpdate: boolean + deprecated?: string // npm deprecation message for the latest version, if any + enginesNode?: string // declared engines.node range for the latest version, if any + vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them +} + +export interface HeadlessReport { + schemaVersion: number // HEADLESS_SCHEMA_VERSION — lets agents pin to a known shape + summary: { + total: number // Packages scanned + outdated: number // Packages with an available update + major: number // Of the outdated, how many are a major bump + vulnerable: number // Of the outdated, how many have ≥1 known advisory on the current version + } + outdated: HeadlessReportEntry[] +} diff --git a/src/features/headless/vulnerability-audit.ts b/src/features/headless/vulnerability-audit.ts new file mode 100644 index 0000000..71c13d6 --- /dev/null +++ b/src/features/headless/vulnerability-audit.ts @@ -0,0 +1,76 @@ +import * as semver from 'semver' +import type { PackageInfo, VulnerabilitySeverity } from '../../types' +import { fetchVulnerabilities, VulnerabilityInfo } from '../../services' +import { toComparableVersion } from '../../utils' +import type { HeadlessAdvisory, HeadlessVulnerability } from './types' + +/** + * Audit the outdated packages' currently-installed versions (one bulk request, matching the + * interactive audit) and, for each advisory, cross-reference its affected range against the + * upgrade targets — so callers can state whether upgrading actually *fixes* the issue. + * + * Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a + * failed audit never blocks the report. Returns only the vulnerable packages, keyed by package. + */ +export async function auditVulnerabilities( + outdated: PackageInfo[] +): Promise> { + const result = new Map() + if (outdated.length === 0) return result + + // The bulk advisory API is keyed by package name (one version per name), so dedupe by name. + const versions = new Map() + for (const pkg of outdated) { + if (!versions.has(pkg.name)) versions.set(pkg.name, pkg.currentVersion) + } + + const advisories = await fetchVulnerabilities(versions) + if (advisories.size === 0) return result + + for (const pkg of outdated) { + const found = advisories.get(pkg.name) + if (!found || found.vulnerabilities.length === 0 || !found.highestSeverity) continue + result.set(pkg, summarizeVulnerability(pkg, found.vulnerabilities, found.highestSeverity)) + } + return result +} + +function summarizeVulnerability( + pkg: PackageInfo, + vulnerabilities: VulnerabilityInfo[], + highestSeverity: VulnerabilitySeverity +): HeadlessVulnerability { + const advisories: HeadlessAdvisory[] = vulnerabilities.map((vuln) => ({ + id: vuln.id, + title: vuln.title, + severity: vuln.severity, + url: vuln.url, + vulnerableVersions: vuln.vulnerable_versions, + fixedByRange: upgradeClears(pkg.rangeVersion, vuln.vulnerable_versions), + fixedByLatest: upgradeClears(pkg.latestVersion, vuln.vulnerable_versions), + })) + + return { + count: advisories.length, + highestSeverity, + fixedByRange: advisories.every((advisory) => advisory.fixedByRange), + fixedByLatest: advisories.every((advisory) => advisory.fixedByLatest), + advisories, + } +} + +/** + * True when upgrading to `target` escapes an advisory's affected range. Conservative: if either + * the target or the advisory range can't be parsed, we do NOT claim a fix — `semver.satisfies` + * treats an invalid range as "matches nothing", which would otherwise read as a false "fixed". + */ +export function upgradeClears(target: string, vulnerableVersions: string): boolean { + const comparable = toComparableVersion(target) + if (!comparable) return false + if (semver.validRange(vulnerableVersions) === null) return false + try { + return !semver.satisfies(comparable, vulnerableVersions, { includePrerelease: true }) + } catch { + return false + } +} diff --git a/src/types/domain.ts b/src/types/domain.ts index b4ec049..702a5ec 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -87,56 +87,6 @@ export interface UpgradeOptions extends VulnerabilityDisplayOptions { saveExact?: boolean // Write bare versions instead of preserving the range prefix (^/~) } -export interface HeadlessOptions { - json?: boolean // Emit a machine-readable JSON report on stdout - check?: boolean // Exit non-zero when updates exist (CI gate) -} - -/** Bump when the `--json` shape changes in a way consumers (scripts, agents) must adapt to. */ -export const HEADLESS_SCHEMA_VERSION = 1 - -export interface HeadlessAdvisory { - id: number - title: string - severity: VulnerabilitySeverity - url: string - vulnerableVersions: string // The advisory's affected semver range, verbatim from npm - fixedByRange: boolean // The in-range target (`range`) is no longer affected - fixedByLatest: boolean // The latest target (`latest`) is no longer affected -} - -export interface HeadlessVulnerability { - count: number - highestSeverity: VulnerabilitySeverity - fixedByRange: boolean // Every advisory is cleared by upgrading within the current range - fixedByLatest: boolean // Every advisory is cleared by upgrading to latest - advisories: HeadlessAdvisory[] -} - -export interface HeadlessReportEntry { - name: string - current: string // Raw specifier from package.json (with ^/~ prefix) - range: string // Latest version satisfying the current range - latest: string // Absolute latest version - type: DependencyType - packageJsonPath: string - hasMajorUpdate: boolean - deprecated?: string // npm deprecation message for the latest version, if any - enginesNode?: string // declared engines.node range for the latest version, if any - vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them -} - -export interface HeadlessReport { - schemaVersion: number // HEADLESS_SCHEMA_VERSION — lets agents pin to a known shape - summary: { - total: number // Packages scanned - outdated: number // Packages with an available update - major: number // Of the outdated, how many are a major bump - vulnerable: number // Of the outdated, how many have ≥1 known advisory on the current version - } - outdated: HeadlessReportEntry[] -} - export interface PackageJson { dependencies?: Record devDependencies?: Record diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index 6f57112..8ba113b 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ upgradeRunnerRun: vi.fn(), - upgradeRunnerRunHeadless: vi.fn(), + headlessRun: vi.fn(), loadProjectConfig: vi.fn(), checkForUpdateAsync: vi.fn(), getGitWorkingTreeState: vi.fn(), @@ -12,7 +12,12 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../src/index', () => ({ UpgradeRunner: class { run = mocks.upgradeRunnerRun - runHeadless = mocks.upgradeRunnerRunHeadless + }, +})) + +vi.mock('../../src/features/headless', () => ({ + HeadlessRunner: class { + run = mocks.headlessRun }, })) @@ -58,7 +63,7 @@ describe('CLI git dirty preflight', () => { mocks.getGitWorkingTreeState.mockReturnValue({ isRepo: false, isDirty: false }) mocks.promptForImmediateConfirmation.mockResolvedValue(true) mocks.upgradeRunnerRun.mockResolvedValue(undefined) - mocks.upgradeRunnerRunHeadless.mockResolvedValue(undefined) + mocks.headlessRun.mockResolvedValue(undefined) }) afterEach(() => { @@ -158,7 +163,7 @@ describe('CLI headless routing', () => { mocks.getGitWorkingTreeState.mockReturnValue({ isRepo: true, isDirty: true }) mocks.promptForImmediateConfirmation.mockResolvedValue(true) mocks.upgradeRunnerRun.mockResolvedValue(undefined) - mocks.upgradeRunnerRunHeadless.mockResolvedValue(undefined) + mocks.headlessRun.mockResolvedValue(undefined) }) afterEach(() => { @@ -172,7 +177,7 @@ describe('CLI headless routing', () => { await runCli({ dir: '/repo', exclude: '', ignore: '', maxDepth: '10' }) - expect(mocks.upgradeRunnerRunHeadless).toHaveBeenCalledWith({ + expect(mocks.headlessRun).toHaveBeenCalledWith({ json: undefined, check: undefined, }) @@ -185,14 +190,14 @@ describe('CLI headless routing', () => { await runCli({ dir: '/repo', exclude: '', ignore: '', maxDepth: '10' }) - expect(mocks.upgradeRunnerRunHeadless).toHaveBeenCalledTimes(1) + expect(mocks.headlessRun).toHaveBeenCalledTimes(1) expect(mocks.upgradeRunnerRun).not.toHaveBeenCalled() }) it('routes to runHeadless with json/check flags even in a TTY', async () => { await runCli({ dir: '/repo', exclude: '', ignore: '', maxDepth: '10', json: true, check: true }) - expect(mocks.upgradeRunnerRunHeadless).toHaveBeenCalledWith({ json: true, check: true }) + expect(mocks.headlessRun).toHaveBeenCalledWith({ json: true, check: true }) expect(mocks.upgradeRunnerRun).not.toHaveBeenCalled() }) }) diff --git a/test/unit/core/headless-runner.test.ts b/test/unit/core/headless-runner.test.ts deleted file mode 100644 index 97a7810..0000000 --- a/test/unit/core/headless-runner.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - getOutdatedPackages: vi.fn(), - getOutdatedPackagesOnly: vi.fn(), - hasPackageJson: vi.fn(), - detectPackageManager: vi.fn(), - fetchVulnerabilities: vi.fn(), -})) - -vi.mock('../../../src/core/package-detector', () => ({ - PackageDetector: class { - getOutdatedPackages = mocks.getOutdatedPackages - getOutdatedPackagesOnly = mocks.getOutdatedPackagesOnly - hasPackageJson = mocks.hasPackageJson - }, -})) - -vi.mock('../../../src/services', () => ({ - fetchVulnerabilities: mocks.fetchVulnerabilities, -})) - -vi.mock('../../../src/interactive-ui', () => ({ - InteractiveUI: class {}, -})) - -vi.mock('../../../src/core/upgrader', () => ({ - PackageUpgrader: class {}, -})) - -vi.mock('../../../src/services/package-manager-detector', () => ({ - PackageManagerDetector: { - detect: mocks.detectPackageManager, - getInfo: mocks.detectPackageManager, - }, -})) - -import { UpgradeRunner } from '../../../src/core/upgrade-runner' - -const OUTDATED = { - name: 'axios', - currentVersion: '^0.27.0', - rangeVersion: '0.27.2', - latestVersion: '1.16.1', - type: 'dependencies', - packageJsonPath: '/repo/package.json', - isOutdated: true, - hasRangeUpdate: true, - hasMajorUpdate: true, - deprecated: 'use the platform fetch instead', - enginesNode: '>=14', -} - -const UP_TO_DATE = { - name: 'left-pad', - currentVersion: '^1.3.0', - rangeVersion: '1.3.0', - latestVersion: '1.3.0', - type: 'dependencies', - packageJsonPath: '/repo/package.json', - isOutdated: false, - hasRangeUpdate: false, - hasMajorUpdate: false, -} - -describe('UpgradeRunner.runHeadless', () => { - const originalExitCode = process.exitCode - - beforeEach(() => { - vi.clearAllMocks() - mocks.hasPackageJson.mockReturnValue(true) - mocks.detectPackageManager.mockReturnValue({ - name: 'npm', - displayName: 'npm', - lockFile: 'package-lock.json', - workspaceFile: null, - installCommand: 'npm install', - color: null, - }) - // Real-ish filter so the report's "total vs outdated" split is exercised. - mocks.getOutdatedPackagesOnly.mockImplementation((pkgs: any[]) => - pkgs.filter((p) => p.isOutdated) - ) - mocks.getOutdatedPackages.mockResolvedValue([OUTDATED, UP_TO_DATE]) - // No advisories by default; one test overrides this. - mocks.fetchVulnerabilities.mockResolvedValue(new Map()) - }) - - afterEach(() => { - process.exitCode = originalExitCode - }) - - it('--json prints a single, valid JSON document with the documented shape', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ json: true }) - - // stdout carries the JSON and nothing else. - expect(logSpy).toHaveBeenCalledTimes(1) - const report = JSON.parse(logSpy.mock.calls[0][0] as string) - - expect(report.schemaVersion).toBe(1) - expect(report.summary).toEqual({ total: 2, outdated: 1, major: 1, vulnerable: 0 }) - expect(report.outdated).toHaveLength(1) - expect(report.outdated[0]).toMatchObject({ - name: 'axios', - current: '^0.27.0', - range: '0.27.2', - latest: '1.16.1', - type: 'dependencies', - packageJsonPath: '/repo/package.json', - hasMajorUpdate: true, - deprecated: 'use the platform fetch instead', - enginesNode: '>=14', - }) - // Optional fields are omitted when absent, not emitted as null/undefined. - expect('vulnerability' in report.outdated[0]).toBe(false) - - logSpy.mockRestore() - }) - - it('--json cross-references advisories against the upgrade targets (fixed-by verdict)', async () => { - // axios fixture: current ^0.27.0, range 0.27.2, latest 1.16.1. - // Advisory A (<1.0.0): the latest (1.16.1) escapes it, the in-range bump (0.27.2) does not. - // Advisory B (>=0.0.1): affects everything, so neither target fixes it. - mocks.fetchVulnerabilities.mockResolvedValue( - new Map([ - [ - 'axios', - { - packageName: 'axios', - highestSeverity: 'high', - vulnerabilities: [ - { - id: 1, - title: 'SSRF', - severity: 'high', - url: 'https://github.com/advisories/GHSA-a', - vulnerable_versions: '<1.0.0', - }, - { - id: 2, - title: 'Prototype pollution', - severity: 'moderate', - url: 'https://github.com/advisories/GHSA-b', - vulnerable_versions: '>=0.0.1', - }, - ], - }, - ], - ]) - ) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ json: true }) - - // The audit checks the currently-installed specifier. - expect(mocks.fetchVulnerabilities).toHaveBeenCalledWith(new Map([['axios', '^0.27.0']])) - - const report = JSON.parse(logSpy.mock.calls[0][0] as string) - expect(report.summary.vulnerable).toBe(1) - expect(report.outdated[0].vulnerability).toEqual({ - count: 2, - highestSeverity: 'high', - // Aggregates use AND across advisories: B blocks both, so neither target clears everything. - fixedByRange: false, - fixedByLatest: false, - advisories: [ - { - id: 1, - title: 'SSRF', - severity: 'high', - url: 'https://github.com/advisories/GHSA-a', - vulnerableVersions: '<1.0.0', - fixedByRange: false, - fixedByLatest: true, - }, - { - id: 2, - title: 'Prototype pollution', - severity: 'moderate', - url: 'https://github.com/advisories/GHSA-b', - vulnerableVersions: '>=0.0.1', - fixedByRange: false, - fixedByLatest: false, - }, - ], - }) - - logSpy.mockRestore() - }) - - it('--check sets a non-zero exit code when updates exist', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - process.exitCode = 0 - - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ check: true }) - - expect(process.exitCode).toBe(1) - logSpy.mockRestore() - }) - - it('--check leaves the exit code at 0 when everything is up to date', async () => { - mocks.getOutdatedPackages.mockResolvedValue([UP_TO_DATE]) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - process.exitCode = 0 - - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ check: true }) - - expect(process.exitCode).toBe(0) - logSpy.mockRestore() - }) - - it('with no flags prints a plain line-based report and recap, no exit code', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - process.exitCode = 0 - - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({}) - - const lines = logSpy.mock.calls.map((c) => String(c[0])) - expect(lines.some((l) => l.includes('axios') && l.includes('→'))).toBe(true) - expect(lines.some((l) => /outdated across 1 file/.test(l))).toBe(true) - expect(process.exitCode).toBe(0) - logSpy.mockRestore() - }) - - it('exits 2 on error (no package.json)', async () => { - mocks.hasPackageJson.mockReturnValue(false) - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await new UpgradeRunner({ cwd: '/no-pkg' }).runHeadless({ json: true }) - - expect(exitSpy).toHaveBeenCalledWith(2) - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json')) - exitSpy.mockRestore() - errorSpy.mockRestore() - }) -}) diff --git a/test/unit/features/headless/headless-runner.test.ts b/test/unit/features/headless/headless-runner.test.ts new file mode 100644 index 0000000..e94e107 --- /dev/null +++ b/test/unit/features/headless/headless-runner.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getOutdatedPackages: vi.fn(), + getOutdatedPackagesOnly: vi.fn(), + hasPackageJson: vi.fn(), + fetchVulnerabilities: vi.fn(), +})) + +vi.mock('../../../../src/core/package-detector', () => ({ + PackageDetector: class { + getOutdatedPackages = mocks.getOutdatedPackages + getOutdatedPackagesOnly = mocks.getOutdatedPackagesOnly + hasPackageJson = mocks.hasPackageJson + }, +})) + +vi.mock('../../../../src/services', () => ({ + fetchVulnerabilities: mocks.fetchVulnerabilities, +})) + +import { HeadlessRunner } from '../../../../src/features/headless' + +const OUTDATED = { + name: 'axios', + currentVersion: '^0.27.0', + rangeVersion: '0.27.2', + latestVersion: '1.16.1', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + isOutdated: true, + hasRangeUpdate: true, + hasMajorUpdate: true, +} + +const UP_TO_DATE = { + name: 'left-pad', + currentVersion: '^1.3.0', + rangeVersion: '1.3.0', + latestVersion: '1.3.0', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, +} + +describe('HeadlessRunner.run', () => { + const originalExitCode = process.exitCode + + beforeEach(() => { + vi.clearAllMocks() + mocks.hasPackageJson.mockReturnValue(true) + mocks.getOutdatedPackagesOnly.mockImplementation((pkgs: any[]) => + pkgs.filter((p) => p.isOutdated) + ) + mocks.getOutdatedPackages.mockResolvedValue([OUTDATED, UP_TO_DATE]) + mocks.fetchVulnerabilities.mockResolvedValue(new Map()) + }) + + afterEach(() => { + process.exitCode = originalExitCode + }) + + it('--json prints one valid JSON document with schemaVersion and summary', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await new HeadlessRunner({ cwd: '/repo' }).run({ json: true }) + + expect(logSpy).toHaveBeenCalledTimes(1) + const report = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(report.schemaVersion).toBe(1) + expect(report.summary).toEqual({ total: 2, outdated: 1, major: 1, vulnerable: 0 }) + expect(report.outdated).toHaveLength(1) + expect(report.outdated[0].name).toBe('axios') + expect('vulnerability' in report.outdated[0]).toBe(false) + + logSpy.mockRestore() + }) + + it('--json cross-references advisories against the upgrade targets', async () => { + // Advisory A (<1.0.0): latest (1.16.1) escapes it, the in-range bump (0.27.2) does not. + mocks.fetchVulnerabilities.mockResolvedValue( + new Map([ + [ + 'axios', + { + packageName: 'axios', + highestSeverity: 'high', + vulnerabilities: [ + { + id: 1, + title: 'SSRF', + severity: 'high', + url: 'https://github.com/advisories/GHSA-a', + vulnerable_versions: '<1.0.0', + }, + ], + }, + ], + ]) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await new HeadlessRunner({ cwd: '/repo' }).run({ json: true }) + + // The audit checks the currently-installed specifier. + expect(mocks.fetchVulnerabilities).toHaveBeenCalledWith(new Map([['axios', '^0.27.0']])) + + const report = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(report.summary.vulnerable).toBe(1) + expect(report.outdated[0].vulnerability).toMatchObject({ + count: 1, + highestSeverity: 'high', + fixedByRange: false, + fixedByLatest: true, + advisories: [{ id: 1, vulnerableVersions: '<1.0.0', fixedByRange: false, fixedByLatest: true }], + }) + + logSpy.mockRestore() + }) + + it('--check sets exit code 1 when updates exist, 0 when up to date', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + process.exitCode = 0 + await new HeadlessRunner({ cwd: '/repo' }).run({ check: true }) + expect(process.exitCode).toBe(1) + + mocks.getOutdatedPackages.mockResolvedValue([UP_TO_DATE]) + process.exitCode = 0 + await new HeadlessRunner({ cwd: '/repo' }).run({ check: true }) + expect(process.exitCode).toBe(0) + + logSpy.mockRestore() + }) + + it('with no flags prints a plain report and leaves the exit code untouched', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + process.exitCode = 0 + + await new HeadlessRunner({ cwd: '/repo' }).run({}) + + const output = String(logSpy.mock.calls[0][0]) + expect(output).toContain('axios') + expect(output).toMatch(/outdated across 1 file/) + expect(process.exitCode).toBe(0) + logSpy.mockRestore() + }) + + it('exits 2 on error (no package.json)', async () => { + mocks.hasPackageJson.mockReturnValue(false) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await new HeadlessRunner({ cwd: '/no-pkg' }).run({ json: true }) + + expect(exitSpy).toHaveBeenCalledWith(2) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json')) + exitSpy.mockRestore() + errorSpy.mockRestore() + }) +}) diff --git a/test/unit/features/headless/vulnerability-audit.test.ts b/test/unit/features/headless/vulnerability-audit.test.ts new file mode 100644 index 0000000..8a82479 --- /dev/null +++ b/test/unit/features/headless/vulnerability-audit.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ fetchVulnerabilities: vi.fn() })) + +vi.mock('../../../../src/services', () => ({ + fetchVulnerabilities: mocks.fetchVulnerabilities, +})) + +import { auditVulnerabilities, upgradeClears } from '../../../../src/features/headless' + +describe('upgradeClears', () => { + it('reports a fix when the target escapes the affected range', () => { + expect(upgradeClears('1.16.1', '<1.0.0')).toBe(true) + expect(upgradeClears('2.0.0', '>=0.1.0 <1.5.0')).toBe(true) + }) + + it('reports no fix when the target is still in the affected range', () => { + expect(upgradeClears('0.27.2', '<1.0.0')).toBe(false) + expect(upgradeClears('1.0.0', '>=0.0.1')).toBe(false) + }) + + it('is conservative: an unparseable target or range is NOT a fix', () => { + expect(upgradeClears('not-a-version', '<1.0.0')).toBe(false) + expect(upgradeClears('1.0.0', 'definitely-not-a-range')).toBe(false) + }) + + it('coerces loose target versions (prefixes) before comparing', () => { + expect(upgradeClears('^1.16.1', '<1.0.0')).toBe(true) + }) +}) + +describe('auditVulnerabilities', () => { + const pkg = { + name: 'axios', + currentVersion: '^0.27.0', + rangeVersion: '0.27.2', + latestVersion: '1.16.1', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + isOutdated: true, + hasRangeUpdate: true, + hasMajorUpdate: true, + } as any + + beforeEach(() => vi.clearAllMocks()) + + it('returns an empty map and skips the network when there is nothing outdated', async () => { + const result = await auditVulnerabilities([]) + expect(result.size).toBe(0) + expect(mocks.fetchVulnerabilities).not.toHaveBeenCalled() + }) + + it('aggregates per-advisory fix verdicts with AND across advisories', async () => { + mocks.fetchVulnerabilities.mockResolvedValue( + new Map([ + [ + 'axios', + { + packageName: 'axios', + highestSeverity: 'high', + vulnerabilities: [ + { id: 1, title: 'A', severity: 'high', url: 'u1', vulnerable_versions: '<1.0.0' }, + { id: 2, title: 'B', severity: 'moderate', url: 'u2', vulnerable_versions: '>=0.0.1' }, + ], + }, + ], + ]) + ) + + const result = await auditVulnerabilities([pkg]) + const summary = result.get(pkg)! + + expect(summary.count).toBe(2) + expect(summary.highestSeverity).toBe('high') + // Advisory B (>=0.0.1) affects every target, so neither aggregate can be a full fix. + expect(summary.fixedByRange).toBe(false) + expect(summary.fixedByLatest).toBe(false) + expect(summary.advisories[0]).toMatchObject({ id: 1, fixedByLatest: true, fixedByRange: false }) + expect(summary.advisories[1]).toMatchObject({ id: 2, fixedByLatest: false, fixedByRange: false }) + }) + + it('omits packages with no advisories', async () => { + mocks.fetchVulnerabilities.mockResolvedValue(new Map()) + const result = await auditVulnerabilities([pkg]) + expect(result.size).toBe(0) + }) +})