UNPKG

apisurf

Version:

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

221 lines (220 loc) 9.78 kB
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 } }