From 9286b72b4fecfd3704cb05e0c70518272bda4dd1 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 09:15:34 +0000 Subject: [PATCH] fix(omptype): preserved TypeBox constraint keywords Emit pattern, non-URL format, and multipleOf from the TypeBox adapter alongside the runtime narrows that enforce them. Embedded builders lowered by the legacy compatibility shim now retain identical validation constraints in the raw JSON-Schema validator. Added adapter-level wire assertions and an end-to-end unsafe embedding regression. --- .../test/extensibility/typebox-shim.test.ts | 25 +++++++++++++++++++ packages/omptype/CHANGELOG.md | 4 +++ packages/omptype/src/typebox.ts | 9 +++++-- packages/omptype/test/typebox.test.ts | 6 +++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/extensibility/typebox-shim.test.ts b/packages/coding-agent/test/extensibility/typebox-shim.test.ts index 1ad333392..7eb792225 100644 --- a/packages/coding-agent/test/extensibility/typebox-shim.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-shim.test.ts @@ -280,6 +280,31 @@ describe("pi.typebox compatibility shim", () => { expect(schema.safeParse(true).success).toBe(false); }); + it("preserves wire-only constraints on embedded schema builders", () => { + const schema = Type.Unsafe({ + type: "object", + properties: { + code: Type.String({ pattern: "^x" }), + count: Type.Number({ multipleOf: 2 }), + }, + required: ["code", "count"], + additionalProperties: false, + }); + + expect(schema.toJsonSchema()).toEqual({ + type: "object", + properties: { + code: { type: "string", pattern: "^x" }, + count: { type: "number", multipleOf: 2 }, + }, + required: ["code", "count"], + additionalProperties: false, + }); + expect(schema.safeParse({ code: "xray", count: 4 }).success).toBe(true); + expect(schema.safeParse({ code: "bad", count: 4 }).success).toBe(false); + expect(schema.safeParse({ code: "xray", count: 3 }).success).toBe(false); + }); + it("recovers the wire schema when a builder is spread into a new document", () => { const base = Type.Unsafe({ anyOf: [ diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md index 37c104eec..d3e1d0c6f 100644 --- a/packages/omptype/CHANGELOG.md +++ b/packages/omptype/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the TypeBox adapter omitting `pattern`, non-URL `format`, and `multipleOf` from emitted JSON Schema even though runtime validation enforced them ([#8425](https://github.com/can1357/oh-my-pi/pull/8425)). + ## [17.3.0] - 2026-08-13 ### Added diff --git a/packages/omptype/src/typebox.ts b/packages/omptype/src/typebox.ts index 7e252a556..b674d47d6 100644 --- a/packages/omptype/src/typebox.ts +++ b/packages/omptype/src/typebox.ts @@ -220,7 +220,11 @@ function tString(opts?: StringOpts): TString { const valid = formatPredicate(format); schema = schema.narrow((value, ctx) => valid(value) || ctx.mustBe(`a string in ${format} format`)); } - return applyMeta(schema, opts); + const result = applyMeta(schema, opts); + const keywords: Record = {}; + if (opts?.pattern !== undefined) keywords.pattern = opts.pattern; + if (opts?.format !== undefined) keywords.format = opts.format === "url" ? "uri" : opts.format; + return opts?.pattern !== undefined || opts?.format !== undefined ? withJsonSchemaKeywords(result, keywords) : result; } function formatPredicate(format: string): (value: string) => boolean { @@ -290,7 +294,8 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber { ); }); } - return applyMeta(schema, opts); + const result = applyMeta(schema, opts); + return opts?.multipleOf !== undefined ? withJsonSchemaKeywords(result, { multipleOf: opts.multipleOf }) : result; } function tLiteral(value: V, opts?: Meta): TLiteral { diff --git a/packages/omptype/test/typebox.test.ts b/packages/omptype/test/typebox.test.ts index 0ded2d33f..24cfb5a78 100644 --- a/packages/omptype/test/typebox.test.ts +++ b/packages/omptype/test/typebox.test.ts @@ -46,11 +46,17 @@ describe("TypeBox adapter", () => { expect(valid(Type.String({ format: "email" }), "a@b.co")).toBe(true); expect(valid(Type.String({ format: "email" }), "nope")).toBe(false); expect(Type.String({ format: "url" }).toJsonSchema()).toEqual({ type: "string", format: "uri" }); + expect(Type.String({ pattern: "^[a-z]+$", format: "email" }).toJsonSchema()).toEqual({ + type: "string", + pattern: "^[a-z]+$", + format: "email", + }); const number = Type.Number({ minimum: 1, maximum: 10, multipleOf: 2 }); expect(valid(number, 4)).toBe(true); expect(valid(number, 0)).toBe(false); expect(valid(number, 3)).toBe(false); + expect(number.toJsonSchema()).toEqual({ type: "number", minimum: 1, maximum: 10, multipleOf: 2 }); const exclusive = Type.Number({ exclusiveMinimum: 1, exclusiveMaximum: 3 }); expect(valid(exclusive, 2)).toBe(true); expect(valid(exclusive, 1)).toBe(false);