@featurevisor/core 2.17.0 → 2.19.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 (106) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/coverage/clover.xml +1692 -1196
  3. package/coverage/coverage-final.json +29 -22
  4. package/coverage/lcov-report/builder/allocator.ts.html +26 -26
  5. package/coverage/lcov-report/builder/buildDatafile.ts.html +2017 -0
  6. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +35 -35
  7. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +213 -45
  8. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +28 -28
  9. package/coverage/lcov-report/builder/convertToV1.ts.html +583 -0
  10. package/coverage/lcov-report/builder/getFeatureRanges.ts.html +268 -0
  11. package/coverage/lcov-report/builder/hashes.ts.html +412 -0
  12. package/coverage/lcov-report/builder/index.html +90 -30
  13. package/coverage/lcov-report/builder/mutateVariables.ts.html +66 -45
  14. package/coverage/lcov-report/builder/mutator.ts.html +95 -95
  15. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  16. package/coverage/lcov-report/builder/traffic.ts.html +60 -57
  17. package/coverage/lcov-report/config/index.html +1 -1
  18. package/coverage/lcov-report/config/index.ts.html +2 -2
  19. package/coverage/lcov-report/config/projectConfig.ts.html +28 -28
  20. package/coverage/lcov-report/datasource/adapter.ts.html +2 -2
  21. package/coverage/lcov-report/datasource/datasource.ts.html +4 -4
  22. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +34 -34
  23. package/coverage/lcov-report/datasource/index.html +1 -1
  24. package/coverage/lcov-report/datasource/index.ts.html +1 -1
  25. package/coverage/lcov-report/index.html +39 -39
  26. package/coverage/lcov-report/linter/attributeSchema.ts.html +1 -1
  27. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  28. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  29. package/coverage/lcov-report/linter/conditionSchema.ts.html +17 -17
  30. package/coverage/lcov-report/linter/featureSchema.ts.html +879 -417
  31. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  32. package/coverage/lcov-report/linter/index.html +34 -34
  33. package/coverage/lcov-report/linter/lintProject.ts.html +25 -25
  34. package/coverage/lcov-report/linter/mutationNotation.ts.html +224 -122
  35. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  36. package/coverage/lcov-report/linter/schema.ts.html +175 -85
  37. package/coverage/lcov-report/linter/segmentSchema.ts.html +1 -1
  38. package/coverage/lcov-report/linter/testSchema.ts.html +5 -5
  39. package/coverage/lcov-report/list/index.html +1 -1
  40. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  41. package/coverage/lcov-report/parsers/index.html +1 -1
  42. package/coverage/lcov-report/parsers/index.ts.html +4 -4
  43. package/coverage/lcov-report/parsers/json.ts.html +2 -2
  44. package/coverage/lcov-report/parsers/yml.ts.html +6 -6
  45. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  46. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  47. package/coverage/lcov-report/tester/index.html +1 -1
  48. package/coverage/lcov-report/utils/extractKeys.ts.html +493 -0
  49. package/coverage/lcov-report/utils/git.ts.html +3 -3
  50. package/coverage/lcov-report/utils/index.html +54 -9
  51. package/coverage/lcov-report/utils/index.ts.html +94 -0
  52. package/coverage/lcov-report/utils/pretty.ts.html +121 -0
  53. package/coverage/lcov.info +3206 -2260
  54. package/json-schema/attribute.json +25 -9
  55. package/json-schema/feature.json +319 -238
  56. package/json-schema/segment.json +76 -45
  57. package/lib/builder/buildDatafile.js +50 -3
  58. package/lib/builder/buildDatafile.js.map +1 -1
  59. package/lib/builder/buildDatafile.spec.d.ts +1 -0
  60. package/lib/builder/buildDatafile.spec.js +233 -0
  61. package/lib/builder/buildDatafile.spec.js.map +1 -0
  62. package/lib/builder/buildScopedDatafile.js +37 -1
  63. package/lib/builder/buildScopedDatafile.js.map +1 -1
  64. package/lib/builder/buildScopedDatafile.spec.js +289 -0
  65. package/lib/builder/buildScopedDatafile.spec.js.map +1 -1
  66. package/lib/builder/mutateVariables.d.ts +1 -1
  67. package/lib/builder/mutateVariables.js +4 -1
  68. package/lib/builder/mutateVariables.js.map +1 -1
  69. package/lib/builder/mutateVariables.spec.js +29 -0
  70. package/lib/builder/mutateVariables.spec.js.map +1 -1
  71. package/lib/builder/traffic.js +1 -0
  72. package/lib/builder/traffic.js.map +1 -1
  73. package/lib/builder/traffic.spec.js +26 -0
  74. package/lib/builder/traffic.spec.js.map +1 -1
  75. package/lib/generate-code/typescript.js +63 -20
  76. package/lib/generate-code/typescript.js.map +1 -1
  77. package/lib/linter/featureSchema.d.ts +237 -22
  78. package/lib/linter/featureSchema.js +175 -84
  79. package/lib/linter/featureSchema.js.map +1 -1
  80. package/lib/linter/featureSchema.spec.js +268 -0
  81. package/lib/linter/featureSchema.spec.js.map +1 -1
  82. package/lib/linter/mutationNotation.js +54 -19
  83. package/lib/linter/mutationNotation.js.map +1 -1
  84. package/lib/linter/mutationNotation.spec.js +49 -0
  85. package/lib/linter/mutationNotation.spec.js.map +1 -1
  86. package/lib/linter/schema.d.ts +1 -0
  87. package/lib/linter/schema.js +13 -0
  88. package/lib/linter/schema.js.map +1 -1
  89. package/lib/linter/schema.spec.js +51 -0
  90. package/lib/linter/schema.spec.js.map +1 -1
  91. package/package.json +5 -5
  92. package/src/builder/buildDatafile.spec.ts +267 -0
  93. package/src/builder/buildDatafile.ts +85 -4
  94. package/src/builder/buildScopedDatafile.spec.ts +330 -0
  95. package/src/builder/buildScopedDatafile.ts +57 -1
  96. package/src/builder/mutateVariables.spec.ts +45 -0
  97. package/src/builder/mutateVariables.ts +7 -0
  98. package/src/builder/traffic.spec.ts +33 -0
  99. package/src/builder/traffic.ts +1 -0
  100. package/src/generate-code/typescript.ts +87 -20
  101. package/src/linter/featureSchema.spec.ts +315 -0
  102. package/src/linter/featureSchema.ts +269 -115
  103. package/src/linter/mutationNotation.spec.ts +62 -0
  104. package/src/linter/mutationNotation.ts +47 -13
  105. package/src/linter/schema.spec.ts +72 -0
  106. package/src/linter/schema.ts +30 -0
@@ -2,12 +2,7 @@ import type { Schema, SchemaType } from "@featurevisor/types";
2
2
  import { z } from "zod";
3
3
 
4
4
  import { ProjectConfig } from "../config";
5
- import {
6
- isMutationKey,
7
- validateMutationKey,
8
- parsePathMapKey,
9
- resolveSchemaAtPath,
10
- } from "./mutationNotation";
5
+ import { isMutationKey, validateMutationKey } from "./mutationNotation";
11
6
  import {
12
7
  valueZodSchema,
13
8
  propertyTypeEnum,
@@ -51,6 +46,7 @@ function resolveVariableSchema(
51
46
  type?: string;
52
47
  items?: unknown;
53
48
  properties?: unknown;
49
+ additionalProperties?: unknown;
54
50
  required?: string[];
55
51
  enum?: unknown[];
56
52
  const?: unknown;
@@ -69,6 +65,7 @@ function resolveVariableSchema(
69
65
  type?: string;
70
66
  items?: unknown;
71
67
  properties?: unknown;
68
+ additionalProperties?: unknown;
72
69
  required?: string[];
73
70
  enum?: unknown[];
74
71
  const?: unknown;
@@ -89,6 +86,7 @@ function resolveVariableSchema(
89
86
  type?: string;
90
87
  items?: unknown;
91
88
  properties?: unknown;
89
+ additionalProperties?: unknown;
92
90
  required?: string[];
93
91
  enum?: unknown[];
94
92
  const?: unknown;
@@ -133,6 +131,7 @@ function valueMatchesSchema(
133
131
  enum?: unknown[];
134
132
  oneOf?: unknown[];
135
133
  properties?: Record<string, unknown>;
134
+ additionalProperties?: unknown;
136
135
  required?: string[];
137
136
  items?: unknown;
138
137
  minimum?: number;
@@ -195,8 +194,16 @@ function valueMatchesSchema(
195
194
  if (type === "object") {
196
195
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
197
196
  const props = resolved.properties;
198
- if (!props || typeof props !== "object") return true;
197
+ const additional = resolved.additionalProperties;
199
198
  const obj = value as Record<string, unknown>;
199
+ if ((!props || typeof props !== "object") && (!additional || typeof additional !== "object")) {
200
+ return true;
201
+ }
202
+ if (!props || typeof props !== "object") {
203
+ return Object.keys(obj).every((key) =>
204
+ valueMatchesSchema(additional as { [k: string]: unknown }, obj[key], schemasByKey),
205
+ );
206
+ }
200
207
  const required = new Set(resolved.required || []);
201
208
  for (const key of required) {
202
209
  if (!Object.prototype.hasOwnProperty.call(obj, key)) return false;
@@ -205,9 +212,15 @@ function valueMatchesSchema(
205
212
  }
206
213
  for (const key of Object.keys(obj)) {
207
214
  const propSchema = props[key];
208
- if (!propSchema) return false;
209
- if (!valueMatchesSchema(propSchema as { [k: string]: unknown }, obj[key], schemasByKey))
215
+ if (propSchema) {
216
+ if (!valueMatchesSchema(propSchema as { [k: string]: unknown }, obj[key], schemasByKey))
217
+ return false;
218
+ } else if (additional && typeof additional === "object") {
219
+ if (!valueMatchesSchema(additional as { [k: string]: unknown }, obj[key], schemasByKey))
220
+ return false;
221
+ } else {
210
222
  return false;
223
+ }
211
224
  }
212
225
  return true;
213
226
  }
@@ -259,6 +272,7 @@ function valueDeepEqual(a: unknown, b: unknown): boolean {
259
272
  type SchemaLikeForRequired = {
260
273
  type?: string;
261
274
  properties?: Record<string, unknown>;
275
+ additionalProperties?: unknown;
262
276
  required?: string[];
263
277
  items?: unknown;
264
278
  schema?: string;
@@ -325,6 +339,19 @@ function refineRequiredKeysInSchema(
325
339
  }
326
340
  }
327
341
  }
342
+ const additionalProperties = (current as { additionalProperties?: unknown }).additionalProperties;
343
+ if (
344
+ additionalProperties &&
345
+ typeof additionalProperties === "object" &&
346
+ !Array.isArray(additionalProperties)
347
+ ) {
348
+ refineRequiredKeysInSchema(
349
+ additionalProperties as SchemaLikeForRequired,
350
+ [...pathPrefix, "additionalProperties"],
351
+ ctx,
352
+ schemasByKey,
353
+ );
354
+ }
328
355
 
329
356
  if (items && typeof items === "object" && !Array.isArray(items)) {
330
357
  refineRequiredKeysInSchema(
@@ -453,6 +480,7 @@ function refineVariableValueObject(
453
480
  projectConfig: ProjectConfig,
454
481
  variableSchema: {
455
482
  properties?: Record<string, unknown>;
483
+ additionalProperties?: unknown;
456
484
  required?: string[];
457
485
  type: string;
458
486
  },
@@ -464,6 +492,7 @@ function refineVariableValueObject(
464
492
  ): void {
465
493
  const label = getVariableLabel(variableSchema, variableKey, path);
466
494
  const schemaProperties = variableSchema.properties;
495
+ const additionalProperties = variableSchema.additionalProperties;
467
496
 
468
497
  if (schemaProperties && typeof schemaProperties === "object") {
469
498
  const requiredKeys =
@@ -485,7 +514,17 @@ function refineVariableValueObject(
485
514
 
486
515
  for (const key of Object.keys(variableValue)) {
487
516
  const propSchema = schemaProperties[key];
488
- if (!propSchema) {
517
+ if (!propSchema && additionalProperties && typeof additionalProperties === "object") {
518
+ superRefineVariableValue(
519
+ projectConfig,
520
+ additionalProperties as Parameters<typeof superRefineVariableValue>[1],
521
+ variableValue[key],
522
+ [...path, key],
523
+ ctx,
524
+ key,
525
+ schemasByKey,
526
+ );
527
+ } else if (!propSchema) {
489
528
  ctx.addIssue({
490
529
  code: z.ZodIssueCode.custom,
491
530
  message: `Unknown property "${key}" in variable "${label}" (not in schema)`,
@@ -503,6 +542,18 @@ function refineVariableValueObject(
503
542
  );
504
543
  }
505
544
  }
545
+ } else if (additionalProperties && typeof additionalProperties === "object") {
546
+ for (const key of Object.keys(variableValue)) {
547
+ superRefineVariableValue(
548
+ projectConfig,
549
+ additionalProperties as Parameters<typeof superRefineVariableValue>[1],
550
+ variableValue[key],
551
+ [...path, key],
552
+ ctx,
553
+ key,
554
+ schemasByKey,
555
+ );
556
+ }
506
557
  } else {
507
558
  for (const key of Object.keys(variableValue)) {
508
559
  const propValue = variableValue[key];
@@ -944,6 +995,162 @@ function refineVariableEntry(
944
995
  );
945
996
  }
946
997
 
998
+ function refineVariableOverrides({
999
+ ctx,
1000
+ variableSchemaByKey,
1001
+ variableOverrides,
1002
+ pathPrefix,
1003
+ projectConfig,
1004
+ schemasByKey,
1005
+ }: {
1006
+ ctx: z.RefinementCtx;
1007
+ variableSchemaByKey: Record<string, unknown>;
1008
+ variableOverrides: Record<string, Array<{ value?: unknown }>>;
1009
+ pathPrefix: (string | number)[];
1010
+ projectConfig: ProjectConfig;
1011
+ schemasByKey?: Record<string, Schema>;
1012
+ }) {
1013
+ for (const variableKey of Object.keys(variableOverrides)) {
1014
+ if (isMutationKey(variableKey)) {
1015
+ ctx.addIssue({
1016
+ code: z.ZodIssueCode.custom,
1017
+ message:
1018
+ `Variable override key "${variableKey}" must be a declared variable key, not mutation notation. ` +
1019
+ "Use mutation notation inside each override's `value` object.",
1020
+ path: [...pathPrefix, variableKey],
1021
+ });
1022
+ continue;
1023
+ }
1024
+
1025
+ const variableSchema = variableSchemaByKey[variableKey];
1026
+ if (!variableSchema) {
1027
+ ctx.addIssue({
1028
+ code: z.ZodIssueCode.custom,
1029
+ message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
1030
+ path: [...pathPrefix, variableKey],
1031
+ });
1032
+ continue;
1033
+ }
1034
+
1035
+ const variableSchemaWithRef = variableSchema as { schema?: string };
1036
+ const resolvedVariableSchema = resolveVariableSchema(
1037
+ variableSchema as Parameters<typeof resolveVariableSchema>[0],
1038
+ schemasByKey,
1039
+ );
1040
+ if (variableSchemaWithRef.schema) {
1041
+ if (!resolvedVariableSchema) {
1042
+ ctx.addIssue({
1043
+ code: z.ZodIssueCode.custom,
1044
+ message: `Schema "${variableSchemaWithRef.schema}" could not be loaded for variable "${variableKey}".`,
1045
+ path: [...pathPrefix, variableKey],
1046
+ });
1047
+ continue;
1048
+ }
1049
+ }
1050
+
1051
+ const overrides = variableOverrides[variableKey];
1052
+ if (!Array.isArray(overrides)) {
1053
+ continue;
1054
+ }
1055
+
1056
+ overrides.forEach((override, overrideN) => {
1057
+ const overrideValue = override.value;
1058
+ const valuePath = [...pathPrefix, variableKey, overrideN, "value"];
1059
+
1060
+ if (
1061
+ typeof overrideValue === "object" &&
1062
+ overrideValue !== null &&
1063
+ !Array.isArray(overrideValue)
1064
+ ) {
1065
+ const pathMap = overrideValue as Record<string, unknown>;
1066
+ const pathKeys = Object.keys(pathMap);
1067
+ const hasStructuredObjectSchema =
1068
+ resolvedVariableSchema?.type === "object" &&
1069
+ (Boolean(resolvedVariableSchema.properties) ||
1070
+ Boolean(resolvedVariableSchema.additionalProperties));
1071
+ const treatAsPathMap =
1072
+ pathKeys.some((pathKey) => isMutationKey(pathKey)) ||
1073
+ (hasStructuredObjectSchema && pathKeys.length > 0);
1074
+
1075
+ if (treatAsPathMap) {
1076
+ for (const pathKey of pathKeys) {
1077
+ const composedMutationKey = `${variableKey}.${pathKey}`;
1078
+ const validation = validateMutationKey(
1079
+ composedMutationKey,
1080
+ variableSchemaByKey as Record<string, Schema>,
1081
+ schemasByKey,
1082
+ );
1083
+
1084
+ if (!validation.valid) {
1085
+ ctx.addIssue({
1086
+ code: z.ZodIssueCode.custom,
1087
+ message:
1088
+ validation.error ??
1089
+ `Invalid mutation path "${pathKey}" in variableOverride for "${variableKey}".`,
1090
+ path: [...valuePath, pathKey],
1091
+ });
1092
+ continue;
1093
+ }
1094
+
1095
+ if (validation.operation === "remove") {
1096
+ if (pathMap[pathKey] !== null) {
1097
+ ctx.addIssue({
1098
+ code: z.ZodIssueCode.custom,
1099
+ message: `Mutation "${pathKey}:remove" must use \`null\` as value.`,
1100
+ path: [...valuePath, pathKey],
1101
+ });
1102
+ }
1103
+ continue;
1104
+ }
1105
+
1106
+ const fallbackValueSchema =
1107
+ validation.operation === "append" ||
1108
+ validation.operation === "prepend" ||
1109
+ validation.operation === "before" ||
1110
+ validation.operation === "after"
1111
+ ? ({ type: "string" } as Schema)
1112
+ : null;
1113
+ const valueSchema = validation.valueSchema ?? fallbackValueSchema;
1114
+ if (!valueSchema) {
1115
+ continue;
1116
+ }
1117
+
1118
+ superRefineVariableValue(
1119
+ projectConfig,
1120
+ valueSchema,
1121
+ pathMap[pathKey],
1122
+ [...valuePath, pathKey],
1123
+ ctx,
1124
+ pathKey,
1125
+ schemasByKey,
1126
+ );
1127
+ }
1128
+ } else {
1129
+ superRefineVariableValue(
1130
+ projectConfig,
1131
+ variableSchema as Parameters<typeof superRefineVariableValue>[1],
1132
+ overrideValue,
1133
+ valuePath,
1134
+ ctx,
1135
+ variableKey,
1136
+ schemasByKey,
1137
+ );
1138
+ }
1139
+ } else {
1140
+ superRefineVariableValue(
1141
+ projectConfig,
1142
+ variableSchema as Parameters<typeof superRefineVariableValue>[1],
1143
+ overrideValue,
1144
+ valuePath,
1145
+ ctx,
1146
+ variableKey,
1147
+ schemasByKey,
1148
+ );
1149
+ }
1150
+ });
1151
+ }
1152
+ }
1153
+
947
1154
  function refineForce({
948
1155
  ctx,
949
1156
  parsedFeature, // eslint-disable-line
@@ -1009,6 +1216,18 @@ function refineRules({
1009
1216
  });
1010
1217
  }
1011
1218
 
1219
+ // rules[n].variableOverrides[key][].value (path-map or full value)
1220
+ if (rule.variableOverrides) {
1221
+ refineVariableOverrides({
1222
+ ctx,
1223
+ variableSchemaByKey,
1224
+ variableOverrides: rule.variableOverrides,
1225
+ pathPrefix: [...pathPrefix, ruleN, "variableOverrides"],
1226
+ projectConfig,
1227
+ schemasByKey,
1228
+ });
1229
+ }
1230
+
1012
1231
  // rules[n].variationWeights
1013
1232
  if (rule.variationWeights) {
1014
1233
  if (!parsedFeature.variations) {
@@ -1087,6 +1306,11 @@ export function getFeatureZodSchema(
1087
1306
  const schemaZodSchema = getSchemaZodSchema(availableSchemaKeys);
1088
1307
  const variableValueZodSchema = valueZodSchema;
1089
1308
  const variableValueOrNullZodSchema = z.union([valueZodSchema, z.null()]);
1309
+ const overridePathMapValueZodSchema = z.record(variableValueOrNullZodSchema);
1310
+ const overrideValueZodSchema = z.union([
1311
+ variableValueOrNullZodSchema,
1312
+ overridePathMapValueZodSchema,
1313
+ ]);
1090
1314
 
1091
1315
  const variationValueZodSchema = z.string().min(1);
1092
1316
 
@@ -1135,6 +1359,26 @@ export function getFeatureZodSchema(
1135
1359
  enabled: z.boolean().optional(),
1136
1360
  variation: variationValueZodSchema.optional(),
1137
1361
  variables: z.record(variableValueOrNullZodSchema).optional(),
1362
+ variableOverrides: z
1363
+ .record(
1364
+ z.array(
1365
+ z.union([
1366
+ z
1367
+ .object({
1368
+ conditions: conditionsZodSchema,
1369
+ value: overrideValueZodSchema,
1370
+ })
1371
+ .strict(),
1372
+ z
1373
+ .object({
1374
+ segments: groupSegmentsZodSchema,
1375
+ value: overrideValueZodSchema,
1376
+ })
1377
+ .strict(),
1378
+ ]),
1379
+ ),
1380
+ )
1381
+ .optional(),
1138
1382
  variationWeights: z.record(z.number().min(0).max(100)).optional(),
1139
1383
  })
1140
1384
  .strict(),
@@ -1280,6 +1524,7 @@ export function getFeatureZodSchema(
1280
1524
  type: z.union([z.literal("json"), propertyTypeEnum]).optional(),
1281
1525
  items: schemaZodSchema.optional(),
1282
1526
  properties: z.record(schemaZodSchema).optional(),
1527
+ additionalProperties: schemaZodSchema.optional(),
1283
1528
  required: z.array(z.string()).optional(),
1284
1529
  enum: z.array(variableValueZodSchema).optional(),
1285
1530
  const: variableValueZodSchema.optional(),
@@ -1315,7 +1560,7 @@ export function getFeatureZodSchema(
1315
1560
  ctx.addIssue({
1316
1561
  code: z.ZodIssueCode.custom,
1317
1562
  message:
1318
- "Variable schema cannot have both `schema` (reference) and inline properties (`type`, `oneOf`, `properties`, `required`, `items`). Use one or the other.",
1563
+ "Variable schema cannot have both `schema` (reference) and inline properties (`type`, `oneOf`, `properties`, `additionalProperties`, `required`, `items`). Use one or the other.",
1319
1564
  path: [],
1320
1565
  });
1321
1566
  return;
@@ -1324,6 +1569,8 @@ export function getFeatureZodSchema(
1324
1569
  const hasInlineStructure =
1325
1570
  ("type" in variableSchema && variableSchema.type != null) ||
1326
1571
  ("properties" in variableSchema && variableSchema.properties != null) ||
1572
+ ("additionalProperties" in variableSchema &&
1573
+ variableSchema.additionalProperties != null) ||
1327
1574
  ("required" in variableSchema && variableSchema.required != null) ||
1328
1575
  ("items" in variableSchema && variableSchema.items != null) ||
1329
1576
  ("oneOf" in variableSchema && variableSchema.oneOf != null);
@@ -1340,7 +1587,7 @@ export function getFeatureZodSchema(
1340
1587
  ctx.addIssue({
1341
1588
  code: z.ZodIssueCode.custom,
1342
1589
  message:
1343
- "When `schema` is set, do not set `type`, `oneOf`, `properties`, `required`, or `items`.",
1590
+ "When `schema` is set, do not set `type`, `oneOf`, `properties`, `additionalProperties`, `required`, or `items`.",
1344
1591
  path: [],
1345
1592
  });
1346
1593
  }
@@ -1400,13 +1647,13 @@ export function getFeatureZodSchema(
1400
1647
  z
1401
1648
  .object({
1402
1649
  conditions: conditionsZodSchema,
1403
- value: variableValueZodSchema,
1650
+ value: overrideValueZodSchema,
1404
1651
  })
1405
1652
  .strict(),
1406
1653
  z
1407
1654
  .object({
1408
1655
  segments: groupSegmentsZodSchema,
1409
- value: variableValueZodSchema,
1656
+ value: overrideValueZodSchema,
1410
1657
  })
1411
1658
  .strict(),
1412
1659
  ]),
@@ -1593,107 +1840,14 @@ export function getFeatureZodSchema(
1593
1840
 
1594
1841
  // variations[n].variableOverrides[key][].value (path-map or full value)
1595
1842
  if (variation.variableOverrides) {
1596
- for (const variableKey of Object.keys(variation.variableOverrides)) {
1597
- const variableSchema = variableSchemaByKey[variableKey];
1598
- if (!variableSchema) {
1599
- ctx.addIssue({
1600
- code: z.ZodIssueCode.custom,
1601
- message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
1602
- path: ["variations", variationN, "variableOverrides", variableKey],
1603
- });
1604
- continue;
1605
- }
1606
- // When variable references a schema by name, ensure it can be resolved
1607
- const variableSchemaWithRef = variableSchema as { schema?: string };
1608
- if (variableSchemaWithRef.schema) {
1609
- const resolvedVarSchema = resolveVariableSchema(
1610
- variableSchema as Parameters<typeof resolveVariableSchema>[0],
1611
- schemasByKey,
1612
- );
1613
- if (!resolvedVarSchema) {
1614
- ctx.addIssue({
1615
- code: z.ZodIssueCode.custom,
1616
- message: `Schema "${variableSchemaWithRef.schema}" could not be loaded for variable "${variableKey}".`,
1617
- path: ["variations", variationN, "variableOverrides", variableKey],
1618
- });
1619
- continue;
1620
- }
1621
- }
1622
- const overrides = variation.variableOverrides[variableKey];
1623
- if (!Array.isArray(overrides)) continue;
1624
- overrides.forEach((override, overrideN) => {
1625
- const overrideValue = override.value;
1626
- const valuePath = [
1627
- "variations",
1628
- variationN,
1629
- "variableOverrides",
1630
- variableKey,
1631
- overrideN,
1632
- "value",
1633
- ];
1634
- if (
1635
- typeof overrideValue === "object" &&
1636
- overrideValue !== null &&
1637
- !Array.isArray(overrideValue)
1638
- ) {
1639
- const pathMap = overrideValue as Record<string, unknown>;
1640
- for (const pathKey of Object.keys(pathMap)) {
1641
- const pathSegments = parsePathMapKey(pathKey);
1642
- if (!pathSegments || pathSegments.length === 0) {
1643
- ctx.addIssue({
1644
- code: z.ZodIssueCode.custom,
1645
- message: `Invalid mutation path "${pathKey}" in variableOverride for "${variableKey}".`,
1646
- path: [...valuePath, pathKey],
1647
- });
1648
- continue;
1649
- }
1650
- const atPathSchema = resolveSchemaAtPath(
1651
- variableSchema as Schema,
1652
- pathSegments,
1653
- schemasByKey,
1654
- );
1655
- if (!atPathSchema) {
1656
- const effectiveSchema = resolveVariableSchema(
1657
- variableSchema as Parameters<typeof resolveVariableSchema>[0],
1658
- schemasByKey,
1659
- );
1660
- const isFlatObject =
1661
- effectiveSchema?.type === "object" &&
1662
- (!effectiveSchema.properties ||
1663
- Object.keys(effectiveSchema.properties).length === 0);
1664
- if (isFlatObject) {
1665
- continue;
1666
- }
1667
- ctx.addIssue({
1668
- code: z.ZodIssueCode.custom,
1669
- message: `Path "${pathKey}" is invalid for variable "${variableKey}" (not in schema).`,
1670
- path: [...valuePath, pathKey],
1671
- });
1672
- continue;
1673
- }
1674
- superRefineVariableValue(
1675
- projectConfig,
1676
- atPathSchema,
1677
- pathMap[pathKey],
1678
- [...valuePath, pathKey],
1679
- ctx,
1680
- pathKey,
1681
- schemasByKey,
1682
- );
1683
- }
1684
- } else {
1685
- superRefineVariableValue(
1686
- projectConfig,
1687
- variableSchema as Parameters<typeof superRefineVariableValue>[1],
1688
- overrideValue,
1689
- valuePath,
1690
- ctx,
1691
- variableKey,
1692
- schemasByKey,
1693
- );
1694
- }
1695
- });
1696
- }
1843
+ refineVariableOverrides({
1844
+ ctx,
1845
+ variableSchemaByKey,
1846
+ variableOverrides: variation.variableOverrides,
1847
+ pathPrefix: ["variations", variationN, "variableOverrides"],
1848
+ projectConfig,
1849
+ schemasByKey,
1850
+ });
1697
1851
  }
1698
1852
  });
1699
1853
  }
@@ -257,6 +257,17 @@ describe("mutationNotation.ts", () => {
257
257
  expect(result).toBeNull();
258
258
  });
259
259
 
260
+ it("resolves unknown object key via additionalProperties", () => {
261
+ const result = resolveSchemaAtPath(
262
+ {
263
+ type: "object",
264
+ additionalProperties: { type: "string" },
265
+ },
266
+ [{ key: "dynamicKey" }],
267
+ );
268
+ expect(result).toEqual({ type: "string" });
269
+ });
270
+
260
271
  it("returns null when stepping into non-object", () => {
261
272
  const result = resolveSchemaAtPath(objectSchema, [{ key: "width" }, { key: "foo" }]);
262
273
  expect(result).toBeNull();
@@ -384,6 +395,21 @@ describe("mutationNotation.ts", () => {
384
395
  tags: arraySchema,
385
396
  items: arrayOfObjectsSchema,
386
397
  settings: nestedObjectSchema,
398
+ partialMutationShowcase: {
399
+ type: "object",
400
+ properties: {
401
+ rows: {
402
+ type: "array",
403
+ items: {
404
+ type: "object",
405
+ properties: {
406
+ id: { type: "integer" },
407
+ label: { type: "string" },
408
+ },
409
+ },
410
+ },
411
+ },
412
+ },
387
413
  };
388
414
 
389
415
  it("returns invalid when key is empty (parseMutationKey null)", () => {
@@ -446,6 +472,18 @@ describe("mutationNotation.ts", () => {
446
472
  expect(r.error).toContain("path does not exist");
447
473
  });
448
474
 
475
+ it("valid when path uses additionalProperties", () => {
476
+ const schemas: Record<string, Schema> = {
477
+ labels: {
478
+ type: "object",
479
+ additionalProperties: { type: "string" },
480
+ },
481
+ };
482
+ const r = validateMutationKey("labels.headline", schemas);
483
+ expect(r.valid).toBe(true);
484
+ expect(r.valueSchema).toEqual({ type: "string" });
485
+ });
486
+
449
487
  it("invalid when target schema is oneOf", () => {
450
488
  const schemas: Record<string, Schema> = {
451
489
  x: {
@@ -535,6 +573,22 @@ describe("mutationNotation.ts", () => {
535
573
  expect(r.operation).toBe("before");
536
574
  });
537
575
 
576
+ it("valid on nested array selector segment in object variable", () => {
577
+ const r = validateMutationKey(
578
+ "partialMutationShowcase.rows[id=1]:after",
579
+ variableSchemaByKey,
580
+ );
581
+ expect(r.valid).toBe(true);
582
+ expect(r.operation).toBe("after");
583
+ expect(r.valueSchema).toEqual({
584
+ type: "object",
585
+ properties: {
586
+ id: { type: "integer" },
587
+ label: { type: "string" },
588
+ },
589
+ });
590
+ });
591
+
538
592
  it("invalid when path does not exist or does not point to array element", () => {
539
593
  const r = validateMutationKey("config.fake[0]:after", variableSchemaByKey);
540
594
  expect(r.valid).toBe(false);
@@ -589,6 +643,14 @@ describe("mutationNotation.ts", () => {
589
643
  expect(r.valid).toBe(true);
590
644
  });
591
645
 
646
+ it("valid when removing nested array element by selector", () => {
647
+ const r = validateMutationKey(
648
+ "partialMutationShowcase.rows[id=2]:remove",
649
+ variableSchemaByKey,
650
+ );
651
+ expect(r.valid).toBe(true);
652
+ });
653
+
592
654
  it("invalid when path does not exist", () => {
593
655
  const r = validateMutationKey("config.bad:remove", variableSchemaByKey);
594
656
  expect(r.valid).toBe(false);