@aspruyt/xfg 5.7.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/cli/sync-command.js +35 -37
  2. package/dist/cli/types.d.ts +12 -11
  3. package/dist/{output → cli}/unified-summary.d.ts +3 -3
  4. package/dist/{output → cli}/unified-summary.js +4 -4
  5. package/dist/config/file-reference-resolver.js +24 -56
  6. package/dist/config/normalizer.js +29 -40
  7. package/dist/config/validator.js +94 -102
  8. package/dist/lifecycle/ado-migration-source.d.ts +1 -1
  9. package/dist/lifecycle/ado-migration-source.js +1 -1
  10. package/dist/lifecycle/github-lifecycle-provider.d.ts +5 -6
  11. package/dist/lifecycle/github-lifecycle-provider.js +51 -20
  12. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  13. package/dist/lifecycle/lifecycle-formatter.js +1 -1
  14. package/dist/lifecycle/lifecycle-helpers.d.ts +1 -1
  15. package/dist/lifecycle/repo-lifecycle-manager.d.ts +1 -1
  16. package/dist/lifecycle/repo-lifecycle-manager.js +16 -6
  17. package/dist/lifecycle/types.d.ts +30 -8
  18. package/dist/output/lifecycle-report.d.ts +4 -2
  19. package/dist/output/settings-report.d.ts +4 -4
  20. package/dist/repo/detector.d.ts +8 -0
  21. package/dist/{shared/repo-detector.js → repo/detector.js} +1 -4
  22. package/dist/repo/index.d.ts +4 -0
  23. package/dist/repo/index.js +3 -0
  24. package/dist/{shared/repo-metadata-provider.d.ts → repo/metadata-provider.d.ts} +3 -3
  25. package/dist/{shared/repo-metadata-provider.js → repo/metadata-provider.js} +3 -3
  26. package/dist/{shared/repo-detector.d.ts → repo/types.d.ts} +1 -7
  27. package/dist/repo/types.js +1 -0
  28. package/dist/{shared/repo-info-utils.d.ts → repo/utils.d.ts} +1 -1
  29. package/dist/{shared/repo-info-utils.js → repo/utils.js} +1 -1
  30. package/dist/settings/base-processor.d.ts +1 -1
  31. package/dist/settings/base-processor.js +1 -1
  32. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +1 -1
  33. package/dist/settings/code-scanning/github-code-scanning-strategy.js +1 -1
  34. package/dist/settings/code-scanning/processor.d.ts +2 -2
  35. package/dist/settings/code-scanning/types.d.ts +1 -1
  36. package/dist/settings/index.d.ts +1 -1
  37. package/dist/settings/labels/formatter.js +16 -11
  38. package/dist/settings/labels/github-labels-strategy.d.ts +1 -1
  39. package/dist/settings/labels/github-labels-strategy.js +1 -1
  40. package/dist/settings/labels/processor.d.ts +1 -1
  41. package/dist/settings/labels/types.d.ts +1 -1
  42. package/dist/settings/repo-settings/diff.d.ts +1 -1
  43. package/dist/settings/repo-settings/diff.js +1 -1
  44. package/dist/settings/repo-settings/formatter.js +2 -4
  45. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +4 -4
  46. package/dist/settings/repo-settings/github-repo-settings-strategy.js +4 -4
  47. package/dist/settings/repo-settings/processor.d.ts +2 -2
  48. package/dist/settings/repo-settings/processor.js +5 -5
  49. package/dist/settings/repo-settings/types.d.ts +4 -4
  50. package/dist/settings/rulesets/diff-algorithm.js +1 -1
  51. package/dist/settings/rulesets/formatter.js +0 -3
  52. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +1 -1
  53. package/dist/settings/rulesets/github-ruleset-strategy.js +1 -1
  54. package/dist/settings/rulesets/processor.d.ts +1 -1
  55. package/dist/settings/rulesets/types.d.ts +1 -1
  56. package/dist/shared/command-executor.js +3 -3
  57. package/dist/shared/gh-api-utils.d.ts +7 -4
  58. package/dist/shared/gh-api-utils.js +2 -2
  59. package/dist/shared/retry-utils.js +1 -1
  60. package/dist/shared/xfg-template.d.ts +22 -2
  61. package/dist/sync/auth-options-builder.d.ts +1 -1
  62. package/dist/sync/auth-options-builder.js +1 -1
  63. package/dist/sync/branch-manager.d.ts +1 -1
  64. package/dist/sync/commit-push-manager.d.ts +2 -2
  65. package/dist/sync/commit-push-manager.js +5 -3
  66. package/dist/sync/file-sync-orchestrator.d.ts +1 -1
  67. package/dist/sync/file-sync-strategy.d.ts +1 -1
  68. package/dist/sync/file-writer.js +44 -10
  69. package/dist/sync/repository-processor.d.ts +1 -1
  70. package/dist/sync/repository-session.d.ts +1 -1
  71. package/dist/sync/sync-workflow.d.ts +1 -1
  72. package/dist/sync/sync-workflow.js +2 -1
  73. package/dist/sync/types.d.ts +7 -4
  74. package/dist/vcs/{azure-pr-strategy.d.ts → ado-pr-strategy.d.ts} +2 -2
  75. package/dist/vcs/{azure-pr-strategy.js → ado-pr-strategy.js} +4 -4
  76. package/dist/vcs/authenticated-git-ops.d.ts +2 -0
  77. package/dist/vcs/authenticated-git-ops.js +6 -0
  78. package/dist/vcs/commit-strategy-selector.d.ts +2 -2
  79. package/dist/vcs/commit-strategy-selector.js +2 -2
  80. package/dist/vcs/file-mode-fixup-commit-strategy.d.ts +8 -6
  81. package/dist/vcs/file-mode-fixup-commit-strategy.js +79 -30
  82. package/dist/vcs/git-ops.d.ts +15 -3
  83. package/dist/vcs/git-ops.js +57 -24
  84. package/dist/vcs/github-app-token-manager.d.ts +3 -3
  85. package/dist/vcs/github-app-token-manager.js +4 -4
  86. package/dist/vcs/github-pr-strategy.d.ts +1 -1
  87. package/dist/vcs/github-pr-strategy.js +4 -4
  88. package/dist/vcs/gitlab-pr-strategy.d.ts +1 -1
  89. package/dist/vcs/gitlab-pr-strategy.js +4 -4
  90. package/dist/vcs/graphql-commit-strategy.js +8 -3
  91. package/dist/vcs/index.d.ts +1 -1
  92. package/dist/vcs/pr-creator.d.ts +1 -1
  93. package/dist/vcs/pr-strategy-factory.d.ts +1 -1
  94. package/dist/vcs/pr-strategy-factory.js +3 -3
  95. package/dist/vcs/pr-strategy.d.ts +1 -1
  96. package/dist/vcs/pr-strategy.js +1 -1
  97. package/dist/vcs/types.d.ts +10 -3
  98. package/package.json +2 -2
  99. /package/dist/{shared → vcs}/sanitize-utils.d.ts +0 -0
  100. /package/dist/{shared → vcs}/sanitize-utils.js +0 -0
@@ -2,12 +2,12 @@ import { resolve, join } from "node:path";
2
2
  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
- import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../shared/repo-detector.js";
5
+ import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../repo/index.js";
6
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
9
  import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, CodeScanningProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, GitHubCodeScanningStrategy, } from "../settings/index.js";
10
- import { GitHubRepoMetadataProvider } from "../shared/repo-metadata-provider.js";
10
+ import { GitHubRepoMetadataProvider } from "../repo/index.js";
11
11
  import { ShellCommandExecutor } from "../shared/command-executor.js";
12
12
  import { Logger } from "../shared/logger.js";
13
13
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
@@ -44,7 +44,7 @@ import { buildSyncReport } from "./sync-report-builder.js";
44
44
  import { formatSyncReportCLI } from "../output/sync-report.js";
45
45
  import { buildLifecycleReport } from "./lifecycle-report-builder.js";
46
46
  import { formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecycle-report.js";
47
- import { writeUnifiedSummary } from "../output/unified-summary.js";
47
+ import { writeUnifiedSummary } from "./unified-summary.js";
48
48
  import { toErrorMessage } from "../shared/type-guards.js";
49
49
  import { resolveGitHubToken } from "../shared/gh-api-utils.js";
50
50
  import { RepoLifecycleManager, runLifecycleCheck, } from "../lifecycle/index.js";
@@ -116,7 +116,8 @@ async function runAndStoreResult(factory, repoConfig, repoInfo, opts, repoName,
116
116
  return result;
117
117
  }
118
118
  function buildSettingsDescriptors(ctx) {
119
- const { repoConfig, repoInfo, options, token, repoName, settingsCollector, rulesetProcessorFactory, repoSettingsProcessorFactory, labelsProcessorFactory, codeScanningProcessorFactory, } = ctx;
119
+ const { repoConfig, repoInfo, options, token, repoName, settingsCollector } = ctx;
120
+ const { factories } = ctx;
120
121
  const sharedOpts = {
121
122
  dryRun: options.dryRun,
122
123
  noDelete: options.noDelete,
@@ -126,28 +127,28 @@ function buildSettingsDescriptors(ctx) {
126
127
  {
127
128
  key: "rulesets",
128
129
  label: "Rulesets",
129
- run: () => runAndStoreResult(rulesetProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
130
+ run: () => runAndStoreResult(factories.rulesets, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
130
131
  e.rulesetResult = r;
131
132
  }),
132
133
  },
133
134
  {
134
135
  key: "labels",
135
136
  label: "Labels",
136
- run: () => runAndStoreResult(labelsProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
137
+ run: () => runAndStoreResult(factories.labels, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
137
138
  e.labelsResult = r;
138
139
  }),
139
140
  },
140
141
  {
141
142
  key: "repo",
142
143
  label: "Repo Settings",
143
- run: () => runAndStoreResult(repoSettingsProcessorFactory, repoConfig, repoInfo, { dryRun: options.dryRun, token }, repoName, settingsCollector, (e, r) => {
144
+ run: () => runAndStoreResult(factories.repo, repoConfig, repoInfo, { dryRun: options.dryRun, token }, repoName, settingsCollector, (e, r) => {
144
145
  e.settingsResult = r;
145
146
  }),
146
147
  },
147
148
  {
148
149
  key: "codeScanning",
149
150
  label: "Code Scanning",
150
- run: () => runAndStoreResult(codeScanningProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
151
+ run: () => runAndStoreResult(factories.codeScanning, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
151
152
  e.codeScanningResult = r;
152
153
  }),
153
154
  },
@@ -211,6 +212,14 @@ function displayReports(reportResults, lifecycleReportInputs, settingsCollector,
211
212
  summaryPath: process.env.GITHUB_STEP_SUMMARY,
212
213
  });
213
214
  }
215
+ function pushFailure(results, repoName, error) {
216
+ results.push({
217
+ repoName,
218
+ success: false,
219
+ fileChanges: [],
220
+ error: toErrorMessage(error),
221
+ });
222
+ }
214
223
  /**
215
224
  * Process a single repository: resolve URL, run lifecycle check, sync files, apply settings.
216
225
  * Pushes results into ctx.reportResults, ctx.lifecycleReportInputs, and ctx.settingsCollector.
@@ -239,12 +248,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
239
248
  }
240
249
  catch (error) {
241
250
  getLogger().error(repoNumber, repoConfig.git, toErrorMessage(error));
242
- ctx.reportResults.push({
243
- repoName: repoConfig.git,
244
- success: false,
245
- fileChanges: [],
246
- error: toErrorMessage(error),
247
- });
251
+ pushFailure(ctx.reportResults, repoConfig.git, error);
248
252
  return;
249
253
  }
250
254
  const repoName = getRepoDisplayName(repoInfo);
@@ -280,10 +284,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
280
284
  options,
281
285
  token: repoToken,
282
286
  settingsCollector: ctx.settingsCollector,
283
- rulesetProcessorFactory: ctx.rulesetProcessorFactory,
284
- repoSettingsProcessorFactory: ctx.repoSettingsProcessorFactory,
285
- labelsProcessorFactory: ctx.labelsProcessorFactory,
286
- codeScanningProcessorFactory: ctx.codeScanningProcessorFactory,
287
+ factories: ctx.factories,
287
288
  });
288
289
  }
289
290
  /**
@@ -330,12 +331,7 @@ async function runLifecyclePhase(repo, ctx) {
330
331
  }
331
332
  catch (error) {
332
333
  getLogger().error(repoNumber, repo.repoName, `Lifecycle error: ${toErrorMessage(error)}`);
333
- ctx.reportResults.push({
334
- repoName: repo.repoName,
335
- success: false,
336
- fileChanges: [],
337
- error: toErrorMessage(error),
338
- });
334
+ pushFailure(ctx.reportResults, repo.repoName, error);
339
335
  return true;
340
336
  }
341
337
  }
@@ -383,19 +379,24 @@ async function runFileSyncPhase(repo, ctx) {
383
379
  }
384
380
  catch (error) {
385
381
  getLogger().error(repoNumber, repo.repoName, toErrorMessage(error));
386
- ctx.reportResults.push({
387
- repoName: repo.repoName,
388
- success: false,
389
- fileChanges: [],
390
- error: toErrorMessage(error),
391
- });
382
+ pushFailure(ctx.reportResults, repo.repoName, error);
392
383
  }
393
384
  }
394
385
  export async function runSync(options, deps = {}) {
395
386
  // Reset module-level singletons to ensure fresh state per invocation
396
387
  _defaultExecutor = undefined;
397
388
  _logger = undefined;
398
- const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), codeScanningProcessorFactory = createDefaultCodeScanningProcessorFactory(), } = deps;
389
+ const { lifecycleManager, settingsProcessorFactories } = deps;
390
+ const factories = {
391
+ rulesets: settingsProcessorFactories?.rulesets ??
392
+ createDefaultRulesetProcessorFactory(),
393
+ labels: settingsProcessorFactories?.labels ??
394
+ createDefaultLabelsProcessorFactory(),
395
+ repo: settingsProcessorFactories?.repo ??
396
+ createDefaultRepoSettingsProcessorFactory(),
397
+ codeScanning: settingsProcessorFactories?.codeScanning ??
398
+ createDefaultCodeScanningProcessorFactory(),
399
+ };
399
400
  const configPath = resolve(options.config);
400
401
  if (!existsSync(configPath)) {
401
402
  throw new ValidationError(`Config path not found: ${configPath}`);
@@ -420,9 +421,9 @@ export async function runSync(options, deps = {}) {
420
421
  getLogger().log(`Found ${config.repos.length} repositories to process`);
421
422
  getLogger().log(`Target files: ${formatFileNames(fileNames)}`);
422
423
  getLogger().log(`Branch: ${branchName}\n`);
423
- const tokenManager = createTokenManager(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY
424
+ const tokenManager = createTokenManager(process.env.XFG_GITHUB_CLIENT_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY
424
425
  ? {
425
- appId: process.env.XFG_GITHUB_APP_ID,
426
+ clientId: process.env.XFG_GITHUB_CLIENT_ID,
426
427
  privateKey: process.env.XFG_GITHUB_APP_PRIVATE_KEY,
427
428
  }
428
429
  : undefined);
@@ -443,10 +444,7 @@ export async function runSync(options, deps = {}) {
443
444
  reportResults: [],
444
445
  lifecycleReportInputs: [],
445
446
  settingsCollector: new ResultsCollector(),
446
- rulesetProcessorFactory,
447
- repoSettingsProcessorFactory,
448
- labelsProcessorFactory,
449
- codeScanningProcessorFactory,
447
+ factories,
450
448
  };
451
449
  for (let i = 0; i < config.repos.length; i++) {
452
450
  await processSingleRepo(config.repos[i], i, ctx);
@@ -1,8 +1,8 @@
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, ICodeScanningProcessor, BaseProcessorResult } from "../settings/index.js";
5
- import type { RepoInfo } from "../shared/repo-detector.js";
4
+ import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, ICodeScanningProcessor, BaseProcessorResult, ActiveAction } from "../settings/index.js";
5
+ import type { RepoInfo } from "../repo/index.js";
6
6
  import type { ResultsCollector } from "./results-collector.js";
7
7
  export type ProcessorFactory = () => IRepositoryProcessor;
8
8
  export type SettingsProcessorFactory<T extends ISettingsProcessor> = () => T;
@@ -10,16 +10,20 @@ export type RulesetProcessorFactory = SettingsProcessorFactory<IRulesetProcessor
10
10
  export type RepoSettingsProcessorFactory = SettingsProcessorFactory<IRepoSettingsProcessor>;
11
11
  export type LabelsProcessorFactory = SettingsProcessorFactory<ILabelsProcessor>;
12
12
  export type CodeScanningProcessorFactory = SettingsProcessorFactory<ICodeScanningProcessor>;
13
+ export type SettingsKind = "rulesets" | "labels" | "repo" | "codeScanning";
14
+ export interface SettingsProcessorFactories {
15
+ rulesets: RulesetProcessorFactory;
16
+ labels: LabelsProcessorFactory;
17
+ repo: RepoSettingsProcessorFactory;
18
+ codeScanning: CodeScanningProcessorFactory;
19
+ }
13
20
  /**
14
21
  * Dependencies for the sync command (dependency injection).
15
22
  */
16
23
  export interface SyncDependencies {
17
24
  processorFactory?: ProcessorFactory;
18
25
  lifecycleManager?: IRepoLifecycleManager;
19
- rulesetProcessorFactory?: RulesetProcessorFactory;
20
- repoSettingsProcessorFactory?: RepoSettingsProcessorFactory;
21
- labelsProcessorFactory?: LabelsProcessorFactory;
22
- codeScanningProcessorFactory?: CodeScanningProcessorFactory;
26
+ settingsProcessorFactories?: Partial<SettingsProcessorFactories>;
23
27
  }
24
28
  export interface SharedOptions {
25
29
  config: string;
@@ -39,7 +43,7 @@ export interface SyncResultEntry {
39
43
  success: boolean;
40
44
  fileChanges: Array<{
41
45
  path: string;
42
- action: "create" | "update" | "delete";
46
+ action: ActiveAction;
43
47
  diffLines?: string[];
44
48
  }>;
45
49
  prUrl?: string;
@@ -64,8 +68,5 @@ export interface ApplyRepoSettingsContext {
64
68
  options: SyncOptions;
65
69
  token: string | undefined;
66
70
  settingsCollector: ResultsCollector;
67
- rulesetProcessorFactory: NonNullable<SyncDependencies["rulesetProcessorFactory"]>;
68
- repoSettingsProcessorFactory: NonNullable<SyncDependencies["repoSettingsProcessorFactory"]>;
69
- labelsProcessorFactory: NonNullable<SyncDependencies["labelsProcessorFactory"]>;
70
- codeScanningProcessorFactory: NonNullable<SyncDependencies["codeScanningProcessorFactory"]>;
71
+ factories: SettingsProcessorFactories;
71
72
  }
@@ -1,6 +1,6 @@
1
- import type { LifecycleReport } from "./lifecycle-report.js";
2
- import type { SyncReport } from "./types.js";
3
- import type { SettingsReport } from "./settings-report.js";
1
+ import type { LifecycleReport } from "../output/lifecycle-report.js";
2
+ import type { SyncReport } from "../output/types.js";
3
+ import type { SettingsReport } from "../output/settings-report.js";
4
4
  interface UnifiedSummaryInput {
5
5
  lifecycle?: LifecycleReport;
6
6
  sync?: SyncReport;
@@ -1,7 +1,7 @@
1
- import { hasLifecycleChanges } from "./lifecycle-report.js";
2
- import { writeGitHubStepSummary } from "./github-summary.js";
3
- import { renderSyncLines } from "./sync-report.js";
4
- import { renderRepoSettingsDiffLines, formatCountEntry, } from "./settings-report.js";
1
+ import { hasLifecycleChanges } from "../output/lifecycle-report.js";
2
+ import { writeGitHubStepSummary } from "../output/github-summary.js";
3
+ import { renderSyncLines } from "../output/sync-report.js";
4
+ import { renderRepoSettingsDiffLines, formatCountEntry, } from "../output/settings-report.js";
5
5
  // =============================================================================
6
6
  // Helpers
7
7
  // =============================================================================
@@ -83,6 +83,25 @@ function resolveContentValue(value, configDir) {
83
83
  // Otherwise return as-is (objects, arrays, plain strings)
84
84
  return value;
85
85
  }
86
+ function resolveContentInFilesMap(filesMap, configDir) {
87
+ if (!filesMap) {
88
+ return;
89
+ }
90
+ for (const [fileName, fileConfig] of Object.entries(filesMap)) {
91
+ if (fileConfig === false) {
92
+ continue;
93
+ }
94
+ if (fileConfig &&
95
+ typeof fileConfig === "object" &&
96
+ "content" in fileConfig) {
97
+ const typed = fileConfig;
98
+ const resolved = resolveContentValue(typed.content, configDir);
99
+ if (resolved !== undefined) {
100
+ filesMap[fileName] = { ...fileConfig, content: resolved };
101
+ }
102
+ }
103
+ }
104
+ }
86
105
  /**
87
106
  * Resolve all file references in a raw config.
88
107
  * Walks through files at root level and per-repo level.
@@ -100,74 +119,23 @@ export function resolveFileReferencesInConfig(raw, options) {
100
119
  result.prTemplate = resolved;
101
120
  }
102
121
  // Resolve root-level file content
103
- if (result.files) {
104
- for (const [fileName, fileConfig] of Object.entries(result.files)) {
105
- if (fileConfig &&
106
- typeof fileConfig === "object" &&
107
- "content" in fileConfig) {
108
- const resolved = resolveContentValue(fileConfig.content, configDir);
109
- if (resolved !== undefined) {
110
- result.files[fileName] = { ...fileConfig, content: resolved };
111
- }
112
- }
113
- }
114
- }
122
+ resolveContentInFilesMap(result.files, configDir);
115
123
  // Resolve group-level file content
116
124
  if (result.groups) {
117
- for (const [groupName, group] of Object.entries(result.groups)) {
118
- if (group.files) {
119
- for (const [fileName, fileConfig] of Object.entries(group.files)) {
120
- if (fileConfig &&
121
- typeof fileConfig === "object" &&
122
- "content" in fileConfig) {
123
- const resolved = resolveContentValue(fileConfig.content, configDir);
124
- if (resolved !== undefined) {
125
- result.groups[groupName].files[fileName] = {
126
- ...fileConfig,
127
- content: resolved,
128
- };
129
- }
130
- }
131
- }
132
- }
125
+ for (const group of Object.values(result.groups)) {
126
+ resolveContentInFilesMap(group.files, configDir);
133
127
  }
134
128
  }
135
129
  // Resolve conditional group file content
136
130
  if (result.conditionalGroups) {
137
131
  for (const cg of result.conditionalGroups) {
138
- if (cg.files) {
139
- for (const [fileName, fileConfig] of Object.entries(cg.files)) {
140
- if (fileConfig &&
141
- typeof fileConfig === "object" &&
142
- "content" in fileConfig) {
143
- const resolved = resolveContentValue(fileConfig.content, configDir);
144
- if (resolved !== undefined) {
145
- cg.files[fileName] = { ...fileConfig, content: resolved };
146
- }
147
- }
148
- }
149
- }
132
+ resolveContentInFilesMap(cg.files, configDir);
150
133
  }
151
134
  }
152
135
  // Resolve per-repo file content
153
136
  if (result.repos) {
154
137
  for (const repo of result.repos) {
155
- if (repo.files) {
156
- for (const [fileName, fileOverride] of Object.entries(repo.files)) {
157
- // Skip false (exclusion) entries
158
- if (fileOverride === false) {
159
- continue;
160
- }
161
- if (fileOverride &&
162
- typeof fileOverride === "object" &&
163
- "content" in fileOverride) {
164
- const resolved = resolveContentValue(fileOverride.content, configDir);
165
- if (resolved !== undefined) {
166
- repo.files[fileName] = { ...fileOverride, content: resolved };
167
- }
168
- }
169
- }
170
- }
138
+ resolveContentInFilesMap(repo.files, configDir);
171
139
  }
172
140
  }
173
141
  return result;
@@ -294,6 +294,28 @@ function mergeGroupPROptions(rootPR, groupNames, groupDefs) {
294
294
  }
295
295
  return accumulated;
296
296
  }
297
+ /**
298
+ * Merges a named-entry map (e.g. rulesets, labels) where overlay entries
299
+ * extend or replace base entries. `inherit: false` in the overlay discards
300
+ * the base; `false` values mark explicit opt-outs. The merge callback runs
301
+ * when both base and overlay have an entry with the same name.
302
+ */
303
+ function mergeNamedEntries(base, overlay, merge) {
304
+ const inherit = shouldInherit(overlay);
305
+ const result = inherit ? { ...(base ?? {}) } : {};
306
+ for (const [name, entry] of Object.entries(overlay)) {
307
+ if (name === "inherit")
308
+ continue;
309
+ if (entry === false) {
310
+ result[name] = false;
311
+ }
312
+ else if (typeof entry === "object" && entry !== null) {
313
+ const existing = result[name];
314
+ result[name] = merge(existing, entry);
315
+ }
316
+ }
317
+ return result;
318
+ }
297
319
  /**
298
320
  * Merges two raw settings layers (root/group into accumulated).
299
321
  * Unlike mergeSettings(), this operates on raw types and returns raw types,
@@ -308,26 +330,9 @@ function mergeRawSettings(base, overlay) {
308
330
  const result = base ? structuredClone(base) : {};
309
331
  // Merge rulesets
310
332
  if (overlay.rulesets) {
311
- const inheritRulesets = shouldInherit(overlay.rulesets);
312
- if (!inheritRulesets) {
313
- // Discard accumulated rulesets, start fresh with overlay's own
314
- result.rulesets = {};
315
- }
316
- if (!result.rulesets)
317
- result.rulesets = {};
318
- for (const [name, ruleset] of Object.entries(overlay.rulesets)) {
319
- if (name === "inherit")
320
- continue;
321
- if (ruleset === false) {
322
- result.rulesets[name] = false;
323
- }
324
- else if (typeof ruleset === "object") {
325
- const existing = result.rulesets[name];
326
- result.rulesets[name] = existing
327
- ? mergeRuleset(existing, ruleset)
328
- : structuredClone(ruleset);
329
- }
330
- }
333
+ result.rulesets = mergeNamedEntries(result.rulesets, overlay.rulesets, (existing, entry) => existing && typeof existing === "object"
334
+ ? mergeRuleset(existing, entry)
335
+ : structuredClone(entry));
331
336
  }
332
337
  // Merge repo settings: overlay replaces base (shallow merge, same as mergeSettings)
333
338
  if (overlay.repo !== undefined) {
@@ -343,26 +348,10 @@ function mergeRawSettings(base, overlay) {
343
348
  }
344
349
  // Merge labels
345
350
  if (overlay.labels) {
346
- const inheritLabels = shouldInherit(overlay.labels);
347
- if (!inheritLabels) {
348
- result.labels = {};
349
- }
350
- if (!result.labels)
351
- result.labels = {};
352
- for (const [name, label] of Object.entries(overlay.labels)) {
353
- if (name === "inherit")
354
- continue;
355
- if (label === false) {
356
- result.labels[name] = false;
357
- }
358
- else if (typeof label === "object") {
359
- const existing = result.labels[name];
360
- result.labels[name] = {
361
- ...(existing && typeof existing === "object" ? existing : {}),
362
- ...label,
363
- };
364
- }
365
- }
351
+ result.labels = mergeNamedEntries(result.labels, overlay.labels, (existing, entry) => ({
352
+ ...(existing && typeof existing === "object" ? existing : {}),
353
+ ...entry,
354
+ }));
366
355
  }
367
356
  // Merge code scanning: overlay fully replaces base (same semantics as mergeSettings)
368
357
  if (overlay.codeScanning !== undefined) {