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);