@aspruyt/xfg 4.0.0 → 4.0.2

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 (174) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/index.d.ts +1 -2
  3. package/dist/cli/index.js +0 -1
  4. package/dist/cli/program.js +7 -2
  5. package/dist/cli/{settings/results-collector.d.ts → results-collector.d.ts} +1 -1
  6. package/dist/cli/{settings/results-collector.js → results-collector.js} +2 -1
  7. package/dist/cli/settings-report-builder.d.ts +1 -3
  8. package/dist/cli/sync-command.d.ts +2 -24
  9. package/dist/cli/sync-command.js +295 -301
  10. package/dist/cli/types.d.ts +60 -40
  11. package/dist/cli/types.js +1 -12
  12. package/dist/config/errors.d.ts +9 -0
  13. package/dist/config/errors.js +11 -0
  14. package/dist/config/file-reference-resolver.d.ts +2 -1
  15. package/dist/config/file-reference-resolver.js +10 -8
  16. package/dist/config/formatter.d.ts +3 -2
  17. package/dist/config/index.d.ts +4 -6
  18. package/dist/config/index.js +4 -8
  19. package/dist/config/loader.js +4 -2
  20. package/dist/config/merge.d.ts +0 -9
  21. package/dist/config/merge.js +2 -7
  22. package/dist/config/normalizer.d.ts +4 -0
  23. package/dist/config/normalizer.js +61 -110
  24. package/dist/config/types.d.ts +15 -19
  25. package/dist/config/types.js +1 -1
  26. package/dist/config/validator.d.ts +0 -4
  27. package/dist/config/validator.js +286 -363
  28. package/dist/config/validators/file-validator.d.ts +2 -8
  29. package/dist/config/validators/file-validator.js +6 -17
  30. package/dist/config/validators/index.d.ts +3 -3
  31. package/dist/config/validators/index.js +3 -3
  32. package/dist/config/validators/repo-settings-validator.d.ts +0 -6
  33. package/dist/config/validators/repo-settings-validator.js +9 -9
  34. package/dist/config/validators/ruleset-validator.d.ts +0 -14
  35. package/dist/config/validators/ruleset-validator.js +28 -28
  36. package/dist/lifecycle/ado-migration-source.js +2 -1
  37. package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -5
  38. package/dist/lifecycle/github-lifecycle-provider.js +79 -90
  39. package/dist/lifecycle/index.d.ts +2 -6
  40. package/dist/lifecycle/index.js +0 -4
  41. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  42. package/dist/lifecycle/lifecycle-formatter.js +4 -0
  43. package/dist/lifecycle/lifecycle-helpers.d.ts +3 -2
  44. package/dist/lifecycle/repo-lifecycle-manager.js +4 -11
  45. package/dist/lifecycle/types.d.ts +0 -8
  46. package/dist/output/github-summary.d.ts +5 -0
  47. package/dist/output/github-summary.js +9 -2
  48. package/dist/output/index.d.ts +2 -2
  49. package/dist/output/index.js +1 -1
  50. package/dist/output/lifecycle-report.js +5 -23
  51. package/dist/output/settings-report.d.ts +14 -3
  52. package/dist/output/settings-report.js +137 -197
  53. package/dist/output/summary-utils.d.ts +1 -1
  54. package/dist/output/summary-utils.js +2 -1
  55. package/dist/output/sync-report.js +5 -8
  56. package/dist/output/unified-summary.d.ts +2 -1
  57. package/dist/output/unified-summary.js +71 -133
  58. package/dist/settings/base-processor.d.ts +67 -0
  59. package/dist/settings/base-processor.js +91 -0
  60. package/dist/settings/index.d.ts +4 -3
  61. package/dist/settings/index.js +3 -3
  62. package/dist/settings/labels/converter.d.ts +2 -1
  63. package/dist/settings/labels/github-labels-strategy.d.ts +9 -18
  64. package/dist/settings/labels/github-labels-strategy.js +17 -73
  65. package/dist/settings/labels/index.d.ts +2 -6
  66. package/dist/settings/labels/index.js +1 -9
  67. package/dist/settings/labels/processor.d.ts +6 -30
  68. package/dist/settings/labels/processor.js +62 -152
  69. package/dist/settings/labels/types.d.ts +5 -8
  70. package/dist/settings/repo-settings/formatter.d.ts +2 -2
  71. package/dist/settings/repo-settings/formatter.js +6 -6
  72. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +11 -12
  73. package/dist/settings/repo-settings/github-repo-settings-strategy.js +32 -79
  74. package/dist/settings/repo-settings/index.d.ts +2 -5
  75. package/dist/settings/repo-settings/index.js +1 -9
  76. package/dist/settings/repo-settings/processor.d.ts +6 -27
  77. package/dist/settings/repo-settings/processor.js +51 -104
  78. package/dist/settings/repo-settings/types.d.ts +7 -9
  79. package/dist/settings/rulesets/diff-algorithm.d.ts +0 -4
  80. package/dist/settings/rulesets/diff-algorithm.js +1 -10
  81. package/dist/settings/rulesets/diff.d.ts +1 -1
  82. package/dist/settings/rulesets/diff.js +2 -21
  83. package/dist/settings/rulesets/formatter.d.ts +1 -3
  84. package/dist/settings/rulesets/formatter.js +1 -8
  85. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +11 -51
  86. package/dist/settings/rulesets/github-ruleset-strategy.js +24 -85
  87. package/dist/settings/rulesets/index.d.ts +3 -6
  88. package/dist/settings/rulesets/index.js +5 -9
  89. package/dist/settings/rulesets/processor.d.ts +8 -33
  90. package/dist/settings/rulesets/processor.js +58 -151
  91. package/dist/settings/rulesets/types.d.ts +35 -6
  92. package/dist/shared/command-executor.d.ts +2 -22
  93. package/dist/shared/command-executor.js +8 -7
  94. package/dist/shared/env.d.ts +0 -8
  95. package/dist/shared/env.js +14 -70
  96. package/dist/shared/file-status.d.ts +2 -0
  97. package/dist/shared/file-status.js +13 -0
  98. package/dist/shared/gh-api-utils.d.ts +46 -0
  99. package/dist/shared/gh-api-utils.js +107 -0
  100. package/dist/shared/index.d.ts +5 -5
  101. package/dist/shared/index.js +3 -3
  102. package/dist/shared/interpolation-engine.d.ts +31 -0
  103. package/dist/shared/interpolation-engine.js +50 -0
  104. package/dist/shared/logger.d.ts +3 -7
  105. package/dist/shared/logger.js +4 -1
  106. package/dist/shared/repo-detector.d.ts +17 -2
  107. package/dist/shared/repo-detector.js +27 -0
  108. package/dist/shared/retry-utils.d.ts +9 -17
  109. package/dist/shared/retry-utils.js +22 -28
  110. package/dist/shared/sanitize-utils.d.ts +0 -7
  111. package/dist/shared/sanitize-utils.js +0 -7
  112. package/dist/shared/shell-utils.d.ts +1 -0
  113. package/dist/shared/shell-utils.js +3 -0
  114. package/dist/shared/string-utils.d.ts +4 -0
  115. package/dist/shared/string-utils.js +6 -0
  116. package/dist/shared/type-guards.d.ts +17 -0
  117. package/dist/shared/type-guards.js +26 -0
  118. package/dist/shared/workspace-utils.d.ts +0 -4
  119. package/dist/shared/workspace-utils.js +0 -4
  120. package/dist/{sync → shared}/xfg-template.d.ts +3 -2
  121. package/dist/{sync → shared}/xfg-template.js +13 -54
  122. package/dist/sync/auth-options-builder.d.ts +4 -5
  123. package/dist/sync/auth-options-builder.js +15 -26
  124. package/dist/sync/branch-manager.d.ts +5 -0
  125. package/dist/sync/branch-manager.js +12 -10
  126. package/dist/sync/commit-push-manager.d.ts +1 -1
  127. package/dist/sync/commit-push-manager.js +22 -18
  128. package/dist/sync/diff-utils.d.ts +4 -9
  129. package/dist/sync/diff-utils.js +2 -19
  130. package/dist/sync/file-sync-orchestrator.js +9 -8
  131. package/dist/sync/file-writer.d.ts +2 -1
  132. package/dist/sync/file-writer.js +3 -6
  133. package/dist/sync/index.d.ts +2 -15
  134. package/dist/sync/index.js +0 -19
  135. package/dist/sync/manifest-manager.d.ts +4 -0
  136. package/dist/sync/manifest-manager.js +5 -1
  137. package/dist/sync/manifest.d.ts +10 -41
  138. package/dist/sync/manifest.js +11 -56
  139. package/dist/sync/pr-merge-handler.d.ts +2 -6
  140. package/dist/sync/pr-merge-handler.js +6 -3
  141. package/dist/sync/repository-processor.d.ts +1 -2
  142. package/dist/sync/repository-processor.js +20 -12
  143. package/dist/sync/repository-session.js +5 -14
  144. package/dist/sync/sync-workflow.js +31 -38
  145. package/dist/sync/types.d.ts +43 -178
  146. package/dist/vcs/authenticated-git-ops.d.ts +27 -70
  147. package/dist/vcs/authenticated-git-ops.js +70 -96
  148. package/dist/vcs/azure-pr-strategy.d.ts +6 -4
  149. package/dist/vcs/azure-pr-strategy.js +34 -82
  150. package/dist/vcs/branch-utils.d.ts +6 -0
  151. package/dist/vcs/branch-utils.js +29 -0
  152. package/dist/vcs/commit-strategy-selector.d.ts +5 -0
  153. package/dist/vcs/commit-strategy-selector.js +10 -0
  154. package/dist/vcs/git-commit-strategy.js +1 -2
  155. package/dist/vcs/git-ops.d.ts +15 -59
  156. package/dist/vcs/git-ops.js +46 -110
  157. package/dist/vcs/github-app-token-manager.d.ts +0 -6
  158. package/dist/vcs/github-app-token-manager.js +5 -12
  159. package/dist/vcs/github-pr-strategy.d.ts +5 -5
  160. package/dist/vcs/github-pr-strategy.js +44 -122
  161. package/dist/vcs/gitlab-pr-strategy.d.ts +6 -4
  162. package/dist/vcs/gitlab-pr-strategy.js +39 -87
  163. package/dist/vcs/graphql-commit-strategy.d.ts +3 -4
  164. package/dist/vcs/graphql-commit-strategy.js +31 -63
  165. package/dist/vcs/index.d.ts +3 -16
  166. package/dist/vcs/index.js +2 -33
  167. package/dist/vcs/pr-creator.d.ts +9 -9
  168. package/dist/vcs/pr-creator.js +11 -10
  169. package/dist/vcs/pr-strategy-factory.d.ts +5 -0
  170. package/dist/vcs/pr-strategy-factory.js +17 -0
  171. package/dist/vcs/pr-strategy.d.ts +13 -26
  172. package/dist/vcs/pr-strategy.js +20 -25
  173. package/dist/vcs/types.d.ts +87 -21
  174. package/package.json +2 -1
@@ -1,6 +1,8 @@
1
1
  import { isTextContent, isObjectContent, isStructuredFileExtension, validateFileName, VALID_STRATEGIES, } from "./validators/file-validator.js";
2
2
  import { validateRepoSettings } from "./validators/repo-settings-validator.js";
3
3
  import { validateRuleset } from "./validators/ruleset-validator.js";
4
+ import { escapeRegExp } from "../shared/shell-utils.js";
5
+ import { ValidationError } from "./errors.js";
4
6
  // Pattern for valid config ID: alphanumeric, hyphens, underscores
5
7
  const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
6
8
  const CONFIG_ID_MAX_LENGTH = 64;
@@ -19,12 +21,6 @@ function isValidGitUrl(url) {
19
21
  }
20
22
  return false;
21
23
  }
22
- /**
23
- * Escape special regex characters in a string.
24
- */
25
- function escapeRegExp(str) {
26
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
- }
28
24
  /**
29
25
  * Check if a git URL points to GitHub (github.com).
30
26
  * Used to reject GitHub URLs as migration sources (not supported).
@@ -45,66 +41,136 @@ function getGitDisplayName(git) {
45
41
  }
46
42
  return git;
47
43
  }
44
+ /**
45
+ * Validate file config fields shared between root files and per-repo overrides.
46
+ */
47
+ function validateFileConfigFields(fileConfig, fileName, context) {
48
+ if (fileConfig.content !== undefined) {
49
+ const hasText = isTextContent(fileConfig.content);
50
+ const hasObject = isObjectContent(fileConfig.content);
51
+ if (!hasText && !hasObject) {
52
+ throw new ValidationError(`${context} file '${fileName}' content must be an object, string, or array of strings`);
53
+ }
54
+ const isStructured = isStructuredFileExtension(fileName);
55
+ if (isStructured && hasText) {
56
+ throw new ValidationError(`${context} file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
57
+ }
58
+ if (!isStructured && hasObject) {
59
+ throw new ValidationError(`${context} file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
60
+ }
61
+ }
62
+ if (fileConfig.mergeStrategy !== undefined &&
63
+ !VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
64
+ throw new ValidationError(`${context} file '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
65
+ }
66
+ const booleanFields = [
67
+ "createOnly",
68
+ "executable",
69
+ "template",
70
+ "deleteOrphaned",
71
+ ];
72
+ for (const field of booleanFields) {
73
+ if (fileConfig[field] !== undefined &&
74
+ typeof fileConfig[field] !== "boolean") {
75
+ throw new ValidationError(`${context} file '${fileName}' ${field} must be a boolean`);
76
+ }
77
+ }
78
+ const stringFields = ["schemaUrl"];
79
+ for (const field of stringFields) {
80
+ if (fileConfig[field] !== undefined &&
81
+ typeof fileConfig[field] !== "string") {
82
+ throw new ValidationError(`${context} file '${fileName}' ${field} must be a string`);
83
+ }
84
+ }
85
+ if (fileConfig.header !== undefined) {
86
+ if (typeof fileConfig.header !== "string" &&
87
+ (!Array.isArray(fileConfig.header) ||
88
+ !fileConfig.header.every((h) => typeof h === "string"))) {
89
+ throw new ValidationError(`${context} file '${fileName}' header must be a string or array of strings`);
90
+ }
91
+ }
92
+ if (fileConfig.vars !== undefined) {
93
+ if (typeof fileConfig.vars !== "object" ||
94
+ fileConfig.vars === null ||
95
+ Array.isArray(fileConfig.vars)) {
96
+ throw new ValidationError(`${context} file '${fileName}' vars must be an object with string values`);
97
+ }
98
+ for (const [key, value] of Object.entries(fileConfig.vars)) {
99
+ if (typeof value !== "string") {
100
+ throw new ValidationError(`${context} file '${fileName}' vars.${key} must be a string`);
101
+ }
102
+ }
103
+ }
104
+ }
48
105
  /**
49
106
  * Validates a single label configuration.
50
107
  */
51
108
  function validateLabel(label, name, context) {
52
109
  if (typeof label !== "object" || label === null || Array.isArray(label)) {
53
- throw new Error(`${context}: label '${name}' must be an object`);
110
+ throw new ValidationError(`${context}: label '${name}' must be an object`);
54
111
  }
55
112
  const l = label;
56
113
  if (typeof l.color !== "string" || !/^#?[0-9a-fA-F]{6}$/.test(l.color)) {
57
- throw new Error(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
114
+ throw new ValidationError(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
58
115
  }
59
116
  if (l.description !== undefined) {
60
117
  if (typeof l.description !== "string") {
61
- throw new Error(`${context}: label '${name}' description must be a string`);
118
+ throw new ValidationError(`${context}: label '${name}' description must be a string`);
62
119
  }
63
120
  if (l.description.length > 100) {
64
- throw new Error(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
121
+ throw new ValidationError(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
65
122
  }
66
123
  }
67
124
  if (l.new_name !== undefined && typeof l.new_name !== "string") {
68
- throw new Error(`${context}: label '${name}' new_name must be a string`);
125
+ throw new ValidationError(`${context}: label '${name}' new_name must be a string`);
69
126
  }
70
127
  }
128
+ function buildRootSettingsContext(config) {
129
+ return {
130
+ rulesetNames: config.settings?.rulesets
131
+ ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
132
+ : [],
133
+ hasRepoSettings: config.settings?.repo !== undefined && config.settings.repo !== false,
134
+ labelNames: config.settings?.labels
135
+ ? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
136
+ : [],
137
+ };
138
+ }
71
139
  /**
72
140
  * Validates settings object containing rulesets, labels, and repo settings.
73
141
  */
74
- export function validateSettings(settings, context, rootRulesetNames, hasRootRepoSettings, rootLabelNames) {
142
+ function validateSettings(settings, context, rootRulesetNames, hasRootRepoSettings, rootLabelNames) {
75
143
  if (typeof settings !== "object" ||
76
144
  settings === null ||
77
145
  Array.isArray(settings)) {
78
- throw new Error(`${context}: settings must be an object`);
146
+ throw new ValidationError(`${context}: settings must be an object`);
79
147
  }
80
148
  const s = settings;
81
149
  if (s.rulesets !== undefined) {
82
150
  if (typeof s.rulesets !== "object" ||
83
151
  s.rulesets === null ||
84
152
  Array.isArray(s.rulesets)) {
85
- throw new Error(`${context}: rulesets must be an object`);
153
+ throw new ValidationError(`${context}: rulesets must be an object`);
86
154
  }
87
155
  const rulesets = s.rulesets;
88
156
  for (const [name, ruleset] of Object.entries(rulesets)) {
89
157
  // Skip reserved key
90
158
  if (name === "inherit")
91
159
  continue;
92
- // Check for opt-out of non-existent root ruleset
93
160
  if (ruleset === false) {
94
161
  if (rootRulesetNames && !rootRulesetNames.includes(name)) {
95
- throw new Error(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
162
+ throw new ValidationError(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
96
163
  }
97
164
  continue; // Skip further validation for false entries
98
165
  }
99
166
  validateRuleset(ruleset, name, context);
100
167
  }
101
168
  }
102
- // Validate labels
103
169
  if (s.labels !== undefined) {
104
170
  if (typeof s.labels !== "object" ||
105
171
  s.labels === null ||
106
172
  Array.isArray(s.labels)) {
107
- throw new Error(`${context}: labels must be an object`);
173
+ throw new ValidationError(`${context}: labels must be an object`);
108
174
  }
109
175
  const labels = s.labels;
110
176
  for (const [name, label] of Object.entries(labels)) {
@@ -112,7 +178,7 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
112
178
  continue;
113
179
  if (label === false) {
114
180
  if (rootLabelNames && !rootLabelNames.includes(name)) {
115
- throw new Error(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
181
+ throw new ValidationError(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
116
182
  }
117
183
  continue;
118
184
  }
@@ -120,18 +186,17 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
120
186
  }
121
187
  }
122
188
  if (s.deleteOrphaned !== undefined && typeof s.deleteOrphaned !== "boolean") {
123
- throw new Error(`${context}: settings.deleteOrphaned must be a boolean`);
189
+ throw new ValidationError(`${context}: settings.deleteOrphaned must be a boolean`);
124
190
  }
125
- // Validate repo settings
126
191
  if (s.repo !== undefined) {
127
192
  if (s.repo === false) {
128
193
  if (!rootRulesetNames) {
129
194
  // Root level — repo: false not valid here
130
- throw new Error(`${context}: repo: false is not valid at root level. Define repo settings or remove the field.`);
195
+ throw new ValidationError(`${context}: repo: false is not valid at root level. Define repo settings or remove the field.`);
131
196
  }
132
197
  // Per-repo level — check root has repo settings to opt out of
133
198
  if (!hasRootRepoSettings) {
134
- throw new Error(`${context}: Cannot opt out of repo settings — not defined in root settings.repo`);
199
+ throw new ValidationError(`${context}: Cannot opt out of repo settings — not defined in root settings.repo`);
135
200
  }
136
201
  // Valid opt-out, skip further repo validation
137
202
  }
@@ -140,389 +205,250 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
140
205
  }
141
206
  }
142
207
  }
143
- /**
144
- * Validates raw config structure before normalization.
145
- * @throws Error if validation fails
146
- */
147
- export function validateRawConfig(config) {
148
- // Validate required id field
208
+ function validateConfigId(config) {
149
209
  if (!config.id || typeof config.id !== "string") {
150
- throw new Error("Config requires an 'id' field. This unique identifier is used to namespace managed files in .xfg.json");
210
+ throw new ValidationError("Config requires an 'id' field. This unique identifier is used to namespace managed files in .xfg.json");
151
211
  }
152
212
  if (!CONFIG_ID_PATTERN.test(config.id)) {
153
- throw new Error(`Config 'id' contains invalid characters: '${config.id}'. Use only alphanumeric characters, hyphens, and underscores.`);
213
+ throw new ValidationError(`Config 'id' contains invalid characters: '${config.id}'. Use only alphanumeric characters, hyphens, and underscores.`);
154
214
  }
155
215
  if (config.id.length > CONFIG_ID_MAX_LENGTH) {
156
- throw new Error(`Config 'id' exceeds maximum length of ${CONFIG_ID_MAX_LENGTH} characters`);
157
- }
158
- // Validate at least one of files or settings exists (including in groups)
159
- const hasFiles = config.files &&
160
- typeof config.files === "object" &&
161
- Object.keys(config.files).length > 0;
162
- const hasSettings = config.settings && typeof config.settings === "object";
163
- const hasGroupFiles = config.groups &&
164
- typeof config.groups === "object" &&
165
- !Array.isArray(config.groups) &&
166
- Object.values(config.groups).some((g) => g.files &&
167
- Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
168
- const hasGroupSettings = config.groups &&
169
- typeof config.groups === "object" &&
170
- !Array.isArray(config.groups) &&
171
- Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
172
- if (!hasFiles && !hasSettings && !hasGroupFiles && !hasGroupSettings) {
173
- throw new Error("Config requires at least one of: 'files' or 'settings'. " +
174
- "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
216
+ throw new ValidationError(`Config 'id' exceeds maximum length of ${CONFIG_ID_MAX_LENGTH} characters`);
175
217
  }
176
- const fileNames = hasFiles ? Object.keys(config.files) : [];
177
- // Check for reserved key 'inherit' at root files level
178
- if (hasFiles && "inherit" in config.files) {
179
- throw new Error("'inherit' is a reserved key and cannot be used as a filename");
218
+ }
219
+ function validateRootFiles(config) {
220
+ if (!config.files || Object.keys(config.files).length === 0)
221
+ return;
222
+ if ("inherit" in config.files) {
223
+ throw new ValidationError("'inherit' is a reserved key and cannot be used as a filename");
180
224
  }
181
- // Validate each file definition
182
- for (const fileName of fileNames) {
225
+ for (const fileName of Object.keys(config.files)) {
183
226
  validateFileName(fileName);
184
227
  const fileConfig = config.files[fileName];
185
228
  if (!fileConfig || typeof fileConfig !== "object") {
186
- throw new Error(`File '${fileName}' must have a configuration object`);
187
- }
188
- // Validate content type
189
- if (fileConfig.content !== undefined) {
190
- const hasText = isTextContent(fileConfig.content);
191
- const hasObject = isObjectContent(fileConfig.content);
192
- if (!hasText && !hasObject) {
193
- throw new Error(`File '${fileName}' content must be an object, string, or array of strings`);
194
- }
195
- // Validate content type matches file extension
196
- const isStructured = isStructuredFileExtension(fileName);
197
- if (isStructured && hasText) {
198
- throw new Error(`File '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
199
- }
200
- if (!isStructured && hasObject) {
201
- throw new Error(`File '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
202
- }
203
- }
204
- if (fileConfig.mergeStrategy !== undefined &&
205
- !VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
206
- throw new Error(`File '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
229
+ throw new ValidationError(`File '${fileName}' must have a configuration object`);
207
230
  }
208
- if (fileConfig.createOnly !== undefined &&
209
- typeof fileConfig.createOnly !== "boolean") {
210
- throw new Error(`File '${fileName}' createOnly must be a boolean`);
211
- }
212
- if (fileConfig.executable !== undefined &&
213
- typeof fileConfig.executable !== "boolean") {
214
- throw new Error(`File '${fileName}' executable must be a boolean`);
231
+ validateFileConfigFields(fileConfig, fileName, `File '${fileName}':`);
232
+ }
233
+ }
234
+ function validateRootSettings(config) {
235
+ if (config.settings === undefined)
236
+ return;
237
+ validateSettings(config.settings, "Root");
238
+ if (config.settings.rulesets && "inherit" in config.settings.rulesets) {
239
+ throw new ValidationError("'inherit' is a reserved key and cannot be used as a ruleset name");
240
+ }
241
+ if (config.settings.labels && "inherit" in config.settings.labels) {
242
+ throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
243
+ }
244
+ }
245
+ function validateGithubHosts(config) {
246
+ if (config.githubHosts === undefined)
247
+ return;
248
+ if (!Array.isArray(config.githubHosts) ||
249
+ !config.githubHosts.every((h) => typeof h === "string")) {
250
+ throw new ValidationError("githubHosts must be an array of strings");
251
+ }
252
+ for (const host of config.githubHosts) {
253
+ if (!host) {
254
+ throw new ValidationError("githubHosts entries must be non-empty hostnames");
215
255
  }
216
- if (fileConfig.header !== undefined) {
217
- if (typeof fileConfig.header !== "string" &&
218
- (!Array.isArray(fileConfig.header) ||
219
- !fileConfig.header.every((h) => typeof h === "string"))) {
220
- throw new Error(`File '${fileName}' header must be a string or array of strings`);
221
- }
256
+ if (host.includes("://")) {
257
+ throw new ValidationError(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
222
258
  }
223
- if (fileConfig.schemaUrl !== undefined &&
224
- typeof fileConfig.schemaUrl !== "string") {
225
- throw new Error(`File '${fileName}' schemaUrl must be a string`);
259
+ if (host.includes("/")) {
260
+ throw new ValidationError(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
226
261
  }
227
- if (fileConfig.template !== undefined &&
228
- typeof fileConfig.template !== "boolean") {
229
- throw new Error(`File '${fileName}' template must be a boolean`);
262
+ }
263
+ }
264
+ function validatePrOptions(config) {
265
+ if (config.prOptions?.labels === undefined)
266
+ return;
267
+ if (!Array.isArray(config.prOptions.labels)) {
268
+ throw new ValidationError("prOptions.labels must be an array of strings");
269
+ }
270
+ for (const label of config.prOptions.labels) {
271
+ if (typeof label !== "string" || label.length === 0) {
272
+ throw new ValidationError("prOptions.labels entries must be non-empty strings");
230
273
  }
231
- if (fileConfig.vars !== undefined) {
232
- if (typeof fileConfig.vars !== "object" ||
233
- fileConfig.vars === null ||
234
- Array.isArray(fileConfig.vars)) {
235
- throw new Error(`File '${fileName}' vars must be an object with string values`);
236
- }
237
- for (const [key, value] of Object.entries(fileConfig.vars)) {
238
- if (typeof value !== "string") {
239
- throw new Error(`File '${fileName}' vars.${key} must be a string`);
240
- }
274
+ }
275
+ }
276
+ function validateGroups(config) {
277
+ if (config.groups === undefined)
278
+ return;
279
+ if (typeof config.groups !== "object" ||
280
+ config.groups === null ||
281
+ Array.isArray(config.groups)) {
282
+ throw new ValidationError("groups must be an object");
283
+ }
284
+ const rootCtx = buildRootSettingsContext(config);
285
+ for (const [groupName, group] of Object.entries(config.groups)) {
286
+ if (groupName === "inherit") {
287
+ throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
288
+ }
289
+ if (group.files) {
290
+ for (const [fileName, fileConfig] of Object.entries(group.files)) {
291
+ if (fileName === "inherit")
292
+ continue;
293
+ if (fileConfig === false)
294
+ continue;
295
+ if (fileConfig === undefined)
296
+ continue;
297
+ validateFileConfigFields(fileConfig, fileName, `groups.${groupName}:`);
241
298
  }
242
299
  }
243
- if (fileConfig.deleteOrphaned !== undefined &&
244
- typeof fileConfig.deleteOrphaned !== "boolean") {
245
- throw new Error(`File '${fileName}' deleteOrphaned must be a boolean`);
300
+ if (group.settings !== undefined) {
301
+ validateSettings(group.settings, `groups.${groupName}`, rootCtx.rulesetNames, rootCtx.hasRepoSettings, rootCtx.labelNames);
246
302
  }
247
303
  }
248
- // Validate global deleteOrphaned
249
- if (config.deleteOrphaned !== undefined &&
250
- typeof config.deleteOrphaned !== "boolean") {
251
- throw new Error("Global deleteOrphaned must be a boolean");
304
+ }
305
+ function validateRepoEntry(config, repo, index) {
306
+ if (!repo.git) {
307
+ throw new ValidationError(`Repo at index ${index} missing required field: git`);
252
308
  }
253
- if (!config.repos || !Array.isArray(config.repos)) {
254
- throw new Error("Config missing required field: repos (must be an array)");
309
+ if (Array.isArray(repo.git) && repo.git.length === 0) {
310
+ throw new ValidationError(`Repo at index ${index} has empty git array`);
255
311
  }
256
- // Validate root settings
257
- if (config.settings !== undefined) {
258
- validateSettings(config.settings, "Root");
259
- // Check for reserved key 'inherit' at root rulesets level
260
- if (config.settings.rulesets && "inherit" in config.settings.rulesets) {
261
- throw new Error("'inherit' is a reserved key and cannot be used as a ruleset name");
312
+ const repoLabel = getGitDisplayName(repo.git);
313
+ if (repo.upstream !== undefined && repo.source !== undefined) {
314
+ throw new ValidationError(`Repo ${repoLabel}: 'upstream' and 'source' are mutually exclusive. ` +
315
+ `Use 'upstream' to fork, or 'source' to migrate, not both.`);
316
+ }
317
+ if (repo.upstream !== undefined) {
318
+ if (typeof repo.upstream !== "string") {
319
+ throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a string`);
262
320
  }
263
- // Check for reserved key 'inherit' at root labels level
264
- if (config.settings.labels && "inherit" in config.settings.labels) {
265
- throw new Error("'inherit' is a reserved key and cannot be used as a label name");
321
+ if (!isValidGitUrl(repo.upstream)) {
322
+ throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a valid git URL ` +
323
+ `(SSH: git@host:path or HTTPS: https://host/path)`);
266
324
  }
267
325
  }
268
- // Validate githubHosts if provided
269
- if (config.githubHosts !== undefined) {
270
- if (!Array.isArray(config.githubHosts) ||
271
- !config.githubHosts.every((h) => typeof h === "string")) {
272
- throw new Error("githubHosts must be an array of strings");
326
+ if (repo.source !== undefined) {
327
+ if (typeof repo.source !== "string") {
328
+ throw new ValidationError(`Repo ${repoLabel}: 'source' must be a string`);
273
329
  }
274
- for (const host of config.githubHosts) {
275
- if (!host) {
276
- throw new Error("githubHosts entries must be non-empty hostnames");
277
- }
278
- if (host.includes("://")) {
279
- throw new Error(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
280
- }
281
- if (host.includes("/")) {
282
- throw new Error(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
283
- }
330
+ if (!isValidGitUrl(repo.source)) {
331
+ throw new ValidationError(`Repo ${repoLabel}: 'source' must be a valid git URL ` +
332
+ `(SSH: git@host:path or HTTPS: https://host/path)`);
284
333
  }
285
- }
286
- // Validate prOptions.labels if present
287
- if (config.prOptions?.labels !== undefined) {
288
- if (!Array.isArray(config.prOptions.labels)) {
289
- throw new Error("prOptions.labels must be an array of strings");
334
+ if (isGitHubUrl(repo.source, config.githubHosts)) {
335
+ throw new ValidationError(`Repo ${repoLabel}: 'source' cannot be a GitHub URL. ` +
336
+ `Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
290
337
  }
291
- for (const label of config.prOptions.labels) {
292
- if (typeof label !== "string" || label.length === 0) {
293
- throw new Error("prOptions.labels entries must be non-empty strings");
338
+ }
339
+ if (repo.groups !== undefined) {
340
+ if (!Array.isArray(repo.groups) ||
341
+ !repo.groups.every((g) => typeof g === "string")) {
342
+ throw new ValidationError(`Repo at index ${index}: groups must be an array of strings`);
343
+ }
344
+ const seen = new Set();
345
+ for (const groupName of repo.groups) {
346
+ if (!config.groups || !config.groups[groupName]) {
347
+ throw new ValidationError(`Repo at index ${index}: group '${groupName}' is not defined in root 'groups'`);
294
348
  }
349
+ if (seen.has(groupName)) {
350
+ throw new ValidationError(`Repo at index ${index}: duplicate group '${groupName}'`);
351
+ }
352
+ seen.add(groupName);
295
353
  }
296
354
  }
297
- // Validate groups
298
- if (config.groups !== undefined) {
299
- if (typeof config.groups !== "object" ||
300
- config.groups === null ||
301
- Array.isArray(config.groups)) {
302
- throw new Error("groups must be an object");
355
+ if (repo.files) {
356
+ if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
357
+ throw new ValidationError(`Repo at index ${index}: files must be an object`);
303
358
  }
304
- const rootRulesetNames = config.settings?.rulesets
305
- ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
306
- : [];
307
- const hasRootRepoSettings = config.settings?.repo !== undefined && config.settings.repo !== false;
308
- const rootLabelNames = config.settings?.labels
309
- ? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
310
- : [];
311
- for (const [groupName, group] of Object.entries(config.groups)) {
312
- if (groupName === "inherit") {
313
- throw new Error("'inherit' is a reserved key and cannot be used as a group name");
314
- }
315
- // Validate group files
316
- if (group.files) {
317
- for (const [fileName, fileConfig] of Object.entries(group.files)) {
318
- if (fileName === "inherit")
319
- continue;
320
- if (fileConfig === false)
321
- continue;
322
- if (fileConfig === undefined)
323
- continue;
324
- const fc = fileConfig;
325
- if (fc.content !== undefined) {
326
- const hasText = isTextContent(fc.content);
327
- const hasObject = isObjectContent(fc.content);
328
- if (!hasText && !hasObject) {
329
- throw new Error(`groups.${groupName}: file '${fileName}' content must be an object, string, or array of strings`);
330
- }
331
- const isStructured = isStructuredFileExtension(fileName);
332
- if (isStructured && hasText) {
333
- throw new Error(`groups.${groupName}: file '${fileName}' has JSON/YAML extension but string content`);
334
- }
335
- if (!isStructured && hasObject) {
336
- throw new Error(`groups.${groupName}: file '${fileName}' has text extension but object content`);
337
- }
359
+ const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
360
+ if (repo.groups && config.groups) {
361
+ for (const groupName of repo.groups) {
362
+ const group = config.groups[groupName];
363
+ if (group?.files) {
364
+ for (const fn of Object.keys(group.files)) {
365
+ if (fn !== "inherit")
366
+ knownFiles.add(fn);
338
367
  }
339
368
  }
340
369
  }
341
- // Validate group settings
342
- if (group.settings !== undefined) {
343
- validateSettings(group.settings, `groups.${groupName}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
344
- }
345
- }
346
- }
347
- // Validate each repo
348
- for (let i = 0; i < config.repos.length; i++) {
349
- const repo = config.repos[i];
350
- if (!repo.git) {
351
- throw new Error(`Repo at index ${i} missing required field: git`);
352
- }
353
- if (Array.isArray(repo.git) && repo.git.length === 0) {
354
- throw new Error(`Repo at index ${i} has empty git array`);
355
370
  }
356
- // Validate lifecycle fields (upstream/source)
357
- if (repo.upstream !== undefined && repo.source !== undefined) {
358
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' and 'source' are mutually exclusive. ` +
359
- `Use 'upstream' to fork, or 'source' to migrate, not both.`);
360
- }
361
- if (repo.upstream !== undefined) {
362
- if (typeof repo.upstream !== "string") {
363
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' must be a string`);
364
- }
365
- if (!isValidGitUrl(repo.upstream)) {
366
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' must be a valid git URL ` +
367
- `(SSH: git@host:path or HTTPS: https://host/path)`);
371
+ for (const fileName of Object.keys(repo.files)) {
372
+ if (fileName === "inherit") {
373
+ const inheritValue = repo.files.inherit;
374
+ if (typeof inheritValue !== "boolean") {
375
+ throw new ValidationError(`Repo at index ${index}: files.inherit must be a boolean`);
376
+ }
377
+ continue;
368
378
  }
369
- }
370
- if (repo.source !== undefined) {
371
- if (typeof repo.source !== "string") {
372
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' must be a string`);
379
+ if (!knownFiles.has(fileName)) {
380
+ throw new ValidationError(`Repo at index ${index} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group.`);
373
381
  }
374
- if (!isValidGitUrl(repo.source)) {
375
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' must be a valid git URL ` +
376
- `(SSH: git@host:path or HTTPS: https://host/path)`);
382
+ const fileOverride = repo.files[fileName];
383
+ if (fileOverride === false) {
384
+ continue;
377
385
  }
378
- if (isGitHubUrl(repo.source, config.githubHosts)) {
379
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' cannot be a GitHub URL. ` +
380
- `Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
386
+ if (fileOverride.override && !fileOverride.content) {
387
+ throw new ValidationError(`Repo ${repoLabel} has override: true for file '${fileName}' but no content defined. ` +
388
+ `Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
381
389
  }
390
+ validateFileConfigFields(fileOverride, fileName, `Repo ${repoLabel}:`);
382
391
  }
383
- // Validate per-repo groups
384
- if (repo.groups !== undefined) {
385
- if (!Array.isArray(repo.groups) ||
386
- !repo.groups.every((g) => typeof g === "string")) {
387
- throw new Error(`Repo at index ${i}: groups must be an array of strings`);
388
- }
389
- const seen = new Set();
392
+ }
393
+ if (repo.settings !== undefined) {
394
+ const rootCtx = buildRootSettingsContext(config);
395
+ if (repo.groups && config.groups) {
390
396
  for (const groupName of repo.groups) {
391
- if (!config.groups || !config.groups[groupName]) {
392
- throw new Error(`Repo at index ${i}: group '${groupName}' is not defined in root 'groups'`);
393
- }
394
- if (seen.has(groupName)) {
395
- throw new Error(`Repo at index ${i}: duplicate group '${groupName}'`);
396
- }
397
- seen.add(groupName);
398
- }
399
- }
400
- // Validate per-repo file overrides
401
- if (repo.files) {
402
- if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
403
- throw new Error(`Repo at index ${i}: files must be an object`);
404
- }
405
- // Build the set of known files once per repo (root + referenced groups)
406
- const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
407
- if (repo.groups && config.groups) {
408
- for (const groupName of repo.groups) {
409
- const group = config.groups[groupName];
410
- if (group?.files) {
411
- for (const fn of Object.keys(group.files)) {
412
- if (fn !== "inherit")
413
- knownFiles.add(fn);
414
- }
397
+ const group = config.groups[groupName];
398
+ if (group?.settings?.rulesets) {
399
+ for (const name of Object.keys(group.settings.rulesets)) {
400
+ if (name !== "inherit")
401
+ rootCtx.rulesetNames.push(name);
415
402
  }
416
403
  }
417
- }
418
- for (const fileName of Object.keys(repo.files)) {
419
- // Skip reserved key 'inherit'
420
- if (fileName === "inherit") {
421
- const inheritValue = repo.files.inherit;
422
- if (typeof inheritValue !== "boolean") {
423
- throw new Error(`Repo at index ${i}: files.inherit must be a boolean`);
424
- }
425
- continue;
426
- }
427
- // Ensure the file is defined at root level or in a referenced group
428
- if (!knownFiles.has(fileName)) {
429
- throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group.`);
430
- }
431
- const fileOverride = repo.files[fileName];
432
- // false means exclude this file for this repo - no further validation needed
433
- if (fileOverride === false) {
434
- continue;
435
- }
436
- if (fileOverride.override && !fileOverride.content) {
437
- throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined. ` +
438
- `Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
439
- }
440
- // Validate content type
441
- if (fileOverride.content !== undefined) {
442
- const hasText = isTextContent(fileOverride.content);
443
- const hasObject = isObjectContent(fileOverride.content);
444
- if (!hasText && !hasObject) {
445
- throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object, string, or array of strings`);
446
- }
447
- // Validate content type matches file extension
448
- const isStructured = isStructuredFileExtension(fileName);
449
- if (isStructured && hasText) {
450
- throw new Error(`Repo at index ${i}: file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
451
- }
452
- if (!isStructured && hasObject) {
453
- throw new Error(`Repo at index ${i}: file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
454
- }
455
- }
456
- if (fileOverride.createOnly !== undefined &&
457
- typeof fileOverride.createOnly !== "boolean") {
458
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
459
- }
460
- if (fileOverride.executable !== undefined &&
461
- typeof fileOverride.executable !== "boolean") {
462
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' executable must be a boolean`);
463
- }
464
- if (fileOverride.header !== undefined) {
465
- if (typeof fileOverride.header !== "string" &&
466
- (!Array.isArray(fileOverride.header) ||
467
- !fileOverride.header.every((h) => typeof h === "string"))) {
468
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' header must be a string or array of strings`);
469
- }
470
- }
471
- if (fileOverride.schemaUrl !== undefined &&
472
- typeof fileOverride.schemaUrl !== "string") {
473
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
474
- }
475
- if (fileOverride.template !== undefined &&
476
- typeof fileOverride.template !== "boolean") {
477
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' template must be a boolean`);
478
- }
479
- if (fileOverride.vars !== undefined) {
480
- if (typeof fileOverride.vars !== "object" ||
481
- fileOverride.vars === null ||
482
- Array.isArray(fileOverride.vars)) {
483
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars must be an object with string values`);
484
- }
485
- for (const [key, value] of Object.entries(fileOverride.vars)) {
486
- if (typeof value !== "string") {
487
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars.${key} must be a string`);
488
- }
489
- }
490
- }
491
- if (fileOverride.deleteOrphaned !== undefined &&
492
- typeof fileOverride.deleteOrphaned !== "boolean") {
493
- throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' deleteOrphaned must be a boolean`);
494
- }
495
- }
496
- }
497
- // Validate per-repo settings
498
- if (repo.settings !== undefined) {
499
- const rootRulesetNames = config.settings?.rulesets
500
- ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
501
- : [];
502
- const hasRootRepoSettings = config.settings?.repo !== undefined && config.settings.repo !== false;
503
- const rootLabelNames = config.settings?.labels
504
- ? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
505
- : [];
506
- // Augment known names with those from the repo's referenced groups
507
- if (repo.groups && config.groups) {
508
- for (const groupName of repo.groups) {
509
- const group = config.groups[groupName];
510
- if (group?.settings?.rulesets) {
511
- for (const name of Object.keys(group.settings.rulesets)) {
512
- if (name !== "inherit")
513
- rootRulesetNames.push(name);
514
- }
515
- }
516
- if (group?.settings?.labels) {
517
- for (const name of Object.keys(group.settings.labels)) {
518
- if (name !== "inherit")
519
- rootLabelNames.push(name);
520
- }
404
+ if (group?.settings?.labels) {
405
+ for (const name of Object.keys(group.settings.labels)) {
406
+ if (name !== "inherit")
407
+ rootCtx.labelNames.push(name);
521
408
  }
522
409
  }
523
410
  }
524
- validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
525
411
  }
412
+ validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx.rulesetNames, rootCtx.hasRepoSettings, rootCtx.labelNames);
413
+ }
414
+ }
415
+ /**
416
+ * Validates raw config structure before normalization.
417
+ * @throws Error if validation fails
418
+ */
419
+ export function validateRawConfig(config) {
420
+ validateConfigId(config);
421
+ const hasFiles = config.files &&
422
+ typeof config.files === "object" &&
423
+ Object.keys(config.files).length > 0;
424
+ const hasSettings = config.settings && typeof config.settings === "object";
425
+ const hasGroupFiles = config.groups &&
426
+ typeof config.groups === "object" &&
427
+ !Array.isArray(config.groups) &&
428
+ Object.values(config.groups).some((g) => g.files &&
429
+ Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
430
+ const hasGroupSettings = config.groups &&
431
+ typeof config.groups === "object" &&
432
+ !Array.isArray(config.groups) &&
433
+ Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
434
+ if (!hasFiles && !hasSettings && !hasGroupFiles && !hasGroupSettings) {
435
+ throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
436
+ "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
437
+ }
438
+ validateRootFiles(config);
439
+ if (config.deleteOrphaned !== undefined &&
440
+ typeof config.deleteOrphaned !== "boolean") {
441
+ throw new ValidationError("Global deleteOrphaned must be a boolean");
442
+ }
443
+ if (!config.repos || !Array.isArray(config.repos)) {
444
+ throw new ValidationError("Config missing required field: repos (must be an array)");
445
+ }
446
+ validateRootSettings(config);
447
+ validateGithubHosts(config);
448
+ validatePrOptions(config);
449
+ validateGroups(config);
450
+ for (let i = 0; i < config.repos.length; i++) {
451
+ validateRepoEntry(config, config.repos[i], i);
526
452
  }
527
453
  }
528
454
  // =============================================================================
@@ -548,7 +474,7 @@ export function validateForSync(config) {
548
474
  !hasSettings &&
549
475
  !hasRepoSettings &&
550
476
  !hasGroupSettings) {
551
- throw new Error("Config requires at least one of: 'files' or 'settings'. " +
477
+ throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
552
478
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
553
479
  }
554
480
  }
@@ -558,16 +484,13 @@ export function validateForSync(config) {
558
484
  export function hasActionableSettings(settings) {
559
485
  if (!settings)
560
486
  return false;
561
- // Check for rulesets (filter out inherit key)
562
487
  if (settings.rulesets &&
563
488
  Object.keys(settings.rulesets).filter((k) => k !== "inherit").length > 0) {
564
489
  return true;
565
490
  }
566
- // Check for repo settings
567
491
  if (settings.repo && Object.keys(settings.repo).length > 0) {
568
492
  return true;
569
493
  }
570
- // Check for labels (filter out inherit key)
571
494
  if (settings.labels &&
572
495
  Object.keys(settings.labels).filter((k) => k !== "inherit").length > 0) {
573
496
  return true;