From 465f830bcdf4d25e2f46b7cc2893976841217c18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 02:25:28 +0000 Subject: [PATCH] fix(config): support uri formats in schema validation --- src/config/validation.ts | 29 +++++++++++++++++++++ src/plugins/schema-validator.test.ts | 39 ++++++++++++++++++++++++++++ src/plugins/schema-validator.ts | 22 ++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/config/validation.ts b/src/config/validation.ts index 063ec391d28..f8b0230e351 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -40,6 +40,9 @@ type AllowedValuesCollection = { hasValues: boolean; }; +const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i; +const STREAMING_ALLOWED_VALUES = [true, false, "off", "partial", "block", "progress"] as const; + function toIssueRecord(value: unknown): UnknownIssueRecord | null { if (!value || typeof value !== "object") { return null; @@ -47,6 +50,28 @@ function toIssueRecord(value: unknown): UnknownIssueRecord | null { return value as UnknownIssueRecord; } +function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection { + const message = typeof record.message === "string" ? record.message : ""; + const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE); + if (expectedMatch?.[1]) { + const values = [...expectedMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); + return { values, incomplete: false, hasValues: values.length > 0 }; + } + + const path = Array.isArray(record.path) + ? record.path.filter((segment): segment is string => typeof segment === "string") + : []; + if (path.at(-1) === "streaming") { + return { + values: [...STREAMING_ALLOWED_VALUES], + incomplete: false, + hasValues: true, + }; + } + + return { values: [], incomplete: false, hasValues: false }; +} + function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection { const record = toIssueRecord(issue); if (!record) { @@ -70,6 +95,10 @@ function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection return { values: [], incomplete: true, hasValues: false }; } + if (code === "custom") { + return collectAllowedValuesFromCustomIssue(record); + } + if (code !== "invalid_union") { return { values: [], incomplete: false, hasValues: false }; } diff --git a/src/plugins/schema-validator.test.ts b/src/plugins/schema-validator.test.ts index 2646230c30f..98187027624 100644 --- a/src/plugins/schema-validator.test.ts +++ b/src/plugins/schema-validator.test.ts @@ -231,4 +231,43 @@ describe("schema validator", () => { expect(issue?.text).not.toContain("\x1b"); } }); + + it("supports uri-formatted string schemas", () => { + const valid = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.uri.valid", + schema: { + type: "object", + properties: { + apiRoot: { + type: "string", + format: "uri", + }, + }, + required: ["apiRoot"], + }, + value: { apiRoot: "https://api.telegram.org" }, + }); + expect(valid.ok).toBe(true); + + const invalid = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.uri.invalid", + schema: { + type: "object", + properties: { + apiRoot: { + type: "string", + format: "uri", + }, + }, + required: ["apiRoot"], + }, + value: { apiRoot: "not a uri" }, + }); + expect(invalid.ok).toBe(false); + if (!invalid.ok) { + expect(invalid.errors.find((entry) => entry.path === "apiRoot")?.message).toContain( + "must match format", + ); + } + }); }); diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index 5c99d435893..4f84ad6a570 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -5,6 +5,15 @@ import { sanitizeTerminalText } from "../terminal/safe-text.js"; const require = createRequire(import.meta.url); type AjvLike = { + addFormat: ( + name: string, + format: + | RegExp + | { + type?: string; + validate: (value: string) => boolean; + }, + ) => AjvLike; compile: (schema: Record) => ValidateFunction; }; const ajvSingletons = new Map<"default" | "defaults", AjvLike>(); @@ -25,6 +34,19 @@ function getAjv(mode: "default" | "defaults"): AjvLike { removeAdditional: false, ...(mode === "defaults" ? { useDefaults: true } : {}), }); + instance.addFormat("uri", { + type: "string", + validate: (value: string) => { + try { + // Accept absolute URIs so generated config schemas can keep JSON Schema + // `format: "uri"` without noisy AJV warnings during validation/build. + new URL(value); + return true; + } catch { + return false; + } + }, + }); ajvSingletons.set(mode, instance); return instance; }