mcp-server-diff 2.1.0 → 2.1.6
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 +116 -1
- package/dist/cli/index.js +226 -17
- package/dist/index.js +32 -5
- package/package.json +6 -1
- package/.github/dependabot.yml +0 -21
- package/.github/workflows/ci.yml +0 -51
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/release.yml +0 -51
- package/.prettierignore +0 -3
- package/.prettierrc +0 -8
- package/CONTRIBUTING.md +0 -81
- package/action.yml +0 -250
- package/eslint.config.mjs +0 -47
- package/jest.config.mjs +0 -26
- package/src/__tests__/fixtures/http-server.ts +0 -103
- package/src/__tests__/fixtures/stdio-server.ts +0 -158
- package/src/__tests__/integration.test.ts +0 -306
- package/src/__tests__/runner.test.ts +0 -430
- package/src/cli.ts +0 -421
- package/src/diff.ts +0 -252
- package/src/git.ts +0 -262
- package/src/index.ts +0 -284
- package/src/logger.ts +0 -93
- package/src/probe.ts +0 -327
- package/src/reporter.ts +0 -214
- package/src/runner.ts +0 -902
- package/src/types.ts +0 -155
- package/tsconfig.json +0 -30
package/src/cli.ts
DELETED
|
@@ -1,421 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP Server Diff - CLI Entry Point
|
|
3
|
-
*
|
|
4
|
-
* Standalone CLI for diffing MCP server public interfaces.
|
|
5
|
-
* Can compare any two servers or multiple servers against a base.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { parseArgs } from "node:util";
|
|
9
|
-
import * as fs from "fs";
|
|
10
|
-
import { probeServer } from "./probe.js";
|
|
11
|
-
import { compareProbeResults, extractCounts, type DiffResult } from "./diff.js";
|
|
12
|
-
import { ConsoleLogger, QuietLogger, setLogger, log } from "./logger.js";
|
|
13
|
-
import type { ProbeResult, PrimitiveCounts } from "./types.js";
|
|
14
|
-
|
|
15
|
-
interface ServerConfig {
|
|
16
|
-
name: string;
|
|
17
|
-
transport: "stdio" | "streamable-http";
|
|
18
|
-
start_command?: string;
|
|
19
|
-
server_url?: string;
|
|
20
|
-
headers?: Record<string, string>;
|
|
21
|
-
env_vars?: Record<string, string>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface DiffConfig {
|
|
25
|
-
base: ServerConfig;
|
|
26
|
-
targets: ServerConfig[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface ComparisonResult {
|
|
30
|
-
base: string;
|
|
31
|
-
target: string;
|
|
32
|
-
hasDifferences: boolean;
|
|
33
|
-
diffs: DiffResult[];
|
|
34
|
-
baseCounts: PrimitiveCounts;
|
|
35
|
-
targetCounts: PrimitiveCounts;
|
|
36
|
-
error?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Parse command line arguments
|
|
41
|
-
*/
|
|
42
|
-
function parseCliArgs() {
|
|
43
|
-
const { values, positionals } = parseArgs({
|
|
44
|
-
options: {
|
|
45
|
-
base: { type: "string", short: "b" },
|
|
46
|
-
target: { type: "string", short: "t" },
|
|
47
|
-
config: { type: "string", short: "c" },
|
|
48
|
-
output: { type: "string", short: "o", default: "summary" },
|
|
49
|
-
verbose: { type: "boolean", short: "v", default: false },
|
|
50
|
-
quiet: { type: "boolean", short: "q", default: false },
|
|
51
|
-
help: { type: "boolean", short: "h", default: false },
|
|
52
|
-
version: { type: "boolean", default: false },
|
|
53
|
-
},
|
|
54
|
-
allowPositionals: true,
|
|
55
|
-
strict: true,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
return { values, positionals };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Print help message
|
|
63
|
-
*/
|
|
64
|
-
function printHelp(): void {
|
|
65
|
-
console.log(`
|
|
66
|
-
mcp-server-diff - Diff MCP server public interfaces
|
|
67
|
-
|
|
68
|
-
USAGE:
|
|
69
|
-
mcp-server-diff [OPTIONS]
|
|
70
|
-
mcp-server-diff --base "python -m server" --target "node dist/stdio.js"
|
|
71
|
-
mcp-server-diff --config servers.json
|
|
72
|
-
|
|
73
|
-
OPTIONS:
|
|
74
|
-
-b, --base <command> Base server command (stdio) or URL (http)
|
|
75
|
-
-t, --target <command> Target server command (stdio) or URL (http)
|
|
76
|
-
-c, --config <file> Config file with base and targets
|
|
77
|
-
-o, --output <format> Output format: json, markdown, summary (default: summary)
|
|
78
|
-
-v, --verbose Verbose output
|
|
79
|
-
-q, --quiet Quiet mode (only output diffs)
|
|
80
|
-
-h, --help Show this help
|
|
81
|
-
--version Show version
|
|
82
|
-
|
|
83
|
-
CONFIG FILE FORMAT:
|
|
84
|
-
{
|
|
85
|
-
"base": {
|
|
86
|
-
"name": "python-server",
|
|
87
|
-
"transport": "stdio",
|
|
88
|
-
"start_command": "python -m mcp_server"
|
|
89
|
-
},
|
|
90
|
-
"targets": [
|
|
91
|
-
{
|
|
92
|
-
"name": "typescript-server",
|
|
93
|
-
"transport": "stdio",
|
|
94
|
-
"start_command": "node dist/stdio.js"
|
|
95
|
-
}
|
|
96
|
-
]
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
OUTPUT FORMATS:
|
|
100
|
-
summary - One line per comparison (default)
|
|
101
|
-
json - Raw JSON with full diff details
|
|
102
|
-
markdown - Formatted markdown report
|
|
103
|
-
|
|
104
|
-
EXAMPLES:
|
|
105
|
-
# Compare two stdio servers
|
|
106
|
-
mcp-server-diff -b "python -m server" -t "node dist/index.js"
|
|
107
|
-
|
|
108
|
-
# Compare against HTTP server
|
|
109
|
-
mcp-server-diff -b "python -m server" -t "http://localhost:3000/mcp"
|
|
110
|
-
|
|
111
|
-
# Use config file for multiple comparisons
|
|
112
|
-
mcp-server-diff -c servers.json -o markdown
|
|
113
|
-
|
|
114
|
-
# Output raw JSON for CI
|
|
115
|
-
mcp-server-diff -c servers.json -o json -q
|
|
116
|
-
`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Load and parse config file
|
|
121
|
-
*/
|
|
122
|
-
function loadConfig(configPath: string): DiffConfig {
|
|
123
|
-
const content = fs.readFileSync(configPath, "utf-8");
|
|
124
|
-
const config = JSON.parse(content) as DiffConfig;
|
|
125
|
-
|
|
126
|
-
if (!config.base) {
|
|
127
|
-
throw new Error("Config must have a 'base' server");
|
|
128
|
-
}
|
|
129
|
-
if (!config.targets || config.targets.length === 0) {
|
|
130
|
-
throw new Error("Config must have at least one 'target' server");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return config;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Create a server config from a command string
|
|
138
|
-
*/
|
|
139
|
-
function commandToConfig(command: string, name: string): ServerConfig {
|
|
140
|
-
if (command.startsWith("http://") || command.startsWith("https://")) {
|
|
141
|
-
return {
|
|
142
|
-
name,
|
|
143
|
-
transport: "streamable-http",
|
|
144
|
-
server_url: command,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
name,
|
|
150
|
-
transport: "stdio",
|
|
151
|
-
start_command: command,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Probe a server and return results
|
|
157
|
-
*/
|
|
158
|
-
async function probeServerConfig(config: ServerConfig): Promise<ProbeResult> {
|
|
159
|
-
if (config.transport === "stdio") {
|
|
160
|
-
if (!config.start_command) {
|
|
161
|
-
throw new Error(`No start_command for stdio server: ${config.name}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const parts = config.start_command.split(/\s+/);
|
|
165
|
-
const command = parts[0];
|
|
166
|
-
const args = parts.slice(1);
|
|
167
|
-
|
|
168
|
-
return await probeServer({
|
|
169
|
-
transport: "stdio",
|
|
170
|
-
command,
|
|
171
|
-
args,
|
|
172
|
-
envVars: config.env_vars,
|
|
173
|
-
});
|
|
174
|
-
} else {
|
|
175
|
-
if (!config.server_url) {
|
|
176
|
-
throw new Error(`No server_url for HTTP server: ${config.name}`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return await probeServer({
|
|
180
|
-
transport: "streamable-http",
|
|
181
|
-
url: config.server_url,
|
|
182
|
-
headers: config.headers,
|
|
183
|
-
envVars: config.env_vars,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Compare base against all targets
|
|
190
|
-
*/
|
|
191
|
-
async function runComparisons(config: DiffConfig): Promise<ComparisonResult[]> {
|
|
192
|
-
const results: ComparisonResult[] = [];
|
|
193
|
-
|
|
194
|
-
log.info(`\n📍 Probing base: ${config.base.name}`);
|
|
195
|
-
let baseResult: ProbeResult;
|
|
196
|
-
try {
|
|
197
|
-
baseResult = await probeServerConfig(config.base);
|
|
198
|
-
if (baseResult.error) {
|
|
199
|
-
throw new Error(baseResult.error);
|
|
200
|
-
}
|
|
201
|
-
} catch (error) {
|
|
202
|
-
log.error(`Failed to probe base server: ${error}`);
|
|
203
|
-
return config.targets.map((target) => ({
|
|
204
|
-
base: config.base.name,
|
|
205
|
-
target: target.name,
|
|
206
|
-
hasDifferences: true,
|
|
207
|
-
diffs: [{ endpoint: "error", diff: `Base server probe failed: ${error}` }],
|
|
208
|
-
baseCounts: { tools: 0, prompts: 0, resources: 0, resourceTemplates: 0 },
|
|
209
|
-
targetCounts: { tools: 0, prompts: 0, resources: 0, resourceTemplates: 0 },
|
|
210
|
-
error: String(error),
|
|
211
|
-
}));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const baseCounts = extractCounts(baseResult);
|
|
215
|
-
|
|
216
|
-
for (const target of config.targets) {
|
|
217
|
-
log.info(`\n🎯 Probing target: ${target.name}`);
|
|
218
|
-
|
|
219
|
-
const result: ComparisonResult = {
|
|
220
|
-
base: config.base.name,
|
|
221
|
-
target: target.name,
|
|
222
|
-
hasDifferences: false,
|
|
223
|
-
diffs: [],
|
|
224
|
-
baseCounts,
|
|
225
|
-
targetCounts: { tools: 0, prompts: 0, resources: 0, resourceTemplates: 0 },
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
const targetResult = await probeServerConfig(target);
|
|
230
|
-
|
|
231
|
-
if (targetResult.error) {
|
|
232
|
-
result.hasDifferences = true;
|
|
233
|
-
result.diffs = [{ endpoint: "error", diff: `Target probe failed: ${targetResult.error}` }];
|
|
234
|
-
result.error = targetResult.error;
|
|
235
|
-
} else {
|
|
236
|
-
result.targetCounts = extractCounts(targetResult);
|
|
237
|
-
result.diffs = compareProbeResults(baseResult, targetResult);
|
|
238
|
-
result.hasDifferences = result.diffs.length > 0;
|
|
239
|
-
}
|
|
240
|
-
} catch (error) {
|
|
241
|
-
result.hasDifferences = true;
|
|
242
|
-
result.diffs = [{ endpoint: "error", diff: `Target probe failed: ${error}` }];
|
|
243
|
-
result.error = String(error);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
results.push(result);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return results;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Output results in summary format
|
|
254
|
-
*/
|
|
255
|
-
function outputSummary(results: ComparisonResult[]): void {
|
|
256
|
-
console.log("\n📊 Comparison Results:\n");
|
|
257
|
-
|
|
258
|
-
let hasAnyDiff = false;
|
|
259
|
-
for (const result of results) {
|
|
260
|
-
const status = result.hasDifferences ? "❌" : "✅";
|
|
261
|
-
const diffCount = result.diffs.length;
|
|
262
|
-
const counts = `(${result.targetCounts.tools}T/${result.targetCounts.prompts}P/${result.targetCounts.resources}R)`;
|
|
263
|
-
|
|
264
|
-
if (result.error) {
|
|
265
|
-
console.log(`${status} ${result.target} ${counts} - ERROR: ${result.error}`);
|
|
266
|
-
} else if (result.hasDifferences) {
|
|
267
|
-
console.log(`${status} ${result.target} ${counts} - ${diffCount} difference(s)`);
|
|
268
|
-
hasAnyDiff = true;
|
|
269
|
-
} else {
|
|
270
|
-
console.log(`${status} ${result.target} ${counts} - matches base`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
console.log("");
|
|
275
|
-
if (hasAnyDiff) {
|
|
276
|
-
console.log("Run with -o markdown or -o json for detailed diffs.");
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Output results in JSON format
|
|
282
|
-
*/
|
|
283
|
-
function outputJson(results: ComparisonResult[]): void {
|
|
284
|
-
const output = {
|
|
285
|
-
timestamp: new Date().toISOString(),
|
|
286
|
-
results: results.map((r) => ({
|
|
287
|
-
base: r.base,
|
|
288
|
-
target: r.target,
|
|
289
|
-
hasDifferences: r.hasDifferences,
|
|
290
|
-
baseCounts: r.baseCounts,
|
|
291
|
-
targetCounts: r.targetCounts,
|
|
292
|
-
diffs: r.diffs,
|
|
293
|
-
error: r.error,
|
|
294
|
-
})),
|
|
295
|
-
summary: {
|
|
296
|
-
total: results.length,
|
|
297
|
-
matching: results.filter((r) => !r.hasDifferences).length,
|
|
298
|
-
different: results.filter((r) => r.hasDifferences).length,
|
|
299
|
-
},
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
console.log(JSON.stringify(output, null, 2));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Output results in markdown format
|
|
307
|
-
*/
|
|
308
|
-
function outputMarkdown(results: ComparisonResult[]): void {
|
|
309
|
-
const lines: string[] = [];
|
|
310
|
-
|
|
311
|
-
lines.push("# MCP Server Diff Report");
|
|
312
|
-
lines.push("");
|
|
313
|
-
lines.push(`**Generated:** ${new Date().toISOString()}`);
|
|
314
|
-
lines.push("");
|
|
315
|
-
|
|
316
|
-
lines.push("## Summary");
|
|
317
|
-
lines.push("");
|
|
318
|
-
lines.push("| Server | Tools | Prompts | Resources | Status |");
|
|
319
|
-
lines.push("|--------|-------|---------|-----------|--------|");
|
|
320
|
-
|
|
321
|
-
for (const result of results) {
|
|
322
|
-
const status = result.hasDifferences
|
|
323
|
-
? result.error
|
|
324
|
-
? "❌ Error"
|
|
325
|
-
: `⚠️ ${result.diffs.length} diff(s)`
|
|
326
|
-
: "✅ Match";
|
|
327
|
-
const c = result.targetCounts;
|
|
328
|
-
lines.push(`| ${result.target} | ${c.tools} | ${c.prompts} | ${c.resources} | ${status} |`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
lines.push("");
|
|
332
|
-
|
|
333
|
-
const diffsPresent = results.filter((r) => r.hasDifferences && r.diffs.length > 0);
|
|
334
|
-
if (diffsPresent.length > 0) {
|
|
335
|
-
lines.push("## Differences");
|
|
336
|
-
lines.push("");
|
|
337
|
-
|
|
338
|
-
for (const result of diffsPresent) {
|
|
339
|
-
lines.push(`### ${result.target}`);
|
|
340
|
-
lines.push("");
|
|
341
|
-
|
|
342
|
-
for (const { endpoint, diff } of result.diffs) {
|
|
343
|
-
lines.push(`**${endpoint}**`);
|
|
344
|
-
lines.push("");
|
|
345
|
-
lines.push("```diff");
|
|
346
|
-
lines.push(diff);
|
|
347
|
-
lines.push("```");
|
|
348
|
-
lines.push("");
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
} else {
|
|
352
|
-
lines.push("## ✅ All Servers Match");
|
|
353
|
-
lines.push("");
|
|
354
|
-
lines.push("No differences detected between base and target servers.");
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
console.log(lines.join("\n"));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Main CLI entry point
|
|
362
|
-
*/
|
|
363
|
-
async function main(): Promise<void> {
|
|
364
|
-
const { values } = parseCliArgs();
|
|
365
|
-
|
|
366
|
-
if (values.help) {
|
|
367
|
-
printHelp();
|
|
368
|
-
process.exit(0);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (values.version) {
|
|
372
|
-
console.log("mcp-server-diff v2.1.0");
|
|
373
|
-
process.exit(0);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Set up logger - CLI uses console logger by default
|
|
377
|
-
if (values.quiet) {
|
|
378
|
-
setLogger(new QuietLogger());
|
|
379
|
-
} else {
|
|
380
|
-
setLogger(new ConsoleLogger(values.verbose || false));
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
let config: DiffConfig;
|
|
384
|
-
|
|
385
|
-
if (values.config) {
|
|
386
|
-
config = loadConfig(values.config);
|
|
387
|
-
} else if (values.base && values.target) {
|
|
388
|
-
config = {
|
|
389
|
-
base: commandToConfig(values.base, "base"),
|
|
390
|
-
targets: [commandToConfig(values.target, "target")],
|
|
391
|
-
};
|
|
392
|
-
} else {
|
|
393
|
-
console.error("Error: Must provide --config or both --base and --target");
|
|
394
|
-
console.error("Run with --help for usage.");
|
|
395
|
-
process.exit(1);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const results = await runComparisons(config);
|
|
399
|
-
|
|
400
|
-
const outputFormat = values.output || "summary";
|
|
401
|
-
switch (outputFormat) {
|
|
402
|
-
case "json":
|
|
403
|
-
outputJson(results);
|
|
404
|
-
break;
|
|
405
|
-
case "markdown":
|
|
406
|
-
outputMarkdown(results);
|
|
407
|
-
break;
|
|
408
|
-
case "summary":
|
|
409
|
-
default:
|
|
410
|
-
outputSummary(results);
|
|
411
|
-
break;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const hasDiffs = results.some((r) => r.hasDifferences);
|
|
415
|
-
process.exit(hasDiffs ? 1 : 0);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
main().catch((error) => {
|
|
419
|
-
console.error("Fatal error:", error);
|
|
420
|
-
process.exit(1);
|
|
421
|
-
});
|
package/src/diff.ts
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core diffing logic for MCP servers
|
|
3
|
-
*
|
|
4
|
-
* Pure functions for comparing probe results - no I/O side effects.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { ProbeResult, PrimitiveCounts } from "./types.js";
|
|
8
|
-
import { probeResultToFiles } from "./probe.js";
|
|
9
|
-
|
|
10
|
-
export interface DiffResult {
|
|
11
|
-
endpoint: string;
|
|
12
|
-
diff: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface ComparisonResult {
|
|
16
|
-
baseName: string;
|
|
17
|
-
targetName: string;
|
|
18
|
-
hasDifferences: boolean;
|
|
19
|
-
diffs: DiffResult[];
|
|
20
|
-
baseCounts: PrimitiveCounts;
|
|
21
|
-
targetCounts: PrimitiveCounts;
|
|
22
|
-
baseError?: string;
|
|
23
|
-
targetError?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Extract primitive counts from a probe result
|
|
28
|
-
*/
|
|
29
|
-
export function extractCounts(result: ProbeResult): PrimitiveCounts {
|
|
30
|
-
return {
|
|
31
|
-
tools: result.tools?.tools?.length || 0,
|
|
32
|
-
prompts: result.prompts?.prompts?.length || 0,
|
|
33
|
-
resources: result.resources?.resources?.length || 0,
|
|
34
|
-
resourceTemplates: result.resourceTemplates?.resourceTemplates?.length || 0,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Compare two probe results and return structured diff results
|
|
40
|
-
*/
|
|
41
|
-
export function compareProbeResults(
|
|
42
|
-
baseResult: ProbeResult,
|
|
43
|
-
targetResult: ProbeResult
|
|
44
|
-
): DiffResult[] {
|
|
45
|
-
const baseFiles = probeResultToFiles(baseResult);
|
|
46
|
-
const targetFiles = probeResultToFiles(targetResult);
|
|
47
|
-
const diffs: DiffResult[] = [];
|
|
48
|
-
|
|
49
|
-
const allEndpoints = new Set([...baseFiles.keys(), ...targetFiles.keys()]);
|
|
50
|
-
|
|
51
|
-
for (const endpoint of allEndpoints) {
|
|
52
|
-
const baseContent = baseFiles.get(endpoint);
|
|
53
|
-
const targetContent = targetFiles.get(endpoint);
|
|
54
|
-
|
|
55
|
-
if (!targetContent && baseContent) {
|
|
56
|
-
diffs.push({
|
|
57
|
-
endpoint,
|
|
58
|
-
diff: `Endpoint removed in target (was present in base)`,
|
|
59
|
-
});
|
|
60
|
-
} else if (targetContent && !baseContent) {
|
|
61
|
-
diffs.push({
|
|
62
|
-
endpoint,
|
|
63
|
-
diff: `Endpoint added in target (not present in base)`,
|
|
64
|
-
});
|
|
65
|
-
} else if (baseContent !== targetContent) {
|
|
66
|
-
const diff = generateJsonDiff(endpoint, baseContent || "", targetContent || "");
|
|
67
|
-
if (diff) {
|
|
68
|
-
diffs.push({ endpoint, diff });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return diffs;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Generate semantic JSON diff
|
|
78
|
-
*/
|
|
79
|
-
export function generateJsonDiff(name: string, base: string, target: string): string | null {
|
|
80
|
-
try {
|
|
81
|
-
const baseObj = JSON.parse(base);
|
|
82
|
-
const targetObj = JSON.parse(target);
|
|
83
|
-
|
|
84
|
-
const differences = findJsonDifferences(baseObj, targetObj, "");
|
|
85
|
-
|
|
86
|
-
if (differences.length === 0) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const diffLines = [`--- base/${name}.json`, `+++ target/${name}.json`, ""];
|
|
91
|
-
diffLines.push(...differences);
|
|
92
|
-
|
|
93
|
-
return diffLines.join("\n");
|
|
94
|
-
} catch {
|
|
95
|
-
// Fallback for non-JSON
|
|
96
|
-
if (base === target) return null;
|
|
97
|
-
return `--- base/${name}\n+++ target/${name}\n- ${base}\n+ ${target}`;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Recursively find differences between two JSON objects
|
|
103
|
-
*/
|
|
104
|
-
export function findJsonDifferences(base: unknown, target: unknown, path: string): string[] {
|
|
105
|
-
const diffs: string[] = [];
|
|
106
|
-
|
|
107
|
-
if (base === null || base === undefined) {
|
|
108
|
-
if (target !== null && target !== undefined) {
|
|
109
|
-
diffs.push(`+ ${path || "root"}: ${formatValue(target)}`);
|
|
110
|
-
}
|
|
111
|
-
return diffs;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (target === null || target === undefined) {
|
|
115
|
-
diffs.push(`- ${path || "root"}: ${formatValue(base)}`);
|
|
116
|
-
return diffs;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (typeof base !== typeof target) {
|
|
120
|
-
diffs.push(`- ${path || "root"}: ${formatValue(base)}`);
|
|
121
|
-
diffs.push(`+ ${path || "root"}: ${formatValue(target)}`);
|
|
122
|
-
return diffs;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (Array.isArray(base) && Array.isArray(target)) {
|
|
126
|
-
return compareArrays(base, target, path);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (typeof base === "object" && typeof target === "object") {
|
|
130
|
-
const baseObj = base as Record<string, unknown>;
|
|
131
|
-
const targetObj = target as Record<string, unknown>;
|
|
132
|
-
const allKeys = new Set([...Object.keys(baseObj), ...Object.keys(targetObj)]);
|
|
133
|
-
|
|
134
|
-
for (const key of allKeys) {
|
|
135
|
-
const newPath = path ? `${path}.${key}` : key;
|
|
136
|
-
|
|
137
|
-
if (!(key in baseObj)) {
|
|
138
|
-
diffs.push(`+ ${newPath}: ${formatValue(targetObj[key])}`);
|
|
139
|
-
} else if (!(key in targetObj)) {
|
|
140
|
-
diffs.push(`- ${newPath}: ${formatValue(baseObj[key])}`);
|
|
141
|
-
} else {
|
|
142
|
-
diffs.push(...findJsonDifferences(baseObj[key], targetObj[key], newPath));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return diffs;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (base !== target) {
|
|
149
|
-
diffs.push(`- ${path}: ${formatValue(base)}`);
|
|
150
|
-
diffs.push(`+ ${path}: ${formatValue(target)}`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return diffs;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Compare arrays by finding items by their identity
|
|
158
|
-
*/
|
|
159
|
-
function compareArrays(base: unknown[], target: unknown[], path: string): string[] {
|
|
160
|
-
const diffs: string[] = [];
|
|
161
|
-
|
|
162
|
-
const baseItems = new Map<string, { item: unknown; index: number }>();
|
|
163
|
-
const targetItems = new Map<string, { item: unknown; index: number }>();
|
|
164
|
-
|
|
165
|
-
base.forEach((item, index) => {
|
|
166
|
-
const key = getItemKey(item, index);
|
|
167
|
-
baseItems.set(key, { item, index });
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
target.forEach((item, index) => {
|
|
171
|
-
const key = getItemKey(item, index);
|
|
172
|
-
targetItems.set(key, { item, index });
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// Find removed items
|
|
176
|
-
for (const [key, { item }] of baseItems) {
|
|
177
|
-
if (!targetItems.has(key)) {
|
|
178
|
-
diffs.push(`- ${path}[${key}]: ${formatValue(item)}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Find added items
|
|
183
|
-
for (const [key, { item }] of targetItems) {
|
|
184
|
-
if (!baseItems.has(key)) {
|
|
185
|
-
diffs.push(`+ ${path}[${key}]: ${formatValue(item)}`);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Find modified items
|
|
190
|
-
for (const [key, { item: baseItem }] of baseItems) {
|
|
191
|
-
const targetEntry = targetItems.get(key);
|
|
192
|
-
if (targetEntry) {
|
|
193
|
-
diffs.push(...findJsonDifferences(baseItem, targetEntry.item, `${path}[${key}]`));
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return diffs;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Get a unique key for an array item
|
|
202
|
-
*/
|
|
203
|
-
function getItemKey(item: unknown, index: number): string {
|
|
204
|
-
if (item === null || item === undefined || typeof item !== "object") {
|
|
205
|
-
return `#${index}`;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const obj = item as Record<string, unknown>;
|
|
209
|
-
|
|
210
|
-
if (typeof obj.name === "string") return obj.name;
|
|
211
|
-
if (typeof obj.uri === "string") return obj.uri;
|
|
212
|
-
if (typeof obj.uriTemplate === "string") return obj.uriTemplate;
|
|
213
|
-
if (typeof obj.method === "string") return obj.method;
|
|
214
|
-
|
|
215
|
-
return `#${index}`;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Format a value for display in diff output
|
|
220
|
-
*/
|
|
221
|
-
export function formatValue(value: unknown): string {
|
|
222
|
-
if (value === null) return "null";
|
|
223
|
-
if (value === undefined) return "undefined";
|
|
224
|
-
|
|
225
|
-
if (typeof value === "string") {
|
|
226
|
-
if (value.length > 100) {
|
|
227
|
-
return JSON.stringify(value.slice(0, 100) + "...");
|
|
228
|
-
}
|
|
229
|
-
return JSON.stringify(value);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (typeof value === "object") {
|
|
233
|
-
const json = JSON.stringify(value);
|
|
234
|
-
if (json.length > 200) {
|
|
235
|
-
return json.slice(0, 200) + "...";
|
|
236
|
-
}
|
|
237
|
-
return json;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return String(value);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Convert DiffResult array to Map for backward compatibility
|
|
245
|
-
*/
|
|
246
|
-
export function diffsToMap(diffs: DiffResult[]): Map<string, string> {
|
|
247
|
-
const map = new Map<string, string>();
|
|
248
|
-
for (const { endpoint, diff } of diffs) {
|
|
249
|
-
map.set(endpoint, diff);
|
|
250
|
-
}
|
|
251
|
-
return map;
|
|
252
|
-
}
|