@featurevisor/core 2.20.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/coverage/clover.xml +1286 -515
  3. package/coverage/coverage-final.json +17 -16
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildDatafile.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  8. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  9. package/coverage/lcov-report/builder/convertToV1.ts.html +1 -1
  10. package/coverage/lcov-report/builder/getFeatureRanges.ts.html +1 -1
  11. package/coverage/lcov-report/builder/hashes.ts.html +1 -1
  12. package/coverage/lcov-report/builder/index.html +1 -1
  13. package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
  14. package/coverage/lcov-report/builder/mutator.ts.html +1 -1
  15. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  16. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  17. package/coverage/lcov-report/config/index.html +1 -1
  18. package/coverage/lcov-report/config/index.ts.html +1 -1
  19. package/coverage/lcov-report/config/projectConfig.ts.html +42 -42
  20. package/coverage/lcov-report/datasource/adapter.ts.html +2 -2
  21. package/coverage/lcov-report/datasource/datasource.ts.html +43 -43
  22. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +48 -48
  23. package/coverage/lcov-report/datasource/index.html +19 -19
  24. package/coverage/lcov-report/datasource/index.ts.html +4 -4
  25. package/coverage/lcov-report/generate-code/index.html +116 -0
  26. package/coverage/lcov-report/generate-code/typescript.ts.html +3004 -0
  27. package/coverage/lcov-report/index.html +44 -29
  28. package/coverage/lcov-report/linter/attributeSchema.ts.html +1815 -30
  29. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  30. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  31. package/coverage/lcov-report/linter/conditionSchema.ts.html +1606 -157
  32. package/coverage/lcov-report/linter/featureSchema.ts.html +56 -56
  33. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  34. package/coverage/lcov-report/linter/index.html +39 -39
  35. package/coverage/lcov-report/linter/lintProject.ts.html +189 -78
  36. package/coverage/lcov-report/linter/mutationNotation.ts.html +1 -1
  37. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  38. package/coverage/lcov-report/linter/schema.ts.html +59 -59
  39. package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
  40. package/coverage/lcov-report/linter/testSchema.ts.html +6 -6
  41. package/coverage/lcov-report/list/index.html +1 -1
  42. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  43. package/coverage/lcov-report/parsers/index.html +1 -1
  44. package/coverage/lcov-report/parsers/index.ts.html +4 -4
  45. package/coverage/lcov-report/parsers/json.ts.html +2 -2
  46. package/coverage/lcov-report/parsers/yml.ts.html +6 -6
  47. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  48. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  49. package/coverage/lcov-report/tester/index.html +1 -1
  50. package/coverage/lcov-report/utils/extractKeys.ts.html +1 -1
  51. package/coverage/lcov-report/utils/git.ts.html +3 -3
  52. package/coverage/lcov-report/utils/index.html +1 -1
  53. package/coverage/lcov-report/utils/index.ts.html +1 -1
  54. package/coverage/lcov-report/utils/pretty.ts.html +1 -1
  55. package/coverage/lcov.info +2624 -926
  56. package/lib/generate-code/typescript.js +57 -29
  57. package/lib/generate-code/typescript.js.map +1 -1
  58. package/lib/generate-code/typescript.spec.d.ts +1 -0
  59. package/lib/generate-code/typescript.spec.js +78 -0
  60. package/lib/generate-code/typescript.spec.js.map +1 -0
  61. package/lib/linter/attributeSchema.d.ts +352 -20
  62. package/lib/linter/attributeSchema.js +426 -16
  63. package/lib/linter/attributeSchema.js.map +1 -1
  64. package/lib/linter/attributeSchema.spec.d.ts +1 -0
  65. package/lib/linter/attributeSchema.spec.js +266 -0
  66. package/lib/linter/attributeSchema.spec.js.map +1 -0
  67. package/lib/linter/conditionSchema.d.ts +2 -1
  68. package/lib/linter/conditionSchema.js +370 -100
  69. package/lib/linter/conditionSchema.js.map +1 -1
  70. package/lib/linter/conditionSchema.spec.js +200 -153
  71. package/lib/linter/conditionSchema.spec.js.map +1 -1
  72. package/lib/linter/featureSchema.spec.js +114 -3
  73. package/lib/linter/featureSchema.spec.js.map +1 -1
  74. package/lib/linter/lintProject.js +45 -14
  75. package/lib/linter/lintProject.js.map +1 -1
  76. package/lib/linter/segmentSchema.spec.js +71 -1
  77. package/lib/linter/segmentSchema.spec.js.map +1 -1
  78. package/package.json +5 -5
  79. package/src/generate-code/typescript.spec.ts +108 -0
  80. package/src/generate-code/typescript.ts +80 -34
  81. package/src/linter/attributeSchema.spec.ts +342 -0
  82. package/src/linter/attributeSchema.ts +613 -18
  83. package/src/linter/conditionSchema.spec.ts +231 -189
  84. package/src/linter/conditionSchema.ts +582 -99
  85. package/src/linter/featureSchema.spec.ts +129 -4
  86. package/src/linter/lintProject.ts +55 -18
  87. package/src/linter/segmentSchema.spec.ts +82 -1
@@ -1,3 +1,4 @@
1
+ import type { Attribute, AttributeProperty, Schema } from "@featurevisor/types";
1
2
  import { z } from "zod";
2
3
 
3
4
  import { ProjectConfig } from "../config";
@@ -9,8 +10,8 @@ const stringOperators = [
9
10
  "notContains",
10
11
  "startsWith",
11
12
  "endsWith",
12
- "includes",
13
- "notIncludes",
13
+ "matches",
14
+ "notMatches",
14
15
  ];
15
16
  const semverOperators = [
16
17
  "semverEquals",
@@ -21,24 +22,537 @@ const semverOperators = [
21
22
  "semverLessThanOrEquals",
22
23
  ];
23
24
  const dateOperators = ["before", "after"];
24
- const arrayOperators = ["in", "notIn"];
25
- const regexOperators = ["matches", "notMatches"];
25
+ const arrayMembershipOperators = ["in", "notIn"];
26
+ const arrayOperators = ["includes", "notIncludes"];
26
27
  const operatorsWithoutValue = ["exists", "notExists"];
27
28
 
28
29
  const isoDateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|([+\-]\d{2}:\d{2})))?$/;
29
30
 
31
+ type SchemaNode = Attribute | AttributeProperty | Schema;
32
+ type ResolvedLeaf =
33
+ | { kind: "schema"; schema: SchemaNode }
34
+ | { kind: "flat-object-property" }
35
+ | { kind: "flat-array-item" };
36
+
30
37
  function isIsoDateString(value: string): boolean {
31
38
  return isoDateRegex.test(value);
32
39
  }
33
40
 
41
+ function isPrimitiveAttributeValue(value: unknown): boolean {
42
+ return (
43
+ value === null ||
44
+ typeof value === "string" ||
45
+ typeof value === "number" ||
46
+ typeof value === "boolean"
47
+ );
48
+ }
49
+
50
+ function isStringArray(value: unknown): value is string[] {
51
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
52
+ }
53
+
54
+ function valueDeepEqual(a: unknown, b: unknown): boolean {
55
+ if (a === b) return true;
56
+ if (typeof a !== typeof b) return false;
57
+ if (a === null || b === null) return a === b;
58
+
59
+ if (typeof a === "object" && typeof b === "object") {
60
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
61
+ if (Array.isArray(a) && Array.isArray(b)) {
62
+ if (a.length !== b.length) return false;
63
+ return a.every((value, index) => valueDeepEqual(value, b[index]));
64
+ }
65
+
66
+ const keysA = Object.keys(a as object).sort();
67
+ const keysB = Object.keys(b as object).sort();
68
+ if (keysA.length !== keysB.length || keysA.some((key, index) => key !== keysB[index])) {
69
+ return false;
70
+ }
71
+
72
+ return keysA.every((key) =>
73
+ valueDeepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
74
+ );
75
+ }
76
+
77
+ return false;
78
+ }
79
+
80
+ function resolveSchemaRefs(schema: SchemaNode, schemasByKey: Record<string, Schema>): SchemaNode {
81
+ if ("schema" in schema && schema.schema && schemasByKey[schema.schema]) {
82
+ return resolveSchemaRefs(schemasByKey[schema.schema], schemasByKey);
83
+ }
84
+
85
+ return schema;
86
+ }
87
+
88
+ function getLeafType(resolvedLeaf: ResolvedLeaf): string | undefined {
89
+ if (resolvedLeaf.kind === "flat-object-property" || resolvedLeaf.kind === "flat-array-item") {
90
+ return "primitive";
91
+ }
92
+
93
+ return "type" in resolvedLeaf.schema ? resolvedLeaf.schema.type : undefined;
94
+ }
95
+
96
+ function getLeafTypes(resolvedLeaf: ResolvedLeaf, schemasByKey: Record<string, Schema>): string[] {
97
+ if (resolvedLeaf.kind === "flat-object-property" || resolvedLeaf.kind === "flat-array-item") {
98
+ return ["primitive"];
99
+ }
100
+
101
+ const resolved = resolveSchemaRefs(resolvedLeaf.schema, schemasByKey) as SchemaNode & {
102
+ type?: string;
103
+ oneOf?: unknown[];
104
+ };
105
+
106
+ if (resolved.oneOf && Array.isArray(resolved.oneOf) && resolved.oneOf.length > 0) {
107
+ return Array.from(
108
+ new Set(
109
+ resolved.oneOf.flatMap((branch) =>
110
+ getLeafTypes({ kind: "schema", schema: branch as SchemaNode }, schemasByKey),
111
+ ),
112
+ ),
113
+ );
114
+ }
115
+
116
+ return resolved.type ? [resolved.type] : [];
117
+ }
118
+
119
+ function resolveAttributePath(
120
+ attributePath: string,
121
+ attributesByKey: Record<string, Attribute>,
122
+ schemasByKey: Record<string, Schema>,
123
+ ): ResolvedLeaf | null {
124
+ const [rootKey, ...rest] = attributePath.split(".");
125
+ const rootAttribute = attributesByKey[rootKey];
126
+
127
+ if (!rootAttribute) {
128
+ return null;
129
+ }
130
+
131
+ let current: SchemaNode = rootAttribute;
132
+
133
+ if (rest.length === 0) {
134
+ return { kind: "schema", schema: resolveSchemaRefs(current, schemasByKey) };
135
+ }
136
+
137
+ for (let index = 0; index < rest.length; index++) {
138
+ const segment = rest[index];
139
+ current = resolveSchemaRefs(current, schemasByKey);
140
+ const type = "type" in current ? current.type : undefined;
141
+
142
+ if (type !== "object") {
143
+ return null;
144
+ }
145
+
146
+ if ("oneOf" in current && Array.isArray(current.oneOf) && current.oneOf.length > 0) {
147
+ return null;
148
+ }
149
+
150
+ const properties = "properties" in current ? current.properties : undefined;
151
+ const additionalProperties =
152
+ "additionalProperties" in current ? current.additionalProperties : undefined;
153
+
154
+ if (properties && typeof properties === "object" && properties[segment]) {
155
+ current = properties[segment];
156
+ continue;
157
+ }
158
+
159
+ if (additionalProperties && typeof additionalProperties === "object") {
160
+ current = additionalProperties;
161
+ continue;
162
+ }
163
+
164
+ if (!properties && !additionalProperties) {
165
+ return index === rest.length - 1 ? { kind: "flat-object-property" } : null;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ return { kind: "schema", schema: resolveSchemaRefs(current, schemasByKey) };
172
+ }
173
+
174
+ function matchesSchemaValue(
175
+ schema: SchemaNode,
176
+ value: unknown,
177
+ schemasByKey: Record<string, Schema>,
178
+ ): boolean {
179
+ const resolved = resolveSchemaRefs(schema, schemasByKey) as SchemaNode & {
180
+ type?: string;
181
+ enum?: unknown[];
182
+ const?: unknown;
183
+ oneOf?: unknown[];
184
+ properties?: Record<string, unknown>;
185
+ additionalProperties?: unknown;
186
+ required?: string[];
187
+ items?: unknown;
188
+ minLength?: number;
189
+ maxLength?: number;
190
+ pattern?: string;
191
+ minimum?: number;
192
+ maximum?: number;
193
+ minItems?: number;
194
+ maxItems?: number;
195
+ uniqueItems?: boolean;
196
+ };
197
+
198
+ if (resolved.oneOf && Array.isArray(resolved.oneOf) && resolved.oneOf.length > 0) {
199
+ const matches = resolved.oneOf.filter((branch) =>
200
+ matchesSchemaValue(branch as SchemaNode, value, schemasByKey),
201
+ ).length;
202
+ return matches === 1;
203
+ }
204
+
205
+ if (resolved.const !== undefined) {
206
+ return valueDeepEqual(value, resolved.const);
207
+ }
208
+
209
+ if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
210
+ return resolved.enum.some((entry) => valueDeepEqual(entry, value));
211
+ }
212
+
213
+ const type = resolved.type;
214
+ if (!type) {
215
+ return true;
216
+ }
217
+
218
+ if (type === "boolean") {
219
+ return typeof value === "boolean";
220
+ }
221
+
222
+ if (type === "string" || type === "semver" || type === "date") {
223
+ if (typeof value !== "string") {
224
+ return false;
225
+ }
226
+
227
+ if (resolved.minLength !== undefined && value.length < resolved.minLength) return false;
228
+ if (resolved.maxLength !== undefined && value.length > resolved.maxLength) return false;
229
+
230
+ if (resolved.pattern !== undefined) {
231
+ try {
232
+ if (!new RegExp(resolved.pattern).test(value)) return false;
233
+ } catch {
234
+ return true;
235
+ }
236
+ }
237
+
238
+ if (type === "date" && !isIsoDateString(value)) {
239
+ return false;
240
+ }
241
+
242
+ return true;
243
+ }
244
+
245
+ if (type === "integer" || type === "double") {
246
+ if (typeof value !== "number") {
247
+ return false;
248
+ }
249
+
250
+ if (type === "integer" && !Number.isInteger(value)) {
251
+ return false;
252
+ }
253
+
254
+ if (resolved.minimum !== undefined && value < resolved.minimum) return false;
255
+ if (resolved.maximum !== undefined && value > resolved.maximum) return false;
256
+ return true;
257
+ }
258
+
259
+ if (type === "object") {
260
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
261
+ return false;
262
+ }
263
+
264
+ const objectValue = value as Record<string, unknown>;
265
+ const properties = resolved.properties;
266
+ const additionalProperties = resolved.additionalProperties;
267
+
268
+ if ((!properties || typeof properties !== "object") && !additionalProperties) {
269
+ return Object.values(objectValue).every((entry) => isPrimitiveAttributeValue(entry));
270
+ }
271
+
272
+ if (properties && typeof properties === "object") {
273
+ const required = new Set(resolved.required || []);
274
+ for (const key of required) {
275
+ if (!Object.prototype.hasOwnProperty.call(objectValue, key)) {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ for (const key of Object.keys(objectValue)) {
281
+ if (properties[key]) {
282
+ if (!matchesSchemaValue(properties[key] as SchemaNode, objectValue[key], schemasByKey)) {
283
+ return false;
284
+ }
285
+ continue;
286
+ }
287
+
288
+ if (additionalProperties && typeof additionalProperties === "object") {
289
+ if (
290
+ !matchesSchemaValue(additionalProperties as SchemaNode, objectValue[key], schemasByKey)
291
+ ) {
292
+ return false;
293
+ }
294
+ continue;
295
+ }
296
+
297
+ return false;
298
+ }
299
+
300
+ return true;
301
+ }
302
+
303
+ if (additionalProperties && typeof additionalProperties === "object") {
304
+ return Object.values(objectValue).every((entry) =>
305
+ matchesSchemaValue(additionalProperties as SchemaNode, entry, schemasByKey),
306
+ );
307
+ }
308
+
309
+ return true;
310
+ }
311
+
312
+ if (type === "array") {
313
+ if (!Array.isArray(value)) {
314
+ return false;
315
+ }
316
+
317
+ if (resolved.minItems !== undefined && value.length < resolved.minItems) return false;
318
+ if (resolved.maxItems !== undefined && value.length > resolved.maxItems) return false;
319
+
320
+ if (resolved.uniqueItems) {
321
+ for (let i = 0; i < value.length; i++) {
322
+ for (let j = i + 1; j < value.length; j++) {
323
+ if (valueDeepEqual(value[i], value[j])) return false;
324
+ }
325
+ }
326
+ }
327
+
328
+ if (!resolved.items || typeof resolved.items !== "object") {
329
+ return value.every((entry) => typeof entry === "string");
330
+ }
331
+
332
+ return value.every((entry) =>
333
+ matchesSchemaValue(resolved.items as SchemaNode, entry, schemasByKey),
334
+ );
335
+ }
336
+
337
+ return true;
338
+ }
339
+
340
+ function matchesLeafValue(
341
+ leaf: ResolvedLeaf,
342
+ value: unknown,
343
+ schemasByKey: Record<string, Schema>,
344
+ ): boolean {
345
+ if (leaf.kind === "flat-object-property" || leaf.kind === "flat-array-item") {
346
+ return leaf.kind === "flat-array-item"
347
+ ? typeof value === "string"
348
+ : isPrimitiveAttributeValue(value);
349
+ }
350
+
351
+ return matchesSchemaValue(leaf.schema, value, schemasByKey);
352
+ }
353
+
354
+ function getArrayItemLeaf(
355
+ leaf: ResolvedLeaf,
356
+ schemasByKey: Record<string, Schema>,
357
+ ): ResolvedLeaf | null {
358
+ if (leaf.kind !== "schema") {
359
+ return leaf.kind === "flat-array-item" ? leaf : null;
360
+ }
361
+
362
+ const resolved = resolveSchemaRefs(leaf.schema, schemasByKey);
363
+ if ("oneOf" in resolved && Array.isArray(resolved.oneOf) && resolved.oneOf.length > 0) {
364
+ const itemLeaves = resolved.oneOf
365
+ .map((branch) =>
366
+ getArrayItemLeaf({ kind: "schema", schema: branch as SchemaNode }, schemasByKey),
367
+ )
368
+ .filter((item): item is ResolvedLeaf => item !== null);
369
+
370
+ if (itemLeaves.length === 0) {
371
+ return null;
372
+ }
373
+
374
+ if (itemLeaves.every((item) => item.kind === "flat-array-item")) {
375
+ return { kind: "flat-array-item" };
376
+ }
377
+
378
+ return {
379
+ kind: "schema",
380
+ schema: {
381
+ oneOf: itemLeaves.map((item) =>
382
+ item.kind === "schema" ? item.schema : ({ type: "string" } as AttributeProperty),
383
+ ),
384
+ } as AttributeProperty,
385
+ };
386
+ }
387
+
388
+ if (!("type" in resolved) || resolved.type !== "array") {
389
+ return null;
390
+ }
391
+
392
+ if ("items" in resolved && resolved.items && typeof resolved.items === "object") {
393
+ return {
394
+ kind: "schema",
395
+ schema: resolveSchemaRefs(resolved.items as SchemaNode, schemasByKey),
396
+ };
397
+ }
398
+
399
+ return { kind: "flat-array-item" };
400
+ }
401
+
402
+ function addIssue(
403
+ context: z.RefinementCtx,
404
+ message: string,
405
+ path: (string | number)[] = ["value"],
406
+ ): void {
407
+ context.addIssue({
408
+ code: z.ZodIssueCode.custom,
409
+ message,
410
+ path,
411
+ });
412
+ }
413
+
414
+ function validateAttributeAwareCondition(
415
+ data: {
416
+ attribute: string;
417
+ operator: string;
418
+ value?: unknown;
419
+ regexFlags?: string;
420
+ },
421
+ context: z.RefinementCtx,
422
+ attributesByKey: Record<string, Attribute>,
423
+ schemasByKey: Record<string, Schema>,
424
+ ): void {
425
+ const resolvedLeaf = resolveAttributePath(data.attribute, attributesByKey, schemasByKey);
426
+ if (!resolvedLeaf) {
427
+ return;
428
+ }
429
+
430
+ const leafType = getLeafType(resolvedLeaf);
431
+ const leafTypes = getLeafTypes(resolvedLeaf, schemasByKey);
432
+
433
+ if (operatorsWithoutValue.includes(data.operator)) {
434
+ return;
435
+ }
436
+
437
+ if (resolvedLeaf.kind === "schema" && leafType === "object") {
438
+ addIssue(
439
+ context,
440
+ `Attribute "${data.attribute}" resolves to an object. Use a nested attribute path or \`exists\`/\`notExists\` instead.`,
441
+ );
442
+ return;
443
+ }
444
+
445
+ if (
446
+ numericOperators.includes(data.operator) &&
447
+ !leafTypes.some((type) => ["integer", "double"].includes(type))
448
+ ) {
449
+ addIssue(
450
+ context,
451
+ `Operator "${data.operator}" can only be used with integer or double attributes.`,
452
+ );
453
+ return;
454
+ }
455
+
456
+ if (
457
+ stringOperators.includes(data.operator) &&
458
+ !leafTypes.some((type) => ["string", "semver", "date"].includes(type))
459
+ ) {
460
+ addIssue(context, `Operator "${data.operator}" can only be used with string-like attributes.`);
461
+ return;
462
+ }
463
+
464
+ if (
465
+ semverOperators.includes(data.operator) &&
466
+ !leafTypes.some((type) => ["string", "semver"].includes(type))
467
+ ) {
468
+ addIssue(
469
+ context,
470
+ `Operator "${data.operator}" can only be used with string or semver attributes.`,
471
+ );
472
+ return;
473
+ }
474
+
475
+ if (
476
+ dateOperators.includes(data.operator) &&
477
+ !leafTypes.some((type) => ["string", "date"].includes(type))
478
+ ) {
479
+ addIssue(
480
+ context,
481
+ `Operator "${data.operator}" can only be used with string or date attributes.`,
482
+ );
483
+ return;
484
+ }
485
+
486
+ if (arrayMembershipOperators.includes(data.operator)) {
487
+ if (
488
+ !["primitive", "string", "semver", "date", "integer", "double", "boolean"].includes(
489
+ leafType || "",
490
+ )
491
+ ) {
492
+ addIssue(
493
+ context,
494
+ `Operator "${data.operator}" can only be used with primitive attribute values.`,
495
+ );
496
+ return;
497
+ }
498
+
499
+ if (!Array.isArray(data.value)) {
500
+ return;
501
+ }
502
+
503
+ data.value.forEach((entry, index) => {
504
+ if (!matchesLeafValue(resolvedLeaf, entry, schemasByKey)) {
505
+ addIssue(
506
+ context,
507
+ `Value at index ${index} does not match the schema of attribute "${data.attribute}".`,
508
+ ["value", index],
509
+ );
510
+ }
511
+ });
512
+
513
+ return;
514
+ }
515
+
516
+ if (arrayOperators.includes(data.operator)) {
517
+ const itemLeaf = getArrayItemLeaf(resolvedLeaf, schemasByKey);
518
+ if (!itemLeaf) {
519
+ addIssue(context, `Operator "${data.operator}" can only be used with array attributes.`);
520
+ return;
521
+ }
522
+
523
+ const itemType = getLeafType(itemLeaf);
524
+ if (itemType === "object" || itemType === "array") {
525
+ addIssue(context, `Operator "${data.operator}" only supports arrays of primitive values.`);
526
+ return;
527
+ }
528
+
529
+ if (!matchesLeafValue(itemLeaf, data.value, schemasByKey)) {
530
+ addIssue(context, `Value does not match the item schema of attribute "${data.attribute}".`);
531
+ }
532
+
533
+ return;
534
+ }
535
+
536
+ if (commonOperators.includes(data.operator)) {
537
+ if (data.value === null) {
538
+ return;
539
+ }
540
+
541
+ if (!matchesLeafValue(resolvedLeaf, data.value, schemasByKey)) {
542
+ addIssue(context, `Value does not match the schema of attribute "${data.attribute}".`);
543
+ }
544
+ }
545
+ }
546
+
34
547
  export function getConditionsZodSchema(
35
548
  projectConfig: ProjectConfig,
36
- availableAttributeKeys: [string, ...string[]],
549
+ attributesByKey: Record<string, Attribute>,
550
+ schemasByKey: Record<string, Schema> = {},
37
551
  ) {
38
552
  const plainConditionZodSchema = z
39
553
  .object({
40
554
  attribute: z.string().refine(
41
- (value) => availableAttributeKeys.includes(value),
555
+ (value) => resolveAttributePath(value, attributesByKey, schemasByKey) !== null,
42
556
  (value) => ({
43
557
  message: `Unknown attribute "${value}"`,
44
558
  }),
@@ -49,8 +563,8 @@ export function getConditionsZodSchema(
49
563
  ...stringOperators,
50
564
  ...semverOperators,
51
565
  ...dateOperators,
566
+ ...arrayMembershipOperators,
52
567
  ...arrayOperators,
53
- ...regexOperators,
54
568
  ...operatorsWithoutValue,
55
569
  ]),
56
570
  value: z
@@ -73,7 +587,6 @@ export function getConditionsZodSchema(
73
587
  .optional(),
74
588
  })
75
589
  .superRefine((data, context) => {
76
- // common
77
590
  if (
78
591
  commonOperators.includes(data.operator) &&
79
592
  !(
@@ -85,117 +598,90 @@ export function getConditionsZodSchema(
85
598
  data.value === null
86
599
  )
87
600
  ) {
88
- context.addIssue({
89
- code: z.ZodIssueCode.custom,
90
- message: `when operator is "${data.operator}", value has to be either a string, number, boolean, date or null`,
91
- path: ["value"],
92
- });
601
+ addIssue(
602
+ context,
603
+ `when operator is "${data.operator}", value has to be either a string, number, boolean, date or null`,
604
+ );
93
605
  }
94
606
 
95
- // numeric
96
607
  if (numericOperators.includes(data.operator) && typeof data.value !== "number") {
97
- context.addIssue({
98
- code: z.ZodIssueCode.custom,
99
- message: `when operator is "${data.operator}", value must be a number`,
100
- path: ["value"],
101
- });
608
+ addIssue(context, `when operator is "${data.operator}", value must be a number`);
102
609
  }
103
610
 
104
- // string
105
- if (stringOperators.includes(data.operator) && typeof data.value !== "string") {
106
- context.addIssue({
107
- code: z.ZodIssueCode.custom,
108
- message: `when operator is "${data.operator}", value must be a string`,
109
- path: ["value"],
110
- });
111
- }
112
-
113
- // semver
114
- if (semverOperators.includes(data.operator) && typeof data.value !== "string") {
115
- context.addIssue({
116
- code: z.ZodIssueCode.custom,
117
- message: `when operator is "${data.operator}", value must be a string`,
118
- path: ["value"],
119
- });
611
+ if (
612
+ [...stringOperators, ...semverOperators].includes(data.operator) &&
613
+ typeof data.value !== "string"
614
+ ) {
615
+ addIssue(context, `when operator is "${data.operator}", value must be a string`);
120
616
  }
121
617
 
122
- // date
123
618
  if (dateOperators.includes(data.operator)) {
124
- // Value is required and must not be undefined or null
125
619
  if (typeof data.value === "undefined" || data.value === null || data.value === "") {
126
- context.addIssue({
127
- code: z.ZodIssueCode.custom,
128
- message: `when operator is "${data.operator}", value must be provided`,
129
- path: ["value"],
130
- });
620
+ addIssue(context, `when operator is "${data.operator}", value must be provided`);
131
621
  } else if (typeof data.value === "string") {
132
- // If it's a string, must be valid ISO 8601
133
622
  if (!isIsoDateString(data.value)) {
134
- context.addIssue({
135
- code: z.ZodIssueCode.custom,
136
- message: `when operator is "${data.operator}", value must be a stringified date in ISO 8601 format`,
137
- path: ["value"],
138
- });
139
- } else {
140
- // valid ISO 8601 string -- no issue
623
+ addIssue(
624
+ context,
625
+ `when operator is "${data.operator}", value must be a stringified date in ISO 8601 format`,
626
+ );
141
627
  }
142
628
  } else if (data.value instanceof Date) {
143
- // If it's a Date, must be valid
144
629
  if (isNaN(data.value.getTime())) {
145
- context.addIssue({
146
- code: z.ZodIssueCode.custom,
147
- message: `when operator is "${data.operator}", value is a Date but invalid`,
148
- path: ["value"],
149
- });
630
+ addIssue(context, `when operator is "${data.operator}", value is a Date but invalid`);
150
631
  }
151
- // Valid JS Date -- no issue
152
632
  } else {
153
- // NOT a string, NOT a Date
154
- context.addIssue({
155
- code: z.ZodIssueCode.custom,
156
- message: `when operator is "${data.operator}", value must be a stringified date in ISO 8601 format or a valid Date object`,
157
- path: ["value"],
158
- });
633
+ addIssue(
634
+ context,
635
+ `when operator is "${data.operator}", value must be a stringified date in ISO 8601 format or a valid Date object`,
636
+ );
159
637
  }
160
638
  }
161
639
 
162
- // array
163
- if (arrayOperators.includes(data.operator) && !Array.isArray(data.value)) {
164
- context.addIssue({
165
- code: z.ZodIssueCode.custom,
166
- message: `when operator is "${data.operator}", value must be an array of strings`,
167
- path: ["value"],
168
- });
640
+ if (arrayMembershipOperators.includes(data.operator) && !Array.isArray(data.value)) {
641
+ addIssue(context, `when operator is "${data.operator}", value must be an array of strings`);
169
642
  }
170
643
 
171
- // regex
172
- if (regexOperators.includes(data.operator)) {
173
- if (typeof data.value !== "string") {
174
- context.addIssue({
175
- code: z.ZodIssueCode.custom,
176
- message: `when operator is "${data.operator}", value must be a string`,
177
- path: ["value"],
178
- });
644
+ if (
645
+ arrayMembershipOperators.includes(data.operator) &&
646
+ Array.isArray(data.value) &&
647
+ !isStringArray(data.value)
648
+ ) {
649
+ addIssue(context, `when operator is "${data.operator}", value must be an array of strings`);
650
+ }
651
+
652
+ if (!arrayMembershipOperators.includes(data.operator) && Array.isArray(data.value)) {
653
+ if (!["in", "notIn"].includes(data.operator)) {
654
+ addIssue(context, `when operator is "${data.operator}", value must not be an array`);
179
655
  }
180
- } else {
181
- // regex flags are not needed
182
- if (data.regexFlags) {
183
- context.addIssue({
184
- code: z.ZodIssueCode.custom,
185
- message: `when operator is nether "matches" nor "notMatches", regexFlags are not needed`,
186
- path: ["regexFlags"],
187
- });
656
+ }
657
+
658
+ if (regexFlagsRequired(data.operator)) {
659
+ if (typeof data.value !== "string") {
660
+ addIssue(context, `when operator is "${data.operator}", value must be a string`);
188
661
  }
662
+ } else if (data.regexFlags) {
663
+ addIssue(
664
+ context,
665
+ `when operator is nether "matches" nor "notMatches", regexFlags are not needed`,
666
+ ["regexFlags"],
667
+ );
189
668
  }
190
669
 
191
- // operators without value
192
670
  if (operatorsWithoutValue.includes(data.operator) && data.value !== undefined) {
193
- context.addIssue({
194
- code: z.ZodIssueCode.custom,
195
- message: `when operator is "${data.operator}", value is not needed`,
196
- path: ["value"],
197
- });
671
+ addIssue(context, `when operator is "${data.operator}", value is not needed`);
198
672
  }
673
+
674
+ validateAttributeAwareCondition(
675
+ data as {
676
+ attribute: string;
677
+ operator: string;
678
+ value?: unknown;
679
+ regexFlags?: string;
680
+ },
681
+ context,
682
+ attributesByKey,
683
+ schemasByKey,
684
+ );
199
685
  });
200
686
 
201
687
  const andOrNotConditionZodSchema = z.union([
@@ -217,14 +703,11 @@ export function getConditionsZodSchema(
217
703
  ]);
218
704
 
219
705
  const everyoneZodSchema = z.literal("*");
220
-
221
706
  const conditionZodSchema = z.union([andOrNotConditionZodSchema, plainConditionZodSchema]);
222
707
 
223
- const conditionsZodSchema = z.union([
224
- conditionZodSchema,
225
- z.array(conditionZodSchema),
226
- everyoneZodSchema,
227
- ]);
708
+ return z.union([conditionZodSchema, z.array(conditionZodSchema), everyoneZodSchema]);
709
+ }
228
710
 
229
- return conditionsZodSchema;
711
+ function regexFlagsRequired(operator: string): boolean {
712
+ return operator === "matches" || operator === "notMatches";
230
713
  }