codex-plugin-doctor 1.74.1 → 1.75.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
@@ -589,9 +589,9 @@ jobs:
589
589
  runs-on: ubuntu-latest
590
590
  steps:
591
591
  - uses: actions/checkout@v5
592
- - uses: Esquetta/CodexPluginDoctor@v1.74.1
592
+ - uses: Esquetta/CodexPluginDoctor@v1.75.0
593
593
  with:
594
- version: "1.74.1"
594
+ version: "1.75.0"
595
595
  path: .
596
596
  runtime: "true"
597
597
  policy: codex-publish
@@ -53,9 +53,9 @@ const MAX_NESTED_DEPTH = 8;
53
53
  // Keywords that only hold or name subschemas and never validate the object's own keys, so a
54
54
  // new property beside them stays compatible. Any other unclassified keyword may govern keys.
55
55
  const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
56
- // `anyOf` alternatives beyond this many on either side are not paired.
56
+ // `anyOf` or discriminated `oneOf` alternatives beyond this many on either side are not paired.
57
57
  const MAX_ALTERNATIVES = 32;
58
- // Composition wrappers (`allOf` and `anyOf`) followed along one path; deeper wrappers are unclassified.
58
+ // Composition wrappers (`allOf`, `anyOf`, and discriminated `oneOf`) followed along one path; deeper wrappers are unclassified.
59
59
  const MAX_WRAPPER_HOPS = 16;
60
60
  // A `$ref` chain longer than this, such as an alias cycle, is not resolved.
61
61
  const MAX_REFERENCE_HOPS = 8;
@@ -258,18 +258,67 @@ function singleAllOf(schema) {
258
258
  return null;
259
259
  return Object.keys(schema).every((key) => key === "allOf" || DOCUMENTATION_KEYWORDS.has(key)) ? { inner: schema.allOf[0] } : null;
260
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))
261
+ function discriminatorProperty(value) {
262
+ if (!isJsonObject(value) || typeof value.propertyName !== "string")
264
263
  return null;
265
- return Object.keys(schema).every((key) => key === "anyOf" || DOCUMENTATION_KEYWORDS.has(key)) ? schema.anyOf : null;
264
+ return Object.keys(value).every((key) => key === "propertyName" || key === "mapping") ? value.propertyName : null;
266
265
  }
267
- function isComposition(schema) {
268
- return singleAllOf(schema) !== null || alternatives(schema) !== null;
266
+ /**
267
+ * The discriminator value of each alternative, when every alternative resolves to an object schema
268
+ * that requires the property and fixes it to one value no other alternative uses; null otherwise.
269
+ * Such alternatives cannot both match one value, so their oneOf accepts what an anyOf would.
270
+ */
271
+ function discriminatorValues(list, property, document) {
272
+ // Draft-04 has no const, and many validators of earlier drafts drop keywords beside a $ref.
273
+ if (list.length > MAX_ALTERNATIVES || (isJsonObject(document) && typeof document.$schema === "string" && /draft-0[34]/u.test(document.$schema)))
274
+ return null;
275
+ const values = new Map();
276
+ const seen = new Set();
277
+ for (const alternative of list) {
278
+ const schema = resolveReferences(alternative, document)?.schema;
279
+ if (!isJsonObject(schema))
280
+ return null;
281
+ const types = typeSet(schema);
282
+ const declared = objectMap(schema.properties)?.[property];
283
+ if (types === null || types.size !== 1 || !types.has("object") || !stringSet(schema.required)?.has(property)
284
+ || !isJsonObject(declared) || declared.$ref !== undefined)
285
+ return null;
286
+ const fixed = Object.hasOwn(declared, "const") ? [declared.const] : declared.enum;
287
+ if (!Array.isArray(fixed) || fixed.length !== 1 || (Array.isArray(declared.enum) && Object.hasOwn(declared, "const")
288
+ && !(declared.enum.length === 1 && Object.is(declared.enum[0], declared.const))))
289
+ return null;
290
+ const value = fixed[0];
291
+ if (value !== null && !["string", "number", "boolean"].includes(typeof value))
292
+ return null;
293
+ const key = JSON.stringify(value);
294
+ if (seen.has(key))
295
+ return null;
296
+ seen.add(key);
297
+ values.set(alternative, key);
298
+ }
299
+ return values;
269
300
  }
270
301
  /**
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.
302
+ * The alternatives of a schema that is only `anyOf`, or only a discriminated `oneOf` whose
303
+ * alternatives exclude each other, with documentation beside it; null otherwise.
304
+ */
305
+ function alternatives(schema, document) {
306
+ if (!isJsonObject(schema))
307
+ return null;
308
+ if (Array.isArray(schema.anyOf)) {
309
+ return Object.keys(schema).every((key) => key === "anyOf" || DOCUMENTATION_KEYWORDS.has(key)) ? { list: schema.anyOf, discriminator: null } : null;
310
+ }
311
+ if (!Array.isArray(schema.oneOf) || !Object.keys(schema).every((key) => key === "oneOf" || key === "discriminator" || DOCUMENTATION_KEYWORDS.has(key)))
312
+ return null;
313
+ const property = discriminatorProperty(schema.discriminator);
314
+ return property !== null && discriminatorValues(schema.oneOf, property, document) !== null ? { list: schema.oneOf, discriminator: property } : null;
315
+ }
316
+ function isComposition(schema, document) {
317
+ return singleAllOf(schema) !== null || alternatives(schema, document) !== null;
318
+ }
319
+ /**
320
+ * Compares two schemas when at least one is a one-element `allOf`, an `anyOf`, or a discriminated
321
+ * `oneOf` with only documentation beside it. Wrappers keep the depth, so their own count bounds how far one path unwraps.
273
322
  */
274
323
  function compareComposition(before, after, direction, depth, equal, reasons, scope) {
275
324
  if (scope.wrappers >= MAX_WRAPPER_HOPS) {
@@ -285,7 +334,9 @@ function compareComposition(before, after, direction, depth, equal, reasons, sco
285
334
  }
286
335
  else {
287
336
  // 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);
337
+ const beforeAlternatives = alternatives(before, scope.before) ?? { list: [before], discriminator: null };
338
+ const afterAlternatives = alternatives(after, scope.after) ?? { list: [after], discriminator: null };
339
+ compareAlternatives(beforeAlternatives, afterAlternatives, direction, depth, equal, reasons, scope);
289
340
  }
290
341
  scope.wrappers -= 1;
291
342
  }
@@ -298,19 +349,20 @@ function alternativeKey(alternative) {
298
349
  return alternative.type === undefined ? null : `type:${JSON.stringify(alternative.type)}`;
299
350
  }
300
351
  /**
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.
352
+ * Compares `anyOf` alternatives, or discriminated `oneOf` alternatives that exclude each other. A
353
+ * value is accepted when any alternative accepts it, so inputs stay compatible when every earlier
354
+ * alternative is covered by a later one, and outputs when every later alternative is covered by an
355
+ * earlier one. Identical alternatives match first, then alternatives with the same discriminator
356
+ * value, then the same reference target or type, then a single remaining alternative on each side.
305
357
  */
306
358
  function compareAlternatives(before, after, direction, depth, equal, reasons, scope) {
307
359
  const codes = CODES[direction];
308
- if (before.length > MAX_ALTERNATIVES || after.length > MAX_ALTERNATIVES) {
360
+ if (before.list.length > MAX_ALTERNATIVES || after.list.length > MAX_ALTERNATIVES) {
309
361
  reasons.add(codes.unclassified);
310
362
  return;
311
363
  }
312
- const unpairedBefore = [...before];
313
- const unpairedAfter = [...after];
364
+ const unpairedBefore = [...before.list];
365
+ const unpairedAfter = [...after.list];
314
366
  for (let index = unpairedBefore.length - 1; index >= 0; index -= 1) {
315
367
  const match = unpairedAfter.findIndex((candidate) => sameMeaning(unpairedBefore[index], candidate, equal, scope));
316
368
  if (match !== -1) {
@@ -319,17 +371,25 @@ function compareAlternatives(before, after, direction, depth, equal, reasons, sc
319
371
  }
320
372
  }
321
373
  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);
374
+ const pairBy = (beforeKey, afterKey) => {
375
+ for (const key of new Set(unpairedBefore.map(beforeKey))) {
376
+ if (key === null)
377
+ continue;
378
+ const beforeMatches = unpairedBefore.filter((alternative) => beforeKey(alternative) === key);
379
+ const afterMatches = unpairedAfter.filter((alternative) => afterKey(alternative) === key);
380
+ if (beforeMatches.length !== 1 || afterMatches.length !== 1)
381
+ continue;
382
+ pairs.push([beforeMatches[0], afterMatches[0]]);
383
+ unpairedBefore.splice(unpairedBefore.indexOf(beforeMatches[0]), 1);
384
+ unpairedAfter.splice(unpairedAfter.indexOf(afterMatches[0]), 1);
385
+ }
386
+ };
387
+ if (before.discriminator !== null && before.discriminator === after.discriminator) {
388
+ const beforeValues = discriminatorValues(before.list, before.discriminator, scope.before);
389
+ const afterValues = discriminatorValues(after.list, after.discriminator, scope.after);
390
+ pairBy((alternative) => beforeValues?.get(alternative) ?? null, (alternative) => afterValues?.get(alternative) ?? null);
332
391
  }
392
+ pairBy(alternativeKey, alternativeKey);
333
393
  if (unpairedBefore.length === 1 && unpairedAfter.length === 1) {
334
394
  pairs.push([unpairedBefore.pop(), unpairedAfter.pop()]);
335
395
  }
@@ -594,20 +654,47 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
594
654
  }
595
655
  // When only one side is a composition, the other keeps its reference unresolved, so it can still
596
656
  // 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);
657
+ const beforeComposed = isComposition(resolvedBefore.schema, scope.before);
658
+ const afterComposed = isComposition(resolvedAfter.schema, scope.after);
659
+ // True when the original schema, before its reference is resolved, pairs with an alternative of
660
+ // the composite, as a model or a union does with its nullable anyOf.
661
+ const pairsWithAlternative = (original, composite, document) => {
662
+ const key = alternativeKey(original);
663
+ return (alternatives(composite, document)?.list ?? [])
664
+ .some((alternative) => (key !== null && alternativeKey(alternative) === key) || sameMeaning(original, alternative, equal, scope));
665
+ };
599
666
  if (beforeComposed !== afterComposed) {
600
667
  // The reference is kept only when an alternative can pair with it; otherwise its target compares
601
668
  // with inline alternatives by type.
602
669
  const composite = beforeComposed ? resolvedBefore.schema : resolvedAfter.schema;
603
670
  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));
671
+ const keepReference = pairsWithAlternative(original, composite, beforeComposed ? scope.before : scope.after);
607
672
  const plain = keepReference ? original : (beforeComposed ? resolvedAfter.schema : resolvedBefore.schema);
608
673
  compareComposition(beforeComposed ? composite : plain, beforeComposed ? plain : composite, direction, depth, equal, reasons, scope);
609
674
  return;
610
675
  }
676
+ if (beforeComposed && afterComposed) {
677
+ // A composite that appears whole as one alternative of the other side, such as a union made
678
+ // nullable, compares as that single alternative rather than alternative by alternative.
679
+ // A reference match counts only while the matched alternative is still a composite, so a
680
+ // definition that stopped being a union compares alternative by alternative instead.
681
+ const isWholeAlternative = (original, composite, document) => (alternatives(composite, document)?.list ?? []).some((alternative) => sameMeaning(original, alternative, equal, scope)
682
+ || (alternativeKey(original) !== null && alternativeKey(alternative) === alternativeKey(original)
683
+ && isComposition(resolveReferences(alternative, document)?.schema, document)));
684
+ const beforeIsAlternative = isWholeAlternative(before, resolvedAfter.schema, scope.after);
685
+ const afterIsAlternative = isWholeAlternative(after, resolvedBefore.schema, scope.before);
686
+ if (beforeIsAlternative !== afterIsAlternative) {
687
+ if (scope.wrappers >= MAX_WRAPPER_HOPS) {
688
+ reasons.add(codes.unclassified);
689
+ return;
690
+ }
691
+ const side = (whole, original, composite, document) => whole ? { list: [original], discriminator: null } : alternatives(composite, document) ?? { list: [composite], discriminator: null };
692
+ scope.wrappers += 1;
693
+ compareAlternatives(side(beforeIsAlternative, before, resolvedBefore.schema, scope.before), side(afterIsAlternative, after, resolvedAfter.schema, scope.after), direction, depth, equal, reasons, scope);
694
+ scope.wrappers -= 1;
695
+ return;
696
+ }
697
+ }
611
698
  if (resolvedBefore.ref !== undefined || resolvedAfter.ref !== undefined) {
612
699
  // A reference pair already being compared further up is assumed to hold, so recursive
613
700
  // definitions terminate; a pair with one inline side is bounded by the depth limit. A pair
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.74.1",
3
+ "version": "1.75.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",