@multiplier-labs/stepflow 0.3.1

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 (76) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/README.md +408 -0
  3. package/dist/chunk-FQCT3I2A.js +1512 -0
  4. package/dist/chunk-FQCT3I2A.js.map +1 -0
  5. package/dist/chunk-GXY66V7Q.js +111 -0
  6. package/dist/chunk-GXY66V7Q.js.map +1 -0
  7. package/dist/chunk-U5QGN3RT.js +715 -0
  8. package/dist/chunk-U5QGN3RT.js.map +1 -0
  9. package/dist/chunk-XCF4PLN7.js +575 -0
  10. package/dist/chunk-XCF4PLN7.js.map +1 -0
  11. package/dist/events/index.d.ts +346 -0
  12. package/dist/events/index.js +11 -0
  13. package/dist/events/index.js.map +1 -0
  14. package/dist/index-CPfGsmrp.d.ts +567 -0
  15. package/dist/index.d.ts +748 -0
  16. package/dist/index.js +1707 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/scheduler/index.d.ts +6 -0
  19. package/dist/scheduler/index.js +12 -0
  20. package/dist/scheduler/index.js.map +1 -0
  21. package/dist/storage/index.d.ts +402 -0
  22. package/dist/storage/index.js +13 -0
  23. package/dist/storage/index.js.map +1 -0
  24. package/dist/types-BVShq7zT.d.ts +150 -0
  25. package/dist/types-BlwFEGzj.d.ts +276 -0
  26. package/dist/types-DzkEqnQJ.d.ts +75 -0
  27. package/docs/github-packages-setup.md +97 -0
  28. package/docs/stepflow-api-reference.md +1662 -0
  29. package/package.json +85 -0
  30. package/src/core/engine.test.ts +1421 -0
  31. package/src/core/engine.ts +717 -0
  32. package/src/core/index.ts +7 -0
  33. package/src/core/orchestrator.ts +590 -0
  34. package/src/core/types.ts +242 -0
  35. package/src/events/index.ts +8 -0
  36. package/src/events/memory.test.ts +109 -0
  37. package/src/events/memory.ts +95 -0
  38. package/src/events/socketio.test.ts +428 -0
  39. package/src/events/socketio.ts +269 -0
  40. package/src/events/types.ts +121 -0
  41. package/src/events/webhook.test.ts +695 -0
  42. package/src/events/webhook.ts +591 -0
  43. package/src/index.ts +270 -0
  44. package/src/planning/index.ts +52 -0
  45. package/src/planning/planner.ts +601 -0
  46. package/src/planning/planning.test.ts +790 -0
  47. package/src/planning/registry.test.ts +445 -0
  48. package/src/planning/registry.ts +369 -0
  49. package/src/planning/types.ts +570 -0
  50. package/src/scheduler/cron.test.ts +480 -0
  51. package/src/scheduler/cron.ts +449 -0
  52. package/src/scheduler/index.ts +18 -0
  53. package/src/scheduler/postgres-persistence.test.ts +906 -0
  54. package/src/scheduler/postgres-persistence.ts +546 -0
  55. package/src/scheduler/sqlite-persistence.test.ts +235 -0
  56. package/src/scheduler/sqlite-persistence.ts +268 -0
  57. package/src/scheduler/types.ts +78 -0
  58. package/src/storage/index.ts +27 -0
  59. package/src/storage/memory.test.ts +399 -0
  60. package/src/storage/memory.ts +248 -0
  61. package/src/storage/postgres.ts +1550 -0
  62. package/src/storage/sqlite.test.ts +593 -0
  63. package/src/storage/sqlite.ts +662 -0
  64. package/src/storage/types.ts +368 -0
  65. package/src/utils/errors.test.ts +152 -0
  66. package/src/utils/errors.ts +191 -0
  67. package/src/utils/id.ts +21 -0
  68. package/src/utils/index.ts +8 -0
  69. package/src/utils/logger.test.ts +127 -0
  70. package/src/utils/logger.ts +96 -0
  71. package/src/utils/postgres-deps.ts +63 -0
  72. package/src/utils/retry.test.ts +197 -0
  73. package/src/utils/retry.ts +121 -0
  74. package/tsconfig.json +21 -0
  75. package/tsup.config.ts +14 -0
  76. package/vitest.config.ts +9 -0
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Rule-based planner implementation.
3
+ * Selects recipes and generates plans based on conditions and input analysis.
4
+ */
5
+
6
+ import RE2 from "re2";
7
+ import { generateId } from "../utils/id";
8
+ import type {
9
+ ConditionOperator,
10
+ Plan,
11
+ PlannedStep,
12
+ Planner,
13
+ PlanModification,
14
+ PlanningContext,
15
+ PlanValidationResult,
16
+ Recipe,
17
+ RecipeCondition,
18
+ RecipeRegistry,
19
+ RecipeSelectionResult,
20
+ ResourceEstimate,
21
+ StepHandlerRegistry,
22
+ WorkflowKind,
23
+ } from "./types";
24
+
25
+ // ============================================================================
26
+ // Condition Evaluation
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Get a nested value from an object using dot notation.
31
+ */
32
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
33
+ return path.split(".").reduce<unknown>((current, key) => {
34
+ if (current && typeof current === "object" && key in current) {
35
+ return (current as Record<string, unknown>)[key];
36
+ }
37
+ return undefined;
38
+ }, obj);
39
+ }
40
+
41
+ /**
42
+ * Evaluate a single condition against a value.
43
+ */
44
+ function evaluateCondition(
45
+ operator: ConditionOperator,
46
+ fieldValue: unknown,
47
+ conditionValue: unknown,
48
+ ): boolean {
49
+ switch (operator) {
50
+ case "eq":
51
+ return fieldValue === conditionValue;
52
+
53
+ case "neq":
54
+ return fieldValue !== conditionValue;
55
+
56
+ case "gt":
57
+ return typeof fieldValue === "number" &&
58
+ typeof conditionValue === "number"
59
+ ? fieldValue > conditionValue
60
+ : false;
61
+
62
+ case "gte":
63
+ return typeof fieldValue === "number" &&
64
+ typeof conditionValue === "number"
65
+ ? fieldValue >= conditionValue
66
+ : false;
67
+
68
+ case "lt":
69
+ return typeof fieldValue === "number" &&
70
+ typeof conditionValue === "number"
71
+ ? fieldValue < conditionValue
72
+ : false;
73
+
74
+ case "lte":
75
+ return typeof fieldValue === "number" &&
76
+ typeof conditionValue === "number"
77
+ ? fieldValue <= conditionValue
78
+ : false;
79
+
80
+ case "contains":
81
+ if (
82
+ typeof fieldValue === "string" &&
83
+ typeof conditionValue === "string"
84
+ ) {
85
+ return fieldValue.includes(conditionValue);
86
+ }
87
+ if (Array.isArray(fieldValue)) {
88
+ return fieldValue.includes(conditionValue);
89
+ }
90
+ return false;
91
+
92
+ case "matches":
93
+ if (
94
+ typeof fieldValue === "string" &&
95
+ typeof conditionValue === "string"
96
+ ) {
97
+ try {
98
+ return new RE2(conditionValue).test(fieldValue);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ return false;
104
+
105
+ case "exists":
106
+ return (
107
+ fieldValue !== undefined && fieldValue !== null && fieldValue !== ""
108
+ );
109
+
110
+ case "notExists":
111
+ return (
112
+ fieldValue === undefined || fieldValue === null || fieldValue === ""
113
+ );
114
+
115
+ default:
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Score a recipe's conditions against input.
122
+ * Returns a score from 0-100 based on how well conditions match.
123
+ */
124
+ function scoreConditions(
125
+ conditions: RecipeCondition[] | undefined,
126
+ input: Record<string, unknown>,
127
+ ): number {
128
+ // No conditions = default match with low score
129
+ if (!conditions || conditions.length === 0) {
130
+ return 10;
131
+ }
132
+
133
+ let matched = 0;
134
+ for (const condition of conditions) {
135
+ const fieldValue = getNestedValue(input, condition.field);
136
+ if (evaluateCondition(condition.operator, fieldValue, condition.value)) {
137
+ matched++;
138
+ }
139
+ }
140
+
141
+ // All conditions must match for any positive score
142
+ if (matched < conditions.length) {
143
+ return 0;
144
+ }
145
+
146
+ // Base score 50 ensures condition-matched recipes beat unconditional defaults (score 10).
147
+ // Each additional condition adds 10 points, capped at 100 so forced recipes (100) still win.
148
+ return Math.min(100, 50 + conditions.length * 10);
149
+ }
150
+
151
+ // ============================================================================
152
+ // Rule-Based Planner Implementation
153
+ // ============================================================================
154
+
155
+ /**
156
+ * Configuration for the RuleBasedPlanner.
157
+ */
158
+ export interface RuleBasedPlannerConfig {
159
+ /** Recipe registry to use */
160
+ recipeRegistry: RecipeRegistry;
161
+
162
+ /** Step handler registry for validation */
163
+ handlerRegistry?: StepHandlerRegistry;
164
+
165
+ /** Whether to validate handler references during planning */
166
+ validateHandlers?: boolean;
167
+
168
+ /** Resource estimation defaults (used by estimateResources) */
169
+ resourceEstimates?: {
170
+ /** Estimated API calls per step (default: 1) */
171
+ apiCallsPerStep?: number;
172
+ /** Estimated tokens per step (default: 500) */
173
+ tokensPerStep?: number;
174
+ /** Estimated duration per step in ms (default: 2000) */
175
+ durationPerStep?: number;
176
+ /** Estimated API calls per child workflow (default: 5) */
177
+ apiCallsPerChild?: number;
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Rule-based planner that selects recipes based on condition matching.
183
+ */
184
+ export class RuleBasedPlanner implements Planner {
185
+ private recipeRegistry: RecipeRegistry;
186
+ private handlerRegistry?: StepHandlerRegistry;
187
+ private validateHandlers: boolean;
188
+ private resourceEstimates: Required<
189
+ NonNullable<RuleBasedPlannerConfig["resourceEstimates"]>
190
+ >;
191
+
192
+ constructor(config: RuleBasedPlannerConfig) {
193
+ this.recipeRegistry = config.recipeRegistry;
194
+ this.handlerRegistry = config.handlerRegistry;
195
+ this.validateHandlers = config.validateHandlers ?? false;
196
+ this.resourceEstimates = {
197
+ apiCallsPerStep: config.resourceEstimates?.apiCallsPerStep ?? 1,
198
+ tokensPerStep: config.resourceEstimates?.tokensPerStep ?? 500,
199
+ durationPerStep: config.resourceEstimates?.durationPerStep ?? 2000,
200
+ apiCallsPerChild: config.resourceEstimates?.apiCallsPerChild ?? 5,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Select the best recipe for a workflow kind and input.
206
+ */
207
+ async selectRecipe(
208
+ workflowKind: WorkflowKind,
209
+ input: Record<string, unknown>,
210
+ context?: PlanningContext,
211
+ ): Promise<RecipeSelectionResult> {
212
+ // Check for forced recipe in hints
213
+ if (context?.hints?.forceRecipeId) {
214
+ const forced = this.recipeRegistry.get(context.hints.forceRecipeId);
215
+ if (forced) {
216
+ return {
217
+ recipe: forced,
218
+ score: 100,
219
+ reason: `Forced recipe: ${context.hints.forceRecipeId}`,
220
+ };
221
+ }
222
+ }
223
+
224
+ // Check for preferred variant in hints
225
+ if (context?.hints?.preferredVariant) {
226
+ const preferred = this.recipeRegistry.getVariant(
227
+ workflowKind,
228
+ context.hints.preferredVariant,
229
+ );
230
+ if (preferred) {
231
+ return {
232
+ recipe: preferred,
233
+ score: 90,
234
+ reason: `Preferred variant: ${context.hints.preferredVariant}`,
235
+ };
236
+ }
237
+ }
238
+
239
+ // Get all recipes for this workflow kind
240
+ const recipes = this.recipeRegistry.getByKind(workflowKind);
241
+
242
+ if (recipes.length === 0) {
243
+ throw new Error(`No recipes found for workflow kind: ${workflowKind}`);
244
+ }
245
+
246
+ // Score each recipe
247
+ const scored = recipes.map((recipe) => ({
248
+ recipe,
249
+ conditionScore: scoreConditions(recipe.conditions, input),
250
+ priorityScore: recipe.priority ?? 0,
251
+ }));
252
+
253
+ // Sort by condition score (desc), then priority (desc)
254
+ scored.sort((a, b) => {
255
+ if (b.conditionScore !== a.conditionScore) {
256
+ return b.conditionScore - a.conditionScore;
257
+ }
258
+ return b.priorityScore - a.priorityScore;
259
+ });
260
+
261
+ // Find best matching recipe
262
+ const best = scored.find((s) => s.conditionScore > 0);
263
+
264
+ if (best) {
265
+ const reason = this.buildSelectionReason(best.recipe, input);
266
+ return {
267
+ recipe: best.recipe,
268
+ score: best.conditionScore,
269
+ reason,
270
+ };
271
+ }
272
+
273
+ // Fall back to default recipe
274
+ const defaultRecipe = this.recipeRegistry.getDefault(workflowKind);
275
+ if (defaultRecipe) {
276
+ return {
277
+ recipe: defaultRecipe,
278
+ score: 10,
279
+ reason: `Fallback to default recipe: ${defaultRecipe.id}`,
280
+ };
281
+ }
282
+
283
+ // Last resort: use first recipe
284
+ return {
285
+ recipe: recipes[0],
286
+ score: 5,
287
+ reason: `No matching recipe, using first available: ${recipes[0].id}`,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Generate a plan from a recipe and input.
293
+ */
294
+ async generatePlan(
295
+ recipe: Recipe,
296
+ input: Record<string, unknown>,
297
+ context?: PlanningContext,
298
+ ): Promise<Plan> {
299
+ const modifications: PlanModification[] = [];
300
+
301
+ // Start with recipe steps
302
+ let steps = recipe.steps.map((step) => this.recipeStepToPlannedStep(step));
303
+
304
+ // Apply hint-based modifications
305
+ if (context?.hints) {
306
+ const { skipSteps, includeSteps, additionalConfig } = context.hints;
307
+
308
+ // Mark steps to skip
309
+ if (skipSteps && skipSteps.length > 0) {
310
+ for (const stepKey of skipSteps) {
311
+ const stepIndex = steps.findIndex((s) => s.key === stepKey);
312
+ if (stepIndex >= 0) {
313
+ steps = steps.filter((s) => s.key !== stepKey);
314
+ modifications.push({
315
+ type: "remove_step",
316
+ stepKey,
317
+ value: null,
318
+ reason: `Skipped via planning hints`,
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ // Apply additional config to all steps
325
+ if (additionalConfig) {
326
+ for (const step of steps) {
327
+ step.config = { ...step.config, ...additionalConfig };
328
+ }
329
+ modifications.push({
330
+ type: "modify_step",
331
+ value: additionalConfig,
332
+ reason: "Applied additional config from planning hints",
333
+ });
334
+ }
335
+ }
336
+
337
+ // Apply constraint-based modifications
338
+ if (context?.constraints) {
339
+ const constraintMods = this.applyConstraints(steps, context.constraints);
340
+ modifications.push(...constraintMods.modifications);
341
+ steps = constraintMods.steps;
342
+ }
343
+
344
+ // Build the plan
345
+ const plan: Plan = {
346
+ id: generateId(),
347
+ recipeId: recipe.id,
348
+ variant: recipe.variant,
349
+ modifications,
350
+ steps,
351
+ defaults: recipe.defaults ?? {},
352
+ reasoning: this.buildPlanReasoning(recipe, modifications, context),
353
+ createdAt: new Date(),
354
+ };
355
+
356
+ // Estimate resources
357
+ plan.resourceEstimate = this.estimateResources(plan);
358
+
359
+ return plan;
360
+ }
361
+
362
+ /**
363
+ * Combined operation: select recipe and generate plan.
364
+ */
365
+ async plan(
366
+ workflowKind: WorkflowKind,
367
+ input: Record<string, unknown>,
368
+ context?: PlanningContext,
369
+ ): Promise<Plan> {
370
+ const selection = await this.selectRecipe(workflowKind, input, context);
371
+ const plan = await this.generatePlan(selection.recipe, input, context);
372
+
373
+ // Add selection reasoning to plan
374
+ plan.reasoning = `${selection.reason}. ${plan.reasoning ?? ""}`.trim();
375
+
376
+ return plan;
377
+ }
378
+
379
+ /**
380
+ * Validate a plan before execution.
381
+ */
382
+ validatePlan(plan: Plan): PlanValidationResult {
383
+ const errors: string[] = [];
384
+ const warnings: string[] = [];
385
+
386
+ // Check plan has steps
387
+ if (!plan.steps || plan.steps.length === 0) {
388
+ errors.push("Plan has no steps");
389
+ }
390
+
391
+ // Check for duplicate step keys
392
+ const stepKeys = new Set<string>();
393
+ for (const step of plan.steps) {
394
+ if (stepKeys.has(step.key)) {
395
+ errors.push(`Duplicate step key: ${step.key}`);
396
+ }
397
+ stepKeys.add(step.key);
398
+ }
399
+
400
+ // Validate each step
401
+ for (const step of plan.steps) {
402
+ if (!step.key) {
403
+ errors.push("Step missing key");
404
+ }
405
+ if (!step.name) {
406
+ warnings.push(`Step '${step.key}' missing name`);
407
+ }
408
+ if (!step.handlerRef) {
409
+ errors.push(`Step '${step.key}' missing handlerRef`);
410
+ }
411
+
412
+ // Validate handler exists (if registry available)
413
+ if (this.validateHandlers && this.handlerRegistry) {
414
+ if (!this.handlerRegistry.has(step.handlerRef)) {
415
+ errors.push(
416
+ `Step '${step.key}' references unknown handler: ${step.handlerRef}`,
417
+ );
418
+ }
419
+ }
420
+ }
421
+
422
+ // Check child workflows
423
+ if (plan.childWorkflows) {
424
+ for (const child of plan.childWorkflows) {
425
+ if (!child.kind) {
426
+ errors.push("Child workflow missing kind");
427
+ }
428
+ }
429
+ }
430
+
431
+ return {
432
+ valid: errors.length === 0,
433
+ errors,
434
+ warnings,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Estimate resources required for a plan.
440
+ */
441
+ estimateResources(plan: Plan): ResourceEstimate {
442
+ const {
443
+ apiCallsPerStep,
444
+ tokensPerStep,
445
+ durationPerStep,
446
+ apiCallsPerChild,
447
+ } = this.resourceEstimates;
448
+
449
+ const stepCount = plan.steps.length;
450
+ const childCount = plan.childWorkflows?.length ?? 0;
451
+
452
+ return {
453
+ apiCalls: stepCount * apiCallsPerStep + childCount * apiCallsPerChild,
454
+ tokens: stepCount * tokensPerStep,
455
+ duration: stepCount * durationPerStep,
456
+ };
457
+ }
458
+
459
+ // ============================================================================
460
+ // Private Helpers
461
+ // ============================================================================
462
+
463
+ /**
464
+ * Convert a recipe step to a planned step.
465
+ */
466
+ private recipeStepToPlannedStep(step: Recipe["steps"][0]): PlannedStep {
467
+ return {
468
+ key: step.key,
469
+ name: step.name,
470
+ handlerRef: step.handlerRef,
471
+ config: step.config ?? {},
472
+ onError: step.onError,
473
+ maxRetries: step.maxRetries,
474
+ retryDelay: step.retryDelay,
475
+ retryBackoff: step.retryBackoff,
476
+ timeout: step.timeout,
477
+ skipCondition: step.skipCondition,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Apply constraints to steps and generate modifications.
483
+ */
484
+ private applyConstraints(
485
+ steps: PlannedStep[],
486
+ constraints: NonNullable<PlanningContext["constraints"]>,
487
+ ): { steps: PlannedStep[]; modifications: PlanModification[] } {
488
+ const modifications: PlanModification[] = [];
489
+ const perStepTimeout = constraints.maxDuration
490
+ ? Math.floor(constraints.maxDuration / steps.length)
491
+ : undefined;
492
+
493
+ // Apply all constraint mutations in a single pass
494
+ const modifiedSteps = steps.map((step) => {
495
+ const modified = { ...step };
496
+
497
+ if (constraints.priority === "speed") {
498
+ modified.timeout = modified.timeout
499
+ ? Math.min(modified.timeout, 30000)
500
+ : 30000;
501
+ modified.maxRetries = Math.min(modified.maxRetries ?? 3, 1);
502
+ }
503
+
504
+ if (constraints.priority === "cost") {
505
+ modified.maxRetries = 0;
506
+ }
507
+
508
+ if (perStepTimeout !== undefined) {
509
+ modified.timeout = modified.timeout
510
+ ? Math.min(modified.timeout, perStepTimeout)
511
+ : perStepTimeout;
512
+ }
513
+
514
+ return modified;
515
+ });
516
+
517
+ // Record what was applied
518
+ if (constraints.priority === "speed") {
519
+ modifications.push({
520
+ type: "set_default",
521
+ value: { priority: "speed" },
522
+ reason: "Optimized for speed: reduced timeouts and retries",
523
+ });
524
+ }
525
+ if (constraints.priority === "cost") {
526
+ modifications.push({
527
+ type: "set_default",
528
+ value: { priority: "cost" },
529
+ reason: "Optimized for cost: disabled retries",
530
+ });
531
+ }
532
+ if (constraints.maxDuration) {
533
+ modifications.push({
534
+ type: "set_default",
535
+ value: { maxDuration: constraints.maxDuration },
536
+ reason: `Applied duration constraint: ${constraints.maxDuration}ms total`,
537
+ });
538
+ }
539
+
540
+ return { steps: modifiedSteps, modifications };
541
+ }
542
+
543
+ /**
544
+ * Build a human-readable selection reason.
545
+ */
546
+ private buildSelectionReason(
547
+ recipe: Recipe,
548
+ input: Record<string, unknown>,
549
+ ): string {
550
+ const parts: string[] = [`Selected recipe: ${recipe.id}`];
551
+
552
+ if (recipe.conditions && recipe.conditions.length > 0) {
553
+ const conditionDescriptions = recipe.conditions.map((c) => {
554
+ const value = getNestedValue(input, c.field);
555
+ return `${c.field} ${c.operator} ${JSON.stringify(c.value)} (actual: ${JSON.stringify(value)})`;
556
+ });
557
+ parts.push(`Matched conditions: ${conditionDescriptions.join(", ")}`);
558
+ }
559
+
560
+ if (recipe.priority !== undefined && recipe.priority > 0) {
561
+ parts.push(`Priority: ${recipe.priority}`);
562
+ }
563
+
564
+ return parts.join(". ");
565
+ }
566
+
567
+ /**
568
+ * Build reasoning text for a plan.
569
+ */
570
+ private buildPlanReasoning(
571
+ recipe: Recipe,
572
+ modifications: PlanModification[],
573
+ context?: PlanningContext,
574
+ ): string {
575
+ const parts: string[] = [];
576
+
577
+ parts.push(`Using recipe '${recipe.name}' (${recipe.variant} variant)`);
578
+
579
+ if (modifications.length > 0) {
580
+ parts.push(`Applied ${modifications.length} modification(s)`);
581
+ }
582
+
583
+ if (context?.constraints) {
584
+ const constraintList: string[] = [];
585
+ if (context.constraints.priority) {
586
+ constraintList.push(`priority=${context.constraints.priority}`);
587
+ }
588
+ if (context.constraints.maxDuration) {
589
+ constraintList.push(`maxDuration=${context.constraints.maxDuration}ms`);
590
+ }
591
+ if (context.constraints.maxApiCalls) {
592
+ constraintList.push(`maxApiCalls=${context.constraints.maxApiCalls}`);
593
+ }
594
+ if (constraintList.length > 0) {
595
+ parts.push(`Constraints: ${constraintList.join(", ")}`);
596
+ }
597
+ }
598
+
599
+ return parts.join(". ") + ".";
600
+ }
601
+ }