@fuzdev/fuz_gitops 0.78.1 → 0.79.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 (179) hide show
  1. package/README.md +78 -24
  2. package/dist/ModulesDetail.svelte +15 -22
  3. package/dist/ModulesDetail.svelte.d.ts.map +1 -1
  4. package/dist/ModulesNav.svelte +4 -1
  5. package/dist/ModulesPage.svelte +1 -3
  6. package/dist/ModulesPage.svelte.d.ts.map +1 -1
  7. package/dist/PageHeader.svelte +2 -2
  8. package/dist/PageHeader.svelte.d.ts +0 -3
  9. package/dist/PageHeader.svelte.d.ts.map +1 -1
  10. package/dist/PullRequestsDetail.svelte +10 -9
  11. package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
  12. package/dist/PullRequestsPage.svelte +1 -3
  13. package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
  14. package/dist/ReposTable.svelte +27 -48
  15. package/dist/ReposTable.svelte.d.ts.map +1 -1
  16. package/dist/ReposTree.svelte +5 -19
  17. package/dist/ReposTree.svelte.d.ts.map +1 -1
  18. package/dist/ReposTreeNav.svelte +5 -7
  19. package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
  20. package/dist/TablePage.svelte +1 -3
  21. package/dist/TablePage.svelte.d.ts.map +1 -1
  22. package/dist/TreeItemPage.svelte +1 -3
  23. package/dist/TreeItemPage.svelte.d.ts.map +1 -1
  24. package/dist/TreePage.svelte +1 -3
  25. package/dist/TreePage.svelte.d.ts.map +1 -1
  26. package/dist/ci_reconcile.d.ts +6 -10
  27. package/dist/ci_reconcile.d.ts.map +1 -1
  28. package/dist/ci_reconcile.js +6 -8
  29. package/dist/dependency_graph.d.ts +26 -49
  30. package/dist/dependency_graph.d.ts.map +1 -1
  31. package/dist/dependency_graph.js +37 -80
  32. package/dist/dependency_updater.d.ts +1 -1
  33. package/dist/dependency_updater.d.ts.map +1 -1
  34. package/dist/dependency_updater.js +6 -1
  35. package/dist/fetch_repo_data.d.ts +34 -8
  36. package/dist/fetch_repo_data.d.ts.map +1 -1
  37. package/dist/fetch_repo_data.js +44 -22
  38. package/dist/fs_fetch_value_cache.d.ts +1 -1
  39. package/dist/fs_fetch_value_cache.js +1 -1
  40. package/dist/git_operations.d.ts +6 -49
  41. package/dist/git_operations.d.ts.map +1 -1
  42. package/dist/git_operations.js +13 -126
  43. package/dist/github.d.ts +17 -2
  44. package/dist/github.d.ts.map +1 -1
  45. package/dist/github.js +15 -6
  46. package/dist/github_helpers.d.ts.map +1 -1
  47. package/dist/github_helpers.js +3 -11
  48. package/dist/gitops_analyze.task.d.ts +24 -2
  49. package/dist/gitops_analyze.task.d.ts.map +1 -1
  50. package/dist/gitops_analyze.task.js +43 -30
  51. package/dist/gitops_config.d.ts +34 -79
  52. package/dist/gitops_config.d.ts.map +1 -1
  53. package/dist/gitops_config.js +76 -53
  54. package/dist/gitops_constants.d.ts +6 -4
  55. package/dist/gitops_constants.d.ts.map +1 -1
  56. package/dist/gitops_constants.js +6 -4
  57. package/dist/gitops_plan.task.d.ts +29 -3
  58. package/dist/gitops_plan.task.d.ts.map +1 -1
  59. package/dist/gitops_plan.task.js +80 -58
  60. package/dist/gitops_publish.task.d.ts +62 -2
  61. package/dist/gitops_publish.task.d.ts.map +1 -1
  62. package/dist/gitops_publish.task.js +187 -102
  63. package/dist/gitops_run.task.d.ts +74 -0
  64. package/dist/gitops_run.task.d.ts.map +1 -1
  65. package/dist/gitops_run.task.js +174 -160
  66. package/dist/gitops_sync.task.d.ts +65 -3
  67. package/dist/gitops_sync.task.d.ts.map +1 -1
  68. package/dist/gitops_sync.task.js +154 -30
  69. package/dist/gitops_task_helpers.d.ts +86 -59
  70. package/dist/gitops_task_helpers.d.ts.map +1 -1
  71. package/dist/gitops_task_helpers.js +120 -79
  72. package/dist/gitops_validate.task.d.ts +1 -2
  73. package/dist/gitops_validate.task.d.ts.map +1 -1
  74. package/dist/gitops_validate.task.js +15 -22
  75. package/dist/graph_validation.d.ts +16 -27
  76. package/dist/graph_validation.d.ts.map +1 -1
  77. package/dist/graph_validation.js +23 -64
  78. package/dist/local_repo.d.ts +50 -62
  79. package/dist/local_repo.d.ts.map +1 -1
  80. package/dist/local_repo.js +80 -193
  81. package/dist/log_helpers.d.ts +8 -8
  82. package/dist/log_helpers.d.ts.map +1 -1
  83. package/dist/multi_repo_publisher.d.ts +10 -0
  84. package/dist/multi_repo_publisher.d.ts.map +1 -1
  85. package/dist/multi_repo_publisher.js +115 -51
  86. package/dist/npm_registry.d.ts +23 -19
  87. package/dist/npm_registry.d.ts.map +1 -1
  88. package/dist/npm_registry.js +20 -47
  89. package/dist/operations.d.ts +75 -180
  90. package/dist/operations.d.ts.map +1 -1
  91. package/dist/operations.js +15 -7
  92. package/dist/operations_defaults.d.ts +17 -2
  93. package/dist/operations_defaults.d.ts.map +1 -1
  94. package/dist/operations_defaults.js +104 -121
  95. package/dist/output_helpers.d.ts +36 -0
  96. package/dist/output_helpers.d.ts.map +1 -1
  97. package/dist/output_helpers.js +37 -3
  98. package/dist/preflight_checks.d.ts +20 -27
  99. package/dist/preflight_checks.d.ts.map +1 -1
  100. package/dist/preflight_checks.js +37 -126
  101. package/dist/publish_steps.d.ts +2 -4
  102. package/dist/publish_steps.d.ts.map +1 -1
  103. package/dist/publish_steps.js +2 -6
  104. package/dist/publishing_event.d.ts +2 -8
  105. package/dist/publishing_event.d.ts.map +1 -1
  106. package/dist/publishing_event.js +5 -4
  107. package/dist/publishing_event_handler.d.ts +10 -8
  108. package/dist/publishing_event_handler.d.ts.map +1 -1
  109. package/dist/publishing_event_handler.js +14 -12
  110. package/dist/publishing_plan.d.ts +16 -1
  111. package/dist/publishing_plan.d.ts.map +1 -1
  112. package/dist/publishing_plan.js +328 -250
  113. package/dist/publishing_plan_logging.d.ts +1 -1
  114. package/dist/publishing_plan_logging.d.ts.map +1 -1
  115. package/dist/publishing_plan_logging.js +21 -14
  116. package/dist/repo.svelte.d.ts +7 -0
  117. package/dist/repo.svelte.d.ts.map +1 -1
  118. package/dist/repo.svelte.js +3 -0
  119. package/dist/repo_readiness.d.ts +210 -0
  120. package/dist/repo_readiness.d.ts.map +1 -0
  121. package/dist/repo_readiness.js +516 -0
  122. package/dist/repos_status.d.ts +3340 -0
  123. package/dist/repos_status.d.ts.map +1 -0
  124. package/dist/repos_status.js +555 -0
  125. package/dist/repos_status_load.d.ts +65 -0
  126. package/dist/repos_status_load.d.ts.map +1 -0
  127. package/dist/repos_status_load.js +131 -0
  128. package/package.json +7 -27
  129. package/src/lib/ci_reconcile.ts +7 -11
  130. package/src/lib/dependency_graph.ts +45 -97
  131. package/src/lib/dependency_updater.ts +7 -1
  132. package/src/lib/fetch_repo_data.ts +66 -29
  133. package/src/lib/fs_fetch_value_cache.ts +1 -1
  134. package/src/lib/git_operations.ts +15 -187
  135. package/src/lib/github.ts +20 -8
  136. package/src/lib/github_helpers.ts +5 -12
  137. package/src/lib/gitops_analyze.task.ts +79 -41
  138. package/src/lib/gitops_config.ts +88 -137
  139. package/src/lib/gitops_constants.ts +6 -4
  140. package/src/lib/gitops_plan.task.ts +113 -61
  141. package/src/lib/gitops_publish.task.ts +255 -122
  142. package/src/lib/gitops_run.task.ts +250 -178
  143. package/src/lib/gitops_sync.task.ts +208 -33
  144. package/src/lib/gitops_task_helpers.ts +166 -110
  145. package/src/lib/gitops_validate.task.ts +17 -27
  146. package/src/lib/graph_validation.ts +25 -86
  147. package/src/lib/local_repo.ts +109 -316
  148. package/src/lib/log_helpers.ts +8 -18
  149. package/src/lib/multi_repo_publisher.ts +138 -53
  150. package/src/lib/npm_registry.ts +37 -62
  151. package/src/lib/operations.ts +78 -150
  152. package/src/lib/operations_defaults.ts +108 -154
  153. package/src/lib/output_helpers.ts +55 -3
  154. package/src/lib/preflight_checks.ts +47 -183
  155. package/src/lib/publish_steps.ts +15 -13
  156. package/src/lib/publishing_event.ts +5 -4
  157. package/src/lib/publishing_event_handler.ts +18 -16
  158. package/src/lib/publishing_plan.ts +451 -287
  159. package/src/lib/publishing_plan_logging.ts +32 -23
  160. package/src/lib/repo.svelte.ts +8 -0
  161. package/src/lib/repo_readiness.ts +647 -0
  162. package/src/lib/repos_status.ts +671 -0
  163. package/src/lib/repos_status_load.ts +173 -0
  164. package/dist/config_reconcile.d.ts +0 -46
  165. package/dist/config_reconcile.d.ts.map +0 -1
  166. package/dist/config_reconcile.js +0 -64
  167. package/dist/paths.d.ts +0 -7
  168. package/dist/paths.d.ts.map +0 -1
  169. package/dist/paths.js +0 -6
  170. package/dist/repo_ops.d.ts +0 -59
  171. package/dist/repo_ops.d.ts.map +0 -1
  172. package/dist/repo_ops.js +0 -170
  173. package/dist/resolved_gitops_config.d.ts +0 -9
  174. package/dist/resolved_gitops_config.d.ts.map +0 -1
  175. package/dist/resolved_gitops_config.js +0 -12
  176. package/src/lib/config_reconcile.ts +0 -98
  177. package/src/lib/paths.ts +0 -6
  178. package/src/lib/repo_ops.ts +0 -216
  179. package/src/lib/resolved_gitops_config.ts +0 -27
@@ -5,18 +5,22 @@ import { is_breaking_change, compare_bump_types, calculate_next_version } from '
5
5
  import { default_changeset_operations } from './operations_defaults.js';
6
6
  import { GITOPS_MAX_ITERATIONS_DEFAULT } from './gitops_constants.js';
7
7
  import { calculate_dependency_updates, get_required_bump_for_dependencies } from './publishing_plan_helpers.js';
8
- // Re-export logging functions
9
- export { log_publishing_plan } from './publishing_plan_logging.js';
8
+ /**
9
+ * Classifies a version change — the one classification the plan's logger,
10
+ * markdown, and side-effect preview share. A change without changesets of its
11
+ * own is `auto` even if its bump was raised.
12
+ */
13
+ export const version_change_kind = (change) => !change.has_changesets ? 'auto' : change.needs_bump_escalation ? 'escalation' : 'explicit';
10
14
  /**
11
15
  * Generates a publishing plan showing what would happen during publishing.
12
16
  * Shows version changes, dependency updates, and breaking change cascades.
13
17
  * Uses fixed-point iteration to resolve transitive cascades.
14
18
  */
15
19
  export const generate_publishing_plan = async (all_repos, options = {}) => {
16
- const { log, ops = default_changeset_operations, verbose } = options;
20
+ const { log, ops = default_changeset_operations, verbose = false } = options;
17
21
  log?.info(st('cyan', 'Generating publishing plan...'));
18
22
  const warnings = [];
19
- const info = []; // Informational status (not warnings)
23
+ const info = [];
20
24
  const errors = [];
21
25
  // Publishing concerns only npm packages. Non-npm repos (e.g. Rust cargo repos) are
22
26
  // loaded for the dashboard but have no npm identity in the changeset cascade — drop them
@@ -27,20 +31,13 @@ export const generate_publishing_plan = async (all_repos, options = {}) => {
27
31
  non_npm_repos.map((r) => r.library.name).join(', '));
28
32
  }
29
33
  const repos = all_repos.filter(repo_is_npm);
30
- // Verbose data collection
31
- const verbose_changeset_details = [];
32
- const verbose_iterations = [];
33
34
  // Build dependency graph and validate
34
35
  let publishing_order;
35
36
  let production_cycles;
36
37
  let dev_cycles;
37
- let graph = null;
38
+ let graph;
38
39
  try {
39
- const validation = validate_dependency_graph(repos, {
40
- throw_on_prod_cycles: false, // Collect errors instead of throwing
41
- log_cycles: false, // We'll handle our own error collection
42
- log_order: false // Plan generation doesn't need to log order
43
- });
40
+ const validation = validate_dependency_graph(repos);
44
41
  publishing_order = validation.publishing_order;
45
42
  production_cycles = validation.production_cycles;
46
43
  dev_cycles = validation.dev_cycles;
@@ -59,94 +56,201 @@ export const generate_publishing_plan = async (all_repos, options = {}) => {
59
56
  breaking_cascades: new Map(),
60
57
  warnings,
61
58
  info,
59
+ no_changes: [],
62
60
  errors
63
61
  };
64
62
  }
65
- // Collect cycle errors
66
- if (production_cycles.length > 0) {
67
- for (const cycle of production_cycles) {
68
- errors.push(`Production dependency cycle: ${cycle.join(' → ')}`);
69
- }
63
+ for (const cycle of production_cycles) {
64
+ errors.push(`Production dependency cycle: ${cycle.join(' → ')}`);
70
65
  }
71
66
  // Dev cycles are shown in gitops_analyze, not repeated here
72
67
  if (dev_cycles.length > 0) {
73
68
  info.push(`${dev_cycles.length} dev dependency cycle(s) detected (normal, shown in gitops_analyze)`);
74
69
  }
75
- // Initial pass: get all packages with explicit changesets
76
- const predicted_versions = new Map();
77
- const breaking_packages = new Set();
78
- const version_changes = [];
70
+ // Checked once per repo; the initial pass and the no-changes list both read it.
71
+ const changeset_presence = new Map(await Promise.all(repos.map(async (repo) => [repo.library.name, await ops.has_changesets({ repo })])));
72
+ const state = {
73
+ version_changes: [],
74
+ predicted_versions: new Map(),
75
+ breaking_packages: new Set()
76
+ };
77
+ const { changeset_details, repos_without_bump } = await plan_initial_pass({
78
+ repos,
79
+ publishing_order,
80
+ changeset_presence,
81
+ state,
82
+ ops,
83
+ log,
84
+ verbose,
85
+ warnings,
86
+ errors
87
+ });
88
+ const cascade = plan_resolve_cascades({ repos, state, changeset_details, verbose });
89
+ // Worded after the cascades, which may give such a repo an auto-changeset
90
+ for (const repo of repos_without_bump) {
91
+ const publishes = state.version_changes.some((vc) => vc.package_name === repo.library.name);
92
+ warnings.push(await describe_changesets_without_bump(repo, publishes, ops));
93
+ }
94
+ if (!cascade.converged) {
95
+ const convergence_warning = plan_convergence_warning(repos, state);
96
+ if (convergence_warning)
97
+ warnings.push(convergence_warning);
98
+ }
99
+ // Final dependency updates calculation after convergence
100
+ const { dependency_updates, breaking_cascades } = calculate_dependency_updates(repos, state.predicted_versions, state.breaking_packages);
101
+ // Packages with nothing to publish: no version change and no changesets
102
+ const no_changes = [];
103
+ for (const repo of repos) {
104
+ const name = repo.library.name;
105
+ if (state.version_changes.some((vc) => vc.package_name === name))
106
+ continue;
107
+ const presence = changeset_presence.get(name);
108
+ if (presence?.ok && !presence.value) {
109
+ no_changes.push(name);
110
+ }
111
+ }
112
+ const verbose_data = verbose
113
+ ? plan_verbose_data({
114
+ graph,
115
+ version_changes: state.version_changes,
116
+ dependency_updates,
117
+ breaking_cascades,
118
+ changeset_details,
119
+ iterations: cascade.verbose_iterations,
120
+ total_iterations: cascade.iterations,
121
+ prod_cycle_count: production_cycles.length,
122
+ dev_cycle_count: dev_cycles.length
123
+ })
124
+ : undefined;
125
+ return {
126
+ publishing_order,
127
+ version_changes: state.version_changes,
128
+ dependency_updates,
129
+ breaking_cascades,
130
+ warnings,
131
+ info,
132
+ no_changes,
133
+ errors,
134
+ verbose_data
135
+ };
136
+ };
137
+ /**
138
+ * The initial pass: a version change for each package with explicit changesets,
139
+ * in publishing order.
140
+ *
141
+ * @mutates options.state - adds the explicit version changes, their predicted versions, and the breaking ones
142
+ * @mutates options.warnings - private packages carrying changesets
143
+ * @mutates options.errors - failed changeset checks and predictions
144
+ */
145
+ const plan_initial_pass = async (options) => {
146
+ const { repos, publishing_order, changeset_presence, state, ops, log, verbose } = options;
147
+ const { warnings, errors } = options;
148
+ const changeset_details = [];
149
+ const repos_without_bump = [];
79
150
  for (const pkg_name of publishing_order) {
80
151
  const repo = repos.find((r) => r.library.name === pkg_name);
81
152
  if (!repo)
82
153
  continue;
154
+ const has_result = changeset_presence.get(pkg_name);
155
+ if (!has_result)
156
+ continue;
83
157
  // Private packages never publish — exclude them from version changes entirely (no
84
158
  // publish step, npm-wait, bump escalation, or auto-changeset). They keep their slot in
85
159
  // the topological order. Flag a private package that carries a changeset, since that
86
160
  // changeset can't be published.
87
161
  if (repo.package_json.private) {
88
- const private_has_changesets = await ops.has_changesets({ repo });
89
- if (private_has_changesets.ok && private_has_changesets.value) {
162
+ if (has_result.ok && has_result.value) {
90
163
  warnings.push(`${pkg_name} is private — its changeset(s) will not be published`);
91
164
  }
92
165
  continue;
93
166
  }
94
- // Check for changesets
95
- const has_result = await ops.has_changesets({ repo });
96
167
  if (!has_result.ok) {
97
168
  errors.push(`Failed to check changesets for ${pkg_name}: ${has_result.message}`);
98
169
  continue;
99
170
  }
100
- if (has_result.value) {
101
- // Predict version from changesets
102
- const prediction = await ops.predict_next_version({ repo, log });
103
- if (!prediction) {
104
- // No changesets found - this shouldn't happen since has_changesets returned true
105
- continue;
106
- }
107
- if (!prediction.ok) {
108
- errors.push(`Failed to predict version for ${pkg_name}: ${prediction.message}`);
109
- continue;
110
- }
111
- // Capture changeset details for verbose output
112
- if (verbose) {
113
- const changesets_result = await ops.read_changesets({ repo, log });
114
- if (changesets_result.ok) {
115
- const files = changesets_result.value
116
- .filter((cs) => cs.packages.some((p) => p.name === pkg_name))
117
- .map((cs) => {
118
- const pkg_entry = cs.packages.find((p) => p.name === pkg_name);
119
- return {
120
- filename: cs.filename,
121
- bump_type: pkg_entry?.bump_type || prediction.bump_type,
122
- summary: cs.summary
123
- };
124
- });
125
- if (files.length > 0) {
126
- verbose_changeset_details.push({ package_name: pkg_name, files });
127
- }
128
- }
129
- }
130
- {
131
- const old_version = repo.package_json.version || '0.0.0';
132
- const is_breaking = is_breaking_change(old_version, prediction.bump_type);
133
- predicted_versions.set(pkg_name, prediction.version);
134
- if (is_breaking) {
135
- breaking_packages.add(pkg_name);
136
- }
137
- version_changes.push({
138
- package_name: pkg_name,
139
- from: old_version,
140
- to: prediction.version,
141
- bump_type: prediction.bump_type,
142
- breaking: is_breaking,
143
- has_changesets: true
171
+ if (!has_result.value)
172
+ continue;
173
+ // Predict version from changesets
174
+ const prediction = await ops.predict_next_version({ repo, log });
175
+ if (!prediction) {
176
+ // Changeset files exist, but none yields a bump for this package
177
+ repos_without_bump.push(repo);
178
+ continue;
179
+ }
180
+ if (!prediction.ok) {
181
+ errors.push(`Failed to predict version for ${pkg_name}: ${prediction.message}`);
182
+ continue;
183
+ }
184
+ // Capture changeset details for verbose output
185
+ if (verbose) {
186
+ const changesets_result = await ops.read_changesets({ repo, log });
187
+ if (changesets_result.ok) {
188
+ const files = changesets_result.value
189
+ .filter((cs) => cs.packages.some((p) => p.name === pkg_name))
190
+ .map((cs) => {
191
+ const pkg_entry = cs.packages.find((p) => p.name === pkg_name);
192
+ return {
193
+ filename: cs.filename,
194
+ bump_type: pkg_entry?.bump_type || prediction.bump_type,
195
+ summary: cs.summary
196
+ };
144
197
  });
198
+ if (files.length > 0) {
199
+ changeset_details.push({ package_name: pkg_name, files });
200
+ }
145
201
  }
146
202
  }
203
+ const old_version = repo.package_json.version || '0.0.0';
204
+ const is_breaking = is_breaking_change(old_version, prediction.bump_type);
205
+ state.predicted_versions.set(pkg_name, prediction.version);
206
+ if (is_breaking) {
207
+ state.breaking_packages.add(pkg_name);
208
+ }
209
+ state.version_changes.push({
210
+ package_name: pkg_name,
211
+ from: old_version,
212
+ to: prediction.version,
213
+ bump_type: prediction.bump_type,
214
+ breaking: is_breaking,
215
+ has_changesets: true
216
+ });
217
+ }
218
+ return { changeset_details, repos_without_bump };
219
+ };
220
+ /**
221
+ * Words the warning for a repo whose changeset files yield no bump for its
222
+ * package. It takes no bump from them: it publishes only as the auto-generated
223
+ * change a dependency update requires, and otherwise not at all.
224
+ *
225
+ * @param publishes - whether the settled plan has a version change for the repo
226
+ */
227
+ const describe_changesets_without_bump = async (repo, publishes, ops) => {
228
+ const pkg_name = repo.library.name;
229
+ const consequence = publishes
230
+ ? 'it takes no bump from them and publishes only as the auto-generated change a dependency update requires'
231
+ : 'it will not publish';
232
+ // no `log`: `predict_next_version` already logged any invalid files
233
+ const read = await ops.read_changesets({ repo });
234
+ if (!read.ok) {
235
+ return `${pkg_name} has changeset files that could not be read (${read.message}) — ${consequence}`;
147
236
  }
148
- // Fixed-point iteration to resolve transitive cascades
149
- // Loop until no new version changes are discovered
237
+ if (read.value.length === 0) {
238
+ return `${pkg_name} has changeset files, but none parses — ${consequence}; check their frontmatter`;
239
+ }
240
+ return (`${pkg_name} has changeset files, but none that parses names ${pkg_name} — ${consequence}; ` +
241
+ `check the package names in their frontmatter`);
242
+ };
243
+ /**
244
+ * Fixed-point iteration over the dependency updates the current predictions
245
+ * imply: escalates explicit changes a breaking dependency outgrows, and adds or
246
+ * raises auto-changeset changes for dependents, until a pass finds nothing new.
247
+ *
248
+ * @mutates options.state - escalates and adds version changes, with their predicted versions and breaking set
249
+ */
250
+ const plan_resolve_cascades = (options) => {
251
+ const { repos, state, changeset_details, verbose } = options;
252
+ const { version_changes, predicted_versions, breaking_packages } = state;
253
+ const verbose_iterations = [];
150
254
  let iteration = 0;
151
255
  let changed = true;
152
256
  while (changed && iteration < GITOPS_MAX_ITERATIONS_DEFAULT) {
@@ -167,93 +271,89 @@ export const generate_publishing_plan = async (all_repos, options = {}) => {
167
271
  continue;
168
272
  // Get required bump from dependencies
169
273
  const required_bump = get_required_bump_for_dependencies(repo, dependency_updates, breaking_packages);
274
+ if (!required_bump)
275
+ continue;
170
276
  // Find triggering dependency for verbose output
171
277
  let triggering_dep = null;
172
- if (required_bump) {
173
- const relevant_updates = dependency_updates.filter((u) => u.dependent_package === pkg_name &&
174
- (u.type === 'dependencies' || u.type === 'peerDependencies') &&
175
- breaking_packages.has(u.updated_dependency));
176
- if (relevant_updates.length > 0) {
177
- triggering_dep = `${relevant_updates[0].updated_dependency} BREAKING`;
178
- }
278
+ const relevant_updates = dependency_updates.filter((u) => u.dependent_package === pkg_name &&
279
+ (u.type === 'dependencies' || u.type === 'peerDependencies') &&
280
+ breaking_packages.has(u.updated_dependency));
281
+ if (relevant_updates.length > 0) {
282
+ triggering_dep = `${relevant_updates[0].updated_dependency} BREAKING`;
179
283
  }
180
- // Check if already in version_changes (has changesets)
284
+ const old_version = repo.package_json.version || '0.0.0';
285
+ const new_version = calculate_next_version(old_version, required_bump);
286
+ const is_breaking = is_breaking_change(old_version, required_bump);
181
287
  const existing_entry = version_changes.find((vc) => vc.package_name === pkg_name);
182
288
  if (existing_entry) {
183
- // Package has changesets - check if it needs bump escalation
184
- if (required_bump && compare_bump_types(required_bump, existing_entry.bump_type) > 0) {
185
- // Dependencies require a larger bump than existing changesets provide
186
- const old_version = repo.package_json.version || '0.0.0';
187
- const new_version = calculate_next_version(old_version, required_bump);
188
- // Only mark as changed if version actually changed
189
- if (existing_entry.to !== new_version) {
190
- changed = true;
191
- verbose_new_changes++;
192
- // Capture verbose data before updating
193
- if (verbose) {
194
- const changeset_detail = verbose_changeset_details.find((d) => d.package_name === pkg_name);
195
- verbose_iteration_packages.push({
196
- name: pkg_name,
197
- changeset_count: changeset_detail?.files.length || 1,
198
- bump_from_changesets: existing_entry.bump_type,
199
- required_bump,
200
- triggering_dep,
201
- action: 'escalation',
202
- version_to: new_version,
203
- is_breaking: is_breaking_change(old_version, required_bump)
204
- });
205
- }
206
- existing_entry.needs_bump_escalation = true;
207
- existing_entry.existing_bump = existing_entry.bump_type;
208
- existing_entry.required_bump = required_bump;
209
- existing_entry.bump_type = required_bump;
210
- existing_entry.to = new_version;
211
- existing_entry.breaking = is_breaking_change(old_version, required_bump);
212
- // Update predicted versions
213
- predicted_versions.set(pkg_name, new_version);
214
- if (existing_entry.breaking) {
215
- breaking_packages.add(pkg_name);
216
- }
217
- }
289
+ // Dependencies may require a larger bump than the entry has. Only mark as
290
+ // changed if the version actually changes.
291
+ if (compare_bump_types(required_bump, existing_entry.bump_type) <= 0)
292
+ continue;
293
+ if (existing_entry.to === new_version)
294
+ continue;
295
+ changed = true;
296
+ verbose_new_changes++;
297
+ // An auto-changeset entry raised by a dependency that turned breaking later in the
298
+ // iteration (a dependent listed before its dependency) is still an auto-changeset:
299
+ // the executor generates its changeset at the bump the plan settles on. Only an
300
+ // entry with changesets of its own escalates.
301
+ const kind = version_change_kind(existing_entry);
302
+ if (verbose) {
303
+ const changeset_detail = changeset_details.find((d) => d.package_name === pkg_name);
304
+ verbose_iteration_packages.push({
305
+ name: pkg_name,
306
+ changeset_count: kind === 'auto' ? 0 : changeset_detail?.files.length || 1,
307
+ bump_from_changesets: kind === 'auto' ? null : existing_entry.bump_type,
308
+ required_bump,
309
+ triggering_dep,
310
+ action: kind === 'auto' ? 'auto_changeset' : 'escalation',
311
+ version_to: new_version,
312
+ is_breaking
313
+ });
314
+ }
315
+ if (kind !== 'auto') {
316
+ existing_entry.needs_bump_escalation = true;
317
+ existing_entry.existing_bump ??= existing_entry.bump_type;
318
+ existing_entry.required_bump = required_bump;
319
+ }
320
+ existing_entry.bump_type = required_bump;
321
+ existing_entry.to = new_version;
322
+ existing_entry.breaking = is_breaking;
323
+ predicted_versions.set(pkg_name, new_version);
324
+ if (is_breaking) {
325
+ breaking_packages.add(pkg_name);
218
326
  }
219
327
  }
220
- else if (required_bump) {
328
+ else if (!predicted_versions.has(pkg_name)) {
221
329
  // No existing changesets but needs changeset for dependency updates
222
- const old_version = repo.package_json.version || '0.0.0';
223
- const new_version = calculate_next_version(old_version, required_bump);
224
- // Check if this is a new version (not already in version_changes)
225
- if (!predicted_versions.has(pkg_name)) {
226
- changed = true;
227
- verbose_new_changes++;
228
- const is_breaking = is_breaking_change(old_version, required_bump);
229
- // Capture verbose data
230
- if (verbose) {
231
- verbose_iteration_packages.push({
232
- name: pkg_name,
233
- changeset_count: 0,
234
- bump_from_changesets: null,
235
- required_bump,
236
- triggering_dep,
237
- action: 'auto_changeset',
238
- version_to: new_version,
239
- is_breaking
240
- });
241
- }
242
- if (is_breaking) {
243
- breaking_packages.add(pkg_name);
244
- }
245
- version_changes.push({
246
- package_name: pkg_name,
247
- from: old_version,
248
- to: new_version,
249
- bump_type: required_bump,
250
- breaking: is_breaking,
251
- has_changesets: false,
252
- will_generate_changeset: true
330
+ changed = true;
331
+ verbose_new_changes++;
332
+ if (verbose) {
333
+ verbose_iteration_packages.push({
334
+ name: pkg_name,
335
+ changeset_count: 0,
336
+ bump_from_changesets: null,
337
+ required_bump,
338
+ triggering_dep,
339
+ action: 'auto_changeset',
340
+ version_to: new_version,
341
+ is_breaking
253
342
  });
254
- // Update predicted versions
255
- predicted_versions.set(pkg_name, new_version);
256
343
  }
344
+ if (is_breaking) {
345
+ breaking_packages.add(pkg_name);
346
+ }
347
+ version_changes.push({
348
+ package_name: pkg_name,
349
+ from: old_version,
350
+ to: new_version,
351
+ bump_type: required_bump,
352
+ breaking: is_breaking,
353
+ has_changesets: false,
354
+ will_generate_changeset: true
355
+ });
356
+ predicted_versions.set(pkg_name, new_version);
257
357
  }
258
358
  }
259
359
  // Store verbose iteration data
@@ -265,116 +365,94 @@ export const generate_publishing_plan = async (all_repos, options = {}) => {
265
365
  });
266
366
  }
267
367
  }
268
- // Check if we hit iteration limit without convergence
269
- if (iteration === GITOPS_MAX_ITERATIONS_DEFAULT && changed) {
270
- // Calculate how many packages still need processing
271
- const pending_packages = [];
272
- // Recalculate one more time to see what's pending
273
- const { dependency_updates: pending_updates } = calculate_dependency_updates(repos, predicted_versions, breaking_packages);
274
- for (const repo of repos) {
275
- const pkg_name = repo.library.name;
276
- if (repo.package_json.private)
277
- continue; // private packages never publish
278
- const required_bump = get_required_bump_for_dependencies(repo, pending_updates, breaking_packages);
279
- // Check if this package would need processing
280
- const existing_entry = version_changes.find((vc) => vc.package_name === pkg_name);
281
- const needs_escalation = existing_entry &&
282
- required_bump &&
283
- compare_bump_types(required_bump, existing_entry.bump_type) > 0;
284
- const needs_auto_changeset = !existing_entry && required_bump;
285
- if (needs_escalation || needs_auto_changeset) {
286
- pending_packages.push(pkg_name);
287
- }
368
+ return { iterations: iteration, converged: !changed, verbose_iterations };
369
+ };
370
+ /**
371
+ * Words the warning for a plan that hit `GITOPS_MAX_ITERATIONS_DEFAULT` still
372
+ * changing, naming the packages one more pass would change.
373
+ *
374
+ * @returns the warning, or `null` when one more pass would change nothing (the last pass converged it)
375
+ */
376
+ const plan_convergence_warning = (repos, state) => {
377
+ const { version_changes, predicted_versions, breaking_packages } = state;
378
+ const pending_packages = [];
379
+ // Recalculate one more time to see what's pending
380
+ const { dependency_updates: pending_updates } = calculate_dependency_updates(repos, predicted_versions, breaking_packages);
381
+ for (const repo of repos) {
382
+ const pkg_name = repo.library.name;
383
+ if (repo.package_json.private)
384
+ continue; // private packages never publish
385
+ const required_bump = get_required_bump_for_dependencies(repo, pending_updates, breaking_packages);
386
+ if (!required_bump)
387
+ continue;
388
+ const existing_entry = version_changes.find((vc) => vc.package_name === pkg_name);
389
+ if (!existing_entry || compare_bump_types(required_bump, existing_entry.bump_type) > 0) {
390
+ pending_packages.push(pkg_name);
288
391
  }
289
- // Add warning with diagnostics
290
- const pending_count = pending_packages.length;
291
- const estimated_iterations = Math.ceil(pending_count / 2); // Rough estimate
292
- warnings.push(`Reached maximum iterations (${GITOPS_MAX_ITERATIONS_DEFAULT}) without full convergence - ` +
293
- `${pending_count} package(s) may still need processing: ${pending_packages.join(', ')}. ` +
294
- `Estimated ${estimated_iterations} more iteration(s) needed.`);
295
392
  }
296
- // Final dependency updates calculation after convergence
297
- const { dependency_updates, breaking_cascades } = calculate_dependency_updates(repos, predicted_versions, breaking_packages);
298
- // Identify packages with no changes
299
- for (const repo of repos) {
300
- const has_version_change = version_changes.some((vc) => vc.package_name === repo.library.name);
301
- if (!has_version_change) {
302
- const has_result = await ops.has_changesets({ repo });
303
- if (has_result.ok && !has_result.value) {
304
- info.push(repo.library.name);
305
- }
393
+ if (pending_packages.length === 0)
394
+ return null;
395
+ return (`Reached maximum iterations (${GITOPS_MAX_ITERATIONS_DEFAULT}) without full convergence - ` +
396
+ `${pending_packages.length} package(s) may still need processing: ${pending_packages.join(', ')}.`);
397
+ };
398
+ /**
399
+ * Assembles the verbose diagnostics: propagation chains from the breaking
400
+ * cascades and a summary of the dependency graph, beside the changeset and
401
+ * iteration details the passes collected.
402
+ */
403
+ const plan_verbose_data = (options) => {
404
+ const { graph, version_changes, dependency_updates, breaking_cascades } = options;
405
+ // Build propagation chains from breaking_cascades
406
+ const propagation_chains = [];
407
+ for (const [source, affected] of breaking_cascades) {
408
+ const chain = [];
409
+ for (const pkg of affected) {
410
+ // Determine dep type and action
411
+ const update = dependency_updates.find((u) => u.dependent_package === pkg && u.updated_dependency === source);
412
+ const dep_type = update?.type === 'peerDependencies' ? 'peer' : 'prod';
413
+ const version_change = version_changes.find((vc) => vc.package_name === pkg);
414
+ const kind = version_change && version_change_kind(version_change);
415
+ const action = kind === 'auto' ? 'auto-changeset' : kind === 'escalation' ? 'bump escalation' : 'update';
416
+ chain.push({ pkg, dep_type, action });
417
+ }
418
+ if (chain.length > 0) {
419
+ propagation_chains.push({ source, chain });
306
420
  }
307
421
  }
308
- // Build verbose data if requested
309
- let verbose_data;
310
- if (verbose) {
311
- // Build propagation chains from breaking_cascades
312
- const propagation_chains = [];
313
- for (const [source, affected] of breaking_cascades) {
314
- const chain = [];
315
- for (const pkg of affected) {
316
- // Determine dep type and action
317
- const update = dependency_updates.find((u) => u.dependent_package === pkg && u.updated_dependency === source);
318
- const dep_type = update?.type === 'peerDependencies' ? 'peer' : 'prod';
319
- const version_change = version_changes.find((vc) => vc.package_name === pkg);
320
- let action = 'update';
321
- if (version_change?.will_generate_changeset) {
322
- action = 'auto-changeset';
422
+ // Build graph summary
423
+ const prod_peer_edges = [];
424
+ const dev_edges = [];
425
+ let internal_dep_count = 0;
426
+ for (const [pkg_name, node] of graph.nodes) {
427
+ for (const [dep_name, spec] of node.dependencies) {
428
+ // Only count internal dependencies (deps that are also in the graph)
429
+ if (graph.nodes.has(dep_name)) {
430
+ internal_dep_count++;
431
+ if (spec.type === 'dev') {
432
+ dev_edges.push({ from: pkg_name, to: dep_name });
323
433
  }
324
- else if (version_change?.needs_bump_escalation) {
325
- action = 'bump escalation';
326
- }
327
- chain.push({ pkg, dep_type, action });
328
- }
329
- if (chain.length > 0) {
330
- propagation_chains.push({ source, chain });
331
- }
332
- }
333
- // Build graph summary
334
- const prod_peer_edges = [];
335
- const dev_edges = [];
336
- let internal_dep_count = 0;
337
- for (const [pkg_name, node] of graph.nodes) {
338
- for (const [dep_name, spec] of node.dependencies) {
339
- // Only count internal dependencies (deps that are also in the graph)
340
- if (graph.nodes.has(dep_name)) {
341
- internal_dep_count++;
342
- if (spec.type === 'dev') {
343
- dev_edges.push({ from: pkg_name, to: dep_name });
344
- }
345
- else {
346
- prod_peer_edges.push({
347
- from: pkg_name,
348
- to: dep_name,
349
- type: spec.type === 'peer' ? 'peer' : 'prod'
350
- });
351
- }
434
+ else {
435
+ prod_peer_edges.push({
436
+ from: pkg_name,
437
+ to: dep_name,
438
+ type: spec.type === 'peer' ? 'peer' : 'prod'
439
+ });
352
440
  }
353
441
  }
354
442
  }
355
- verbose_data = {
356
- changeset_details: verbose_changeset_details,
357
- iterations: verbose_iterations,
358
- propagation_chains,
359
- graph_summary: {
360
- package_count: graph.nodes.size,
361
- internal_dep_count,
362
- prod_peer_edges,
363
- dev_edges,
364
- prod_cycle_count: production_cycles.length,
365
- dev_cycle_count: dev_cycles.length
366
- },
367
- total_iterations: iteration
368
- };
369
443
  }
370
444
  return {
371
- publishing_order,
372
- version_changes,
373
- dependency_updates,
374
- breaking_cascades,
375
- warnings,
376
- info,
377
- errors,
378
- verbose_data
445
+ changeset_details: options.changeset_details,
446
+ iterations: options.iterations,
447
+ propagation_chains,
448
+ graph_summary: {
449
+ package_count: graph.nodes.size,
450
+ internal_dep_count,
451
+ prod_peer_edges,
452
+ dev_edges,
453
+ prod_cycle_count: options.prod_cycle_count,
454
+ dev_cycle_count: options.dev_cycle_count
455
+ },
456
+ total_iterations: options.total_iterations
379
457
  };
380
458
  };