@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
@@ -42,6 +42,11 @@ function buildCommentOnlyYaml(header, schemaUrl) {
42
42
  return undefined;
43
43
  return lines.join("\n") + "\n";
44
44
  }
45
+ function buildJson5HeaderComment(header) {
46
+ if (!header || header.length === 0)
47
+ return undefined;
48
+ return header.map((h) => `// ${h}`).join("\n") + "\n";
49
+ }
45
50
  /**
46
51
  * Converts content to string in the appropriate format.
47
52
  * Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
@@ -55,6 +60,12 @@ export function convertContentToString(content, fileName, options) {
55
60
  return commentOnly;
56
61
  }
57
62
  }
63
+ if (format === "json5" && options) {
64
+ const commentOnly = buildJson5HeaderComment(options.header);
65
+ if (commentOnly) {
66
+ return commentOnly;
67
+ }
68
+ }
58
69
  return "";
59
70
  }
60
71
  if (typeof content === "string") {
@@ -92,8 +103,14 @@ export function convertContentToString(content, fileName, options) {
92
103
  indent: 2,
93
104
  defaultStringType: "QUOTE_DOUBLE",
94
105
  defaultKeyType: "PLAIN",
106
+ lineWidth: 0,
95
107
  });
96
108
  }
97
- // JSON and JSON5 both use standard JSON.stringify (valid JSON5 superset)
109
+ if (format === "json5" && options) {
110
+ const headerComment = buildJson5HeaderComment(options.header);
111
+ if (headerComment) {
112
+ return headerComment + JSON.stringify(content, null, 2) + "\n";
113
+ }
114
+ }
98
115
  return JSON.stringify(content, null, 2) + "\n";
99
116
  }
@@ -1,4 +1,4 @@
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";
1
+ export type { PRMergeOptions, 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";
@@ -13,7 +13,13 @@ export { normalizeConfigInternal as normalizeConfig };
13
13
  * Use this when you need to perform command-specific validation before normalizing.
14
14
  */
15
15
  export function loadRawConfig(configPath) {
16
- const stat = statSync(configPath);
16
+ let stat;
17
+ try {
18
+ stat = statSync(configPath);
19
+ }
20
+ catch (error) {
21
+ throw new ValidationError(`Failed to read config at ${configPath}: ${toErrorMessage(error)}`, { cause: error });
22
+ }
17
23
  if (stat.isDirectory()) {
18
24
  return loadRawConfigFromDirectory(configPath);
19
25
  }
@@ -24,7 +30,13 @@ export function loadConfig(configPath, env) {
24
30
  return normalizeConfigInternal(rawConfig, env);
25
31
  }
26
32
  function loadRawConfigFromFile(filePath) {
27
- const content = readFileSync(filePath, "utf-8");
33
+ let content;
34
+ try {
35
+ content = readFileSync(filePath, "utf-8");
36
+ }
37
+ catch (error) {
38
+ throw new ValidationError(`Failed to read config file ${filePath}: ${toErrorMessage(error)}`, { cause: error });
39
+ }
28
40
  const configDir = dirname(filePath);
29
41
  let rawConfig;
30
42
  try {
@@ -32,7 +44,7 @@ function loadRawConfigFromFile(filePath) {
32
44
  }
33
45
  catch (error) {
34
46
  const message = toErrorMessage(error);
35
- throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`);
47
+ throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`, { cause: error });
36
48
  }
37
49
  // Resolve file references before validation so content type checking works
38
50
  rawConfig = resolveFileReferencesInConfig(rawConfig, { configDir });
@@ -40,7 +52,13 @@ function loadRawConfigFromFile(filePath) {
40
52
  return rawConfig;
41
53
  }
42
54
  function loadRawConfigFromDirectory(dirPath) {
43
- const entries = readdirSync(dirPath, { withFileTypes: true });
55
+ let entries;
56
+ try {
57
+ entries = readdirSync(dirPath, { withFileTypes: true });
58
+ }
59
+ catch (error) {
60
+ throw new ValidationError(`Failed to read config directory ${dirPath}: ${toErrorMessage(error)}`, { cause: error });
61
+ }
44
62
  const yamlFiles = entries
45
63
  .filter((entry) => entry.isFile() &&
46
64
  [".yaml", ".yml"].includes(extname(entry.name).toLowerCase()))
@@ -51,7 +69,13 @@ function loadRawConfigFromDirectory(dirPath) {
51
69
  }
52
70
  const fragments = yamlFiles.map((fileName) => {
53
71
  const filePath = join(dirPath, fileName);
54
- const content = readFileSync(filePath, "utf-8");
72
+ let content;
73
+ try {
74
+ content = readFileSync(filePath, "utf-8");
75
+ }
76
+ catch (error) {
77
+ throw new ValidationError(`Failed to read config file ${filePath}: ${toErrorMessage(error)}`, { cause: error });
78
+ }
55
79
  const configDir = dirname(filePath);
56
80
  let config;
57
81
  try {
@@ -59,7 +83,7 @@ function loadRawConfigFromDirectory(dirPath) {
59
83
  }
60
84
  catch (error) {
61
85
  const message = toErrorMessage(error);
62
- throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`);
86
+ throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`, { cause: error });
63
87
  }
64
88
  if (!config || typeof config !== "object") {
65
89
  throw new ValidationError(`Config file ${fileName} is empty or invalid — expected a YAML mapping`);
@@ -2,7 +2,17 @@
2
2
  * Deep merge utilities for JSON configuration objects.
3
3
  * Supports per-field array merge strategies via $arrayMerge + $values directives.
4
4
  */
5
- export type ArrayMergeStrategy = "replace" | "append" | "prepend";
5
+ /**
6
+ * Candidate keys for matching array items by identity rather than index.
7
+ * Order matters — first key found across all items wins.
8
+ */
9
+ export declare const MATCH_KEY_CANDIDATES: readonly ["type", "actor_id"];
10
+ /**
11
+ * Finds a key that uniquely identifies items in both arrays.
12
+ * Returns the first candidate key present in every item of both arrays, or undefined.
13
+ */
14
+ export declare function findMatchKey(base: unknown[], overlay: unknown[]): string | undefined;
15
+ export type ArrayMergeStrategy = "replace" | "append" | "prepend" | "merge";
6
16
  export interface MergeContext {
7
17
  defaultArrayStrategy: ArrayMergeStrategy;
8
18
  }
@@ -3,16 +3,87 @@
3
3
  * Supports per-field array merge strategies via $arrayMerge + $values directives.
4
4
  */
5
5
  import { isPlainObject } from "../shared/type-guards.js";
6
+ /**
7
+ * Candidate keys for matching array items by identity rather than index.
8
+ * Order matters — first key found across all items wins.
9
+ */
10
+ export const MATCH_KEY_CANDIDATES = ["type", "actor_id"];
11
+ /**
12
+ * Finds a key that uniquely identifies items in both arrays.
13
+ * Returns the first candidate key present in every item of both arrays, or undefined.
14
+ */
15
+ export function findMatchKey(base, overlay) {
16
+ if (base.length === 0 && overlay.length === 0)
17
+ return undefined;
18
+ const hasKey = (item, key) => isPlainObject(item) && key in item;
19
+ for (const candidate of MATCH_KEY_CANDIDATES) {
20
+ if (base.every((item) => hasKey(item, candidate)) &&
21
+ overlay.every((item) => hasKey(item, candidate))) {
22
+ return candidate;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
6
27
  /**
7
28
  * Keys reserved for xfg merge directives.
8
29
  * Only these are stripped during merge — standard $-prefixed keys
9
30
  * like $schema, $id, $ref, $generated are preserved.
10
31
  */
11
32
  const XFG_DIRECTIVES = new Set(["$arrayMerge", "$values"]);
33
+ function mergeByKey(base, overlay, matchKey, ctx) {
34
+ const baseByKey = new Map();
35
+ // findMatchKey guarantees every item in both arrays is a plain object with matchKey
36
+ for (let i = 0; i < base.length; i++) {
37
+ const item = base[i];
38
+ const keyValue = item[matchKey];
39
+ if (keyValue !== undefined && !baseByKey.has(keyValue)) {
40
+ baseByKey.set(keyValue, { item, index: i });
41
+ }
42
+ }
43
+ const appended = [];
44
+ for (const overlayItem of overlay) {
45
+ const item = overlayItem;
46
+ const keyValue = item[matchKey];
47
+ const baseEntry = baseByKey.get(keyValue);
48
+ if (baseEntry) {
49
+ baseByKey.set(keyValue, {
50
+ item: deepMerge(baseEntry.item, item, ctx),
51
+ index: baseEntry.index,
52
+ });
53
+ }
54
+ else {
55
+ appended.push(overlayItem);
56
+ }
57
+ }
58
+ const result = [];
59
+ for (let i = 0; i < base.length; i++) {
60
+ const item = base[i];
61
+ const keyValue = item[matchKey];
62
+ const entry = baseByKey.get(keyValue);
63
+ if (entry && entry.index === i) {
64
+ result.push(entry.item);
65
+ }
66
+ else {
67
+ result.push(item);
68
+ }
69
+ }
70
+ result.push(...appended);
71
+ return result;
72
+ }
12
73
  const arrayMergeStrategies = new Map([
13
74
  ["replace", (_base, overlay) => overlay],
14
75
  ["append", (base, overlay) => [...base, ...overlay]],
15
76
  ["prepend", (base, overlay) => [...overlay, ...base]],
77
+ [
78
+ "merge",
79
+ (base, overlay, ctx) => {
80
+ const matchKey = findMatchKey(base, overlay);
81
+ if (!matchKey) {
82
+ return [...base, ...overlay];
83
+ }
84
+ return mergeByKey(base, overlay, matchKey, ctx);
85
+ },
86
+ ],
16
87
  ]);
17
88
  /**
18
89
  * Checks if a value is an unresolved $arrayMerge directive object
@@ -28,12 +99,11 @@ function isUnresolvedDirective(value) {
28
99
  arrayMergeStrategies.has(value.$arrayMerge) &&
29
100
  Array.isArray(value.$values));
30
101
  }
31
- function mergeArrays(base, overlay, strategy) {
102
+ function mergeArrays(base, overlay, strategy, ctx) {
32
103
  const handler = arrayMergeStrategies.get(strategy);
33
104
  if (handler) {
34
- return handler(base, overlay);
105
+ return handler(base, overlay, ctx);
35
106
  }
36
- // Fallback to replace for unknown strategies
37
107
  return overlay;
38
108
  }
39
109
  /**
@@ -61,16 +131,17 @@ export function deepMerge(base, overlay, ctx) {
61
131
  const values = overlayValue.$values;
62
132
  if ((strategy === "replace" ||
63
133
  strategy === "append" ||
64
- strategy === "prepend") &&
134
+ strategy === "prepend" ||
135
+ strategy === "merge") &&
65
136
  Array.isArray(values) &&
66
137
  Array.isArray(resolvedBase)) {
67
- result[key] = mergeArrays(resolvedBase, values, strategy);
138
+ result[key] = mergeArrays(resolvedBase, values, strategy, ctx);
68
139
  continue;
69
140
  }
70
141
  }
71
142
  // Both are arrays — use default strategy
72
143
  if (Array.isArray(resolvedBase) && Array.isArray(overlayValue)) {
73
- result[key] = mergeArrays(resolvedBase, overlayValue, ctx.defaultArrayStrategy);
144
+ result[key] = mergeArrays(resolvedBase, overlayValue, ctx.defaultArrayStrategy, ctx);
74
145
  continue;
75
146
  }
76
147
  // Both are plain objects — recurse
@@ -144,6 +215,7 @@ export function mergeTextContent(base, overlay, strategy = "replace") {
144
215
  if (Array.isArray(base)) {
145
216
  switch (strategy) {
146
217
  case "append":
218
+ case "merge":
147
219
  return [...base, ...overlay];
148
220
  case "prepend":
149
221
  return [...overlay, ...base];
@@ -1,5 +1,5 @@
1
1
  import { deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
2
- import { interpolateContent } from "../shared/env.js";
2
+ import { interpolateContent } from "./env.js";
3
3
  import { expandRepoGroups } from "./extends-resolver.js";
4
4
  /**
5
5
  * Clone content, stripping merge directives from object content.
@@ -79,22 +79,8 @@ function mergePROptions(global, perRepo) {
79
79
  return perRepo;
80
80
  if (!perRepo)
81
81
  return global;
82
- const result = {};
83
- const merge = perRepo.merge ?? global.merge;
84
- const mergeStrategy = perRepo.mergeStrategy ?? global.mergeStrategy;
85
- const deleteBranch = perRepo.deleteBranch ?? global.deleteBranch;
86
- const bypassReason = perRepo.bypassReason ?? global.bypassReason;
87
- const labels = perRepo.labels ?? global.labels;
88
- if (merge !== undefined)
89
- result.merge = merge;
90
- if (mergeStrategy !== undefined)
91
- result.mergeStrategy = mergeStrategy;
92
- if (deleteBranch !== undefined)
93
- result.deleteBranch = deleteBranch;
94
- if (bypassReason !== undefined)
95
- result.bypassReason = bypassReason;
96
- if (labels !== undefined)
97
- result.labels = labels;
82
+ const merged = { ...global, ...perRepo };
83
+ const result = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
98
84
  return Object.keys(result).length > 0 ? result : undefined;
99
85
  }
100
86
  /**
@@ -454,6 +440,35 @@ function resolveFileEntry(fileName, fileConfig, repoOverride, inheritFiles, glob
454
440
  globalDeleteOrphaned,
455
441
  };
456
442
  }
443
+ function normalizeRepoEntry(ctx) {
444
+ const files = [];
445
+ const inheritFiles = shouldInherit(ctx.rawRepo.files);
446
+ for (const fileName of ctx.fileNames) {
447
+ if (fileName === "inherit")
448
+ continue;
449
+ const entry = resolveFileEntry(fileName, ctx.effectiveRootFiles[fileName], ctx.rawRepo.files?.[fileName], inheritFiles, ctx.globalDeleteOrphaned, ctx.env);
450
+ if (entry)
451
+ files.push(entry);
452
+ }
453
+ for (const fileName of ctx.repoOnlyFileNames) {
454
+ const repoOverride = ctx.rawRepo.files[fileName];
455
+ if (repoOverride === false)
456
+ continue;
457
+ const entry = resolveFileEntry(fileName, {}, repoOverride, true, ctx.globalDeleteOrphaned, ctx.env);
458
+ if (entry)
459
+ files.push(entry);
460
+ }
461
+ const prOptions = mergePROptions(ctx.effectivePROptions, ctx.rawRepo.prOptions);
462
+ const settings = mergeSettings(ctx.effectiveSettings, ctx.rawRepo.settings);
463
+ return {
464
+ git: ctx.gitUrl,
465
+ files,
466
+ prOptions,
467
+ settings,
468
+ upstream: ctx.rawRepo.upstream,
469
+ source: ctx.rawRepo.source,
470
+ };
471
+ }
457
472
  /**
458
473
  * Normalizes raw config into expanded, merged config.
459
474
  * Pipeline: expand git arrays -> merge content -> interpolate env vars
@@ -485,29 +500,29 @@ export function normalizeConfig(raw, env) {
485
500
  effectiveSettings = merged.settings;
486
501
  }
487
502
  const fileNames = Object.keys(effectiveRootFiles);
488
- for (const gitUrl of gitUrls) {
489
- const files = [];
490
- const inheritFiles = shouldInherit(rawRepo.files);
491
- for (const fileName of fileNames) {
492
- // Skip reserved key
493
- if (fileName === "inherit")
503
+ // Collect repo-only file names (defined at repo level but not in root/groups)
504
+ const repoOnlyFileNames = [];
505
+ if (rawRepo.files) {
506
+ for (const name of Object.keys(rawRepo.files)) {
507
+ if (name === "inherit")
494
508
  continue;
495
- const entry = resolveFileEntry(fileName, effectiveRootFiles[fileName], rawRepo.files?.[fileName], inheritFiles, raw.deleteOrphaned, env);
496
- if (entry)
497
- files.push(entry);
509
+ if (!effectiveRootFiles[name]) {
510
+ repoOnlyFileNames.push(name);
511
+ }
498
512
  }
499
- // Merge PR options: per-repo overrides effective (root + groups)
500
- const prOptions = mergePROptions(effectivePROptions, rawRepo.prOptions);
501
- // Merge settings: per-repo deep merges with effective (root + groups)
502
- const settings = mergeSettings(effectiveSettings, rawRepo.settings);
503
- expandedRepos.push({
504
- git: gitUrl,
505
- files,
506
- prOptions,
507
- settings,
508
- upstream: rawRepo.upstream,
509
- source: rawRepo.source,
510
- });
513
+ }
514
+ for (const gitUrl of gitUrls) {
515
+ expandedRepos.push(normalizeRepoEntry({
516
+ gitUrl,
517
+ rawRepo,
518
+ effectiveRootFiles,
519
+ fileNames,
520
+ repoOnlyFileNames,
521
+ effectivePROptions,
522
+ effectiveSettings,
523
+ globalDeleteOrphaned: raw.deleteOrphaned,
524
+ env,
525
+ }));
511
526
  }
512
527
  }
513
528
  // Normalize root settings by reusing mergeSettings with no per-repo overlay.
@@ -1,4 +1,4 @@
1
- import type { RawConfig, RawRepoSettings, RawRootSettings } from "./types.js";
1
+ import type { RawConfig, RawRootSettings, RawRepoSettings } from "./types.js";
2
2
  /**
3
3
  * Validates raw config structure before normalization.
4
4
  * @throws ValidationError if validation fails
@@ -9,7 +9,4 @@ export declare function validateRawConfig(config: RawConfig): void;
9
9
  * @throws ValidationError if neither files nor settings are present
10
10
  */
11
11
  export declare function validateForSync(config: RawConfig): void;
12
- /**
13
- * Checks if settings contain actionable configuration.
14
- */
15
12
  export declare function hasActionableSettings(settings: RawRootSettings | RawRepoSettings | undefined): boolean;