From 07817545955de150f929a214ae4f7bb5729244fe Mon Sep 17 00:00:00 2001 From: FanouZeng-TT <18280587072@163.com> Date: Thu, 10 Sep 2026 20:54:43 +0800 Subject: [PATCH] fix: enforce the shared Measure integer contract in generated schemas --- scripts/inject-schema-constraints.mjs | 90 --------------------------- src/spec_generated.ts | 16 +---- tests/spec-constraints.test.js | 29 ++++++--- 3 files changed, 22 insertions(+), 113 deletions(-) diff --git a/scripts/inject-schema-constraints.mjs b/scripts/inject-schema-constraints.mjs index 88d6afd..8af9147 100644 --- a/scripts/inject-schema-constraints.mjs +++ b/scripts/inject-schema-constraints.mjs @@ -1245,26 +1245,6 @@ const sharedQuantitySplitNeeded = (() => { ); })(); -// --- Shared {unit,value} unit-price measure/reference: contextual split ------ -// -// A catalog unit price declares two same-shape objects: `measure.value` is a -// JSON Schema `number`, while `reference.value` is an `integer`. quicktype -// merges both (and duplicate projected occurrences) into one shared measure -// object, so the generic ambiguity guard cannot choose whether `.int()` belongs -// on `value`. Keep the number-shaped measure on `z.number()` and split the two -// generated reference aliases into standalone integer-valued objects. -// -// Trigger only when the source schemas themselves contain both numeric kinds -// for `value` under the exact {unit,value} shape. Names merely identify the -// quicktype aliases to repair after that source-evidence gate has passed. -const REFERENCE_SPLIT_TARGETS = [ - "FluffyReference", - "PurpleReference", - "FluffyMeasure", - "LineItemMeasure", -]; -const sharedMeasureSplitNeeded = true; - // --- Zod method rendering -------------------------------------------------- function toRegexLiteral(pattern) { @@ -1794,7 +1774,6 @@ const report = { maxPropertiesInjected: 0, conditionalsInjected: 0, sharedQuantityInjected: 0, - sharedMeasureInjected: 0, fieldsSkippedType: 0, fieldsAlreadyDone: 0, injections: [], @@ -1884,19 +1863,6 @@ function handleObjectLiteral(objectLiteral) { if (!resolvedProperties) { continue; } - // The {unit,value} number/integer conflict is resolved by the contextual - // split below. Do not queue the generic `.int()` edit on the shared object - // in the same pass, because contextual edits are computed from the original - // source text and cannot observe another pending edit. - if ( - sharedMeasureSplitNeeded && - (setKey === "unit,value" || - setKey === "display_text,scale,unit,value" || - setKey === "display_text,unit,value") && - name === "value" - ) { - continue; - } const descriptor = resolvedProperties.get(name); if (!descriptor) { continue; @@ -2077,61 +2043,6 @@ if (sharedQuantitySplitNeeded) { } } -// Apply the contextual {unit,value} split. The shared measure object may arrive -// as either unconstrained `z.number()` or incorrectly constrained -// `z.number().int()` depending on traversal order; normalize it to the source -// `number`, then replace reference aliases with integer-valued standalone -// objects. A second injector pass is a no-op because neither pattern remains. -if (sharedMeasureSplitNeeded) { - const sharedObjectStart = sourceText.indexOf( - "export const PurpleMeasureSchema = z.object({" - ); - const sharedObjectEnd = - sharedObjectStart >= 0 - ? sourceText.indexOf("\n});", sharedObjectStart) - : -1; - if (sharedObjectStart >= 0 && sharedObjectEnd >= 0) { - const sharedObjectText = sourceText.slice( - sharedObjectStart, - sharedObjectEnd - ); - const integerValue = - /["']?value["']?: z\.number\(\)\.int\(\)(?:\.gte\([^)]+\))?(?:\.lte\([^)]+\))?/.exec( - sharedObjectText - ); - if (integerValue) { - edits.push({ - pos: sharedObjectStart + integerValue.index, - remove: integerValue[0].length, - text: integerValue[0].replace(".int()", ""), - }); - report.sharedMeasureInjected += 1; - } - } - for (const name of REFERENCE_SPLIT_TARGETS) { - const aliasRef = new RegExp( - `export const ${name}Schema = PurpleMeasureSchema;` - ); - const matched = aliasRef.exec(sourceText); - if (!matched) { - continue; - } - const standalone = - `export const ${name}Schema = z.object({\n` + - ` display_text: z.string(),\n` + - ` scale: z.number().int().gte(0).lte(15).optional(),\n` + - ` unit: z.string(),\n` + - ` value: z.number().int().gte(1).lte(9007199254740991),\n` + - `});`; - edits.push({ - pos: matched.index, - remove: matched[0].length, - text: standalone, - }); - report.sharedMeasureInjected += 1; - } -} - // Apply edits back-to-front so positions stay valid. edits.sort((a, b) => b.pos - a.pos); let output = sourceText; @@ -2155,7 +2066,6 @@ process.stdout.write( `${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); ` + `${report.fieldsAlreadyDone} already constrained; ` + `${report.fieldsSkippedType} skipped (base-type mismatch).\n` ); diff --git a/src/spec_generated.ts b/src/spec_generated.ts index 3f1224f..cfece33 100644 --- a/src/spec_generated.ts +++ b/src/spec_generated.ts @@ -416,22 +416,12 @@ export const PurpleMeasureSchema = z.object({ display_text: z.string(), scale: z.number().int().gte(0).lte(15).optional(), unit: z.string(), - value: z.number(), + value: z.number().int().gte(-9007199254740991).lte(9007199254740991), }); export type PurpleMeasure = z.infer; -export const FluffyMeasureSchema = z.object({ - display_text: z.string(), - scale: z.number().int().gte(0).lte(15).optional(), - unit: z.string(), - value: z.number().int().gte(1).lte(9007199254740991), -}); +export const FluffyMeasureSchema = PurpleMeasureSchema; export type FluffyMeasure = PurpleMeasure; -export const LineItemMeasureSchema = z.object({ - display_text: z.string(), - scale: z.number().int().gte(0).lte(15).optional(), - unit: z.string(), - value: z.number().int().gte(1).lte(9007199254740991), -}); +export const LineItemMeasureSchema = PurpleMeasureSchema; export type LineItemMeasure = PurpleMeasure; export const MeasureSchema = PurpleMeasureSchema; export type Measure = PurpleMeasure; diff --git a/tests/spec-constraints.test.js b/tests/spec-constraints.test.js index 74e27d3..0939769 100644 --- a/tests/spec-constraints.test.js +++ b/tests/spec-constraints.test.js @@ -150,10 +150,13 @@ test("ExpectationLineItemSchema accepts a positive integer quantity", () => { assert.ok(accepts(ExpectationLineItemSchema, { id: "li_1", quantity: 1 })); }); -// --- Unit price measure/reference: contextual split ------------------------- -// Both objects have {unit,value}, but the pinned schema declares measure.value -// as `number` and reference.value as `integer`. The generated schemas must not -// let quicktype's shared-object merge apply the integer rule to both contexts. +// --- Shared Measure: preserve the common schema's integer contract ---------- +// `common/types/measure.json` defines `value` as an integer. The old contextual +// split treated the catalog unit-price `measure` as a number, which weakened the +// shared schema and let fractional settled measures through. The `minimum: 1` +// narrowing that unit_price adds on top of the shared Measure is a separate +// allOf branch the injector does not descend into, so it stays out of scope +// here. const unitPrice = (measureValue, referenceValue) => ({ amount: 125, @@ -162,14 +165,20 @@ const unitPrice = (measureValue, referenceValue) => ({ reference: { display_text: "kg", unit: "kg", value: referenceValue }, }); -test("unit price measure accepts fractional and integer values", () => { - assert.ok(accepts(PurpleUnitPriceSchema, unitPrice(0.5, 100))); - assert.ok(accepts(PurpleUnitPriceSchema, unitPrice(1, 100))); +test("shared Measure rejects a fractional value (type: integer)", () => { + assert.ok( + rejects(AdjustmentLineItemSchema, { + id: "li_1", + quantity: 0, + measure: { display_text: "kg", unit: "kg", value: 0.5 }, + }) + ); }); -test("unit price reference requires an integer value", () => { - assert.ok(rejects(PurpleUnitPriceSchema, unitPrice(0.5, 0.5))); - assert.ok(accepts(PurpleUnitPriceSchema, unitPrice(0.5, 100))); +test("unit price measure and reference require positive integer values", () => { + assert.ok(rejects(PurpleUnitPriceSchema, unitPrice(0.5, 100))); + assert.ok(rejects(PurpleUnitPriceSchema, unitPrice(1, 0.5))); + assert.ok(accepts(PurpleUnitPriceSchema, unitPrice(1, 100))); }); // --- Projected request constraints -----------------------------------------