/** * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import semver from "semver"; import { logger } from "../utils/logger.js"; import { NpmService } from "./npmService.js"; import type { DependencyType, DependencyUpdate, PackageJson, } from "../types/package.types.js"; /** * Checks if a version string is a valid semver range. * @param version - The version string to validate. * @returns True if the version is a valid semver range. */ const isValidSemverRange = (version: string): boolean => { if (version.startsWith("file:")) { return false; } if (version.startsWith("git:")) { return false; } if (version.startsWith("http:")) { return false; } if (version.startsWith("https:")) { return false; } if (version.includes("github:")) { return false; } if (version === "*" || version === "latest") { return false; } return true; }; /** * Removes version prefixes and coerces to valid semver. * @param version - The version string to sanitise. * @returns The cleaned version string, or null if invalid. */ const cleanVersion = (version: string): string | null => { const stripped = version.replace(/^[<=>^~]+/, ""); const coerced = semver.coerce(stripped); return coerced?.version ?? null; }; /** * Determines if an update is needed based on version comparison. * @param currentVersion - The currently installed version. * @param latestVersion - The latest available version. * @returns True if an update is needed. */ const shouldUpdate = ( currentVersion: string, latestVersion: string, ): boolean => { try { if (currentVersion === latestVersion) { return false; } return semver.lt(currentVersion, latestVersion); // eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive must be lowercase /* v8 ignore start -- @preserve */ } catch (error) { void logger.error( `Error comparing versions: ${currentVersion} vs ${latestVersion}`, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error error as Error, ); return false; } // eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive must be lowercase /* v8 ignore stop -- @preserve */ }; /** * Service for analysing package dependencies and finding updates. */ // eslint-disable-next-line stylistic/padded-blocks -- Blank line needed before JSDoc per lines-around-comment rule class DependencyAnalyzerService { /** * Creates a new DependencyAnalyzerService instance. * @param npmService - The npm service for fetching package information. */ public constructor(private readonly npmService: NpmService) {} /** * Analyses a package.json and finds available updates. * @param packageJson - The parsed package.json content. * @returns Array of available dependency updates. */ public async analyzePackageJson( packageJson: PackageJson, ): Promise> { const updates: Array = []; const dependencyTypes: Array = [ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", ]; for (const type of dependencyTypes) { const deps = packageJson[type]; if (deps === undefined) { continue; } for (const [ packageName, currentVersion ] of Object.entries(deps)) { if (!isValidSemverRange(currentVersion)) { continue; } // eslint-disable-next-line no-await-in-loop -- Sequential dependency checks are required const update = await this.checkForUpdate( packageName, currentVersion, type, ); if (update !== null) { updates.push(update); } } } return updates; } /** * Checks if a specific package has an available update. * @param packageName - The name of the package to check. * @param currentVersion - The currently installed version. * @param type - The dependency category (dependencies, devDependencies, etc.). * @returns The update information or null if no update available. */ private async checkForUpdate( packageName: string, currentVersion: string, type: DependencyType, ): Promise { try { const packageInfo = await this.npmService.getPackageInfo(packageName); if (packageInfo === null) { return null; } // Use mature version (at least 10 days old) instead of dist-tags.latest const latestVersion = NpmService.getLatestMatureVersion( packageInfo, 10, ); if (latestVersion === null) { return null; } const cleanCurrentVersion = cleanVersion(currentVersion); if (cleanCurrentVersion === null) { return null; } if (shouldUpdate(cleanCurrentVersion, latestVersion)) { return { currentVersion, latestVersion, packageName, type, }; } return null; } catch (error) { await logger.error( `Error checking update for ${packageName}`, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error error as Error, ); return null; } } } export { DependencyAnalyzerService };