spm_version_updates 1.1.1

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.
@@ -0,0 +1,532 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "allow_host_normalizer"
4
+ require_relative "credential_redactor"
5
+ require_relative "git_operations"
6
+ require_relative "manifest_parser"
7
+ require_relative "package_resolved"
8
+ require_relative "repository_update_rules"
9
+ require_relative "semver"
10
+ require_relative "spm_package_context"
11
+ require_relative "upgrade_suggestion"
12
+ require_relative "version_tag_fetcher"
13
+ require_relative "version_tags_persistent_cache"
14
+ require_relative "xcode_parser"
15
+
16
+ # Core SPM version checking logic (migrated from Danger plugin)
17
+ class SpmChecker
18
+ VERSION_TAG_WORKER_COUNT = 8
19
+
20
+ # Raised when allow-hosts blocks a repository before git is contacted.
21
+ class DisallowedRepositoryHost < StandardError; end
22
+
23
+ # Structured facts about each warning, used by the GitHub Action comment
24
+ # renderer. `check_for_updates` and `check_manifests` still return the legacy
25
+ # string warnings for compatibility with existing plugin-style callers.
26
+ attr_reader :warning_details
27
+
28
+ attr_accessor :allow_hosts,
29
+ :check_branches,
30
+ :check_revisions,
31
+ :check_when_exact,
32
+ :ignore_repos,
33
+ :repository_update_rules,
34
+ :report_above_maximum,
35
+ :report_pre_releases,
36
+ :version_tags_cache_dir,
37
+ :version_tags_cache_ttl_seconds
38
+
39
+ # Optional callable `(package, error)` invoked instead of raising when a git
40
+ # lookup fails, so callers like the Danger plugin can warn and keep checking
41
+ # the remaining packages. When nil (the default), lookup failures raise one
42
+ # combined GitOperations::LsRemoteError exactly as before.
43
+ attr_accessor :lookup_failure_handler
44
+
45
+ # Optional callable `(resolved_path, error)` invoked instead of raising when a
46
+ # Package.resolved file is malformed; the file is skipped. When nil (the
47
+ # default), PackageResolved::MalformedFileError is raised.
48
+ attr_accessor :malformed_resolved_handler
49
+
50
+ def self.redact_credentials(value)
51
+ CredentialRedactor.redact(value)
52
+ end
53
+
54
+ def initialize
55
+ @check_when_exact = @check_revisions = @report_above_maximum = @report_pre_releases = false
56
+ @check_branches = true
57
+ @lookup_failure_handler = @malformed_resolved_handler = nil
58
+ @ignore_repos = []
59
+ @repository_update_rules = RepositoryUpdateRules.empty
60
+ @allow_hosts = []
61
+ @warnings = []
62
+ @warning_details = []
63
+ @version_tags_cache = {}
64
+ @version_tag_lookup_errors = {}
65
+ @reported_lookup_failures = {}
66
+ @version_tags_cache_dir = nil
67
+ @version_tags_cache_ttl_seconds = VersionTagsPersistentCache::DEFAULT_TTL_SECONDS
68
+ end
69
+
70
+ # Check for SPM updates using an Xcode project as the source of dependencies.
71
+ #
72
+ # @param [String] xcodeproj_path The path to your Xcode project
73
+ # @return [Array<String>] Array of warning messages
74
+ def check_for_updates(xcodeproj_path)
75
+ clear_warnings
76
+ reset_version_tags_cache
77
+ normalize_ignore_repos
78
+ normalize_allow_hosts
79
+
80
+ remote_packages = XcodeParser.get_packages(xcodeproj_path)
81
+ resolved_versions = XcodeParser.get_resolved_versions(xcodeproj_path, &@malformed_resolved_handler)
82
+ puts("Found resolved versions for #{resolved_versions.size} packages")
83
+ warn_for_empty_xcode_project(remote_packages, resolved_versions, xcodeproj_path)
84
+
85
+ check_packages(remote_packages, resolved_versions)
86
+ @warnings
87
+ end
88
+
89
+ # Check for SPM updates using one or more `Package.swift` manifests as the
90
+ # source of dependencies.
91
+ #
92
+ # Resolved pins from every `Package.resolved` are merged by normalized
93
+ # repository URL into a single lookup. Each manifest's direct dependencies are
94
+ # then compared against that lookup, and the originating manifest is attached
95
+ # to every warning so multi-manifest repos can tell where an update applies.
96
+ #
97
+ # @param [Array<String>] manifest_paths Paths to one or more `Package.swift`
98
+ # @param [Array<String>, nil] resolved_paths Optional explicit
99
+ # `Package.resolved` paths. When omitted, a `Package.resolved` next to
100
+ # each manifest is used.
101
+ # @raise [ManifestParser::CouldNotFindResolvedFile] if no resolved file exists
102
+ # @return [Array<String>] Array of warning messages
103
+ def check_manifests(manifest_paths, resolved_paths = nil)
104
+ clear_warnings
105
+ reset_version_tags_cache
106
+ normalize_ignore_repos
107
+ normalize_allow_hosts
108
+
109
+ resolved_versions = merged_resolved_versions(manifest_paths, resolved_paths)
110
+ puts("Found resolved versions for #{resolved_versions.size} packages")
111
+
112
+ manifest_paths.each { |manifest_path|
113
+ remote_packages = ManifestParser.get_packages(manifest_path)
114
+ check_packages(remote_packages, resolved_versions, manifest_path)
115
+ }
116
+ @warnings
117
+ end
118
+
119
+ private
120
+
121
+ def normalize_ignore_repos
122
+ @ignore_repos = Array(@ignore_repos).map { |repo| GitOperations.trim_repo_url(repo) }
123
+ end
124
+
125
+ def normalize_allow_hosts
126
+ raw_allow_hosts = configured_allow_hosts
127
+ @allow_hosts = raw_allow_hosts.filter_map { |host| AllowHostNormalizer.normalize(host) }
128
+ return unless invalid_allow_hosts_configuration?(raw_allow_hosts)
129
+
130
+ raise(ArgumentError, "allow-hosts was configured, but no entries could be parsed as hostnames")
131
+ end
132
+
133
+ def configured_allow_hosts
134
+ AllowHostNormalizer.configured_entries(@allow_hosts)
135
+ end
136
+
137
+ def invalid_allow_hosts_configuration?(raw_allow_hosts)
138
+ raw_allow_hosts.any? && @allow_hosts.empty?
139
+ end
140
+
141
+ def clear_warnings
142
+ @warnings.clear
143
+ @warning_details.clear
144
+ end
145
+
146
+ def warn_for_empty_xcode_project(remote_packages, resolved_versions, xcodeproj_path)
147
+ return unless remote_packages.empty? && !resolved_versions.empty?
148
+
149
+ puts(
150
+ "WARNING: No XCRemoteSwiftPackageReference entries were found in #{xcodeproj_path}, " \
151
+ "but Package.resolved contains resolved packages. If dependencies are declared in " \
152
+ "Package.swift files, use package-manifest-paths instead."
153
+ )
154
+ end
155
+
156
+ def reset_version_tags_cache
157
+ @version_tags_cache = {}
158
+ @version_tag_lookup_errors = {}
159
+ @reported_lookup_failures = {}
160
+ end
161
+
162
+ # Merge the resolved pins of every relevant `Package.resolved` file.
163
+ #
164
+ # Every expected resolved file must exist. A missing one would silently drop a
165
+ # manifest's pins and produce misleading "all up to date" results, so we fail
166
+ # loudly and name the missing file(s) instead.
167
+ #
168
+ # @return [Hash<String, String>]
169
+ def merged_resolved_versions(manifest_paths, resolved_paths)
170
+ paths = Array(resolved_paths).map(&:to_s).reject(&:empty?)
171
+ paths = manifest_paths.map { |manifest| ManifestParser.default_resolved_path(manifest) } if paths.empty?
172
+
173
+ missing = paths.reject { |path| File.exist?(path) }
174
+ raise(ManifestParser::CouldNotFindResolvedFile, missing.join(", ")) unless missing.empty?
175
+
176
+ puts("Reading resolved packages from: #{paths}")
177
+ paths.each_with_object({}) { |path, pins| pins.merge!(resolved_versions_from(path)) }
178
+ end
179
+
180
+ def resolved_versions_from(path)
181
+ PackageResolved.versions_from(path)
182
+ rescue PackageResolved::MalformedFileError => error
183
+ raise unless @malformed_resolved_handler
184
+
185
+ @malformed_resolved_handler.call(path, error)
186
+ {}
187
+ end
188
+
189
+ # Compare a set of declared dependencies against the resolved pins.
190
+ #
191
+ # Packages are keyed by their normalized repository URL, which is what we match
192
+ # against `resolved_versions` and `ignore_repos`. The original, scheme-bearing
193
+ # URL travels in the entry as `repository_url` and is what we hand to git --
194
+ # the normalized key is not a valid git remote.
195
+ #
196
+ # @param remote_packages [Hash<String, Hash>] normalized URL => { "repository_url", "requirement" }
197
+ # @param resolved_versions [Hash<String, String>] normalized URL => version
198
+ # @param source [String, nil] the manifest a warning should be attributed to
199
+ def check_packages(remote_packages, resolved_versions, source = nil)
200
+ packages = package_contexts(remote_packages, resolved_versions, source)
201
+ prefetch_version_tags(packages)
202
+
203
+ packages.each { |package|
204
+ lookup_error = @version_tag_lookup_errors[package.cache_key]
205
+ if lookup_error
206
+ raise(lookup_error) unless @lookup_failure_handler
207
+
208
+ next report_lookup_failure(package, lookup_error)
209
+ end
210
+
211
+ check_package_handling_lookup_failure(package)
212
+ }
213
+ end
214
+
215
+ def check_package_handling_lookup_failure(package)
216
+ check_package(package)
217
+ rescue GitOperations::LsRemoteError => error
218
+ raise unless @lookup_failure_handler
219
+
220
+ @version_tag_lookup_errors[package.cache_key] = error
221
+ report_lookup_failure(package, error)
222
+ end
223
+
224
+ # A dependency shared by several manifests fails its lookup once per run:
225
+ # the error is cached so it is never re-fetched, and reported to the handler
226
+ # only the first time it is seen.
227
+ def report_lookup_failure(package, error)
228
+ key = package.cache_key
229
+ return if @reported_lookup_failures.key?(key)
230
+
231
+ @reported_lookup_failures[key] = true
232
+ @lookup_failure_handler.call(package, error)
233
+ end
234
+
235
+ def package_contexts(remote_packages, resolved_versions, source)
236
+ remote_packages.filter_map { |normalized_url, entry|
237
+ next if @ignore_repos.include?(normalized_url)
238
+
239
+ repository_url = entry["repository_url"]
240
+ requirement = entry["requirement"]
241
+ next unless requirement
242
+
243
+ name = GitOperations.repo_name(normalized_url)
244
+
245
+ resolved_version = resolved_versions[normalized_url]
246
+
247
+ unless resolved_version
248
+ puts("Unable to locate the current version for #{name} (#{self.class.redact_credentials(repository_url)})")
249
+ next
250
+ end
251
+
252
+ kind = requirement["kind"]
253
+ validate_repository_host(name, repository_url, source) if git_lookup_required?(kind)
254
+
255
+ SpmPackageContext.new(
256
+ cache_key: version_tags_cache_key(normalized_url, repository_url),
257
+ kind:,
258
+ name:,
259
+ normalized_url:,
260
+ repository_url:,
261
+ persistent_cache_key: VersionTagsPersistentCache.cache_key(normalized_url, repository_url),
262
+ requirement:,
263
+ resolved_version:,
264
+ source:
265
+ )
266
+ }
267
+ end
268
+
269
+ def validate_repository_host(name, repository_url, source)
270
+ return if @allow_hosts.empty?
271
+
272
+ host = GitOperations.host(repository_url)
273
+ return if host && @allow_hosts.include?(host)
274
+
275
+ raise(DisallowedRepositoryHost, disallowed_repository_host_message(name, source, host || "unknown host"))
276
+ end
277
+
278
+ def disallowed_repository_host_message(name, source, host_note)
279
+ source_note = source ? " from #{source}" : ""
280
+ "Repository host #{host_note.inspect} for #{name}#{source_note} is not allowed by allow-hosts (allowed: #{@allow_hosts.join(', ')})"
281
+ end
282
+
283
+ def version_tags_cache_key(normalized_url, repository_url)
284
+ "#{normalized_url}\n#{repository_url}"
285
+ end
286
+
287
+ # Failed lookups land in @version_tag_lookup_errors keyed by cache key
288
+ # (always empty when no lookup_failure_handler is configured -- failures
289
+ # raise instead).
290
+ def prefetch_version_tags(packages)
291
+ pending = pending_version_tag_lookups(packages)
292
+ return if pending.empty?
293
+
294
+ persistent_cache = VersionTagsPersistentCache.new(directory: @version_tags_cache_dir, ttl_seconds: @version_tags_cache_ttl_seconds)
295
+ results, errors = VersionTagFetcher.call(
296
+ pending,
297
+ worker_limit: VERSION_TAG_WORKER_COUNT,
298
+ persistent_cache:,
299
+ raise_on_error: !@lookup_failure_handler
300
+ )
301
+ @version_tags_cache.merge!(results)
302
+ @version_tag_lookup_errors.merge!(errors)
303
+ end
304
+
305
+ def pending_version_tag_lookups(packages)
306
+ packages.each_with_object({}) { |package, lookups|
307
+ next unless version_tag_lookup_required?(package.kind)
308
+ next if @version_tag_lookup_errors.key?(package.cache_key)
309
+
310
+ package.add_version_tag_lookup(lookups, @version_tags_cache)
311
+ }.values
312
+ end
313
+
314
+ def version_tag_lookup_required?(kind)
315
+ {
316
+ "branch" => false,
317
+ "revision" => @check_revisions,
318
+ "exactVersion" => @check_when_exact
319
+ }.fetch(kind, true)
320
+ end
321
+
322
+ def git_lookup_required?(kind)
323
+ return @check_branches if kind == "branch"
324
+
325
+ version_tag_lookup_required?(kind)
326
+ end
327
+
328
+ def version_tags_for(package)
329
+ @version_tags_cache.fetch(package.cache_key, [])
330
+ end
331
+
332
+ def newest_reportable_version(available_versions)
333
+ available_versions.find { |version| reportable_version?(version) }
334
+ end
335
+
336
+ def reportable_version?(version)
337
+ @report_pre_releases || !version.pre
338
+ end
339
+
340
+ def check_package(package)
341
+ available_versions = version_tags_for(package)
342
+
343
+ case package.kind
344
+ when "branch"
345
+ warn_for_branch(package) if @check_branches
346
+ when "revision"
347
+ warn_for_revision(package, available_versions) if @check_revisions
348
+ else
349
+ check_versioned_package(package, available_versions)
350
+ end
351
+ end
352
+
353
+ def check_versioned_package(package, available_versions = nil)
354
+ kind = package.kind
355
+ repository_url = package.repository_url
356
+ return if kind == "exactVersion" && !@check_when_exact
357
+
358
+ available_versions ||= GitOperations.version_tags(repository_url)
359
+ return if available_versions.empty?
360
+ return if available_versions.first.to_s == package.resolved_version
361
+
362
+ case kind
363
+ when "exactVersion"
364
+ warn_for_new_versions_exact(package, available_versions)
365
+ when "upToNextMajorVersion"
366
+ warn_for_new_versions(package, available_versions, :major)
367
+ when "upToNextMinorVersion"
368
+ warn_for_new_versions(package, available_versions, :minor)
369
+ when "versionRange"
370
+ warn_for_new_versions_range(package, available_versions)
371
+ else
372
+ puts("Not processing dependency rule '#{kind}' for #{package.name} (#{self.class.redact_credentials(repository_url)})")
373
+ end
374
+ end
375
+
376
+ def add_warning(message, package, detail)
377
+ record = warning_detail_record(message, package, detail)
378
+ return if @repository_update_rules.suppressed?(record)
379
+
380
+ record_warning(message, package, record)
381
+ end
382
+
383
+ def record_warning(message, package, record)
384
+ @warnings << [message, package.source_line].compact.join("\n")
385
+ @warning_details << record
386
+ puts("WARNING: #{message}#{package.source_suffix}")
387
+ end
388
+
389
+ def warning_detail_record(message, package, detail)
390
+ detail.merge(message:, source: package.source).compact
391
+ end
392
+
393
+ def warning_detail(type, package, available_version, note = nil)
394
+ {
395
+ type: type.to_s,
396
+ package: package.name,
397
+ normalized_url: package.normalized_url,
398
+ repository_url: package.repository_url,
399
+ current_version: package.resolved_version.to_s,
400
+ available_version: available_version.to_s,
401
+ note:
402
+ }.merge(UpgradeSuggestion.fields(package, available_version, type))
403
+ end
404
+
405
+ # Warns if the branch has a newer commit than the resolved version.
406
+ def warn_for_branch(package)
407
+ warning = package.branch_update_warning
408
+ return unless warning
409
+
410
+ message, last_commit, note = warning
411
+
412
+ add_warning(
413
+ message,
414
+ package,
415
+ warning_detail(:branch, package, last_commit, note)
416
+ )
417
+ end
418
+
419
+ # Reports the latest tagged version for a dependency pinned to a raw revision.
420
+ # There is no general way to know whether an arbitrary commit is behind, so
421
+ # this is purely informational and only runs when +check_revisions+ is enabled.
422
+ def warn_for_revision(package, available_versions)
423
+ newest_version = newest_reportable_version(available_versions)
424
+ return unless newest_version
425
+
426
+ add_warning(
427
+ "#{package.name} is pinned to a revision (#{package.resolved_version}); latest tagged version is #{newest_version}",
428
+ package,
429
+ warning_detail(:revision, package, newest_version, "revision pin")
430
+ )
431
+ end
432
+
433
+ def warn_for_new_versions_exact(package, available_versions)
434
+ resolved_version = package.resolved_version
435
+ newest_version = newest_reportable_version(available_versions)
436
+ return unless newest_version
437
+ return if newest_version.to_s == resolved_version
438
+
439
+ add_warning(
440
+ "Newer version of #{package.name}: #{newest_version} (but this package is set to exact version #{resolved_version})",
441
+ package,
442
+ warning_detail(:version, package, newest_version, "exact version")
443
+ )
444
+ end
445
+
446
+ def warn_for_new_versions_range(package, available_versions)
447
+ name = package.name
448
+ requirement = package.requirement
449
+ resolved_version = package.resolved_version
450
+
451
+ begin
452
+ max_version = SpmVersionUpdates::Semver.new(requirement["maximumVersion"])
453
+ rescue ArgumentError => error
454
+ puts("Unable to extract semver from #{requirement} for #{name} (#{error})")
455
+ return
456
+ end
457
+ # Honor the pre-release policy: never report a pre-release as the newest
458
+ # version when report_pre_releases is false.
459
+ newest = newest_reportable_version(available_versions)
460
+ return unless newest
461
+
462
+ if newest < max_version
463
+ unless newest.to_s == resolved_version
464
+ add_warning(
465
+ "Newer version of #{name}: #{newest}",
466
+ package,
467
+ warning_detail(:version, package, newest, "version range")
468
+ )
469
+ end
470
+ else
471
+ newest_meeting_reqs = available_versions.find { |version|
472
+ version < max_version && reportable_version?(version)
473
+ }
474
+ unless newest_meeting_reqs.nil? || newest_meeting_reqs.to_s == resolved_version
475
+ add_warning(
476
+ "Newer version of #{name}: #{newest_meeting_reqs}",
477
+ package,
478
+ warning_detail(:version, package, newest_meeting_reqs, "version range")
479
+ )
480
+ end
481
+ if @report_above_maximum
482
+ add_warning(
483
+ "Newest version of #{name}: #{newest} (but this package is configured up to the next #{max_version} version)",
484
+ package,
485
+ warning_detail(:above_maximum, package, newest, "above configured maximum")
486
+ )
487
+ end
488
+ end
489
+ end
490
+
491
+ def warn_for_new_versions(package, available_versions, major_or_minor)
492
+ name = package.name
493
+ resolved_version_string = package.resolved_version
494
+
495
+ begin
496
+ resolved_version = SpmVersionUpdates::Semver.new(resolved_version_string)
497
+ rescue ArgumentError => error
498
+ puts("Unable to extract semver from #{resolved_version_string} for #{name} (#{error})")
499
+ return
500
+ end
501
+ # upToNextMajor allows any version with the same major; upToNextMinor additionally
502
+ # requires the same minor. Comparing minor alone would wrongly match e.g. 2.5.0
503
+ # against a resolved 1.5.0.
504
+ newest_meeting_reqs = available_versions.find { |version|
505
+ version.major == resolved_version.major &&
506
+ (major_or_minor == :major || version.minor == resolved_version.minor) &&
507
+ reportable_version?(version)
508
+ }
509
+
510
+ unless newest_meeting_reqs.nil? || newest_meeting_reqs == resolved_version
511
+ add_warning(
512
+ "Newer version of #{name}: #{newest_meeting_reqs}",
513
+ package,
514
+ warning_detail(:version, package, newest_meeting_reqs, "up to next #{major_or_minor}")
515
+ )
516
+ end
517
+ return unless @report_above_maximum
518
+
519
+ newest_above_reqs = newest_reportable_version(available_versions)
520
+ # Suppressed only when nothing exists above the constraint (the newest overall
521
+ # is the newest in-constraint version). Being at the newest in-constraint
522
+ # version is intentionally still reported here, since report_above_maximum
523
+ # exists precisely to surface the out-of-range (e.g. next major) version.
524
+ return if newest_above_reqs == newest_meeting_reqs
525
+
526
+ add_warning(
527
+ "Newest version of #{name}: #{newest_above_reqs} (but this package is configured up to the next #{major_or_minor} version)",
528
+ package,
529
+ warning_detail(:above_maximum, package, newest_above_reqs, "above configured maximum")
530
+ )
531
+ end
532
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_operations"
4
+
5
+ # Carries normalized package facts through version, branch, and revision checks.
6
+ SpmPackageContext = Struct.new(
7
+ :cache_key,
8
+ :kind,
9
+ :name,
10
+ :normalized_url,
11
+ :repository_url,
12
+ :persistent_cache_key,
13
+ :requirement,
14
+ :resolved_version,
15
+ :source,
16
+ keyword_init: true
17
+ ) {
18
+ def add_version_tag_lookup(lookups, cache)
19
+ key = cache_key
20
+ lookups[key] ||= [cache_key, repository_url, persistent_cache_key] unless cache.key?(key)
21
+ end
22
+
23
+ def branch
24
+ requirement["branch"]
25
+ end
26
+
27
+ def branch_update?(last_commit)
28
+ last_commit && last_commit != resolved_version
29
+ end
30
+
31
+ def branch_warning_message(last_commit)
32
+ "Newer commit available for #{name} (#{branch}): #{last_commit}"
33
+ end
34
+
35
+ def branch_update_warning
36
+ last_commit = GitOperations.branch_last_commit(repository_url, branch)
37
+ return unless branch_update?(last_commit)
38
+
39
+ [branch_warning_message(last_commit), last_commit, "branch: #{branch}"]
40
+ end
41
+
42
+ def source_line
43
+ "Source: #{source}" if source
44
+ end
45
+
46
+ def source_suffix
47
+ source ? " (#{source})" : ""
48
+ end
49
+ }
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "semver"
4
+
5
+ # Classifies semantic-version update deltas for reporting and fail thresholds.
6
+ module UpdateSeverity
7
+ LEVELS = ["major", "minor", "patch"].freeze
8
+ SEVERITY_RECORD_TYPES = ["", "version", "above_maximum"].freeze
9
+ THRESHOLD_LEVELS = {
10
+ "major" => ["major"],
11
+ "minor" => ["major", "minor"],
12
+ "patch" => ["major", "minor", "patch"]
13
+ }.freeze
14
+
15
+ def self.apply(record)
16
+ return record unless SEVERITY_RECORD_TYPES.include?(record["type"].to_s)
17
+
18
+ severity = for_versions(record["current_version"], record["available_version"])
19
+ severity ? record.merge("severity" => severity) : record
20
+ end
21
+
22
+ def self.for_versions(current_version, available_version)
23
+ versions = parse_versions(current_version, available_version)
24
+ return nil unless versions
25
+
26
+ current, available = versions
27
+ available > current ? numeric_delta(current, available) : nil
28
+ end
29
+
30
+ def self.counts(records)
31
+ records.each_with_object(zero_counts) { |record, result|
32
+ severity = record["severity"]
33
+ result[severity] += 1 if result.key?(severity)
34
+ }
35
+ end
36
+
37
+ def self.count_at_or_above(counts, threshold)
38
+ Array(THRESHOLD_LEVELS[threshold]).sum { |severity| counts.fetch(severity, 0) }
39
+ end
40
+
41
+ def self.threshold?(value)
42
+ THRESHOLD_LEVELS.key?(value)
43
+ end
44
+
45
+ def self.zero_counts
46
+ LEVELS.to_h { |severity| [severity, 0] }
47
+ end
48
+
49
+ def self.parse_version(value)
50
+ SpmVersionUpdates::Semver.new(value.to_s)
51
+ rescue ArgumentError
52
+ nil
53
+ end
54
+
55
+ def self.parse_versions(current_version, available_version)
56
+ current = parse_version(current_version)
57
+ available = parse_version(available_version)
58
+ current && available ? [current, available] : nil
59
+ end
60
+
61
+ def self.numeric_delta(current, available)
62
+ return "major" unless available.major == current.major
63
+ return "minor" unless available.minor == current.minor
64
+
65
+ "patch"
66
+ end
67
+ end