@aspruyt/xfg 5.3.0 → 5.4.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.
@@ -132,6 +132,23 @@ export function resolveFileReferencesInConfig(raw, options) {
132
132
  }
133
133
  }
134
134
  }
135
+ // Resolve conditional group file content
136
+ if (result.conditionalGroups) {
137
+ for (const cg of result.conditionalGroups) {
138
+ if (cg.files) {
139
+ for (const [fileName, fileConfig] of Object.entries(cg.files)) {
140
+ if (fileConfig &&
141
+ typeof fileConfig === "object" &&
142
+ "content" in fileConfig) {
143
+ const resolved = resolveContentValue(fileConfig.content, configDir);
144
+ if (resolved !== undefined) {
145
+ cg.files[fileName] = { ...fileConfig, content: resolved };
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
135
152
  // Resolve per-repo file content
136
153
  if (result.repos) {
137
154
  for (const repo of result.repos) {
@@ -18,6 +18,10 @@ export declare function deepMerge(base: Record<string, unknown>, overlay: Record
18
18
  * Strip xfg merge directive keys ($arrayMerge, $values) from an object.
19
19
  * Works recursively on nested objects and arrays.
20
20
  * Standard $-prefixed keys ($schema, $id, $ref, etc.) are preserved.
21
+ *
22
+ * When an unresolved directive object is found (only contains $arrayMerge + $values),
23
+ * it is replaced with the $values array. This handles the case where a directive
24
+ * had no base array to merge with.
21
25
  */
22
26
  export declare function stripMergeDirectives(obj: Record<string, unknown>): Record<string, unknown>;
23
27
  export declare function createMergeContext(defaultStrategy?: ArrayMergeStrategy): MergeContext;
@@ -14,6 +14,20 @@ const arrayMergeStrategies = new Map([
14
14
  ["append", (base, overlay) => [...base, ...overlay]],
15
15
  ["prepend", (base, overlay) => [...overlay, ...base]],
16
16
  ]);
17
+ /**
18
+ * Checks if a value is an unresolved $arrayMerge directive object
19
+ * (only contains $arrayMerge + $values keys, with a valid strategy and array values).
20
+ */
21
+ function isUnresolvedDirective(value) {
22
+ if (!isPlainObject(value))
23
+ return false;
24
+ const keys = Object.keys(value);
25
+ return (keys.length === 2 &&
26
+ keys.every((k) => XFG_DIRECTIVES.has(k)) &&
27
+ typeof value.$arrayMerge === "string" &&
28
+ arrayMergeStrategies.has(value.$arrayMerge) &&
29
+ Array.isArray(value.$values));
30
+ }
17
31
  function mergeArrays(base, overlay, strategy) {
18
32
  const handler = arrayMergeStrategies.get(strategy);
19
33
  if (handler) {
@@ -36,6 +50,11 @@ export function deepMerge(base, overlay, ctx) {
36
50
  if (XFG_DIRECTIVES.has(key))
37
51
  continue;
38
52
  const baseValue = base[key];
53
+ // If base is an unresolved directive (from a previous layer with no base array),
54
+ // resolve it to its $values array before proceeding with merge logic.
55
+ const resolvedBase = isUnresolvedDirective(baseValue)
56
+ ? baseValue.$values
57
+ : baseValue;
39
58
  // Per-field $arrayMerge + $values directive
40
59
  if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
41
60
  const strategy = overlayValue.$arrayMerge;
@@ -44,19 +63,19 @@ export function deepMerge(base, overlay, ctx) {
44
63
  strategy === "append" ||
45
64
  strategy === "prepend") &&
46
65
  Array.isArray(values) &&
47
- Array.isArray(baseValue)) {
48
- result[key] = mergeArrays(baseValue, values, strategy);
66
+ Array.isArray(resolvedBase)) {
67
+ result[key] = mergeArrays(resolvedBase, values, strategy);
49
68
  continue;
50
69
  }
51
70
  }
52
71
  // Both are arrays — use default strategy
53
- if (Array.isArray(baseValue) && Array.isArray(overlayValue)) {
54
- result[key] = mergeArrays(baseValue, overlayValue, ctx.defaultArrayStrategy);
72
+ if (Array.isArray(resolvedBase) && Array.isArray(overlayValue)) {
73
+ result[key] = mergeArrays(resolvedBase, overlayValue, ctx.defaultArrayStrategy);
55
74
  continue;
56
75
  }
57
76
  // Both are plain objects — recurse
58
- if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
59
- result[key] = deepMerge(baseValue, overlayValue, ctx);
77
+ if (isPlainObject(resolvedBase) && isPlainObject(overlayValue)) {
78
+ result[key] = deepMerge(resolvedBase, overlayValue, ctx);
60
79
  continue;
61
80
  }
62
81
  // Otherwise, overlay wins (including null values)
@@ -68,6 +87,10 @@ export function deepMerge(base, overlay, ctx) {
68
87
  * Strip xfg merge directive keys ($arrayMerge, $values) from an object.
69
88
  * Works recursively on nested objects and arrays.
70
89
  * Standard $-prefixed keys ($schema, $id, $ref, etc.) are preserved.
90
+ *
91
+ * When an unresolved directive object is found (only contains $arrayMerge + $values),
92
+ * it is replaced with the $values array. This handles the case where a directive
93
+ * had no base array to merge with.
71
94
  */
72
95
  export function stripMergeDirectives(obj) {
73
96
  const result = {};
@@ -76,7 +99,13 @@ export function stripMergeDirectives(obj) {
76
99
  if (XFG_DIRECTIVES.has(key))
77
100
  continue;
78
101
  if (isPlainObject(value)) {
79
- result[key] = stripMergeDirectives(value);
102
+ if (isUnresolvedDirective(value)) {
103
+ // Resolve to the $values array, stripping directives from items
104
+ result[key] = value.$values.map((item) => isPlainObject(item) ? stripMergeDirectives(item) : item);
105
+ }
106
+ else {
107
+ result[key] = stripMergeDirectives(value);
108
+ }
80
109
  }
81
110
  else if (Array.isArray(value)) {
82
111
  result[key] = value.map((item) => isPlainObject(item) ? stripMergeDirectives(item) : item);
@@ -170,7 +170,8 @@ export function mergeSettings(root, perRepo) {
170
170
  if (!inheritRulesets && !repoRuleset && rootRuleset) {
171
171
  continue;
172
172
  }
173
- result.rulesets[name] = mergeRuleset(rootRuleset, repoRuleset);
173
+ const merged = mergeRuleset(rootRuleset, repoRuleset);
174
+ result.rulesets[name] = stripMergeDirectives(merged);
174
175
  }
175
176
  // Clean up empty rulesets object
176
177
  if (Object.keys(result.rulesets).length === 0) {
@@ -1,4 +1,5 @@
1
1
  import { ValidationError } from "../../shared/errors.js";
2
+ import { isPlainObject } from "../../shared/type-guards.js";
2
3
  /** Compile-time validates an array matches a type union, while keeping string[] runtime type for .includes() */
3
4
  function validValues(values) {
4
5
  return values;
@@ -62,6 +63,31 @@ const VALID_RULE_TYPES = validValues([
62
63
  "max_file_path_length",
63
64
  "max_file_size",
64
65
  ]);
66
+ // Intentionally duplicated from merge.ts — validator should not depend on merge internals
67
+ const VALID_MERGE_STRATEGIES = ["replace", "append", "prepend"];
68
+ /**
69
+ * Checks if a value is an $arrayMerge directive: { $arrayMerge: strategy, $values: [...] }
70
+ */
71
+ function isArrayMergeDirective(value) {
72
+ if (!isPlainObject(value))
73
+ return false;
74
+ const keys = Object.keys(value);
75
+ return (keys.length === 2 &&
76
+ keys.every((k) => k === "$arrayMerge" || k === "$values") &&
77
+ VALID_MERGE_STRATEGIES.includes(value.$arrayMerge) &&
78
+ Array.isArray(value.$values));
79
+ }
80
+ /**
81
+ * Extracts the $values array from a directive, or returns the value as-is if it's already an array.
82
+ * Returns null if value is neither an array nor a valid directive.
83
+ */
84
+ function extractArrayOrDirectiveValues(value) {
85
+ if (Array.isArray(value))
86
+ return value;
87
+ if (isArrayMergeDirective(value))
88
+ return value.$values;
89
+ return null;
90
+ }
65
91
  /**
66
92
  * Validates a single ruleset rule.
67
93
  */
@@ -158,11 +184,12 @@ export function validateRuleset(ruleset, name, context) {
158
184
  }
159
185
  // Validate bypassActors
160
186
  if (rs.bypassActors !== undefined) {
161
- if (!Array.isArray(rs.bypassActors)) {
162
- throw new ValidationError(`${context}: ruleset '${name}' bypassActors must be an array`);
187
+ const actors = extractArrayOrDirectiveValues(rs.bypassActors);
188
+ if (actors === null) {
189
+ throw new ValidationError(`${context}: ruleset '${name}' bypassActors must be an array or $arrayMerge directive`);
163
190
  }
164
- for (let i = 0; i < rs.bypassActors.length; i++) {
165
- const actor = rs.bypassActors[i];
191
+ for (let i = 0; i < actors.length; i++) {
192
+ const actor = actors[i];
166
193
  if (typeof actor !== "object" || actor === null) {
167
194
  throw new ValidationError(`${context}: ruleset '${name}' bypassActors[${i}] must be an object`);
168
195
  }
@@ -193,25 +220,28 @@ export function validateRuleset(ruleset, name, context) {
193
220
  Array.isArray(refName)) {
194
221
  throw new ValidationError(`${context}: ruleset '${name}' conditions.refName must be an object`);
195
222
  }
196
- if (refName.include !== undefined &&
197
- (!Array.isArray(refName.include) ||
198
- !refName.include.every((s) => typeof s === "string"))) {
199
- throw new ValidationError(`${context}: ruleset '${name}' conditions.refName.include must be an array of strings`);
223
+ if (refName.include !== undefined) {
224
+ const include = extractArrayOrDirectiveValues(refName.include);
225
+ if (include === null || !include.every((s) => typeof s === "string")) {
226
+ throw new ValidationError(`${context}: ruleset '${name}' conditions.refName.include must be an array of strings or $arrayMerge directive with string $values`);
227
+ }
200
228
  }
201
- if (refName.exclude !== undefined &&
202
- (!Array.isArray(refName.exclude) ||
203
- !refName.exclude.every((s) => typeof s === "string"))) {
204
- throw new ValidationError(`${context}: ruleset '${name}' conditions.refName.exclude must be an array of strings`);
229
+ if (refName.exclude !== undefined) {
230
+ const exclude = extractArrayOrDirectiveValues(refName.exclude);
231
+ if (exclude === null || !exclude.every((s) => typeof s === "string")) {
232
+ throw new ValidationError(`${context}: ruleset '${name}' conditions.refName.exclude must be an array of strings or $arrayMerge directive with string $values`);
233
+ }
205
234
  }
206
235
  }
207
236
  }
208
237
  // Validate rules array
209
238
  if (rs.rules !== undefined) {
210
- if (!Array.isArray(rs.rules)) {
211
- throw new ValidationError(`${context}: ruleset '${name}' rules must be an array`);
239
+ const rules = extractArrayOrDirectiveValues(rs.rules);
240
+ if (rules === null) {
241
+ throw new ValidationError(`${context}: ruleset '${name}' rules must be an array or $arrayMerge directive`);
212
242
  }
213
- for (let i = 0; i < rs.rules.length; i++) {
214
- validateRule(rs.rules[i], `${context}: ruleset '${name}' rules[${i}]`);
243
+ for (let i = 0; i < rules.length; i++) {
244
+ validateRule(rules[i], `${context}: ruleset '${name}' rules[${i}]`);
215
245
  }
216
246
  }
217
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab — declaratively, from a single YAML config",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",