@featurevisor/core 2.10.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 +22 -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 +283 -49
  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 +153 -17
  41. package/lib/linter/featureSchema.js +536 -49
  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 +333 -52
  67. package/src/linter/conditionSchema.spec.ts +446 -0
  68. package/src/linter/featureSchema.spec.ts +1218 -0
  69. package/src/linter/featureSchema.ts +747 -70
  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,297 @@ 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
+
253
+ /**
254
+ * Recursively validates that every `required` array (at this level and in nested
255
+ * object/array schemas) only contains keys that exist in the same level's `properties`.
256
+ * Adds Zod issues with the correct path for invalid required field names.
257
+ */
258
+ function refineRequiredKeysInSchema(
259
+ schema: {
260
+ type?: string;
261
+ properties?: Record<string, unknown>;
262
+ required?: string[];
263
+ items?: unknown;
264
+ },
265
+ pathPrefix: (string | number)[],
266
+ ctx: z.RefinementCtx,
267
+ ): void {
268
+ if (!schema || typeof schema !== "object") return;
269
+
270
+ const effectiveType = schema.type;
271
+ const properties = schema.properties;
272
+ const required = schema.required;
273
+ const items = schema.items;
274
+
275
+ if (
276
+ effectiveType === "object" &&
277
+ Array.isArray(required) &&
278
+ required.length > 0 &&
279
+ properties &&
280
+ typeof properties === "object"
281
+ ) {
282
+ const allowedKeys = Object.keys(properties);
283
+ required.forEach((key, index) => {
284
+ if (!allowedKeys.includes(key)) {
285
+ ctx.addIssue({
286
+ code: z.ZodIssueCode.custom,
287
+ message: `Unknown required field "${key}". \`required\` must only contain property names defined in \`properties\`. Allowed: ${allowedKeys.length ? allowedKeys.join(", ") : "(none)"}.`,
288
+ path: [...pathPrefix, "required", index],
289
+ });
290
+ }
291
+ });
292
+ }
293
+
294
+ if (properties && typeof properties === "object") {
295
+ for (const key of Object.keys(properties)) {
296
+ const nested = properties[key];
297
+ if (nested && typeof nested === "object") {
298
+ refineRequiredKeysInSchema(
299
+ nested as Parameters<typeof refineRequiredKeysInSchema>[0],
300
+ [...pathPrefix, "properties", key],
301
+ ctx,
302
+ );
303
+ }
304
+ }
305
+ }
306
+
307
+ if (items && typeof items === "object" && !Array.isArray(items)) {
308
+ refineRequiredKeysInSchema(
309
+ items as Parameters<typeof refineRequiredKeysInSchema>[0],
310
+ [...pathPrefix, "items"],
311
+ ctx,
312
+ );
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
+ }
327
+ }
328
+
29
329
  function typeOfValue(value: unknown): string {
30
330
  if (value === null) return "null";
31
331
  if (value === undefined) return "undefined";
@@ -36,21 +336,68 @@ function typeOfValue(value: unknown): string {
36
336
  /**
37
337
  * Validates a variable value against an array schema. Recursively validates each item
38
338
  * when the schema defines `items` (nested arrays/objects use the same refinement).
339
+ * Enforces minItems, maxItems, and uniqueItems when set.
39
340
  */
40
341
  function refineVariableValueArray(
41
342
  projectConfig: ProjectConfig,
42
- variableSchema: { items?: unknown; type: string },
343
+ variableSchema: {
344
+ items?: unknown;
345
+ type: string;
346
+ minItems?: number;
347
+ maxItems?: number;
348
+ uniqueItems?: boolean;
349
+ },
43
350
  variableValue: unknown[],
44
351
  path: (string | number)[],
45
352
  ctx: z.RefinementCtx,
46
353
  variableKey?: string,
354
+ schemasByKey?: Record<string, Schema>,
47
355
  ): void {
48
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
+ }
49
388
  const itemSchema = variableSchema.items;
50
389
 
51
390
  if (itemSchema) {
52
391
  variableValue.forEach((item, index) => {
53
- 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
+ );
54
401
  });
55
402
  } else {
56
403
  if (!isArrayOfStrings(variableValue)) {
@@ -89,6 +436,7 @@ function refineVariableValueObject(
89
436
  path: (string | number)[],
90
437
  ctx: z.RefinementCtx,
91
438
  variableKey?: string,
439
+ schemasByKey?: Record<string, Schema>,
92
440
  ): void {
93
441
  const label = getVariableLabel(variableSchema, variableKey, path);
94
442
  const schemaProperties = variableSchema.properties;
@@ -96,7 +444,9 @@ function refineVariableValueObject(
96
444
  if (schemaProperties && typeof schemaProperties === "object") {
97
445
  const requiredKeys =
98
446
  variableSchema.required && variableSchema.required.length > 0
99
- ? variableSchema.required
447
+ ? variableSchema.required.filter((k) =>
448
+ Object.prototype.hasOwnProperty.call(schemaProperties, k),
449
+ )
100
450
  : Object.keys(schemaProperties);
101
451
 
102
452
  for (const key of requiredKeys) {
@@ -125,6 +475,7 @@ function refineVariableValueObject(
125
475
  [...path, key],
126
476
  ctx,
127
477
  key,
478
+ schemasByKey,
128
479
  );
129
480
  }
130
481
  }
@@ -160,6 +511,7 @@ function superRefineVariableValue(
160
511
  path,
161
512
  ctx,
162
513
  variableKey?: string,
514
+ schemasByKey?: Record<string, Schema>,
163
515
  ) {
164
516
  const label = getVariableLabel(variableSchema, variableKey, path);
165
517
 
@@ -179,6 +531,66 @@ function superRefineVariableValue(
179
531
  return;
180
532
  }
181
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
+
182
594
  // Require a value (no undefined) for every variable usage
183
595
  if (variableValue === undefined) {
184
596
  ctx.addIssue({
@@ -189,10 +601,10 @@ function superRefineVariableValue(
189
601
  return;
190
602
  }
191
603
 
192
- const expectedType = variableSchema.type;
604
+ const expectedType = effectiveSchema.type;
193
605
  const gotType = typeOfValue(variableValue);
194
606
 
195
- // string — only string allowed
607
+ // string — only string allowed; schema minLength/maxLength/pattern applied when set
196
608
  if (expectedType === "string") {
197
609
  if (typeof variableValue !== "string") {
198
610
  ctx.addIssue({
@@ -203,6 +615,37 @@ function superRefineVariableValue(
203
615
  return;
204
616
  }
205
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
+
206
649
  if (
207
650
  projectConfig.maxVariableStringLength &&
208
651
  variableValue.length > projectConfig.maxVariableStringLength
@@ -241,6 +684,23 @@ function superRefineVariableValue(
241
684
  message: `Variable "${label}" (type integer) must be an integer; got ${variableValue}.`,
242
685
  path,
243
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
+ });
244
704
  }
245
705
  return;
246
706
  }
@@ -261,6 +721,23 @@ function superRefineVariableValue(
261
721
  message: `Variable "${label}" (type double) must be a finite number; got ${variableValue}.`,
262
722
  path,
263
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
+ });
264
741
  }
265
742
  return;
266
743
  }
@@ -287,7 +764,21 @@ function superRefineVariableValue(
287
764
  });
288
765
  return;
289
766
  }
290
- 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
+ );
291
782
  return;
292
783
  }
293
784
 
@@ -307,11 +798,16 @@ function superRefineVariableValue(
307
798
  }
308
799
  refineVariableValueObject(
309
800
  projectConfig,
310
- variableSchema,
801
+ effectiveSchema as {
802
+ properties?: Record<string, unknown>;
803
+ required?: string[];
804
+ type: string;
805
+ },
311
806
  variableValue as Record<string, unknown>,
312
807
  path,
313
808
  ctx,
314
809
  variableKey,
810
+ schemasByKey,
315
811
  );
316
812
  return;
317
813
  }
@@ -366,6 +862,7 @@ function refineForce({
366
862
  force,
367
863
  pathPrefix,
368
864
  projectConfig,
865
+ schemasByKey,
369
866
  }) {
370
867
  force.forEach((f, fN) => {
371
868
  // force[n].variation
@@ -382,14 +879,24 @@ function refineForce({
382
879
  // force[n].variables[key]
383
880
  if (f.variables) {
384
881
  Object.keys(f.variables).forEach((variableKey) => {
385
- superRefineVariableValue(
386
- projectConfig,
387
- variableSchemaByKey[variableKey],
388
- f.variables[variableKey],
389
- pathPrefix.concat([fN, "variables", variableKey]),
390
- ctx,
391
- variableKey,
392
- );
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
+ }
393
900
  });
394
901
  }
395
902
  });
@@ -403,19 +910,30 @@ function refineRules({
403
910
  rules,
404
911
  pathPrefix,
405
912
  projectConfig,
913
+ schemasByKey,
406
914
  }) {
407
915
  rules.forEach((rule, ruleN) => {
408
916
  // rules[n].variables[key]
409
917
  if (rule.variables) {
410
918
  Object.keys(rule.variables).forEach((variableKey) => {
411
- superRefineVariableValue(
412
- projectConfig,
413
- variableSchemaByKey[variableKey],
414
- rule.variables[variableKey],
415
- pathPrefix.concat([ruleN, "variables", variableKey]),
416
- ctx,
417
- variableKey,
418
- );
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
+ }
419
937
  });
420
938
  }
421
939
 
@@ -491,8 +1009,10 @@ export function getFeatureZodSchema(
491
1009
  availableAttributeKeys: [string, ...string[]],
492
1010
  availableSegmentKeys: [string, ...string[]],
493
1011
  availableFeatureKeys: [string, ...string[]],
1012
+ availableSchemaKeys: string[] = [],
1013
+ schemasByKey: Record<string, Schema> = {},
494
1014
  ) {
495
- const propertyZodSchema = getPropertyZodSchema();
1015
+ const schemaZodSchema = getSchemaZodSchema(availableSchemaKeys);
496
1016
  const variableValueZodSchema = valueZodSchema;
497
1017
 
498
1018
  const variationValueZodSchema = z.string().min(1);
@@ -674,11 +1194,31 @@ export function getFeatureZodSchema(
674
1194
  .object({
675
1195
  deprecated: z.boolean().optional(),
676
1196
 
677
- type: z.union([z.literal("json"), propertyTypeEnum]),
678
- // array: when omitted, treated as array of strings
679
- items: propertyZodSchema.optional(),
680
- // object: when omitted, treated as flat object (primitive values only)
681
- properties: z.record(propertyZodSchema).optional(),
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(),
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(),
682
1222
 
683
1223
  description: z.string().optional(),
684
1224
 
@@ -687,7 +1227,85 @@ export function getFeatureZodSchema(
687
1227
 
688
1228
  useDefaultWhenDisabled: z.boolean().optional(),
689
1229
  })
690
- .strict(),
1230
+ .strict()
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
+ }
1302
+ // Validate required ⊆ properties at this level and in all nested object schemas
1303
+ refineRequiredKeysInSchema(
1304
+ variableSchema as Parameters<typeof refineRequiredKeysInSchema>[0],
1305
+ [],
1306
+ ctx,
1307
+ );
1308
+ }),
691
1309
  )
692
1310
  .optional(),
693
1311
 
@@ -777,6 +1395,10 @@ export function getFeatureZodSchema(
777
1395
  return;
778
1396
  }
779
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]
780
1402
  const variableSchemaByKey = value.variablesSchema;
781
1403
  const variationValues: string[] = [];
782
1404
 
@@ -791,6 +1413,41 @@ export function getFeatureZodSchema(
791
1413
  variableKeys.forEach((variableKey) => {
792
1414
  const variableSchema = variableSchemaByKey[variableKey];
793
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
+
794
1451
  if (variableKey === "variation") {
795
1452
  ctx.addIssue({
796
1453
  code: z.ZodIssueCode.custom,
@@ -807,6 +1464,7 @@ export function getFeatureZodSchema(
807
1464
  ["variablesSchema", variableKey, "defaultValue"],
808
1465
  ctx,
809
1466
  variableKey,
1467
+ schemasByKey,
810
1468
  );
811
1469
 
812
1470
  // disabledValue (only when present)
@@ -818,54 +1476,69 @@ export function getFeatureZodSchema(
818
1476
  ["variablesSchema", variableKey, "disabledValue"],
819
1477
  ctx,
820
1478
  variableKey,
1479
+ schemasByKey,
821
1480
  );
822
1481
  }
823
1482
  });
824
1483
 
825
- // variations
1484
+ // variations: validate variation.variables and variation.variableOverrides (each value against its variable schema)
826
1485
  if (value.variations) {
827
1486
  value.variations.forEach((variation, variationN) => {
828
- if (!variation.variables) {
829
- return;
830
- }
831
-
832
1487
  // variations[n].variables[key]
833
- for (const variableKey of Object.keys(variation.variables)) {
834
- 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
+ }
835
1511
 
836
- superRefineVariableValue(
837
- projectConfig,
838
- variableSchemaByKey[variableKey],
839
- variableValue,
840
- ["variations", variationN, "variables", variableKey],
841
- ctx,
842
- variableKey,
843
- );
844
-
845
- // variations[n].variableOverrides[n].value
846
- if (variation.variableOverrides) {
847
- for (const variableKey of Object.keys(variation.variableOverrides)) {
848
- const overrides = variation.variableOverrides[variableKey];
849
-
850
- if (Array.isArray(overrides)) {
851
- overrides.forEach((override, overrideN) => {
852
- superRefineVariableValue(
853
- projectConfig,
854
- variableSchemaByKey[variableKey],
855
- override.value,
856
- [
857
- "variations",
858
- variationN,
859
- "variableOverrides",
860
- variableKey,
861
- overrideN,
862
- "value",
863
- ],
864
- 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",
865
1533
  variableKey,
866
- );
867
- });
868
- }
1534
+ overrideN,
1535
+ "value",
1536
+ ],
1537
+ ctx,
1538
+ variableKey,
1539
+ schemasByKey,
1540
+ );
1541
+ });
869
1542
  }
870
1543
  }
871
1544
  }
@@ -885,6 +1558,7 @@ export function getFeatureZodSchema(
885
1558
  pathPrefix: ["rules", environmentKey],
886
1559
  ctx,
887
1560
  projectConfig,
1561
+ schemasByKey,
888
1562
  });
889
1563
  }
890
1564
 
@@ -898,6 +1572,7 @@ export function getFeatureZodSchema(
898
1572
  pathPrefix: ["force", environmentKey],
899
1573
  ctx,
900
1574
  projectConfig,
1575
+ schemasByKey,
901
1576
  });
902
1577
  }
903
1578
  }
@@ -914,6 +1589,7 @@ export function getFeatureZodSchema(
914
1589
  pathPrefix: ["rules"],
915
1590
  ctx,
916
1591
  projectConfig,
1592
+ schemasByKey,
917
1593
  });
918
1594
  }
919
1595
 
@@ -927,6 +1603,7 @@ export function getFeatureZodSchema(
927
1603
  pathPrefix: ["force"],
928
1604
  ctx,
929
1605
  projectConfig,
1606
+ schemasByKey,
930
1607
  });
931
1608
  }
932
1609
  }