@featurevisor/core 2.18.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 (93) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +1581 -1119
  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 +686 -401
  31. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  32. package/coverage/lcov-report/linter/index.html +27 -27
  33. package/coverage/lcov-report/linter/lintProject.ts.html +25 -25
  34. package/coverage/lcov-report/linter/mutationNotation.ts.html +191 -122
  35. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  36. package/coverage/lcov-report/linter/schema.ts.html +72 -72
  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 +2955 -2095
  54. package/lib/builder/buildDatafile.js +50 -3
  55. package/lib/builder/buildDatafile.js.map +1 -1
  56. package/lib/builder/buildDatafile.spec.d.ts +1 -0
  57. package/lib/builder/buildDatafile.spec.js +233 -0
  58. package/lib/builder/buildDatafile.spec.js.map +1 -0
  59. package/lib/builder/buildScopedDatafile.js +37 -1
  60. package/lib/builder/buildScopedDatafile.js.map +1 -1
  61. package/lib/builder/buildScopedDatafile.spec.js +289 -0
  62. package/lib/builder/buildScopedDatafile.spec.js.map +1 -1
  63. package/lib/builder/mutateVariables.d.ts +1 -1
  64. package/lib/builder/mutateVariables.js +4 -1
  65. package/lib/builder/mutateVariables.js.map +1 -1
  66. package/lib/builder/mutateVariables.spec.js +29 -0
  67. package/lib/builder/mutateVariables.spec.js.map +1 -1
  68. package/lib/builder/traffic.js +1 -0
  69. package/lib/builder/traffic.js.map +1 -1
  70. package/lib/builder/traffic.spec.js +26 -0
  71. package/lib/builder/traffic.spec.js.map +1 -1
  72. package/lib/linter/featureSchema.d.ts +228 -22
  73. package/lib/linter/featureSchema.js +137 -76
  74. package/lib/linter/featureSchema.js.map +1 -1
  75. package/lib/linter/featureSchema.spec.js +175 -0
  76. package/lib/linter/featureSchema.spec.js.map +1 -1
  77. package/lib/linter/mutationNotation.js +35 -10
  78. package/lib/linter/mutationNotation.js.map +1 -1
  79. package/lib/linter/mutationNotation.spec.js +31 -0
  80. package/lib/linter/mutationNotation.spec.js.map +1 -1
  81. package/package.json +5 -5
  82. package/src/builder/buildDatafile.spec.ts +267 -0
  83. package/src/builder/buildDatafile.ts +85 -4
  84. package/src/builder/buildScopedDatafile.spec.ts +330 -0
  85. package/src/builder/buildScopedDatafile.ts +57 -1
  86. package/src/builder/mutateVariables.spec.ts +45 -0
  87. package/src/builder/mutateVariables.ts +7 -0
  88. package/src/builder/traffic.spec.ts +33 -0
  89. package/src/builder/traffic.ts +1 -0
  90. package/src/linter/featureSchema.spec.ts +197 -0
  91. package/src/linter/featureSchema.ts +204 -109
  92. package/src/linter/mutationNotation.spec.ts +39 -0
  93. package/src/linter/mutationNotation.ts +30 -7
@@ -610,6 +610,34 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
610
610
  "maxLength",
611
611
  );
612
612
  });
613
+
614
+ it("accepts plain-key partial object overrides for structured object schemas", () => {
615
+ expectParseSuccess(
616
+ baseFeature({
617
+ variablesSchema: {
618
+ config: {
619
+ type: "object",
620
+ properties: {
621
+ theme: { type: "string" },
622
+ compact: { type: "boolean" },
623
+ },
624
+ required: ["theme", "compact"],
625
+ defaultValue: { theme: "light", compact: true },
626
+ },
627
+ },
628
+ variations: [
629
+ { value: "control", weight: 50 },
630
+ {
631
+ value: "treatment",
632
+ weight: 50,
633
+ variableOverrides: {
634
+ config: [{ segments: "*", value: { theme: "dark" } }],
635
+ },
636
+ },
637
+ ],
638
+ }),
639
+ );
640
+ });
613
641
  });
614
642
 
615
643
  describe("rules: variables", () => {
@@ -701,6 +729,175 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
701
729
  });
702
730
  });
703
731
 
732
+ describe("rules: variableOverrides", () => {
733
+ it("accepts rule variableOverrides with values matching variablesSchema", () => {
734
+ expectParseSuccess(
735
+ baseFeature({
736
+ variablesSchema: {
737
+ config: {
738
+ type: "object",
739
+ properties: {
740
+ title: { type: "string" },
741
+ nested: {
742
+ type: "object",
743
+ properties: {
744
+ count: { type: "integer" },
745
+ },
746
+ },
747
+ },
748
+ defaultValue: {
749
+ title: "default",
750
+ nested: { count: 0 },
751
+ },
752
+ },
753
+ },
754
+ rules: {
755
+ staging: [
756
+ {
757
+ key: "r1",
758
+ segments: "*",
759
+ percentage: 100,
760
+ variableOverrides: {
761
+ config: [
762
+ {
763
+ segments: "countries/germany",
764
+ value: {
765
+ "nested.count": 5,
766
+ },
767
+ },
768
+ ],
769
+ },
770
+ },
771
+ ],
772
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
773
+ },
774
+ }),
775
+ );
776
+ });
777
+
778
+ it("rejects rule variableOverrides for unknown variable key", () => {
779
+ expectParseFailure(
780
+ baseFeature({
781
+ variablesSchema: {
782
+ config: { type: "string", defaultValue: "x" },
783
+ },
784
+ rules: {
785
+ staging: [
786
+ {
787
+ key: "r1",
788
+ segments: "*",
789
+ percentage: 100,
790
+ variableOverrides: {
791
+ unknown: [{ segments: "*", value: "x" }],
792
+ },
793
+ },
794
+ ],
795
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
796
+ },
797
+ }),
798
+ "not defined in",
799
+ );
800
+ });
801
+
802
+ it("rejects mutation notation in rule variableOverrides key", () => {
803
+ expectParseFailure(
804
+ baseFeature({
805
+ variablesSchema: {
806
+ config: {
807
+ type: "object",
808
+ properties: { title: { type: "string" } },
809
+ defaultValue: { title: "x" },
810
+ },
811
+ },
812
+ rules: {
813
+ staging: [
814
+ {
815
+ key: "r1",
816
+ segments: "*",
817
+ percentage: 100,
818
+ variableOverrides: {
819
+ "config.title": [{ segments: "*", value: "y" }],
820
+ },
821
+ },
822
+ ],
823
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
824
+ },
825
+ }),
826
+ "must be a declared variable key",
827
+ );
828
+ });
829
+
830
+ it("rejects rule variableOverride path not present in schema", () => {
831
+ expectParseFailure(
832
+ baseFeature({
833
+ variablesSchema: {
834
+ config: {
835
+ type: "object",
836
+ properties: { title: { type: "string" } },
837
+ defaultValue: { title: "x" },
838
+ },
839
+ },
840
+ rules: {
841
+ staging: [
842
+ {
843
+ key: "r1",
844
+ segments: "*",
845
+ percentage: 100,
846
+ variableOverrides: {
847
+ config: [
848
+ {
849
+ segments: "*",
850
+ value: {
851
+ "nested.missing": "x",
852
+ },
853
+ },
854
+ ],
855
+ },
856
+ },
857
+ ],
858
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
859
+ },
860
+ }),
861
+ "invalid for variable",
862
+ );
863
+ });
864
+
865
+ it("rejects non-null payload for :remove in rule variableOverrides", () => {
866
+ expectParseFailure(
867
+ baseFeature({
868
+ variablesSchema: {
869
+ config: {
870
+ type: "object",
871
+ properties: { title: { type: "string" }, optional: { type: "string" } },
872
+ defaultValue: { title: "x", optional: "y" },
873
+ },
874
+ },
875
+ rules: {
876
+ staging: [
877
+ {
878
+ key: "r1",
879
+ segments: "*",
880
+ percentage: 100,
881
+ variableOverrides: {
882
+ config: [
883
+ {
884
+ conditions: [{ attribute: "country", operator: "equals", value: "de" }],
885
+ value: {
886
+ "optional:remove": "not-null",
887
+ },
888
+ },
889
+ ],
890
+ },
891
+ },
892
+ ],
893
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
894
+ },
895
+ }),
896
+ "must use `null` as value",
897
+ );
898
+ });
899
+ });
900
+
704
901
  describe("force: variables", () => {
705
902
  it("accepts force variables that match variablesSchema", () => {
706
903
  expectParseSuccess(
@@ -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,
@@ -1000,6 +995,162 @@ function refineVariableEntry(
1000
995
  );
1001
996
  }
1002
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
+
1003
1154
  function refineForce({
1004
1155
  ctx,
1005
1156
  parsedFeature, // eslint-disable-line
@@ -1065,6 +1216,18 @@ function refineRules({
1065
1216
  });
1066
1217
  }
1067
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
+
1068
1231
  // rules[n].variationWeights
1069
1232
  if (rule.variationWeights) {
1070
1233
  if (!parsedFeature.variations) {
@@ -1143,6 +1306,11 @@ export function getFeatureZodSchema(
1143
1306
  const schemaZodSchema = getSchemaZodSchema(availableSchemaKeys);
1144
1307
  const variableValueZodSchema = valueZodSchema;
1145
1308
  const variableValueOrNullZodSchema = z.union([valueZodSchema, z.null()]);
1309
+ const overridePathMapValueZodSchema = z.record(variableValueOrNullZodSchema);
1310
+ const overrideValueZodSchema = z.union([
1311
+ variableValueOrNullZodSchema,
1312
+ overridePathMapValueZodSchema,
1313
+ ]);
1146
1314
 
1147
1315
  const variationValueZodSchema = z.string().min(1);
1148
1316
 
@@ -1191,6 +1359,26 @@ export function getFeatureZodSchema(
1191
1359
  enabled: z.boolean().optional(),
1192
1360
  variation: variationValueZodSchema.optional(),
1193
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(),
1194
1382
  variationWeights: z.record(z.number().min(0).max(100)).optional(),
1195
1383
  })
1196
1384
  .strict(),
@@ -1459,13 +1647,13 @@ export function getFeatureZodSchema(
1459
1647
  z
1460
1648
  .object({
1461
1649
  conditions: conditionsZodSchema,
1462
- value: variableValueZodSchema,
1650
+ value: overrideValueZodSchema,
1463
1651
  })
1464
1652
  .strict(),
1465
1653
  z
1466
1654
  .object({
1467
1655
  segments: groupSegmentsZodSchema,
1468
- value: variableValueZodSchema,
1656
+ value: overrideValueZodSchema,
1469
1657
  })
1470
1658
  .strict(),
1471
1659
  ]),
@@ -1652,107 +1840,14 @@ export function getFeatureZodSchema(
1652
1840
 
1653
1841
  // variations[n].variableOverrides[key][].value (path-map or full value)
1654
1842
  if (variation.variableOverrides) {
1655
- for (const variableKey of Object.keys(variation.variableOverrides)) {
1656
- const variableSchema = variableSchemaByKey[variableKey];
1657
- if (!variableSchema) {
1658
- ctx.addIssue({
1659
- code: z.ZodIssueCode.custom,
1660
- message: `Variable "${variableKey}" is not defined in \`variablesSchema\`.`,
1661
- path: ["variations", variationN, "variableOverrides", variableKey],
1662
- });
1663
- continue;
1664
- }
1665
- // When variable references a schema by name, ensure it can be resolved
1666
- const variableSchemaWithRef = variableSchema as { schema?: string };
1667
- if (variableSchemaWithRef.schema) {
1668
- const resolvedVarSchema = resolveVariableSchema(
1669
- variableSchema as Parameters<typeof resolveVariableSchema>[0],
1670
- schemasByKey,
1671
- );
1672
- if (!resolvedVarSchema) {
1673
- ctx.addIssue({
1674
- code: z.ZodIssueCode.custom,
1675
- message: `Schema "${variableSchemaWithRef.schema}" could not be loaded for variable "${variableKey}".`,
1676
- path: ["variations", variationN, "variableOverrides", variableKey],
1677
- });
1678
- continue;
1679
- }
1680
- }
1681
- const overrides = variation.variableOverrides[variableKey];
1682
- if (!Array.isArray(overrides)) continue;
1683
- overrides.forEach((override, overrideN) => {
1684
- const overrideValue = override.value;
1685
- const valuePath = [
1686
- "variations",
1687
- variationN,
1688
- "variableOverrides",
1689
- variableKey,
1690
- overrideN,
1691
- "value",
1692
- ];
1693
- if (
1694
- typeof overrideValue === "object" &&
1695
- overrideValue !== null &&
1696
- !Array.isArray(overrideValue)
1697
- ) {
1698
- const pathMap = overrideValue as Record<string, unknown>;
1699
- for (const pathKey of Object.keys(pathMap)) {
1700
- const pathSegments = parsePathMapKey(pathKey);
1701
- if (!pathSegments || pathSegments.length === 0) {
1702
- ctx.addIssue({
1703
- code: z.ZodIssueCode.custom,
1704
- message: `Invalid mutation path "${pathKey}" in variableOverride for "${variableKey}".`,
1705
- path: [...valuePath, pathKey],
1706
- });
1707
- continue;
1708
- }
1709
- const atPathSchema = resolveSchemaAtPath(
1710
- variableSchema as Schema,
1711
- pathSegments,
1712
- schemasByKey,
1713
- );
1714
- if (!atPathSchema) {
1715
- const effectiveSchema = resolveVariableSchema(
1716
- variableSchema as Parameters<typeof resolveVariableSchema>[0],
1717
- schemasByKey,
1718
- );
1719
- const isFlatObject =
1720
- effectiveSchema?.type === "object" &&
1721
- (!effectiveSchema.properties ||
1722
- Object.keys(effectiveSchema.properties).length === 0);
1723
- if (isFlatObject) {
1724
- continue;
1725
- }
1726
- ctx.addIssue({
1727
- code: z.ZodIssueCode.custom,
1728
- message: `Path "${pathKey}" is invalid for variable "${variableKey}" (not in schema).`,
1729
- path: [...valuePath, pathKey],
1730
- });
1731
- continue;
1732
- }
1733
- superRefineVariableValue(
1734
- projectConfig,
1735
- atPathSchema,
1736
- pathMap[pathKey],
1737
- [...valuePath, pathKey],
1738
- ctx,
1739
- pathKey,
1740
- schemasByKey,
1741
- );
1742
- }
1743
- } else {
1744
- superRefineVariableValue(
1745
- projectConfig,
1746
- variableSchema as Parameters<typeof superRefineVariableValue>[1],
1747
- overrideValue,
1748
- valuePath,
1749
- ctx,
1750
- variableKey,
1751
- schemasByKey,
1752
- );
1753
- }
1754
- });
1755
- }
1843
+ refineVariableOverrides({
1844
+ ctx,
1845
+ variableSchemaByKey,
1846
+ variableOverrides: variation.variableOverrides,
1847
+ pathPrefix: ["variations", variationN, "variableOverrides"],
1848
+ projectConfig,
1849
+ schemasByKey,
1850
+ });
1756
1851
  }
1757
1852
  });
1758
1853
  }
@@ -395,6 +395,21 @@ describe("mutationNotation.ts", () => {
395
395
  tags: arraySchema,
396
396
  items: arrayOfObjectsSchema,
397
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
+ },
398
413
  };
399
414
 
400
415
  it("returns invalid when key is empty (parseMutationKey null)", () => {
@@ -558,6 +573,22 @@ describe("mutationNotation.ts", () => {
558
573
  expect(r.operation).toBe("before");
559
574
  });
560
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
+
561
592
  it("invalid when path does not exist or does not point to array element", () => {
562
593
  const r = validateMutationKey("config.fake[0]:after", variableSchemaByKey);
563
594
  expect(r.valid).toBe(false);
@@ -612,6 +643,14 @@ describe("mutationNotation.ts", () => {
612
643
  expect(r.valid).toBe(true);
613
644
  });
614
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
+
615
654
  it("invalid when path does not exist", () => {
616
655
  const r = validateMutationKey("config.bad:remove", variableSchemaByKey);
617
656
  expect(r.valid).toBe(false);
@@ -132,12 +132,7 @@ function getContainerSchemaAtPath(
132
132
  const lastSegment = pathSegments[pathSegments.length - 1];
133
133
  for (const seg of pathWithoutLast) {
134
134
  if (isOneOfSchema(current)) return null;
135
- if ("index" in seg || "selector" in seg) {
136
- if (current.type !== "array") return null;
137
- const itemSchema = current.items;
138
- if (!itemSchema || typeof itemSchema !== "object") return null;
139
- current = resolveSchemaRef(itemSchema, schemasByKey);
140
- } else {
135
+ if (seg.key) {
141
136
  if (current.type !== "object") return null;
142
137
  const props = current.properties;
143
138
  const additional = current.additionalProperties;
@@ -150,12 +145,40 @@ function getContainerSchemaAtPath(
150
145
  }
151
146
  current = resolveSchemaRef(next, schemasByKey);
152
147
  }
148
+ if ("index" in seg || "selector" in seg) {
149
+ if (current?.type !== "array") return null;
150
+ const itemSchema = current.items;
151
+ if (!itemSchema || typeof itemSchema !== "object") return null;
152
+ current = resolveSchemaRef(itemSchema, schemasByKey);
153
+ }
153
154
  if (!current) return null;
154
155
  }
155
156
  if (!current) return null;
156
157
  const parentSchema = current;
157
158
  if ("index" in lastSegment || "selector" in lastSegment) {
158
- return { containerSchema: parentSchema, lastSegment, parentSchema };
159
+ let arraySchema: Schema | null = parentSchema;
160
+ if (lastSegment.key) {
161
+ if (arraySchema.type !== "object") return null;
162
+ const props = arraySchema.properties;
163
+ const additional = arraySchema.additionalProperties;
164
+ const next = props && typeof props === "object" ? props[lastSegment.key] : undefined;
165
+ if (next === undefined) {
166
+ if (!additional || typeof additional !== "object") return null;
167
+ arraySchema = resolveSchemaRef(additional, schemasByKey);
168
+ } else {
169
+ arraySchema = resolveSchemaRef(next, schemasByKey);
170
+ }
171
+ if (!arraySchema) return null;
172
+ }
173
+ const normalizedLastSegment =
174
+ "index" in lastSegment
175
+ ? ({ key: "", index: lastSegment.index } as PathSegment)
176
+ : ({ key: "", selector: lastSegment.selector } as PathSegment);
177
+ return {
178
+ containerSchema: arraySchema,
179
+ lastSegment: normalizedLastSegment,
180
+ parentSchema: arraySchema,
181
+ };
159
182
  }
160
183
  const propSchema =
161
184
  parentSchema.properties?.[lastSegment.key] ?? parentSchema.additionalProperties;