codex-plugin-doctor 1.75.0 → 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 +74 -17
- 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",
|
|
@@ -752,12 +805,14 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
752
805
|
compareNumericAndArrayConstraints(inner, outer, codes, reasons);
|
|
753
806
|
compareStringConstraints(inner, outer, codes, reasons);
|
|
754
807
|
if (!sameMeaning(before.items, after.items, equal, scope)) {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
+
});
|
|
761
816
|
}
|
|
762
817
|
}
|
|
763
818
|
const innerRequired = stringSet(inner.required);
|
|
@@ -770,13 +825,15 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
|
|
|
770
825
|
}
|
|
771
826
|
for (const key of outerRequired) {
|
|
772
827
|
if (!innerRequired.has(key))
|
|
773
|
-
reasons.add(codes.required);
|
|
828
|
+
within(reasons, ["properties", key], () => reasons.add(codes.required));
|
|
774
829
|
}
|
|
775
830
|
for (const key of Object.keys(beforeProperties)) {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
+
});
|
|
780
837
|
}
|
|
781
838
|
// In an object whose undeclared keys were governed by other keywords, or a nested input object
|
|
782
839
|
// that left them open, a newly declared property can reject values callers send (input) or
|
|
@@ -834,7 +891,7 @@ function result(reasons) {
|
|
|
834
891
|
const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
|
|
835
892
|
? "breaking"
|
|
836
893
|
: ordered.length > 0 ? "unclassified" : "compatible";
|
|
837
|
-
return { impact, reasons: ordered };
|
|
894
|
+
return reasons instanceof LocatingReasons ? { impact, reasons: ordered, locations: reasons.locations } : { impact, reasons: ordered };
|
|
838
895
|
}
|
|
839
896
|
export function classifyRemovedTool() {
|
|
840
897
|
return { impact: "breaking", reasons: ["tool-removed"] };
|
|
@@ -842,15 +899,15 @@ export function classifyRemovedTool() {
|
|
|
842
899
|
export function classifyAddedTool() {
|
|
843
900
|
return { impact: "compatible", reasons: [] };
|
|
844
901
|
}
|
|
845
|
-
export function classifyChangedTool(before, after, fields, equal) {
|
|
846
|
-
const reasons = new Set();
|
|
902
|
+
export function classifyChangedTool(before, after, fields, equal, options = {}) {
|
|
903
|
+
const reasons = options.locate ? new LocatingReasons() : new Set();
|
|
847
904
|
if (fields.includes("inputSchema")) {
|
|
848
|
-
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)));
|
|
849
906
|
}
|
|
850
907
|
if (fields.includes("outputSchema"))
|
|
851
|
-
classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
|
|
908
|
+
within(reasons, ["outputSchema"], () => classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons));
|
|
852
909
|
if (fields.includes("annotations"))
|
|
853
|
-
classifyAnnotations(before.annotations, after.annotations, reasons);
|
|
910
|
+
within(reasons, ["annotations"], () => classifyAnnotations(before.annotations, after.annotations, reasons));
|
|
854
911
|
if (fields.includes("other"))
|
|
855
912
|
reasons.add("other-fields-unclassified");
|
|
856
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