|
18 | 18 | * - `jsonrpc: z.any()` → `z.literal("2.0")` |
19 | 19 | * - Add `.int()` refinements to ProgressTokenSchema, RequestIdSchema |
20 | 20 | * - Add `.default([])` to content arrays for backwards compatibility |
| 21 | + * - Add `.passthrough()` to ToolSchema.outputSchema for JSON Schema extensibility |
| 22 | + * - Reorder unions so specific schemas match before general ones (e.g., EnumSchema before StringSchema) |
21 | 23 | * - `z.union([z.literal("a"), ...])` → `z.enum(["a", ...])` |
22 | 24 | * - Field-level validation overrides (datetime, startsWith, etc.) |
23 | 25 | * |
@@ -100,11 +102,18 @@ const ARRAY_DEFAULT_FIELDS: Record<string, string[]> = { |
100 | 102 | }; |
101 | 103 |
|
102 | 104 | /** |
103 | | - * Fields that need .passthrough() to preserve unknown JSON Schema properties. |
104 | | - * These are JSON Schema objects where extra properties like additionalProperties must be kept. |
| 105 | + * Union member ordering: ensure specific schemas match before general ones. |
| 106 | + * More specific schemas (with more required fields) must come first in unions, |
| 107 | + * otherwise Zod will match a simpler schema and strip extra fields. |
| 108 | + * |
| 109 | + * Example: { type: 'string', enum: [...], enumNames: [...] } should match |
| 110 | + * LegacyTitledEnumSchema (which has enumNames) before UntitledSingleSelectEnumSchema. |
105 | 111 | */ |
106 | | -const PASSTHROUGH_FIELDS: Record<string, string[]> = { |
107 | | - 'ToolSchema': ['outputSchema'], |
| 112 | +const UNION_MEMBER_ORDER: Record<string, string[]> = { |
| 113 | + // EnumSchema must come before StringSchema (both have type: 'string') |
| 114 | + 'PrimitiveSchemaDefinitionSchema': ['EnumSchemaSchema', 'BooleanSchemaSchema', 'StringSchemaSchema', 'NumberSchemaSchema'], |
| 115 | + // LegacyTitledEnumSchema must come first (has enumNames field) |
| 116 | + 'EnumSchemaSchema': ['LegacyTitledEnumSchemaSchema', 'SingleSelectEnumSchemaSchema', 'MultiSelectEnumSchemaSchema'], |
108 | 117 | }; |
109 | 118 |
|
110 | 119 | /** |
@@ -666,7 +675,7 @@ const AST_TRANSFORMS: Transform[] = [ |
666 | 675 | transformTypeofExpressions, |
667 | 676 | transformIntegerRefinements, |
668 | 677 | transformArrayDefaults, |
669 | | - transformPassthroughFields, |
| 678 | + reorderUnionMembers, |
670 | 679 | transformUnionToEnum, |
671 | 680 | applyFieldOverrides, |
672 | 681 | addStrictToSchemas, |
@@ -695,34 +704,13 @@ function postProcess(content: string): string { |
695 | 704 | ); |
696 | 705 |
|
697 | 706 | // Add .passthrough() to outputSchema to preserve JSON Schema properties like additionalProperties |
698 | | - // Pattern matches: outputSchema: z.object({...}).optional() |
699 | | - // We need to insert .passthrough() before .optional() |
700 | | - // Note: ts-to-zod generates inline, so pattern is: outputSchema: z.object({...}).optional() |
| 707 | + // This allows extra fields like 'additionalProperties' to be preserved when parsing tool schemas |
701 | 708 | const outputSchemaPattern = /(outputSchema:\s*z\.object\(\{[\s\S]*?\}\))(\.optional\(\))/g; |
702 | | - const newContent = content.replace(outputSchemaPattern, '$1.passthrough()$2'); |
703 | | - if (newContent !== content) { |
704 | | - content = newContent; |
| 709 | + if (outputSchemaPattern.test(content)) { |
| 710 | + content = content.replace(outputSchemaPattern, '$1.passthrough()$2'); |
705 | 711 | console.log(' ✓ Added .passthrough() to ToolSchema.outputSchema'); |
706 | 712 | } |
707 | 713 |
|
708 | | - // Reorder PrimitiveSchemaDefinitionSchema union: EnumSchemaSchema must come FIRST |
709 | | - // Otherwise, { type: 'string', enum: [...] } matches StringSchemaSchema and loses the enum field |
710 | | - const primitiveUnionPattern = /PrimitiveSchemaDefinitionSchema\s*=\s*z\s*\n?\s*\.union\(\[StringSchemaSchema,\s*NumberSchemaSchema,\s*BooleanSchemaSchema,\s*EnumSchemaSchema\]\)/; |
711 | | - const reorderedUnion = 'PrimitiveSchemaDefinitionSchema = z\n .union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema])'; |
712 | | - if (primitiveUnionPattern.test(content)) { |
713 | | - content = content.replace(primitiveUnionPattern, reorderedUnion); |
714 | | - console.log(' ✓ Reordered PrimitiveSchemaDefinitionSchema union (EnumSchemaSchema first)'); |
715 | | - } |
716 | | - |
717 | | - // Reorder EnumSchemaSchema union: LegacyTitledEnumSchemaSchema must come FIRST |
718 | | - // Otherwise, { type: 'string', enum: [...], enumNames: [...] } matches UntitledSingleSelectEnumSchema and loses enumNames |
719 | | - const enumUnionPattern = /EnumSchemaSchema\s*=\s*z\.union\(\[SingleSelectEnumSchemaSchema,\s*MultiSelectEnumSchemaSchema,\s*LegacyTitledEnumSchemaSchema\]\)/; |
720 | | - const reorderedEnumUnion = 'EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema])'; |
721 | | - if (enumUnionPattern.test(content)) { |
722 | | - content = content.replace(enumUnionPattern, reorderedEnumUnion); |
723 | | - console.log(' ✓ Reordered EnumSchemaSchema union (LegacyTitledEnumSchemaSchema first)'); |
724 | | - } |
725 | | - |
726 | 714 | // AST-based transforms using ts-morph |
727 | 715 | const project = new Project({ useInMemoryFileSystem: true }); |
728 | 716 | const sourceFile = project.createSourceFile('schemas.ts', content); |
@@ -854,11 +842,66 @@ function transformArrayDefaults(sourceFile: SourceFile): void { |
854 | 842 | } |
855 | 843 |
|
856 | 844 | /** |
857 | | - * Add .passthrough() to fields that need to preserve unknown JSON Schema properties. |
858 | | - * This is done via text replacement in postProcess since the structure is complex. |
| 845 | + * Reorder union members according to UNION_MEMBER_ORDER configuration. |
| 846 | + * This ensures more specific schemas are matched before general ones. |
859 | 847 | */ |
860 | | -function transformPassthroughFields(_sourceFile: SourceFile): void { |
861 | | - // This is handled in postProcess via text replacement for simplicity |
| 848 | +function reorderUnionMembers(sourceFile: SourceFile): void { |
| 849 | + for (const [schemaName, desiredOrder] of Object.entries(UNION_MEMBER_ORDER)) { |
| 850 | + const varDecl = sourceFile.getVariableDeclaration(schemaName); |
| 851 | + if (!varDecl) continue; |
| 852 | + |
| 853 | + const initializer = varDecl.getInitializer(); |
| 854 | + if (!initializer) continue; |
| 855 | + |
| 856 | + // Find the z.union([...]) call |
| 857 | + let unionCall: CallExpression | undefined; |
| 858 | + initializer.forEachDescendant((node) => { |
| 859 | + if (!Node.isCallExpression(node)) return; |
| 860 | + const expr = node.getExpression(); |
| 861 | + if (!Node.isPropertyAccessExpression(expr)) return; |
| 862 | + if (expr.getName() === 'union') { |
| 863 | + unionCall = node; |
| 864 | + } |
| 865 | + }); |
| 866 | + |
| 867 | + if (!unionCall) { |
| 868 | + // Handle case where it's directly z.union(...) at top level |
| 869 | + if (Node.isCallExpression(initializer)) { |
| 870 | + const expr = initializer.getExpression(); |
| 871 | + if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'union') { |
| 872 | + unionCall = initializer; |
| 873 | + } |
| 874 | + } |
| 875 | + } |
| 876 | + |
| 877 | + if (!unionCall) continue; |
| 878 | + |
| 879 | + const args = unionCall.getArguments(); |
| 880 | + if (args.length !== 1) continue; |
| 881 | + |
| 882 | + const arrayArg = args[0]; |
| 883 | + if (!Node.isArrayLiteralExpression(arrayArg)) continue; |
| 884 | + |
| 885 | + // Get current member names |
| 886 | + const elements = arrayArg.getElements(); |
| 887 | + const currentMembers = elements.map(e => e.getText().trim()); |
| 888 | + |
| 889 | + // Check if reordering is needed |
| 890 | + const orderedMembers = [...currentMembers].sort((a, b) => { |
| 891 | + const aIdx = desiredOrder.indexOf(a); |
| 892 | + const bIdx = desiredOrder.indexOf(b); |
| 893 | + // If not in desiredOrder, keep at end |
| 894 | + if (aIdx === -1 && bIdx === -1) return 0; |
| 895 | + if (aIdx === -1) return 1; |
| 896 | + if (bIdx === -1) return -1; |
| 897 | + return aIdx - bIdx; |
| 898 | + }); |
| 899 | + |
| 900 | + if (JSON.stringify(currentMembers) !== JSON.stringify(orderedMembers)) { |
| 901 | + arrayArg.replaceWithText('[' + orderedMembers.join(', ') + ']'); |
| 902 | + console.log(` ✓ Reordered ${schemaName} union members`); |
| 903 | + } |
| 904 | + } |
862 | 905 | } |
863 | 906 |
|
864 | 907 | /** |
|
0 commit comments