apisurf
Version:
Analyze API surface changes between npm package versions to catch breaking changes
1,276 lines (1,190 loc) • 45.1 kB
JavaScript
import { detectSemverViolation } from './detectSemverViolation.js';
import { writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Renders a semver violation warning if detected
*/
function renderSemverViolation(result) {
if (!result.fromVersion || !result.toVersion) {
return '';
}
const violation = detectSemverViolation(result.fromVersion, result.toVersion, result.semverImpact.minimumBump);
if (violation.hasViolation && violation.actualBump) {
return `<div class="semver-violation">(But they shipped a <span class="version-type">${violation.actualBump}</span>! <span class="emoji">🤦</span>)</div>`;
}
return '';
}
// Strip ANSI color codes from text
function stripAnsiCodes(text) {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
// Simple TypeScript syntax highlighting
function highlightTypeScript(code) {
// First strip ANSI codes and escape HTML
const cleanCode = stripAnsiCodes(code);
// Check if the code already contains HTML tags (double highlighting issue)
if (cleanCode.includes('class="hljs-')) {
console.warn('Warning: Code already contains HTML highlighting:', cleanCode);
// Strip out any existing HTML tags
const strippedCode = cleanCode.replace(/<[^>]*>/g, '');
return highlightTypeScript(strippedCode);
}
const escaped = cleanCode.replace(/[&<>"']/g, m => {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return map[m];
});
// Keywords
const keywords = /\b(export|import|from|class|interface|type|enum|const|let|var|function|async|await|return|if|else|for|while|do|switch|case|break|continue|try|catch|finally|throw|new|this|super|extends|implements|private|public|protected|static|readonly|abstract|namespace|module|declare|as|is|in|of|typeof|keyof|never|any|void|null|undefined|true|false)\b/g;
// Types
const types = /\b(string|number|boolean|object|symbol|bigint|unknown|any|void|never|null|undefined|Promise|Array|Map|Set|Date|RegExp|Error|Function)\b/g;
// Strings
const strings = /(['"`])(?:(?=(\\?))\\2[\s\S])*?\1/g;
// Comments - be careful with multiline
const comments = /(\/\/[^\n]*$|\/\*[\s\S]*?\*\/)/gm;
// Numbers
const numbers = /\b(\d+(\.\d+)?([eE][+-]?\d+)?|0x[0-9a-fA-F]+|0b[01]+|0o[0-7]+)\b/g;
// Generic parameters - match already escaped angle brackets
const generics = /<([^&]+)>/g;
// Apply replacements in order - comments and strings first to avoid replacing inside them
let highlighted = escaped;
// First, mark comments and strings so we don't replace inside them
const commentMatches = [];
const stringMatches = [];
highlighted = highlighted.replace(comments, (match, _p1, offset) => {
const replacement = `\0COMMENT${commentMatches.length}\0`;
commentMatches.push({ start: offset, end: offset + match.length, content: `<span class="hljs-comment">${match}</span>` });
return replacement;
});
highlighted = highlighted.replace(strings, (match, _p1, offset) => {
const replacement = `\0STRING${stringMatches.length}\0`;
stringMatches.push({ start: offset, end: offset + match.length, content: `<span class="hljs-string">${match}</span>` });
return replacement;
});
// Now apply other replacements
highlighted = highlighted
.replace(keywords, '<span class="hljs-keyword">$1</span>')
.replace(types, '<span class="hljs-type">$1</span>')
.replace(numbers, '<span class="hljs-number">$1</span>')
.replace(generics, '<<span class="hljs-type">$1</span>>');
// Restore comments and strings
commentMatches.forEach((match, i) => {
highlighted = highlighted.replace(`\0COMMENT${i}\0`, match.content);
});
stringMatches.forEach((match, i) => {
highlighted = highlighted.replace(`\0STRING${i}\0`, match.content);
});
return highlighted;
}
/**
* Formats the diff results as an HTML report with FluentUI styling
*/
export function formatHtmlOutput(result) {
const timestamp = new Date().toISOString();
const reportPath = join(tmpdir(), `apisurf-report-${Date.now()}.html`);
const html = generateHtmlReport(result, timestamp);
writeFileSync(reportPath, html);
// Open the report in the default browser
openInBrowser(reportPath);
return `HTML report generated at: ${reportPath}`;
}
async function openInBrowser(filePath) {
const platform = process.platform;
let command;
if (platform === 'darwin') {
command = `open "${filePath}"`;
}
else if (platform === 'win32') {
command = `start "${filePath}"`;
}
else {
command = `xdg-open "${filePath}"`;
}
try {
await execAsync(command);
}
catch (error) {
console.error('Failed to open browser:', error);
}
}
function generateHtmlReport(result, timestamp) {
const breakingChangeCount = result.packages.reduce((sum, pkg) => sum + pkg.breakingChanges.length, 0);
const nonBreakingChangeCount = result.packages.reduce((sum, pkg) => sum + pkg.nonBreakingChanges.length, 0);
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Package Diff Report</title>
<style>
@font-face {
font-family: 'Segoe UI Web';
src: local('Segoe UI'), local('Segoe UI Web Regular'), local('Segoe UI Regular');
font-weight: 400;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI Web', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 1.5;
color: #323130;
background-color: #f3f2f1;
margin: 0;
padding: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
margin-bottom: 20px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0 0 8px 0;
color: #323130;
}
.subtitle {
color: #605e5c;
margin: 0 0 20px 0;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.summary-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
text-align: center;
}
.summary-card .number {
font-size: 36px;
font-weight: 600;
margin: 8px 0;
}
.summary-card .label {
color: #605e5c;
font-size: 14px;
}
.summary-card.breaking .number {
color: #d13438;
}
.summary-card.non-breaking .number {
color: #107c10;
}
.summary-card.impact .number {
color: #0078d4;
}
.package-section {
background: white;
border-radius: 8px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
margin-bottom: 16px;
overflow: hidden;
}
.package-header {
padding: 16px 24px;
background: #faf9f8;
border-bottom: 1px solid #edebe9;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
}
.package-header:hover {
background: #f3f2f1;
}
.package-title {
font-size: 18px;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
font-weight: 400;
}
.badge.breaking {
background: #fde7e9;
color: #a80000;
}
.badge.non-breaking {
background: #dff6dd;
color: #0b6a0b;
}
.chevron {
transition: transform 0.2s;
color: #605e5c;
display: inline-block;
font-size: 12px;
}
.chevron.expanded {
transform: rotate(90deg);
}
/* Syntax highlighting styles */
.hljs-keyword { color: #0000ff; font-weight: 600; }
.hljs-type { color: #2b91af; }
.hljs-string { color: #a31515; }
.hljs-number { color: #098658; }
.hljs-comment { color: #008000; font-style: italic; }
.hljs-function { color: #795e26; }
.hljs-class { color: #267f99; }
/* Diff view styles */
.diff-section {
margin-bottom: 20px;
}
.diff-section-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
color: #323130;
}
.diff-list {
list-style: none;
padding: 0;
margin: 0;
}
.diff-list li {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
}
.diff-list.removed li {
background: #ffeef0;
border-left: 3px solid #d13438;
}
.diff-list.added li {
background: #e6ffed;
border-left: 3px solid #107c10;
}
.diff-item {
margin-bottom: 16px;
padding: 12px;
background: #faf9f8;
border-radius: 4px;
}
.diff-item-name {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
color: #0078d4;
}
.diff-before, .diff-after {
margin: 4px 0;
font-size: 13px;
}
.diff-before code {
background: #ffeef0;
padding: 2px 6px;
border-radius: 3px;
}
.diff-after code {
background: #e6ffed;
padding: 2px 6px;
border-radius: 3px;
}
.diff-note {
font-size: 13px;
color: #605e5c;
margin: 8px 0;
font-style: italic;
}
.package-content {
display: none;
padding: 24px;
}
.package-content.expanded {
display: block;
}
.changes-section {
margin-bottom: 24px;
}
.changes-section h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
color: #323130;
}
.change-item {
background: #faf9f8;
border: 1px solid #edebe9;
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
}
.change-header {
padding: 12px 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.change-header:hover {
background: #f3f2f1;
}
.change-type {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 4px;
margin-right: 12px;
}
.change-type.breaking {
background: #d13438;
color: white;
}
.change-type.non-breaking {
background: #107c10;
color: white;
}
.change-description {
flex: 1;
color: #323130;
}
.change-details {
display: none;
padding: 16px;
background: white;
border-top: 1px solid #edebe9;
}
.change-details.expanded {
display: block;
}
.code-block {
background: #f8f8f8;
border: 1px solid #e1e1e1;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.code-before {
background: #ffeef0;
border-color: #fdb8c0;
}
.code-after {
background: #e6ffed;
border-color: #acf2bd;
}
.code-label {
font-size: 12px;
font-weight: 600;
color: #605e5c;
margin-bottom: 4px;
}
.no-changes {
color: #605e5c;
font-style: italic;
padding: 20px;
text-align: center;
}
.footer {
text-align: center;
color: #605e5c;
font-size: 12px;
margin-top: 40px;
padding: 20px;
}
.npm-info {
background: #e1f5fe;
border: 1px solid #81d4fa;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.npm-info-icon {
font-size: 20px;
}
.npm-info-text {
flex: 1;
}
.npm-info-package {
font-weight: 600;
color: #0078d4;
}
.npm-info-text a {
color: #0078d4;
text-decoration: none;
}
.npm-info-text a:hover {
text-decoration: underline;
}
/* Code chicklets for inline code */
.code-chicklet {
display: inline-block;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
background: #f3f2f1;
color: #323130;
padding: 2px 8px;
border-radius: 4px;
margin: 0 2px;
font-weight: normal;
vertical-align: baseline;
border: 1px solid #e1e1e1;
}
/* Semver violation styles */
.semver-violation {
margin-top: 8px;
font-size: 14px;
color: #a80000;
font-weight: 500;
}
.semver-violation .emoji {
font-size: 26px;
vertical-align: middle;
}
.version-type {
display: inline-block;
background: #d13438;
color: white;
padding: 3px 8px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
font-size: 13px;
}
/* Debug info styles */
.debug-info {
margin-top: 16px;
padding: 12px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
display: none;
white-space: pre-wrap;
}
.debug-info.expanded {
display: block;
}
.debug-toggle {
font-size: 12px;
color: #0078d4;
cursor: pointer;
text-decoration: underline;
margin-top: 8px;
display: inline-block;
}
.debug-toggle:hover {
color: #005a9e;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Package API Diff Report</h1>
<p class="subtitle">Generated on ${new Date(timestamp).toLocaleString()}</p>
${result.npmPackage ? `
<div class="npm-info">
<span class="npm-info-icon">📦</span>
<div class="npm-info-text">
NPM Package: <span class="npm-info-package">${result.npmPackage}</span>
${result.fromVersion && result.toVersion ? `(${result.fromVersion} → ${result.toVersion})` : ''}
${result.repositoryUrl ? `<br>Repository: <a href="${result.repositoryUrl}" target="_blank" rel="noopener noreferrer">${result.repositoryUrl}</a>` : ''}
</div>
</div>
` : ''}
<div class="summary-cards">
<div class="summary-card breaking">
<div class="label">Breaking Changes</div>
<div class="number">${breakingChangeCount}</div>
</div>
<div class="summary-card non-breaking">
<div class="label">Non-Breaking Changes</div>
<div class="number">${nonBreakingChangeCount}</div>
</div>
<div class="summary-card impact">
<div class="label">Semver Impact</div>
<div class="number">${result.semverImpact.minimumBump.toUpperCase()}</div>
${renderSemverViolation(result)}
</div>
</div>
<p>${result.summary}</p>
</div>
${result.packages.map((pkg, index) => generatePackageSection(pkg, index)).join('')}
${result.packages.length === 0 ? '<div class="no-changes">No packages with changes found.</div>' : ''}
<div class="footer">
Generated by apisurf • ${timestamp}
</div>
</div>
<script>
// Toggle package sections
document.querySelectorAll('.package-header').forEach(header => {
header.addEventListener('click', () => {
const content = header.nextElementSibling;
const chevron = header.querySelector('.chevron');
content.classList.toggle('expanded');
chevron.classList.toggle('expanded');
});
});
// Toggle change details (only for items with chevrons)
document.querySelectorAll('.change-header').forEach(header => {
const chevron = header.querySelector('.chevron');
if (chevron) {
header.addEventListener('click', () => {
const details = header.nextElementSibling;
details.classList.toggle('expanded');
chevron.classList.toggle('expanded');
});
}
});
// Expand first package by default if it has changes
const firstPackage = document.querySelector('.package-content');
const firstChevron = document.querySelector('.package-header .chevron');
if (firstPackage) {
firstPackage.classList.add('expanded');
firstChevron.classList.add('expanded');
}
</script>
</body>
</html>`;
}
function generatePackageSection(pkg, _index) {
const hasChanges = pkg.breakingChanges.length > 0 || pkg.nonBreakingChanges.length > 0;
if (!hasChanges) {
return '';
}
return `
<div class="package-section">
<div class="package-header">
<h2 class="package-title">
${pkg.name}
${pkg.breakingChanges.length > 0 ? `<span class="badge breaking">${pkg.breakingChanges.length} breaking</span>` : ''}
${pkg.nonBreakingChanges.length > 0 ? `<span class="badge non-breaking">${pkg.nonBreakingChanges.length} non-breaking</span>` : ''}
</h2>
<span class="chevron">❯</span>
</div>
<div class="package-content">
${pkg.breakingChanges.length > 0 ? `
<div class="changes-section">
<h3>Breaking Changes</h3>
${pkg.breakingChanges.map(change => generateBreakingChangeItem(change)).join('')}
</div>
` : ''}
${pkg.nonBreakingChanges.length > 0 ? `
<div class="changes-section">
<h3>Non-Breaking Changes</h3>
${pkg.nonBreakingChanges.map(change => generateNonBreakingChangeItem(change)).join('')}
</div>
` : ''}
</div>
</div>`;
}
function generateBreakingChangeItem(change) {
// Check if we have actual code details to show
// Don't show details if:
// 1. No before value
// 2. Before is just a simple identifier (no spaces, parentheses, etc.)
// 3. The description already contains all the information
const isSimpleIdentifier = change.before && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(change.before.trim());
const hasDetails = change.before && !isSimpleIdentifier && change.before.length > 50;
if (!hasDetails) {
// No expandable details needed
return `
<div class="change-item">
<div class="change-header" style="cursor: default;">
<div>
<span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
</div>
</div>`;
}
// For complex types, provide a better diff view
if (change.type === 'type-changed' && change.after) {
return generateTypeDiffItem(change);
}
return `
<div class="change-item">
<div class="change-header">
<div>
<span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
<span class="chevron">❯</span>
</div>
<div class="change-details">
<div class="code-label">Before:</div>
<div class="code-block code-before">${highlightTypeScript(change.before)}</div>
${change.after ? `
<div class="code-label">After:</div>
<div class="code-block code-after">${highlightTypeScript(change.after)}</div>
` : ''}
</div>
</div>`;
}
// Generate a better diff view for type changes
function generateTypeDiffItem(change) {
const before = stripAnsiCodes(change.before);
const after = stripAnsiCodes(change.after || '');
// Parse the type definitions to find differences
const diff = analyzeTypeDifferences(before, after);
// If no specific changes were detected, it means parsing failed
if (diff.html === '<div class="no-changes">No specific changes detected</div>') {
console.warn(`Failed to parse granular diff for: ${change.description}`);
console.warn('Before:', before.substring(0, 200) + (before.length > 200 ? '...' : ''));
console.warn('After:', after.substring(0, 200) + (after.length > 200 ? '...' : ''));
// For debugging - let's see what kind of definition this is
if (!before.includes('{') || !after.includes('{')) {
console.warn('Missing braces in type definition');
}
// Fall back - show minimal info with debug details
return `
<div class="change-item">
<div class="change-header">
<div>
<span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
<span class="chevron">❯</span>
</div>
<div class="change-details">
<div class="diff-note">Unable to parse detailed changes. The signature has been modified.</div>
<span class="debug-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">Show full signatures</span>
<div class="debug-info">
Parsing failed - showing full signatures:
Before:
${before}
After:
${after}
</div>
</div>
</div>`;
}
return `
<div class="change-item">
<div class="change-header">
<div>
<span class="change-type breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
<span class="chevron">❯</span>
</div>
<div class="change-details">
${diff.html}
<span class="debug-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">Show debug info</span>
<div class="debug-info">
Type: ${change.type}
Description: ${stripAnsiCodes(change.description)}
Before signature:
${before}
After signature:
${after}
Parsed diff:
${JSON.stringify(diff, null, 2)}
</div>
</div>
</div>`;
}
// Analyze differences between two type definitions
function analyzeTypeDifferences(before, after) {
// Check if it's an enum
if (before.includes('enum ') || after.includes('enum ')) {
return analyzeEnumDifferences(before, after);
}
// Check if it's a class or interface (including export/declare modifiers)
const classOrInterfaceRegex = /\b(class|interface)\s+\w+/;
if (classOrInterfaceRegex.test(before) || classOrInterfaceRegex.test(after)) {
return analyzeClassOrInterfaceDifferences(before, after);
}
// Check if it's a function signature
if (before.includes('(') && after.includes('(')) {
return analyzeFunctionDifferences(before, after);
}
// Default to showing before/after
return {
html: `
<div class="code-label">Before:</div>
<div class="code-block code-before">${highlightTypeScript(before)}</div>
<div class="code-label">After:</div>
<div class="code-block code-after">${highlightTypeScript(after)}</div>
`
};
}
// Analyze enum differences
function analyzeEnumDifferences(before, after) {
// Extract enum values
const beforeValues = extractEnumValues(before);
const afterValues = extractEnumValues(after);
const removed = beforeValues.filter(v => !afterValues.includes(v));
const added = afterValues.filter(v => !beforeValues.includes(v));
let html = '';
if (removed.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">❌ Removed Values</h4>
<ul class="diff-list removed">
${removed.map(v => `<li>${escapeHtml(v)}</li>`).join('')}
</ul>
</div>
`;
}
if (added.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">✅ Added Values</h4>
<ul class="diff-list added">
${added.map(v => `<li>${escapeHtml(v)}</li>`).join('')}
</ul>
</div>
`;
}
return { html: html || '<div class="no-changes">No specific changes detected</div>' };
}
// Normalize a signature for comparison (remove comments and extra whitespace)
function normalizeForComparison(signature) {
// Remove single-line comments
let normalized = signature.replace(/\/\/.*$/gm, '');
// Remove multi-line comments
normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, '');
// Collapse multiple spaces/newlines into single spaces
normalized = normalized.replace(/\s+/g, ' ');
// Trim whitespace
return normalized.trim();
}
// Extract enum values from enum definition
function extractEnumValues(enumDef) {
const values = [];
// Find the opening brace
const startIdx = enumDef.indexOf('{');
if (startIdx === -1)
return values;
// Find the matching closing brace
let braceCount = 0;
let endIdx = -1;
for (let i = startIdx; i < enumDef.length; i++) {
if (enumDef[i] === '{')
braceCount++;
if (enumDef[i] === '}')
braceCount--;
if (braceCount === 0) {
endIdx = i;
break;
}
}
if (endIdx === -1)
return values;
const enumBody = enumDef.substring(startIdx + 1, endIdx);
const lines = enumBody.split(/[,\n]/).map(line => line.trim()).filter(Boolean);
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Remove inline comments but keep the rest of the line
line = line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '').trim();
// Skip empty lines or lines that were only comments
if (!line || line.startsWith('/*'))
continue;
// Match enum values (with or without assignment)
const match = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:=|,|$)/);
if (match) {
values.push(match[1]);
}
}
return values;
}
// Analyze class or interface differences
function analyzeClassOrInterfaceDifferences(before, after) {
// Extract properties and methods
const beforeMembers = extractClassMembers(before);
const afterMembers = extractClassMembers(after);
const removedProps = beforeMembers.properties.filter(p => !afterMembers.properties.some(ap => ap.name === p.name));
const addedProps = afterMembers.properties.filter(p => !beforeMembers.properties.some(bp => bp.name === p.name));
const changedProps = beforeMembers.properties.filter(p => {
const afterProp = afterMembers.properties.find(ap => ap.name === p.name);
if (!afterProp)
return false;
// Normalize signatures to ignore comment changes
const beforeNorm = normalizeForComparison(p.signature);
const afterNorm = normalizeForComparison(afterProp.signature);
return beforeNorm !== afterNorm;
});
const removedMethods = beforeMembers.methods.filter(m => !afterMembers.methods.some(am => am.name === m.name));
const addedMethods = afterMembers.methods.filter(m => !beforeMembers.methods.some(bm => bm.name === m.name));
const changedMethods = beforeMembers.methods.filter(m => {
const afterMethod = afterMembers.methods.find(am => am.name === m.name);
if (!afterMethod)
return false;
// Normalize signatures to ignore comment changes
const beforeNorm = normalizeForComparison(m.signature);
const afterNorm = normalizeForComparison(afterMethod.signature);
return beforeNorm !== afterNorm;
});
let html = '';
if (removedProps.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">❌ Removed Properties</h4>
<ul class="diff-list removed">
${removedProps.map(p => `<li><code>${highlightTypeScript(p.signature)}</code></li>`).join('')}
</ul>
</div>
`;
}
if (removedMethods.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">❌ Removed Methods</h4>
<ul class="diff-list removed">
${removedMethods.map(m => `<li><code>${highlightTypeScript(m.signature)}</code></li>`).join('')}
</ul>
</div>
`;
}
if (changedProps.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">🔄 Changed Properties</h4>
${changedProps.map(p => {
const afterProp = afterMembers.properties.find(ap => ap.name === p.name);
return `
<div class="diff-item">
<div class="diff-item-name">${escapeHtml(p.name)}</div>
<div class="diff-before">Before: <code>${highlightTypeScript(p.signature)}</code></div>
<div class="diff-after">After: <code>${highlightTypeScript(afterProp.signature)}</code></div>
</div>
`;
}).join('')}
</div>
`;
}
if (changedMethods.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">🔄 Changed Method Signatures</h4>
${changedMethods.map(m => {
const afterMethod = afterMembers.methods.find(am => am.name === m.name);
return `
<div class="diff-item">
<div class="diff-item-name">${escapeHtml(m.name)}</div>
<div class="diff-before">Before: <code>${highlightTypeScript(m.signature)}</code></div>
<div class="diff-after">After: <code>${highlightTypeScript(afterMethod.signature)}</code></div>
</div>
`;
}).join('')}
</div>
`;
}
if (addedProps.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">✅ Added Properties</h4>
<ul class="diff-list added">
${addedProps.map(p => `<li><code>${highlightTypeScript(p.signature)}</code></li>`).join('')}
</ul>
</div>
`;
}
if (addedMethods.length > 0) {
html += `
<div class="diff-section">
<h4 class="diff-section-title">✅ Added Methods</h4>
<ul class="diff-list added">
${addedMethods.map(m => `<li><code>${highlightTypeScript(m.signature)}</code></li>`).join('')}
</ul>
</div>
`;
}
return { html: html || '<div class="no-changes">No specific changes detected</div>' };
}
// Extract class/interface members
function extractClassMembers(classDef) {
const properties = [];
const methods = [];
// Remove block comments but preserve structure
let cleanedDef = classDef;
// Remove JSDoc comments while preserving line structure
cleanedDef = cleanedDef.replace(/\/\*\*[\s\S]*?\*\//g, (match) => {
// Replace with same number of newlines to preserve line numbers
const newlines = match.match(/\n/g) || [];
return newlines.join('');
});
// Find the opening brace
const startIdx = cleanedDef.indexOf('{');
if (startIdx === -1)
return { properties, methods };
// Find the matching closing brace
let braceCount = 0;
let endIdx = -1;
for (let i = startIdx; i < cleanedDef.length; i++) {
if (cleanedDef[i] === '{')
braceCount++;
if (cleanedDef[i] === '}')
braceCount--;
if (braceCount === 0) {
endIdx = i;
break;
}
}
if (endIdx === -1)
return { properties, methods };
const body = cleanedDef.substring(startIdx + 1, endIdx);
// Split by lines and process each potential property/method
const lines = body.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('//') || line.startsWith('/*')) {
i++;
continue;
}
// Check if this line contains a property or method declaration
// Remove modifiers for matching
let cleanLine = line;
const modifiers = /(public|private|protected|static|readonly|async|abstract|override|get|set)\s+/g;
cleanLine = cleanLine.replace(modifiers, '');
// Match property: name: type or name?: type
const propMatch = cleanLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\??\s*:/);
if (propMatch) {
// Get the full property declaration (might span multiple lines)
let fullDeclaration = line;
let j = i + 1;
// If line doesn't end with semicolon, look for it on next lines
while (!fullDeclaration.includes(';') && j < lines.length) {
fullDeclaration += ' ' + lines[j].trim();
j++;
}
// Remove the semicolon and any trailing whitespace
fullDeclaration = fullDeclaration.replace(/;.*$/, '').trim();
properties.push({
name: propMatch[1],
signature: fullDeclaration
});
i = j;
continue;
}
// Match method: name() or name<T>()
const methodMatch = cleanLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:<[^>]+>)?\s*\(/);
if (methodMatch) {
// Get the full method declaration
let fullDeclaration = line;
let j = i + 1;
let parenCount = 1; // We already found the opening paren
// Find the closing parenthesis
let foundEnd = false;
for (let k = line.indexOf('(') + 1; k < line.length; k++) {
if (line[k] === '(')
parenCount++;
if (line[k] === ')')
parenCount--;
if (parenCount === 0) {
foundEnd = true;
break;
}
}
// If not found on this line, check next lines
while (!foundEnd && j < lines.length) {
const nextLine = lines[j].trim();
fullDeclaration += ' ' + nextLine;
for (let k = 0; k < nextLine.length; k++) {
if (nextLine[k] === '(')
parenCount++;
if (nextLine[k] === ')')
parenCount--;
if (parenCount === 0) {
foundEnd = true;
break;
}
}
j++;
}
// Find the end of the return type (usually ends with ; or {)
while (j < lines.length && !fullDeclaration.includes(';') && !fullDeclaration.includes('{')) {
fullDeclaration += ' ' + lines[j].trim();
j++;
}
// Remove any body or semicolon
fullDeclaration = fullDeclaration.replace(/[;{].*$/, '').trim();
methods.push({
name: methodMatch[1],
signature: fullDeclaration
});
i = j;
continue;
}
i++;
}
return { properties, methods };
}
// Analyze function signature differences
function analyzeFunctionDifferences(before, after) {
// Extract function name and parameters
const beforeMatch = before.match(/^([^(]+)\(([^)]*)\)/);
const afterMatch = after.match(/^([^(]+)\(([^)]*)\)/);
if (!beforeMatch || !afterMatch) {
return {
html: `
<div class="code-label">Before:</div>
<div class="code-block code-before">${highlightTypeScript(before)}</div>
<div class="code-label">After:</div>
<div class="code-block code-after">${highlightTypeScript(after)}</div>
`
};
}
const beforeParams = beforeMatch[2].split(',').map(p => p.trim()).filter(Boolean);
const afterParams = afterMatch[2].split(',').map(p => p.trim()).filter(Boolean);
let html = '<div class="diff-section">';
html += '<h4 class="diff-section-title">Function Signature Changed</h4>';
if (beforeParams.length !== afterParams.length) {
html += `<p class="diff-note">Parameter count changed: ${beforeParams.length} → ${afterParams.length}</p>`;
}
html += `
<div class="diff-item">
<div class="diff-before">Before: <code>${highlightTypeScript(before)}</code></div>
<div class="diff-after">After: <code>${highlightTypeScript(after)}</code></div>
</div>
`;
html += '</div>';
return { html };
}
function generateNonBreakingChangeItem(change) {
// Check if we have actual details to show
// Don't show details if:
// 1. No details value
// 2. Details is just a simple identifier (no spaces, parentheses, etc.)
// 3. The description already contains all the information
const isSimpleIdentifier = change.details && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(change.details.trim());
const hasDetails = change.details && !isSimpleIdentifier && change.details.length > 50;
// For type-updated changes, try to show a diff view
if (change.type === 'type-updated' && hasDetails) {
const details = stripAnsiCodes(change.details);
// Check if it's an enum
if (details.includes('enum ')) {
const enumValues = extractEnumValues(details);
return `
<div class="change-item">
<div class="change-header">
<div>
<span class="change-type non-breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
<span class="chevron">❯</span>
</div>
<div class="change-details">
<div class="diff-section">
<h4 class="diff-section-title">✅ Added Values</h4>
<ul class="diff-list added">
${enumValues.map((v) => `<li>${highlightTypeScript(v)}</li>`).join('')}
</ul>
</div>
<span class="debug-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">Show debug info</span>
<div class="debug-info">
Type: ${change.type}
Description: ${stripAnsiCodes(change.description)}
Full signature:
${details}
Extracted enum values: ${JSON.stringify(enumValues, null, 2)}
</div>
</div>
</div>`;
}
}
if (!hasDetails) {
// No expandable details needed
return `
<div class="change-item">
<div class="change-header" style="cursor: default;">
<div>
<span class="change-type non-breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
</div>
</div>`;
}
return `
<div class="change-item">
<div class="change-header">
<div>
<span class="change-type non-breaking">${change.type.replace(/-/g, ' ')}</span>
<span class="change-description">${formatDescriptionWithCode(change.description)}</span>
</div>
<span class="chevron">❯</span>
</div>
<div class="change-details">
<div class="code-block">${highlightTypeScript(change.details)}</div>
</div>
</div>`;
}
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Format a description string by converting code identifiers in quotes to code chicklets
*/
function formatDescriptionWithCode(description) {
// First strip ANSI codes
const stripped = stripAnsiCodes(description);
// Replace 'identifier' patterns with code chicklets BEFORE escaping HTML
// This regex matches single-quoted identifiers that look like code
const withChicklets = stripped.replace(/'([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)'/g, (_match, identifier) => `<code-chicklet>${identifier}</code-chicklet>`);
// Now escape HTML but preserve our special tags
const parts = withChicklets.split(/(<code-chicklet>[^<]+<\/code-chicklet>)/g);
const escaped = parts.map(part => {
if (part.startsWith('<code-chicklet>') && part.endsWith('</code-chicklet>')) {
// Don't escape our special tags
return part;
}
else {
// Escape HTML in regular text
return part.replace(/[&<>"']/g, m => {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return map[m];
});
}
}).join('');
// Replace our special tags with actual span elements
const formatted = escaped.replace(/<code-chicklet>([^<]+)<\/code-chicklet>/g, '<span class="code-chicklet">$1</span>');
return formatted;
}