diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 39207e52b6578..9c8289835f631 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -4701,6 +4701,14 @@ def resource(name, klass = Resource, &block) # depends_on "python@3.14" => :no_linkage # ``` # + # On a `:build` dependency, such as a compiler, `:no_linkage` also lets an + # outdated installed version be used when building from source, whatever + # its `compatibility_version`. + # + # ```ruby + # depends_on "llvm" => [:build, :no_linkage] + # ``` + # # `:recommended` dependencies are built by default. # But a `--without-...` option is generated to opt-out. # diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index ae81b445b57f0..6689945942a2c 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -829,7 +829,12 @@ def expand_dependencies_for_formula_once(formula, keepable_build_dependencies, k dep_formula = dep.to_formula next if keepable_build_dependencies&.exclude?(dep_formula.full_name) - next unless dep.satisfied?(minimum_compatibility_version: dep_formula.compatibility_version) + + if dep.no_linkage? + next if !dep_formula.opt_prefix.exist? || dep.missing_options.present? + else + next unless dep.satisfied?(minimum_compatibility_version: dep_formula.compatibility_version) + end runtime_dependencies = dep_formula.any_installed_keg&.runtime_dependencies next if runtime_dependencies.nil? diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 409973e7ce0d3..33fdc73f62d99 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -1107,6 +1107,22 @@ class Requested < Formula end describe "#expand_dependencies_for_formula" do + shared_examples "the old behaviour when building a bottle" do + before { allow(f).to receive(:deps).and_return([build_dep]) } + + it "upgrades it" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + context "with --build-from-source" do + let(:build_from_source_formulae) { [f.full_name] } + + it "upgrades it" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + end + end + it "checks equal dependency satisfaction once per expansion" do shared_formula = instance_double(Formula, deps: [], name: "shared", full_name: "shared") shared_dep = Dependency.new("shared") @@ -1190,7 +1206,8 @@ class Requested < Formula end end let(:build_bottle) { false } - let(:installer) { described_class.new(f, build_bottle:) } + let(:build_from_source_formulae) { [] } + let(:installer) { described_class.new(f, build_bottle:, build_from_source_formulae:) } before do build_dep_formula = formula build_dep.name do @@ -1241,12 +1258,97 @@ class Requested < Formula expect(installer.expand_dependencies_for_formula(f).map(&:name)).not_to include(other_build_dep.name) end + it "upgrades it with a different compatibility_version" do + allow(build_dep).to receive(:satisfied?).with(minimum_compatibility_version: 1).and_return(false) + allow(f).to receive(:deps).and_return([build_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "upgrades it if its install receipt has no runtime dependencies" do + keg = instance_double(Keg, runtime_dependencies: nil) + allow(build_dep.to_formula).to receive(:any_installed_keg).and_return(keg) + allow(f).to receive(:deps).and_return([build_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "upgrades an outdated runtime dependency with a matching compatibility_version" do + runtime_dep = Dependency.new(build_dep.name) + allow(runtime_dep).to receive(:satisfied?).and_return(false) + allow(runtime_dep).to receive(:satisfied?).with(minimum_compatibility_version: 1).and_return(true) + allow(f).to receive(:deps).and_return([runtime_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(runtime_dep.name) + end + context "when building a bottle" do let(:build_bottle) { true } - it "upgrades it" do - allow(f).to receive(:deps).and_return([build_dep]) + include_examples "the old behaviour when building a bottle" + + it "upgrades the dependencies it was built against" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(runtime_dep_formula.name) + end + end + end + + context "with an outdated :no_linkage build dependency with a different compatibility_version" do + let(:tags) { [:build, :no_linkage] } + let(:build_dep) { Dependency.new("homebrew-no-linkage-build-dependency", tags) } + let(:f) do + formula "homebrew-keep-no-linkage-build-dependency" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + end + let(:build_bottle) { false } + let(:build_from_source_formulae) { [] } + let(:installer) { described_class.new(f, build_bottle:, build_from_source_formulae:) } + + before do + build_dep_formula = formula build_dep.name do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + allow(build_dep_formula).to receive_messages( + compatibility_version: 2, + opt_prefix: instance_double(Pathname, exist?: true), + any_installed_keg: instance_double(Keg, runtime_dependencies: []), + ) + stub_formula_loader(build_dep_formula) + allow(build_dep).to receive_messages(satisfied?: false, missing_options: Options.new) + allow(f).to receive(:deps).and_return([build_dep]) + allow(installer).to receive_messages(effective_build_options_for: BuildOptions.new(Options.new, Options.new), + install_bottle_for?: false) + end + + it "keeps it" do + expect(installer.expand_dependencies_for_formula(f)).to be_empty + end + + it "upgrades it if it isn't opt-linked" do + allow(build_dep.to_formula).to receive(:opt_prefix).and_return(instance_double(Pathname, exist?: false)) + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "upgrades it if it's missing required options" do + allow(build_dep).to receive(:missing_options).and_return(Options.create(["--with-foo"])) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + context "when building a bottle" do + let(:build_bottle) { true } + + include_examples "the old behaviour when building a bottle" + end + + context "without :build" do + let(:tags) { [:no_linkage] } + + it "upgrades it" do expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) end end diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 50c346652becb..81ac8ba465c35 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -183,6 +183,7 @@ A `Hash` (e.g. `=>`) adds information to a dependency. Given a string or symbol, * `:test` means this is only required when running `brew test`. * `:no_linkage` means the formula does not link against this dependency; it does not change when the dependency is required. All explicit declarations of the same dependency within a spec and platform must agree on `:no_linkage`; stable, HEAD and different platforms may differ. + On a `:build` dependency, such as a compiler, it also lets an outdated installed version be used when building from source, whatever its `compatibility_version`. * `:optional` (not allowed in `Homebrew/homebrew-core`) generates an implicit `with-foo` option for the formula. This means that, given `depends_on "foo" => :optional`, the user must pass `--with-foo` to use the dependency. * `:recommended` (not allowed in `Homebrew/homebrew-core`) generates an implicit `without-foo` option, meaning that the dependency is enabled by default and the user must pass `--without-foo` to disable this dependency. The default description can be overridden using the [`option`](/rubydoc/Formula.html#option-class_method) syntax (in this case, the [`option` declaration](#adding-optional-steps) must precede the dependency):