@aspruyt/xfg 6.0.3 → 6.2.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 (143) hide show
  1. package/dist/cli/lifecycle-report-builder.d.ts +2 -2
  2. package/dist/cli/lifecycle-report-builder.js +3 -11
  3. package/dist/cli/program.d.ts +2 -1
  4. package/dist/cli/program.js +2 -3
  5. package/dist/cli/repo-sync-runner.d.ts +24 -0
  6. package/dist/cli/repo-sync-runner.js +156 -0
  7. package/dist/cli/results-collector.d.ts +1 -1
  8. package/dist/cli/results-collector.js +2 -2
  9. package/dist/cli/settings-factories.d.ts +7 -0
  10. package/dist/cli/settings-factories.js +27 -0
  11. package/dist/cli/settings-report-builder.d.ts +1 -1
  12. package/dist/cli/settings-report-builder.js +12 -23
  13. package/dist/cli/settings-runner.d.ts +2 -0
  14. package/dist/cli/settings-runner.js +87 -0
  15. package/dist/cli/sync-command.d.ts +1 -1
  16. package/dist/cli/sync-command.js +31 -372
  17. package/dist/cli/sync-report-builder.d.ts +1 -1
  18. package/dist/cli/sync-utils.d.ts +8 -0
  19. package/dist/cli/sync-utils.js +36 -0
  20. package/dist/cli/types.d.ts +5 -7
  21. package/dist/cli/unified-summary.d.ts +1 -3
  22. package/dist/cli/unified-summary.js +7 -5
  23. package/dist/cli.js +2 -1
  24. package/dist/{shared → config}/env.js +2 -2
  25. package/dist/config/extends-resolver.js +4 -3
  26. package/dist/config/file-reference-resolver.js +4 -2
  27. package/dist/config/formatter.js +18 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/loader.js +30 -6
  30. package/dist/config/merge.d.ts +11 -1
  31. package/dist/config/merge.js +78 -6
  32. package/dist/config/normalizer.js +53 -38
  33. package/dist/config/validator.d.ts +1 -4
  34. package/dist/config/validator.js +13 -599
  35. package/dist/config/validators/file-validator.d.ts +2 -1
  36. package/dist/config/validators/file-validator.js +9 -1
  37. package/dist/config/validators/group-validator.d.ts +3 -0
  38. package/dist/config/validators/group-validator.js +167 -0
  39. package/dist/config/validators/repo-entry-validator.d.ts +2 -0
  40. package/dist/config/validators/repo-entry-validator.js +165 -0
  41. package/dist/config/validators/repo-settings-validator.js +18 -7
  42. package/dist/config/validators/ruleset-validator.js +2 -5
  43. package/dist/config/validators/shared.d.ts +11 -0
  44. package/dist/config/validators/shared.js +242 -0
  45. package/dist/lifecycle/ado-migration-source.js +2 -4
  46. package/dist/lifecycle/github-lifecycle-provider.d.ts +7 -11
  47. package/dist/lifecycle/github-lifecycle-provider.js +125 -136
  48. package/dist/lifecycle/{lifecycle-helpers.d.ts → helpers.d.ts} +5 -1
  49. package/dist/lifecycle/{lifecycle-helpers.js → helpers.js} +9 -8
  50. package/dist/lifecycle/index.d.ts +2 -2
  51. package/dist/lifecycle/index.js +1 -1
  52. package/dist/lifecycle/repo-lifecycle-factory.d.ts +2 -2
  53. package/dist/output/github-summary.js +2 -3
  54. package/dist/output/index.d.ts +4 -0
  55. package/dist/output/index.js +4 -0
  56. package/dist/output/lifecycle-report.d.ts +1 -1
  57. package/dist/output/lifecycle-report.js +5 -0
  58. package/dist/output/sync-report.d.ts +25 -3
  59. package/dist/output/sync-report.js +11 -11
  60. package/dist/settings/base-processor.d.ts +18 -7
  61. package/dist/settings/base-processor.js +26 -5
  62. package/dist/settings/code-scanning/diff.js +2 -2
  63. package/dist/settings/code-scanning/formatter.d.ts +2 -6
  64. package/dist/settings/code-scanning/formatter.js +2 -25
  65. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +3 -7
  66. package/dist/settings/code-scanning/github-code-scanning-strategy.js +2 -2
  67. package/dist/settings/code-scanning/processor.js +6 -4
  68. package/dist/settings/code-scanning/types.d.ts +10 -8
  69. package/dist/settings/labels/github-labels-strategy.d.ts +3 -11
  70. package/dist/settings/labels/types.d.ts +12 -10
  71. package/dist/settings/repo-settings/diff.d.ts +1 -1
  72. package/dist/settings/repo-settings/diff.js +1 -1
  73. package/dist/settings/repo-settings/formatter.d.ts +2 -6
  74. package/dist/settings/repo-settings/formatter.js +4 -23
  75. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -2
  76. package/dist/settings/repo-settings/github-repo-settings-strategy.js +8 -7
  77. package/dist/settings/repo-settings/processor.js +11 -11
  78. package/dist/settings/repo-settings/types.d.ts +2 -2
  79. package/dist/settings/rulesets/diff-algorithm.js +4 -2
  80. package/dist/settings/rulesets/diff.js +2 -51
  81. package/dist/settings/rulesets/formatter.js +4 -0
  82. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +3 -3
  83. package/dist/settings/rulesets/github-ruleset-strategy.js +4 -6
  84. package/dist/settings/rulesets/index.d.ts +1 -1
  85. package/dist/settings/rulesets/index.js +0 -2
  86. package/dist/settings/rulesets/processor.js +1 -1
  87. package/dist/settings/rulesets/types.d.ts +6 -2
  88. package/dist/shared/command-executor.d.ts +4 -4
  89. package/dist/shared/command-executor.js +9 -7
  90. package/dist/shared/diff-format.d.ts +1 -0
  91. package/dist/shared/diff-format.js +10 -0
  92. package/dist/shared/errors.d.ts +7 -4
  93. package/dist/shared/errors.js +8 -8
  94. package/dist/shared/gh-api-utils.d.ts +3 -34
  95. package/dist/shared/gh-api-utils.js +23 -53
  96. package/dist/shared/gh-token-utils.d.ts +26 -0
  97. package/dist/shared/gh-token-utils.js +32 -0
  98. package/dist/shared/json-utils.js +1 -1
  99. package/dist/shared/regex-utils.d.ts +1 -0
  100. package/dist/shared/regex-utils.js +3 -0
  101. package/dist/shared/retry-utils.d.ts +1 -0
  102. package/dist/shared/retry-utils.js +13 -7
  103. package/dist/sync/auth-options-builder.js +1 -1
  104. package/dist/sync/branch-manager.js +5 -3
  105. package/dist/sync/commit-push-manager.js +2 -3
  106. package/dist/sync/diff-utils.d.ts +0 -1
  107. package/dist/sync/diff-utils.js +5 -10
  108. package/dist/sync/file-sync-orchestrator.js +0 -2
  109. package/dist/sync/file-writer.d.ts +3 -0
  110. package/dist/sync/file-writer.js +84 -81
  111. package/dist/sync/index.d.ts +0 -1
  112. package/dist/sync/index.js +0 -1
  113. package/dist/sync/manifest.js +1 -1
  114. package/dist/sync/pr-merge-handler.js +6 -6
  115. package/dist/sync/sync-workflow.js +1 -1
  116. package/dist/sync/types.d.ts +2 -2
  117. package/dist/vcs/ado-pr-strategy.d.ts +3 -5
  118. package/dist/vcs/ado-pr-strategy.js +131 -33
  119. package/dist/vcs/authenticated-git-ops.js +45 -23
  120. package/dist/vcs/git-commit-strategy.js +10 -6
  121. package/dist/vcs/git-ops.js +30 -24
  122. package/dist/vcs/github-pr-strategy.d.ts +3 -2
  123. package/dist/vcs/github-pr-strategy.js +80 -30
  124. package/dist/vcs/gitlab-pr-strategy.d.ts +2 -5
  125. package/dist/vcs/gitlab-pr-strategy.js +88 -87
  126. package/dist/vcs/graphql-commit-strategy.d.ts +1 -5
  127. package/dist/vcs/graphql-commit-strategy.js +21 -37
  128. package/dist/vcs/pr-creator.js +9 -2
  129. package/dist/vcs/pr-strategy.d.ts +2 -3
  130. package/dist/vcs/pr-strategy.js +0 -1
  131. package/dist/vcs/types.d.ts +9 -5
  132. package/package.json +5 -5
  133. package/dist/config/validators/index.d.ts +0 -3
  134. package/dist/config/validators/index.js +0 -6
  135. package/dist/output/types.d.ts +0 -20
  136. package/dist/output/types.js +0 -1
  137. package/dist/shared/shell-utils.d.ts +0 -6
  138. package/dist/shared/shell-utils.js +0 -17
  139. /package/dist/{shared → config}/env.d.ts +0 -0
  140. /package/dist/lifecycle/{lifecycle-formatter.d.ts → formatter.d.ts} +0 -0
  141. /package/dist/lifecycle/{lifecycle-formatter.js → formatter.js} +0 -0
  142. /package/dist/{vcs → shared}/sanitize-utils.d.ts +0 -0
  143. /package/dist/{vcs → shared}/sanitize-utils.js +0 -0
@@ -1,259 +1,11 @@
1
- import { resolveExtendsChain, expandRepoGroups } from "./extends-resolver.js";
2
- import { isTextContent, isObjectContent, isStructuredFileExtension, validateFileName, VALID_STRATEGIES, } from "./validators/file-validator.js";
3
- import { validateRepoSettings } from "./validators/repo-settings-validator.js";
4
- import { validateRuleset } from "./validators/ruleset-validator.js";
5
- import { escapeRegExp } from "../shared/shell-utils.js";
1
+ import { validateFileName } from "./validators/file-validator.js";
6
2
  import { isPlainObject } from "../shared/type-guards.js";
7
3
  import { ValidationError } from "../shared/errors.js";
8
- // Pattern for valid config ID: alphanumeric, hyphens, underscores
4
+ import { validateFileConfigFields, validateSettings, } from "./validators/shared.js";
5
+ import { validateGroups, validateConditionalGroups, } from "./validators/group-validator.js";
6
+ import { validateRepoEntry } from "./validators/repo-entry-validator.js";
9
7
  const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
10
8
  const CONFIG_ID_MAX_LENGTH = 64;
11
- /**
12
- * Check if a string looks like a valid git URL.
13
- * Supports SSH (git@host:path) and HTTPS (https://host/path) formats.
14
- */
15
- function isValidGitUrl(url) {
16
- // SSH format: git@hostname:path OR HTTPS format: https://hostname/path
17
- return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
18
- }
19
- /**
20
- * Check if a git URL points to GitHub (github.com).
21
- * Used to reject GitHub URLs as migration sources (not supported).
22
- */
23
- function isGitHubUrl(url, githubHosts) {
24
- const hosts = ["github.com", ...(githubHosts ?? [])];
25
- for (const host of hosts) {
26
- if (url.startsWith(`git@${host}:`) ||
27
- url.match(new RegExp(`^https?://${escapeRegExp(host)}/`))) {
28
- return true;
29
- }
30
- }
31
- return false;
32
- }
33
- function getGitDisplayName(git) {
34
- if (Array.isArray(git)) {
35
- return git[0] || "unknown";
36
- }
37
- return git;
38
- }
39
- /**
40
- * Validate file config fields shared between root files and per-repo overrides.
41
- */
42
- function validateFileConfigFields(fileConfig, fileName, context) {
43
- if (fileConfig.content !== undefined) {
44
- const hasText = isTextContent(fileConfig.content);
45
- const hasObject = isObjectContent(fileConfig.content);
46
- if (!hasText && !hasObject) {
47
- throw new ValidationError(`${context} file '${fileName}' content must be an object, string, or array of strings`);
48
- }
49
- const isStructured = isStructuredFileExtension(fileName);
50
- if (isStructured && hasText) {
51
- throw new ValidationError(`${context} file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
52
- }
53
- if (!isStructured && hasObject) {
54
- 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.`);
55
- }
56
- }
57
- if (fileConfig.mergeStrategy !== undefined &&
58
- !VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
59
- throw new ValidationError(`${context} file '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
60
- }
61
- const booleanFields = [
62
- "createOnly",
63
- "executable",
64
- "template",
65
- "deleteOrphaned",
66
- ];
67
- for (const field of booleanFields) {
68
- if (fileConfig[field] !== undefined &&
69
- typeof fileConfig[field] !== "boolean") {
70
- throw new ValidationError(`${context} file '${fileName}' ${field} must be a boolean`);
71
- }
72
- }
73
- const stringFields = ["schemaUrl"];
74
- for (const field of stringFields) {
75
- if (fileConfig[field] !== undefined &&
76
- typeof fileConfig[field] !== "string") {
77
- throw new ValidationError(`${context} file '${fileName}' ${field} must be a string`);
78
- }
79
- }
80
- if (fileConfig.header !== undefined) {
81
- if (typeof fileConfig.header !== "string" &&
82
- (!Array.isArray(fileConfig.header) ||
83
- !fileConfig.header.every((h) => typeof h === "string"))) {
84
- throw new ValidationError(`${context} file '${fileName}' header must be a string or array of strings`);
85
- }
86
- }
87
- if (fileConfig.vars !== undefined) {
88
- if (!isPlainObject(fileConfig.vars)) {
89
- throw new ValidationError(`${context} file '${fileName}' vars must be an object with string values`);
90
- }
91
- for (const [key, value] of Object.entries(fileConfig.vars)) {
92
- if (typeof value !== "string") {
93
- throw new ValidationError(`${context} file '${fileName}' vars.${key} must be a string`);
94
- }
95
- }
96
- }
97
- }
98
- /**
99
- * Validates a single label configuration.
100
- */
101
- function validateLabel(label, name, context) {
102
- if (!isPlainObject(label)) {
103
- throw new ValidationError(`${context}: label '${name}' must be an object`);
104
- }
105
- const l = label;
106
- if (typeof l.color !== "string" || !/^#?[0-9a-fA-F]{6}$/.test(l.color)) {
107
- throw new ValidationError(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
108
- }
109
- if (l.description !== undefined) {
110
- if (typeof l.description !== "string") {
111
- throw new ValidationError(`${context}: label '${name}' description must be a string`);
112
- }
113
- if (l.description.length > 100) {
114
- throw new ValidationError(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
115
- }
116
- }
117
- if (l.new_name !== undefined && typeof l.new_name !== "string") {
118
- throw new ValidationError(`${context}: label '${name}' new_name must be a string`);
119
- }
120
- }
121
- function buildRootSettingsContext(config) {
122
- return {
123
- rulesetNames: config.settings?.rulesets
124
- ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
125
- : [],
126
- hasRepoSettings: config.settings?.repo !== undefined && config.settings.repo !== false,
127
- hasCodeScanningSettings: config.settings?.codeScanning !== undefined &&
128
- config.settings.codeScanning !== false,
129
- labelNames: config.settings?.labels
130
- ? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
131
- : [],
132
- };
133
- }
134
- /**
135
- * Validates settings object containing rulesets, labels, and repo settings.
136
- */
137
- function validateSettingsRulesets(settings, context, rootCtx) {
138
- if (settings.rulesets === undefined)
139
- return;
140
- if (!isPlainObject(settings.rulesets)) {
141
- throw new ValidationError(`${context}: rulesets must be an object`);
142
- }
143
- const rulesets = settings.rulesets;
144
- for (const [name, ruleset] of Object.entries(rulesets)) {
145
- if (name === "inherit")
146
- continue;
147
- if (ruleset === false) {
148
- if (rootCtx && !rootCtx.rulesetNames.includes(name)) {
149
- throw new ValidationError(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
150
- }
151
- continue;
152
- }
153
- validateRuleset(ruleset, name, context);
154
- }
155
- }
156
- function validateSettingsLabels(settings, context, rootCtx) {
157
- if (settings.labels === undefined)
158
- return;
159
- if (!isPlainObject(settings.labels)) {
160
- throw new ValidationError(`${context}: labels must be an object`);
161
- }
162
- const labels = settings.labels;
163
- for (const [name, label] of Object.entries(labels)) {
164
- if (name === "inherit")
165
- continue;
166
- if (label === false) {
167
- if (rootCtx && !rootCtx.labelNames.includes(name)) {
168
- throw new ValidationError(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
169
- }
170
- continue;
171
- }
172
- validateLabel(label, name, context);
173
- }
174
- }
175
- function validateSettingsDeleteOrphaned(settings, context) {
176
- if (settings.deleteOrphaned !== undefined &&
177
- typeof settings.deleteOrphaned !== "boolean") {
178
- throw new ValidationError(`${context}: settings.deleteOrphaned must be a boolean`);
179
- }
180
- }
181
- function validateSettingsRepo(settings, context, rootCtx) {
182
- if (settings.repo === undefined)
183
- return;
184
- if (settings.repo === false) {
185
- if (!rootCtx) {
186
- throw new ValidationError(`${context}: repo: false is not valid at root level. Define repo settings or remove the field.`);
187
- }
188
- if (!rootCtx.hasRepoSettings) {
189
- throw new ValidationError(`${context}: Cannot opt out of repo settings — not defined in root settings.repo`);
190
- }
191
- return;
192
- }
193
- validateRepoSettings(settings.repo, context);
194
- }
195
- function validateSettingsCodeScanning(settings, context, rootCtx) {
196
- if (settings.codeScanning === undefined)
197
- return;
198
- if (settings.codeScanning === false) {
199
- if (!rootCtx) {
200
- throw new ValidationError(`${context}: codeScanning: false is not valid at root level. Define codeScanning settings or remove the field.`);
201
- }
202
- if (!rootCtx.hasCodeScanningSettings) {
203
- throw new ValidationError(`${context}: Cannot opt out of code scanning settings — not defined in root settings.codeScanning`);
204
- }
205
- return;
206
- }
207
- validateCodeScanningSettings(settings.codeScanning, `${context} codeScanning`);
208
- }
209
- function validateSettings(settings, context, rootCtx) {
210
- if (!isPlainObject(settings)) {
211
- throw new ValidationError(`${context}: settings must be an object`);
212
- }
213
- validateSettingsRulesets(settings, context, rootCtx);
214
- validateSettingsLabels(settings, context, rootCtx);
215
- validateSettingsDeleteOrphaned(settings, context);
216
- validateSettingsRepo(settings, context, rootCtx);
217
- validateSettingsCodeScanning(settings, context, rootCtx);
218
- }
219
- const VALID_CODE_SCANNING_STATES = ["configured", "not-configured"];
220
- const VALID_CODE_SCANNING_QUERY_SUITES = ["default", "extended"];
221
- const VALID_CODE_SCANNING_LANGUAGES = [
222
- "actions",
223
- "c-cpp",
224
- "csharp",
225
- "go",
226
- "java-kotlin",
227
- "javascript-typescript",
228
- "python",
229
- "ruby",
230
- "swift",
231
- ];
232
- function validateCodeScanningSettings(settings, context) {
233
- if (!isPlainObject(settings)) {
234
- throw new ValidationError(`${context}: must be an object`);
235
- }
236
- if (settings.state === undefined) {
237
- throw new ValidationError(`${context}: state is required`);
238
- }
239
- if (!VALID_CODE_SCANNING_STATES.includes(settings.state)) {
240
- throw new ValidationError(`${context}: state must be one of: ${VALID_CODE_SCANNING_STATES.join(", ")}`);
241
- }
242
- if (settings.querySuite !== undefined &&
243
- !VALID_CODE_SCANNING_QUERY_SUITES.includes(settings.querySuite)) {
244
- throw new ValidationError(`${context}: querySuite must be one of: ${VALID_CODE_SCANNING_QUERY_SUITES.join(", ")}`);
245
- }
246
- if (settings.languages !== undefined) {
247
- if (!Array.isArray(settings.languages)) {
248
- throw new ValidationError(`${context}: languages must be an array`);
249
- }
250
- for (const lang of settings.languages) {
251
- if (!VALID_CODE_SCANNING_LANGUAGES.includes(lang)) {
252
- throw new ValidationError(`${context}: invalid language "${lang}". Valid languages: ${VALID_CODE_SCANNING_LANGUAGES.join(", ")}`);
253
- }
254
- }
255
- }
256
- }
257
9
  function validateConfigId(config) {
258
10
  if (!config.id || typeof config.id !== "string") {
259
11
  throw new ValidationError("Config requires an 'id' field. This unique identifier is used to namespace managed files in .xfg.json");
@@ -322,342 +74,6 @@ function validatePrOptions(config) {
322
74
  }
323
75
  }
324
76
  }
325
- /**
326
- * Validates the extends field on a single group definition.
327
- * Checks type, self-reference, and that all referenced groups exist.
328
- */
329
- function validateGroupExtends(groupName, extends_, groupNames) {
330
- // Type check
331
- if (typeof extends_ === "string") {
332
- if (extends_.length === 0) {
333
- throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
334
- }
335
- // Self-reference
336
- if (extends_ === groupName) {
337
- throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
338
- }
339
- // Existence
340
- if (!groupNames.has(extends_)) {
341
- throw new ValidationError(`groups.${groupName}: extends references undefined group '${extends_}'`);
342
- }
343
- }
344
- else if (Array.isArray(extends_)) {
345
- if (extends_.length === 0) {
346
- throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
347
- }
348
- const seen = new Set();
349
- for (const entry of extends_) {
350
- if (typeof entry !== "string") {
351
- throw new ValidationError(`groups.${groupName}: 'extends' array entries must be strings`);
352
- }
353
- if (entry.length === 0) {
354
- throw new ValidationError(`groups.${groupName}: 'extends' array entries must be non-empty strings`);
355
- }
356
- if (entry === groupName) {
357
- throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
358
- }
359
- if (!groupNames.has(entry)) {
360
- throw new ValidationError(`groups.${groupName}: extends references undefined group '${entry}'`);
361
- }
362
- if (seen.has(entry)) {
363
- throw new ValidationError(`groups.${groupName}: duplicate '${entry}' in extends`);
364
- }
365
- seen.add(entry);
366
- }
367
- }
368
- else {
369
- throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
370
- }
371
- }
372
- /**
373
- * Detects circular extends chains across all groups.
374
- * Reuses resolveExtendsChain from extends-resolver.ts to avoid
375
- * duplicating the chain-walking logic. Converts thrown errors
376
- * to ValidationError.
377
- */
378
- function validateNoCircularExtends(groups) {
379
- for (const name of Object.keys(groups)) {
380
- if (!groups[name].extends)
381
- continue;
382
- try {
383
- resolveExtendsChain(name, groups);
384
- }
385
- catch (error) {
386
- throw new ValidationError(error instanceof Error ? error.message : String(error));
387
- }
388
- }
389
- }
390
- function validateGroups(config) {
391
- if (config.groups === undefined)
392
- return;
393
- if (!isPlainObject(config.groups)) {
394
- throw new ValidationError("groups must be an object");
395
- }
396
- const rootCtx = buildRootSettingsContext(config);
397
- const groupNames = new Set(Object.keys(config.groups));
398
- for (const [groupName, group] of Object.entries(config.groups)) {
399
- if (groupName === "inherit") {
400
- throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
401
- }
402
- if (groupName === "extends") {
403
- throw new ValidationError("'extends' is a reserved key and cannot be used as a group name");
404
- }
405
- // Validate extends field
406
- if (group.extends !== undefined) {
407
- validateGroupExtends(groupName, group.extends, groupNames);
408
- }
409
- if (group.files) {
410
- for (const [fileName, fileConfig] of Object.entries(group.files)) {
411
- if (fileName === "inherit")
412
- continue;
413
- if (fileConfig === false)
414
- continue;
415
- if (fileConfig === undefined)
416
- continue;
417
- validateFileConfigFields(fileConfig, fileName, `groups.${groupName}:`);
418
- }
419
- }
420
- if (group.settings !== undefined) {
421
- validateSettings(group.settings, `groups.${groupName}`, rootCtx);
422
- }
423
- }
424
- // Validate no circular extends after individual validation
425
- validateNoCircularExtends(config.groups);
426
- }
427
- function validateGroupRefArray(arr, fieldName, ctx, groupNames) {
428
- if (!Array.isArray(arr) || arr.length === 0) {
429
- throw new ValidationError(`${ctx}: '${fieldName}' must be a non-empty array of strings`);
430
- }
431
- const seen = new Set();
432
- for (const name of arr) {
433
- if (typeof name !== "string") {
434
- throw new ValidationError(`${ctx}: '${fieldName}' entries must be strings`);
435
- }
436
- if (!groupNames.includes(name)) {
437
- throw new ValidationError(`${ctx}: group '${name}' in ${fieldName} is not defined in root 'groups'`);
438
- }
439
- if (seen.has(name)) {
440
- throw new ValidationError(`${ctx}: duplicate group '${name}' in ${fieldName}`);
441
- }
442
- seen.add(name);
443
- }
444
- }
445
- function validateConditionalGroups(config) {
446
- if (config.conditionalGroups === undefined)
447
- return;
448
- if (!Array.isArray(config.conditionalGroups)) {
449
- throw new ValidationError("conditionalGroups must be an array");
450
- }
451
- const rootCtx = buildRootSettingsContext(config);
452
- const groupNames = config.groups ? Object.keys(config.groups) : [];
453
- for (let i = 0; i < config.conditionalGroups.length; i++) {
454
- const entry = config.conditionalGroups[i];
455
- const ctx = `conditionalGroups[${i}]`;
456
- // Validate 'when' clause
457
- if (!entry.when || !isPlainObject(entry.when)) {
458
- throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
459
- }
460
- const { allOf, anyOf, noneOf } = entry.when;
461
- if (!allOf && !anyOf && !noneOf) {
462
- throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf', 'anyOf', or 'noneOf'`);
463
- }
464
- if (allOf !== undefined) {
465
- validateGroupRefArray(allOf, "allOf", ctx, groupNames);
466
- }
467
- if (anyOf !== undefined) {
468
- validateGroupRefArray(anyOf, "anyOf", ctx, groupNames);
469
- }
470
- if (noneOf !== undefined) {
471
- validateGroupRefArray(noneOf, "noneOf", ctx, groupNames);
472
- }
473
- // Cross-operator overlap: noneOf must not share groups with allOf or anyOf
474
- if (noneOf) {
475
- const noneOfSet = new Set(noneOf);
476
- if (allOf) {
477
- for (const g of allOf) {
478
- if (noneOfSet.has(g)) {
479
- throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with allOf (contradictory condition)`);
480
- }
481
- }
482
- }
483
- if (anyOf) {
484
- for (const g of anyOf) {
485
- if (noneOfSet.has(g)) {
486
- throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with anyOf (contradictory condition)`);
487
- }
488
- }
489
- }
490
- }
491
- // Validate files
492
- if (entry.files) {
493
- for (const [fileName, fileConfig] of Object.entries(entry.files)) {
494
- if (fileName === "inherit")
495
- continue;
496
- if (fileConfig === false)
497
- continue;
498
- if (fileConfig === undefined)
499
- continue;
500
- validateFileConfigFields(fileConfig, fileName, `${ctx}:`);
501
- }
502
- }
503
- // Validate settings
504
- if (entry.settings !== undefined) {
505
- validateSettings(entry.settings, ctx, rootCtx);
506
- }
507
- }
508
- }
509
- function validateRepoGitField(repo, index) {
510
- if (!repo.git) {
511
- throw new ValidationError(`Repo at index ${index} missing required field: git`);
512
- }
513
- if (Array.isArray(repo.git) && repo.git.length === 0) {
514
- throw new ValidationError(`Repo at index ${index} has empty git array`);
515
- }
516
- return getGitDisplayName(repo.git);
517
- }
518
- function validateRepoOrigins(config, repo, repoLabel) {
519
- if (repo.upstream !== undefined && repo.source !== undefined) {
520
- throw new ValidationError(`Repo ${repoLabel}: 'upstream' and 'source' are mutually exclusive. ` +
521
- `Use 'upstream' to fork, or 'source' to migrate, not both.`);
522
- }
523
- if (repo.upstream !== undefined) {
524
- if (typeof repo.upstream !== "string") {
525
- throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a string`);
526
- }
527
- if (!isValidGitUrl(repo.upstream)) {
528
- throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a valid git URL ` +
529
- `(SSH: git@host:path or HTTPS: https://host/path)`);
530
- }
531
- }
532
- if (repo.source !== undefined) {
533
- if (typeof repo.source !== "string") {
534
- throw new ValidationError(`Repo ${repoLabel}: 'source' must be a string`);
535
- }
536
- if (!isValidGitUrl(repo.source)) {
537
- throw new ValidationError(`Repo ${repoLabel}: 'source' must be a valid git URL ` +
538
- `(SSH: git@host:path or HTTPS: https://host/path)`);
539
- }
540
- if (isGitHubUrl(repo.source, config.githubHosts)) {
541
- throw new ValidationError(`Repo ${repoLabel}: 'source' cannot be a GitHub URL. ` +
542
- `Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
543
- }
544
- }
545
- }
546
- function validateRepoGroups(config, repo, index) {
547
- if (repo.groups === undefined)
548
- return;
549
- if (!Array.isArray(repo.groups) ||
550
- !repo.groups.every((g) => typeof g === "string")) {
551
- throw new ValidationError(`Repo at index ${index}: groups must be an array of strings`);
552
- }
553
- const seen = new Set();
554
- for (const groupName of repo.groups) {
555
- if (!config.groups || !config.groups[groupName]) {
556
- throw new ValidationError(`Repo at index ${index}: group '${groupName}' is not defined in root 'groups'`);
557
- }
558
- if (seen.has(groupName)) {
559
- throw new ValidationError(`Repo at index ${index}: duplicate group '${groupName}'`);
560
- }
561
- seen.add(groupName);
562
- }
563
- }
564
- function validateRepoFiles(config, repo, index, repoLabel) {
565
- if (!repo.files)
566
- return;
567
- if (!isPlainObject(repo.files)) {
568
- throw new ValidationError(`Repo at index ${index}: files must be an object`);
569
- }
570
- const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
571
- if (repo.groups && config.groups) {
572
- const expandedGroups = expandRepoGroups(repo.groups, config.groups);
573
- for (const groupName of expandedGroups) {
574
- const group = config.groups[groupName];
575
- if (group?.files) {
576
- for (const fileName of Object.keys(group.files)) {
577
- if (fileName !== "inherit")
578
- knownFiles.add(fileName);
579
- }
580
- }
581
- }
582
- }
583
- if (config.conditionalGroups) {
584
- for (const cg of config.conditionalGroups) {
585
- if (cg.files) {
586
- for (const fileName of Object.keys(cg.files)) {
587
- if (fileName !== "inherit")
588
- knownFiles.add(fileName);
589
- }
590
- }
591
- }
592
- }
593
- for (const fileName of Object.keys(repo.files)) {
594
- if (fileName === "inherit") {
595
- const inheritValue = repo.files.inherit;
596
- if (typeof inheritValue !== "boolean") {
597
- throw new ValidationError(`Repo at index ${index}: files.inherit must be a boolean`);
598
- }
599
- continue;
600
- }
601
- if (!knownFiles.has(fileName)) {
602
- throw new ValidationError(`Repo at index ${index} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group.`);
603
- }
604
- const fileOverride = repo.files[fileName];
605
- if (fileOverride === false) {
606
- continue;
607
- }
608
- if (fileOverride.override && !fileOverride.content) {
609
- throw new ValidationError(`Repo ${repoLabel} has override: true for file '${fileName}' but no content defined. ` +
610
- `Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
611
- }
612
- validateFileConfigFields(fileOverride, fileName, `Repo ${repoLabel}:`);
613
- }
614
- }
615
- function enrichSettingsContext(rootCtx, settings) {
616
- if (!settings)
617
- return;
618
- if (settings.rulesets) {
619
- for (const name of Object.keys(settings.rulesets)) {
620
- if (name !== "inherit")
621
- rootCtx.rulesetNames.push(name);
622
- }
623
- }
624
- if (settings.labels) {
625
- for (const name of Object.keys(settings.labels)) {
626
- if (name !== "inherit")
627
- rootCtx.labelNames.push(name);
628
- }
629
- }
630
- if (settings.repo !== undefined && settings.repo !== false) {
631
- rootCtx.hasRepoSettings = true;
632
- }
633
- if (settings.codeScanning !== undefined && settings.codeScanning !== false) {
634
- rootCtx.hasCodeScanningSettings = true;
635
- }
636
- }
637
- function validateRepoSettingsEntry(config, repo, repoLabel) {
638
- if (repo.settings === undefined)
639
- return;
640
- const rootCtx = buildRootSettingsContext(config);
641
- if (repo.groups && config.groups) {
642
- const expandedGroups = expandRepoGroups(repo.groups, config.groups);
643
- for (const groupName of expandedGroups) {
644
- enrichSettingsContext(rootCtx, config.groups[groupName]?.settings);
645
- }
646
- }
647
- if (config.conditionalGroups) {
648
- for (const cg of config.conditionalGroups) {
649
- enrichSettingsContext(rootCtx, cg.settings);
650
- }
651
- }
652
- validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
653
- }
654
- function validateRepoEntry(config, repo, index) {
655
- const repoLabel = validateRepoGitField(repo, index);
656
- validateRepoOrigins(config, repo, repoLabel);
657
- validateRepoGroups(config, repo, index);
658
- validateRepoFiles(config, repo, index, repoLabel);
659
- validateRepoSettingsEntry(config, repo, repoLabel);
660
- }
661
77
  function hasGroupFiles(config) {
662
78
  return (isPlainObject(config.groups) &&
663
79
  Object.values(config.groups).some((g) => g.files &&
@@ -668,9 +84,13 @@ function hasConditionalGroupFiles(config) {
668
84
  config.conditionalGroups.some((cg) => cg.files &&
669
85
  Object.keys(cg.files).filter((k) => k !== "inherit" && cg.files[k] !== false).length > 0));
670
86
  }
671
- function hasConditionalGroupSettings(config, predicate) {
87
+ function hasConditionalGroupSettingsPresent(config) {
88
+ return (Array.isArray(config.conditionalGroups) &&
89
+ config.conditionalGroups.some((cg) => cg.settings && isPlainObject(cg.settings)));
90
+ }
91
+ function hasConditionalGroupSettingsActionable(config) {
672
92
  return (Array.isArray(config.conditionalGroups) &&
673
- config.conditionalGroups.some((cg) => cg.settings && predicate(cg.settings)));
93
+ config.conditionalGroups.some((cg) => cg.settings && hasActionableSettings(cg.settings)));
674
94
  }
675
95
  function hasConditionalGroupPR(config) {
676
96
  return (Array.isArray(config.conditionalGroups) &&
@@ -688,7 +108,7 @@ export function validateRawConfig(config) {
688
108
  const hasGrpSettings = isPlainObject(config.groups) &&
689
109
  Object.values(config.groups).some((g) => g.settings && isPlainObject(g.settings));
690
110
  const hasCondGrpFiles = hasConditionalGroupFiles(config);
691
- const hasCondGrpSettings = hasConditionalGroupSettings(config, isPlainObject);
111
+ const hasCondGrpSettings = hasConditionalGroupSettingsPresent(config);
692
112
  const hasCondGrpPR = hasConditionalGroupPR(config);
693
113
  if (!hasFiles &&
694
114
  !hasSettings &&
@@ -717,9 +137,6 @@ export function validateRawConfig(config) {
717
137
  validateRepoEntry(config, config.repos[i], i);
718
138
  }
719
139
  }
720
- // =============================================================================
721
- // Command-Specific Validators
722
- // =============================================================================
723
140
  /**
724
141
  * Validates that config is suitable for the sync command.
725
142
  * @throws ValidationError if neither files nor settings are present
@@ -732,7 +149,7 @@ export function validateForSync(config) {
732
149
  const hasGroupSettings = isPlainObject(config.groups) &&
733
150
  Object.values(config.groups).some((g) => g.settings && hasActionableSettings(g.settings));
734
151
  const hasCondGrpFiles = hasConditionalGroupFiles(config);
735
- const hasCondGrpSettings = hasConditionalGroupSettings(config, hasActionableSettings);
152
+ const hasCondGrpSettings = hasConditionalGroupSettingsActionable(config);
736
153
  const hasCondGrpPR = hasConditionalGroupPR(config);
737
154
  if (!hasRootFiles &&
738
155
  !hasGrpFiles &&
@@ -746,9 +163,6 @@ export function validateForSync(config) {
746
163
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
747
164
  }
748
165
  }
749
- /**
750
- * Checks if settings contain actionable configuration.
751
- */
752
166
  export function hasActionableSettings(settings) {
753
167
  if (!settings)
754
168
  return false;
@@ -763,7 +177,7 @@ export function hasActionableSettings(settings) {
763
177
  Object.keys(settings.labels).filter((k) => k !== "inherit").length > 0) {
764
178
  return true;
765
179
  }
766
- if (settings.codeScanning && typeof settings.codeScanning === "object") {
180
+ if (settings.codeScanning) {
767
181
  return true;
768
182
  }
769
183
  return false;
@@ -2,7 +2,8 @@ import { isTextContent } from "../merge.js";
2
2
  import { isPlainObject } from "../../shared/type-guards.js";
3
3
  export { isTextContent };
4
4
  export { isPlainObject as isObjectContent };
5
- declare const VALID_STRATEGIES: string[];
5
+ export declare function validValues<T extends string>(values: readonly T[]): readonly string[];
6
+ declare const VALID_STRATEGIES: readonly string[];
6
7
  /**
7
8
  * Check if file extension is for structured output (JSON/YAML).
8
9
  */