diff --git a/scripts/api-diff.js b/scripts/api-diff.js
index d8247df..5297e5e 100644
--- a/scripts/api-diff.js
+++ b/scripts/api-diff.js
@@ -14,13 +14,21 @@ const changes = {
};
// Helper function to track component references
-function findComponentRefs(obj, components) {
+function findComponentRefs(obj, components, spec = currentSpec) {
if (!obj) return;
if (typeof obj === 'object') {
if (obj['$ref'] && obj['$ref'].startsWith('#/components/')) {
- components.add(obj['$ref'].split('/').pop());
+ const componentName = obj['$ref'].split('/').pop();
+ components.add(componentName);
+
+ // Follow the reference to check nested components
+ const [_, category, name] = obj['$ref'].split('/');
+ const referencedComponent = spec.components?.[category]?.[name];
+ if (referencedComponent) {
+ findComponentRefs(referencedComponent, components, spec);
+ }
}
- Object.values(obj).forEach(value => findComponentRefs(value, components));
+ Object.values(obj).forEach(value => findComponentRefs(value, components, spec));
}
}
@@ -31,9 +39,18 @@ function compareComponents() {
for (const [category, components] of Object.entries(currComps)) {
for (const [name, def] of Object.entries(components)) {
- if (!prevComps[category]?.[name] ||
- JSON.stringify(prevComps[category][name]) !== JSON.stringify(def)) {
+ const prevDef = prevComps[category]?.[name];
+ if (!prevDef || JSON.stringify(prevDef) !== JSON.stringify(def)) {
changes.components.add(name);
+
+ // Also check which components reference this changed component
+ Object.entries(currComps[category] || {}).forEach(([otherName, otherDef]) => {
+ const refsSet = new Set();
+ findComponentRefs(otherDef, refsSet);
+ if (refsSet.has(name)) {
+ changes.components.add(otherName);
+ }
+ });
}
}
}
@@ -110,6 +127,53 @@ function getChanges(previous, current) {
return changes;
}
+// Helper function to check if a schema references a component or its dependencies
+function schemaReferencesComponent(schema, componentName, visitedRefs = new Set()) {
+ if (!schema) return false;
+
+ // Prevent infinite recursion
+ const schemaKey = JSON.stringify(schema);
+ if (visitedRefs.has(schemaKey)) return false;
+ visitedRefs.add(schemaKey);
+
+ // Direct reference check
+ if (schema.$ref) {
+ const refPath = schema.$ref;
+ if (refPath === `#/components/schemas/${componentName}`) return true;
+
+ // Follow the reference to check nested components
+ const [_, category, name] = refPath.split('/');
+ const referencedComponent = currentSpec.components?.[category]?.[name];
+ if (referencedComponent && schemaReferencesComponent(referencedComponent, componentName, visitedRefs)) {
+ return true;
+ }
+ }
+
+ // Check combiners (oneOf, anyOf, allOf)
+ for (const combiner of ['oneOf', 'anyOf', 'allOf']) {
+ if (schema[combiner] && Array.isArray(schema[combiner])) {
+ if (schema[combiner].some(s => schemaReferencesComponent(s, componentName, visitedRefs))) {
+ return true;
+ }
+ }
+ }
+
+ // Check properties if it's an object
+ if (schema.properties) {
+ if (Object.values(schema.properties).some(prop =>
+ schemaReferencesComponent(prop, componentName, visitedRefs))) {
+ return true;
+ }
+ }
+
+ // Check array items
+ if (schema.items && schemaReferencesComponent(schema.items, componentName, visitedRefs)) {
+ return true;
+ }
+
+ return false;
+}
+
// Helper function to detect where a component is used in an endpoint
function findComponentUsage(details, componentName) {
const usage = [];
@@ -117,26 +181,37 @@ function findComponentUsage(details, componentName) {
// Check parameters
if (details.parameters) {
const hasComponent = details.parameters.some(p =>
- (p.$ref && p.$ref.includes(componentName)) ||
- (p.schema && p.schema.$ref && p.schema.$ref.includes(componentName))
+ (p.$ref && schemaReferencesComponent({ $ref: p.$ref }, componentName)) ||
+ (p.schema && schemaReferencesComponent(p.schema, componentName))
);
if (hasComponent) usage.push('parameters');
}
// Check requestBody
- if (details.requestBody &&
- details.requestBody.content &&
- Object.values(details.requestBody.content).some(c =>
- c.schema && c.schema.$ref && c.schema.$ref.includes(componentName))) {
- usage.push('requestBody');
+ if (details.requestBody) {
+ let hasComponent = false;
+ if (details.requestBody.$ref) {
+ hasComponent = schemaReferencesComponent({ $ref: details.requestBody.$ref }, componentName);
+ } else if (details.requestBody.content) {
+ hasComponent = Object.values(details.requestBody.content).some(c =>
+ c.schema && schemaReferencesComponent(c.schema, componentName)
+ );
+ }
+ if (hasComponent) usage.push('requestBody');
}
// Check responses
- if (details.responses &&
- Object.values(details.responses).some(r =>
- r.content && Object.values(r.content).some(c =>
- c.schema && c.schema.$ref && c.schema.$ref.includes(componentName)))) {
- usage.push('responses');
+ if (details.responses) {
+ const hasComponent = Object.entries(details.responses).some(([code, r]) => {
+ if (r.$ref) return schemaReferencesComponent({ $ref: r.$ref }, componentName);
+ if (r.content) {
+ return Object.values(r.content).some(c =>
+ c.schema && schemaReferencesComponent(c.schema, componentName)
+ );
+ }
+ return false;
+ });
+ if (hasComponent) usage.push('responses');
}
return usage;
@@ -160,56 +235,88 @@ function generateReleaseNotes() {
sections.push(section);
}
- // Modified endpoints
- if (Object.keys(changes.modified).length > 0 || Object.keys(changes.affectedByComponents).length > 0) {
- let section = '## Modified\n';
+ // Helper function to generate route modification details
+ function generateModifiedRouteDetails(path, changes) {
+ let details = '';
+ const methodsToProcess = new Set();
- // Combine and sort all modified paths
- const allModifiedPaths = new Set([
- ...Object.keys(changes.modified),
- ...Object.keys(changes.affectedByComponents)
- ]);
+ // Collect all affected methods
+ if (changes.modified[path]) {
+ changes.modified[path].forEach(({method}) => methodsToProcess.add(method));
+ }
+ if (changes.affectedByComponents[path]) {
+ changes.affectedByComponents[path].methods.forEach(method => methodsToProcess.add(method));
+ }
- Array.from(allModifiedPaths)
+ // Process each method
+ Array.from(methodsToProcess)
.sort()
- .forEach(path => {
- // Handle both direct modifications and component changes for each path
- const methodsToProcess = new Set();
+ .forEach(method => {
+ details += `- [${method}] \`${path}\`\n`;
- // Collect all affected methods
- if (changes.modified[path]) {
- changes.modified[path].forEach(({method}) => methodsToProcess.add(method));
+ // Add direct changes
+ const directChanges = changes.modified[path]?.find(m => m.method === method);
+ if (directChanges) {
+ directChanges.changes.sort().forEach(change => {
+ details += ` - ${change}\n`;
+ });
}
- if (changes.affectedByComponents[path]) {
- changes.affectedByComponents[path].methods.forEach(method => methodsToProcess.add(method));
+
+ // Add component changes
+ if (changes.affectedByComponents[path]?.methods.has(method)) {
+ const methodDetails = currentSpec.paths[path][method.toLowerCase()];
+ Array.from(changes.affectedByComponents[path].components)
+ .sort()
+ .forEach(component => {
+ const usageLocations = findComponentUsage(methodDetails, component).sort();
+ details += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`;
+ });
}
+ });
+ return details;
+ }
- // Process each method
- Array.from(methodsToProcess)
- .sort()
- .forEach(method => {
- section += `- [${method}] \`${path}\`\n`;
-
- // Add direct changes
- const directChanges = changes.modified[path]?.find(m => m.method === method);
- if (directChanges) {
- directChanges.changes.sort().forEach(change => {
- section += ` - ${change}\n`;
- });
- }
-
- // Add component changes
- if (changes.affectedByComponents[path]?.methods.has(method)) {
- const methodDetails = currentSpec.paths[path][method.toLowerCase()];
- Array.from(changes.affectedByComponents[path].components)
- .sort()
- .forEach(component => {
- const usageLocations = findComponentUsage(methodDetails, component).sort();
- section += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`;
- });
- }
- });
+ // Modified endpoints
+ if (Object.keys(changes.modified).length > 0 || Object.keys(changes.affectedByComponents).length > 0) {
+ let section = '## Modified\n';
+
+ // First show all directly modified paths
+ const directlyModifiedPaths = Object.keys(changes.modified).sort();
+ directlyModifiedPaths.forEach(path => {
+ section += generateModifiedRouteDetails(path, changes);
+ });
+
+ // Then show component-affected paths (but not ones that were directly modified)
+ const componentAffectedEntries = Object.entries(changes.affectedByComponents)
+ .filter(([path]) => !changes.modified[path]) // Only paths not already shown above
+ .flatMap(([path, details]) =>
+ Array.from(details.methods).map(method => ({path, method}))
+ )
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
+
+ // Show first 5 component-affected method-path combinations
+ const visibleEntries = componentAffectedEntries.slice(0, 5);
+ const processedPaths = new Set();
+
+ visibleEntries.forEach(({path}) => {
+ if (!processedPaths.has(path)) {
+ section += generateModifiedRouteDetails(path, changes);
+ processedPaths.add(path);
+ }
+ });
+
+ // Collapse any remaining entries
+ const remainingEntries = componentAffectedEntries.slice(5);
+ if (remainingEntries.length > 0) {
+ section += '\nShow more routes affected by component changes...
\n\n';
+ const remainingPaths = new Set();
+ remainingEntries.forEach(({path}) => remainingPaths.add(path));
+ Array.from(remainingPaths).sort().forEach(path => {
+ section += generateModifiedRouteDetails(path, changes);
});
+ section += ' \n';
+ }
+
sections.push(section);
}
diff --git a/tests/fixtures/collapse-modified-components/current.json b/tests/fixtures/collapse-modified-components/current.json
new file mode 100644
index 0000000..145520a
--- /dev/null
+++ b/tests/fixtures/collapse-modified-components/current.json
@@ -0,0 +1,240 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Test API",
+ "description": "A sample API for testing"
+ },
+ "servers": [
+ {
+ "url": "https://api.example.com/v1"
+ }
+ ],
+ "paths": {
+ "/user": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test1": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test2": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test3": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test4": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test5": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test6": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "User": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ },
+ "email": {
+ "$ref": "#/components/schemas/Email"
+ }
+ }
+ },
+ "Email": {
+ "type": "string",
+ "format": "email"
+ },
+ "NewUser": {
+ "type": "object",
+ "required": [
+ "name",
+ "email"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/collapse-modified-components/expected.md b/tests/fixtures/collapse-modified-components/expected.md
new file mode 100644
index 0000000..99c062e
--- /dev/null
+++ b/tests/fixtures/collapse-modified-components/expected.md
@@ -0,0 +1,19 @@
+## Modified
+- [POST] `/user`
+ - `User` modified in responses
+- [POST] `/user/test1`
+ - `User` modified in responses
+- [POST] `/user/test2`
+ - `User` modified in responses
+- [POST] `/user/test3`
+ - `User` modified in responses
+- [POST] `/user/test4`
+ - `User` modified in responses
+
+Show more routes affected by component changes...
+
+- [POST] `/user/test5`
+ - `User` modified in responses
+- [POST] `/user/test6`
+ - `User` modified in responses
+
diff --git a/tests/fixtures/collapse-modified-components/previous.json b/tests/fixtures/collapse-modified-components/previous.json
new file mode 100644
index 0000000..474c40f
--- /dev/null
+++ b/tests/fixtures/collapse-modified-components/previous.json
@@ -0,0 +1,240 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Test API",
+ "description": "A sample API for testing"
+ },
+ "servers": [
+ {
+ "url": "https://api.example.com/v1"
+ }
+ ],
+ "paths": {
+ "/user": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test1": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test2": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test3": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test4": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test5": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/test6": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "User": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "$ref": "#/components/schemas/Email"
+ }
+ }
+ },
+ "Email": {
+ "type": "string",
+ "format": "email"
+ },
+ "NewUser": {
+ "type": "object",
+ "required": [
+ "name",
+ "email"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/modified-component/expected.md b/tests/fixtures/modified-component/expected.md
index 33ef17f..3ec4df5 100644
--- a/tests/fixtures/modified-component/expected.md
+++ b/tests/fixtures/modified-component/expected.md
@@ -1,4 +1,4 @@
## Modified
- [POST] `/user`
- `NewUser` modified in requestBody
- - `User` modified in requestBody, responses
+ - `User` modified in responses
diff --git a/tests/fixtures/modified-nested-components/current.json b/tests/fixtures/modified-nested-components/current.json
new file mode 100644
index 0000000..a041f8d
--- /dev/null
+++ b/tests/fixtures/modified-nested-components/current.json
@@ -0,0 +1,78 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Test API",
+ "description": "A sample API for testing"
+ },
+ "servers": [
+ {
+ "url": "https://api.example.com/v1"
+ }
+ ],
+ "paths": {
+ "/user": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "User": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "$ref": "#/components/schemas/Email"
+ }
+ }
+ },
+ "Email": {
+ "type": "string",
+ "format": "email"
+ },
+ "NewUser": {
+ "type": "object",
+ "required": [
+ "name",
+ "email"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/modified-nested-components/expected.md b/tests/fixtures/modified-nested-components/expected.md
new file mode 100644
index 0000000..46e8ee1
--- /dev/null
+++ b/tests/fixtures/modified-nested-components/expected.md
@@ -0,0 +1,3 @@
+## Modified
+- [POST] `/user`
+ - `User` modified in responses
diff --git a/tests/fixtures/modified-nested-components/previous.json b/tests/fixtures/modified-nested-components/previous.json
new file mode 100644
index 0000000..ded4bdc
--- /dev/null
+++ b/tests/fixtures/modified-nested-components/previous.json
@@ -0,0 +1,77 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Test API",
+ "description": "A sample API for testing"
+ },
+ "servers": [
+ {
+ "url": "https://api.example.com/v1"
+ }
+ ],
+ "paths": {
+ "/user": {
+ "post": {
+ "summary": "Create a new user profile",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NewUser"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "User created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "User": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "$ref": "#/components/schemas/Email"
+ }
+ }
+ },
+ "Email": {
+ "type": "string"
+ },
+ "NewUser": {
+ "type": "object",
+ "required": [
+ "name",
+ "email"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}