codex-plugin-doctor 1.74.1 → 1.76.0
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 +4 -3
- package/dist/core/mcp-tool-diff.d.ts +7 -2
- package/dist/core/mcp-tool-diff.js +6 -4
- package/dist/core/mcp-tool-impact.d.ts +10 -1
- package/dist/core/mcp-tool-impact.js +194 -50
- package/dist/core/output-contract.js +12 -2
- package/dist/reporting/quote-server-text.d.ts +2 -0
- package/dist/reporting/quote-server-text.js +7 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.js +23 -2
- package/dist/reporting/render-mcp-tool-diff-report.js +4 -1
- package/dist/run-cli.js +10 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,7 +120,8 @@ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-
|
|
|
120
120
|
This reports added, removed, and structurally changed definitions using numeric
|
|
121
121
|
locations, and labels each change `breaking`, `compatible`, or `unclassified`
|
|
122
122
|
with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
|
|
123
|
-
unclassified changes,
|
|
123
|
+
unclassified changes, `--markdown` for pull request tables, and `--show-names`
|
|
124
|
+
locally to see tool names and the JSON Pointer of each reason. Incomplete inputs, invalid definitions, or duplicate names
|
|
124
125
|
prevent a comparison. Impact labels follow nested object properties, array
|
|
125
126
|
items, and common length and range bounds, but remain heuristics, not proof of
|
|
126
127
|
compatibility.
|
|
@@ -589,9 +590,9 @@ jobs:
|
|
|
589
590
|
runs-on: ubuntu-latest
|
|
590
591
|
steps:
|
|
591
592
|
- uses: actions/checkout@v5
|
|
592
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
593
|
+
- uses: Esquetta/CodexPluginDoctor@v1.76.0
|
|
593
594
|
with:
|
|
594
|
-
version: "1.
|
|
595
|
+
version: "1.76.0"
|
|
595
596
|
path: .
|
|
596
597
|
runtime: "true"
|
|
597
598
|
policy: codex-publish
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
|
|
1
|
+
import { type ToolChangeImpact, type ToolChangeLocation, type ToolChangeReason } from "./mcp-tool-impact.js";
|
|
2
2
|
import { type McpToolFileReport } from "./mcp-tool-file.js";
|
|
3
3
|
type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
|
|
4
4
|
export interface McpToolDiffReport {
|
|
@@ -32,7 +32,12 @@ export interface McpToolDiffReport {
|
|
|
32
32
|
fields: DiffField[];
|
|
33
33
|
impact: ToolChangeImpact;
|
|
34
34
|
reasons: ToolChangeReason[];
|
|
35
|
+
name?: string;
|
|
36
|
+
locations?: ToolChangeLocation[];
|
|
35
37
|
}>;
|
|
36
38
|
}
|
|
37
|
-
export
|
|
39
|
+
export interface McpToolDiffOptions {
|
|
40
|
+
showNames?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function compareMcpToolFiles(beforePath: string, afterPath: string, options?: McpToolDiffOptions): Promise<McpToolDiffReport>;
|
|
38
43
|
export {};
|
|
@@ -85,7 +85,7 @@ function incompleteReport(before, after, status, reason) {
|
|
|
85
85
|
changes: []
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
-
export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
88
|
+
export async function compareMcpToolFiles(beforePath, afterPath, options = {}) {
|
|
89
89
|
const [beforeSnapshot, afterSnapshot] = await Promise.all([
|
|
90
90
|
loadMcpToolFileSnapshot(beforePath),
|
|
91
91
|
loadMcpToolFileSnapshot(afterPath)
|
|
@@ -106,6 +106,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
106
106
|
if (beforeDefinitions === null || afterDefinitions === null) {
|
|
107
107
|
return incompleteReport(before, after, "incomplete", "ambiguous-names");
|
|
108
108
|
}
|
|
109
|
+
const named = (name) => (options.showNames === true ? { name } : {});
|
|
109
110
|
const changes = [];
|
|
110
111
|
let added = 0;
|
|
111
112
|
let removed = 0;
|
|
@@ -115,7 +116,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
115
116
|
const afterDefinition = afterDefinitions.get(name);
|
|
116
117
|
if (afterDefinition === undefined) {
|
|
117
118
|
removed += 1;
|
|
118
|
-
changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
|
|
119
|
+
changes.push({ kind: "removed", ...named(name), beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
|
|
119
120
|
continue;
|
|
120
121
|
}
|
|
121
122
|
const fields = changedFields(beforeDefinition.value, afterDefinition.value);
|
|
@@ -126,17 +127,18 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
126
127
|
changed += 1;
|
|
127
128
|
changes.push({
|
|
128
129
|
kind: "changed",
|
|
130
|
+
...named(name),
|
|
129
131
|
beforeToolIndex: beforeDefinition.index,
|
|
130
132
|
afterToolIndex: afterDefinition.index,
|
|
131
133
|
fields,
|
|
132
|
-
...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual)
|
|
134
|
+
...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual, { locate: options.showNames === true })
|
|
133
135
|
});
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
for (const [name, afterDefinition] of afterDefinitions) {
|
|
137
139
|
if (!beforeDefinitions.has(name)) {
|
|
138
140
|
added += 1;
|
|
139
|
-
changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
|
|
141
|
+
changes.push({ kind: "added", ...named(name), beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
return {
|
|
@@ -2,12 +2,21 @@ type JsonObject = Record<string, unknown>;
|
|
|
2
2
|
export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
|
|
3
3
|
export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-constraint-tightened", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-constraint-loosened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
|
|
4
4
|
export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
|
|
5
|
+
export interface ToolChangeLocation {
|
|
6
|
+
reason: ToolChangeReason;
|
|
7
|
+
pointer: string;
|
|
8
|
+
}
|
|
5
9
|
export interface ToolChangeClassification {
|
|
6
10
|
impact: ToolChangeImpact;
|
|
7
11
|
reasons: ToolChangeReason[];
|
|
12
|
+
locations?: ToolChangeLocation[];
|
|
8
13
|
}
|
|
14
|
+
export declare const MAX_TOOL_CHANGE_LOCATIONS = 100;
|
|
15
|
+
export declare const MAX_TOOL_CHANGE_POINTER_LENGTH = 1000;
|
|
9
16
|
type Equal = (left: unknown, right: unknown) => boolean;
|
|
10
17
|
export declare function classifyRemovedTool(): ToolChangeClassification;
|
|
11
18
|
export declare function classifyAddedTool(): ToolChangeClassification;
|
|
12
|
-
export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal
|
|
19
|
+
export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal, options?: {
|
|
20
|
+
locate?: boolean;
|
|
21
|
+
}): ToolChangeClassification;
|
|
13
22
|
export {};
|
|
@@ -19,6 +19,59 @@ export const TOOL_CHANGE_REASONS = [
|
|
|
19
19
|
"annotation-safety-reduced",
|
|
20
20
|
"other-fields-unclassified"
|
|
21
21
|
];
|
|
22
|
+
export const MAX_TOOL_CHANGE_LOCATIONS = 100;
|
|
23
|
+
// Longer pointers are not recorded, so a long property name cannot make locating costly or the report large.
|
|
24
|
+
export const MAX_TOOL_CHANGE_POINTER_LENGTH = 1_000;
|
|
25
|
+
/**
|
|
26
|
+
* A reason set that also records, for each reason, the JSON Pointer of the schema position being
|
|
27
|
+
* compared when it was added. A shared definition is compared once, so it is located at the first
|
|
28
|
+
* reference that reached it.
|
|
29
|
+
*/
|
|
30
|
+
class LocatingReasons extends Set {
|
|
31
|
+
locations = [];
|
|
32
|
+
// Escaped pointer segments of the position being compared, and the pointer length at each one;
|
|
33
|
+
// a segment that would pass the length limit is left empty, with an infinite length.
|
|
34
|
+
segments = [];
|
|
35
|
+
lengths = [0];
|
|
36
|
+
seen = new Set();
|
|
37
|
+
enter(tokens) {
|
|
38
|
+
for (const token of tokens) {
|
|
39
|
+
const previous = this.lengths[this.lengths.length - 1];
|
|
40
|
+
const segment = previous + 1 + token.length > MAX_TOOL_CHANGE_POINTER_LENGTH ? "" : `/${token.replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
|
41
|
+
this.segments.push(segment);
|
|
42
|
+
this.lengths.push(segment === "" || previous + segment.length > MAX_TOOL_CHANGE_POINTER_LENGTH ? Infinity : previous + segment.length);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
leave(count) {
|
|
46
|
+
this.segments.length -= count;
|
|
47
|
+
this.lengths.length -= count;
|
|
48
|
+
}
|
|
49
|
+
add(reason) {
|
|
50
|
+
if (this.locations.length < MAX_TOOL_CHANGE_LOCATIONS && this.lengths[this.lengths.length - 1] !== Infinity) {
|
|
51
|
+
const pointer = this.segments.join("");
|
|
52
|
+
const key = `${reason} ${pointer}`;
|
|
53
|
+
if (!this.seen.has(key)) {
|
|
54
|
+
this.seen.add(key);
|
|
55
|
+
this.locations.push({ reason, pointer });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return super.add(reason);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Runs `compare` with `tokens` appended to the recorded location, when locations are recorded. */
|
|
62
|
+
function within(reasons, tokens, compare) {
|
|
63
|
+
if (!(reasons instanceof LocatingReasons)) {
|
|
64
|
+
compare();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
reasons.enter(tokens);
|
|
68
|
+
try {
|
|
69
|
+
compare();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
reasons.leave(tokens.length);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
22
75
|
const UNCLASSIFIED_REASONS = new Set([
|
|
23
76
|
"input-schema-unclassified",
|
|
24
77
|
"output-schema-unclassified",
|
|
@@ -53,9 +106,9 @@ const MAX_NESTED_DEPTH = 8;
|
|
|
53
106
|
// Keywords that only hold or name subschemas and never validate the object's own keys, so a
|
|
54
107
|
// new property beside them stays compatible. Any other unclassified keyword may govern keys.
|
|
55
108
|
const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
|
|
56
|
-
// `anyOf` alternatives beyond this many on either side are not paired.
|
|
109
|
+
// `anyOf` or discriminated `oneOf` alternatives beyond this many on either side are not paired.
|
|
57
110
|
const MAX_ALTERNATIVES = 32;
|
|
58
|
-
// Composition wrappers (`allOf` and `
|
|
111
|
+
// Composition wrappers (`allOf`, `anyOf`, and discriminated `oneOf`) followed along one path; deeper wrappers are unclassified.
|
|
59
112
|
const MAX_WRAPPER_HOPS = 16;
|
|
60
113
|
// A `$ref` chain longer than this, such as an alias cycle, is not resolved.
|
|
61
114
|
const MAX_REFERENCE_HOPS = 8;
|
|
@@ -258,18 +311,67 @@ function singleAllOf(schema) {
|
|
|
258
311
|
return null;
|
|
259
312
|
return Object.keys(schema).every((key) => key === "allOf" || DOCUMENTATION_KEYWORDS.has(key)) ? { inner: schema.allOf[0] } : null;
|
|
260
313
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
314
|
+
function discriminatorProperty(value) {
|
|
315
|
+
if (!isJsonObject(value) || typeof value.propertyName !== "string")
|
|
316
|
+
return null;
|
|
317
|
+
return Object.keys(value).every((key) => key === "propertyName" || key === "mapping") ? value.propertyName : null;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The discriminator value of each alternative, when every alternative resolves to an object schema
|
|
321
|
+
* that requires the property and fixes it to one value no other alternative uses; null otherwise.
|
|
322
|
+
* Such alternatives cannot both match one value, so their oneOf accepts what an anyOf would.
|
|
323
|
+
*/
|
|
324
|
+
function discriminatorValues(list, property, document) {
|
|
325
|
+
// Draft-04 has no const, and many validators of earlier drafts drop keywords beside a $ref.
|
|
326
|
+
if (list.length > MAX_ALTERNATIVES || (isJsonObject(document) && typeof document.$schema === "string" && /draft-0[34]/u.test(document.$schema)))
|
|
327
|
+
return null;
|
|
328
|
+
const values = new Map();
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
for (const alternative of list) {
|
|
331
|
+
const schema = resolveReferences(alternative, document)?.schema;
|
|
332
|
+
if (!isJsonObject(schema))
|
|
333
|
+
return null;
|
|
334
|
+
const types = typeSet(schema);
|
|
335
|
+
const declared = objectMap(schema.properties)?.[property];
|
|
336
|
+
if (types === null || types.size !== 1 || !types.has("object") || !stringSet(schema.required)?.has(property)
|
|
337
|
+
|| !isJsonObject(declared) || declared.$ref !== undefined)
|
|
338
|
+
return null;
|
|
339
|
+
const fixed = Object.hasOwn(declared, "const") ? [declared.const] : declared.enum;
|
|
340
|
+
if (!Array.isArray(fixed) || fixed.length !== 1 || (Array.isArray(declared.enum) && Object.hasOwn(declared, "const")
|
|
341
|
+
&& !(declared.enum.length === 1 && Object.is(declared.enum[0], declared.const))))
|
|
342
|
+
return null;
|
|
343
|
+
const value = fixed[0];
|
|
344
|
+
if (value !== null && !["string", "number", "boolean"].includes(typeof value))
|
|
345
|
+
return null;
|
|
346
|
+
const key = JSON.stringify(value);
|
|
347
|
+
if (seen.has(key))
|
|
348
|
+
return null;
|
|
349
|
+
seen.add(key);
|
|
350
|
+
values.set(alternative, key);
|
|
351
|
+
}
|
|
352
|
+
return values;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The alternatives of a schema that is only `anyOf`, or only a discriminated `oneOf` whose
|
|
356
|
+
* alternatives exclude each other, with documentation beside it; null otherwise.
|
|
357
|
+
*/
|
|
358
|
+
function alternatives(schema, document) {
|
|
359
|
+
if (!isJsonObject(schema))
|
|
360
|
+
return null;
|
|
361
|
+
if (Array.isArray(schema.anyOf)) {
|
|
362
|
+
return Object.keys(schema).every((key) => key === "anyOf" || DOCUMENTATION_KEYWORDS.has(key)) ? { list: schema.anyOf, discriminator: null } : null;
|
|
363
|
+
}
|
|
364
|
+
if (!Array.isArray(schema.oneOf) || !Object.keys(schema).every((key) => key === "oneOf" || key === "discriminator" || DOCUMENTATION_KEYWORDS.has(key)))
|
|
264
365
|
return null;
|
|
265
|
-
|
|
366
|
+
const property = discriminatorProperty(schema.discriminator);
|
|
367
|
+
return property !== null && discriminatorValues(schema.oneOf, property, document) !== null ? { list: schema.oneOf, discriminator: property } : null;
|
|
266
368
|
}
|
|
267
|
-
function isComposition(schema) {
|
|
268
|
-
return singleAllOf(schema) !== null || alternatives(schema) !== null;
|
|
369
|
+
function isComposition(schema, document) {
|
|
370
|
+
return singleAllOf(schema) !== null || alternatives(schema, document) !== null;
|
|
269
371
|
}
|
|
270
372
|
/**
|
|
271
|
-
* Compares two schemas when at least one is a one-element `allOf
|
|
272
|
-
* documentation beside it. Wrappers keep the depth, so their own count bounds how far one path unwraps.
|
|
373
|
+
* Compares two schemas when at least one is a one-element `allOf`, an `anyOf`, or a discriminated
|
|
374
|
+
* `oneOf` with only documentation beside it. Wrappers keep the depth, so their own count bounds how far one path unwraps.
|
|
273
375
|
*/
|
|
274
376
|
function compareComposition(before, after, direction, depth, equal, reasons, scope) {
|
|
275
377
|
if (scope.wrappers >= MAX_WRAPPER_HOPS) {
|
|
@@ -285,7 +387,9 @@ function compareComposition(before, after, direction, depth, equal, reasons, sco
|
|
|
285
387
|
}
|
|
286
388
|
else {
|
|
287
389
|
// A schema that is not an anyOf compares as an anyOf with that schema as its only alternative.
|
|
288
|
-
|
|
390
|
+
const beforeAlternatives = alternatives(before, scope.before) ?? { list: [before], discriminator: null };
|
|
391
|
+
const afterAlternatives = alternatives(after, scope.after) ?? { list: [after], discriminator: null };
|
|
392
|
+
compareAlternatives(beforeAlternatives, afterAlternatives, direction, depth, equal, reasons, scope);
|
|
289
393
|
}
|
|
290
394
|
scope.wrappers -= 1;
|
|
291
395
|
}
|
|
@@ -298,19 +402,20 @@ function alternativeKey(alternative) {
|
|
|
298
402
|
return alternative.type === undefined ? null : `type:${JSON.stringify(alternative.type)}`;
|
|
299
403
|
}
|
|
300
404
|
/**
|
|
301
|
-
* Compares `anyOf` alternatives
|
|
302
|
-
*
|
|
303
|
-
* alternative is covered by
|
|
304
|
-
*
|
|
405
|
+
* Compares `anyOf` alternatives, or discriminated `oneOf` alternatives that exclude each other. A
|
|
406
|
+
* value is accepted when any alternative accepts it, so inputs stay compatible when every earlier
|
|
407
|
+
* alternative is covered by a later one, and outputs when every later alternative is covered by an
|
|
408
|
+
* earlier one. Identical alternatives match first, then alternatives with the same discriminator
|
|
409
|
+
* value, then the same reference target or type, then a single remaining alternative on each side.
|
|
305
410
|
*/
|
|
306
411
|
function compareAlternatives(before, after, direction, depth, equal, reasons, scope) {
|
|
307
412
|
const codes = CODES[direction];
|
|
308
|
-
if (before.length > MAX_ALTERNATIVES || after.length > MAX_ALTERNATIVES) {
|
|
413
|
+
if (before.list.length > MAX_ALTERNATIVES || after.list.length > MAX_ALTERNATIVES) {
|
|
309
414
|
reasons.add(codes.unclassified);
|
|
310
415
|
return;
|
|
311
416
|
}
|
|
312
|
-
const unpairedBefore = [...before];
|
|
313
|
-
const unpairedAfter = [...after];
|
|
417
|
+
const unpairedBefore = [...before.list];
|
|
418
|
+
const unpairedAfter = [...after.list];
|
|
314
419
|
for (let index = unpairedBefore.length - 1; index >= 0; index -= 1) {
|
|
315
420
|
const match = unpairedAfter.findIndex((candidate) => sameMeaning(unpairedBefore[index], candidate, equal, scope));
|
|
316
421
|
if (match !== -1) {
|
|
@@ -319,17 +424,25 @@ function compareAlternatives(before, after, direction, depth, equal, reasons, sc
|
|
|
319
424
|
}
|
|
320
425
|
}
|
|
321
426
|
const pairs = [];
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
427
|
+
const pairBy = (beforeKey, afterKey) => {
|
|
428
|
+
for (const key of new Set(unpairedBefore.map(beforeKey))) {
|
|
429
|
+
if (key === null)
|
|
430
|
+
continue;
|
|
431
|
+
const beforeMatches = unpairedBefore.filter((alternative) => beforeKey(alternative) === key);
|
|
432
|
+
const afterMatches = unpairedAfter.filter((alternative) => afterKey(alternative) === key);
|
|
433
|
+
if (beforeMatches.length !== 1 || afterMatches.length !== 1)
|
|
434
|
+
continue;
|
|
435
|
+
pairs.push([beforeMatches[0], afterMatches[0]]);
|
|
436
|
+
unpairedBefore.splice(unpairedBefore.indexOf(beforeMatches[0]), 1);
|
|
437
|
+
unpairedAfter.splice(unpairedAfter.indexOf(afterMatches[0]), 1);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
if (before.discriminator !== null && before.discriminator === after.discriminator) {
|
|
441
|
+
const beforeValues = discriminatorValues(before.list, before.discriminator, scope.before);
|
|
442
|
+
const afterValues = discriminatorValues(after.list, after.discriminator, scope.after);
|
|
443
|
+
pairBy((alternative) => beforeValues?.get(alternative) ?? null, (alternative) => afterValues?.get(alternative) ?? null);
|
|
332
444
|
}
|
|
445
|
+
pairBy(alternativeKey, alternativeKey);
|
|
333
446
|
if (unpairedBefore.length === 1 && unpairedAfter.length === 1) {
|
|
334
447
|
pairs.push([unpairedBefore.pop(), unpairedAfter.pop()]);
|
|
335
448
|
}
|
|
@@ -594,20 +707,47 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
594
707
|
}
|
|
595
708
|
// When only one side is a composition, the other keeps its reference unresolved, so it can still
|
|
596
709
|
// pair with an alternative that refers to the same definition, as when a model becomes optional.
|
|
597
|
-
const beforeComposed = isComposition(resolvedBefore.schema);
|
|
598
|
-
const afterComposed = isComposition(resolvedAfter.schema);
|
|
710
|
+
const beforeComposed = isComposition(resolvedBefore.schema, scope.before);
|
|
711
|
+
const afterComposed = isComposition(resolvedAfter.schema, scope.after);
|
|
712
|
+
// True when the original schema, before its reference is resolved, pairs with an alternative of
|
|
713
|
+
// the composite, as a model or a union does with its nullable anyOf.
|
|
714
|
+
const pairsWithAlternative = (original, composite, document) => {
|
|
715
|
+
const key = alternativeKey(original);
|
|
716
|
+
return (alternatives(composite, document)?.list ?? [])
|
|
717
|
+
.some((alternative) => (key !== null && alternativeKey(alternative) === key) || sameMeaning(original, alternative, equal, scope));
|
|
718
|
+
};
|
|
599
719
|
if (beforeComposed !== afterComposed) {
|
|
600
720
|
// The reference is kept only when an alternative can pair with it; otherwise its target compares
|
|
601
721
|
// with inline alternatives by type.
|
|
602
722
|
const composite = beforeComposed ? resolvedBefore.schema : resolvedAfter.schema;
|
|
603
723
|
const original = beforeComposed ? after : before;
|
|
604
|
-
const
|
|
605
|
-
const keepReference = (alternatives(composite) ?? [])
|
|
606
|
-
.some((alternative) => (plainKey !== null && alternativeKey(alternative) === plainKey) || sameMeaning(original, alternative, equal, scope));
|
|
724
|
+
const keepReference = pairsWithAlternative(original, composite, beforeComposed ? scope.before : scope.after);
|
|
607
725
|
const plain = keepReference ? original : (beforeComposed ? resolvedAfter.schema : resolvedBefore.schema);
|
|
608
726
|
compareComposition(beforeComposed ? composite : plain, beforeComposed ? plain : composite, direction, depth, equal, reasons, scope);
|
|
609
727
|
return;
|
|
610
728
|
}
|
|
729
|
+
if (beforeComposed && afterComposed) {
|
|
730
|
+
// A composite that appears whole as one alternative of the other side, such as a union made
|
|
731
|
+
// nullable, compares as that single alternative rather than alternative by alternative.
|
|
732
|
+
// A reference match counts only while the matched alternative is still a composite, so a
|
|
733
|
+
// definition that stopped being a union compares alternative by alternative instead.
|
|
734
|
+
const isWholeAlternative = (original, composite, document) => (alternatives(composite, document)?.list ?? []).some((alternative) => sameMeaning(original, alternative, equal, scope)
|
|
735
|
+
|| (alternativeKey(original) !== null && alternativeKey(alternative) === alternativeKey(original)
|
|
736
|
+
&& isComposition(resolveReferences(alternative, document)?.schema, document)));
|
|
737
|
+
const beforeIsAlternative = isWholeAlternative(before, resolvedAfter.schema, scope.after);
|
|
738
|
+
const afterIsAlternative = isWholeAlternative(after, resolvedBefore.schema, scope.before);
|
|
739
|
+
if (beforeIsAlternative !== afterIsAlternative) {
|
|
740
|
+
if (scope.wrappers >= MAX_WRAPPER_HOPS) {
|
|
741
|
+
reasons.add(codes.unclassified);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const side = (whole, original, composite, document) => whole ? { list: [original], discriminator: null } : alternatives(composite, document) ?? { list: [composite], discriminator: null };
|
|
745
|
+
scope.wrappers += 1;
|
|
746
|
+
compareAlternatives(side(beforeIsAlternative, before, resolvedBefore.schema, scope.before), side(afterIsAlternative, after, resolvedAfter.schema, scope.after), direction, depth, equal, reasons, scope);
|
|
747
|
+
scope.wrappers -= 1;
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
611
751
|
if (resolvedBefore.ref !== undefined || resolvedAfter.ref !== undefined) {
|
|
612
752
|
// A reference pair already being compared further up is assumed to hold, so recursive
|
|
613
753
|
// definitions terminate; a pair with one inline side is bounded by the depth limit. A pair
|
|
@@ -665,12 +805,14 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
665
805
|
compareNumericAndArrayConstraints(inner, outer, codes, reasons);
|
|
666
806
|
compareStringConstraints(inner, outer, codes, reasons);
|
|
667
807
|
if (!sameMeaning(before.items, after.items, equal, scope)) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
808
|
+
within(reasons, ["items"], () => {
|
|
809
|
+
if (unevaluated)
|
|
810
|
+
reasons.add(codes.unclassified);
|
|
811
|
+
else if (outer.items !== undefined && inner.items === undefined)
|
|
812
|
+
reasons.add(codes.constraint);
|
|
813
|
+
else if (outer.items !== undefined)
|
|
814
|
+
compareSchema(before.items, after.items, direction, depth + 1, equal, reasons, scope);
|
|
815
|
+
});
|
|
674
816
|
}
|
|
675
817
|
}
|
|
676
818
|
const innerRequired = stringSet(inner.required);
|
|
@@ -683,13 +825,15 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
683
825
|
}
|
|
684
826
|
for (const key of outerRequired) {
|
|
685
827
|
if (!innerRequired.has(key))
|
|
686
|
-
reasons.add(codes.required);
|
|
828
|
+
within(reasons, ["properties", key], () => reasons.add(codes.required));
|
|
687
829
|
}
|
|
688
830
|
for (const key of Object.keys(beforeProperties)) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
831
|
+
within(reasons, ["properties", key], () => {
|
|
832
|
+
if (!Object.hasOwn(afterProperties, key))
|
|
833
|
+
reasons.add(codes.propertyRemoved);
|
|
834
|
+
else
|
|
835
|
+
compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons, scope);
|
|
836
|
+
});
|
|
693
837
|
}
|
|
694
838
|
// In an object whose undeclared keys were governed by other keywords, or a nested input object
|
|
695
839
|
// that left them open, a newly declared property can reject values callers send (input) or
|
|
@@ -747,7 +891,7 @@ function result(reasons) {
|
|
|
747
891
|
const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
|
|
748
892
|
? "breaking"
|
|
749
893
|
: ordered.length > 0 ? "unclassified" : "compatible";
|
|
750
|
-
return { impact, reasons: ordered };
|
|
894
|
+
return reasons instanceof LocatingReasons ? { impact, reasons: ordered, locations: reasons.locations } : { impact, reasons: ordered };
|
|
751
895
|
}
|
|
752
896
|
export function classifyRemovedTool() {
|
|
753
897
|
return { impact: "breaking", reasons: ["tool-removed"] };
|
|
@@ -755,15 +899,15 @@ export function classifyRemovedTool() {
|
|
|
755
899
|
export function classifyAddedTool() {
|
|
756
900
|
return { impact: "compatible", reasons: [] };
|
|
757
901
|
}
|
|
758
|
-
export function classifyChangedTool(before, after, fields, equal) {
|
|
759
|
-
const reasons = new Set();
|
|
902
|
+
export function classifyChangedTool(before, after, fields, equal, options = {}) {
|
|
903
|
+
const reasons = options.locate ? new LocatingReasons() : new Set();
|
|
760
904
|
if (fields.includes("inputSchema")) {
|
|
761
|
-
compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema));
|
|
905
|
+
within(reasons, ["inputSchema"], () => compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema)));
|
|
762
906
|
}
|
|
763
907
|
if (fields.includes("outputSchema"))
|
|
764
|
-
classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
|
|
908
|
+
within(reasons, ["outputSchema"], () => classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons));
|
|
765
909
|
if (fields.includes("annotations"))
|
|
766
|
-
classifyAnnotations(before.annotations, after.annotations, reasons);
|
|
910
|
+
within(reasons, ["annotations"], () => classifyAnnotations(before.annotations, after.annotations, reasons));
|
|
767
911
|
if (fields.includes("other"))
|
|
768
912
|
reasons.add("other-fields-unclassified");
|
|
769
913
|
return result(reasons);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { packageVersion } from "../version.js";
|
|
3
|
-
import { TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
|
|
3
|
+
import { MAX_TOOL_CHANGE_LOCATIONS, MAX_TOOL_CHANGE_POINTER_LENGTH, TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
|
|
4
4
|
import { ruleCatalog } from "../rules/rule-catalog.js";
|
|
5
5
|
const runtimeCapabilityStatusSchema = {
|
|
6
6
|
type: "string",
|
|
@@ -836,7 +836,17 @@ publicSchemaDefinitions.push({
|
|
|
836
836
|
afterToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
|
|
837
837
|
fields: { type: "array", maxItems: 6, uniqueItems: true, items: { enum: ["inputSchema", "outputSchema", "description", "title", "annotations", "other"] } },
|
|
838
838
|
impact: { enum: ["breaking", "compatible", "unclassified"] },
|
|
839
|
-
reasons: { type: "array", maxItems: TOOL_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...TOOL_CHANGE_REASONS] } }
|
|
839
|
+
reasons: { type: "array", maxItems: TOOL_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...TOOL_CHANGE_REASONS] } },
|
|
840
|
+
// Only with --show-names.
|
|
841
|
+
name: { type: "string" },
|
|
842
|
+
locations: {
|
|
843
|
+
type: "array", maxItems: MAX_TOOL_CHANGE_LOCATIONS,
|
|
844
|
+
items: {
|
|
845
|
+
type: "object", required: ["reason", "pointer"],
|
|
846
|
+
properties: { reason: { enum: [...TOOL_CHANGE_REASONS] }, pointer: { type: "string", maxLength: MAX_TOOL_CHANGE_POINTER_LENGTH } },
|
|
847
|
+
additionalProperties: false
|
|
848
|
+
}
|
|
849
|
+
}
|
|
840
850
|
}, additionalProperties: false
|
|
841
851
|
}
|
|
842
852
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Control, format, and line or paragraph separator characters, which can drive a terminal, end a line,
|
|
2
|
+
// reorder text, or hide in it; JSON escapes only the C0 controls among them.
|
|
3
|
+
const UNSAFE_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
4
|
+
/** Quotes server-supplied text, such as a tool name or a JSON Pointer, as a JSON string that only holds printable characters. */
|
|
5
|
+
export function quoteServerText(text) {
|
|
6
|
+
return JSON.stringify(text).replace(UNSAFE_TEXT, (character) => Array.from({ length: character.length }, (_, index) => `\\u${character.charCodeAt(index).toString(16).padStart(4, "0")}`).join(""));
|
|
7
|
+
}
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
import { quoteServerText } from "./quote-server-text.js";
|
|
1
2
|
function count(value) {
|
|
2
3
|
return value === null || value === undefined ? "not compared" : String(value);
|
|
3
4
|
}
|
|
4
5
|
function toolIndex(value) {
|
|
5
6
|
return value === null ? "-" : String(value);
|
|
6
7
|
}
|
|
8
|
+
// Names and pointers come from the server, so they are quoted and put in a code span, where Markdown,
|
|
9
|
+
// HTML, mentions, and links stay inert; the fence outgrows any backtick run, and pipes are escaped for the table.
|
|
10
|
+
function code(text) {
|
|
11
|
+
const quoted = quoteServerText(text);
|
|
12
|
+
let run = 0;
|
|
13
|
+
let longest = 0;
|
|
14
|
+
for (const character of quoted) {
|
|
15
|
+
run = character === "`" ? run + 1 : 0;
|
|
16
|
+
longest = Math.max(longest, run);
|
|
17
|
+
}
|
|
18
|
+
const fence = "`".repeat(longest + 1);
|
|
19
|
+
return `${fence}${quoted.replace(/\|/gu, "\\|")}${fence}`;
|
|
20
|
+
}
|
|
7
21
|
export function renderMcpToolDiffMarkdown(report) {
|
|
8
22
|
const comparison = report.comparison;
|
|
23
|
+
const named = report.changes.some((change) => change.name !== undefined);
|
|
9
24
|
const lines = [
|
|
10
25
|
"## MCP Tool Definition Diff",
|
|
11
26
|
"",
|
|
@@ -16,7 +31,11 @@ export function renderMcpToolDiffMarkdown(report) {
|
|
|
16
31
|
`| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
|
|
17
32
|
];
|
|
18
33
|
if (report.changes.length > 0) {
|
|
19
|
-
lines.push("", "### Changes", "", "| Change | Before tool | After tool | Fields | Impact | Reasons |", "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
|
|
34
|
+
lines.push("", "### Changes", "", named ? "| Change | Tool | Before tool | After tool | Fields | Impact | Reasons |" : "| Change | Before tool | After tool | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : code(change.name)}` : ""} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
|
|
35
|
+
}
|
|
36
|
+
const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : code(change.name)} | \`${location.reason}\` | ${location.pointer === "" ? "(tool)" : code(location.pointer)} |`));
|
|
37
|
+
if (located.length > 0) {
|
|
38
|
+
lines.push("", "### Locations", "", "| Tool | Reason | Location |", "| --- | --- | --- |", ...located);
|
|
20
39
|
}
|
|
21
40
|
const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
|
|
22
41
|
const location = finding.location;
|
|
@@ -28,6 +47,8 @@ export function renderMcpToolDiffMarkdown(report) {
|
|
|
28
47
|
if (findings.length > 0) {
|
|
29
48
|
lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
|
|
30
49
|
}
|
|
31
|
-
lines.push("",
|
|
50
|
+
lines.push("", named
|
|
51
|
+
? `Tool names and schema locations are shown because --show-names was used; schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`
|
|
52
|
+
: `Tool numbers are one-based positions in the before and after files; names and schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`);
|
|
32
53
|
return lines.join("\n");
|
|
33
54
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { quoteServerText } from "./quote-server-text.js";
|
|
1
2
|
export function renderMcpToolDiffReport(report) {
|
|
2
3
|
const comparison = report.comparison;
|
|
3
4
|
return [
|
|
@@ -39,7 +40,9 @@ export function renderMcpToolDiffReport(report) {
|
|
|
39
40
|
...(change.afterToolIndex === null ? [] : [`after tool ${change.afterToolIndex}`])
|
|
40
41
|
];
|
|
41
42
|
const impact = `${change.impact}${change.reasons.length ? ` (${change.reasons.join(", ")})` : ""}`;
|
|
42
|
-
|
|
43
|
+
const name = change.name === undefined ? "" : ` ${quoteServerText(change.name)}`;
|
|
44
|
+
const at = (change.locations ?? []).map((location) => `\n At ${location.pointer === "" ? "(tool)" : quoteServerText(location.pointer)}: ${location.reason}`).join("");
|
|
45
|
+
return `${change.kind.toUpperCase()}${name} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}${at}`;
|
|
43
46
|
})
|
|
44
47
|
].join("\n");
|
|
45
48
|
}
|
package/dist/run-cli.js
CHANGED
|
@@ -111,7 +111,7 @@ function writeExactStdout(io, message) {
|
|
|
111
111
|
}
|
|
112
112
|
class CliUsageError extends Error {
|
|
113
113
|
}
|
|
114
|
-
const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
|
|
114
|
+
const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking] [--show-names]";
|
|
115
115
|
const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
|
|
116
116
|
const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor tools <path> --server <name> --runtime [--sandbox docker [--env <NAME>]...] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
|
|
117
117
|
const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
|
|
@@ -1393,17 +1393,22 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1393
1393
|
if (command === "doctor") {
|
|
1394
1394
|
if (maybePath === "tools-diff") {
|
|
1395
1395
|
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1396
|
-
io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Changes are classified heuristically as breaking, compatible, or unclassified; compatibility is not tested.\n--fail-on breaking exits 1 only for breaking or unclassified changes (default: any change or input warning)
|
|
1396
|
+
io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Changes are classified heuristically as breaking, compatible, or unclassified; compatibility is not tested.\n--fail-on breaking exits 1 only for breaking or unclassified changes (default: any change or input warning).
|
|
1397
|
+
--show-names adds tool names and the JSON Pointer of each reason; reports omit both by default, so keep it out of public output such as pull request comments.`);
|
|
1397
1398
|
return 0;
|
|
1398
1399
|
}
|
|
1399
1400
|
let beforePath;
|
|
1400
1401
|
let afterPath;
|
|
1401
1402
|
let format;
|
|
1402
1403
|
let failOn;
|
|
1404
|
+
let showNames = false;
|
|
1403
1405
|
let invalid = false;
|
|
1404
1406
|
for (let index = 0; index < remainingArgs.length; index += 1) {
|
|
1405
1407
|
const flag = remainingArgs[index];
|
|
1406
|
-
if (
|
|
1408
|
+
if (flag === "--show-names" && !showNames) {
|
|
1409
|
+
showNames = true;
|
|
1410
|
+
}
|
|
1411
|
+
else if ((flag === "--json" || flag === "--markdown") && format === undefined) {
|
|
1407
1412
|
format = flag === "--json" ? "json" : "markdown";
|
|
1408
1413
|
}
|
|
1409
1414
|
else if (flag === "--fail-on" && failOn === undefined) {
|
|
@@ -1436,7 +1441,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1436
1441
|
io.writeStderr(`Invalid comparison arguments. ${toolsDiffUsage}`);
|
|
1437
1442
|
return 2;
|
|
1438
1443
|
}
|
|
1439
|
-
const
|
|
1444
|
+
const compare = options.compareMcpToolFilesImpl ?? compareMcpToolFiles;
|
|
1445
|
+
const report = await (showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath));
|
|
1440
1446
|
io.writeStdout(format === "json" ? JSON.stringify(report, null, 2)
|
|
1441
1447
|
: format === "markdown" ? renderMcpToolDiffMarkdown(report)
|
|
1442
1448
|
: renderMcpToolDiffReport(report));
|
package/package.json
CHANGED