@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
@@ -0,0 +1,237 @@
1
+ import type { VariableSchema, VariableValue } from "@featurevisor/types";
2
+
3
+ export type PathPart =
4
+ | { key: string }
5
+ | { key: string; index: number }
6
+ | { key: string; selector: { prop: string; value: string } };
7
+
8
+ export type MutationOperation = "set" | "append" | "prepend" | "after" | "before" | "remove";
9
+
10
+ export interface ParsedNotation {
11
+ segments: PathPart[];
12
+ operation: MutationOperation;
13
+ }
14
+
15
+ export function parseNotation(notation: string): ParsedNotation {
16
+ let rest = notation.trim();
17
+ const operationMatch = rest.match(/:((?:append|prepend|after|before|remove))$/);
18
+ const operation: MutationOperation = operationMatch
19
+ ? (operationMatch[1] as MutationOperation)
20
+ : "set";
21
+ if (operationMatch) {
22
+ rest = rest.slice(0, -operationMatch[0].length);
23
+ }
24
+
25
+ const segments: PathPart[] = [];
26
+ let i = 0;
27
+ while (i < rest.length) {
28
+ let key = "";
29
+ while (i < rest.length && rest[i] !== "." && rest[i] !== "[") {
30
+ key += rest[i];
31
+ i++;
32
+ }
33
+ key = key.trim();
34
+ if (key) {
35
+ if (rest[i] === "[") {
36
+ i++;
37
+ const bracketStart = i;
38
+ while (i < rest.length && rest[i] !== "]") i++;
39
+ const bracketContent = rest.slice(bracketStart, i);
40
+ i++;
41
+ const eq = bracketContent.indexOf("=");
42
+ if (eq >= 0) {
43
+ const prop = bracketContent.slice(0, eq).trim();
44
+ let val = bracketContent.slice(eq + 1).trim();
45
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
46
+ else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
47
+ segments.push({ key, selector: { prop, value: val } });
48
+ } else {
49
+ const index = parseInt(bracketContent.trim(), 10);
50
+ segments.push({ key, index });
51
+ }
52
+ } else {
53
+ segments.push({ key });
54
+ }
55
+ } else if (rest[i] === "[") {
56
+ i++;
57
+ const bracketStart = i;
58
+ while (i < rest.length && rest[i] !== "]") i++;
59
+ const bracketContent = rest.slice(bracketStart, i);
60
+ i++;
61
+ const eq = bracketContent.indexOf("=");
62
+ if (eq >= 0) {
63
+ const prop = bracketContent.slice(0, eq).trim();
64
+ let val = bracketContent.slice(eq + 1).trim();
65
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
66
+ else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
67
+ segments.push({ key: "", selector: { prop, value: val } });
68
+ } else {
69
+ const index = parseInt(bracketContent.trim(), 10);
70
+ segments.push({ key: "", index });
71
+ }
72
+ }
73
+ if (rest[i] === ".") i++;
74
+ }
75
+
76
+ return { segments, operation };
77
+ }
78
+
79
+ function getAtSegment(obj: VariableValue, seg: PathPart): VariableValue {
80
+ if (obj === null || obj === undefined) return undefined;
81
+ const o = obj as Record<string, unknown>;
82
+ if ("index" in seg) {
83
+ const arr =
84
+ seg.key === ""
85
+ ? Array.isArray(obj)
86
+ ? obj
87
+ : undefined
88
+ : Array.isArray(obj)
89
+ ? obj
90
+ : (o[seg.key] as unknown[]);
91
+ if (!Array.isArray(arr)) return undefined;
92
+ return arr[seg.index] as VariableValue;
93
+ }
94
+ if ("selector" in seg) {
95
+ const arr =
96
+ seg.key === ""
97
+ ? Array.isArray(obj)
98
+ ? obj
99
+ : undefined
100
+ : Array.isArray(obj)
101
+ ? obj
102
+ : (o[seg.key] as unknown[]);
103
+ if (!Array.isArray(arr)) return undefined;
104
+ const { prop, value } = seg.selector;
105
+ const found = arr.find((item) => {
106
+ if (item === null || typeof item !== "object") return false;
107
+ const v = (item as Record<string, unknown>)[prop];
108
+ return String(v) === value;
109
+ });
110
+ return found as VariableValue;
111
+ }
112
+ return o[seg.key] as VariableValue;
113
+ }
114
+
115
+ function setAtSegment(
116
+ obj: Record<string, unknown> | unknown[],
117
+ seg: PathPart,
118
+ _value: VariableValue,
119
+ op: MutationOperation,
120
+ setValue: VariableValue | undefined,
121
+ ): void {
122
+ if ("index" in seg) {
123
+ const i = seg.index;
124
+ const arr =
125
+ seg.key === ""
126
+ ? Array.isArray(obj)
127
+ ? obj
128
+ : undefined
129
+ : Array.isArray(obj)
130
+ ? obj
131
+ : ((obj as Record<string, unknown>)[seg.key] as unknown[]);
132
+ if (!Array.isArray(arr)) return;
133
+ if (op === "remove") {
134
+ arr.splice(i, 1);
135
+ return;
136
+ }
137
+ if (op === "set") {
138
+ arr[i] = setValue;
139
+ return;
140
+ }
141
+ return;
142
+ }
143
+ if ("selector" in seg) {
144
+ const arr =
145
+ seg.key === ""
146
+ ? Array.isArray(obj)
147
+ ? obj
148
+ : undefined
149
+ : Array.isArray(obj)
150
+ ? obj
151
+ : ((obj as Record<string, unknown>)[seg.key] as unknown[]);
152
+ if (!Array.isArray(arr)) return;
153
+ const { prop, value: selVal } = seg.selector;
154
+ const numVal = /^\d+$/.test(selVal) ? parseInt(selVal, 10) : null;
155
+ const idx = arr.findIndex((item) => {
156
+ if (item === null || typeof item !== "object") return false;
157
+ const v = (item as Record<string, unknown>)[prop];
158
+ return String(v) === selVal || (numVal !== null && v === numVal);
159
+ });
160
+ if (idx < 0) return;
161
+ if (op === "remove") {
162
+ arr.splice(idx, 1);
163
+ return;
164
+ }
165
+ if (op === "after" && setValue !== undefined) {
166
+ arr.splice(idx + 1, 0, setValue);
167
+ return;
168
+ }
169
+ if (op === "before" && setValue !== undefined) {
170
+ arr.splice(idx, 0, setValue);
171
+ return;
172
+ }
173
+ return;
174
+ }
175
+ const key = seg.key;
176
+ const o = obj as Record<string, unknown>;
177
+ if (op === "remove") {
178
+ delete o[key];
179
+ return;
180
+ }
181
+ if (op === "append" && setValue !== undefined) {
182
+ const arr = (o[key] ?? []) as unknown[];
183
+ arr.push(setValue);
184
+ o[key] = arr;
185
+ return;
186
+ }
187
+ if (op === "prepend" && setValue !== undefined) {
188
+ const arr = (o[key] ?? []) as unknown[];
189
+ arr.unshift(setValue);
190
+ o[key] = arr;
191
+ return;
192
+ }
193
+ if (op === "set") {
194
+ o[key] = setValue as unknown;
195
+ }
196
+ }
197
+
198
+ export function mutate(
199
+ _schema: VariableSchema,
200
+ value: VariableValue,
201
+ notation: string,
202
+ setValue: VariableValue | undefined,
203
+ ): VariableValue {
204
+ if (value === null || value === undefined) return value;
205
+ const result = JSON.parse(JSON.stringify(value)) as VariableValue;
206
+
207
+ const { segments, operation } = parseNotation(notation);
208
+ if (segments.length === 0) {
209
+ if (Array.isArray(result) && setValue !== undefined) {
210
+ const arr = result as VariableValue[];
211
+ if (operation === "append") {
212
+ arr.push(setValue);
213
+ return result;
214
+ }
215
+ if (operation === "prepend") {
216
+ arr.unshift(setValue);
217
+ return result;
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+
223
+ const last = segments[segments.length - 1];
224
+ const parentSegments = segments.slice(0, -1);
225
+
226
+ let container: Record<string, unknown> | unknown[] = result as
227
+ | Record<string, unknown>
228
+ | unknown[];
229
+ for (const seg of parentSegments) {
230
+ const next = getAtSegment(container as VariableValue, seg);
231
+ if (next === undefined) return result;
232
+ container = next as Record<string, unknown> | unknown[];
233
+ }
234
+
235
+ setAtSegment(container, last, undefined, operation, setValue);
236
+ return result;
237
+ }
@@ -0,0 +1,31 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import { getProjectConfig, ENVIRONMENTS_DIRECTORY_NAME } from "./projectConfig";
6
+
7
+ function createTempProject(configBody: string) {
8
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-project-config-"));
9
+ fs.writeFileSync(path.join(tempRoot, "featurevisor.config.js"), configBody, "utf8");
10
+ return tempRoot;
11
+ }
12
+
13
+ describe("core: projectConfig", () => {
14
+ it("sets splitByEnvironment=false and environmentsDirectoryPath by default", () => {
15
+ const root = createTempProject("module.exports = {};");
16
+ const config = getProjectConfig(root);
17
+
18
+ expect(config.splitByEnvironment).toBe(false);
19
+ expect(config.environmentsDirectoryPath).toBe(path.join(root, ENVIRONMENTS_DIRECTORY_NAME));
20
+ });
21
+
22
+ it("throws when splitByEnvironment=true and environments=false", () => {
23
+ const root = createTempProject(
24
+ "module.exports = { splitByEnvironment: true, environments: false };",
25
+ );
26
+
27
+ expect(() => getProjectConfig(root)).toThrow(
28
+ "Invalid configuration: splitByEnvironment=true requires environments to be an array",
29
+ );
30
+ });
31
+ });
@@ -18,6 +18,7 @@ export const DATAFILES_DIRECTORY_NAME = "datafiles";
18
18
  export const DATAFILE_NAME_PATTERN = "featurevisor-%s.json";
19
19
  export const REVISION_FILE_NAME = "REVISION";
20
20
  export const SITE_EXPORT_DIRECTORY_NAME = "out";
21
+ export const ENVIRONMENTS_DIRECTORY_NAME = "environments";
21
22
 
22
23
  export const CONFIG_MODULE_NAME = "featurevisor.config.js";
23
24
  export const ROOT_DIR_PLACEHOLDER = "<rootDir>";
@@ -52,8 +53,10 @@ export interface ProjectConfig {
52
53
  datafileNamePattern: string;
53
54
  revisionFileName: string;
54
55
  siteExportDirectoryPath: string;
56
+ environmentsDirectoryPath: string;
55
57
 
56
58
  environments: string[] | false;
59
+ splitByEnvironment: boolean;
57
60
  tags: string[];
58
61
  scopes?: Scope[];
59
62
 
@@ -91,6 +94,7 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
91
94
  adapter: FilesystemAdapter,
92
95
 
93
96
  featuresDirectoryPath: path.join(rootDirectoryPath, FEATURES_DIRECTORY_NAME),
97
+ environmentsDirectoryPath: path.join(rootDirectoryPath, ENVIRONMENTS_DIRECTORY_NAME),
94
98
  segmentsDirectoryPath: path.join(rootDirectoryPath, SEGMENTS_DIRECTORY_NAME),
95
99
  attributesDirectoryPath: path.join(rootDirectoryPath, ATTRIBUTES_DIRECTORY_NAME),
96
100
  groupsDirectoryPath: path.join(rootDirectoryPath, GROUPS_DIRECTORY_NAME),
@@ -104,6 +108,7 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
104
108
 
105
109
  enforceCatchAllRule: false,
106
110
  plugins: [],
111
+ splitByEnvironment: false,
107
112
 
108
113
  maxVariableStringLength: undefined,
109
114
  maxVariableArrayStringifiedLength: undefined,
@@ -136,6 +141,12 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
136
141
  finalConfig.parser = parsers[finalConfig.parser];
137
142
  }
138
143
 
144
+ if (finalConfig.splitByEnvironment && finalConfig.environments === false) {
145
+ throw new Error(
146
+ "Invalid configuration: splitByEnvironment=true requires environments to be an array",
147
+ );
148
+ }
149
+
139
150
  return finalConfig as ProjectConfig;
140
151
  }
141
152
 
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
1
2
  import type {
2
3
  DatafileContent,
3
4
  EnvironmentKey,
@@ -17,6 +18,13 @@ export interface DatafileOptions {
17
18
  datafilesDir?: string;
18
19
  }
19
20
 
21
+ export type FeatureEnvironmentProperty = "rules" | "force" | "expose";
22
+
23
+ export interface FeatureSourcePaths {
24
+ baseFilePath: string;
25
+ environmentFilePaths: Record<string, string>;
26
+ }
27
+
20
28
  export abstract class Adapter {
21
29
  // entities
22
30
  abstract listEntities(entityType: EntityType): Promise<string[]>;
@@ -47,4 +55,17 @@ export abstract class Adapter {
47
55
  entityType?: EntityType,
48
56
  entityKey?: string,
49
57
  ): Promise<Commit>;
58
+
59
+ // feature source helpers (used by lint/history attribution)
60
+ async getFeatureSourcePaths(_featureKey: string): Promise<FeatureSourcePaths | undefined> {
61
+ return undefined;
62
+ }
63
+
64
+ async getFeaturePropertySourcePath(
65
+ _featureKey: string,
66
+ _property: FeatureEnvironmentProperty,
67
+ _environment?: string,
68
+ ): Promise<string | undefined> {
69
+ return undefined;
70
+ }
50
71
  }
@@ -18,7 +18,12 @@ import type {
18
18
  import { ProjectConfig } from "../config";
19
19
  import { CustomParser } from "../parsers";
20
20
 
21
- import { Adapter, DatafileOptions } from "./adapter";
21
+ import {
22
+ Adapter,
23
+ DatafileOptions,
24
+ FeatureEnvironmentProperty,
25
+ FeatureSourcePaths,
26
+ } from "./adapter";
22
27
 
23
28
  export class Datasource {
24
29
  private adapter: Adapter;
@@ -93,6 +98,18 @@ export class Datasource {
93
98
  return this.adapter.deleteEntity("feature", featureKey);
94
99
  }
95
100
 
101
+ getFeatureSourcePaths(featureKey: FeatureKey): Promise<FeatureSourcePaths | undefined> {
102
+ return this.adapter.getFeatureSourcePaths(featureKey);
103
+ }
104
+
105
+ getFeaturePropertySourcePath(
106
+ featureKey: FeatureKey,
107
+ property: FeatureEnvironmentProperty,
108
+ environment?: string,
109
+ ): Promise<string | undefined> {
110
+ return this.adapter.getFeaturePropertySourcePath(featureKey, property, environment);
111
+ }
112
+
96
113
  async getRequiredFeaturesChain(
97
114
  featureKey: FeatureKey,
98
115
  chain = new Set<FeatureKey>(),
@@ -0,0 +1,153 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import { getProjectConfig } from "../config";
6
+ import { Datasource } from "./datasource";
7
+
8
+ function writeFile(filePath: string, content: string) {
9
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
10
+ fs.writeFileSync(filePath, content, "utf8");
11
+ }
12
+
13
+ function createSplitProject(configBody: string) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-fs-adapter-"));
15
+ writeFile(path.join(root, "featurevisor.config.js"), configBody);
16
+
17
+ return root;
18
+ }
19
+
20
+ describe("core: filesystemAdapter (splitByEnvironment)", () => {
21
+ it("merges environment files into readFeature output", async () => {
22
+ const root = createSplitProject(
23
+ "module.exports = { environments: ['staging', 'production'], splitByEnvironment: true };",
24
+ );
25
+
26
+ writeFile(
27
+ path.join(root, "features", "checkout.yml"),
28
+ [
29
+ "key: checkout",
30
+ "description: Checkout feature",
31
+ "tags:",
32
+ " - all",
33
+ "bucketBy: userId",
34
+ ].join("\n"),
35
+ );
36
+
37
+ writeFile(
38
+ path.join(root, "environments", "staging", "checkout.yml"),
39
+ ["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
40
+ );
41
+
42
+ writeFile(
43
+ path.join(root, "environments", "production", "checkout.yml"),
44
+ [
45
+ "rules:",
46
+ " - key: everyone",
47
+ " segments: '*'",
48
+ " percentage: 0",
49
+ "expose: false",
50
+ ].join("\n"),
51
+ );
52
+
53
+ const config = getProjectConfig(root);
54
+ const datasource = new Datasource(config, root);
55
+ const feature = await datasource.readFeature("checkout");
56
+ const rulesByEnvironment = feature.rules as Record<string, any[]>;
57
+ const exposeByEnvironment = feature.expose as Record<string, unknown>;
58
+
59
+ expect(rulesByEnvironment?.staging?.[0]?.percentage).toBe(100);
60
+ expect(rulesByEnvironment?.production?.[0]?.percentage).toBe(0);
61
+ expect(exposeByEnvironment?.production).toBe(false);
62
+ });
63
+
64
+ it("throws when any environment feature file is missing", async () => {
65
+ const root = createSplitProject(
66
+ "module.exports = { environments: ['staging', 'production'], splitByEnvironment: true };",
67
+ );
68
+
69
+ writeFile(
70
+ path.join(root, "features", "myFeature.yml"),
71
+ ["key: myFeature", "description: My feature", "tags:", " - all", "bucketBy: userId"].join(
72
+ "\n",
73
+ ),
74
+ );
75
+
76
+ writeFile(
77
+ path.join(root, "environments", "staging", "myFeature.yml"),
78
+ ["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
79
+ );
80
+
81
+ const config = getProjectConfig(root);
82
+ const datasource = new Datasource(config, root);
83
+
84
+ await expect(datasource.readFeature("myFeature")).rejects.toMatchObject({
85
+ featurevisorFilePath: expect.stringContaining(
86
+ path.join("environments", "production", "myFeature.yml"),
87
+ ),
88
+ });
89
+ });
90
+
91
+ it("throws when base feature file still defines rules in split mode", async () => {
92
+ const root = createSplitProject(
93
+ "module.exports = { environments: ['staging'], splitByEnvironment: true };",
94
+ );
95
+
96
+ writeFile(
97
+ path.join(root, "features", "foo.yml"),
98
+ [
99
+ "key: foo",
100
+ "description: Foo",
101
+ "tags:",
102
+ " - all",
103
+ "bucketBy: userId",
104
+ "rules:",
105
+ " staging:",
106
+ " - key: everyone",
107
+ " segments: '*'",
108
+ " percentage: 100",
109
+ ].join("\n"),
110
+ );
111
+
112
+ writeFile(
113
+ path.join(root, "environments", "staging", "foo.yml"),
114
+ ["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
115
+ );
116
+
117
+ const config = getProjectConfig(root);
118
+ const datasource = new Datasource(config, root);
119
+
120
+ await expect(datasource.readFeature("foo")).rejects.toThrow(
121
+ "base file must not define rules, force, or expose when splitByEnvironment=true",
122
+ );
123
+ });
124
+
125
+ it("throws when environment file has unknown keys", async () => {
126
+ const root = createSplitProject(
127
+ "module.exports = { environments: ['staging'], splitByEnvironment: true };",
128
+ );
129
+
130
+ writeFile(
131
+ path.join(root, "features", "foo.yml"),
132
+ ["key: foo", "description: Foo", "tags:", " - all", "bucketBy: userId"].join("\n"),
133
+ );
134
+
135
+ writeFile(
136
+ path.join(root, "environments", "staging", "foo.yml"),
137
+ [
138
+ "rules:",
139
+ " - key: everyone",
140
+ " segments: '*'",
141
+ " percentage: 100",
142
+ "extraKey: not allowed",
143
+ ].join("\n"),
144
+ );
145
+
146
+ const config = getProjectConfig(root);
147
+ const datasource = new Datasource(config, root);
148
+
149
+ await expect(datasource.readFeature("foo")).rejects.toThrow(
150
+ "Unknown key(s) in environment feature file: extraKey",
151
+ );
152
+ });
153
+ });