spm_version_updates 1.2.0 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53afb3742bddcef276c343767b4570a4d428cefe7560ba2460d461dfcf66c3c1
4
- data.tar.gz: bde5dd5228e9c7ae5188efa42ef0343bc1def8b45bb22f16a9ce343fb9f94e4e
3
+ metadata.gz: f7d008830011f3c063859526406f0ff3cb686dc80990d87bc3c684289f9eb833
4
+ data.tar.gz: 5257644ec20b9e1798a2f5072609173cb5892ace8bf1ea8bcfe3240dcc2a8b3b
5
5
  SHA512:
6
- metadata.gz: 80f629e2ef3cc2c5a2162a61cedf485948bbd88616451d666b82776fc4034744cd2b9cb8d78c2c02628446f32027206e9d1913b0fe495ca51cf53619b599ede1
7
- data.tar.gz: 50adb15cd7e1703487bc386a7f0bc81d022698d172ece4008b20ab33b9602c578bf33622a5a26340c713d1e02a5fd5af2d0d7fb9ec2db8fbcfe083ba8f3ace0a
6
+ metadata.gz: 38899d7c7a51654d7574625ac1bc2581f1817771f656c94bf4d24103dc861e1b162539295402c62be7c9f1138867a7161dc57f0f9a7ce1e8e586fb275bb794b3
7
+ data.tar.gz: 308cf9c70aaf5ea5ba120188d2dcb0df36a2d28d3cb9f671a01a71f92e935c9f82a7c29facc06013b6f6388bc9c69e7d137a37c3779f156ea6edb3d04fcd886f
data/README.md CHANGED
@@ -23,18 +23,17 @@ require "spm_version_updates"
23
23
 
24
24
  checker = SpmChecker.new
25
25
 
26
- # Manifest mode: check one or more Package.swift files (a Package.resolved
27
- # next to each manifest is used automatically when present).
28
- warnings = checker.check_manifests(["path/to/Package.swift"])
26
+ # Manifest mode: check one or more Package.swift files.
27
+ result = checker.check_manifests(["path/to/Package.swift"])
29
28
 
30
29
  # Xcode mode: check the packages referenced by an Xcode project.
31
- # warnings = checker.check_for_updates("path/to/App.xcodeproj")
30
+ # result = checker.check_for_updates("path/to/App.xcodeproj")
32
31
 
33
- warnings.each { |warning| puts warning }
32
+ result.updates.each { |update| puts update["message"] }
34
33
 
35
- # Structured details (repository URL, current/available version, severity,
36
- # suggested update command, ...) for each warning:
37
- checker.warning_details.each { |detail| p detail }
34
+ # String-keyed details include repository URL, current/available version,
35
+ # suggested update command, source, and related report fields.
36
+ result.updates.each { |update| p update }
38
37
  ```
39
38
 
40
39
  Behavior is configurable through accessors on `SpmChecker` — for example
@@ -113,9 +112,9 @@ is recorded on the checker — separate from update warnings, so update counts
113
112
  and fail-on thresholds are unaffected:
114
113
 
115
114
  ```ruby
116
- checker.check_manifests(["Modules/Package.swift"])
115
+ result = checker.check_manifests(["Modules/Package.swift"])
117
116
 
118
- checker.parse_warnings.each do |record|
117
+ result.parse_warnings.each do |record|
119
118
  # String-keyed hash: "type" ("parse_warning"), "reason", "source"
120
119
  # (the manifest path), "snippet" (credential-redacted, truncated),
121
120
  # and a human-readable "message".
@@ -7,13 +7,8 @@ require_relative "update_severity"
7
7
  module FailOnThreshold
8
8
  ANY = "any"
9
9
 
10
- def self.from_inputs(explicit_fail_on, legacy_fail_on)
11
- input_name, value = [
12
- ["fail-on", explicit_fail_on],
13
- ["fail-on-updates", legacy_fail_on],
14
- ["fail-on-updates", "false"],
15
- ].find { |_name, candidate| candidate }
16
- normalize(value, input_name)
10
+ def self.from_input(value)
11
+ normalize(value)
17
12
  end
18
13
 
19
14
  def self.failure_message(threshold, reporter)
@@ -29,13 +24,13 @@ module FailOnThreshold
29
24
  UpdateSeverity.count_at_or_above(reporter.severity_counts, threshold)
30
25
  end
31
26
 
32
- def self.normalize(value, input_name)
33
- normalized = value.downcase
34
- return nil if ["false", "none"].include?(normalized)
35
- return ANY if normalized == "true"
27
+ def self.normalize(value)
28
+ normalized = value.to_s.strip.downcase
29
+ return nil if ["", "false", "none"].include?(normalized)
30
+ return ANY if ["true", ANY].include?(normalized)
36
31
  return normalized if UpdateSeverity.threshold?(normalized)
37
32
 
38
- raise(SpmVersionUpdates::ConfigurationError, "#{input_name} must be false, true, major, minor, or patch")
33
+ raise(SpmVersionUpdates::ConfigurationError, "fail-on must be false, true, any, major, minor, or patch")
39
34
  end
40
35
 
41
36
  def self.build_message(threshold, count)
@@ -21,6 +21,123 @@ require_relative "package_resolved"
21
21
  # `"revision"`) so the same comparison logic can be reused for both modes.
22
22
  module ManifestParser
23
23
  PACKAGE_CALL = ".package("
24
+ # Raw body and byte offsets for a direct `.package(...)` declaration.
25
+ PackageCallSpan = Struct.new(:body, :body_start, :body_end, keyword_init: true)
26
+
27
+ # Navigates a normal Swift double-quoted string literal.
28
+ class StringLiteral
29
+ def initialize(content, index)
30
+ @content = content
31
+ @index = index
32
+ @cursor = index
33
+ end
34
+
35
+ def copy_to(output)
36
+ output << @content[@index...skip_index]
37
+ skip_index
38
+ end
39
+
40
+ def skip_index
41
+ @cursor = @index + 1
42
+ @cursor = next_cursor until finished?
43
+ finish_index
44
+ end
45
+
46
+ private
47
+
48
+ def finished?
49
+ @cursor >= @content.length || @content[@cursor] == '"'
50
+ end
51
+
52
+ def next_cursor
53
+ @content[@cursor] == "\\" ? @cursor + 2 : @cursor + 1
54
+ end
55
+
56
+ def finish_index
57
+ @cursor >= @content.length ? @cursor : @cursor + 1
58
+ end
59
+ end
60
+ private_constant :StringLiteral
61
+
62
+ # Navigates a Swift raw string literal such as #"..."# or #"""..."""#.
63
+ class RawStringLiteral
64
+ def initialize(content, index)
65
+ @content = content
66
+ @index = index
67
+ @hash_count = leading_hash_count
68
+ @quote_count = quote_count
69
+ end
70
+
71
+ def literal?
72
+ @hash_count.positive? && @quote_count.positive?
73
+ end
74
+
75
+ def copy_to(output)
76
+ output << @content[@index...skip_index]
77
+ skip_index
78
+ end
79
+
80
+ def skip_index
81
+ length = @content.length
82
+ index = @index + @hash_count + @quote_count
83
+ index += 1 until index >= length || ends_at?(index)
84
+ [index + @quote_count + @hash_count, length].min
85
+ end
86
+
87
+ private
88
+
89
+ def leading_hash_count
90
+ index = @index
91
+ index += 1 while @content[index] == "#"
92
+ index - @index
93
+ end
94
+
95
+ def quote_count
96
+ quote_index = @index + @hash_count
97
+ return 3 if @content[quote_index, 3] == '"""'
98
+ return 1 if @content[quote_index] == '"'
99
+
100
+ 0
101
+ end
102
+
103
+ def ends_at?(index)
104
+ @content[index, @quote_count] == '"' * @quote_count &&
105
+ @content[(index + @quote_count), @hash_count] == "#" * @hash_count
106
+ end
107
+ end
108
+ private_constant :RawStringLiteral
109
+
110
+ # Finds `.package(` markers while skipping Swift string literals.
111
+ class PackageCallFinder
112
+ def initialize(content)
113
+ @content = content
114
+ end
115
+
116
+ def next_from(search_start)
117
+ index = search_start
118
+ while index < @content.length
119
+ return index if package_call_at?(index)
120
+
121
+ index = next_index(index)
122
+ end
123
+ nil
124
+ end
125
+
126
+ private
127
+
128
+ def package_call_at?(index)
129
+ @content[index, PACKAGE_CALL.length] == PACKAGE_CALL
130
+ end
131
+
132
+ def next_index(index)
133
+ raw_string = RawStringLiteral.new(@content, index)
134
+ return raw_string.skip_index if raw_string.literal?
135
+ return StringLiteral.new(@content, index).skip_index if @content[index] == '"'
136
+
137
+ index + 1
138
+ end
139
+ end
140
+ private_constant :PackageCallFinder
24
141
 
25
142
  # Find the direct SPM dependencies declared in a `Package.swift` manifest.
26
143
  #
@@ -44,6 +161,15 @@ module ManifestParser
44
161
 
45
162
  content = strip_comments(File.read(manifest_path))
46
163
  package_calls(content, &on_skip).each_with_object({}) { |call, packages|
164
+ if call.include?("\\(")
165
+ on_skip&.call({ reason: "unsupported_string_interpolation", snippet: call })
166
+ next
167
+ end
168
+ if call.match?(/#+"/)
169
+ on_skip&.call({ reason: "unsupported_raw_string", snippet: call })
170
+ next
171
+ end
172
+
47
173
  url = call[/\burl\s*:\s*"([^"]+)"/, 1]
48
174
  next if url.nil? # local package (path:) or otherwise unrecognized
49
175
 
@@ -73,6 +199,15 @@ module ManifestParser
73
199
  File.join(File.dirname(manifest_path), "Package.resolved")
74
200
  end
75
201
 
202
+ # Extract raw source spans for `.package(...)` calls. Offsets are byte indexes
203
+ # into the original content and point to the call body, excluding outer parens.
204
+ #
205
+ # @param [String] content raw manifest source
206
+ # @return [Array<PackageCallSpan>]
207
+ def self.package_call_spans(content)
208
+ package_spans(content).map { |span| PackageCallSpan.new(**span) }
209
+ end
210
+
76
211
  # Extract the argument body of each `.package( ... )` call, honoring nested
77
212
  # parentheses (e.g. `.upToNextMajor(from: "1.0.0")`) and string literals.
78
213
  #
@@ -82,20 +217,42 @@ module ManifestParser
82
217
  # @param [String] content The (comment-stripped) manifest source
83
218
  # @return [Array<String>]
84
219
  def self.package_calls(content, &on_skip)
220
+ package_spans(content, &on_skip).map { |span| span[:body] }
221
+ end
222
+
223
+ def self.package_spans(content, &on_skip)
85
224
  calls = []
225
+ scan_package_spans(content, on_skip) { |span| calls << span }
226
+ calls
227
+ end
228
+
229
+ def self.scan_package_spans(content, on_skip)
86
230
  search_start = 0
87
- while (marker_index = content.index(PACKAGE_CALL, search_start))
88
- open_index = marker_index + PACKAGE_CALL.length - 1
89
- close_index = matching_paren(content, open_index)
90
- if close_index.nil?
91
- on_skip&.call({ reason: "unbalanced_parentheses", snippet: content[marker_index, 300] })
92
- break
93
- end
231
+ while (marker_index = next_package_call(content, search_start))
232
+ span = package_span(content, marker_index, on_skip)
233
+ break unless span
94
234
 
95
- calls << content[(open_index + 1)...close_index]
96
- search_start = close_index + 1
235
+ yield(span)
236
+ search_start = span[:body_end] + 1
97
237
  end
98
- calls
238
+ end
239
+
240
+ def self.package_span(content, marker_index, on_skip)
241
+ open_index = marker_index + PACKAGE_CALL.length - 1
242
+ close_index = matching_paren(content, open_index)
243
+ return unbalanced_package_span(content, marker_index, on_skip) unless close_index
244
+
245
+ body_start = open_index + 1
246
+ { body: content[body_start...close_index], body_start:, body_end: close_index }
247
+ end
248
+
249
+ def self.unbalanced_package_span(content, marker_index, on_skip)
250
+ on_skip&.call({ reason: "unbalanced_parentheses", snippet: content[marker_index, 300] })
251
+ nil
252
+ end
253
+
254
+ def self.next_package_call(content, search_start)
255
+ PackageCallFinder.new(content).next_from(search_start)
99
256
  end
100
257
 
101
258
  # Map the body of a `.package(...)` call to an Xcodeproj-style requirement.
@@ -156,17 +313,15 @@ module ManifestParser
156
313
  depth = 0
157
314
  index = open_index
158
315
  length = content.length
159
- in_string = false
160
316
  while index < length
161
317
  char = content[index]
162
- if in_string
163
- if char == "\\"
164
- index += 2
165
- next
166
- end
167
- in_string = false if char == '"'
318
+ raw_string = RawStringLiteral.new(content, index)
319
+ if raw_string.literal?
320
+ index = raw_string.skip_index
321
+ next
168
322
  elsif char == '"'
169
- in_string = true
323
+ index = StringLiteral.new(content, index).skip_index
324
+ next
170
325
  elsif char == "("
171
326
  depth += 1
172
327
  elsif char == ")"
@@ -190,7 +345,10 @@ module ManifestParser
190
345
  while index < length
191
346
  char = content[index]
192
347
  nxt = content[index + 1]
193
- if char == '"'
348
+ raw_string = RawStringLiteral.new(content, index)
349
+ if raw_string.literal?
350
+ index = raw_string.copy_to(output)
351
+ elsif char == '"'
194
352
  index = copy_string_literal(content, index, output)
195
353
  elsif char == "/" && nxt == "/"
196
354
  index += 1 while index < length && content[index] != "\n"
@@ -209,21 +367,7 @@ module ManifestParser
209
367
  #
210
368
  # @return [Integer]
211
369
  def self.copy_string_literal(content, index, output)
212
- length = content.length
213
- output << content[index] # opening quote
214
- index += 1
215
- while index < length
216
- char = content[index]
217
- output << char
218
- if char == "\\"
219
- output << content[index + 1] if index + 1 < length
220
- index += 2
221
- next
222
- end
223
- index += 1
224
- break if char == '"'
225
- end
226
- index
370
+ StringLiteral.new(content, index).copy_to(output)
227
371
  end
228
372
 
229
373
  # Return the index just past the closing `*/` of a block comment. Swift block
@@ -249,7 +393,11 @@ module ManifestParser
249
393
  end
250
394
 
251
395
  private_class_method :package_calls,
252
- :requirement_for,
396
+ :package_spans,
397
+ :scan_package_spans,
398
+ :package_span,
399
+ :unbalanced_package_span,
400
+ :next_package_call,
253
401
  :version_range_requirement,
254
402
  :increment_patch_version,
255
403
  :matching_paren,
@@ -0,0 +1,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_operations"
4
+ require_relative "manifest_parser"
5
+ require_relative "semver"
6
+
7
+ # Rewrites supported Package.swift dependency requirements for update records.
8
+ module ManifestUpdater
9
+ SUPPORTED_KINDS = %w(exactVersion upToNextMajorVersion upToNextMinorVersion versionRange).freeze
10
+
11
+ # Result of rewriting one manifest's dependency declarations.
12
+ Result = Struct.new(:content, :applied, :skipped, :changed, keyword_init: true) {
13
+ def changed?
14
+ changed
15
+ end
16
+ }
17
+
18
+ def self.rewrite(content, updates)
19
+ Rewriter.new(content, updates).rewrite
20
+ end
21
+
22
+ def self.update_file(manifest_path, updates)
23
+ original = File.read(manifest_path)
24
+ result = rewrite(original, updates)
25
+ File.write(manifest_path, result.content) if result.changed?
26
+ result
27
+ end
28
+
29
+ # Normalized update input used by the private rewrite pipeline.
30
+ class UpdateRecord
31
+ def initialize(attributes)
32
+ @attributes = attributes.to_h.transform_keys(&:to_s)
33
+ end
34
+
35
+ def kind
36
+ value("requirement_kind")
37
+ end
38
+
39
+ def type
40
+ value("type")
41
+ end
42
+
43
+ def normalized_url
44
+ value("normalized_url")
45
+ end
46
+
47
+ def available_version
48
+ value("available_version")
49
+ end
50
+
51
+ def applied_entry
52
+ @attributes.merge("available_version" => available_version)
53
+ end
54
+
55
+ def skipped_entry(reason)
56
+ @attributes.merge("reason" => reason)
57
+ end
58
+
59
+ private
60
+
61
+ def value(key)
62
+ @attributes[key]
63
+ end
64
+ end
65
+ private_constant :UpdateRecord
66
+
67
+ # Collects the rewrite outcome while package declarations are scanned.
68
+ class RewriteChanges
69
+ attr_reader :edits, :applied, :skipped
70
+
71
+ def initialize
72
+ @edits = []
73
+ @applied = []
74
+ @skipped = []
75
+ end
76
+
77
+ def apply(update, edits)
78
+ @edits.concat(edits)
79
+ @applied << update.applied_entry
80
+ end
81
+
82
+ def skip(update, reason)
83
+ @skipped << update.skipped_entry(reason)
84
+ end
85
+ end
86
+ private_constant :RewriteChanges
87
+
88
+ # Success or failure from attempting to rewrite one update record.
89
+ RewritePlan = Struct.new(:edits, :reason, keyword_init: true) {
90
+ def self.success(edits)
91
+ new(edits:, reason: nil)
92
+ end
93
+
94
+ def self.failure(reason)
95
+ new(edits: [], reason:)
96
+ end
97
+
98
+ def failed?
99
+ reason
100
+ end
101
+ }
102
+ private_constant :RewritePlan
103
+
104
+ # Computes all declaration edits for one update record.
105
+ class EditPlanner
106
+ def initialize(update, spans)
107
+ @update = update
108
+ @spans = spans
109
+ end
110
+
111
+ def plan
112
+ return RewritePlan.failure("declaration_not_found") if matching_spans.empty?
113
+
114
+ plan_matching_spans
115
+ end
116
+
117
+ private
118
+
119
+ attr_reader :update
120
+
121
+ def matching_spans
122
+ @matching_spans ||= @spans.select { |span| SpanPackage.new(span).normalized_url == update.normalized_url }
123
+ end
124
+
125
+ def plan_matching_spans
126
+ attempts = matching_spans.map { |span| EditAttempt.new(update, span).plan }
127
+ attempts.find(&:failed?) || RewritePlan.success(attempts.flat_map(&:edits))
128
+ end
129
+ end
130
+ private_constant :EditPlanner
131
+
132
+ # Package metadata parsed from one declaration span.
133
+ class SpanPackage
134
+ def initialize(span)
135
+ @span = span
136
+ end
137
+
138
+ def normalized_url
139
+ GitOperations.trim_repo_url(url.value)
140
+ end
141
+
142
+ private
143
+
144
+ def url
145
+ UrlLiteral.new(@span.body)
146
+ end
147
+ end
148
+ private_constant :SpanPackage
149
+
150
+ # Reads a Package.swift dependency URL literal from one declaration body.
151
+ class UrlLiteral
152
+ def initialize(body)
153
+ @body = body
154
+ end
155
+
156
+ def value
157
+ @body[/\burl\s*:\s*"([^"]+)"/, 1]
158
+ end
159
+ end
160
+ private_constant :UrlLiteral
161
+
162
+ # Source body checks that determine whether a declaration can be safely edited.
163
+ class DeclarationBody
164
+ def initialize(body)
165
+ @body = body
166
+ end
167
+
168
+ def unsupported_syntax?
169
+ @body.include?("\\(") || @body.match?(/#+"/)
170
+ end
171
+ end
172
+ private_constant :DeclarationBody
173
+
174
+ # Attempts the single-span edit for one update and returns a rewrite plan.
175
+ class EditAttempt
176
+ def initialize(update, span)
177
+ @update = update
178
+ @span = span
179
+ end
180
+
181
+ def plan
182
+ return RewritePlan.failure("unsupported_syntax") if unsupported_syntax?
183
+ return RewritePlan.failure("requirement_mismatch") unless requirement_matches? && edit
184
+ return RewritePlan.failure("verification_failed") unless verifier.verified?
185
+
186
+ RewritePlan.success([edit])
187
+ end
188
+
189
+ private
190
+
191
+ attr_reader :update, :span
192
+
193
+ def unsupported_syntax?
194
+ DeclarationBody.new(span.body).unsupported_syntax?
195
+ end
196
+
197
+ def requirement_matches?
198
+ requirement && requirement["kind"] == update.kind
199
+ end
200
+
201
+ def requirement
202
+ @requirement ||= ManifestParser.requirement_for(span.body)
203
+ end
204
+
205
+ def edit
206
+ @edit ||= RequirementEdit.new(update, span).to_h
207
+ end
208
+
209
+ def verifier
210
+ EditVerifier.new(update, span, edit)
211
+ end
212
+ end
213
+ private_constant :EditAttempt
214
+
215
+ # Builds the byte-range replacement for one supported requirement.
216
+ class RequirementEdit
217
+ def initialize(update, span)
218
+ @update = update
219
+ @span = span
220
+ end
221
+
222
+ def to_h
223
+ return unless target && offset
224
+
225
+ body_start = span.body_start
226
+ {
227
+ start: body_start + offset[0],
228
+ finish: body_start + offset[1],
229
+ replacement: target
230
+ }
231
+ end
232
+
233
+ private
234
+
235
+ attr_reader :update, :span
236
+
237
+ def target
238
+ @target ||= TargetVersion.new(update, span.body).value
239
+ end
240
+
241
+ def offset
242
+ @offset ||= VersionLiteralLocator.new(span.body, update).offset
243
+ end
244
+ end
245
+ private_constant :RequirementEdit
246
+
247
+ # Chooses the target version, including range upper-bound expansion.
248
+ class TargetVersion
249
+ def initialize(update, body)
250
+ @update = update
251
+ @body = body
252
+ end
253
+
254
+ def value
255
+ return @update.available_version unless expand_range_maximum?
256
+
257
+ maximum_for_range
258
+ end
259
+
260
+ private
261
+
262
+ def expand_range_maximum?
263
+ @update.kind == "versionRange" && @update.type == "above_maximum"
264
+ end
265
+
266
+ def maximum_for_range
267
+ available_version = @update.available_version
268
+ return available_version if range.closed?
269
+
270
+ version = SpmVersionUpdates::Semver.new(available_version)
271
+ "#{version.major + 1}.0.0"
272
+ rescue ArgumentError
273
+ nil
274
+ end
275
+
276
+ def range
277
+ @range ||= VersionRangeLiteral.new(@body)
278
+ end
279
+ end
280
+ private_constant :TargetVersion
281
+
282
+ # Locates the version literal that should be replaced inside a declaration.
283
+ class VersionLiteralLocator
284
+ def initialize(body, update)
285
+ @body = body
286
+ @update = update
287
+ end
288
+
289
+ def offset
290
+ case @update.kind
291
+ when "exactVersion" then exact_offset
292
+ when "upToNextMajorVersion" then major_offset
293
+ when "upToNextMinorVersion" then minor_offset
294
+ when "versionRange" then range_offset
295
+ end
296
+ end
297
+
298
+ private
299
+
300
+ def exact_offset
301
+ first_capture([/\bexact\s*:\s*"([^"]+)"/, /\.exact\s*\(\s*"([^"]+)"/])
302
+ end
303
+
304
+ def major_offset
305
+ first_capture([/\.upToNextMajor\s*\(\s*from\s*:\s*"([^"]+)"/, /\bfrom\s*:\s*"([^"]+)"/])
306
+ end
307
+
308
+ def minor_offset
309
+ first_capture([/\.upToNextMinor\s*\(\s*from\s*:\s*"([^"]+)"/])
310
+ end
311
+
312
+ def range
313
+ @range ||= VersionRangeLiteral.new(@body)
314
+ end
315
+
316
+ def range_offset
317
+ @update.type == "above_maximum" ? range.maximum_offset : range.minimum_offset
318
+ end
319
+
320
+ def first_capture(patterns)
321
+ patterns.each { |pattern|
322
+ match = @body.match(pattern)
323
+ return match.offset(1) if match
324
+ }
325
+ nil
326
+ end
327
+ end
328
+ private_constant :VersionLiteralLocator
329
+
330
+ # Parsed Swift range literal for supported version-range requirements.
331
+ class VersionRangeLiteral
332
+ def initialize(body)
333
+ @match = body.match(/"([^"]+)"\s*(\.\.[.<])\s*"([^"]+)"/)
334
+ end
335
+
336
+ def closed?
337
+ operator == "..."
338
+ end
339
+
340
+ def minimum_offset
341
+ return unless @match
342
+
343
+ @match.offset(1)
344
+ end
345
+
346
+ def maximum_offset
347
+ return unless @match
348
+
349
+ @match.offset(3)
350
+ end
351
+
352
+ private
353
+
354
+ def operator
355
+ @match&.[](2)
356
+ end
357
+ end
358
+ private_constant :VersionRangeLiteral
359
+
360
+ # Verifies a generated edit still parses to the original requirement kind.
361
+ class EditVerifier
362
+ def initialize(update, span, edit)
363
+ @update = update
364
+ @span = span
365
+ @edit = edit
366
+ end
367
+
368
+ def verified?
369
+ return false unless edited_requirement_kind == @update.kind
370
+
371
+ edited_value == @edit[:replacement]
372
+ end
373
+
374
+ private
375
+
376
+ def edited_requirement_kind
377
+ ManifestParser.requirement_for(edited_body)&.fetch("kind", nil)
378
+ end
379
+
380
+ def edited_value
381
+ offset = VersionLiteralLocator.new(edited_body, @update).offset
382
+ offset && edited_body[offset[0]...offset[1]]
383
+ end
384
+
385
+ def edited_body
386
+ @edited_body ||= EditedBody.new(@span, @edit).value
387
+ end
388
+ end
389
+ private_constant :EditVerifier
390
+
391
+ # Applies one edit against a declaration body for verification.
392
+ class EditedBody
393
+ def initialize(span, edit)
394
+ @span = span
395
+ @edit = edit
396
+ end
397
+
398
+ def value
399
+ @value ||= @span.body.dup.tap { |body| body[relative_range] = @edit[:replacement] }
400
+ end
401
+
402
+ private
403
+
404
+ def relative_range
405
+ relative_start...relative_finish
406
+ end
407
+
408
+ def relative_start
409
+ @edit[:start] - @span.body_start
410
+ end
411
+
412
+ def relative_finish
413
+ @edit[:finish] - @span.body_start
414
+ end
415
+ end
416
+ private_constant :EditedBody
417
+
418
+ # Applies collected edits from right to left to preserve byte offsets.
419
+ class AppliedEdit
420
+ def initialize(attributes)
421
+ @attributes = attributes
422
+ end
423
+
424
+ def start
425
+ @attributes[:start]
426
+ end
427
+
428
+ def apply_to(content)
429
+ content[start...@attributes[:finish]] = @attributes[:replacement]
430
+ end
431
+ end
432
+ private_constant :AppliedEdit
433
+
434
+ # Applies collected edits from right to left to preserve byte offsets.
435
+ class EditApplier
436
+ def initialize(content, edits)
437
+ @content = content
438
+ @edits = edits.map { |edit| AppliedEdit.new(edit) }
439
+ end
440
+
441
+ def content
442
+ @edits.sort_by { |edit| -edit.start }
443
+ .each_with_object(@content.dup) { |edit, updated|
444
+ edit.apply_to(updated)
445
+ }
446
+ end
447
+ end
448
+ private_constant :EditApplier
449
+
450
+ # Stateful implementation kept private so the public API remains small.
451
+ class Rewriter
452
+ def initialize(content, updates)
453
+ @content = content
454
+ @updates = Array(updates).map { |update| UpdateRecord.new(update) }
455
+ @spans = ManifestParser.package_call_spans(content)
456
+ @changes = RewriteChanges.new
457
+ end
458
+
459
+ def rewrite
460
+ @updates.each { |update| rewrite_update(update) }
461
+ updated_content = EditApplier.new(@content, @changes.edits).content
462
+ Result.new(content: updated_content, applied: @changes.applied, skipped: @changes.skipped, changed: updated_content != @content)
463
+ end
464
+
465
+ private
466
+
467
+ def rewrite_update(update)
468
+ plan = rewrite_plan(update)
469
+ return @changes.skip(update, plan.reason) if plan.failed?
470
+
471
+ @changes.apply(update, plan.edits)
472
+ end
473
+
474
+ def rewrite_plan(update)
475
+ return RewritePlan.failure("unsupported_requirement_kind") unless SUPPORTED_KINDS.include?(update.kind)
476
+
477
+ EditPlanner.new(update, @spans).plan
478
+ end
479
+ end
480
+ private_constant :Rewriter
481
+ end
@@ -27,13 +27,29 @@ module PackageResolved
27
27
  # @raise [MalformedFileError] if the file is not valid JSON
28
28
  # @return [Hash<String, String>] normalized repository URL => version or revision
29
29
  def self.versions_from(path)
30
+ pins_from(path).to_h { |pin| [pin["normalized_url"], pin["version"] || pin["revision"]] }
31
+ end
32
+
33
+ # Extract structured pins from a `Package.resolved` file.
34
+ #
35
+ # @param [String] path The path to a `Package.resolved` file
36
+ # @raise [MalformedFileError] if the file is not valid JSON
37
+ # @return [Array<Hash>] pin records with normalized_url, repository_url,
38
+ # version, and revision
39
+ def self.pins_from(path)
30
40
  contents = load_contents(path)
31
41
  pins = contents["pins"] || contents.dig("object", "pins") || []
32
- pins.to_h { |pin|
33
- [
34
- GitOperations.trim_repo_url(pin["location"] || pin["repositoryURL"]),
35
- pin.dig("state", "version") || pin.dig("state", "revision"),
36
- ]
42
+ pins.map { |pin| pin_record(pin) }
43
+ end
44
+
45
+ def self.pin_record(pin)
46
+ repository_url = pin["location"] || pin["repositoryURL"]
47
+ state = pin["state"] || {}
48
+ {
49
+ "normalized_url" => GitOperations.trim_repo_url(repository_url),
50
+ "repository_url" => repository_url,
51
+ "version" => state["version"],
52
+ "revision" => state["revision"]
37
53
  }
38
54
  end
39
55
 
@@ -43,5 +59,6 @@ module PackageResolved
43
59
  raise(MalformedFileError.new(path, error.message))
44
60
  end
45
61
 
46
- private_class_method :load_contents
62
+ private_class_method :load_contents,
63
+ :pin_record
47
64
  end
@@ -5,7 +5,7 @@ require "semverify"
5
5
  # Namespace for the core gem's published constants (gem version and the
6
6
  # {SpmVersionUpdates::Semver} value object).
7
7
  module SpmVersionUpdates
8
- # SemVer value object used by both the GitHub Action and legacy Danger plugin.
8
+ # SemVer value object used by both the GitHub Action and Danger plugin.
9
9
  class Semver
10
10
  include Comparable
11
11
 
@@ -16,21 +16,166 @@ require_relative "version_tags_persistent_cache"
16
16
  require_relative "xcode_parser"
17
17
 
18
18
  # Core SPM version checking logic (migrated from Danger plugin)
19
+ # rubocop:disable Metrics/ClassLength
19
20
  class SpmChecker
20
- VERSION_TAG_WORKER_COUNT = 8
21
+ DEFAULT_VERSION_LOOKUP_WORKERS = 4
21
22
 
22
23
  # Raised when allow-hosts blocks a repository before git is contacted.
23
24
  class DisallowedRepositoryHost < SpmVersionUpdates::PolicyError; end
24
25
 
25
- # Structured facts about each warning, used by the GitHub Action comment
26
- # renderer. `check_for_updates` and `check_manifests` still return the legacy
27
- # string warnings for compatibility with existing plugin-style callers.
28
- attr_reader :warning_details
26
+ # Filters configured Package.resolved paths and reports missing files.
27
+ class ResolvedPathList
28
+ def initialize(paths, missing_handler)
29
+ @paths = paths
30
+ @missing_handler = missing_handler
31
+ end
32
+
33
+ def existing
34
+ return @paths if missing.empty?
35
+
36
+ raise(ManifestParser::CouldNotFindResolvedFile, missing.join(", ")) unless @missing_handler
37
+
38
+ @missing_handler.call(missing)
39
+ @paths - missing
40
+ end
41
+
42
+ private
43
+
44
+ def missing
45
+ @missing ||= @paths.reject { |path| File.exist?(path) }
46
+ end
47
+ end
48
+ private_constant :ResolvedPathList
49
+
50
+ # Converts resolved pins into the package and version maps consumed by checks.
51
+ class ResolvedPackageEntries
52
+ def initialize(path, malformed_handler)
53
+ @path = path
54
+ @malformed_handler = malformed_handler
55
+ end
56
+
57
+ def packages_and_versions
58
+ pins.each_with_object([{}, {}]) { |pin, (packages, versions)|
59
+ entry = ResolvedPinEntry.new(pin)
60
+ entry.add_to(packages, versions)
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def pins
67
+ PackageResolved.pins_from(@path)
68
+ rescue PackageResolved::MalformedFileError => error
69
+ raise unless @malformed_handler
70
+
71
+ @malformed_handler.call(@path, error)
72
+ []
73
+ end
74
+ end
75
+ private_constant :ResolvedPackageEntries
76
+
77
+ # Normalized data derived from one Package.resolved pin.
78
+ class ResolvedPinEntry
79
+ def initialize(pin)
80
+ @pin = pin
81
+ end
82
+
83
+ def normalized_url
84
+ @pin["normalized_url"]
85
+ end
86
+
87
+ def package
88
+ {
89
+ "repository_url" => @pin["repository_url"],
90
+ "requirement" => requirement
91
+ }
92
+ end
93
+
94
+ def resolved_version
95
+ @pin["version"] || @pin["revision"]
96
+ end
97
+
98
+ def add_to(packages, versions)
99
+ packages[normalized_url] = package
100
+ versions[normalized_url] = resolved_version
101
+ end
102
+
103
+ private
104
+
105
+ def requirement
106
+ version = @pin["version"]
107
+ return { "kind" => "resolvedPin", "version" => version } if version
29
108
 
30
- # ParseWarning records for `.package(...)` declarations the manifest parser
31
- # had to skip. Kept separate from update warnings so reported update counts
32
- # and fail-on thresholds are unaffected.
33
- attr_reader :parse_warnings
109
+ { "kind" => "revision", "revision" => @pin["revision"] }
110
+ end
111
+ end
112
+ private_constant :ResolvedPinEntry
113
+
114
+ # Builds warning detail records while preserving explicit nil requirements.
115
+ class WarningDetailRecord
116
+ def initialize(message, package, detail)
117
+ @message = message
118
+ @package = package
119
+ @detail = detail
120
+ end
121
+
122
+ def to_h
123
+ with_optional_requirement(compacted_record)
124
+ end
125
+
126
+ private
127
+
128
+ def record
129
+ @detail.merge(message: @message, source: @package.source)
130
+ end
131
+
132
+ def compacted_record
133
+ record.compact
134
+ end
135
+
136
+ def with_optional_requirement(compacted)
137
+ return compacted unless record.key?(:suggested_requirement)
138
+
139
+ compacted.merge(suggested_requirement: record[:suggested_requirement])
140
+ end
141
+ end
142
+ private_constant :WarningDetailRecord
143
+
144
+ # Parses a package's resolved version for semantic comparisons.
145
+ class PackageSemver
146
+ def initialize(package)
147
+ @package = package
148
+ end
149
+
150
+ def value
151
+ resolved_version = @package.resolved_version
152
+ SpmVersionUpdates::Semver.new(resolved_version)
153
+ rescue ArgumentError => error
154
+ puts("Unable to extract semver from #{resolved_version} for #{@package.name} (#{error})")
155
+ nil
156
+ end
157
+ end
158
+ private_constant :PackageSemver
159
+
160
+ # Structured result from one checker run. Parse warnings stay separate from
161
+ # update records so counts and fail-on thresholds only consider real updates.
162
+ class Result
163
+ attr_reader :updates, :parse_warnings
164
+
165
+ def initialize(updates:, parse_warnings:)
166
+ @updates = normalize_records(updates)
167
+ @parse_warnings = normalize_records(parse_warnings)
168
+ end
169
+
170
+ private
171
+
172
+ def normalize_records(records)
173
+ Array(records).map { |record| record.to_h.transform_keys(&:to_s).compact.freeze }
174
+ .freeze
175
+ end
176
+ end
177
+
178
+ attr_reader :version_lookup_workers
34
179
 
35
180
  attr_accessor :allow_hosts,
36
181
  :check_branches,
@@ -54,6 +199,12 @@ class SpmChecker
54
199
  # default), PackageResolved::MalformedFileError is raised.
55
200
  attr_accessor :malformed_resolved_handler
56
201
 
202
+ # Optional callable `(missing_paths)` invoked instead of raising when expected
203
+ # Package.resolved files are missing. Missing paths are dropped and packages
204
+ # without a resolved version keep the existing "Unable to locate..." behavior:
205
+ # no warning record is created and no version tags are fetched for them.
206
+ attr_accessor :missing_resolved_handler
207
+
57
208
  def self.redact_credentials(value)
58
209
  CredentialRedactor.redact(value)
59
210
  end
@@ -61,12 +212,12 @@ class SpmChecker
61
212
  def initialize
62
213
  @check_when_exact = @check_revisions = @report_above_maximum = @report_pre_releases = false
63
214
  @check_branches = true
64
- @lookup_failure_handler = @malformed_resolved_handler = nil
215
+ @lookup_failure_handler = @malformed_resolved_handler = @missing_resolved_handler = nil
65
216
  @ignore_repos = []
66
217
  @repository_update_rules = RepositoryUpdateRules.empty
67
218
  @allow_hosts = []
68
- @warnings = []
69
- @warning_details = []
219
+ @version_lookup_workers = DEFAULT_VERSION_LOOKUP_WORKERS
220
+ @updates = []
70
221
  @parse_warnings = []
71
222
  @version_tags_cache = {}
72
223
  @version_tag_lookup_errors = {}
@@ -75,15 +226,19 @@ class SpmChecker
75
226
  @version_tags_cache_ttl_seconds = VersionTagsPersistentCache::DEFAULT_TTL_SECONDS
76
227
  end
77
228
 
229
+ def version_lookup_workers=(value)
230
+ workers = Integer(value.to_s, 10, exception: false)
231
+ raise(SpmVersionUpdates::ConfigurationError, "version_lookup_workers must be a positive integer") unless workers && workers >= 1
232
+
233
+ @version_lookup_workers = workers
234
+ end
235
+
78
236
  # Check for SPM updates using an Xcode project as the source of dependencies.
79
237
  #
80
238
  # @param [String] xcodeproj_path The path to your Xcode project
81
- # @return [Array<String>] Array of warning messages
239
+ # @return [Result] Structured update records and parse warnings
82
240
  def check_for_updates(xcodeproj_path)
83
- clear_warnings
84
- reset_version_tags_cache
85
- normalize_ignore_repos
86
- normalize_allow_hosts
241
+ prepare_run
87
242
 
88
243
  remote_packages = XcodeParser.get_packages(xcodeproj_path)
89
244
  resolved_versions = XcodeParser.get_resolved_versions(xcodeproj_path, &@malformed_resolved_handler)
@@ -91,7 +246,7 @@ class SpmChecker
91
246
  warn_for_empty_xcode_project(remote_packages, resolved_versions, xcodeproj_path)
92
247
 
93
248
  check_packages(remote_packages, resolved_versions)
94
- @warnings
249
+ result
95
250
  end
96
251
 
97
252
  # Check for SPM updates using one or more `Package.swift` manifests as the
@@ -107,12 +262,9 @@ class SpmChecker
107
262
  # `Package.resolved` paths. When omitted, a `Package.resolved` next to
108
263
  # each manifest is used.
109
264
  # @raise [ManifestParser::CouldNotFindResolvedFile] if no resolved file exists
110
- # @return [Array<String>] Array of warning messages
265
+ # @return [Result] Structured update records and parse warnings
111
266
  def check_manifests(manifest_paths, resolved_paths = nil)
112
- clear_warnings
113
- reset_version_tags_cache
114
- normalize_ignore_repos
115
- normalize_allow_hosts
267
+ prepare_run
116
268
 
117
269
  resolved_versions = merged_resolved_versions(manifest_paths, resolved_paths)
118
270
  puts("Found resolved versions for #{resolved_versions.size} packages")
@@ -120,11 +272,34 @@ class SpmChecker
120
272
  manifest_paths.each { |manifest_path|
121
273
  check_packages(manifest_packages(manifest_path), resolved_versions, manifest_path)
122
274
  }
123
- @warnings
275
+ result
276
+ end
277
+
278
+ # Check for SPM updates using one or more `Package.resolved` files as the
279
+ # source of dependencies. Version pins are compared directly with available
280
+ # tags; revision-only pins are reported only when check_revisions is enabled.
281
+ #
282
+ # @param [Array<String>] resolved_paths Paths to one or more Package.resolved files
283
+ # @raise [SpmVersionUpdates::ConfigurationError] if no paths are supplied
284
+ # @return [Result] Structured update records and parse warnings
285
+ def check_resolved(resolved_paths)
286
+ prepare_run
287
+ paths = normalized_resolved_paths(resolved_paths)
288
+ raise(SpmVersionUpdates::ConfigurationError, "package-resolved-paths must be set") if paths.empty?
289
+
290
+ check_existing_resolved_paths(paths)
291
+ result
124
292
  end
125
293
 
126
294
  private
127
295
 
296
+ def prepare_run
297
+ clear_updates
298
+ reset_version_tags_cache
299
+ normalize_ignore_repos
300
+ normalize_allow_hosts
301
+ end
302
+
128
303
  def manifest_packages(manifest_path)
129
304
  ManifestParser.get_packages(manifest_path) { |skip| record_parse_warning(skip, manifest_path) }
130
305
  end
@@ -149,9 +324,12 @@ class SpmChecker
149
324
  raw_allow_hosts.any? && @allow_hosts.empty?
150
325
  end
151
326
 
152
- def clear_warnings
153
- @warnings.clear
154
- @warning_details.clear
327
+ def result
328
+ Result.new(updates: @updates, parse_warnings: @parse_warnings)
329
+ end
330
+
331
+ def clear_updates
332
+ @updates.clear
155
333
  @parse_warnings.clear
156
334
  end
157
335
 
@@ -185,16 +363,23 @@ class SpmChecker
185
363
  #
186
364
  # @return [Hash<String, String>]
187
365
  def merged_resolved_versions(manifest_paths, resolved_paths)
188
- paths = Array(resolved_paths).map(&:to_s).reject(&:empty?)
366
+ paths = normalized_resolved_paths(resolved_paths)
189
367
  paths = manifest_paths.map { |manifest| ManifestParser.default_resolved_path(manifest) } if paths.empty?
190
368
 
191
- missing = paths.reject { |path| File.exist?(path) }
192
- raise(ManifestParser::CouldNotFindResolvedFile, missing.join(", ")) unless missing.empty?
369
+ paths = existing_paths(paths)
193
370
 
194
371
  puts("Reading resolved packages from: #{paths}")
195
372
  paths.each_with_object({}) { |path, pins| pins.merge!(resolved_versions_from(path)) }
196
373
  end
197
374
 
375
+ def normalized_resolved_paths(paths)
376
+ Array(paths).map(&:to_s).reject(&:empty?)
377
+ end
378
+
379
+ def existing_paths(paths)
380
+ ResolvedPathList.new(paths, @missing_resolved_handler).existing
381
+ end
382
+
198
383
  def resolved_versions_from(path)
199
384
  PackageResolved.versions_from(path)
200
385
  rescue PackageResolved::MalformedFileError => error
@@ -204,6 +389,17 @@ class SpmChecker
204
389
  {}
205
390
  end
206
391
 
392
+ def packages_from_resolved(path)
393
+ ResolvedPackageEntries.new(path, @malformed_resolved_handler).packages_and_versions
394
+ end
395
+
396
+ def check_existing_resolved_paths(paths)
397
+ existing_paths(paths).each { |path|
398
+ remote_packages, resolved_versions = packages_from_resolved(path)
399
+ check_packages(remote_packages, resolved_versions, path)
400
+ }
401
+ end
402
+
207
403
  # Compare a set of declared dependencies against the resolved pins.
208
404
  #
209
405
  # Packages are keyed by their normalized repository URL, which is what we match
@@ -312,7 +508,7 @@ class SpmChecker
312
508
  persistent_cache = VersionTagsPersistentCache.new(directory: @version_tags_cache_dir, ttl_seconds: @version_tags_cache_ttl_seconds)
313
509
  results, errors = VersionTagFetcher.call(
314
510
  pending,
315
- worker_limit: VERSION_TAG_WORKER_COUNT,
511
+ worker_limit: @version_lookup_workers,
316
512
  persistent_cache:,
317
513
  raise_on_error: !@lookup_failure_handler
318
514
  )
@@ -386,6 +582,8 @@ class SpmChecker
386
582
  warn_for_new_versions(package, available_versions, :minor)
387
583
  when "versionRange"
388
584
  warn_for_new_versions_range(package, available_versions)
585
+ when "resolvedPin"
586
+ warn_for_new_versions_pin(package, available_versions)
389
587
  else
390
588
  puts("Not processing dependency rule '#{kind}' for #{package.name} (#{self.class.redact_credentials(repository_url)})")
391
589
  end
@@ -399,13 +597,12 @@ class SpmChecker
399
597
  end
400
598
 
401
599
  def record_warning(message, package, record)
402
- @warnings << [message, package.source_line].compact.join("\n")
403
- @warning_details << record
600
+ @updates << record
404
601
  puts("WARNING: #{message}#{package.source_suffix}")
405
602
  end
406
603
 
407
604
  def warning_detail_record(message, package, detail)
408
- detail.merge(message:, source: package.source).compact
605
+ WarningDetailRecord.new(message, package, detail).to_h
409
606
  end
410
607
 
411
608
  def warning_detail(type, package, available_version, note = nil)
@@ -506,6 +703,20 @@ class SpmChecker
506
703
  end
507
704
  end
508
705
 
706
+ def warn_for_new_versions_pin(package, available_versions)
707
+ resolved_version = PackageSemver.new(package).value
708
+ return unless resolved_version
709
+
710
+ newest_version = newest_reportable_version(available_versions)
711
+ return unless newest_version && newest_version > resolved_version
712
+
713
+ add_warning(
714
+ "Newer version of #{package.name}: #{newest_version}",
715
+ package,
716
+ warning_detail(:version, package, newest_version, "resolved pin")
717
+ )
718
+ end
719
+
509
720
  def warn_for_new_versions(package, available_versions, major_or_minor)
510
721
  name = package.name
511
722
  resolved_version_string = package.resolved_version
@@ -548,3 +759,4 @@ class SpmChecker
548
759
  )
549
760
  end
550
761
  end
762
+ # rubocop:enable Metrics/ClassLength
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpmVersionUpdates
4
- VERSION = "1.2.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -7,6 +7,7 @@ require_relative "spm_version_updates/fail_on_threshold"
7
7
  require_relative "spm_version_updates/git_host_normalizer"
8
8
  require_relative "spm_version_updates/git_operations"
9
9
  require_relative "spm_version_updates/manifest_parser"
10
+ require_relative "spm_version_updates/manifest_updater"
10
11
  require_relative "spm_version_updates/package_resolved"
11
12
  require_relative "spm_version_updates/parse_warning"
12
13
  require_relative "spm_version_updates/repository_link"
@@ -40,7 +40,7 @@ Gem::Specification.new do |spec|
40
40
  spec.require_paths = ["lib"]
41
41
  spec.metadata["rubygems_mfa_required"] = "true"
42
42
 
43
- spec.add_runtime_dependency("semverify", "~> 0.3")
43
+ spec.add_dependency("semverify", "~> 0.3")
44
44
  # Xcode-project mode additionally requires the "xcodeproj" gem (loaded
45
45
  # lazily); manifest mode works without it.
46
46
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spm_version_updates
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harold Martin
@@ -41,6 +41,7 @@ files:
41
41
  - lib/spm_version_updates/git_host_normalizer.rb
42
42
  - lib/spm_version_updates/git_operations.rb
43
43
  - lib/spm_version_updates/manifest_parser.rb
44
+ - lib/spm_version_updates/manifest_updater.rb
44
45
  - lib/spm_version_updates/package_resolved.rb
45
46
  - lib/spm_version_updates/parse_warning.rb
46
47
  - lib/spm_version_updates/repository_link.rb