@aspruyt/xfg 5.2.0 → 5.3.1

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.
@@ -44,7 +44,7 @@ export interface SyncResultEntry {
44
44
  mergeOutcome?: "manual" | "auto" | "force" | "direct";
45
45
  error?: string;
46
46
  }
47
- export interface SettingsResult extends Pick<BaseProcessorResult, "success" | "message" | "skipped"> {
47
+ export interface SettingsResult extends BaseProcessorResult {
48
48
  planOutput?: {
49
49
  lines?: string[];
50
50
  };
@@ -0,0 +1,13 @@
1
+ import type { RawGroupConfig } from "./types.js";
2
+ /**
3
+ * Resolves a single group's extends chain into an ordered list of group names.
4
+ * Parents appear before children (topological order). Detects circular extends
5
+ * and missing group references.
6
+ */
7
+ export declare function resolveExtendsChain(groupName: string, groupDefs: Record<string, RawGroupConfig>): string[];
8
+ /**
9
+ * Expands a repo's group list by resolving extends chains for each group.
10
+ * Returns the full ordered list with transitive parents, deduplicated.
11
+ * First occurrence wins for deduplication (preserves topological order).
12
+ */
13
+ export declare function expandRepoGroups(repoGroups: string[], groupDefs: Record<string, RawGroupConfig>): string[];
@@ -0,0 +1,63 @@
1
+ const MAX_EXTENDS_DEPTH = 100;
2
+ /**
3
+ * Resolves a single group's extends chain into an ordered list of group names.
4
+ * Parents appear before children (topological order). Detects circular extends
5
+ * and missing group references.
6
+ */
7
+ export function resolveExtendsChain(groupName, groupDefs) {
8
+ function walk(name, visited, depth) {
9
+ if (depth > MAX_EXTENDS_DEPTH) {
10
+ throw new Error(`Extends chain exceeds maximum depth of ${MAX_EXTENDS_DEPTH} — likely misconfigured`);
11
+ }
12
+ if (visited.has(name)) {
13
+ const cycle = [...visited, name].join(" -> ");
14
+ throw new Error(`Circular extends detected: ${cycle}`);
15
+ }
16
+ visited.add(name);
17
+ const group = groupDefs[name];
18
+ if (!group) {
19
+ throw new Error(`Group '${name}' referenced in extends chain does not exist`);
20
+ }
21
+ if (!group.extends) {
22
+ return [name];
23
+ }
24
+ const parents = Array.isArray(group.extends)
25
+ ? group.extends
26
+ : [group.extends];
27
+ const result = [];
28
+ const seen = new Set();
29
+ for (const parent of parents) {
30
+ const chain = walk(parent, new Set(visited), depth + 1);
31
+ for (const n of chain) {
32
+ if (!seen.has(n)) {
33
+ seen.add(n);
34
+ result.push(n);
35
+ }
36
+ }
37
+ }
38
+ if (!seen.has(name)) {
39
+ result.push(name);
40
+ }
41
+ return result;
42
+ }
43
+ return walk(groupName, new Set(), 0);
44
+ }
45
+ /**
46
+ * Expands a repo's group list by resolving extends chains for each group.
47
+ * Returns the full ordered list with transitive parents, deduplicated.
48
+ * First occurrence wins for deduplication (preserves topological order).
49
+ */
50
+ export function expandRepoGroups(repoGroups, groupDefs) {
51
+ const result = [];
52
+ const seen = new Set();
53
+ for (const groupName of repoGroups) {
54
+ const chain = resolveExtendsChain(groupName, groupDefs);
55
+ for (const name of chain) {
56
+ if (!seen.has(name)) {
57
+ seen.add(name);
58
+ result.push(name);
59
+ }
60
+ }
61
+ }
62
+ return result;
63
+ }
@@ -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) {
@@ -1,4 +1,4 @@
1
- export type { MergeMode, MergeStrategy, BypassActor, StatusCheckConfig, CodeScanningTool, PullRequestRuleParameters, RulesetRule, Ruleset, GitHubRepoSettings, RepoVisibility, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, Label, RepoSettings, RawFileConfig, RawRepoFileOverride, RawRepoSettings, RawRepoConfig, RawConfig, RawConditionalGroupWhen, RawConditionalGroupConfig, RepoConfig, Config, FileContent, ContentValue, } from "./types.js";
1
+ export type { MergeMode, MergeStrategy, BypassActor, StatusCheckConfig, CodeScanningTool, PullRequestRuleParameters, RulesetRule, Ruleset, GitHubRepoSettings, RepoVisibility, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, Label, RepoSettings, RawFileConfig, RawRepoFileOverride, RawGroupConfig, RawRepoSettings, RawRepoConfig, RawConfig, RawConditionalGroupWhen, RawConditionalGroupConfig, RepoConfig, Config, FileContent, ContentValue, } from "./types.js";
2
2
  export { RULESET_COMPARABLE_FIELDS } from "./types.js";
3
3
  export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
4
4
  export { convertContentToString } from "./formatter.js";
@@ -1,5 +1,6 @@
1
1
  import { deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
2
2
  import { interpolateContent } from "../shared/env.js";
3
+ import { expandRepoGroups } from "./extends-resolver.js";
3
4
  /**
4
5
  * Clone content, stripping merge directives from object content.
5
6
  * Text content is cloned as-is since it has no merge directives.
@@ -203,6 +204,47 @@ export function mergeSettings(root, perRepo) {
203
204
  }
204
205
  return Object.keys(result).length > 0 ? result : undefined;
205
206
  }
207
+ /**
208
+ * Applies a single file-layer onto an accumulated file map: inherit:false clears,
209
+ * file:false removes entries, otherwise deep-merges content.
210
+ */
211
+ function applyFileLayer(accumulated, layerFiles) {
212
+ const inheritFiles = shouldInherit(layerFiles);
213
+ if (!inheritFiles) {
214
+ accumulated = {};
215
+ }
216
+ for (const [fileName, fileConfig] of Object.entries(layerFiles)) {
217
+ if (fileName === "inherit")
218
+ continue;
219
+ if (fileConfig === false) {
220
+ delete accumulated[fileName];
221
+ continue;
222
+ }
223
+ if (fileConfig === undefined)
224
+ continue;
225
+ const existing = accumulated[fileName];
226
+ if (existing) {
227
+ const overlay = fileConfig;
228
+ let mergedContent;
229
+ if (overlay.override || !existing.content || !overlay.content) {
230
+ mergedContent = overlay.content ?? existing.content;
231
+ }
232
+ else {
233
+ mergedContent = mergeContentPair(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
234
+ }
235
+ const { override: _override, ...restFileConfig } = fileConfig;
236
+ accumulated[fileName] = {
237
+ ...existing,
238
+ ...restFileConfig,
239
+ content: mergedContent,
240
+ };
241
+ }
242
+ else {
243
+ accumulated[fileName] = structuredClone(fileConfig);
244
+ }
245
+ }
246
+ return accumulated;
247
+ }
206
248
  /**
207
249
  * Merges group file layers onto root files, producing an effective root file map.
208
250
  * Each group layer is processed in order: inherit:false clears accumulated,
@@ -214,45 +256,7 @@ function mergeGroupFiles(rootFiles, groupNames, groupDefs) {
214
256
  const group = groupDefs[groupName];
215
257
  if (!group?.files)
216
258
  continue;
217
- const inheritFiles = shouldInherit(group.files);
218
- if (!inheritFiles) {
219
- // Intentionally clear: "discard everything above me"
220
- accumulated = {};
221
- }
222
- for (const [fileName, fileConfig] of Object.entries(group.files)) {
223
- if (fileName === "inherit")
224
- continue;
225
- // file: false removes from accumulated set
226
- if (fileConfig === false) {
227
- delete accumulated[fileName];
228
- continue;
229
- }
230
- if (fileConfig === undefined)
231
- continue;
232
- const existing = accumulated[fileName];
233
- if (existing) {
234
- // Deep-merge content if both sides have object content
235
- const overlay = fileConfig;
236
- let mergedContent;
237
- if (overlay.override || !existing.content || !overlay.content) {
238
- // override:true or one side missing content — use overlay content
239
- mergedContent = overlay.content ?? existing.content;
240
- }
241
- else {
242
- mergedContent = mergeContentPair(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
243
- }
244
- const { override: _override, ...restFileConfig } = fileConfig;
245
- accumulated[fileName] = {
246
- ...existing,
247
- ...restFileConfig,
248
- content: mergedContent,
249
- };
250
- }
251
- else {
252
- // New file introduced by group
253
- accumulated[fileName] = structuredClone(fileConfig);
254
- }
255
- }
259
+ accumulated = applyFileLayer(accumulated, group.files);
256
260
  }
257
261
  return accumulated;
258
262
  }
@@ -385,42 +389,8 @@ function mergeConditionalGroups(accumulatedFiles, accumulatedPROptions, accumula
385
389
  for (const cg of conditionalGroups) {
386
390
  if (!evaluateWhenClause(cg.when, effectiveGroups))
387
391
  continue;
388
- // Merge files using same logic as mergeGroupFiles inner loop
389
392
  if (cg.files) {
390
- const inheritFiles = shouldInherit(cg.files);
391
- if (!inheritFiles) {
392
- files = {};
393
- }
394
- for (const [fileName, fileConfig] of Object.entries(cg.files)) {
395
- if (fileName === "inherit")
396
- continue;
397
- if (fileConfig === false) {
398
- delete files[fileName];
399
- continue;
400
- }
401
- if (fileConfig === undefined)
402
- continue;
403
- const existing = files[fileName];
404
- if (existing) {
405
- const overlay = fileConfig;
406
- let mergedContent;
407
- if (overlay.override || !existing.content || !overlay.content) {
408
- mergedContent = overlay.content ?? existing.content;
409
- }
410
- else {
411
- mergedContent = mergeContentPair(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
412
- }
413
- const { override: _override, ...restFileConfig } = fileConfig;
414
- files[fileName] = {
415
- ...existing,
416
- ...restFileConfig,
417
- content: mergedContent,
418
- };
419
- }
420
- else {
421
- files[fileName] = structuredClone(fileConfig);
422
- }
423
- }
393
+ files = applyFileLayer(files, cg.files);
424
394
  }
425
395
  // Merge prOptions
426
396
  if (cg.prOptions) {
@@ -471,19 +441,23 @@ export function normalizeConfig(raw, env) {
471
441
  const expandedRepos = [];
472
442
  for (const rawRepo of raw.repos) {
473
443
  const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
444
+ // Phase 0: Expand extends chains
445
+ const expandedGroups = rawRepo.groups?.length
446
+ ? expandRepoGroups(rawRepo.groups, raw.groups ?? {})
447
+ : [];
474
448
  // Phase 1: Resolve groups - build effective root files/prOptions/settings by merging group layers
475
- let effectiveRootFiles = rawRepo.groups?.length
476
- ? mergeGroupFiles(raw.files ?? {}, rawRepo.groups, raw.groups ?? {})
449
+ let effectiveRootFiles = expandedGroups.length
450
+ ? mergeGroupFiles(raw.files ?? {}, expandedGroups, raw.groups ?? {})
477
451
  : (raw.files ?? {});
478
- let effectivePROptions = rawRepo.groups?.length
479
- ? mergeGroupPROptions(raw.prOptions, rawRepo.groups, raw.groups ?? {})
452
+ let effectivePROptions = expandedGroups.length
453
+ ? mergeGroupPROptions(raw.prOptions, expandedGroups, raw.groups ?? {})
480
454
  : raw.prOptions;
481
- let effectiveSettings = rawRepo.groups?.length
482
- ? mergeGroupSettings(raw.settings, rawRepo.groups, raw.groups ?? {})
455
+ let effectiveSettings = expandedGroups.length
456
+ ? mergeGroupSettings(raw.settings, expandedGroups, raw.groups ?? {})
483
457
  : raw.settings;
484
458
  // Phase 2 + 3: Evaluate and merge conditional groups
485
459
  if (raw.conditionalGroups?.length) {
486
- const effectiveGroups = new Set(rawRepo.groups ?? []);
460
+ const effectiveGroups = new Set(expandedGroups);
487
461
  const merged = mergeConditionalGroups(effectiveRootFiles, effectivePROptions, effectiveSettings, effectiveGroups, raw.conditionalGroups);
488
462
  effectiveRootFiles = merged.files;
489
463
  effectivePROptions = merged.prOptions;
@@ -292,6 +292,7 @@ export interface RawRepoFileOverride {
292
292
  deleteOrphaned?: boolean;
293
293
  }
294
294
  export interface RawGroupConfig {
295
+ extends?: string | string[];
295
296
  files?: Record<string, RawFileConfig | RawRepoFileOverride | false> & {
296
297
  inherit?: boolean;
297
298
  };
@@ -1,3 +1,4 @@
1
+ import { resolveExtendsChain, expandRepoGroups } from "./extends-resolver.js";
1
2
  import { isTextContent, isObjectContent, isStructuredFileExtension, validateFileName, VALID_STRATEGIES, } from "./validators/file-validator.js";
2
3
  import { validateRepoSettings } from "./validators/repo-settings-validator.js";
3
4
  import { validateRuleset } from "./validators/ruleset-validator.js";
@@ -259,6 +260,71 @@ function validatePrOptions(config) {
259
260
  }
260
261
  }
261
262
  }
263
+ /**
264
+ * Validates the extends field on a single group definition.
265
+ * Checks type, self-reference, and that all referenced groups exist.
266
+ */
267
+ function validateGroupExtends(groupName, extends_, groupNames) {
268
+ // Type check
269
+ if (typeof extends_ === "string") {
270
+ if (extends_.length === 0) {
271
+ throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
272
+ }
273
+ // Self-reference
274
+ if (extends_ === groupName) {
275
+ throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
276
+ }
277
+ // Existence
278
+ if (!groupNames.has(extends_)) {
279
+ throw new ValidationError(`groups.${groupName}: extends references undefined group '${extends_}'`);
280
+ }
281
+ }
282
+ else if (Array.isArray(extends_)) {
283
+ if (extends_.length === 0) {
284
+ throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
285
+ }
286
+ const seen = new Set();
287
+ for (const entry of extends_) {
288
+ if (typeof entry !== "string") {
289
+ throw new ValidationError(`groups.${groupName}: 'extends' array entries must be strings`);
290
+ }
291
+ if (entry.length === 0) {
292
+ throw new ValidationError(`groups.${groupName}: 'extends' array entries must be non-empty strings`);
293
+ }
294
+ if (entry === groupName) {
295
+ throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
296
+ }
297
+ if (!groupNames.has(entry)) {
298
+ throw new ValidationError(`groups.${groupName}: extends references undefined group '${entry}'`);
299
+ }
300
+ if (seen.has(entry)) {
301
+ throw new ValidationError(`groups.${groupName}: duplicate '${entry}' in extends`);
302
+ }
303
+ seen.add(entry);
304
+ }
305
+ }
306
+ else {
307
+ throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
308
+ }
309
+ }
310
+ /**
311
+ * Detects circular extends chains across all groups.
312
+ * Reuses resolveExtendsChain from extends-resolver.ts to avoid
313
+ * duplicating the chain-walking logic. Converts thrown errors
314
+ * to ValidationError.
315
+ */
316
+ function validateNoCircularExtends(groups) {
317
+ for (const name of Object.keys(groups)) {
318
+ if (!groups[name].extends)
319
+ continue;
320
+ try {
321
+ resolveExtendsChain(name, groups);
322
+ }
323
+ catch (error) {
324
+ throw new ValidationError(error instanceof Error ? error.message : String(error));
325
+ }
326
+ }
327
+ }
262
328
  function validateGroups(config) {
263
329
  if (config.groups === undefined)
264
330
  return;
@@ -266,10 +332,18 @@ function validateGroups(config) {
266
332
  throw new ValidationError("groups must be an object");
267
333
  }
268
334
  const rootCtx = buildRootSettingsContext(config);
335
+ const groupNames = new Set(Object.keys(config.groups));
269
336
  for (const [groupName, group] of Object.entries(config.groups)) {
270
337
  if (groupName === "inherit") {
271
338
  throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
272
339
  }
340
+ if (groupName === "extends") {
341
+ throw new ValidationError("'extends' is a reserved key and cannot be used as a group name");
342
+ }
343
+ // Validate extends field
344
+ if (group.extends !== undefined) {
345
+ validateGroupExtends(groupName, group.extends, groupNames);
346
+ }
273
347
  if (group.files) {
274
348
  for (const [fileName, fileConfig] of Object.entries(group.files)) {
275
349
  if (fileName === "inherit")
@@ -285,6 +359,8 @@ function validateGroups(config) {
285
359
  validateSettings(group.settings, `groups.${groupName}`, rootCtx);
286
360
  }
287
361
  }
362
+ // Validate no circular extends after individual validation
363
+ validateNoCircularExtends(config.groups);
288
364
  }
289
365
  function validateConditionalGroups(config) {
290
366
  if (config.conditionalGroups === undefined)
@@ -422,7 +498,8 @@ function validateRepoFiles(config, repo, index, repoLabel) {
422
498
  }
423
499
  const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
424
500
  if (repo.groups && config.groups) {
425
- for (const groupName of repo.groups) {
501
+ const expandedGroups = expandRepoGroups(repo.groups, config.groups);
502
+ for (const groupName of expandedGroups) {
426
503
  const group = config.groups[groupName];
427
504
  if (group?.files) {
428
505
  for (const fn of Object.keys(group.files)) {
@@ -469,7 +546,8 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
469
546
  return;
470
547
  const rootCtx = buildRootSettingsContext(config);
471
548
  if (repo.groups && config.groups) {
472
- for (const groupName of repo.groups) {
549
+ const expandedGroups = expandRepoGroups(repo.groups, config.groups);
550
+ for (const groupName of expandedGroups) {
473
551
  const group = config.groups[groupName];
474
552
  if (group?.settings?.rulesets) {
475
553
  for (const name of Object.keys(group.settings.rulesets)) {
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { writeGitHubStepSummary } from "./github-summary.js";
3
+ import { formatScalarValue } from "../shared/string-utils.js";
3
4
  /**
4
5
  * Shared recursive renderer for ruleset config objects.
5
6
  * The formatLine callback controls indentation style and coloring:
@@ -127,14 +128,9 @@ export function formatSettingsReportCLI(report) {
127
128
  return lines;
128
129
  }
129
130
  function formatValuePlain(val) {
130
- if (val === null)
131
- return "null";
132
- if (val === undefined)
133
- return "undefined";
134
- if (typeof val === "string")
135
- return `"${val}"`;
136
- if (typeof val === "boolean")
137
- return val ? "true" : "false";
131
+ const scalar = formatScalarValue(val);
132
+ if (scalar !== undefined)
133
+ return scalar;
138
134
  if (typeof val === "object")
139
135
  return JSON.stringify(val);
140
136
  return String(val);
@@ -1,40 +1,46 @@
1
1
  import { isGitHubRepo, getRepoDisplayName } from "../shared/repo-detector.js";
2
2
  import { toErrorMessage } from "../shared/type-guards.js";
3
+ /**
4
+ * Build a base result that satisfies TResult for guard early-returns.
5
+ * All TResult subtypes only extend BaseProcessorResult with optional fields,
6
+ * so a base-only object is structurally valid. If adding a new processor
7
+ * result type, ensure all extension fields are optional.
8
+ */
9
+ function baseResult(result) {
10
+ return result;
11
+ }
3
12
  /**
4
13
  * Common boilerplate for GitHub settings processors: GitHub-only gating,
5
14
  * empty settings check, token resolution, and error wrapping.
6
15
  */
7
16
  export async function withGitHubGuards(repoConfig, repoInfo, options, guards) {
8
17
  const repoName = getRepoDisplayName(repoInfo);
9
- // Safe cast: all TResult subtypes (RulesetProcessorResult, LabelsProcessorResult,
10
- // RepoSettingsProcessorResult) only extend BaseProcessorResult with optional fields.
11
- // If adding a new processor result type, ensure all extension fields are optional.
12
18
  if (!isGitHubRepo(repoInfo)) {
13
- return {
19
+ return baseResult({
14
20
  success: true,
15
21
  repoName,
16
22
  message: `Skipped: ${repoName} is not a GitHub repository`,
17
23
  skipped: true,
18
- };
24
+ });
19
25
  }
20
26
  if (!guards.hasDesiredSettings(repoConfig)) {
21
- return {
27
+ return baseResult({
22
28
  success: true,
23
29
  repoName,
24
30
  message: guards.emptySettingsMessage,
25
31
  skipped: true,
26
- };
32
+ });
27
33
  }
28
34
  try {
29
35
  return await guards.applySettings(repoInfo, repoConfig, options, options.token, repoName);
30
36
  }
31
37
  catch (error) {
32
38
  const message = toErrorMessage(error);
33
- return {
39
+ return baseResult({
34
40
  success: false,
35
41
  repoName,
36
42
  message: `Failed: ${message}`,
37
- };
43
+ });
38
44
  }
39
45
  }
40
46
  /** Type predicate that narrows entries with an active (non-"unchanged") action. */
@@ -29,6 +29,6 @@ export interface LabelChange {
29
29
  * @param deleteOrphaned - If true, delete current labels not in desired config
30
30
  * @param noDelete - If true, skip delete operations
31
31
  * @returns Array of changes to apply
32
- * @throws Error if rename collisions are detected
32
+ * @throws ValidationError if rename collisions are detected
33
33
  */
34
34
  export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>, deleteOrphaned: boolean, noDelete: boolean): LabelChange[];
@@ -15,7 +15,7 @@ import { ValidationError } from "../../shared/errors.js";
15
15
  * @param deleteOrphaned - If true, delete current labels not in desired config
16
16
  * @param noDelete - If true, skip delete operations
17
17
  * @returns Array of changes to apply
18
- * @throws Error if rename collisions are detected
18
+ * @throws ValidationError if rename collisions are detected
19
19
  */
20
20
  export function diffLabels(current, desired, deleteOrphaned, noDelete) {
21
21
  const changes = [];
@@ -1,17 +1,10 @@
1
1
  import chalk from "chalk";
2
+ import { formatScalarValue } from "../../shared/string-utils.js";
2
3
  /**
3
4
  * Format a value for display.
4
5
  */
5
6
  function formatValue(val) {
6
- if (val === null)
7
- return "null";
8
- if (val === undefined)
9
- return "undefined";
10
- if (typeof val === "string")
11
- return `"${val}"`;
12
- if (typeof val === "boolean")
13
- return val ? "true" : "false";
14
- return String(val);
7
+ return formatScalarValue(val) ?? String(val);
15
8
  }
16
9
  /**
17
10
  * Get warning message for a property change.
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { projectToDesiredShape, normalizeRuleset, } from "./diff.js";
3
+ import { formatScalarValue } from "../../shared/string-utils.js";
3
4
  import { computePropertyDiffs, } from "./diff-algorithm.js";
4
5
  import { isPlainObject } from "../../shared/type-guards.js";
5
6
  /**
@@ -39,12 +40,9 @@ function buildTree(diffs) {
39
40
  * Format a value for inline display (scalars and simple arrays only).
40
41
  */
41
42
  function formatValue(val) {
42
- if (val === null)
43
- return "null";
44
- if (val === undefined)
45
- return "undefined";
46
- if (typeof val === "string")
47
- return `"${val}"`;
43
+ const scalar = formatScalarValue(val);
44
+ if (scalar !== undefined)
45
+ return scalar;
48
46
  if (Array.isArray(val)) {
49
47
  if (val.every((v) => typeof v !== "object" || v === null)) {
50
48
  return `[${val.map(formatValue).join(", ")}]`;
@@ -224,10 +222,10 @@ export function formatRulesetPlan(changes) {
224
222
  for (const c of changes) {
225
223
  grouped[c.action].push(c);
226
224
  }
225
+ if (grouped.create.length > 0) {
226
+ lines.push(chalk.bold(" Create:"));
227
+ }
227
228
  for (const change of grouped.create) {
228
- if (grouped.create.indexOf(change) === 0) {
229
- lines.push(chalk.bold(" Create:"));
230
- }
231
229
  lines.push(chalk.green(` + ruleset "${change.name}"`));
232
230
  if (change.desired) {
233
231
  lines.push(...formatFullConfig(change.desired, 2));
@@ -243,10 +241,10 @@ export function formatRulesetPlan(changes) {
243
241
  });
244
242
  lines.push("");
245
243
  }
244
+ if (grouped.update.length > 0) {
245
+ lines.push(chalk.bold(" Update:"));
246
+ }
246
247
  for (const change of grouped.update) {
247
- if (grouped.update.indexOf(change) === 0) {
248
- lines.push(chalk.bold(" Update:"));
249
- }
250
248
  lines.push(chalk.yellow(` ~ ruleset "${change.name}"`));
251
249
  if (change.current && change.desired) {
252
250
  const currentNorm = normalizeRuleset(change.current);
@@ -1,6 +1,6 @@
1
1
  export declare function sanitizeBranchName(fileName: string): string;
2
2
  /**
3
3
  * Validates a user-provided branch name against git's naming rules.
4
- * @throws Error if the branch name is invalid
4
+ * @throws ValidationError if the branch name is invalid
5
5
  */
6
6
  export declare function validateBranchName(branchName: string): void;
@@ -9,7 +9,7 @@ export function sanitizeBranchName(fileName) {
9
9
  }
10
10
  /**
11
11
  * Validates a user-provided branch name against git's naming rules.
12
- * @throws Error if the branch name is invalid
12
+ * @throws ValidationError if the branch name is invalid
13
13
  */
14
14
  export function validateBranchName(branchName) {
15
15
  if (!branchName || branchName.trim() === "") {
@@ -110,7 +110,7 @@ export class GhApiClient {
110
110
  this.retries = retries;
111
111
  this.cwd = cwd;
112
112
  }
113
- async call(method, endpoint, params) {
113
+ call(method, endpoint, params) {
114
114
  return ghApiCall(method, endpoint, {
115
115
  executor: this.executor,
116
116
  retries: this.retries,
@@ -2,3 +2,9 @@
2
2
  * Convert a camelCase string to snake_case.
3
3
  */
4
4
  export declare function camelToSnake(str: string): string;
5
+ /**
6
+ * Format a scalar value for display: null, undefined, string, boolean.
7
+ * Returns undefined for non-scalar types (arrays, objects) so callers
8
+ * can apply domain-specific formatting.
9
+ */
10
+ export declare function formatScalarValue(val: unknown): string | undefined;
@@ -4,3 +4,19 @@
4
4
  export function camelToSnake(str) {
5
5
  return str.replace(/([A-Z])/g, "_$1").toLowerCase();
6
6
  }
7
+ /**
8
+ * Format a scalar value for display: null, undefined, string, boolean.
9
+ * Returns undefined for non-scalar types (arrays, objects) so callers
10
+ * can apply domain-specific formatting.
11
+ */
12
+ export function formatScalarValue(val) {
13
+ if (val === null)
14
+ return "null";
15
+ if (val === undefined)
16
+ return "undefined";
17
+ if (typeof val === "string")
18
+ return `"${val}"`;
19
+ if (typeof val === "boolean")
20
+ return val ? "true" : "false";
21
+ return undefined;
22
+ }
@@ -1,5 +1,5 @@
1
1
  import { RepoInfo } from "../shared/repo-detector.js";
2
- import { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
2
+ import type { GitHubAppTokenManager } from "../vcs/index.js";
3
3
  import type { AuthResult, IAuthOptionsBuilder } from "./types.js";
4
4
  import type { ILogger } from "../shared/logger.js";
5
5
  export declare class AuthOptionsBuilder implements IAuthOptionsBuilder {
@@ -1,7 +1,7 @@
1
1
  import type { RepoConfig } from "../config/index.js";
2
2
  import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import type { ILogger } from "../shared/logger.js";
4
- import type { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
4
+ import type { GitHubAppTokenManager } from "../vcs/index.js";
5
5
  import type { IFileWriter, IManifestManager, IBranchManager, IAuthOptionsBuilder, IRepositorySession, ICommitPushManager, IFileSyncOrchestrator, IPRMergeHandler, ISyncWorkflow, IRepositoryProcessor, GitOpsFactory, ProcessorOptions, ProcessorResult } from "./types.js";
6
6
  /**
7
7
  * Thin facade that delegates to SyncWorkflow with FileSyncStrategy.
@@ -18,7 +18,7 @@ export declare class GitOps implements ILocalGitOps {
18
18
  /**
19
19
  * Validates that a file path doesn't escape the workspace directory.
20
20
  * @returns The resolved absolute file path
21
- * @throws Error if path traversal is detected
21
+ * @throws ValidationError if path traversal is detected
22
22
  */
23
23
  private validatePath;
24
24
  cleanWorkspace(): void;
@@ -70,7 +70,7 @@ export declare class GitOps implements ILocalGitOps {
70
70
  /**
71
71
  * Stage all changes and commit with the given message.
72
72
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
73
- * @returns true if a commit was made, false if there were no staged changes
73
+ * @returns true if a commit was made (or would be made in dry-run mode), false if there were no staged changes
74
74
  */
75
75
  commit(message: string): Promise<boolean>;
76
76
  /**
@@ -20,7 +20,7 @@ export class GitOps {
20
20
  /**
21
21
  * Validates that a file path doesn't escape the workspace directory.
22
22
  * @returns The resolved absolute file path
23
- * @throws Error if path traversal is detected
23
+ * @throws ValidationError if path traversal is detected
24
24
  */
25
25
  validatePath(fileName) {
26
26
  const filePath = join(this._workDir, fileName);
@@ -209,7 +209,7 @@ export class GitOps {
209
209
  /**
210
210
  * Stage all changes and commit with the given message.
211
211
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
212
- * @returns true if a commit was made, false if there were no staged changes
212
+ * @returns true if a commit was made (or would be made in dry-run mode), false if there were no staged changes
213
213
  */
214
214
  async commit(message) {
215
215
  if (this.dryRun) {
@@ -43,7 +43,8 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
43
43
  * Uses the createCommitOnBranch mutation for verified commits.
44
44
  *
45
45
  * @returns Commit result with SHA and verified: true
46
- * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
46
+ * @throws ValidationError if repo is not GitHub or payload exceeds 50MB
47
+ * @throws GraphQLApiError if the API call fails
47
48
  */
48
49
  commit(options: CommitOptions): Promise<CommitResult>;
49
50
  /**
@@ -70,7 +70,8 @@ export class GraphQLCommitStrategy {
70
70
  * Uses the createCommitOnBranch mutation for verified commits.
71
71
  *
72
72
  * @returns Commit result with SHA and verified: true
73
- * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
73
+ * @throws ValidationError if repo is not GitHub or payload exceeds 50MB
74
+ * @throws GraphQLApiError if the API call fails
74
75
  */
75
76
  async commit(options) {
76
77
  const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, token, } = options;
@@ -1,5 +1,6 @@
1
1
  export type { PRMergeConfig, FileChange, FileAction, IGitOps, ILocalGitOps, IPRStrategy, GitAuthOptions, PRResult, ICommitStrategy, } from "./types.js";
2
2
  export type { GitOpsOptions } from "./git-ops.js";
3
3
  export { createCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
4
+ export type { GitHubAppTokenManager } from "./github-app-token-manager.js";
4
5
  export { createPRStrategy } from "./pr-strategy-factory.js";
5
6
  export { createPR, mergePR } from "./pr-creator.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "5.2.0",
3
+ "version": "5.3.1",
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",