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