@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
@@ -0,0 +1,267 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import type { DatafileContent, ExistingState, ParsedFeature } from "@featurevisor/types";
6
+
7
+ import type { ProjectConfig } from "../config";
8
+ import { parsers } from "../parsers";
9
+ import { buildDatafile } from "./buildDatafile";
10
+
11
+ function createProjectConfig(root: string, stringify = true): ProjectConfig {
12
+ return {
13
+ featuresDirectoryPath: path.join(root, "features"),
14
+ segmentsDirectoryPath: path.join(root, "segments-missing"),
15
+ attributesDirectoryPath: path.join(root, "attributes-missing"),
16
+ groupsDirectoryPath: path.join(root, "groups-missing"),
17
+ schemasDirectoryPath: path.join(root, "schemas-missing"),
18
+ testsDirectoryPath: path.join(root, "tests-missing"),
19
+ stateDirectoryPath: path.join(root, ".featurevisor"),
20
+ datafilesDirectoryPath: path.join(root, "datafiles"),
21
+ datafileNamePattern: "featurevisor-%s.json",
22
+ revisionFileName: "REVISION",
23
+ siteExportDirectoryPath: path.join(root, "out"),
24
+ environmentsDirectoryPath: path.join(root, "environments"),
25
+ environments: ["staging", "production"],
26
+ splitByEnvironment: false,
27
+ tags: ["all"],
28
+ scopes: [],
29
+ adapter: {},
30
+ plugins: [],
31
+ defaultBucketBy: "userId",
32
+ parser: parsers.yml,
33
+ prettyState: true,
34
+ prettyDatafile: false,
35
+ stringify,
36
+ enforceCatchAllRule: false,
37
+ };
38
+ }
39
+
40
+ function createMockDatasource(feature: ParsedFeature) {
41
+ return {
42
+ listSchemas: async () => [],
43
+ readSchema: async () => {
44
+ throw new Error("readSchema should not be called");
45
+ },
46
+ listFeatures: async () => ["withRuleOverrides"],
47
+ readFeature: async () => feature,
48
+ listSegments: async () => [],
49
+ readSegment: async () => {
50
+ throw new Error("readSegment should not be called");
51
+ },
52
+ listAttributes: async () => [],
53
+ readAttribute: async () => {
54
+ throw new Error("readAttribute should not be called");
55
+ },
56
+ } as any;
57
+ }
58
+
59
+ function createFeatureFixture(): ParsedFeature {
60
+ return {
61
+ key: "withRuleOverrides",
62
+ description: "Rule variableOverrides fixture",
63
+ tags: ["all"],
64
+ bucketBy: "userId",
65
+ variablesSchema: {
66
+ config: {
67
+ type: "object",
68
+ defaultValue: {
69
+ source: "default",
70
+ nested: { value: 1 },
71
+ list: ["d"],
72
+ rows: [
73
+ { id: 1, label: "one" },
74
+ { id: 2, label: "two" },
75
+ ],
76
+ flag: false,
77
+ },
78
+ },
79
+ fallbackConfig: {
80
+ type: "object",
81
+ defaultValue: {
82
+ origin: "default",
83
+ count: 1,
84
+ },
85
+ },
86
+ },
87
+ variations: [{ value: "control", weight: 100 }],
88
+ rules: {
89
+ staging: [
90
+ {
91
+ key: "rule1",
92
+ segments: "*",
93
+ percentage: 100,
94
+ variables: {
95
+ config: {
96
+ source: "rule",
97
+ nested: { value: 10 },
98
+ list: ["base"],
99
+ rows: [
100
+ { id: 1, label: "one" },
101
+ { id: 2, label: "two" },
102
+ ],
103
+ flag: true,
104
+ },
105
+ },
106
+ variableOverrides: {
107
+ config: [
108
+ {
109
+ segments: { and: ["mobile", "touch"] },
110
+ value: {
111
+ "nested.value": 20,
112
+ "list:append": "m",
113
+ "rows[id=1]:after": { id: 15, label: "one-half" },
114
+ "rows[id=2]:remove": null,
115
+ },
116
+ },
117
+ {
118
+ conditions: [{ attribute: "country", operator: "equals", value: "de" }],
119
+ value: {
120
+ "nested.value": 30,
121
+ "list:prepend": "de",
122
+ "rows[id=1]:before": { id: 0, label: "zero" },
123
+ },
124
+ },
125
+ {
126
+ conditions: [{ attribute: "device", operator: "equals", value: "desktop" }],
127
+ value: {
128
+ source: "full",
129
+ nested: { value: 999 },
130
+ list: ["x"],
131
+ rows: [{ id: 7, label: "seven" }],
132
+ flag: false,
133
+ },
134
+ },
135
+ ],
136
+ fallbackConfig: [
137
+ {
138
+ conditions: [{ attribute: "country", operator: "equals", value: "nl" }],
139
+ value: {
140
+ count: 5,
141
+ },
142
+ },
143
+ ],
144
+ },
145
+ },
146
+ ],
147
+ production: [
148
+ {
149
+ key: "everyone",
150
+ segments: "*",
151
+ percentage: 100,
152
+ },
153
+ ],
154
+ },
155
+ } as ParsedFeature;
156
+ }
157
+
158
+ describe("core: buildDatafile", function () {
159
+ let root: string;
160
+ let existingState: ExistingState;
161
+
162
+ beforeEach(function () {
163
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-buildDatafile-"));
164
+ fs.mkdirSync(path.join(root, "features"), { recursive: true });
165
+ existingState = { features: {} };
166
+ });
167
+
168
+ afterEach(function () {
169
+ fs.rmSync(root, { recursive: true, force: true });
170
+ });
171
+
172
+ test("resolves rule variableOverrides mutations using rule variables as base and default fallback when missing", async function () {
173
+ const config = createProjectConfig(root, true);
174
+ const datasource = createMockDatasource(createFeatureFixture());
175
+
176
+ const result = (await buildDatafile(
177
+ config,
178
+ datasource,
179
+ {
180
+ schemaVersion: "2",
181
+ revision: "1",
182
+ environment: "staging",
183
+ },
184
+ existingState,
185
+ )) as DatafileContent;
186
+
187
+ const trafficRule = result.features.withRuleOverrides.traffic.find((t) => t.key === "rule1");
188
+
189
+ expect(trafficRule).toBeDefined();
190
+ expect(trafficRule?.variableOverrides).toBeDefined();
191
+
192
+ // Base comes from rule variables (source remains "rule"), not from schema default.
193
+ expect(trafficRule?.variableOverrides?.config[0].value).toEqual({
194
+ source: "rule",
195
+ nested: { value: 20 },
196
+ list: ["base", "m"],
197
+ rows: [
198
+ { id: 1, label: "one" },
199
+ { id: 15, label: "one-half" },
200
+ ],
201
+ flag: true,
202
+ });
203
+
204
+ // Independent override still uses the same rule-level base and preserves order.
205
+ expect(trafficRule?.variableOverrides?.config[1].value).toEqual({
206
+ source: "rule",
207
+ nested: { value: 30 },
208
+ list: ["de", "base"],
209
+ rows: [
210
+ { id: 0, label: "zero" },
211
+ { id: 1, label: "one" },
212
+ { id: 2, label: "two" },
213
+ ],
214
+ flag: true,
215
+ });
216
+
217
+ // Full object override passes through unchanged.
218
+ expect(trafficRule?.variableOverrides?.config[2].value).toEqual({
219
+ source: "full",
220
+ nested: { value: 999 },
221
+ list: ["x"],
222
+ rows: [{ id: 7, label: "seven" }],
223
+ flag: false,
224
+ });
225
+
226
+ // No rule variable for fallbackConfig -> mutation base is variable schema defaultValue.
227
+ expect(trafficRule?.variableOverrides?.fallbackConfig[0].value).toEqual({
228
+ origin: "default",
229
+ count: 5,
230
+ });
231
+
232
+ // Supports stringification for segments + conditions.
233
+ expect(typeof trafficRule?.variableOverrides?.config[0].segments).toBe("string");
234
+ expect(trafficRule?.variableOverrides?.config[0].segments).toEqual(
235
+ JSON.stringify({ and: ["mobile", "touch"] }),
236
+ );
237
+ expect(typeof trafficRule?.variableOverrides?.config[1].conditions).toBe("string");
238
+ expect(trafficRule?.variableOverrides?.config[1].conditions).toEqual(
239
+ JSON.stringify([{ attribute: "country", operator: "equals", value: "de" }]),
240
+ );
241
+ });
242
+
243
+ test("keeps segments and conditions non-stringified when projectConfig.stringify is false", async function () {
244
+ const config = createProjectConfig(root, false);
245
+ const datasource = createMockDatasource(createFeatureFixture());
246
+
247
+ const result = (await buildDatafile(
248
+ config,
249
+ datasource,
250
+ {
251
+ schemaVersion: "2",
252
+ revision: "1",
253
+ environment: "staging",
254
+ },
255
+ existingState,
256
+ )) as DatafileContent;
257
+
258
+ const trafficRule = result.features.withRuleOverrides.traffic.find((t) => t.key === "rule1");
259
+
260
+ expect(trafficRule?.variableOverrides?.config[0].segments).toEqual({
261
+ and: ["mobile", "touch"],
262
+ });
263
+ expect(trafficRule?.variableOverrides?.config[1].conditions).toEqual([
264
+ { attribute: "country", operator: "equals", value: "de" },
265
+ ]);
266
+ });
267
+ });
@@ -201,6 +201,30 @@ export async function buildDatafile(
201
201
  for (const parsedRule of rules) {
202
202
  const extractedSegmentKeys = extractSegmentKeysFromGroupSegments(parsedRule.segments);
203
203
  extractedSegmentKeys.forEach((segmentKey) => segmentKeysUsedByTag.add(segmentKey));
204
+
205
+ if (parsedRule.variableOverrides) {
206
+ for (const variableOverrides of Object.values(parsedRule.variableOverrides)) {
207
+ for (const override of variableOverrides) {
208
+ if (typeof override.conditions !== "undefined") {
209
+ const extractedAttributeKeys = extractAttributeKeysFromConditions(
210
+ override.conditions,
211
+ );
212
+ extractedAttributeKeys.forEach((attributeKey) =>
213
+ attributeKeysUsedByTag.add(attributeKey),
214
+ );
215
+ }
216
+
217
+ if (typeof override.segments !== "undefined") {
218
+ const extractedOverrideSegmentKeys = extractSegmentKeysFromGroupSegments(
219
+ override.segments as GroupSegment | GroupSegment[],
220
+ );
221
+ extractedOverrideSegmentKeys.forEach((segmentKey) =>
222
+ segmentKeysUsedByTag.add(segmentKey),
223
+ );
224
+ }
225
+ }
226
+ }
227
+ }
204
228
  }
205
229
 
206
230
  const feature: Feature = {
@@ -226,6 +250,10 @@ export async function buildDatafile(
226
250
  const variableKeys = Object.keys(variableOverrides);
227
251
 
228
252
  for (const variableKey of variableKeys) {
253
+ const baseValue = mappedVariation.variables
254
+ ? mappedVariation.variables[variableKey]
255
+ : undefined;
256
+
229
257
  mappedVariation.variableOverrides[variableKey] = variableOverrides[
230
258
  variableKey
231
259
  ].map((override: VariableOverride) => {
@@ -246,6 +274,7 @@ export async function buildDatafile(
246
274
  parsedFeature.variablesSchema,
247
275
  variableKey,
248
276
  override.value,
277
+ baseValue,
249
278
  ),
250
279
  };
251
280
  }
@@ -267,6 +296,7 @@ export async function buildDatafile(
267
296
  parsedFeature.variablesSchema,
268
297
  variableKey,
269
298
  override.value,
299
+ baseValue,
270
300
  ),
271
301
  };
272
302
  }
@@ -285,12 +315,63 @@ export async function buildDatafile(
285
315
  existingState.features[featureKey],
286
316
  featureRanges.get(featureKey) || [],
287
317
  ).map((t: Traffic) => {
318
+ const resolvedVariables = resolveMutationsForMultipleVariables(
319
+ parsedFeature.variablesSchema,
320
+ t.variables,
321
+ );
322
+
323
+ let resolvedVariableOverrides = t.variableOverrides;
324
+
325
+ if (t.variableOverrides) {
326
+ resolvedVariableOverrides = {};
327
+ const variableKeys = Object.keys(t.variableOverrides);
328
+
329
+ for (const variableKey of variableKeys) {
330
+ const overrides = t.variableOverrides[variableKey];
331
+ const baseValue = resolvedVariables ? resolvedVariables[variableKey] : undefined;
332
+
333
+ resolvedVariableOverrides[variableKey] = overrides.map(
334
+ (override: VariableOverride) => {
335
+ if (typeof override.conditions !== "undefined") {
336
+ return {
337
+ conditions:
338
+ projectConfig.stringify && typeof override.conditions !== "string"
339
+ ? JSON.stringify(override.conditions)
340
+ : override.conditions,
341
+ value: resolveMutationsForSingleVariable(
342
+ parsedFeature.variablesSchema,
343
+ variableKey,
344
+ override.value,
345
+ baseValue,
346
+ ),
347
+ };
348
+ }
349
+
350
+ if (typeof override.segments !== "undefined") {
351
+ return {
352
+ segments:
353
+ projectConfig.stringify && typeof override.segments !== "string"
354
+ ? JSON.stringify(override.segments)
355
+ : override.segments,
356
+ value: resolveMutationsForSingleVariable(
357
+ parsedFeature.variablesSchema,
358
+ variableKey,
359
+ override.value,
360
+ baseValue,
361
+ ),
362
+ };
363
+ }
364
+
365
+ return override;
366
+ },
367
+ );
368
+ }
369
+ }
370
+
288
371
  return {
289
372
  ...t,
290
- variables: resolveMutationsForMultipleVariables(
291
- parsedFeature.variablesSchema,
292
- t.variables,
293
- ),
373
+ variables: resolvedVariables,
374
+ variableOverrides: resolvedVariableOverrides,
294
375
  segments:
295
376
  typeof t.segments !== "string" && projectConfig.stringify
296
377
  ? JSON.stringify(t.segments)