diff --git a/scripts/inject-schema-constraints.mjs b/scripts/inject-schema-constraints.mjs index 8506c35..88d6afd 100644 --- a/scripts/inject-schema-constraints.mjs +++ b/scripts/inject-schema-constraints.mjs @@ -124,6 +124,7 @@ const CONSTRAINT_KEYS = [ "maxItems", "uniqueItems", "minProperties", + "maxProperties", "format", ]; @@ -344,6 +345,8 @@ function describeConstraint(propertyNode, file) { if (eff.uniqueItems === true) descriptor.uniqueItems = true; if (eff.minProperties !== undefined) descriptor.minProperties = eff.minProperties; + if (eff.maxProperties !== undefined) + descriptor.maxProperties = eff.maxProperties; const propertyNamesPattern = resolvePropertyNamesPattern( propertyNode.propertyNames, file @@ -473,6 +476,11 @@ const propertyNamesIndex = new Map(); // setKey -> Map(signature -> descriptor). const minPropertiesIndex = new Map(); +// Object-level `maxProperties`, keyed the same way as minPropertiesIndex so a +// map bounded on both sides (e.g. LocationServes' one-entry shape) renders both +// refinements against the same key count. +const maxPropertiesIndex = new Map(); + // Object-level numeric constraints guarded by a simple discriminator condition. // Every resolved object shape records either its canonical rule list or an empty // list, so a shape used both with and without conditions becomes ambiguous and @@ -613,6 +621,35 @@ function recordMinProperties(node, properties) { minPropertiesIndex.get(setKey).set(signature, descriptor); } +function recordMaxProperties(node, properties) { + let maxProperties = node.maxProperties; + let additionalProperties = node.additionalProperties; + if (maxProperties === undefined && Array.isArray(node.allOf)) { + for (const sub of node.allOf) { + if (sub && typeof sub === "object" && sub.maxProperties !== undefined) { + maxProperties = sub.maxProperties; + if (additionalProperties === undefined) { + additionalProperties = sub.additionalProperties; + } + break; + } + } + } + if (maxProperties === undefined) { + return; + } + const setKey = Object.keys(properties).sort().join(","); + const descriptor = { + maximum: maxProperties, + retainAdditionalProperties: additionalProperties !== false, + }; + const signature = JSON.stringify(descriptor); + if (!maxPropertiesIndex.has(setKey)) { + maxPropertiesIndex.set(setKey, new Map()); + } + maxPropertiesIndex.get(setKey).set(signature, descriptor); +} + function numericBounds(node) { if (!node || typeof node !== "object") return null; const descriptor = {}; @@ -1011,6 +1048,7 @@ function walkSchema(node, file, seen = new Set(), depth = 0) { ); recordPropertyNames(node, resolvedObject.properties, file); recordMinProperties(node, resolvedObject.properties); + recordMaxProperties(node, resolvedObject.properties); recordConditionalRules(node, resolvedObject.properties); } recordVariantUnionRules(node, file); @@ -1125,6 +1163,19 @@ for (const [setKey, bySignature] of minPropertiesIndex) { } } +const resolvedMaxProperties = new Map(); +for (const [setKey, bySignature] of maxPropertiesIndex) { + if (bySignature.size === 1) { + resolvedMaxProperties.set(setKey, [...bySignature.values()][0]); + } else { + ambiguous.push({ + setKey, + name: "", + count: bySignature.size, + }); + } +} + // Only inject conditional rules when every occurrence of a property set agrees // on the exact non-empty rule list. This includes empty signatures, preventing // an unrelated object with the same shape from inheriting conditional logic. @@ -1321,6 +1372,37 @@ function renderMinPropertiesRefine(minimum, retainAdditionalProperties) { ); } +function renderMaxPropertiesRefine(maximum, retainAdditionalProperties) { + return ( + (retainAdditionalProperties ? `.catchall(z.any())` : "") + + `.refine((value) => Object.keys(value).length <= ${maximum}, ` + + `{ message: "Object must contain at most ${maximum} property(ies) (maxProperties)" })` + ); +} + +/** + * Splice the object-level property-count bounds as one chained edit. A + * `.refine(...)` result no longer exposes `.catchall`, so two independently + * rendered bounds that each prepend it would throw at parse time; one leading + * `.catchall(z.any())` is shared by both refinements. + */ +function renderObjectCountRefines(minDescriptor, maxDescriptor) { + const retainAdditionalProperties = + minDescriptor?.retainAdditionalProperties ?? + maxDescriptor?.retainAdditionalProperties; + return ( + (retainAdditionalProperties ? `.catchall(z.any())` : "") + + (minDescriptor + ? `.refine((value) => Object.keys(value).length >= ${minDescriptor.minimum}, ` + + `{ message: "Object must contain at least ${minDescriptor.minimum} property(ies) (minProperties)" })` + : "") + + (maxDescriptor + ? `.refine((value) => Object.keys(value).length <= ${maxDescriptor.maximum}, ` + + `{ message: "Object must contain at most ${maxDescriptor.maximum} property(ies) (maxProperties)" })` + : "") + ); +} + function renderConditionalRefine(rules) { const normalized = rules.map((rule) => ({ kind: rule.kind, @@ -1395,6 +1477,7 @@ function methodsFor(descriptor, baseKind) { descriptor.containsGroups !== undefined; const isRecord = descriptor.minProperties !== undefined || + descriptor.maxProperties !== undefined || descriptor.propertyNamesPattern !== undefined; if (isNumeric) { @@ -1430,6 +1513,9 @@ function methodsFor(descriptor, baseKind) { if (descriptor.minProperties !== undefined) { methods.push(renderMinPropertiesRefine(descriptor.minProperties, false)); } + if (descriptor.maxProperties !== undefined) { + methods.push(renderMaxPropertiesRefine(descriptor.maxProperties, false)); + } if (descriptor.propertyNamesPattern !== undefined) { methods.push(renderRecordKeyPattern(descriptor.propertyNamesPattern)); } @@ -1705,6 +1791,7 @@ const report = { unionBranchesInjected: 0, propertyNamesInjected: 0, minPropertiesInjected: 0, + maxPropertiesInjected: 0, conditionalsInjected: 0, sharedQuantityInjected: 0, sharedMeasureInjected: 0, @@ -1737,6 +1824,7 @@ function handleObjectLiteral(objectLiteral) { const resolvedUnionProperties = resolvedStringArrayUnions.get(setKey); const propertyNamesDescriptor = resolvedPropertyNames.get(setKey); const minPropertiesDescriptor = resolvedMinProperties.get(setKey); + const maxPropertiesDescriptor = resolvedMaxProperties.get(setKey); // If/then rules and variant-union rules render through the same conditional // superRefine; cross-index conflicts were already resolved to neither. const conditionalRules = @@ -1746,6 +1834,7 @@ function handleObjectLiteral(objectLiteral) { !resolvedUnionProperties && !propertyNamesDescriptor && !minPropertiesDescriptor && + !maxPropertiesDescriptor && !conditionalRules ) { return; @@ -1872,20 +1961,26 @@ function handleObjectLiteral(objectLiteral) { matchedAny = true; } } - if (minPropertiesDescriptor) { + if (minPropertiesDescriptor || maxPropertiesDescriptor) { const objectCall = objectLiteral.parent; if ( objectCall && ts.isCallExpression(objectCall) && !objectAlreadyConstrained(objectCall) ) { - const text = renderMinPropertiesRefine( - minPropertiesDescriptor.minimum, - minPropertiesDescriptor.retainAdditionalProperties + const text = renderObjectCountRefines( + minPropertiesDescriptor, + maxPropertiesDescriptor ); edits.push({ pos: objectCall.getEnd(), text }); - report.minPropertiesInjected += 1; - report.injections.push(`${setKey} :: ${text}`); + if (minPropertiesDescriptor) { + report.minPropertiesInjected += 1; + report.injections.push(`${setKey} :: ${text}`); + } + if (maxPropertiesDescriptor) { + report.maxPropertiesInjected += 1; + report.injections.push(`${setKey} :: ${text}`); + } matchedAny = true; } else if (objectCall && objectAlreadyConstrained(objectCall)) { report.fieldsAlreadyDone += 1; @@ -2057,6 +2152,7 @@ process.stdout.write( `${report.unionBranchesInjected} string-array union branch constraint(s); ` + `${report.propertyNamesInjected} propertyNames key-check(s); ` + `${report.minPropertiesInjected} object minProperties check(s); ` + + `${report.maxPropertiesInjected} object maxProperties check(s); ` + `${report.conditionalsInjected} conditional check(s); ` + `${report.sharedQuantityInjected} shared-quantity split edit(s); ` + `${report.sharedMeasureInjected} shared-measure split edit(s); ` + diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 94bf518..20ca388 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -1898,6 +1898,9 @@ export const LocationServesSchema = z .catchall(z.any()) .refine((value) => Object.keys(value).length >= 1, { message: "Object must contain at least 1 property(ies) (minProperties)", + }) + .refine((value) => Object.keys(value).length <= 1, { + message: "Object must contain at most 1 property(ies) (maxProperties)", }); export type LocationServes = z.infer; diff --git a/tests/spec-constraints.test.js b/tests/spec-constraints.test.js index cb33ba4..af1bd92 100644 --- a/tests/spec-constraints.test.js +++ b/tests/spec-constraints.test.js @@ -38,6 +38,7 @@ const { PaymentHandlerResponseSchema, AvailablePaymentInstrumentSchema, DescriptionSchema, + LocationServesSchema, OrderConfirmationSchema, LineItemQuantityRefSchema, AdjustmentLineItemSchema, @@ -388,6 +389,38 @@ test("DescriptionSchema enforces minProperties and retains additional properties }); }); +// --- LocationServesSchema: exactly one service-target form ------------------ +// location_serves.json declares minProperties: 1 AND maxProperties: 1 — +// "A one-entry map ... The Platform MUST supply exactly one target form." + +test("LocationServesSchema rejects an empty service-target map (minProperties)", () => { + assert.ok(rejects(LocationServesSchema, {})); +}); + +test("LocationServesSchema accepts a single target representation", () => { + assert.ok( + accepts(LocationServesSchema, { + point: { latitude: 37.77, longitude: -122.42 }, + }) + ); + assert.ok(accepts(LocationServesSchema, { "com.example/area": "bay-area" })); +}); + +test("LocationServesSchema rejects two target representations (maxProperties)", () => { + assert.ok( + rejects(LocationServesSchema, { + point: { latitude: 37.77, longitude: -122.42 }, + "com.example/area": "bay-area", + }) + ); + assert.ok( + rejects(LocationServesSchema, { + "com.example/area": "bay-area", + "com.example/radius_km": 10, + }) + ); +}); + test("MediaSchema rejects a url that is not a URL (format: uri)", () => { assert.ok(rejects(MediaSchema, { type: "image", url: "not a url" })); assert.ok(