codex-plugin-doctor 1.66.0 → 1.67.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
@@ -121,8 +121,9 @@ 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
123
  unclassified changes. Incomplete inputs, invalid definitions, or duplicate names
124
- prevent a comparison. Impact labels are root-level schema heuristics, not proof
125
- of compatibility.
124
+ prevent a comparison. Impact labels follow nested object properties, array
125
+ items, and common length and range bounds, but remain heuristics, not proof of
126
+ compatibility.
126
127
  See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
127
128
  privacy, limits, and exit codes.
128
129
 
@@ -562,9 +563,9 @@ jobs:
562
563
  runs-on: ubuntu-latest
563
564
  steps:
564
565
  - uses: actions/checkout@v5
565
- - uses: Esquetta/CodexPluginDoctor@v1.66.0
566
+ - uses: Esquetta/CodexPluginDoctor@v1.67.0
566
567
  with:
567
- version: "1.66.0"
568
+ version: "1.67.0"
568
569
  path: .
569
570
  runtime: "true"
570
571
  policy: codex-publish
@@ -1,6 +1,6 @@
1
1
  type JsonObject = Record<string, unknown>;
2
2
  export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
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-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-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
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
5
  export interface ToolChangeClassification {
6
6
  impact: ToolChangeImpact;
@@ -5,6 +5,7 @@ export const TOOL_CHANGE_REASONS = [
5
5
  "input-property-removed",
6
6
  "input-property-type-narrowed",
7
7
  "input-property-enum-narrowed",
8
+ "input-constraint-tightened",
8
9
  "input-additional-properties-closed",
9
10
  "input-schema-unclassified",
10
11
  "output-schema-removed",
@@ -13,6 +14,7 @@ export const TOOL_CHANGE_REASONS = [
13
14
  "output-guarantee-removed",
14
15
  "output-property-type-widened",
15
16
  "output-property-enum-widened",
17
+ "output-constraint-loosened",
16
18
  "output-schema-unclassified",
17
19
  "annotation-safety-reduced",
18
20
  "other-fields-unclassified"
@@ -25,7 +27,33 @@ const UNCLASSIFIED_REASONS = new Set([
25
27
  // Keywords that document a schema without constraining accepted or produced values.
26
28
  const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "default", "deprecated", "$comment"]);
27
29
  const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
28
- const PROPERTY_STRUCTURAL_KEYWORDS = new Set(["type", "enum"]);
30
+ const LOWER_BOUND_KEYWORDS = ["minimum", "minLength", "minItems"];
31
+ const UPPER_BOUND_KEYWORDS = ["maximum", "maxLength", "maxItems"];
32
+ const NESTED_STRUCTURAL_KEYWORDS = new Set([
33
+ "type", "enum", "properties", "required", "additionalProperties", "items", ...LOWER_BOUND_KEYWORDS, ...UPPER_BOUND_KEYWORDS
34
+ ]);
35
+ // Subschemas nested deeper than this below the root are not compared and stay unclassified.
36
+ const MAX_NESTED_DEPTH = 8;
37
+ const CODES = {
38
+ input: {
39
+ unclassified: "input-schema-unclassified",
40
+ rootType: "input-type-narrowed",
41
+ propertyType: "input-property-type-narrowed",
42
+ enumChanged: "input-property-enum-narrowed",
43
+ constraint: "input-constraint-tightened",
44
+ required: "input-required-added",
45
+ propertyRemoved: "input-property-removed"
46
+ },
47
+ output: {
48
+ unclassified: "output-schema-unclassified",
49
+ rootType: "output-type-widened",
50
+ propertyType: "output-property-type-widened",
51
+ enumChanged: "output-property-enum-widened",
52
+ constraint: "output-constraint-loosened",
53
+ required: "output-guarantee-removed",
54
+ propertyRemoved: "output-property-removed"
55
+ }
56
+ };
29
57
  // MCP tool annotation defaults, oriented so `true` is the safer reading.
30
58
  const SAFETY_HINTS = [
31
59
  { key: "readOnlyHint", absent: false, safe: true },
@@ -91,62 +119,104 @@ function keywordsOutside(schema, allowed) {
91
119
  }
92
120
  return rest;
93
121
  }
122
+ function isFiniteNumber(value) {
123
+ return typeof value === "number" && Number.isFinite(value);
124
+ }
125
+ /** Flags bounds of `outer` that `inner` does not satisfy, so `inner` values may fall outside `outer`. */
126
+ function compareBounds(inner, outer, codes, reasons) {
127
+ for (const [keywords, covered] of [
128
+ [LOWER_BOUND_KEYWORDS, (innerBound, outerBound) => innerBound >= outerBound],
129
+ [UPPER_BOUND_KEYWORDS, (innerBound, outerBound) => innerBound <= outerBound]
130
+ ]) {
131
+ for (const keyword of keywords) {
132
+ const outerBound = outer[keyword];
133
+ const innerBound = inner[keyword];
134
+ if ((outerBound !== undefined && !isFiniteNumber(outerBound)) || (innerBound !== undefined && !isFiniteNumber(innerBound))) {
135
+ reasons.add(codes.unclassified);
136
+ }
137
+ else if (outerBound !== undefined && (innerBound === undefined || !covered(innerBound, outerBound))) {
138
+ reasons.add(codes.constraint);
139
+ }
140
+ }
141
+ }
142
+ }
94
143
  /**
95
- * Compares one property subschema. `direction` is "input" when callers must keep being
96
- * accepted (after must cover before) and "output" when consumers must keep understanding
97
- * results (before must cover after).
144
+ * Compares one schema and its object and array subschemas. `direction` is "input" when callers
145
+ * must keep being accepted (after must cover before) and "output" when consumers must keep
146
+ * understanding results (before must cover after). Depth 0 is the tool's root schema.
98
147
  */
99
- function compareProperty(before, after, direction, equal, reasons) {
148
+ function compareSchema(before, after, direction, depth, equal, reasons) {
100
149
  if (equal(before, after))
101
150
  return;
102
- const unclassified = direction === "input" ? "input-schema-unclassified" : "output-schema-unclassified";
103
- if (!isJsonObject(before) || !isJsonObject(after)
104
- || !equal(keywordsOutside(before, PROPERTY_STRUCTURAL_KEYWORDS), keywordsOutside(after, PROPERTY_STRUCTURAL_KEYWORDS))) {
105
- reasons.add(unclassified);
151
+ const codes = CODES[direction];
152
+ const root = depth === 0;
153
+ const structural = root ? ROOT_STRUCTURAL_KEYWORDS : NESTED_STRUCTURAL_KEYWORDS;
154
+ if (depth > MAX_NESTED_DEPTH || !isJsonObject(before) || !isJsonObject(after)
155
+ || !equal(keywordsOutside(before, structural), keywordsOutside(after, structural))
156
+ || !equal(before.$schema, after.$schema)) {
157
+ reasons.add(codes.unclassified);
106
158
  return;
107
159
  }
108
160
  const [inner, outer] = direction === "input" ? [before, after] : [after, before];
109
- if (!typesCovered(typeSet(inner), typeSet(outer))) {
110
- reasons.add(direction === "input" ? "input-property-type-narrowed" : "output-property-type-widened");
161
+ // unevaluatedItems/unevaluatedProperties apply to whatever items/additionalProperties leave
162
+ // uncovered, so a change in either cannot be judged on its own.
163
+ const unevaluated = ["unevaluatedItems", "unevaluatedProperties"].some((key) => before[key] !== undefined || after[key] !== undefined);
164
+ if (!typesCovered(typeSet(inner), typeSet(outer)))
165
+ reasons.add(root ? codes.rootType : codes.propertyType);
166
+ if (!root) {
167
+ const enumResult = enumCovered(inner.enum, outer.enum, equal);
168
+ if (enumResult === null)
169
+ reasons.add(codes.unclassified);
170
+ else if (!enumResult)
171
+ reasons.add(codes.enumChanged);
172
+ compareBounds(inner, outer, codes, reasons);
173
+ if (!equal(before.items, after.items)) {
174
+ if (unevaluated)
175
+ reasons.add(codes.unclassified);
176
+ else if (outer.items !== undefined && inner.items === undefined)
177
+ reasons.add(codes.constraint);
178
+ else if (outer.items !== undefined)
179
+ compareSchema(before.items, after.items, direction, depth + 1, equal, reasons);
180
+ }
111
181
  }
112
- const enumResult = enumCovered(inner.enum, outer.enum, equal);
113
- if (enumResult === null)
114
- reasons.add(unclassified);
115
- else if (!enumResult)
116
- reasons.add(direction === "input" ? "input-property-enum-narrowed" : "output-property-enum-widened");
117
- }
118
- function classifyInputSchema(before, after, equal, reasons) {
119
- if (!isJsonObject(before) || !isJsonObject(after)
120
- || !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
121
- || !equal(before.$schema, after.$schema)) {
122
- reasons.add("input-schema-unclassified");
123
- return;
124
- }
125
- if (!typesCovered(typeSet(before), typeSet(after)))
126
- reasons.add("input-type-narrowed");
127
- const beforeRequired = stringSet(before.required);
128
- const afterRequired = stringSet(after.required);
182
+ const innerRequired = stringSet(inner.required);
183
+ const outerRequired = stringSet(outer.required);
129
184
  const beforeProperties = objectMap(before.properties);
130
185
  const afterProperties = objectMap(after.properties);
131
- if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
132
- reasons.add("input-schema-unclassified");
186
+ if (innerRequired === null || outerRequired === null || beforeProperties === null || afterProperties === null) {
187
+ reasons.add(codes.unclassified);
133
188
  return;
134
189
  }
135
- for (const key of afterRequired) {
136
- if (!beforeRequired.has(key))
137
- reasons.add("input-required-added");
190
+ for (const key of outerRequired) {
191
+ if (!innerRequired.has(key))
192
+ reasons.add(codes.required);
138
193
  }
139
194
  for (const key of Object.keys(beforeProperties)) {
140
195
  if (!Object.hasOwn(afterProperties, key))
141
- reasons.add("input-property-removed");
196
+ reasons.add(codes.propertyRemoved);
142
197
  else
143
- compareProperty(beforeProperties[key], afterProperties[key], "input", equal, reasons);
198
+ compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons);
144
199
  }
200
+ // In a nested object whose undeclared keys were open or governed by other keywords, a newly
201
+ // declared property can reject values callers send (input) or produce values consumers did
202
+ // not expect (output).
203
+ const addedKeys = Object.keys(afterProperties).filter((key) => !Object.hasOwn(beforeProperties, key));
204
+ if (!root && addedKeys.length > 0) {
205
+ const governed = isJsonObject(before.additionalProperties) || Object.keys(keywordsOutside(before, structural)).length > 0;
206
+ const open = Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
207
+ if (governed || (direction === "input" && open))
208
+ reasons.add(codes.unclassified);
209
+ }
210
+ // Callers already send a key that was required before it was declared, with any value.
211
+ if (direction === "input" && addedKeys.some((key) => innerRequired.has(key)))
212
+ reasons.add(codes.unclassified);
145
213
  if (!equal(before.additionalProperties, after.additionalProperties)) {
146
- if (after.additionalProperties === false)
214
+ if (unevaluated)
215
+ reasons.add(codes.unclassified);
216
+ else if (direction === "input" && after.additionalProperties === false)
147
217
  reasons.add("input-additional-properties-closed");
148
- else if (!(after.additionalProperties === undefined || after.additionalProperties === true))
149
- reasons.add("input-schema-unclassified");
218
+ else if (direction === "output" || !(after.additionalProperties === undefined || after.additionalProperties === true))
219
+ reasons.add(codes.unclassified);
150
220
  }
151
221
  }
152
222
  function classifyOutputSchema(before, after, equal, reasons) {
@@ -156,34 +226,7 @@ function classifyOutputSchema(before, after, equal, reasons) {
156
226
  reasons.add("output-schema-removed");
157
227
  return;
158
228
  }
159
- if (!isJsonObject(before) || !isJsonObject(after)
160
- || !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
161
- || !equal(before.$schema, after.$schema)) {
162
- reasons.add("output-schema-unclassified");
163
- return;
164
- }
165
- if (!typesCovered(typeSet(after), typeSet(before)))
166
- reasons.add("output-type-widened");
167
- const beforeRequired = stringSet(before.required);
168
- const afterRequired = stringSet(after.required);
169
- const beforeProperties = objectMap(before.properties);
170
- const afterProperties = objectMap(after.properties);
171
- if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
172
- reasons.add("output-schema-unclassified");
173
- return;
174
- }
175
- for (const key of beforeRequired) {
176
- if (!afterRequired.has(key))
177
- reasons.add("output-guarantee-removed");
178
- }
179
- for (const key of Object.keys(beforeProperties)) {
180
- if (!Object.hasOwn(afterProperties, key))
181
- reasons.add("output-property-removed");
182
- else
183
- compareProperty(beforeProperties[key], afterProperties[key], "output", equal, reasons);
184
- }
185
- if (!equal(before.additionalProperties, after.additionalProperties))
186
- reasons.add("output-schema-unclassified");
229
+ compareSchema(before, after, "output", 0, equal, reasons);
187
230
  }
188
231
  function hint(annotations, key, absent) {
189
232
  if (annotations === undefined)
@@ -219,7 +262,7 @@ export function classifyAddedTool() {
219
262
  export function classifyChangedTool(before, after, fields, equal) {
220
263
  const reasons = new Set();
221
264
  if (fields.includes("inputSchema"))
222
- classifyInputSchema(before.inputSchema, after.inputSchema, equal, reasons);
265
+ compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons);
223
266
  if (fields.includes("outputSchema"))
224
267
  classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
225
268
  if (fields.includes("annotations"))
@@ -19,7 +19,7 @@ export function renderMcpToolDiffReport(report) {
19
19
  `Server catalog: ${report.coverage.serverCatalog}`,
20
20
  `Tool execution: ${report.coverage.toolExecution}`,
21
21
  `Impact classification: ${report.coverage.impactClassification}`,
22
- "Only the supplied responses are compared. Impact labels are root-level schema heuristics: a compatible label or an unchanged result does not establish compatibility.",
22
+ "Only the supplied responses are compared. Impact labels are schema heuristics: a compatible label or an unchanged result does not establish compatibility.",
23
23
  ...["before", "after"].flatMap((side) => {
24
24
  const source = report[side];
25
25
  const label = side === "before" ? "Before" : "After";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.66.0",
3
+ "version": "1.67.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",