codex-plugin-doctor 1.66.0 → 1.68.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
|
|
125
|
-
of
|
|
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
|
|
|
@@ -141,7 +142,9 @@ execution, or client compatibility. Existing `doctor discover` behavior is uncha
|
|
|
141
142
|
Add `--save-response <path>` to record a completely enumerated catalog as a
|
|
142
143
|
`tools/list` response for `tools-file` and `tools-diff`; nothing is written when
|
|
143
144
|
enumeration is incomplete. The GitHub Action can compare saved responses with
|
|
144
|
-
`tools-diff-before`, `tools-diff-after`, and `tools-diff-fail-on
|
|
145
|
+
`tools-diff-before`, `tools-diff-after`, and `tools-diff-fail-on`, capture the
|
|
146
|
+
live list with `tools-capture-url`, and skip the plugin package check with
|
|
147
|
+
`check: "false"` in repositories that only ship an MCP server.
|
|
145
148
|
See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
|
|
146
149
|
|
|
147
150
|
### Remote MCP Readiness
|
|
@@ -562,9 +565,9 @@ jobs:
|
|
|
562
565
|
runs-on: ubuntu-latest
|
|
563
566
|
steps:
|
|
564
567
|
- uses: actions/checkout@v5
|
|
565
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
568
|
+
- uses: Esquetta/CodexPluginDoctor@v1.68.0
|
|
566
569
|
with:
|
|
567
|
-
version: "1.
|
|
570
|
+
version: "1.68.0"
|
|
568
571
|
path: .
|
|
569
572
|
runtime: "true"
|
|
570
573
|
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
|
|
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
|
|
96
|
-
* accepted (after must cover before) and "output" when consumers must keep
|
|
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
|
|
148
|
+
function compareSchema(before, after, direction, depth, equal, reasons) {
|
|
100
149
|
if (equal(before, after))
|
|
101
150
|
return;
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
113
|
-
|
|
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 (
|
|
132
|
-
reasons.add(
|
|
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
|
|
136
|
-
if (!
|
|
137
|
-
reasons.add(
|
|
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(
|
|
196
|
+
reasons.add(codes.propertyRemoved);
|
|
142
197
|
else
|
|
143
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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