From 5aaf48ae5cf753677da613f488a04bb2809a5c8f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:28:53 -0700 Subject: [PATCH] fix(zod): resolve `@uuid` version and `@time` precision by argument name Both attributes read their first optional parameter from `attr.args[0]`, so `@uuid(message: "custom", version: 7)` picked up the message and silently fell back to version-agnostic UUID validation. Look the argument up by name instead, matching how `@length` already resolves `min`/`max`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/zod/src/utils.ts | 16 +++++- packages/zod/test/string-validation.test.ts | 55 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/zod/test/string-validation.test.ts diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index c5a949afd..db773096e 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -30,6 +30,18 @@ function getArgValue(expr: Expression | und return expr.value as T; } +function getNamedAttributeArgValue( + attr: AttributeApplication, + name: string, +): T | undefined { + const named = attr.args?.find((a) => a.name === name); + if (named) { + return getArgValue(named.value); + } else { + return undefined; + } +} + export function addStringValidation( schema: z.ZodString, attributes: readonly AttributeApplication[] | undefined, @@ -81,7 +93,7 @@ export function addStringValidation( break; } case '@uuid': { - const version = getArgValue(attr.args?.[0]?.value); + const version = getNamedAttributeArgValue(attr, 'version'); if (version === 4) { result = result.uuidv4(); } else if (version === 7) { @@ -101,7 +113,7 @@ export function addStringValidation( result = result.date(); break; case '@time': { - const precision = getArgValue(attr.args?.[0]?.value); + const precision = getNamedAttributeArgValue(attr, 'precision'); result = result.time({ precision }); break; } diff --git a/packages/zod/test/string-validation.test.ts b/packages/zod/test/string-validation.test.ts new file mode 100644 index 000000000..8550c9110 --- /dev/null +++ b/packages/zod/test/string-validation.test.ts @@ -0,0 +1,55 @@ +import { ExpressionUtils, type AttributeApplication } from '@zenstackhq/schema'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { addStringValidation } from '../src/utils'; + +function attr(name: string, args: { name?: string; value: string | number | boolean }[]): AttributeApplication { + return { name, args: args.map((a) => ({ name: a.name, value: ExpressionUtils.literal(a.value) })) }; +} + +function validate(attribute: AttributeApplication, value: string) { + return addStringValidation(z.string(), [attribute]).safeParse(value).success; +} + +describe('string validation attributes', () => { + const uuidV4 = '20ef31c8-a2c6-4dca-b87b-838e364ab4b3'; + const uuidV7 = '0199a1b2-c3d4-7abc-8def-0123456789ab'; + + it('accepts any uuid version when no version is given', () => { + expect(validate(attr('@uuid', []), uuidV4)).toBe(true); + expect(validate(attr('@uuid', []), uuidV7)).toBe(true); + expect(validate(attr('@uuid', []), 'not-a-uuid')).toBe(false); + }); + + it('respects a named version arg regardless of argument order', () => { + const versionFirst = attr('@uuid', [ + { name: 'version', value: 7 }, + { name: 'message', value: 'custom' }, + ]); + expect(validate(versionFirst, uuidV7)).toBe(true); + expect(validate(versionFirst, uuidV4)).toBe(false); + + const messageFirst = attr('@uuid', [ + { name: 'message', value: 'custom' }, + { name: 'version', value: 7 }, + ]); + expect(validate(messageFirst, uuidV7)).toBe(true); + expect(validate(messageFirst, uuidV4)).toBe(false); + + const v4 = attr('@uuid', [ + { name: 'message', value: 'custom' }, + { name: 'version', value: 4 }, + ]); + expect(validate(v4, uuidV4)).toBe(true); + expect(validate(v4, uuidV7)).toBe(false); + }); + + it('resolves @time precision from a named arg in any order', () => { + const messageFirst = attr('@time', [ + { name: 'message', value: 'custom' }, + { name: 'precision', value: 3 }, + ]); + expect(validate(messageFirst, '12:00:00.123')).toBe(true); + expect(validate(messageFirst, '12:00:00')).toBe(false); + }); +});