Skip to content

Commit 3222063

Browse files
ochafikclaude
andcommitted
refactor: declarative union member ordering and remove no-op transform
- Replace regex-based union reordering with declarative UNION_MEMBER_ORDER config - Add AST-based reorderUnionMembers transform that uses the config - Remove no-op transformPassthroughFields (handled in postProcess) - Remove unused PASSTHROUGH_FIELDS config - Update header docs to mention passthrough and union reordering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0a9bb9f commit 3222063

File tree

1 file changed

+76
-33
lines changed

1 file changed

+76
-33
lines changed

scripts/generate-schemas.ts

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
* - `jsonrpc: z.any()` → `z.literal("2.0")`
1919
* - Add `.int()` refinements to ProgressTokenSchema, RequestIdSchema
2020
* - 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)
2123
* - `z.union([z.literal("a"), ...])` → `z.enum(["a", ...])`
2224
* - Field-level validation overrides (datetime, startsWith, etc.)
2325
*
@@ -100,11 +102,18 @@ const ARRAY_DEFAULT_FIELDS: Record<string, string[]> = {
100102
};
101103

102104
/**
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.
105111
*/
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'],
108117
};
109118

110119
/**
@@ -666,7 +675,7 @@ const AST_TRANSFORMS: Transform[] = [
666675
transformTypeofExpressions,
667676
transformIntegerRefinements,
668677
transformArrayDefaults,
669-
transformPassthroughFields,
678+
reorderUnionMembers,
670679
transformUnionToEnum,
671680
applyFieldOverrides,
672681
addStrictToSchemas,
@@ -695,34 +704,13 @@ function postProcess(content: string): string {
695704
);
696705

697706
// 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
701708
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');
705711
console.log(' ✓ Added .passthrough() to ToolSchema.outputSchema');
706712
}
707713

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-
726714
// AST-based transforms using ts-morph
727715
const project = new Project({ useInMemoryFileSystem: true });
728716
const sourceFile = project.createSourceFile('schemas.ts', content);
@@ -854,11 +842,66 @@ function transformArrayDefaults(sourceFile: SourceFile): void {
854842
}
855843

856844
/**
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.
859847
*/
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+
}
862905
}
863906

864907
/**

0 commit comments

Comments
 (0)