swallowkit 2.0.0-alpha.3 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/cli/commands/add-auth.js +37 -6
  2. package/dist/cli/commands/add-auth.js.map +1 -1
  3. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  4. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  5. package/dist/cli/commands/configure-mcp.js +42 -0
  6. package/dist/cli/commands/configure-mcp.js.map +1 -0
  7. package/dist/cli/commands/init.d.ts +6 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +19 -5
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/cli/commands/migrations.d.ts.map +1 -1
  12. package/dist/cli/commands/migrations.js +25 -0
  13. package/dist/cli/commands/migrations.js.map +1 -1
  14. package/dist/cli/commands/provision.d.ts +2 -0
  15. package/dist/cli/commands/provision.d.ts.map +1 -1
  16. package/dist/cli/commands/provision.js +45 -16
  17. package/dist/cli/commands/provision.js.map +1 -1
  18. package/dist/cli/commands/scaffold.d.ts +4 -0
  19. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  20. package/dist/cli/commands/scaffold.js +85 -3
  21. package/dist/cli/commands/scaffold.js.map +1 -1
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +29 -4
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/core/operations/create-model.d.ts +2 -0
  27. package/dist/core/operations/create-model.d.ts.map +1 -1
  28. package/dist/core/operations/create-model.js +60 -8
  29. package/dist/core/operations/create-model.js.map +1 -1
  30. package/dist/core/operations/model-migration.d.ts +22 -0
  31. package/dist/core/operations/model-migration.d.ts.map +1 -0
  32. package/dist/core/operations/model-migration.js +36 -0
  33. package/dist/core/operations/model-migration.js.map +1 -0
  34. package/dist/core/operations/provision-operations.d.ts +19 -1
  35. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  36. package/dist/core/operations/provision-operations.js +62 -35
  37. package/dist/core/operations/provision-operations.js.map +1 -1
  38. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  39. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  40. package/dist/core/operations/scaffold-apply.js +1 -0
  41. package/dist/core/operations/scaffold-apply.js.map +1 -1
  42. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  43. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  44. package/dist/core/operations/scaffold-machine.js.map +1 -1
  45. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  46. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  47. package/dist/core/operations/scaffold-plan.js +3 -1
  48. package/dist/core/operations/scaffold-plan.js.map +1 -1
  49. package/dist/core/project/boundaries.d.ts +5 -2
  50. package/dist/core/project/boundaries.d.ts.map +1 -1
  51. package/dist/core/project/boundaries.js +51 -20
  52. package/dist/core/project/boundaries.js.map +1 -1
  53. package/dist/core/project/capabilities.d.ts +2 -0
  54. package/dist/core/project/capabilities.d.ts.map +1 -1
  55. package/dist/core/project/capabilities.js +29 -5
  56. package/dist/core/project/capabilities.js.map +1 -1
  57. package/dist/core/project/mcp-config.d.ts +26 -0
  58. package/dist/core/project/mcp-config.d.ts.map +1 -0
  59. package/dist/core/project/mcp-config.js +117 -0
  60. package/dist/core/project/mcp-config.js.map +1 -0
  61. package/dist/core/project/react-project.js +1 -1
  62. package/dist/core/project/workflows.d.ts.map +1 -1
  63. package/dist/core/project/workflows.js +23 -5
  64. package/dist/core/project/workflows.js.map +1 -1
  65. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  66. package/dist/core/scaffold/auth-generator.js +18 -19
  67. package/dist/core/scaffold/auth-generator.js.map +1 -1
  68. package/dist/core/scaffold/model-parser.d.ts +23 -0
  69. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  70. package/dist/core/scaffold/model-parser.js +407 -137
  71. package/dist/core/scaffold/model-parser.js.map +1 -1
  72. package/dist/core/scaffold/native-schema-generator.d.ts +13 -0
  73. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  74. package/dist/core/scaffold/native-schema-generator.js +289 -76
  75. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  76. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  77. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  78. package/dist/core/scaffold/openapi-generator.js +53 -3
  79. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  80. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  81. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  82. package/dist/core/scaffold/ui-generator.js +68 -7
  83. package/dist/core/scaffold/ui-generator.js.map +1 -1
  84. package/dist/core/verify/index.d.ts +4 -3
  85. package/dist/core/verify/index.d.ts.map +1 -1
  86. package/dist/core/verify/index.js +122 -5
  87. package/dist/core/verify/index.js.map +1 -1
  88. package/dist/machine/contracts.d.ts +1 -0
  89. package/dist/machine/contracts.d.ts.map +1 -1
  90. package/dist/machine/errors.d.ts +3 -2
  91. package/dist/machine/errors.d.ts.map +1 -1
  92. package/dist/machine/errors.js +2 -1
  93. package/dist/machine/errors.js.map +1 -1
  94. package/dist/machine/index.d.ts.map +1 -1
  95. package/dist/machine/index.js +42 -17
  96. package/dist/machine/index.js.map +1 -1
  97. package/dist/mcp/index.d.ts +4 -2
  98. package/dist/mcp/index.d.ts.map +1 -1
  99. package/dist/mcp/index.js +154 -15
  100. package/dist/mcp/index.js.map +1 -1
  101. package/dist/mcp/version-selection.d.ts +55 -0
  102. package/dist/mcp/version-selection.d.ts.map +1 -0
  103. package/dist/mcp/version-selection.js +152 -0
  104. package/dist/mcp/version-selection.js.map +1 -0
  105. package/dist/utils/azure-cli.d.ts +44 -0
  106. package/dist/utils/azure-cli.d.ts.map +1 -0
  107. package/dist/utils/azure-cli.js +164 -0
  108. package/dist/utils/azure-cli.js.map +1 -0
  109. package/dist/utils/semver.d.ts +14 -0
  110. package/dist/utils/semver.d.ts.map +1 -0
  111. package/dist/utils/semver.js +61 -0
  112. package/dist/utils/semver.js.map +1 -0
  113. package/package.json +6 -3
  114. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  115. package/src/__tests__/agent-loop-phase34.test.ts +124 -0
  116. package/src/__tests__/azure-cli.test.ts +199 -0
  117. package/src/__tests__/boundaries.test.ts +42 -0
  118. package/src/__tests__/csharp-schema.test.ts +261 -0
  119. package/src/__tests__/generated-project-matrix.test.ts +789 -0
  120. package/src/__tests__/mcp-version.test.ts +262 -0
  121. package/src/__tests__/mcp.test.ts +64 -3
  122. package/src/__tests__/multi-auth.test.ts +115 -4
  123. package/src/__tests__/plan-apply.test.ts +170 -0
  124. package/src/__tests__/schema-analysis.test.ts +329 -0
  125. package/src/cli/commands/add-auth.ts +36 -6
  126. package/src/cli/commands/configure-mcp.ts +54 -0
  127. package/src/cli/commands/init.ts +23 -8
  128. package/src/cli/commands/migrations.ts +22 -0
  129. package/src/cli/commands/provision.ts +53 -17
  130. package/src/cli/commands/scaffold.ts +105 -3
  131. package/src/cli/index.ts +28 -5
  132. package/src/core/operations/create-model.ts +51 -13
  133. package/src/core/operations/model-migration.ts +52 -0
  134. package/src/core/operations/provision-operations.ts +86 -42
  135. package/src/core/operations/scaffold-apply.ts +2 -0
  136. package/src/core/operations/scaffold-machine.ts +1 -0
  137. package/src/core/operations/scaffold-plan.ts +5 -1
  138. package/src/core/project/agent-templates/AGENTS.md +67 -6
  139. package/src/core/project/agent-templates/CLAUDE.md +7 -1
  140. package/src/core/project/agent-templates/copilot-instructions.md +7 -1
  141. package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
  142. package/src/core/project/boundaries.ts +62 -20
  143. package/src/core/project/capabilities.ts +32 -5
  144. package/src/core/project/mcp-config.ts +84 -0
  145. package/src/core/project/react-project.ts +1 -1
  146. package/src/core/project/workflows.ts +23 -5
  147. package/src/core/scaffold/auth-generator.ts +18 -19
  148. package/src/core/scaffold/model-parser.ts +413 -129
  149. package/src/core/scaffold/native-schema-generator.ts +313 -78
  150. package/src/core/scaffold/openapi-generator.ts +63 -5
  151. package/src/core/scaffold/ui-generator.ts +70 -8
  152. package/src/core/verify/index.ts +118 -6
  153. package/src/machine/contracts.ts +1 -0
  154. package/src/machine/errors.ts +4 -2
  155. package/src/machine/index.ts +45 -20
  156. package/src/mcp/index.ts +173 -18
  157. package/src/mcp/version-selection.ts +163 -0
  158. package/src/utils/azure-cli.ts +154 -0
  159. package/src/utils/semver.ts +59 -0
@@ -5,7 +5,7 @@ import { spawn, spawnSync } from "child_process";
5
5
  import { BackendLanguage } from "../../types";
6
6
  import { FileOperationSession } from "../operations/file-session";
7
7
  import { ModelInfo, toKebabCase } from "./model-parser";
8
- import { generateOpenApiDocument } from "./openapi-generator";
8
+ import { expandInlineObjectModels, generateOpenApiDocument } from "./openapi-generator";
9
9
  import {
10
10
  buildProjectLocalUvInstallerEnv,
11
11
  buildUvPipInstallArgs,
@@ -154,8 +154,22 @@ function csharpStringLiteral(value: string): string {
154
154
  return JSON.stringify(value);
155
155
  }
156
156
 
157
- function isDateLikeField(field: Pick<ModelInfo["fields"][number], "name" | "type">): boolean {
158
- return field.type === "date" || (field.type === "string" && field.name.toLowerCase().endsWith("at"));
157
+ /** Date/time types are used only when the Zod schema declares them (z.iso.datetime(), z.iso.date(), z.date()). */
158
+ function getCSharpScalarType(field: ModelInfo["fields"][number]): string {
159
+ switch (field.type) {
160
+ case "string":
161
+ return field.format === "date-time" ? "DateTime" : field.format === "date" ? "DateOnly" : "string";
162
+ case "number":
163
+ return field.isInteger ? "long" : "double";
164
+ case "boolean":
165
+ return "bool";
166
+ case "date":
167
+ return "DateTime";
168
+ case "object":
169
+ return "Dictionary<string, object>";
170
+ default:
171
+ return "object";
172
+ }
159
173
  }
160
174
 
161
175
  export function getCSharpSchemaModelPath(outputDir: string, modelName: string): string {
@@ -211,6 +225,11 @@ export function getPythonNativeGeneratorArgs(specPath: string, outputPath: strin
211
225
  }
212
226
 
213
227
  function getCSharpFieldBaseType(field: ModelInfo["fields"][number]): string {
228
+ // Arrays first: arrays of nested models or enums are lists of the element type.
229
+ if (field.isArray) {
230
+ return `List<${getCSharpArrayElementType(field)}>`;
231
+ }
232
+
214
233
  if (field.isNestedSchema && field.nestedModelName) {
215
234
  return field.nestedModelName;
216
235
  }
@@ -219,24 +238,7 @@ function getCSharpFieldBaseType(field: ModelInfo["fields"][number]): string {
219
238
  return `${toPascalIdentifier(field.name)}Enum`;
220
239
  }
221
240
 
222
- if (field.isArray) {
223
- return `List<${getCSharpArrayElementType(field)}>`;
224
- }
225
-
226
- switch (field.type) {
227
- case "string":
228
- return isDateLikeField(field) ? "DateTime" : "string";
229
- case "number":
230
- return "decimal";
231
- case "boolean":
232
- return "bool";
233
- case "date":
234
- return "DateTime";
235
- case "object":
236
- return "Dictionary<string, object>";
237
- default:
238
- return "object";
239
- }
241
+ return getCSharpScalarType(field);
240
242
  }
241
243
 
242
244
  function getCSharpArrayElementType(field: ModelInfo["fields"][number]): string {
@@ -248,25 +250,12 @@ function getCSharpArrayElementType(field: ModelInfo["fields"][number]): string {
248
250
  return `${toPascalIdentifier(field.name)}Enum`;
249
251
  }
250
252
 
251
- switch (field.type) {
252
- case "string":
253
- return isDateLikeField(field) ? "DateTime" : "string";
254
- case "number":
255
- return "decimal";
256
- case "boolean":
257
- return "bool";
258
- case "date":
259
- return "DateTime";
260
- case "object":
261
- return "Dictionary<string, object>";
262
- default:
263
- return "object";
264
- }
253
+ return getCSharpScalarType(field);
265
254
  }
266
255
 
267
256
  function getCSharpPropertyType(field: ModelInfo["fields"][number]): string {
268
257
  const baseType = getCSharpFieldBaseType(field);
269
- return field.isOptional ? `${baseType}?` : baseType;
258
+ return field.isOptional || field.isNullable ? `${baseType}?` : baseType;
270
259
  }
271
260
 
272
261
  function getCSharpOptionType(field: ModelInfo["fields"][number]): string {
@@ -333,12 +322,120 @@ function buildCSharpEnumToJsonCases(field: ModelInfo["fields"][number], members:
333
322
  .join("\n\n");
334
323
  }
335
324
 
325
+ function csharpNumberLiteral(value: number): string {
326
+ return Number.isInteger(value) ? `${value}` : `${value}d`;
327
+ }
328
+
329
+ function csharpRegexOptions(flags: string | undefined): string {
330
+ const options = [
331
+ flags?.includes("i") ? "RegexOptions.IgnoreCase" : null,
332
+ flags?.includes("m") ? "RegexOptions.Multiline" : null,
333
+ flags?.includes("s") ? "RegexOptions.Singleline" : null,
334
+ ].filter(Boolean);
335
+ return options.length > 0 ? options.join(" | ") : "RegexOptions.None";
336
+ }
337
+
338
+ function isCSharpReferenceType(field: ModelInfo["fields"][number]): boolean {
339
+ if (field.isArray || field.isNestedSchema || field.type === "object") return true;
340
+ if (field.enumValues?.length) return false;
341
+ return field.type === "string" && field.format !== "date-time" && field.format !== "date";
342
+ }
343
+
344
+ /**
345
+ * Translate Zod constraints into IValidatableObject checks. Regexes use search semantics like
346
+ * Zod's RegExp.test. String formats (email/uuid/url) and value-type presence stay with Zod.
347
+ */
348
+ function buildCSharpValueChecks(
349
+ field: ModelInfo["fields"][number],
350
+ accessor: string,
351
+ member: string,
352
+ optionalValue: boolean
353
+ ): string[] {
354
+ const constraints = field.constraints ?? {};
355
+ const checks: string[] = [];
356
+ const add = (condition: string, message: string) =>
357
+ checks.push(`if (${condition})\n yield return new ValidationResult(${csharpStringLiteral(message)}, new[] { ${member} });`);
358
+
359
+ if (field.isNestedSchema && field.nestedModelName) {
360
+ // Pattern variables in an if condition stay in scope for the enclosing block, so keep names unique.
361
+ const variable = `validated${toPascalIdentifier(field.name)}${accessor === "item" ? "Item" : ""}`;
362
+ checks.push(`if (${accessor} is IValidatableObject ${variable})\n foreach (var ${variable}Result in ${variable}.Validate(new ValidationContext(${accessor})))\n yield return new ValidationResult(${variable}Result.ErrorMessage, ${variable}Result.MemberNames.Select(name => ${member} + "." + name).ToArray());`);
363
+ return checks;
364
+ }
365
+ if (field.enumValues?.length) return checks;
366
+
367
+ if (field.type === "string" && field.format !== "date-time" && field.format !== "date") {
368
+ if (constraints.minLength !== undefined) add(`${accessor} != null && ${accessor}.Length < ${constraints.minLength}`, `${field.name} must contain at least ${constraints.minLength} character(s).`);
369
+ if (constraints.maxLength !== undefined) add(`${accessor} != null && ${accessor}.Length > ${constraints.maxLength}`, `${field.name} must contain at most ${constraints.maxLength} character(s).`);
370
+ if (constraints.pattern !== undefined) {
371
+ add(
372
+ `${accessor} != null && !Regex.IsMatch(${accessor}, ${csharpStringLiteral(constraints.pattern)}, ${csharpRegexOptions(constraints.patternFlags)})`,
373
+ `${field.name} must match /${constraints.pattern}/${constraints.patternFlags ?? ""}.`
374
+ );
375
+ }
376
+ }
377
+
378
+ if (field.type === "number") {
379
+ const value = optionalValue ? `${accessor}.Value` : accessor;
380
+ const guard = optionalValue ? `${accessor}.HasValue && ` : "";
381
+ if (constraints.minimum !== undefined) {
382
+ add(`${guard}${value} ${constraints.exclusiveMinimum ? "<=" : "<"} ${csharpNumberLiteral(constraints.minimum)}`,
383
+ `${field.name} must be ${constraints.exclusiveMinimum ? "greater than" : "greater than or equal to"} ${constraints.minimum}.`);
384
+ }
385
+ if (constraints.maximum !== undefined) {
386
+ add(`${guard}${value} ${constraints.exclusiveMaximum ? ">=" : ">"} ${csharpNumberLiteral(constraints.maximum)}`,
387
+ `${field.name} must be ${constraints.exclusiveMaximum ? "less than" : "less than or equal to"} ${constraints.maximum}.`);
388
+ }
389
+ }
390
+ return checks;
391
+ }
392
+
393
+ function buildCSharpValidationBody(model: ModelInfo): string {
394
+ const blocks: string[] = [];
395
+ for (const field of model.fields) {
396
+ const propertyName = toPascalIdentifier(field.name);
397
+ const member = csharpStringLiteral(field.name);
398
+ const constraints = field.constraints ?? {};
399
+
400
+ // .nullable() fields accept null in Zod, so only non-nullable required references are rejected.
401
+ if (!field.isOptional && !field.isNullable && isCSharpReferenceType(field)) {
402
+ blocks.push(`if (${propertyName} is null)\n yield return new ValidationResult(${csharpStringLiteral(`${field.name} is required.`)}, new[] { ${member} });`);
403
+ }
404
+
405
+ if (field.isArray) {
406
+ if (constraints.minItems !== undefined) {
407
+ blocks.push(`if (${propertyName} != null && ${propertyName}.Count < ${constraints.minItems})\n yield return new ValidationResult(${csharpStringLiteral(`${field.name} must contain at least ${constraints.minItems} item(s).`)}, new[] { ${member} });`);
408
+ }
409
+ if (constraints.maxItems !== undefined) {
410
+ blocks.push(`if (${propertyName} != null && ${propertyName}.Count > ${constraints.maxItems})\n yield return new ValidationResult(${csharpStringLiteral(`${field.name} must contain at most ${constraints.maxItems} item(s).`)}, new[] { ${member} });`);
411
+ }
412
+ const itemChecks = buildCSharpValueChecks(
413
+ { ...field, isArray: false },
414
+ "item",
415
+ `${member} + "[" + index + "]"`,
416
+ false
417
+ );
418
+ if (itemChecks.length > 0) {
419
+ blocks.push(`if (${propertyName} != null)\n {\n for (var index = 0; index < ${propertyName}.Count; index++)\n {\n var item = ${propertyName}[index];\n${itemChecks.map((check) => ` ${check.replace(/\n {16}/g, "\n ").replace(/\n {20}/g, "\n ")}`).join("\n")}\n }\n }`);
420
+ }
421
+ continue;
422
+ }
423
+
424
+ const optionalValue = (field.isOptional || field.isNullable === true) && !isCSharpReferenceType(field);
425
+ blocks.push(...buildCSharpValueChecks(field, propertyName, member, optionalValue));
426
+ }
427
+
428
+ return blocks.length > 0
429
+ ? blocks.map((block) => ` ${block}`).join("\n\n")
430
+ : " yield break;";
431
+ }
432
+
336
433
  function generateLegacyCompatibleCSharpModelSource(model: ModelInfo): string {
337
434
  const requiredFields = model.fields.filter((field) => !field.isOptional);
338
435
  const optionalFields = model.fields.filter((field) => field.isOptional);
339
436
 
340
437
  const constructorParams = [
341
- ...requiredFields.map((field) => `${getCSharpFieldBaseType(field)} ${field.name}`),
438
+ ...requiredFields.map((field) => `${getCSharpPropertyType(field)} ${field.name}`),
342
439
  ...optionalFields.map((field) => `${getCSharpOptionType(field)} ${field.name} = default`),
343
440
  ].join(", ");
344
441
 
@@ -356,7 +453,7 @@ function generateLegacyCompatibleCSharpModelSource(model: ModelInfo): string {
356
453
  return ` /// <summary>
357
454
  /// Defines ${toPascalIdentifier(field.name)}
358
455
  /// </summary>
359
- [JsonConverter(typeof(JsonStringEnumConverter))]
456
+ [JsonConverter(typeof(${enumType}JsonConverter))]
360
457
  public enum ${enumType}
361
458
  {
362
459
  ${buildCSharpEnumMembers(enumMembers)}
@@ -377,6 +474,22 @@ ${buildCSharpEnumFromStringCases(field, enumMembers, true)}
377
474
  ${buildCSharpEnumToJsonCases(field, enumMembers)}
378
475
 
379
476
  throw new NotImplementedException($"Value could not be handled: '{value}'");
477
+ }
478
+
479
+ /// <summary>
480
+ /// Reads and writes ${enumType} with the exact Zod enum values.
481
+ /// </summary>
482
+ public sealed class ${enumType}JsonConverter : JsonConverter<${enumType}>
483
+ {
484
+ public override ${enumType} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
485
+ {
486
+ var value = reader.GetString();
487
+ var parsed = value == null ? null : ${enumType}FromStringOrDefault(value);
488
+ return parsed ?? throw new JsonException($"Unsupported ${enumType} value: '{value}'");
489
+ }
490
+
491
+ public override void Write(Utf8JsonWriter writer, ${enumType} value, JsonSerializerOptions options)
492
+ => writer.WriteStringValue(${enumType}ToJsonValue(value));
380
493
  }`;
381
494
  })
382
495
  .join("\n\n");
@@ -404,7 +517,8 @@ ${buildCSharpEnumToJsonCases(field, enumMembers)}
404
517
  /// <summary>
405
518
  /// Gets or Sets ${propertyName}
406
519
  /// </summary>
407
- [JsonPropertyName("${field.name}")]
520
+ [JsonPropertyName("${field.name}")]${field.isNullable ? "" : `
521
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`}
408
522
  public ${propertyType} ${propertyName} { get { return this.${propertyName}Option; } set { this.${propertyName}Option = new(value); } }`;
409
523
  })
410
524
  .join("\n\n");
@@ -428,8 +542,11 @@ ${buildCSharpEnumToJsonCases(field, enumMembers)}
428
542
  using System;
429
543
  using System.Collections.Generic;
430
544
  using System.ComponentModel.DataAnnotations;
545
+ using System.Linq;
431
546
  using System.Text;
547
+ using System.Text.Json;
432
548
  using System.Text.Json.Serialization;
549
+ using System.Text.RegularExpressions;
433
550
  using SwallowKitBackendModels.Client;
434
551
 
435
552
  namespace SwallowKitBackendModels.Model
@@ -439,12 +556,22 @@ namespace SwallowKitBackendModels.Model
439
556
  /// </summary>
440
557
  public partial class ${model.name} : IValidatableObject
441
558
  {
559
+ /// <summary>
560
+ /// Used by System.Text.Json. Optional values are marked as set only when present in the JSON.
561
+ /// </summary>
562
+ #pragma warning disable CS8618
442
563
  [JsonConstructor]
564
+ public ${model.name}()
565
+ {
566
+ OnCreated();
567
+ }
568
+ #pragma warning restore CS8618
569
+ ${constructorParams ? `
443
570
  public ${model.name}(${constructorParams})
444
571
  {
445
572
  ${constructorAssignments}
446
573
  }
447
-
574
+ ` : ""}
448
575
  partial void OnCreated();
449
576
  ${enumBlocks ? `\n\n${enumBlocks}` : ""}
450
577
 
@@ -461,27 +588,19 @@ ${toStringBody}
461
588
 
462
589
  IEnumerable<ValidationResult> IValidatableObject.Validate(ValidationContext validationContext)
463
590
  {
464
- yield break;
591
+ ${buildCSharpValidationBody(model)}
465
592
  }
466
593
  }
467
594
  }
468
595
  `;
469
596
  }
470
597
 
471
- function getPythonTypeName(field: ModelInfo["fields"][number]): string {
472
- if (field.isNestedSchema && field.nestedModelName) {
473
- return field.nestedModelName;
474
- }
475
-
476
- if (field.isArray) {
477
- return `List[${getPythonArrayElementType(field)}]`;
478
- }
479
-
598
+ function getPythonScalarType(field: ModelInfo["fields"][number]): string {
480
599
  switch (field.type) {
481
600
  case "string":
482
- return isDateLikeField(field) ? "datetime" : "StrictStr";
601
+ return field.format === "date-time" ? "datetime" : field.format === "date" ? "date" : "StrictStr";
483
602
  case "number":
484
- return "Union[StrictFloat, StrictInt]";
603
+ return field.isInteger ? "StrictInt" : "Union[StrictFloat, StrictInt]";
485
604
  case "boolean":
486
605
  return "StrictBool";
487
606
  case "date":
@@ -493,43 +612,137 @@ function getPythonTypeName(field: ModelInfo["fields"][number]): string {
493
612
  }
494
613
  }
495
614
 
615
+ /** pydantic Field bounds for a scalar value (string length or numeric range). */
616
+ function getPythonValueConstraintArgs(field: ModelInfo["fields"][number]): string[] {
617
+ const constraints = field.constraints ?? {};
618
+ if (field.enumValues?.length || field.isNestedSchema) return [];
619
+ const args: string[] = [];
620
+ if (field.type === "string" && field.format !== "date-time" && field.format !== "date") {
621
+ if (constraints.minLength !== undefined) args.push(`min_length=${constraints.minLength}`);
622
+ if (constraints.maxLength !== undefined) args.push(`max_length=${constraints.maxLength}`);
623
+ }
624
+ if (field.type === "number") {
625
+ if (constraints.minimum !== undefined) args.push(`${constraints.exclusiveMinimum ? "gt" : "ge"}=${constraints.minimum}`);
626
+ if (constraints.maximum !== undefined) args.push(`${constraints.exclusiveMaximum ? "lt" : "le"}=${constraints.maximum}`);
627
+ }
628
+ return args;
629
+ }
630
+
496
631
  function getPythonArrayElementType(field: ModelInfo["fields"][number]): string {
497
632
  if (field.isNestedSchema && field.nestedModelName) {
498
633
  return field.nestedModelName;
499
634
  }
500
635
 
501
- switch (field.type) {
502
- case "string":
503
- return isDateLikeField(field) ? "datetime" : "StrictStr";
504
- case "number":
505
- return "Union[StrictFloat, StrictInt]";
506
- case "boolean":
507
- return "StrictBool";
508
- case "date":
509
- return "datetime";
510
- case "object":
511
- return "Dict[str, Any]";
512
- default:
513
- return "Any";
636
+ const elementType = getPythonScalarType(field);
637
+ const elementArgs = getPythonValueConstraintArgs(field);
638
+ return elementArgs.length > 0 ? `Annotated[${elementType}, Field(${elementArgs.join(", ")})]` : elementType;
639
+ }
640
+
641
+ function getPythonTypeName(field: ModelInfo["fields"][number]): string {
642
+ if (field.isArray) {
643
+ return `List[${getPythonArrayElementType(field)}]`;
644
+ }
645
+
646
+ if (field.isNestedSchema && field.nestedModelName) {
647
+ return field.nestedModelName;
514
648
  }
649
+
650
+ return getPythonScalarType(field);
515
651
  }
516
652
 
517
653
  function buildPythonFieldDeclaration(field: ModelInfo["fields"][number]): string {
518
654
  const pythonName = toSnakeCase(field.name);
519
655
  const typeName = field.isOptional ? `Optional[${getPythonTypeName(field)}]` : getPythonTypeName(field);
520
- const aliasSuffix = pythonName !== field.name ? `, alias="${field.name}"` : "";
656
+ const fieldArgs = [
657
+ ...(field.isOptional ? ["default=None"] : []),
658
+ ...(pythonName !== field.name ? [`alias="${field.name}"`] : []),
659
+ ...(field.isArray
660
+ ? [
661
+ ...(field.constraints?.minItems !== undefined ? [`min_length=${field.constraints.minItems}`] : []),
662
+ ...(field.constraints?.maxItems !== undefined ? [`max_length=${field.constraints.maxItems}`] : []),
663
+ ]
664
+ : getPythonValueConstraintArgs(field)),
665
+ ];
521
666
 
522
- if (field.isOptional) {
523
- return `${pythonName}: ${typeName} = Field(default=None${aliasSuffix})`;
524
- }
667
+ return fieldArgs.length > 0
668
+ ? `${pythonName}: ${typeName} = Field(${fieldArgs.join(", ")})`
669
+ : `${pythonName}: ${typeName}`;
670
+ }
671
+
672
+ function buildPythonRegexFlags(flags: string | undefined): string {
673
+ const values = [
674
+ flags?.includes("i") ? "re.IGNORECASE" : null,
675
+ flags?.includes("m") ? "re.MULTILINE" : null,
676
+ flags?.includes("s") ? "re.DOTALL" : null,
677
+ ].filter(Boolean);
678
+ return values.length > 0 ? values.join(" | ") : "0";
679
+ }
525
680
 
526
- if (aliasSuffix) {
527
- return `${pythonName}: ${typeName} = Field(${aliasSuffix.slice(2)})`;
681
+ export type PythonRegexTranslation = { pattern: string } | { unsupported: string };
682
+
683
+ /**
684
+ * Translates a JavaScript regex (as used by Zod) to Python `re` syntax. Constructs Python cannot
685
+ * express identically are reported as unsupported so the validator is skipped instead of breaking imports.
686
+ */
687
+ export function toPythonRegex(pattern: string, flags = ""): PythonRegexTranslation {
688
+ const unsupportedFlags = [...flags].filter((flag) => !"imsgu".includes(flag));
689
+ if (unsupportedFlags.length > 0) return { unsupported: `flag(s) ${unsupportedFlags.join("")} have no Python equivalent` };
690
+ const checks: Array<[RegExp, string]> = [
691
+ [/\\[pP]\{/, "Unicode property escapes (\\p{...})"],
692
+ [/\\u\{/, "code point escapes (\\u{...})"],
693
+ [/\\c[A-Za-z]/, "control escapes (\\cX)"],
694
+ [/\(\?<[=!]/, "lookbehind (JavaScript allows variable width; Python does not)"],
695
+ [/\[\^?\]/, "empty character classes ([] / [^])"],
696
+ ];
697
+ for (const [construct, label] of checks) {
698
+ if (construct.test(pattern)) return { unsupported: label };
528
699
  }
700
+ return {
701
+ pattern: pattern
702
+ .replace(/\(\?<([A-Za-z_][A-Za-z0-9_]*)>/g, "(?P<$1>")
703
+ .replace(/\\k<([A-Za-z_][A-Za-z0-9_]*)>/g, "(?P=$1)"),
704
+ };
705
+ }
706
+
707
+ function pythonRegexFields(model: ModelInfo): Array<{ field: ModelInfo["fields"][number]; translation: PythonRegexTranslation }> {
708
+ return model.fields
709
+ .filter((field) => field.type === "string" && !field.enumValues?.length && field.constraints?.pattern !== undefined)
710
+ .map((field) => ({ field, translation: toPythonRegex(field.constraints!.pattern!, field.constraints!.patternFlags) }));
711
+ }
529
712
 
530
- return `${pythonName}: ${typeName}`;
713
+ export function getPythonRegexWarnings(model: ModelInfo): string[] {
714
+ return pythonRegexFields(model)
715
+ .filter((entry): entry is { field: ModelInfo["fields"][number]; translation: { unsupported: string } } => "unsupported" in entry.translation)
716
+ .map(({ field, translation }) =>
717
+ `python-regex-not-enforced: ${model.name}.${field.name} uses ${translation.unsupported}; the Python model does not enforce /${field.constraints!.pattern}/. The BFF Zod schema still validates it.`);
531
718
  }
532
719
 
720
+ /**
721
+ * Zod regex checks use RegExp.test (search), so validators use re.search instead of pydantic's anchored pattern.
722
+ * They run before type conversion so date/time strings are checked as the JSON text Zod sees; non-string
723
+ * values are left to the field's own type validation.
724
+ */
725
+ function buildPythonPatternValidators(model: ModelInfo): string {
726
+ return pythonRegexFields(model)
727
+ .filter((entry): entry is { field: ModelInfo["fields"][number]; translation: { pattern: string } } => "pattern" in entry.translation)
728
+ .map(({ field, translation }) => {
729
+ const pythonName = toSnakeCase(field.name);
730
+ const pattern = JSON.stringify(translation.pattern);
731
+ const flags = buildPythonRegexFlags(field.constraints!.patternFlags);
732
+ const message = JSON.stringify(`${field.name} must match /${field.constraints!.pattern}/${field.constraints!.patternFlags ?? ""}`);
733
+ return ` @field_validator('${pythonName}', mode='before')
734
+ def ${pythonName}_validate_pattern(cls, value):
735
+ """Validates the Zod regex constraint"""
736
+ if value is None:
737
+ return value
738
+
739
+ for item in (value if isinstance(value, list) else [value]):
740
+ if isinstance(item, str) and not re.search(${pattern}, item, ${flags}):
741
+ raise ValueError(${message})
742
+ return value`;
743
+ })
744
+ .join("\n\n");
745
+ }
533
746
  function buildPythonEnumValidators(model: ModelInfo): string {
534
747
  return model.fields
535
748
  .filter((field) => field.enumValues?.length)
@@ -566,7 +779,7 @@ function buildPythonModelImports(model: ModelInfo): string {
566
779
  function generateLegacyCompatiblePythonModelSource(model: ModelInfo): string {
567
780
  const fieldDeclarations = model.fields.map((field) => ` ${buildPythonFieldDeclaration(field)}`).join("\n");
568
781
  const propertyNames = model.fields.map((field) => `"${field.name}"`).join(", ");
569
- const validators = buildPythonEnumValidators(model);
782
+ const validators = [buildPythonEnumValidators(model), buildPythonPatternValidators(model)].filter(Boolean).join("\n\n");
570
783
  const dictAssignments = model.fields
571
784
  .map((field) => ` "${field.name}": obj.get("${field.name}")`)
572
785
  .join(",\n");
@@ -590,9 +803,9 @@ import pprint
590
803
  import re # noqa: F401
591
804
  import json
592
805
 
593
- from datetime import datetime
806
+ from datetime import date, datetime
594
807
  from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, StrictStr, field_validator
595
- from typing import Any, ClassVar, Dict, List, Optional, Set, Union
808
+ from typing import Annotated, Any, ClassVar, Dict, List, Optional, Set, Union
596
809
  from typing_extensions import Self
597
810
 
598
811
  ${buildPythonModelImports(model)}class ${model.name}(BaseModel):
@@ -616,7 +829,7 @@ ${validators ? `\n\n${validators}` : ""}
616
829
 
617
830
  def to_json(self) -> str:
618
831
  """Returns the JSON representation of the model using alias"""
619
- return json.dumps(self.to_dict())
832
+ return self.model_dump_json(by_alias=True, exclude_none=True)
620
833
 
621
834
  @classmethod
622
835
  def from_json(cls, json_str: str) -> Optional[Self]:
@@ -726,6 +939,21 @@ function ensurePythonCodegenProjectFiles(
726
939
  return requirementsPath;
727
940
  }
728
941
 
942
+ /** Generated backend_models import pydantic v2 at runtime, so the deployed Functions app must install it. */
943
+ export const PYTHON_SCHEMA_RUNTIME_REQUIREMENT = "pydantic>=2.6,<3";
944
+
945
+ function ensurePythonRuntimeRequirement(functionsRoot: string, session: FileOperationSession): void {
946
+ const requirementsPath = path.join(functionsRoot, "requirements.txt");
947
+ const current = session.fileExists(requirementsPath) ? session.readFile(requirementsPath) : "";
948
+ const declared = current.split(/\r?\n/).some((line) => line.trim().split(/[<>=!~;[ ]/)[0].toLowerCase() === "pydantic");
949
+ if (declared) return;
950
+ const prefix = current && !current.endsWith("\n") ? `${current}\n` : current;
951
+ session.writeFile(requirementsPath, `${prefix}${PYTHON_SCHEMA_RUNTIME_REQUIREMENT}\n`, {
952
+ ownership: "extension-point",
953
+ generator: "native-schema",
954
+ });
955
+ }
956
+
729
957
  async function ensureProjectLocalUvCommand(projectRoot: string): Promise<{ command: string; env: NodeJS.ProcessEnv }> {
730
958
  const uvEnv = { ...process.env };
731
959
  const { localUvExecutable } = getProjectLocalUvPaths(projectRoot);
@@ -869,6 +1097,7 @@ async function generatePythonSchemaArtifacts(
869
1097
  sourceModel: string
870
1098
  ): Promise<void> {
871
1099
  ensurePythonCodegenProjectFiles(functionsRoot, session, sourceModel);
1100
+ ensurePythonRuntimeRequirement(functionsRoot, session);
872
1101
 
873
1102
  const packageRoot = path.join(outputDir, "backend_models");
874
1103
  const modelsRoot = path.join(packageRoot, "models");
@@ -886,6 +1115,10 @@ async function generatePythonSchemaArtifacts(
886
1115
  );
887
1116
 
888
1117
  for (const model of models) {
1118
+ for (const warning of getPythonRegexWarnings(model)) {
1119
+ session.addWarning(warning);
1120
+ console.warn(`⚠️ ${warning}`);
1121
+ }
889
1122
  const modelPath = getPythonSchemaModelPath(outputDir, model.name);
890
1123
  session.writeFile(modelPath, generateLegacyCompatiblePythonModelSource(model), {
891
1124
  ownership: "managed",
@@ -927,10 +1160,12 @@ export async function generateLanguageSchemaArtifacts(
927
1160
  backendLanguage === "csharp" ? "csharp-models" : "python-models"
928
1161
  );
929
1162
 
1163
+ // Inline z.object fields become named classes (e.g. ProbeItemNested) so their shape is preserved.
1164
+ const nativeModels = expandInlineObjectModels(models);
930
1165
  if (backendLanguage === "csharp") {
931
- await generateCSharpSchemaArtifacts(models, outputDir, functionsRoot, session, rootModel.name);
1166
+ await generateCSharpSchemaArtifacts(nativeModels, outputDir, functionsRoot, session, rootModel.name);
932
1167
  } else {
933
- await generatePythonSchemaArtifacts(models, outputDir, functionsRoot, session, rootModel.name);
1168
+ await generatePythonSchemaArtifacts(nativeModels, outputDir, functionsRoot, session, rootModel.name);
934
1169
  }
935
1170
 
936
1171
  console.log(
@@ -1,4 +1,4 @@
1
- import { FieldInfo, ModelInfo, toCamelCase } from "./model-parser";
1
+ import { FieldInfo, ModelInfo, toCamelCase, toPascalCase } from "./model-parser";
2
2
 
3
3
  type OpenApiSchema =
4
4
  | {
@@ -11,9 +11,59 @@ type OpenApiSchema =
11
11
  additionalProperties?: boolean;
12
12
  nullable?: boolean;
13
13
  $ref?: string;
14
+ minLength?: number;
15
+ maxLength?: number;
16
+ pattern?: string;
17
+ minimum?: number;
18
+ maximum?: number;
19
+ exclusiveMinimum?: boolean;
20
+ exclusiveMaximum?: boolean;
21
+ minItems?: number;
22
+ maxItems?: number;
14
23
  };
15
24
 
25
+ /**
26
+ * Promote inline `z.object({...})` fields to named models (e.g. ProbeItem.nested -> ProbeItemNested)
27
+ * so OpenAPI components and native classes keep their shape. Expanded fields reference the new model.
28
+ */
29
+ export function expandInlineObjectModels(models: ModelInfo[]): ModelInfo[] {
30
+ const names = new Set(models.map((model) => model.name));
31
+ const synthetic: ModelInfo[] = [];
32
+
33
+ const expand = (owner: ModelInfo): ModelInfo => ({
34
+ ...owner,
35
+ fields: owner.fields.map((field) => {
36
+ if (!field.objectFields || field.isNestedSchema) return field;
37
+ let name = `${owner.name}${toPascalCase(field.name)}`;
38
+ while (names.has(name)) name = `${name}Object`;
39
+ names.add(name);
40
+ const nested = expand({
41
+ ...owner,
42
+ name,
43
+ displayName: name,
44
+ schemaName: name,
45
+ fields: field.objectFields,
46
+ hasId: field.objectFields.some((child) => child.name === "id"),
47
+ hasCreatedAt: field.objectFields.some((child) => child.name === "createdAt"),
48
+ hasUpdatedAt: field.objectFields.some((child) => child.name === "updatedAt"),
49
+ nestedSchemaRefs: [],
50
+ partitionKey: "/id",
51
+ connectorConfig: undefined,
52
+ authPolicy: undefined,
53
+ });
54
+ synthetic.push(nested);
55
+ const rest = { ...field };
56
+ delete rest.objectFields;
57
+ return { ...rest, isNestedSchema: true, nestedModelName: name };
58
+ }),
59
+ });
60
+
61
+ const expanded = models.map(expand);
62
+ return [...expanded, ...synthetic];
63
+ }
64
+
16
65
  function toScalarSchema(field: FieldInfo): OpenApiSchema {
66
+ const constraints = field.constraints ?? {};
17
67
  if (field.enumValues?.length) {
18
68
  return {
19
69
  type: "string",
@@ -25,7 +75,10 @@ function toScalarSchema(field: FieldInfo): OpenApiSchema {
25
75
  case "string":
26
76
  return {
27
77
  type: "string",
28
- format: field.name.toLowerCase().endsWith("at") ? "date-time" : undefined,
78
+ ...(field.format && field.format !== "time" ? { format: field.format } : {}),
79
+ ...(constraints.minLength !== undefined ? { minLength: constraints.minLength } : {}),
80
+ ...(constraints.maxLength !== undefined ? { maxLength: constraints.maxLength } : {}),
81
+ ...(constraints.pattern !== undefined ? { pattern: constraints.pattern } : {}),
29
82
  };
30
83
  case "date":
31
84
  return {
@@ -34,7 +87,11 @@ function toScalarSchema(field: FieldInfo): OpenApiSchema {
34
87
  };
35
88
  case "number":
36
89
  return {
37
- type: "number",
90
+ ...(field.isInteger ? { type: "integer", format: "int64" } : { type: "number", format: "double" }),
91
+ ...(constraints.minimum !== undefined ? { minimum: constraints.minimum } : {}),
92
+ ...(constraints.exclusiveMinimum ? { exclusiveMinimum: true } : {}),
93
+ ...(constraints.maximum !== undefined ? { maximum: constraints.maximum } : {}),
94
+ ...(constraints.exclusiveMaximum ? { exclusiveMaximum: true } : {}),
38
95
  };
39
96
  case "boolean":
40
97
  return {
@@ -59,6 +116,8 @@ function toFieldSchema(field: FieldInfo): OpenApiSchema {
59
116
  return {
60
117
  type: "array",
61
118
  items: schema,
119
+ ...(field.constraints?.minItems !== undefined ? { minItems: field.constraints.minItems } : {}),
120
+ ...(field.constraints?.maxItems !== undefined ? { maxItems: field.constraints.maxItems } : {}),
62
121
  };
63
122
  }
64
123
 
@@ -82,11 +141,10 @@ function createComponentSchema(model: ModelInfo): OpenApiSchema {
82
141
  required,
83
142
  };
84
143
  }
85
-
86
144
  export function generateOpenApiDocument(models: ModelInfo[], rootModel: ModelInfo): string {
87
145
  const rootModelRoute = toCamelCase(rootModel.name);
88
146
  const components = Object.fromEntries(
89
- models.map((model) => [model.name, createComponentSchema(model)])
147
+ expandInlineObjectModels(models).map((model) => [model.name, createComponentSchema(model)])
90
148
  );
91
149
 
92
150
  const document = {