diff --git a/lib/index.js b/lib/index.js index d380df4..e43fc27 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const { c } = require('./utils/colors'); -const { parseVersion, compareVersions, isUnparseableVersionSpec, hasRangeSpecifier } = require('./utils/version'); +const { parseVersion, compareVersions, isUnparseableVersionSpec, hasRangeSpecifier, applyVersionPrefix } = require('./utils/version'); const { findAllPackageJsons, findProjectRoot, findMonorepoRoot } = require('./utils/filesystem'); const { detectPackageManager, getInstalledVersion, runInstall } = require('./utils/package-manager'); const { vulnerabilities, getAllPackages } = require('./vulnerabilities'); @@ -76,6 +76,7 @@ function analyzePackageJson(pkgPath) { vulnerablePackages.push({ package: packageName, current: displayVersion, + originalSpecifier: allDeps[packageName], // Preserve original for prefix extraction cves: affectedCves, inDeps: !!pkg.dependencies?.[packageName], inDevDeps: !!pkg.devDependencies?.[packageName], @@ -119,6 +120,7 @@ function computeMinimalFixes(analysisResults) { fileFixes.push({ package: vuln.package, current: vuln.current, + originalSpecifier: vuln.originalSpecifier, patched: highestVersion, cves: vuln.cves.map(c => c.id), note: notes.length > 0 ? notes[0] : null, // Use first note @@ -150,8 +152,9 @@ function applyFixes(pkgPath, fixes) { for (const fix of fixes) { if (!fix.patched) continue; - // Pin exact version - const newVersion = fix.patched; + // Preserve the original version prefix (^, ~, etc.) + const originalSpecifier = fix.originalSpecifier || fix.current; + const newVersion = applyVersionPrefix(originalSpecifier, fix.patched); if (fix.inDeps && pkg.dependencies?.[fix.package]) { pkg.dependencies[fix.package] = newVersion; @@ -248,7 +251,9 @@ async function run() { for (const fix of file.fixes) { const cveList = formatCves(fix.cves); - console.log(c('dim', ` ${fix.package}: `) + c('red', fix.current) + c('dim', ' -> ') + c('green', fix.patched || '?') + c('magenta', cveList)); + const originalSpecifier = fix.originalSpecifier || fix.current; + const patchedWithPrefix = fix.patched ? applyVersionPrefix(originalSpecifier, fix.patched) : '?'; + console.log(c('dim', ` ${fix.package}: `) + c('red', fix.current) + c('dim', ' -> ') + c('green', patchedWithPrefix) + c('magenta', cveList)); if (fix.note) { console.log(c('dim', ` ${fix.note}`)); } diff --git a/lib/utils/version.js b/lib/utils/version.js index aae95fe..a7c7dd0 100644 --- a/lib/utils/version.js +++ b/lib/utils/version.js @@ -93,10 +93,73 @@ function cleanVersion(version) { return version.replace(/^[\^~>=<]+/, '').trim(); } +/** + * Check if a version specifier uses a complex range that we can't safely transform + * Returns an object with { unsupported: boolean, reason: string } + */ +function hasUnsupportedRange(version) { + if (!version) return { unsupported: false }; + + // Hyphen ranges: "15.0.0 - 16.0.0" + if (/\d\s+-\s+\d/.test(version)) { + return { unsupported: true, reason: 'hyphen-range' }; + } + + // OR ranges: "^15.0.0 || ^16.0.0" + if (version.includes('||')) { + return { unsupported: true, reason: 'or-range' }; + } + + // X-ranges: "15.x", "15.3.x", "15.*" + if (/\d+\.x|\.x\.|x$|\.\*|\*$/i.test(version)) { + return { unsupported: true, reason: 'x-range' }; + } + + // Less-than ranges don't make sense to preserve: <16.0.0 -> <15.3.7 is wrong + if (/^<[^=]/.test(version) || /^<=/.test(version)) { + return { unsupported: true, reason: 'less-than-range' }; + } + + return { unsupported: false }; +} + +/** + * Extract the prefix from a version specifier (^, ~, >=, etc.) + * Returns empty string for exact versions + * Only handles simple prefixes - use hasUnsupportedRange() first to check for complex ranges + */ +function getVersionPrefix(version) { + if (!version) return ''; + // Only extract ^, ~, >, >= (not < or <= which don't make sense to preserve) + const match = version.match(/^([\^~]|>=?)/); + return match ? match[1] : ''; +} + +/** + * Apply a prefix to a version, preserving range semantics + * If original had ^, new version gets ^ + * If original had ~, new version gets ~ + * If original was exact, new version is exact + * + * For unsupported ranges, returns the exact new version (no prefix) + */ +function applyVersionPrefix(originalVersion, newVersion) { + const unsupported = hasUnsupportedRange(originalVersion); + if (unsupported.unsupported) { + // Can't safely preserve range, return exact version + return newVersion; + } + const prefix = getVersionPrefix(originalVersion); + return prefix + newVersion; +} + module.exports = { parseVersion, compareVersions, isUnparseableVersionSpec, hasRangeSpecifier, + hasUnsupportedRange, cleanVersion, + getVersionPrefix, + applyVersionPrefix, }; diff --git a/test/apply-fixes.test.js b/test/apply-fixes.test.js new file mode 100644 index 0000000..3d75225 --- /dev/null +++ b/test/apply-fixes.test.js @@ -0,0 +1,144 @@ +/** + * End-to-end tests for applying fixes to package.json + * + * Verifies that version specifier prefixes (^, ~, etc.) are preserved + * when fixes are applied. + */ + +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Import the functions we're testing +const { analyzePackageJson, computeMinimalFixes } = require('../lib/index'); + +// We need to access applyFixes which isn't exported, so we'll test via the full flow +// by creating temp package.json files and running analysis + fix + +describe('applyFixes prefix preservation', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fix-react2shell-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPackageJson(deps) { + const pkgPath = path.join(tempDir, 'package.json'); + fs.writeFileSync(pkgPath, JSON.stringify({ + name: 'test-app', + dependencies: deps, + }, null, 2)); + return pkgPath; + } + + function createNodeModulesVersion(packageName, version) { + const pkgDir = path.join(tempDir, 'node_modules', packageName); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: packageName, + version: version, + })); + } + + describe('originalSpecifier is captured during analysis', () => { + it('should capture exact version specifier', () => { + const pkgPath = createPackageJson({ next: '15.3.0' }); + createNodeModulesVersion('next', '15.3.0'); + + const analysis = analyzePackageJson(pkgPath); + + assert.strictEqual(analysis.vulnerabilities.length, 1); + assert.strictEqual(analysis.vulnerabilities[0].originalSpecifier, '15.3.0'); + }); + + it('should capture ^ prefix specifier', () => { + const pkgPath = createPackageJson({ next: '^15.3.0' }); + createNodeModulesVersion('next', '15.3.4'); + + const analysis = analyzePackageJson(pkgPath); + + assert.strictEqual(analysis.vulnerabilities.length, 1); + assert.strictEqual(analysis.vulnerabilities[0].originalSpecifier, '^15.3.0'); + }); + + it('should capture ~ prefix specifier', () => { + const pkgPath = createPackageJson({ next: '~15.3.0' }); + createNodeModulesVersion('next', '15.3.4'); + + const analysis = analyzePackageJson(pkgPath); + + assert.strictEqual(analysis.vulnerabilities.length, 1); + assert.strictEqual(analysis.vulnerabilities[0].originalSpecifier, '~15.3.0'); + }); + + it('should capture >= prefix specifier', () => { + const pkgPath = createPackageJson({ next: '>=15.3.0' }); + createNodeModulesVersion('next', '15.3.4'); + + const analysis = analyzePackageJson(pkgPath); + + assert.strictEqual(analysis.vulnerabilities.length, 1); + assert.strictEqual(analysis.vulnerabilities[0].originalSpecifier, '>=15.3.0'); + }); + }); + + describe('originalSpecifier flows through computeMinimalFixes', () => { + it('should preserve originalSpecifier in computed fixes', () => { + const pkgPath = createPackageJson({ next: '^15.3.0' }); + createNodeModulesVersion('next', '15.3.4'); + + const analysis = analyzePackageJson(pkgPath); + const fixes = computeMinimalFixes([analysis]); + + assert.strictEqual(fixes.length, 1); + assert.strictEqual(fixes[0].fixes[0].originalSpecifier, '^15.3.0'); + assert.strictEqual(fixes[0].fixes[0].patched, '15.3.7'); + }); + + it('should work with multiple packages', () => { + const pkgPath = createPackageJson({ + next: '^15.3.0', + 'react-server-dom-webpack': '~19.0.0', + }); + createNodeModulesVersion('next', '15.3.4'); + createNodeModulesVersion('react-server-dom-webpack', '19.0.0'); + + const analysis = analyzePackageJson(pkgPath); + const fixes = computeMinimalFixes([analysis]); + + assert.strictEqual(fixes[0].fixes.length, 2); + + const nextFix = fixes[0].fixes.find(f => f.package === 'next'); + const rscFix = fixes[0].fixes.find(f => f.package === 'react-server-dom-webpack'); + + assert.strictEqual(nextFix.originalSpecifier, '^15.3.0'); + assert.strictEqual(rscFix.originalSpecifier, '~19.0.0'); + }); + }); + + describe('non-vulnerable versions are not flagged', () => { + it('should not flag patched versions', () => { + const pkgPath = createPackageJson({ next: '^15.3.7' }); + createNodeModulesVersion('next', '15.3.7'); + + const analysis = analyzePackageJson(pkgPath); + + assert.strictEqual(analysis.vulnerabilities.length, 0); + }); + + it('should not flag versions above patch', () => { + const pkgPath = createPackageJson({ next: '^15.3.10' }); + createNodeModulesVersion('next', '15.3.10'); + + const analysis = analyzePackageJson(pkgPath); + + assert.strictEqual(analysis.vulnerabilities.length, 0); + }); + }); +}); diff --git a/test/version.test.js b/test/version.test.js new file mode 100644 index 0000000..b8431a3 --- /dev/null +++ b/test/version.test.js @@ -0,0 +1,194 @@ +/** + * Tests for version utility functions + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { + getVersionPrefix, + applyVersionPrefix, + hasUnsupportedRange, + cleanVersion, +} = require('../lib/utils/version'); + +describe('getVersionPrefix', () => { + it('should extract ^ prefix', () => { + assert.strictEqual(getVersionPrefix('^15.3.0'), '^'); + }); + + it('should extract ~ prefix', () => { + assert.strictEqual(getVersionPrefix('~15.3.0'), '~'); + }); + + it('should extract >= prefix', () => { + assert.strictEqual(getVersionPrefix('>=15.3.0'), '>='); + }); + + it('should extract > prefix', () => { + assert.strictEqual(getVersionPrefix('>15.3.0'), '>'); + }); + + it('should NOT extract < prefix (unsupported)', () => { + assert.strictEqual(getVersionPrefix('<16.0.0'), ''); + }); + + it('should NOT extract <= prefix (unsupported)', () => { + assert.strictEqual(getVersionPrefix('<=16.0.0'), ''); + }); + + it('should return empty string for exact versions', () => { + assert.strictEqual(getVersionPrefix('15.3.0'), ''); + }); + + it('should return empty string for null', () => { + assert.strictEqual(getVersionPrefix(null), ''); + }); + + it('should return empty string for undefined', () => { + assert.strictEqual(getVersionPrefix(undefined), ''); + }); + + it('should handle canary versions with prefix', () => { + assert.strictEqual(getVersionPrefix('^15.6.0-canary.58'), '^'); + }); +}); + +describe('hasUnsupportedRange', () => { + describe('supported ranges', () => { + it('should accept exact versions', () => { + assert.strictEqual(hasUnsupportedRange('15.3.0').unsupported, false); + }); + + it('should accept ^ prefix', () => { + assert.strictEqual(hasUnsupportedRange('^15.3.0').unsupported, false); + }); + + it('should accept ~ prefix', () => { + assert.strictEqual(hasUnsupportedRange('~15.3.0').unsupported, false); + }); + + it('should accept >= prefix', () => { + assert.strictEqual(hasUnsupportedRange('>=15.3.0').unsupported, false); + }); + + it('should accept > prefix', () => { + assert.strictEqual(hasUnsupportedRange('>15.3.0').unsupported, false); + }); + }); + + describe('unsupported ranges', () => { + it('should reject < prefix', () => { + const result = hasUnsupportedRange('<16.0.0'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'less-than-range'); + }); + + it('should reject <= prefix', () => { + const result = hasUnsupportedRange('<=16.0.0'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'less-than-range'); + }); + + it('should reject hyphen ranges', () => { + const result = hasUnsupportedRange('15.0.0 - 16.0.0'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'hyphen-range'); + }); + + it('should reject OR ranges', () => { + const result = hasUnsupportedRange('^15.0.0 || ^16.0.0'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'or-range'); + }); + + it('should reject x-ranges (15.x)', () => { + const result = hasUnsupportedRange('15.x'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'x-range'); + }); + + it('should reject x-ranges (15.3.x)', () => { + const result = hasUnsupportedRange('15.3.x'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'x-range'); + }); + + it('should reject star ranges (15.*)', () => { + const result = hasUnsupportedRange('15.*'); + assert.strictEqual(result.unsupported, true); + assert.strictEqual(result.reason, 'x-range'); + }); + }); +}); + +describe('applyVersionPrefix', () => { + describe('supported prefixes', () => { + it('should preserve ^ prefix', () => { + assert.strictEqual(applyVersionPrefix('^15.3.0', '15.3.7'), '^15.3.7'); + }); + + it('should preserve ~ prefix', () => { + assert.strictEqual(applyVersionPrefix('~15.3.0', '15.3.7'), '~15.3.7'); + }); + + it('should preserve >= prefix', () => { + assert.strictEqual(applyVersionPrefix('>=15.3.0', '15.3.7'), '>=15.3.7'); + }); + + it('should preserve > prefix', () => { + assert.strictEqual(applyVersionPrefix('>15.3.0', '15.3.7'), '>15.3.7'); + }); + + it('should keep exact version when no prefix', () => { + assert.strictEqual(applyVersionPrefix('15.3.0', '15.3.7'), '15.3.7'); + }); + + it('should handle canary versions', () => { + assert.strictEqual(applyVersionPrefix('^15.6.0-canary.50', '15.6.0-canary.59'), '^15.6.0-canary.59'); + }); + + it('should handle upgrading from stable to canary with prefix', () => { + assert.strictEqual(applyVersionPrefix('^15.5.0', '15.6.0-canary.59'), '^15.6.0-canary.59'); + }); + }); + + describe('unsupported ranges fall back to exact version', () => { + it('should pin exact for < prefix', () => { + assert.strictEqual(applyVersionPrefix('<16.0.0', '15.3.7'), '15.3.7'); + }); + + it('should pin exact for <= prefix', () => { + assert.strictEqual(applyVersionPrefix('<=16.0.0', '15.3.7'), '15.3.7'); + }); + + it('should pin exact for hyphen ranges', () => { + assert.strictEqual(applyVersionPrefix('15.0.0 - 16.0.0', '15.3.7'), '15.3.7'); + }); + + it('should pin exact for OR ranges', () => { + assert.strictEqual(applyVersionPrefix('^15.0.0 || ^16.0.0', '15.3.7'), '15.3.7'); + }); + + it('should pin exact for x-ranges', () => { + assert.strictEqual(applyVersionPrefix('15.x', '15.3.7'), '15.3.7'); + }); + }); +}); + +describe('cleanVersion', () => { + it('should remove ^ prefix', () => { + assert.strictEqual(cleanVersion('^15.3.0'), '15.3.0'); + }); + + it('should remove ~ prefix', () => { + assert.strictEqual(cleanVersion('~15.3.0'), '15.3.0'); + }); + + it('should remove >= prefix', () => { + assert.strictEqual(cleanVersion('>=15.3.0'), '15.3.0'); + }); + + it('should keep exact versions unchanged', () => { + assert.strictEqual(cleanVersion('15.3.0'), '15.3.0'); + }); +});