From a9aa984cb81e8ec8ec577287b2b8bbd78c983fa0 Mon Sep 17 00:00:00 2001 From: Patrick Linnane Date: Wed, 14 Oct 2026 12:44:54 -0700 Subject: [PATCH] Preserve and validate no_linkage metadata - `Formula#to_hash` dropped `:no_linkage`, so formulae loaded from the API lost the linkage expectations `brew linkage` relies on. - Emit a `no_linkage_dependencies` name list beside the existing string arrays and overlay it when loading, keeping older readers working. - Reject conflicting `:no_linkage` declarations for one dependency within a spec and platform at load time, since the overlay is by name. Signed-off-by: Patrick Linnane --- .../api/formula/formula_struct_generator.rb | 12 +- Library/Homebrew/formula.rb | 21 ++++ .../formula/formula_struct_generator_spec.rb | 19 +++ Library/Homebrew/test/formula_spec.rb | 95 +++++++++++++++ Library/Homebrew/test/formulary_spec.rb | 114 ++++++++++++++++++ docs/Formula-Cookbook.md | 2 + 6 files changed, 261 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/api/formula/formula_struct_generator.rb b/Library/Homebrew/api/formula/formula_struct_generator.rb index 18fd69cf3d2b6..0c0219c3017ee 100644 --- a/Library/Homebrew/api/formula/formula_struct_generator.rb +++ b/Library/Homebrew/api/formula/formula_struct_generator.rb @@ -152,6 +152,7 @@ def generate_formula_struct_hash(hash, bottle_tag: Homebrew::SimulateSystem.curr "test_dependencies" => hash["test_dependencies"] || [], "recommended_dependencies" => hash["recommended_dependencies"] || [], "optional_dependencies" => hash["optional_dependencies"] || [], + "no_linkage_dependencies" => hash["no_linkage_dependencies"] || [], "uses_from_macos" => hash["uses_from_macos"] || [], "uses_from_macos_bounds" => hash["uses_from_macos_bounds"] || [], } @@ -245,10 +246,17 @@ def symbolize_dependency_hash(hash) sig { params(deps_hash: DependencyHash).returns(T::Array[FormulaStruct::DependsOnArgs]) } def process_dependencies(deps_hash) - dependencies = deps_hash.fetch("dependencies", []) + no_linkage_names = deps_hash.fetch("no_linkage_dependencies", []) + dependencies = deps_hash.fetch("dependencies", []).map do |dep| + next dep unless no_linkage_names.include?(dep) + + { dep => :no_linkage } + end dependencies + [:build, :test, :recommended, :optional].filter_map do |type| deps_hash["#{type}_dependencies"]&.map do |dep| - { dep => type } + next { dep => type } unless no_linkage_names.include?(dep) + + { dep => [type, :no_linkage] } end end.flatten(1) end diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 39a7b6d32edf8..fe09327c208a1 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -439,6 +439,13 @@ def spec_eval(name) return unless spec.url spec.owner = self + spec.declared_deps.reject(&:implicit?).group_by(&:name).each do |dep_name, declarations| + next if declarations.none?(&:no_linkage?) + next if declarations.all?(&:no_linkage?) + + raise FormulaSpecificationError, + "#{full_name}: conflicting :no_linkage declarations for #{dep_name.inspect} in the #{name} spec" + end add_global_deps_to_spec(spec) instance_variable_set(:"@#{name}", spec) end @@ -3135,6 +3142,7 @@ def to_hash "test_dependencies" => [], "recommended_dependencies" => [], "optional_dependencies" => [], + "no_linkage_dependencies" => [], "uses_from_macos" => [], "uses_from_macos_bounds" => [], "requirements" => serialized_requirements, @@ -3401,6 +3409,10 @@ def dependencies_hash .reject(&:uses_from_macos?) .map(&:name) .uniq + dep_hash["no_linkage_dependencies"] = spec_deps.select(&:no_linkage?) + .reject(&:uses_from_macos?) + .map(&:name) + .uniq uses_from_macos_deps = spec_deps.select(&:uses_from_macos?).uniq dep_hash["uses_from_macos"] = uses_from_macos_deps.map do |dep| @@ -4676,6 +4688,15 @@ def resource(name, klass = Resource, &block) # depends_on "node" => :test # ``` # + # `:no_linkage` declares that the formula does not link against this dependency. + # It does not change when the dependency is required. + # All declarations of the same dependency within a spec and platform must + # agree on `:no_linkage`; stable, HEAD and different platforms may differ. + # + # ```ruby + # depends_on "python@3.14" => :no_linkage + # ``` + # # `:recommended` dependencies are built by default. # But a `--without-...` option is generated to opt-out. # diff --git a/Library/Homebrew/test/api/formula/formula_struct_generator_spec.rb b/Library/Homebrew/test/api/formula/formula_struct_generator_spec.rb index d297186a6776d..ba8e95948b3b7 100644 --- a/Library/Homebrew/test/api/formula/formula_struct_generator_spec.rb +++ b/Library/Homebrew/test/api/formula/formula_struct_generator_spec.rb @@ -135,6 +135,17 @@ ).to eq [[], []] end + it "annotates no_linkage dependencies without duplicating them" do + deps_hash = { + "dependencies" => ["foo", "bar"], + "test_dependencies" => ["baz"], + "no_linkage_dependencies" => ["bar", "baz"], + } + + expect(described_class.process_dependencies_and_requirements(deps_hash, nil, :stable)) + .to eq [["foo", { "bar" => :no_linkage }, { "baz" => [:test, :no_linkage] }], []] + end + describe "::generate_formula_struct_hash" do subject(:struct) { described_class.generate_formula_struct_hash(hash) } @@ -161,6 +172,14 @@ expect(struct.head_dependencies).to eq struct.stable_dependencies end + it "carries no_linkage dependencies into stable and head dependencies" do + hash.update({ + "dependencies" => ["foo", "bar"], + "no_linkage_dependencies" => ["bar"], + }) + expect([struct.stable_dependencies, struct.head_dependencies]).to all(eq(["foo", { "bar" => :no_linkage }])) + end + it "preserves stable patches" do hash.update({ "patches" => [ diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index 69b4d7a3e329d..d51ca6b62b0d4 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -2105,6 +2105,101 @@ def service_name = "custom.override.name" expect(h["patches"]).to eq([]) end + specify "#to_hash dependencies" do + f = formula "foo" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + depends_on "bar" + depends_on "baz" => :no_linkage + head do + url "foo-HEAD" + depends_on "qux" => [:test, :no_linkage] + end + end + stub_formula_loader(f) + + expect(f.to_hash).to include( + "dependencies" => ["bar", "baz"], + "test_dependencies" => [], + "no_linkage_dependencies" => ["baz"], + "head_dependencies" => include("test_dependencies" => ["qux"], + "no_linkage_dependencies" => ["baz", "qux"]), + ) + end + + describe "dependency linkage declarations" do + test_each([[[], [:test, :no_linkage]], [[:no_linkage], [:test]], [[], [:no_linkage]]] + .flat_map { |tags| [tags, tags.reverse] }) do |(first_tags, second_tags)| + it "rejects conflicting #{first_tags} then #{second_tags} declarations" do + expect do + formula "foo" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + depends_on "bar" => first_tags + depends_on "bar" => second_tags + end + end.to raise_error(FormulaSpecificationError, + 'foo: conflicting :no_linkage declarations for "bar" in the stable spec') + end + end + + it "allows matching declarations across roles" do + f = formula "foo" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + depends_on "bar" => :no_linkage + depends_on "bar" => [:test, :no_linkage] + end + + expect(f.declared_deps.map(&:tags)).to eq([[:no_linkage], [:test, :no_linkage]]) + end + + it "ignores implicit dependencies when validating declarations" do + f = formula "foo" do + T.bind(self, T.class_of(Formula)) + stable do + url "foo-1.0" + dependency_collector.add(Dependency.new("bar", [:build, :implicit])) + depends_on "bar" => :no_linkage + end + end + + expect(f.declared_deps.map(&:tags)).to eq([[:build, :implicit], [:no_linkage]]) + end + + it "allows stable and HEAD to have different linkage declarations" do + f = formula "foo" do + T.bind(self, T.class_of(Formula)) + stable do + url "foo-1.0" + depends_on "bar" + end + head do + url "foo-HEAD" + depends_on "bar" => :no_linkage + end + end + + expect([f.stable&.declared_deps&.map(&:tags), f.head&.declared_deps&.map(&:tags)]) + .to eq([[[]], [[:no_linkage]]]) + end + + it "rejects conflicting HEAD declarations while loading stable" do + expect do + formula "foo" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + depends_on "bar" + head do + url "foo-HEAD" + depends_on "bar" => [:test, :no_linkage] + end + end + end.to raise_error(FormulaSpecificationError, + 'foo: conflicting :no_linkage declarations for "bar" in the head spec') + end + end + describe "#to_hash patches" do it "serialises an external patch" do f = formula "foo" do diff --git a/Library/Homebrew/test/formulary_spec.rb b/Library/Homebrew/test/formulary_spec.rb index 620c9d3c8fc5d..471c8ae055739 100644 --- a/Library/Homebrew/test/formulary_spec.rb +++ b/Library/Homebrew/test/formulary_spec.rb @@ -175,6 +175,86 @@ class SensitiveEnv < Formula end end + describe "dependency metadata round trip" do + let(:formula_name) { "no-linkage-round-trip" } + let(:formula_content) do + <<~RUBY + class NoLinkageRoundTrip < Formula + desc "Dependency metadata round trip" + homepage "https://example.com" + license "MIT" + url "https://example.com/no-linkage-round-trip-1.0.tar.gz" + depends_on "shared" => :no_linkage + + on_macos do + depends_on "platform" => :no_linkage + end + on_linux do + depends_on "platform" + end + + head do + url "https://example.com/no-linkage-round-trip.git", branch: "main" + depends_on "head-tool" => [:test, :no_linkage] + end + end + RUBY + end + let(:mac_tag) { Utils::Bottles::Tag.new(system: :sequoia, arch: :arm) } + let(:linux_tag) { Utils::Bottles::Tag.new(system: :linux, arch: :intel) } + let(:json) do + Homebrew::SimulateSystem.with_tag(mac_tag) do + JSON.parse(JSON.generate(described_class.factory(formula_path).to_hash_with_variations)) + end + end + + before do + stub_const("OnSystem::VALID_OS_ARCH_TAGS", [mac_tag, linux_tag]) + allow(CoreTap.instance).to receive(:git_head).and_return("0" * 40) + allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false) + formula_path.dirname.mkpath + formula_path.write formula_content + end + + test_each([:arm64_sequoia, :x86_64_linux].product([:stable, :head], + [:public, :internal])) do |(tag_symbol, spec, format)| + it "preserves #{spec} declarations through the #{format} API on #{tag_symbol}" do + payload = json + tag = (tag_symbol == :arm64_sequoia) ? mac_tag : linux_tag + Homebrew::SimulateSystem.with_tag(tag) do + source = described_class.factory(formula_path, spec) + restored = if format == :public + described_class::FormulaJSONContentsLoader.new(formula_name, payload).get_formula(spec) + else + struct = Homebrew::API::Formula::FormulaStructGenerator.generate_formula_struct_hash(payload, + bottle_tag: tag) + internal_json = JSON.parse(JSON.generate(struct.serialize(bottle_tag: tag))) + restored_struct = Homebrew::API::FormulaStruct.deserialize(internal_json, bottle_tag: tag) + klass = described_class.load_formula_from_struct!(formula_name, restored_struct, + api_source: internal_json, tap_git_head: "", + flags: [], internal_api: true) + klass.new(formula_name, formula_path, spec) + end + + expect(restored.declared_deps.map { |dep| [dep.name, dep.tags] }) + .to eq(source.declared_deps.map { |dep| [dep.name, dep.tags] }) + end + end + end + + it "writes an empty overlay when a variation removes the last no_linkage tag" do + formula_path.write(formula_content.sub('depends_on "shared" => :no_linkage', 'depends_on "shared"')) + + expect(json.fetch("variations").fetch("x86_64_linux").fetch("no_linkage_dependencies")).to eq([]) + end + + it "rejects conflicting declarations in another platform's variation" do + formula_path.write(formula_content.sub(/depends_on "platform"$/, 'depends_on "shared"')) + + expect { json }.to raise_error(FormulaSpecificationError, /conflicting :no_linkage declarations for "shared"/) + end + end + describe "::factory" do context "without the API", :no_api do before do @@ -682,6 +762,16 @@ def formula_json_contents(extra_items = {}) } end + let(:no_linkage_variations_json) do + { + "variations" => { + Homebrew::SimulateSystem.current_tag.to_s => { + "no_linkage_dependencies" => ["dep"], + }, + }, + } + end + let(:older_macos_variations_json) do { "variations" => { @@ -774,6 +864,30 @@ def formula_json_contents(extra_items = {}) expect(formula.loaded_from_internal_api?).to be true end + it "returns a Formula with no_linkage dependencies" do + allow(Homebrew::API::Formula).to receive(:all_formulae) + .and_return formula_json_contents("no_linkage_dependencies" => ["dep"]) + + formula = described_class.factory(formula_name) + expect(formula.deps.find { |dep| dep.name == "dep" }).to be_no_linkage + end + + it "returns a Formula with no_linkage uses_from_macos dependencies" do + allow(Homebrew::API::Formula).to receive(:all_formulae) + .and_return formula_json_contents("uses_from_macos" => [{ "uses_from_macos_dep" => "no_linkage" }]) + + formula = described_class.factory(formula_name) + expect(formula.declared_deps.find { |dep| dep.name == "uses_from_macos_dep" }).to be_no_linkage + end + + it "returns a Formula with no_linkage dependencies from variations" do + allow(Homebrew::API::Formula).to receive(:all_formulae) + .and_return formula_json_contents(no_linkage_variations_json) + + formula = described_class.factory(formula_name) + expect(formula.deps.find { |dep| dep.name == "dep" }).to be_no_linkage + end + it "runs post-install steps loaded from the internal API without source Ruby" do step = { "type" => "warn", "message" => "loaded from internal API" } allow(Homebrew::API::Formula).to receive(:all_formulae) diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 90799d2e8d647..50c346652becb 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -181,6 +181,8 @@ A `Hash` (e.g. `=>`) adds information to a dependency. Given a string or symbol, * `:build` means this is a build-time only dependency so it can be skipped when installing from a bottle or when listing missing dependencies using `brew missing`. * `: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. * `: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):