UNPKG

apisurf

Version:

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

375 lines (374 loc) 16.9 kB
import { execSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { parseApiSurface } from '../utilities/parseApiSurface.js'; import { parseTypeScriptDefinitions } from './parseTypeScriptDefinitions.js'; /** * Creates an npm package analyzer for downloading and analyzing packages. */ export function createNpmPackageAnalyzer(config = {}) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apisurf-')); const registry = config.registry; const verbose = config.verbose ?? false; const format = config.format ?? 'console'; async function downloadPackage(packageName, version) { const packageDir = path.join(tempDir, `${packageName.replace('/', '-')}-${version}`); fs.mkdirSync(packageDir, { recursive: true }); try { // Show download progress for console and html formats if (format === 'console' || format === 'html') { process.stdout.write(`☐ Downloading ${packageName}@${version}...`); } if (verbose) { console.log(`\nInstalling ${packageName}@${version} with dependencies...`); } // Create a minimal package.json for the installation const tempPackageJson = { name: `temp-${packageName.replace('/', '-')}-${version}`, version: '1.0.0', dependencies: { [packageName]: version } }; fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify(tempPackageJson, null, 2)); // Copy .npmrc files to ensure auth and registry settings are respected copyNpmrcFiles(packageDir); // Install the package and its dependencies const installCommand = buildNpmInstallCommand(); execSync(installCommand, { cwd: packageDir, stdio: verbose ? 'inherit' : 'pipe' }); // The package will be installed in node_modules const installedPath = path.join(packageDir, 'node_modules', packageName); if (!fs.existsSync(installedPath)) { throw new Error(`Failed to install package ${packageName}@${version}`); } // Show completion for console and html formats if (format === 'console' || format === 'html') { process.stdout.write(`\r✅ Downloaded ${packageName}@${version}\n`); } return { name: packageName, version, tempDir: packageDir, packagePath: installedPath }; } catch (error) { // Show failure for console and html formats if (format === 'console' || format === 'html') { process.stdout.write(`\r☒ Failed to download ${packageName}@${version}\n`); } throw new Error(`Failed to install ${packageName}@${version}: ${error instanceof Error ? error.message : String(error)}`); } } async function extractApiSurface(packageInfo) { const packageJsonPath = path.join(packageInfo.packagePath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { throw new Error(`package.json not found in ${packageInfo.packagePath}`); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Extract repository URL let repositoryUrl; if (packageJson.repository) { if (typeof packageJson.repository === 'string') { repositoryUrl = packageJson.repository; } else if (packageJson.repository.url) { repositoryUrl = packageJson.repository.url; // Clean up git URLs to be web URLs if (repositoryUrl) { repositoryUrl = repositoryUrl .replace(/^git\+/, '') .replace(/\.git$/, '') .replace(/^git:\/\//, 'https://') .replace(/^ssh:\/\/git@/, 'https://'); } } } // First, try to find TypeScript definition files const typeDefPaths = findTypeDefinitionPaths(packageJson, packageInfo.packagePath); for (const typeDefPath of typeDefPaths) { if (fs.existsSync(typeDefPath)) { try { const typeDefContent = fs.readFileSync(typeDefPath, 'utf8'); if (verbose) { console.log(`Analyzing TypeScript definitions from: ${typeDefPath}`); } return parseTypeScriptDefinitions(typeDefContent, packageInfo.name, packageInfo.version, typeDefPath, repositoryUrl); } catch (error) { console.warn(`Warning: Failed to parse TypeScript definitions from ${typeDefPath}:`, error); continue; } } } // Fallback to JavaScript/CommonJS analysis const jsEntryPoints = []; // Add standard entry points if (packageJson.main) jsEntryPoints.push(packageJson.main); if (packageJson.module) jsEntryPoints.push(packageJson.module); // Handle exports field - extract all string values recursively if (packageJson.exports && packageJson.exports['.']) { const extractStrings = (obj) => { if (typeof obj === 'string') { jsEntryPoints.push(obj); } else if (obj && typeof obj === 'object') { for (const value of Object.values(obj)) { extractStrings(value); } } }; extractStrings(packageJson.exports['.']); } // Add fallback entry points jsEntryPoints.push('index.js', 'index.ts'); // Remove duplicates and filter out non-strings const uniqueEntryPoints = [...new Set(jsEntryPoints)].filter(entry => typeof entry === 'string'); let sourceContent = null; let entryPath = null; // Try each potential entry point for (const entry of uniqueEntryPoints) { // Skip non-string entries (should be filtered already, but double-check) if (typeof entry !== 'string') { console.warn(`Warning: Skipping non-string entry point:`, entry); continue; } const possiblePaths = [ path.join(packageInfo.packagePath, entry), path.join(packageInfo.packagePath, entry.replace(/\.js$/, '.ts')), ]; for (const possiblePath of possiblePaths) { if (fs.existsSync(possiblePath)) { try { sourceContent = fs.readFileSync(possiblePath, 'utf8'); entryPath = possiblePath; // If this is a wrapper file that just does requires, try to find the actual implementation if (isWrapperFile(sourceContent)) { const actualEntry = followWrapperToImplementation(sourceContent, packageInfo.packagePath); if (actualEntry) { const actualContent = fs.readFileSync(actualEntry, 'utf8'); sourceContent = actualContent; entryPath = actualEntry; } } break; } catch { continue; } } } if (sourceContent) break; } // If no entry point found, try to find implementation files directly if (!sourceContent) { const implementationPaths = [ // Generic development builds path.join(packageInfo.packagePath, 'lib/index.development.js'), path.join(packageInfo.packagePath, 'dist/index.development.js'), // Standard library paths path.join(packageInfo.packagePath, 'lib/index.js'), path.join(packageInfo.packagePath, 'dist/index.js'), path.join(packageInfo.packagePath, 'src/index.ts'), path.join(packageInfo.packagePath, 'index.js'), ]; for (const implPath of implementationPaths) { if (fs.existsSync(implPath)) { try { sourceContent = fs.readFileSync(implPath, 'utf8'); entryPath = implPath; break; } catch { continue; } } } } if (!sourceContent) { if (verbose) { console.warn(`Warning: Could not find entry point for ${packageInfo.name}@${packageInfo.version}`); } return { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: packageInfo.name, version: packageInfo.version, typeDefinitions: new Map(), repositoryUrl }; } if (verbose) { console.log(`Analyzing API surface from: ${entryPath}`); } const apiSurface = parseApiSurface(sourceContent, packageInfo.name, packageInfo.version, entryPath || undefined); apiSurface.repositoryUrl = repositoryUrl; return apiSurface; } function findTypeDefinitionPaths(packageJson, packagePath) { const typeDefPaths = []; // Check package.json "types" or "typings" field if (packageJson.types && typeof packageJson.types === 'string') { typeDefPaths.push(path.join(packagePath, packageJson.types)); } if (packageJson.typings && typeof packageJson.typings === 'string') { typeDefPaths.push(path.join(packagePath, packageJson.typings)); } // Check exports map for types condition if (packageJson.exports) { for (const [, exportConfig] of Object.entries(packageJson.exports)) { if (typeof exportConfig === 'object' && exportConfig !== null) { const config = exportConfig; // Extract type paths from nested exports if (config.types) { if (typeof config.types === 'string') { typeDefPaths.push(path.join(packagePath, config.types)); } else if (typeof config.types === 'object') { // Handle conditional types (e.g., { require: "...", default: "..." }) for (const typeValue of Object.values(config.types)) { if (typeof typeValue === 'string') { typeDefPaths.push(path.join(packagePath, typeValue)); } } } } } } } // Common fallback locations for TypeScript definitions const fallbackPaths = [ 'index.d.ts', 'lib/index.d.ts', 'dist/index.d.ts', 'types/index.d.ts', 'typings/index.d.ts' ]; for (const fallback of fallbackPaths) { typeDefPaths.push(path.join(packagePath, fallback)); } return typeDefPaths; } function isWrapperFile(content) { // Check if this is a simple conditional wrapper (like React's index.js) const lines = content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//')); // Simple heuristic: if it's mostly just require/module.exports and conditionals const requireCount = lines.filter(l => l.includes('require(')).length; const conditionalCount = lines.filter(l => l.includes('if (') || l.includes('process.env')).length; const totalLogicLines = lines.filter(l => !l.startsWith('\'use strict\'') && !l.startsWith('"use strict"') && l !== '{' && l !== '}' && l !== ')' && l !== '(' && !l.startsWith('*') && !l.startsWith('/*')).length; return totalLogicLines <= 6 && (requireCount > 0 || conditionalCount > 0); } function followWrapperToImplementation(wrapperContent, packagePath) { // Look for require statements and try the development version first const requireMatches = wrapperContent.match(/require\(['"]([^'"]+)['"]\)/g); if (!requireMatches) return null; const potentialPaths = []; for (const requireMatch of requireMatches) { const match = requireMatch.match(/require\(['"]([^'"]+)['"]\)/); if (match) { let requirePath = match[1]; // Convert relative requires to absolute paths if (requirePath.startsWith('./')) { requirePath = requirePath.substring(2); } // Always prefer development builds over production/minified builds for accurate API analysis if (requirePath.includes('.production.') || requirePath.includes('.min.')) { const devPath = requirePath .replace('.production.', '.development.') .replace('.min.', '.'); potentialPaths.push(path.join(packagePath, devPath)); } else { // If it's already a development path, prioritize it potentialPaths.unshift(path.join(packagePath, requirePath)); } // Always add the original path as fallback if (!potentialPaths.includes(path.join(packagePath, requirePath))) { potentialPaths.push(path.join(packagePath, requirePath)); } } } // Try each potential path, prioritizing development builds for (const potentialPath of potentialPaths) { if (fs.existsSync(potentialPath)) { return potentialPath; } } return null; } function copyNpmrcFiles(targetDir) { // Copy project .npmrc if it exists const projectNpmrc = path.join(process.cwd(), '.npmrc'); if (fs.existsSync(projectNpmrc)) { const targetNpmrc = path.join(targetDir, '.npmrc'); fs.copyFileSync(projectNpmrc, targetNpmrc); if (verbose) { console.log('Copied project .npmrc for registry configuration'); } } // Note: We don't copy user ~/.npmrc because npm will automatically find it // from the user's home directory. Copying it could cause conflicts. } function buildNpmInstallCommand() { let cmd = 'npm install'; if (registry) { cmd += ` --registry=${registry}`; } // npm will now automatically find: // 1. User config (~/.npmrc) - contains auth keys // 2. Project config (./.npmrc) - we copied this above // 3. Built-in config return cmd; } function cleanup() { try { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } } catch (error) { console.warn(`Warning: Failed to cleanup temp directory ${tempDir}:`, error); } } return { downloadPackage, extractApiSurface, cleanup }; } /** * Analyzes API surface changes between two versions of an npm package. */ export async function analyzeNpmPackageVersions(options) { const { packageName, fromVersion, toVersion, registry, verbose = false, format = 'console' } = options; const analyzer = createNpmPackageAnalyzer({ registry, verbose, format }); try { // Download both versions const [basePackage, headPackage] = await Promise.all([ analyzer.downloadPackage(packageName, fromVersion), analyzer.downloadPackage(packageName, toVersion) ]); // Extract API surfaces const [base, head] = await Promise.all([ analyzer.extractApiSurface(basePackage), analyzer.extractApiSurface(headPackage) ]); return { base, head }; } finally { analyzer.cleanup(); } }