@featurevisor/core 2.14.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 (93) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +774 -652
  3. package/coverage/coverage-final.json +19 -18
  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/mutateVariables.ts.html +1 -1
  10. package/coverage/lcov-report/builder/mutator.ts.html +1 -1
  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 +76 -25
  18. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +690 -66
  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 +43 -43
  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 +152 -164
  27. package/coverage/lcov-report/linter/groupSchema.ts.html +3 -3
  28. package/coverage/lcov-report/linter/index.html +34 -34
  29. package/coverage/lcov-report/linter/lintProject.ts.html +428 -83
  30. package/coverage/lcov-report/linter/mutationNotation.ts.html +3 -3
  31. package/coverage/lcov-report/linter/printError.ts.html +9 -9
  32. package/coverage/lcov-report/linter/schema.ts.html +4 -4
  33. package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
  34. package/coverage/lcov-report/linter/testSchema.ts.html +6 -6
  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 +1374 -1107
  47. package/lib/config/projectConfig.d.ts +3 -0
  48. package/lib/config/projectConfig.js +7 -1
  49. package/lib/config/projectConfig.js.map +1 -1
  50. package/lib/config/projectConfig.spec.d.ts +1 -0
  51. package/lib/config/projectConfig.spec.js +24 -0
  52. package/lib/config/projectConfig.spec.js.map +1 -0
  53. package/lib/datasource/adapter.d.ts +7 -0
  54. package/lib/datasource/adapter.js +7 -0
  55. package/lib/datasource/adapter.js.map +1 -1
  56. package/lib/datasource/datasource.d.ts +3 -1
  57. package/lib/datasource/datasource.js +6 -0
  58. package/lib/datasource/datasource.js.map +1 -1
  59. package/lib/datasource/filesystemAdapter.d.ts +8 -0
  60. package/lib/datasource/filesystemAdapter.js +134 -7
  61. package/lib/datasource/filesystemAdapter.js.map +1 -1
  62. package/lib/datasource/filesystemAdapter.spec.d.ts +1 -0
  63. package/lib/datasource/filesystemAdapter.spec.js +88 -0
  64. package/lib/datasource/filesystemAdapter.spec.js.map +1 -0
  65. package/lib/linter/conditionSchema.spec.js +2 -0
  66. package/lib/linter/conditionSchema.spec.js.map +1 -1
  67. package/lib/linter/featureSchema.js +46 -49
  68. package/lib/linter/featureSchema.js.map +1 -1
  69. package/lib/linter/featureSchema.spec.js +17 -0
  70. package/lib/linter/featureSchema.spec.js.map +1 -1
  71. package/lib/linter/lintProject.d.ts +2 -0
  72. package/lib/linter/lintProject.js +85 -8
  73. package/lib/linter/lintProject.js.map +1 -1
  74. package/lib/linter/lintProject.spec.js +37 -0
  75. package/lib/linter/lintProject.spec.js.map +1 -1
  76. package/lib/linter/segmentSchema.spec.js +2 -0
  77. package/lib/linter/segmentSchema.spec.js.map +1 -1
  78. package/lib/utils/git.js +32 -7
  79. package/lib/utils/git.js.map +1 -1
  80. package/package.json +2 -2
  81. package/src/config/projectConfig.spec.ts +31 -0
  82. package/src/config/projectConfig.ts +11 -0
  83. package/src/datasource/adapter.ts +21 -0
  84. package/src/datasource/datasource.ts +18 -1
  85. package/src/datasource/filesystemAdapter.spec.ts +153 -0
  86. package/src/datasource/filesystemAdapter.ts +216 -8
  87. package/src/linter/conditionSchema.spec.ts +2 -0
  88. package/src/linter/featureSchema.spec.ts +21 -0
  89. package/src/linter/featureSchema.ts +84 -88
  90. package/src/linter/lintProject.spec.ts +74 -0
  91. package/src/linter/lintProject.ts +123 -8
  92. package/src/linter/segmentSchema.spec.ts +2 -0
  93. package/src/utils/git.ts +41 -7
@@ -1465,15 +1465,9 @@ export function getFeatureZodSchema(
1465
1465
  }
1466
1466
  }
1467
1467
 
1468
- if (!value.variablesSchema) {
1469
- return;
1470
- }
1471
-
1472
- // Every variable value is validated against its schema from variablesSchema. Sources covered:
1473
- // 1. variablesSchema[key].defaultValue 2. variablesSchema[key].disabledValue
1474
- // 3. variations[n].variables[key] 4. variations[n].variableOverrides[key][].value
1475
- // 5. rules[env][n].variables[key] 6. force[env][n].variables[key]
1476
- const variableSchemaByKey = value.variablesSchema;
1468
+ // When variablesSchema is absent, variableSchemaByKey is {} so any variable key used in
1469
+ // rules/force/variations will be reported as "not defined in variablesSchema".
1470
+ const variableSchemaByKey = value.variablesSchema ?? {};
1477
1471
  const variationValues: string[] = [];
1478
1472
 
1479
1473
  if (value.variations) {
@@ -1482,100 +1476,102 @@ export function getFeatureZodSchema(
1482
1476
  });
1483
1477
  }
1484
1478
 
1485
- // variablesSchema[key]
1486
- const variableKeys = Object.keys(variableSchemaByKey);
1487
- variableKeys.forEach((variableKey) => {
1488
- const variableSchema = variableSchemaByKey[variableKey];
1479
+ // variablesSchema[key] — only when feature defines variablesSchema
1480
+ if (value.variablesSchema) {
1481
+ const variableKeys = Object.keys(variableSchemaByKey);
1482
+ variableKeys.forEach((variableKey) => {
1483
+ const variableSchema = variableSchemaByKey[variableKey];
1489
1484
 
1490
- // When variable references a schema by name, ensure it resolves and validate the referenced schema
1491
- if ("schema" in variableSchema && variableSchema.schema) {
1492
- const resolvedSchema = resolveVariableSchema(
1493
- variableSchema as Parameters<typeof resolveVariableSchema>[0],
1494
- schemasByKey,
1495
- );
1496
- if (!resolvedSchema) {
1497
- ctx.addIssue({
1498
- code: z.ZodIssueCode.custom,
1499
- message: `Schema "${variableSchema.schema}" could not be loaded for variable "${variableKey}".`,
1500
- path: ["variablesSchema", variableKey],
1501
- });
1502
- } else {
1503
- refineRequiredKeysInSchema(
1504
- resolvedSchema as SchemaLikeForRequired,
1505
- ["variablesSchema", variableKey],
1506
- ctx,
1485
+ // When variable references a schema by name, ensure it resolves and validate the referenced schema
1486
+ if ("schema" in variableSchema && variableSchema.schema) {
1487
+ const resolvedSchema = resolveVariableSchema(
1488
+ variableSchema as Parameters<typeof resolveVariableSchema>[0],
1507
1489
  schemasByKey,
1508
1490
  );
1491
+ if (!resolvedSchema) {
1492
+ ctx.addIssue({
1493
+ code: z.ZodIssueCode.custom,
1494
+ message: `Schema "${variableSchema.schema}" could not be loaded for variable "${variableKey}".`,
1495
+ path: ["variablesSchema", variableKey],
1496
+ });
1497
+ } else {
1498
+ refineRequiredKeysInSchema(
1499
+ resolvedSchema as SchemaLikeForRequired,
1500
+ ["variablesSchema", variableKey],
1501
+ ctx,
1502
+ schemasByKey,
1503
+ );
1504
+ }
1509
1505
  }
1510
- }
1511
-
1512
- // When type and enum are both present, all enum values must match the type
1513
- const effectiveSchema = resolveVariableSchema(variableSchema, schemasByKey);
1514
- if (
1515
- effectiveSchema &&
1516
- effectiveSchema.type &&
1517
- Array.isArray(effectiveSchema.enum) &&
1518
- effectiveSchema.enum.length > 0
1519
- ) {
1520
- refineEnumMatchesType(
1521
- effectiveSchema as Parameters<typeof refineEnumMatchesType>[0],
1522
- ["variablesSchema", variableKey],
1523
- ctx,
1524
- );
1525
- }
1526
1506
 
1527
- // Inline variable schemas: validate minimum/maximum, minLength/maxLength/pattern, minItems/maxItems/uniqueItems
1528
- if (!("schema" in variableSchema) || !variableSchema.schema) {
1529
- const pathPrefix = ["variablesSchema", variableKey];
1530
- refineMinimumMaximum(
1531
- variableSchema as Parameters<typeof refineMinimumMaximum>[0],
1532
- pathPrefix,
1533
- ctx,
1534
- );
1535
- refineStringLengthPattern(
1536
- variableSchema as Parameters<typeof refineStringLengthPattern>[0],
1537
- pathPrefix,
1538
- ctx,
1539
- );
1540
- refineArrayItems(
1541
- variableSchema as Parameters<typeof refineArrayItems>[0],
1542
- pathPrefix,
1543
- ctx,
1544
- );
1545
- }
1507
+ // When type and enum are both present, all enum values must match the type
1508
+ const effectiveSchema = resolveVariableSchema(variableSchema, schemasByKey);
1509
+ if (
1510
+ effectiveSchema &&
1511
+ effectiveSchema.type &&
1512
+ Array.isArray(effectiveSchema.enum) &&
1513
+ effectiveSchema.enum.length > 0
1514
+ ) {
1515
+ refineEnumMatchesType(
1516
+ effectiveSchema as Parameters<typeof refineEnumMatchesType>[0],
1517
+ ["variablesSchema", variableKey],
1518
+ ctx,
1519
+ );
1520
+ }
1546
1521
 
1547
- if (variableKey === "variation") {
1548
- ctx.addIssue({
1549
- code: z.ZodIssueCode.custom,
1550
- message: `Variable key "${variableKey}" is reserved and cannot be used.`,
1551
- path: ["variablesSchema", variableKey],
1552
- });
1553
- }
1522
+ // Inline variable schemas: validate minimum/maximum, minLength/maxLength/pattern, minItems/maxItems/uniqueItems
1523
+ if (!("schema" in variableSchema) || !variableSchema.schema) {
1524
+ const pathPrefix = ["variablesSchema", variableKey];
1525
+ refineMinimumMaximum(
1526
+ variableSchema as Parameters<typeof refineMinimumMaximum>[0],
1527
+ pathPrefix,
1528
+ ctx,
1529
+ );
1530
+ refineStringLengthPattern(
1531
+ variableSchema as Parameters<typeof refineStringLengthPattern>[0],
1532
+ pathPrefix,
1533
+ ctx,
1534
+ );
1535
+ refineArrayItems(
1536
+ variableSchema as Parameters<typeof refineArrayItems>[0],
1537
+ pathPrefix,
1538
+ ctx,
1539
+ );
1540
+ }
1554
1541
 
1555
- // defaultValue
1556
- superRefineVariableValue(
1557
- projectConfig,
1558
- variableSchema,
1559
- variableSchema.defaultValue,
1560
- ["variablesSchema", variableKey, "defaultValue"],
1561
- ctx,
1562
- variableKey,
1563
- schemasByKey,
1564
- );
1542
+ if (variableKey === "variation") {
1543
+ ctx.addIssue({
1544
+ code: z.ZodIssueCode.custom,
1545
+ message: `Variable key "${variableKey}" is reserved and cannot be used.`,
1546
+ path: ["variablesSchema", variableKey],
1547
+ });
1548
+ }
1565
1549
 
1566
- // disabledValue (only when present)
1567
- if (variableSchema.disabledValue !== undefined) {
1550
+ // defaultValue
1568
1551
  superRefineVariableValue(
1569
1552
  projectConfig,
1570
1553
  variableSchema,
1571
- variableSchema.disabledValue,
1572
- ["variablesSchema", variableKey, "disabledValue"],
1554
+ variableSchema.defaultValue,
1555
+ ["variablesSchema", variableKey, "defaultValue"],
1573
1556
  ctx,
1574
1557
  variableKey,
1575
1558
  schemasByKey,
1576
1559
  );
1577
- }
1578
- });
1560
+
1561
+ // disabledValue (only when present)
1562
+ if (variableSchema.disabledValue !== undefined) {
1563
+ superRefineVariableValue(
1564
+ projectConfig,
1565
+ variableSchema,
1566
+ variableSchema.disabledValue,
1567
+ ["variablesSchema", variableKey, "disabledValue"],
1568
+ ctx,
1569
+ variableKey,
1570
+ schemasByKey,
1571
+ );
1572
+ }
1573
+ });
1574
+ }
1579
1575
 
1580
1576
  // variations: validate variation.variables and variation.variableOverrides (each value against its variable schema)
1581
1577
  if (value.variations) {
@@ -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
 
@@ -22,7 +22,9 @@ function minimalProjectConfig(): ProjectConfig {
22
22
  datafileNamePattern: "",
23
23
  revisionFileName: "",
24
24
  siteExportDirectoryPath: "",
25
+ environmentsDirectoryPath: "",
25
26
  environments: ["staging", "production"],
27
+ splitByEnvironment: false,
26
28
  tags: ["all"],
27
29
  adapter: {},
28
30
  plugins: [],
package/src/utils/git.ts CHANGED
@@ -5,6 +5,12 @@ import type { Commit, EntityDiff, EntityType } from "@featurevisor/types";
5
5
  import { ProjectConfig } from "../config";
6
6
  import { CustomParser } from "../parsers";
7
7
 
8
+ function isWithinDirectory(directoryPath: string, fileDirectoryPath: string): boolean {
9
+ return (
10
+ fileDirectoryPath === directoryPath || fileDirectoryPath.startsWith(directoryPath + path.sep)
11
+ );
12
+ }
13
+
8
14
  function parseGitCommitShowOutput(gitShowOutput: string) {
9
15
  const result = {
10
16
  hash: "",
@@ -83,17 +89,22 @@ export function getCommit(
83
89
 
84
90
  // get entity type
85
91
  let type: EntityType = "attribute";
86
- if (relativeDir === projectConfig.attributesDirectoryPath) {
92
+ if (isWithinDirectory(projectConfig.attributesDirectoryPath, relativeDir)) {
87
93
  type = "attribute";
88
- } else if (relativeDir === projectConfig.segmentsDirectoryPath) {
94
+ } else if (isWithinDirectory(projectConfig.segmentsDirectoryPath, relativeDir)) {
89
95
  type = "segment";
90
- } else if (relativeDir === projectConfig.featuresDirectoryPath) {
96
+ } else if (isWithinDirectory(projectConfig.featuresDirectoryPath, relativeDir)) {
91
97
  type = "feature";
92
- } else if (relativeDir === projectConfig.groupsDirectoryPath) {
98
+ } else if (
99
+ projectConfig.splitByEnvironment &&
100
+ isWithinDirectory(projectConfig.environmentsDirectoryPath, relativeDir)
101
+ ) {
102
+ type = "feature";
103
+ } else if (isWithinDirectory(projectConfig.groupsDirectoryPath, relativeDir)) {
93
104
  type = "group";
94
- } else if (relativeDir === projectConfig.schemasDirectoryPath) {
105
+ } else if (isWithinDirectory(projectConfig.schemasDirectoryPath, relativeDir)) {
95
106
  type = "schema";
96
- } else if (relativeDir === projectConfig.testsDirectoryPath) {
107
+ } else if (isWithinDirectory(projectConfig.testsDirectoryPath, relativeDir)) {
97
108
  type = "test";
98
109
  } else {
99
110
  // unknown type
@@ -109,7 +120,30 @@ export function getCommit(
109
120
  return;
110
121
  }
111
122
 
112
- const key = fileName.replace(extensionWithDot, "");
123
+ let key = fileName.replace(extensionWithDot, "");
124
+
125
+ if (
126
+ type === "feature" &&
127
+ projectConfig.splitByEnvironment &&
128
+ absolutePath.startsWith(projectConfig.environmentsDirectoryPath + path.sep)
129
+ ) {
130
+ const featureRelativePath = absolutePath
131
+ .replace(projectConfig.environmentsDirectoryPath + path.sep, "")
132
+ .split(path.sep)
133
+ .slice(1)
134
+ .join(path.sep)
135
+ .replace(extensionWithDot, "");
136
+
137
+ key = featureRelativePath.replace(/\\/g, "/");
138
+ } else if (
139
+ type === "feature" &&
140
+ absolutePath.startsWith(projectConfig.featuresDirectoryPath + path.sep)
141
+ ) {
142
+ key = absolutePath
143
+ .replace(projectConfig.featuresDirectoryPath + path.sep, "")
144
+ .replace(extensionWithDot, "")
145
+ .replace(/\\/g, "/");
146
+ }
113
147
 
114
148
  const entityDiff: EntityDiff = {
115
149
  type,