apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
221 lines (220 loc) • 9.78 kB
JavaScript
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { parseEnumDefinition } from './parseEnumDefinition.js';
import { parseInterfaceDefinition } from './parseInterfaceDefinition.js';
import { parseFunctionSignature } from './parseFunctionSignature.js';
import { parseTypeDefinition } from './parseTypeDefinition.js';
import { parseCommonJSStatically } from './parseCommonJSStatically.js';
import { loadModuleWithVM } from './loadModuleWithVM.js';
/**
* Parses source code to extract the public API surface including exports and type information.
* Supports both ES6/TypeScript modules and CommonJS modules.
*/
export function parseApiSurface(sourceContent, packageName, version, modulePath, gitBranch, packagePath) {
const namedExports = new Set();
const typeOnlyExports = new Set();
const starExports = [];
const typeDefinitions = new Map();
let defaultExport = false;
// Detect if this is a CommonJS file
const isCommonJS = sourceContent.includes('exports.') || sourceContent.includes('module.exports');
if (isCommonJS) {
return parseCommonJSApiSurface(sourceContent, packageName, version, modulePath, gitBranch, packagePath);
}
// Parse ES6/TypeScript export statements
const lines = sourceContent.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Type-only exports: export type { Foo, Bar } or export type { Foo } from './module'
const typeOnlyExportMatch = trimmed.match(/^export\s+type\s*{\s*([^}]+)\s*}\s*(?:from\s*['"]([^'"]+)['"])?/);
if (typeOnlyExportMatch) {
const exports = typeOnlyExportMatch[1].split(',').map(e => e.trim().split(' as ')[0]);
const fromModule = typeOnlyExportMatch[2];
exports.forEach(exp => {
typeOnlyExports.add(exp);
// If this is a re-export and we have git branch info, try to parse the source
if (fromModule && gitBranch && packagePath) {
tryParseReExportedType(exp, fromModule, gitBranch, packagePath, typeDefinitions);
}
});
continue;
}
// Mixed exports with type modifier: export { foo, type Bar } or export { foo } from './module'
const mixedExportMatch = trimmed.match(/^export\s*{\s*([^}]+)\s*}\s*(?:from\s*['"]([^'"]+)['"])?/);
if (mixedExportMatch) {
const exportItems = mixedExportMatch[1].split(',');
const fromModule = mixedExportMatch[2];
exportItems.forEach(item => {
const cleanItem = item.trim();
if (cleanItem.startsWith('type ')) {
const typeName = cleanItem.replace(/^type\s+/, '').split(' as ')[0];
typeOnlyExports.add(typeName);
}
else {
const exportName = cleanItem.split(' as ')[0];
namedExports.add(exportName);
// If this is a re-export and we have git branch info, try to parse the source
if (fromModule && gitBranch && packagePath) {
tryParseReExportedType(exportName, fromModule, gitBranch, packagePath, typeDefinitions);
}
}
});
continue;
}
// Direct type exports: export interface/type/enum Foo
const directTypeExportMatch = trimmed.match(/^export\s+(interface|type|enum)\s+(\w+)/);
if (directTypeExportMatch) {
const [, kind, name] = directTypeExportMatch;
typeOnlyExports.add(name);
// Parse the full definition
if (kind === 'enum') {
const enumDef = parseEnumDefinition(lines, i, name);
if (enumDef) {
typeDefinitions.set(name, enumDef);
}
}
else if (kind === 'interface') {
const interfaceDef = parseInterfaceDefinition(lines, i, name);
if (interfaceDef) {
typeDefinitions.set(name, interfaceDef);
}
}
else if (kind === 'type') {
const typeDef = parseTypeDefinition(lines, i, name);
if (typeDef) {
typeDefinitions.set(name, typeDef);
}
}
continue;
}
// Direct value exports: export const/let/var/function/class foo
const directExportMatch = trimmed.match(/^export\s+(const|let|var|function|class)\s+(\w+)/);
if (directExportMatch) {
const [, kind, name] = directExportMatch;
namedExports.add(name);
// Parse function signatures for const/let/var declarations
if (kind === 'const' || kind === 'let' || kind === 'var') {
const funcDef = parseFunctionSignature(lines, i, name);
if (funcDef) {
typeDefinitions.set(name, funcDef);
}
}
continue;
}
// Default export
if (trimmed.startsWith('export default')) {
defaultExport = true;
continue;
}
// Star exports: export * from './module'
const starExportMatch = trimmed.match(/^export\s*\*\s*from\s*['"]([^'"]+)['"]/);
if (starExportMatch) {
starExports.push(starExportMatch[1]);
continue;
}
}
return {
namedExports,
typeOnlyExports,
defaultExport,
starExports,
packageName,
version,
typeDefinitions
};
}
function parseCommonJSApiSurface(sourceContent, packageName, version, modulePath, _gitBranch, _packagePath) {
const namedExports = new Set();
const typeOnlyExports = new Set();
const starExports = [];
let defaultExport = false;
try {
// If we have a file path, try to use VM to dynamically load the module
if (modulePath && fs.existsSync(modulePath)) {
const moduleExports = loadModuleWithVM(modulePath, packageName, version);
if (moduleExports !== null) {
// Inspect the actual exports object
for (const exportName of Object.keys(moduleExports)) {
if (exportName !== 'default' && exportName !== '__esModule') {
namedExports.add(exportName);
}
}
// Check for default export
if (moduleExports.default !== undefined ||
(typeof moduleExports === 'function' || typeof moduleExports === 'object')) {
defaultExport = true;
}
return {
namedExports,
typeOnlyExports,
defaultExport,
starExports,
packageName,
version,
typeDefinitions: new Map()
};
}
}
}
catch (error) {
console.warn(`Warning: Failed to analyze CommonJS module for ${packageName}@${version} with VM, falling back to static analysis:`, error);
}
// Fallback to static regex-based analysis
return parseCommonJSStatically(sourceContent, packageName, version);
}
/**
* Try to parse a re-exported type definition from its source file
*/
function tryParseReExportedType(exportName, fromModule, gitBranch, packagePath, typeDefinitions) {
try {
// Resolve the module path
let modulePath = fromModule;
if (!modulePath.endsWith('.ts') && !modulePath.endsWith('.js')) {
modulePath += '.ts'; // Try TypeScript first
}
if (modulePath.endsWith('.js')) {
modulePath = modulePath.replace(/\.js$/, '.ts');
}
// Build the relative path from the package root
const srcPath = path.join('src', modulePath);
const relativePath = path.relative(process.cwd(), path.join(packagePath, srcPath));
// Try to read the source file from git
const sourceContent = execSync(`git show ${gitBranch}:${relativePath}`, { encoding: 'utf8' });
// Parse the source file to find the exported definition
const lines = sourceContent.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Look for the specific export
const enumMatch = trimmed.match(new RegExp(`^export\\s+enum\\s+${exportName}\\s*{`));
if (enumMatch) {
const enumDef = parseEnumDefinition(lines, i, exportName);
if (enumDef) {
typeDefinitions.set(exportName, enumDef);
break;
}
}
const interfaceMatch = trimmed.match(new RegExp(`^export\\s+interface\\s+${exportName}\\s*{`));
if (interfaceMatch) {
const interfaceDef = parseInterfaceDefinition(lines, i, exportName);
if (interfaceDef) {
typeDefinitions.set(exportName, interfaceDef);
break;
}
}
const typeMatch = trimmed.match(new RegExp(`^export\\s+type\\s+${exportName}\\s*=`));
if (typeMatch) {
const typeDef = parseTypeDefinition(lines, i, exportName);
if (typeDef) {
typeDefinitions.set(exportName, typeDef);
break;
}
}
}
}
catch (error) {
// Silently fail - we'll just not have deep type info for this re-export
}
}