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