diff --git a/app/controllers/admin/backups_controller.rb b/app/controllers/admin/backups_controller.rb
index 2660c9895e8..fd429312e73 100644
--- a/app/controllers/admin/backups_controller.rb
+++ b/app/controllers/admin/backups_controller.rb
@@ -238,11 +238,11 @@ class Admin::BackupsController < Admin::AdminController
   end
 
   def valid_extension?(filename)
-    /\.(tar\.gz|t?gz)$/i =~ filename
+    /\.(tar\.gz|t?gz)\z/i =~ filename
   end
 
   def valid_filename?(filename)
-    !!(/^[a-zA-Z0-9\._\-]+$/ =~ filename)
+    !!(/\A[a-zA-Z0-9\._\-]+\z/ =~ filename)
   end
 
   def render_error(message_key)
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index abc8b9e83e2..ffa8463a9c4 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -7,9 +7,9 @@ class Admin::ReportsController < Admin::StaffController
         ApplicationRequest
           .req_types
           .keys
-          .select { |r| r =~ /^page_view_/ && r !~ /mobile/ }
+          .select { |r| r =~ /\Apage_view_/ && r !~ /mobile/ }
           .map { |r| r + "_reqs" } +
-        Report.singleton_methods.grep(/^report_(?!about|storage_stats)/)
+        Report.singleton_methods.grep(/\Areport_(?!about|storage_stats)/)
 
     reports =
       reports_methods.map do |name|
@@ -61,7 +61,7 @@ class Admin::ReportsController < Admin::StaffController
   def show
     report_type = params[:type]
 
-    raise Discourse::NotFound unless report_type =~ /^[a-z0-9\_]+$/
+    raise Discourse::NotFound unless report_type =~ /\A[a-z0-9\_]+\z/
 
     args = parse_params(params)
 
diff --git a/app/controllers/admin/site_texts_controller.rb b/app/controllers/admin/site_texts_controller.rb
index 041c59128c8..a92065399f9 100644
--- a/app/controllers/admin/site_texts_controller.rb
+++ b/app/controllers/admin/site_texts_controller.rb
@@ -160,7 +160,7 @@ class Admin::SiteTextsController < Admin::AdminController
     { id: key, value: value, locale: locale }
   end
 
-  PLURALIZED_REGEX = /(.*)\.(zero|one|two|few|many|other)$/
+  PLURALIZED_REGEX = /(.*)\.(zero|one|two|few|many|other)\z/
 
   def find_site_text(locale)
     if self.class.restricted_keys.include?(params[:id])
diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb
index bf8ebb27d94..c25c2c17677 100644
--- a/app/controllers/admin/themes_controller.rb
+++ b/app/controllers/admin/themes_controller.rb
@@ -108,7 +108,7 @@ class Admin::ThemesController < Admin::AdminController
           render json: @theme, status: :created
         rescue RemoteTheme::ImportError => e
           if params[:force]
-            theme_name = params[:remote].gsub(/.git$/, "").split("/").last
+            theme_name = params[:remote].gsub(/.git\z/, "").split("/").last
 
             remote_theme = RemoteTheme.new
             remote_theme.private_key = private_key
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7302867e75b..770415b952e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -679,7 +679,7 @@ class ApplicationController < ActionController::Base
 
     DiscoursePluginRegistry.html_builders.each do |name, _|
       if name.start_with?("client:")
-        data[name.sub(/^client:/, "")] = DiscoursePluginRegistry.build_html(name, self)
+        data[name.sub(/\Aclient:/, "")] = DiscoursePluginRegistry.build_html(name, self)
       end
     end
 
diff --git a/app/controllers/embed_controller.rb b/app/controllers/embed_controller.rb
index 8ff1f870c0c..f7939812207 100644
--- a/app/controllers/embed_controller.rb
+++ b/app/controllers/embed_controller.rb
@@ -28,11 +28,11 @@ class EmbedController < ApplicationController
     end
 
     if @embed_id = params[:discourse_embed_id]
-      raise Discourse::InvalidParameters.new(:embed_id) unless @embed_id =~ /^de\-[a-zA-Z0-9]+$/
+      raise Discourse::InvalidParameters.new(:embed_id) unless @embed_id =~ /\Ade\-[a-zA-Z0-9]+\z/
     end
 
     if @embed_class = params[:embed_class]
-      unless @embed_class =~ /^[a-zA-Z0-9\-_]+$/
+      unless @embed_class =~ /\A[a-zA-Z0-9\-_]+\z/
         raise Discourse::InvalidParameters.new(:embed_class)
       end
     end
@@ -139,7 +139,7 @@ class EmbedController < ApplicationController
     by_url = {}
 
     if embed_urls.present?
-      urls = embed_urls.map { |u| u.sub(/#discourse-comments$/, "").sub(%r{/$}, "") }
+      urls = embed_urls.map { |u| u.sub(/#discourse-comments\z/, "").sub(%r{/\z}, "") }
       topic_embeds = TopicEmbed.where(embed_url: urls).includes(:topic).references(:topic)
 
       topic_embeds.each do |te|
diff --git a/app/controllers/extra_locales_controller.rb b/app/controllers/extra_locales_controller.rb
index 63fdb5d81c9..a6c04bee718 100644
--- a/app/controllers/extra_locales_controller.rb
+++ b/app/controllers/extra_locales_controller.rb
@@ -71,6 +71,6 @@ class ExtraLocalesController < ApplicationController
   private
 
   def valid_bundle?(bundle)
-    bundle == OVERRIDES_BUNDLE || (bundle =~ /^(admin|wizard)$/ && current_user&.staff?)
+    bundle == OVERRIDES_BUNDLE || (bundle =~ /\A(admin|wizard)\z/ && current_user&.staff?)
   end
 end
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index a11b1bc7a8f..586ed5dffda 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -203,7 +203,7 @@ class SessionController < ApplicationController
         end
 
         # If it's not a relative URL check the host
-        if return_path !~ %r{^/[^/]}
+        if return_path !~ %r{\A/[^/]}
           begin
             uri = URI(return_path)
             if (uri.hostname == Discourse.current_hostname)
diff --git a/app/controllers/theme_javascripts_controller.rb b/app/controllers/theme_javascripts_controller.rb
index 63d6484d712..159402889fe 100644
--- a/app/controllers/theme_javascripts_controller.rb
+++ b/app/controllers/theme_javascripts_controller.rb
@@ -47,7 +47,7 @@ class ThemeJavascriptsController < ApplicationController
 
   def show_tests
     digest = params[:digest]
-    raise Discourse::NotFound if !digest.match?(/^\h{40}$/)
+    raise Discourse::NotFound if !digest.match?(/\A\h{40}\z/)
 
     theme = Theme.find_by(id: params[:theme_id])
     raise Discourse::NotFound if theme.blank?
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index c2a54dc9ff6..7b01ff55e0b 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -83,7 +83,7 @@ class TopicsController < ApplicationController
 
     # Special case: a slug with a number in front should look by slug first before looking
     # up that particular number
-    if params[:id] && params[:id] =~ /^\d+[^\d\\]+$/
+    if params[:id] && params[:id] =~ /\A\d+[^\d\\]+\z/
       topic = Topic.find_by_slug(params[:id])
       return redirect_to_correct_topic(topic, opts[:post_number]) if topic
     end
diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb
index c943d32615c..db47e4b701f 100644
--- a/app/controllers/user_avatars_controller.rb
+++ b/app/controllers/user_avatars_controller.rb
@@ -39,7 +39,7 @@ class UserAvatarsController < ApplicationController
   def show_proxy_letter
     is_asset_path
 
-    if SiteSetting.external_system_avatars_url !~ %r{^/letter_avatar_proxy}
+    if SiteSetting.external_system_avatars_url !~ %r{\A/letter_avatar_proxy}
       raise Discourse::NotFound
     end
 
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 23032ada602..efeadc161e7 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -473,7 +473,7 @@ class UsersController < ApplicationController
   end
 
   def my_redirect
-    raise Discourse::NotFound if params[:path] !~ %r{^[a-z_\-/]+$}
+    raise Discourse::NotFound if params[:path] !~ %r{\A[a-z_\-/]+\z}
 
     if current_user.blank?
       cookies[:destination_url] = path("/my/#{params[:path]}")
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 537e45b1e75..3f7cafff491 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -50,7 +50,7 @@ module ApplicationHelper
 
   def google_universal_analytics_json(ua_domain_name = nil)
     result = {}
-    result[:cookieDomain] = ua_domain_name.gsub(%r{^http(s)?://}, "") if ua_domain_name
+    result[:cookieDomain] = ua_domain_name.gsub(%r{\Ahttp(s)?://}, "") if ua_domain_name
     result[:userId] = current_user.id if current_user.present?
     result[:allowLinker] = true if SiteSetting.ga_universal_auto_link_domains.present?
     result.to_json
@@ -117,9 +117,9 @@ module ApplicationHelper
       # seconds.
       if !script.start_with?("discourse/tests/")
         if is_brotli_req?
-          path = path.gsub(/\.([^.]+)$/, '.br.\1')
+          path = path.gsub(/\.([^.]+)\z/, '.br.\1')
         elsif is_gzip_req?
-          path = path.gsub(/\.([^.]+)$/, '.gz.\1')
+          path = path.gsub(/\.([^.]+)\z/, '.gz.\1')
         end
       end
     elsif GlobalSetting.cdn_url&.start_with?("https") && is_brotli_req? &&
diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb
index e92dbeccd96..deb40425015 100644
--- a/app/helpers/user_notifications_helper.rb
+++ b/app/helpers/user_notifications_helper.rb
@@ -20,8 +20,8 @@ module UserNotificationsHelper
 
   def logo_url
     logo_url = SiteSetting.site_digest_logo_url
-    logo_url = SiteSetting.site_logo_url if logo_url.blank? || logo_url =~ /\.svg$/i
-    return nil if logo_url.blank? || logo_url =~ /\.svg$/i
+    logo_url = SiteSetting.site_logo_url if logo_url.blank? || logo_url =~ /\.svg\z/i
+    return nil if logo_url.blank? || logo_url =~ /\.svg\z/i
     logo_url
   end
 
diff --git a/app/jobs/base.rb b/app/jobs/base.rb
index 0f671c95a55..4d1d2529e5c 100644
--- a/app/jobs/base.rb
+++ b/app/jobs/base.rb
@@ -29,7 +29,7 @@ module Jobs
   end
 
   def self.num_email_retry_jobs
-    Sidekiq::RetrySet.new.count { |job| job.klass =~ /Email$/ }
+    Sidekiq::RetrySet.new.count { |job| job.klass =~ /Email\z/ }
   end
 
   class Base
diff --git a/app/jobs/onceoff/onceoff.rb b/app/jobs/onceoff/onceoff.rb
index df9c94e54d0..efbc0ae5f5e 100644
--- a/app/jobs/onceoff/onceoff.rb
+++ b/app/jobs/onceoff/onceoff.rb
@@ -4,7 +4,7 @@ class Jobs::Onceoff < ::Jobs::Base
   sidekiq_options retry: false
 
   def self.name_for(klass)
-    klass.name.sub(/^Jobs\:\:/, "")
+    klass.name.sub(/\AJobs\:\:/, "")
   end
 
   def running_key_name
diff --git a/app/jobs/regular/update_username.rb b/app/jobs/regular/update_username.rb
index 3a0ed3194ef..e9b56cdd69e 100644
--- a/app/jobs/regular/update_username.rb
+++ b/app/jobs/regular/update_username.rb
@@ -29,9 +29,9 @@ module Jobs
       @raw_quote_regex = /(\[quote\s*=\s*["'']?)#{@old_username}(\,?[^\]]*\])/i
 
       cooked_username = PrettyText::Helpers.format_username(@old_username)
-      @cooked_mention_username_regex = /^@#{cooked_username}$/i
+      @cooked_mention_username_regex = /\A@#{cooked_username}\z/i
       @cooked_mention_user_path_regex =
-        %r{^/u(?:sers)?/#{UrlHelper.encode_component(cooked_username)}$}i
+        %r{\A/u(?:sers)?/#{UrlHelper.encode_component(cooked_username)}\z}i
       @cooked_quote_username_regex = /(?<=\s)#{cooked_username}(?=:)/i
 
       update_posts
diff --git a/app/models/admin_dashboard_data.rb b/app/models/admin_dashboard_data.rb
index 8c3245a803b..1e845f1f15b 100644
--- a/app/models/admin_dashboard_data.rb
+++ b/app/models/admin_dashboard_data.rb
@@ -377,7 +377,7 @@ class AdminDashboardData
   end
 
   def subfolder_ends_in_slash_check
-    I18n.t("dashboard.subfolder_ends_in_slash") if Discourse.base_path =~ %r{/$}
+    I18n.t("dashboard.subfolder_ends_in_slash") if Discourse.base_path =~ %r{/\z}
   end
 
   def email_polling_errored_recently
diff --git a/app/models/category.rb b/app/models/category.rb
index 220ca006cd7..a049414df0e 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -421,7 +421,7 @@ class Category < ActiveRecord::Base
     end
 
     # only allow to use category itself id.
-    match_id = /^(\d+)-category/.match(self.slug)
+    match_id = /\A(\d+)-category/.match(self.slug)
     if match_id.present?
       errors.add(:slug, :invalid) if new_record? || (match_id[1] != self.id.to_s)
     end
@@ -897,7 +897,7 @@ class Category < ActiveRecord::Base
       slug_path.inject(nil) do |parent_id, slug|
         category = Category.where(slug: slug, parent_category_id: parent_id)
 
-        if match_id = /^(\d+)-category/.match(slug).presence
+        if match_id = /\A(\d+)-category/.match(slug).presence
           category = category.or(Category.where(id: match_id[1], parent_category_id: parent_id))
         end
 
diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb
index 2b2f64f08a3..5330abdd694 100644
--- a/app/models/concerns/has_custom_fields.rb
+++ b/app/models/concerns/has_custom_fields.rb
@@ -20,7 +20,7 @@ module HasCustomFields
 
       sorted_types = types.keys.select { |k| k.end_with?("*") }.sort_by(&:length).reverse
 
-      sorted_types.each { |t| return types[t] if key =~ /^#{t}/i }
+      sorted_types.each { |t| return types[t] if key =~ /\A#{t}/i }
 
       types[key]
     end
diff --git a/app/models/concerns/reports/top_uploads.rb b/app/models/concerns/reports/top_uploads.rb
index d5e2955b7d9..b9caaa63fab 100644
--- a/app/models/concerns/reports/top_uploads.rb
+++ b/app/models/concerns/reports/top_uploads.rb
@@ -66,7 +66,7 @@ module Reports::TopUploads
       builder.where("up.created_at < :end_date", end_date: report.end_date)
 
       if extension_filter
-        builder.where("up.extension = :extension", extension: extension_filter.sub(/^\./, ""))
+        builder.where("up.extension = :extension", extension: extension_filter.sub(/\A\./, ""))
       end
 
       builder.query.each do |row|
diff --git a/app/models/embeddable_host.rb b/app/models/embeddable_host.rb
index b9c63aff5b3..721faed2b61 100644
--- a/app/models/embeddable_host.rb
+++ b/app/models/embeddable_host.rb
@@ -6,8 +6,8 @@ class EmbeddableHost < ActiveRecord::Base
   after_destroy :reset_embedding_settings
 
   before_validation do
-    self.host.sub!(%r{^https?://}, "")
-    self.host.sub!(%r{/.*$}, "")
+    self.host.sub!(%r{\Ahttps?://}, "")
+    self.host.sub!(%r{/.*\z}, "")
   end
 
   # TODO(2021-07-23): Remove
diff --git a/app/models/emoji.rb b/app/models/emoji.rb
index 94b85eb154d..84a0f718dcc 100644
--- a/app/models/emoji.rb
+++ b/app/models/emoji.rb
@@ -173,7 +173,7 @@ class Emoji
       emojis.each do |name, url|
         result << Emoji.new.tap do |e|
           e.name = name
-          url = (Discourse.base_path + url) if url[%r{^/[^/]}]
+          url = (Discourse.base_path + url) if url[%r{\A/[^/]}]
           e.url = url
           e.group = group || DEFAULT_GROUP
         end
diff --git a/app/models/global_setting.rb b/app/models/global_setting.rb
index efd83403a06..51506e1c8c7 100644
--- a/app/models/global_setting.rb
+++ b/app/models/global_setting.rb
@@ -5,7 +5,7 @@ class GlobalSetting
     define_singleton_method(key) { provider.lookup(key, default) }
   end
 
-  VALID_SECRET_KEY ||= /^[0-9a-f]{128}$/
+  VALID_SECRET_KEY ||= /\A[0-9a-f]{128}\z/
   # this is named SECRET_TOKEN as opposed to SECRET_KEY_BASE
   # for legacy reasons
   REDIS_SECRET_KEY ||= "SECRET_TOKEN"
@@ -251,7 +251,7 @@ class GlobalSetting
   class BaseProvider
     def self.coerce(setting)
       return setting == "true" if setting == "true" || setting == "false"
-      return $1.to_i if setting.to_s.strip =~ /^([0-9]+)$/
+      return $1.to_i if setting.to_s.strip =~ /\A([0-9]+)\z/
       setting
     end
 
@@ -283,7 +283,7 @@ class GlobalSetting
         .result()
         .split("\n")
         .each do |line|
-          if line =~ /^\s*([a-z_]+[a-z0-9_]*)\s*=\s*(\"([^\"]*)\"|\'([^\']*)\'|[^#]*)/
+          if line =~ /\A\s*([a-z_]+[a-z0-9_]*)\s*=\s*(\"([^\"]*)\"|\'([^\']*)\'|[^#]*)/
             @data[$1.strip.to_sym] = ($4 || $3 || $2).strip
           end
         end
@@ -314,7 +314,7 @@ class GlobalSetting
     end
 
     def keys
-      ENV.keys.select { |k| k =~ /^DISCOURSE_/ }.map { |k| k[10..-1].downcase.to_sym }
+      ENV.keys.select { |k| k =~ /\ADISCOURSE_/ }.map { |k| k[10..-1].downcase.to_sym }
     end
   end
 
diff --git a/app/models/group.rb b/app/models/group.rb
index c31436815a9..f6ca9fc0bcb 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -1005,7 +1005,7 @@ class Group < ActiveRecord::Base
     user = email_username_user
     domain = email_username_domain
     if user.present? && domain.present?
-      /^#{Regexp.escape(user)}(\+[^@]*)?@#{Regexp.escape(domain)}$/i
+      /\A#{Regexp.escape(user)}(\+[^@]*)?@#{Regexp.escape(domain)}\z/i
     end
   end
 
@@ -1160,8 +1160,8 @@ class Group < ActiveRecord::Base
     value
       .split("|")
       .each do |domain|
-        domain.sub!(%r{^https?://}, "")
-        domain.sub!(%r{/.*$}, "")
+        domain.sub!(%r{\Ahttps?://}, "")
+        domain.sub!(%r{/.*\z}, "")
 
         if domain =~ Group::VALID_DOMAIN_REGEX
           valid_domains << domain
diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb
index dbfa6d10c66..954686ca086 100644
--- a/app/models/optimized_image.rb
+++ b/app/models/optimized_image.rb
@@ -142,7 +142,7 @@ class OptimizedImage < ActiveRecord::Base
   end
 
   def local?
-    !(url =~ %r{^(https?:)?//})
+    !(url =~ %r{\A(https?:)?//})
   end
 
   def calculate_filesize
@@ -337,7 +337,7 @@ class OptimizedImage < ActiveRecord::Base
     else
       error = +"Failed to optimize image:"
 
-      if e.message =~ /^convert:([^`]+)/
+      if e.message =~ /\Aconvert:([^`]+)/
         error << $1
       else
         error << " unknown reason"
diff --git a/app/models/post_action_type.rb b/app/models/post_action_type.rb
index db785c307fe..3f2f8d7e977 100644
--- a/app/models/post_action_type.rb
+++ b/app/models/post_action_type.rb
@@ -7,8 +7,8 @@ class PostActionType < ActiveRecord::Base
   include AnonCacheInvalidator
 
   def expire_cache
-    ApplicationSerializer.expire_cache_fragment!(/^post_action_types_/)
-    ApplicationSerializer.expire_cache_fragment!(/^post_action_flag_types_/)
+    ApplicationSerializer.expire_cache_fragment!(/\Apost_action_types_/)
+    ApplicationSerializer.expire_cache_fragment!(/\Apost_action_flag_types_/)
   end
 
   class << self
diff --git a/app/models/published_page.rb b/app/models/published_page.rb
index 937ba59310e..5fd47bb9236 100644
--- a/app/models/published_page.rb
+++ b/app/models/published_page.rb
@@ -8,7 +8,7 @@ class PublishedPage < ActiveRecord::Base
 
   validate :slug_format
   def slug_format
-    if slug !~ /^[a-zA-Z\-\_0-9]+$/
+    if slug !~ /\A[a-zA-Z\-\_0-9]+\z/
       errors.add(:slug, I18n.t("publish_page.slug_errors.invalid"))
     elsif %w[check-slug by-topic].include?(slug)
       errors.add(:slug, I18n.t("publish_page.slug_errors.unavailable"))
diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb
index baed6456548..4894a8a73c3 100644
--- a/app/models/remote_theme.rb
+++ b/app/models/remote_theme.rb
@@ -15,8 +15,8 @@ class RemoteTheme < ActiveRecord::Base
 
   ALLOWED_FIELDS = %w[scss embedded_scss head_tag header after_header body_tag footer]
 
-  GITHUB_REGEXP = %r{^https?://github\.com/}
-  GITHUB_SSH_REGEXP = %r{^ssh://git@github\.com:}
+  GITHUB_REGEXP = %r{\Ahttps?://github\.com/}
+  GITHUB_SSH_REGEXP = %r{\Assh://git@github\.com:}
 
   has_one :theme, autosave: false
   scope :joined_remotes,
@@ -329,7 +329,7 @@ class RemoteTheme < ActiveRecord::Base
 
   def github_diff_link
     if github_repo_url.present? && local_version != remote_version
-      "#{github_repo_url.gsub(/\.git$/, "")}/compare/#{local_version}...#{remote_version}"
+      "#{github_repo_url.gsub(/\.git\z/, "")}/compare/#{local_version}...#{remote_version}"
     end
   end
 
diff --git a/app/models/report.rb b/app/models/report.rb
index cd9b7623ac1..1cf45d27cfe 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -268,8 +268,8 @@ class Report
         wrap_slow_query do
           if respond_to?(report_method)
             public_send(report_method, report)
-          elsif type =~ /_reqs$/
-            req_report(report, type.split(/_reqs$/)[0].to_sym)
+          elsif type =~ /_reqs\z/
+            req_report(report, type.split(/_reqs\z/)[0].to_sym)
           else
             return nil
           end
diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb
index 8f389cb6700..02b1b47421b 100644
--- a/app/models/reviewable.rb
+++ b/app/models/reviewable.rb
@@ -60,7 +60,7 @@ class Reviewable < ActiveRecord::Base
   end
 
   def self.valid_type?(type)
-    return false unless type =~ /^Reviewable[A-Za-z]+$/
+    return false unless type =~ /\AReviewable[A-Za-z]+\z/
     type.constantize <= Reviewable
   rescue NameError
     false
diff --git a/app/models/screened_url.rb b/app/models/screened_url.rb
index 0e827cf522d..dcc19bc3e31 100644
--- a/app/models/screened_url.rb
+++ b/app/models/screened_url.rb
@@ -17,7 +17,7 @@ class ScreenedUrl < ActiveRecord::Base
 
   def normalize
     self.url = ScreenedUrl.normalize_url(self.url) if self.url
-    self.domain = self.domain.downcase.sub(/^www\./, "") if self.domain
+    self.domain = self.domain.downcase.sub(/\Awww\./, "") if self.domain
   end
 
   def self.watch(url, domain, opts = {})
@@ -30,8 +30,8 @@ class ScreenedUrl < ActiveRecord::Base
 
   def self.normalize_url(url)
     normalized = url.gsub(%r{http(s?)://}i, "")
-    normalized.gsub!(%r{(/)+$}, "") # trim trailing slashes
-    normalized.gsub!(%r{^([^/]+)(?:/)?}) { |m| m.downcase } # downcase the domain part of the url
+    normalized.gsub!(%r{(/)+\z}, "") # trim trailing slashes
+    normalized.gsub!(%r{\A([^/]+)(?:/)?}) { |m| m.downcase } # downcase the domain part of the url
     normalized
   end
 end
diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb
index 780789c42bf..a2404c7f83f 100644
--- a/app/models/theme_field.rb
+++ b/app/models/theme_field.rb
@@ -94,7 +94,7 @@ class ThemeField < ActiveRecord::Base
       .css('script[type="text/x-handlebars"]')
       .each do |node|
         name = node["name"] || node["data-template-name"] || "broken"
-        is_raw = name =~ /\.(raw|hbr)$/
+        is_raw = name =~ /\.(raw|hbr)\z/
         hbs_template = node.inner_html
 
         begin
@@ -523,63 +523,63 @@ class ThemeField < ActiveRecord::Base
   FILE_MATCHERS = [
     ThemeFileMatcher.new(
       regex:
-        %r{^(?<target>(?:mobile|desktop|common))/(?<name>(?:head_tag|header|after_header|body_tag|footer))\.html$},
+        %r{\A(?<target>(?:mobile|desktop|common))/(?<name>(?:head_tag|header|after_header|body_tag|footer))\.html\z},
       targets: %i[mobile desktop common],
       names: %w[head_tag header after_header body_tag footer],
       types: :html,
       canonical: ->(h) { "#{h[:target]}/#{h[:name]}.html" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^(?<target>(?:mobile|desktop|common))/(?:\k<target>)\.scss$},
+      regex: %r{\A(?<target>(?:mobile|desktop|common))/(?:\k<target>)\.scss\z},
       targets: %i[mobile desktop common],
       names: "scss",
       types: :scss,
       canonical: ->(h) { "#{h[:target]}/#{h[:target]}.scss" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^common/embedded\.scss$},
+      regex: %r{\Acommon/embedded\.scss\z},
       targets: :common,
       names: "embedded_scss",
       types: :scss,
       canonical: ->(h) { "common/embedded.scss" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^common/color_definitions\.scss$},
+      regex: %r{\Acommon/color_definitions\.scss\z},
       targets: :common,
       names: "color_definitions",
       types: :scss,
       canonical: ->(h) { "common/color_definitions.scss" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^(?:scss|stylesheets)/(?<name>.+)\.scss$},
+      regex: %r{\A(?:scss|stylesheets)/(?<name>.+)\.scss\z},
       targets: :extra_scss,
       names: nil,
       types: :scss,
       canonical: ->(h) { "stylesheets/#{h[:name]}.scss" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^javascripts/(?<name>.+)$},
+      regex: %r{\Ajavascripts/(?<name>.+)\z},
       targets: :extra_js,
       names: nil,
       types: :js,
       canonical: ->(h) { "javascripts/#{h[:name]}" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^test/(?<name>.+)$},
+      regex: %r{\Atest/(?<name>.+)\z},
       targets: :tests_js,
       names: nil,
       types: :js,
       canonical: ->(h) { "test/#{h[:name]}" },
     ),
     ThemeFileMatcher.new(
-      regex: /^settings\.ya?ml$/,
+      regex: /\Asettings\.ya?ml\z/,
       names: "yaml",
       types: :yaml,
       targets: :settings,
       canonical: ->(h) { "settings.yml" },
     ),
     ThemeFileMatcher.new(
-      regex: %r{^locales/(?<name>(?:#{I18n.available_locales.join("|")}))\.yml$},
+      regex: %r{\Alocales/(?<name>(?:#{I18n.available_locales.join("|")}))\.yml\z},
       names: I18n.available_locales.map(&:to_s),
       types: :yaml,
       targets: :translations,
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 10a0e7a5386..e373f4f4d4a 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -1200,7 +1200,7 @@ class Topic < ActiveRecord::Base
       else
         !!invite_to_topic(invited_by, target_user, group_ids, guardian)
       end
-    elsif username_or_email =~ /^.+@.+$/ && guardian.can_invite_via_email?(self)
+    elsif username_or_email =~ /\A.+@.+\z/ && guardian.can_invite_via_email?(self)
       !!Invite.generate(
         invited_by,
         email: username_or_email,
diff --git a/app/models/topic_embed.rb b/app/models/topic_embed.rb
index 3b9545bf815..8f9bb37c22a 100644
--- a/app/models/topic_embed.rb
+++ b/app/models/topic_embed.rb
@@ -25,7 +25,7 @@ class TopicEmbed < ActiveRecord::Base
   end
 
   def self.normalize_url(url)
-    url.downcase.sub(%r{/$}, "").sub(/\-+/, "-").strip
+    url.downcase.sub(%r{/\z}, "").sub(/\-+/, "-").strip
   end
 
   def self.imported_from_html(url)
@@ -36,7 +36,7 @@ class TopicEmbed < ActiveRecord::Base
 
   # Import an article from a source (RSS/Atom/Other)
   def self.import(user, url, title, contents, category_id: nil, cook_method: nil, tags: nil)
-    return unless url =~ %r{^https?\://}
+    return unless url =~ %r{\Ahttps?\://}
 
     contents = first_paragraph_from(contents) if SiteSetting.embed_truncate && cook_method.nil?
     contents ||= ""
@@ -253,7 +253,7 @@ class TopicEmbed < ActiveRecord::Base
   end
 
   def self.topic_id_for_embed(embed_url)
-    embed_url = normalize_url(embed_url).sub(%r{^https?\://}, "")
+    embed_url = normalize_url(embed_url).sub(%r{\Ahttps?\://}, "")
     TopicEmbed.where("embed_url ~* ?", "^https?://#{Regexp.escape(embed_url)}$").pluck_first(
       :topic_id,
     )
diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb
index 03bc3587b45..05d35f55ae6 100644
--- a/app/models/topic_link.rb
+++ b/app/models/topic_link.rb
@@ -175,7 +175,7 @@ class TopicLink < ActiveRecord::Base
 
     lookup = {}
     results.each do |tl|
-      normalized = tl.url.downcase.sub(%r{^https?://}, "").sub(%r{/$}, "")
+      normalized = tl.url.downcase.sub(%r{\Ahttps?://}, "").sub(%r{/\z}, "")
       lookup[normalized] = {
         domain: tl.domain,
         username: tl.user.username_lower,
diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb
index a1f121ee844..751493dbd54 100644
--- a/app/models/topic_link_click.rb
+++ b/app/models/topic_link_click.rb
@@ -21,9 +21,9 @@ class TopicLinkClick < ActiveRecord::Base
     uri = UrlHelper.relaxed_parse(url)
     urls = Set.new
     urls << url
-    if url =~ /^http/
-      urls << url.sub(/^https/, "http")
-      urls << url.sub(/^http:/, "https:")
+    if url =~ /\Ahttp/
+      urls << url.sub(/\Ahttps/, "http")
+      urls << url.sub(/\Ahttp:/, "https:")
       urls << UrlHelper.schemaless(url)
     end
     urls << UrlHelper.absolute_without_cdn(url)
@@ -90,7 +90,7 @@ class TopicLinkClick < ActiveRecord::Base
     # If no link is found...
     unless link.present?
       # ... return the url for relative links or when using the same host
-      return url if url =~ %r{^/[^/]} || uri.try(:host) == Discourse.current_hostname
+      return url if url =~ %r{\A/[^/]} || uri.try(:host) == Discourse.current_hostname
 
       # If we have it somewhere else on the site, just allow the redirect.
       # This is likely due to a onebox of another topic.
diff --git a/app/models/translation_override.rb b/app/models/translation_override.rb
index 73137243ed0..eddaac89831 100644
--- a/app/models/translation_override.rb
+++ b/app/models/translation_override.rb
@@ -147,7 +147,7 @@ class TranslationOverride < ActiveRecord::Base
   end
 
   def transform_pluralized_key(key)
-    match = key.match(/(.*)\.(zero|two|few|many)$/)
+    match = key.match(/(.*)\.(zero|two|few|many)\z/)
     match ? match.to_a.second + ".other" : key
   end
 end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 964ef52d1dd..86bf760b8da 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -263,7 +263,7 @@ class Upload < ActiveRecord::Base
   end
 
   def local?
-    !(url =~ %r{^(https?:)?//})
+    !(url =~ %r{\A(https?:)?//})
   end
 
   def fix_dimensions!
@@ -526,7 +526,7 @@ class Upload < ActiveRecord::Base
             # keep track of the url
             previous_url = upload.url.dup
             # where is the file currently stored?
-            external = previous_url =~ %r{^//}
+            external = previous_url =~ %r{\A//}
             # download if external
             if external
               url = SiteSetting.scheme + ":" + previous_url
diff --git a/app/models/user.rb b/app/models/user.rb
index e4e8faad86b..5bc5304b2fd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -396,7 +396,7 @@ class User < ActiveRecord::Base
       .reserved_usernames
       .unicode_normalize
       .split("|")
-      .any? { |reserved| username.match?(/^#{Regexp.escape(reserved).gsub('\*', ".*")}$/) }
+      .any? { |reserved| username.match?(/\A#{Regexp.escape(reserved).gsub('\*', ".*")}\z/) }
   end
 
   def self.editable_user_custom_fields(by_staff: false)
@@ -1117,7 +1117,7 @@ class User < ActiveRecord::Base
     # TODO it may be worth caching this in a distributed cache, should be benched
     if SiteSetting.external_system_avatars_enabled
       url = SiteSetting.external_system_avatars_url.dup
-      url = +"#{Discourse.base_path}#{url}" unless url =~ %r{^https?://}
+      url = +"#{Discourse.base_path}#{url}" unless url =~ %r{\Ahttps?://}
       url.gsub! "{color}", letter_avatar_color(normalized_username)
       url.gsub! "{username}", UrlHelper.encode_component(username)
       url.gsub! "{first_letter}",
diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb
index 2ed6d1b0279..edd8ade93b9 100644
--- a/app/models/user_profile.rb
+++ b/app/models/user_profile.rb
@@ -45,7 +45,7 @@ class UserProfile < ActiveRecord::Base
 
   def bio_excerpt(length = 350, opts = {})
     return nil if bio_cooked.blank?
-    excerpt = PrettyText.excerpt(bio_cooked, length, opts).sub(/<br>$/, "")
+    excerpt = PrettyText.excerpt(bio_cooked, length, opts).sub(/<br>\z/, "")
     return excerpt if excerpt.blank? || (user.has_trust_level?(TrustLevel[1]) && !user.suspended?)
     PrettyText.strip_links(excerpt)
   end
diff --git a/app/models/username_validator.rb b/app/models/username_validator.rb
index 8c82653b205..842b676356a 100644
--- a/app/models/username_validator.rb
+++ b/app/models/username_validator.rb
@@ -40,13 +40,13 @@ class UsernameValidator
     errors.empty?
   end
 
-  CONFUSING_EXTENSIONS ||= /\.(js|json|css|htm|html|xml|jpg|jpeg|png|gif|bmp|ico|tif|tiff|woff)$/i
+  CONFUSING_EXTENSIONS ||= /\.(js|json|css|htm|html|xml|jpg|jpeg|png|gif|bmp|ico|tif|tiff|woff)\z/i
   MAX_CHARS ||= 60
 
   ASCII_INVALID_CHAR_PATTERN ||= /[^\w.-]/
   UNICODE_INVALID_CHAR_PATTERN ||= /[^\p{Alnum}\p{M}._-]/
-  INVALID_LEADING_CHAR_PATTERN ||= /^[^\p{Alnum}\p{M}_]+/
-  INVALID_TRAILING_CHAR_PATTERN ||= /[^\p{Alnum}\p{M}]+$/
+  INVALID_LEADING_CHAR_PATTERN ||= /\A[^\p{Alnum}\p{M}_]+/
+  INVALID_TRAILING_CHAR_PATTERN ||= /[^\p{Alnum}\p{M}]+\z/
   REPEATED_SPECIAL_CHAR_PATTERN ||= /[-_.]{2,}/
 
   private
diff --git a/app/models/watched_word.rb b/app/models/watched_word.rb
index e493f50e416..3e9b4d5b575 100644
--- a/app/models/watched_word.rb
+++ b/app/models/watched_word.rb
@@ -19,7 +19,7 @@ class WatchedWord < ActiveRecord::Base
 
   before_validation do
     self.word = self.class.normalize_word(self.word)
-    if self.action == WatchedWord.actions[:link] && !(self.replacement =~ %r{^https?://})
+    if self.action == WatchedWord.actions[:link] && !(self.replacement =~ %r{\Ahttps?://})
       self.replacement =
         "#{Discourse.base_url}#{self.replacement&.starts_with?("/") ? "" : "/"}#{self.replacement}"
     end
diff --git a/app/serializers/user_card_serializer.rb b/app/serializers/user_card_serializer.rb
index d2c32facdc9..301082cab61 100644
--- a/app/serializers/user_card_serializer.rb
+++ b/app/serializers/user_card_serializer.rb
@@ -113,7 +113,7 @@ class UserCardSerializer < BasicUserSerializer
       end
 
     return if uri.nil? || uri.host.nil?
-    uri.host.sub(/^www\./, "") + uri.path
+    uri.host.sub(/\Awww\./, "") + uri.path
   end
 
   def ignored
diff --git a/app/services/search_indexer.rb b/app/services/search_indexer.rb
index a17ac7b34ba..ea1c1b8e89c 100644
--- a/app/services/search_indexer.rb
+++ b/app/services/search_indexer.rb
@@ -50,7 +50,7 @@ class SearchIndexer
       .reduce(additional_lexemes) do |array, (lexeme, _, positions)|
         count = 0
 
-        if lexeme !~ /^(\d+\.)?(\d+\.)*(\*|\d+)$/
+        if lexeme !~ /\A(\d+\.)?(\d+\.)*(\*|\d+)\z/
           loop do
             count += 1
             break if count >= 10 # Safeguard here to prevent infinite loop when a term has many dots
diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb
index 511755628f4..6a569b6a532 100644
--- a/app/services/user_updater.rb
+++ b/app/services/user_updater.rb
@@ -347,6 +347,6 @@ class UserUpdater
 
   def format_url(website)
     return nil if website.blank?
-    website =~ /^http/ ? website : "http://#{website}"
+    website =~ /\Ahttp/ ? website : "http://#{website}"
   end
 end
diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb
index 49564110bfc..fd9c364b3b3 100644
--- a/lib/admin_user_index_query.rb
+++ b/lib/admin_user_index_query.rb
@@ -50,7 +50,7 @@ class AdminUserIndexQuery
 
     custom_order = params[:order]
     if custom_order.present? &&
-         without_dir = SORTABLE_MAPPING[custom_order.downcase.sub(/ (asc|desc)$/, "")]
+         without_dir = SORTABLE_MAPPING[custom_order.downcase.sub(/ (asc|desc)\z/, "")]
       order << "#{without_dir} #{custom_direction}"
     end
 
diff --git a/lib/autospec/manager.rb b/lib/autospec/manager.rb
index 87dded1963c..fba758be265 100644
--- a/lib/autospec/manager.rb
+++ b/lib/autospec/manager.rb
@@ -153,7 +153,7 @@ class Autospec::Manager
       filename, _ = failed_specs[0].split(":")
       if filename && File.exist?(filename) && !File.directory?(filename)
         spec = File.read(filename)
-        start, _ = spec.split(/\S*#focus\S*$/)
+        start, _ = spec.split(/\S*#focus\S*\z/)
         if start.length < spec.length
           line = start.scan(/\n/).length + 1
           puts "Found #focus tag on line #{line}!"
@@ -194,7 +194,7 @@ class Autospec::Manager
   def listen_for_changes
     puts "@@@@@@@@@@@@ listen_for_changes" if @debug
 
-    options = { ignore: %r{^lib/autospec} }
+    options = { ignore: %r{\Alib/autospec} }
 
     if @opts[:force_polling]
       options[:force_polling] = true
@@ -216,7 +216,7 @@ class Autospec::Manager
         # process_change can acquire a mutex and block
         # the acceptor
         Thread.new do
-          if file =~ /(es6|js)$/
+          if file =~ /(es6|js)\z/
             process_change([[file]])
           else
             process_change([[file, line]])
diff --git a/lib/autospec/reload_css.rb b/lib/autospec/reload_css.rb
index 78258bed17f..f8fb530aa8e 100644
--- a/lib/autospec/reload_css.rb
+++ b/lib/autospec/reload_css.rb
@@ -10,11 +10,11 @@ class Autospec::ReloadCss
   end
 
   # css, scss, sass or handlebars
-  watch(/\.css$/)
-  watch(/\.ca?ss\.erb$/)
-  watch(/\.s[ac]ss$/)
-  watch(/\.hbs$/)
-  watch(/\.hbr$/)
+  watch(/\.css\z/)
+  watch(/\.ca?ss\.erb\z/)
+  watch(/\.s[ac]ss\z/)
+  watch(/\.hbs\z/)
+  watch(/\.hbr\z/)
 
   def self.message_bus
     MessageBus::Instance.new.tap do |bus|
@@ -44,7 +44,7 @@ class Autospec::ReloadCss
       p = p.sub(/\.sass\.erb/, "")
       p = p.sub(/\.sass/, "")
       p = p.sub(/\.scss/, "")
-      p = p.sub(%r{^app/assets/stylesheets}, "assets")
+      p = p.sub(%r{\Aapp/assets/stylesheets}, "assets")
       { name: p, hash: hash || SecureRandom.hex }
     end
     message_bus.publish "/file-change", paths
diff --git a/lib/autospec/rspec_runner.rb b/lib/autospec/rspec_runner.rb
index 408b6fe79e0..e5efa38e2ea 100644
--- a/lib/autospec/rspec_runner.rb
+++ b/lib/autospec/rspec_runner.rb
@@ -11,29 +11,31 @@ module Autospec
     end
 
     # Discourse specific
-    watch(%r{^lib/(.+)\.rb$}) { |m| "spec/components/#{m[1]}_spec.rb" }
+    watch(%r{\Alib/(.+)\.rb\z}) { |m| "spec/components/#{m[1]}_spec.rb" }
 
-    watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
-    watch(%r{^app/(.+)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
-    watch(%r{^spec/.+_spec\.rb$})
-    watch(%r{^spec/support/.+\.rb$}) { "spec" }
+    watch(%r{\Aapp/(.+)\.rb\z}) { |m| "spec/#{m[1]}_spec.rb" }
+    watch(%r{\Aapp/(.+)(\.erb|\.haml)\z}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
+    watch(%r{\Aspec/.+_spec\.rb\z})
+    watch(%r{\Aspec/support/.+\.rb\z}) { "spec" }
     watch("app/controllers/application_controller.rb") { "spec/requests" }
 
     watch(%r{app/controllers/(.+).rb}) { |m| "spec/requests/#{m[1]}_spec.rb" }
 
-    watch(%r{^app/views/(.+)/.+\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" }
+    watch(%r{\Aapp/views/(.+)/.+\.(erb|haml)\z}) { |m| "spec/requests/#{m[1]}_spec.rb" }
 
-    watch(%r{^spec/fabricators/.+_fabricator\.rb$}) { "spec" }
+    watch(%r{\Aspec/fabricators/.+_fabricator\.rb\z}) { "spec" }
 
-    watch(%r{^app/assets/javascripts/pretty-text/.*\.js\.es6$}) do
+    watch(%r{\Aapp/assets/javascripts/pretty-text/.*\.js\.es6\z}) do
+      "spec/components/pretty_text_spec.rb"
+    end
+    watch(%r{\Aplugins/.*/discourse-markdown/.*\.js\.es6\z}) do
       "spec/components/pretty_text_spec.rb"
     end
-    watch(%r{^plugins/.*/discourse-markdown/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" }
 
-    watch(%r{^plugins/.*/spec/.*\.rb})
-    watch(%r{^(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" }
-    watch(%r{^(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" }
-    watch(%r{^(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" }
+    watch(%r{\Aplugins/.*/spec/.*\.rb})
+    watch(%r{\A(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" }
+    watch(%r{\A(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" }
+    watch(%r{\A(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" }
 
     RELOADERS = Set.new
     def self.reload(pattern)
diff --git a/lib/autospec/simple_runner.rb b/lib/autospec/simple_runner.rb
index dcf88e44434..09f813f877c 100644
--- a/lib/autospec/simple_runner.rb
+++ b/lib/autospec/simple_runner.rb
@@ -29,7 +29,7 @@ module Autospec
       # launch rspec
       Dir.chdir(Rails.root) do # rubocop:disable Discourse/NoChdir because this is not part of the app
         env = { "RAILS_ENV" => "test" }
-        if specs.split(" ").any? { |s| s =~ %r{^(./)?plugins} }
+        if specs.split(" ").any? { |s| s =~ %r{\A(./)?plugins} }
           env["LOAD_PLUGINS"] = "1"
           puts "Loading plugins while running specs"
         end
diff --git a/lib/backup_restore/backup_file_handler.rb b/lib/backup_restore/backup_file_handler.rb
index 645fad9f64d..19d79f20e99 100644
--- a/lib/backup_restore/backup_file_handler.rb
+++ b/lib/backup_restore/backup_file_handler.rb
@@ -11,7 +11,7 @@ module BackupRestore
       @filename = filename
       @current_db = current_db
       @root_tmp_directory = root_tmp_directory
-      @is_archive = !(@filename =~ /\.sql\.gz$/)
+      @is_archive = !(@filename =~ /\.sql\.gz\z/)
       @store_location = location
     end
 
diff --git a/lib/backup_restore/database_restorer.rb b/lib/backup_restore/database_restorer.rb
index 2a3bc6985c7..e913ba99324 100644
--- a/lib/backup_restore/database_restorer.rb
+++ b/lib/backup_restore/database_restorer.rb
@@ -164,7 +164,7 @@ module BackupRestore
 
       DatabaseRestorer.core_migration_files.each do |path|
         require path
-        class_name = File.basename(path, ".rb").sub(/^\d+_/, "").camelize
+        class_name = File.basename(path, ".rb").sub(/\A\d+_/, "").camelize
         migration_class = class_name.constantize
 
         if migration_class.const_defined?(:DROPPED_TABLES)
diff --git a/lib/backup_restore/s3_backup_store.rb b/lib/backup_restore/s3_backup_store.rb
index 10e2798642a..2237ccfa8b8 100644
--- a/lib/backup_restore/s3_backup_store.rb
+++ b/lib/backup_restore/s3_backup_store.rb
@@ -173,7 +173,7 @@ module BackupRestore
             path = Regexp.quote(path)
           end
 
-          %r{^#{path}[^/]*\.t?gz$}i
+          %r{\A#{path}[^/]*\.t?gz\z}i
         end
     end
 
diff --git a/lib/composer_messages_finder.rb b/lib/composer_messages_finder.rb
index 878727f20c9..e394bb39840 100644
--- a/lib/composer_messages_finder.rb
+++ b/lib/composer_messages_finder.rb
@@ -8,7 +8,7 @@ class ComposerMessagesFinder
   end
 
   def self.check_methods
-    @check_methods ||= instance_methods.find_all { |m| m =~ /^check\_/ }
+    @check_methods ||= instance_methods.find_all { |m| m =~ /\Acheck\_/ }
   end
 
   def find
diff --git a/lib/compression/gzip.rb b/lib/compression/gzip.rb
index c668b088f72..f32836f957e 100644
--- a/lib/compression/gzip.rb
+++ b/lib/compression/gzip.rb
@@ -38,7 +38,7 @@ module Compression
 
     def build_entry_path(dest_path, _, compressed_file_path)
       basename = File.basename(compressed_file_path)
-      basename.gsub!(/#{Regexp.escape(extension)}$/, "")
+      basename.gsub!(/#{Regexp.escape(extension)}\z/, "")
       File.join(dest_path, basename)
     end
 
diff --git a/lib/content_security_policy/extension.rb b/lib/content_security_policy/extension.rb
index 150e0048622..36a3ed8df55 100644
--- a/lib/content_security_policy/extension.rb
+++ b/lib/content_security_policy/extension.rb
@@ -80,7 +80,7 @@ class ContentSecurityPolicy
 
           uri.query = nil # CSP should not include query part of url
 
-          uri_string = uri.to_s.sub(%r{^//}, "") # Protocol-less CSP should not have // at beginning of URL
+          uri_string = uri.to_s.sub(%r{\A//}, "") # Protocol-less CSP should not have // at beginning of URL
 
           auto_script_src_extension[:script_src] << uri_string
         rescue URI::Error
diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb
index 01463bb7761..b811be43ce3 100644
--- a/lib/cooked_post_processor.rb
+++ b/lib/cooked_post_processor.rb
@@ -242,10 +242,10 @@ class CookedPostProcessor
 
         if !cropped && upload.width && resized_w > upload.width
           cooked_url = UrlHelper.cook_url(upload.url, secure: @post.with_secure_uploads?)
-          srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x"
+          srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0\z/, "")}x"
         elsif t = upload.thumbnail(resized_w, resized_h)
           cooked_url = UrlHelper.cook_url(t.url, secure: @post.with_secure_uploads?)
-          srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x"
+          srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0\z/, "")}x"
         end
 
         img[
@@ -295,7 +295,7 @@ class CookedPostProcessor
 
   def get_filename(upload, src)
     return File.basename(src) unless upload
-    return upload.original_filename unless upload.original_filename =~ /^blob(\.png)?$/i
+    return upload.original_filename unless upload.original_filename =~ /\Ablob(\.png)?\z/i
     I18n.t("upload.pasted_image_filename")
   end
 
diff --git a/lib/cooked_processor_mixin.rb b/lib/cooked_processor_mixin.rb
index bc872b45010..561dedb4f98 100644
--- a/lib/cooked_processor_mixin.rb
+++ b/lib/cooked_processor_mixin.rb
@@ -174,7 +174,7 @@ module CookedProcessorMixin
     return @size_cache[url] if @size_cache.has_key?(url)
 
     absolute_url = url
-    absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ %r{^/[^/]}
+    absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ %r{\A/[^/]}
 
     return unless absolute_url
 
diff --git a/lib/discourse_connect_base.rb b/lib/discourse_connect_base.rb
index b5e04d8f212..f39c2556fb6 100644
--- a/lib/discourse_connect_base.rb
+++ b/lib/discourse_connect_base.rb
@@ -99,7 +99,7 @@ class DiscourseConnectBase
     end
 
     decoded_hash.each do |k, v|
-      if field = k[/^custom\.(.+)$/, 1]
+      if field = k[/\Acustom\.(.+)\z/, 1]
         sso.custom_fields[field] = v
       end
     end
diff --git a/lib/discourse_diff.rb b/lib/discourse_diff.rb
index e31a699f85a..16a27b67472 100644
--- a/lib/discourse_diff.rb
+++ b/lib/discourse_diff.rb
@@ -160,7 +160,7 @@ class DiscourseDiff
     while i < text.size
       if text[i] =~ /\w/
         t << text[i]
-      elsif text[i] =~ /[ \t]/ && t.join =~ /^\w+$/
+      elsif text[i] =~ /[ \t]/ && t.join =~ /\A\w+\z/
         begin
           t << text[i]
           i += 1
diff --git a/lib/discourse_plugin_registry.rb b/lib/discourse_plugin_registry.rb
index daaa144b367..40cd5a061c5 100644
--- a/lib/discourse_plugin_registry.rb
+++ b/lib/discourse_plugin_registry.rb
@@ -158,8 +158,8 @@ class DiscoursePluginRegistry
     end
   end
 
-  JS_REGEX = /\.js$|\.js\.erb$|\.js\.es6$/
-  HANDLEBARS_REGEX = /\.(hb[rs]|js\.handlebars)$/
+  JS_REGEX = /\.js$|\.js\.erb$|\.js\.es6\z/
+  HANDLEBARS_REGEX = /\.(hb[rs]|js\.handlebars)\z/
 
   def self.register_asset(asset, opts = nil, plugin_directory_name = nil)
     if asset =~ JS_REGEX
@@ -172,7 +172,7 @@ class DiscoursePluginRegistry
       else
         self.javascripts << asset
       end
-    elsif asset =~ /\.css$|\.scss$/
+    elsif asset =~ /\.css$|\.scss\z/
       if opts == :mobile
         self.mobile_stylesheets[plugin_directory_name] ||= Set.new
         self.mobile_stylesheets[plugin_directory_name] << asset
diff --git a/lib/discourse_redis.rb b/lib/discourse_redis.rb
index e47e9734c56..ca969f3f284 100644
--- a/lib/discourse_redis.rb
+++ b/lib/discourse_redis.rb
@@ -276,7 +276,7 @@ class DiscourseRedis
     def eval(redis, *args, **kwargs)
       redis.evalsha @sha1, *args, **kwargs
     rescue ::Redis::CommandError => e
-      if e.to_s =~ /^NOSCRIPT/
+      if e.to_s =~ /\ANOSCRIPT/
         redis.eval @script, *args, **kwargs
       else
         raise
diff --git a/lib/email/message_id_service.rb b/lib/email/message_id_service.rb
index c2cdcbbf2b6..e1a9fea0e88 100644
--- a/lib/email/message_id_service.rb
+++ b/lib/email/message_id_service.rb
@@ -136,7 +136,7 @@ module Email
 
       def message_id_clean(message_id)
         if message_id.present? && is_message_id_rfc?(message_id)
-          message_id.gsub(/^<|>$/, "")
+          message_id.gsub(/\A<|>\z/, "")
         else
           message_id
         end
diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb
index 05b51c7eb00..2dcf33f8475 100644
--- a/lib/email/receiver.rb
+++ b/lib/email/receiver.rb
@@ -381,7 +381,7 @@ module Email
       @mail[:precedence].to_s[/list|junk|bulk|auto_reply/i] ||
         @mail[:from].to_s[/(mailer[\-_]?daemon|post[\-_]?master|no[\-_]?reply)@/i] ||
         @mail[:subject].to_s[
-          /^\s*(Auto:|Automatic reply|Autosvar|Automatisk svar|Automatisch antwoord|Abwesenheitsnotiz|Risposta Non al computer|Automatisch antwoord|Auto Response|Respuesta automática|Fuori sede|Out of Office|Frånvaro|Réponse automatique)/i
+          /\A\s*(Auto:|Automatic reply|Autosvar|Automatisk svar|Automatisch antwoord|Abwesenheitsnotiz|Risposta Non al computer|Automatisch antwoord|Auto Response|Respuesta automática|Fuori sede|Out of Office|Frånvaro|Réponse automatique)/i
         ] ||
         @mail.header.to_s[
           /auto[\-_]?(response|submitted|replied|reply|generated|respond)|holidayreply|machinegenerated/i
@@ -393,7 +393,7 @@ module Email
       when "X-Spam-Flag"
         @mail[:x_spam_flag].to_s[/YES/i]
       when "X-Spam-Status"
-        @mail[:x_spam_status].to_s[/^Yes, /i]
+        @mail[:x_spam_status].to_s[/\AYes, /i]
       when "X-SES-Spam-Verdict"
         @mail[:x_ses_spam_verdict].to_s[/FAIL/i]
       else
@@ -639,7 +639,7 @@ module Email
           .uniq
 
       @previous_replies_regex ||=
-        /^--[- ]\n\*(?:#{strings.map { |x| Regexp.escape(x) }.join("|")})\*\n/im
+        /\A--[- ]\n\*(?:#{strings.map { |x| Regexp.escape(x) }.join("|")})\*\n/im
     end
 
     def reply_above_line_regex
@@ -747,12 +747,12 @@ module Email
 
       if value[/<[^>]+>/]
         from_address = value[/<([^>]+)>/, 1]
-        from_display_name = value[/^([^<]+)/, 1]
+        from_display_name = value[/\A([^<]+)/, 1]
       end
 
       if (from_address.blank? || !from_address["@"]) && value[/\[mailto:[^\]]+\]/]
         from_address = value[/\[mailto:([^\]]+)\]/, 1]
-        from_display_name = value[/^([^\[]+)/, 1]
+        from_display_name = value[/\A([^\[]+)/, 1]
       end
 
       [from_address&.downcase, from_display_name&.strip]
@@ -1016,7 +1016,7 @@ module Email
     end
 
     def has_been_forwarded?
-      subject[/^[[:blank:]]*(fwd?|tr)[[:blank:]]?:/i] && embedded_email_raw.present?
+      subject[/\A[[:blank:]]*(fwd?|tr)[[:blank:]]?:/i] && embedded_email_raw.present?
     end
 
     def embedded_email_raw
diff --git a/lib/email/styles.rb b/lib/email/styles.rb
index ee240b981ef..6482b70eec6 100644
--- a/lib/email/styles.rb
+++ b/lib/email/styles.rb
@@ -84,9 +84,9 @@ module Email
 
           if img["src"]
             # ensure all urls are absolute
-            img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{^/[^/]}]
+            img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{\A/[^/]}]
             # ensure no schemaless urls
-            img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{^//}]
+            img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{\A//}]
           end
         end
 
@@ -110,7 +110,7 @@ module Email
         .css("a.attachment")
         .each do |a|
           # ensure all urls are absolute
-          a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{^/[^/]}
+          a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{\A/[^/]}
 
           # ensure no schemaless urls
           a["href"] = "#{uri.scheme}:#{a["href"]}" if a["href"] && a["href"].starts_with?("//")
diff --git a/lib/email_cook.rb b/lib/email_cook.rb
index 2c76e1f2ff2..8af5b3c5fe6 100644
--- a/lib/email_cook.rb
+++ b/lib/email_cook.rb
@@ -4,7 +4,7 @@
 class EmailCook
   def self.raw_regexp
     @raw_regexp ||=
-      %r{^\[plaintext\]$\n(.*)\n^\[/plaintext\]$(?:\s^\[attachments\]$\n(.*)\n^\[/attachments\]$)?(?:\s^\[elided\]$\n(.*)\n^\[/elided\]$)?}m
+      %r{\A\[plaintext\]$\n(.*)\n^\[/plaintext\]$(?:\s^\[attachments\]$\n(.*)\n^\[/attachments\]$)?(?:\s^\[elided\]$\n(.*)\n^\[/elided\]$)?}m
   end
 
   def initialize(raw)
@@ -14,7 +14,7 @@ class EmailCook
 
   def add_quote(result, buffer)
     if buffer.present?
-      return if buffer =~ /\A(<br>)+\z$/
+      return if buffer =~ /\A(<br>)+\z\z/
       result << "<blockquote>#{buffer}</blockquote>"
     end
   end
@@ -22,7 +22,7 @@ class EmailCook
   def link_string!(line, unescaped_line)
     unescaped_line = unescaped_line.strip
     line.gsub!(/\S+/) do |str|
-      if str.match?(%r{^(https?://)[\S]+$}i)
+      if str.match?(%r{\A(https?://)[\S]+\z}i)
         begin
           url = URI.parse(str).to_s
           if unescaped_line == url
@@ -48,11 +48,11 @@ class EmailCook
 
     text.each_line do |line|
       # replace indentation with non-breaking spaces
-      line.sub!(/^\s{2,}/) { |s| "\u00A0" * s.length }
+      line.sub!(/\A\s{2,}/) { |s| "\u00A0" * s.length }
 
-      if line =~ /^\s*>/
+      if line =~ /\A\s*>/
         in_quote = true
-        line.sub!(/^[\s>]*/, "")
+        line.sub!(/\A[\s>]*/, "")
 
         unescaped_line = line
         line = CGI.escapeHTML(line)
diff --git a/lib/file_helper.rb b/lib/file_helper.rb
index 9b57251f83c..aeab06d2900 100644
--- a/lib/file_helper.rb
+++ b/lib/file_helper.rb
@@ -52,7 +52,7 @@ class FileHelper
     retain_on_max_file_size_exceeded: false
   )
     url = "https:" + url if url.start_with?("//")
-    raise Discourse::InvalidParameters.new(:url) unless url =~ %r{^https?://}
+    raise Discourse::InvalidParameters.new(:url) unless url =~ %r{\Ahttps?://}
 
     tmp = nil
 
@@ -175,26 +175,26 @@ class FileHelper
   end
 
   def self.supported_video_regexp
-    @@supported_video_regexp ||= /\.(#{supported_video.to_a.join("|")})$/i
+    @@supported_video_regexp ||= /\.(#{supported_video.to_a.join("|")})\z/i
   end
 
   def self.supported_audio_regexp
-    @@supported_audio_regexp ||= /\.(#{supported_audio.to_a.join("|")})$/i
+    @@supported_audio_regexp ||= /\.(#{supported_audio.to_a.join("|")})\z/i
   end
 
   def self.supported_images_regexp
-    @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})$/i
+    @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})\z/i
   end
 
   def self.inline_images_regexp
-    @@inline_images_regexp ||= /\.(#{inline_images.to_a.join("|")})$/i
+    @@inline_images_regexp ||= /\.(#{inline_images.to_a.join("|")})\z/i
   end
 
   def self.supported_media_regexp
     @@supported_media_regexp ||=
       begin
         media = supported_images | supported_audio | supported_video
-        /\.(#{media.to_a.join("|")})$/i
+        /\.(#{media.to_a.join("|")})\z/i
       end
   end
 
@@ -202,7 +202,7 @@ class FileHelper
     @@supported_playable_media_regexp ||=
       begin
         media = supported_audio | supported_video
-        /\.(#{media.to_a.join("|")})$/i
+        /\.(#{media.to_a.join("|")})\z/i
       end
   end
 end
diff --git a/lib/file_store/base_store.rb b/lib/file_store/base_store.rb
index f73114ca897..f1a958fb8a5 100644
--- a/lib/file_store/base_store.rb
+++ b/lib/file_store/base_store.rb
@@ -113,7 +113,7 @@ module FileStore
               end
             )
 
-          url = SiteSetting.scheme + ":" + url if url =~ %r{^//}
+          url = SiteSetting.scheme + ":" + url if url =~ %r{\A//}
           file =
             FileHelper.download(
               url,
diff --git a/lib/file_store/local_store.rb b/lib/file_store/local_store.rb
index c0461aa50a7..3a6f1137557 100644
--- a/lib/file_store/local_store.rb
+++ b/lib/file_store/local_store.rb
@@ -128,7 +128,7 @@ module FileStore
       count = 0
       model.find_each do |upload|
         # could be a remote image
-        next unless upload.url =~ %r{^/[^/]}
+        next unless upload.url =~ %r{\A/[^/]}
 
         path = "#{public_dir}#{upload.url}"
         bad = true
diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb
index 8fdbd2d9fab..03d181467bb 100644
--- a/lib/file_store/s3_store.rb
+++ b/lib/file_store/s3_store.rb
@@ -216,7 +216,7 @@ module FileStore
 
     def path_for(upload)
       url = upload&.url
-      FileStore::LocalStore.new.path_for(upload) if url && url[%r{^/[^/]}]
+      FileStore::LocalStore.new.path_for(upload) if url && url[%r{\A/[^/]}]
     end
 
     def url_for(upload, force_download: false)
@@ -233,7 +233,7 @@ module FileStore
 
     def cdn_url(url)
       return url if SiteSetting.Upload.s3_cdn_url.blank?
-      schema = url[%r{^(https?:)?//}, 1]
+      schema = url[%r{\A(https?:)?//}, 1]
       folder = s3_bucket_folder_path.nil? ? "" : "#{s3_bucket_folder_path}/"
       url.sub(
         File.join("#{schema}#{absolute_base_url}", folder),
diff --git a/lib/freedom_patches/translate_accelerator.rb b/lib/freedom_patches/translate_accelerator.rb
index b13464012b5..a95ab7275c0 100644
--- a/lib/freedom_patches/translate_accelerator.rb
+++ b/lib/freedom_patches/translate_accelerator.rb
@@ -39,7 +39,7 @@ module I18n
 
         if @loaded_locales.empty?
           # load all rb files
-          I18n.backend.load_translations(I18n.load_path.grep(/\.rb$/))
+          I18n.backend.load_translations(I18n.load_path.grep(/\.rb\z/))
 
           # load plural rules from plugins
           DiscoursePluginRegistry.locales.each do |plugin_locale, options|
@@ -50,14 +50,14 @@ module I18n
         end
 
         # load it
-        I18n.backend.load_translations(I18n.load_path.grep(/\.#{Regexp.escape locale}\.yml$/))
+        I18n.backend.load_translations(I18n.load_path.grep(/\.#{Regexp.escape locale}\.yml\z/))
 
         if Discourse.allow_dev_populate?
           I18n.backend.load_translations(
-            I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}\.yml$}),
+            I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}\.yml\z}),
           )
           I18n.backend.load_translations(
-            I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}/.*\.yml$}),
+            I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}/.*\.yml\z}),
           )
         end
 
diff --git a/lib/git_url.rb b/lib/git_url.rb
index 9410c1d3f86..d1980a94de7 100644
--- a/lib/git_url.rb
+++ b/lib/git_url.rb
@@ -10,7 +10,7 @@ module GitUrl
       end
 
       if url.start_with?("https://github.com/") && !url.end_with?(".git")
-        url = url.gsub(%r{/$}, "")
+        url = url.gsub(%r{/\z}, "")
         url += ".git"
       end
 
diff --git a/lib/global_path.rb b/lib/global_path.rb
index 318aa346f30..fb0ad36239e 100644
--- a/lib/global_path.rb
+++ b/lib/global_path.rb
@@ -12,7 +12,7 @@ module GlobalPath
   def upload_cdn_path(p)
     p = Discourse.store.cdn_url(p) if SiteSetting.Upload.s3_cdn_url.present?
 
-    (p =~ /^http/ || p =~ %r{^//}) ? p : cdn_path(p)
+    (p =~ /\Ahttp/ || p =~ %r{\A//}) ? p : cdn_path(p)
   end
 
   def cdn_relative_path(path)
diff --git a/lib/guardian/ensure_magic.rb b/lib/guardian/ensure_magic.rb
index 62cece83b61..3a2dbdea657 100644
--- a/lib/guardian/ensure_magic.rb
+++ b/lib/guardian/ensure_magic.rb
@@ -3,7 +3,7 @@
 # Support for ensure_{blah}! methods.
 module EnsureMagic
   def method_missing(method, *args, &block)
-    if method.to_s =~ /^ensure_(.*)\!$/
+    if method.to_s =~ /\Aensure_(.*)\!\z/
       can_method = :"#{Regexp.last_match[1]}?"
 
       if respond_to?(can_method)
diff --git a/lib/html_prettify.rb b/lib/html_prettify.rb
index 074a3c8a2a5..f56665a96a8 100644
--- a/lib/html_prettify.rb
+++ b/lib/html_prettify.rb
@@ -249,8 +249,8 @@ class HtmlPrettify < String
     # Special case if the very first character is a quote followed by
     # punctuation at a non-word-break. Close the quotes by brute
     # force:
-    str.gsub!(/^'(?=#{punct_class}\B)/, entity(:single_right_quote))
-    str.gsub!(/^"(?=#{punct_class}\B)/, entity(:double_right_quote))
+    str.gsub!(/\A'(?=#{punct_class}\B)/, entity(:single_right_quote))
+    str.gsub!(/\A"(?=#{punct_class}\B)/, entity(:double_right_quote))
 
     # Special case for double sets of quotes, e.g.:
     #   <p>He said, "'Quoted' words in a larger quote."</p>
diff --git a/lib/i18n/locale_file_checker.rb b/lib/i18n/locale_file_checker.rb
index de9eae3056c..e7d71ece03f 100644
--- a/lib/i18n/locale_file_checker.rb
+++ b/lib/i18n/locale_file_checker.rb
@@ -49,7 +49,7 @@ class LocaleFileChecker
   end
 
   def reference_file(path)
-    path = path.gsub(/\.\w{2,}\.yml$/, ".#{REFERENCE_LOCALE}.yml")
+    path = path.gsub(/\.\w{2,}\.yml\z/, ".#{REFERENCE_LOCALE}.yml")
     path if File.exist?(path)
   end
 
diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb
index 553113e7899..a62f4e53cfc 100644
--- a/lib/middleware/anonymous_cache.rb
+++ b/lib/middleware/anonymous_cache.rb
@@ -25,8 +25,8 @@ module Middleware
     def self.compile_key_builder
       method = +"def self.__compiled_key_builder(h)\n  \""
       cache_key_segments.each do |k, v|
-        raise "Invalid key name" unless k =~ /^[a-z]+$/
-        raise "Invalid method name" unless v =~ /^key_[a-z_\?]+$/
+        raise "Invalid key name" unless k =~ /\A[a-z]+\z/
+        raise "Invalid method name" unless v =~ /\Akey_[a-z_\?]+\z/
         method << "|#{k}=#\{h.#{v}}"
       end
       method << "\"\nend"
diff --git a/lib/middleware/missing_avatars.rb b/lib/middleware/missing_avatars.rb
index 958aecaa3ee..b0f6d18a14d 100644
--- a/lib/middleware/missing_avatars.rb
+++ b/lib/middleware/missing_avatars.rb
@@ -11,7 +11,7 @@ module Middleware
     end
 
     def call(env)
-      if (env["REQUEST_PATH"] =~ %r{^/uploads/default/avatars})
+      if (env["REQUEST_PATH"] =~ %r{\A/uploads/default/avatars})
         path = "#{Rails.root}/public#{env["REQUEST_PATH"]}"
         unless File.exist?(path)
           default_image = "#{Rails.root}/public/images/d-logo-sketch-small.png"
diff --git a/lib/migration/safe_migrate.rb b/lib/migration/safe_migrate.rb
index ce3013300e0..26e093c3036 100644
--- a/lib/migration/safe_migrate.rb
+++ b/lib/migration/safe_migrate.rb
@@ -116,7 +116,7 @@ class Migration::SafeMigrate
   end
 
   def self.protect!(sql)
-    if sql =~ /^\s*(?:drop\s+table|alter\s+table.*rename\s+to)\s+/i
+    if sql =~ /\A\s*(?:drop\s+table|alter\s+table.*rename\s+to)\s+/i
       $stdout.puts("", <<~TEXT)
         WARNING
         -------------------------------------------------------------------------------------
@@ -129,7 +129,7 @@ class Migration::SafeMigrate
         in use by live applications.
       TEXT
       raise Discourse::InvalidMigration, "Attempt was made to drop a table"
-    elsif sql =~ /^\s*alter\s+table.*(?:rename|drop)\s+/i
+    elsif sql =~ /\A\s*alter\s+table.*(?:rename|drop)\s+/i
       $stdout.puts("", <<~TEXT)
         WARNING
         -------------------------------------------------------------------------------------
diff --git a/lib/onebox/engine.rb b/lib/onebox/engine.rb
index 838986e685b..94567ad3623 100644
--- a/lib/onebox/engine.rb
+++ b/lib/onebox/engine.rb
@@ -7,7 +7,7 @@ module Onebox
     end
 
     def self.engines
-      constants.select { |constant| constant.to_s =~ /Onebox$/ }.sort.map(&method(:const_get))
+      constants.select { |constant| constant.to_s =~ /Onebox\z/ }.sort.map(&method(:const_get))
     end
 
     def self.all_iframe_origins
diff --git a/lib/onebox/layout.rb b/lib/onebox/layout.rb
index e6e31daa6f9..53c63bb834f 100644
--- a/lib/onebox/layout.rb
+++ b/lib/onebox/layout.rb
@@ -15,7 +15,7 @@ module Onebox
       @record = Onebox::Helpers.symbolize_keys(record)
 
       # Fix any relative paths
-      if @record[:image] && @record[:image] =~ %r{^/[^/]}
+      if @record[:image] && @record[:image] =~ %r{\A/[^/]}
         @record[:image] = "#{uri.scheme}://#{uri.host}/#{@record[:image]}"
       end
 
@@ -40,7 +40,7 @@ module Onebox
         link: record[:link],
         title: record[:title],
         favicon: record[:favicon],
-        domain: record[:domain] || uri.host.to_s.sub(/^www\./, ""),
+        domain: record[:domain] || uri.host.to_s.sub(/\Awww\./, ""),
         article_published_time: record[:article_published_time],
         article_published_time_title: record[:article_published_time_title],
         metadata_1_label: record[:metadata_1_label],
diff --git a/lib/onebox/mixins/git_blob_onebox.rb b/lib/onebox/mixins/git_blob_onebox.rb
index 511a6b60849..713606d687f 100644
--- a/lib/onebox/mixins/git_blob_onebox.rb
+++ b/lib/onebox/mixins/git_blob_onebox.rb
@@ -119,7 +119,7 @@ module Onebox
           a_lines = str.lines
           a_lines.each do |l|
             l = l.chomp("\n") # remove new line
-            m = l.match(/^[ ]*/) # find leading spaces 0 or more
+            m = l.match(/\A[ ]*/) # find leading spaces 0 or more
             unless m.nil? || l.size == m[0].size || l.size == 0 # no match | only spaces in line | empty line
               m_str_length = m[0].size
               if m_str_length <= 1 # minimum space is 1 or nothing we can break we found our minimum
@@ -166,7 +166,7 @@ module Onebox
             @file = m[:file]
             @lang = Onebox::FileTypeFinder.from_file_name(m[:file])
 
-            if @lang == "stl" && link.match?(%r{^https?://(www\.)?github\.com.*/blob/})
+            if @lang == "stl" && link.match?(%r{\Ahttps?://(www\.)?github\.com.*/blob/})
               @model_file = @lang.dup
               @raw = "https://render.githubusercontent.com/view/solid?url=" + self.raw_template(m)
             else
diff --git a/lib/onebox/open_graph.rb b/lib/onebox/open_graph.rb
index 8ff754fc520..10c1f165d9c 100644
--- a/lib/onebox/open_graph.rb
+++ b/lib/onebox/open_graph.rb
@@ -32,8 +32,8 @@ module Onebox
       doc
         .css("meta")
         .each do |m|
-          if (m["property"] && m["property"][/^(?:og|article|product):(.+)$/i]) ||
-               (m["name"] && m["name"][/^(?:og|article|product):(.+)$/i])
+          if (m["property"] && m["property"][/\A(?:og|article|product):(.+)\z/i]) ||
+               (m["name"] && m["name"][/\A(?:og|article|product):(.+)\z/i])
             value = (m["content"] || m["value"]).to_s
             next if Onebox::Helpers.blank?(value)
             key = $1.tr("-:", "_").to_sym
diff --git a/lib/onebox/sanitize_config.rb b/lib/onebox/sanitize_config.rb
index d0ea4ed417a..77f97ee9257 100644
--- a/lib/onebox/sanitize_config.rb
+++ b/lib/onebox/sanitize_config.rb
@@ -58,7 +58,7 @@ module Onebox
                   next unless env[:node_name] == "a"
                   a_tag = env[:node]
                   a_tag["href"] ||= "#"
-                  if a_tag["href"] =~ %r{^(?:[a-z]+:)?//}
+                  if a_tag["href"] =~ %r{\A(?:[a-z]+:)?//}
                     a_tag["rel"] = "nofollow ugc noopener"
                   else
                     a_tag.remove_attribute("target")
diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb
index 2427b9b9b3c..6d6bd34f14f 100644
--- a/lib/oneboxer.rb
+++ b/lib/oneboxer.rb
@@ -6,8 +6,8 @@ Dir["#{Rails.root}/lib/onebox/engine/*_onebox.rb"].sort.each { |f| require f }
 
 module Oneboxer
   ONEBOX_CSS_CLASS = "onebox"
-  AUDIO_REGEX = /^\.(mp3|og[ga]|opus|wav|m4[abpr]|aac|flac)$/i
-  VIDEO_REGEX = /^\.(mov|mp4|webm|m4v|3gp|ogv|avi|mpeg|ogv)$/i
+  AUDIO_REGEX = /\A\.(mp3|og[ga]|opus|wav|m4[abpr]|aac|flac)\z/i
+  VIDEO_REGEX = /\A\.(mov|mp4|webm|m4v|3gp|ogv|avi|mpeg|ogv)\z/i
 
   # keep reloaders happy
   unless defined?(Oneboxer::Result)
diff --git a/lib/plain_text_to_markdown.rb b/lib/plain_text_to_markdown.rb
index 8e582e6f365..d914cd867e5 100644
--- a/lib/plain_text_to_markdown.rb
+++ b/lib/plain_text_to_markdown.rb
@@ -100,7 +100,7 @@ class PlainTextToMarkdown
 
   # @param line [Line]
   def remove_quote_level_indicators!(line)
-    match_data = line.text.match(/^(?<indicators>>+)\s?(?<text>.*)/)
+    match_data = line.text.match(/\A(?<indicators>>+)\s?(?<text>.*)/)
 
     if match_data
       line.text = match_data[:text]
@@ -128,7 +128,7 @@ class PlainTextToMarkdown
   def classify_line_as_code!(line, previous_line)
     line.code_block = previous_line.code_block unless previous_line.nil? ||
       previous_line.valid_code_block?
-    return unless line.text =~ /^\s{0,3}```/
+    return unless line.text =~ /\A\s{0,3}```/
 
     if line.code_block.present?
       line.code_block.end_line = line
@@ -173,7 +173,7 @@ class PlainTextToMarkdown
   end
 
   def indent_with_non_breaking_spaces(text)
-    text.sub(/^\s+/) do |s|
+    text.sub(/\A\s+/) do |s|
       # replace tabs with 2 spaces
       s.gsub!("\t", "  ")
 
diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb
index 743f35283e5..77f9e4ff2c7 100644
--- a/lib/plugin/instance.rb
+++ b/lib/plugin/instance.rb
@@ -675,17 +675,17 @@ class Plugin::Instance
       DiscoursePluginRegistry.register_glob(admin_path, "hbr", admin: true)
 
       DiscourseJsProcessor.plugin_transpile_paths << root_path.sub(Rails.root.to_s, "").sub(
-        %r{^/*},
+        %r{\A/*},
         "",
       )
       DiscourseJsProcessor.plugin_transpile_paths << admin_path.sub(Rails.root.to_s, "").sub(
-        %r{^/*},
+        %r{\A/*},
         "",
       )
 
       test_path = "#{root_dir_name}/test/javascripts"
       DiscourseJsProcessor.plugin_transpile_paths << test_path.sub(Rails.root.to_s, "").sub(
-        %r{^/*},
+        %r{\A/*},
         "",
       )
     end
@@ -1299,7 +1299,7 @@ class Plugin::Instance
   private
 
   def validate_directory_column_name(column_name)
-    match = /^[_a-z]+$/.match(column_name)
+    match = /\A[_a-z]+\z/.match(column_name)
     unless match
       raise "Invalid directory column name '#{column_name}'. Can only contain a-z and underscores"
     end
diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb
index 56a4f0349ff..e041c4fb8f1 100644
--- a/lib/pretty_text.rb
+++ b/lib/pretty_text.rb
@@ -48,7 +48,7 @@ module PrettyText
     filename = find_file(root_path, part_name)
     if filename
       source = File.read("#{root_path}#{filename}")
-      source = ERB.new(source).result(binding) if filename =~ /\.erb$/
+      source = ERB.new(source).result(binding) if filename =~ /\.erb\z/
 
       transpiler = DiscourseJsProcessor::Transpiler.new
       transpiled = transpiler.perform(source, "#{Rails.root}/app/assets/javascripts/", part_name)
@@ -64,7 +64,7 @@ module PrettyText
   def self.ctx_load_directory(ctx, path)
     root_path = "#{Rails.root}/app/assets/javascripts/"
     Dir["#{root_path}#{path}/**/*"].sort.each do |f|
-      apply_es6_file(ctx, root_path, f.sub(root_path, "").sub(/\.js(.es6)?$/, ""))
+      apply_es6_file(ctx, root_path, f.sub(root_path, "").sub(/\.js(.es6)?\z/, ""))
     end
   end
 
@@ -116,9 +116,9 @@ module PrettyText
       to_load << a if File.file?(a) && a =~ /discourse-markdown/
     end
     to_load.uniq.each do |f|
-      if f =~ %r{^.+assets/javascripts/}
+      if f =~ %r{\A.+assets/javascripts/}
         root = Regexp.last_match[0]
-        apply_es6_file(ctx, root, f.sub(root, "").sub(/\.js(\.es6)?$/, ""))
+        apply_es6_file(ctx, root, f.sub(root, "").sub(/\.js(\.es6)?\z/, ""))
       end
     end
 
diff --git a/lib/pretty_text/helpers.rb b/lib/pretty_text/helpers.rb
index 621f59d7f8e..2ca864fb4ab 100644
--- a/lib/pretty_text/helpers.rb
+++ b/lib/pretty_text/helpers.rb
@@ -102,7 +102,7 @@ module PrettyText
     # TODO (martin) Remove this when everything is using hashtag_lookup
     # after enable_experimental_hashtag_autocomplete is default.
     def category_tag_hashtag_lookup(text)
-      is_tag = text =~ /#{TAG_HASHTAG_POSTFIX}$/
+      is_tag = text =~ /#{TAG_HASHTAG_POSTFIX}\z/
 
       if !is_tag && category = Category.query_from_hashtag_slug(text)
         [category.url, text]
diff --git a/lib/require_dependency_backward_compatibility.rb b/lib/require_dependency_backward_compatibility.rb
index 5b550e45d37..b5e3e0248d7 100644
--- a/lib/require_dependency_backward_compatibility.rb
+++ b/lib/require_dependency_backward_compatibility.rb
@@ -14,7 +14,7 @@ module RequireDependencyBackwardCompatibility
   def require_dependency(filename)
     name = filename.to_s
     return if name == "jobs/base"
-    return super(name.sub(%r{^lib/}, "")) if name.start_with?("lib/")
+    return super(name.sub(%r{\Alib/}, "")) if name.start_with?("lib/")
     super
   end
 
diff --git a/lib/retrieve_title.rb b/lib/retrieve_title.rb
index 826792650f4..ddbeb628386 100644
--- a/lib/retrieve_title.rb
+++ b/lib/retrieve_title.rb
@@ -32,7 +32,7 @@ module RetrieveTitle
       # A horrible hack - YouTube uses `document.title` to populate the title
       # for some reason. For any other site than YouTube this wouldn't be worth it.
       if title == "YouTube" && html =~ /document\.title *= *"(.*)";/
-        title = Regexp.last_match[1].sub(/ - YouTube$/, "")
+        title = Regexp.last_match[1].sub(/ - YouTube\z/, "")
       end
 
       if !title && node = doc.at('meta[property="og:title"]')
@@ -53,11 +53,12 @@ module RetrieveTitle
 
   def self.max_chunk_size(uri)
     # Exception for sites that leave the title until very late.
-    if uri.host =~ /(^|\.)amazon\.(com|ca|co\.uk|es|fr|de|it|com\.au|com\.br|cn|in|co\.jp|com\.mx)$/
+    if uri.host =~
+         /(^|\.)amazon\.(com|ca|co\.uk|es|fr|de|it|com\.au|com\.br|cn|in|co\.jp|com\.mx)\z/
       return 500
     end
-    return 300 if uri.host =~ /(^|\.)youtube\.com$/ || uri.host =~ /(^|\.)youtu\.be$/
-    return 50 if uri.host =~ /(^|\.)github\.com$/
+    return 300 if uri.host =~ /(^|\.)youtube\.com\z/ || uri.host =~ /(^|\.)youtu\.be\z/
+    return 50 if uri.host =~ /(^|\.)github\.com\z/
 
     # default is 20k
     20
diff --git a/lib/route_matcher.rb b/lib/route_matcher.rb
index 4e4fd028d1b..2c466055556 100644
--- a/lib/route_matcher.rb
+++ b/lib/route_matcher.rb
@@ -46,7 +46,7 @@ class RouteMatcher
     return true if actions.nil? # actions are unrestricted
 
     # message_bus is not a rails route, special handling
-    return true if actions.include?("message_bus") && request.fullpath =~ %r{^/message-bus/.*/poll}
+    return true if actions.include?("message_bus") && request.fullpath =~ %r{\A/message-bus/.*/poll}
 
     path_params = path_params_from_request(request)
     actions.include? "#{path_params[:controller]}##{path_params[:action]}"
diff --git a/lib/s3_inventory.rb b/lib/s3_inventory.rb
index 91da944b927..3b9f4391e77 100644
--- a/lib/s3_inventory.rb
+++ b/lib/s3_inventory.rb
@@ -334,7 +334,7 @@ class S3Inventory
     objects = []
 
     hive_path = File.join(inventory_path, bucket_name, inventory_id, "hive")
-    @s3_helper.list(hive_path).each { |obj| objects << obj if obj.key.match?(/symlink\.txt$/i) }
+    @s3_helper.list(hive_path).each { |obj| objects << obj if obj.key.match?(/symlink\.txt\z/i) }
 
     objects
   rescue Aws::Errors::ServiceError => e
diff --git a/lib/search.rb b/lib/search.rb
index 77748100573..0429afe2bbd 100644
--- a/lib/search.rb
+++ b/lib/search.rb
@@ -128,7 +128,7 @@ class Search
     end
 
     data.gsub!(/\S+/) do |str|
-      if str =~ %r{^["]?((https?://)[\S]+)["]?$}
+      if str =~ %r{\A["]?((https?://)[\S]+)["]?\z}
         begin
           uri = URI.parse(Regexp.last_match[1])
           uri.query = nil
@@ -145,9 +145,9 @@ class Search
   end
 
   def self.word_to_date(str)
-    return Time.zone.now.beginning_of_day.days_ago(str.to_i) if str =~ /^[0-9]{1,3}$/
+    return Time.zone.now.beginning_of_day.days_ago(str.to_i) if str =~ /\A[0-9]{1,3}\z/
 
-    if str =~ /^([12][0-9]{3})(-([0-1]?[0-9]))?(-([0-3]?[0-9]))?$/
+    if str =~ /\A([12][0-9]{3})(-([0-1]?[0-9]))?(-([0-3]?[0-9]))?\z/
       year = $1.to_i
       month = $2 ? $3.to_i : 1
       day = $4 ? $5.to_i : 1
@@ -307,7 +307,7 @@ class Search
 
     # If the term is a number or url to a topic, just include that topic
     if @opts[:search_for_id] && %w[topic private_messages all_topics].include?(@results.type_filter)
-      if @term =~ /^\d+$/
+      if @term =~ /\A\d+\z/
         single_topic(@term.to_i)
       else
         if route = Discourse.route_for(@term)
@@ -355,7 +355,7 @@ class Search
     Array.wrap(@custom_topic_eager_loads)
   end
 
-  advanced_filter(/^in:personal-direct$/i) do |posts|
+  advanced_filter(/\Ain:personal-direct\z/i) do |posts|
     if @guardian.user
       posts.joins("LEFT JOIN topic_allowed_groups tg ON posts.topic_id = tg.topic_id").where(
         <<~SQL,
@@ -376,60 +376,60 @@ class Search
     end
   end
 
-  advanced_filter(/^in:all-pms$/i) { |posts| posts.private_posts if @guardian.is_admin? }
+  advanced_filter(/\Ain:all-pms\z/i) { |posts| posts.private_posts if @guardian.is_admin? }
 
-  advanced_filter(/^in:tagged$/i) do |posts|
+  advanced_filter(/\Ain:tagged\z/i) do |posts|
     posts.where("EXISTS (SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = posts.topic_id)")
   end
 
-  advanced_filter(/^in:untagged$/i) do |posts|
+  advanced_filter(/\Ain:untagged\z/i) do |posts|
     posts.joins(
       "LEFT JOIN topic_tags ON
         topic_tags.topic_id = posts.topic_id",
     ).where("topic_tags.id IS NULL")
   end
 
-  advanced_filter(/^status:open$/i) do |posts|
+  advanced_filter(/\Astatus:open\z/i) do |posts|
     posts.where("NOT topics.closed AND NOT topics.archived")
   end
 
-  advanced_filter(/^status:closed$/i) { |posts| posts.where("topics.closed") }
+  advanced_filter(/\Astatus:closed\z/i) { |posts| posts.where("topics.closed") }
 
-  advanced_filter(/^status:public$/i) do |posts|
+  advanced_filter(/\Astatus:public\z/i) do |posts|
     category_ids = Category.where(read_restricted: false).pluck(:id)
 
     posts.where("topics.category_id in (?)", category_ids)
   end
 
-  advanced_filter(/^status:archived$/i) { |posts| posts.where("topics.archived") }
+  advanced_filter(/\Astatus:archived\z/i) { |posts| posts.where("topics.archived") }
 
-  advanced_filter(/^status:noreplies$/i) { |posts| posts.where("topics.posts_count = 1") }
+  advanced_filter(/\Astatus:noreplies\z/i) { |posts| posts.where("topics.posts_count = 1") }
 
-  advanced_filter(/^status:single_user$/i) { |posts| posts.where("topics.participant_count = 1") }
+  advanced_filter(/\Astatus:single_user\z/i) { |posts| posts.where("topics.participant_count = 1") }
 
-  advanced_filter(/^posts_count:(\d+)$/i) do |posts, match|
+  advanced_filter(/\Aposts_count:(\d+)\z/i) do |posts, match|
     posts.where("topics.posts_count = ?", match.to_i)
   end
 
-  advanced_filter(/^min_post_count:(\d+)$/i) do |posts, match|
+  advanced_filter(/\Amin_post_count:(\d+)\z/i) do |posts, match|
     posts.where("topics.posts_count >= ?", match.to_i)
   end
 
-  advanced_filter(/^min_posts:(\d+)$/i) do |posts, match|
+  advanced_filter(/\Amin_posts:(\d+)\z/i) do |posts, match|
     posts.where("topics.posts_count >= ?", match.to_i)
   end
 
-  advanced_filter(/^max_posts:(\d+)$/i) do |posts, match|
+  advanced_filter(/\Amax_posts:(\d+)\z/i) do |posts, match|
     posts.where("topics.posts_count <= ?", match.to_i)
   end
 
-  advanced_filter(/^in:first|^f$/i) { |posts| posts.where("posts.post_number = 1") }
+  advanced_filter(/\Ain:first|^f\z/i) { |posts| posts.where("posts.post_number = 1") }
 
-  advanced_filter(/^in:pinned$/i) { |posts| posts.where("topics.pinned_at IS NOT NULL") }
+  advanced_filter(/\Ain:pinned\z/i) { |posts| posts.where("topics.pinned_at IS NOT NULL") }
 
-  advanced_filter(/^in:wiki$/i) { |posts, match| posts.where(wiki: true) }
+  advanced_filter(/\Ain:wiki\z/i) { |posts, match| posts.where(wiki: true) }
 
-  advanced_filter(/^badge:(.*)$/i) do |posts, match|
+  advanced_filter(/\Abadge:(.*)\z/i) do |posts, match|
     badge_id = Badge.where("name ilike ? OR id = ?", match, match.to_i).pluck_first(:id)
     if badge_id
       posts.where(
@@ -454,7 +454,7 @@ class Search
     )
   end
 
-  advanced_filter(/^in:(likes)$/i) do |posts, match|
+  advanced_filter(/\Ain:(likes)\z/i) do |posts, match|
     post_action_type_filter(posts, PostActionType.types[:like]) if @guardian.user
   end
 
@@ -462,7 +462,7 @@ class Search
   # this at some point, as it only acts on posts at the moment. On the other
   # hand, this may not be necessary, as the user bookmark list has advanced
   # search based on a RegisteredBookmarkable's #search_query method.
-  advanced_filter(/^in:(bookmarks)$/i) do |posts, match|
+  advanced_filter(/\Ain:(bookmarks)\z/i) do |posts, match|
     posts.where(<<~SQL, @guardian.user.id) if @guardian.user
         posts.id IN (
           SELECT bookmarkable_id FROM bookmarks
@@ -471,20 +471,20 @@ class Search
       SQL
   end
 
-  advanced_filter(/^in:posted$/i) do |posts|
+  advanced_filter(/\Ain:posted\z/i) do |posts|
     posts.where("posts.user_id = ?", @guardian.user.id) if @guardian.user
   end
 
-  advanced_filter(/^in:(created|mine)$/i) do |posts|
+  advanced_filter(/\Ain:(created|mine)\z/i) do |posts|
     posts.where(user_id: @guardian.user.id, post_number: 1) if @guardian.user
   end
 
-  advanced_filter(/^created:@(.*)$/i) do |posts, match|
+  advanced_filter(/\Acreated:@(.*)\z/i) do |posts, match|
     user_id = User.where(username: match.downcase).pluck_first(:id)
     posts.where(user_id: user_id, post_number: 1)
   end
 
-  advanced_filter(/^in:(watching|tracking)$/i) do |posts, match|
+  advanced_filter(/\Ain:(watching|tracking)\z/i) do |posts, match|
     if @guardian.user
       level = TopicUser.notification_levels[match.downcase.to_sym]
       posts.where(
@@ -499,7 +499,7 @@ class Search
     end
   end
 
-  advanced_filter(/^in:seen$/i) do |posts|
+  advanced_filter(/\Ain:seen\z/i) do |posts|
     if @guardian.user
       posts.joins(
         "INNER JOIN post_timings ON
@@ -511,7 +511,7 @@ class Search
     end
   end
 
-  advanced_filter(/^in:unseen$/i) do |posts|
+  advanced_filter(/\Ain:unseen\z/i) do |posts|
     if @guardian.user
       posts.joins(
         "LEFT JOIN post_timings ON
@@ -523,9 +523,9 @@ class Search
     end
   end
 
-  advanced_filter(/^with:images$/i) { |posts| posts.where("posts.image_upload_id IS NOT NULL") }
+  advanced_filter(/\Awith:images\z/i) { |posts| posts.where("posts.image_upload_id IS NOT NULL") }
 
-  advanced_filter(/^category:(.+)$/i) do |posts, match|
+  advanced_filter(/\Acategory:(.+)\z/i) do |posts, match|
     exact = false
 
     if match[0] == "="
@@ -544,7 +544,7 @@ class Search
     end
   end
 
-  advanced_filter(/^\#([\p{L}\p{M}0-9\-:=]+)$/i) do |posts, match|
+  advanced_filter(/\A\#([\p{L}\p{M}0-9\-:=]+)\z/i) do |posts, match|
     category_slug, subcategory_slug = match.to_s.split(":")
     next unless category_slug
 
@@ -614,7 +614,7 @@ class Search
     end
   end
 
-  advanced_filter(/^group:(.+)$/i) do |posts, match|
+  advanced_filter(/\Agroup:(.+)\z/i) do |posts, match|
     group_query =
       Group
         .visible_groups(@guardian.user)
@@ -637,7 +637,7 @@ class Search
     end
   end
 
-  advanced_filter(/^group_messages:(.+)$/i) do |posts, match|
+  advanced_filter(/\Agroup_messages:(.+)\z/i) do |posts, match|
     group_id =
       Group
         .visible_groups(@guardian.user)
@@ -656,7 +656,7 @@ class Search
     end
   end
 
-  advanced_filter(/^user:(.+)$/i) do |posts, match|
+  advanced_filter(/\Auser:(.+)\z/i) do |posts, match|
     user_id =
       User
         .where(staged: false)
@@ -669,7 +669,7 @@ class Search
     end
   end
 
-  advanced_filter(/^\@(\S+)$/i) do |posts, match|
+  advanced_filter(/\A\@(\S+)\z/i) do |posts, match|
     username = User.normalize_username(match)
 
     user_id = User.not_staged.where(username_lower: username).pluck_first(:id)
@@ -683,7 +683,7 @@ class Search
     end
   end
 
-  advanced_filter(/^before:(.*)$/i) do |posts, match|
+  advanced_filter(/\Abefore:(.*)\z/i) do |posts, match|
     if date = Search.word_to_date(match)
       posts.where("posts.created_at < ?", date)
     else
@@ -691,7 +691,7 @@ class Search
     end
   end
 
-  advanced_filter(/^after:(.*)$/i) do |posts, match|
+  advanced_filter(/\Aafter:(.*)\z/i) do |posts, match|
     if date = Search.word_to_date(match)
       posts.where("posts.created_at > ?", date)
     else
@@ -699,15 +699,15 @@ class Search
     end
   end
 
-  advanced_filter(/^tags?:([\p{L}\p{M}0-9,\-_+]+)$/i) do |posts, match|
+  advanced_filter(/\Atags?:([\p{L}\p{M}0-9,\-_+]+)\z/i) do |posts, match|
     search_tags(posts, match, positive: true)
   end
 
-  advanced_filter(/^\-tags?:([\p{L}\p{M}0-9,\-_+]+)$/i) do |posts, match|
+  advanced_filter(/\A\-tags?:([\p{L}\p{M}0-9,\-_+]+)\z/i) do |posts, match|
     search_tags(posts, match, positive: false)
   end
 
-  advanced_filter(/^filetypes?:([a-zA-Z0-9,\-_]+)$/i) do |posts, match|
+  advanced_filter(/\Afiletypes?:([a-zA-Z0-9,\-_]+)\z/i) do |posts, match|
     file_extensions = match.split(",").map(&:downcase)
     posts.where(
       "posts.id IN (
@@ -726,11 +726,11 @@ class Search
     )
   end
 
-  advanced_filter(/^min_views:(\d+)$/i) do |posts, match|
+  advanced_filter(/\Amin_views:(\d+)\z/i) do |posts, match|
     posts.where("topics.views >= ?", match.to_i)
   end
 
-  advanced_filter(/^max_views:(\d+)$/i) do |posts, match|
+  advanced_filter(/\Amax_views:(\d+)\z/i) do |posts, match|
     posts.where("topics.views <= ?", match.to_i)
   end
 
@@ -789,38 +789,38 @@ class Search
         if word == "l"
           @order = :latest
           nil
-        elsif word =~ /^order:\w+$/i
+        elsif word =~ /\Aorder:\w+\z/i
           @order = word.downcase.gsub("order:", "").to_sym
           nil
-        elsif word =~ /^in:title$/i || word == "t"
+        elsif word =~ /\Ain:title\z/i || word == "t"
           @in_title = true
           nil
-        elsif word =~ /^topic:(\d+)$/i
+        elsif word =~ /\Atopic:(\d+)\z/i
           topic_id = $1.to_i
           if topic_id > 1
             topic = Topic.find_by(id: topic_id)
             @search_context = topic if @guardian.can_see?(topic)
           end
           nil
-        elsif word =~ /^in:all$/i
+        elsif word =~ /\Ain:all\z/i
           @search_all_topics = true
           nil
-        elsif word =~ /^in:personal$/i
+        elsif word =~ /\Ain:personal\z/i
           @search_pms = true
           nil
-        elsif word =~ /^in:messages$/i
+        elsif word =~ /\Ain:messages\z/i
           @search_pms = true
           nil
-        elsif word =~ /^in:personal-direct$/i
+        elsif word =~ /\Ain:personal-direct\z/i
           @search_pms = true
           nil
-        elsif word =~ /^in:all-pms$/i
+        elsif word =~ /\Ain:all-pms\z/i
           @search_all_pms = true
           nil
-        elsif word =~ /^group_messages:(.+)$/i
+        elsif word =~ /\Agroup_messages:(.+)\z/i
           @search_pms = true
           nil
-        elsif word =~ /^personal_messages:(.+)$/i
+        elsif word =~ /\Apersonal_messages:(.+)\z/i
           if user = User.find_by_username($1)
             @search_pms = true
             @search_context = user
diff --git a/lib/shrink_uploaded_image.rb b/lib/shrink_uploaded_image.rb
index d1a7f14f012..f46a3b72073 100644
--- a/lib/shrink_uploaded_image.rb
+++ b/lib/shrink_uploaded_image.rb
@@ -98,7 +98,7 @@ class ShrinkUploadedImage
       elsif !post.topic || post.topic.trashed?
         log "A deleted topic"
       elsif post.cooked.include?(original_upload.sha1)
-        if post.raw.include?("#{Discourse.base_url.sub(%r{^https?://}i, "")}/t/")
+        if post.raw.include?("#{Discourse.base_url.sub(%r{\Ahttps?://}i, "")}/t/")
           log "Updating a topic onebox"
         else
           log "Updating an external onebox"
diff --git a/lib/site_settings/validations.rb b/lib/site_settings/validations.rb
index fdc36ff57e4..b8ac0309644 100644
--- a/lib/site_settings/validations.rb
+++ b/lib/site_settings/validations.rb
@@ -243,7 +243,7 @@ module SiteSettings::Validations
 
   def validate_cors_origins(new_val)
     return if new_val.blank?
-    return unless new_val.split("|").any?(%r{/$})
+    return unless new_val.split("|").any?(%r{/\z})
     validate_error :cors_origins_should_not_have_trailing_slash
   end
 
diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb
index 217bed7737e..a9d636b6c3b 100644
--- a/lib/stylesheet/manager.rb
+++ b/lib/stylesheet/manager.rb
@@ -11,7 +11,7 @@ class Stylesheet::Manager
 
   CACHE_PATH ||= "tmp/stylesheet-cache"
   MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}"
-  THEME_REGEX ||= /_theme$/
+  THEME_REGEX ||= /_theme\z/
   COLOR_SCHEME_STYLESHEET ||= "color_definitions"
 
   @@lock = Mutex.new
diff --git a/lib/stylesheet/manager/builder.rb b/lib/stylesheet/manager/builder.rb
index 47dbae0650c..9e022b8d59b 100644
--- a/lib/stylesheet/manager/builder.rb
+++ b/lib/stylesheet/manager/builder.rb
@@ -35,7 +35,7 @@ class Stylesheet::Manager::Builder
       end
     end
 
-    rtl = @target.to_s =~ /_rtl$/
+    rtl = @target.to_s =~ /_rtl\z/
     css, source_map =
       with_load_paths do |load_paths|
         Stylesheet::Compiler.compile_asset(
diff --git a/lib/stylesheet/watcher.rb b/lib/stylesheet/watcher.rb
index a2c8189e83b..8e41e6832a1 100644
--- a/lib/stylesheet/watcher.rb
+++ b/lib/stylesheet/watcher.rb
@@ -21,7 +21,7 @@ module Stylesheet
       @default_paths = ["app/assets/stylesheets"]
       Discourse.plugins.each do |plugin|
         if plugin.path.to_s.include?(Rails.root.to_s)
-          @default_paths << File.dirname(plugin.path).sub(Rails.root.to_s, "").sub(%r{^/}, "")
+          @default_paths << File.dirname(plugin.path).sub(Rails.root.to_s, "").sub(%r{\A/}, "")
         else
           # if plugin doesn’t seem to be in our app, consider it as outside of the app
           # and ignore it
@@ -41,7 +41,7 @@ module Stylesheet
         end
       end
 
-      listener_opts = { ignore: /xxxx/, only: /\.(css|scss)$/ }
+      listener_opts = { ignore: /xxxx/, only: /\.(css|scss)\z/ }
       listener_opts[:force_polling] = true if ENV["FORCE_POLLING"]
 
       Thread.new do
diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
index f00ab3068e9..1c9b3a3311f 100644
--- a/lib/tasks/assets.rake
+++ b/lib/tasks/assets.rake
@@ -307,7 +307,7 @@ task "assets:precompile" => "assets:precompile:before" do
       concurrent? do |proc|
         manifest
           .files
-          .select { |k, v| k =~ /\.js$/ }
+          .select { |k, v| k =~ /\.js\z/ }
           .each do |file, info|
             path = "#{assets_path}/#{file}"
             _file = (d = File.dirname(file)) == "." ? "_#{file}" : "#{d}/_#{File.basename(file)}"
diff --git a/lib/tasks/cdn.rake b/lib/tasks/cdn.rake
index ee2b0c14ee0..559144bf9d2 100644
--- a/lib/tasks/cdn.rake
+++ b/lib/tasks/cdn.rake
@@ -10,7 +10,7 @@ task "assets:prestage" => :environment do |t|
   def get_assets(path)
     Dir
       .glob("#{Rails.root}/public/assets/#{path}*")
-      .map { |f| "/assets/#{path}#{f.split("/")[-1]}" if f =~ /[a-f0-9]{16}\.(css|js)$/ }
+      .map { |f| "/assets/#{path}#{f.split("/")[-1]}" if f =~ /[a-f0-9]{16}\.(css|js)\z/ }
       .compact
   end
 
diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake
index 403fd6749cb..68fb2d50f7d 100644
--- a/lib/tasks/db.rake
+++ b/lib/tasks/db.rake
@@ -109,7 +109,7 @@ class SeedHelper
   def self.filter
     # Allows a plugin to exclude any specified seed data files from running
     if DiscoursePluginRegistry.seedfu_filter.any?
-      /^(?!.*(#{DiscoursePluginRegistry.seedfu_filter.to_a.join("|")})).*$/
+      /\A(?!.*(#{DiscoursePluginRegistry.seedfu_filter.to_a.join("|")})).*\z/
     else
       nil
     end
diff --git a/lib/tasks/emoji.rake b/lib/tasks/emoji.rake
index fe049fc550d..e9bce247dce 100644
--- a/lib/tasks/emoji.rake
+++ b/lib/tasks/emoji.rake
@@ -459,7 +459,7 @@ end
 def codepoints_to_code(codepoints, fitzpatrick_scale)
   codepoints = codepoints.map { |c| c.to_s(16).rjust(4, "0") }.join("_").downcase
 
-  codepoints.gsub!(/_fe0f$/, "") if !fitzpatrick_scale
+  codepoints.gsub!(/_fe0f\z/, "") if !fitzpatrick_scale
 
   codepoints
 end
diff --git a/lib/tasks/plugin.rake b/lib/tasks/plugin.rake
index 32255f2f301..6f4d67fe12e 100644
--- a/lib/tasks/plugin.rake
+++ b/lib/tasks/plugin.rake
@@ -86,7 +86,7 @@ task "plugin:update", :plugin do |t, args|
 
   upstream_branch =
     `git -C '#{plugin_path}' for-each-ref --format='%(upstream:short)' $(git -C '#{plugin_path}' symbolic-ref -q HEAD)`.strip
-  has_origin_main = `git -C '#{plugin_path}' branch -a`.match?(%r{remotes/origin/main$})
+  has_origin_main = `git -C '#{plugin_path}' branch -a`.match?(%r{remotes/origin/main\z})
   has_local_main = `git -C '#{plugin_path}' show-ref refs/heads/main`.present?
 
   if upstream_branch == "origin/master" && has_origin_main
diff --git a/lib/tasks/release_note.rake b/lib/tasks/release_note.rake
index 8f665d6eba8..e4bbe63f3d0 100644
--- a/lib/tasks/release_note.rake
+++ b/lib/tasks/release_note.rake
@@ -3,12 +3,12 @@
 DATE_REGEX ||= /\A\d{4}-\d{2}-\d{2}/
 
 CHANGE_TYPES ||= [
-  { pattern: /^FEATURE:/, heading: "New Features" },
-  { pattern: /^FIX:/, heading: "Bug Fixes" },
-  { pattern: /^UX:/, heading: "UX Changes" },
-  { pattern: /^SECURITY:/, heading: "Security Changes" },
-  { pattern: /^PERF:/, heading: "Performance" },
-  { pattern: /^A11Y:/, heading: "Accessibility" },
+  { pattern: /\AFEATURE:/, heading: "New Features" },
+  { pattern: /\AFIX:/, heading: "Bug Fixes" },
+  { pattern: /\AUX:/, heading: "UX Changes" },
+  { pattern: /\ASECURITY:/, heading: "Security Changes" },
+  { pattern: /\APERF:/, heading: "Performance" },
+  { pattern: /\AA11Y:/, heading: "Accessibility" },
 ]
 
 desc "generate a release note from the important commits"
@@ -83,7 +83,7 @@ def find_changes(repo, from, to)
   CHANGE_TYPES.each { |ct| changes[ct] = Set.new }
 
   out.each_line do |comment|
-    next if comment =~ /^\s*Revert/
+    next if comment =~ /\A\s*Revert/
     split_comments(comment).each do |line|
       ct = CHANGE_TYPES.find { |t| line =~ t[:pattern] }
       changes[ct] << better(line) if ct
@@ -122,7 +122,7 @@ def better(line)
 end
 
 def remove_prefix(line)
-  line.gsub(/^(FIX|FEATURE|UX|SECURITY|PERF|A11Y):/, "").strip
+  line.gsub(/\A(FIX|FEATURE|UX|SECURITY|PERF|A11Y):/, "").strip
 end
 
 def escape_brackets(line)
@@ -130,7 +130,7 @@ def escape_brackets(line)
 end
 
 def remove_pull_request(line)
-  line.gsub(/ \(\#\d+\)$/, "")
+  line.gsub(/ \(\#\d+\)\z/, "")
 end
 
 def split_comments(text)
diff --git a/lib/tasks/typepad.thor b/lib/tasks/typepad.thor
index 649f12db523..bbef98037e6 100644
--- a/lib/tasks/typepad.thor
+++ b/lib/tasks/typepad.thor
@@ -34,7 +34,7 @@ class Typepad < Thor
     File.open(options[:file]).each_line do |l|
       l = l.scrub
 
-      if l =~ /^--------$/
+      if l =~ /\A--------\z/
         parsed_entry = process_entry(input)
         if parsed_entry
           puts "Parsed #{parsed_entry[:title]}"
@@ -119,7 +119,7 @@ class Typepad < Thor
   def parse_meta_data(section)
     result = {}
     section.split(/\n/).each do |l|
-      if l =~ /^([A-Z\ ]+)\: (.*)$/
+      if l =~ /\A([A-Z\ ]+)\: (.*)\z/
         key, value = Regexp.last_match[1], Regexp.last_match[2]
         clean_type!(key)
         value.strip!
@@ -134,7 +134,7 @@ class Typepad < Thor
 
   def parse_section(section)
     section.strip!
-    if section =~ /^([^:]+):/
+    if section =~ /\A([^:]+):/
       type = clean_type!(Regexp.last_match[1])
       value = section.split("\n")[1..-1].join("\n")
       value.strip!
@@ -195,8 +195,8 @@ class Typepad < Thor
 
         comment[:name] = comment[:author]
         if comment[:author]
-          comment[:author].gsub!(/^[_\.]+/, '')
-          comment[:author].gsub!(/[_\.]+$/, '')
+          comment[:author].gsub!(/\A[_\.]+/, '')
+          comment[:author].gsub!(/[_\.]+\z/, '')
 
           if comment[:author].size < 12
             comment[:author].gsub!(/ /, '_')
diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake
index 6314efff696..1104a9f3a77 100644
--- a/lib/tasks/uploads.rake
+++ b/lib/tasks/uploads.rake
@@ -35,7 +35,7 @@ def gather_uploads
     .where("url !~ ?", "^\/uploads\/#{current_db}")
     .find_each do |upload|
       begin
-        old_db = upload.url[%r{^/uploads/([^/]+)/}, 1]
+        old_db = upload.url[%r{\A/uploads/([^/]+)/}, 1]
         from = upload.url.dup
         to = upload.url.sub("/uploads/#{old_db}/", "/uploads/#{current_db}/")
         source = "#{public_directory}#{from}"
@@ -321,8 +321,8 @@ def regenerate_missing_optimized
     scope.find_each do |optimized_image|
       upload = optimized_image.upload
 
-      next unless optimized_image.url =~ %r{^/[^/]}
-      next unless upload.url =~ %r{^/[^/]}
+      next unless optimized_image.url =~ %r{\A/[^/]}
+      next unless upload.url =~ %r{\A/[^/]}
 
       thumbnail = "#{public_directory}#{optimized_image.url}"
       original = "#{public_directory}#{upload.url}"
diff --git a/lib/theme_javascript_compiler.rb b/lib/theme_javascript_compiler.rb
index 65d10d96fe6..f67009bec3c 100644
--- a/lib/theme_javascript_compiler.rb
+++ b/lib/theme_javascript_compiler.rb
@@ -200,7 +200,7 @@ class ThemeJavascriptCompiler
   end
 
   def raw_template_name(name)
-    name = name.sub(/\.(raw|hbr)$/, "")
+    name = name.sub(/\.(raw|hbr)\z/, "")
     name.inspect
   end
 
@@ -228,7 +228,7 @@ class ThemeJavascriptCompiler
 
   def append_module(script, name, include_variables: true)
     original_filename = name
-    name = "discourse/theme-#{@theme_id}/#{name.gsub(%r{^discourse/}, "")}"
+    name = "discourse/theme-#{@theme_id}/#{name.gsub(%r{\Adiscourse/}, "")}"
 
     script = "#{theme_settings}#{script}" if include_variables
     transpiler = DiscourseJsProcessor::Transpiler.new
diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb
index 084c71584a3..f9cdc99b441 100644
--- a/lib/topic_creator.rb
+++ b/lib/topic_creator.rb
@@ -181,7 +181,7 @@ class TopicCreator
 
         return Category.find(SiteSetting.shared_drafts_category) if @opts[:shared_draft]
 
-        if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /^\d+$/)
+        if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /\A\d+\z/)
           Category.find_by(id: @opts[:category])
         end
       end
diff --git a/lib/topic_query.rb b/lib/topic_query.rb
index 37c3120f491..4d3ea15bbd9 100644
--- a/lib/topic_query.rb
+++ b/lib/topic_query.rb
@@ -14,7 +14,7 @@ class TopicQuery
   def self.validators
     @validators ||=
       begin
-        int = lambda { |x| Integer === x || (String === x && x.match?(/^-?[0-9]+$/)) }
+        int = lambda { |x| Integer === x || (String === x && x.match?(/\A-?[0-9]+\z/)) }
         zero_up_to_max_int = lambda { |x| int.call(x) && x.to_i.between?(0, PG_MAX_INT) }
         array_or_string = lambda { |x| Array === x || String === x }
 
diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb
index 32922cc468d..c826a4b906f 100644
--- a/lib/upload_creator.rb
+++ b/lib/upload_creator.rb
@@ -386,7 +386,7 @@ class UploadCreator
   end
 
   def convert_heif_to_jpeg?
-    File.extname(@filename).downcase.match?(/\.hei(f|c)$/)
+    File.extname(@filename).downcase.match?(/\.hei(f|c)\z/)
   end
 
   def convert_heif!
@@ -593,7 +593,7 @@ class UploadCreator
   def should_optimize?
     # GIF is too slow (plus, we'll soon be converting them to MP4)
     # Optimizing SVG is useless
-    return false if @file.path =~ /\.(gif|svg)$/i
+    return false if @file.path =~ /\.(gif|svg)\z/i
     # Safeguard for large PNGs
     return pixels < 2_000_000 if @file.path =~ /\.png/i
     # Everything else is fine!
diff --git a/lib/url_helper.rb b/lib/url_helper.rb
index 097b3866db0..320f3babd28 100644
--- a/lib/url_helper.rb
+++ b/lib/url_helper.rb
@@ -48,8 +48,8 @@ class UrlHelper
   end
 
   def self.absolute(url, cdn = Discourse.asset_host)
-    cdn = "https:#{cdn}" if cdn && cdn =~ %r{^//}
-    url =~ %r{^/[^/]} ? (cdn || Discourse.base_url_no_prefix) + url : url
+    cdn = "https:#{cdn}" if cdn && cdn =~ %r{\A//}
+    url =~ %r{\A/[^/]} ? (cdn || Discourse.base_url_no_prefix) + url : url
   end
 
   def self.absolute_without_cdn(url)
@@ -57,7 +57,7 @@ class UrlHelper
   end
 
   def self.schemaless(url)
-    url.sub(/^http:/i, "")
+    url.sub(/\Ahttp:/i, "")
   end
 
   def self.secure_proxy_without_cdn(url)
diff --git a/lib/validators/css_color_validator.rb b/lib/validators/css_color_validator.rb
index fdc1fe8f28a..6085ab49aa3 100644
--- a/lib/validators/css_color_validator.rb
+++ b/lib/validators/css_color_validator.rb
@@ -156,7 +156,7 @@ class CssColorValidator
   end
 
   def valid_value?(val)
-    !!(val =~ /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ || COLORS.include?(val&.downcase))
+    !!(val =~ /\A#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/ || COLORS.include?(val&.downcase))
   end
 
   def error_message
diff --git a/lib/validators/unicode_username_allowlist_validator.rb b/lib/validators/unicode_username_allowlist_validator.rb
index 824e5e43442..f60c161707c 100644
--- a/lib/validators/unicode_username_allowlist_validator.rb
+++ b/lib/validators/unicode_username_allowlist_validator.rb
@@ -9,7 +9,7 @@ class UnicodeUsernameAllowlistValidator
     @error_message = nil
     return true if value.blank?
 
-    if value.match?(%r{^/.*/[imxo]*$})
+    if value.match?(%r{\A/.*/[imxo]*\z})
       @error_message =
         I18n.t("site_settings.errors.allowed_unicode_usernames.leading_trailing_slash")
     else