diff --git a/swift/lib/dependabot/swift/file_parser.rb b/swift/lib/dependabot/swift/file_parser.rb index 681f27b8765..cca6c20a34f 100644 --- a/swift/lib/dependabot/swift/file_parser.rb +++ b/swift/lib/dependabot/swift/file_parser.rb @@ -2,10 +2,12 @@ # frozen_string_literal: true require "dependabot/dependency" +require "dependabot/experiments" require "dependabot/file_parsers" require "dependabot/file_parsers/base" require "dependabot/swift/file_parser/dependency_parser" require "dependabot/swift/file_parser/manifest_parser" +require "dependabot/swift/file_parser/xcode_spm_resolver" require "dependabot/swift/package_manager" require "dependabot/swift/language" @@ -18,6 +20,34 @@ class FileParser < Dependabot::FileParsers::Base sig { override.returns(T::Array[Dependabot::Dependency]) } def parse + if package_manifest_file + parse_classic_spm + elsif xcode_spm_mode? + parse_xcode_spm + else + raise "No Package.swift or Xcode Package.resolved found!" + end + end + + sig { returns(Ecosystem) } + def ecosystem + @ecosystem ||= T.let( + begin + Ecosystem.new( + name: ECOSYSTEM, + language: language, + package_manager: package_manager + ) + end, + T.nilable(Dependabot::Ecosystem) + ) + end + + private + + # Classic SPM parsing: uses swift CLI via DependencyParser + ManifestParser + sig { returns(T::Array[Dependabot::Dependency]) } + def parse_classic_spm dependency_set = DependencySet.new dependency_parser.parse.map do |dep| @@ -41,21 +71,21 @@ def parse dependency_set.dependencies end - sig { returns(Ecosystem) } - def ecosystem - @ecosystem ||= T.let( - begin - Ecosystem.new( - name: ECOSYSTEM, - language: language, - package_manager: package_manager - ) - end, - T.nilable(Dependabot::Ecosystem) - ) + # Xcode SPM parsing: delegates to XcodeSpmResolver which parses + # Package.resolved JSON and enriches with project.pbxproj requirements + sig { returns(T::Array[Dependabot::Dependency]) } + def parse_xcode_spm + XcodeSpmResolver.new( + xcode_resolved_files: xcode_resolved_files, + pbxproj_files: pbxproj_files + ).parse end - private + sig { returns(T::Boolean) } + def xcode_spm_mode? + Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) && + xcode_resolved_files.any? + end sig { returns(Dependabot::Swift::FileParser::DependencyParser) } def dependency_parser @@ -68,7 +98,15 @@ def dependency_parser sig { override.void } def check_required_files - raise "No Package.swift!" unless package_manifest_file + return if package_manifest_file + + if Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) + return if xcode_resolved_files.any? + + raise "No Package.swift or Xcode Package.resolved found!" + end + + raise "No Package.swift!" end sig { returns(T.nilable(Dependabot::DependencyFile)) } @@ -77,6 +115,30 @@ 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 + 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/") && + !f.support_file? + end, + T.nilable(T::Array[Dependabot::DependencyFile]) + ) + end + + # All project.pbxproj support files + sig { returns(T::Array[Dependabot::DependencyFile]) } + def pbxproj_files + @pbxproj_files ||= T.let( + dependency_files.select do |f| + f.name.end_with?("project.pbxproj") && f.support_file? + end, + T.nilable(T::Array[Dependabot::DependencyFile]) + ) + end + sig { returns(Ecosystem::VersionManager) } def package_manager @package_manager ||= T.let( diff --git a/swift/lib/dependabot/swift/file_parser/dependency_parser.rb b/swift/lib/dependabot/swift/file_parser/dependency_parser.rb index 3b754579916..ac446cc7429 100644 --- a/swift/lib/dependabot/swift/file_parser/dependency_parser.rb +++ b/swift/lib/dependabot/swift/file_parser/dependency_parser.rb @@ -5,8 +5,8 @@ require "dependabot/file_parsers/base" require "dependabot/shared_helpers" require "dependabot/dependency" +require "dependabot/swift/url_helpers" require "json" -require "uri" module Dependabot module Swift @@ -79,7 +79,7 @@ def subdependencies(data, level: 0) def all_dependencies(data, level: 0) identity = data["identity"] url = SharedHelpers.scp_to_standard(data["url"]) - name = normalize(url) + name = UrlHelpers.normalize_name(url) version = data["version"] source = { type: "git", url: url, ref: version, branch: nil } @@ -97,13 +97,6 @@ def all_dependencies(data, level: 0) [dep, *subdependencies(data, level: level + 1)].compact end - sig { params(source: String).returns(String) } - def normalize(source) - uri = URI.parse(source.downcase) - - "#{uri.host}#{uri.path}".delete_prefix("www.").delete_suffix(".git") - end - sig { returns(T::Array[Dependabot::DependencyFile]) } attr_reader :dependency_files diff --git a/swift/lib/dependabot/swift/file_parser/package_resolved_parser.rb b/swift/lib/dependabot/swift/file_parser/package_resolved_parser.rb new file mode 100644 index 00000000000..a1928f93b90 --- /dev/null +++ b/swift/lib/dependabot/swift/file_parser/package_resolved_parser.rb @@ -0,0 +1,165 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "sorbet-runtime" +require "dependabot/dependency" +require "dependabot/errors" +require "dependabot/shared_helpers" +require "dependabot/swift/file_parser" +require "dependabot/swift/url_helpers" + +module Dependabot + module Swift + class FileParser < Dependabot::FileParsers::Base + class PackageResolvedParser + extend T::Sig + + SUPPORTED_VERSIONS = T.let([1, 2, 3].freeze, T::Array[Integer]) + + # Maps schema version to the JSON keys used for each pin field + PIN_KEYS = T.let( + { + 1 => { url: "repositoryURL", identity: "package", state: "state" }, + 2 => { url: "location", identity: "identity", state: "state" }, + 3 => { url: "location", identity: "identity", state: "state" } + }.freeze, + T::Hash[Integer, T::Hash[Symbol, String]] + ) + + sig { params(resolved_file: Dependabot::DependencyFile).void } + def initialize(resolved_file) + @resolved_file = resolved_file + end + + sig { returns(T::Array[Dependabot::Dependency]) } + def parse + parsed = parse_json + schema_version = detect_schema_version(parsed) + pins = extract_pins(parsed, schema_version) + + pins.filter_map { |pin| build_dependency(pin, schema_version) } + end + + private + + sig { returns(Dependabot::DependencyFile) } + attr_reader :resolved_file + + sig { returns(T::Hash[String, T.untyped]) } + def parse_json + content = resolved_file.content + unless content + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} has no content" + ) + end + + JSON.parse(content) + rescue JSON::ParserError => e + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} is not valid JSON: #{e.message}" + ) + end + + sig { params(parsed: T::Hash[String, T.untyped]).returns(Integer) } + def detect_schema_version(parsed) + version = parsed["version"] + + unless version.is_a?(Integer) && SUPPORTED_VERSIONS.include?(version) + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} has unsupported schema version: #{version.inspect}. " \ + "Supported versions: #{SUPPORTED_VERSIONS.join(', ')}" + ) + end + + version + end + + sig do + params( + parsed: T::Hash[String, T.untyped], + schema_version: Integer + ).returns(T::Array[T::Hash[String, T.untyped]]) + end + def extract_pins(parsed, schema_version) + pins = if schema_version == 1 + parsed.dig("object", "pins") + else + # v2 and v3 use the same top-level "pins" key + parsed["pins"] + end + + unless pins.is_a?(Array) + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} is missing the expected 'pins' array " \ + "(schema version #{schema_version})" + ) + end + + pins + end + + sig do + params( + pin: T::Hash[String, T.untyped], + schema_version: Integer + ).returns(T.nilable(Dependabot::Dependency)) + end + def build_dependency(pin, schema_version) + keys = T.must(PIN_KEYS[schema_version]) + url = pin[T.must(keys[:url])] + return nil unless url.is_a?(String) && !url.empty? + + state = pin[T.must(keys[:state])] || {} + identity = pin[T.must(keys[:identity])] + # v1 uses a display name for "package"; normalize to lowercase like v2/v3 "identity". + # v2/v3 identity is always lowercase per spec, so only v1 needs downcasing. + identity = identity&.downcase if schema_version == 1 + + build_dependency_object( + identity: identity, + url: url, + version: state["version"], + revision: state["revision"], + branch: state["branch"] + ) + end + + sig do + params( + identity: T.nilable(String), + url: String, + version: T.nilable(String), + revision: T.nilable(String), + branch: T.nilable(String) + ).returns(T.nilable(Dependabot::Dependency)) + end + def build_dependency_object(identity:, url:, version:, revision:, branch:) + normalized_url = SharedHelpers.scp_to_standard(url) + name = UrlHelpers.normalize_name(normalized_url) + ref = version || revision + + source = { type: "git", url: normalized_url, ref: ref, branch: branch } + + Dependency.new( + name: name, + version: version, + package_manager: "swift", + requirements: [{ + requirement: version ? "= #{version}" : nil, + groups: ["dependencies"], + file: resolved_file.name, + source: source + }], + metadata: { identity: identity } + ) + end + end + end + end +end diff --git a/swift/lib/dependabot/swift/file_parser/pbxproj_parser.rb b/swift/lib/dependabot/swift/file_parser/pbxproj_parser.rb new file mode 100644 index 00000000000..1168256ce3e --- /dev/null +++ b/swift/lib/dependabot/swift/file_parser/pbxproj_parser.rb @@ -0,0 +1,199 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/errors" +require "dependabot/shared_helpers" +require "dependabot/swift/file_parser" +require "dependabot/swift/native_requirement" +require "dependabot/swift/url_helpers" + +module Dependabot + module Swift + class FileParser < Dependabot::FileParsers::Base + # Parses XCRemoteSwiftPackageReference entries from a project.pbxproj file + # to extract dependency requirement constraints declared in Xcode. + # + # Returns a hash keyed by normalized dependency name (e.g. "github.com/owner/repo") + # mapping to requirement metadata, so the main parser can enrich + # Package.resolved dependencies with requirement info from the Xcode project. + class PbxprojParser + extend T::Sig + + # Regex to extract XCRemoteSwiftPackageReference blocks from pbxproj. + # Uses [^}]* to match the requirement block content — this is safe because + # Xcode requirement blocks are always flat dictionaries with no nested braces. + PACKAGE_REF_BLOCK = T.let( + / + isa\s*=\s*XCRemoteSwiftPackageReference;\s* + repositoryURL\s*=\s*"(?[^"]+)";\s* + requirement\s*=\s*\{(?[^}]*)\}; + /mx, + Regexp + ) + + # Patterns for extracting requirement fields + KIND_PATTERN = T.let(/kind\s*=\s*(\w+);/, Regexp) + MIN_VERSION_PATTERN = T.let(/minimumVersion\s*=\s*([\d.]+);/, Regexp) + MAX_VERSION_PATTERN = T.let(/maximumVersion\s*=\s*([\d.]+);/, Regexp) + VERSION_PATTERN = T.let(/version\s*=\s*([\d.]+);/, Regexp) + BRANCH_PATTERN = T.let(/branch\s*=\s*"?([^";]+)"?;/, Regexp) + REVISION_PATTERN = T.let(/revision\s*=\s*"?([^";]+)"?;/, Regexp) + + sig { params(pbxproj_file: Dependabot::DependencyFile).void } + def initialize(pbxproj_file) + @pbxproj_file = pbxproj_file + end + + # Returns a hash mapping normalized URL to requirement metadata. + # Each entry includes the Dependabot requirement string and the raw + # Xcode requirement kind/version info for use in metadata. + sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) } + def parse + content = pbxproj_file.content + return {} unless content + + requirements = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]]) + + content.scan(PACKAGE_REF_BLOCK).each do |url, requirement_block| + url = T.cast(url, String) + requirement_block = T.cast(requirement_block, String) + normalized_url = SharedHelpers.scp_to_standard(url) + name = UrlHelpers.normalize_name(normalized_url) + + req_info = parse_requirement_block(requirement_block) + next unless req_info + + requirements[name] = req_info.merge( + url: normalized_url, + file: pbxproj_file.name + ) + end + + requirements + end + + private + + sig { returns(Dependabot::DependencyFile) } + attr_reader :pbxproj_file + + sig do + params(block: String) + .returns(T.nilable(T::Hash[Symbol, T.untyped])) + end + def parse_requirement_block(block) + kind = block.match(KIND_PATTERN)&.captures&.first + return nil unless kind + + case kind + when "upToNextMajorVersion" + build_up_to_next_major(block) + when "upToNextMinorVersion" + build_up_to_next_minor(block) + when "exactVersion" + build_exact(block) + when "versionRange" + build_range(block) + when "branch" + build_branch(block) + when "revision" + build_revision(block) + end + end + + sig { params(block: String).returns(T::Hash[Symbol, T.untyped]) } + def build_up_to_next_major(block) + min_version = extract_version(block, MIN_VERSION_PATTERN) + requirement_string = "from: \"#{min_version}\"" + requirement = parse_native_requirement(requirement_string) + + { + requirement: requirement, + requirement_string: requirement_string, + kind: "upToNextMajorVersion" + } + end + + sig { params(block: String).returns(T::Hash[Symbol, T.untyped]) } + def build_up_to_next_minor(block) + min_version = extract_version(block, MIN_VERSION_PATTERN) + requirement_string = ".upToNextMinor(from: \"#{min_version}\")" + requirement = parse_native_requirement(requirement_string) + + { + requirement: requirement, + requirement_string: requirement_string, + kind: "upToNextMinorVersion" + } + end + + sig { params(block: String).returns(T::Hash[Symbol, T.untyped]) } + def build_exact(block) + version = extract_version(block, MIN_VERSION_PATTERN) || extract_version(block, VERSION_PATTERN) + requirement_string = "exact: \"#{version}\"" + requirement = parse_native_requirement(requirement_string) + + { + requirement: requirement, + requirement_string: requirement_string, + kind: "exactVersion" + } + end + + sig { params(block: String).returns(T::Hash[Symbol, T.untyped]) } + def build_range(block) + min_version = extract_version(block, MIN_VERSION_PATTERN) + max_version = extract_version(block, MAX_VERSION_PATTERN) + requirement_string = "\"#{min_version}\"..<\"#{max_version}\"" + requirement = parse_native_requirement(requirement_string) + + { + requirement: requirement, + requirement_string: requirement_string, + kind: "versionRange" + } + end + + sig { params(block: String).returns(T::Hash[Symbol, T.untyped]) } + def build_branch(block) + branch = block.match(BRANCH_PATTERN)&.captures&.first + + { + requirement: nil, + requirement_string: nil, + kind: "branch", + branch: branch + } + end + + sig { params(block: String).returns(T::Hash[Symbol, T.untyped]) } + def build_revision(block) + revision = block.match(REVISION_PATTERN)&.captures&.first + + { + requirement: nil, + requirement_string: nil, + kind: "revision", + revision: revision + } + end + + sig { params(block: String, pattern: Regexp).returns(T.nilable(String)) } + def extract_version(block, pattern) + block.match(pattern)&.captures&.first + end + + # Parses a requirement string into a Dependabot requirement via + # NativeRequirement. Returns nil if the string is malformed rather + # than raising, so a single bad entry doesn't stop parsing. + sig { params(requirement_string: String).returns(T.nilable(String)) } + def parse_native_requirement(requirement_string) + NativeRequirement.new(requirement_string).to_s + rescue RuntimeError, Gem::Requirement::BadRequirementError + nil + end + end + end + end +end diff --git a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb new file mode 100644 index 00000000000..61c84574158 --- /dev/null +++ b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb @@ -0,0 +1,129 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/dependency" +require "dependabot/file_parsers/base/dependency_set" +require "dependabot/swift/file_parser" +require "dependabot/swift/file_parser/package_resolved_parser" +require "dependabot/swift/file_parser/pbxproj_parser" + +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 + # corresponding project.pbxproj files. + class XcodeSpmResolver + extend T::Sig + + sig do + params( + xcode_resolved_files: T::Array[Dependabot::DependencyFile], + pbxproj_files: T::Array[Dependabot::DependencyFile] + ).void + end + def initialize(xcode_resolved_files:, pbxproj_files:) + @xcode_resolved_files = xcode_resolved_files + @pbxproj_files = pbxproj_files + end + + sig { returns(T::Array[Dependabot::Dependency]) } + def parse + dependency_set = Dependabot::FileParsers::Base::DependencySet.new + + scoped_requirements = aggregate_pbxproj_requirements + + 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, {}) + + resolved_deps.each do |dep| + enriched = enrich_with_pbxproj_requirements(dep, pbxproj_requirements) + dependency_set << enriched + end + end + + dependency_set.dependencies + end + + private + + sig { returns(T::Array[Dependabot::DependencyFile]) } + attr_reader :xcode_resolved_files + + sig { returns(T::Array[Dependabot::DependencyFile]) } + 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. + 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] ||= {} + + PbxprojParser.new(pbxproj_file).parse.each do |name, req_info| + T.must(scoped[xcodeproj_dir])[name] = req_info + end + end + + scoped + end + + # Enriches a dependency parsed from Package.resolved with requirement + # info from the matching project.pbxproj + sig do + params( + dep: Dependabot::Dependency, + pbxproj_requirements: T::Hash[String, T::Hash[Symbol, T.untyped]] + ).returns(Dependabot::Dependency) + end + def enrich_with_pbxproj_requirements(dep, pbxproj_requirements) + req_info = pbxproj_requirements[dep.name] + return dep unless req_info + + pbxproj_file = req_info[:file] + requirement_str = req_info[:requirement] + requirement_string = req_info[:requirement_string] + + new_requirements = dep.requirements.map do |req| + req.merge( + requirement: requirement_str || req[:requirement], + file: pbxproj_file, + metadata: { + # declaration_string is not applicable for Xcode-managed SPM + # (no Package.swift manifest to extract it from) + declaration_string: nil, + requirement_string: requirement_string + }.compact + ) + end + + Dependency.new( + name: dep.name, + version: dep.version, + package_manager: dep.package_manager, + requirements: new_requirements, + metadata: dep.metadata + ) + 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" + sig { params(path: String).returns(T.nilable(String)) } + def extract_xcodeproj_dir(path) + match = path.match(%r{^(.*?\.xcodeproj)/}) + match&.captures&.first + end + end + end + end +end diff --git a/swift/lib/dependabot/swift/url_helpers.rb b/swift/lib/dependabot/swift/url_helpers.rb new file mode 100644 index 00000000000..438ced41050 --- /dev/null +++ b/swift/lib/dependabot/swift/url_helpers.rb @@ -0,0 +1,24 @@ +# typed: strong +# frozen_string_literal: true + +require "uri" +require "sorbet-runtime" + +module Dependabot + module Swift + # Shared URL normalization utilities used by multiple parsers. + # Produces a canonical dependency name from a git repository URL + # by stripping the scheme, "www." prefix, and ".git" suffix. + module UrlHelpers + extend T::Sig + + sig { params(source: String).returns(String) } + def self.normalize_name(source) + uri = URI.parse(source.downcase) + "#{uri.host}#{uri.path}".delete_prefix("www.").delete_suffix(".git") + rescue URI::InvalidURIError + source.downcase.delete_suffix(".git") + end + end + end +end diff --git a/swift/spec/dependabot/swift/file_parser/package_resolved_parser_spec.rb b/swift/spec/dependabot/swift/file_parser/package_resolved_parser_spec.rb new file mode 100644 index 00000000000..dd1aca35ce1 --- /dev/null +++ b/swift/spec/dependabot/swift/file_parser/package_resolved_parser_spec.rb @@ -0,0 +1,297 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency_file" +require "dependabot/swift/file_parser/package_resolved_parser" + +RSpec.describe Dependabot::Swift::FileParser::PackageResolvedParser do + subject(:parser) { described_class.new(resolved_file) } + + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: file_name, + content: file_content + ) + end + + describe "#parse" do + context "with v2 schema" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "parses dependencies correctly" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + expect(dep.package_manager).to eq("swift") + expect(dep.metadata).to eq({ identity: "swift-nio" }) + end + + it "sets correct requirements" do + dep = parser.parse.first + req = dep.requirements.first + + expect(req[:requirement]).to eq("= 2.54.0") + expect(req[:groups]).to eq(["dependencies"]) + expect(req[:file]).to eq(file_name) + expect(req[:source]).to eq( + { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "2.54.0", + branch: nil + } + ) + end + end + + context "with v1 schema" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_v1_resolved", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "parses dependencies correctly" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + expect(dep.package_manager).to eq("swift") + expect(dep.metadata).to eq({ identity: "swift-nio" }) + end + + it "normalizes v1 identity from package name" do + dep = parser.parse.first + # v1 uses "package" field, lowercased for identity + expect(dep.metadata[:identity]).to eq("swift-nio") + end + end + + context "with v3 schema" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_v3_resolved", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "parses dependencies correctly (same as v2 structure)" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + end + end + + context "with revision-only pin (no version)" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_revision_only", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "parses with nil version" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to be_nil + end + + it "uses revision as ref in source" do + dep = parser.parse.first + source = dep.requirements.first[:source] + + expect(source[:ref]).to eq("6213ba7a06febe8fef60563a4a7d26a4085783cf") + expect(source[:branch]).to be_nil + end + + it "sets requirement to nil for revision-only pins" do + dep = parser.parse.first + expect(dep.requirements.first[:requirement]).to be_nil + end + end + + context "with multiple dependencies" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_multi_req", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "parses all dependencies" do + deps = parser.parse + expect(deps.length).to eq(4) + + names = deps.map(&:name) + expect(names).to contain_exactly( + "github.com/apple/swift-nio", + "github.com/apple/swift-collections", + "github.com/apple/swift-argument-parser", + "github.com/apple/swift-log" + ) + end + end + + context "with empty pins array" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_empty_pins", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "returns an empty array" do + expect(parser.parse).to be_empty + end + end + + context "with invalid JSON" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_invalid_json", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "raises DependencyFileNotParseable with file path" do + expect { parser.parse }.to raise_error( + Dependabot::DependencyFileNotParseable + ) do |error| + expect(error.file_path).to eq(file_name) + expect(error.message).to include("not valid JSON") + end + end + end + + context "with unknown schema version" do + let(:file_name) { "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" } + let(:file_content) do + fixture( + "projects", + "xcode_project_unknown_schema", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + end + + it "raises DependencyFileNotParseable with schema info" do + expect { parser.parse }.to raise_error( + Dependabot::DependencyFileNotParseable + ) do |error| + expect(error.file_path).to eq(file_name) + expect(error.message).to include("unsupported schema version") + expect(error.message).to include("99") + end + end + end + + context "with existing v1 fixture (ReactiveCocoa)" do + let(:file_name) { "Package.resolved" } + let(:file_content) { fixture("projects", "ReactiveCocoa", "Package.resolved") } + + it "parses all v1 dependencies" do + deps = parser.parse + expect(deps.length).to eq(5) + + names = deps.map(&:name) + expect(names).to include("github.com/quick/quick") + expect(names).to include("github.com/quick/nimble") + expect(names).to include("github.com/reactivecocoa/reactiveswift") + end + + it "normalizes URLs correctly" do + quick_dep = parser.parse.find { |d| d.metadata[:identity] == "quick" } + expect(quick_dep.name).to eq("github.com/quick/quick") + expect(quick_dep.requirements.first[:source][:url]).to eq("https://github.com/Quick/Quick.git") + end + end + + context "with SCP-style URL in pin" do + let(:file_name) { "Package.resolved" } + let(:file_content) do + <<~JSON + { + "pins": [ + { + "identity": "my-package", + "kind": "remoteSourceControl", + "location": "git@github.com:owner/my-package.git", + "state": { "revision": "abc123", "version": "1.0.0" } + } + ], + "version": 2 + } + JSON + end + + it "normalizes SCP URLs to HTTPS" do + dep = parser.parse.first + expect(dep.name).to eq("github.com/owner/my-package") + expect(dep.requirements.first[:source][:url]).to eq("https://github.com/owner/my-package.git") + end + end + end +end diff --git a/swift/spec/dependabot/swift/file_parser/pbxproj_parser_spec.rb b/swift/spec/dependabot/swift/file_parser/pbxproj_parser_spec.rb new file mode 100644 index 00000000000..b8642c7db22 --- /dev/null +++ b/swift/spec/dependabot/swift/file_parser/pbxproj_parser_spec.rb @@ -0,0 +1,146 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency_file" +require "dependabot/swift/file_parser/pbxproj_parser" + +RSpec.describe Dependabot::Swift::FileParser::PbxprojParser do + subject(:parser) { described_class.new(pbxproj_file) } + + let(:pbxproj_file) do + Dependabot::DependencyFile.new( + name: file_name, + content: file_content, + support_file: true + ) + end + + describe "#parse" do + context "with upToNextMajorVersion requirement" do + let(:file_name) { "MyApp.xcodeproj/project.pbxproj" } + let(:file_content) { fixture("projects", "xcode_project", "MyApp.xcodeproj", "project.pbxproj") } + + it "parses the requirement correctly" do + reqs = parser.parse + expect(reqs.length).to eq(1) + + name = reqs.keys.first + expect(name).to eq("github.com/apple/swift-nio") + + req_info = reqs[name] + expect(req_info[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(req_info[:requirement_string]).to eq("from: \"2.54.0\"") + expect(req_info[:kind]).to eq("upToNextMajorVersion") + expect(req_info[:file]).to eq(file_name) + expect(req_info[:url]).to eq("https://github.com/apple/swift-nio.git") + end + end + + context "with multiple requirement types" do + let(:file_name) { "MyApp.xcodeproj/project.pbxproj" } + let(:file_content) { fixture("projects", "xcode_project_multi_req", "MyApp.xcodeproj", "project.pbxproj") } + + it "parses all package references" do + reqs = parser.parse + expect(reqs.length).to eq(4) + end + + it "parses upToNextMajorVersion correctly" do + req = parser.parse["github.com/apple/swift-nio"] + expect(req[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(req[:requirement_string]).to eq("from: \"2.54.0\"") + expect(req[:kind]).to eq("upToNextMajorVersion") + end + + it "parses upToNextMinorVersion correctly" do + req = parser.parse["github.com/apple/swift-collections"] + expect(req[:requirement]).to eq(">= 1.0.0, < 1.1.0") + expect(req[:requirement_string]).to eq(".upToNextMinor(from: \"1.0.0\")") + expect(req[:kind]).to eq("upToNextMinorVersion") + end + + it "parses exactVersion correctly" do + req = parser.parse["github.com/apple/swift-argument-parser"] + expect(req[:requirement]).to eq("= 1.2.0") + expect(req[:requirement_string]).to eq("exact: \"1.2.0\"") + expect(req[:kind]).to eq("exactVersion") + end + + it "parses versionRange correctly" do + req = parser.parse["github.com/apple/swift-log"] + expect(req[:requirement]).to eq(">= 1.4.0, < 2.0.0") + expect(req[:requirement_string]).to eq("\"1.4.0\"..<\"2.0.0\"") + expect(req[:kind]).to eq("versionRange") + end + end + + context "with revision requirement" do + let(:file_name) { "MyApp.xcodeproj/project.pbxproj" } + let(:file_content) do + fixture("projects", "xcode_project_revision_only", "MyApp.xcodeproj", "project.pbxproj") + end + + it "returns nil requirement for revision-pinned packages" do + reqs = parser.parse + expect(reqs.length).to eq(1) + + req = reqs["github.com/apple/swift-nio"] + expect(req[:requirement]).to be_nil + expect(req[:requirement_string]).to be_nil + expect(req[:kind]).to eq("revision") + expect(req[:revision]).to eq("6213ba7a06febe8fef60563a4a7d26a4085783cf") + end + end + + context "with no XCRemoteSwiftPackageReference entries" do + let(:file_name) { "MyApp.xcodeproj/project.pbxproj" } + let(:file_content) do + fixture("projects", "xcode_project_empty_pins", "MyApp.xcodeproj", "project.pbxproj") + end + + it "returns an empty hash" do + expect(parser.parse).to be_empty + end + end + + context "with nil content" do + let(:file_name) { "MyApp.xcodeproj/project.pbxproj" } + let(:file_content) { nil } + + it "returns an empty hash" do + expect(parser.parse).to be_empty + end + end + + context "with branch requirement" do + let(:file_name) { "MyApp.xcodeproj/project.pbxproj" } + let(:file_content) do + <<~PBXPROJ + // !$*UTF8*$! + { + archiveVersion = 1; + objects = { + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = branch; + branch = main; + }; + }; + }; + rootObject = A1B2C3D4E5F60000; + } + PBXPROJ + end + + it "returns nil requirement with branch info" do + req = parser.parse["github.com/apple/swift-nio"] + expect(req[:requirement]).to be_nil + expect(req[:kind]).to eq("branch") + expect(req[:branch]).to eq("main") + end + end + end +end diff --git a/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb b/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb new file mode 100644 index 00000000000..262e83744f6 --- /dev/null +++ b/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb @@ -0,0 +1,384 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency_file" +require "dependabot/swift/file_parser/xcode_spm_resolver" + +RSpec.describe Dependabot::Swift::FileParser::XcodeSpmResolver do + subject(:resolver) do + described_class.new( + xcode_resolved_files: xcode_resolved_files, + pbxproj_files: pbxproj_files + ) + end + + let(:xcode_resolved_files) { [] } + let(:pbxproj_files) { [] } + + describe "#parse" do + context "with a single Xcode project (v2 Package.resolved)" do + let(:project_name) { "xcode_project" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses dependencies" do + deps = resolver.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + expect(dep.package_manager).to eq("swift") + end + + it "enriches dependencies with pbxproj requirements" do + dep = resolver.parse.first + req = dep.requirements.first + + expect(req[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(req[:file]).to eq("MyApp.xcodeproj/project.pbxproj") + expect(req[:metadata][:requirement_string]).to eq("from: \"2.54.0\"") + end + + it "sets correct source info" do + dep = resolver.parse.first + source = dep.requirements.first[:source] + + expect(source[:type]).to eq("git") + expect(source[:url]).to eq("https://github.com/apple/swift-nio.git") + expect(source[:ref]).to eq("2.54.0") + end + end + + context "with multiple .xcodeproj directories" do + let(:project_name) { "xcode_project_multiple" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "AppA.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ), + Dependabot::DependencyFile.new( + name: "AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "AppB.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppA.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppB.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppB.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses dependencies from all resolved files" do + deps = resolver.parse + names = deps.map(&:name) + + expect(names).to include("github.com/apple/swift-nio") + expect(names).to include("github.com/apple/swift-collections") + end + + it "associates requirements with correct pbxproj files" do + deps = resolver.parse + nio = deps.find { |d| d.name == "github.com/apple/swift-nio" } + collections = deps.find { |d| d.name == "github.com/apple/swift-collections" } + + expect(nio.requirements.first[:file]).to eq("AppA.xcodeproj/project.pbxproj") + expect(collections.requirements.first[:file]).to eq("AppB.xcodeproj/project.pbxproj") + end + end + + context "with multiple dependencies and requirement types" do + let(:project_name) { "xcode_project_multi_req" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses all dependencies" do + deps = resolver.parse + expect(deps.length).to eq(4) + + names = deps.map(&:name) + expect(names).to contain_exactly( + "github.com/apple/swift-nio", + "github.com/apple/swift-collections", + "github.com/apple/swift-argument-parser", + "github.com/apple/swift-log" + ) + end + + it "applies correct requirement types from pbxproj" do + deps = resolver.parse + nio = deps.find { |d| d.name == "github.com/apple/swift-nio" } + collections = deps.find { |d| d.name == "github.com/apple/swift-collections" } + parser_dep = deps.find { |d| d.name == "github.com/apple/swift-argument-parser" } + log = deps.find { |d| d.name == "github.com/apple/swift-log" } + + expect(nio.requirements.first[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(collections.requirements.first[:requirement]).to eq(">= 1.0.0, < 1.1.0") + expect(parser_dep.requirements.first[:requirement]).to eq("= 1.2.0") + expect(log.requirements.first[:requirement]).to eq(">= 1.4.0, < 2.0.0") + end + end + + context "with no pbxproj file (only Package.resolved)" do + let(:project_name) { "xcode_project" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) { [] } + + it "parses dependencies without requirement enrichment" do + deps = resolver.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + expect(dep.requirements.first[:requirement]).to eq("= 2.54.0") + end + end + + context "with empty pins" do + let(:project_name) { "xcode_project_empty_pins" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "returns an empty dependency list" do + expect(resolver.parse).to be_empty + end + end + + context "with revision-only pin (no version)" do + let(:project_name) { "xcode_project_revision_only" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses with nil version" do + dep = resolver.parse.first + expect(dep.version).to be_nil + end + + it "records revision in source ref" do + dep = resolver.parse.first + source = dep.requirements.first[:source] + expect(source[:ref]).to eq("6213ba7a06febe8fef60563a4a7d26a4085783cf") + end + end + + context "with v1 Package.resolved" do + let(:project_name) { "xcode_project_v1_resolved" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses v1 format dependencies" do + deps = resolver.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + end + end + + context "with v3 Package.resolved" do + let(:project_name) { "xcode_project_v3_resolved" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses v3 format dependencies" do + deps = resolver.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + end + end + + context "with no resolved files" do + let(:xcode_resolved_files) { [] } + let(:pbxproj_files) { [] } + + it "returns an empty dependency list" do + expect(resolver.parse).to be_empty + end + end + end +end diff --git a/swift/spec/dependabot/swift/file_parser_spec.rb b/swift/spec/dependabot/swift/file_parser_spec.rb index 835a74d926a..c8332a54126 100644 --- a/swift/spec/dependabot/swift/file_parser_spec.rb +++ b/swift/spec/dependabot/swift/file_parser_spec.rb @@ -339,4 +339,483 @@ end end end + + context "when enable_swift_xcode_spm experiment is enabled" do + before { Dependabot::Experiments.register(:enable_swift_xcode_spm, true) } + after { Dependabot::Experiments.register(:enable_swift_xcode_spm, false) } + + context "with a single Xcode project (v2 Package.resolved)" do + let(:project_name) { "xcode_project" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses Xcode SPM dependencies" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + expect(dep.package_manager).to eq("swift") + end + + it "enriches dependencies with pbxproj requirements" do + dep = parser.parse.first + req = dep.requirements.first + + expect(req[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(req[:file]).to eq("MyApp.xcodeproj/project.pbxproj") + expect(req[:metadata][:requirement_string]).to eq("from: \"2.54.0\"") + end + + it "sets correct source info" do + dep = parser.parse.first + source = dep.requirements.first[:source] + + expect(source[:type]).to eq("git") + expect(source[:url]).to eq("https://github.com/apple/swift-nio.git") + expect(source[:ref]).to eq("2.54.0") + end + end + + context "with v1 Package.resolved" do + let(:project_name) { "xcode_project_v1_resolved" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses v1 format dependencies" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + end + end + + context "with v3 Package.resolved" do + let(:project_name) { "xcode_project_v3_resolved" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses v3 format dependencies" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + end + end + + context "with multiple dependencies and requirement types" do + let(:project_name) { "xcode_project_multi_req" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses all dependencies" do + deps = parser.parse + expect(deps.length).to eq(4) + + names = deps.map(&:name) + expect(names).to contain_exactly( + "github.com/apple/swift-nio", + "github.com/apple/swift-collections", + "github.com/apple/swift-argument-parser", + "github.com/apple/swift-log" + ) + end + + it "applies correct requirement types from pbxproj" do + deps = parser.parse + nio = deps.find { |d| d.name == "github.com/apple/swift-nio" } + collections = deps.find { |d| d.name == "github.com/apple/swift-collections" } + parser_dep = deps.find { |d| d.name == "github.com/apple/swift-argument-parser" } + log = deps.find { |d| d.name == "github.com/apple/swift-log" } + + expect(nio.requirements.first[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(collections.requirements.first[:requirement]).to eq(">= 1.0.0, < 1.1.0") + expect(parser_dep.requirements.first[:requirement]).to eq("= 1.2.0") + expect(log.requirements.first[:requirement]).to eq(">= 1.4.0, < 2.0.0") + end + end + + context "with multiple .xcodeproj directories" do + let(:project_name) { "xcode_project_multiple" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppA.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "AppA.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ), + Dependabot::DependencyFile.new( + name: "AppB.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppB.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "AppB.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses dependencies from all resolved files" do + deps = parser.parse + names = deps.map(&:name) + + expect(names).to include("github.com/apple/swift-nio") + expect(names).to include("github.com/apple/swift-collections") + end + + it "associates requirements with correct pbxproj files" do + deps = parser.parse + nio = deps.find { |d| d.name == "github.com/apple/swift-nio" } + collections = deps.find { |d| d.name == "github.com/apple/swift-collections" } + + # With scoped requirements, nio comes from AppA and collections from AppB + expect(nio.requirements.first[:file]).to eq("AppA.xcodeproj/project.pbxproj") + expect(collections.requirements.first[:file]).to eq("AppB.xcodeproj/project.pbxproj") + end + end + + context "with revision-only pin (no version)" do + let(:project_name) { "xcode_project_revision_only" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses with nil version" do + dep = parser.parse.first + expect(dep.version).to be_nil + end + + it "records revision in source ref" do + dep = parser.parse.first + source = dep.requirements.first[:source] + expect(source[:ref]).to eq("6213ba7a06febe8fef60563a4a7d26a4085783cf") + end + end + + context "with no pbxproj file (only Package.resolved)" do + let(:project_name) { "xcode_project" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "parses dependencies without requirement enrichment" do + deps = parser.parse + expect(deps.length).to eq(1) + + dep = deps.first + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.version).to eq("2.54.0") + # Without pbxproj, requirement comes from Package.resolved only + expect(dep.requirements.first[:requirement]).to eq("= 2.54.0") + end + end + + context "with invalid JSON in Package.resolved" do + let(:project_name) { "xcode_project_invalid_json" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "raises DependencyFileNotParseable" do + expect { parser.parse }.to raise_error(Dependabot::DependencyFileNotParseable) + end + end + + context "with unknown schema version" do + let(:project_name) { "xcode_project_unknown_schema" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "raises DependencyFileNotParseable with schema info" do + expect { parser.parse }.to raise_error(Dependabot::DependencyFileNotParseable) do |error| + expect(error.message).to include("unsupported schema version") + end + end + end + + context "with empty pins" do + let(:project_name) { "xcode_project_empty_pins" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "returns an empty dependency list" do + expect(parser.parse).to be_empty + end + end + + context "with both Package.swift and .xcodeproj present" do + let(:project_name) { "xcode_project_with_manifest" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "Package.swift", + content: fixture("projects", project_name, "Package.swift") + ), + Dependabot::DependencyFile.new( + name: "Package.resolved", + content: fixture("projects", project_name, "Package.resolved") + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "uses classic SPM path (Package.swift takes precedence)" do + deps = parser.parse + # Classic SPM parses via swift CLI, so requirements come from Package.swift + dep = deps.find { |d| d.name == "github.com/apple/swift-nio" } + expect(dep).not_to be_nil + expect(dep.requirements.first[:file]).to eq("Package.swift") + end + end + end + + context "when enable_swift_xcode_spm experiment is disabled" do + before { Dependabot::Experiments.register(:enable_swift_xcode_spm, false) } + + context "with only Xcode files (no Package.swift)" do + let(:project_name) { "xcode_project" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + it "raises an error about missing Package.swift" do + expect { parser }.to raise_error("No Package.swift!") + end + end + end end diff --git a/swift/spec/fixtures/projects/xcode_project_empty_pins/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_empty_pins/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..f86e0ed8e59 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_empty_pins/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,10 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_empty_pins/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_empty_pins/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..f17c4c2bc29 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_empty_pins/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,4 @@ +{ + "pins" : [], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_project_invalid_json/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_invalid_json/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..f86e0ed8e59 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_invalid_json/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,10 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_invalid_json/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_invalid_json/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..558ad3d3bc8 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_invalid_json/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1 @@ +{ this is not valid json !!! diff --git a/swift/spec/fixtures/projects/xcode_project_multi_req/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_multi_req/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..61cec5122f6 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_multi_req/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,47 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.54.0; + }; + }; + A1B2C3D4E5F60002 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 1.0.0; + }; + }; + A1B2C3D4E5F60003 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser"; + requirement = { + kind = exactVersion; + minimumVersion = 1.2.0; + }; + }; + A1B2C3D4E5F60004 /* XCRemoteSwiftPackageReference "swift-log" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-log.git"; + requirement = { + kind = versionRange; + minimumVersion = 1.4.0; + maximumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_multi_req/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_multi_req/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..06aade84383 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_multi_req/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "version" : "2.54.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "532d8b529501fb73a2166571de84ab462956c153", + "version" : "1.5.4" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..a73b5c39f56 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = revision; + revision = 6213ba7a06febe8fef60563a4a7d26a4085783cf; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..5f9a09f6916 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,13 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_project_unknown_schema/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_unknown_schema/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..f86e0ed8e59 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_unknown_schema/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,10 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_unknown_schema/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_unknown_schema/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..decbb5e1ea6 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_unknown_schema/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,4 @@ +{ + "pins" : [], + "version" : 99 +} diff --git a/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..3d547e1ee6d --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.40.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..81794724110 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "version": "2.54.0" + } + } + ] + }, + "version": 1 +} diff --git a/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..4f1a4b117b8 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.54.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..9e38d7cf143 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "abc123", + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "version" : "2.54.0" + } + } + ], + "version" : 3 +}