From 15648fe83e8847e3ff510e4452a2d81beb805a65 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Mon, 22 Jun 2026 12:59:42 +0200 Subject: [PATCH] Warn about missing libraries in brew info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a dependency's soname bumps and the old dylib is removed, an installed formula's binaries can be left linking against a library that no longer exists, aborting at load time with a dyld error. Surface this in the `brew info` Dependencies section for installed formulae as a "Missing libraries" line, marking each library with a red ✘ like other dependency statuses. The fix suggestion is `brew upgrade` when the formula is outdated (which also relinks it) and `brew reinstall` otherwise. The libraries are read from the existing LinkageChecker, combining broken dependencies (dylibs under opt//) and broken dylibs that map to no dependency. --- Library/Homebrew/cmd/info.rb | 25 +++++++++++++---- Library/Homebrew/formula.rb | 14 ++++++++++ Library/Homebrew/linkage_checker.rb | 5 +++- Library/Homebrew/test/cmd/info_spec.rb | 38 +++++++++++++------------- Library/Homebrew/test/formula_spec.rb | 38 ++++++++++++++++++++++++++ Library/Homebrew/utils/output.rb | 38 ++++++++++++++++++-------- 6 files changed, 121 insertions(+), 37 deletions(-) diff --git a/Library/Homebrew/cmd/info.rb b/Library/Homebrew/cmd/info.rb index 14137404063c8..ee13f051bfcf9 100644 --- a/Library/Homebrew/cmd/info.rb +++ b/Library/Homebrew/cmd/info.rb @@ -642,6 +642,9 @@ def info_formula(formula, shadowed_by: nil) kegs = shadowing_formula ? [] : formula.installed_kegs installed = kegs.any? outdated = installed && formula.outdated? + missing_libraries, missing_library_deps = formula.missing_library_linkage if installed + missing_libraries ||= [] + missing_library_deps ||= Set.new if outdated && (upgrade_version = specs.first.presence) installed_version = formula.linked_version || kegs.max_by(&:scheme_and_version)&.version @@ -656,6 +659,7 @@ def info_formula(formula, shadowed_by: nil) end name_with_status = pretty_install_status( title_name, + warning: missing_libraries.present?, installed:, outdated:, deprecated: formula.deprecated?, @@ -753,11 +757,17 @@ def info_formula(formula, shadowed_by: nil) tab_deps = (kegs.any? && type != "build") ? tab_runtime_deps : nil "#{type.capitalize} (#{deps.count}): " \ - "#{decorate_dependencies(deps, tab_runtime_deps: tab_deps, mark_uninstalled: kegs.any?)}" + "#{decorate_dependencies(deps, tab_runtime_deps: tab_deps, mark_uninstalled: kegs.any?, + missing_library_deps:)}" end if dependency_lines.present? || tab_runtime_deps.present? || installed_dependents.any? ohai "Dependencies" puts dependency_lines + missing_library_names = missing_libraries.map { |lib| File.basename(lib) }.uniq + if missing_library_names.present? + decorated = missing_library_names.map { |lib| pretty_uninstalled(lib, bold: false) }.join(", ") + puts "Missing libraries (#{missing_library_names.count}): #{decorated}" + end if tab_runtime_deps.present? installed_count = tab_runtime_deps.count do |dep| dep_name = dep["full_name"]&.then { Utils.name_from_full_name(it) } @@ -869,11 +879,13 @@ def installed_section_lines(formula, verbose: false) end sig { - params(dependencies: T::Array[Dependency], - tab_runtime_deps: T.nilable(T::Array[T::Hash[String, T.untyped]]), - mark_uninstalled: T::Boolean).returns(String) + params(dependencies: T::Array[Dependency], + tab_runtime_deps: T.nilable(T::Array[T::Hash[String, T.untyped]]), + mark_uninstalled: T::Boolean, + missing_library_deps: T::Set[String]).returns(String) } - def decorate_dependencies(dependencies, tab_runtime_deps: nil, mark_uninstalled: true) + def decorate_dependencies(dependencies, tab_runtime_deps: nil, mark_uninstalled: true, + missing_library_deps: Set.new) dependencies.map do |dep| display = dep_display_s(dep) full_name = tab_runtime_deps&.find do |d| @@ -889,7 +901,8 @@ def decorate_dependencies(dependencies, tab_runtime_deps: nil, mark_uninstalled: end installed ||= formula.any_version_installed? if !installed && formula outdated = T.let(installed && formula&.outdated? == true, T::Boolean) - pretty_install_status(display, installed:, outdated:, mark_uninstalled:) + warning = missing_library_deps.include?(Utils.name_from_full_name(dep.name)) + pretty_install_status(display, warning:, installed:, outdated:, mark_uninstalled:) end.join(", ") end diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index b8f744fcc54dd..396d6126ab36e 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -3441,6 +3441,20 @@ def undeclared_runtime_dependencies public + sig { returns([T::Array[String], T::Set[String]]) } + def missing_library_linkage + keg = any_installed_keg + return [[], Set.new] unless keg&.directory? + + CacheStoreDatabase.use(:linkage) do |db| + typed_db = T.cast(db, CacheStoreDatabase[String, T::Hash[T.any(String, Symbol), T.anything]]) + linkage_checker = LinkageChecker.new(keg, self, cache_db: typed_db) + own_libraries = (linkage_checker.broken_deps.fetch(name, []) + linkage_checker.broken_dylibs.to_a).uniq.sort + dependency_names = linkage_checker.broken_deps.keys.reject { |dep| dep == name }.to_set + [own_libraries, dependency_names] + end + end + # To call out to the system, we use the `system` method and we prefer # you give the args separately as in the line below, otherwise a subshell # has to be opened first. diff --git a/Library/Homebrew/linkage_checker.rb b/Library/Homebrew/linkage_checker.rb index 6e08488d1d3d7..e59aec6a05a55 100644 --- a/Library/Homebrew/linkage_checker.rb +++ b/Library/Homebrew/linkage_checker.rb @@ -23,7 +23,10 @@ class LinkageChecker attr_reader :indirect_deps, :undeclared_deps, :unwanted_system_dylibs sig { returns(T::Set[String]) } - attr_reader :system_dylibs + attr_reader :system_dylibs, :broken_dylibs + + sig { returns(T::Hash[String, T::Array[String]]) } + attr_reader :broken_deps sig { params( diff --git a/Library/Homebrew/test/cmd/info_spec.rb b/Library/Homebrew/test/cmd/info_spec.rb index 757f8e855b860..b032e72f15a17 100644 --- a/Library/Homebrew/test/cmd/info_spec.rb +++ b/Library/Homebrew/test/cmd/info_spec.rb @@ -284,7 +284,7 @@ def installed_info_cask option "with-foo", "Build with foo" end allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Installs from source: yes/).to_stdout @@ -332,7 +332,7 @@ def installed_info_cask deprecate! date: "2024-01-01", because: :versioned_formula end allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/==> .*testball.*\(deprecated\):/).to_stdout @@ -350,7 +350,7 @@ def installed_info_cask disable! date: "2024-01-01", because: :unmaintained end allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/==> .*testball.*\(disabled\):/).to_stdout @@ -475,7 +475,7 @@ def installed_info_cask url "https://brew.sh/testball-0.1.tar.gz" end allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula, shadowed_by: Tap.fetch("homebrew/core")) } .to output(%r{Warning: `testball` shadows `homebrew/core/testball`}).to_stdout @@ -595,7 +595,7 @@ def installed_info_cask dependent_tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) allow(direct_dependency).to receive(:satisfied?).and_return(true) expected_output = Regexp.new( @@ -637,7 +637,7 @@ def installed_info_cask end allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/^Dependents \(2\): another-dependent, some-dependent$/).to_stdout @@ -673,7 +673,7 @@ def installed_info_cask installed_dep_tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) allow(direct_dependency).to receive(:satisfied?).and_return(true) expect { info.send(:info_formula, formula) } @@ -702,7 +702,7 @@ def installed_info_cask tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*✘/).to_stdout @@ -739,7 +739,7 @@ def installed_info_cask allow(direct_dependency).to receive(:to_formula).and_return(bar_formula) allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*✔/).to_stdout @@ -776,7 +776,7 @@ def installed_info_cask allow(direct_dependency).to receive(:to_formula).and_return(bar_formula) allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*↑/).to_stdout @@ -806,7 +806,7 @@ def installed_info_cask allow(direct_dependency).to receive(:to_formula).and_return(bar_formula) allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*✔/).to_stdout @@ -836,7 +836,7 @@ def installed_info_cask allow(direct_dependency).to receive(:to_formula).and_return(bar_formula) allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*↑/).to_stdout @@ -866,7 +866,7 @@ def installed_info_cask allow(direct_dependency).to receive(:to_formula).and_return(pkgconf_formula) allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*pkg-config.*✔/).to_stdout @@ -886,7 +886,7 @@ def installed_info_cask end allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): bar\n/).to_stdout @@ -913,7 +913,7 @@ def installed_info_cask tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*✘/).to_stdout @@ -946,7 +946,7 @@ def installed_info_cask bar_tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(/Required \(1\): .*bar.*↑/).to_stdout @@ -1071,7 +1071,7 @@ def installed_info_cask tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to output(a_string_including("==> Binaries\nanother\ndaemon\ntestball\n")).to_stdout @@ -1121,7 +1121,7 @@ def installed_info_cask tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to not_to_output(/==> Binaries/).to_stdout @@ -1144,7 +1144,7 @@ def installed_info_cask tab.write allow(info).to receive(:github_info).with(formula).and_return("https://example.com/testball.rb") - allow(formula).to receive(:core_formula?).and_return(false) + allow(formula).to receive_messages(core_formula?: false, missing_library_linkage: [[], Set.new]) expect { info.send(:info_formula, formula) } .to not_to_output(/==> Binaries/).to_stdout diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index 3c2ae670d4438..0d6cf80ca2f73 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -1625,6 +1625,44 @@ def post_install; end end end + describe "#missing_library_linkage" do + let(:f) do + formula("foo") do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + end + + it "returns empty when no keg is installed" do + allow(f).to receive(:any_installed_keg).and_return(nil) + expect(f.missing_library_linkage).to eq([[], Set.new]) + end + + it "returns only the formula's own and orphan libraries, excluding dependency-owned ones" do + keg = instance_double(Keg, directory?: true) + allow(f).to receive(:any_installed_keg).and_return(keg) + linkage_checker = instance_double( + LinkageChecker, + broken_deps: { "foo" => ["libfoo.1.dylib"], "gmp" => ["libgmp.10.dylib"] }, + broken_dylibs: Set["liborphan.2.dylib"], + ) + allow(LinkageChecker).to receive(:new).and_return(linkage_checker) + expect(f.missing_library_linkage.first).to eq(["libfoo.1.dylib", "liborphan.2.dylib"]) + end + + it "returns the dependency names that own missing libraries, excluding the formula itself" do + keg = instance_double(Keg, directory?: true) + allow(f).to receive(:any_installed_keg).and_return(keg) + linkage_checker = instance_double( + LinkageChecker, + broken_deps: { "foo" => ["libfoo.1.dylib"], "gmp" => ["libgmp.10.dylib"] }, + broken_dylibs: Set.new, + ) + allow(LinkageChecker).to receive(:new).and_return(linkage_checker) + expect(f.missing_library_linkage.last).to eq(Set["gmp"]) + end + end + specify "requirements" do # don't try to load/fetch gcc/glibc allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false) diff --git a/Library/Homebrew/utils/output.rb b/Library/Homebrew/utils/output.rb index 1ab1aba42aa1f..5c8507f9c53bf 100644 --- a/Library/Homebrew/utils/output.rb +++ b/Library/Homebrew/utils/output.rb @@ -264,7 +264,7 @@ def pretty_installed(string) sig { params(string: String, bold: T::Boolean).returns(String) } def pretty_upgradable(string, bold: true) - weight = bold ? Tty.bold : "" + weight = bold ? Tty.bold.to_s : "" if !$stdout.tty? string elsif Homebrew::EnvConfig.no_emoji? @@ -294,29 +294,45 @@ def pretty_disabled(string) # Keep status labels, colours and emoji in sync with # `pretty_uninstalled` in Library/Homebrew/utils.sh. - sig { params(string: String).returns(String) } - def pretty_uninstalled(string) + sig { params(string: String, bold: T::Boolean).returns(String) } + def pretty_uninstalled(string, bold: true) + weight = bold ? Tty.bold.to_s : "" + if !$stdout.tty? + string + elsif Homebrew::EnvConfig.no_emoji? + Formatter.error("#{weight}#{string} (uninstalled)#{Tty.reset}") + else + "#{weight}#{string} #{Formatter.error("✘")}#{Tty.reset}" + end + end + + sig { params(string: String, bold: T::Boolean).returns(String) } + def pretty_warning(string, bold: true) + weight = bold ? Tty.bold.to_s : "" if !$stdout.tty? string elsif Homebrew::EnvConfig.no_emoji? - Formatter.error("#{Tty.bold}#{string} (uninstalled)#{Tty.reset}") + Formatter.warning("#{weight}#{string} (warning)#{Tty.reset}") else - "#{Tty.bold}#{string} #{Formatter.error("✘")}#{Tty.reset}" + "#{weight}#{string} #{Formatter.warning("⚠")}#{Tty.reset}" end end sig { - params(string: String, installed: T::Boolean, outdated: T::Boolean, deprecated: T::Boolean, - disabled: T::Boolean, mark_uninstalled: T::Boolean, bold: T::Boolean).returns(String) + params(string: String, installed: T::Boolean, warning: T::Boolean, outdated: T::Boolean, + deprecated: T::Boolean, disabled: T::Boolean, mark_uninstalled: T::Boolean, + bold: T::Boolean).returns(String) } - def pretty_install_status(string, installed:, outdated: false, deprecated: false, disabled: false, - mark_uninstalled: true, bold: true) - status = if installed && outdated + def pretty_install_status(string, installed:, warning: false, outdated: false, deprecated: false, + disabled: false, mark_uninstalled: true, bold: true) + status = if warning + pretty_warning(string, bold:) + elsif installed && outdated pretty_upgradable(string, bold:) elsif installed pretty_installed(string) elsif mark_uninstalled - pretty_uninstalled(string) + pretty_uninstalled(string, bold:) else string end