@featurevisor/core 2.20.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/coverage/clover.xml +1286 -515
  3. package/coverage/coverage-final.json +17 -16
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildDatafile.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  8. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  9. package/coverage/lcov-report/builder/convertToV1.ts.html +1 -1
  10. package/coverage/lcov-report/builder/getFeatureRanges.ts.html +1 -1
  11. package/coverage/lcov-report/builder/hashes.ts.html +1 -1
  12. package/coverage/lcov-report/builder/index.html +1 -1
  13. package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
  14. package/coverage/lcov-report/builder/mutator.ts.html +1 -1
  15. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  16. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  17. package/coverage/lcov-report/config/index.html +1 -1
  18. package/coverage/lcov-report/config/index.ts.html +1 -1
  19. package/coverage/lcov-report/config/projectConfig.ts.html +42 -42
  20. package/coverage/lcov-report/datasource/adapter.ts.html +2 -2
  21. package/coverage/lcov-report/datasource/datasource.ts.html +43 -43
  22. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +48 -48
  23. package/coverage/lcov-report/datasource/index.html +19 -19
  24. package/coverage/lcov-report/datasource/index.ts.html +4 -4
  25. package/coverage/lcov-report/generate-code/index.html +116 -0
  26. package/coverage/lcov-report/generate-code/typescript.ts.html +3004 -0
  27. package/coverage/lcov-report/index.html +44 -29
  28. package/coverage/lcov-report/linter/attributeSchema.ts.html +1815 -30
  29. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  30. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  31. package/coverage/lcov-report/linter/conditionSchema.ts.html +1606 -157
  32. package/coverage/lcov-report/linter/featureSchema.ts.html +56 -56
  33. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  34. package/coverage/lcov-report/linter/index.html +39 -39
  35. package/coverage/lcov-report/linter/lintProject.ts.html +189 -78
  36. package/coverage/lcov-report/linter/mutationNotation.ts.html +1 -1
  37. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  38. package/coverage/lcov-report/linter/schema.ts.html +59 -59
  39. package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
  40. package/coverage/lcov-report/linter/testSchema.ts.html +6 -6
  41. package/coverage/lcov-report/list/index.html +1 -1
  42. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  43. package/coverage/lcov-report/parsers/index.html +1 -1
  44. package/coverage/lcov-report/parsers/index.ts.html +4 -4
  45. package/coverage/lcov-report/parsers/json.ts.html +2 -2
  46. package/coverage/lcov-report/parsers/yml.ts.html +6 -6
  47. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  48. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  49. package/coverage/lcov-report/tester/index.html +1 -1
  50. package/coverage/lcov-report/utils/extractKeys.ts.html +1 -1
  51. package/coverage/lcov-report/utils/git.ts.html +3 -3
  52. package/coverage/lcov-report/utils/index.html +1 -1
  53. package/coverage/lcov-report/utils/index.ts.html +1 -1
  54. package/coverage/lcov-report/utils/pretty.ts.html +1 -1
  55. package/coverage/lcov.info +2624 -926
  56. package/lib/generate-code/typescript.js +57 -29
  57. package/lib/generate-code/typescript.js.map +1 -1
  58. package/lib/generate-code/typescript.spec.d.ts +1 -0
  59. package/lib/generate-code/typescript.spec.js +78 -0
  60. package/lib/generate-code/typescript.spec.js.map +1 -0
  61. package/lib/linter/attributeSchema.d.ts +352 -20
  62. package/lib/linter/attributeSchema.js +426 -16
  63. package/lib/linter/attributeSchema.js.map +1 -1
  64. package/lib/linter/attributeSchema.spec.d.ts +1 -0
  65. package/lib/linter/attributeSchema.spec.js +266 -0
  66. package/lib/linter/attributeSchema.spec.js.map +1 -0
  67. package/lib/linter/conditionSchema.d.ts +2 -1
  68. package/lib/linter/conditionSchema.js +370 -100
  69. package/lib/linter/conditionSchema.js.map +1 -1
  70. package/lib/linter/conditionSchema.spec.js +200 -153
  71. package/lib/linter/conditionSchema.spec.js.map +1 -1
  72. package/lib/linter/featureSchema.spec.js +114 -3
  73. package/lib/linter/featureSchema.spec.js.map +1 -1
  74. package/lib/linter/lintProject.js +45 -14
  75. package/lib/linter/lintProject.js.map +1 -1
  76. package/lib/linter/segmentSchema.spec.js +71 -1
  77. package/lib/linter/segmentSchema.spec.js.map +1 -1
  78. package/package.json +5 -5
  79. package/src/generate-code/typescript.spec.ts +108 -0
  80. package/src/generate-code/typescript.ts +80 -34
  81. package/src/linter/attributeSchema.spec.ts +342 -0
  82. package/src/linter/attributeSchema.ts +613 -18
  83. package/src/linter/conditionSchema.spec.ts +231 -189
  84. package/src/linter/conditionSchema.ts +582 -99
  85. package/src/linter/featureSchema.spec.ts +129 -4
  86. package/src/linter/lintProject.ts +55 -18
  87. package/src/linter/segmentSchema.spec.ts +82 -1
@@ -4,7 +4,7 @@
4
4
  * rules, force). Ensures every place a variable value can be set is validated against
5
5
  * the variable's schema.
6
6
  */
7
- import type { Schema } from "@featurevisor/types";
7
+ import type { Attribute, Schema } from "@featurevisor/types";
8
8
  import { z } from "zod";
9
9
 
10
10
  import type { ProjectConfig } from "../config";
@@ -42,7 +42,44 @@ function minimalProjectConfig(overrides: Partial<ProjectConfig> = {}): ProjectCo
42
42
  }
43
43
 
44
44
  /** Attributes and segments that appear in conditions; keep small for tests. */
45
- const TEST_ATTRIBUTES: [string, ...string[]] = ["userId", "country", "device"];
45
+ const TEST_ATTRIBUTES: Record<string, Attribute> = {
46
+ userId: {
47
+ description: "User ID",
48
+ type: "string",
49
+ },
50
+ country: {
51
+ description: "Country",
52
+ type: "string",
53
+ },
54
+ device: {
55
+ description: "Device",
56
+ type: "string",
57
+ },
58
+ permissions: {
59
+ description: "Permissions",
60
+ type: "array",
61
+ items: {
62
+ type: "string",
63
+ enum: ["read", "write", "admin"],
64
+ },
65
+ },
66
+ version: {
67
+ description: "Version",
68
+ oneOf: [{ type: "string" }, { type: "double" }],
69
+ },
70
+ traits: {
71
+ description: "Traits",
72
+ type: "object",
73
+ },
74
+ };
75
+ const TEST_ATTRIBUTE_KEYS: [string, ...string[]] = [
76
+ "userId",
77
+ "country",
78
+ "device",
79
+ "permissions",
80
+ "version",
81
+ "traits",
82
+ ];
46
83
  const TEST_SEGMENTS: [string, ...string[]] = ["*", "countries/germany", "countries/france"];
47
84
  const TEST_FEATURES: [string, ...string[]] = ["testFeature"];
48
85
  const TEST_SCHEMA_KEYS = ["link", "slugSchema"];
@@ -76,7 +113,7 @@ function getFeatureSchema() {
76
113
  return getFeatureZodSchema(
77
114
  projectConfig,
78
115
  conditionsZodSchema,
79
- TEST_ATTRIBUTES,
116
+ TEST_ATTRIBUTE_KEYS,
80
117
  TEST_SEGMENTS,
81
118
  TEST_FEATURES,
82
119
  TEST_SCHEMA_KEYS,
@@ -140,6 +177,94 @@ function expectErrorSurfaces(
140
177
  }
141
178
 
142
179
  describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable values)", () => {
180
+ describe("attribute-aware rule and force conditions", () => {
181
+ it("accepts force conditions using semver operators on oneOf attributes", () => {
182
+ expectParseSuccess(
183
+ baseFeature({
184
+ variations: [
185
+ { value: "control", weight: 50 },
186
+ { value: "treatment", weight: 50 },
187
+ ],
188
+ force: {
189
+ staging: [
190
+ {
191
+ conditions: [
192
+ { attribute: "version", operator: "semverGreaterThan", value: "5.0.0" },
193
+ ],
194
+ variation: "treatment",
195
+ },
196
+ ],
197
+ production: [],
198
+ },
199
+ }),
200
+ );
201
+ });
202
+
203
+ it("accepts force conditions using flat object nested paths", () => {
204
+ expectParseSuccess(
205
+ baseFeature({
206
+ variations: [
207
+ { value: "control", weight: 50 },
208
+ { value: "treatment", weight: 50 },
209
+ ],
210
+ force: {
211
+ staging: [
212
+ {
213
+ conditions: [
214
+ { attribute: "traits.favoriteTheme", operator: "equals", value: "dark" },
215
+ ],
216
+ variation: "treatment",
217
+ },
218
+ ],
219
+ production: [],
220
+ },
221
+ }),
222
+ );
223
+ });
224
+
225
+ it("rejects force conditions when oneOf attribute value matches no branch", () => {
226
+ expectParseFailure(
227
+ baseFeature({
228
+ variations: [
229
+ { value: "control", weight: 50 },
230
+ { value: "treatment", weight: 50 },
231
+ ],
232
+ force: {
233
+ staging: [
234
+ {
235
+ conditions: [{ attribute: "version", operator: "equals", value: true }],
236
+ variation: "treatment",
237
+ },
238
+ ],
239
+ production: [],
240
+ },
241
+ }),
242
+ 'Value does not match the schema of attribute "version"',
243
+ );
244
+ });
245
+
246
+ it("rejects force conditions when array item value violates enum constraints", () => {
247
+ expectParseFailure(
248
+ baseFeature({
249
+ variations: [
250
+ { value: "control", weight: 50 },
251
+ { value: "treatment", weight: 50 },
252
+ ],
253
+ force: {
254
+ staging: [
255
+ {
256
+ conditions: [{ attribute: "permissions", operator: "includes", value: "delete" }],
257
+ variation: "treatment",
258
+ },
259
+ ],
260
+ production: [],
261
+ },
262
+ }),
263
+ 'Value does not match the item schema of attribute "permissions"',
264
+ );
265
+ });
266
+ });
267
+
143
268
  describe("variablesSchema: schema reference", () => {
144
269
  it("accepts variable with schema reference and valid defaultValue", () => {
145
270
  expectParseSuccess(
@@ -174,7 +299,7 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
174
299
  const schemaWithEmptySchemasByKey = getFeatureZodSchema(
175
300
  projectConfig,
176
301
  conditionsZodSchema,
177
- TEST_ATTRIBUTES,
302
+ TEST_ATTRIBUTE_KEYS,
178
303
  TEST_SEGMENTS,
179
304
  TEST_FEATURES,
180
305
  ["link"],
@@ -1,7 +1,7 @@
1
1
  // for use in node only
2
2
  import * as path from "path";
3
3
 
4
- import type { Schema } from "@featurevisor/types";
4
+ import type { Attribute, Schema } from "@featurevisor/types";
5
5
  import type { ZodError } from "zod";
6
6
 
7
7
  import { getAttributeZodSchema } from "./attributeSchema";
@@ -59,6 +59,28 @@ async function getAuthorsOfEntity(datasource, entityType, entityKey): Promise<st
59
59
  return authors;
60
60
  }
61
61
 
62
+ function collectExplicitAttributePaths(
63
+ attributeKey: string,
64
+ attribute: Attribute,
65
+ result: Set<string>,
66
+ ): void {
67
+ result.add(attributeKey);
68
+
69
+ function visit(prefix: string, schema: Attribute | Attribute["properties"][string]) {
70
+ if (!schema || schema.type !== "object" || !schema.properties) {
71
+ return;
72
+ }
73
+
74
+ for (const propertyKey of Object.keys(schema.properties)) {
75
+ const propertyPath = `${prefix}.${propertyKey}`;
76
+ result.add(propertyPath);
77
+ visit(propertyPath, schema.properties[propertyKey]);
78
+ }
79
+ }
80
+
81
+ visit(attributeKey, attribute);
82
+ }
83
+
62
84
  export async function lintProject(
63
85
  deps: Dependencies,
64
86
  options: LintProjectOptions = {},
@@ -326,9 +348,29 @@ export async function lintProject(
326
348
  log("");
327
349
  }
328
350
 
351
+ // List schemas and load parsed schemas for attribute and feature validation.
352
+ const schemas = await datasource.listSchemas();
353
+ const schemasByKey: Record<string, Schema> = {};
354
+ for (const key of schemas) {
355
+ try {
356
+ schemasByKey[key] = await datasource.readSchema(key);
357
+ } catch {
358
+ // Schema file may be invalid; skip for cross-entity resolution and let schema linting report it.
359
+ }
360
+ }
361
+
329
362
  // lint attributes
330
363
  const attributes = await datasource.listAttributes();
331
364
  const attributeZodSchema = getAttributeZodSchema();
365
+ const attributesByKey: Record<string, Attribute> = {};
366
+
367
+ for (const key of attributes) {
368
+ try {
369
+ attributesByKey[key] = await datasource.readAttribute(key);
370
+ } catch {
371
+ // Attribute read failures are reported during attribute linting below.
372
+ }
373
+ }
332
374
 
333
375
  if (!options.entityType || options.entityType === "attribute") {
334
376
  const filteredKeys = !keyPattern
@@ -354,7 +396,7 @@ export async function lintProject(
354
396
  }
355
397
 
356
398
  try {
357
- const parsed = await datasource.readAttribute(key);
399
+ const parsed = attributesByKey[key] ?? (await datasource.readAttribute(key));
358
400
  const result = attributeZodSchema.safeParse(parsed);
359
401
 
360
402
  if (!result.success && "error" in result) {
@@ -366,14 +408,20 @@ export async function lintProject(
366
408
  }
367
409
  }
368
410
 
369
- const flattenedAttributes = await datasource.listFlattenedAttributes();
411
+ const explicitAttributePaths = new Set<string>();
412
+ for (const key of attributes) {
413
+ const parsed = attributesByKey[key];
414
+ if (!parsed) {
415
+ continue;
416
+ }
417
+
418
+ collectExplicitAttributePaths(key, parsed, explicitAttributePaths);
419
+ }
420
+ const flattenedAttributes = Array.from(explicitAttributePaths);
370
421
 
371
422
  // lint segments
372
423
  const segments = await datasource.listSegments();
373
- const conditionsZodSchema = getConditionsZodSchema(
374
- projectConfig,
375
- flattenedAttributes as [string, ...string[]],
376
- );
424
+ const conditionsZodSchema = getConditionsZodSchema(projectConfig, attributesByKey, schemasByKey);
377
425
  const segmentZodSchema = getSegmentZodSchema(projectConfig, conditionsZodSchema);
378
426
 
379
427
  if (!options.entityType || options.entityType === "segment") {
@@ -410,17 +458,6 @@ export async function lintProject(
410
458
  }
411
459
  }
412
460
 
413
- // List schemas and load parsed schemas for feature variable validation (schema references)
414
- const schemas = await datasource.listSchemas();
415
- const schemasByKey: Record<string, Schema> = {};
416
- for (const key of schemas) {
417
- try {
418
- schemasByKey[key] = await datasource.readSchema(key);
419
- } catch {
420
- // Schema file may be invalid; skip for feature variable resolution
421
- }
422
- }
423
-
424
461
  // lint features
425
462
  const features = await datasource.listFeatures();
426
463
  const featureZodSchema = getFeatureZodSchema(
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { z } from "zod";
7
7
 
8
+ import type { Attribute } from "@featurevisor/types";
8
9
  import type { ProjectConfig } from "../config";
9
10
  import { getConditionsZodSchema } from "./conditionSchema";
10
11
  import { getSegmentZodSchema } from "./segmentSchema";
@@ -36,7 +37,36 @@ function minimalProjectConfig(): ProjectConfig {
36
37
  };
37
38
  }
38
39
 
39
- const TEST_ATTRIBUTES: [string, ...string[]] = ["userId", "country", "device"];
40
+ const TEST_ATTRIBUTES: Record<string, Attribute> = {
41
+ userId: {
42
+ description: "User ID",
43
+ type: "string",
44
+ },
45
+ country: {
46
+ description: "Country",
47
+ type: "string",
48
+ },
49
+ device: {
50
+ description: "Device",
51
+ type: "string",
52
+ },
53
+ permissions: {
54
+ description: "Permissions",
55
+ type: "array",
56
+ items: {
57
+ type: "string",
58
+ enum: ["read", "write", "admin"],
59
+ },
60
+ },
61
+ version: {
62
+ description: "Version",
63
+ oneOf: [{ type: "string" }, { type: "double" }],
64
+ },
65
+ traits: {
66
+ description: "Traits",
67
+ type: "object",
68
+ },
69
+ };
40
70
 
41
71
  function getSegmentSchema() {
42
72
  const projectConfig = minimalProjectConfig();
@@ -187,6 +217,57 @@ describe("segmentSchema.ts :: getSegmentZodSchema", () => {
187
217
  "Unknown attribute",
188
218
  );
189
219
  });
220
+
221
+ it("accepts segment conditions using semver operators on oneOf attributes", () => {
222
+ expectSegmentSuccess({
223
+ description: "Version above v5",
224
+ conditions: {
225
+ and: [
226
+ { attribute: "device", operator: "equals", value: "desktop" },
227
+ { attribute: "version", operator: "semverGreaterThan", value: "5.0.0" },
228
+ ],
229
+ },
230
+ });
231
+ });
232
+
233
+ it("accepts segment conditions using flat object nested paths", () => {
234
+ expectSegmentSuccess({
235
+ description: "Dark mode users",
236
+ conditions: {
237
+ attribute: "traits.favoriteTheme",
238
+ operator: "equals",
239
+ value: "dark",
240
+ },
241
+ });
242
+ });
243
+
244
+ it("rejects segment conditions when oneOf attribute value matches no branch", () => {
245
+ expectSegmentFailure(
246
+ {
247
+ description: "Bad version",
248
+ conditions: {
249
+ attribute: "version",
250
+ operator: "equals",
251
+ value: true,
252
+ },
253
+ },
254
+ 'Value does not match the schema of attribute "version"',
255
+ );
256
+ });
257
+
258
+ it("rejects segment conditions when array item value violates enum constraints", () => {
259
+ expectSegmentFailure(
260
+ {
261
+ description: "Bad permission",
262
+ conditions: {
263
+ attribute: "permissions",
264
+ operator: "includes",
265
+ value: "delete",
266
+ },
267
+ },
268
+ 'Value does not match the item schema of attribute "permissions"',
269
+ );
270
+ });
190
271
  });
191
272
 
192
273
  describe("optional archived", () => {