apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
375 lines (374 loc) • 16.9 kB
JavaScript
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();
}
}