@featurevisor/core 2.13.0 → 2.14.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 (80) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +906 -557
  3. package/coverage/coverage-final.json +11 -8
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  8. package/coverage/lcov-report/builder/index.html +38 -8
  9. package/coverage/lcov-report/builder/mutateVariables.ts.html +400 -0
  10. package/coverage/lcov-report/builder/mutator.ts.html +796 -0
  11. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  12. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  13. package/coverage/lcov-report/config/index.html +1 -1
  14. package/coverage/lcov-report/config/projectConfig.ts.html +1 -1
  15. package/coverage/lcov-report/datasource/adapter.ts.html +1 -1
  16. package/coverage/lcov-report/datasource/datasource.ts.html +4 -4
  17. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +23 -23
  18. package/coverage/lcov-report/datasource/index.html +1 -1
  19. package/coverage/lcov-report/datasource/index.ts.html +1 -1
  20. package/coverage/lcov-report/index.html +28 -28
  21. package/coverage/lcov-report/linter/attributeSchema.ts.html +1 -1
  22. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  23. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  24. package/coverage/lcov-report/linter/conditionSchema.ts.html +7 -7
  25. package/coverage/lcov-report/linter/featureSchema.ts.html +789 -318
  26. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  27. package/coverage/lcov-report/linter/index.html +34 -19
  28. package/coverage/lcov-report/linter/lintProject.ts.html +25 -25
  29. package/coverage/lcov-report/linter/mutationNotation.ts.html +1309 -0
  30. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  31. package/coverage/lcov-report/linter/schema.ts.html +69 -69
  32. package/coverage/lcov-report/linter/segmentSchema.ts.html +1 -1
  33. package/coverage/lcov-report/linter/testSchema.ts.html +5 -5
  34. package/coverage/lcov-report/list/index.html +1 -1
  35. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  36. package/coverage/lcov-report/parsers/index.html +1 -1
  37. package/coverage/lcov-report/parsers/index.ts.html +1 -1
  38. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  39. package/coverage/lcov-report/parsers/yml.ts.html +2 -2
  40. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  41. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  42. package/coverage/lcov-report/tester/index.html +1 -1
  43. package/coverage/lcov-report/utils/git.ts.html +1 -1
  44. package/coverage/lcov-report/utils/index.html +1 -1
  45. package/coverage/lcov.info +1839 -1120
  46. package/lib/builder/buildDatafile.js +8 -3
  47. package/lib/builder/buildDatafile.js.map +1 -1
  48. package/lib/builder/mutateVariables.d.ts +14 -0
  49. package/lib/builder/mutateVariables.js +90 -0
  50. package/lib/builder/mutateVariables.js.map +1 -0
  51. package/lib/builder/mutateVariables.spec.d.ts +1 -0
  52. package/lib/builder/mutateVariables.spec.js +1045 -0
  53. package/lib/builder/mutateVariables.spec.js.map +1 -0
  54. package/lib/builder/mutator.d.ts +20 -0
  55. package/lib/builder/mutator.js +223 -0
  56. package/lib/builder/mutator.js.map +1 -0
  57. package/lib/builder/mutator.spec.d.ts +1 -0
  58. package/lib/builder/mutator.spec.js +368 -0
  59. package/lib/builder/mutator.spec.js.map +1 -0
  60. package/lib/linter/featureSchema.d.ts +7 -7
  61. package/lib/linter/featureSchema.js +138 -62
  62. package/lib/linter/featureSchema.js.map +1 -1
  63. package/lib/linter/featureSchema.spec.js +150 -0
  64. package/lib/linter/featureSchema.spec.js.map +1 -1
  65. package/lib/linter/mutationNotation.d.ts +47 -0
  66. package/lib/linter/mutationNotation.js +381 -0
  67. package/lib/linter/mutationNotation.js.map +1 -0
  68. package/lib/linter/mutationNotation.spec.d.ts +1 -0
  69. package/lib/linter/mutationNotation.spec.js +549 -0
  70. package/lib/linter/mutationNotation.spec.js.map +1 -0
  71. package/package.json +2 -2
  72. package/src/builder/buildDatafile.ts +29 -3
  73. package/src/builder/mutateVariables.spec.ts +1134 -0
  74. package/src/builder/mutateVariables.ts +105 -0
  75. package/src/builder/mutator.spec.ts +413 -0
  76. package/src/builder/mutator.ts +237 -0
  77. package/src/linter/featureSchema.spec.ts +182 -0
  78. package/src/linter/featureSchema.ts +254 -97
  79. package/src/linter/mutationNotation.spec.ts +642 -0
  80. package/src/linter/mutationNotation.ts +408 -0
@@ -0,0 +1,237 @@
1
+ import type { VariableSchema, VariableValue } from "@featurevisor/types";
2
+
3
+ export type PathPart =
4
+ | { key: string }
5
+ | { key: string; index: number }
6
+ | { key: string; selector: { prop: string; value: string } };
7
+
8
+ export type MutationOperation = "set" | "append" | "prepend" | "after" | "before" | "remove";
9
+
10
+ export interface ParsedNotation {
11
+ segments: PathPart[];
12
+ operation: MutationOperation;
13
+ }
14
+
15
+ export function parseNotation(notation: string): ParsedNotation {
16
+ let rest = notation.trim();
17
+ const operationMatch = rest.match(/:((?:append|prepend|after|before|remove))$/);
18
+ const operation: MutationOperation = operationMatch
19
+ ? (operationMatch[1] as MutationOperation)
20
+ : "set";
21
+ if (operationMatch) {
22
+ rest = rest.slice(0, -operationMatch[0].length);
23
+ }
24
+
25
+ const segments: PathPart[] = [];
26
+ let i = 0;
27
+ while (i < rest.length) {
28
+ let key = "";
29
+ while (i < rest.length && rest[i] !== "." && rest[i] !== "[") {
30
+ key += rest[i];
31
+ i++;
32
+ }
33
+ key = key.trim();
34
+ if (key) {
35
+ if (rest[i] === "[") {
36
+ i++;
37
+ const bracketStart = i;
38
+ while (i < rest.length && rest[i] !== "]") i++;
39
+ const bracketContent = rest.slice(bracketStart, i);
40
+ i++;
41
+ const eq = bracketContent.indexOf("=");
42
+ if (eq >= 0) {
43
+ const prop = bracketContent.slice(0, eq).trim();
44
+ let val = bracketContent.slice(eq + 1).trim();
45
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
46
+ else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
47
+ segments.push({ key, selector: { prop, value: val } });
48
+ } else {
49
+ const index = parseInt(bracketContent.trim(), 10);
50
+ segments.push({ key, index });
51
+ }
52
+ } else {
53
+ segments.push({ key });
54
+ }
55
+ } else if (rest[i] === "[") {
56
+ i++;
57
+ const bracketStart = i;
58
+ while (i < rest.length && rest[i] !== "]") i++;
59
+ const bracketContent = rest.slice(bracketStart, i);
60
+ i++;
61
+ const eq = bracketContent.indexOf("=");
62
+ if (eq >= 0) {
63
+ const prop = bracketContent.slice(0, eq).trim();
64
+ let val = bracketContent.slice(eq + 1).trim();
65
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
66
+ else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
67
+ segments.push({ key: "", selector: { prop, value: val } });
68
+ } else {
69
+ const index = parseInt(bracketContent.trim(), 10);
70
+ segments.push({ key: "", index });
71
+ }
72
+ }
73
+ if (rest[i] === ".") i++;
74
+ }
75
+
76
+ return { segments, operation };
77
+ }
78
+
79
+ function getAtSegment(obj: VariableValue, seg: PathPart): VariableValue {
80
+ if (obj === null || obj === undefined) return undefined;
81
+ const o = obj as Record<string, unknown>;
82
+ if ("index" in seg) {
83
+ const arr =
84
+ seg.key === ""
85
+ ? Array.isArray(obj)
86
+ ? obj
87
+ : undefined
88
+ : Array.isArray(obj)
89
+ ? obj
90
+ : (o[seg.key] as unknown[]);
91
+ if (!Array.isArray(arr)) return undefined;
92
+ return arr[seg.index] as VariableValue;
93
+ }
94
+ if ("selector" in seg) {
95
+ const arr =
96
+ seg.key === ""
97
+ ? Array.isArray(obj)
98
+ ? obj
99
+ : undefined
100
+ : Array.isArray(obj)
101
+ ? obj
102
+ : (o[seg.key] as unknown[]);
103
+ if (!Array.isArray(arr)) return undefined;
104
+ const { prop, value } = seg.selector;
105
+ const found = arr.find((item) => {
106
+ if (item === null || typeof item !== "object") return false;
107
+ const v = (item as Record<string, unknown>)[prop];
108
+ return String(v) === value;
109
+ });
110
+ return found as VariableValue;
111
+ }
112
+ return o[seg.key] as VariableValue;
113
+ }
114
+
115
+ function setAtSegment(
116
+ obj: Record<string, unknown> | unknown[],
117
+ seg: PathPart,
118
+ _value: VariableValue,
119
+ op: MutationOperation,
120
+ setValue: VariableValue | undefined,
121
+ ): void {
122
+ if ("index" in seg) {
123
+ const i = seg.index;
124
+ const arr =
125
+ seg.key === ""
126
+ ? Array.isArray(obj)
127
+ ? obj
128
+ : undefined
129
+ : Array.isArray(obj)
130
+ ? obj
131
+ : ((obj as Record<string, unknown>)[seg.key] as unknown[]);
132
+ if (!Array.isArray(arr)) return;
133
+ if (op === "remove") {
134
+ arr.splice(i, 1);
135
+ return;
136
+ }
137
+ if (op === "set") {
138
+ arr[i] = setValue;
139
+ return;
140
+ }
141
+ return;
142
+ }
143
+ if ("selector" in seg) {
144
+ const arr =
145
+ seg.key === ""
146
+ ? Array.isArray(obj)
147
+ ? obj
148
+ : undefined
149
+ : Array.isArray(obj)
150
+ ? obj
151
+ : ((obj as Record<string, unknown>)[seg.key] as unknown[]);
152
+ if (!Array.isArray(arr)) return;
153
+ const { prop, value: selVal } = seg.selector;
154
+ const numVal = /^\d+$/.test(selVal) ? parseInt(selVal, 10) : null;
155
+ const idx = arr.findIndex((item) => {
156
+ if (item === null || typeof item !== "object") return false;
157
+ const v = (item as Record<string, unknown>)[prop];
158
+ return String(v) === selVal || (numVal !== null && v === numVal);
159
+ });
160
+ if (idx < 0) return;
161
+ if (op === "remove") {
162
+ arr.splice(idx, 1);
163
+ return;
164
+ }
165
+ if (op === "after" && setValue !== undefined) {
166
+ arr.splice(idx + 1, 0, setValue);
167
+ return;
168
+ }
169
+ if (op === "before" && setValue !== undefined) {
170
+ arr.splice(idx, 0, setValue);
171
+ return;
172
+ }
173
+ return;
174
+ }
175
+ const key = seg.key;
176
+ const o = obj as Record<string, unknown>;
177
+ if (op === "remove") {
178
+ delete o[key];
179
+ return;
180
+ }
181
+ if (op === "append" && setValue !== undefined) {
182
+ const arr = (o[key] ?? []) as unknown[];
183
+ arr.push(setValue);
184
+ o[key] = arr;
185
+ return;
186
+ }
187
+ if (op === "prepend" && setValue !== undefined) {
188
+ const arr = (o[key] ?? []) as unknown[];
189
+ arr.unshift(setValue);
190
+ o[key] = arr;
191
+ return;
192
+ }
193
+ if (op === "set") {
194
+ o[key] = setValue as unknown;
195
+ }
196
+ }
197
+
198
+ export function mutate(
199
+ _schema: VariableSchema,
200
+ value: VariableValue,
201
+ notation: string,
202
+ setValue: VariableValue | undefined,
203
+ ): VariableValue {
204
+ if (value === null || value === undefined) return value;
205
+ const result = JSON.parse(JSON.stringify(value)) as VariableValue;
206
+
207
+ const { segments, operation } = parseNotation(notation);
208
+ if (segments.length === 0) {
209
+ if (Array.isArray(result) && setValue !== undefined) {
210
+ const arr = result as VariableValue[];
211
+ if (operation === "append") {
212
+ arr.push(setValue);
213
+ return result;
214
+ }
215
+ if (operation === "prepend") {
216
+ arr.unshift(setValue);
217
+ return result;
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+
223
+ const last = segments[segments.length - 1];
224
+ const parentSegments = segments.slice(0, -1);
225
+
226
+ let container: Record<string, unknown> | unknown[] = result as
227
+ | Record<string, unknown>
228
+ | unknown[];
229
+ for (const seg of parentSegments) {
230
+ const next = getAtSegment(container as VariableValue, seg);
231
+ if (next === undefined) return result;
232
+ container = next as Record<string, unknown> | unknown[];
233
+ }
234
+
235
+ setAtSegment(container, last, undefined, operation, setValue);
236
+ return result;
237
+ }
@@ -166,6 +166,38 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
166
166
  );
167
167
  });
168
168
 
169
+ it("rejects variable with schema reference when schema could not be loaded (missing from schemasByKey)", () => {
170
+ const projectConfig = minimalProjectConfig();
171
+ const conditionsZodSchema = getConditionsZodSchema(projectConfig, TEST_ATTRIBUTES);
172
+ const schemaWithEmptySchemasByKey = getFeatureZodSchema(
173
+ projectConfig,
174
+ conditionsZodSchema,
175
+ TEST_ATTRIBUTES,
176
+ TEST_SEGMENTS,
177
+ TEST_FEATURES,
178
+ ["link"],
179
+ {},
180
+ );
181
+ const result = schemaWithEmptySchemasByKey.safeParse(
182
+ baseFeature({
183
+ variablesSchema: {
184
+ myLink: {
185
+ schema: "link",
186
+ defaultValue: { title: "Home", url: "/" },
187
+ },
188
+ },
189
+ }),
190
+ );
191
+ expect(result.success).toBe(false);
192
+ if (result.success) return;
193
+ const messages = (result as z.SafeParseError<unknown>).error.issues
194
+ .map((i) => (typeof i.message === "string" ? i.message : ""))
195
+ .join(" ");
196
+ expect(messages).toContain("could not be loaded");
197
+ expect(messages).toContain("link");
198
+ expect(messages).toContain("myLink");
199
+ });
200
+
169
201
  it("rejects variable with schema reference when defaultValue does not match schema", () => {
170
202
  expectParseFailure(
171
203
  baseFeature({
@@ -1215,4 +1247,154 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
1215
1247
  );
1216
1248
  });
1217
1249
  });
1250
+
1251
+ describe("mutation notation in variables", () => {
1252
+ it("accepts valid dot-notation mutation keys in rules.variables", () => {
1253
+ expectParseSuccess(
1254
+ baseFeature({
1255
+ variablesSchema: {
1256
+ config: {
1257
+ type: "object",
1258
+ properties: {
1259
+ theme: { type: "string" },
1260
+ width: { type: "integer" },
1261
+ },
1262
+ defaultValue: { theme: "light", width: 100 },
1263
+ },
1264
+ },
1265
+ rules: {
1266
+ staging: [
1267
+ {
1268
+ key: "r1",
1269
+ segments: "*",
1270
+ percentage: 100,
1271
+ variables: { "config.theme": "dark", "config.width": 1200 },
1272
+ },
1273
+ ],
1274
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
1275
+ },
1276
+ }),
1277
+ );
1278
+ });
1279
+
1280
+ it("accepts valid array-index mutation keys in rules.variables", () => {
1281
+ expectParseSuccess(
1282
+ baseFeature({
1283
+ variablesSchema: {
1284
+ tags: {
1285
+ type: "array",
1286
+ items: { type: "string" },
1287
+ defaultValue: ["a", "b"],
1288
+ },
1289
+ },
1290
+ rules: {
1291
+ staging: [
1292
+ { key: "r1", segments: "*", percentage: 100, variables: { "tags[0]": "first" } },
1293
+ ],
1294
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
1295
+ },
1296
+ }),
1297
+ );
1298
+ });
1299
+
1300
+ it("rejects mutation key when root variable is not in variablesSchema", () => {
1301
+ expectErrorSurfaces(
1302
+ baseFeature({
1303
+ variablesSchema: {
1304
+ title: { type: "string", defaultValue: "Default" },
1305
+ },
1306
+ rules: {
1307
+ staging: [
1308
+ { key: "r1", segments: "*", percentage: 100, variables: { "unknownVar.foo": "x" } },
1309
+ ],
1310
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
1311
+ },
1312
+ }),
1313
+ {
1314
+ pathContains: ["rules", "variables", "unknownVar.foo"],
1315
+ messageContains: "not defined in",
1316
+ },
1317
+ );
1318
+ });
1319
+
1320
+ it("rejects invalid path (property not in schema)", () => {
1321
+ expectErrorSurfaces(
1322
+ baseFeature({
1323
+ variablesSchema: {
1324
+ config: {
1325
+ type: "object",
1326
+ properties: { theme: { type: "string" } },
1327
+ defaultValue: { theme: "light" },
1328
+ },
1329
+ },
1330
+ rules: {
1331
+ staging: [
1332
+ {
1333
+ key: "r1",
1334
+ segments: "*",
1335
+ percentage: 100,
1336
+ variables: { "config.unknownProp": "x" },
1337
+ },
1338
+ ],
1339
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
1340
+ },
1341
+ }),
1342
+ { pathContains: ["rules", "variables"], messageContains: "path does not exist" },
1343
+ );
1344
+ });
1345
+
1346
+ it("rejects :append on non-array (object property)", () => {
1347
+ expectErrorSurfaces(
1348
+ baseFeature({
1349
+ variablesSchema: {
1350
+ config: {
1351
+ type: "object",
1352
+ properties: { theme: { type: "string" } },
1353
+ defaultValue: { theme: "light" },
1354
+ },
1355
+ },
1356
+ rules: {
1357
+ staging: [
1358
+ { key: "r1", segments: "*", percentage: 100, variables: { "config:append": "x" } },
1359
+ ],
1360
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
1361
+ },
1362
+ }),
1363
+ { pathContains: ["rules", "variables"], messageContains: "only allowed on array" },
1364
+ );
1365
+ });
1366
+
1367
+ it("rejects :remove on required object property", () => {
1368
+ expectErrorSurfaces(
1369
+ baseFeature({
1370
+ variablesSchema: {
1371
+ config: {
1372
+ type: "object",
1373
+ properties: {
1374
+ theme: { type: "string" },
1375
+ compact: { type: "boolean" },
1376
+ },
1377
+ required: ["compact"],
1378
+ defaultValue: { theme: "light", compact: true },
1379
+ },
1380
+ },
1381
+ rules: {
1382
+ staging: [
1383
+ {
1384
+ key: "r1",
1385
+ segments: "*",
1386
+ percentage: 100,
1387
+ variables: { "config.compact:remove": null },
1388
+ },
1389
+ ],
1390
+ production: [{ key: "r1", segments: "*", percentage: 100 }],
1391
+ },
1392
+ }),
1393
+ {
1394
+ pathContains: ["rules", "variables"],
1395
+ messageContains: "Cannot remove required property",
1396
+ },
1397
+ );
1398
+ });
1399
+ });
1218
1400
  });