UNPKG

apisurf

Version:

Analyze API surface changes between npm package versions to catch breaking changes

180 lines (179 loc) 7.05 kB
import { createNpmPackageAnalyzer } from './analyzeNpmPackageVersions.js'; import * as fs from 'fs'; import * as path from 'path'; /** * Analyzes a single version of an NPM package and extracts its API surface. * Handles multiple entry points if the package has an exports map. */ export async function analyzeInspect(options) { const analyzer = createNpmPackageAnalyzer({ registry: options.registry, verbose: options.verbose, format: options.format }); const errors = []; const apiSurfaces = new Map(); try { // Download the package const packageInfo = await analyzer.downloadPackage(options.packageName, options.version); // Read package.json to check for exports map const packageJsonPath = path.join(packageInfo.packagePath, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Extract the main API surface const mainSurface = await analyzer.extractApiSurface(packageInfo); // Sort all exports alphabetically sortApiSurface(mainSurface); apiSurfaces.set('main', mainSurface); // Check for additional export paths in package.json exports field if (packageJson.exports && typeof packageJson.exports === 'object') { const exportPaths = extractExportPaths(packageJson.exports); for (const exportPath of exportPaths) { if (exportPath === '.' || exportPath === './') continue; // Skip main export try { // Create a modified package info for this specific export const exportPackageInfo = { ...packageInfo, // Override the main entry point for this specific export packagePath: packageInfo.packagePath, name: `${packageInfo.name}${exportPath.substring(1)}` // Remove leading dot }; // Try to extract API surface for this export path const exportSurface = await extractExportPathSurface(analyzer, exportPackageInfo, exportPath, packageJson); if (exportSurface) { sortApiSurface(exportSurface); apiSurfaces.set(exportPath, exportSurface); } } catch (error) { errors.push(`Failed to analyze export path ${exportPath}: ${error instanceof Error ? error.message : String(error)}`); } } } // Clean up analyzer.cleanup(); return { packageName: options.packageName, version: packageInfo.version, repositoryUrl: mainSurface.repositoryUrl, apiSurfaces, summary: `Successfully inspected ${options.packageName}@${packageInfo.version} (${apiSurfaces.size} entry point${apiSurfaces.size > 1 ? 's' : ''})`, success: true, errors: errors.length > 0 ? errors : undefined }; } catch (error) { analyzer.cleanup(); return { packageName: options.packageName, version: options.version, apiSurfaces, summary: `Failed to inspect ${options.packageName}@${options.version}`, success: false, errors: [error instanceof Error ? error.message : String(error)] }; } } /** * Sorts all exports in an API surface alphabetically. */ function sortApiSurface(surface) { // Convert Sets to sorted Sets surface.namedExports = new Set([...surface.namedExports].sort()); surface.typeOnlyExports = new Set([...surface.typeOnlyExports].sort()); // Sort star exports surface.starExports.sort(); // Sort type definitions by name if (surface.typeDefinitions) { const sortedEntries = [...surface.typeDefinitions.entries()] .sort((a, b) => a[0].localeCompare(b[0])); surface.typeDefinitions = new Map(sortedEntries); // Sort properties and members within each type definition for (const [, def] of surface.typeDefinitions) { if (def.properties) { const sortedProps = [...def.properties.entries()] .sort((a, b) => a[0].localeCompare(b[0])); def.properties = new Map(sortedProps); } if (def.members) { def.members.sort(); } if (def.extendedProperties) { def.extendedProperties.sort((a, b) => a.name.localeCompare(b.name)); } } } } /** * Extracts export paths from package.json exports field. */ function extractExportPaths(exports) { const paths = new Set(); function traverse(obj, currentPath) { if (typeof obj === 'string') { if (currentPath) paths.add(currentPath); } else if (obj && typeof obj === 'object') { for (const key of Object.keys(obj)) { if (key.startsWith('.')) { // This is an export path paths.add(key); traverse(obj[key], key); } else { // This is a condition (like "import", "require", "types") traverse(obj[key], currentPath); } } } } traverse(exports); return Array.from(paths).sort(); } /** * Extracts API surface for a specific export path. */ async function extractExportPathSurface(analyzer, packageInfo, exportPath, packageJson) { // Find the actual file path for this export const exportConfig = resolveExportPath(packageJson.exports, exportPath); if (!exportConfig) return null; // Create a temporary modified package.json that points to this export const tempPackageInfo = { ...packageInfo, // We'll need to analyze the specific export path exportPath, exportConfig }; try { // Use the analyzer to extract API surface for this specific export // This is a simplified approach - in reality, we'd need to handle // the export path resolution more carefully const surface = await analyzer.extractApiSurface(tempPackageInfo); surface.packageName = `${packageInfo.name}${exportPath.substring(1)}`; return surface; } catch (_error) { // Some export paths might not be analyzable (e.g., CSS files) return null; } } /** * Resolves an export path to its configuration. */ function resolveExportPath(exports, targetPath) { if (!exports || typeof exports !== 'object') return null; // Direct path match if (exports[targetPath]) { return exports[targetPath]; } // Check nested paths for (const [key, value] of Object.entries(exports)) { if (key === targetPath) { return value; } } return null; }