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 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, and `--markdown` for pull request tables. Incomplete inputs, invalid definitions, or duplicate names
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.74.1
593
+ - uses: Esquetta/CodexPluginDoctor@v1.76.0
593
594
  with:
594
- version: "1.74.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 declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
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): ToolChangeClassification;
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 `anyOf`) followed along one path; deeper wrappers are unclassified.
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
- /** The alternatives of a schema that is only `anyOf` with documentation, or null. */
262
- function alternatives(schema) {
263
- if (!isJsonObject(schema) || !Array.isArray(schema.anyOf))
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
- return Object.keys(schema).every((key) => key === "anyOf" || DOCUMENTATION_KEYWORDS.has(key)) ? schema.anyOf : null;
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` or an `anyOf` with only
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
- compareAlternatives(alternatives(before) ?? [before], alternatives(after) ?? [after], direction, depth, equal, reasons, scope);
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. A value is accepted when any alternative accepts it, so inputs stay
302
- * compatible when every earlier alternative is covered by a later one, and outputs when every later
303
- * alternative is covered by an earlier one. Identical alternatives match first, then alternatives with
304
- * the same reference target or type, then a single remaining alternative on each side.
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
- for (const key of new Set(unpairedBefore.map(alternativeKey))) {
323
- if (key === null)
324
- continue;
325
- const beforeMatches = unpairedBefore.filter((alternative) => alternativeKey(alternative) === key);
326
- const afterMatches = unpairedAfter.filter((alternative) => alternativeKey(alternative) === key);
327
- if (beforeMatches.length !== 1 || afterMatches.length !== 1)
328
- continue;
329
- pairs.push([beforeMatches[0], afterMatches[0]]);
330
- unpairedBefore.splice(unpairedBefore.indexOf(beforeMatches[0]), 1);
331
- unpairedAfter.splice(unpairedAfter.indexOf(afterMatches[0]), 1);
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 plainKey = alternativeKey(original);
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
- if (unevaluated)
669
- reasons.add(codes.unclassified);
670
- else if (outer.items !== undefined && inner.items === undefined)
671
- reasons.add(codes.constraint);
672
- else if (outer.items !== undefined)
673
- compareSchema(before.items, after.items, direction, depth + 1, equal, reasons, scope);
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
- if (!Object.hasOwn(afterProperties, key))
690
- reasons.add(codes.propertyRemoved);
691
- else
692
- compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons, scope);
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,2 @@
1
+ /** Quotes server-supplied text, such as a tool name or a JSON Pointer, as a JSON string that only holds printable characters. */
2
+ export declare function quoteServerText(text: string): string;
@@ -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("", `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.`);
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
- return `${change.kind.toUpperCase()} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}`;
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 ((flag === "--json" || flag === "--markdown") && format === undefined) {
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 report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.74.1",
3
+ "version": "1.76.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",