diff --git a/Library/Homebrew/tap_auditor.rb b/Library/Homebrew/tap_auditor.rb index 7a5063546c5fa..2211b9a71307a 100644 --- a/Library/Homebrew/tap_auditor.rb +++ b/Library/Homebrew/tap_auditor.rb @@ -4,7 +4,7 @@ module Homebrew # Auditor for checking common violations in {Tap}s. class TapAuditor - attr_reader :name, :path, :formula_names, :formula_aliases, :formula_renames, :cask_tokens, + attr_reader :name, :path, :formula_names, :formula_aliases, :formula_renames, :cask_tokens, :cask_renames, :tap_audit_exceptions, :tap_style_exceptions, :problems sig { params(tap: Tap, strict: T.nilable(T::Boolean)).void } @@ -27,6 +27,7 @@ def initialize(tap, strict:) formula_alias.split("/").last end @formula_renames = tap.formula_renames + @cask_renames = tap.cask_renames @formula_names = tap.formula_names.map do |formula_name| formula_name.split("/").last end @@ -54,7 +55,8 @@ def audit_json_files def audit_tap_formula_lists check_formula_list_directory "audit_exceptions", @tap_audit_exceptions check_formula_list_directory "style_exceptions", @tap_style_exceptions - check_formula_list "formula_renames", @formula_renames.values + check_renames "formula_renames.json", @formula_renames, @formula_names, @formula_aliases + check_renames "cask_renames.json", @cask_renames, @cask_tokens check_formula_list ".github/autobump.txt", @tap_autobump unless @tap_official check_formula_list "synced_versions_formulae", @tap_synced_versions_formulae.flatten end @@ -107,5 +109,78 @@ def check_formula_list_directory(directory_name, lists) check_formula_list "#{directory_name}/#{list_name}", list end end + + sig { + params(list_file: String, renames_hash: T::Hash[String, String], valid_tokens: T::Array[String], + valid_aliases: T::Array[String]).void + } + def check_renames(list_file, renames_hash, valid_tokens, valid_aliases = []) + item_type = list_file.include?("cask") ? "casks" : "formulae" + + # Collect all validation issues in a single pass + invalid_format_entries = [] + invalid_targets = [] + chained_rename_suggestions = [] + conflicts = [] + + renames_hash.each do |old_name, new_name| + # Check for .rb extensions + if old_name.end_with?(".rb") || new_name.end_with?(".rb") + invalid_format_entries << "\"#{old_name}\": \"#{new_name}\"" + end + + # Check that new name exists + if valid_tokens.exclude?(new_name) && valid_aliases.exclude?(new_name) && !renames_hash.key?(new_name) + invalid_targets << new_name + end + + # Check for chained renames and follow to final target + if renames_hash.key?(new_name) + final = new_name + seen = Set.new([old_name, new_name]) + while renames_hash.key?(final) + next_name = renames_hash[final] + break if next_name.nil? || seen.include?(next_name) + + final = next_name + seen << final + end + chained_rename_suggestions << " \"#{old_name}\": \"#{final}\" (instead of chained rename)" + end + + # Check for conflicts + conflicts << old_name if valid_tokens.include?(old_name) + end + + if invalid_format_entries.any? + problem <<~EOS + #{list_file} contains entries with '.rb' file extensions. + Rename entries should use formula/cask names only, without '.rb' extensions. + Invalid entries: #{invalid_format_entries.join(", ")} + EOS + end + + if invalid_targets.any? + problem <<~EOS + #{list_file} contains renames to #{item_type} that do not exist in the #{@name} tap. + Invalid targets: #{invalid_targets.join(", ")} + EOS + end + + if chained_rename_suggestions.any? + problem <<~EOS + #{list_file} contains chained renames that should be collapsed. + Chained renames don't work automatically; each old name should point directly to the final target: + #{chained_rename_suggestions.join("\n")} + EOS + end + + return if conflicts.none? + + problem <<~EOS + #{list_file} contains old names that conflict with existing #{item_type} in the #{@name} tap. + Renames only work after the old #{item_type} are deleted. Conflicting names: #{conflicts.join(", ")} + EOS + end end end diff --git a/Library/Homebrew/test/tap_auditor_spec.rb b/Library/Homebrew/test/tap_auditor_spec.rb new file mode 100644 index 0000000000000..0b45b339b2f0b --- /dev/null +++ b/Library/Homebrew/test/tap_auditor_spec.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require "tap_auditor" + +RSpec.describe Homebrew::TapAuditor do + let(:tap) { Tap.fetch("homebrew", "foo") } + let(:tap_path) { tap.path } + let(:auditor) { described_class.new(tap, strict: false) } + + def write_cask(token, path = tap_path/"Casks"/"#{token}.rb") + path.dirname.mkpath + path.write <<~RUBY + cask "#{token}" do + version "1.0" + url "https://brew.sh/#{token}-1.0.dmg" + name "#{token.capitalize} Cask" + homepage "https://brew.sh" + end + RUBY + end + + def write_formula(name, path = tap_path/"Formula"/"#{name}.rb") + path.dirname.mkpath + path.write <<~RUBY + class #{name.capitalize} < Formula + url "https://brew.sh/#{name}-1.0.tar.gz" + version "1.0" + end + RUBY + end + + before do + tap_path.mkpath + tap.clear_cache + end + + describe "#audit" do + subject(:problems) do + auditor.audit + auditor.problems + end + + context "with cask_renames.json" do + let(:cask_renames_path) { tap_path/"cask_renames.json" } + let(:renames_data) { {} } + + before do + cask_renames_path.write JSON.pretty_generate(renames_data) + end + + context "when .rb extension in old cask name (key)" do + let(:renames_data) { { "oldcask.rb" => "newcask" } } + + before do + write_cask("newcask") + end + + it "detects the invalid format" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + cask_renames.json contains entries with '.rb' file extensions. + Rename entries should use formula/cask names only, without '.rb' extensions. + Invalid entries: "oldcask.rb": "newcask" + EOS + ) + end + end + + context "when .rb extension in new cask name (value)" do + let(:renames_data) { { "oldcask" => "newcask.rb" } } + + before do + write_cask("newcask") + end + + it "detects the invalid format" do + expect(problems.count).to eq(2) + + invalid_format_problem = problems.find do |p| + p[:message].include?("entries with '.rb' file extensions") + end + expect(invalid_format_problem[:message]).to eq( + <<~EOS, + cask_renames.json contains entries with '.rb' file extensions. + Rename entries should use formula/cask names only, without '.rb' extensions. + Invalid entries: "oldcask": "newcask.rb" + EOS + ) + + invalid_target_problem = problems.find do |p| + p[:message].include?("Invalid targets") + end + expect(invalid_target_problem[:message]).to eq( + <<~EOS, + cask_renames.json contains renames to casks that do not exist in the homebrew/foo tap. + Invalid targets: newcask.rb + EOS + ) + end + end + + context "when missing target cask" do + let(:renames_data) { { "oldcask" => "nonexistent" } } + + it "detects the missing target" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + cask_renames.json contains renames to casks that do not exist in the homebrew/foo tap. + Invalid targets: nonexistent + EOS + ) + end + end + + context "with chained renames" do + let(:renames_data) do + { + "oldcask" => "newcask", + "newcask" => "finalcask", + } + end + + before do + write_cask("finalcask") + end + + it "detects the chained renames" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + cask_renames.json contains chained renames that should be collapsed. + Chained renames don't work automatically; each old name should point directly to the final target: + "oldcask": "finalcask" (instead of chained rename) + EOS + ) + end + end + + context "with multi-level chained renames" do + let(:renames_data) do + { + "oldcask" => "newcask", + "newcask" => "intermediatecask", + "intermediatecask" => "finalcask", + } + end + + before do + write_cask("intermediatecask") + write_cask("finalcask") + end + + it "suggests final target" do + expect(problems.count).to eq(2) + + chained_problem = problems.find { |p| p[:message].include?("chained renames") } + expect(chained_problem[:message]).to eq( + <<~EOS, + cask_renames.json contains chained renames that should be collapsed. + Chained renames don't work automatically; each old name should point directly to the final target: + "oldcask": "finalcask" (instead of chained rename) + "newcask": "finalcask" (instead of chained rename) + EOS + ) + + conflict_problem = problems.find { |p| p[:message].include?("conflict") } + expect(conflict_problem[:message]).to eq( + <<~EOS, + cask_renames.json contains old names that conflict with existing casks in the homebrew/foo tap. + Renames only work after the old casks are deleted. Conflicting names: intermediatecask + EOS + ) + end + end + + context "with chained renames where intermediates don't exist" do + let(:renames_data) do + { + "veryoldcask" => "intermediatecask", + "intermediatecask" => "finalcask", + } + end + + before do + write_cask("finalcask") + end + + it "reports chained rename error, not invalid target error" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + cask_renames.json contains chained renames that should be collapsed. + Chained renames don't work automatically; each old name should point directly to the final target: + "veryoldcask": "finalcask" (instead of chained rename) + EOS + ) + end + end + + context "when old name conflicts with existing cask" do + let(:renames_data) { { "newcask" => "anothercask" } } + + before do + write_cask("newcask") + write_cask("anothercask") + end + + it "detects the conflict" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + cask_renames.json contains old names that conflict with existing casks in the homebrew/foo tap. + Renames only work after the old casks are deleted. Conflicting names: newcask + EOS + ) + end + end + + context "with correct rename entries" do + let(:renames_data) { { "oldcask" => "newcask" } } + + before do + write_cask("newcask") + end + + it "passes validation" do + rename_problems = problems.select { |p| p[:message].include?("cask_renames") } + expect(rename_problems).to be_empty + end + end + end + + context "with formula_renames.json" do + let(:formula_renames_path) { tap_path/"formula_renames.json" } + let(:renames_data) { {} } + + before do + formula_renames_path.write JSON.pretty_generate(renames_data) + end + + context "when .rb extension in formula rename keys" do + let(:renames_data) { { "oldformula.rb" => "newformula" } } + + before do + write_formula("newformula") + end + + it "detects the invalid format" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + formula_renames.json contains entries with '.rb' file extensions. + Rename entries should use formula/cask names only, without '.rb' extensions. + Invalid entries: "oldformula.rb": "newformula" + EOS + ) + end + end + + context "with chained formula renames" do + let(:renames_data) do + { + "oldformula" => "newformula", + "newformula" => "finalformula", + } + end + + before do + write_formula("finalformula") + end + + it "detects the chained renames" do + expect(problems.count).to eq(1) + expect(problems.first[:message]).to eq( + <<~EOS, + formula_renames.json contains chained renames that should be collapsed. + Chained renames don't work automatically; each old name should point directly to the final target: + "oldformula": "finalformula" (instead of chained rename) + EOS + ) + end + end + + context "with correct formula rename entries" do + let(:renames_data) { { "oldformula" => "newformula" } } + + before do + write_formula("newformula") + end + + it "passes validation" do + rename_problems = problems.select { |p| p[:message].include?("formula_renames") } + expect(rename_problems).to be_empty + end + end + end + end +end diff --git a/docs/Rename-A-Formula.md b/docs/Rename-A-Formula.md index 4c7089565a11a..bac3595f3184d 100644 --- a/docs/Rename-A-Formula.md +++ b/docs/Rename-A-Formula.md @@ -2,7 +2,9 @@ last_review_date: "1970-01-01" --- -# Renaming a Formula +# Renaming a Formula or Cask + +## Renaming a Formula Sometimes software and formulae need to be renamed. To rename a formula you need to: @@ -17,3 +19,18 @@ A `formula_renames.json` example for a formula rename: "ack": "newack" } ``` + +## Renaming a Cask + +To rename a cask, follow a similar process: + +1. Rename the cask file and update the cask stanza to use the new cask token. The new token must meet all the usual rules of cask naming. +2. Create a pull request on the corresponding tap deleting the old cask file, adding the new cask file, and adding it to `cask_renames.json` with a commit message like `new-token: renamed from old-token`. + +A `cask_renames.json` example: + +```json +{ + "old-token": "new-token" +} +```