UNPKG

apisurf

Version:

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

188 lines (187 loc) 7.17 kB
import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as vm from 'vm'; import { parseApiSurface } from './parseApiSurface.js'; /** * Extracts the API surface of a package from a specific Git branch or commit. * Attempts to parse TypeScript source files and falls back to JavaScript analysis. */ export async function extractApiSurface(pkg, branch) { const packageJsonPath = path.join(pkg.path, 'package.json'); try { // Get package.json from specific branch const packageJsonContent = execSync(`git show ${branch}:${path.relative(process.cwd(), packageJsonPath)}`, { encoding: 'utf8' }); const packageJson = JSON.parse(packageJsonContent); // Extract main entry point const mainFile = packageJson.main || packageJson.exports?.['.'] || 'index.js'; const entryPath = path.join(pkg.path, mainFile); // Find TypeScript source file let sourceContent; try { // Try direct fallback to src/index.ts first const indexTsPath = path.join(pkg.path, 'src/index.ts'); const relativeIndexPath = path.relative(process.cwd(), indexTsPath); sourceContent = execSync(`git show ${branch}:${relativeIndexPath}`, { encoding: 'utf8' }); } catch { // If that fails, try converting main entry path const tsEntryPath = entryPath.replace(/\.js$/, '.ts').replace(/dist\//, 'src/'); const relativeTsPath = path.relative(process.cwd(), tsEntryPath); sourceContent = execSync(`git show ${branch}:${relativeTsPath}`, { encoding: 'utf8' }); } return parseApiSurface(sourceContent, packageJson.name, packageJson.version, undefined, branch, pkg.path); } catch (error) { console.warn(`Warning: Could not extract API surface for ${pkg.name} at ${branch}:`, error); return { namedExports: new Set(), typeOnlyExports: new Set(), defaultExport: false, starExports: [], packageName: pkg.name, version: '0.0.0', typeDefinitions: new Map() }; } } /** * Parses CommonJS modules using static analysis. * Fallback when VM-based analysis fails. */ export function parseCommonJSStatically(sourceContent, packageName, version) { const namedExports = new Set(); const typeOnlyExports = new Set(); const starExports = []; let defaultExport = false; const lines = sourceContent.split('\n'); for (const line of lines) { const trimmed = line.trim(); // Skip comments and empty lines if (trimmed.startsWith('//') || trimmed.startsWith('/*') || !trimmed) { continue; } // Direct exports: exports.foo = bar const directExportMatch = trimmed.match(/^exports\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/); if (directExportMatch) { namedExports.add(directExportMatch[1]); continue; } // Computed exports: exports['foo'] = bar const computedExportMatch = trimmed.match(/^exports\[['"]([^'"]+)['"]\]\s*=/); if (computedExportMatch) { namedExports.add(computedExportMatch[1]); continue; } // Object.defineProperty exports: Object.defineProperty(exports, 'foo', ...) const definePropertyMatch = trimmed.match(/^Object\.defineProperty\(exports,\s*['"]([^'"]+)['"],/); if (definePropertyMatch) { namedExports.add(definePropertyMatch[1]); continue; } // Module.exports default export if (trimmed.match(/^module\.exports\s*=/)) { defaultExport = true; continue; } // __exportStar calls (bundler generated) const exportStarMatch = trimmed.match(/__exportStar\(require\(['"]([^'"]+)['"]\)/); if (exportStarMatch) { starExports.push(exportStarMatch[1]); continue; } } return { namedExports, typeOnlyExports, defaultExport, starExports, packageName, version, typeDefinitions: new Map() }; } /** * Loads a CommonJS module using Node.js VM for dynamic analysis. * Provides safer execution environment for untrusted code. */ export function loadModuleWithVM(modulePath) { try { // Create a sandbox with proper Node.js environment const sandbox = { require: (id) => { // Use Node.js standard require resolution from the module's directory // This will now work properly since we have installed dependencies try { if (id.startsWith('.')) { // Relative requires const resolvedPath = path.resolve(path.dirname(modulePath), id); return require(resolvedPath); } else { // Package requires - resolve from the module's location return require(require.resolve(id, { paths: [path.dirname(modulePath)] })); } } catch (error) { // If resolution still fails, return empty object to prevent crashes return {}; } }, module: { exports: {} }, exports: {}, __filename: modulePath, __dirname: path.dirname(modulePath), process: { env: { NODE_ENV: 'development' }, versions: process.versions, platform: process.platform }, console: { log: () => { }, warn: () => { }, error: () => { }, info: () => { }, debug: () => { } }, Buffer, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate, global: {}, Object, Array, String, Number, Boolean, Function, Error, Date, RegExp, Math, JSON, undefined: undefined, null: null }; // Make sure exports and module.exports point to the same object sandbox.module.exports = sandbox.exports; // Create VM context const context = vm.createContext(sandbox); // Read the module source const moduleSource = fs.readFileSync(modulePath, 'utf8'); // Execute the module in the sandbox vm.runInContext(moduleSource, context, { filename: modulePath, timeout: 10000 // 10 second timeout to prevent infinite loops }); // Return the exports (prefer module.exports over exports) return sandbox.module.exports || sandbox.exports; } catch (error) { console.warn(`VM execution failed for ${modulePath}:`, error); return null; } }