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 +4 -4
- data/README.md +9 -10
- data/lib/spm_version_updates/fail_on_threshold.rb +7 -12
- data/lib/spm_version_updates/manifest_parser.rb +183 -35
- data/lib/spm_version_updates/manifest_updater.rb +481 -0
- data/lib/spm_version_updates/package_resolved.rb +23 -6
- data/lib/spm_version_updates/semver.rb +1 -1
- data/lib/spm_version_updates/spm_checker.rb +246 -34
- data/lib/spm_version_updates/version.rb +1 -1
- data/lib/spm_version_updates.rb +1 -0
- data/spm_version_updates.gemspec +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7d008830011f3c063859526406f0ff3cb686dc80990d87bc3c684289f9eb833
|
|
4
|
+
data.tar.gz: 5257644ec20b9e1798a2f5072609173cb5892ace8bf1ea8bcfe3240dcc2a8b3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
27
|
-
|
|
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
|
-
#
|
|
30
|
+
# result = checker.check_for_updates("path/to/App.xcodeproj")
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
result.updates.each { |update| puts update["message"] }
|
|
34
33
|
|
|
35
|
-
#
|
|
36
|
-
# suggested update command,
|
|
37
|
-
|
|
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
|
-
|
|
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.
|
|
11
|
-
|
|
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
|
|
33
|
-
normalized = value.downcase
|
|
34
|
-
return nil if ["false", "none"].include?(normalized)
|
|
35
|
-
return ANY if
|
|
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, "
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
search_start =
|
|
235
|
+
yield(span)
|
|
236
|
+
search_start = span[:body_end] + 1
|
|
97
237
|
end
|
|
98
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
@
|
|
69
|
-
@
|
|
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 [
|
|
239
|
+
# @return [Result] Structured update records and parse warnings
|
|
82
240
|
def check_for_updates(xcodeproj_path)
|
|
83
|
-
|
|
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
|
-
|
|
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 [
|
|
265
|
+
# @return [Result] Structured update records and parse warnings
|
|
111
266
|
def check_manifests(manifest_paths, resolved_paths = nil)
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
@
|
|
154
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
data/lib/spm_version_updates.rb
CHANGED
|
@@ -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"
|
data/spm_version_updates.gemspec
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|