@featurevisor/core 2.11.0 → 2.12.0

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 (79) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +684 -3
  3. package/coverage/coverage-final.json +4 -0
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  8. package/coverage/lcov-report/builder/index.html +1 -1
  9. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  10. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  11. package/coverage/lcov-report/index.html +25 -10
  12. package/coverage/lcov-report/linter/conditionSchema.ts.html +775 -0
  13. package/coverage/lcov-report/linter/featureSchema.ts.html +4924 -0
  14. package/coverage/lcov-report/linter/index.html +161 -0
  15. package/coverage/lcov-report/linter/schema.ts.html +1471 -0
  16. package/coverage/lcov-report/linter/segmentSchema.ts.html +130 -0
  17. package/coverage/lcov-report/list/index.html +1 -1
  18. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  19. package/coverage/lcov-report/parsers/index.html +1 -1
  20. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  21. package/coverage/lcov-report/parsers/yml.ts.html +1 -1
  22. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  23. package/coverage/lcov-report/tester/index.html +1 -1
  24. package/coverage/lcov.info +1471 -0
  25. package/lib/builder/buildDatafile.js +15 -1
  26. package/lib/builder/buildDatafile.js.map +1 -1
  27. package/lib/config/projectConfig.d.ts +2 -0
  28. package/lib/config/projectConfig.js +3 -1
  29. package/lib/config/projectConfig.js.map +1 -1
  30. package/lib/datasource/datasource.d.ts +6 -1
  31. package/lib/datasource/datasource.js +16 -0
  32. package/lib/datasource/datasource.js.map +1 -1
  33. package/lib/datasource/filesystemAdapter.js +10 -0
  34. package/lib/datasource/filesystemAdapter.js.map +1 -1
  35. package/lib/generate-code/typescript.js +280 -46
  36. package/lib/generate-code/typescript.js.map +1 -1
  37. package/lib/linter/conditionSchema.spec.d.ts +1 -0
  38. package/lib/linter/conditionSchema.spec.js +331 -0
  39. package/lib/linter/conditionSchema.spec.js.map +1 -0
  40. package/lib/linter/featureSchema.d.ts +129 -20
  41. package/lib/linter/featureSchema.js +489 -48
  42. package/lib/linter/featureSchema.js.map +1 -1
  43. package/lib/linter/featureSchema.spec.d.ts +1 -0
  44. package/lib/linter/featureSchema.spec.js +978 -0
  45. package/lib/linter/featureSchema.spec.js.map +1 -0
  46. package/lib/linter/lintProject.js +67 -1
  47. package/lib/linter/lintProject.js.map +1 -1
  48. package/lib/linter/schema.d.ts +42 -0
  49. package/lib/linter/schema.js +417 -0
  50. package/lib/linter/schema.js.map +1 -0
  51. package/lib/linter/schema.spec.d.ts +1 -0
  52. package/lib/linter/schema.spec.js +483 -0
  53. package/lib/linter/schema.spec.js.map +1 -0
  54. package/lib/linter/segmentSchema.spec.d.ts +1 -0
  55. package/lib/linter/segmentSchema.spec.js +231 -0
  56. package/lib/linter/segmentSchema.spec.js.map +1 -0
  57. package/lib/tester/testFeature.js +5 -3
  58. package/lib/tester/testFeature.js.map +1 -1
  59. package/lib/utils/git.js +3 -0
  60. package/lib/utils/git.js.map +1 -1
  61. package/package.json +5 -5
  62. package/src/builder/buildDatafile.ts +17 -1
  63. package/src/config/projectConfig.ts +3 -0
  64. package/src/datasource/datasource.ts +23 -0
  65. package/src/datasource/filesystemAdapter.ts +7 -0
  66. package/src/generate-code/typescript.ts +330 -49
  67. package/src/linter/conditionSchema.spec.ts +446 -0
  68. package/src/linter/featureSchema.spec.ts +1218 -0
  69. package/src/linter/featureSchema.ts +671 -69
  70. package/src/linter/lintProject.ts +84 -0
  71. package/src/linter/schema.spec.ts +617 -0
  72. package/src/linter/schema.ts +462 -0
  73. package/src/linter/segmentSchema.spec.ts +273 -0
  74. package/src/tester/testFeature.ts +5 -3
  75. package/src/utils/git.ts +2 -0
  76. package/lib/linter/propertySchema.d.ts +0 -5
  77. package/lib/linter/propertySchema.js +0 -43
  78. package/lib/linter/propertySchema.js.map +0 -1
  79. package/src/linter/propertySchema.ts +0 -47
@@ -1,7 +1,16 @@
1
+ import type { Schema } from "@featurevisor/types";
1
2
  import { z } from "zod";
2
3
 
3
4
  import { ProjectConfig } from "../config";
4
- import { valueZodSchema, propertyTypeEnum, getPropertyZodSchema } from "./propertySchema";
5
+ import {
6
+ valueZodSchema,
7
+ propertyTypeEnum,
8
+ getSchemaZodSchema,
9
+ refineEnumMatchesType,
10
+ refineMinimumMaximum,
11
+ refineStringLengthPattern,
12
+ refineArrayItems,
13
+ } from "./schema";
5
14
 
6
15
  const tagRegex = /^[a-z0-9-]+$/;
7
16
 
@@ -26,6 +35,221 @@ function getVariableLabel(variableSchema, variableKey, path) {
26
35
  );
27
36
  }
28
37
 
38
+ /**
39
+ * Resolve variable schema to the Schema used for value validation.
40
+ * When variable has `schema` (reference), returns the parsed Schema from schemasByKey; otherwise returns the inline variable schema.
41
+ */
42
+ function resolveVariableSchema(
43
+ variableSchema: {
44
+ schema?: string;
45
+ type?: string;
46
+ items?: unknown;
47
+ properties?: unknown;
48
+ required?: string[];
49
+ enum?: unknown[];
50
+ const?: unknown;
51
+ oneOf?: unknown[];
52
+ minimum?: number;
53
+ maximum?: number;
54
+ minLength?: number;
55
+ maxLength?: number;
56
+ pattern?: string;
57
+ minItems?: number;
58
+ maxItems?: number;
59
+ uniqueItems?: boolean;
60
+ },
61
+ schemasByKey?: Record<string, Schema>,
62
+ ): {
63
+ type?: string;
64
+ items?: unknown;
65
+ properties?: unknown;
66
+ required?: string[];
67
+ enum?: unknown[];
68
+ const?: unknown;
69
+ oneOf?: unknown[];
70
+ minimum?: number;
71
+ maximum?: number;
72
+ minLength?: number;
73
+ maxLength?: number;
74
+ pattern?: string;
75
+ minItems?: number;
76
+ maxItems?: number;
77
+ uniqueItems?: boolean;
78
+ } | null {
79
+ if (variableSchema.schema) {
80
+ return schemasByKey?.[variableSchema.schema] ?? null;
81
+ }
82
+ return variableSchema as {
83
+ type?: string;
84
+ items?: unknown;
85
+ properties?: unknown;
86
+ required?: string[];
87
+ enum?: unknown[];
88
+ const?: unknown;
89
+ oneOf?: unknown[];
90
+ minimum?: number;
91
+ maximum?: number;
92
+ minLength?: number;
93
+ maxLength?: number;
94
+ pattern?: string;
95
+ minItems?: number;
96
+ maxItems?: number;
97
+ uniqueItems?: boolean;
98
+ };
99
+ }
100
+
101
+ /** Resolve a schema by following schema references (schema: key). Used for nested schemas that may have oneOf. */
102
+ function resolveSchemaRefs(
103
+ schema: { schema?: string; [k: string]: unknown },
104
+ schemasByKey?: Record<string, Schema>,
105
+ ): { [k: string]: unknown } {
106
+ if (schema.schema && schemasByKey?.[schema.schema]) {
107
+ return resolveSchemaRefs(
108
+ schemasByKey[schema.schema] as { schema?: string; [k: string]: unknown },
109
+ schemasByKey,
110
+ );
111
+ }
112
+ return schema;
113
+ }
114
+
115
+ /**
116
+ * Returns true if the value matches the given schema (const, enum, type, object properties, array items, or exactly one of oneOf).
117
+ * Used for oneOf validation: value must match exactly one branch.
118
+ */
119
+ function valueMatchesSchema(
120
+ schema: { [k: string]: unknown },
121
+ value: unknown,
122
+ schemasByKey?: Record<string, Schema>,
123
+ ): boolean {
124
+ const resolved = resolveSchemaRefs(schema, schemasByKey) as {
125
+ type?: string;
126
+ const?: unknown;
127
+ enum?: unknown[];
128
+ oneOf?: unknown[];
129
+ properties?: Record<string, unknown>;
130
+ required?: string[];
131
+ items?: unknown;
132
+ minimum?: number;
133
+ maximum?: number;
134
+ minLength?: number;
135
+ maxLength?: number;
136
+ pattern?: string;
137
+ minItems?: number;
138
+ maxItems?: number;
139
+ uniqueItems?: boolean;
140
+ };
141
+
142
+ if (resolved.oneOf && Array.isArray(resolved.oneOf) && resolved.oneOf.length > 0) {
143
+ const matchCount = resolved.oneOf.filter((branch) =>
144
+ valueMatchesSchema(branch as { [k: string]: unknown }, value, schemasByKey),
145
+ ).length;
146
+ return matchCount === 1;
147
+ }
148
+
149
+ if (resolved.const !== undefined) {
150
+ return valueDeepEqual(value, resolved.const);
151
+ }
152
+
153
+ if (resolved.enum !== undefined && Array.isArray(resolved.enum)) {
154
+ return resolved.enum.some((e) => valueDeepEqual(value, e));
155
+ }
156
+
157
+ const type = resolved.type;
158
+ if (!type) return false;
159
+
160
+ if (type === "string") {
161
+ if (typeof value !== "string") return false;
162
+ const s = value as string;
163
+ if (resolved.minLength !== undefined && s.length < resolved.minLength) return false;
164
+ if (resolved.maxLength !== undefined && s.length > resolved.maxLength) return false;
165
+ if (resolved.pattern !== undefined) {
166
+ try {
167
+ if (!new RegExp(resolved.pattern).test(s)) return false;
168
+ } catch {
169
+ return true;
170
+ }
171
+ }
172
+ return true;
173
+ }
174
+ if (type === "boolean") return typeof value === "boolean";
175
+ if (type === "integer") {
176
+ if (typeof value !== "number" || !Number.isInteger(value)) return false;
177
+ if (resolved.minimum !== undefined && (value as number) < resolved.minimum) return false;
178
+ if (resolved.maximum !== undefined && (value as number) > resolved.maximum) return false;
179
+ return true;
180
+ }
181
+ if (type === "double") {
182
+ if (typeof value !== "number") return false;
183
+ if (resolved.minimum !== undefined && (value as number) < resolved.minimum) return false;
184
+ if (resolved.maximum !== undefined && (value as number) > resolved.maximum) return false;
185
+ return true;
186
+ }
187
+ if (type === "json") return typeof value === "string";
188
+
189
+ if (type === "object") {
190
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
191
+ const props = resolved.properties;
192
+ if (!props || typeof props !== "object") return true;
193
+ const obj = value as Record<string, unknown>;
194
+ const required = new Set(resolved.required || []);
195
+ for (const key of required) {
196
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) return false;
197
+ if (!valueMatchesSchema(props[key] as { [k: string]: unknown }, obj[key], schemasByKey))
198
+ return false;
199
+ }
200
+ for (const key of Object.keys(obj)) {
201
+ const propSchema = props[key];
202
+ if (!propSchema) return false;
203
+ if (!valueMatchesSchema(propSchema as { [k: string]: unknown }, obj[key], schemasByKey))
204
+ return false;
205
+ }
206
+ return true;
207
+ }
208
+
209
+ if (type === "array") {
210
+ if (!Array.isArray(value)) return false;
211
+ const arr = value as unknown[];
212
+ if (resolved.minItems !== undefined && arr.length < resolved.minItems) return false;
213
+ if (resolved.maxItems !== undefined && arr.length > resolved.maxItems) return false;
214
+ if (resolved.uniqueItems) {
215
+ for (let i = 0; i < arr.length; i++) {
216
+ for (let j = i + 1; j < arr.length; j++) {
217
+ if (valueDeepEqual(arr[i], arr[j])) return false;
218
+ }
219
+ }
220
+ }
221
+ const itemSchema = resolved.items;
222
+ if (!itemSchema || typeof itemSchema !== "object")
223
+ return arr.every((v) => typeof v === "string");
224
+ return arr.every((item) =>
225
+ valueMatchesSchema(itemSchema as { [k: string]: unknown }, item, schemasByKey),
226
+ );
227
+ }
228
+
229
+ return false;
230
+ }
231
+
232
+ /** Deep equality for variable values (primitives, plain objects, arrays). */
233
+ function valueDeepEqual(a: unknown, b: unknown): boolean {
234
+ if (a === b) return true;
235
+ if (typeof a !== typeof b) return false;
236
+ if (a === null || b === null) return a === b;
237
+ if (typeof a === "object" && typeof b === "object") {
238
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
239
+ if (Array.isArray(a) && Array.isArray(b)) {
240
+ if (a.length !== b.length) return false;
241
+ return a.every((v, i) => valueDeepEqual(v, b[i]));
242
+ }
243
+ const keysA = Object.keys(a as object).sort();
244
+ const keysB = Object.keys(b as object).sort();
245
+ if (keysA.length !== keysB.length || keysA.some((k, i) => k !== keysB[i])) return false;
246
+ return keysA.every((k) =>
247
+ valueDeepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
248
+ );
249
+ }
250
+ return false;
251
+ }
252
+
29
253
  /**
30
254
  * Recursively validates that every `required` array (at this level and in nested
31
255
  * object/array schemas) only contains keys that exist in the same level's `properties`.
@@ -87,6 +311,19 @@ function refineRequiredKeysInSchema(
87
311
  ctx,
88
312
  );
89
313
  }
314
+
315
+ const oneOf = (schema as { oneOf?: unknown[] }).oneOf;
316
+ if (oneOf && Array.isArray(oneOf)) {
317
+ oneOf.forEach((branch, i) => {
318
+ if (branch && typeof branch === "object") {
319
+ refineRequiredKeysInSchema(
320
+ branch as Parameters<typeof refineRequiredKeysInSchema>[0],
321
+ [...pathPrefix, "oneOf", i],
322
+ ctx,
323
+ );
324
+ }
325
+ });
326
+ }
90
327
  }
91
328
 
92
329
  function typeOfValue(value: unknown): string {
@@ -99,21 +336,68 @@ function typeOfValue(value: unknown): string {
99
336
  /**
100
337
  * Validates a variable value against an array schema. Recursively validates each item
101
338
  * when the schema defines `items` (nested arrays/objects use the same refinement).
339
+ * Enforces minItems, maxItems, and uniqueItems when set.
102
340
  */
103
341
  function refineVariableValueArray(
104
342
  projectConfig: ProjectConfig,
105
- variableSchema: { items?: unknown; type: string },
343
+ variableSchema: {
344
+ items?: unknown;
345
+ type: string;
346
+ minItems?: number;
347
+ maxItems?: number;
348
+ uniqueItems?: boolean;
349
+ },
106
350
  variableValue: unknown[],
107
351
  path: (string | number)[],
108
352
  ctx: z.RefinementCtx,
109
353
  variableKey?: string,
354
+ schemasByKey?: Record<string, Schema>,
110
355
  ): void {
111
356
  const label = getVariableLabel(variableSchema, variableKey, path);
357
+ const minItems = variableSchema.minItems;
358
+ const maxItems = variableSchema.maxItems;
359
+ const uniqueItems = variableSchema.uniqueItems;
360
+ if (minItems !== undefined && variableValue.length < minItems) {
361
+ ctx.addIssue({
362
+ code: z.ZodIssueCode.custom,
363
+ message: `Variable "${label}" (type array) length (${variableValue.length}) is less than \`minItems\` (${minItems}).`,
364
+ path,
365
+ });
366
+ }
367
+ if (maxItems !== undefined && variableValue.length > maxItems) {
368
+ ctx.addIssue({
369
+ code: z.ZodIssueCode.custom,
370
+ message: `Variable "${label}" (type array) length (${variableValue.length}) is greater than \`maxItems\` (${maxItems}).`,
371
+ path,
372
+ });
373
+ }
374
+ if (uniqueItems) {
375
+ for (let i = 0; i < variableValue.length; i++) {
376
+ for (let j = i + 1; j < variableValue.length; j++) {
377
+ if (valueDeepEqual(variableValue[i], variableValue[j])) {
378
+ ctx.addIssue({
379
+ code: z.ZodIssueCode.custom,
380
+ message: `Variable "${label}" (type array) has duplicate items at indices ${i} and ${j} but \`uniqueItems\` is true.`,
381
+ path,
382
+ });
383
+ break;
384
+ }
385
+ }
386
+ }
387
+ }
112
388
  const itemSchema = variableSchema.items;
113
389
 
114
390
  if (itemSchema) {
115
391
  variableValue.forEach((item, index) => {
116
- superRefineVariableValue(projectConfig, itemSchema, item, [...path, index], ctx, variableKey);
392
+ superRefineVariableValue(
393
+ projectConfig,
394
+ itemSchema,
395
+ item,
396
+ [...path, index],
397
+ ctx,
398
+ variableKey,
399
+ schemasByKey,
400
+ );
117
401
  });
118
402
  } else {
119
403
  if (!isArrayOfStrings(variableValue)) {
@@ -152,6 +436,7 @@ function refineVariableValueObject(
152
436
  path: (string | number)[],
153
437
  ctx: z.RefinementCtx,
154
438
  variableKey?: string,
439
+ schemasByKey?: Record<string, Schema>,
155
440
  ): void {
156
441
  const label = getVariableLabel(variableSchema, variableKey, path);
157
442
  const schemaProperties = variableSchema.properties;
@@ -190,6 +475,7 @@ function refineVariableValueObject(
190
475
  [...path, key],
191
476
  ctx,
192
477
  key,
478
+ schemasByKey,
193
479
  );
194
480
  }
195
481
  }
@@ -225,6 +511,7 @@ function superRefineVariableValue(
225
511
  path,
226
512
  ctx,
227
513
  variableKey?: string,
514
+ schemasByKey?: Record<string, Schema>,
228
515
  ) {
229
516
  const label = getVariableLabel(variableSchema, variableKey, path);
230
517
 
@@ -244,6 +531,66 @@ function superRefineVariableValue(
244
531
  return;
245
532
  }
246
533
 
534
+ const effectiveSchema = resolveVariableSchema(variableSchema, schemasByKey);
535
+ if (variableSchema.schema && effectiveSchema === null) {
536
+ ctx.addIssue({
537
+ code: z.ZodIssueCode.custom,
538
+ message: `Schema "${variableSchema.schema}" could not be loaded for value validation.`,
539
+ path,
540
+ });
541
+ return;
542
+ }
543
+
544
+ if (!effectiveSchema) {
545
+ return;
546
+ }
547
+
548
+ const effectiveOneOf = (effectiveSchema as { oneOf?: unknown[] }).oneOf;
549
+ if (effectiveOneOf !== undefined && Array.isArray(effectiveOneOf) && effectiveOneOf.length > 0) {
550
+ const matchCount = effectiveOneOf.filter((branch) =>
551
+ valueMatchesSchema(branch as { [k: string]: unknown }, variableValue, schemasByKey),
552
+ ).length;
553
+ if (matchCount === 0) {
554
+ ctx.addIssue({
555
+ code: z.ZodIssueCode.custom,
556
+ message: `Variable "${label}" must match exactly one of the \`oneOf\` schemas (got ${JSON.stringify(variableValue)}; matched none).`,
557
+ path,
558
+ });
559
+ } else if (matchCount > 1) {
560
+ ctx.addIssue({
561
+ code: z.ZodIssueCode.custom,
562
+ message: `Variable "${label}" must match exactly one of the \`oneOf\` schemas (matched ${matchCount}).`,
563
+ path,
564
+ });
565
+ }
566
+ return;
567
+ }
568
+
569
+ const effectiveConst = (effectiveSchema as { const?: unknown }).const;
570
+ if (effectiveConst !== undefined) {
571
+ if (!valueDeepEqual(variableValue, effectiveConst)) {
572
+ ctx.addIssue({
573
+ code: z.ZodIssueCode.custom,
574
+ message: `Variable "${label}" must equal the constant value defined in schema (got ${JSON.stringify(variableValue)}).`,
575
+ path,
576
+ });
577
+ }
578
+ return;
579
+ }
580
+
581
+ const effectiveEnum = (effectiveSchema as { enum?: unknown[] }).enum;
582
+ if (effectiveEnum !== undefined && Array.isArray(effectiveEnum) && effectiveEnum.length > 0) {
583
+ const allowed = effectiveEnum.some((v) => valueDeepEqual(variableValue, v));
584
+ if (!allowed) {
585
+ ctx.addIssue({
586
+ code: z.ZodIssueCode.custom,
587
+ message: `Variable "${label}" must be one of the allowed enum values (got ${JSON.stringify(variableValue)}).`,
588
+ path,
589
+ });
590
+ }
591
+ return;
592
+ }
593
+
247
594
  // Require a value (no undefined) for every variable usage
248
595
  if (variableValue === undefined) {
249
596
  ctx.addIssue({
@@ -254,10 +601,10 @@ function superRefineVariableValue(
254
601
  return;
255
602
  }
256
603
 
257
- const expectedType = variableSchema.type;
604
+ const expectedType = effectiveSchema.type;
258
605
  const gotType = typeOfValue(variableValue);
259
606
 
260
- // string — only string allowed
607
+ // string — only string allowed; schema minLength/maxLength/pattern applied when set
261
608
  if (expectedType === "string") {
262
609
  if (typeof variableValue !== "string") {
263
610
  ctx.addIssue({
@@ -268,6 +615,37 @@ function superRefineVariableValue(
268
615
  return;
269
616
  }
270
617
 
618
+ const strMinLen = (effectiveSchema as { minLength?: number }).minLength;
619
+ const strMaxLen = (effectiveSchema as { maxLength?: number }).maxLength;
620
+ const strPattern = (effectiveSchema as { pattern?: string }).pattern;
621
+ if (strMinLen !== undefined && variableValue.length < strMinLen) {
622
+ ctx.addIssue({
623
+ code: z.ZodIssueCode.custom,
624
+ message: `Variable "${label}" (type string) length (${variableValue.length}) is less than \`minLength\` (${strMinLen}).`,
625
+ path,
626
+ });
627
+ }
628
+ if (strMaxLen !== undefined && variableValue.length > strMaxLen) {
629
+ ctx.addIssue({
630
+ code: z.ZodIssueCode.custom,
631
+ message: `Variable "${label}" (type string) length (${variableValue.length}) is greater than \`maxLength\` (${strMaxLen}).`,
632
+ path,
633
+ });
634
+ }
635
+ if (strPattern !== undefined) {
636
+ try {
637
+ if (!new RegExp(strPattern).test(variableValue)) {
638
+ ctx.addIssue({
639
+ code: z.ZodIssueCode.custom,
640
+ message: `Variable "${label}" (type string) does not match \`pattern\`.`,
641
+ path,
642
+ });
643
+ }
644
+ } catch {
645
+ // invalid regex already reported at schema parse time
646
+ }
647
+ }
648
+
271
649
  if (
272
650
  projectConfig.maxVariableStringLength &&
273
651
  variableValue.length > projectConfig.maxVariableStringLength
@@ -306,6 +684,23 @@ function superRefineVariableValue(
306
684
  message: `Variable "${label}" (type integer) must be an integer; got ${variableValue}.`,
307
685
  path,
308
686
  });
687
+ return;
688
+ }
689
+ const intMin = (effectiveSchema as { minimum?: number }).minimum;
690
+ const intMax = (effectiveSchema as { maximum?: number }).maximum;
691
+ if (intMin !== undefined && variableValue < intMin) {
692
+ ctx.addIssue({
693
+ code: z.ZodIssueCode.custom,
694
+ message: `Variable "${label}" (type integer) must be >= minimum (${intMin}); got ${variableValue}.`,
695
+ path,
696
+ });
697
+ }
698
+ if (intMax !== undefined && variableValue > intMax) {
699
+ ctx.addIssue({
700
+ code: z.ZodIssueCode.custom,
701
+ message: `Variable "${label}" (type integer) must be <= maximum (${intMax}); got ${variableValue}.`,
702
+ path,
703
+ });
309
704
  }
310
705
  return;
311
706
  }
@@ -326,6 +721,23 @@ function superRefineVariableValue(
326
721
  message: `Variable "${label}" (type double) must be a finite number; got ${variableValue}.`,
327
722
  path,
328
723
  });
724
+ return;
725
+ }
726
+ const doubleMin = (effectiveSchema as { minimum?: number }).minimum;
727
+ const doubleMax = (effectiveSchema as { maximum?: number }).maximum;
728
+ if (doubleMin !== undefined && variableValue < doubleMin) {
729
+ ctx.addIssue({
730
+ code: z.ZodIssueCode.custom,
731
+ message: `Variable "${label}" (type double) must be >= minimum (${doubleMin}); got ${variableValue}.`,
732
+ path,
733
+ });
734
+ }
735
+ if (doubleMax !== undefined && variableValue > doubleMax) {
736
+ ctx.addIssue({
737
+ code: z.ZodIssueCode.custom,
738
+ message: `Variable "${label}" (type double) must be <= maximum (${doubleMax}); got ${variableValue}.`,
739
+ path,
740
+ });
329
741
  }
330
742
  return;
331
743
  }
@@ -352,7 +764,21 @@ function superRefineVariableValue(
352
764
  });
353
765
  return;
354
766
  }
355
- refineVariableValueArray(projectConfig, variableSchema, variableValue, path, ctx, variableKey);
767
+ refineVariableValueArray(
768
+ projectConfig,
769
+ effectiveSchema as {
770
+ items?: unknown;
771
+ type: string;
772
+ minItems?: number;
773
+ maxItems?: number;
774
+ uniqueItems?: boolean;
775
+ },
776
+ variableValue,
777
+ path,
778
+ ctx,
779
+ variableKey,
780
+ schemasByKey,
781
+ );
356
782
  return;
357
783
  }
358
784
 
@@ -372,11 +798,16 @@ function superRefineVariableValue(
372
798
  }
373
799
  refineVariableValueObject(
374
800
  projectConfig,
375
- variableSchema,
801
+ effectiveSchema as {
802
+ properties?: Record<string, unknown>;
803
+ required?: string[];
804
+ type: string;
805
+ },
376
806
  variableValue as Record<string, unknown>,
377
807
  path,
378
808
  ctx,
379
809
  variableKey,
810
+ schemasByKey,
380
811
  );
381
812
  return;
382
813
  }
@@ -431,6 +862,7 @@ function refineForce({
431
862
  force,
432
863
  pathPrefix,
433
864
  projectConfig,
865
+ schemasByKey,
434
866
  }) {
435
867
  force.forEach((f, fN) => {
436
868
  // force[n].variation
@@ -447,14 +879,24 @@ function refineForce({
447
879
  // force[n].variables[key]
448
880
  if (f.variables) {
449
881
  Object.keys(f.variables).forEach((variableKey) => {
450
- superRefineVariableValue(
451
- projectConfig,
452
- variableSchemaByKey[variableKey],
453
- f.variables[variableKey],
454
- pathPrefix.concat([fN, "variables", variableKey]),
455
- ctx,
456
- variableKey,
457
- );
882
+ const variableSchema = variableSchemaByKey[variableKey];
883
+ if (!variableSchema) {
884
+ ctx.addIssue({
885
+ code: z.ZodIssueCode.custom,
886
+ message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
887
+ path: pathPrefix.concat([fN, "variables", variableKey]),
888
+ });
889
+ } else {
890
+ superRefineVariableValue(
891
+ projectConfig,
892
+ variableSchema,
893
+ f.variables[variableKey],
894
+ pathPrefix.concat([fN, "variables", variableKey]),
895
+ ctx,
896
+ variableKey,
897
+ schemasByKey,
898
+ );
899
+ }
458
900
  });
459
901
  }
460
902
  });
@@ -468,19 +910,30 @@ function refineRules({
468
910
  rules,
469
911
  pathPrefix,
470
912
  projectConfig,
913
+ schemasByKey,
471
914
  }) {
472
915
  rules.forEach((rule, ruleN) => {
473
916
  // rules[n].variables[key]
474
917
  if (rule.variables) {
475
918
  Object.keys(rule.variables).forEach((variableKey) => {
476
- superRefineVariableValue(
477
- projectConfig,
478
- variableSchemaByKey[variableKey],
479
- rule.variables[variableKey],
480
- pathPrefix.concat([ruleN, "variables", variableKey]),
481
- ctx,
482
- variableKey,
483
- );
919
+ const variableSchema = variableSchemaByKey[variableKey];
920
+ if (!variableSchema) {
921
+ ctx.addIssue({
922
+ code: z.ZodIssueCode.custom,
923
+ message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
924
+ path: pathPrefix.concat([ruleN, "variables", variableKey]),
925
+ });
926
+ } else {
927
+ superRefineVariableValue(
928
+ projectConfig,
929
+ variableSchema,
930
+ rule.variables[variableKey],
931
+ pathPrefix.concat([ruleN, "variables", variableKey]),
932
+ ctx,
933
+ variableKey,
934
+ schemasByKey,
935
+ );
936
+ }
484
937
  });
485
938
  }
486
939
 
@@ -556,8 +1009,10 @@ export function getFeatureZodSchema(
556
1009
  availableAttributeKeys: [string, ...string[]],
557
1010
  availableSegmentKeys: [string, ...string[]],
558
1011
  availableFeatureKeys: [string, ...string[]],
1012
+ availableSchemaKeys: string[] = [],
1013
+ schemasByKey: Record<string, Schema> = {},
559
1014
  ) {
560
- const propertyZodSchema = getPropertyZodSchema();
1015
+ const schemaZodSchema = getSchemaZodSchema(availableSchemaKeys);
561
1016
  const variableValueZodSchema = valueZodSchema;
562
1017
 
563
1018
  const variationValueZodSchema = z.string().min(1);
@@ -739,13 +1194,31 @@ export function getFeatureZodSchema(
739
1194
  .object({
740
1195
  deprecated: z.boolean().optional(),
741
1196
 
742
- type: z.union([z.literal("json"), propertyTypeEnum]),
743
- // array: when omitted, treated as array of strings
744
- items: propertyZodSchema.optional(),
745
- // object: when omitted, treated as flat object (primitive values only)
746
- properties: z.record(propertyZodSchema).optional(),
747
- // object: optional list of required property names
1197
+ // Reference to a reusable schema (mutually exclusive with type/properties/required/items)
1198
+ schema: z
1199
+ .string()
1200
+ .refine(
1201
+ (value) => availableSchemaKeys.includes(value),
1202
+ (value) => ({ message: `Unknown schema "${value}"` }),
1203
+ )
1204
+ .optional(),
1205
+
1206
+ // Inline schema (mutually exclusive with schema)
1207
+ type: z.union([z.literal("json"), propertyTypeEnum]).optional(),
1208
+ items: schemaZodSchema.optional(),
1209
+ properties: z.record(schemaZodSchema).optional(),
748
1210
  required: z.array(z.string()).optional(),
1211
+ enum: z.array(variableValueZodSchema).optional(),
1212
+ const: variableValueZodSchema.optional(),
1213
+ oneOf: z.array(schemaZodSchema).min(1).optional(),
1214
+ minimum: z.number().optional(),
1215
+ maximum: z.number().optional(),
1216
+ minLength: z.number().optional(),
1217
+ maxLength: z.number().optional(),
1218
+ pattern: z.string().optional(),
1219
+ minItems: z.number().optional(),
1220
+ maxItems: z.number().optional(),
1221
+ uniqueItems: z.boolean().optional(),
749
1222
 
750
1223
  description: z.string().optional(),
751
1224
 
@@ -756,6 +1229,76 @@ export function getFeatureZodSchema(
756
1229
  })
757
1230
  .strict()
758
1231
  .superRefine((variableSchema, ctx) => {
1232
+ const hasRef = "schema" in variableSchema && variableSchema.schema != null;
1233
+ const hasInline =
1234
+ "type" in variableSchema &&
1235
+ variableSchema.type != null &&
1236
+ variableSchema.type !== undefined;
1237
+ const hasOneOf =
1238
+ "oneOf" in variableSchema &&
1239
+ Array.isArray(variableSchema.oneOf) &&
1240
+ variableSchema.oneOf.length > 0;
1241
+ if (hasRef && (hasInline || hasOneOf)) {
1242
+ ctx.addIssue({
1243
+ code: z.ZodIssueCode.custom,
1244
+ message:
1245
+ "Variable schema cannot have both `schema` (reference) and inline properties (`type`, `oneOf`, `properties`, `required`, `items`). Use one or the other.",
1246
+ path: [],
1247
+ });
1248
+ return;
1249
+ }
1250
+ if (hasRef) {
1251
+ const hasInlineStructure =
1252
+ ("type" in variableSchema && variableSchema.type != null) ||
1253
+ ("properties" in variableSchema && variableSchema.properties != null) ||
1254
+ ("required" in variableSchema && variableSchema.required != null) ||
1255
+ ("items" in variableSchema && variableSchema.items != null) ||
1256
+ ("oneOf" in variableSchema && variableSchema.oneOf != null);
1257
+ const hasInlineValidation =
1258
+ ("minimum" in variableSchema && variableSchema.minimum !== undefined) ||
1259
+ ("maximum" in variableSchema && variableSchema.maximum !== undefined) ||
1260
+ ("minLength" in variableSchema && variableSchema.minLength !== undefined) ||
1261
+ ("maxLength" in variableSchema && variableSchema.maxLength !== undefined) ||
1262
+ ("pattern" in variableSchema && variableSchema.pattern !== undefined) ||
1263
+ ("minItems" in variableSchema && variableSchema.minItems !== undefined) ||
1264
+ ("maxItems" in variableSchema && variableSchema.maxItems !== undefined) ||
1265
+ ("uniqueItems" in variableSchema && variableSchema.uniqueItems !== undefined);
1266
+ if (hasInlineStructure) {
1267
+ ctx.addIssue({
1268
+ code: z.ZodIssueCode.custom,
1269
+ message:
1270
+ "When `schema` is set, do not set `type`, `oneOf`, `properties`, `required`, or `items`.",
1271
+ path: [],
1272
+ });
1273
+ }
1274
+ if (hasInlineValidation) {
1275
+ ctx.addIssue({
1276
+ code: z.ZodIssueCode.custom,
1277
+ message:
1278
+ "When `schema` is set, do not set `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `minItems`, `maxItems`, or `uniqueItems`; use the referenced schema to define these.",
1279
+ path: [],
1280
+ });
1281
+ }
1282
+ return;
1283
+ }
1284
+ if (!hasInline && !hasOneOf) {
1285
+ ctx.addIssue({
1286
+ code: z.ZodIssueCode.custom,
1287
+ message:
1288
+ "Variable schema must have either `schema` (reference to a schema key), `type` (inline schema), or `oneOf` (inline oneOf schemas).",
1289
+ path: [],
1290
+ });
1291
+ return;
1292
+ }
1293
+ if (hasInline && hasOneOf) {
1294
+ ctx.addIssue({
1295
+ code: z.ZodIssueCode.custom,
1296
+ message:
1297
+ "Variable schema cannot have both `type` and `oneOf` at the top level. Use one or the other.",
1298
+ path: [],
1299
+ });
1300
+ return;
1301
+ }
759
1302
  // Validate required ⊆ properties at this level and in all nested object schemas
760
1303
  refineRequiredKeysInSchema(
761
1304
  variableSchema as Parameters<typeof refineRequiredKeysInSchema>[0],
@@ -852,6 +1395,10 @@ export function getFeatureZodSchema(
852
1395
  return;
853
1396
  }
854
1397
 
1398
+ // Every variable value is validated against its schema from variablesSchema. Sources covered:
1399
+ // 1. variablesSchema[key].defaultValue 2. variablesSchema[key].disabledValue
1400
+ // 3. variations[n].variables[key] 4. variations[n].variableOverrides[key][].value
1401
+ // 5. rules[env][n].variables[key] 6. force[env][n].variables[key]
855
1402
  const variableSchemaByKey = value.variablesSchema;
856
1403
  const variationValues: string[] = [];
857
1404
 
@@ -866,6 +1413,41 @@ export function getFeatureZodSchema(
866
1413
  variableKeys.forEach((variableKey) => {
867
1414
  const variableSchema = variableSchemaByKey[variableKey];
868
1415
 
1416
+ // When type and enum are both present, all enum values must match the type
1417
+ const effectiveSchema = resolveVariableSchema(variableSchema, schemasByKey);
1418
+ if (
1419
+ effectiveSchema &&
1420
+ effectiveSchema.type &&
1421
+ Array.isArray(effectiveSchema.enum) &&
1422
+ effectiveSchema.enum.length > 0
1423
+ ) {
1424
+ refineEnumMatchesType(
1425
+ effectiveSchema as Parameters<typeof refineEnumMatchesType>[0],
1426
+ ["variablesSchema", variableKey],
1427
+ ctx,
1428
+ );
1429
+ }
1430
+
1431
+ // Inline variable schemas: validate minimum/maximum, minLength/maxLength/pattern, minItems/maxItems/uniqueItems
1432
+ if (!("schema" in variableSchema) || !variableSchema.schema) {
1433
+ const pathPrefix = ["variablesSchema", variableKey];
1434
+ refineMinimumMaximum(
1435
+ variableSchema as Parameters<typeof refineMinimumMaximum>[0],
1436
+ pathPrefix,
1437
+ ctx,
1438
+ );
1439
+ refineStringLengthPattern(
1440
+ variableSchema as Parameters<typeof refineStringLengthPattern>[0],
1441
+ pathPrefix,
1442
+ ctx,
1443
+ );
1444
+ refineArrayItems(
1445
+ variableSchema as Parameters<typeof refineArrayItems>[0],
1446
+ pathPrefix,
1447
+ ctx,
1448
+ );
1449
+ }
1450
+
869
1451
  if (variableKey === "variation") {
870
1452
  ctx.addIssue({
871
1453
  code: z.ZodIssueCode.custom,
@@ -882,6 +1464,7 @@ export function getFeatureZodSchema(
882
1464
  ["variablesSchema", variableKey, "defaultValue"],
883
1465
  ctx,
884
1466
  variableKey,
1467
+ schemasByKey,
885
1468
  );
886
1469
 
887
1470
  // disabledValue (only when present)
@@ -893,54 +1476,69 @@ export function getFeatureZodSchema(
893
1476
  ["variablesSchema", variableKey, "disabledValue"],
894
1477
  ctx,
895
1478
  variableKey,
1479
+ schemasByKey,
896
1480
  );
897
1481
  }
898
1482
  });
899
1483
 
900
- // variations
1484
+ // variations: validate variation.variables and variation.variableOverrides (each value against its variable schema)
901
1485
  if (value.variations) {
902
1486
  value.variations.forEach((variation, variationN) => {
903
- if (!variation.variables) {
904
- return;
905
- }
906
-
907
1487
  // variations[n].variables[key]
908
- for (const variableKey of Object.keys(variation.variables)) {
909
- const variableValue = variation.variables[variableKey];
1488
+ if (variation.variables) {
1489
+ for (const variableKey of Object.keys(variation.variables)) {
1490
+ const variableValue = variation.variables[variableKey];
1491
+ const variableSchema = variableSchemaByKey[variableKey];
1492
+ if (!variableSchema) {
1493
+ ctx.addIssue({
1494
+ code: z.ZodIssueCode.custom,
1495
+ message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
1496
+ path: ["variations", variationN, "variables", variableKey],
1497
+ });
1498
+ } else {
1499
+ superRefineVariableValue(
1500
+ projectConfig,
1501
+ variableSchema,
1502
+ variableValue,
1503
+ ["variations", variationN, "variables", variableKey],
1504
+ ctx,
1505
+ variableKey,
1506
+ schemasByKey,
1507
+ );
1508
+ }
1509
+ }
1510
+ }
910
1511
 
911
- superRefineVariableValue(
912
- projectConfig,
913
- variableSchemaByKey[variableKey],
914
- variableValue,
915
- ["variations", variationN, "variables", variableKey],
916
- ctx,
917
- variableKey,
918
- );
919
-
920
- // variations[n].variableOverrides[n].value
921
- if (variation.variableOverrides) {
922
- for (const variableKey of Object.keys(variation.variableOverrides)) {
923
- const overrides = variation.variableOverrides[variableKey];
924
-
925
- if (Array.isArray(overrides)) {
926
- overrides.forEach((override, overrideN) => {
927
- superRefineVariableValue(
928
- projectConfig,
929
- variableSchemaByKey[variableKey],
930
- override.value,
931
- [
932
- "variations",
933
- variationN,
934
- "variableOverrides",
935
- variableKey,
936
- overrideN,
937
- "value",
938
- ],
939
- ctx,
1512
+ // variations[n].variableOverrides[key][].value (validated even when variation.variables is absent)
1513
+ if (variation.variableOverrides) {
1514
+ for (const variableKey of Object.keys(variation.variableOverrides)) {
1515
+ const overrides = variation.variableOverrides[variableKey];
1516
+ const variableSchema = variableSchemaByKey[variableKey];
1517
+ if (!variableSchema) {
1518
+ ctx.addIssue({
1519
+ code: z.ZodIssueCode.custom,
1520
+ message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
1521
+ path: ["variations", variationN, "variableOverrides", variableKey],
1522
+ });
1523
+ } else if (Array.isArray(overrides)) {
1524
+ overrides.forEach((override, overrideN) => {
1525
+ superRefineVariableValue(
1526
+ projectConfig,
1527
+ variableSchema,
1528
+ override.value,
1529
+ [
1530
+ "variations",
1531
+ variationN,
1532
+ "variableOverrides",
940
1533
  variableKey,
941
- );
942
- });
943
- }
1534
+ overrideN,
1535
+ "value",
1536
+ ],
1537
+ ctx,
1538
+ variableKey,
1539
+ schemasByKey,
1540
+ );
1541
+ });
944
1542
  }
945
1543
  }
946
1544
  }
@@ -960,6 +1558,7 @@ export function getFeatureZodSchema(
960
1558
  pathPrefix: ["rules", environmentKey],
961
1559
  ctx,
962
1560
  projectConfig,
1561
+ schemasByKey,
963
1562
  });
964
1563
  }
965
1564
 
@@ -973,6 +1572,7 @@ export function getFeatureZodSchema(
973
1572
  pathPrefix: ["force", environmentKey],
974
1573
  ctx,
975
1574
  projectConfig,
1575
+ schemasByKey,
976
1576
  });
977
1577
  }
978
1578
  }
@@ -989,6 +1589,7 @@ export function getFeatureZodSchema(
989
1589
  pathPrefix: ["rules"],
990
1590
  ctx,
991
1591
  projectConfig,
1592
+ schemasByKey,
992
1593
  });
993
1594
  }
994
1595
 
@@ -1002,6 +1603,7 @@ export function getFeatureZodSchema(
1002
1603
  pathPrefix: ["force"],
1003
1604
  ctx,
1004
1605
  projectConfig,
1606
+ schemasByKey,
1005
1607
  });
1006
1608
  }
1007
1609
  }