mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 06:47:33 +00:00
[feat] Add automated API changelog generation workflow
Implements a GitHub Actions workflow that automatically generates API changelogs by comparing TypeScript type definitions between versions. Changes: - Add release-api-changelogs.yaml workflow triggered after npm types release - Create snapshot-api.js script to extract API surface from TypeScript defs - Create compare-api-snapshots.js to generate human-readable changelogs - Initialize docs/API-CHANGELOG.md to track public API changes The workflow: 1. Triggers after Release NPM Types workflow completes 2. Builds and snapshots current and previous API surfaces 3. Compares snapshots to detect additions, removals, and modifications 4. Generates formatted changelog with breaking changes highlighted 5. Creates draft PR for review before merging This automates documentation of breaking changes for extension developers without manual effort, supporting the large extension ecosystem. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
180
.github/workflows/release-api-changelogs.yaml
vendored
Normal file
180
.github/workflows/release-api-changelogs.yaml
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
name: Release API Changelogs
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Release NPM Types']
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: release-api-changelogs-${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
generate_changelog:
|
||||
name: Generate API Changelog
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on successful completion of the Release NPM Types workflow
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for comparing versions
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $VERSION"
|
||||
|
||||
- name: Get previous version
|
||||
id: previous_version
|
||||
run: |
|
||||
# Get all version tags sorted
|
||||
CURRENT_VERSION="${{ steps.current_version.outputs.version }}"
|
||||
TAGS=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$')
|
||||
|
||||
# Find the previous version tag (skip current if it exists)
|
||||
PREVIOUS_TAG=""
|
||||
for tag in $TAGS; do
|
||||
TAG_VERSION=${tag#v}
|
||||
if [ "$TAG_VERSION" != "$CURRENT_VERSION" ]; then
|
||||
PREVIOUS_TAG=$tag
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous version found, this may be the first release"
|
||||
echo "version=" >> $GITHUB_OUTPUT
|
||||
echo "tag=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=${PREVIOUS_TAG#v}" >> $GITHUB_OUTPUT
|
||||
echo "tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Previous version: ${PREVIOUS_TAG#v}"
|
||||
fi
|
||||
|
||||
- name: Build current types
|
||||
run: pnpm build:types
|
||||
|
||||
- name: Snapshot current API
|
||||
id: current_snapshot
|
||||
run: |
|
||||
# Create snapshots directory
|
||||
mkdir -p .api-snapshots
|
||||
|
||||
# Generate snapshot of current types
|
||||
node scripts/snapshot-api.js dist/index.d.ts > .api-snapshots/current.json
|
||||
|
||||
echo "Current API snapshot created"
|
||||
|
||||
- name: Checkout previous version
|
||||
if: steps.previous_version.outputs.tag != ''
|
||||
run: |
|
||||
# Stash current changes
|
||||
git stash
|
||||
|
||||
# Checkout previous version
|
||||
git checkout ${{ steps.previous_version.outputs.tag }}
|
||||
|
||||
- name: Build previous types
|
||||
if: steps.previous_version.outputs.tag != ''
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:types
|
||||
|
||||
- name: Snapshot previous API
|
||||
if: steps.previous_version.outputs.tag != ''
|
||||
run: |
|
||||
# Generate snapshot of previous types
|
||||
node scripts/snapshot-api.js dist/index.d.ts > .api-snapshots/previous.json
|
||||
|
||||
echo "Previous API snapshot created"
|
||||
|
||||
- name: Return to current version
|
||||
if: steps.previous_version.outputs.tag != ''
|
||||
run: |
|
||||
git checkout -
|
||||
git stash pop || true
|
||||
|
||||
- name: Compare API snapshots and generate changelog
|
||||
id: generate_changelog
|
||||
run: |
|
||||
# Create docs directory if it doesn't exist
|
||||
mkdir -p docs
|
||||
|
||||
# Run the comparison script
|
||||
if [ -f .api-snapshots/previous.json ]; then
|
||||
node scripts/compare-api-snapshots.js \
|
||||
.api-snapshots/previous.json \
|
||||
.api-snapshots/current.json \
|
||||
${{ steps.previous_version.outputs.version }} \
|
||||
${{ steps.current_version.outputs.version }} \
|
||||
>> docs/API-CHANGELOG.md
|
||||
else
|
||||
# First release - just document the initial API surface
|
||||
echo "## v${{ steps.current_version.outputs.version }} ($(date +%Y-%m-%d))" >> docs/API-CHANGELOG.md
|
||||
echo "" >> docs/API-CHANGELOG.md
|
||||
echo "Initial API release." >> docs/API-CHANGELOG.md
|
||||
echo "" >> docs/API-CHANGELOG.md
|
||||
fi
|
||||
|
||||
# Check if there are any changes
|
||||
if git diff --quiet docs/API-CHANGELOG.md; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "No API changes detected"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "API changes detected"
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.generate_changelog.outputs.has_changes == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[docs] Update API changelog for v${{ steps.current_version.outputs.version }}'
|
||||
title: '[docs] API Changelog for v${{ steps.current_version.outputs.version }}'
|
||||
body: |
|
||||
## API Changelog Update
|
||||
|
||||
This PR documents public API changes between v${{ steps.previous_version.outputs.version }} and v${{ steps.current_version.outputs.version }}.
|
||||
|
||||
The changelog has been automatically generated by comparing TypeScript type definitions between versions.
|
||||
|
||||
### Review Instructions
|
||||
- Review the changes in `docs/API-CHANGELOG.md`
|
||||
- Verify accuracy of breaking changes
|
||||
- Add any additional context or migration notes if needed
|
||||
- Merge when ready to publish changelog
|
||||
|
||||
---
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
branch: api-changelog-v${{ steps.current_version.outputs.version }}
|
||||
base: main
|
||||
labels: documentation
|
||||
delete-branch: true
|
||||
draft: true
|
||||
add-paths: |
|
||||
docs/API-CHANGELOG.md
|
||||
27
docs/API-CHANGELOG.md
Normal file
27
docs/API-CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Public API Changelog
|
||||
|
||||
This changelog documents changes to the ComfyUI Frontend public API surface across versions. The public API surface includes types, interfaces, and objects used by third-party extensions and custom nodes.
|
||||
|
||||
**Important**: This is an automatically generated changelog based on TypeScript type definitions. Breaking changes are marked with ⚠️.
|
||||
|
||||
## What is tracked
|
||||
|
||||
This changelog tracks changes to the following public API components exported from `@comfyorg/comfyui-frontend-types`:
|
||||
|
||||
- **Type Aliases**: Type definitions used by extensions
|
||||
- **Interfaces**: Object shapes and contracts
|
||||
- **Enums**: Enumerated values
|
||||
- **Functions**: Public utility functions
|
||||
- **Classes**: Exported classes and their public members
|
||||
- **Constants**: Public constant values
|
||||
|
||||
## Migration Guide
|
||||
|
||||
When breaking changes occur, refer to the specific version section below for:
|
||||
- What changed
|
||||
- Why it changed (if applicable)
|
||||
- How to migrate your code
|
||||
|
||||
---
|
||||
|
||||
<!-- Automated changelog entries will be added below -->
|
||||
418
scripts/compare-api-snapshots.js
Normal file
418
scripts/compare-api-snapshots.js
Normal file
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Compares two API snapshots and generates a human-readable changelog
|
||||
* documenting additions, removals, and modifications to the public API.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length < 4) {
|
||||
console.error(
|
||||
'Usage: compare-api-snapshots.js <previous.json> <current.json> <previous-version> <current-version>'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const [previousPath, currentPath, previousVersion, currentVersion] = args
|
||||
|
||||
if (!fs.existsSync(previousPath)) {
|
||||
console.error(`Previous snapshot not found: ${previousPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
console.error(`Current snapshot not found: ${currentPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const previousApi = JSON.parse(fs.readFileSync(previousPath, 'utf-8'))
|
||||
const currentApi = JSON.parse(fs.readFileSync(currentPath, 'utf-8'))
|
||||
|
||||
/**
|
||||
* Compare two API snapshots and generate changelog
|
||||
*/
|
||||
function compareApis(previous, current) {
|
||||
const changes = {
|
||||
breaking: [],
|
||||
additions: [],
|
||||
modifications: [],
|
||||
deprecations: []
|
||||
}
|
||||
|
||||
const categories = [
|
||||
'types',
|
||||
'interfaces',
|
||||
'enums',
|
||||
'functions',
|
||||
'classes',
|
||||
'constants'
|
||||
]
|
||||
|
||||
for (const category of categories) {
|
||||
const prevItems = previous[category] || {}
|
||||
const currItems = current[category] || {}
|
||||
|
||||
// Find additions
|
||||
for (const name in currItems) {
|
||||
if (!prevItems[name]) {
|
||||
changes.additions.push({
|
||||
category,
|
||||
name,
|
||||
item: currItems[name]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find removals and modifications
|
||||
for (const name in prevItems) {
|
||||
if (!currItems[name]) {
|
||||
changes.breaking.push({
|
||||
category,
|
||||
name,
|
||||
type: 'removed',
|
||||
item: prevItems[name]
|
||||
})
|
||||
} else {
|
||||
// Check for modifications
|
||||
const diff = compareItems(prevItems[name], currItems[name], category)
|
||||
if (diff.length > 0) {
|
||||
changes.modifications.push({
|
||||
category,
|
||||
name,
|
||||
changes: diff
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two items and return differences
|
||||
*/
|
||||
function compareItems(prev, curr, category) {
|
||||
const differences = []
|
||||
|
||||
if (category === 'interfaces' || category === 'classes') {
|
||||
// Compare members
|
||||
const prevMembers = new Map(prev.members?.map((m) => [m.name, m]) || [])
|
||||
const currMembers = new Map(curr.members?.map((m) => [m.name, m]) || [])
|
||||
|
||||
// Find added members
|
||||
for (const [name, member] of currMembers) {
|
||||
if (!prevMembers.has(name)) {
|
||||
differences.push({
|
||||
type: 'member_added',
|
||||
name,
|
||||
member
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed members
|
||||
for (const [name, member] of prevMembers) {
|
||||
if (!currMembers.has(name)) {
|
||||
differences.push({
|
||||
type: 'member_removed',
|
||||
name,
|
||||
member
|
||||
})
|
||||
} else {
|
||||
// Check if member type changed
|
||||
const prevMember = prevMembers.get(name)
|
||||
const currMember = currMembers.get(name)
|
||||
|
||||
if (prevMember.type !== currMember.type) {
|
||||
differences.push({
|
||||
type: 'member_type_changed',
|
||||
name,
|
||||
from: prevMember.type,
|
||||
to: currMember.type
|
||||
})
|
||||
}
|
||||
|
||||
if (prevMember.optional !== currMember.optional) {
|
||||
differences.push({
|
||||
type: 'member_optionality_changed',
|
||||
name,
|
||||
from: prevMember.optional ? 'optional' : 'required',
|
||||
to: currMember.optional ? 'optional' : 'required'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare methods (for classes and interfaces)
|
||||
if (category === 'classes') {
|
||||
const prevMethods = new Map(prev.methods?.map((m) => [m.name, m]) || [])
|
||||
const currMethods = new Map(curr.methods?.map((m) => [m.name, m]) || [])
|
||||
|
||||
for (const [name, method] of currMethods) {
|
||||
if (!prevMethods.has(name)) {
|
||||
differences.push({
|
||||
type: 'method_added',
|
||||
name,
|
||||
method
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, method] of prevMethods) {
|
||||
if (!currMethods.has(name)) {
|
||||
differences.push({
|
||||
type: 'method_removed',
|
||||
name,
|
||||
method
|
||||
})
|
||||
} else {
|
||||
const prevMethod = prevMethods.get(name)
|
||||
const currMethod = currMethods.get(name)
|
||||
|
||||
if (prevMethod.returnType !== currMethod.returnType) {
|
||||
differences.push({
|
||||
type: 'method_return_type_changed',
|
||||
name,
|
||||
from: prevMethod.returnType,
|
||||
to: currMethod.returnType
|
||||
})
|
||||
}
|
||||
|
||||
// Compare parameters
|
||||
if (
|
||||
JSON.stringify(prevMethod.parameters) !==
|
||||
JSON.stringify(currMethod.parameters)
|
||||
) {
|
||||
differences.push({
|
||||
type: 'method_signature_changed',
|
||||
name,
|
||||
from: prevMethod.parameters,
|
||||
to: currMethod.parameters
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (category === 'functions') {
|
||||
// Compare function signatures
|
||||
if (prev.returnType !== curr.returnType) {
|
||||
differences.push({
|
||||
type: 'return_type_changed',
|
||||
from: prev.returnType,
|
||||
to: curr.returnType
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(prev.parameters) !== JSON.stringify(curr.parameters)
|
||||
) {
|
||||
differences.push({
|
||||
type: 'parameters_changed',
|
||||
from: prev.parameters,
|
||||
to: curr.parameters
|
||||
})
|
||||
}
|
||||
} else if (category === 'enums') {
|
||||
// Compare enum members
|
||||
const prevMembers = new Set(prev.members?.map((m) => m.name) || [])
|
||||
const currMembers = new Set(curr.members?.map((m) => m.name) || [])
|
||||
|
||||
for (const member of currMembers) {
|
||||
if (!prevMembers.has(member)) {
|
||||
differences.push({
|
||||
type: 'enum_member_added',
|
||||
name: member
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const member of prevMembers) {
|
||||
if (!currMembers.has(member)) {
|
||||
differences.push({
|
||||
type: 'enum_member_removed',
|
||||
name: member
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return differences
|
||||
}
|
||||
|
||||
/**
|
||||
* Format changelog as markdown
|
||||
*/
|
||||
function formatChangelog(changes, prevVersion, currVersion) {
|
||||
const lines = []
|
||||
|
||||
lines.push(`## v${currVersion} (${new Date().toISOString().split('T')[0]})`)
|
||||
lines.push('')
|
||||
lines.push(
|
||||
`Comparing v${prevVersion} → v${currVersion}. This changelog documents changes to the public API surface that third-party extensions and custom nodes depend on.`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
// Breaking changes
|
||||
if (changes.breaking.length > 0) {
|
||||
lines.push('### ⚠️ Breaking Changes')
|
||||
lines.push('')
|
||||
|
||||
const grouped = groupByCategory(changes.breaking)
|
||||
for (const [category, items] of Object.entries(grouped)) {
|
||||
lines.push(`**${categoryToTitle(category)}**`)
|
||||
lines.push('')
|
||||
for (const item of items) {
|
||||
lines.push(`- **Removed**: \`${item.name}\``)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// Additions
|
||||
if (changes.additions.length > 0) {
|
||||
lines.push('### ✨ Additions')
|
||||
lines.push('')
|
||||
|
||||
const grouped = groupByCategory(changes.additions)
|
||||
for (const [category, items] of Object.entries(grouped)) {
|
||||
lines.push(`**${categoryToTitle(category)}**`)
|
||||
lines.push('')
|
||||
for (const item of items) {
|
||||
lines.push(`- \`${item.name}\``)
|
||||
if (item.item.members && item.item.members.length > 0) {
|
||||
const publicMembers = item.item.members.filter(
|
||||
(m) => !m.visibility || m.visibility === 'public'
|
||||
)
|
||||
if (publicMembers.length > 0 && publicMembers.length <= 5) {
|
||||
lines.push(
|
||||
` - Members: ${publicMembers.map((m) => `\`${m.name}\``).join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// Modifications
|
||||
if (changes.modifications.length > 0) {
|
||||
lines.push('### 🔄 Modifications')
|
||||
lines.push('')
|
||||
|
||||
const hasBreakingMods = changes.modifications.some((mod) =>
|
||||
mod.changes.some((c) => isBreakingChange(c))
|
||||
)
|
||||
|
||||
if (hasBreakingMods) {
|
||||
lines.push('> **Note**: Some modifications may be breaking changes.')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const grouped = groupByCategory(changes.modifications)
|
||||
for (const [category, items] of Object.entries(grouped)) {
|
||||
lines.push(`**${categoryToTitle(category)}**`)
|
||||
lines.push('')
|
||||
for (const item of items) {
|
||||
lines.push(`- \`${item.name}\``)
|
||||
for (const change of item.changes) {
|
||||
const formatted = formatChange(change)
|
||||
if (formatted) {
|
||||
lines.push(` ${formatted}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
changes.breaking.length === 0 &&
|
||||
changes.additions.length === 0 &&
|
||||
changes.modifications.length === 0
|
||||
) {
|
||||
lines.push('_No API changes detected._')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function groupByCategory(items) {
|
||||
const grouped = {}
|
||||
for (const item of items) {
|
||||
if (!grouped[item.category]) {
|
||||
grouped[item.category] = []
|
||||
}
|
||||
grouped[item.category].push(item)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
function categoryToTitle(category) {
|
||||
const titles = {
|
||||
types: 'Type Aliases',
|
||||
interfaces: 'Interfaces',
|
||||
enums: 'Enums',
|
||||
functions: 'Functions',
|
||||
classes: 'Classes',
|
||||
constants: 'Constants'
|
||||
}
|
||||
return titles[category] || category
|
||||
}
|
||||
|
||||
function isBreakingChange(change) {
|
||||
const breakingTypes = [
|
||||
'member_removed',
|
||||
'method_removed',
|
||||
'member_type_changed',
|
||||
'method_return_type_changed',
|
||||
'method_signature_changed',
|
||||
'return_type_changed',
|
||||
'parameters_changed',
|
||||
'enum_member_removed'
|
||||
]
|
||||
return breakingTypes.includes(change.type)
|
||||
}
|
||||
|
||||
function formatChange(change) {
|
||||
switch (change.type) {
|
||||
case 'member_added':
|
||||
return `- ✨ Added member: \`${change.name}\``
|
||||
case 'member_removed':
|
||||
return `- ⚠️ **Breaking**: Removed member: \`${change.name}\``
|
||||
case 'member_type_changed':
|
||||
return `- ⚠️ **Breaking**: Member \`${change.name}\` type changed: \`${change.from}\` → \`${change.to}\``
|
||||
case 'member_optionality_changed':
|
||||
return `- ${change.to === 'required' ? '⚠️ **Breaking**' : '✨'}: Member \`${change.name}\` is now ${change.to}`
|
||||
case 'method_added':
|
||||
return `- ✨ Added method: \`${change.name}()\``
|
||||
case 'method_removed':
|
||||
return `- ⚠️ **Breaking**: Removed method: \`${change.name}()\``
|
||||
case 'method_return_type_changed':
|
||||
return `- ⚠️ **Breaking**: Method \`${change.name}()\` return type changed: \`${change.from}\` → \`${change.to}\``
|
||||
case 'method_signature_changed':
|
||||
return `- ⚠️ **Breaking**: Method \`${change.name}()\` signature changed`
|
||||
case 'return_type_changed':
|
||||
return `- ⚠️ **Breaking**: Return type changed: \`${change.from}\` → \`${change.to}\``
|
||||
case 'parameters_changed':
|
||||
return `- ⚠️ **Breaking**: Function parameters changed`
|
||||
case 'enum_member_added':
|
||||
return `- ✨ Added enum value: \`${change.name}\``
|
||||
case 'enum_member_removed':
|
||||
return `- ⚠️ **Breaking**: Removed enum value: \`${change.name}\``
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const changes = compareApis(previousApi, currentApi)
|
||||
const changelog = formatChangelog(changes, previousVersion, currentVersion)
|
||||
|
||||
console.log(changelog)
|
||||
238
scripts/snapshot-api.js
Normal file
238
scripts/snapshot-api.js
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generates a JSON snapshot of the public API surface from TypeScript definitions.
|
||||
* This snapshot is used to track API changes between versions.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as ts from 'typescript'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: snapshot-api.js <path-to-index.d.ts>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const filePath = args[0]
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract API surface from TypeScript definitions
|
||||
*/
|
||||
function extractApiSurface(sourceFile) {
|
||||
const api = {
|
||||
types: {},
|
||||
interfaces: {},
|
||||
enums: {},
|
||||
functions: {},
|
||||
classes: {},
|
||||
constants: {}
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
// Extract type aliases
|
||||
if (ts.isTypeAliasDeclaration(node) && node.name) {
|
||||
const name = node.name.text
|
||||
api.types[name] = {
|
||||
kind: 'type',
|
||||
name,
|
||||
text: node.getText(sourceFile),
|
||||
exported: hasExportModifier(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract interfaces
|
||||
if (ts.isInterfaceDeclaration(node) && node.name) {
|
||||
const name = node.name.text
|
||||
const members = []
|
||||
|
||||
node.members.forEach((member) => {
|
||||
if (ts.isPropertySignature(member) && member.name) {
|
||||
members.push({
|
||||
name: member.name.getText(sourceFile),
|
||||
type: member.type ? member.type.getText(sourceFile) : 'any',
|
||||
optional: !!member.questionToken
|
||||
})
|
||||
} else if (ts.isMethodSignature(member) && member.name) {
|
||||
members.push({
|
||||
name: member.name.getText(sourceFile),
|
||||
kind: 'method',
|
||||
parameters: member.parameters.map((p) => ({
|
||||
name: p.name.getText(sourceFile),
|
||||
type: p.type ? p.type.getText(sourceFile) : 'any',
|
||||
optional: !!p.questionToken
|
||||
})),
|
||||
returnType: member.type ? member.type.getText(sourceFile) : 'void'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
api.interfaces[name] = {
|
||||
kind: 'interface',
|
||||
name,
|
||||
members,
|
||||
exported: hasExportModifier(node),
|
||||
heritage: node.heritageClauses
|
||||
? node.heritageClauses
|
||||
.map((clause) =>
|
||||
clause.types.map((type) => type.getText(sourceFile))
|
||||
)
|
||||
.flat()
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
// Extract enums
|
||||
if (ts.isEnumDeclaration(node) && node.name) {
|
||||
const name = node.name.text
|
||||
const members = node.members.map((member) => ({
|
||||
name: member.name.getText(sourceFile),
|
||||
value: member.initializer
|
||||
? member.initializer.getText(sourceFile)
|
||||
: undefined
|
||||
}))
|
||||
|
||||
api.enums[name] = {
|
||||
kind: 'enum',
|
||||
name,
|
||||
members,
|
||||
exported: hasExportModifier(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract functions
|
||||
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||
const name = node.name.text
|
||||
api.functions[name] = {
|
||||
kind: 'function',
|
||||
name,
|
||||
parameters: node.parameters.map((p) => ({
|
||||
name: p.name.getText(sourceFile),
|
||||
type: p.type ? p.type.getText(sourceFile) : 'any',
|
||||
optional: !!p.questionToken
|
||||
})),
|
||||
returnType: node.type ? node.type.getText(sourceFile) : 'any',
|
||||
exported: hasExportModifier(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract classes
|
||||
if (ts.isClassDeclaration(node) && node.name) {
|
||||
const name = node.name.text
|
||||
const members = []
|
||||
const methods = []
|
||||
|
||||
node.members.forEach((member) => {
|
||||
if (ts.isPropertyDeclaration(member) && member.name) {
|
||||
members.push({
|
||||
name: member.name.getText(sourceFile),
|
||||
type: member.type ? member.type.getText(sourceFile) : 'any',
|
||||
static: hasStaticModifier(member),
|
||||
visibility: getVisibility(member)
|
||||
})
|
||||
} else if (ts.isMethodDeclaration(member) && member.name) {
|
||||
methods.push({
|
||||
name: member.name.getText(sourceFile),
|
||||
parameters: member.parameters.map((p) => ({
|
||||
name: p.name.getText(sourceFile),
|
||||
type: p.type ? p.type.getText(sourceFile) : 'any',
|
||||
optional: !!p.questionToken
|
||||
})),
|
||||
returnType: member.type ? member.type.getText(sourceFile) : 'any',
|
||||
static: hasStaticModifier(member),
|
||||
visibility: getVisibility(member)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
api.classes[name] = {
|
||||
kind: 'class',
|
||||
name,
|
||||
members,
|
||||
methods,
|
||||
exported: hasExportModifier(node),
|
||||
heritage: node.heritageClauses
|
||||
? node.heritageClauses
|
||||
.map((clause) =>
|
||||
clause.types.map((type) => type.getText(sourceFile))
|
||||
)
|
||||
.flat()
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variable declarations (constants)
|
||||
if (ts.isVariableStatement(node)) {
|
||||
node.declarationList.declarations.forEach((decl) => {
|
||||
if (decl.name && ts.isIdentifier(decl.name)) {
|
||||
const name = decl.name.text
|
||||
api.constants[name] = {
|
||||
kind: 'constant',
|
||||
name,
|
||||
type: decl.type ? decl.type.getText(sourceFile) : 'unknown',
|
||||
exported: hasExportModifier(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
function hasExportModifier(node) {
|
||||
return (
|
||||
node.modifiers &&
|
||||
node.modifiers.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function hasStaticModifier(node) {
|
||||
return (
|
||||
node.modifiers &&
|
||||
node.modifiers.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.StaticKeyword
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getVisibility(node) {
|
||||
if (!node.modifiers) return 'public'
|
||||
if (
|
||||
node.modifiers.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.PrivateKeyword
|
||||
)
|
||||
)
|
||||
return 'private'
|
||||
if (
|
||||
node.modifiers.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.ProtectedKeyword
|
||||
)
|
||||
)
|
||||
return 'protected'
|
||||
return 'public'
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return api
|
||||
}
|
||||
|
||||
// Read and parse the file
|
||||
const sourceCode = fs.readFileSync(filePath, 'utf-8')
|
||||
const sourceFile = ts.createSourceFile(
|
||||
path.basename(filePath),
|
||||
sourceCode,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
)
|
||||
|
||||
const apiSurface = extractApiSurface(sourceFile)
|
||||
|
||||
// Output as JSON
|
||||
console.log(JSON.stringify(apiSurface, null, 2))
|
||||
Reference in New Issue
Block a user