apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
270 lines (269 loc) • 12.1 kB
JavaScript
import chalk from 'chalk';
/**
* Normalize a signature by removing comments and extra whitespace
* to avoid marking comment changes as breaking changes
*/
function normalizeSignature(signature) {
// Remove single-line comments
let normalized = signature.replace(/\/\/.*$/gm, '');
// Remove multi-line comments (including JSDoc)
normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, '');
// Collapse multiple spaces/newlines into single spaces
normalized = normalized.replace(/\s+/g, ' ');
// Trim whitespace
return normalized.trim();
}
/**
* Compares TypeScript type definitions to identify breaking and non-breaking changes.
*/
export function compareTypeDefinitions(base, head) {
const breakingChanges = [];
const nonBreakingChanges = [];
// Check for removed types/interfaces
for (const [name, baseDef] of base) {
if (!head.has(name)) {
breakingChanges.push({
type: 'export-removed',
description: `Removed ${baseDef.kind} '${chalk.bold(name)}'`,
before: name
});
}
}
// Check for added types/interfaces
for (const [name, headDef] of head) {
if (!base.has(name)) {
nonBreakingChanges.push({
type: 'export-added',
description: `Added ${headDef.kind} '${chalk.bold(name)}'`,
details: name
});
}
}
// Check for changes in existing types
for (const [name, headDef] of head) {
const baseDef = base.get(name);
if (baseDef) {
// Check for signature changes (normalize to ignore comment changes)
const baseNormalized = normalizeSignature(baseDef.signature || '');
const headNormalized = normalizeSignature(headDef.signature || '');
if (baseNormalized !== headNormalized) {
// For functions, analyze parameter changes more specifically
if (baseDef.kind === 'function' && headDef.kind === 'function') {
const paramChanges = analyzeFunctionParameterChanges(baseDef, headDef);
breakingChanges.push(...paramChanges.breaking);
nonBreakingChanges.push(...paramChanges.nonBreaking);
}
else if ((baseDef.kind === 'interface' || baseDef.kind === 'class') &&
(headDef.kind === 'interface' || headDef.kind === 'class')) {
// For interfaces and classes, we need to analyze if the changes are actually breaking
const hasBreakingChanges = analyzeIfChangesAreBreaking(baseDef, headDef);
if (hasBreakingChanges) {
breakingChanges.push({
type: 'type-changed',
description: `Changed ${baseDef.kind} '${chalk.bold(name)}' signature`,
before: baseDef.signature || '',
after: headDef.signature || ''
});
}
else {
// Only non-breaking changes (like adding optional properties)
nonBreakingChanges.push({
type: 'type-updated',
description: `Updated ${baseDef.kind} '${chalk.bold(name)}' (non-breaking changes)`,
details: headDef.signature || ''
});
}
}
else if (baseDef.kind === 'enum' && headDef.kind === 'enum') {
// For enums, check if changes are breaking (removing values) or non-breaking (adding values)
const hasBreakingChanges = analyzeEnumChanges(baseDef, headDef);
if (hasBreakingChanges) {
breakingChanges.push({
type: 'type-changed',
description: `Changed ${baseDef.kind} '${chalk.bold(name)}' signature`,
before: baseDef.signature || '',
after: headDef.signature || ''
});
}
else {
// Only non-breaking changes (like adding enum values)
nonBreakingChanges.push({
type: 'type-updated',
description: `Updated ${baseDef.kind} '${chalk.bold(name)}' (non-breaking changes)`,
details: headDef.signature || ''
});
}
}
else {
// Generic type change
breakingChanges.push({
type: 'type-changed',
description: `Changed ${baseDef.kind} '${chalk.bold(name)}' signature`,
before: baseDef.signature || '',
after: headDef.signature || ''
});
}
}
}
}
return { breakingChanges, nonBreakingChanges };
}
function analyzeFunctionParameterChanges(baseDef, headDef) {
const breaking = [];
const nonBreaking = [];
const baseParams = baseDef.parameters || [];
const headParams = headDef.parameters || [];
// Check for removed parameters (breaking)
if (headParams.length < baseParams.length) {
breaking.push({
type: 'parameter-changed',
description: `Function '${chalk.bold(baseDef.name)}' removed parameters (${baseParams.length} → ${headParams.length})`,
before: baseParams.join(', '),
after: headParams.join(', ')
});
}
// Check for added parameters (potentially breaking if required)
if (headParams.length > baseParams.length) {
const addedParams = headParams.slice(baseParams.length);
const hasOptionalParams = addedParams.some(param => param.includes('?') || param.includes('= '));
if (hasOptionalParams) {
nonBreaking.push({
type: 'parameter-added',
description: `Function '${chalk.bold(baseDef.name)}' added optional parameters`,
details: addedParams.join(', ')
});
}
else {
breaking.push({
type: 'parameter-changed',
description: `Function '${chalk.bold(baseDef.name)}' added required parameters`,
before: baseParams.join(', '),
after: headParams.join(', ')
});
}
}
// Check for parameter type changes
for (let i = 0; i < Math.min(baseParams.length, headParams.length); i++) {
if (baseParams[i] !== headParams[i]) {
breaking.push({
type: 'parameter-changed',
description: `Function '${chalk.bold(baseDef.name)}' changed parameter ${i + 1} type`,
before: baseParams[i],
after: headParams[i]
});
}
}
// Check for return type changes
if (baseDef.returnType !== headDef.returnType) {
breaking.push({
type: 'type-changed',
description: `Function '${chalk.bold(baseDef.name)}' changed return type`,
before: baseDef.returnType || 'unknown',
after: headDef.returnType || 'unknown'
});
}
return { breaking, nonBreaking };
}
/**
* Analyzes if changes to an enum are breaking
* Returns true if there are breaking changes (removed values), false if only non-breaking changes (added values)
*/
function analyzeEnumChanges(baseDef, headDef) {
// Use structured members data if available, fallback to signature parsing
const baseValues = baseDef.members || extractEnumValuesFromSignature(baseDef.signature || '');
const headValues = headDef.members || extractEnumValuesFromSignature(headDef.signature || '');
// Convert to Sets for efficient comparison
const headValuesSet = new Set(headValues);
// Check for removed enum values (breaking)
for (const value of baseValues) {
if (!headValuesSet.has(value)) {
return true; // Removing an enum value is breaking
}
}
// If we get here, there are only non-breaking changes (added values)
return false;
}
/**
* Extract enum values from an enum signature
*/
function extractEnumValuesFromSignature(signature) {
const values = [];
// Match enum values - looking for identifiers followed by = or , or }
// Handle multi-line enums by using /s flag (dotAll)
const enumBodyMatch = signature.match(/\{([\s\S]*)\}/);
if (enumBodyMatch) {
const enumBody = enumBodyMatch[1];
// Split by comma and extract the value names
// Handle potential comments and newlines
const parts = enumBody.split(',');
for (const part of parts) {
// Remove comments and trim
const cleanPart = part.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
if (cleanPart) {
// Extract just the identifier before = or whitespace
const valueName = cleanPart.split(/[=\s]/)[0].trim();
if (valueName && valueName !== '') {
values.push(valueName);
}
}
}
}
return values;
}
/**
* Analyzes if changes between two interface/class definitions are breaking
* Returns true if there are breaking changes, false if only non-breaking changes
*/
function analyzeIfChangesAreBreaking(baseDef, headDef) {
// Use extendedProperties if available for more accurate analysis
if (baseDef.extendedProperties && headDef.extendedProperties) {
const basePropsMap = new Map(baseDef.extendedProperties.map(p => [p.name, p]));
const headPropsMap = new Map(headDef.extendedProperties.map(p => [p.name, p]));
// Check for removed properties (always breaking)
for (const [propName] of basePropsMap) {
if (!headPropsMap.has(propName)) {
return true; // Removing a property is breaking
}
}
// Check for added required properties (breaking)
for (const [propName, prop] of headPropsMap) {
if (!basePropsMap.has(propName) && prop.required) {
return true; // Adding a required property is breaking
}
}
// If we get here, there are only non-breaking changes
return false;
}
// Fallback to original implementation using properties Map
const baseProps = baseDef.properties || new Map();
const headProps = headDef.properties || new Map();
// Check for removed properties (always breaking)
for (const [propName] of baseProps) {
if (!headProps.has(propName)) {
return true; // Removing a property is breaking
}
}
// Check for added required properties (breaking)
for (const [propName, _propType] of headProps) {
if (!baseProps.has(propName)) {
// Check if the property is optional by looking at the signature
const headSignature = headDef.signature || '';
// Look for the property in the signature with optional marker
const optionalPropRegex = new RegExp(`\\b${propName}\\s*\\?\\s*:`);
const readonlyOptionalRegex = new RegExp(`\\breadonly\\s+${propName}\\s*\\?\\s*:`);
const isOptional = optionalPropRegex.test(headSignature) || readonlyOptionalRegex.test(headSignature);
if (!isOptional) {
return true; // Adding a required property is breaking
}
}
}
// Check for property type changes (breaking)
for (const [propName, headPropType] of headProps) {
const basePropType = baseProps.get(propName);
if (basePropType && basePropType !== headPropType) {
return true; // Changing a property type is breaking
}
}
// If we get here, there are only non-breaking changes (like adding optional properties)
return false;
}