From 314c1cca6de5e57a88a0fe64839fbd48bd50a546 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 24 May 2026 09:37:50 -0400 Subject: [PATCH] fix: preserve schema helper types --- .changeset/product-schema-object-helpers.md | 5 + scripts/check-adopter-types.ts | 58 ++++++ scripts/generate-zod-from-ts.ts | 194 +++++++++++++++++- src/lib/schemas/index.ts | 19 +- src/lib/server/create-adcp-server.ts | 2 +- src/lib/types/inline-enums.generated.ts | 30 +++ src/lib/types/schemas.generated.ts | 22 +- src/lib/utils/tool-request-schemas.ts | 89 +++++++- .../zod-object-intersections.type-test.ts | 88 ++++++++ .../generate-zod-object-intersections.test.js | 89 +++++--- test/lib/zod-schemas.test.js | 5 + 11 files changed, 537 insertions(+), 64 deletions(-) create mode 100644 .changeset/product-schema-object-helpers.md diff --git a/.changeset/product-schema-object-helpers.md b/.changeset/product-schema-object-helpers.md new file mode 100644 index 000000000..f1c93ce0d --- /dev/null +++ b/.changeset/product-schema-object-helpers.md @@ -0,0 +1,5 @@ +--- +"@adcp/sdk": patch +--- + +Restore ZodObject helper access on ProductSchema and marker-backed canonical format schemas by collapsing marker-only intersections during schema generation. diff --git a/scripts/check-adopter-types.ts b/scripts/check-adopter-types.ts index dcf44a809..f0b6ee719 100644 --- a/scripts/check-adopter-types.ts +++ b/scripts/check-adopter-types.ts @@ -53,12 +53,70 @@ const ADOPTER_SOURCE = ` // orthogonal to this guard's scope. Widen when those are addressed. import type { AdcpServer } from '@adcp/sdk/server'; import { createSingleAgentClient, extractAdcpErrorFromMcp, extractAdcpErrorFromTransport } from '@adcp/sdk'; +import type { AccountReference } from '@adcp/sdk'; +import { customToolFor, TOOL_INPUT_SHAPES, TOOL_REQUEST_SCHEMAS } from '@adcp/sdk/schemas'; declare const _server: AdcpServer; void _server; void createSingleAgentClient; void extractAdcpErrorFromMcp; void extractAdcpErrorFromTransport; + +void TOOL_REQUEST_SCHEMAS.get_products.shape.brief; +void TOOL_REQUEST_SCHEMAS.create_media_buy.shape.account; +// @ts-expect-error known tool request schemas should reject bogus fields +void TOOL_REQUEST_SCHEMAS.create_media_buy.shape.not_a_real_field; +void TOOL_REQUEST_SCHEMAS.preview_creative.shape.request_type; +const previewRequestType: 'single' | 'batch' | 'variant' = + TOOL_REQUEST_SCHEMAS.preview_creative.shape.request_type.parse('single'); +void previewRequestType; +// @ts-expect-error TS7056 object annotations should keep known request fields exact +void TOOL_REQUEST_SCHEMAS.preview_creative.shape.not_a_real_field; +void TOOL_INPUT_SHAPES.creative_approval.rights_id; +void TOOL_INPUT_SHAPES.update_media_buy.media_buy_id; +// @ts-expect-error update_media_buy input shape should reject bogus fields +void TOOL_INPUT_SHAPES.update_media_buy.not_a_real_field; + +function assertOptionalAccountReference(account: AccountReference | undefined): void { + if (account && 'account_id' in account) { + const accountId: string = account.account_id; + void accountId; + } +} + +customToolFor('creative_approval', 'Submit creative for approval', TOOL_INPUT_SHAPES.creative_approval, async args => { + const rightsId: string = args.rights_id; + void rightsId; + // @ts-expect-error unknown creative approval fields should not type-check + void args.not_a_real_field; +}); + +customToolFor('create_media_buy', 'Create a media buy', TOOL_INPUT_SHAPES.create_media_buy, async args => { + assertOptionalAccountReference(args.account); +}); + +customToolFor('update_media_buy', 'Update a media buy', TOOL_INPUT_SHAPES.update_media_buy, async args => { + const mediaBuyId: string = args.media_buy_id; + void mediaBuyId; + assertOptionalAccountReference(args.account); + // @ts-expect-error customToolFor handler args should reject bogus update fields + void args.not_a_real_field; +}); + +customToolFor('preview_creative', 'Preview a creative', TOOL_INPUT_SHAPES.preview_creative, async args => { + const requestType: 'single' | 'batch' | 'variant' = args.request_type; + void requestType; +}); + +declare const runtimeToolName: string; +void TOOL_INPUT_SHAPES[runtimeToolName]; +void TOOL_REQUEST_SCHEMAS[runtimeToolName]?.shape; + +// @ts-expect-error unknown tool names are not valid customToolFor shapes without narrowing +customToolFor('creative_approval', 'x', TOOL_INPUT_SHAPES.typo_tool, async args => args); + +// @ts-expect-error unknown fields should not type-check +void TOOL_INPUT_SHAPES.creative_approval.not_a_real_field; `; function run(cmd: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv): void { diff --git a/scripts/generate-zod-from-ts.ts b/scripts/generate-zod-from-ts.ts index fd0011547..6c39a2ab3 100644 --- a/scripts/generate-zod-from-ts.ts +++ b/scripts/generate-zod-from-ts.ts @@ -81,8 +81,8 @@ const TS7056_SCHEMAS: Array<{ name: string; tsType?: string; objectShape?: boole // 3.1.0-beta.2 pin flip — `.and(z.union([...]))` compound patterns push // inferred types past TS7056's .d.ts serialization limit. Carry the TS // type so callers' `params` keep narrowing. - { name: 'PreviewCreativeRequestSchema', tsType: 'PreviewCreativeRequest' }, - { name: 'UpdateMediaBuyRequestSchema', tsType: 'UpdateMediaBuyRequest' }, + { name: 'PreviewCreativeRequestSchema', tsType: 'PreviewCreativeRequest', objectShape: true }, + { name: 'UpdateMediaBuyRequestSchema', tsType: 'UpdateMediaBuyRequest', objectShape: true }, { name: 'UpdateMediaBuyResponseSchema', tsType: 'UpdateMediaBuyResponse' }, { name: 'BuildCreativeResponseSchema', tsType: 'BuildCreativeResponse' }, { name: 'SyncEventSourcesResponseSchema', tsType: 'SyncEventSourcesResponse' }, @@ -100,11 +100,10 @@ function postProcessTS7056Annotations(content: string): string { ); } // Object-shaped schemas (pure `z.object({...}).passthrough()`) are - // annotated `z.ZodObject` so call sites that constrain to - // ZodObject (e.g. `withOptionalAccount>`) - // keep working. The `any` shape parameter erases inner-field inference - // — that's the trade-off TS7056 forces on us, and it's what these call - // sites already accept for the in-bound `z.ZodObject` constraint. + // annotated as ZodObjects so call sites that use `.shape`, `.extend()`, + // `.pick()`, or `.omit()` keep working. When a TS type is available, + // keep the shape keys and field value types tied to that type so + // downstream helpers like customToolFor() still infer typed args. // // Intersection-shaped schemas (`z.object().passthrough().and(z.union(...))`) // use the 2-type-param `z.ZodType` form. `z.input` @@ -113,7 +112,14 @@ function postProcessTS7056Annotations(content: string): string { // keep their narrowing. let annotation: string; if (objectShape) { - annotation = 'z.ZodObject'; + if (tsType) { + const widened = `${tsType} & Record`; + const objectShapeType = `{ [K in keyof ${tsType}]-?: z.ZodType<${tsType}[K], ${tsType}[K]> }`; + annotation = `z.ZodObject<${objectShapeType}, any> & z.ZodType<${widened}, ${widened}>`; + typesToImport.push(tsType); + } else { + annotation = 'z.ZodObject'; + } } else if (tsType) { const widened = `${tsType} & Record`; annotation = `z.ZodType<${widened}, ${widened}>`; @@ -798,6 +804,164 @@ function schemaShapeForExpression( return undefined; } +function splitTopLevelList(body: string): string[] | undefined { + const parts: string[] = []; + let depth = 0; + let partStart = 0; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + const literalEnd = skipQuotedOrRegexLiteral(body, i); + if (literalEnd !== undefined) { + i = literalEnd - 1; + continue; + } + + if (ch === '(' || ch === '{' || ch === '[') depth++; + else if (ch === ')' || ch === '}' || ch === ']') depth--; + else if (ch === ',' && depth === 0) { + const part = body.slice(partStart, i).trim(); + if (!part) return undefined; + parts.push(part); + partStart = i + 1; + } + } + + const last = body.slice(partStart).trim(); + if (!last) return undefined; + parts.push(last); + return parts; +} + +function unionArmsForExpression(expression: string): string[] | undefined { + const trimmed = expression.trim(); + if (!trimmed.startsWith('z.union(')) return undefined; + + const call = scanBalanced(trimmed, 'z.union'.length); + if (!call || trimmed.slice(call.end).trim()) return undefined; + + const arg = call.body.trim(); + if (!arg.startsWith('[')) return undefined; + + const array = scanBalanced(arg, 0, '[', ']'); + if (!array || arg.slice(array.end).trim()) return undefined; + + return splitTopLevelList(array.body); +} + +function isOpaqueRecordMarkerExpression( + expression: string, + schemaExpressions: Map, + cache: Map, + visiting = new Set() +): boolean { + const trimmed = normalizeSchemaExpression(expression); + if (trimmed === 'z.record(z.string(), z.unknown())') return true; + + const named = trimmed.match(/^(\w+Schema)$/)?.[1]; + if (!named) return false; + if (cache.has(named)) return cache.get(named) ?? false; + if (visiting.has(named)) return false; + + const namedExpression = schemaExpressions.get(named); + if (!namedExpression) return false; + + visiting.add(named); + const result = isOpaqueRecordMarkerExpression(namedExpression, schemaExpressions, cache, visiting); + visiting.delete(named); + cache.set(named, result); + return result; +} + +function isOpaqueMarkerUnion( + expression: string, + schemaExpressions: Map, + markerCache: Map, + visiting = new Set() +): boolean { + const trimmed = normalizeSchemaExpression(expression); + const named = trimmed.match(/^(\w+Schema)$/)?.[1]; + if (named) { + if (visiting.has(named)) return false; + const namedExpression = schemaExpressions.get(named); + if (!namedExpression) return false; + + visiting.add(named); + const result = isOpaqueMarkerUnion(namedExpression, schemaExpressions, markerCache, visiting); + visiting.delete(named); + return result; + } + + const arms = unionArmsForExpression(trimmed); + return ( + arms !== undefined && + arms.length > 0 && + arms.every(arm => isOpaqueRecordMarkerExpression(arm, schemaExpressions, markerCache)) + ); +} + +function rewriteLeadingMarkerUnionObjectAnd( + expression: string, + schemaExpressions: Map, + shapeCache: Map, + markerCache: Map +): string { + let depth = 0; + + for (let i = 0; i < expression.length; i++) { + const ch = expression[i]; + const literalEnd = skipQuotedOrRegexLiteral(expression, i); + if (literalEnd !== undefined) { + i = literalEnd - 1; + continue; + } + + if (ch === '(' || ch === '{' || ch === '[') depth++; + else if (ch === ')' || ch === '}' || ch === ']') depth--; + else if (depth === 0 && expression.startsWith('.and(', i)) { + const base = expression.slice(0, i); + const arg = scanBalanced(expression, i + '.and'.length); + if (!arg) return expression; + + if (isOpaqueMarkerUnion(base, schemaExpressions, markerCache)) { + const argShape = schemaShapeForExpression(arg.body, schemaExpressions, shapeCache); + if (argShape) return arg.body + expression.slice(arg.end); + } + + return expression; + } + } + + return expression; +} + +function postProcessMarkerUnionObjectIntersections(content: string): string { + const schemaExpressions = extractSchemaExports(content); + const shapeCache = new Map(); + const markerCache = new Map(); + const exportRegex = /export const (\w+Schema)(?::[^=]+)? = /g; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = exportRegex.exec(content))) { + const name = match[1]; + const expressionStart = exportRegex.lastIndex; + const expression = schemaExpressions.get(name); + if (!expression) continue; + + const expressionEnd = expressionStart + expression.length; + const rewritten = rewriteLeadingMarkerUnionObjectAnd(expression, schemaExpressions, shapeCache, markerCache); + + result += content.slice(lastIndex, expressionStart) + rewritten; + lastIndex = expressionEnd; + exportRegex.lastIndex = expressionEnd; + } + + result += content.slice(lastIndex); + return result; +} + function postProcessObjectIntersections(content: string): string { const schemaExpressions = extractSchemaExports(content); const shapeCache = new Map(); @@ -1039,6 +1203,14 @@ async function generateZodSchemas() { // Zod strips those fields, causing data loss for consumers who need them. zodSchemas = postProcessForPassthrough(zodSchemas); + // Post-process: Collapse marker-only union/object intersections. + // ProductSchema currently intersects opaque V1/V2 marker records with its real object shape. + // While those marker schemas are just z.record(z.string(), z.unknown()), the union adds no + // validation beyond passthrough object semantics and only removes .extend/.omit/.pick helpers. + // When the marker schemas gain real fields, this pass stops firing and preserves the richer + // intersection for maintainers to handle deliberately. + zodSchemas = postProcessMarkerUnionObjectIntersections(zodSchemas); + // Post-process: Turn safe object/object intersections into ZodObject merges. // ts-to-zod emits `.and()` for TypeScript object intersections, but ZodIntersection // does not expose object helpers like .shape/.extend/.omit/.pick. This keeps the @@ -1104,6 +1276,10 @@ if (require.main === module) { }); } -export const __test__ = { postProcessForNullish, postProcessObjectIntersections }; +export const __test__ = { + postProcessForNullish, + postProcessMarkerUnionObjectIntersections, + postProcessObjectIntersections, +}; export { generateZodSchemas }; diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts index 1d0d7bc04..e49e49cca 100644 --- a/src/lib/schemas/index.ts +++ b/src/lib/schemas/index.ts @@ -37,15 +37,24 @@ import type { z } from 'zod'; import * as schemas from '../types/schemas.generated'; import { TOOL_REQUEST_SCHEMAS } from '../utils/tool-request-schemas'; +import type { KnownToolRequestSchemas } from '../utils/tool-request-schemas'; export * from '../types/schemas.generated'; export { TOOL_REQUEST_SCHEMAS } from '../utils/tool-request-schemas'; type InputShape = Record; +type ShapeOf = T extends { shape: infer TShape extends InputShape } ? TShape : never; +type ToolInputShapes = { + [K in keyof KnownToolRequestSchemas]: ShapeOf; +} & { + creative_approval: typeof schemas.CreativeApprovalRequestSchema.shape; + update_rights: typeof schemas.UpdateRightsRequestSchema.shape; +} & { + readonly [toolName: string]: Readonly | undefined; +}; -function shapeOf(s: unknown): InputShape | undefined { - if (!s) return undefined; - const candidate = (s as { shape?: InputShape }).shape; +function shapeOf(s: T | undefined): T['shape'] | undefined { + const candidate = s?.shape; return candidate && typeof candidate === 'object' ? candidate : undefined; } @@ -67,7 +76,7 @@ function shapeOf(s: unknown): InputShape | undefined { * framework-registrable) — CI's `ci:schema-check` catches missing * map entries by diffing against the generated schemas. */ -export const TOOL_INPUT_SHAPES: Readonly>> = Object.freeze({ +export const TOOL_INPUT_SHAPES = Object.freeze({ ...Object.fromEntries( Object.entries(TOOL_REQUEST_SCHEMAS).map(([k, s]) => { const shape = shapeOf(s); @@ -81,7 +90,7 @@ export const TOOL_INPUT_SHAPES: Readonly>> = ), creative_approval: schemas.CreativeApprovalRequestSchema.shape, update_rights: schemas.UpdateRightsRequestSchema.shape, -}); +}) as Readonly; /** * Register a custom tool with MCP-compatible `inputSchema` + handler diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 37b5827e8..1ca715b0e 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -3452,7 +3452,7 @@ export function createAdcpServer(config: AdcpServerConfig } | undefined; + const schema = (TOOL_REQUEST_SCHEMAS as Readonly }>>)[toolName]; if (!schema?.shape) { logger.warn(`No schema found for tool "${toolName}" in TOOL_REQUEST_SCHEMAS, skipping`); continue; diff --git a/src/lib/types/inline-enums.generated.ts b/src/lib/types/inline-enums.generated.ts index 9d386b147..b27aa96e3 100644 --- a/src/lib/types/inline-enums.generated.ts +++ b/src/lib/types/inline-enums.generated.ts @@ -61,6 +61,11 @@ export const CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_Compositio /** single | CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement.output_modality */ export const CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_OutputModalityValues = ["text", "audio", "card"] as const; +// ====== CanonicalFormatDisplayTag ====== + +/** array of | CanonicalFormatDisplayTag.supported_tag_types */ +export const CanonicalFormatDisplayTag_SupportedTagTypesValues = ["iframe", "javascript", "1x1_redirect"] as const; + // ====== CanonicalFormatHostedAudio ====== /** single | CanonicalFormatHostedAudio.asset_source */ @@ -83,6 +88,18 @@ export const CanonicalFormatHostedVideo_OrientationValues = ["vertical", "horizo /** array of | CanonicalFormatHostedVideo.video_codecs */ export const CanonicalFormatHostedVideo_VideoCodecsValues = ["h264", "h265", "vp8", "vp9", "av1", "prores"] as const; +// ====== CanonicalFormatHTML5Banner ====== + +/** single | CanonicalFormatHTML5Banner.clicktag_macro */ +export const CanonicalFormatHTML5Banner_ClicktagMacroValues = ["clickTag", "clickTAG"] as const; +/** single | CanonicalFormatHTML5Banner.mraid_version */ +export const CanonicalFormatHTML5Banner_MraidVersionValues = ["2.0", "3.0"] as const; + +// ====== CanonicalFormatImage ====== + +/** array of | CanonicalFormatImage.image_formats */ +export const CanonicalFormatImage_ImageFormatsValues = ["jpg", "jpeg", "png", "gif", "webp", "svg"] as const; + // ====== CanonicalFormatImageCarousel ====== /** array of | CanonicalFormatImageCarousel.allowed_card_asset_types */ @@ -516,6 +533,9 @@ export const CanonicalFormatBase_CompositionModelValues = CanonicalFormatAgentPl // --- CanonicalFormatDAASTAudio --- /** @deprecated use `CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues` — same literal set, CanonicalFormatDAASTAudio.composition_model duplicates the canonical export. */ export const CanonicalFormatDAASTAudio_CompositionModelValues = CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues; +// --- CanonicalFormatDisplayTag --- +/** @deprecated use `CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues` — same literal set, CanonicalFormatDisplayTag.composition_model duplicates the canonical export. */ +export const CanonicalFormatDisplayTag_CompositionModelValues = CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues; // --- CanonicalFormatHostedAudio --- /** @deprecated use `AudioAssetRequirements_ChannelsValues` — same literal set, CanonicalFormatHostedAudio.audio_channels duplicates the canonical export. */ export const CanonicalFormatHostedAudio_AudioChannelsValues = AudioAssetRequirements_ChannelsValues; @@ -528,6 +548,16 @@ export const CanonicalFormatHostedVideo_AssetSourceValues = CanonicalFormatHoste export const CanonicalFormatHostedVideo_BuyerAssetAcceptanceValues = CanonicalFormatHostedAudio_BuyerAssetAcceptanceValues; /** @deprecated use `CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues` — same literal set, CanonicalFormatHostedVideo.composition_model duplicates the canonical export. */ export const CanonicalFormatHostedVideo_CompositionModelValues = CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues; +// --- CanonicalFormatHTML5Banner --- +/** @deprecated use `CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues` — same literal set, CanonicalFormatHTML5Banner.composition_model duplicates the canonical export. */ +export const CanonicalFormatHTML5Banner_CompositionModelValues = CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues; +// --- CanonicalFormatImage --- +/** @deprecated use `CanonicalFormatHostedAudio_AssetSourceValues` — same literal set, CanonicalFormatImage.asset_source duplicates the canonical export. */ +export const CanonicalFormatImage_AssetSourceValues = CanonicalFormatHostedAudio_AssetSourceValues; +/** @deprecated use `CanonicalFormatHostedAudio_BuyerAssetAcceptanceValues` — same literal set, CanonicalFormatImage.buyer_asset_acceptance duplicates the canonical export. */ +export const CanonicalFormatImage_BuyerAssetAcceptanceValues = CanonicalFormatHostedAudio_BuyerAssetAcceptanceValues; +/** @deprecated use `CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues` — same literal set, CanonicalFormatImage.composition_model duplicates the canonical export. */ +export const CanonicalFormatImage_CompositionModelValues = CanonicalFormatAgentPlacementAISurfaceSponsoredPlacement_CompositionModelValues; // --- CanonicalFormatImageCarousel --- /** @deprecated use `CanonicalFormatImageCarousel_AllowedCardAssetTypesValues` — same literal set, CanonicalFormatImageCarousel.allowed_card_media_asset_types duplicates the canonical export. */ export const CanonicalFormatImageCarousel_AllowedCardMediaAssetTypesValues = CanonicalFormatImageCarousel_AllowedCardAssetTypesValues; diff --git a/src/lib/types/schemas.generated.ts b/src/lib/types/schemas.generated.ts index 38dfb1a35..b1057b545 100644 --- a/src/lib/types/schemas.generated.ts +++ b/src/lib/types/schemas.generated.ts @@ -1,5 +1,5 @@ // Generated Zod v4 schemas from TypeScript types -// Generated at: 2026-05-24T11:13:30.748Z +// Generated at: 2026-05-24T13:59:59.954Z // Sources: // - core.generated.ts (core types) // - tools.generated.ts (tool types) @@ -631,7 +631,7 @@ export const NoneSchema = z.record(z.string(), z.unknown()); export const SizeModeMutexSchema = z.union([FixedSchema, MultiSizeSchema, ResponsiveSchema, NoneSchema]); -export const CanonicalFormatDisplayTagSchema = SizeModeMutexSchema.and(z.object({ +export const CanonicalFormatDisplayTagSchema = z.object({ experimental: z.boolean().optional(), deprecated: z.boolean().optional(), v1_translatable: z.boolean().optional(), @@ -660,7 +660,7 @@ export const CanonicalFormatDisplayTagSchema = SizeModeMutexSchema.and(z.object( backup_image_required: z.boolean().optional(), backup_image_max_size_kb: z.number().min(1).optional(), om_sdk_required: z.boolean().optional() -}).passthrough()); +}).passthrough(); export const PriceAdjustmentKindSchema = z.union([z.literal("fee"), z.literal("discount"), z.literal("commission"), z.literal("settlement")]); @@ -698,7 +698,7 @@ export const TalentRoleSchema = z.union([z.literal("host"), z.literal("guest"), export const DerivativeTypeSchema = z.union([z.literal("clip"), z.literal("highlight"), z.literal("recap"), z.literal("trailer"), z.literal("bonus")]); -export const CanonicalFormatImageSchema = SizeModeMutexSchema.and(z.object({ +export const CanonicalFormatImageSchema = z.object({ experimental: z.boolean().optional(), deprecated: z.boolean().optional(), v1_translatable: z.boolean().optional(), @@ -729,9 +729,9 @@ export const CanonicalFormatImageSchema = SizeModeMutexSchema.and(z.object({ cta_values: z.array(z.string()).optional(), asset_source: z.union([z.literal("buyer_uploaded"), z.literal("publisher_host_recorded"), z.literal("seller_pre_rendered_from_brief"), z.literal("seller_human_designed"), z.literal("agent_synthesized")]).optional(), buyer_asset_acceptance: z.union([z.literal("accepted"), z.literal("rejected")]).optional() -}).passthrough()); +}).passthrough(); -export const CanonicalFormatHTML5BannerSchema = SizeModeMutexSchema.and(z.object({ +export const CanonicalFormatHTML5BannerSchema = z.object({ experimental: z.boolean().optional(), deprecated: z.boolean().optional(), v1_translatable: z.boolean().optional(), @@ -765,7 +765,7 @@ export const CanonicalFormatHTML5BannerSchema = SizeModeMutexSchema.and(z.object backup_image_required: z.boolean().optional(), backup_image_max_size_kb: z.number().min(1).optional(), ssl_required: z.boolean().optional() -}).passthrough()); +}).passthrough(); export const DisplayTagFormatDeclarationSchema = z.object({ format_kind: z.literal("display_tag"), @@ -6070,7 +6070,7 @@ export const BuildCreativeMultiSuccessSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); -export const PreviewCreativeRequestSchema: z.ZodType, PreviewCreativeRequest & Record> = z.object({ +export const PreviewCreativeRequestSchema: z.ZodObject<{ [K in keyof PreviewCreativeRequest]-?: z.ZodType }, any> & z.ZodType, PreviewCreativeRequest & Record> = z.object({ adcp_version: z.string().optional(), adcp_major_version: z.number().optional(), request_type: z.union([z.literal("single"), z.literal("batch"), z.literal("variant")]), @@ -7898,7 +7898,7 @@ export const CreateMediaBuySuccessSchema = z.object({ ext: ExtensionObjectSchema.optional() }).passthrough(); -export const ProductSchema = z.union([V1ProductNamedFormatReferenceSchema, V2ProductInlineFormatDeclarationsSchema]).and(z.object({ +export const ProductSchema = z.object({ product_id: z.string(), name: z.string(), description: z.string(), @@ -7993,7 +7993,7 @@ export const ProductSchema = z.union([V1ProductNamedFormatReferenceSchema, V2Pro ext: ExtensionObjectSchema.optional() }).passthrough().optional(), ext: ExtensionObjectSchema.optional() -}).passthrough()); +}).passthrough(); export const GetProductsAsyncInputRequiredSchema = z.object({ reason: z.union([z.literal("CLARIFICATION_NEEDED"), z.literal("BUDGET_REQUIRED")]).optional(), @@ -9000,7 +9000,7 @@ export const CreateMediaBuyResponseSchema = z.object({ adcp_major_version: z.number().optional() }).passthrough().and(z.union([CreateMediaBuySuccessSchema, CreateMediaBuyErrorSchema, CreateMediaBuySubmittedSchema])); -export const UpdateMediaBuyRequestSchema: z.ZodType, UpdateMediaBuyRequest & Record> = z.object({ +export const UpdateMediaBuyRequestSchema: z.ZodObject<{ [K in keyof UpdateMediaBuyRequest]-?: z.ZodType }, any> & z.ZodType, UpdateMediaBuyRequest & Record> = z.object({ adcp_version: z.string().optional(), adcp_major_version: z.number().optional(), account: AccountReferenceSchema, diff --git a/src/lib/utils/tool-request-schemas.ts b/src/lib/utils/tool-request-schemas.ts index 19f95a836..18dac39d1 100644 --- a/src/lib/utils/tool-request-schemas.ts +++ b/src/lib/utils/tool-request-schemas.ts @@ -14,24 +14,97 @@ import { z } from 'zod'; import * as schemas from '../types/schemas.generated'; +type InputShape = Record; +type WithOptionalAccountShape = Omit & { + account: z.ZodOptional; +}; + /** * Make `account` optional for MCP tool registration so requests missing it * reach the handler, which can return a proper adcpError('INVALID_REQUEST') * instead of a raw MCP schema validation error. The schema_validation * storyboard sends create_media_buy without account to test error handling. */ -function withOptionalAccount>(schema: T) { - return schema.extend({ account: schema.shape.account.optional() }); +function withOptionalAccount( + schema: z.ZodObject +): z.ZodObject, any> { + return schema.extend({ account: schema.shape.account.optional() }) as z.ZodObject< + WithOptionalAccountShape, + any + >; } -export const TOOL_REQUEST_SCHEMAS: Partial> = { +const CreateMediaBuyToolRequestSchema = withOptionalAccount(schemas.CreateMediaBuyRequestSchema); +const UpdateMediaBuyToolRequestSchema = withOptionalAccount(schemas.UpdateMediaBuyRequestSchema); + +export type KnownToolRequestSchemas = { + get_products: typeof schemas.GetProductsRequestSchema; + create_media_buy: typeof CreateMediaBuyToolRequestSchema; + update_media_buy: typeof UpdateMediaBuyToolRequestSchema; + get_media_buys: typeof schemas.GetMediaBuysRequestSchema; + get_media_buy_delivery: typeof schemas.GetMediaBuyDeliveryRequestSchema; + provide_performance_feedback: typeof schemas.ProvidePerformanceFeedbackRequestSchema; + list_creative_formats: typeof schemas.ListCreativeFormatsRequestSchema; + build_creative: typeof schemas.BuildCreativeRequestSchema; + preview_creative: typeof schemas.PreviewCreativeRequestSchema; + sync_creatives: typeof schemas.SyncCreativesRequestSchema; + list_creatives: typeof schemas.ListCreativesRequestSchema; + get_creative_delivery: typeof schemas.GetCreativeDeliveryRequestSchema; + get_signals: typeof schemas.GetSignalsRequestSchema; + activate_signal: typeof schemas.ActivateSignalRequestSchema; + sync_accounts: typeof schemas.SyncAccountsRequestSchema; + list_accounts: typeof schemas.ListAccountsRequestSchema; + sync_governance: typeof schemas.SyncGovernanceRequestSchema; + sync_audiences: typeof schemas.SyncAudiencesRequestSchema; + report_usage: typeof schemas.ReportUsageRequestSchema; + get_account_financials: typeof schemas.GetAccountFinancialsRequestSchema; + sync_catalogs: typeof schemas.SyncCatalogsRequestSchema; + sync_event_sources: typeof schemas.SyncEventSourcesRequestSchema; + log_event: typeof schemas.LogEventRequestSchema; + get_media_buy_artifacts: typeof schemas.GetMediaBuyArtifactsRequestSchema; + get_creative_features: typeof schemas.GetCreativeFeaturesRequestSchema; + create_property_list: typeof schemas.CreatePropertyListRequestSchema; + get_property_list: typeof schemas.GetPropertyListRequestSchema; + update_property_list: typeof schemas.UpdatePropertyListRequestSchema; + list_property_lists: typeof schemas.ListPropertyListsRequestSchema; + delete_property_list: typeof schemas.DeletePropertyListRequestSchema; + list_content_standards: typeof schemas.ListContentStandardsRequestSchema; + get_content_standards: typeof schemas.GetContentStandardsRequestSchema; + create_content_standards: typeof schemas.CreateContentStandardsRequestSchema; + update_content_standards: typeof schemas.UpdateContentStandardsRequestSchema; + calibrate_content: typeof schemas.CalibrateContentRequestSchema; + validate_content_delivery: typeof schemas.ValidateContentDeliveryRequestSchema; + validate_property_delivery: typeof schemas.ValidatePropertyDeliveryRequestSchema; + sync_plans: typeof schemas.SyncPlansRequestSchema; + check_governance: typeof schemas.CheckGovernanceRequestSchema; + report_plan_outcome: typeof schemas.ReportPlanOutcomeRequestSchema; + get_plan_audit_logs: typeof schemas.GetPlanAuditLogsRequestSchema; + create_collection_list: typeof schemas.CreateCollectionListRequestSchema; + update_collection_list: typeof schemas.UpdateCollectionListRequestSchema; + get_collection_list: typeof schemas.GetCollectionListRequestSchema; + list_collection_lists: typeof schemas.ListCollectionListsRequestSchema; + delete_collection_list: typeof schemas.DeleteCollectionListRequestSchema; + si_get_offering: typeof schemas.SIGetOfferingRequestSchema; + si_initiate_session: typeof schemas.SIInitiateSessionRequestSchema; + si_send_message: typeof schemas.SISendMessageRequestSchema; + si_terminate_session: typeof schemas.SITerminateSessionRequestSchema; + get_adcp_capabilities: typeof schemas.GetAdCPCapabilitiesRequestSchema; + comply_test_controller: typeof schemas.ComplyTestControllerRequestSchema; + get_brand_identity: typeof schemas.GetBrandIdentityRequestSchema; + get_rights: typeof schemas.GetRightsRequestSchema; + acquire_rights: typeof schemas.AcquireRightsRequestSchema; + update_rights: typeof schemas.UpdateRightsRequestSchema; +}; + +export type ToolRequestSchemas = Readonly & { + readonly [toolName: string]: z.ZodObject | undefined; +}; + +export const TOOL_REQUEST_SCHEMAS: ToolRequestSchemas = { // Product discovery & media buy get_products: schemas.GetProductsRequestSchema, - create_media_buy: withOptionalAccount(schemas.CreateMediaBuyRequestSchema), - // UpdateMediaBuyRequestSchema is annotated `z.ZodType<...>` (TS7056 workaround - // in scripts/generate-zod-from-ts.ts) rather than `z.ZodObject`. Cast back to - // ZodObject at the one site that needs it; runtime shape is unchanged. - update_media_buy: withOptionalAccount(schemas.UpdateMediaBuyRequestSchema as unknown as z.ZodObject), + create_media_buy: CreateMediaBuyToolRequestSchema, + update_media_buy: UpdateMediaBuyToolRequestSchema, get_media_buys: schemas.GetMediaBuysRequestSchema, get_media_buy_delivery: schemas.GetMediaBuyDeliveryRequestSchema, provide_performance_feedback: schemas.ProvidePerformanceFeedbackRequestSchema, diff --git a/src/type-tests/zod-object-intersections.type-test.ts b/src/type-tests/zod-object-intersections.type-test.ts index b6e9a5131..2d8247f34 100644 --- a/src/type-tests/zod-object-intersections.type-test.ts +++ b/src/type-tests/zod-object-intersections.type-test.ts @@ -3,19 +3,107 @@ // common helpers such as .shape, .extend(), .omit(), and .pick(). import { z } from 'zod'; +import { customToolFor, TOOL_INPUT_SHAPES } from '../lib/schemas'; +import type { AccountReference } from '../lib/types'; import { + CanonicalFormatImageSchema, CreativeVariantSchema, GroupVideoAssetSchema, IndividualImageAssetSchema, + ProductSchema, TasksGetRequestSchema, TasksGetResponseSchema, ValidatePropertyDeliveryRequestSchema, ValidatePropertyDeliveryResponseSchema, } from '../lib/types/schemas.generated'; +import { TOOL_REQUEST_SCHEMAS } from '../lib/utils/tool-request-schemas'; const validatePropertyDeliveryShape = ValidatePropertyDeliveryRequestSchema.shape; void validatePropertyDeliveryShape.list_id; +const productShape = ProductSchema.shape; +void productShape.product_id; + +const productPick = ProductSchema.pick({ product_id: true, name: true }); +void productPick; + +const productOmit = ProductSchema.omit({ forecast: true }); +void productOmit; + +const productExtend = ProductSchema.extend({ + audit_id: z.string().optional(), +}); +void productExtend; + +const canonicalFormatImageShape = CanonicalFormatImageSchema.shape; +void canonicalFormatImageShape.image_formats; + +const getProductsShape = TOOL_REQUEST_SCHEMAS.get_products.shape; +void getProductsShape.brief; + +const createMediaBuyShape = TOOL_REQUEST_SCHEMAS.create_media_buy.shape; +void createMediaBuyShape.account; +// @ts-expect-error known tool request schemas should reject bogus fields +void createMediaBuyShape.not_a_real_field; + +void TOOL_REQUEST_SCHEMAS.preview_creative.shape.request_type; +const previewRequestType: 'single' | 'batch' | 'variant' = + TOOL_REQUEST_SCHEMAS.preview_creative.shape.request_type.parse('single'); +void previewRequestType; +// @ts-expect-error TS7056 object annotations should keep known request fields exact +void TOOL_REQUEST_SCHEMAS.preview_creative.shape.not_a_real_field; + +void TOOL_INPUT_SHAPES.update_media_buy.media_buy_id; +// @ts-expect-error update_media_buy input shape should reject bogus fields +void TOOL_INPUT_SHAPES.update_media_buy.not_a_real_field; + +const creativeApprovalShape = TOOL_INPUT_SHAPES.creative_approval; +void creativeApprovalShape.rights_id; + +function assertOptionalAccountReference(account: AccountReference | undefined): void { + if (account && 'account_id' in account) { + const accountId: string = account.account_id; + void accountId; + } +} + +customToolFor('creative_approval', 'Submit creative for approval', creativeApprovalShape, async args => { + const rightsId: string = args.rights_id; + void rightsId; + // @ts-expect-error unknown creative approval fields should not type-check + void args.not_a_real_field; +}); + +customToolFor('create_media_buy', 'Create a media buy', TOOL_INPUT_SHAPES.create_media_buy, async args => { + assertOptionalAccountReference(args.account); +}); + +customToolFor('update_media_buy', 'Update a media buy', TOOL_INPUT_SHAPES.update_media_buy, async args => { + const mediaBuyId: string = args.media_buy_id; + void mediaBuyId; + assertOptionalAccountReference(args.account); + // @ts-expect-error customToolFor handler args should reject bogus update fields + void args.not_a_real_field; +}); + +customToolFor('preview_creative', 'Preview a creative', TOOL_INPUT_SHAPES.preview_creative, async args => { + const requestType: 'single' | 'batch' | 'variant' = args.request_type; + void requestType; +}); + +declare const runtimeToolName: string; +const runtimeToolShape = TOOL_INPUT_SHAPES[runtimeToolName]; +void runtimeToolShape; + +const runtimeRequestSchema = TOOL_REQUEST_SCHEMAS[runtimeToolName]; +void runtimeRequestSchema?.shape; + +// @ts-expect-error unknown tool names are not valid customToolFor shapes without narrowing +customToolFor('creative_approval', 'x', TOOL_INPUT_SHAPES.typo_tool, async args => args); + +// @ts-expect-error unknown fields should not type-check +void TOOL_INPUT_SHAPES.creative_approval.not_a_real_field; + const validatePropertyDeliveryPick = ValidatePropertyDeliveryRequestSchema.pick({ list_id: true, records: true }); void validatePropertyDeliveryPick; diff --git a/test/generate-zod-object-intersections.test.js b/test/generate-zod-object-intersections.test.js index 09f835ab6..16b5aa3b6 100644 --- a/test/generate-zod-object-intersections.test.js +++ b/test/generate-zod-object-intersections.test.js @@ -7,8 +7,8 @@ const { spawnSync } = require('node:child_process'); const REPO_ROOT = path.resolve(__dirname, '..'); -function postProcessObjectIntersections(input) { - const harnessDir = fs.mkdtempSync(path.join(os.tmpdir(), '.zod-object-intersections-')); +function runPostProcess(methodName, input, tmpPrefix) { + const harnessDir = fs.mkdtempSync(path.join(os.tmpdir(), tmpPrefix)); const scriptPath = path.join(harnessDir, 'harness.ts'); const outPath = path.join(harnessDir, 'out.txt'); const generateZodPath = path.join(REPO_ROOT, 'scripts/generate-zod-from-ts.ts'); @@ -20,7 +20,7 @@ import { writeFileSync } from 'fs'; import { __test__ } from ${JSON.stringify(generateZodPath)}; const input = ${JSON.stringify(input)}; -writeFileSync(${JSON.stringify(outPath)}, __test__.postProcessObjectIntersections(input)); +writeFileSync(${JSON.stringify(outPath)}, __test__[${JSON.stringify(methodName)}](input)); ` ); @@ -38,35 +38,16 @@ writeFileSync(${JSON.stringify(outPath)}, __test__.postProcessObjectIntersection } } -function postProcessForNullish(input) { - const harnessDir = fs.mkdtempSync(path.join(os.tmpdir(), '.zod-nullish-')); - const scriptPath = path.join(harnessDir, 'harness.ts'); - const outPath = path.join(harnessDir, 'out.txt'); - const generateZodPath = path.join(REPO_ROOT, 'scripts/generate-zod-from-ts.ts'); - - fs.writeFileSync( - scriptPath, - ` -import { writeFileSync } from 'fs'; -import { __test__ } from ${JSON.stringify(generateZodPath)}; +function postProcessObjectIntersections(input) { + return runPostProcess('postProcessObjectIntersections', input, '.zod-object-intersections-'); +} -const input = ${JSON.stringify(input)}; -writeFileSync(${JSON.stringify(outPath)}, __test__.postProcessForNullish(input)); -` - ); +function postProcessForNullish(input) { + return runPostProcess('postProcessForNullish', input, '.zod-nullish-'); +} - try { - const result = spawnSync('npx', ['tsx', scriptPath], { - cwd: REPO_ROOT, - encoding: 'utf8', - }); - if (result.status !== 0) { - throw new Error(`harness failed (${result.status}): ${result.stderr}\n${result.stdout}`); - } - return fs.readFileSync(outPath, 'utf8'); - } finally { - fs.rmSync(harnessDir, { recursive: true, force: true }); - } +function postProcessMarkerUnionObjectIntersections(input) { + return runPostProcess('postProcessMarkerUnionObjectIntersections', input, '.zod-marker-union-'); } test('postProcessForNullish keeps never optional constraints strict', () => { @@ -81,6 +62,54 @@ export const ExampleSchema = z.object({ assert.match(output, /allowed: z\.string\(\)\.nullish\(\)/); }); +test('postProcessMarkerUnionObjectIntersections collapses opaque marker unions', () => { + const output = postProcessMarkerUnionObjectIntersections(` +export const V1MarkerSchema = z.record(z.string(), z.unknown()); +export const V2MarkerSchema = z.record(z.string(), z.unknown()); + +export const ProductSchema = z.union([V1MarkerSchema, V2MarkerSchema]).and(z.object({ + product_id: z.string(), + name: z.string() +}).passthrough()); +`); + + assert.match(output, /export const ProductSchema = z\.object\(/); + assert.doesNotMatch(output, /ProductSchema = z\.union\(\[V1MarkerSchema, V2MarkerSchema\]\)\.and/); +}); + +test('postProcessMarkerUnionObjectIntersections collapses named opaque marker unions', () => { + const output = postProcessMarkerUnionObjectIntersections(` +export const FixedSchema = z.record(z.string(), z.unknown()); +export const ResponsiveSchema = z.record(z.string(), z.unknown()); +export const SizeModeMutexSchema = z.union([FixedSchema, ResponsiveSchema]); + +export const CanonicalFormatImageSchema = SizeModeMutexSchema.and(z.object({ + width: z.number().optional(), + height: z.number().optional() +}).passthrough()); +`); + + assert.match(output, /export const CanonicalFormatImageSchema = z\.object\(/); + assert.doesNotMatch(output, /CanonicalFormatImageSchema = SizeModeMutexSchema\.and/); +}); + +test('postProcessMarkerUnionObjectIntersections keeps unions once markers gain fields', () => { + const output = postProcessMarkerUnionObjectIntersections(` +export const V1MarkerSchema = z.object({ + format_id: z.string() +}).passthrough(); +export const V2MarkerSchema = z.record(z.string(), z.unknown()); + +export const FutureProductSchema = z.union([V1MarkerSchema, V2MarkerSchema]).and(z.object({ + product_id: z.string(), + name: z.string() +}).passthrough()); +`); + + assert.match(output, /export const FutureProductSchema = z\.union\(\[V1MarkerSchema, V2MarkerSchema\]\)\.and/); + assert.doesNotMatch(output, /FutureProductSchema = z\.object\(/); +}); + test('postProcessObjectIntersections merges safe object intersections', () => { const output = postProcessObjectIntersections(` export const BaseSchema = z.object({ diff --git a/test/lib/zod-schemas.test.js b/test/lib/zod-schemas.test.js index 26622a9c0..2c4eb513d 100644 --- a/test/lib/zod-schemas.test.js +++ b/test/lib/zod-schemas.test.js @@ -16,6 +16,11 @@ describe('Zod Schema Validation', () => { assert.ok(schemas.ProductSchema, 'ProductSchema should exist'); assert.ok(typeof schemas.ProductSchema.safeParse === 'function', 'ProductSchema should have safeParse method'); + assert.ok(schemas.ProductSchema.shape.product_id, 'ProductSchema should expose object shape'); + assert.equal(typeof schemas.ProductSchema.extend, 'function', 'ProductSchema should support extend'); + assert.equal(typeof schemas.ProductSchema.omit, 'function', 'ProductSchema should support omit'); + assert.equal(typeof schemas.ProductSchema.pick, 'function', 'ProductSchema should support pick'); + assert.ok(schemas.CanonicalFormatImageSchema.shape.image_formats, 'canonical formats should expose object shape'); }); test('MediaBuySchema validates valid media buy', async () => {