UNPKG

apisurf

Version:

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

270 lines (269 loc) 12.1 kB
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; }