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/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 be6b0e7..bd0f064 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -3,9 +3,6 @@ import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' import { - HeadlessOptions, - HeadlessReport, - HeadlessReportEntry, PackageInfo, PackageLoadProgress, PackageSelectionState, @@ -14,8 +11,6 @@ import { PackageManagerInfo, } from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' -import { fetchVulnerabilities } from '../services' -import { createVulnerabilitySummary } from '../ui/presenters/vulnerability' import { ConsoleUtils } from '../ui/utils' import { getPerformanceTracker } from '../features/debug' @@ -189,124 +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) - - // 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) - - if (options.json) { - // stdout is reserved for the JSON document only. - console.log(JSON.stringify(this.buildHeadlessReport(packages, outdated), null, 2)) - } else { - this.printPlainReport(outdated) - } - - // 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[]): HeadlessReport { - return { - summary: { - total: all.length, - outdated: outdated.length, - major: outdated.filter((pkg) => pkg.hasMajorUpdate).length, - vulnerable: outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length, - }, - 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 - if (pkg.vulnerability) entry.vulnerability = pkg.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. - * Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a - * failed audit never blocks the report. - */ - private async enrichWithVulnerabilities(outdated: PackageInfo[]): Promise { - if (outdated.length === 0) return - - // 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 - - 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 - ) - } - } - - private printPlainReport(outdated: PackageInfo[]): 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 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}` - ) - } - - 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` : '' - console.log(`\n${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`) - } - 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 ad6903d..702a5ec 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 }> } @@ -85,34 +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) -} - -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?: VulnerabilitySummary -} - -export interface HeadlessReport { - 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/features/headless/headless-runner.test.ts similarity index 55% rename from test/unit/core/headless-runner.test.ts rename to test/unit/features/headless/headless-runner.test.ts index 3a7d96e..e94e107 100644 --- a/test/unit/core/headless-runner.test.ts +++ b/test/unit/features/headless/headless-runner.test.ts @@ -4,11 +4,10 @@ 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', () => ({ +vi.mock('../../../../src/core/package-detector', () => ({ PackageDetector: class { getOutdatedPackages = mocks.getOutdatedPackages getOutdatedPackagesOnly = mocks.getOutdatedPackagesOnly @@ -16,26 +15,11 @@ vi.mock('../../../src/core/package-detector', () => ({ }, })) -vi.mock('../../../src/services', () => ({ +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' +import { HeadlessRunner } from '../../../../src/features/headless' const OUTDATED = { name: 'axios', @@ -47,8 +31,6 @@ const OUTDATED = { isOutdated: true, hasRangeUpdate: true, hasMajorUpdate: true, - deprecated: 'use the platform fetch instead', - enginesNode: '>=14', } const UP_TO_DATE = { @@ -63,26 +45,16 @@ const UP_TO_DATE = { hasMajorUpdate: false, } -describe('UpgradeRunner.runHeadless', () => { +describe('HeadlessRunner.run', () => { 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()) }) @@ -90,35 +62,24 @@ describe('UpgradeRunner.runHeadless', () => { process.exitCode = originalExitCode }) - it('--json prints a single, valid JSON document with the documented shape', async () => { + it('--json prints one valid JSON document with schemaVersion and summary', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ json: true }) + await new HeadlessRunner({ cwd: '/repo' }).run({ 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(report.outdated[0].name).toBe('axios') expect('vulnerability' in report.outdated[0]).toBe(false) 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', 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([ [ @@ -128,10 +89,10 @@ 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', }, ], @@ -141,7 +102,7 @@ describe('UpgradeRunner.runHeadless', () => { ) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await new UpgradeRunner({ cwd: '/repo' }).runHeadless({ json: true }) + 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']])) @@ -151,43 +112,38 @@ describe('UpgradeRunner.runHeadless', () => { expect(report.outdated[0].vulnerability).toMatchObject({ count: 1, highestSeverity: 'high', - detailsUrl: 'https://github.com/advisories/GHSA-test', - advisories: [{ id: 1234, severity: 'high' }], + fixedByRange: false, + fixedByLatest: true, + advisories: [{ id: 1, vulnerableVersions: '<1.0.0', fixedByRange: false, fixedByLatest: true }], }) logSpy.mockRestore() }) - it('--check sets a non-zero exit code when updates exist', async () => { + 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 UpgradeRunner({ cwd: '/repo' }).runHeadless({ check: true }) + process.exitCode = 0 + await new HeadlessRunner({ cwd: '/repo' }).run({ 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 }) - + await new HeadlessRunner({ cwd: '/repo' }).run({ 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 () => { + 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 UpgradeRunner({ cwd: '/repo' }).runHeadless({}) + await new HeadlessRunner({ cwd: '/repo' }).run({}) - 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) + 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() }) @@ -197,7 +153,7 @@ describe('UpgradeRunner.runHeadless', () => { 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 }) + await new HeadlessRunner({ cwd: '/no-pkg' }).run({ json: true }) expect(exitSpy).toHaveBeenCalledWith(2) expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json')) 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) + }) +})