Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 102 additions & 6 deletions scripts/inject-schema-constraints.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const CONSTRAINT_KEYS = [
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
"format",
];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: "<maxProperties>",
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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1395,6 +1477,7 @@ function methodsFor(descriptor, baseKind) {
descriptor.containsGroups !== undefined;
const isRecord =
descriptor.minProperties !== undefined ||
descriptor.maxProperties !== undefined ||
descriptor.propertyNamesPattern !== undefined;

if (isNumeric) {
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -1705,6 +1791,7 @@ const report = {
unionBranchesInjected: 0,
propertyNamesInjected: 0,
minPropertiesInjected: 0,
maxPropertiesInjected: 0,
conditionalsInjected: 0,
sharedQuantityInjected: 0,
sharedMeasureInjected: 0,
Expand Down Expand Up @@ -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 =
Expand All @@ -1746,6 +1834,7 @@ function handleObjectLiteral(objectLiteral) {
!resolvedUnionProperties &&
!propertyNamesDescriptor &&
!minPropertiesDescriptor &&
!maxPropertiesDescriptor &&
!conditionalRules
) {
return;
Expand Down Expand Up @@ -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} :: <minProperties> ${text}`);
if (minPropertiesDescriptor) {
report.minPropertiesInjected += 1;
report.injections.push(`${setKey} :: <minProperties> ${text}`);
}
if (maxPropertiesDescriptor) {
report.maxPropertiesInjected += 1;
report.injections.push(`${setKey} :: <maxProperties> ${text}`);
}
matchedAny = true;
} else if (objectCall && objectAlreadyConstrained(objectCall)) {
report.fieldsAlreadyDone += 1;
Expand Down Expand Up @@ -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); ` +
Expand Down
3 changes: 3 additions & 0 deletions src/spec_generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof LocationServesSchema>;

Expand Down
33 changes: 33 additions & 0 deletions tests/spec-constraints.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const {
PaymentHandlerResponseSchema,
AvailablePaymentInstrumentSchema,
DescriptionSchema,
LocationServesSchema,
OrderConfirmationSchema,
LineItemQuantityRefSchema,
AdjustmentLineItemSchema,
Expand Down Expand Up @@ -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(
Expand Down
Loading