kettle-family 1.3.7 → 1.3.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec4f59e4397fe55738de3f0e3e482a2053d3c771716e9d94de40478f76ba80ae
4
- data.tar.gz: 7ea569f07fa6cc8cc46cf5351cf93441ece85870c80ab51aed2458e0ce66346a
3
+ metadata.gz: f74d2fca80b37d1577e7212df092067958426dc780d1e07594b545d057a7321e
4
+ data.tar.gz: 23f68c9dd77d68828708ecfea9d64c87af8b12182689e9c9c15e0dfddc6648d5
5
5
  SHA512:
6
- metadata.gz: 9dac940565396b2209bd67e957af7f2e015318d70572c05cca684e8de26c43d75755a142727d994baa5fbd646ce674fa7ab6c84bdb913ca9c6685824fd3beca6
7
- data.tar.gz: 53c75a31e0896365a67c7b0ec46a45830d1e71abe37843dc715ee7f086f39166fbf39c36620efc0760d00848a6ce11238da0ee81cf84cfa6ced5e192e13178e4
6
+ metadata.gz: 327d0135be9e3ee4fb1abea1794d3acf2e714f791b3e83063bb6290a5466ada57b0d49b969f85cbde18050499c34bb57a2334cd7b2a98857f411fe4b015147d9
7
+ data.tar.gz: 21892de4191d48f57e7b9183d6a45bbb7d6ef5b3ba1b1a3a4922ba42b39da09ddb0f3d8ba30d3e6c86730a86c876023e405b1d93617896f369916245b6b45718
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,39 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.9] - 2026-10-10
34
+
35
+ - TAG: [v1.3.9][1.3.9t]
36
+ - COVERAGE: 93.43% -- 7068/7565 lines in 43 files
37
+ - BRANCH COVERAGE: 77.95% -- 2832/3633 branches in 43 files
38
+ - 35.42% documented
39
+
40
+ ### Added
41
+
42
+ - New `clean-blockers` command uninstalls locally installed gems, from any family, whose unreleased versions are pinned by members’ lockfiles. Unlike `clean-unreleased`, which can only remove a family sibling newer than its latest release, it removes cross-family offenders: a kettle-dev member blocked by unreleased StructuredMerge gems, or vice versa.
43
+
44
+ - Add clean-installed: the deterministic inverse of install. Uninstalls each selected member's current source version (plus configured install local_dependencies, which install also covers) when that version is unreleased, including cached .gem files. The other two cleanups cannot see this pollution class: clean-unreleased derives candidates from release state and can miss versions it judges released, and clean-blockers only sees lockfile pins, so an installed-but-unpinned gem (observed: commonmarker-merge 7.1.10) survives and then breaks gem activation with a dangling exact pin once the rest of its series is removed. Release state comes from the same ReleaseStateCheck clean-unreleased uses; members at a released source version are left alone. Also extracts MemberLoader from LocalInstall so install and clean-installed construct members identically.
45
+
46
+ - Add a release-mode pre-clean to bup, bupb, and update-bundler: before a registry-only re-resolution, installed gems whose versions no registry publishes are removed, including cross-family gems named in member lockfiles. Previously the release-mode update only detected such pins after the fact (PublishedVersionCheck) and its advice to re-run the update was circular, because bundler resolves against the local gem dir and >= floors always re-pin the installed unreleased version. Removal up front turns that opaque post-update failure into a resolved-before-it-mattered cleanup. Fails open when the registry is unreachable: a network blip never removes gems. Also adds the standalone clean-installed command, the deterministic inverse of install, which uninstalls each member's unreleased source version (plus install local_dependencies) including cached .gem files.
47
+
48
+ ### Changed
49
+
50
+ - [kc] kettle-jem/prepare: updated 2 project files:
51
+ - dependencies (2)
52
+
53
+ - [kc] kettle-jem/template: updated 3 project files:
54
+ - code and tests (1)
55
+ - documentation (1)
56
+ - other (1)
57
+
58
+ ### Fixed
59
+
60
+ - Add a structured CommandResult#diagnostics channel carrying complete, machine-readable findings. stdout is a human summary truncated to its last 20 lines, so a JSON consumer parsing it to decide what to act on silently acted on an incomplete set: a real cross-family release reported 28 unpublished lockfile pins and the JSON carried only 20, losing 8 gems. PublishedVersionCheck now emits each pin as gem, version, remote, member, and lockfile data alongside its message.
61
+
62
+ - clean-blockers (and clean-unreleased, sharing GemUninstallSupport): two uninstall bugs fixed. The batched gem uninstall now retries once with --ignore-dependencies when RubyGems aborts on a dependent outside the batch (Gem::DependencyRemovalException), re-batching only what survived the abort; the dependency refusal previously left a partial cleanup reported as failure. And the cleanup now removes the cached .gem files as well as the installed specifications -- gem uninstall leaves cache/<name>-<version>.gem behind, and the next bundle install was reinstalling the same unreleased versions from that cache without touching the network, so the cleanup did not hold.
63
+
64
+ - release: stop handing member kettle-release children the family-root dev bundle via BUNDLE_GEMFILE in release mode. That bundle resolves the family's own members, which are unreleased while any member is being released, so the child's kettle-release died booting (observed: "Could not find kettle-family-1.3.9 ... in locally installed gems" for every member). In release mode the child now resolves kettle-release from its own released dependency; local release tooling (KETTLE_DEV_DEV set) still pins the local kettle-dev bundle.
65
+
33
66
  ## [1.3.7] - 2026-10-09
34
67
 
35
68
  - TAG: [v1.3.7][1.3.7t]
@@ -2931,7 +2964,9 @@ Please file a bug if you notice a violation of semantic versioning.
2931
2964
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2932
2965
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2933
2966
 
2934
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.7...HEAD
2967
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.9...HEAD
2968
+ [1.3.9]: https://github.com/kettle-dev/kettle-family/compare/v1.3.7...v1.3.9
2969
+ [1.3.9t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.9
2935
2970
  [1.3.7]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...v1.3.7
2936
2971
  [1.3.7t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.7
2937
2972
  [1.3.6]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...v1.3.6
data/README.md CHANGED
@@ -1192,7 +1192,7 @@ Thanks for RTFM. ☺️
1192
1192
  [📌gitmoji]: https://gitmoji.dev
1193
1193
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1194
1194
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1195
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.231-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1195
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.565-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1196
1196
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1197
1197
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1198
1198
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/lockfile_reset"
4
+
5
+ module Kettle
6
+ module Family
7
+ # Removes locally installed gem versions that block a command, including gems
8
+ # belonging to OTHER families.
9
+ #
10
+ # Why this exists alongside +UnreleasedGemCleanup+ (`clean-unreleased`): that
11
+ # command derives candidates from family members' release state, so it can
12
+ # only ever remove a member of its own family whose installed version exceeds
13
+ # the latest release. The gems that actually block a release are usually
14
+ # cross-family, and each family is blocked by the other's legitimately
15
+ # installed development versions. Measured live:
16
+ #
17
+ # kettle-dev 28 pins ast-crispr 7.1.10, ast-merge, tree_haver, ...
18
+ # kettle-drift 22 pins (all structuredmerge gems)
19
+ # kettle-family 28 pins (all structuredmerge gems)
20
+ # kettle-changelog 2 pins nomono 1.1.7, token-resolver 2.0.13
21
+ # kettle-jem 3 pins kettle-test 2.0.24, nomono, token-resolver
22
+ # smorg-rb 3 pins (all kettle-dev gems)
23
+ #
24
+ # Neither family's `clean-unreleased` can see the other's offenders, which is
25
+ # what forced the kettle-dev 3.1.9 and kettle-family 1.3.7 releases to be
26
+ # finished manually after `gem push`: the tag, checksum and GitHub Release
27
+ # steps all run through a release-mode dependency update, and that cannot
28
+ # resolve while cross-family unreleased pins are present.
29
+ #
30
+ # The blocker set comes from calling PublishedVersionCheck directly on each
31
+ # selected member and reading its structured diagnostics, NOT from running the
32
+ # blocked command and parsing its report. A dry-run release cannot surface
33
+ # them: workflow.rb:2522-2526 replaces the member result with a synthetic
34
+ # "release readiness requires lockfile normalization" entry before
35
+ # append_release_internal_checks runs, so published_version_check never
36
+ # appears and every diagnostics array is empty. The findings are computed
37
+ # inside release_lockfile_readiness_would_fail? (workflow.rb:5785-5794) and
38
+ # then discarded in favour of a boolean. Running with --execute to obtain them
39
+ # would run the release, which is what we are trying to unblock.
40
+ #
41
+ # PublishedVersionCheck is read-only, so this is safe to run without
42
+ # --execute; only the uninstall itself is gated.
43
+ class BlockerCleanup
44
+ PHASE = "clean_blockers"
45
+ BLOCKER_KIND = "unpublished_lockfile_pin"
46
+ NO_BLOCKERS_MESSAGE = "no unpublished lockfile pins found"
47
+ # A member in local-gem mode resolves its family siblings through PATH
48
+ # remotes, so its lockfile has no GEM-section specs to check. Reporting
49
+ # "no blockers" for it would claim a clean bill of health that was never
50
+ # established, so it is reported separately.
51
+ NOT_INSPECTABLE_MESSAGE = "lockfile has no registry-resolved pins; nothing inspectable"
52
+ NOT_REMOVABLE_PREFIX = "blockers pinned but not installed in a gem home (source checkout, not removable): "
53
+
54
+ include GemUninstallSupport
55
+
56
+ def initialize(members:, execute: false, runner: nil)
57
+ @members = members
58
+ @execute = execute
59
+ @runner = runner || CommandRunner.new(execute: execute)
60
+ end
61
+
62
+ # @return [Array<CommandResult>]
63
+ def results
64
+ findings = members.map { |member| member_blockers(member) }
65
+ removable = removable_candidates(findings)
66
+ annotated = annotate_contributors(findings, removable)
67
+ informational = informational_results(annotated)
68
+ return informational if removable.empty?
69
+
70
+ informational + batched_cleanup_results(annotated, removable)
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :members, :execute, :runner
76
+
77
+ def uninstall_phase
78
+ PHASE
79
+ end
80
+
81
+ # One member's blockers, from the check's structured diagnostics.
82
+ #
83
+ # @return [Hash] member, blockers (deduped [name, version, remote]),
84
+ # inspectable (whether the lockfile had registry pins at all)
85
+ def member_blockers(member)
86
+ lockfile = File.join(member.root, "Gemfile.lock")
87
+ return {member: member, blockers: [], inspectable: false} unless File.file?(lockfile)
88
+
89
+ pins = registry_pins(lockfile)
90
+ result = PublishedVersionCheck.call(member: member)
91
+ blockers = result.diagnostics
92
+ .select { |diagnostic| diagnostic["kind"] == BLOCKER_KIND }
93
+ .map { |diagnostic| [diagnostic["gem"], diagnostic["version"], diagnostic["remote"]] }
94
+ .uniq
95
+
96
+ {member: member, blockers: blockers, inspectable: !pins.empty?}
97
+ end
98
+
99
+ def registry_pins(lockfile)
100
+ Kettle::Dev::LockfileReset.registry_gem_specs_from_source(File.read(lockfile))
101
+ rescue
102
+ []
103
+ end
104
+
105
+ # Blockers that are actually installed in a gem home and can therefore be
106
+ # removed, deduplicated across members by [name, version].
107
+ #
108
+ # A blocker that is not installed in a gem home is a PATH or git source
109
+ # checkout: `gem uninstall` cannot remove it, and including one would abort
110
+ # the whole batch. Those are reported rather than attempted.
111
+ #
112
+ # The version is normalized by stripping any platform suffix, because a
113
+ # lockfile pins `nokogiri (1.19.4-x86_64-linux-gnu)` while gem homes store
114
+ # the spec as `nokogiri-1.19.4-x86_64-linux-gnu.gemspec` and
115
+ # #installed_versions records it as 1.19.4.
116
+ def removable_candidates(findings)
117
+ findings.flat_map { |finding| normalized_blockers(finding) }
118
+ .uniq
119
+ .select { |name, version| installed_versions(name).include?(version) }
120
+ end
121
+
122
+ # A finding's blockers as [name, Gem::Version] pairs, minus any whose version
123
+ # cannot be parsed. Shared by #removable_candidates and #annotate_contributors
124
+ # so both see identical pairs and cannot drift apart.
125
+ def normalized_blockers(finding)
126
+ finding.fetch(:blockers).filter_map do |name, version, _remote|
127
+ normalized = normalize_version(version)
128
+ [name, normalized] unless normalized.nil?
129
+ end
130
+ end
131
+
132
+ # Strips any platform suffix and parses, rejecting blanks.
133
+ #
134
+ # The blank guard matters: `Gem::Version.new("")` returns version "0" rather
135
+ # than raising, so an empty version from a malformed diagnostic would become
136
+ # a bogus `name:0` uninstall argument instead of being skipped.
137
+ #
138
+ # @return [Gem::Version, nil]
139
+ def normalize_version(version)
140
+ text = version.to_s.split("-", 2).first.to_s.strip
141
+ return nil if text.empty?
142
+
143
+ parse_gem_version(text)
144
+ end
145
+
146
+ # Adds `:own` — the subset of the global removable set this member pinned.
147
+ #
148
+ # Computed once here rather than on demand: three call sites need it
149
+ # (host selection, contributing filter, per-member reporting), and each
150
+ # would otherwise re-run the same normalization and membership test.
151
+ def annotate_contributors(findings, removable)
152
+ findings.map do |finding|
153
+ own = normalized_blockers(finding).select { |candidate| removable.include?(candidate) }.uniq
154
+ finding.merge(own: own)
155
+ end
156
+ end
157
+
158
+ # Per-member reporting. Members that contributed removable blockers get the
159
+ # shared batch outcome; the rest get a plain informational result.
160
+ def informational_results(annotated)
161
+ annotated.filter_map do |finding|
162
+ next if finding.fetch(:own).any?
163
+
164
+ member = finding.fetch(:member)
165
+ message = if finding.fetch(:blockers).any?
166
+ unremovable_blockers_message(finding)
167
+ elsif finding.fetch(:inspectable)
168
+ NO_BLOCKERS_MESSAGE
169
+ else
170
+ NOT_INSPECTABLE_MESSAGE
171
+ end
172
+ informational_result(member: member, message: message)
173
+ end
174
+ end
175
+
176
+ def contributors(annotated)
177
+ annotated.select { |finding| finding.fetch(:own).any? }
178
+ end
179
+
180
+ # Names the blockers this member pinned that cannot be removed here, and
181
+ # why, so a cross-family offender is attributable rather than mysterious.
182
+ def unremovable_blockers_message(finding)
183
+ names = finding.fetch(:blockers).map { |name, version, _remote| "#{name} #{version}" }
184
+ "#{NOT_REMOVABLE_PREFIX}#{names.join(", ")}"
185
+ end
186
+
187
+ # Runs the batch once, then reports that one outcome against every member
188
+ # that contributed to it. See the class comment for why a single batched
189
+ # invocation is required rather than one per gem.
190
+ def batched_cleanup_results(annotated, removable)
191
+ contributing = contributors(annotated)
192
+ # Defensive: `removable` is derived from findings' own blockers, so a
193
+ # non-empty set always has at least one contributor. Guarded anyway
194
+ # because a nil host would raise NoMethodError inside the uninstall.
195
+ host = contributing.first&.fetch(:member)
196
+ return [] if host.nil?
197
+
198
+ command = batched_uninstall_command(removable)
199
+ # gem uninstall does not depend on the working directory, so the first
200
+ # contributing member provides a real directory to execute in.
201
+ outcome = if execute
202
+ verified_uninstall_outcome(candidates: removable, host: host, command: command)
203
+ else
204
+ dry_run_uninstall_outcome(command: command)
205
+ end
206
+
207
+ contributing.map { |finding| member_result(finding, outcome) }
208
+ end
209
+
210
+ # One member's view of the shared batch outcome, naming the gems that member
211
+ # pinned so a failure is attributable rather than mysterious.
212
+ #
213
+ # The member's own contribution comes from the annotated finding:
214
+ # PublishedVersionCheck hits the registry, so re-invoking it during
215
+ # reporting would add a network round-trip per member.
216
+ def member_result(finding, outcome)
217
+ member = finding.fetch(:member)
218
+ own = finding.fetch(:own)
219
+ own_reason = describe_gem_versions(own)
220
+ reason = if outcome.ok? || own.empty?
221
+ outcome.reason
222
+ else
223
+ "batched gem uninstall including #{own_reason} failed"
224
+ end
225
+ outcome.dup.tap do |copy|
226
+ copy.member_name = member.name
227
+ copy.workdir = member.root
228
+ copy.stdout = outcome.skipped ? "would uninstall #{own_reason}" : outcome.stdout
229
+ copy.reason = reason
230
+ end
231
+ end
232
+
233
+ def informational_result(member:, message:)
234
+ CommandResult.new(
235
+ member_name: member.name,
236
+ phase: PHASE,
237
+ command: ["internal", PHASE],
238
+ workdir: member.root,
239
+ status: 0,
240
+ success: true,
241
+ stdout: message,
242
+ stderr: "",
243
+ elapsed_seconds: 0.0,
244
+ skipped: false,
245
+ reason: nil
246
+ )
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Family
5
+ # Uninstalls exactly what `install` installs: each selected member's current
6
+ # source version, plus the cached .gem files.
7
+ #
8
+ # This is the deterministic inverse of `install`, and it exists because the
9
+ # other two cleanups cannot see this class of pollution:
10
+ #
11
+ # - +UnreleasedGemCleanup+ (`clean-unreleased`) removes installed versions
12
+ # newer than the latest release, but it derives candidates per member from
13
+ # release state and can miss versions it judges released.
14
+ # - +BlockerCleanup+ (`clean-blockers`) removes what members' lockfiles pin,
15
+ # so an installed-but-unpinned gem is invisible to it. Observed live:
16
+ # commonmarker-merge 7.1.10 survived a blocker cleanup (no release
17
+ # lockfile pinned it), then broke gem activation family-wide with its
18
+ # dangling exact pin on markdown-merge = 7.1.10 once the rest of the
19
+ # series was removed.
20
+ #
21
+ # Candidates are the members `install` would act on -- the selected members
22
+ # plus any configured install local_dependencies -- restricted to those whose
23
+ # source version is unreleased (a member already at its released version has
24
+ # nothing to remove; uninstalling it would only churn the bundles that
25
+ # resolve it). Release state comes from the same ReleaseStateCheck
26
+ # +UnreleasedGemCleanup+ uses.
27
+ #
28
+ # The uninstall itself is the shared GemUninstallSupport batch: one
29
+ # invocation, a documented --ignore-dependencies retry when an out-of-batch
30
+ # dependent aborts it, cache removal, and post-uninstall re-enumeration.
31
+ #
32
+ # Note for the family running it: when this command is executed from an
33
+ # installed kettle-family, the batch includes kettle-family itself. The
34
+ # uninstall runs in a subprocess, so self-removal mid-run is safe; the
35
+ # executable falls back to the latest released install afterwards.
36
+ class CleanInstalled
37
+ PHASE = "clean_installed"
38
+ RELEASED_MESSAGE = "source version is released; nothing to uninstall"
39
+ NOT_INSTALLED_MESSAGE = "source version is not installed"
40
+ UNKNOWN_VERSION_MESSAGE = "source version is unknown; nothing to uninstall"
41
+ UNKNOWN_RELEASED_MESSAGE = "latest released version is unknown; no cleanup attempted"
42
+
43
+ include GemUninstallSupport
44
+ include MemberLoader
45
+
46
+ def initialize(config:, members:, execute: false, runner: nil)
47
+ @config = config
48
+ @members = members
49
+ @execute = execute
50
+ @runner = runner || CommandRunner.new(execute: execute)
51
+ end
52
+
53
+ # @return [Array<CommandResult>]
54
+ def results
55
+ findings = member_findings
56
+ removable = findings.filter_map { |finding| finding[:candidate] }.uniq
57
+ annotated = findings.map do |finding|
58
+ candidate = finding[:candidate]
59
+ own = (candidate && removable.include?(candidate)) ? [candidate] : []
60
+ finding.merge(own: own)
61
+ end
62
+ informational = annotated.filter_map { |finding| informational_for(finding) }
63
+ return informational if removable.empty?
64
+
65
+ informational + batched_cleanup_results(annotated.select { |finding| finding[:own].any? }, removable)
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :config, :members, :execute, :runner
71
+
72
+ def uninstall_phase
73
+ PHASE
74
+ end
75
+
76
+ # The members `install` would act on: selected members plus configured
77
+ # install local_dependencies, deduplicated by name.
78
+ def candidate_members
79
+ local = config.install_local_dependencies.map { |path| member_from_path(path) }
80
+ seen = {}
81
+ (local + members).each_with_object([]) do |member, memo|
82
+ next if seen.key?(member.name)
83
+
84
+ seen[member.name] = true
85
+ memo << member
86
+ end
87
+ end
88
+
89
+ def release_state_by_member
90
+ ReleaseStateCheck.new(config: config, members: candidate_members).results.each_with_object({}) do |result, memo|
91
+ memo[result.member_name] = result
92
+ end
93
+ end
94
+
95
+ # One member's candidate: its source version, when that version is
96
+ # unreleased and installed. [name, Gem::Version] or nil, plus the reason
97
+ # there is nothing to do.
98
+ def member_findings
99
+ states = release_state_by_member
100
+ candidate_members.map do |member|
101
+ state = states[member.name]
102
+ candidate, message = candidate_for(member, state)
103
+ {member: member, candidate: candidate, message: message, state: state}
104
+ end
105
+ end
106
+
107
+ def candidate_for(member, state)
108
+ return [nil, nil] unless state&.ok?
109
+
110
+ latest = latest_released_version(state.state)
111
+ return [nil, nil] unless latest
112
+
113
+ source = parse_gem_version(member.version.to_s)
114
+ return [nil, UNKNOWN_VERSION_MESSAGE] unless source
115
+ return [nil, RELEASED_MESSAGE] unless source > latest
116
+ return [[member.name, source], nil] if installed_versions(member.name).include?(source)
117
+
118
+ [nil, NOT_INSTALLED_MESSAGE]
119
+ end
120
+
121
+ def latest_released_version(state)
122
+ value = state.fetch("latest_released", nil).to_s
123
+ return nil if value.empty? || value == "unknown"
124
+
125
+ Gem::Version.new(value.delete_prefix("v"))
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ # Per-member outcomes that are not part of the batch: unreadable release
131
+ # state (a failure naming its member), or nothing to do (informational).
132
+ def informational_for(finding)
133
+ member = finding.fetch(:member)
134
+ state = finding[:state]
135
+ return release_state_failure(member: member, release_state: state) unless state&.ok?
136
+
137
+ message = finding[:message]
138
+ return nil if message.nil? && finding[:own].any?
139
+ return informational_result(member: member, message: UNKNOWN_RELEASED_MESSAGE) if message.nil?
140
+
141
+ informational_result(member: member, message: message)
142
+ end
143
+
144
+ # Runs the batch once, then reports that one outcome against every member
145
+ # whose gem was in it, mirroring UnreleasedGemCleanup: the command and
146
+ # exit state are shared, but each member reports only its own gem so a
147
+ # failure stays attributable.
148
+ def batched_cleanup_results(contributors, removable)
149
+ host = contributors.first.fetch(:member)
150
+ command = batched_uninstall_command(removable)
151
+ outcome = if execute
152
+ verified_uninstall_outcome(candidates: removable, host: host, command: command)
153
+ else
154
+ dry_run_uninstall_outcome(command: command)
155
+ end
156
+
157
+ contributors.map do |finding|
158
+ member_result(finding.fetch(:member), outcome, finding.fetch(:own))
159
+ end
160
+ end
161
+
162
+ def member_result(member, outcome, own_candidates)
163
+ own = describe_gem_versions(own_candidates)
164
+ reason = if outcome.ok? || own_candidates.empty?
165
+ outcome.reason
166
+ else
167
+ "batched gem uninstall including #{own} failed"
168
+ end
169
+ outcome.dup.tap do |copy|
170
+ copy.member_name = member.name
171
+ copy.workdir = member.root
172
+ copy.stdout = outcome.skipped ? "would uninstall #{own}" : outcome.stdout
173
+ copy.reason = reason
174
+ end
175
+ end
176
+
177
+ def informational_result(member:, message:)
178
+ CommandResult.new(
179
+ member_name: member.name,
180
+ phase: PHASE,
181
+ command: ["internal", PHASE],
182
+ workdir: member.root,
183
+ status: 0,
184
+ success: true,
185
+ stdout: message,
186
+ stderr: "",
187
+ elapsed_seconds: 0.0,
188
+ skipped: false,
189
+ reason: nil
190
+ )
191
+ end
192
+
193
+ def release_state_failure(member:, release_state:)
194
+ CommandResult.new(
195
+ member_name: member.name,
196
+ phase: PHASE,
197
+ command: ["internal", PHASE],
198
+ workdir: member.root,
199
+ status: release_state&.status || 1,
200
+ success: false,
201
+ stdout: "",
202
+ stderr: release_state&.stderr.to_s,
203
+ elapsed_seconds: 0.0,
204
+ skipped: false,
205
+ reason: "release state unavailable"
206
+ )
207
+ end
208
+ end
209
+ end
210
+ end
@@ -11,7 +11,7 @@ module Kettle
11
11
  class CLI < CommandKit::Command
12
12
  include CommandKit::Commands
13
13
 
14
- COMMANDS = %w[version mise-trust discover plan report metadata check clean-unreleased reconcile-releases reset test lint docs template gha-sha-pins bup bupb update-bundler bex install bump bump-version add-changelog release push pull sync up branch-lanes release-state state].freeze
14
+ COMMANDS = %w[version mise-trust discover plan report metadata check clean-blockers clean-installed clean-unreleased reconcile-releases reset test lint docs template gha-sha-pins bup bupb update-bundler bex install bump bump-version add-changelog release push pull sync up branch-lanes release-state state].freeze
15
15
  WORKFLOW_COMMANDS = %w[check reset test lint docs template gha-sha-pins bup bupb update-bundler bex release push pull sync up].freeze
16
16
 
17
17
  command_name "kettle-family"
@@ -353,6 +353,18 @@ module Kettle
353
353
  description "Uninstall locally installed family gem versions newer than the latest released version."
354
354
  end
355
355
 
356
+ class CleanBlockers < WorkflowCommand
357
+ command_name "clean-blockers"
358
+ usage "[options]"
359
+ description "Uninstall locally installed gems, from any family, whose unreleased versions are pinned by members' lockfiles."
360
+ end
361
+
362
+ class CleanInstalled < WorkflowCommand
363
+ command_name "clean-installed"
364
+ usage "[options]"
365
+ description "Uninstall the exact gem versions install installs: each member's current source version, when unreleased, including cached .gem files."
366
+ end
367
+
356
368
  class ReconcileReleases < BaseCommand
357
369
  include ExecutionOptions
358
370
 
@@ -657,6 +669,8 @@ module Kettle
657
669
  command "report", ReportCommand
658
670
  command Metadata
659
671
  command Check
672
+ command CleanBlockers
673
+ command CleanInstalled
660
674
  command CleanUnreleased
661
675
  command ReconcileReleases
662
676
  command Reset
@@ -870,6 +884,8 @@ module Kettle
870
884
  return bump_version_results(config: config, members: members, options: options, phase: command, release_state_results: release_state_results) if %w[bump bump-version].include?(command)
871
885
  return add_changelog_results(members: members, options: options) if command == "add-changelog"
872
886
  return clean_unreleased_results(config: config, members: members, options: options) if command == "clean-unreleased"
887
+ return clean_blockers_results(members: members, options: options) if command == "clean-blockers"
888
+ return clean_installed_results(config: config, members: members, options: options) if command == "clean-installed"
873
889
  return reconcile_release_results(config: config, members: members, options: options) if command == "reconcile-releases"
874
890
  return branch_lane_results(config: config, members: members) if command == "branch-lanes"
875
891
  return release_state_results(config: config, members: members, jobs: options[:jobs], event_handler: state_event_handler) if command == "release-state"
@@ -1361,6 +1377,14 @@ module Kettle
1361
1377
  UnreleasedGemCleanup.new(config: config, members: members, execute: options[:execute]).results
1362
1378
  end
1363
1379
 
1380
+ def clean_blockers_results(members:, options:)
1381
+ BlockerCleanup.new(members: members, execute: options[:execute]).results
1382
+ end
1383
+
1384
+ def clean_installed_results(config:, members:, options:)
1385
+ CleanInstalled.new(config: config, members: members, execute: options[:execute]).results
1386
+ end
1387
+
1364
1388
  def reconcile_release_results(config:, members:, options:)
1365
1389
  ReleaseReconciler.new(config: config, members: members, execute: options[:execute]).results
1366
1390
  end
@@ -18,7 +18,12 @@ module Kettle
18
18
  :output_streamed,
19
19
  :log_path,
20
20
  :resume_step,
21
- :resume_command
21
+ :resume_command,
22
+ # Machine-readable findings, complete and unsummarized. See #diagnostics.
23
+ # Appended last because this Struct is positional: specs construct it with
24
+ # positional arguments, so inserting a member mid-list would silently shift
25
+ # every value after it.
26
+ :diagnostics
22
27
  ) do
23
28
  def to_h
24
29
  {
@@ -37,10 +42,37 @@ module Kettle
37
42
  "output_streamed" => output_streamed?,
38
43
  "log_path" => log_path,
39
44
  "resume_step" => resume_step,
40
- "resume_command" => resume_command
45
+ "resume_command" => resume_command,
46
+ "diagnostics" => diagnostics
41
47
  }
42
48
  end
43
49
 
50
+ # Structured findings for machine consumers, complete and unsummarized.
51
+ #
52
+ # `stdout` is a human-facing summary and is deliberately truncated to its
53
+ # last 20 lines by #summarize, so it cannot carry a full finding set. A
54
+ # consumer that parsed the truncated `stdout` to decide what to act on
55
+ # would silently act on an incomplete set. That is not hypothetical:
56
+ # PublishedVersionCheck reported 28 unpublished lockfile pins for one
57
+ # member, and the JSON report carried only the last 20, losing 8 gems.
58
+ #
59
+ # The text report is unaffected — it prints `stdout` in full — which is why
60
+ # this only bites JSON consumers.
61
+ #
62
+ # Each entry is a Hash whose shape is defined by the producing check, plus
63
+ # a "message" string matching the human-readable line it summarizes. Always
64
+ # an Array, never nil, so consumers need no guard.
65
+ #
66
+ # Read through `self[:diagnostics]` rather than `super`: the block passed to
67
+ # `Struct.new` is class_eval'd onto the generated class, so this method
68
+ # REPLACES the generated accessor and has no superclass method to call.
69
+ # `Struct#[]` reads the slot directly and cannot recurse into this method.
70
+ #
71
+ # @return [Array<Hash>]
72
+ def diagnostics
73
+ Array(self[:diagnostics])
74
+ end
75
+
44
76
  def ok?
45
77
  success || skipped
46
78
  end