@featurevisor/core 2.13.0 → 2.15.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 (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/coverage/clover.xml +1499 -1028
  3. package/coverage/coverage-final.json +21 -17
  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 +30 -15
  14. package/coverage/lcov-report/config/index.ts.html +88 -0
  15. package/coverage/lcov-report/config/projectConfig.ts.html +79 -46
  16. package/coverage/lcov-report/datasource/adapter.ts.html +74 -11
  17. package/coverage/lcov-report/datasource/datasource.ts.html +77 -26
  18. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +693 -69
  19. package/coverage/lcov-report/datasource/index.html +35 -35
  20. package/coverage/lcov-report/datasource/index.ts.html +1 -1
  21. package/coverage/lcov-report/index.html +53 -53
  22. package/coverage/lcov-report/linter/attributeSchema.ts.html +4 -4
  23. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  24. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  25. package/coverage/lcov-report/linter/conditionSchema.ts.html +7 -7
  26. package/coverage/lcov-report/linter/featureSchema.ts.html +848 -389
  27. package/coverage/lcov-report/linter/groupSchema.ts.html +3 -3
  28. package/coverage/lcov-report/linter/index.html +50 -35
  29. package/coverage/lcov-report/linter/lintProject.ts.html +437 -92
  30. package/coverage/lcov-report/linter/mutationNotation.ts.html +1309 -0
  31. package/coverage/lcov-report/linter/printError.ts.html +9 -9
  32. package/coverage/lcov-report/linter/schema.ts.html +69 -69
  33. package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
  34. package/coverage/lcov-report/linter/testSchema.ts.html +10 -10
  35. package/coverage/lcov-report/list/index.html +1 -1
  36. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  37. package/coverage/lcov-report/parsers/index.html +1 -1
  38. package/coverage/lcov-report/parsers/index.ts.html +4 -4
  39. package/coverage/lcov-report/parsers/json.ts.html +2 -2
  40. package/coverage/lcov-report/parsers/yml.ts.html +6 -6
  41. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  42. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  43. package/coverage/lcov-report/tester/index.html +1 -1
  44. package/coverage/lcov-report/utils/git.ts.html +118 -16
  45. package/coverage/lcov-report/utils/index.html +14 -14
  46. package/coverage/lcov.info +2918 -1932
  47. package/lib/builder/buildDatafile.js +8 -3
  48. package/lib/builder/buildDatafile.js.map +1 -1
  49. package/lib/builder/mutateVariables.d.ts +14 -0
  50. package/lib/builder/mutateVariables.js +90 -0
  51. package/lib/builder/mutateVariables.js.map +1 -0
  52. package/lib/builder/mutateVariables.spec.d.ts +1 -0
  53. package/lib/builder/mutateVariables.spec.js +1045 -0
  54. package/lib/builder/mutateVariables.spec.js.map +1 -0
  55. package/lib/builder/mutator.d.ts +20 -0
  56. package/lib/builder/mutator.js +223 -0
  57. package/lib/builder/mutator.js.map +1 -0
  58. package/lib/builder/mutator.spec.d.ts +1 -0
  59. package/lib/builder/mutator.spec.js +368 -0
  60. package/lib/builder/mutator.spec.js.map +1 -0
  61. package/lib/config/projectConfig.d.ts +3 -0
  62. package/lib/config/projectConfig.js +7 -1
  63. package/lib/config/projectConfig.js.map +1 -1
  64. package/lib/config/projectConfig.spec.d.ts +1 -0
  65. package/lib/config/projectConfig.spec.js +24 -0
  66. package/lib/config/projectConfig.spec.js.map +1 -0
  67. package/lib/datasource/adapter.d.ts +7 -0
  68. package/lib/datasource/adapter.js +7 -0
  69. package/lib/datasource/adapter.js.map +1 -1
  70. package/lib/datasource/datasource.d.ts +3 -1
  71. package/lib/datasource/datasource.js +6 -0
  72. package/lib/datasource/datasource.js.map +1 -1
  73. package/lib/datasource/filesystemAdapter.d.ts +8 -0
  74. package/lib/datasource/filesystemAdapter.js +134 -7
  75. package/lib/datasource/filesystemAdapter.js.map +1 -1
  76. package/lib/datasource/filesystemAdapter.spec.d.ts +1 -0
  77. package/lib/datasource/filesystemAdapter.spec.js +88 -0
  78. package/lib/datasource/filesystemAdapter.spec.js.map +1 -0
  79. package/lib/linter/conditionSchema.spec.js +2 -0
  80. package/lib/linter/conditionSchema.spec.js.map +1 -1
  81. package/lib/linter/featureSchema.d.ts +7 -7
  82. package/lib/linter/featureSchema.js +176 -103
  83. package/lib/linter/featureSchema.js.map +1 -1
  84. package/lib/linter/featureSchema.spec.js +167 -0
  85. package/lib/linter/featureSchema.spec.js.map +1 -1
  86. package/lib/linter/lintProject.d.ts +2 -0
  87. package/lib/linter/lintProject.js +85 -8
  88. package/lib/linter/lintProject.js.map +1 -1
  89. package/lib/linter/lintProject.spec.js +37 -0
  90. package/lib/linter/lintProject.spec.js.map +1 -1
  91. package/lib/linter/mutationNotation.d.ts +47 -0
  92. package/lib/linter/mutationNotation.js +381 -0
  93. package/lib/linter/mutationNotation.js.map +1 -0
  94. package/lib/linter/mutationNotation.spec.d.ts +1 -0
  95. package/lib/linter/mutationNotation.spec.js +549 -0
  96. package/lib/linter/mutationNotation.spec.js.map +1 -0
  97. package/lib/linter/segmentSchema.spec.js +2 -0
  98. package/lib/linter/segmentSchema.spec.js.map +1 -1
  99. package/lib/utils/git.js +32 -7
  100. package/lib/utils/git.js.map +1 -1
  101. package/package.json +2 -2
  102. package/src/builder/buildDatafile.ts +29 -3
  103. package/src/builder/mutateVariables.spec.ts +1134 -0
  104. package/src/builder/mutateVariables.ts +105 -0
  105. package/src/builder/mutator.spec.ts +413 -0
  106. package/src/builder/mutator.ts +237 -0
  107. package/src/config/projectConfig.spec.ts +31 -0
  108. package/src/config/projectConfig.ts +11 -0
  109. package/src/datasource/adapter.ts +21 -0
  110. package/src/datasource/datasource.ts +18 -1
  111. package/src/datasource/filesystemAdapter.spec.ts +153 -0
  112. package/src/datasource/filesystemAdapter.ts +216 -8
  113. package/src/linter/conditionSchema.spec.ts +2 -0
  114. package/src/linter/featureSchema.spec.ts +203 -0
  115. package/src/linter/featureSchema.ts +320 -167
  116. package/src/linter/lintProject.spec.ts +74 -0
  117. package/src/linter/lintProject.ts +123 -8
  118. package/src/linter/mutationNotation.spec.ts +642 -0
  119. package/src/linter/mutationNotation.ts +408 -0
  120. package/src/linter/segmentSchema.spec.ts +2 -0
  121. package/src/utils/git.ts +41 -7
@@ -15,6 +15,46 @@ function createTempProjectFromExample1() {
15
15
  return tempRoot;
16
16
  }
17
17
 
18
+ function createTempSplitProject() {
19
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-lint-split-"));
20
+
21
+ fs.writeFileSync(
22
+ path.join(tempRoot, "featurevisor.config.js"),
23
+ [
24
+ "module.exports = {",
25
+ " environments: ['staging', 'production'],",
26
+ " splitByEnvironment: true,",
27
+ " tags: ['all'],",
28
+ "};",
29
+ ].join("\n"),
30
+ "utf8",
31
+ );
32
+
33
+ fs.mkdirSync(path.join(tempRoot, "features"), { recursive: true });
34
+ fs.mkdirSync(path.join(tempRoot, "environments", "staging"), { recursive: true });
35
+ fs.mkdirSync(path.join(tempRoot, "environments", "production"), { recursive: true });
36
+
37
+ fs.writeFileSync(
38
+ path.join(tempRoot, "features", "foo.yml"),
39
+ ["key: foo", "description: Foo", "tags:", " - all", "bucketBy: userId"].join("\n"),
40
+ "utf8",
41
+ );
42
+
43
+ fs.writeFileSync(
44
+ path.join(tempRoot, "environments", "staging", "foo.yml"),
45
+ ["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
46
+ "utf8",
47
+ );
48
+
49
+ fs.writeFileSync(
50
+ path.join(tempRoot, "environments", "production", "foo.yml"),
51
+ ["rules:", " - key: everyone", " segments: '*'", " percentage: 0"].join("\n"),
52
+ "utf8",
53
+ );
54
+
55
+ return tempRoot;
56
+ }
57
+
18
58
  function getDeps(rootDirectoryPath: string) {
19
59
  const projectConfig = getProjectConfig(rootDirectoryPath);
20
60
  const datasource = new Datasource(projectConfig, rootDirectoryPath);
@@ -112,4 +152,38 @@ describe("core: lintProject", function () {
112
152
  expect(result.errors.length).toBeGreaterThan(0);
113
153
  expect(processExitSpy).not.toHaveBeenCalled();
114
154
  });
155
+
156
+ it("reports missing split environment file with environment file path", async () => {
157
+ const splitProjectPath = createTempSplitProject();
158
+ fs.unlinkSync(path.join(splitProjectPath, "environments", "production", "foo.yml"));
159
+
160
+ const result = await lintProject(getDeps(splitProjectPath) as any, {
161
+ json: true,
162
+ entityType: "feature",
163
+ });
164
+
165
+ expect(result.hasError).toBe(true);
166
+ expect(result.errors[0].filePath).toContain(path.join("environments", "production", "foo.yml"));
167
+ });
168
+
169
+ it("reports split environment feature schema errors against environment file path", async () => {
170
+ const splitProjectPath = createTempSplitProject();
171
+ fs.writeFileSync(
172
+ path.join(splitProjectPath, "environments", "staging", "foo.yml"),
173
+ ["rules:", " - key: everyone", " segments: '*'", " percentage: invalid"].join("\n"),
174
+ "utf8",
175
+ );
176
+
177
+ const result = await lintProject(getDeps(splitProjectPath) as any, {
178
+ json: true,
179
+ entityType: "feature",
180
+ });
181
+
182
+ expect(result.hasError).toBe(true);
183
+ expect(
184
+ result.errors.some((error) =>
185
+ error.filePath.includes(path.join("environments", "staging", "foo.yml")),
186
+ ),
187
+ ).toBe(true);
188
+ });
115
189
  });
@@ -37,6 +37,8 @@ export interface LintErrorItem {
37
37
  path: (string | number)[];
38
38
  code?: string;
39
39
  value?: unknown;
40
+ /** Present only when splitByEnvironment is true and the error is from an environment-specific file (rules/force/expose). */
41
+ environment?: string;
40
42
  }
41
43
 
42
44
  export interface LintResult {
@@ -97,6 +99,62 @@ export async function lintProject(
97
99
  return fullPath;
98
100
  }
99
101
 
102
+ async function getFeaturePathFromIssuePath(
103
+ featureKey: string,
104
+ issuePath: (string | number)[] = [],
105
+ ): Promise<string> {
106
+ const defaultPath = getFullPathFromKey("feature", featureKey);
107
+
108
+ if (!projectConfig.splitByEnvironment) {
109
+ return defaultPath;
110
+ }
111
+
112
+ if (!Array.isArray(projectConfig.environments)) {
113
+ return defaultPath;
114
+ }
115
+
116
+ const [topLevelKey, environment] = issuePath;
117
+ if (
118
+ (topLevelKey === "rules" || topLevelKey === "force" || topLevelKey === "expose") &&
119
+ typeof environment === "string"
120
+ ) {
121
+ const sourcePath = await datasource.getFeaturePropertySourcePath(
122
+ featureKey,
123
+ topLevelKey,
124
+ environment,
125
+ );
126
+
127
+ if (sourcePath) {
128
+ return sourcePath;
129
+ }
130
+ }
131
+
132
+ return defaultPath;
133
+ }
134
+
135
+ /**
136
+ * When the error is from an environment-specific file (splitByEnvironment), the Zod path
137
+ * is against the merged feature (e.g. rules.production.1.variables.foo). Return the path
138
+ * as it appears in that file (e.g. rules.1.variables.foo) by stripping the environment segment.
139
+ */
140
+ function getPathRelativeToFeatureFile(
141
+ issuePath: (string | number)[],
142
+ targetPath: string,
143
+ defaultFeaturePath: string,
144
+ ): (string | number)[] {
145
+ if (targetPath === defaultFeaturePath || issuePath.length < 2) {
146
+ return issuePath;
147
+ }
148
+ const [topLevelKey, second] = issuePath;
149
+ if (
150
+ (topLevelKey === "rules" || topLevelKey === "force" || topLevelKey === "expose") &&
151
+ typeof second === "string"
152
+ ) {
153
+ return [topLevelKey, ...issuePath.slice(2)];
154
+ }
155
+ return issuePath;
156
+ }
157
+
100
158
  async function printEntityHeader(entityType: LintEntityType, key: string, fullPath: string) {
101
159
  if (isJsonMode) {
102
160
  return;
@@ -159,10 +217,18 @@ export async function lintProject(
159
217
  fullPath: string,
160
218
  error: unknown,
161
219
  ) {
220
+ const pathFromError =
221
+ error &&
222
+ typeof error === "object" &&
223
+ "featurevisorFilePath" in error &&
224
+ typeof error.featurevisorFilePath === "string"
225
+ ? error.featurevisorFilePath
226
+ : undefined;
227
+ const targetPath = pathFromError || fullPath;
162
228
  const message = error instanceof Error ? error.message : String(error);
163
229
 
164
230
  recordError({
165
- filePath: path.relative(process.cwd(), fullPath),
231
+ filePath: path.relative(process.cwd(), targetPath),
166
232
  entityType,
167
233
  key,
168
234
  message,
@@ -171,7 +237,7 @@ export async function lintProject(
171
237
  });
172
238
 
173
239
  if (!isJsonMode) {
174
- await printEntityHeader(entityType, key, fullPath);
240
+ await printEntityHeader(entityType, key, targetPath);
175
241
  console.log("");
176
242
  console.log(error);
177
243
  }
@@ -184,22 +250,71 @@ export async function lintProject(
184
250
  error: ZodError,
185
251
  ) {
186
252
  const issues = getLintIssuesFromZodError(error);
253
+ const defaultFeaturePath =
254
+ entityType === "feature" ? getFullPathFromKey("feature", key) : fullPath;
255
+
256
+ const issuesWithTargetPath: {
257
+ issue: (typeof issues)[0];
258
+ targetPath: string;
259
+ pathRelativeToFile: (string | number)[];
260
+ }[] = [];
261
+ for (const issue of issues) {
262
+ const targetPath =
263
+ entityType === "feature" ? await getFeaturePathFromIssuePath(key, issue.path) : fullPath;
264
+ const pathRelativeToFile =
265
+ entityType === "feature"
266
+ ? getPathRelativeToFeatureFile(issue.path, targetPath, defaultFeaturePath)
267
+ : issue.path;
268
+ const isFromEnvFile =
269
+ entityType === "feature" &&
270
+ targetPath !== defaultFeaturePath &&
271
+ issue.path.length >= 2 &&
272
+ (issue.path[0] === "rules" || issue.path[0] === "force" || issue.path[0] === "expose") &&
273
+ typeof issue.path[1] === "string";
274
+ const environment = isFromEnvFile ? (issue.path[1] as string) : undefined;
275
+ issuesWithTargetPath.push({ issue, targetPath, pathRelativeToFile });
187
276
 
188
- issues.forEach((issue) => {
189
277
  recordError({
190
- filePath: path.relative(process.cwd(), fullPath),
278
+ filePath: path.relative(process.cwd(), targetPath),
191
279
  entityType,
192
280
  key,
193
281
  message: issue.message,
194
- path: issue.path,
282
+ path: pathRelativeToFile,
195
283
  code: issue.code,
196
284
  value: issue.value,
285
+ ...(environment !== undefined && { environment }),
197
286
  });
198
- });
287
+ }
199
288
 
200
289
  if (!isJsonMode) {
201
- await printEntityHeader(entityType, key, fullPath);
202
- printZodError(error);
290
+ if (entityType === "feature" && issuesWithTargetPath.length > 0) {
291
+ const byPath = new Map<
292
+ string,
293
+ {
294
+ issue: (typeof issuesWithTargetPath)[0]["issue"];
295
+ pathRelativeToFile: (string | number)[];
296
+ }[]
297
+ >();
298
+ for (const { issue, targetPath, pathRelativeToFile } of issuesWithTargetPath) {
299
+ const pathKey = targetPath;
300
+ if (!byPath.has(pathKey)) byPath.set(pathKey, []);
301
+ byPath.get(pathKey)!.push({ issue, pathRelativeToFile });
302
+ }
303
+ for (const [targetPathKey, groupItems] of byPath) {
304
+ await printEntityHeader(entityType, key, targetPathKey);
305
+ for (const { issue, pathRelativeToFile } of groupItems) {
306
+ console.log(CLI_FORMAT_RED, ` => Error: ${issue.message}`);
307
+ console.log(" Path:", pathRelativeToFile.join("."));
308
+ if (typeof issue.value !== "undefined" && issue.value !== "undefined") {
309
+ console.log(" Value:", issue.value);
310
+ }
311
+ console.log("");
312
+ }
313
+ }
314
+ } else {
315
+ await printEntityHeader(entityType, key, fullPath);
316
+ printZodError(error);
317
+ }
203
318
  }
204
319
  }
205
320