@featurevisor/core 2.10.0 → 2.12.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 (79) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/coverage/clover.xml +684 -3
  3. package/coverage/coverage-final.json +4 -0
  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 +1 -1
  9. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  10. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  11. package/coverage/lcov-report/index.html +25 -10
  12. package/coverage/lcov-report/linter/conditionSchema.ts.html +775 -0
  13. package/coverage/lcov-report/linter/featureSchema.ts.html +4924 -0
  14. package/coverage/lcov-report/linter/index.html +161 -0
  15. package/coverage/lcov-report/linter/schema.ts.html +1471 -0
  16. package/coverage/lcov-report/linter/segmentSchema.ts.html +130 -0
  17. package/coverage/lcov-report/list/index.html +1 -1
  18. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  19. package/coverage/lcov-report/parsers/index.html +1 -1
  20. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  21. package/coverage/lcov-report/parsers/yml.ts.html +1 -1
  22. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  23. package/coverage/lcov-report/tester/index.html +1 -1
  24. package/coverage/lcov.info +1471 -0
  25. package/lib/builder/buildDatafile.js +15 -1
  26. package/lib/builder/buildDatafile.js.map +1 -1
  27. package/lib/config/projectConfig.d.ts +2 -0
  28. package/lib/config/projectConfig.js +3 -1
  29. package/lib/config/projectConfig.js.map +1 -1
  30. package/lib/datasource/datasource.d.ts +6 -1
  31. package/lib/datasource/datasource.js +16 -0
  32. package/lib/datasource/datasource.js.map +1 -1
  33. package/lib/datasource/filesystemAdapter.js +10 -0
  34. package/lib/datasource/filesystemAdapter.js.map +1 -1
  35. package/lib/generate-code/typescript.js +283 -49
  36. package/lib/generate-code/typescript.js.map +1 -1
  37. package/lib/linter/conditionSchema.spec.d.ts +1 -0
  38. package/lib/linter/conditionSchema.spec.js +331 -0
  39. package/lib/linter/conditionSchema.spec.js.map +1 -0
  40. package/lib/linter/featureSchema.d.ts +153 -17
  41. package/lib/linter/featureSchema.js +536 -49
  42. package/lib/linter/featureSchema.js.map +1 -1
  43. package/lib/linter/featureSchema.spec.d.ts +1 -0
  44. package/lib/linter/featureSchema.spec.js +978 -0
  45. package/lib/linter/featureSchema.spec.js.map +1 -0
  46. package/lib/linter/lintProject.js +67 -1
  47. package/lib/linter/lintProject.js.map +1 -1
  48. package/lib/linter/schema.d.ts +42 -0
  49. package/lib/linter/schema.js +417 -0
  50. package/lib/linter/schema.js.map +1 -0
  51. package/lib/linter/schema.spec.d.ts +1 -0
  52. package/lib/linter/schema.spec.js +483 -0
  53. package/lib/linter/schema.spec.js.map +1 -0
  54. package/lib/linter/segmentSchema.spec.d.ts +1 -0
  55. package/lib/linter/segmentSchema.spec.js +231 -0
  56. package/lib/linter/segmentSchema.spec.js.map +1 -0
  57. package/lib/tester/testFeature.js +5 -3
  58. package/lib/tester/testFeature.js.map +1 -1
  59. package/lib/utils/git.js +3 -0
  60. package/lib/utils/git.js.map +1 -1
  61. package/package.json +5 -5
  62. package/src/builder/buildDatafile.ts +17 -1
  63. package/src/config/projectConfig.ts +3 -0
  64. package/src/datasource/datasource.ts +23 -0
  65. package/src/datasource/filesystemAdapter.ts +7 -0
  66. package/src/generate-code/typescript.ts +333 -52
  67. package/src/linter/conditionSchema.spec.ts +446 -0
  68. package/src/linter/featureSchema.spec.ts +1218 -0
  69. package/src/linter/featureSchema.ts +747 -70
  70. package/src/linter/lintProject.ts +84 -0
  71. package/src/linter/schema.spec.ts +617 -0
  72. package/src/linter/schema.ts +462 -0
  73. package/src/linter/segmentSchema.spec.ts +273 -0
  74. package/src/tester/testFeature.ts +5 -3
  75. package/src/utils/git.ts +2 -0
  76. package/lib/linter/propertySchema.d.ts +0 -5
  77. package/lib/linter/propertySchema.js +0 -43
  78. package/lib/linter/propertySchema.js.map +0 -1
  79. package/src/linter/propertySchema.ts +0 -47
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Unit tests for segment schema validation (segments/*.yml).
3
+ * Covers getSegmentZodSchema: description, conditions (plain, and/or/not, array, *),
4
+ * optional archived, and strict (no extra keys).
5
+ */
6
+ import { z } from "zod";
7
+
8
+ import type { ProjectConfig } from "../config";
9
+ import { getConditionsZodSchema } from "./conditionSchema";
10
+ import { getSegmentZodSchema } from "./segmentSchema";
11
+
12
+ function minimalProjectConfig(): ProjectConfig {
13
+ return {
14
+ featuresDirectoryPath: "",
15
+ segmentsDirectoryPath: "",
16
+ attributesDirectoryPath: "",
17
+ groupsDirectoryPath: "",
18
+ schemasDirectoryPath: "",
19
+ testsDirectoryPath: "",
20
+ stateDirectoryPath: "",
21
+ datafilesDirectoryPath: "",
22
+ datafileNamePattern: "",
23
+ revisionFileName: "",
24
+ siteExportDirectoryPath: "",
25
+ environments: ["staging", "production"],
26
+ tags: ["all"],
27
+ adapter: {},
28
+ plugins: [],
29
+ defaultBucketBy: "userId",
30
+ parser: "yml",
31
+ prettyState: true,
32
+ prettyDatafile: false,
33
+ stringify: true,
34
+ };
35
+ }
36
+
37
+ const TEST_ATTRIBUTES: [string, ...string[]] = ["userId", "country", "device"];
38
+
39
+ function getSegmentSchema() {
40
+ const projectConfig = minimalProjectConfig();
41
+ const conditionsZodSchema = getConditionsZodSchema(projectConfig, TEST_ATTRIBUTES);
42
+ return getSegmentZodSchema(projectConfig, conditionsZodSchema);
43
+ }
44
+
45
+ function parseSegment(input: unknown): z.SafeParseReturnType<unknown, unknown> {
46
+ return getSegmentSchema().safeParse(input);
47
+ }
48
+
49
+ function expectSegmentSuccess(input: unknown): void {
50
+ const result = parseSegment(input);
51
+ expect(result.success).toBe(true);
52
+ if (!result.success) {
53
+ const err = (result as z.SafeParseError<unknown>).error;
54
+ const msg = err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
55
+ throw new Error(`Expected segment to pass: ${msg}`);
56
+ }
57
+ }
58
+
59
+ function expectSegmentFailure(input: unknown, messageSubstring?: string): z.ZodError {
60
+ const result = parseSegment(input);
61
+ expect(result.success).toBe(false);
62
+ if (result.success) throw new Error("Expected segment to fail");
63
+ const err = (result as z.SafeParseError<unknown>).error;
64
+ if (messageSubstring) {
65
+ const messages = err.issues
66
+ .map((i) => (typeof i.message === "string" ? i.message : ""))
67
+ .join(" ");
68
+ expect(messages).toContain(messageSubstring);
69
+ }
70
+ return err;
71
+ }
72
+
73
+ /** Assert that an intentional mistake produces an error at the expected path with expected message. */
74
+ function expectSegmentErrorSurfaces(
75
+ input: unknown,
76
+ opts: { pathContains: string[]; messageContains: string },
77
+ ): void {
78
+ const err = expectSegmentFailure(input, opts.messageContains);
79
+ const pathStrings = err.issues.map((i) => i.path.join("."));
80
+ const hasMatchingPath = pathStrings.some((p) =>
81
+ opts.pathContains.every((seg) => p.includes(seg)),
82
+ );
83
+ expect(hasMatchingPath).toBe(true);
84
+ }
85
+
86
+ describe("segmentSchema.ts :: getSegmentZodSchema", () => {
87
+ describe("required fields", () => {
88
+ it("accepts segment with description and conditions (plain condition)", () => {
89
+ expectSegmentSuccess({
90
+ description: "Users in Germany",
91
+ conditions: {
92
+ attribute: "country",
93
+ operator: "equals",
94
+ value: "de",
95
+ },
96
+ });
97
+ });
98
+
99
+ it("rejects segment without description", () => {
100
+ const result = parseSegment({
101
+ conditions: { attribute: "country", operator: "equals", value: "de" },
102
+ });
103
+ expect(result.success).toBe(false);
104
+ if (!result.success) {
105
+ const err = (result as z.SafeParseError<unknown>).error;
106
+ const path = err.issues.map((i) => i.path.join(".")).join(" ");
107
+ expect(path).toContain("description");
108
+ }
109
+ });
110
+
111
+ it("rejects segment without conditions", () => {
112
+ const result = parseSegment({
113
+ description: "A segment",
114
+ });
115
+ expect(result.success).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("conditions: everyone (*)", () => {
120
+ it("accepts segment with conditions *", () => {
121
+ expectSegmentSuccess({
122
+ description: "Everyone",
123
+ conditions: "*",
124
+ });
125
+ });
126
+ });
127
+
128
+ describe("conditions: array of conditions", () => {
129
+ it("accepts segment with conditions as array", () => {
130
+ expectSegmentSuccess({
131
+ description: "Germany or France",
132
+ conditions: [
133
+ { attribute: "country", operator: "equals", value: "de" },
134
+ { attribute: "country", operator: "equals", value: "fr" },
135
+ ],
136
+ });
137
+ });
138
+ });
139
+
140
+ describe("conditions: and / or / not", () => {
141
+ it("accepts segment with and conditions", () => {
142
+ expectSegmentSuccess({
143
+ description: "Germany and mobile",
144
+ conditions: {
145
+ and: [
146
+ { attribute: "country", operator: "equals", value: "de" },
147
+ { attribute: "device", operator: "equals", value: "mobile" },
148
+ ],
149
+ },
150
+ });
151
+ });
152
+
153
+ it("accepts segment with or conditions", () => {
154
+ expectSegmentSuccess({
155
+ description: "Germany or France",
156
+ conditions: {
157
+ or: [
158
+ { attribute: "country", operator: "equals", value: "de" },
159
+ { attribute: "country", operator: "equals", value: "fr" },
160
+ ],
161
+ },
162
+ });
163
+ });
164
+
165
+ it("accepts segment with not conditions", () => {
166
+ expectSegmentSuccess({
167
+ description: "Not US",
168
+ conditions: {
169
+ not: [{ attribute: "country", operator: "equals", value: "us" }],
170
+ },
171
+ });
172
+ });
173
+
174
+ it("rejects segment when nested condition has unknown attribute", () => {
175
+ expectSegmentFailure(
176
+ {
177
+ description: "Bad attr",
178
+ conditions: {
179
+ and: [
180
+ { attribute: "userId", operator: "equals", value: "u1" },
181
+ { attribute: "unknownAttr", operator: "equals", value: "x" },
182
+ ],
183
+ },
184
+ },
185
+ "Unknown attribute",
186
+ );
187
+ });
188
+ });
189
+
190
+ describe("optional archived", () => {
191
+ it("accepts segment with archived true", () => {
192
+ expectSegmentSuccess({
193
+ description: "Old segment",
194
+ conditions: "*",
195
+ archived: true,
196
+ });
197
+ });
198
+
199
+ it("accepts segment with archived false", () => {
200
+ expectSegmentSuccess({
201
+ description: "Active segment",
202
+ conditions: "*",
203
+ archived: false,
204
+ });
205
+ });
206
+
207
+ it("accepts segment without archived", () => {
208
+ expectSegmentSuccess({
209
+ description: "Segment",
210
+ conditions: "*",
211
+ });
212
+ });
213
+ });
214
+
215
+ describe("strict: no extra keys", () => {
216
+ it("rejects segment with extra key at root", () => {
217
+ const result = parseSegment({
218
+ description: "Segment",
219
+ conditions: "*",
220
+ extraKey: true,
221
+ });
222
+ expect(result.success).toBe(false);
223
+ });
224
+ });
225
+
226
+ describe("description type", () => {
227
+ it("rejects description that is not a string", () => {
228
+ const result = parseSegment({
229
+ description: 123,
230
+ conditions: "*",
231
+ });
232
+ expect(result.success).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe("errors surface properly: intentional mistakes produce correct path and message", () => {
237
+ it("missing description: error path includes description", () => {
238
+ expectSegmentErrorSurfaces(
239
+ { conditions: { attribute: "country", operator: "equals", value: "de" } },
240
+ { pathContains: ["description"], messageContains: "Required" },
241
+ );
242
+ });
243
+
244
+ it("conditions with unknown attribute: error path goes into conditions", () => {
245
+ expectSegmentErrorSurfaces(
246
+ {
247
+ description: "Segment",
248
+ conditions: {
249
+ and: [
250
+ { attribute: "country", operator: "equals", value: "de" },
251
+ { attribute: "typoAttr", operator: "equals", value: "x" },
252
+ ],
253
+ },
254
+ },
255
+ { pathContains: ["conditions", "attribute"], messageContains: "Unknown attribute" },
256
+ );
257
+ });
258
+
259
+ it("extra key at root: parse fails and message mentions unrecognized key", () => {
260
+ const result = parseSegment({
261
+ description: "Segment",
262
+ conditions: "*",
263
+ extraKey: true,
264
+ });
265
+ expect(result.success).toBe(false);
266
+ const err = (result as z.SafeParseError<unknown>).error;
267
+ const messages = err.issues
268
+ .map((i) => (typeof i.message === "string" ? i.message : ""))
269
+ .join(" ");
270
+ expect(messages).toMatch(/unrecognized|extraKey/i);
271
+ });
272
+ });
273
+ });
@@ -104,8 +104,8 @@ export async function testFeature(
104
104
  logLevel,
105
105
  });
106
106
 
107
- const feature = await datasource.readFeature(featureKey);
108
- if (!feature) {
107
+ const parsedFeature = await datasource.readFeature(featureKey);
108
+ if (!parsedFeature) {
109
109
  testResult.notFound = true;
110
110
  testResult.passed = false;
111
111
 
@@ -208,7 +208,9 @@ export async function testFeature(
208
208
 
209
209
  let passed;
210
210
 
211
- const variableSchema = feature.variablesSchema?.[variableKey];
211
+ // Use feature from datafile so variable schema is always resolved (ResolvedVariableSchema)
212
+ const featureFromDatafile = datafileContent?.features?.[featureKey];
213
+ const variableSchema = featureFromDatafile?.variablesSchema?.[variableKey];
212
214
 
213
215
  if (!variableSchema) {
214
216
  testResult.passed = false;
package/src/utils/git.ts CHANGED
@@ -91,6 +91,8 @@ export function getCommit(
91
91
  type = "feature";
92
92
  } else if (relativeDir === projectConfig.groupsDirectoryPath) {
93
93
  type = "group";
94
+ } else if (relativeDir === projectConfig.schemasDirectoryPath) {
95
+ type = "schema";
94
96
  } else if (relativeDir === projectConfig.testsDirectoryPath) {
95
97
  type = "test";
96
98
  } else {
@@ -1,5 +0,0 @@
1
- import type { PropertySchema, Value } from "@featurevisor/types";
2
- import { z } from "zod";
3
- export declare const valueZodSchema: z.ZodType<Value>;
4
- export declare const propertyTypeEnum: z.ZodEnum<["boolean", "string", "integer", "double", "object", "array"]>;
5
- export declare function getPropertyZodSchema(): z.ZodType<PropertySchema, z.ZodTypeDef, PropertySchema>;
@@ -1,43 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.propertyTypeEnum = exports.valueZodSchema = void 0;
4
- exports.getPropertyZodSchema = getPropertyZodSchema;
5
- const zod_1 = require("zod");
6
- // Recursive schema for Value: boolean | string | number | ObjectValue | Value[]
7
- exports.valueZodSchema = zod_1.z.lazy(() => zod_1.z.union([
8
- zod_1.z.boolean(),
9
- zod_1.z.string(),
10
- zod_1.z.number(),
11
- // | Date // @TODO: support in future
12
- zod_1.z.record(zod_1.z.string(), exports.valueZodSchema),
13
- zod_1.z.array(exports.valueZodSchema),
14
- ]));
15
- // @TODO: support "date" in future
16
- // @TODO: consider "semver" in future
17
- // @TODO: consider "url" in future
18
- exports.propertyTypeEnum = zod_1.z.enum([
19
- "boolean",
20
- "string",
21
- "integer",
22
- "double",
23
- "object",
24
- "array",
25
- ]);
26
- function getPropertyZodSchema() {
27
- const propertyZodSchema = zod_1.z.lazy(() => zod_1.z
28
- .object({
29
- description: zod_1.z.string().optional(),
30
- type: exports.propertyTypeEnum.optional(),
31
- // enum?: Value[]; const?: Value;
32
- // Numeric: maximum?, minimum?
33
- // String: maxLength?, minLength?, pattern?
34
- items: propertyZodSchema.optional(),
35
- // maxItems?, minItems?, uniqueItems?
36
- required: zod_1.z.array(zod_1.z.string()).optional(),
37
- properties: zod_1.z.record(zod_1.z.string(), propertyZodSchema).optional(),
38
- // Annotations: default?: Value; examples?: Value[];
39
- })
40
- .strict());
41
- return propertyZodSchema;
42
- }
43
- //# sourceMappingURL=propertySchema.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"propertySchema.js","sourceRoot":"","sources":["../../src/linter/propertySchema.ts"],"names":[],"mappings":";;;AA2BA,oDAmBC;AA7CD,6BAAwB;AAExB,gFAAgF;AACnE,QAAA,cAAc,GAAqB,OAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAC1D,OAAC,CAAC,KAAK,CAAC;IACN,OAAC,CAAC,OAAO,EAAE;IACX,OAAC,CAAC,MAAM,EAAE;IACV,OAAC,CAAC,MAAM,EAAE;IACV,qCAAqC;IACrC,OAAC,CAAC,MAAM,CAAC,OAAC,CAAC,MAAM,EAAE,EAAE,sBAAc,CAAC;IACpC,OAAC,CAAC,KAAK,CAAC,sBAAc,CAAC;CACxB,CAAC,CACH,CAAC;AAEF,kCAAkC;AAClC,qCAAqC;AACrC,kCAAkC;AACrB,QAAA,gBAAgB,GAAG,OAAC,CAAC,IAAI,CAAC;IACrC,SAAS;IACT,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,QAAQ;IACR,OAAO;CACR,CAAC,CAAC;AAEH,SAAgB,oBAAoB;IAClC,MAAM,iBAAiB,GAA8B,OAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAC/D,OAAC;SACE,MAAM,CAAC;QACN,WAAW,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;QAClC,IAAI,EAAE,wBAAgB,CAAC,QAAQ,EAAE;QACjC,iCAAiC;QACjC,8BAA8B;QAC9B,2CAA2C;QAC3C,KAAK,EAAE,iBAAiB,CAAC,QAAQ,EAAE;QACnC,qCAAqC;QACrC,QAAQ,EAAE,OAAC,CAAC,KAAK,CAAC,OAAC,CAAC,MAAM,EAAE,CAAC,CAAC,QAAQ,EAAE;QACxC,UAAU,EAAE,OAAC,CAAC,MAAM,CAAC,OAAC,CAAC,MAAM,EAAE,EAAE,iBAAiB,CAAC,CAAC,QAAQ,EAAE;QAC9D,oDAAoD;KACrD,CAAC;SACD,MAAM,EAAE,CACZ,CAAC;IAEF,OAAO,iBAAiB,CAAC;AAC3B,CAAC"}
@@ -1,47 +0,0 @@
1
- import type { PropertySchema, Value } from "@featurevisor/types";
2
- import { z } from "zod";
3
-
4
- // Recursive schema for Value: boolean | string | number | ObjectValue | Value[]
5
- export const valueZodSchema: z.ZodType<Value> = z.lazy(() =>
6
- z.union([
7
- z.boolean(),
8
- z.string(),
9
- z.number(),
10
- // | Date // @TODO: support in future
11
- z.record(z.string(), valueZodSchema),
12
- z.array(valueZodSchema),
13
- ]),
14
- );
15
-
16
- // @TODO: support "date" in future
17
- // @TODO: consider "semver" in future
18
- // @TODO: consider "url" in future
19
- export const propertyTypeEnum = z.enum([
20
- "boolean",
21
- "string",
22
- "integer",
23
- "double",
24
- "object",
25
- "array",
26
- ]);
27
-
28
- export function getPropertyZodSchema() {
29
- const propertyZodSchema: z.ZodType<PropertySchema> = z.lazy(() =>
30
- z
31
- .object({
32
- description: z.string().optional(),
33
- type: propertyTypeEnum.optional(),
34
- // enum?: Value[]; const?: Value;
35
- // Numeric: maximum?, minimum?
36
- // String: maxLength?, minLength?, pattern?
37
- items: propertyZodSchema.optional(),
38
- // maxItems?, minItems?, uniqueItems?
39
- required: z.array(z.string()).optional(),
40
- properties: z.record(z.string(), propertyZodSchema).optional(),
41
- // Annotations: default?: Value; examples?: Value[];
42
- })
43
- .strict(),
44
- );
45
-
46
- return propertyZodSchema;
47
- }