@featurevisor/core 2.9.0 → 2.10.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 (29) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +2 -2
  3. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  4. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  7. package/coverage/lcov-report/builder/index.html +1 -1
  8. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  9. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  10. package/coverage/lcov-report/index.html +1 -1
  11. package/coverage/lcov-report/list/index.html +1 -1
  12. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  13. package/coverage/lcov-report/parsers/index.html +1 -1
  14. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  15. package/coverage/lcov-report/parsers/yml.ts.html +1 -1
  16. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  17. package/coverage/lcov-report/tester/index.html +1 -1
  18. package/lib/generate-code/typescript.js +150 -16
  19. package/lib/generate-code/typescript.js.map +1 -1
  20. package/lib/linter/featureSchema.d.ts +114 -100
  21. package/lib/linter/featureSchema.js +222 -80
  22. package/lib/linter/featureSchema.js.map +1 -1
  23. package/lib/linter/propertySchema.d.ts +5 -0
  24. package/lib/linter/propertySchema.js +43 -0
  25. package/lib/linter/propertySchema.js.map +1 -0
  26. package/package.json +5 -5
  27. package/src/generate-code/typescript.ts +168 -18
  28. package/src/linter/featureSchema.ts +282 -95
  29. package/src/linter/propertySchema.ts +47 -0
@@ -1,23 +1,156 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import { ProjectConfig } from "../config";
4
+ import { valueZodSchema, propertyTypeEnum, getPropertyZodSchema } from "./propertySchema";
4
5
 
5
6
  const tagRegex = /^[a-z0-9-]+$/;
6
7
 
7
- function isFlatObject(value) {
8
- let isFlat = true;
8
+ function isArrayOfStrings(value: unknown): value is string[] {
9
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
10
+ }
11
+
12
+ function isFlatObjectValue(value: unknown): boolean {
13
+ return (
14
+ value === null ||
15
+ typeof value === "string" ||
16
+ typeof value === "number" ||
17
+ typeof value === "boolean"
18
+ );
19
+ }
20
+
21
+ function getVariableLabel(variableSchema, variableKey, path) {
22
+ return (
23
+ variableKey ??
24
+ variableSchema?.key ??
25
+ (path.length > 0 ? String(path[path.length - 1]) : "variable")
26
+ );
27
+ }
28
+
29
+ function typeOfValue(value: unknown): string {
30
+ if (value === null) return "null";
31
+ if (value === undefined) return "undefined";
32
+ if (Array.isArray(value)) return "array";
33
+ return typeof value;
34
+ }
9
35
 
10
- Object.keys(value).forEach((key) => {
11
- if (typeof value[key] === "object") {
12
- isFlat = false;
36
+ /**
37
+ * Validates a variable value against an array schema. Recursively validates each item
38
+ * when the schema defines `items` (nested arrays/objects use the same refinement).
39
+ */
40
+ function refineVariableValueArray(
41
+ projectConfig: ProjectConfig,
42
+ variableSchema: { items?: unknown; type: string },
43
+ variableValue: unknown[],
44
+ path: (string | number)[],
45
+ ctx: z.RefinementCtx,
46
+ variableKey?: string,
47
+ ): void {
48
+ const label = getVariableLabel(variableSchema, variableKey, path);
49
+ const itemSchema = variableSchema.items;
50
+
51
+ if (itemSchema) {
52
+ variableValue.forEach((item, index) => {
53
+ superRefineVariableValue(projectConfig, itemSchema, item, [...path, index], ctx, variableKey);
54
+ });
55
+ } else {
56
+ if (!isArrayOfStrings(variableValue)) {
57
+ ctx.addIssue({
58
+ code: z.ZodIssueCode.custom,
59
+ message: `Variable "${label}" (type array): when \`items\` is not set, array must contain only strings; found non-string element.`,
60
+ path,
61
+ });
13
62
  }
14
- });
63
+ }
15
64
 
16
- return isFlat;
65
+ if (projectConfig.maxVariableArrayStringifiedLength) {
66
+ const stringified = JSON.stringify(variableValue);
67
+ if (stringified.length > projectConfig.maxVariableArrayStringifiedLength) {
68
+ ctx.addIssue({
69
+ code: z.ZodIssueCode.custom,
70
+ message: `Variable "${label}" array is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableArrayStringifiedLength}`,
71
+ path,
72
+ });
73
+ }
74
+ }
17
75
  }
18
76
 
19
- function isArrayOfStrings(value) {
20
- return Array.isArray(value) && value.every((v) => typeof v === "string");
77
+ /**
78
+ * Validates a variable value against an object schema. Recursively validates each property
79
+ * when the schema defines `properties` (nested objects/arrays use the same refinement).
80
+ */
81
+ function refineVariableValueObject(
82
+ projectConfig: ProjectConfig,
83
+ variableSchema: {
84
+ properties?: Record<string, unknown>;
85
+ required?: string[];
86
+ type: string;
87
+ },
88
+ variableValue: Record<string, unknown>,
89
+ path: (string | number)[],
90
+ ctx: z.RefinementCtx,
91
+ variableKey?: string,
92
+ ): void {
93
+ const label = getVariableLabel(variableSchema, variableKey, path);
94
+ const schemaProperties = variableSchema.properties;
95
+
96
+ if (schemaProperties && typeof schemaProperties === "object") {
97
+ const requiredKeys =
98
+ variableSchema.required && variableSchema.required.length > 0
99
+ ? variableSchema.required
100
+ : Object.keys(schemaProperties);
101
+
102
+ for (const key of requiredKeys) {
103
+ if (!Object.prototype.hasOwnProperty.call(variableValue, key)) {
104
+ ctx.addIssue({
105
+ code: z.ZodIssueCode.custom,
106
+ message: `Missing required property "${key}" in variable "${label}"`,
107
+ path: [...path, key],
108
+ });
109
+ }
110
+ }
111
+
112
+ for (const key of Object.keys(variableValue)) {
113
+ const propSchema = schemaProperties[key];
114
+ if (!propSchema) {
115
+ ctx.addIssue({
116
+ code: z.ZodIssueCode.custom,
117
+ message: `Unknown property "${key}" in variable "${label}" (not in schema)`,
118
+ path: [...path, key],
119
+ });
120
+ } else {
121
+ superRefineVariableValue(
122
+ projectConfig,
123
+ propSchema,
124
+ variableValue[key],
125
+ [...path, key],
126
+ ctx,
127
+ key,
128
+ );
129
+ }
130
+ }
131
+ } else {
132
+ for (const key of Object.keys(variableValue)) {
133
+ const propValue = variableValue[key];
134
+ if (!isFlatObjectValue(propValue)) {
135
+ ctx.addIssue({
136
+ code: z.ZodIssueCode.custom,
137
+ message: `Variable "${label}" is a flat object (no \`properties\` in schema); property "${key}" must be a primitive (string, number, boolean, or null), got: ${typeof propValue}`,
138
+ path: [...path, key],
139
+ });
140
+ }
141
+ }
142
+ }
143
+
144
+ if (projectConfig.maxVariableObjectStringifiedLength) {
145
+ const stringified = JSON.stringify(variableValue);
146
+ if (stringified.length > projectConfig.maxVariableObjectStringifiedLength) {
147
+ ctx.addIssue({
148
+ code: z.ZodIssueCode.custom,
149
+ message: `Variable "${label}" object is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableObjectStringifiedLength}`,
150
+ path,
151
+ });
152
+ }
153
+ }
21
154
  }
22
155
 
23
156
  function superRefineVariableValue(
@@ -26,17 +159,16 @@ function superRefineVariableValue(
26
159
  variableValue,
27
160
  path,
28
161
  ctx,
162
+ variableKey?: string,
29
163
  ) {
30
- if (!variableSchema) {
31
- let message = `Unknown variable with value: ${variableValue}`;
32
-
33
- if (path.length > 0) {
34
- const lastPath = path[path.length - 1];
164
+ const label = getVariableLabel(variableSchema, variableKey, path);
35
165
 
36
- if (typeof lastPath === "string") {
37
- message = `Unknown variable "${lastPath}" with value: ${variableValue}`;
38
- }
39
- }
166
+ if (!variableSchema) {
167
+ const variableName =
168
+ path.length > 0 && typeof path[path.length - 1] === "string"
169
+ ? String(path[path.length - 1])
170
+ : "variable";
171
+ const message = `Variable "${variableName}" is used but not defined in variablesSchema. Define it under variablesSchema first, then use it here.`;
40
172
 
41
173
  ctx.addIssue({
42
174
  code: z.ZodIssueCode.custom,
@@ -47,14 +179,28 @@ function superRefineVariableValue(
47
179
  return;
48
180
  }
49
181
 
50
- // string
51
- if (variableSchema.type === "string") {
182
+ // Require a value (no undefined) for every variable usage
183
+ if (variableValue === undefined) {
184
+ ctx.addIssue({
185
+ code: z.ZodIssueCode.custom,
186
+ message: `Variable "${label}" value is required (got undefined).`,
187
+ path,
188
+ });
189
+ return;
190
+ }
191
+
192
+ const expectedType = variableSchema.type;
193
+ const gotType = typeOfValue(variableValue);
194
+
195
+ // string — only string allowed
196
+ if (expectedType === "string") {
52
197
  if (typeof variableValue !== "string") {
53
198
  ctx.addIssue({
54
199
  code: z.ZodIssueCode.custom,
55
- message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): ${variableValue}`,
200
+ message: `Variable "${label}" (type string) must be a string; got ${gotType}.`,
56
201
  path,
57
202
  });
203
+ return;
58
204
  }
59
205
 
60
206
  if (
@@ -63,7 +209,7 @@ function superRefineVariableValue(
63
209
  ) {
64
210
  ctx.addIssue({
65
211
  code: z.ZodIssueCode.custom,
66
- message: `Variable "${variableSchema.key}" value is too long (${variableValue.length} characters), max length is ${projectConfig.maxVariableStringLength}`,
212
+ message: `Variable "${label}" value is too long (${variableValue.length} characters), max length is ${projectConfig.maxVariableStringLength}`,
67
213
  path,
68
214
  });
69
215
  }
@@ -71,94 +217,123 @@ function superRefineVariableValue(
71
217
  return;
72
218
  }
73
219
 
74
- // integer, double
75
- if (["integer", "double"].indexOf(variableSchema.type) > -1) {
220
+ // integer — only integer number allowed (no NaN, no Infinity, no float)
221
+ if (expectedType === "integer") {
76
222
  if (typeof variableValue !== "number") {
77
223
  ctx.addIssue({
78
224
  code: z.ZodIssueCode.custom,
79
- message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): ${variableValue}`,
225
+ message: `Variable "${label}" (type integer) must be a number; got ${gotType}.`,
226
+ path,
227
+ });
228
+ return;
229
+ }
230
+ if (!Number.isFinite(variableValue)) {
231
+ ctx.addIssue({
232
+ code: z.ZodIssueCode.custom,
233
+ message: `Variable "${label}" (type integer) must be a finite number; got ${variableValue}.`,
234
+ path,
235
+ });
236
+ return;
237
+ }
238
+ if (!Number.isInteger(variableValue)) {
239
+ ctx.addIssue({
240
+ code: z.ZodIssueCode.custom,
241
+ message: `Variable "${label}" (type integer) must be an integer; got ${variableValue}.`,
80
242
  path,
81
243
  });
82
244
  }
83
-
84
245
  return;
85
246
  }
86
247
 
87
- // boolean
88
- if (variableSchema.type === "boolean") {
89
- if (typeof variableValue !== "boolean") {
248
+ // double — only finite number allowed
249
+ if (expectedType === "double") {
250
+ if (typeof variableValue !== "number") {
90
251
  ctx.addIssue({
91
252
  code: z.ZodIssueCode.custom,
92
- message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): ${variableValue}`,
253
+ message: `Variable "${label}" (type double) must be a number; got ${gotType}.`,
254
+ path,
255
+ });
256
+ return;
257
+ }
258
+ if (!Number.isFinite(variableValue)) {
259
+ ctx.addIssue({
260
+ code: z.ZodIssueCode.custom,
261
+ message: `Variable "${label}" (type double) must be a finite number; got ${variableValue}.`,
93
262
  path,
94
263
  });
95
264
  }
96
-
97
265
  return;
98
266
  }
99
267
 
100
- // array
101
- if (variableSchema.type === "array") {
102
- if (!Array.isArray(variableValue) || !isArrayOfStrings(variableValue)) {
268
+ // boolean — only boolean allowed
269
+ if (expectedType === "boolean") {
270
+ if (typeof variableValue !== "boolean") {
103
271
  ctx.addIssue({
104
272
  code: z.ZodIssueCode.custom,
105
- message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): \n\n${variableValue}\n\n`,
273
+ message: `Variable "${label}" (type boolean) must be a boolean; got ${gotType}.`,
106
274
  path,
107
275
  });
108
276
  }
109
-
110
- if (projectConfig.maxVariableArrayStringifiedLength) {
111
- const stringified = JSON.stringify(variableValue);
112
-
113
- if (stringified.length > projectConfig.maxVariableArrayStringifiedLength) {
114
- ctx.addIssue({
115
- code: z.ZodIssueCode.custom,
116
- message: `Variable "${variableSchema.key}" array is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableArrayStringifiedLength}`,
117
- path,
118
- });
119
- }
120
- }
121
-
122
277
  return;
123
278
  }
124
279
 
125
- // object
126
- if (variableSchema.type === "object") {
127
- if (typeof variableValue !== "object" || !isFlatObject(variableValue)) {
280
+ // array — only array allowed; without items schema = array of strings
281
+ if (expectedType === "array") {
282
+ if (!Array.isArray(variableValue)) {
128
283
  ctx.addIssue({
129
284
  code: z.ZodIssueCode.custom,
130
- message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): \n\n${variableValue}\n\n`,
285
+ message: `Variable "${label}" (type array) must be an array; got ${gotType}.`,
131
286
  path,
132
287
  });
288
+ return;
133
289
  }
290
+ refineVariableValueArray(projectConfig, variableSchema, variableValue, path, ctx, variableKey);
291
+ return;
292
+ }
134
293
 
135
- if (projectConfig.maxVariableObjectStringifiedLength) {
136
- const stringified = JSON.stringify(variableValue);
137
-
138
- if (stringified.length > projectConfig.maxVariableObjectStringifiedLength) {
139
- ctx.addIssue({
140
- code: z.ZodIssueCode.custom,
141
- message: `Variable "${variableSchema.key}" object is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableObjectStringifiedLength}`,
142
- path,
143
- });
144
- }
294
+ // object — only plain object allowed (no null, no array)
295
+ if (expectedType === "object") {
296
+ if (
297
+ typeof variableValue !== "object" ||
298
+ variableValue === null ||
299
+ Array.isArray(variableValue)
300
+ ) {
301
+ ctx.addIssue({
302
+ code: z.ZodIssueCode.custom,
303
+ message: `Variable "${label}" (type object) must be a plain object; got ${gotType}.`,
304
+ path,
305
+ });
306
+ return;
145
307
  }
146
-
308
+ refineVariableValueObject(
309
+ projectConfig,
310
+ variableSchema,
311
+ variableValue as Record<string, unknown>,
312
+ path,
313
+ ctx,
314
+ variableKey,
315
+ );
147
316
  return;
148
317
  }
149
318
 
150
- // json
151
- if (variableSchema.type === "json") {
319
+ // json — only string containing valid JSON allowed
320
+ if (expectedType === "json") {
321
+ if (typeof variableValue !== "string") {
322
+ ctx.addIssue({
323
+ code: z.ZodIssueCode.custom,
324
+ message: `Variable "${label}" (type json) must be a string (JSON string); got ${gotType}.`,
325
+ path,
326
+ });
327
+ return;
328
+ }
152
329
  try {
153
- JSON.parse(variableValue as string);
330
+ JSON.parse(variableValue);
154
331
 
155
332
  if (projectConfig.maxVariableJSONStringifiedLength) {
156
- const stringified = variableValue;
157
-
158
- if (stringified.length > projectConfig.maxVariableJSONStringifiedLength) {
333
+ if (variableValue.length > projectConfig.maxVariableJSONStringifiedLength) {
159
334
  ctx.addIssue({
160
335
  code: z.ZodIssueCode.custom,
161
- message: `Variable "${variableSchema.key}" JSON is too long (${stringified.length} characters), max length is ${projectConfig.maxVariableJSONStringifiedLength}`,
336
+ message: `Variable "${label}" JSON is too long (${variableValue.length} characters), max length is ${projectConfig.maxVariableJSONStringifiedLength}`,
162
337
  path,
163
338
  });
164
339
  }
@@ -167,13 +342,20 @@ function superRefineVariableValue(
167
342
  } catch (e) {
168
343
  ctx.addIssue({
169
344
  code: z.ZodIssueCode.custom,
170
- message: `Invalid value for variable "${variableSchema.key}" (${variableSchema.type}): \n\n${variableValue}\n\n`,
345
+ message: `Variable "${label}" (type json) must be a valid JSON string; parse failed.`,
171
346
  path,
172
347
  });
173
348
  }
174
349
 
175
350
  return;
176
351
  }
352
+
353
+ // Unknown variable type — schema is invalid or unsupported
354
+ ctx.addIssue({
355
+ code: z.ZodIssueCode.custom,
356
+ message: `Variable "${label}" has unknown or unsupported type "${String(expectedType)}" in variablesSchema.`,
357
+ path,
358
+ });
177
359
  }
178
360
 
179
361
  function refineForce({
@@ -206,6 +388,7 @@ function refineForce({
206
388
  f.variables[variableKey],
207
389
  pathPrefix.concat([fN, "variables", variableKey]),
208
390
  ctx,
391
+ variableKey,
209
392
  );
210
393
  });
211
394
  }
@@ -231,6 +414,7 @@ function refineRules({
231
414
  rule.variables[variableKey],
232
415
  pathPrefix.concat([ruleN, "variables", variableKey]),
233
416
  ctx,
417
+ variableKey,
234
418
  );
235
419
  });
236
420
  }
@@ -308,21 +492,10 @@ export function getFeatureZodSchema(
308
492
  availableSegmentKeys: [string, ...string[]],
309
493
  availableFeatureKeys: [string, ...string[]],
310
494
  ) {
495
+ const propertyZodSchema = getPropertyZodSchema();
496
+ const variableValueZodSchema = valueZodSchema;
497
+
311
498
  const variationValueZodSchema = z.string().min(1);
312
- const variableValueZodSchema = z.union([
313
- z.string(),
314
- z.number(),
315
- z.boolean(),
316
- z.array(z.string()),
317
- z.record(z.unknown()).refine(
318
- (value) => {
319
- return isFlatObject(value);
320
- },
321
- {
322
- message: "object is not flat",
323
- },
324
- ),
325
- ]);
326
499
 
327
500
  const plainGroupSegment = z.string().refine(
328
501
  (value) => value === "*" || availableSegmentKeys.includes(value),
@@ -500,11 +673,19 @@ export function getFeatureZodSchema(
500
673
  z
501
674
  .object({
502
675
  deprecated: z.boolean().optional(),
503
- type: z.enum(["string", "integer", "boolean", "double", "array", "object", "json"]),
676
+
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(),
682
+
504
683
  description: z.string().optional(),
684
+
505
685
  defaultValue: variableValueZodSchema,
506
- useDefaultWhenDisabled: z.boolean().optional(),
507
686
  disabledValue: variableValueZodSchema.optional(),
687
+
688
+ useDefaultWhenDisabled: z.boolean().optional(),
508
689
  })
509
690
  .strict(),
510
691
  )
@@ -625,16 +806,20 @@ export function getFeatureZodSchema(
625
806
  variableSchema.defaultValue,
626
807
  ["variablesSchema", variableKey, "defaultValue"],
627
808
  ctx,
809
+ variableKey,
628
810
  );
629
811
 
630
- // disabledValue
631
- superRefineVariableValue(
632
- projectConfig,
633
- variableSchema,
634
- variableSchema.defaultValue,
635
- ["variablesSchema", variableKey, "disabledValue"],
636
- ctx,
637
- );
812
+ // disabledValue (only when present)
813
+ if (variableSchema.disabledValue !== undefined) {
814
+ superRefineVariableValue(
815
+ projectConfig,
816
+ variableSchema,
817
+ variableSchema.disabledValue,
818
+ ["variablesSchema", variableKey, "disabledValue"],
819
+ ctx,
820
+ variableKey,
821
+ );
822
+ }
638
823
  });
639
824
 
640
825
  // variations
@@ -654,6 +839,7 @@ export function getFeatureZodSchema(
654
839
  variableValue,
655
840
  ["variations", variationN, "variables", variableKey],
656
841
  ctx,
842
+ variableKey,
657
843
  );
658
844
 
659
845
  // variations[n].variableOverrides[n].value
@@ -676,6 +862,7 @@ export function getFeatureZodSchema(
676
862
  "value",
677
863
  ],
678
864
  ctx,
865
+ variableKey,
679
866
  );
680
867
  });
681
868
  }
@@ -0,0 +1,47 @@
1
+ import type { PropertySchema, Value } from "@featurevisor/types";
2
+ import { z } from "zod";
3
+
4
+ // Recursive schema for Value: boolean | string | number | ObjectValue | Value[]
5
+ export const valueZodSchema: z.ZodType<Value> = z.lazy(() =>
6
+ z.union([
7
+ z.boolean(),
8
+ z.string(),
9
+ z.number(),
10
+ // | Date // @TODO: support in future
11
+ z.record(z.string(), valueZodSchema),
12
+ z.array(valueZodSchema),
13
+ ]),
14
+ );
15
+
16
+ // @TODO: support "date" in future
17
+ // @TODO: consider "semver" in future
18
+ // @TODO: consider "url" in future
19
+ export const propertyTypeEnum = z.enum([
20
+ "boolean",
21
+ "string",
22
+ "integer",
23
+ "double",
24
+ "object",
25
+ "array",
26
+ ]);
27
+
28
+ export function getPropertyZodSchema() {
29
+ const propertyZodSchema: z.ZodType<PropertySchema> = z.lazy(() =>
30
+ z
31
+ .object({
32
+ description: z.string().optional(),
33
+ type: propertyTypeEnum.optional(),
34
+ // enum?: Value[]; const?: Value;
35
+ // Numeric: maximum?, minimum?
36
+ // String: maxLength?, minLength?, pattern?
37
+ items: propertyZodSchema.optional(),
38
+ // maxItems?, minItems?, uniqueItems?
39
+ required: z.array(z.string()).optional(),
40
+ properties: z.record(z.string(), propertyZodSchema).optional(),
41
+ // Annotations: default?: Value; examples?: Value[];
42
+ })
43
+ .strict(),
44
+ );
45
+
46
+ return propertyZodSchema;
47
+ }