@aspruyt/xfg 5.3.1 → 5.5.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 (93) hide show
  1. package/dist/{shared → cli}/branch-utils.js +1 -1
  2. package/dist/cli/settings-report-builder.d.ts +6 -1
  3. package/dist/cli/settings-report-builder.js +19 -0
  4. package/dist/cli/sync-command.js +38 -23
  5. package/dist/cli/sync-report-builder.d.ts +2 -1
  6. package/dist/cli/types.d.ts +5 -2
  7. package/dist/config/config-merger.d.ts +6 -0
  8. package/dist/config/config-merger.js +66 -0
  9. package/dist/config/index.d.ts +1 -1
  10. package/dist/config/loader.d.ts +2 -2
  11. package/dist/config/loader.js +50 -6
  12. package/dist/config/merge.d.ts +4 -0
  13. package/dist/config/merge.js +47 -23
  14. package/dist/config/normalizer.js +31 -1
  15. package/dist/config/types.d.ts +12 -0
  16. package/dist/config/validator.js +104 -46
  17. package/dist/config/validators/ruleset-validator.js +46 -16
  18. package/dist/lifecycle/ado-migration-source.d.ts +1 -1
  19. package/dist/lifecycle/github-lifecycle-provider.d.ts +1 -1
  20. package/dist/lifecycle/index.d.ts +1 -1
  21. package/dist/lifecycle/lifecycle-helpers.d.ts +2 -1
  22. package/dist/lifecycle/lifecycle-helpers.js +1 -1
  23. package/dist/lifecycle/repo-lifecycle-factory.d.ts +1 -1
  24. package/dist/lifecycle/repo-lifecycle-manager.js +1 -1
  25. package/dist/output/types.d.ts +2 -1
  26. package/dist/settings/code-scanning/diff.d.ts +19 -0
  27. package/dist/settings/code-scanning/diff.js +75 -0
  28. package/dist/settings/code-scanning/formatter.d.ts +17 -0
  29. package/dist/settings/code-scanning/formatter.js +37 -0
  30. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +19 -0
  31. package/dist/settings/code-scanning/github-code-scanning-strategy.js +20 -0
  32. package/dist/settings/code-scanning/index.d.ts +3 -0
  33. package/dist/settings/code-scanning/index.js +4 -0
  34. package/dist/settings/code-scanning/processor.d.ts +20 -0
  35. package/dist/settings/code-scanning/processor.js +81 -0
  36. package/dist/settings/code-scanning/types.d.ts +22 -0
  37. package/dist/settings/code-scanning/types.js +1 -0
  38. package/dist/settings/index.d.ts +1 -0
  39. package/dist/settings/index.js +2 -0
  40. package/dist/settings/labels/github-labels-strategy.d.ts +2 -2
  41. package/dist/settings/repo-settings/diff.d.ts +2 -1
  42. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +1 -1
  43. package/dist/settings/repo-settings/processor.d.ts +4 -2
  44. package/dist/settings/repo-settings/processor.js +14 -16
  45. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +2 -2
  46. package/dist/settings/rulesets/processor.d.ts +1 -1
  47. package/dist/shared/cleanup-utils.d.ts +8 -0
  48. package/dist/shared/cleanup-utils.js +15 -0
  49. package/dist/shared/gh-api-utils.d.ts +2 -2
  50. package/dist/shared/gh-api-utils.js +7 -4
  51. package/dist/shared/repo-detector.d.ts +1 -20
  52. package/dist/shared/repo-detector.js +3 -45
  53. package/dist/shared/repo-info-utils.d.ts +20 -0
  54. package/dist/shared/repo-info-utils.js +46 -0
  55. package/dist/shared/repo-metadata-provider.d.ts +27 -0
  56. package/dist/shared/repo-metadata-provider.js +20 -0
  57. package/dist/shared/retry-utils.d.ts +1 -1
  58. package/dist/shared/retry-utils.js +1 -1
  59. package/dist/shared/type-guards.d.ts +0 -8
  60. package/dist/shared/type-guards.js +0 -14
  61. package/dist/shared/xfg-template.js +2 -2
  62. package/dist/sync/auth-options-builder.d.ts +1 -1
  63. package/dist/sync/branch-manager.d.ts +3 -7
  64. package/dist/sync/commit-push-manager.d.ts +1 -1
  65. package/dist/sync/commit-push-manager.js +2 -2
  66. package/dist/sync/diff-utils.js +0 -7
  67. package/dist/sync/manifest.js +1 -8
  68. package/dist/sync/repository-processor.d.ts +1 -1
  69. package/dist/sync/repository-processor.js +1 -2
  70. package/dist/sync/repository-session.d.ts +2 -2
  71. package/dist/sync/repository-session.js +1 -1
  72. package/dist/sync/sync-workflow.d.ts +1 -1
  73. package/dist/sync/sync-workflow.js +1 -1
  74. package/dist/vcs/authenticated-git-ops.js +2 -2
  75. package/dist/vcs/azure-pr-strategy.d.ts +1 -1
  76. package/dist/vcs/azure-pr-strategy.js +2 -1
  77. package/dist/vcs/commit-strategy-selector.d.ts +2 -2
  78. package/dist/vcs/git-commit-strategy.d.ts +1 -1
  79. package/dist/vcs/git-ops.d.ts +1 -1
  80. package/dist/vcs/git-ops.js +1 -1
  81. package/dist/vcs/github-pr-strategy.js +3 -2
  82. package/dist/vcs/gitlab-pr-strategy.d.ts +1 -1
  83. package/dist/vcs/gitlab-pr-strategy.js +4 -3
  84. package/dist/vcs/graphql-commit-strategy.d.ts +1 -1
  85. package/dist/vcs/index.d.ts +2 -0
  86. package/dist/vcs/index.js +3 -0
  87. package/dist/vcs/pr-creator.d.ts +2 -2
  88. package/dist/vcs/pr-creator.js +1 -1
  89. package/dist/vcs/pr-strategy-factory.d.ts +2 -2
  90. package/dist/vcs/pr-strategy.d.ts +4 -2
  91. package/dist/vcs/pr-strategy.js +7 -2
  92. package/package.json +2 -1
  93. /package/dist/{shared → cli}/branch-utils.d.ts +0 -0
@@ -1,4 +1,4 @@
1
- import { ValidationError } from "./errors.js";
1
+ import { ValidationError } from "../shared/errors.js";
2
2
  export function sanitizeBranchName(fileName) {
3
3
  return fileName
4
4
  .toLowerCase()
@@ -1,5 +1,5 @@
1
1
  import type { SettingsReport } from "../output/settings-report.js";
2
- import { type RepoSettingsPlanEntry, type RulesetPlanEntry, type LabelsPlanEntry } from "../settings/index.js";
2
+ import { type RepoSettingsPlanEntry, type RulesetPlanEntry, type LabelsPlanEntry, type CodeScanningPlanEntry } from "../settings/index.js";
3
3
  /**
4
4
  * Result from processing a repository's settings and rulesets.
5
5
  * Used to collect results during settings command execution.
@@ -21,6 +21,11 @@ export interface ProcessorResults {
21
21
  entries?: LabelsPlanEntry[];
22
22
  };
23
23
  };
24
+ codeScanningResult?: {
25
+ planOutput?: {
26
+ entries?: CodeScanningPlanEntry[];
27
+ };
28
+ };
24
29
  error?: string;
25
30
  }
26
31
  export declare function buildSettingsReport(results: ProcessorResults[]): SettingsReport;
@@ -66,6 +66,25 @@ export function buildSettingsReport(results) {
66
66
  totals.labels.update += counts.update;
67
67
  totals.labels.delete += counts.delete;
68
68
  }
69
+ // Convert code scanning processor output
70
+ if (result.codeScanningResult?.planOutput?.entries) {
71
+ let csCreates = 0;
72
+ let csUpdates = 0;
73
+ for (const entry of result.codeScanningResult.planOutput.entries) {
74
+ repoChanges.settings.push({
75
+ name: `codeScanning.${entry.property}`,
76
+ action: entry.action,
77
+ oldValue: entry.oldValue,
78
+ newValue: entry.newValue ?? null,
79
+ });
80
+ if (entry.action === "create")
81
+ csCreates++;
82
+ if (entry.action === "update")
83
+ csUpdates++;
84
+ }
85
+ totals.settings.create += csCreates;
86
+ totals.settings.update += csUpdates;
87
+ }
69
88
  if (result.error) {
70
89
  repoChanges.error = result.error;
71
90
  }
@@ -3,10 +3,11 @@ import { existsSync } from "node:fs";
3
3
  import { loadRawConfig, normalizeConfig, validateForSync, } from "../config/index.js";
4
4
  import { ValidationError, SyncError } from "../shared/errors.js";
5
5
  import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../shared/repo-detector.js";
6
- import { sanitizeBranchName, validateBranchName, } from "../shared/branch-utils.js";
6
+ import { sanitizeBranchName, validateBranchName } from "./branch-utils.js";
7
7
  import { createTokenManager } from "../vcs/index.js";
8
8
  import { RepositoryProcessor } from "../sync/index.js";
9
- import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, } from "../settings/index.js";
9
+ import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, CodeScanningProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, GitHubCodeScanningStrategy, } from "../settings/index.js";
10
+ import { GitHubRepoMetadataProvider } from "../shared/repo-metadata-provider.js";
10
11
  import { ShellCommandExecutor } from "../shared/command-executor.js";
11
12
  import { Logger } from "../shared/logger.js";
12
13
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
@@ -24,12 +25,18 @@ function createDefaultRulesetProcessorFactory() {
24
25
  }
25
26
  function createDefaultRepoSettingsProcessorFactory() {
26
27
  const cwd = process.cwd();
27
- return () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(getDefaultExecutor(), { cwd }));
28
+ const executor = getDefaultExecutor();
29
+ return () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(executor, { cwd }), new GitHubRepoMetadataProvider(executor, { cwd }));
28
30
  }
29
31
  function createDefaultLabelsProcessorFactory() {
30
32
  const cwd = process.cwd();
31
33
  return () => new LabelsProcessor(new GitHubLabelsStrategy(getDefaultExecutor(), { cwd }));
32
34
  }
35
+ function createDefaultCodeScanningProcessorFactory() {
36
+ const cwd = process.cwd();
37
+ const executor = getDefaultExecutor();
38
+ return () => new CodeScanningProcessor(new GitHubCodeScanningStrategy(executor, { cwd }), new GitHubRepoMetadataProvider(executor, { cwd }));
39
+ }
33
40
  import { ResultsCollector } from "./results-collector.js";
34
41
  import { buildSettingsReport, } from "./settings-report-builder.js";
35
42
  import { formatSettingsReportCLI } from "../output/settings-report.js";
@@ -40,7 +47,7 @@ import { formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecy
40
47
  import { writeUnifiedSummary } from "../output/unified-summary.js";
41
48
  import { toErrorMessage } from "../shared/type-guards.js";
42
49
  import { resolveGitHubToken } from "../shared/gh-api-utils.js";
43
- import { RepoLifecycleManager, runLifecycleCheck, toCreateRepoSettings, } from "../lifecycle/index.js";
50
+ import { RepoLifecycleManager, runLifecycleCheck, } from "../lifecycle/index.js";
44
51
  function getUniqueFileNames(config) {
45
52
  const fileNames = new Set();
46
53
  for (const repo of config.repos) {
@@ -97,46 +104,53 @@ function logSettingsResult(result, label, repoNumber, repoName, settingsCollecto
97
104
  settingsCollector.appendError(repoName, result.message);
98
105
  }
99
106
  }
107
+ // Each processor returns a subtype of BaseProcessorResult whose planOutput
108
+ // contains both `lines` (for CLI display) and `entries` (for report building).
109
+ // ProcessorResults fields capture only the `entries` slice; the runtime object
110
+ // satisfies both views, so we assign with an explicit per-field cast.
111
+ async function runAndStoreResult(factory, repoConfig, repoInfo, opts, repoName, settingsCollector, assign) {
112
+ const result = await runSettingsProcessor(factory, repoConfig, repoInfo, opts);
113
+ if (!result.skipped) {
114
+ assign(settingsCollector.getOrCreate(repoName), result);
115
+ }
116
+ return result;
117
+ }
100
118
  function buildSettingsDescriptors(ctx) {
101
- const { repoConfig, repoInfo, options, token, repoName, settingsCollector, rulesetProcessorFactory, repoSettingsProcessorFactory, labelsProcessorFactory, } = ctx;
119
+ const { repoConfig, repoInfo, options, token, repoName, settingsCollector, rulesetProcessorFactory, repoSettingsProcessorFactory, labelsProcessorFactory, codeScanningProcessorFactory, } = ctx;
102
120
  const sharedOpts = {
103
121
  dryRun: options.dryRun,
104
122
  noDelete: options.noDelete,
105
123
  token,
106
124
  };
107
- // Each processor returns a subtype of BaseProcessorResult whose planOutput
108
- // contains both `lines` (for CLI display) and `entries` (for report building).
109
- // ProcessorResults fields capture only the `entries` slice; the runtime object
110
- // satisfies both views, so we assign with an explicit per-field cast.
111
- const runAndStore = async (factory, opts, assign) => {
112
- const result = await runSettingsProcessor(factory, repoConfig, repoInfo, opts);
113
- if (!result.skipped) {
114
- assign(settingsCollector.getOrCreate(repoName), result);
115
- }
116
- return result;
117
- };
118
125
  return [
119
126
  {
120
127
  key: "rulesets",
121
128
  label: "Rulesets",
122
- run: () => runAndStore(rulesetProcessorFactory, sharedOpts, (e, r) => {
129
+ run: () => runAndStoreResult(rulesetProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
123
130
  e.rulesetResult = r;
124
131
  }),
125
132
  },
126
133
  {
127
134
  key: "labels",
128
135
  label: "Labels",
129
- run: () => runAndStore(labelsProcessorFactory, sharedOpts, (e, r) => {
136
+ run: () => runAndStoreResult(labelsProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
130
137
  e.labelsResult = r;
131
138
  }),
132
139
  },
133
140
  {
134
141
  key: "repo",
135
142
  label: "Repo Settings",
136
- run: () => runAndStore(repoSettingsProcessorFactory, { dryRun: options.dryRun, token }, (e, r) => {
143
+ run: () => runAndStoreResult(repoSettingsProcessorFactory, repoConfig, repoInfo, { dryRun: options.dryRun, token }, repoName, settingsCollector, (e, r) => {
137
144
  e.settingsResult = r;
138
145
  }),
139
146
  },
147
+ {
148
+ key: "codeScanning",
149
+ label: "Code Scanning",
150
+ run: () => runAndStoreResult(codeScanningProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
151
+ e.codeScanningResult = r;
152
+ }),
153
+ },
140
154
  ];
141
155
  }
142
156
  function runSettingsProcessor(factory, repoConfig, repoInfo, processOptions) {
@@ -269,6 +283,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
269
283
  rulesetProcessorFactory: ctx.rulesetProcessorFactory,
270
284
  repoSettingsProcessorFactory: ctx.repoSettingsProcessorFactory,
271
285
  labelsProcessorFactory: ctx.labelsProcessorFactory,
286
+ codeScanningProcessorFactory: ctx.codeScanningProcessorFactory,
272
287
  });
273
288
  }
274
289
  /**
@@ -278,7 +293,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
278
293
  async function runLifecyclePhase(repo, ctx) {
279
294
  const repoNumber = repo.index + 1;
280
295
  try {
281
- const { outputLines, lifecycleResult } = await runLifecycleCheck(repo.repoConfig, repo.repoInfo, {
296
+ const { outputLines, lifecycleResult, createSettings } = await runLifecycleCheck(repo.repoConfig, repo.repoInfo, {
282
297
  dryRun: ctx.options.dryRun ?? false,
283
298
  resolvedWorkDir: repo.workDir,
284
299
  githubHosts: ctx.config.githubHosts,
@@ -290,7 +305,6 @@ async function runLifecyclePhase(repo, ctx) {
290
305
  for (const line of outputLines) {
291
306
  getLogger().info(line);
292
307
  }
293
- const createSettings = toCreateRepoSettings(ctx.config.settings?.repo);
294
308
  ctx.lifecycleReportInputs.push({
295
309
  repoName: repo.repoName,
296
310
  action: lifecycleResult.action,
@@ -381,10 +395,10 @@ export async function runSync(options, deps = {}) {
381
395
  // Reset module-level singletons to ensure fresh state per invocation
382
396
  _defaultExecutor = undefined;
383
397
  _logger = undefined;
384
- const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), } = deps;
398
+ const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), codeScanningProcessorFactory = createDefaultCodeScanningProcessorFactory(), } = deps;
385
399
  const configPath = resolve(options.config);
386
400
  if (!existsSync(configPath)) {
387
- throw new ValidationError(`Config file not found: ${configPath}`);
401
+ throw new ValidationError(`Config path not found: ${configPath}`);
388
402
  }
389
403
  getLogger().log(`Loading config from: ${configPath}`);
390
404
  if (options.dryRun) {
@@ -432,6 +446,7 @@ export async function runSync(options, deps = {}) {
432
446
  rulesetProcessorFactory,
433
447
  repoSettingsProcessorFactory,
434
448
  labelsProcessorFactory,
449
+ codeScanningProcessorFactory,
435
450
  };
436
451
  for (let i = 0; i < config.repos.length; i++) {
437
452
  await processSingleRepo(config.repos[i], i, ctx);
@@ -1,10 +1,11 @@
1
+ import type { MergeMode } from "../config/index.js";
1
2
  import type { SyncReport, ReportFileChange } from "../output/sync-report.js";
2
3
  interface SyncResultInput {
3
4
  repoName: string;
4
5
  success: boolean;
5
6
  fileChanges: ReportFileChange[];
6
7
  prUrl?: string;
7
- mergeOutcome?: "manual" | "auto" | "force" | "direct";
8
+ mergeOutcome?: MergeMode;
8
9
  error?: string;
9
10
  }
10
11
  export declare function buildSyncReport(results: SyncResultInput[]): SyncReport;
@@ -1,7 +1,7 @@
1
1
  import type { MergeMode, MergeStrategy, RepoConfig } from "../config/index.js";
2
2
  import type { IRepoLifecycleManager } from "../lifecycle/index.js";
3
3
  import type { IRepositoryProcessor } from "../sync/index.js";
4
- import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, BaseProcessorResult } from "../settings/index.js";
4
+ import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, ICodeScanningProcessor, BaseProcessorResult } from "../settings/index.js";
5
5
  import type { RepoInfo } from "../shared/repo-detector.js";
6
6
  import type { ResultsCollector } from "./results-collector.js";
7
7
  export type ProcessorFactory = () => IRepositoryProcessor;
@@ -9,6 +9,7 @@ export type SettingsProcessorFactory<T extends ISettingsProcessor> = () => T;
9
9
  export type RulesetProcessorFactory = SettingsProcessorFactory<IRulesetProcessor>;
10
10
  export type RepoSettingsProcessorFactory = SettingsProcessorFactory<IRepoSettingsProcessor>;
11
11
  export type LabelsProcessorFactory = SettingsProcessorFactory<ILabelsProcessor>;
12
+ export type CodeScanningProcessorFactory = SettingsProcessorFactory<ICodeScanningProcessor>;
12
13
  /**
13
14
  * Dependencies for the sync command (dependency injection).
14
15
  */
@@ -18,6 +19,7 @@ export interface SyncDependencies {
18
19
  rulesetProcessorFactory?: RulesetProcessorFactory;
19
20
  repoSettingsProcessorFactory?: RepoSettingsProcessorFactory;
20
21
  labelsProcessorFactory?: LabelsProcessorFactory;
22
+ codeScanningProcessorFactory?: CodeScanningProcessorFactory;
21
23
  }
22
24
  export interface SharedOptions {
23
25
  config: string;
@@ -41,7 +43,7 @@ export interface SyncResultEntry {
41
43
  diffLines?: string[];
42
44
  }>;
43
45
  prUrl?: string;
44
- mergeOutcome?: "manual" | "auto" | "force" | "direct";
46
+ mergeOutcome?: MergeMode;
45
47
  error?: string;
46
48
  }
47
49
  export interface SettingsResult extends BaseProcessorResult {
@@ -65,4 +67,5 @@ export interface ApplyRepoSettingsContext {
65
67
  rulesetProcessorFactory: NonNullable<SyncDependencies["rulesetProcessorFactory"]>;
66
68
  repoSettingsProcessorFactory: NonNullable<SyncDependencies["repoSettingsProcessorFactory"]>;
67
69
  labelsProcessorFactory: NonNullable<SyncDependencies["labelsProcessorFactory"]>;
70
+ codeScanningProcessorFactory: NonNullable<SyncDependencies["codeScanningProcessorFactory"]>;
68
71
  }
@@ -0,0 +1,6 @@
1
+ import type { RawConfig } from "./types.js";
2
+ export interface ConfigFragment {
3
+ fileName: string;
4
+ config: Partial<RawConfig>;
5
+ }
6
+ export declare function mergeConfigFragments(fragments: ConfigFragment[]): RawConfig;
@@ -0,0 +1,66 @@
1
+ import { ValidationError } from "../shared/errors.js";
2
+ /** Keys that can only appear in one file across a config directory. */
3
+ const SINGLE_FILE_KEYS = [
4
+ "id",
5
+ "files",
6
+ "prOptions",
7
+ "prTemplate",
8
+ "settings",
9
+ "githubHosts",
10
+ "deleteOrphaned",
11
+ ];
12
+ export function mergeConfigFragments(fragments) {
13
+ if (fragments.length === 0) {
14
+ throw new ValidationError("No config fragments to merge");
15
+ }
16
+ const merged = {};
17
+ const singleKeySource = {};
18
+ const allRepos = [];
19
+ const allGroups = {};
20
+ const groupSource = {};
21
+ const allConditionalGroups = [];
22
+ for (const { fileName, config } of fragments) {
23
+ // Enforce single-file keys
24
+ for (const key of SINGLE_FILE_KEYS) {
25
+ if (config[key] !== undefined) {
26
+ if (singleKeySource[key] !== undefined) {
27
+ throw new ValidationError(`'${key}' is defined in both ${singleKeySource[key]} and ${fileName} — this key can only appear in one file`);
28
+ }
29
+ singleKeySource[key] = fileName;
30
+ merged[key] = config[key];
31
+ }
32
+ }
33
+ // Concatenate repos
34
+ if (config.repos) {
35
+ allRepos.push(...config.repos);
36
+ }
37
+ // Merge groups (unique names only)
38
+ if (config.groups) {
39
+ for (const [groupName, groupConfig] of Object.entries(config.groups)) {
40
+ if (groupName in allGroups) {
41
+ throw new ValidationError(`group '${groupName}' is defined in both ${groupSource[groupName]} and ${fileName} — group names must be unique across files`);
42
+ }
43
+ allGroups[groupName] = groupConfig;
44
+ groupSource[groupName] = fileName;
45
+ }
46
+ }
47
+ // Concatenate conditional groups
48
+ if (config.conditionalGroups) {
49
+ allConditionalGroups.push(...config.conditionalGroups);
50
+ }
51
+ }
52
+ if (!merged.id) {
53
+ throw new ValidationError("No 'id' found in any config file — exactly one file must define 'id'");
54
+ }
55
+ if (allRepos.length === 0) {
56
+ throw new ValidationError("No 'repos' found in any config file — at least one file must define 'repos'");
57
+ }
58
+ return {
59
+ ...merged,
60
+ repos: allRepos,
61
+ ...(Object.keys(allGroups).length > 0 ? { groups: allGroups } : {}),
62
+ ...(allConditionalGroups.length > 0
63
+ ? { conditionalGroups: allConditionalGroups }
64
+ : {}),
65
+ };
66
+ }
@@ -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, RawGroupConfig, 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, CodeScanningSettings, CodeScanningState, CodeScanningQuerySuite, CodeScanningLanguage, 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";
@@ -5,5 +5,5 @@ export { normalizeConfigInternal as normalizeConfig };
5
5
  * Load and validate raw config without normalization.
6
6
  * Use this when you need to perform command-specific validation before normalizing.
7
7
  */
8
- export declare function loadRawConfig(filePath: string): RawConfig;
9
- export declare function loadConfig(filePath: string, env: Record<string, string | undefined>): Config;
8
+ export declare function loadRawConfig(configPath: string): RawConfig;
9
+ export declare function loadConfig(configPath: string, env: Record<string, string | undefined>): Config;
@@ -1,17 +1,29 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { readFileSync, statSync, readdirSync } from "node:fs";
2
+ import { dirname, join, extname } from "node:path";
3
3
  import { parse } from "yaml";
4
4
  import { validateRawConfig } from "./validator.js";
5
5
  import { normalizeConfig as normalizeConfigInternal } from "./normalizer.js";
6
6
  import { resolveFileReferencesInConfig } from "./file-reference-resolver.js";
7
7
  import { toErrorMessage } from "../shared/type-guards.js";
8
8
  import { ValidationError } from "../shared/errors.js";
9
+ import { mergeConfigFragments } from "./config-merger.js";
9
10
  export { normalizeConfigInternal as normalizeConfig };
10
11
  /**
11
12
  * Load and validate raw config without normalization.
12
13
  * Use this when you need to perform command-specific validation before normalizing.
13
14
  */
14
- export function loadRawConfig(filePath) {
15
+ export function loadRawConfig(configPath) {
16
+ const stat = statSync(configPath);
17
+ if (stat.isDirectory()) {
18
+ return loadRawConfigFromDirectory(configPath);
19
+ }
20
+ return loadRawConfigFromFile(configPath);
21
+ }
22
+ export function loadConfig(configPath, env) {
23
+ const rawConfig = loadRawConfig(configPath);
24
+ return normalizeConfigInternal(rawConfig, env);
25
+ }
26
+ function loadRawConfigFromFile(filePath) {
15
27
  const content = readFileSync(filePath, "utf-8");
16
28
  const configDir = dirname(filePath);
17
29
  let rawConfig;
@@ -27,7 +39,39 @@ export function loadRawConfig(filePath) {
27
39
  validateRawConfig(rawConfig);
28
40
  return rawConfig;
29
41
  }
30
- export function loadConfig(filePath, env) {
31
- const rawConfig = loadRawConfig(filePath);
32
- return normalizeConfigInternal(rawConfig, env);
42
+ function loadRawConfigFromDirectory(dirPath) {
43
+ const entries = readdirSync(dirPath, { withFileTypes: true });
44
+ const yamlFiles = entries
45
+ .filter((entry) => entry.isFile() &&
46
+ [".yaml", ".yml"].includes(extname(entry.name).toLowerCase()))
47
+ .map((entry) => entry.name)
48
+ .sort();
49
+ if (yamlFiles.length === 0) {
50
+ throw new ValidationError(`No .yaml or .yml files found in directory: ${dirPath}`);
51
+ }
52
+ const fragments = yamlFiles.map((fileName) => {
53
+ const filePath = join(dirPath, fileName);
54
+ const content = readFileSync(filePath, "utf-8");
55
+ const configDir = dirname(filePath);
56
+ let config;
57
+ try {
58
+ config = parse(content);
59
+ }
60
+ catch (error) {
61
+ const message = toErrorMessage(error);
62
+ throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`);
63
+ }
64
+ if (!config || typeof config !== "object") {
65
+ throw new ValidationError(`Config file ${fileName} is empty or invalid — expected a YAML mapping`);
66
+ }
67
+ // Safe cast: resolveFileReferencesInConfig only accesses optional fields
68
+ // (files, groups, etc.), so fragments missing id/repos work correctly.
69
+ config = resolveFileReferencesInConfig(config, {
70
+ configDir,
71
+ });
72
+ return { fileName, config };
73
+ });
74
+ const merged = mergeConfigFragments(fragments);
75
+ validateRawConfig(merged);
76
+ return merged;
33
77
  }
@@ -18,6 +18,10 @@ export declare function deepMerge(base: Record<string, unknown>, overlay: Record
18
18
  * Strip xfg merge directive keys ($arrayMerge, $values) from an object.
19
19
  * Works recursively on nested objects and arrays.
20
20
  * Standard $-prefixed keys ($schema, $id, $ref, etc.) are preserved.
21
+ *
22
+ * When an unresolved directive object is found (only contains $arrayMerge + $values),
23
+ * it is replaced with the $values array. This handles the case where a directive
24
+ * had no base array to merge with.
21
25
  */
22
26
  export declare function stripMergeDirectives(obj: Record<string, unknown>): Record<string, unknown>;
23
27
  export declare function createMergeContext(defaultStrategy?: ArrayMergeStrategy): MergeContext;
@@ -14,6 +14,20 @@ const arrayMergeStrategies = new Map([
14
14
  ["append", (base, overlay) => [...base, ...overlay]],
15
15
  ["prepend", (base, overlay) => [...overlay, ...base]],
16
16
  ]);
17
+ /**
18
+ * Checks if a value is an unresolved $arrayMerge directive object
19
+ * (only contains $arrayMerge + $values keys, with a valid strategy and array values).
20
+ */
21
+ function isUnresolvedDirective(value) {
22
+ if (!isPlainObject(value))
23
+ return false;
24
+ const keys = Object.keys(value);
25
+ return (keys.length === 2 &&
26
+ keys.every((k) => XFG_DIRECTIVES.has(k)) &&
27
+ typeof value.$arrayMerge === "string" &&
28
+ arrayMergeStrategies.has(value.$arrayMerge) &&
29
+ Array.isArray(value.$values));
30
+ }
17
31
  function mergeArrays(base, overlay, strategy) {
18
32
  const handler = arrayMergeStrategies.get(strategy);
19
33
  if (handler) {
@@ -36,6 +50,11 @@ export function deepMerge(base, overlay, ctx) {
36
50
  if (XFG_DIRECTIVES.has(key))
37
51
  continue;
38
52
  const baseValue = base[key];
53
+ // If base is an unresolved directive (from a previous layer with no base array),
54
+ // resolve it to its $values array before proceeding with merge logic.
55
+ const resolvedBase = isUnresolvedDirective(baseValue)
56
+ ? baseValue.$values
57
+ : baseValue;
39
58
  // Per-field $arrayMerge + $values directive
40
59
  if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
41
60
  const strategy = overlayValue.$arrayMerge;
@@ -44,19 +63,19 @@ export function deepMerge(base, overlay, ctx) {
44
63
  strategy === "append" ||
45
64
  strategy === "prepend") &&
46
65
  Array.isArray(values) &&
47
- Array.isArray(baseValue)) {
48
- result[key] = mergeArrays(baseValue, values, strategy);
66
+ Array.isArray(resolvedBase)) {
67
+ result[key] = mergeArrays(resolvedBase, values, strategy);
49
68
  continue;
50
69
  }
51
70
  }
52
71
  // Both are arrays — use default strategy
53
- if (Array.isArray(baseValue) && Array.isArray(overlayValue)) {
54
- result[key] = mergeArrays(baseValue, overlayValue, ctx.defaultArrayStrategy);
72
+ if (Array.isArray(resolvedBase) && Array.isArray(overlayValue)) {
73
+ result[key] = mergeArrays(resolvedBase, overlayValue, ctx.defaultArrayStrategy);
55
74
  continue;
56
75
  }
57
76
  // Both are plain objects — recurse
58
- if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
59
- result[key] = deepMerge(baseValue, overlayValue, ctx);
77
+ if (isPlainObject(resolvedBase) && isPlainObject(overlayValue)) {
78
+ result[key] = deepMerge(resolvedBase, overlayValue, ctx);
60
79
  continue;
61
80
  }
62
81
  // Otherwise, overlay wins (including null values)
@@ -68,6 +87,10 @@ export function deepMerge(base, overlay, ctx) {
68
87
  * Strip xfg merge directive keys ($arrayMerge, $values) from an object.
69
88
  * Works recursively on nested objects and arrays.
70
89
  * Standard $-prefixed keys ($schema, $id, $ref, etc.) are preserved.
90
+ *
91
+ * When an unresolved directive object is found (only contains $arrayMerge + $values),
92
+ * it is replaced with the $values array. This handles the case where a directive
93
+ * had no base array to merge with.
71
94
  */
72
95
  export function stripMergeDirectives(obj) {
73
96
  const result = {};
@@ -76,7 +99,13 @@ export function stripMergeDirectives(obj) {
76
99
  if (XFG_DIRECTIVES.has(key))
77
100
  continue;
78
101
  if (isPlainObject(value)) {
79
- result[key] = stripMergeDirectives(value);
102
+ if (isUnresolvedDirective(value)) {
103
+ // Resolve to the $values array, stripping directives from items
104
+ result[key] = value.$values.map((item) => isPlainObject(item) ? stripMergeDirectives(item) : item);
105
+ }
106
+ else {
107
+ result[key] = stripMergeDirectives(value);
108
+ }
80
109
  }
81
110
  else if (Array.isArray(value)) {
82
111
  result[key] = value.map((item) => isPlainObject(item) ? stripMergeDirectives(item) : item);
@@ -111,23 +140,18 @@ export function mergeTextContent(base, overlay, strategy = "replace") {
111
140
  if (typeof overlay === "string") {
112
141
  return overlay;
113
142
  }
114
- // If overlay is an array
115
- if (Array.isArray(overlay)) {
116
- // If base is also an array, apply merge strategy
117
- if (Array.isArray(base)) {
118
- switch (strategy) {
119
- case "append":
120
- return [...base, ...overlay];
121
- case "prepend":
122
- return [...overlay, ...base];
123
- case "replace":
124
- default:
125
- return overlay;
126
- }
143
+ // If base is also an array, apply merge strategy
144
+ if (Array.isArray(base)) {
145
+ switch (strategy) {
146
+ case "append":
147
+ return [...base, ...overlay];
148
+ case "prepend":
149
+ return [...overlay, ...base];
150
+ case "replace":
151
+ default:
152
+ return overlay;
127
153
  }
128
- // Base is string, overlay is array - overlay replaces
129
- return overlay;
130
154
  }
131
- // Fallback (shouldn't reach here with proper types)
155
+ // Base is string, overlay is array - overlay replaces
132
156
  return overlay;
133
157
  }
@@ -170,7 +170,8 @@ export function mergeSettings(root, perRepo) {
170
170
  if (!inheritRulesets && !repoRuleset && rootRuleset) {
171
171
  continue;
172
172
  }
173
- result.rulesets[name] = mergeRuleset(rootRuleset, repoRuleset);
173
+ const merged = mergeRuleset(rootRuleset, repoRuleset);
174
+ result.rulesets[name] = stripMergeDirectives(merged);
174
175
  }
175
176
  // Clean up empty rulesets object
176
177
  if (Object.keys(result.rulesets).length === 0) {
@@ -202,6 +203,26 @@ export function mergeSettings(root, perRepo) {
202
203
  if (mergedLabels) {
203
204
  result.labels = mergedLabels;
204
205
  }
206
+ // Merge code scanning: per-repo fully replaces root (not shallow merge).
207
+ // Unlike `repo` settings (which shallow-merge via spread), code scanning
208
+ // uses full replacement because its 3 fields (state, querySuite, languages)
209
+ // are tightly coupled — partial inheritance (e.g., inheriting languages
210
+ // from root while changing querySuite) would be confusing.
211
+ // codeScanning: false means opt out of all root code scanning settings
212
+ if (perRepo?.codeScanning === false) {
213
+ // Opt-out: don't include any code scanning settings
214
+ }
215
+ else {
216
+ const mergedCodeScanning = perRepo?.codeScanning ?? root?.codeScanning;
217
+ if (mergedCodeScanning) {
218
+ // At this point mergedCodeScanning is CodeScanningSettings (not false),
219
+ // because root uses RawRootSettings where false is filtered by the
220
+ // outer check on perRepo?.codeScanning === false
221
+ if (typeof mergedCodeScanning === "object") {
222
+ result.codeScanning = mergedCodeScanning;
223
+ }
224
+ }
225
+ }
205
226
  return Object.keys(result).length > 0 ? result : undefined;
206
227
  }
207
228
  /**
@@ -343,6 +364,15 @@ function mergeRawSettings(base, overlay) {
343
364
  }
344
365
  }
345
366
  }
367
+ // Merge code scanning: overlay fully replaces base (same semantics as mergeSettings)
368
+ if (overlay.codeScanning !== undefined) {
369
+ if (overlay.codeScanning === false) {
370
+ result.codeScanning = false;
371
+ }
372
+ else {
373
+ result.codeScanning = structuredClone(overlay.codeScanning);
374
+ }
375
+ }
346
376
  // deleteOrphaned: overlay wins
347
377
  if (overlay.deleteOrphaned !== undefined) {
348
378
  result.deleteOrphaned = overlay.deleteOrphaned;
@@ -259,6 +259,14 @@ export interface Label {
259
259
  /** Rename target. Maps to GitHub API's new_name field. */
260
260
  new_name?: string;
261
261
  }
262
+ export type CodeScanningState = "configured" | "not-configured";
263
+ export type CodeScanningQuerySuite = "default" | "extended";
264
+ export type CodeScanningLanguage = "actions" | "c-cpp" | "csharp" | "go" | "java-kotlin" | "javascript-typescript" | "python" | "ruby" | "swift";
265
+ export interface CodeScanningSettings {
266
+ state: CodeScanningState;
267
+ querySuite?: CodeScanningQuerySuite;
268
+ languages?: CodeScanningLanguage[];
269
+ }
262
270
  export interface RepoSettings {
263
271
  /** GitHub rulesets keyed by name */
264
272
  rulesets?: Record<string, Ruleset>;
@@ -266,6 +274,8 @@ export interface RepoSettings {
266
274
  repo?: GitHubRepoSettings;
267
275
  /** GitHub labels keyed by name */
268
276
  labels?: Record<string, Label>;
277
+ /** GitHub code scanning default setup */
278
+ codeScanning?: CodeScanningSettings;
269
279
  deleteOrphaned?: boolean;
270
280
  }
271
281
  export type ContentValue = Record<string, unknown> | string | string[];
@@ -323,6 +333,7 @@ export interface RawRootSettings {
323
333
  rulesets?: Record<string, Ruleset | false>;
324
334
  repo?: GitHubRepoSettings | false;
325
335
  labels?: Record<string, Label | false>;
336
+ codeScanning?: CodeScanningSettings | false;
326
337
  deleteOrphaned?: boolean;
327
338
  }
328
339
  export interface RawRepoSettings {
@@ -333,6 +344,7 @@ export interface RawRepoSettings {
333
344
  labels?: Record<string, Label | false> & {
334
345
  inherit?: boolean;
335
346
  };
347
+ codeScanning?: CodeScanningSettings | false;
336
348
  deleteOrphaned?: boolean;
337
349
  }
338
350
  export interface RawRepoConfig {