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.
This commit is contained in:
roboomp
2026-08-13 09:15:34 +00:00
parent a4adf17986
commit 9286b72b4f
4 changed files with 42 additions and 2 deletions
@@ -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: [
+4
View File
@@ -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
+7 -2
View File
@@ -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<string, unknown> = {};
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<const V extends string | number | boolean | null>(value: V, opts?: Meta): TLiteral<V> {
+6
View File
@@ -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);