codex-plugin-doctor 1.72.1 → 1.74.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
@@ -149,7 +149,11 @@ live list with `tools-capture-url` (and `tools-capture-token-env`) or, for a loc
149
149
  stdio server with `runtime: "true"`, `tools-capture-server`, and skip the
150
150
  plugin package check with `check: "false"` in repositories that only ship an MCP
151
151
  server. With `tools-diff-comment: "true"` it also posts the comparison as one
152
- pull request comment that later runs update.
152
+ pull request comment that later runs update. `tools-capture-sandbox: docker`
153
+ starts a stdio server in the Docker sandbox, and
154
+ `codex-plugin-doctor init-ci . --tools-diff-server <name> --sandbox docker`
155
+ writes a pull request workflow that captures in one job and compares and
156
+ comments in another.
153
157
 
154
158
  Local stdio servers from a package's MCP configuration can be cataloged too.
155
159
  `--runtime` is required because this starts package code; the server is stopped
@@ -585,9 +589,9 @@ jobs:
585
589
  runs-on: ubuntu-latest
586
590
  steps:
587
591
  - uses: actions/checkout@v5
588
- - uses: Esquetta/CodexPluginDoctor@v1.72.1
592
+ - uses: Esquetta/CodexPluginDoctor@v1.74.0
589
593
  with:
590
- version: "1.72.1"
594
+ version: "1.74.0"
591
595
  path: .
592
596
  runtime: "true"
593
597
  policy: codex-publish
@@ -2,4 +2,10 @@ export interface InitCiResult {
2
2
  rootPath: string;
3
3
  workflowPath: string;
4
4
  }
5
+ export interface ToolsDiffWorkflowOptions {
6
+ server: string;
7
+ baseline: string;
8
+ sandbox: "docker" | null;
9
+ }
10
+ export declare function initToolsDiffWorkflow(targetPath: string, options: ToolsDiffWorkflowOptions): Promise<InitCiResult>;
5
11
  export declare function initCiWorkflow(targetPath: string): Promise<InitCiResult>;
@@ -31,6 +31,80 @@ function buildWorkflow() {
31
31
  ""
32
32
  ].join("\n");
33
33
  }
34
+ // JSON strings are valid YAML double-quoted scalars, so names and paths cannot break the document.
35
+ const yamlString = (value) => JSON.stringify(value);
36
+ /**
37
+ * A pull request workflow in two jobs: the first starts the stdio server and uploads its tool list,
38
+ * and the second compares it with the committed baseline and comments. Only the second job may write
39
+ * to pull requests, and it runs no package code.
40
+ */
41
+ function buildToolsDiffWorkflow(options) {
42
+ return [
43
+ "name: MCP tool diff",
44
+ "",
45
+ "on:",
46
+ " pull_request:",
47
+ "",
48
+ "permissions:",
49
+ " contents: read",
50
+ "",
51
+ "jobs:",
52
+ " capture:",
53
+ " runs-on: ubuntu-latest",
54
+ " steps:",
55
+ " - uses: actions/checkout@v5",
56
+ " with:",
57
+ " persist-credentials: false",
58
+ " # Add install or build steps here if the server needs them; the Docker sandbox has no network.",
59
+ ` - uses: Esquetta/CodexPluginDoctor@v${packageVersion}`,
60
+ " with:",
61
+ ` version: \"${packageVersion}\"`,
62
+ " path: .",
63
+ " check: \"false\"",
64
+ " runtime: \"true\"",
65
+ ` tools-capture-server: ${yamlString(options.server)}`,
66
+ ...(options.sandbox === "docker" ? [" tools-capture-sandbox: docker"] : []),
67
+ " # Catalog findings do not fail this job; the compare job re-checks the saved list.",
68
+ " tools-capture-defer-findings: \"true\"",
69
+ " artifact-name: mcp-tools-capture",
70
+ "",
71
+ " compare:",
72
+ " needs: capture",
73
+ " runs-on: ubuntu-latest",
74
+ " permissions:",
75
+ " contents: read",
76
+ " pull-requests: write",
77
+ " steps:",
78
+ " - uses: actions/checkout@v5",
79
+ " with:",
80
+ " persist-credentials: false",
81
+ " # Outside the checkout, so files from the pull request cannot shadow the downloaded list.",
82
+ " - uses: actions/download-artifact@v5",
83
+ " with:",
84
+ " name: mcp-tools-capture",
85
+ " path: ${{ runner.temp }}/mcp-tools-capture",
86
+ ` - uses: Esquetta/CodexPluginDoctor@v${packageVersion}`,
87
+ " with:",
88
+ ` version: \"${packageVersion}\"`,
89
+ " check: \"false\"",
90
+ ` tools-diff-before: ${yamlString(options.baseline)}`,
91
+ " tools-diff-after: ${{ runner.temp }}/mcp-tools-capture/mcp-tools-current.json",
92
+ " tools-diff-fail-on: breaking",
93
+ " tools-diff-comment: \"true\"",
94
+ " output-dir: ${{ runner.temp }}/codex-plugin-doctor-reports",
95
+ " artifact-name: mcp-tools-diff",
96
+ ""
97
+ ].join("\n");
98
+ }
99
+ export async function initToolsDiffWorkflow(targetPath, options) {
100
+ const rootPath = path.resolve(targetPath);
101
+ const workflowPath = path.join(rootPath, ".github", "workflows", "codex-plugin-doctor-tools.yml");
102
+ await mkdir(path.dirname(workflowPath), { recursive: true });
103
+ await writeFile(workflowPath, buildToolsDiffWorkflow(options), "utf8");
104
+ // doctor tools --save-response writes into an existing directory, so the baseline can be captured next.
105
+ await mkdir(path.dirname(path.resolve(rootPath, options.baseline)), { recursive: true });
106
+ return { rootPath, workflowPath };
107
+ }
34
108
  export async function initCiWorkflow(targetPath) {
35
109
  const rootPath = path.resolve(targetPath);
36
110
  const workflowPath = path.join(rootPath, ".github", "workflows", "codex-plugin-doctor.yml");
@@ -27,13 +27,15 @@ const UNCLASSIFIED_REASONS = new Set([
27
27
  // Keywords that document a schema without constraining accepted or produced values.
28
28
  const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "default", "deprecated", "$comment"]);
29
29
  const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
30
- const LOWER_BOUND_KEYWORDS = ["minimum", "minLength", "minItems"];
31
- const UPPER_BOUND_KEYWORDS = ["maximum", "maxLength", "maxItems"];
30
+ const LOWER_BOUND_KEYWORDS = ["minLength", "minItems"];
31
+ const UPPER_BOUND_KEYWORDS = ["maxLength", "maxItems"];
32
+ // Numeric bounds, compared as one effective bound per side because the exclusive form may replace the inclusive one.
33
+ const NUMERIC_BOUND_KEYWORDS = ["minimum", "exclusiveMinimum", "maximum", "exclusiveMaximum"];
32
34
  // String keywords that can only be introduced or removed with a known effect; a changed value is unclassified.
33
35
  const STRING_CONSTRAINT_KEYWORDS = ["pattern", "format"];
34
36
  const NESTED_STRUCTURAL_KEYWORDS = new Set([
35
- "type", "enum", "const", "properties", "required", "additionalProperties", "items",
36
- ...LOWER_BOUND_KEYWORDS, ...UPPER_BOUND_KEYWORDS, ...STRING_CONSTRAINT_KEYWORDS
37
+ "type", "enum", "const", "properties", "required", "additionalProperties", "items", "multipleOf", "uniqueItems",
38
+ ...LOWER_BOUND_KEYWORDS, ...UPPER_BOUND_KEYWORDS, ...NUMERIC_BOUND_KEYWORDS, ...STRING_CONSTRAINT_KEYWORDS
37
39
  ]);
38
40
  // When every reference is a same-document fragment, definition containers are compared only
39
41
  // where a resolved `$ref` reaches into them; otherwise they are compared as written.
@@ -45,11 +47,16 @@ const NESTED_STRUCTURAL_WITH_DEFINITIONS = new Set([...NESTED_STRUCTURAL_KEYWORD
45
47
  const DATA_KEYWORDS = new Set(["enum", "const", "examples", "example", "default"]);
46
48
  // Keywords whose values map names to subschemas; their keys are names, never keywords.
47
49
  const NAME_MAP_KEYWORDS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"]);
48
- // Subschemas nested deeper than this below the root are not compared and stay unclassified.
50
+ // Subschemas nested deeper than this below the root are not compared and stay unclassified; composition
51
+ // wrappers keep the depth of the schema they wrap and are bounded by MAX_WRAPPER_HOPS instead.
49
52
  const MAX_NESTED_DEPTH = 8;
50
53
  // Keywords that only hold or name subschemas and never validate the object's own keys, so a
51
54
  // new property beside them stays compatible. Any other unclassified keyword may govern keys.
52
55
  const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
56
+ // `anyOf` alternatives beyond this many on either side are not paired.
57
+ const MAX_ALTERNATIVES = 32;
58
+ // Composition wrappers (`allOf` and `anyOf`) followed along one path; deeper wrappers are unclassified.
59
+ const MAX_WRAPPER_HOPS = 16;
53
60
  // A `$ref` chain longer than this, such as an alias cycle, is not resolved.
54
61
  const MAX_REFERENCE_HOPS = 8;
55
62
  // Schema comparisons allowed per tool field; a schema that needs more is unclassified.
@@ -168,6 +175,176 @@ function compareBounds(inner, outer, codes, reasons) {
168
175
  }
169
176
  }
170
177
  }
178
+ /**
179
+ * The tighter of a schema's inclusive and exclusive bound on one side, undefined when it has neither,
180
+ * or null when either is not a finite number (such as a draft-04 boolean `exclusiveMinimum`).
181
+ */
182
+ function numericBound(schema, inclusive, exclusive, lower) {
183
+ if (typeof schema[exclusive] === "boolean") {
184
+ // Draft-04 marks the inclusive keyword itself as exclusive; a boolean alone is invalid in every draft.
185
+ const value = schema[inclusive];
186
+ return isFiniteNumber(value) ? { value, exclusive: schema[exclusive] } : null;
187
+ }
188
+ const candidates = [];
189
+ for (const [keyword, isExclusive] of [[inclusive, false], [exclusive, true]]) {
190
+ const value = schema[keyword];
191
+ if (value === undefined)
192
+ continue;
193
+ if (!isFiniteNumber(value))
194
+ return null;
195
+ candidates.push({ value, exclusive: isExclusive });
196
+ }
197
+ return candidates.reduce((tightest, candidate) => {
198
+ if (tightest === undefined)
199
+ return candidate;
200
+ if (candidate.value === tightest.value)
201
+ return candidate.exclusive ? candidate : tightest;
202
+ return (lower ? candidate.value > tightest.value : candidate.value < tightest.value) ? candidate : tightest;
203
+ }, undefined);
204
+ }
205
+ /** Flags numeric bounds, `multipleOf`, and `uniqueItems` of `outer` that `inner` does not satisfy. */
206
+ function compareNumericAndArrayConstraints(inner, outer, codes, reasons) {
207
+ for (const [inclusive, exclusive, lower] of [["minimum", "exclusiveMinimum", true], ["maximum", "exclusiveMaximum", false]]) {
208
+ const innerBound = numericBound(inner, inclusive, exclusive, lower);
209
+ const outerBound = numericBound(outer, inclusive, exclusive, lower);
210
+ if (innerBound === null || outerBound === null) {
211
+ // A bound that cannot be read still means the same when it did not change.
212
+ const unchanged = Object.is(inner[inclusive], outer[inclusive]) && Object.is(inner[exclusive], outer[exclusive]);
213
+ if (!unchanged)
214
+ reasons.add(codes.unclassified);
215
+ }
216
+ else if (outerBound !== undefined) {
217
+ const covered = innerBound !== undefined && (innerBound.value === outerBound.value
218
+ ? innerBound.exclusive || !outerBound.exclusive
219
+ : lower ? innerBound.value > outerBound.value : innerBound.value < outerBound.value);
220
+ if (!covered)
221
+ reasons.add(codes.constraint);
222
+ }
223
+ }
224
+ const outerMultiple = outer.multipleOf;
225
+ const innerMultiple = inner.multipleOf;
226
+ const validMultiple = (value) => value === undefined || (isFiniteNumber(value) && value > 0);
227
+ if (!validMultiple(outerMultiple) || !validMultiple(innerMultiple)) {
228
+ if (!Object.is(innerMultiple, outerMultiple))
229
+ reasons.add(codes.unclassified);
230
+ }
231
+ else if (outerMultiple !== undefined) {
232
+ if (innerMultiple === undefined) {
233
+ reasons.add(codes.constraint);
234
+ }
235
+ else if (Number.isInteger(innerMultiple) && Number.isInteger(outerMultiple)) {
236
+ // Every multiple of innerMultiple is a multiple of outerMultiple when outerMultiple divides it.
237
+ if (innerMultiple % outerMultiple !== 0)
238
+ reasons.add(codes.constraint);
239
+ }
240
+ else if (innerMultiple !== outerMultiple) {
241
+ // Validators test fractional multiples with floating-point division, which can reject 0.3
242
+ // under multipleOf 0.1, so a changed fractional multipleOf cannot be judged.
243
+ reasons.add(codes.unclassified);
244
+ }
245
+ }
246
+ if ((outer.uniqueItems !== undefined && typeof outer.uniqueItems !== "boolean")
247
+ || (inner.uniqueItems !== undefined && typeof inner.uniqueItems !== "boolean")) {
248
+ if (!Object.is(inner.uniqueItems, outer.uniqueItems))
249
+ reasons.add(codes.unclassified);
250
+ }
251
+ else if (outer.uniqueItems === true && inner.uniqueItems !== true) {
252
+ reasons.add(codes.constraint);
253
+ }
254
+ }
255
+ /** The subschema of a schema that is only a one-element `allOf` with documentation, or null. */
256
+ function singleAllOf(schema) {
257
+ if (!isJsonObject(schema) || !Array.isArray(schema.allOf) || schema.allOf.length !== 1)
258
+ return null;
259
+ return Object.keys(schema).every((key) => key === "allOf" || DOCUMENTATION_KEYWORDS.has(key)) ? { inner: schema.allOf[0] } : null;
260
+ }
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))
264
+ return null;
265
+ return Object.keys(schema).every((key) => key === "anyOf" || DOCUMENTATION_KEYWORDS.has(key)) ? schema.anyOf : null;
266
+ }
267
+ function isComposition(schema) {
268
+ return singleAllOf(schema) !== null || alternatives(schema) !== null;
269
+ }
270
+ /**
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.
273
+ */
274
+ function compareComposition(before, after, direction, depth, equal, reasons, scope) {
275
+ if (scope.wrappers >= MAX_WRAPPER_HOPS) {
276
+ reasons.add(CODES[direction].unclassified);
277
+ return;
278
+ }
279
+ const wrappedBefore = singleAllOf(before);
280
+ const wrappedAfter = singleAllOf(after);
281
+ scope.wrappers += 1;
282
+ if (wrappedBefore !== null || wrappedAfter !== null) {
283
+ // A one-element allOf with only documentation beside it means the same as its subschema.
284
+ compareSchema(wrappedBefore === null ? before : wrappedBefore.inner, wrappedAfter === null ? after : wrappedAfter.inner, direction, depth, equal, reasons, scope);
285
+ }
286
+ else {
287
+ // 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);
289
+ }
290
+ scope.wrappers -= 1;
291
+ }
292
+ /** The reference target or declared type that pairs an alternative with its counterpart. */
293
+ function alternativeKey(alternative) {
294
+ if (!isJsonObject(alternative))
295
+ return null;
296
+ if (typeof alternative.$ref === "string")
297
+ return `ref:${alternative.$ref}`;
298
+ return alternative.type === undefined ? null : `type:${JSON.stringify(alternative.type)}`;
299
+ }
300
+ /**
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.
305
+ */
306
+ function compareAlternatives(before, after, direction, depth, equal, reasons, scope) {
307
+ const codes = CODES[direction];
308
+ if (before.length > MAX_ALTERNATIVES || after.length > MAX_ALTERNATIVES) {
309
+ reasons.add(codes.unclassified);
310
+ return;
311
+ }
312
+ const unpairedBefore = [...before];
313
+ const unpairedAfter = [...after];
314
+ for (let index = unpairedBefore.length - 1; index >= 0; index -= 1) {
315
+ const match = unpairedAfter.findIndex((candidate) => sameMeaning(unpairedBefore[index], candidate, equal, scope));
316
+ if (match !== -1) {
317
+ unpairedBefore.splice(index, 1);
318
+ unpairedAfter.splice(match, 1);
319
+ }
320
+ }
321
+ 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);
332
+ }
333
+ if (unpairedBefore.length === 1 && unpairedAfter.length === 1) {
334
+ pairs.push([unpairedBefore.pop(), unpairedAfter.pop()]);
335
+ }
336
+ else if (unpairedBefore.length > 0 && unpairedAfter.length > 0) {
337
+ reasons.add(codes.unclassified);
338
+ }
339
+ for (const [beforeAlternative, afterAlternative] of pairs) {
340
+ compareSchema(beforeAlternative, afterAlternative, direction, depth, equal, reasons, scope);
341
+ }
342
+ // A dropped input alternative may reject values callers send; an added output alternative may
343
+ // produce values consumers do not expect. The opposite changes only narrow or widen safely.
344
+ const narrowedOrWidened = direction === "input" ? unpairedAfter.length === 0 && unpairedBefore.length > 0 : unpairedBefore.length === 0 && unpairedAfter.length > 0;
345
+ if (narrowedOrWidened)
346
+ reasons.add(depth === 0 ? codes.rootType : codes.propertyType);
347
+ }
171
348
  /** Flags a `pattern` or `format` that `outer` adds and `inner` lacks; a changed value cannot be compared. */
172
349
  function compareStringConstraints(inner, outer, codes, reasons) {
173
350
  for (const keyword of STRING_CONSTRAINT_KEYWORDS) {
@@ -291,22 +468,32 @@ function referenceScope(before, after) {
291
468
  fragmentsOnly: reachesDefinitionsOnlyThroughFragments(before) && reachesDefinitionsOnlyThroughFragments(after),
292
469
  active: new Set(),
293
470
  completed: new Map(),
471
+ inProgress: new Map(),
472
+ wrappers: 0,
294
473
  same: new Map(),
295
474
  pointers: new Map(),
296
475
  remaining: MAX_COMPARISONS
297
476
  };
298
477
  }
299
- /** Records a resolved pair as compared at this depth; returns false when it already was. */
300
- function markCompleted(scope, before, after, depth) {
301
- let byAfter = scope.completed.get(before);
478
+ function pairDepths(pairs, before, after) {
479
+ let byAfter = pairs.get(before);
302
480
  if (byAfter === undefined)
303
- scope.completed.set(before, byAfter = new Map());
481
+ pairs.set(before, byAfter = new Map());
304
482
  let depths = byAfter.get(after);
305
483
  if (depths === undefined)
306
484
  byAfter.set(after, depths = new Set());
307
- if (depths.has(depth))
485
+ return depths;
486
+ }
487
+ /**
488
+ * Records a resolved pair as compared at this depth and wrapper count; returns false when it already
489
+ * was. The wrapper count is part of the key because it limits how far that comparison may unwrap.
490
+ */
491
+ function markCompleted(scope, before, after, depth) {
492
+ const depths = pairDepths(scope.completed, before, after);
493
+ const key = depth * (MAX_WRAPPER_HOPS + 1) + scope.wrappers;
494
+ if (depths.has(key))
308
495
  return false;
309
- depths.add(depth);
496
+ depths.add(key);
310
497
  return true;
311
498
  }
312
499
  /**
@@ -401,10 +588,26 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
401
588
  return;
402
589
  const resolvedBefore = resolveReferences(before, scope.before);
403
590
  const resolvedAfter = resolveReferences(after, scope.after);
404
- if (resolvedBefore === null || resolvedAfter === null) {
591
+ if (resolvedBefore === null || resolvedAfter === null || depth > MAX_NESTED_DEPTH) {
405
592
  reasons.add(codes.unclassified);
406
593
  return;
407
594
  }
595
+ // When only one side is a composition, the other keeps its reference unresolved, so it can still
596
+ // 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);
599
+ if (beforeComposed !== afterComposed) {
600
+ // The reference is kept only when an alternative can pair with it; otherwise its target compares
601
+ // with inline alternatives by type.
602
+ const composite = beforeComposed ? resolvedBefore.schema : resolvedAfter.schema;
603
+ 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));
607
+ const plain = keepReference ? original : (beforeComposed ? resolvedAfter.schema : resolvedBefore.schema);
608
+ compareComposition(beforeComposed ? composite : plain, beforeComposed ? plain : composite, direction, depth, equal, reasons, scope);
609
+ return;
610
+ }
408
611
  if (resolvedBefore.ref !== undefined || resolvedAfter.ref !== undefined) {
409
612
  // A reference pair already being compared further up is assumed to hold, so recursive
410
613
  // definitions terminate; a pair with one inline side is bounded by the depth limit. A pair
@@ -412,22 +615,35 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
412
615
  const pair = resolvedBefore.ref !== undefined && resolvedAfter.ref !== undefined
413
616
  ? JSON.stringify([resolvedBefore.ref, resolvedAfter.ref])
414
617
  : undefined;
618
+ // Reaching a pair again at the same depth, for example through a definition that wraps a
619
+ // reference to itself, is a cycle that never reaches a value, so it cannot be judged.
620
+ const inProgress = pairDepths(scope.inProgress, resolvedBefore.schema, resolvedAfter.schema);
621
+ if (inProgress.has(depth)) {
622
+ reasons.add(codes.unclassified);
623
+ return;
624
+ }
415
625
  if (pair !== undefined && scope.active.has(pair))
416
626
  return;
417
627
  if (!markCompleted(scope, resolvedBefore.schema, resolvedAfter.schema, depth))
418
628
  return;
419
629
  if (pair !== undefined)
420
630
  scope.active.add(pair);
631
+ inProgress.add(depth);
421
632
  compareSchema(resolvedBefore.schema, resolvedAfter.schema, direction, depth, equal, reasons, scope);
633
+ inProgress.delete(depth);
422
634
  if (pair !== undefined)
423
635
  scope.active.delete(pair);
424
636
  return;
425
637
  }
638
+ if (beforeComposed) {
639
+ compareComposition(before, after, direction, depth, equal, reasons, scope);
640
+ return;
641
+ }
426
642
  const root = depth === 0;
427
643
  const structural = scope.fragmentsOnly
428
644
  ? root ? ROOT_STRUCTURAL_WITH_DEFINITIONS : NESTED_STRUCTURAL_WITH_DEFINITIONS
429
645
  : root ? ROOT_STRUCTURAL_KEYWORDS : NESTED_STRUCTURAL_KEYWORDS;
430
- if (depth > MAX_NESTED_DEPTH || !isJsonObject(before) || !isJsonObject(after)
646
+ if (!isJsonObject(before) || !isJsonObject(after)
431
647
  || !sameMeaning(keywordsOutside(before, structural), keywordsOutside(after, structural), equal, scope)
432
648
  || !equal(before.$schema, after.$schema)) {
433
649
  reasons.add(codes.unclassified);
@@ -446,6 +662,7 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
446
662
  else if (!enumResult)
447
663
  reasons.add(codes.enumChanged);
448
664
  compareBounds(inner, outer, codes, reasons);
665
+ compareNumericAndArrayConstraints(inner, outer, codes, reasons);
449
666
  compareStringConstraints(inner, outer, codes, reasons);
450
667
  if (!sameMeaning(before.items, after.items, equal, scope)) {
451
668
  if (unevaluated)
package/dist/run-cli.js CHANGED
@@ -49,7 +49,7 @@ import { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorIn
49
49
  import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
50
50
  import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
51
51
  import { buildDoctorSize, renderDoctorSize, renderDoctorSizeJson } from "./core/doctor-size.js";
52
- import { initCiWorkflow } from "./core/init-ci.js";
52
+ import { initCiWorkflow, initToolsDiffWorkflow } from "./core/init-ci.js";
53
53
  import { watchPlugin } from "./core/watch-plugin.js";
54
54
  import { buildDepAudit, renderDepAudit, renderDepAuditJson, renderDepAuditSarif } from "./core/dep-audit.js";
55
55
  import { initGitHooks, removeGitHooks } from "./core/init-git-hooks.js";
@@ -117,6 +117,40 @@ const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-networ
117
117
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
118
118
  const bearerTokenHelp = "--bearer-token-env reads a bearer token from the named environment variable and sends it only over HTTPS or to an approved loopback target; the token is never printed or saved.";
119
119
  const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
120
+ /** Reads init-ci --tools-diff-server, --baseline, and --sandbox; options is null when none is given. */
121
+ function parseToolsDiffWorkflowOptions(flags) {
122
+ const values = new Map();
123
+ for (let index = 0; index < flags.length; index += 1) {
124
+ if (!["--tools-diff-server", "--baseline", "--sandbox"].includes(flags[index]))
125
+ continue;
126
+ const value = flags[index + 1];
127
+ const present = value !== undefined && !value.startsWith("--");
128
+ values.set(flags[index], present ? value : undefined);
129
+ if (present)
130
+ index += 1;
131
+ }
132
+ if (values.size === 0)
133
+ return { options: null };
134
+ if (!values.has("--tools-diff-server"))
135
+ return { error: "--baseline and --sandbox require --tools-diff-server." };
136
+ // Control characters would break the generated workflow lines, GitHub evaluates "${{" in any
137
+ // workflow value, and the Action rejects values that start with "-".
138
+ const usable = (value) => value !== undefined && value.trim() !== "" && !value.startsWith("-") && !value.includes("${{")
139
+ && !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value);
140
+ const server = values.get("--tools-diff-server");
141
+ if (!usable(server))
142
+ return { error: "--tools-diff-server requires a server name." };
143
+ // The workflow runs on Linux from the repository root, so the baseline is a relative POSIX path.
144
+ const baseline = (values.has("--baseline") ? values.get("--baseline") : "mcp/tools-baseline.json")?.replaceAll("\\", "/");
145
+ if (!usable(baseline) || path.posix.isAbsolute(baseline) || /^[A-Za-z]:/u.test(baseline)
146
+ || path.posix.normalize(baseline).split("/").includes("..")) {
147
+ return { error: "--baseline requires a relative path inside the repository." };
148
+ }
149
+ const sandbox = values.get("--sandbox");
150
+ if (values.has("--sandbox") && sandbox !== "docker")
151
+ return { error: "--sandbox must be docker." };
152
+ return { options: { server, baseline, sandbox: sandbox === "docker" ? "docker" : null } };
153
+ }
120
154
  const MAX_FORWARDED_ENVIRONMENT_VARIABLES = 32;
121
155
  const forwardedEnvironmentHelp = "--env forwards the named host environment variable into the Docker sandbox by name; Doctor never prints its value, writes it to a report, or passes it as a command argument. Native servers already inherit Doctor's environment.";
122
156
  function resolveBearerToken(name, env) {
@@ -340,7 +374,7 @@ function parseRemoteNetworkFlags(flags, runtime) {
340
374
  return { allowNetwork, allowLocalNetwork, allowSessionLifecycle, requireRemoteReliability };
341
375
  }
342
376
  function printUsage(io) {
343
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
377
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path] [--tools-diff-server <name> [--baseline <path>] [--sandbox docker]]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
344
378
  io.writeStderr(discoveryUsage);
345
379
  io.writeStderr(toolsUsage);
346
380
  io.writeStderr(toolsFileUsage);
@@ -1308,9 +1342,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
1308
1342
  (command === "doctor" &&
1309
1343
  (["runtime-plan", "runtime-policy"].includes(maybePath ?? "") ||
1310
1344
  (maybePath === "release-evidence" && remainingArgs[0] !== "verify") ||
1311
- (maybePath === "tools" && remainingArgs.includes("--server"))));
1345
+ (maybePath === "tools" && remainingArgs.includes("--server")))) ||
1346
+ (command === "init-ci" && args.includes("--tools-diff-server"));
1312
1347
  if (args.includes("--sandbox") && !runtimeSandboxSupported) {
1313
- io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, and doctor tools --server.");
1348
+ io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, doctor tools --server, and init-ci --tools-diff-server.");
1314
1349
  return 2;
1315
1350
  }
1316
1351
  if (command === "--version" || command === "-v" || command === "version") {
@@ -2771,6 +2806,23 @@ export async function runCli(args, io = defaultIo, options = {}) {
2771
2806
  }
2772
2807
  if (command === "init-ci") {
2773
2808
  const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
2809
+ const initFlags = maybePath && maybePath.startsWith("--") ? [maybePath, ...remainingArgs] : remainingArgs;
2810
+ const toolsDiff = parseToolsDiffWorkflowOptions(initFlags);
2811
+ if ("error" in toolsDiff) {
2812
+ io.writeStderr(toolsDiff.error);
2813
+ return 2;
2814
+ }
2815
+ if (toolsDiff.options !== null) {
2816
+ const { server, baseline, sandbox } = toolsDiff.options;
2817
+ const toolsResult = await initToolsDiffWorkflow(targetPath, toolsDiff.options);
2818
+ io.writeStdout([
2819
+ "Initialized Codex Plugin Doctor tool diff workflow",
2820
+ `Root: ${toolsResult.rootPath}`,
2821
+ `Workflow: ${toolsResult.workflowPath}`,
2822
+ `Capture and commit a baseline at ${baseline}, for example with: codex-plugin-doctor doctor tools ${JSON.stringify(targetPath)} --server ${JSON.stringify(server)} --runtime${sandbox === "docker" ? " --sandbox docker" : ""} --save-response ${JSON.stringify(path.join(targetPath, baseline))}`
2823
+ ].join("\n"));
2824
+ return 0;
2825
+ }
2774
2826
  const result = await initCiWorkflow(targetPath);
2775
2827
  io.writeStdout([
2776
2828
  "Initialized Codex Plugin Doctor workflow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.72.1",
3
+ "version": "1.74.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",