dependabot-julia 0.386.0 → 0.387.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: 5ee61ab238b4d9d818bf4863f3a1ae53aa062d93b893b16f03881a522e615d49
4
- data.tar.gz: b4b9dfe09b76ec2d28581d543124447694cb97836ec88a7ed0957c4d7aaafc51
3
+ metadata.gz: 76dfbce20089045f1450b8e8c2c48a7b3c9b57fb48036014d0df8408a22c2603
4
+ data.tar.gz: f3594883c239208218847751d87eecbd3f04f9af019e4f9044d8a7da57ee6ac5
5
5
  SHA512:
6
- metadata.gz: b846a9a0e4e372859539e4acfbbbbe2d815919e26f7b25944aa1a32df48270fcad9d7258ea732a764e03c1ce282be09e6225a963880ed867aa44906424f4bbea
7
- data.tar.gz: d631d1f3aa74650a8402052931887af1c2ba393f08b7c08a74549fe142fda32f8378b07cece1b1394bf88450aa62f1cb49eea1502d21e916f18a84128bc07d8d
6
+ metadata.gz: 27f432eee3b36aa258ae409c4afe26aaa4bc8f41589025d1f4147ba4737d4fe0543225c8765178797e5dbc6c66728e93dec966fd8849223b3731b42e29034dce
7
+ data.tar.gz: 6b414d534a79f3d9098878652fd5554091cd3faccdb36cab1236ce6972f74e3a24cc7ac68986b46276829b178961132d16971eb2285b47cecc658a23a572246b
@@ -45,8 +45,11 @@ module Dependabot
45
45
  fetched_files
46
46
  end
47
47
 
48
- sig { params(workspace_info: T::Hash[String, T.untyped]).void }
48
+ sig { params(workspace_info: T::Hash[String, Object]).void }
49
49
  def validate_workspace_info!(workspace_info)
50
+ # Only domain errors ("no project file found") reach here; helper
51
+ # crashes raise from RegistryClient rather than being misreported as
52
+ # a missing Project.toml.
50
53
  error_value = T.cast(workspace_info["error"], T.nilable(String))
51
54
  has_error = !error_value.nil?
52
55
  project_files = T.cast(workspace_info["project_files"], T.nilable(T::Array[String]))
@@ -110,13 +110,87 @@ module Dependabot
110
110
  parse_single_project_file(proj_file, dependencies_map)
111
111
  end
112
112
 
113
+ apply_manifest_versions(dependencies_map)
114
+
113
115
  dependencies_map.values
114
116
  end
115
117
 
118
+ # Resolve the installed version of each dependency from the manifest
119
+ # (Julia's lockfile), matching by UUID.
120
+ sig { params(dependencies_map: T::Hash[String, Dependabot::Dependency]).void }
121
+ def apply_manifest_versions(dependencies_map)
122
+ versions = manifest_versions_by_uuid
123
+ return if versions.empty?
124
+
125
+ dependencies_map.transform_values! do |dep|
126
+ uuid = T.cast(dep.metadata[:julia_uuid], T.nilable(String))
127
+ version = uuid && versions[uuid]
128
+ next dep unless version
129
+
130
+ Dependabot::Dependency.new(
131
+ name: dep.name,
132
+ version: version,
133
+ requirements: dep.requirements,
134
+ package_manager: "julia",
135
+ metadata: dep.metadata
136
+ )
137
+ end
138
+ end
139
+
140
+ sig { returns(T::Hash[String, String]) }
141
+ def manifest_versions_by_uuid
142
+ manifest = manifest_file
143
+ return {} unless manifest
144
+
145
+ result = parse_manifest_content(T.must(manifest.content))
146
+
147
+ if result["error"]
148
+ Dependabot.logger.warn("Failed to parse Julia manifest: #{result['error']}")
149
+ return {}
150
+ end
151
+
152
+ deps = T.cast(result["dependencies"] || [], T::Array[T.untyped])
153
+ deps.each_with_object({}) do |dep_info, map|
154
+ dep_hash = T.cast(dep_info, T::Hash[String, T.untyped])
155
+ uuid = T.cast(dep_hash["uuid"], T.nilable(String))
156
+ version = T.cast(dep_hash["version"], T.nilable(String)).to_s
157
+ next if uuid.nil? || version.empty?
158
+
159
+ map[uuid] = version
160
+ end
161
+ end
162
+
163
+ sig { params(content: String).returns(T::Hash[String, T.untyped]) }
164
+ def parse_manifest_content(content)
165
+ result = T.let(nil, T.nilable(T::Hash[String, T.untyped]))
166
+ Dir.mktmpdir("julia_manifest") do |temp_dir|
167
+ # Written under a fixed name: version-suffixed manifests
168
+ # (Manifest-v1.11.toml) would otherwise be skipped by Pkg when the
169
+ # helper's Julia version doesn't match.
170
+ manifest_path = File.join(temp_dir, "Manifest.toml")
171
+ File.write(manifest_path, content)
172
+ result = registry_client.parse_manifest(manifest_path)
173
+ end
174
+ T.must(result)
175
+ end
176
+
177
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
178
+ def manifest_file
179
+ dependency_files.find do |f|
180
+ File.basename(f.name).match?(/^(Julia)?Manifest(?:-v[\d.]+)?\.toml$/i)
181
+ end
182
+ end
183
+
116
184
  sig { params(proj_file: Dependabot::DependencyFile, dependencies_map: T::Hash[String, Dependabot::Dependency]).void }
117
185
  def parse_single_project_file(proj_file, dependencies_map)
118
186
  temp_dir = Dir.mktmpdir("julia_project")
119
- project_path = File.join(temp_dir, proj_file.name)
187
+ # File names like "../Project.toml" (a workspace root fetched from a
188
+ # member directory) must not escape the temp dir; fall back to the
189
+ # basename since each project file gets its own directory anyway.
190
+ project_path = File.expand_path(File.join(temp_dir, proj_file.name))
191
+ unless project_path.start_with?("#{File.expand_path(temp_dir)}#{File::SEPARATOR}")
192
+ project_path = File.join(temp_dir, File.basename(proj_file.name))
193
+ end
120
194
  FileUtils.mkdir_p(File.dirname(project_path))
121
195
  File.write(project_path, proj_file.content)
122
196
 
@@ -164,6 +238,8 @@ module Dependabot
164
238
  if dependencies_map.key?(name)
165
239
  # Merge requirements from additional project files
166
240
  existing_dep = T.must(dependencies_map[name])
241
+ next if uuid_conflict?(existing_dep, uuid, file_name)
242
+
167
243
  existing_requirements = existing_dep.requirements + [new_requirement]
168
244
  dependencies_map[name] = Dependabot::Dependency.new(
169
245
  name: name,
@@ -185,6 +261,27 @@ module Dependabot
185
261
  end
186
262
  end
187
263
 
264
+ # UUID is a package's identity in Julia: two same-named entries with
265
+ # different UUIDs are different packages, and merging them would run
266
+ # updates against the wrong UUID.
267
+ sig do
268
+ params(
269
+ existing_dep: Dependabot::Dependency,
270
+ uuid: T.nilable(String),
271
+ file_name: String
272
+ ).returns(T::Boolean)
273
+ end
274
+ def uuid_conflict?(existing_dep, uuid, file_name)
275
+ existing_uuid = T.cast(existing_dep.metadata[:julia_uuid], T.nilable(String))
276
+ return false unless uuid && existing_uuid && uuid != existing_uuid
277
+
278
+ Dependabot.logger.warn(
279
+ "Skipping #{existing_dep.name} in #{file_name}: UUID #{uuid} conflicts with #{existing_uuid} " \
280
+ "from another project file"
281
+ )
282
+ true
283
+ end
284
+
188
285
  sig { returns(T::Array[Dependabot::DependencyFile]) }
189
286
  def all_project_files
190
287
  dependency_files.select { |f| f.name.match?(/Project\.toml$/i) }
@@ -15,6 +15,10 @@ module Dependabot
15
15
  class FileUpdater < Dependabot::FileUpdaters::Base
16
16
  extend T::Sig
17
17
 
18
+ # Matches a [compat] table header, tolerating indentation and a trailing
19
+ # comment ("[compat] # pins")
20
+ COMPAT_HEADER_PATTERN = T.let(/^\s*\[compat\]\s*(?:#.*)?$/, Regexp)
21
+
18
22
  sig { returns(T::Array[Regexp]) }
19
23
  def self.updated_files_regex
20
24
  [/(?:Julia)?Project\.toml$/i, /(?:Julia)?Manifest(?:-v[\d.]+)?\.toml$/i]
@@ -58,6 +62,10 @@ module Dependabot
58
62
 
59
63
  return all_projects_only_update(updated_project_files) if actual_manifest.nil?
60
64
 
65
+ # Requirement-only updates (no target versions) have nothing to tell
66
+ # Pkg; ship the Project.toml changes on their own.
67
+ return all_projects_only_update(updated_project_files) if build_updates_hash.empty?
68
+
61
69
  # Write all updated project files to disk for Julia's Pkg
62
70
  write_all_temporary_files(updated_project_files, actual_manifest)
63
71
  result = call_julia_helper
@@ -232,19 +240,30 @@ module Dependabot
232
240
 
233
241
  private
234
242
 
235
- sig { returns(T::Hash[String, String]) }
243
+ sig { returns(T::Hash[String, T::Hash[String, String]]) }
236
244
  def build_updates_hash
237
- updates = {}
238
- dependencies.each do |dependency|
239
- next unless dependency.version
240
-
241
- uuid = T.cast(dependency.metadata[:julia_uuid], String)
242
- updates[uuid] = {
243
- "name" => dependency.name,
244
- "version" => dependency.version
245
- }
246
- end
247
- updates
245
+ @build_updates_hash ||= T.let(
246
+ begin
247
+ updates = T.let({}, T::Hash[String, T::Hash[String, String]])
248
+ dependencies.each do |dependency|
249
+ version = dependency.version
250
+ next unless version
251
+
252
+ uuid = dependency.metadata[:julia_uuid]
253
+ unless uuid.is_a?(String)
254
+ Dependabot.logger.warn("Skipping manifest update for #{dependency.name}: no UUID available")
255
+ next
256
+ end
257
+
258
+ updates[uuid] = {
259
+ "name" => dependency.name,
260
+ "version" => version
261
+ }
262
+ end
263
+ updates
264
+ end,
265
+ T.nilable(T::Hash[String, T::Hash[String, String]])
266
+ )
248
267
  end
249
268
 
250
269
  # Helper methods for DependabotHelper.jl integration
@@ -253,12 +272,25 @@ module Dependabot
253
272
  def registry_client
254
273
  @registry_client ||= T.let(
255
274
  Dependabot::Julia::RegistryClient.new(
256
- credentials: credentials
275
+ credentials: credentials,
276
+ custom_registries: custom_registries
257
277
  ),
258
278
  T.nilable(Dependabot::Julia::RegistryClient)
259
279
  )
260
280
  end
261
281
 
282
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
283
+ def custom_registries
284
+ @custom_registries ||= T.let(
285
+ begin
286
+ registries_config = T.cast(options[:registries], T.nilable(T::Hash[Symbol, T.anything]))
287
+ registries = T.cast(registries_config&.dig(:julia), T.nilable(T::Array[T::Hash[Symbol, T.anything]])) || []
288
+ registries.map { |registry| registry.transform_keys(&:to_sym) }
289
+ end,
290
+ T.nilable(T::Array[T::Hash[Symbol, T.untyped]])
291
+ )
292
+ end
293
+
262
294
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
263
295
  def find_manifest_file
264
296
  # The file fetcher has already identified the correct manifest file
@@ -270,10 +302,17 @@ module Dependabot
270
302
  dependency_files.find do |f|
271
303
  # Use basename to get just the filename, not the full path with ../
272
304
  is_manifest = File.basename(f.name).match?(/^(Julia)?Manifest(?:-v[\d.]+)?\.toml$/i)
273
- is_manifest && (f.directory == project_dir || project_dir.start_with?(f.directory))
305
+ is_manifest && (f.directory == project_dir || parent_directory_of?(f.directory, project_dir))
274
306
  end
275
307
  end
276
308
 
309
+ sig { params(candidate: String, dir: String).returns(T::Boolean) }
310
+ def parent_directory_of?(candidate, dir)
311
+ # Segment-aware prefix check so "/doc" is not treated as a parent of "/docs"
312
+ prefix = candidate.end_with?("/") ? candidate : "#{candidate}/"
313
+ dir.start_with?(prefix)
314
+ end
315
+
277
316
  sig { params(manifest_path: String).returns(Dependabot::DependencyFile) }
278
317
  def manifest_file_for_path(manifest_path)
279
318
  # manifest_path is relative to the project directory (e.g., "Manifest.toml" or "../Manifest.toml")
@@ -288,10 +327,11 @@ module Dependabot
288
327
 
289
328
  # Find the matching manifest file in dependency_files
290
329
  found_manifest = dependency_files.find do |f|
291
- next unless f.name.match?(/^(Julia)?Manifest(?:-v[\d.]+)?\.toml$/i)
330
+ next unless File.basename(f.name).match?(/^(Julia)?Manifest(?:-v[\d.]+)?\.toml$/i)
292
331
 
293
- # Construct the full path for this file and normalize it
294
- file_path = File.join(f.directory, f.name).sub(%r{^/}, "")
332
+ # Construct the full path for this file, resolving ".." segments
333
+ # (workspace manifests are named e.g. "../Manifest.toml")
334
+ file_path = Pathname.new(File.join(f.directory, f.name)).cleanpath.to_s.sub(%r{^/}, "")
295
335
  file_path == resolved_manifest_path
296
336
  end
297
337
 
@@ -311,8 +351,6 @@ module Dependabot
311
351
 
312
352
  sig { override.void }
313
353
  def check_required_files
314
- return if dependency_files.empty?
315
-
316
354
  return if dependency_files.any? { |f| f.name.match?(/^(Julia)?Project\.toml$/i) }
317
355
 
318
356
  raise Dependabot::DependencyFileNotFound, "No Project.toml or JuliaProject.toml found."
@@ -330,71 +368,81 @@ module Dependabot
330
368
 
331
369
  sig { params(content: String, dependency_name: String, new_requirement: String).returns(String) }
332
370
  def update_dependency_requirement_in_content(content, dependency_name, new_requirement)
333
- # Extract the [compat] section to update it specifically
334
- # The regex handles files with or without trailing newlines
335
- compat_section_match = content.match(/^\[compat\]\s*\n((?:(?!\[)[^\n]*(?:\n|\z))*?)(?=^\[|\z)/m)
336
-
337
- if compat_section_match
338
- compat_section = T.must(compat_section_match[1])
339
- # Pattern to match the dependency in the compat section
340
- pattern = /^(\s*#{Regexp.escape(dependency_name)}\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s#\n]+)(\s*(?:\#.*)?)$/
341
-
342
- if compat_section.match?(pattern)
343
- # Replace existing entry in compat section
344
- updated_compat = compat_section.gsub(pattern, "\\1\"#{new_requirement}\"\\2")
345
- content.sub(T.must(compat_section_match[0]), "[compat]\n#{updated_compat}")
346
- else
347
- # Add new entry to existing [compat] section
348
- add_compat_entry_to_content(content, dependency_name, new_requirement)
349
- end
350
- else
351
- # Add new [compat] section
352
- add_compat_entry_to_content(content, dependency_name, new_requirement)
371
+ lines = content.lines
372
+ header_idx = lines.index { |line| line.match?(COMPAT_HEADER_PATTERN) }
373
+
374
+ unless header_idx
375
+ # Add a new [compat] section at the end of the file
376
+ separator = content.end_with?("\n") ? "" : "\n"
377
+ return "#{content}#{separator}\n[compat]\n#{dependency_name} = \"#{new_requirement}\"\n"
353
378
  end
354
- end
355
379
 
356
- sig { params(content: String, dependency_name: String, requirement: String).returns(String) }
357
- def add_compat_entry_to_content(content, dependency_name, requirement)
358
- if content.match?(/^\s*\[compat\]\s*$/m)
359
- compat_section_match = content.match(/^\[compat\]\s*\n((?:(?!\[)[^\n]*(?:\n|\z))*?)(?=^\[|\z)/m)
360
- return content unless compat_section_match
361
-
362
- compat_section = T.must(compat_section_match[1])
363
- entries = parse_compat_entries(compat_section)
364
- entries[dependency_name] = requirement
365
- sorted_entries = sort_compat_entries(entries)
366
- new_compat_section = build_compat_section(sorted_entries)
367
-
368
- content.sub(T.must(compat_section_match[0]), "[compat]\n#{new_compat_section}")
369
- else
370
- content + "\n[compat]\n#{dependency_name} = \"#{requirement}\"\n"
380
+ section_end = ((header_idx + 1)...lines.length).find { |i| T.must(lines[i]).match?(/^\s*\[/) } ||
381
+ lines.length
382
+
383
+ # Replace an existing entry in place, preserving surrounding lines
384
+ entry_pattern =
385
+ /^(\s*#{Regexp.escape(dependency_name)}\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s#\n]+)(\s*(?:\#.*)?)$/
386
+ ((header_idx + 1)...section_end).each do |i|
387
+ line = T.must(lines[i])
388
+ next unless line.match?(entry_pattern)
389
+
390
+ lines[i] = line.sub(entry_pattern) { "#{Regexp.last_match(1)}\"#{new_requirement}\"#{Regexp.last_match(2)}" }
391
+ return lines.join
371
392
  end
372
- end
373
393
 
374
- sig { params(compat_section: String).returns(T::Hash[String, String]) }
375
- def parse_compat_entries(compat_section)
376
- entries = {}
377
- compat_section.each_line do |line|
378
- next if line.strip.empty? || line.strip.start_with?("#")
394
+ insert_compat_entry(lines, header_idx, section_end, dependency_name, new_requirement)
395
+ end
379
396
 
380
- match = line.match(/^\s*([^=\s]+)\s*=\s*(.+?)(?:\s*#.*)?$/)
381
- next unless match
397
+ # Insert a new compat entry in alphabetical position without rewriting
398
+ # the rest of the section, so comments, blank lines and any custom
399
+ # ordering of the existing entries survive.
400
+ sig do
401
+ params(
402
+ lines: T::Array[String],
403
+ header_idx: Integer,
404
+ section_end: Integer,
405
+ dependency_name: String,
406
+ requirement: String
407
+ ).returns(String)
408
+ end
409
+ def insert_compat_entry(lines, header_idx, section_end, dependency_name, requirement)
410
+ insert_at = alphabetical_insert_position(lines, header_idx, section_end, dependency_name)
382
411
 
383
- key = T.must(match[1]).strip
384
- value = T.must(match[2]).strip.gsub(/^["']|["']$/, "")
385
- entries[key] = value
412
+ unless insert_at
413
+ # Append at the end of the section, before any trailing blank lines
414
+ insert_at = section_end
415
+ insert_at -= 1 while insert_at > header_idx + 1 && T.must(lines[insert_at - 1]).strip.empty?
386
416
  end
387
- entries
417
+
418
+ # The preceding line may lack a newline when the section ends the file
419
+ prev = lines[insert_at - 1]
420
+ lines[insert_at - 1] = "#{prev}\n" if prev && !prev.end_with?("\n")
421
+
422
+ lines.insert(insert_at, "#{dependency_name} = \"#{requirement}\"\n")
423
+ lines.join
388
424
  end
389
425
 
390
- sig { params(entries: T::Hash[String, String]).returns(T::Hash[String, String]) }
391
- def sort_compat_entries(entries)
392
- entries.sort.to_h
426
+ sig do
427
+ params(
428
+ lines: T::Array[String],
429
+ header_idx: Integer,
430
+ section_end: Integer,
431
+ dependency_name: String
432
+ ).returns(T.nilable(Integer))
393
433
  end
434
+ def alphabetical_insert_position(lines, header_idx, section_end, dependency_name)
435
+ ((header_idx + 1)...section_end).each do |i|
436
+ key = T.must(lines[i])[/^\s*([^#\s=][^=\s]*)\s*=/, 1]
437
+ next unless key && key > dependency_name
438
+
439
+ insert_at = i
440
+ # Comment lines directly above an entry belong to it
441
+ insert_at -= 1 while insert_at > header_idx + 1 && T.must(lines[insert_at - 1]).strip.start_with?("#")
442
+ return insert_at
443
+ end
394
444
 
395
- sig { params(entries: T::Hash[String, String]).returns(String) }
396
- def build_compat_section(entries)
397
- entries.map { |name, requirement| "#{name} = \"#{requirement}\"\n" }.join
445
+ nil
398
446
  end
399
447
  end
400
448
  end
@@ -51,34 +51,12 @@ module Dependabot
51
51
 
52
52
  sig { params(url_string: String).returns(T.nilable(Dependabot::Source)) }
53
53
  def parse_source_url(url_string)
54
- uri = URI.parse(url_string)
55
- hostname = uri.host
56
- return nil unless hostname
57
-
58
- # Extract repository path and clean it
59
- path = T.must(uri.path).delete_prefix("/").delete_suffix(".git")
60
- path_parts = path.split("/")
61
- return nil if path_parts.length < 2
62
-
63
- repo = "#{path_parts[0]}/#{path_parts[1]}"
64
-
65
- # Determine the provider based on hostname
66
- provider = case hostname
67
- when "github.com" then "github"
68
- when "gitlab.com" then "gitlab"
69
- when /\A.*\.gitlab\.io\z/ then "gitlab"
70
- else
71
- Dependabot.logger.info("Unknown SCM provider for #{hostname}, using generic")
72
- return nil # Return nil for unknown providers
73
- end
74
-
75
- Dependabot::Source.new(
76
- provider: provider,
77
- repo: repo
78
- )
79
- rescue URI::InvalidURIError => e
80
- Dependabot.logger.error("Invalid URI for dependency #{dependency.name}: #{url_string} - #{e.message}")
81
- nil
54
+ # Source.from_url understands all providers Dependabot supports
55
+ # (GitHub, GitLab, Bitbucket, Azure DevOps, ...), unlike a
56
+ # hand-rolled hostname switch
57
+ source = Dependabot::Source.from_url(url_string)
58
+ Dependabot.logger.info("Unknown SCM provider for #{url_string}") if source.nil?
59
+ source
82
60
  end
83
61
  end
84
62
  end
@@ -39,27 +39,23 @@ module Dependabot
39
39
 
40
40
  sig { returns(T::Array[Dependabot::Package::PackageRelease]) }
41
41
  def fetch_package_releases
42
- releases = T.let([], T::Array[Dependabot::Package::PackageRelease])
43
-
44
- begin
45
- registry_client = RegistryClient.new(
46
- credentials: credentials,
47
- custom_registries: custom_registries
48
- )
49
- uuid = T.cast(dependency.metadata[:julia_uuid], T.nilable(String))
50
-
51
- # Fetch all available versions
52
- available_versions = registry_client.fetch_available_versions(dependency.name, uuid)
53
- return releases if available_versions.empty?
54
-
55
- releases = build_releases_for_versions(registry_client, available_versions, uuid)
56
- mark_latest_release(releases)
57
-
58
- releases
59
- rescue StandardError => e
60
- Dependabot.logger.error("Error while fetching package releases for #{dependency.name}: #{e.message}")
61
- releases
62
- end
42
+ registry_client = RegistryClient.new(
43
+ credentials: credentials,
44
+ custom_registries: custom_registries
45
+ )
46
+ uuid = T.cast(dependency.metadata[:julia_uuid], T.nilable(String))
47
+
48
+ # Fetch all available versions. Domain errors (package not found)
49
+ # come back as an empty list; infrastructure failures raise and are
50
+ # classified by the updater's error handler rather than being
51
+ # silently treated as "no update available".
52
+ available_versions = registry_client.fetch_available_versions(dependency.name, uuid)
53
+ return [] if available_versions.empty?
54
+
55
+ releases = build_releases_for_versions(registry_client, available_versions, uuid)
56
+ mark_latest_release(releases)
57
+
58
+ releases
63
59
  end
64
60
 
65
61
  private
@@ -60,7 +60,7 @@ module Dependabot
60
60
  )
61
61
  end
62
62
 
63
- sig { returns(T::Hash[T.untyped, T.untyped]) }
63
+ sig { returns(T::Hash[Symbol, Object]) }
64
64
  def ecosystem
65
65
  {
66
66
  package_manager: "julia",
@@ -3,6 +3,9 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require "time"
6
+ require "uri"
7
+ require "fileutils"
8
+ require "toml-rb"
6
9
  require "dependabot/credential"
7
10
  require "dependabot/julia/version"
8
11
  require "dependabot/shared_helpers"
@@ -35,18 +38,18 @@ module Dependabot
35
38
  )
36
39
 
37
40
  # Check if the result itself contains an error (package not found)
38
- return nil if result["error"]
41
+ if result["error"]
42
+ Dependabot.logger.warn(
43
+ "Failed to fetch latest version for #{package_name}: #{result['error']}"
44
+ )
45
+ return nil
46
+ end
39
47
 
40
48
  # Extract version from the result structure
41
49
  # The Julia helper returns version directly in the result
42
50
  return nil unless result["version"]
43
51
 
44
52
  Gem::Version.new(result["version"])
45
- rescue StandardError => e
46
- Dependabot.logger.warn(
47
- "Failed to fetch latest version for #{package_name}: #{e.message}"
48
- )
49
- nil
50
53
  end
51
54
 
52
55
  sig { params(package_name: String, package_uuid: T.nilable(String)).returns(T.nilable(Gem::Version)) }
@@ -63,17 +66,17 @@ module Dependabot
63
66
  )
64
67
 
65
68
  # Check if the result itself contains an error (package not found)
66
- return nil if result["error"]
69
+ if result["error"]
70
+ Dependabot.logger.warn(
71
+ "Failed to fetch latest version with custom registries for #{package_name}: #{result['error']}"
72
+ )
73
+ return nil
74
+ end
67
75
 
68
76
  # Extract version from the result structure
69
77
  return nil unless result["version"]
70
78
 
71
79
  Gem::Version.new(result["version"])
72
- rescue StandardError => e
73
- Dependabot.logger.warn(
74
- "Failed to fetch latest version with custom registries for #{package_name}: #{e.message}"
75
- )
76
- nil
77
80
  end
78
81
 
79
82
  sig { params(package_name: String, package_uuid: String).returns(T.nilable(T::Hash[String, T.untyped])) }
@@ -82,9 +85,6 @@ module Dependabot
82
85
  function: "get_package_metadata",
83
86
  args: { package_name: package_name, package_uuid: package_uuid }
84
87
  )
85
- rescue StandardError => e
86
- Dependabot.logger.warn("Failed to fetch metadata for #{package_name}: #{e.message}")
87
- nil
88
88
  end
89
89
 
90
90
  sig do
@@ -124,9 +124,6 @@ module Dependabot
124
124
  "project_file" => result["project_file"],
125
125
  "manifest_file" => result["manifest_file"]
126
126
  }
127
- rescue StandardError => e
128
- Dependabot.logger.warn("Failed to find environment files in #{directory}: #{e.message}")
129
- {}
130
127
  end
131
128
 
132
129
  sig { params(directory: String).returns(T::Hash[String, T.untyped]) }
@@ -143,9 +140,6 @@ module Dependabot
143
140
  "manifest_file" => result["manifest_file"] || "",
144
141
  "workspace_root" => result["workspace_root"] || ""
145
142
  }
146
- rescue StandardError => e
147
- Dependabot.logger.warn("Failed to find workspace project files in #{directory}: #{e.message}")
148
- { "error" => e.message }
149
143
  end
150
144
 
151
145
  sig do
@@ -182,7 +176,7 @@ module Dependabot
182
176
  sig do
183
177
  params(
184
178
  project_path: String,
185
- updates: T::Hash[String, String]
179
+ updates: T::Hash[String, T::Hash[String, String]]
186
180
  ).returns(T::Hash[String, T.untyped])
187
181
  end
188
182
  def update_manifest(project_path:, updates:)
@@ -213,8 +207,8 @@ module Dependabot
213
207
  return nil unless result["release_date"]
214
208
 
215
209
  Time.parse(result["release_date"])
216
- rescue StandardError => e
217
- Dependabot.logger.warn("Failed to fetch release date for #{package_name} v#{version}: #{e.message}")
210
+ rescue ArgumentError => e
211
+ Dependabot.logger.warn("Failed to parse release date for #{package_name} v#{version}: #{e.message}")
218
212
  nil
219
213
  end
220
214
 
@@ -232,16 +226,16 @@ module Dependabot
232
226
  )
233
227
 
234
228
  # Check if the result contains an error
235
- return [] if result["error"]
229
+ if result["error"]
230
+ Dependabot.logger.warn("Failed to fetch available versions for #{package_name}: #{result['error']}")
231
+ return []
232
+ end
236
233
 
237
234
  # Extract versions array from the result
238
235
  versions = result["versions"]
239
236
  return [] unless versions.is_a?(Array)
240
237
 
241
238
  versions.map(&:to_s)
242
- rescue StandardError => e
243
- Dependabot.logger.warn("Failed to fetch available versions for #{package_name}: #{e.message}")
244
- []
245
239
  end
246
240
 
247
241
  sig { params(package_name: String, package_uuid: T.nilable(String)).returns(T::Array[String]) }
@@ -258,18 +252,18 @@ module Dependabot
258
252
  )
259
253
 
260
254
  # Check if the result contains an error
261
- return [] if result["error"]
255
+ if result["error"]
256
+ Dependabot.logger.warn(
257
+ "Failed to fetch available versions with custom registries for #{package_name}: #{result['error']}"
258
+ )
259
+ return []
260
+ end
262
261
 
263
262
  # Extract versions array from the result
264
263
  versions = result["versions"]
265
264
  return [] unless versions.is_a?(Array)
266
265
 
267
266
  versions.map(&:to_s)
268
- rescue StandardError => e
269
- Dependabot.logger.warn(
270
- "Failed to fetch available versions with custom registries for #{package_name}: #{e.message}"
271
- )
272
- []
273
267
  end
274
268
 
275
269
  # ============================================================================
@@ -278,6 +272,8 @@ module Dependabot
278
272
 
279
273
  sig { params(dependencies: T::Array[Dependabot::Dependency]).returns(T::Hash[String, T.untyped]) }
280
274
  def batch_fetch_package_info(dependencies)
275
+ return {} if dependencies.empty?
276
+
281
277
  packages = dependencies.map do |dep|
282
278
  {
283
279
  name: dep.name,
@@ -289,9 +285,6 @@ module Dependabot
289
285
  function: "batch_get_package_info",
290
286
  args: { packages: packages }
291
287
  )
292
- rescue StandardError => e
293
- Dependabot.logger.error("Failed to batch fetch package info: #{e.message}")
294
- {}
295
288
  end
296
289
 
297
290
  sig do
@@ -300,6 +293,8 @@ module Dependabot
300
293
  ).returns(T::Hash[String, T::Hash[String, T.nilable(String)]])
301
294
  end
302
295
  def batch_fetch_version_release_dates(packages_versions)
296
+ return {} if packages_versions.empty?
297
+
303
298
  result = call_julia_helper(
304
299
  function: "batch_get_version_release_dates",
305
300
  args: { packages_versions: packages_versions }
@@ -311,13 +306,12 @@ module Dependabot
311
306
 
312
307
  dates.is_a?(Hash) ? dates : {}
313
308
  end
314
- rescue StandardError => e
315
- Dependabot.logger.error("Failed to batch fetch version release dates: #{e.message}")
316
- {}
317
309
  end
318
310
 
319
311
  sig { params(dependencies: T::Array[Dependabot::Dependency]).returns(T::Hash[String, T.untyped]) }
320
312
  def batch_fetch_available_versions(dependencies)
313
+ return {} if dependencies.empty?
314
+
321
315
  packages = dependencies.map do |dep|
322
316
  {
323
317
  name: dep.name,
@@ -329,9 +323,6 @@ module Dependabot
329
323
  function: "batch_get_available_versions",
330
324
  args: { packages: packages }
331
325
  )
332
- rescue StandardError => e
333
- Dependabot.logger.error("Failed to batch fetch available versions: #{e.message}")
334
- {}
335
326
  end
336
327
 
337
328
  private
@@ -385,21 +376,59 @@ module Dependabot
385
376
 
386
377
  if ENV["DEPENDABOT_NATIVE_HELPERS_PATH"]
387
378
  # In production/CI, use the shared depot where packages were precompiled
388
- user_depot = File.join(ENV.fetch("HOME", "/home/dependabot"), ".julia")
389
379
  # Trailing : is intentional. It automatically includes the bundled stdlibs
390
- env["JULIA_DEPOT_PATH"] = "#{user_depot}:"
380
+ env["JULIA_DEPOT_PATH"] = "#{julia_user_depot}:"
391
381
  end
392
382
  # In development use the default Julia depot
393
383
 
394
- # Add Julia-specific environment variables for registry authentication
395
- julia_credentials = credentials.select { |c| c["type"] == "julia_registry" }
396
- julia_credentials.each_with_index do |cred, index|
397
- env["JULIA_PKG_SERVER_REGISTRY_PREFERENCE_#{index}"] = cred.fetch("url")
398
- env["JULIA_PKG_SERVER_#{index}_TOKEN"] = cred.fetch("token") if cred["token"]
384
+ # Pkg supports a single package server via JULIA_PKG_SERVER;
385
+ # authentication uses an auth.toml in the depot, not env vars.
386
+ pkg_server = pkg_server_credential
387
+ if pkg_server
388
+ env["JULIA_PKG_SERVER"] = pkg_server.fetch("url")
389
+ configure_pkg_server_auth(pkg_server)
399
390
  end
400
391
 
401
392
  env
402
393
  end
394
+
395
+ sig { returns(T.nilable(Dependabot::Credential)) }
396
+ def pkg_server_credential
397
+ julia_credentials = credentials.select { |c| c["type"] == "julia_registry" && c["url"] }
398
+ if julia_credentials.length > 1
399
+ Dependabot.logger.warn(
400
+ "Multiple julia_registry credentials configured; Julia's Pkg supports a single " \
401
+ "package server, using the first"
402
+ )
403
+ end
404
+ julia_credentials.first
405
+ end
406
+
407
+ # Write the package server token where Pkg actually reads it:
408
+ # <depot>/servers/<host>/auth.toml
409
+ sig { params(credential: Dependabot::Credential).void }
410
+ def configure_pkg_server_auth(credential)
411
+ token = credential["token"]
412
+ return unless token
413
+
414
+ host = URI.parse(credential.fetch("url")).host
415
+ return unless host
416
+
417
+ auth_dir = File.join(julia_user_depot, "servers", host)
418
+ FileUtils.mkdir_p(auth_dir)
419
+ File.write(File.join(auth_dir, "auth.toml"), TomlRB.dump({ "access_token" => token }))
420
+ rescue URI::InvalidURIError => e
421
+ Dependabot.logger.warn("Invalid julia_registry URL: #{e.message}")
422
+ end
423
+
424
+ sig { returns(String) }
425
+ def julia_user_depot
426
+ if ENV["DEPENDABOT_NATIVE_HELPERS_PATH"]
427
+ File.join(ENV.fetch("HOME", "/home/dependabot"), ".julia")
428
+ else
429
+ File.join(Dir.home, ".julia")
430
+ end
431
+ end
403
432
  end
404
433
  end
405
434
  end
@@ -11,7 +11,7 @@ module Dependabot
11
11
  def self.requirements_array(requirement_string)
12
12
  # Julia version specifiers can be:
13
13
  # - Exact: "1.2.3"
14
- # - Range: "1.2-1.3", ">=1.0, <2.0"
14
+ # - Range: "1.2 - 1.3", ">=1.0, <2.0"
15
15
  # - Caret: "^1.2" (compatible within major version)
16
16
  # - Tilde: "~1.2.3" (compatible within minor version)
17
17
  # Note: Missing compat entry (nil/empty) means any version is acceptable
@@ -35,6 +35,13 @@ module Dependabot
35
35
  # Separate constraints (e.g., "^1.10, 2" or "0.34, 0.35") use version specs
36
36
  # (with or without ^/~) as OR conditions - any matching spec is acceptable.
37
37
  # Only treat as compound if ALL constraints use explicit comparison operators.
38
+ #
39
+ # NOTE: Julia's Pkg unions *all* comma-separated compat specs, including
40
+ # operator-style ones, so ">= 1.0, < 2.0" is `*` to the resolver. We keep
41
+ # intersection semantics for operator-style lists anyway because this
42
+ # method also parses Dependabot ignore conditions (e.g. ">= 2.a, < 3"),
43
+ # which are always intersections; treating those as unions would make
44
+ # every ignore condition match all versions.
38
45
  return false if constraints.length <= 1
39
46
 
40
47
  constraints.all? { |c| c.match?(/^[<>=]/) }
@@ -65,11 +72,15 @@ module Dependabot
65
72
  version
66
73
  end
67
74
 
75
+ # Julia hyphen ranges require whitespace around the hyphen ("1.2 - 3.4");
76
+ # without spaces the hyphen introduces a prerelease tag instead.
77
+ HYPHEN_RANGE_PATTERN = T.let(/^(\d+(?:\.\d+)*)\s+-\s+(\d+(?:\.\d+)*)$/, Regexp)
78
+
68
79
  sig { params(constraint: String).returns(T::Array[String]) }
69
80
  def self.normalize_julia_constraint(constraint)
70
81
  return normalize_caret_constraint(constraint) if constraint.match?(/^\^(\d+(?:\.\d+)*)/)
71
82
  return normalize_tilde_constraint(constraint) if constraint.match?(/^~(\d+(?:\.\d+)*)/)
72
- return normalize_range_constraint(constraint) if constraint.match?(/^(\d+(?:\.\d+)*)-(\d+(?:\.\d+)*)$/)
83
+ return normalize_range_constraint(constraint) if constraint.match?(HYPHEN_RANGE_PATTERN)
73
84
 
74
85
  # Julia treats plain version numbers as caret constraints (implicit ^)
75
86
  # e.g., "1.2.3" is equivalent to "^1.2.3" which means ">= 1.2.3, < 2.0.0"
@@ -134,18 +145,21 @@ module Dependabot
134
145
 
135
146
  sig { params(constraint: String).returns(T::Array[String]) }
136
147
  private_class_method def self.normalize_range_constraint(constraint)
137
- start_version, end_version = constraint.split("-")
138
- end_parts = T.must(end_version).split(".")
139
-
140
- next_minor = if end_parts.length >= 2
141
- major = T.must(end_parts[0])
142
- minor = T.must(end_parts[1])
143
- "#{major}.#{minor.to_i + 1}.0"
144
- else
145
- "#{T.must(end_parts[0]).to_i + 1}.0.0"
146
- end
147
-
148
- [">= #{start_version}", "< #{next_minor}"]
148
+ match = T.must(constraint.match(HYPHEN_RANGE_PATTERN))
149
+ start_version = T.must(match[1])
150
+ end_version = T.must(match[2])
151
+ end_parts = end_version.split(".")
152
+
153
+ # Julia hyphen-range semantics (https://pkgdocs.julialang.org/v1/compatibility/):
154
+ # a fully-specified end is inclusive ("1.2.3 - 4.5.6" admits exactly 4.5.6),
155
+ # while a partial end acts as a wildcard ("0.2 - 0.5" means up to 0.5.*).
156
+ upper_bound = case end_parts.length
157
+ when 1 then "< #{T.must(end_parts[0]).to_i + 1}.0.0"
158
+ when 2 then "< #{T.must(end_parts[0])}.#{T.must(end_parts[1]).to_i + 1}.0"
159
+ else "<= #{end_version}"
160
+ end
161
+
162
+ [">= #{start_version}", upper_bound]
149
163
  end
150
164
  end
151
165
  end
@@ -19,7 +19,7 @@ module Dependabot
19
19
  ignored_versions: T::Array[String],
20
20
  security_advisories: T::Array[Dependabot::SecurityAdvisory],
21
21
  raise_on_ignored: T::Boolean,
22
- cooldown_config: T.nilable(T::Hash[Symbol, T.untyped]),
22
+ cooldown_config: T.nilable(T::Hash[Symbol, Object]),
23
23
  custom_registries: T::Array[T::Hash[Symbol, String]]
24
24
  ).void
25
25
  end
@@ -45,7 +45,14 @@ module Dependabot
45
45
 
46
46
  sig { returns(T.nilable(Gem::Version)) }
47
47
  def latest_version
48
- @latest_version ||= T.let(fetch_latest_version, T.nilable(Gem::Version))
48
+ @latest_version ||= T.let(available_versions.max, T.nilable(Gem::Version))
49
+ end
50
+
51
+ # All selectable versions after cooldown, ignored-version and
52
+ # vulnerability filtering, in ascending order.
53
+ sig { returns(T::Array[Gem::Version]) }
54
+ def available_versions
55
+ @available_versions ||= T.let(fetch_available_versions, T.nilable(T::Array[Gem::Version]))
49
56
  end
50
57
 
51
58
  private
@@ -68,14 +75,14 @@ module Dependabot
68
75
  sig { returns(T::Boolean) }
69
76
  attr_reader :raise_on_ignored
70
77
 
71
- sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
78
+ sig { returns(T.nilable(T::Hash[Symbol, Object])) }
72
79
  attr_reader :cooldown_config
73
80
 
74
81
  sig { returns(T::Array[T::Hash[Symbol, String]]) }
75
82
  attr_reader :custom_registries
76
83
 
77
- sig { returns(T.nilable(Gem::Version)) }
78
- def fetch_latest_version
84
+ sig { returns(T::Array[Gem::Version]) }
85
+ def fetch_available_versions
79
86
  # Fetch all package releases using the PackageDetailsFetcher
80
87
  package_fetcher = Julia::Package::PackageDetailsFetcher.new(
81
88
  dependency: dependency,
@@ -84,32 +91,34 @@ module Dependabot
84
91
  )
85
92
 
86
93
  releases = package_fetcher.fetch_package_releases
87
- return nil if releases.empty?
94
+ return [] if releases.empty?
88
95
 
89
96
  # Filter releases based on cooldown
90
97
  if cooldown_config
91
98
  releases = filter_releases_by_cooldown(releases)
92
- return nil if releases.empty?
99
+ return [] if releases.empty?
93
100
  end
94
101
 
95
102
  # Convert to versions for further filtering
96
103
  versions = releases.map(&:version).sort
97
104
 
105
+ # Filter out prereleases unless the dependency is already on one
106
+ versions = filter_prerelease_versions(versions)
107
+ return [] if versions.empty?
108
+
98
109
  # Filter out ignored versions
99
110
  versions = filter_ignored_versions(versions)
100
- return nil if versions.empty?
111
+ return [] if versions.empty?
101
112
 
102
113
  # Filter out lower versions
103
114
  versions = filter_lower_versions(versions)
104
- return nil if versions.empty?
115
+ return [] if versions.empty?
105
116
 
106
117
  # Filter out vulnerable versions
107
- filtered_versions = Dependabot::UpdateCheckers::VersionFilters.filter_vulnerable_versions(
118
+ Dependabot::UpdateCheckers::VersionFilters.filter_vulnerable_versions(
108
119
  versions,
109
120
  security_advisories
110
121
  )
111
-
112
- filtered_versions.max
113
122
  end
114
123
 
115
124
  sig do
@@ -126,6 +135,21 @@ module Dependabot
126
135
  end
127
136
  end
128
137
 
138
+ sig { params(versions: T::Array[Gem::Version]).returns(T::Array[Gem::Version]) }
139
+ def filter_prerelease_versions(versions)
140
+ return versions if wants_prerelease?
141
+
142
+ versions.reject(&:prerelease?)
143
+ end
144
+
145
+ sig { returns(T::Boolean) }
146
+ def wants_prerelease?
147
+ version = dependency.version
148
+ return false unless version
149
+
150
+ Dependabot::Julia::Version.new(version).prerelease?
151
+ end
152
+
129
153
  sig { params(versions: T::Array[Gem::Version]).returns(T::Array[Gem::Version]) }
130
154
  def filter_ignored_versions(versions)
131
155
  filtered = versions.reject do |version|
@@ -148,7 +172,7 @@ module Dependabot
148
172
  def filter_lower_versions(versions)
149
173
  return versions unless dependency.version
150
174
 
151
- current_version = Gem::Version.new(dependency.version)
175
+ current_version = Dependabot::Julia::Version.new(dependency.version)
152
176
  versions.select { |v| v > current_version }
153
177
  end
154
178
 
@@ -180,19 +204,27 @@ module Dependabot
180
204
  excludes = T.cast(config[:exclude], T.nilable(T::Array[String]))
181
205
 
182
206
  # Check exclusions first
183
- return false if excludes&.any? { |pattern| dependency.name.match?(Regexp.new(pattern.gsub("*", ".*"))) }
207
+ return false if excludes&.any? { |pattern| dependency.name.match?(cooldown_pattern_regex(pattern)) }
184
208
 
185
209
  # Check inclusions (if specified, dependency must match)
186
- return includes.any? { |pattern| dependency.name.match?(Regexp.new(pattern.gsub("*", ".*"))) } if includes&.any?
210
+ return includes.any? { |pattern| dependency.name.match?(cooldown_pattern_regex(pattern)) } if includes&.any?
187
211
 
188
212
  true # Include by default if no include patterns specified
189
213
  end
190
214
 
215
+ # Cooldown include/exclude entries are shell-style globs where only "*"
216
+ # is a wildcard; everything else matches literally and the whole name
217
+ # must match (so "JSON" doesn't also cover "JSON3" or "LazyJSON").
218
+ sig { params(pattern: String).returns(Regexp) }
219
+ def cooldown_pattern_regex(pattern)
220
+ Regexp.new("\\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('.*')}\\z")
221
+ end
222
+
191
223
  sig { params(version: Gem::Version).returns(T.nilable(Integer)) }
192
224
  def determine_cooldown_days(version)
193
225
  return nil unless cooldown_config
194
226
 
195
- current_version = dependency.version ? Gem::Version.new(dependency.version) : nil
227
+ current_version = dependency.version ? Dependabot::Julia::Version.new(dependency.version) : nil
196
228
  return nil unless current_version
197
229
 
198
230
  version_bump_type = determine_version_bump_type(version, current_version)
@@ -23,12 +23,15 @@ module Dependabot
23
23
  T::Array[Dependabot::DependencyRequirement]
24
24
  )
25
25
  @target_version = target_version
26
- @update_strategy = T.let(update_strategy || :bump_versions, Symbol)
26
+ # Julia's ecosystem convention (CompatHelper) is to append a new spec
27
+ # to the existing compat entry, i.e. widen, so that is the default.
28
+ @update_strategy = T.let(update_strategy || :widen_ranges, Symbol)
27
29
  end
28
30
 
29
31
  sig { returns(T::Array[Dependabot::DependencyRequirement]) }
30
32
  def updated_requirements
31
33
  return requirements unless target_version
34
+ return requirements if update_strategy == :lockfile_only
32
35
 
33
36
  target_version_obj = Dependabot::Julia::Version.new(target_version)
34
37
 
@@ -69,17 +72,29 @@ module Dependabot
69
72
 
70
73
  sig { params(requirement_string: String, target_version: Dependabot::Julia::Version).returns(String) }
71
74
  def updated_version_requirement(requirement_string, target_version)
72
- # Don't update range requirements (e.g., "0.34-0.35") - these are explicit manual constraints
73
- return requirement_string if requirement_string.match?(/^\d+(?:\.\d+)*-\d+(?:\.\d+)*$/)
75
+ # Don't update range requirements (e.g., "0.34 - 0.35") - these are explicit manual constraints
76
+ return requirement_string if requirement_string.match?(Dependabot::Julia::Requirement::HYPHEN_RANGE_PATTERN)
74
77
 
75
78
  # Parse all constraints in the requirement string
76
79
  reqs = Dependabot::Julia::Requirement.requirements_array(requirement_string)
77
80
 
78
81
  # Check if any requirement is satisfied by the target version
79
82
  # Note: This uses the implicit caret semantics from the Requirement class
80
- return requirement_string if reqs.any? { |req| req.satisfied_by?(target_version) }
83
+ satisfied = reqs.any? { |req| req.satisfied_by?(target_version) }
84
+
85
+ case update_strategy
86
+ when :bump_versions
87
+ simplified_version_spec(target_version)
88
+ when :bump_versions_if_necessary
89
+ satisfied ? requirement_string : simplified_version_spec(target_version)
90
+ else # :widen_ranges
91
+ satisfied ? requirement_string : append_spec(requirement_string, target_version)
92
+ end
93
+ end
81
94
 
82
- # Otherwise, append a new requirement that includes the target version
95
+ sig { params(requirement_string: String, target_version: Dependabot::Julia::Version).returns(String) }
96
+ def append_spec(requirement_string, target_version)
97
+ # Append a new requirement that includes the target version
83
98
  # Following CompatHelper.jl's approach: use major.minor for versions >= 1.0,
84
99
  # 0.minor for 0.x versions, and 0.0.patch for 0.0.x versions
85
100
  new_spec = simplified_version_spec(target_version)
@@ -77,19 +77,24 @@ module Dependabot
77
77
 
78
78
  sig { override.returns(T.nilable(T.any(Dependabot::Version, String))) }
79
79
  def latest_resolvable_version_with_no_unlock
80
- # Return latest version that satisfies current requirement constraints
81
- return nil unless latest_version
80
+ # Return the highest available version that satisfies the current
81
+ # requirement constraints. requirements_array applies Julia compat
82
+ # semantics (implicit caret, tilde, hyphen ranges) and returns
83
+ # alternatives that are OR'd together.
84
+ candidates = latest_version_finder.available_versions
85
+ return nil if candidates.empty?
82
86
 
83
87
  current_requirement = T.cast(dependency.requirements.first&.fetch(:requirement, nil), T.nilable(String))
84
88
 
85
- if current_requirement.nil? || current_requirement == "*"
86
- return Dependabot::Julia::Version.new(latest_version.to_s)
87
- end
88
-
89
- req = requirement_class.new(current_requirement)
90
- return unless T.cast(req.satisfied_by?(latest_version), T::Boolean)
89
+ best = if current_requirement.nil? || current_requirement.strip == "*"
90
+ candidates.max
91
+ else
92
+ reqs = requirement_class.requirements_array(current_requirement)
93
+ candidates.select { |version| reqs.any? { |req| req.satisfied_by?(version) } }.max
94
+ end
95
+ return nil unless best
91
96
 
92
- Dependabot::Julia::Version.new(latest_version.to_s)
97
+ Dependabot::Julia::Version.new(best.to_s)
93
98
  end
94
99
 
95
100
  sig { override.returns(T::Array[Dependabot::DependencyRequirement]) }
@@ -131,8 +136,9 @@ module Dependabot
131
136
  semver_major_days: cooldown.semver_major_days,
132
137
  semver_minor_days: cooldown.semver_minor_days,
133
138
  semver_patch_days: cooldown.semver_patch_days,
134
- include: cooldown.include,
135
- exclude: cooldown.exclude
139
+ # ReleaseCooldownOptions stores these as Sets; LatestVersionFinder expects Arrays.
140
+ include: cooldown.include.to_a,
141
+ exclude: cooldown.exclude.to_a
136
142
  }
137
143
  end
138
144
 
@@ -6,16 +6,21 @@ require "dependabot/version"
6
6
  module Dependabot
7
7
  module Julia
8
8
  class Version < Dependabot::Version
9
- # Julia follows semantic versioning for most packages
9
+ # Julia follows semantic versioning, including prerelease tags and build
10
+ # metadata. Build metadata is significant in Julia: JLL packages register
11
+ # versions like "1.6.10+0", "1.6.10+1" where the build number orders new
12
+ # builds of the same upstream version.
10
13
  # See: https://docs.julialang.org/en/v1/stdlib/Pkg/#Version-specifier-format
11
- VERSION_PATTERN = T.let(/^v?(\d+(?:\.\d+)*)(?:[-+].*)?$/, Regexp)
14
+ VERSION_PATTERN = T.let(
15
+ /^v?\d+(?:\.\d+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/,
16
+ Regexp
17
+ )
12
18
 
13
19
  sig { override.params(version: T.nilable(T.any(String, Integer, Gem::Version))).returns(T::Boolean) }
14
20
  def self.correct?(version)
15
21
  return false if version.nil?
16
22
 
17
- version_string = version.to_s
18
- VERSION_PATTERN.match?(version_string)
23
+ VERSION_PATTERN.match?(version.to_s.strip)
19
24
  end
20
25
 
21
26
  sig { override.params(version: T.nilable(T.any(String, Integer, Gem::Version))).void }
@@ -26,7 +31,7 @@ module Dependabot
26
31
  version_string = version_string.sub(/^v/, "") if version_string.match?(/^v\d/)
27
32
 
28
33
  @version_string = T.let(version_string, String)
29
- super(version_string)
34
+ super(self.class.gem_compatible_version_string(version_string))
30
35
  end
31
36
 
32
37
  sig do
@@ -37,6 +42,29 @@ module Dependabot
37
42
  def self.new(version)
38
43
  T.cast(super, Dependabot::Julia::Version)
39
44
  end
45
+
46
+ # Gem::Version rejects "+" so build metadata has to be folded away before
47
+ # calling super. A numeric build becomes an extra segment, which preserves
48
+ # Julia's ordering between builds ("1.6.10+1" > "1.6.10+0") and against
49
+ # neighbouring versions ("1.6.10+1" < "1.6.11"). Non-numeric builds are
50
+ # dropped for comparison purposes.
51
+ sig { params(version_string: String).returns(String) }
52
+ def self.gem_compatible_version_string(version_string)
53
+ base, build = version_string.split("+", 2)
54
+ return version_string unless build
55
+
56
+ build.match?(/\A\d+\z/) ? "#{base}.#{build}" : T.must(base)
57
+ end
58
+
59
+ sig { returns(String) }
60
+ def to_s
61
+ @version_string
62
+ end
63
+
64
+ sig { override.returns(String) }
65
+ def to_semver
66
+ @version_string
67
+ end
40
68
  end
41
69
  end
42
70
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-julia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.386.0
4
+ version: 0.387.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.386.0
18
+ version: 0.387.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.386.0
25
+ version: 0.387.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -261,7 +261,7 @@ licenses:
261
261
  - MIT
262
262
  metadata:
263
263
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
264
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.386.0
264
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.387.0
265
265
  rdoc_options: []
266
266
  require_paths:
267
267
  - lib