codex-plugin-doctor 1.72.0 → 1.73.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
@@ -585,9 +585,9 @@ jobs:
585
585
  runs-on: ubuntu-latest
586
586
  steps:
587
587
  - uses: actions/checkout@v5
588
- - uses: Esquetta/CodexPluginDoctor@v1.72.0
588
+ - uses: Esquetta/CodexPluginDoctor@v1.73.0
589
589
  with:
590
- version: "1.72.0"
590
+ version: "1.73.0"
591
591
  path: .
592
592
  runtime: "true"
593
593
  policy: codex-publish
@@ -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,178 @@ 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.
185
+ const value = schema[inclusive];
186
+ if (value === undefined)
187
+ return undefined;
188
+ return isFiniteNumber(value) ? { value, exclusive: schema[exclusive] } : null;
189
+ }
190
+ const candidates = [];
191
+ for (const [keyword, isExclusive] of [[inclusive, false], [exclusive, true]]) {
192
+ const value = schema[keyword];
193
+ if (value === undefined)
194
+ continue;
195
+ if (!isFiniteNumber(value))
196
+ return null;
197
+ candidates.push({ value, exclusive: isExclusive });
198
+ }
199
+ return candidates.reduce((tightest, candidate) => {
200
+ if (tightest === undefined)
201
+ return candidate;
202
+ if (candidate.value === tightest.value)
203
+ return candidate.exclusive ? candidate : tightest;
204
+ return (lower ? candidate.value > tightest.value : candidate.value < tightest.value) ? candidate : tightest;
205
+ }, undefined);
206
+ }
207
+ /** Flags numeric bounds, `multipleOf`, and `uniqueItems` of `outer` that `inner` does not satisfy. */
208
+ function compareNumericAndArrayConstraints(inner, outer, codes, reasons) {
209
+ for (const [inclusive, exclusive, lower] of [["minimum", "exclusiveMinimum", true], ["maximum", "exclusiveMaximum", false]]) {
210
+ const innerBound = numericBound(inner, inclusive, exclusive, lower);
211
+ const outerBound = numericBound(outer, inclusive, exclusive, lower);
212
+ if (innerBound === null || outerBound === null) {
213
+ // A bound that cannot be read still means the same when it did not change.
214
+ const unchanged = Object.is(inner[inclusive], outer[inclusive]) && Object.is(inner[exclusive], outer[exclusive]);
215
+ if (!unchanged)
216
+ reasons.add(codes.unclassified);
217
+ }
218
+ else if (outerBound !== undefined) {
219
+ const covered = innerBound !== undefined && (innerBound.value === outerBound.value
220
+ ? innerBound.exclusive || !outerBound.exclusive
221
+ : lower ? innerBound.value > outerBound.value : innerBound.value < outerBound.value);
222
+ if (!covered)
223
+ reasons.add(codes.constraint);
224
+ }
225
+ }
226
+ const outerMultiple = outer.multipleOf;
227
+ const innerMultiple = inner.multipleOf;
228
+ const validMultiple = (value) => value === undefined || (isFiniteNumber(value) && value > 0);
229
+ if (!validMultiple(outerMultiple) || !validMultiple(innerMultiple)) {
230
+ if (!Object.is(innerMultiple, outerMultiple))
231
+ reasons.add(codes.unclassified);
232
+ }
233
+ else if (outerMultiple !== undefined) {
234
+ if (innerMultiple === undefined) {
235
+ reasons.add(codes.constraint);
236
+ }
237
+ else if (Number.isInteger(innerMultiple) && Number.isInteger(outerMultiple)) {
238
+ // Every multiple of innerMultiple is a multiple of outerMultiple when outerMultiple divides it.
239
+ if (innerMultiple % outerMultiple !== 0)
240
+ reasons.add(codes.constraint);
241
+ }
242
+ else if (innerMultiple !== outerMultiple) {
243
+ // Validators test fractional multiples with floating-point division, which can reject 0.3
244
+ // under multipleOf 0.1, so a changed fractional multipleOf cannot be judged.
245
+ reasons.add(codes.unclassified);
246
+ }
247
+ }
248
+ if ((outer.uniqueItems !== undefined && typeof outer.uniqueItems !== "boolean")
249
+ || (inner.uniqueItems !== undefined && typeof inner.uniqueItems !== "boolean")) {
250
+ if (!Object.is(inner.uniqueItems, outer.uniqueItems))
251
+ reasons.add(codes.unclassified);
252
+ }
253
+ else if (outer.uniqueItems === true && inner.uniqueItems !== true) {
254
+ reasons.add(codes.constraint);
255
+ }
256
+ }
257
+ /** The subschema of a schema that is only a one-element `allOf` with documentation, or null. */
258
+ function singleAllOf(schema) {
259
+ if (!isJsonObject(schema) || !Array.isArray(schema.allOf) || schema.allOf.length !== 1)
260
+ return null;
261
+ return Object.keys(schema).every((key) => key === "allOf" || DOCUMENTATION_KEYWORDS.has(key)) ? { inner: schema.allOf[0] } : null;
262
+ }
263
+ /** The alternatives of a schema that is only `anyOf` with documentation, or null. */
264
+ function alternatives(schema) {
265
+ if (!isJsonObject(schema) || !Array.isArray(schema.anyOf))
266
+ return null;
267
+ return Object.keys(schema).every((key) => key === "anyOf" || DOCUMENTATION_KEYWORDS.has(key)) ? schema.anyOf : null;
268
+ }
269
+ function isComposition(schema) {
270
+ return singleAllOf(schema) !== null || alternatives(schema) !== null;
271
+ }
272
+ /**
273
+ * Compares two schemas when at least one is a one-element `allOf` or an `anyOf` with only
274
+ * documentation beside it. Wrappers keep the depth, so their own count bounds how far one path unwraps.
275
+ */
276
+ function compareComposition(before, after, direction, depth, equal, reasons, scope) {
277
+ if (scope.wrappers >= MAX_WRAPPER_HOPS) {
278
+ reasons.add(CODES[direction].unclassified);
279
+ return;
280
+ }
281
+ const wrappedBefore = singleAllOf(before);
282
+ const wrappedAfter = singleAllOf(after);
283
+ scope.wrappers += 1;
284
+ if (wrappedBefore !== null || wrappedAfter !== null) {
285
+ // A one-element allOf with only documentation beside it means the same as its subschema.
286
+ compareSchema(wrappedBefore === null ? before : wrappedBefore.inner, wrappedAfter === null ? after : wrappedAfter.inner, direction, depth, equal, reasons, scope);
287
+ }
288
+ else {
289
+ // A schema that is not an anyOf compares as an anyOf with that schema as its only alternative.
290
+ compareAlternatives(alternatives(before) ?? [before], alternatives(after) ?? [after], direction, depth, equal, reasons, scope);
291
+ }
292
+ scope.wrappers -= 1;
293
+ }
294
+ /** The reference target or declared type that pairs an alternative with its counterpart. */
295
+ function alternativeKey(alternative) {
296
+ if (!isJsonObject(alternative))
297
+ return null;
298
+ if (typeof alternative.$ref === "string")
299
+ return `ref:${alternative.$ref}`;
300
+ return alternative.type === undefined ? null : `type:${JSON.stringify(alternative.type)}`;
301
+ }
302
+ /**
303
+ * Compares `anyOf` alternatives. A value is accepted when any alternative accepts it, so inputs stay
304
+ * compatible when every earlier alternative is covered by a later one, and outputs when every later
305
+ * alternative is covered by an earlier one. Identical alternatives match first, then alternatives with
306
+ * the same reference target or type, then a single remaining alternative on each side.
307
+ */
308
+ function compareAlternatives(before, after, direction, depth, equal, reasons, scope) {
309
+ const codes = CODES[direction];
310
+ if (before.length > MAX_ALTERNATIVES || after.length > MAX_ALTERNATIVES) {
311
+ reasons.add(codes.unclassified);
312
+ return;
313
+ }
314
+ const unpairedBefore = [...before];
315
+ const unpairedAfter = [...after];
316
+ for (let index = unpairedBefore.length - 1; index >= 0; index -= 1) {
317
+ const match = unpairedAfter.findIndex((candidate) => sameMeaning(unpairedBefore[index], candidate, equal, scope));
318
+ if (match !== -1) {
319
+ unpairedBefore.splice(index, 1);
320
+ unpairedAfter.splice(match, 1);
321
+ }
322
+ }
323
+ const pairs = [];
324
+ for (const key of new Set(unpairedBefore.map(alternativeKey))) {
325
+ if (key === null)
326
+ continue;
327
+ const beforeMatches = unpairedBefore.filter((alternative) => alternativeKey(alternative) === key);
328
+ const afterMatches = unpairedAfter.filter((alternative) => alternativeKey(alternative) === key);
329
+ if (beforeMatches.length !== 1 || afterMatches.length !== 1)
330
+ continue;
331
+ pairs.push([beforeMatches[0], afterMatches[0]]);
332
+ unpairedBefore.splice(unpairedBefore.indexOf(beforeMatches[0]), 1);
333
+ unpairedAfter.splice(unpairedAfter.indexOf(afterMatches[0]), 1);
334
+ }
335
+ if (unpairedBefore.length === 1 && unpairedAfter.length === 1) {
336
+ pairs.push([unpairedBefore.pop(), unpairedAfter.pop()]);
337
+ }
338
+ else if (unpairedBefore.length > 0 && unpairedAfter.length > 0) {
339
+ reasons.add(codes.unclassified);
340
+ }
341
+ for (const [beforeAlternative, afterAlternative] of pairs) {
342
+ compareSchema(beforeAlternative, afterAlternative, direction, depth, equal, reasons, scope);
343
+ }
344
+ // A dropped input alternative may reject values callers send; an added output alternative may
345
+ // produce values consumers do not expect. The opposite changes only narrow or widen safely.
346
+ const narrowedOrWidened = direction === "input" ? unpairedAfter.length === 0 && unpairedBefore.length > 0 : unpairedBefore.length === 0 && unpairedAfter.length > 0;
347
+ if (narrowedOrWidened)
348
+ reasons.add(depth === 0 ? codes.rootType : codes.propertyType);
349
+ }
171
350
  /** Flags a `pattern` or `format` that `outer` adds and `inner` lacks; a changed value cannot be compared. */
172
351
  function compareStringConstraints(inner, outer, codes, reasons) {
173
352
  for (const keyword of STRING_CONSTRAINT_KEYWORDS) {
@@ -291,22 +470,32 @@ function referenceScope(before, after) {
291
470
  fragmentsOnly: reachesDefinitionsOnlyThroughFragments(before) && reachesDefinitionsOnlyThroughFragments(after),
292
471
  active: new Set(),
293
472
  completed: new Map(),
473
+ inProgress: new Map(),
474
+ wrappers: 0,
294
475
  same: new Map(),
295
476
  pointers: new Map(),
296
477
  remaining: MAX_COMPARISONS
297
478
  };
298
479
  }
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);
480
+ function pairDepths(pairs, before, after) {
481
+ let byAfter = pairs.get(before);
302
482
  if (byAfter === undefined)
303
- scope.completed.set(before, byAfter = new Map());
483
+ pairs.set(before, byAfter = new Map());
304
484
  let depths = byAfter.get(after);
305
485
  if (depths === undefined)
306
486
  byAfter.set(after, depths = new Set());
307
- if (depths.has(depth))
487
+ return depths;
488
+ }
489
+ /**
490
+ * Records a resolved pair as compared at this depth and wrapper count; returns false when it already
491
+ * was. The wrapper count is part of the key because it limits how far that comparison may unwrap.
492
+ */
493
+ function markCompleted(scope, before, after, depth) {
494
+ const depths = pairDepths(scope.completed, before, after);
495
+ const key = depth * (MAX_WRAPPER_HOPS + 1) + scope.wrappers;
496
+ if (depths.has(key))
308
497
  return false;
309
- depths.add(depth);
498
+ depths.add(key);
310
499
  return true;
311
500
  }
312
501
  /**
@@ -401,10 +590,18 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
401
590
  return;
402
591
  const resolvedBefore = resolveReferences(before, scope.before);
403
592
  const resolvedAfter = resolveReferences(after, scope.after);
404
- if (resolvedBefore === null || resolvedAfter === null) {
593
+ if (resolvedBefore === null || resolvedAfter === null || depth > MAX_NESTED_DEPTH) {
405
594
  reasons.add(codes.unclassified);
406
595
  return;
407
596
  }
597
+ // When only one side is a composition, the other keeps its reference unresolved, so it can still
598
+ // pair with an alternative that refers to the same definition, as when a model becomes optional.
599
+ const beforeComposed = isComposition(resolvedBefore.schema);
600
+ const afterComposed = isComposition(resolvedAfter.schema);
601
+ if (beforeComposed !== afterComposed) {
602
+ compareComposition(beforeComposed ? resolvedBefore.schema : before, afterComposed ? resolvedAfter.schema : after, direction, depth, equal, reasons, scope);
603
+ return;
604
+ }
408
605
  if (resolvedBefore.ref !== undefined || resolvedAfter.ref !== undefined) {
409
606
  // A reference pair already being compared further up is assumed to hold, so recursive
410
607
  // definitions terminate; a pair with one inline side is bounded by the depth limit. A pair
@@ -412,22 +609,35 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
412
609
  const pair = resolvedBefore.ref !== undefined && resolvedAfter.ref !== undefined
413
610
  ? JSON.stringify([resolvedBefore.ref, resolvedAfter.ref])
414
611
  : undefined;
612
+ // Reaching a pair again at the same depth, for example through a definition that wraps a
613
+ // reference to itself, is a cycle that never reaches a value, so it cannot be judged.
614
+ const inProgress = pairDepths(scope.inProgress, resolvedBefore.schema, resolvedAfter.schema);
615
+ if (inProgress.has(depth)) {
616
+ reasons.add(codes.unclassified);
617
+ return;
618
+ }
415
619
  if (pair !== undefined && scope.active.has(pair))
416
620
  return;
417
621
  if (!markCompleted(scope, resolvedBefore.schema, resolvedAfter.schema, depth))
418
622
  return;
419
623
  if (pair !== undefined)
420
624
  scope.active.add(pair);
625
+ inProgress.add(depth);
421
626
  compareSchema(resolvedBefore.schema, resolvedAfter.schema, direction, depth, equal, reasons, scope);
627
+ inProgress.delete(depth);
422
628
  if (pair !== undefined)
423
629
  scope.active.delete(pair);
424
630
  return;
425
631
  }
632
+ if (beforeComposed) {
633
+ compareComposition(before, after, direction, depth, equal, reasons, scope);
634
+ return;
635
+ }
426
636
  const root = depth === 0;
427
637
  const structural = scope.fragmentsOnly
428
638
  ? root ? ROOT_STRUCTURAL_WITH_DEFINITIONS : NESTED_STRUCTURAL_WITH_DEFINITIONS
429
639
  : root ? ROOT_STRUCTURAL_KEYWORDS : NESTED_STRUCTURAL_KEYWORDS;
430
- if (depth > MAX_NESTED_DEPTH || !isJsonObject(before) || !isJsonObject(after)
640
+ if (!isJsonObject(before) || !isJsonObject(after)
431
641
  || !sameMeaning(keywordsOutside(before, structural), keywordsOutside(after, structural), equal, scope)
432
642
  || !equal(before.$schema, after.$schema)) {
433
643
  reasons.add(codes.unclassified);
@@ -446,6 +656,7 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
446
656
  else if (!enumResult)
447
657
  reasons.add(codes.enumChanged);
448
658
  compareBounds(inner, outer, codes, reasons);
659
+ compareNumericAndArrayConstraints(inner, outer, codes, reasons);
449
660
  compareStringConstraints(inner, outer, codes, reasons);
450
661
  if (!sameMeaning(before.items, after.items, equal, scope)) {
451
662
  if (unevaluated)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.72.0",
3
+ "version": "1.73.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",