Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
244a1e8
extend file updater to support xcode swiftpm dependency update
AbhishekBhaskar Mar 9, 2026
936ff27
extend swift update checker to support xcode swift pm
AbhishekBhaskar Mar 11, 2026
a34086b
refactor according to pr comments
AbhishekBhaskar Mar 11, 2026
b33385e
fix failing lint and spec errors
AbhishekBhaskar Mar 12, 2026
b1c40a8
Merge branch 'main' of github.com:dependabot/dependabot-core into swi…
AbhishekBhaskar Mar 12, 2026
e6de93a
Merge branch 'swift-xcode-spm-update-checker' of github.com:dependabo…
AbhishekBhaskar Mar 12, 2026
94ac90b
merge update checker changes
AbhishekBhaskar Mar 12, 2026
d020372
fix merge conflicts with main
AbhishekBhaskar Mar 16, 2026
97d8502
feat: add xcworkspace support for xcode swiftpm
markhallen Mar 16, 2026
c87df05
refactor: simplify nilable req_file guards
markhallen Mar 16, 2026
0a40a45
fix: tighten xcode workspace scope matching
markhallen Mar 16, 2026
8c14206
fix: ensure file content is not nil before scanning for project refer…
markhallen Mar 16, 2026
aa5c3f5
fix: change Sorbet type from strict to strong in xcode_file_helpers
markhallen Mar 16, 2026
0222a00
add swift xcworkspace support
AbhishekBhaskar Mar 17, 2026
34ac308
Update comment for support files
AbhishekBhaskar Mar 17, 2026
c3e0ef5
Update comment to match functionality
AbhishekBhaskar Mar 17, 2026
2063c7a
Update loop structure for queue
AbhishekBhaskar Mar 17, 2026
a1c6da6
fix sorbet errors
AbhishekBhaskar Mar 17, 2026
4229214
fix regex performance issue
AbhishekBhaskar Mar 17, 2026
912b559
Merge branch 'main' into swift-xcworkspace-on-14394
AbhishekBhaskar Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions swift/lib/dependabot/swift/file_fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@
require "dependabot/experiments"
require "dependabot/file_fetchers"
require "dependabot/file_fetchers/base"
require "dependabot/swift/xcode_file_helpers"

module Dependabot
module Swift
class FileFetcher < Dependabot::FileFetchers::Base
extend T::Sig

XCODEPROJ_SUFFIX = ".xcodeproj"
XCWORKSPACE_SUFFIX = ".xcworkspace"
XCODE_SPM_PACKAGE_RESOLVED_PATH = "project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
XCWORKSPACE_PACKAGE_RESOLVED_PATH = "xcshareddata/swiftpm/Package.resolved"

sig { override.params(filenames: T::Array[String]).returns(T::Boolean) }
def self.required_files_in?(filenames)
return true if filenames.include?("Package.swift")

if Dependabot::Experiments.enabled?(:enable_swift_xcode_spm)
return filenames.any? { |f| f.end_with?("Package.resolved") }
return filenames.any? { |f| XcodeFileHelpers.xcode_resolved_path?(f) }
end

false
Expand All @@ -28,7 +32,7 @@ def self.required_files_in?(filenames)
def self.required_files_message
if Dependabot::Experiments.enabled?(:enable_swift_xcode_spm)
"Repo must contain a Package.swift configuration file or " \
"an .xcodeproj directory with a Package.resolved file."
"an .xcodeproj/.xcworkspace directory with a Package.resolved file."
else
"Repo must contain a Package.swift configuration file."
end
Expand Down Expand Up @@ -74,17 +78,62 @@ def fetch_xcode_spm_files(fetched_files)
resolved = fetch_file_if_present(File.join(xcodeproj_path, XCODE_SPM_PACKAGE_RESOLVED_PATH))
fetched_files << resolved if resolved
end

xcworkspace_dirs.each do |workspace_path|
workspace_data = fetch_support_file(File.join(workspace_path, "contents.xcworkspacedata"))
fetched_files << workspace_data if workspace_data

resolved = fetch_file_if_present(File.join(workspace_path, XCWORKSPACE_PACKAGE_RESOLVED_PATH))
fetched_files << resolved if resolved
end
end

sig { returns(T::Array[String]) }
def xcodeproj_dirs
@xcodeproj_dirs ||= T.let(
repo_contents(dir: ".", raise_errors: false)
.select { |entry| entry.type == "dir" && entry.name.end_with?(".xcodeproj") }
.map(&:name),
discover_dirs_with_suffix(XCODEPROJ_SUFFIX),
T.nilable(T::Array[String])
)
end

sig { returns(T::Array[String]) }
def xcworkspace_dirs
@xcworkspace_dirs ||= T.let(
discover_dirs_with_suffix(XCWORKSPACE_SUFFIX)
.reject { |path| path.include?("#{XCODEPROJ_SUFFIX}/") },
T.nilable(T::Array[String])
)
end

sig { params(suffix: String).returns(T::Array[String]) }
def discover_dirs_with_suffix(suffix)
discovered = T.let([], T::Array[String])
queue = T.let(["."], T::Array[String])
visited = T.let({}, T::Hash[String, T::Boolean])
index = T.let(0, Integer)

while index < queue.length
dir = T.must(queue[index])
index += 1
next if visited[dir]

visited[dir] = true

entries = repo_contents(dir: dir, raise_errors: false)
entries.each do |entry|
next unless entry.type == "dir"

next_dir = dir == "." ? entry.name : File.join(dir, entry.name)
if entry.name.end_with?(suffix)
discovered << next_dir
elsif !entry.name.start_with?(".")
queue << next_dir
end
end
Comment thread
AbhishekBhaskar marked this conversation as resolved.
end

discovered.sort
end
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions swift/lib/dependabot/swift/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
require "dependabot/swift/file_parser/xcode_spm_resolver"
require "dependabot/swift/package_manager"
require "dependabot/swift/language"
require "dependabot/swift/xcode_file_helpers"

module Dependabot
module Swift
Expand Down Expand Up @@ -115,13 +116,12 @@ def package_manifest_file
@package_manifest_file ||= T.let(get_original_file("Package.swift"), T.nilable(Dependabot::DependencyFile))
end

# All non-support Package.resolved files from Xcode project directories
# All non-support Package.resolved files from Xcode project and workspace directories (.xcodeproj, .xcworkspace)
sig { returns(T::Array[Dependabot::DependencyFile]) }
def xcode_resolved_files
@xcode_resolved_files ||= T.let(
dependency_files.select do |f|
f.name.end_with?("Package.resolved") &&
f.name.include?(".xcodeproj/") &&
XcodeFileHelpers.xcode_resolved_path?(f.name) &&
!f.support_file?
end,
T.nilable(T::Array[Dependabot::DependencyFile])
Expand Down
82 changes: 67 additions & 15 deletions swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
require "dependabot/swift/file_parser"
require "dependabot/swift/file_parser/package_resolved_parser"
require "dependabot/swift/file_parser/pbxproj_parser"
require "dependabot/swift/xcode_file_helpers"

module Dependabot
module Swift
class FileParser < Dependabot::FileParsers::Base
# Orchestrates Xcode-managed SwiftPM dependency parsing.
#
# Parses Package.resolved JSON files found inside .xcodeproj directories,
# then enriches each dependency with requirement info extracted from the
# Parses Package.resolved JSON files found inside Xcode project and
# workspace directories (e.g., .xcodeproj and .xcworkspace), then
# enriches each dependency with requirement info extracted from the
# corresponding project.pbxproj files.
class XcodeSpmResolver
extend T::Sig
Expand All @@ -38,8 +40,10 @@ def parse

xcode_resolved_files.each do |resolved_file|
resolved_deps = PackageResolvedParser.new(resolved_file).parse
xcodeproj_dir = extract_xcodeproj_dir(resolved_file.name)
pbxproj_requirements = scoped_requirements.fetch(xcodeproj_dir, {})
pbxproj_requirements = requirements_for_resolved_file(
scoped_requirements: scoped_requirements,
resolved_file_name: resolved_file.name
)
Comment thread
AbhishekBhaskar marked this conversation as resolved.

resolved_deps.each do |dep|
enriched = enrich_with_pbxproj_requirements(dep, pbxproj_requirements)
Expand All @@ -59,24 +63,67 @@ def parse
attr_reader :pbxproj_files

# Collects requirement info from all project.pbxproj support files,
# keyed by xcodeproj directory so each resolved file only sees
# requirements from its own Xcode project.
# keyed by Xcode scope directory so each resolved file can be enriched
# by requirements from its closest matching Xcode scope.
sig { returns(T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]]) }
def aggregate_pbxproj_requirements
scoped = T.let({}, T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]])

pbxproj_files.each do |pbxproj_file|
xcodeproj_dir = extract_xcodeproj_dir(pbxproj_file.name)
scoped[xcodeproj_dir] ||= {}
xcode_scope_dir = extract_xcode_scope_dir(pbxproj_file.name)
scoped[xcode_scope_dir] ||= {}

PbxprojParser.new(pbxproj_file).parse.each do |name, req_info|
T.must(scoped[xcodeproj_dir])[name] = req_info
T.must(scoped[xcode_scope_dir])[name] = req_info
end
end

scoped
end

sig do
params(
scoped_requirements: T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]]
).returns(T::Hash[String, T::Hash[Symbol, T.untyped]])
end
def merge_scopes(scoped_requirements)
scoped_requirements.values.each_with_object({}) do |requirements, merged|
requirements.each { |name, req_info| merged[name] = req_info }
end
end

sig do
params(
scoped_requirements: T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]],
resolved_file_name: String
).returns(T::Hash[String, T::Hash[Symbol, T.untyped]])
end
def requirements_for_resolved_file(scoped_requirements:, resolved_file_name:)
scope_dir = extract_xcode_scope_dir(resolved_file_name)
return T.must(scoped_requirements[scope_dir]) if scoped_requirements.key?(scope_dir)

workspace_root = workspace_root_for_scope(scope_dir)
return {} unless workspace_root

local_scopes = scoped_requirements.select do |candidate_scope, _|
scope_in_workspace_root?(candidate_scope, workspace_root)
end
return {} if local_scopes.empty?

merge_scopes(local_scopes)
end

sig { params(candidate_scope: T.nilable(String), workspace_root: String).returns(T::Boolean) }
def scope_in_workspace_root?(candidate_scope, workspace_root)
return false unless candidate_scope

if workspace_root == "."
!candidate_scope.include?("/")
else
candidate_scope.start_with?("#{workspace_root}/")
end
end

# Enriches a dependency parsed from Package.resolved with requirement
# info from the matching project.pbxproj
sig do
Expand Down Expand Up @@ -117,13 +164,18 @@ def enrich_with_pbxproj_requirements(dep, pbxproj_requirements)
)
end

# Extracts the .xcodeproj directory name from a file path.
# e.g. "MyApp.xcodeproj/project.xcworkspace/.../Package.resolved" -> "MyApp.xcodeproj"
# e.g. "sub/dir/App.xcodeproj/project.pbxproj" -> "sub/dir/App.xcodeproj"
# Extracts the Xcode scope directory (.xcodeproj or .xcworkspace)
# from a file path.
sig { params(path: String).returns(T.nilable(String)) }
def extract_xcodeproj_dir(path)
match = path.match(%r{^(.*?\.xcodeproj)/})
match&.captures&.first
def extract_xcode_scope_dir(path)
XcodeFileHelpers.extract_xcode_scope_dir(path)
end

sig { params(scope_dir: T.nilable(String)).returns(T.nilable(String)) }
def workspace_root_for_scope(scope_dir)
return nil unless scope_dir&.end_with?(".xcworkspace")

File.dirname(scope_dir)
end
end
end
Expand Down
17 changes: 14 additions & 3 deletions swift/lib/dependabot/swift/file_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require "dependabot/swift/file_updater/lockfile_updater"
require "dependabot/swift/file_updater/manifest_updater"
require "dependabot/swift/file_updater/xcode_lockfile_updater"
require "dependabot/swift/xcode_file_helpers"

module Dependabot
module Swift
Expand Down Expand Up @@ -53,7 +54,8 @@ def updated_xcode_spm_files
xcode_resolved_files.each do |resolved_file|
updater = XcodeLockfileUpdater.new(
resolved_file: resolved_file,
dependencies: dependencies
dependencies: dependencies,
workspace_files: xcode_workspace_files
)

next unless updater.lockfile_changed?
Expand Down Expand Up @@ -100,14 +102,23 @@ def xcode_spm_mode?
def xcode_resolved_files
@xcode_resolved_files ||= T.let(
dependency_files.select do |f|
f.name.end_with?("Package.resolved") &&
f.name.include?(".xcodeproj/") &&
XcodeFileHelpers.xcode_resolved_path?(f.name) &&
!f.support_file?
end,
T.nilable(T::Array[Dependabot::DependencyFile])
)
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def xcode_workspace_files
@xcode_workspace_files ||= T.let(
dependency_files.select do |f|
f.name.end_with?("contents.xcworkspacedata") && f.support_file?
end,
T.nilable(T::Array[Dependabot::DependencyFile])
)
end

sig { returns(String) }
def updated_manifest_content
ManifestUpdater.new(
Expand Down
Loading
Loading