diff --git a/.changeset/product-schema-zodobject-ergonomics.md b/.changeset/product-schema-zodobject-ergonomics.md new file mode 100644 index 000000000..dfc739d81 --- /dev/null +++ b/.changeset/product-schema-zodobject-ergonomics.md @@ -0,0 +1,7 @@ +--- +'@adcp/sdk': patch +--- + +Restore `ZodObject` ergonomics for generated schemas whose only intersection arms are opaque `Record` markers. + +`ProductSchema` and related marker-only format schemas now expose object helpers like `.extend()`, `.omit()`, `.pick()`, and `.shape` again without changing runtime validation behavior. diff --git a/scripts/generate-zod-from-ts.ts b/scripts/generate-zod-from-ts.ts index 6c39a2ab3..3cfdfb61d 100644 --- a/scripts/generate-zod-from-ts.ts +++ b/scripts/generate-zod-from-ts.ts @@ -274,7 +274,8 @@ function postProcessUndefinedUnions(content: string): string { /** * Post-process generated Zod schemas to strip .and(z.record(...)) intersections - * from object schemas that already have .passthrough(). + * and equivalent record-only union intersections from object schemas that + * already have .passthrough(). * * ts-to-zod generates these for TypeScript types with index signatures like * `{ field: string } & { [k: string]: unknown }`. Since .passthrough() already @@ -282,7 +283,9 @@ function postProcessUndefinedUnions(content: string): string { * ZodIntersection types that lose .shape access (needed by MCP SDK for tool registration). * * Also handles z.record(...).and(z.object({...})) patterns (record-first intersections) - * by extracting just the z.object() portion. + * by extracting just the z.object() portion, plus inline or named + * z.union([RecordUnknownA, RecordUnknownB]).and(z.object({...})) patterns where + * every union member is only a Record container. */ function postProcessRecordIntersections(content: string): string { let result = content; @@ -293,7 +296,13 @@ function postProcessRecordIntersections(content: string): string { // Pass 2: Replace `z.record(...).and(CONTENT)` with CONTENT (only for redundant records) result = unwrapRecordIntersections(result); - // Pass 3: Strip `.and(z.union([...]))` where content contains z.never() + // Pass 3: Replace `z.union([RecordUnknown...]).and(CONTENT)` with CONTENT. + result = unwrapRecordUnionIntersections(result); + + // Pass 4: Replace `NamedRecordUnion.and(CONTENT)` with CONTENT. + result = unwrapNamedRecordUnionIntersections(result); + + // Pass 5: Strip `.and(z.union([...]))` where content contains z.never() result = stripNeverUnionIntersections(result); return result; @@ -452,6 +461,246 @@ function unwrapRecordIntersections(content: string): string { return result; } +function readBalancedBody( + content: string, + start: number, + openChar: '[' | '(', + closeChar: ']' | ')' +): { body: string; end: number } | undefined { + let i = start; + let depth = 1; + let body = ''; + + while (i < content.length && depth > 0) { + if (content[i] === '"' || content[i] === "'") { + const quote = content[i]; + body += content[i]; + i++; + while (i < content.length && content[i] !== quote) { + if (content[i] === '\\') { + body += content[i]; + i++; + } + body += content[i]; + i++; + } + if (i < content.length) { + body += content[i]; + i++; + } + continue; + } + + if (content[i] === openChar) depth++; + else if (content[i] === closeChar) { + depth--; + if (depth === 0) { + return { body, end: i + 1 }; + } + } + + body += content[i]; + i++; + } + + return undefined; +} + +function splitTopLevelCommaList(content: string): string[] { + const parts: string[] = []; + let current = ''; + let depth = 0; + let i = 0; + + while (i < content.length) { + if (content[i] === '"' || content[i] === "'") { + const quote = content[i]; + current += content[i]; + i++; + while (i < content.length && content[i] !== quote) { + if (content[i] === '\\') { + current += content[i]; + i++; + } + current += content[i]; + i++; + } + if (i < content.length) { + current += content[i]; + i++; + } + continue; + } + + if (content[i] === '(' || content[i] === '[' || content[i] === '{') depth++; + else if (content[i] === ')' || content[i] === ']' || content[i] === '}') depth--; + else if (content[i] === ',' && depth === 0) { + parts.push(current.trim()); + current = ''; + i++; + continue; + } + + current += content[i]; + i++; + } + + const finalPart = current.trim(); + if (finalPart) parts.push(finalPart); + return parts; +} + +function collectRedundantRecordSchemaNames(content: string): Set { + const names = new Set(); + const recordSchemaPattern = + /(?:export\s+)?const\s+(\w+)\s*=\s*z\.record\(\s*z\.string\(\),\s*z\.unknown\(\)\s*\)\s*;?/g; + for (const match of content.matchAll(recordSchemaPattern)) { + names.add(match[1]!); + } + return names; +} + +function isRedundantRecordMember(member: string, recordSchemaNames: Set): boolean { + const trimmed = member.trim(); + return trimmed === 'z.record(z.string(), z.unknown())' || recordSchemaNames.has(trimmed); +} + +function collectRedundantRecordUnionSchemaNames(content: string, recordSchemaNames: Set): Set { + const names = new Set(); + const unionDeclPattern = /(?:export\s+)?const\s+(\w+)\s*=\s*z\.union\(\[/g; + let match: RegExpExecArray | null; + + while ((match = unionDeclPattern.exec(content)) !== null) { + const name = match[1]!; + const unionBodyStart = unionDeclPattern.lastIndex; + const unionBody = readBalancedBody(content, unionBodyStart, '[', ']'); + if (!unionBody || content[unionBody.end] !== ')') continue; + + const afterUnion = content.slice(unionBody.end + 1).trimStart(); + if (!afterUnion.startsWith(';')) continue; + + const members = splitTopLevelCommaList(unionBody.body); + // TODO: one-level only — arms that are themselves z.union(...) are not collected + if (members.length > 0 && members.every(member => isRedundantRecordMember(member, recordSchemaNames))) { + names.add(name); + } + } + + return names; +} + +/** + * Replace `z.union([RecordUnknownA, RecordUnknownB]).and(CONTENT)` with CONTENT, + * but only when CONTENT begins with `z.object(`. Non-object right-hand sides are + * preserved byte-for-byte so richer constraints survive. + * + * Some schema variants are intentionally opaque because TypeScript represents + * sibling-field constraints as `Record` marker arms. Intersecting + * a union of those arms with the real object shape only removes ZodObject methods; + * the later `.passthrough()` object already accepts the same unknown keys. + */ +function unwrapRecordUnionIntersections(content: string): string { + const MARKER = 'z.union(['; + const recordSchemaNames = collectRedundantRecordSchemaNames(content); + let result = ''; + let i = 0; + + while (i < content.length) { + if (content.startsWith(MARKER, i)) { + const unionStart = i; + const unionBodyStart = i + MARKER.length; + const unionBody = readBalancedBody(content, unionBodyStart, '[', ']'); + + if (!unionBody || content[unionBody.end] !== ')') { + result += content[i]; + i++; + continue; + } + + const unionEnd = unionBody.end + 1; + const members = splitTopLevelCommaList(unionBody.body); + const isRedundantUnion = + members.length > 0 && members.every(member => isRedundantRecordMember(member, recordSchemaNames)); + + if (isRedundantUnion && content.startsWith('.and(', unionEnd)) { + const andBodyStart = unionEnd + '.and('.length; + const andBody = readBalancedBody(content, andBodyStart, '(', ')'); + if (andBody && andBody.body.trimStart().startsWith('z.object(')) { + result += andBody.body; + i = andBody.end; + continue; + } + } + + result += content.substring(unionStart, unionEnd); + i = unionEnd; + } else { + result += content[i]; + i++; + } + } + + return result; +} + +/** + * Replace `NamedRecordUnion.and(z.object({...}))` with just the object side, + * but only when the right-hand side begins with `z.object(`. Non-object right-hand + * sides are preserved byte-for-byte so richer constraints survive. + * + * This is the named-schema counterpart to `unwrapRecordUnionIntersections`. + * It intentionally applies only during the marker-only era, where the named + * union is composed exclusively of `Record` marker arms. If a + * future spec version adds real fields to those variants, they will no longer + * be collected here and the generated schema will correctly remain a + * ZodIntersection until the richer constraints are modeled another way. + */ +function unwrapNamedRecordUnionIntersections(content: string): string { + const recordSchemaNames = collectRedundantRecordSchemaNames(content); + const unionSchemaNames = collectRedundantRecordUnionSchemaNames(content, recordSchemaNames); + let result = ''; + let i = 0; + + while (i < content.length) { + let matchedName: string | undefined; + for (const name of unionSchemaNames) { + if (!content.startsWith(`${name}.and(`, i)) continue; + // Left identifier boundary: don't match `FooSizeModeMutexSchema` as `SizeModeMutexSchema`. + if (i > 0 && /[A-Za-z0-9_$]/.test(content[i - 1])) continue; + matchedName = name; + break; + } + + if (!matchedName) { + result += content[i]; + i++; + continue; + } + + const andBodyStart = i + `${matchedName}.and(`.length; + const andBody = readBalancedBody(content, andBodyStart, '(', ')'); + if (!andBody) { + // A collected union name followed by `.and(` with no balanced body means + // the ts-to-zod output is malformed. Crash rather than silently corrupt. + throw new Error( + `unwrapNamedRecordUnionIntersections: unbalanced \`.and(\` at offset ${andBodyStart} for ${matchedName}` + ); + } + if (andBody.body.trimStart().startsWith('z.object(')) { + result += andBody.body; + i = andBody.end; + continue; + } + + // Right-hand side isn't a plain `z.object(...)` — preserve the original + // intersection byte-for-byte so any non-marker constraints survive. + result += content.substring(i, andBody.end); + i = andBody.end; + } + + return result; +} + /** * Strip `.and(z.union([...]))` where the union body contains z.never(). * diff --git a/src/lib/types/schemas.generated.ts b/src/lib/types/schemas.generated.ts index b1057b545..5a16bb544 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-24T13:59:59.954Z +// Generated at: 2026-05-24T14:49:43.372Z // Sources: // - core.generated.ts (core types) // - tools.generated.ts (tool types) diff --git a/src/lib/version.ts b/src/lib/version.ts index e546b2fd8..39b7cbf60 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -66,7 +66,7 @@ export const VERSION_INFO = { library: '8.1.0-beta.8', adcp: '3.1.0-beta.3', compatibleVersions: COMPATIBLE_ADCP_VERSIONS, - generatedAt: '2026-05-24T12:44:56.798Z', + generatedAt: '2026-05-24T12:39:59.880Z', } as const; /** diff --git a/src/type-tests/zod-schema-ergonomics.type-test.ts b/src/type-tests/zod-schema-ergonomics.type-test.ts new file mode 100644 index 000000000..311284983 --- /dev/null +++ b/src/type-tests/zod-schema-ergonomics.type-test.ts @@ -0,0 +1,46 @@ +// Type-level gate for generated Zod object ergonomics. +// +// The public schemas are commonly composed by adopters with ZodObject helpers. +// A redundant `Record` union intersection must not erase those +// methods from schemas whose effective runtime surface is the object shape. + +import { z } from 'zod'; +import { + CanonicalFormatDisplayTagSchema, + CanonicalFormatHTML5BannerSchema, + CanonicalFormatImageSchema, + ProductSchema, +} from '../lib/types/schemas.generated'; + +const ProductWithCacheSchema = ProductSchema.extend({ + _cached_at: z.string().datetime(), +}); +void ProductWithCacheSchema; + +const ProductWithoutDescriptionSchema = ProductSchema.omit({ + description: true, +}); +void ProductWithoutDescriptionSchema; + +const ProductIdentifierSchema = ProductSchema.pick({ + product_id: true, +}); +void ProductIdentifierSchema; + +// Pass 4 (`unwrapNamedRecordUnionIntersections`) target schemas: the +// `SizeModeMutexSchema.and(z.object(...))` form previously left these as +// `ZodIntersection`. Type-level assertion that helpers come back — a +// future codegen regression here surfaces at compile time instead of +// only via the `.d.ts` regression grep. +const DisplayTagExtended = CanonicalFormatDisplayTagSchema.extend({ + _adopter_marker: z.string(), +}); +void DisplayTagExtended; + +const ImagePicked = CanonicalFormatImageSchema.pick({ experimental: true }); +void ImagePicked; + +const HTML5BannerOmitted = CanonicalFormatHTML5BannerSchema.omit({ + deprecated: true, +}); +void HTML5BannerOmitted; diff --git a/test/lib/zod-schemas.test.js b/test/lib/zod-schemas.test.js index 2c4eb513d..5ce778751 100644 --- a/test/lib/zod-schemas.test.js +++ b/test/lib/zod-schemas.test.js @@ -1,5 +1,8 @@ const { test, describe } = require('node:test'); const assert = require('node:assert'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { z } = require('zod'); describe('Zod Schema Validation', () => { let schemas; @@ -23,6 +26,49 @@ describe('Zod Schema Validation', () => { assert.ok(schemas.CanonicalFormatImageSchema.shape.image_formats, 'canonical formats should expose object shape'); }); + test('ProductSchema exposes ZodObject composition helpers', async () => { + if (!schemas) { + schemas = await import('../../dist/lib/types/schemas.generated.js'); + } + + assert.strictEqual(typeof schemas.ProductSchema.extend, 'function', 'ProductSchema should expose .extend()'); + assert.strictEqual(typeof schemas.ProductSchema.omit, 'function', 'ProductSchema should expose .omit()'); + assert.strictEqual(typeof schemas.ProductSchema.pick, 'function', 'ProductSchema should expose .pick()'); + + const extended = schemas.ProductSchema.extend({ _cached_at: z.string().datetime() }); + const omitted = schemas.ProductSchema.omit({ description: true }); + const picked = schemas.ProductSchema.pick({ product_id: true }); + assert.ok(extended.shape._cached_at, 'extended ProductSchema should include the extension field'); + assert.ok(!('description' in omitted.shape), 'omitted ProductSchema should remove the omitted field'); + assert.ok( + picked.safeParse({ product_id: 'prod_123' }).success, + 'picked ProductSchema should validate picked shape' + ); + }); + + test('generated declarations do not expose record-union object intersections', async () => { + if (!schemas) { + schemas = await import('../../dist/lib/types/schemas.generated.js'); + } + + // Runtime check so the test fails closed even if Zod adjusts its tuple stringification. + // Zod 4 dropped `_def.typeName`; constructor name is the stable cross-version probe. + assert.strictEqual( + schemas.ProductSchema.constructor.name, + 'ZodObject', + 'ProductSchema should be a ZodObject, not a ZodIntersection' + ); + + const declarations = readFileSync(path.join(__dirname, '../../dist/lib/types/schemas.generated.d.ts'), 'utf8'); + const matches = declarations.match(/z\.ZodIntersection { if (!schemas) { schemas = await import('../../dist/lib/types/schemas.generated.js');