@kosdev-code/kos-ui-cli 2.0.44 → 2.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +481 -4
- package/package.json +5 -3
- package/src/lib/cli.mjs +59 -467
- package/src/lib/generators/api/compare.mjs +139 -0
- package/src/lib/generators/api/generate.mjs +105 -0
- package/src/lib/generators/api/lib/compare-api.mjs +748 -0
- package/src/lib/generators/api/lib/generate-api.mjs +452 -0
- package/src/lib/generators/dev/index.mjs +437 -0
- package/src/lib/generators/env/index.mjs +1 -0
- package/src/lib/generators/kab/index.mjs +82 -0
- package/src/lib/generators/metadata.json +71 -2
- package/src/lib/generators/model/add-future.mjs +21 -5
- package/src/lib/generators/model/companion.mjs +24 -5
- package/src/lib/generators/model/container.mjs +24 -5
- package/src/lib/generators/model/context.mjs +22 -5
- package/src/lib/generators/model/hook.mjs +22 -5
- package/src/lib/generators/model/model.mjs +3 -1
- package/src/lib/generators/plugin/index.mjs +30 -3
- package/src/lib/generators/serve/index.mjs +74 -0
- package/src/lib/generators/version/index.mjs +182 -0
- package/src/lib/generators/workspace/index.mjs +13 -3
- package/src/lib/plopfile.mjs +12 -0
- package/src/lib/utils/cli-help-display.mjs +84 -0
- package/src/lib/utils/cli-help-utils.mjs +261 -0
- package/src/lib/utils/command-builder.mjs +94 -0
- package/src/lib/utils/dev-config.mjs +150 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getProjectDetails } from '../../../utils/nx-context.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compares two OpenAPI versions and generates a detailed compatibility report.
|
|
8
|
+
*
|
|
9
|
+
* Analyzes generated TypeScript type definitions (openapi.d.ts files) to detect
|
|
10
|
+
* breaking changes, new endpoints, and API modifications. Supports multiple output
|
|
11
|
+
* formats and can compare multiple app namespaces simultaneously.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} options - Comparison options
|
|
14
|
+
* @param {string} options.project - Project name containing generated API types
|
|
15
|
+
* @param {string} [options.app] - Single app namespace to compare (e.g., 'kos', 'dispense')
|
|
16
|
+
* @param {string[]} [options.apps] - Array of app namespaces for multi-app comparison
|
|
17
|
+
* @param {string} options.baseVersion - Base version identifier (e.g., 'v1.8.0')
|
|
18
|
+
* @param {string} options.targetVersion - Target version identifier (e.g., 'v1.9.0')
|
|
19
|
+
* @param {('console'|'json'|'markdown')} [options.format='console'] - Output format
|
|
20
|
+
* @param {string} [options.outputFile] - Optional file path to write output
|
|
21
|
+
* @returns {Promise<{compatible: boolean, results: Array}>} Comparison results with compatibility status
|
|
22
|
+
* @throws {Error} When project not found or required type files don't exist
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```javascript
|
|
26
|
+
* // Basic comparison
|
|
27
|
+
* const result = await compareApiVersions({
|
|
28
|
+
* project: 'device-models',
|
|
29
|
+
* app: 'kos',
|
|
30
|
+
* baseVersion: 'v1.8.0',
|
|
31
|
+
* targetVersion: 'v1.9.0',
|
|
32
|
+
* format: 'console'
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* if (!result.compatible) {
|
|
36
|
+
* console.error('Breaking changes detected!');
|
|
37
|
+
* process.exit(1);
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```javascript
|
|
43
|
+
* // Multi-app comparison with markdown output
|
|
44
|
+
* await compareApiVersions({
|
|
45
|
+
* project: 'device-models',
|
|
46
|
+
* apps: ['kos', 'dispense', 'freestyle'],
|
|
47
|
+
* baseVersion: 'v1.8.0',
|
|
48
|
+
* targetVersion: 'v1.9.0',
|
|
49
|
+
* format: 'markdown',
|
|
50
|
+
* outputFile: 'CHANGELOG-API.md'
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export async function compareApiVersions(options) {
|
|
55
|
+
const {
|
|
56
|
+
project,
|
|
57
|
+
app,
|
|
58
|
+
apps, // Array for multi-app comparison
|
|
59
|
+
baseVersion,
|
|
60
|
+
targetVersion,
|
|
61
|
+
format = 'console',
|
|
62
|
+
outputFile,
|
|
63
|
+
} = options;
|
|
64
|
+
|
|
65
|
+
// Get project configuration (following generate-api.mjs pattern exactly)
|
|
66
|
+
let projectConfig, src, outputPath;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
projectConfig = await getProjectDetails(project);
|
|
70
|
+
src = projectConfig.sourceRoot || projectConfig.root;
|
|
71
|
+
outputPath = projectConfig.targets?.api?.options?.outputPath;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`Project "${project}" not found in workspace: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Use custom output path if specified, otherwise default to src/utils
|
|
77
|
+
const utilsDir = outputPath ? join(src, outputPath) : join(src, "utils");
|
|
78
|
+
const servicesDir = join(utilsDir, "services");
|
|
79
|
+
|
|
80
|
+
// Determine which apps to compare
|
|
81
|
+
const appsToCompare = apps || [app];
|
|
82
|
+
const results = [];
|
|
83
|
+
|
|
84
|
+
// Compare each app
|
|
85
|
+
for (const appName of appsToCompare) {
|
|
86
|
+
const baseSpecPath = join(servicesDir, appName, baseVersion, 'openapi.d.ts');
|
|
87
|
+
const targetSpecPath = join(servicesDir, appName, targetVersion, 'openapi.d.ts');
|
|
88
|
+
|
|
89
|
+
// Validate files exist
|
|
90
|
+
if (!existsSync(baseSpecPath)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Base version not found: ${baseSpecPath}\n` +
|
|
93
|
+
`Generate it by running: kosui api:generate --project ${project}\n` +
|
|
94
|
+
`(Point to ${baseVersion} device first)`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!existsSync(targetSpecPath)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Target version not found: ${targetSpecPath}\n` +
|
|
101
|
+
`Generate it by running: kosui api:generate --project ${project}\n` +
|
|
102
|
+
`(Point to ${targetVersion} device first)`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Load and parse specs
|
|
107
|
+
const baseContent = readFileSync(baseSpecPath, 'utf-8');
|
|
108
|
+
const targetContent = readFileSync(targetSpecPath, 'utf-8');
|
|
109
|
+
|
|
110
|
+
const basePaths = extractPaths(baseContent);
|
|
111
|
+
const targetPaths = extractPaths(targetContent);
|
|
112
|
+
|
|
113
|
+
// Perform comparison
|
|
114
|
+
const comparison = compareSpecs(basePaths, targetPaths, {
|
|
115
|
+
app: appName,
|
|
116
|
+
baseVersion,
|
|
117
|
+
targetVersion,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
results.push(comparison);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Format and output results
|
|
124
|
+
const formatted = formatResults(results, format);
|
|
125
|
+
|
|
126
|
+
if (outputFile) {
|
|
127
|
+
writeFileSync(outputFile, formatted, 'utf-8');
|
|
128
|
+
} else {
|
|
129
|
+
console.log(formatted);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Return exit code based on compatibility
|
|
133
|
+
const hasBreakingChanges = results.some(r => !r.summary.compatible);
|
|
134
|
+
process.exitCode = hasBreakingChanges ? 1 : 0;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
compatible: !hasBreakingChanges,
|
|
138
|
+
results,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extracts API paths and their HTTP methods from OpenAPI TypeScript type definitions.
|
|
144
|
+
*
|
|
145
|
+
* Parses the `export interface paths` section of a generated openapi.d.ts file
|
|
146
|
+
* to build a map of endpoint paths to their available HTTP methods and parameters.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} content - Content of openapi.d.ts file
|
|
149
|
+
* @returns {Map<string, Map<string, Object>>} Map of paths to methods with parameter info
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```javascript
|
|
153
|
+
* const content = readFileSync('openapi.d.ts', 'utf-8');
|
|
154
|
+
* const paths = extractPaths(content);
|
|
155
|
+
* // paths.get('/api/kos/device/status') → Map { 'get' => { params: {...} } }
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
function extractPaths(content) {
|
|
159
|
+
const paths = new Map();
|
|
160
|
+
const pathsMatch = content.match(/export interface paths \{([\s\S]*?)\n\}/);
|
|
161
|
+
|
|
162
|
+
if (!pathsMatch) {
|
|
163
|
+
return paths;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pathsContent = pathsMatch[1];
|
|
167
|
+
const lines = pathsContent.split('\n');
|
|
168
|
+
let currentPath = null;
|
|
169
|
+
let braceCount = 0;
|
|
170
|
+
let pathContent = '';
|
|
171
|
+
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
// Match path property (quoted string at start of line)
|
|
174
|
+
const pathMatch = line.match(/^\s*"([^"]+)":\s*\{/);
|
|
175
|
+
|
|
176
|
+
if (pathMatch && braceCount === 0) {
|
|
177
|
+
currentPath = pathMatch[1];
|
|
178
|
+
braceCount = 1;
|
|
179
|
+
pathContent = line;
|
|
180
|
+
} else if (currentPath) {
|
|
181
|
+
pathContent += '\n' + line;
|
|
182
|
+
braceCount += (line.match(/\{/g) || []).length;
|
|
183
|
+
braceCount -= (line.match(/\}/g) || []).length;
|
|
184
|
+
|
|
185
|
+
if (braceCount === 0) {
|
|
186
|
+
const methods = extractMethods(pathContent);
|
|
187
|
+
paths.set(currentPath, methods);
|
|
188
|
+
currentPath = null;
|
|
189
|
+
pathContent = '';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return paths;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Extracts HTTP methods and their parameters from a path definition string.
|
|
199
|
+
*
|
|
200
|
+
* Parses the TypeScript definition for a single path to identify available
|
|
201
|
+
* HTTP methods (GET, POST, PUT, DELETE, PATCH) and their query, path, and
|
|
202
|
+
* body parameters.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} pathDef - TypeScript definition string for a single path
|
|
205
|
+
* @returns {Map<string, Object>} Map of method names to parameter definitions
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```javascript
|
|
209
|
+
* const pathDef = `"/api/kos/device/status": {
|
|
210
|
+
* get: { parameters: { query: { detailed?: boolean } } }
|
|
211
|
+
* }`;
|
|
212
|
+
* const methods = extractMethods(pathDef);
|
|
213
|
+
* // methods.get('get') → { params: { query: [...], path: [...], body: '...' } }
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
function extractMethods(pathDef) {
|
|
217
|
+
const methods = new Map();
|
|
218
|
+
const methodNames = ['get', 'post', 'put', 'delete', 'patch'];
|
|
219
|
+
|
|
220
|
+
for (const methodName of methodNames) {
|
|
221
|
+
// Match method property (unquoted identifier with any indentation)
|
|
222
|
+
const methodRegex = new RegExp(`^\\s*${methodName}:\\s*\\{`, 'gm');
|
|
223
|
+
const match = methodRegex.exec(pathDef);
|
|
224
|
+
|
|
225
|
+
if (match) {
|
|
226
|
+
const startIdx = match.index;
|
|
227
|
+
let braceCount = 1;
|
|
228
|
+
let endIdx = startIdx + match[0].length;
|
|
229
|
+
|
|
230
|
+
for (let i = endIdx; i < pathDef.length; i++) {
|
|
231
|
+
if (pathDef[i] === '{') braceCount++;
|
|
232
|
+
if (pathDef[i] === '}') braceCount--;
|
|
233
|
+
if (braceCount === 0) {
|
|
234
|
+
endIdx = i + 1;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const methodDef = pathDef.substring(startIdx, endIdx);
|
|
240
|
+
|
|
241
|
+
const params = {
|
|
242
|
+
query: extractParameters(methodDef, 'query'),
|
|
243
|
+
path: extractParameters(methodDef, 'path'),
|
|
244
|
+
body: extractRequestBody(methodDef)
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
methods.set(methodName, { params });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return methods;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Extracts parameters of a specific type from an HTTP method definition.
|
|
256
|
+
*
|
|
257
|
+
* Parses query or path parameters from the TypeScript definition, identifying
|
|
258
|
+
* parameter names and whether they are required or optional.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} methodDef - TypeScript definition for an HTTP method
|
|
261
|
+
* @param {('query'|'path')} paramType - Type of parameters to extract
|
|
262
|
+
* @returns {Array<{name: string, required: boolean}>} Array of parameter definitions
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```javascript
|
|
266
|
+
* const methodDef = `get: { parameters: { query: { id: string, optional?: string } } }`;
|
|
267
|
+
* const params = extractParameters(methodDef, 'query');
|
|
268
|
+
* // Returns: [{ name: 'id', required: true }, { name: 'optional', required: false }]
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
function extractParameters(methodDef, paramType) {
|
|
272
|
+
const params = [];
|
|
273
|
+
const paramMatch = methodDef.match(new RegExp(`${paramType}[?]?:\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm'));
|
|
274
|
+
|
|
275
|
+
if (paramMatch) {
|
|
276
|
+
const paramContent = paramMatch[1];
|
|
277
|
+
const paramRegex = /(\w+)(\?)?:/g;
|
|
278
|
+
let match;
|
|
279
|
+
|
|
280
|
+
while ((match = paramRegex.exec(paramContent)) !== null) {
|
|
281
|
+
if (match[1] !== 'never' && match[1] !== 'header' && match[1] !== 'cookie') {
|
|
282
|
+
params.push({
|
|
283
|
+
name: match[1],
|
|
284
|
+
required: !match[2]
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return params;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Checks if an HTTP method definition includes a request body.
|
|
295
|
+
*
|
|
296
|
+
* Determines whether the endpoint expects a request body by checking for
|
|
297
|
+
* the presence of requestBody schema in the TypeScript definition.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} methodDef - TypeScript definition for an HTTP method
|
|
300
|
+
* @returns {('present'|'none')} Whether a request body is expected
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```javascript
|
|
304
|
+
* const methodDef = `post: { requestBody: { schema: {...} } }`;
|
|
305
|
+
* extractRequestBody(methodDef); // Returns: 'present'
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
function extractRequestBody(methodDef) {
|
|
309
|
+
const bodyMatch = methodDef.match(/requestBody[:\?]\s*\{[\s\S]*?schema:/);
|
|
310
|
+
return bodyMatch ? 'present' : 'none';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Compares parameters between two API versions to detect changes.
|
|
315
|
+
*
|
|
316
|
+
* Identifies added, removed, and modified parameters, classifying changes
|
|
317
|
+
* as either breaking (removed params, new required params) or safe
|
|
318
|
+
* (new optional params, optional becoming required).
|
|
319
|
+
*
|
|
320
|
+
* @param {Array<{name: string, required: boolean}>} baseParams - Parameters from base version
|
|
321
|
+
* @param {Array<{name: string, required: boolean}>} targetParams - Parameters from target version
|
|
322
|
+
* @returns {Array<{type: string, param: string, severity: string}>} Array of parameter changes
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```javascript
|
|
326
|
+
* const base = [{ name: 'id', required: true }];
|
|
327
|
+
* const target = [{ name: 'id', required: false }, { name: 'filter', required: true }];
|
|
328
|
+
* const changes = compareParameters(base, target);
|
|
329
|
+
* // Returns changes including:
|
|
330
|
+
* // - { type: 'changed', param: 'id', change: 'now optional', severity: 'safe' }
|
|
331
|
+
* // - { type: 'added', param: 'filter', required: true, severity: 'breaking' }
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
function compareParameters(baseParams, targetParams) {
|
|
335
|
+
const changes = [];
|
|
336
|
+
const baseNames = new Set(baseParams.map(p => p.name));
|
|
337
|
+
const targetNames = new Set(targetParams.map(p => p.name));
|
|
338
|
+
|
|
339
|
+
// Removed parameters (breaking)
|
|
340
|
+
for (const param of baseParams) {
|
|
341
|
+
if (!targetNames.has(param.name)) {
|
|
342
|
+
changes.push({ type: 'removed', param: param.name, severity: 'breaking' });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Added parameters
|
|
347
|
+
for (const param of targetParams) {
|
|
348
|
+
if (!baseNames.has(param.name)) {
|
|
349
|
+
const severity = param.required ? 'breaking' : 'safe';
|
|
350
|
+
changes.push({ type: 'added', param: param.name, required: param.required, severity });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Changed required status
|
|
355
|
+
for (const baseParam of baseParams) {
|
|
356
|
+
const targetParam = targetParams.find(p => p.name === baseParam.name);
|
|
357
|
+
if (targetParam && baseParam.required !== targetParam.required) {
|
|
358
|
+
const severity = targetParam.required ? 'breaking' : 'safe';
|
|
359
|
+
changes.push({
|
|
360
|
+
type: 'changed',
|
|
361
|
+
param: baseParam.name,
|
|
362
|
+
change: targetParam.required ? 'now required' : 'now optional',
|
|
363
|
+
severity
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return changes;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Performs comprehensive comparison of two OpenAPI specifications.
|
|
373
|
+
*
|
|
374
|
+
* Analyzes paths, methods, and parameters to generate a detailed compatibility
|
|
375
|
+
* report including breaking changes, new endpoints, and modifications. The
|
|
376
|
+
* comparison identifies API evolution and determines backward compatibility.
|
|
377
|
+
*
|
|
378
|
+
* @param {Map<string, Map<string, Object>>} basePaths - Parsed paths from base version
|
|
379
|
+
* @param {Map<string, Map<string, Object>>} targetPaths - Parsed paths from target version
|
|
380
|
+
* @param {Object} metadata - Version metadata (app, baseVersion, targetVersion)
|
|
381
|
+
* @returns {Object} Comprehensive comparison results with summary and detailed changes
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```javascript
|
|
385
|
+
* const basePaths = extractPaths(baseContent);
|
|
386
|
+
* const targetPaths = extractPaths(targetContent);
|
|
387
|
+
* const results = compareSpecs(basePaths, targetPaths, {
|
|
388
|
+
* app: 'kos',
|
|
389
|
+
* baseVersion: 'v1.8.0',
|
|
390
|
+
* targetVersion: 'v1.9.0'
|
|
391
|
+
* });
|
|
392
|
+
*
|
|
393
|
+
* console.log(`Compatible: ${results.summary.compatible}`);
|
|
394
|
+
* console.log(`Breaking changes: ${results.summary.breakingChanges.length}`);
|
|
395
|
+
* ```
|
|
396
|
+
*/
|
|
397
|
+
function compareSpecs(basePaths, targetPaths, metadata) {
|
|
398
|
+
const results = {
|
|
399
|
+
metadata,
|
|
400
|
+
summary: {
|
|
401
|
+
totalPathsBase: basePaths.size,
|
|
402
|
+
totalPathsTarget: targetPaths.size,
|
|
403
|
+
addedPaths: [],
|
|
404
|
+
removedPaths: [],
|
|
405
|
+
modifiedPaths: [],
|
|
406
|
+
compatible: true,
|
|
407
|
+
breakingChanges: []
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Find added paths
|
|
412
|
+
for (const [path, methods] of targetPaths) {
|
|
413
|
+
if (!basePaths.has(path)) {
|
|
414
|
+
results.summary.addedPaths.push({
|
|
415
|
+
path,
|
|
416
|
+
methods: Array.from(methods.keys()).map(m => m.toUpperCase())
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Find removed paths (breaking)
|
|
422
|
+
for (const [path, methods] of basePaths) {
|
|
423
|
+
if (!targetPaths.has(path)) {
|
|
424
|
+
results.summary.removedPaths.push({
|
|
425
|
+
path,
|
|
426
|
+
methods: Array.from(methods.keys()).map(m => m.toUpperCase())
|
|
427
|
+
});
|
|
428
|
+
results.summary.compatible = false;
|
|
429
|
+
results.summary.breakingChanges.push({
|
|
430
|
+
path,
|
|
431
|
+
change: `${Array.from(methods.keys()).map(m => m.toUpperCase()).join(', ')} removed`
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Find modified paths
|
|
437
|
+
for (const [path, baseMethods] of basePaths) {
|
|
438
|
+
if (!targetPaths.has(path)) continue;
|
|
439
|
+
|
|
440
|
+
const targetMethods = targetPaths.get(path);
|
|
441
|
+
const changes = [];
|
|
442
|
+
|
|
443
|
+
// Check for removed methods (breaking)
|
|
444
|
+
for (const [method, baseMethodDef] of baseMethods) {
|
|
445
|
+
if (!targetMethods.has(method)) {
|
|
446
|
+
changes.push(`${method.toUpperCase()} method removed`);
|
|
447
|
+
results.summary.compatible = false;
|
|
448
|
+
results.summary.breakingChanges.push({
|
|
449
|
+
path,
|
|
450
|
+
change: `${method.toUpperCase()} method removed`
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Check for added methods (safe)
|
|
456
|
+
for (const [method] of targetMethods) {
|
|
457
|
+
if (!baseMethods.has(method)) {
|
|
458
|
+
changes.push(`Added ${method.toUpperCase()} method`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Check for parameter changes
|
|
463
|
+
for (const [method, baseMethodDef] of baseMethods) {
|
|
464
|
+
const targetMethodDef = targetMethods.get(method);
|
|
465
|
+
if (!targetMethodDef) continue;
|
|
466
|
+
|
|
467
|
+
// Query parameters
|
|
468
|
+
const queryChanges = compareParameters(baseMethodDef.params.query, targetMethodDef.params.query);
|
|
469
|
+
for (const change of queryChanges) {
|
|
470
|
+
const changeDesc = `${method.toUpperCase()} query parameter "${change.param}" ${change.type}${change.change ? ` (${change.change})` : ''}`;
|
|
471
|
+
|
|
472
|
+
if (change.severity === 'breaking') {
|
|
473
|
+
results.summary.compatible = false;
|
|
474
|
+
results.summary.breakingChanges.push({ path, change: changeDesc });
|
|
475
|
+
} else {
|
|
476
|
+
changes.push(changeDesc);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Path parameters
|
|
481
|
+
const pathChanges = compareParameters(baseMethodDef.params.path, targetMethodDef.params.path);
|
|
482
|
+
for (const change of pathChanges) {
|
|
483
|
+
const changeDesc = `${method.toUpperCase()} path parameter "${change.param}" ${change.type}${change.change ? ` (${change.change})` : ''}`;
|
|
484
|
+
|
|
485
|
+
if (change.severity === 'breaking') {
|
|
486
|
+
results.summary.compatible = false;
|
|
487
|
+
results.summary.breakingChanges.push({ path, change: changeDesc });
|
|
488
|
+
} else {
|
|
489
|
+
changes.push(changeDesc);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Request body changes
|
|
494
|
+
if (baseMethodDef.params.body !== targetMethodDef.params.body) {
|
|
495
|
+
const changeDesc = targetMethodDef.params.body === 'present' && baseMethodDef.params.body === 'none'
|
|
496
|
+
? `${method.toUpperCase()} request body now required`
|
|
497
|
+
: `${method.toUpperCase()} request body changed`;
|
|
498
|
+
|
|
499
|
+
const severity = targetMethodDef.params.body === 'present' && baseMethodDef.params.body === 'none' ? 'breaking' : 'safe';
|
|
500
|
+
|
|
501
|
+
if (severity === 'breaking') {
|
|
502
|
+
results.summary.compatible = false;
|
|
503
|
+
results.summary.breakingChanges.push({ path, change: changeDesc });
|
|
504
|
+
} else {
|
|
505
|
+
changes.push(changeDesc);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (changes.length > 0) {
|
|
511
|
+
results.summary.modifiedPaths.push({ path, changes });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return results;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Formats comparison results according to the specified output format.
|
|
520
|
+
*
|
|
521
|
+
* Delegates to format-specific functions to generate console, JSON, or
|
|
522
|
+
* markdown output suitable for different use cases (terminal display,
|
|
523
|
+
* automation, documentation).
|
|
524
|
+
*
|
|
525
|
+
* @param {Array<Object>} results - Array of comparison results (one per app)
|
|
526
|
+
* @param {('console'|'json'|'markdown')} format - Desired output format
|
|
527
|
+
* @returns {string} Formatted comparison output
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* ```javascript
|
|
531
|
+
* const results = [{ metadata: {...}, summary: {...} }];
|
|
532
|
+
* const output = formatResults(results, 'markdown');
|
|
533
|
+
* writeFileSync('CHANGELOG.md', output);
|
|
534
|
+
* ```
|
|
535
|
+
*/
|
|
536
|
+
function formatResults(results, format) {
|
|
537
|
+
switch (format) {
|
|
538
|
+
case 'json':
|
|
539
|
+
return formatJson(results);
|
|
540
|
+
case 'markdown':
|
|
541
|
+
return formatMarkdown(results);
|
|
542
|
+
case 'console':
|
|
543
|
+
default:
|
|
544
|
+
return formatConsole(results);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Formats comparison results as JSON for machine-readable output.
|
|
550
|
+
*
|
|
551
|
+
* Generates a structured JSON representation suitable for automation,
|
|
552
|
+
* CI/CD pipelines, and programmatic analysis.
|
|
553
|
+
*
|
|
554
|
+
* @param {Array<Object>} results - Array of comparison results
|
|
555
|
+
* @returns {string} JSON-formatted comparison data
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* ```javascript
|
|
559
|
+
* const json = formatJson(results);
|
|
560
|
+
* const parsed = JSON.parse(json);
|
|
561
|
+
* console.log(`Breaking changes: ${parsed[0].summary.breakingChanges.length}`);
|
|
562
|
+
* ```
|
|
563
|
+
*/
|
|
564
|
+
function formatJson(results) {
|
|
565
|
+
return JSON.stringify(results, null, 2);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Formats comparison results as Markdown for documentation and release notes.
|
|
570
|
+
*
|
|
571
|
+
* Generates well-structured markdown with sections for summary, breaking changes,
|
|
572
|
+
* new endpoints, removed endpoints, and modifications. Suitable for changelogs,
|
|
573
|
+
* release notes, and technical documentation.
|
|
574
|
+
*
|
|
575
|
+
* @param {Array<Object>} results - Array of comparison results
|
|
576
|
+
* @returns {string} Markdown-formatted comparison report
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* ```javascript
|
|
580
|
+
* const markdown = formatMarkdown(results);
|
|
581
|
+
* writeFileSync('CHANGELOG-API.md', markdown);
|
|
582
|
+
* ```
|
|
583
|
+
*/
|
|
584
|
+
function formatMarkdown(results) {
|
|
585
|
+
let output = [];
|
|
586
|
+
|
|
587
|
+
for (const result of results) {
|
|
588
|
+
const { metadata, summary } = result;
|
|
589
|
+
|
|
590
|
+
output.push(`# API Changes: ${metadata.app} (${metadata.baseVersion} → ${metadata.targetVersion})\n`);
|
|
591
|
+
|
|
592
|
+
// Summary
|
|
593
|
+
output.push(`## Summary\n`);
|
|
594
|
+
output.push(`- Total endpoints: ${summary.totalPathsBase} → ${summary.totalPathsTarget}`);
|
|
595
|
+
output.push(`- Added: ${summary.addedPaths.length}`);
|
|
596
|
+
output.push(`- Removed: ${summary.removedPaths.length}`);
|
|
597
|
+
output.push(`- Modified: ${summary.modifiedPaths.length}`);
|
|
598
|
+
output.push(`- Breaking changes: ${summary.breakingChanges.length}`);
|
|
599
|
+
output.push(`- **Status**: ${summary.compatible ? '✅ Backward compatible' : '❌ Not backward compatible'}\n`);
|
|
600
|
+
|
|
601
|
+
// Breaking changes
|
|
602
|
+
if (summary.breakingChanges.length > 0) {
|
|
603
|
+
output.push(`## Breaking Changes\n`);
|
|
604
|
+
for (const change of summary.breakingChanges) {
|
|
605
|
+
output.push(`- \`${change.path}\` - ${change.change}`);
|
|
606
|
+
}
|
|
607
|
+
output.push('');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// New endpoints
|
|
611
|
+
if (summary.addedPaths.length > 0) {
|
|
612
|
+
output.push(`## New Endpoints\n`);
|
|
613
|
+
for (const { path, methods } of summary.addedPaths) {
|
|
614
|
+
output.push(`- \`${path}\` - ${methods.join(', ')}`);
|
|
615
|
+
}
|
|
616
|
+
output.push('');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Removed endpoints
|
|
620
|
+
if (summary.removedPaths.length > 0) {
|
|
621
|
+
output.push(`## Removed Endpoints\n`);
|
|
622
|
+
for (const { path, methods } of summary.removedPaths) {
|
|
623
|
+
output.push(`- \`${path}\` - ${methods.join(', ')}`);
|
|
624
|
+
}
|
|
625
|
+
output.push('');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Modified endpoints
|
|
629
|
+
if (summary.modifiedPaths.length > 0) {
|
|
630
|
+
output.push(`## Modified Endpoints (Non-Breaking)\n`);
|
|
631
|
+
for (const { path, changes } of summary.modifiedPaths) {
|
|
632
|
+
output.push(`- \`${path}\` - ${changes.join(', ')}`);
|
|
633
|
+
}
|
|
634
|
+
output.push('');
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return output.join('\n');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Formats comparison results with colored terminal output for human readability.
|
|
643
|
+
*
|
|
644
|
+
* Generates visually appealing console output with color-coded sections,
|
|
645
|
+
* compatibility status, and actionable recommendations. Uses chalk for
|
|
646
|
+
* terminal color support.
|
|
647
|
+
*
|
|
648
|
+
* @param {Array<Object>} results - Array of comparison results
|
|
649
|
+
* @returns {string} Colored terminal output with ASCII decorations
|
|
650
|
+
*
|
|
651
|
+
* @example
|
|
652
|
+
* ```javascript
|
|
653
|
+
* const output = formatConsole(results);
|
|
654
|
+
* console.log(output); // Displays colored comparison in terminal
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
function formatConsole(results) {
|
|
658
|
+
let output = [];
|
|
659
|
+
|
|
660
|
+
for (const result of results) {
|
|
661
|
+
const { metadata, summary } = result;
|
|
662
|
+
|
|
663
|
+
// Header
|
|
664
|
+
output.push(chalk.gray('═'.repeat(63)));
|
|
665
|
+
output.push(chalk.bold.cyan('API Compatibility Analysis\n'));
|
|
666
|
+
output.push(chalk.white(`App: ${metadata.app}`));
|
|
667
|
+
output.push(chalk.white(`Base Version: ${metadata.baseVersion}`));
|
|
668
|
+
output.push(chalk.white(`Target Version: ${metadata.targetVersion}`));
|
|
669
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
670
|
+
|
|
671
|
+
// Compatibility status
|
|
672
|
+
if (summary.compatible) {
|
|
673
|
+
output.push(chalk.green.bold('BACKWARD COMPATIBLE\n'));
|
|
674
|
+
} else {
|
|
675
|
+
output.push(chalk.red.bold('BREAKING CHANGES DETECTED\n'));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
679
|
+
|
|
680
|
+
// Summary
|
|
681
|
+
output.push(chalk.bold('Summary\n'));
|
|
682
|
+
output.push(chalk.white(`Total Paths in Base: ${summary.totalPathsBase}`));
|
|
683
|
+
output.push(chalk.white(`Total Paths in Target: ${summary.totalPathsTarget}`));
|
|
684
|
+
output.push(chalk.white(`Added Paths: ${summary.addedPaths.length}`));
|
|
685
|
+
output.push(chalk.white(`Removed Paths: ${summary.removedPaths.length}`));
|
|
686
|
+
output.push(chalk.white(`Modified Paths: ${summary.modifiedPaths.length}`));
|
|
687
|
+
output.push(chalk.white(`Breaking Changes: ${summary.breakingChanges.length}`));
|
|
688
|
+
output.push('');
|
|
689
|
+
|
|
690
|
+
// Breaking changes
|
|
691
|
+
if (summary.breakingChanges.length > 0) {
|
|
692
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
693
|
+
output.push(chalk.red.bold('BREAKING CHANGES\n'));
|
|
694
|
+
for (const change of summary.breakingChanges) {
|
|
695
|
+
output.push(chalk.yellow(`${change.path}`) + chalk.white(` - ${change.change}`));
|
|
696
|
+
}
|
|
697
|
+
output.push('');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// New endpoints
|
|
701
|
+
if (summary.addedPaths.length > 0) {
|
|
702
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
703
|
+
output.push(chalk.green.bold('NEW ENDPOINTS\n'));
|
|
704
|
+
for (const { path, methods } of summary.addedPaths) {
|
|
705
|
+
output.push(chalk.cyan(path) + chalk.white(` - ${methods.join(', ')}`));
|
|
706
|
+
}
|
|
707
|
+
output.push('');
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Removed endpoints
|
|
711
|
+
if (summary.removedPaths.length > 0) {
|
|
712
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
713
|
+
output.push(chalk.red.bold('REMOVED ENDPOINTS\n'));
|
|
714
|
+
for (const { path, methods } of summary.removedPaths) {
|
|
715
|
+
output.push(chalk.red(path) + chalk.white(` - ${methods.join(', ')}`));
|
|
716
|
+
}
|
|
717
|
+
output.push('');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Modified endpoints
|
|
721
|
+
if (summary.modifiedPaths.length > 0) {
|
|
722
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
723
|
+
output.push(chalk.blue.bold('MODIFIED ENDPOINTS (Non-Breaking)\n'));
|
|
724
|
+
for (const { path, changes } of summary.modifiedPaths) {
|
|
725
|
+
output.push(chalk.cyan(path) + chalk.white(` - ${changes.join(', ')}`));
|
|
726
|
+
}
|
|
727
|
+
output.push('');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
output.push(chalk.gray('═'.repeat(63)) + '\n');
|
|
731
|
+
|
|
732
|
+
// Recommendations
|
|
733
|
+
output.push(chalk.bold('Recommendations\n'));
|
|
734
|
+
if (!summary.compatible) {
|
|
735
|
+
output.push(chalk.yellow('Migration Required - Breaking changes detected'));
|
|
736
|
+
output.push(chalk.white('Review all breaking changes before upgrading'));
|
|
737
|
+
} else if (summary.addedPaths.length > 0) {
|
|
738
|
+
output.push(chalk.green('Safe to upgrade - No breaking changes'));
|
|
739
|
+
output.push(chalk.white('New endpoints available to use'));
|
|
740
|
+
} else {
|
|
741
|
+
output.push(chalk.green('Versions are identical or fully compatible'));
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
output.push('\n' + chalk.gray('═'.repeat(63)));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return output.join('\n');
|
|
748
|
+
}
|