// Copyright 2025 Chris Bunting
// Brief: Handles version constraint resolution and management
// Scope: Resolves version conflicts, checks for updates, and manages version constraints
import * as semver from 'semver';
import { Logger } from '../utils/Logger.js';
export interface VersionRange {
min: string;
max: string;
includeMin: boolean;
includeMax: boolean;
}
export interface VersionConflict {
package: string;
versions: string[];
constraints: string[];
resolution?: string;
}
export interface UpdateInfo {
package: string;
current: string;
latest: string;
wanted: string;
semverDiff: string;
breakingChanges?: BreakingChange[];
}
export interface BreakingChange {
type: 'major' | 'minor' | 'patch';
description: string;
affectedVersion: string;
}
export class VersionResolver {
private logger: Logger;
private cache: Map<string, any>;
constructor(logger?: Logger) {
this.logger = logger || new Logger();
this.cache = new Map();
}
async resolveVersionConstraint(
packageName: string,
constraint: string,
availableVersions: string[]
): Promise<string | null> {
this.logger.debug(`Resolving version constraint for ${packageName}@${constraint}`);
const cacheKey = `${packageName}:${constraint}:${availableVersions.join(',')}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
try {
// Filter versions that satisfy the constraint
const satisfyingVersions = availableVersions.filter(version =>
semver.satisfies(version, constraint)
);
if (satisfyingVersions.length === 0) {
this.logger.warn(`No versions found for ${packageName} satisfying constraint ${constraint}`);
return null;
}
// Sort versions and get the latest one
satisfyingVersions.sort((a, b) => semver.compare(b, a));
const resolvedVersion = satisfyingVersions[0];
this.cache.set(cacheKey, resolvedVersion);
this.logger.debug(`Resolved ${packageName}@${constraint} to ${resolvedVersion}`);
return resolvedVersion;
} catch (error) {
this.logger.error(`Error resolving version constraint for ${packageName}:`, error);
return null;
}
}
async findVersionConflicts(
dependencies: Array<{ name: string; version: string; constraint?: string }>
): Promise<VersionConflict[]> {
this.logger.debug('Finding version conflicts in dependencies');
const packageMap = new Map<string, Array<{ version: string; constraint?: string }>>();
// Group by package name
for (const dep of dependencies) {
if (!packageMap.has(dep.name)) {
packageMap.set(dep.name, []);
}
packageMap.get(dep.name)!.push({
version: dep.version,
constraint: dep.constraint,
});
}
const conflicts: VersionConflict[] = [];
// Find conflicts (packages with multiple different versions)
for (const [packageName, versions] of packageMap) {
const uniqueVersions = [...new Set(versions.map(v => v.version))];
if (uniqueVersions.length > 1) {
const conflict: VersionConflict = {
package: packageName,
versions: uniqueVersions,
constraints: versions.map(v => v.constraint || '*'),
};
// Try to find a resolution
conflict.resolution = await this.findConflictResolution(packageName, versions);
conflicts.push(conflict);
}
}
this.logger.debug(`Found ${conflicts.length} version conflicts`);
return conflicts;
}
private async findConflictResolution(
packageName: string,
versions: Array<{ version: string; constraint?: string }>
): Promise<string | undefined> {
try {
// Try to find a version that satisfies all constraints
const constraints = versions.map(v => v.constraint || '*');
const satisfyingVersions = versions.map(v => v.version);
// Find the latest version that satisfies all constraints
for (const version of satisfyingVersions.sort((a, b) => semver.compare(b, a))) {
const satisfiesAll = constraints.every(constraint =>
semver.satisfies(version, constraint)
);
if (satisfiesAll) {
return version;
}
}
// If no version satisfies all constraints, try to find the latest compatible version
const latestVersion = satisfyingVersions.sort((a, b) => semver.compare(b, a))[0];
return latestVersion;
} catch (error) {
this.logger.error(`Error finding conflict resolution for ${packageName}:`, error);
return undefined;
}
}
async checkForUpdates(
packageName: string,
currentVersion: string,
registryVersions: string[]
): Promise<UpdateInfo | null> {
this.logger.debug(`Checking for updates for ${packageName}@${currentVersion}`);
try {
// Filter valid versions
const validVersions = registryVersions.filter(v => semver.valid(v));
validVersions.sort((a, b) => semver.rcompare(a, b));
if (validVersions.length === 0) {
return null;
}
const latest = validVersions[0];
const wanted = this.findLatestCompatible(currentVersion, validVersions);
const semverDiff = semver.diff(wanted, currentVersion) || 'none';
const updateInfo: UpdateInfo = {
package: packageName,
current: currentVersion,
latest,
wanted,
semverDiff,
};
// Check for breaking changes
if (semverDiff === 'major') {
updateInfo.breakingChanges = await this.identifyBreakingChanges(
packageName,
currentVersion,
wanted
);
}
this.logger.debug(`Update info for ${packageName}: ${JSON.stringify(updateInfo)}`);
return updateInfo;
} catch (error) {
this.logger.error(`Error checking for updates for ${packageName}:`, error);
return null;
}
}
private findLatestCompatible(currentVersion: string, availableVersions: string[]): string {
const currentRange = semver.validRange(currentVersion) || currentVersion;
// Find versions that satisfy the current range
const compatibleVersions = availableVersions.filter(v =>
semver.satisfies(v, currentRange)
);
if (compatibleVersions.length === 0) {
// If no compatible versions, return the current version
return currentVersion;
}
// Return the latest compatible version
return compatibleVersions.sort((a, b) => semver.rcompare(a, b))[0];
}
private async identifyBreakingChanges(
packageName: string,
fromVersion: string,
toVersion: string
): Promise<BreakingChange[]> {
// This is a simplified implementation
// In a real implementation, you would fetch changelog data or use a service
// that tracks breaking changes between versions
const breakingChanges: BreakingChange[] = [];
try {
const diff = semver.diff(toVersion, fromVersion);
if (diff === 'major') {
breakingChanges.push({
type: 'major',
description: `Major version update from ${fromVersion} to ${toVersion}`,
affectedVersion: toVersion,
});
}
// Add more sophisticated breaking change detection here
// This could involve:
// - Fetching and parsing changelogs
// - Analyzing API changes
// - Checking for deprecated features
// - Reviewing type definitions changes
} catch (error) {
this.logger.error(`Error identifying breaking changes for ${packageName}:`, error);
}
return breakingChanges;
}
parseVersionRange(constraint: string): VersionRange | null {
try {
// Handle various range formats
if (constraint.startsWith('^')) {
const version = constraint.substring(1);
return {
min: version,
max: semver.inc(version, 'major') || '999.999.999',
includeMin: true,
includeMax: false,
};
} else if (constraint.startsWith('~')) {
const version = constraint.substring(1);
const parts = version.split('.');
if (parts.length >= 2) {
const maxVersion = `${parts[0]}.${parseInt(parts[1]) + 1}.0`;
return {
min: version,
max: maxVersion,
includeMin: true,
includeMax: false,
};
}
} else if (constraint.includes(' - ')) {
const [min, max] = constraint.split(' - ');
return {
min: min.trim(),
max: max.trim(),
includeMin: true,
includeMax: true,
};
} else if (constraint.includes('>=')) {
// Handle >=X.Y.Z format
const match = constraint.match(/>=(\d+\.\d+\.\d+)/);
if (match) {
return {
min: match[1],
max: '999.999.999',
includeMin: true,
includeMax: true,
};
}
}
// Default case - exact version
if (semver.valid(constraint)) {
return {
min: constraint,
max: constraint,
includeMin: true,
includeMax: true,
};
}
return null;
} catch (error) {
this.logger.error(`Error parsing version range ${constraint}:`, error);
return null;
}
}
isVersionInRange(version: string, range: VersionRange): boolean {
try {
const versionObj = semver.parse(version);
if (!versionObj) return false;
const minVersion = semver.parse(range.min);
const maxVersion = semver.parse(range.max);
if (!minVersion || !maxVersion) return false;
const minCompare = semver.compare(versionObj, minVersion);
const maxCompare = semver.compare(versionObj, maxVersion);
const minOk = range.includeMin ? minCompare >= 0 : minCompare > 0;
const maxOk = range.includeMax ? maxCompare <= 0 : maxCompare < 0;
return minOk && maxOk;
} catch (error) {
this.logger.error(`Error checking if version ${version} is in range:`, error);
return false;
}
}
getUpdatePriority(updateInfo: UpdateInfo): 'high' | 'medium' | 'low' {
switch (updateInfo.semverDiff) {
case 'major':
return 'high'; // Major updates often contain breaking changes
case 'minor':
return 'medium'; // Minor updates add features
case 'patch':
return 'low'; // Patch updates are typically bug fixes
default:
return 'low';
}
}
clearCache(): void {
this.cache.clear();
this.logger.debug('Version resolver cache cleared');
}
}