From 8b7e65e137b8dedfc6f19cbd1abde93f70502af4 Mon Sep 17 00:00:00 2001
From: Paul Gascou-Vaillancourt <pgascouvaillancourt@gitlab.com>
Date: Thu, 17 Oct 2024 16:17:26 +0000
Subject: [PATCH] Create the `HelpPagePath/EnsureValidLinks` cop

This creates a cop that ensures that `help_page_path` usages link to
existing documentation.
As the documentation gets updated, it's easy to forget that we link to
it from many parts of the product. Those links can get outdated as
documented are removed, section moved around, titles changed, etc.
This cop should ensure the product links stay in sync with the
documentation.
---
 .../gitlab/documentation_links/link.yml       | 188 ++++++++++++++++++
 .../cop/gitlab/documentation_links/link.rb    | 182 +++++++++++++++++
 rubocop/rubocop-documentation.yml             |   8 +
 .../gitlab/documentation_links/link_spec.rb   | 156 +++++++++++++++
 4 files changed, 534 insertions(+)
 create mode 100644 .rubocop_todo/gitlab/documentation_links/link.yml
 create mode 100644 rubocop/cop/gitlab/documentation_links/link.rb
 create mode 100644 rubocop/rubocop-documentation.yml
 create mode 100644 spec/rubocop/cop/gitlab/documentation_links/link_spec.rb

diff --git a/.rubocop_todo/gitlab/documentation_links/link.yml b/.rubocop_todo/gitlab/documentation_links/link.yml
new file mode 100644
index 0000000000000..e41a645ac2b91
--- /dev/null
+++ b/.rubocop_todo/gitlab/documentation_links/link.yml
@@ -0,0 +1,188 @@
+---
+# Cop supports --autocorrect.
+Gitlab/DocumentationLinks/Link:
+  Details: grace period
+  Exclude:
+    - '**/*.haml' # Excluding HAML files so as to not conflict with the DocumentationLinks HAML linter
+    - 'app/controllers/concerns/enforces_two_factor_authentication.rb'
+    - 'app/controllers/import/github_controller.rb'
+    - 'app/controllers/jira_connect/app_descriptor_controller.rb'
+    - 'app/controllers/jwt_controller.rb'
+    - 'app/controllers/metrics_controller.rb'
+    - 'app/controllers/repositories/git_http_client_controller.rb'
+    - 'app/helpers/ci/jobs_helper.rb'
+    - 'app/helpers/ci/pipeline_editor_helper.rb'
+    - 'app/helpers/clusters_helper.rb'
+    - 'app/helpers/container_registry/container_registry_helper.rb'
+    - 'app/helpers/environments_helper.rb'
+    - 'app/helpers/feature_flags_helper.rb'
+    - 'app/helpers/ide_helper.rb'
+    - 'app/helpers/import_helper.rb'
+    - 'app/helpers/integrations_helper.rb'
+    - 'app/helpers/invite_members_helper.rb'
+    - 'app/helpers/issuables_helper.rb'
+    - 'app/helpers/issues_helper.rb'
+    - 'app/helpers/merge_requests_helper.rb'
+    - 'app/helpers/mirror_helper.rb'
+    - 'app/helpers/notes_helper.rb'
+    - 'app/helpers/operations_helper.rb'
+    - 'app/helpers/packages_helper.rb'
+    - 'app/helpers/preferences_helper.rb'
+    - 'app/helpers/projects/security/configuration_helper.rb'
+    - 'app/helpers/projects_helper.rb'
+    - 'app/helpers/releases_helper.rb'
+    - 'app/helpers/search_helper.rb'
+    - 'app/helpers/visibility_level_helper.rb'
+    - 'app/helpers/wiki_helper.rb'
+    - 'app/mailers/emails/identity_verification.rb'
+    - 'app/models/concerns/ci/has_completion_reason.rb'
+    - 'app/models/integration.rb'
+    - 'app/models/integrations/apple_app_store.rb'
+    - 'app/models/integrations/beyond_identity.rb'
+    - 'app/models/integrations/google_play.rb'
+    - 'app/models/integrations/irker.rb'
+    - 'app/models/integrations/jira.rb'
+    - 'app/models/integrations/jira_cloud_app.rb'
+    - 'app/models/integrations/phorge.rb'
+    - 'app/models/integrations/zentao.rb'
+    - 'app/models/key.rb'
+    - 'app/models/user.rb'
+    - 'app/presenters/clusters/cluster_presenter.rb'
+    - 'app/presenters/commit_status_presenter.rb'
+    - 'app/presenters/dev_ops_report/metric_presenter.rb'
+    - 'app/presenters/group_clusterable_presenter.rb'
+    - 'app/presenters/instance_clusterable_presenter.rb'
+    - 'app/presenters/key_presenter.rb'
+    - 'app/presenters/project_clusterable_presenter.rb'
+    - 'app/presenters/projects/security/configuration_presenter.rb'
+    - 'app/serializers/issue_entity.rb'
+    - 'app/serializers/merge_request_noteable_entity.rb'
+    - 'app/serializers/merge_request_widget_entity.rb'
+    - 'app/services/jira/requests/base.rb'
+    - 'app/services/merge_requests/refresh_service.rb'
+    - 'app/services/projects/update_service.rb'
+    - 'ee/app/components/namespaces/combined_storage_users/base_alert_component.rb'
+    - 'ee/app/components/namespaces/free_user_cap/enforcement_alert_component.rb'
+    - 'ee/app/components/namespaces/storage/limit_alert_component.rb'
+    - 'ee/app/components/namespaces/storage/pre_enforcement_alert_component.rb'
+    - 'ee/app/components/namespaces/storage/repository_limit_alert_component.rb'
+    - 'ee/app/controllers/concerns/gitlab_subscriptions/trials/duo_common.rb'
+    - 'ee/app/helpers/billing_plans_helper.rb'
+    - 'ee/app/helpers/dependencies_helper.rb'
+    - 'ee/app/helpers/ee/container_registry/container_registry_helper.rb'
+    - 'ee/app/helpers/ee/form_helper.rb'
+    - 'ee/app/helpers/ee/groups/settings_helper.rb'
+    - 'ee/app/helpers/ee/groups_helper.rb'
+    - 'ee/app/helpers/ee/import_helper.rb'
+    - 'ee/app/helpers/ee/projects_helper.rb'
+    - 'ee/app/helpers/ee/security_orchestration_helper.rb'
+    - 'ee/app/helpers/epics_helper.rb'
+    - 'ee/app/helpers/groups/add_ons/discover_duo_pro_helper.rb'
+    - 'ee/app/helpers/groups/discovers_helper.rb'
+    - 'ee/app/helpers/member_roles_helper.rb'
+    - 'ee/app/helpers/namespaces/free_user_cap_helper.rb'
+    - 'ee/app/helpers/projects/learn_gitlab_helper.rb'
+    - 'ee/app/helpers/projects/security/api_fuzzing_configuration_helper.rb'
+    - 'ee/app/helpers/projects/security/sast_configuration_helper.rb'
+    - 'ee/app/helpers/security_helper.rb'
+    - 'ee/app/helpers/vulnerabilities_helper.rb'
+    - 'ee/app/models/ee/member.rb'
+    - 'ee/app/models/integrations/git_guardian.rb'
+    - 'ee/app/models/integrations/github.rb'
+    - 'ee/app/presenters/ee/merge_request_presenter.rb'
+    - 'ee/app/serializers/epic_entity.rb'
+    - 'ee/app/services/ee/auth/container_registry_authentication_service.rb'
+    - 'ee/app/services/incident_management/create_incident_sla_exceeded_label_service.rb'
+    - 'ee/app/services/merge_requests/update_blocks_service.rb'
+    - 'ee/app/services/merge_trains/refresh_merge_request_service.rb'
+    - 'ee/app/services/search/rake_task_executor_service.rb'
+    - 'ee/app/services/security/security_orchestration_policies/project_create_service.rb'
+    - 'ee/lib/api/managed_licenses.rb'
+    - 'ee/lib/api/member_roles.rb'
+    - 'ee/lib/ee/gitlab/namespace_storage_size_error_message.rb'
+    - 'ee/lib/gitlab/analytics/cycle_analytics/summary/change_failure_rate.rb'
+    - 'ee/lib/gitlab/analytics/cycle_analytics/summary/group/deployment_frequency.rb'
+    - 'ee/lib/gitlab/analytics/cycle_analytics/summary/lead_time.rb'
+    - 'ee/lib/gitlab/analytics/cycle_analytics/summary/lead_time_for_changes.rb'
+    - 'ee/lib/gitlab/analytics/cycle_analytics/summary/time_to_merge.rb'
+    - 'ee/lib/gitlab/analytics/cycle_analytics/summary/time_to_restore_service.rb'
+    - 'ee/lib/gitlab/checks/secrets_check.rb'
+    - 'ee/lib/gitlab/expiring_subscription_message.rb'
+    - 'ee/lib/gitlab/llm/chain/tools/tool.rb'
+    - 'ee/lib/gitlab/llm/embeddings/utils/base_content_parser.rb'
+    - 'ee/lib/gitlab/root_excess_size_error_message.rb'
+    - 'ee/lib/security/scan_result_policies/policy_violation_comment.rb'
+    - 'ee/lib/system_check/geo/current_node_check.rb'
+    - 'ee/spec/components/namespaces/free_user_cap/enforcement_alert_component_spec.rb'
+    - 'ee/spec/components/namespaces/free_user_cap/non_owner_enforcement_alert_component_spec.rb'
+    - 'ee/spec/components/namespaces/storage/pre_enforcement_alert_component_spec.rb'
+    - 'ee/spec/features/admin/admin_show_new_user_signups_cap_alert_spec.rb'
+    - 'ee/spec/features/groups/group_overview_spec.rb'
+    - 'ee/spec/features/merge_request/user_edits_multiple_reviewers_mr_spec.rb'
+    - 'ee/spec/features/projects/show_project_spec.rb'
+    - 'ee/spec/features/search/elastic/project_search_spec.rb'
+    - 'ee/spec/features/search/zoekt/search_spec.rb'
+    - 'ee/spec/features/security/project/discover_spec.rb'
+    - 'ee/spec/helpers/container_registry/container_registry_helper_spec.rb'
+    - 'ee/spec/helpers/ee/projects/security/api_fuzzing_configuration_helper_spec.rb'
+    - 'ee/spec/helpers/ee/projects/security/sast_configuration_helper_spec.rb'
+    - 'ee/spec/helpers/epics_helper_spec.rb'
+    - 'ee/spec/helpers/member_roles_helper_spec.rb'
+    - 'ee/spec/helpers/projects/learn_gitlab_helper_spec.rb'
+    - 'ee/spec/helpers/projects_helper_spec.rb'
+    - 'ee/spec/helpers/security_helper_spec.rb'
+    - 'ee/spec/lib/ee/gitlab/namespace_storage_size_error_message_spec.rb'
+    - 'ee/spec/lib/gitlab/analytics/cycle_analytics/summary/change_failure_rate_spec.rb'
+    - 'ee/spec/lib/gitlab/analytics/cycle_analytics/summary/lead_time_for_changes_spec.rb'
+    - 'ee/spec/lib/gitlab/analytics/cycle_analytics/summary/lead_time_spec.rb'
+    - 'ee/spec/lib/gitlab/analytics/cycle_analytics/summary/time_to_restore_service_spec.rb'
+    - 'ee/spec/lib/gitlab/llm/embeddings/utils/base_content_parser_spec.rb'
+    - 'ee/spec/mailers/emails/enterprise_users_spec.rb'
+    - 'ee/spec/requests/api/internal/base_spec.rb'
+    - 'ee/spec/requests/api/member_roles_spec.rb'
+    - 'ee/spec/requests/callout_spec.rb'
+    - 'ee/spec/services/ee/auth/container_registry_authentication_service_spec.rb'
+    - 'ee/spec/support/shared_contexts/secrets_check_shared_contexts.rb'
+    - 'ee/spec/views/admin/application_settings/_ee_package_registry.html.haml_spec.rb'
+    - 'ee/spec/views/groups/add_ons/discover_duo_pro/show.html.haml_spec.rb'
+    - 'ee/spec/views/groups/settings/analytics/_analytics_dashboards.html.haml_spec.rb'
+    - 'ee/spec/views/projects/settings/analytics/_custom_dashboard_projects.html.haml_spec.rb'
+    - 'ee/spec/views/user_settings/profiles/show.html.haml_spec.rb'
+    - 'lib/backup/tasks/database.rb'
+    - 'lib/gitlab/checks/global_file_size_check.rb'
+    - 'lib/gitlab/cycle_analytics/summary/deployment_frequency.rb'
+    - 'lib/gitlab/graphql/deprecations.rb'
+    - 'lib/gitlab/middleware/go.rb'
+    - 'lib/gitlab/security/features.rb'
+    - 'lib/gitlab/usage_data_counters/hll_redis_counter.rb'
+    - 'lib/slack/block_kit/app_home_opened.rb'
+    - 'lib/system_check/helpers.rb'
+    - 'lib/users/internal.rb'
+    - 'lib/web_ide/extensions_marketplace.rb'
+    - 'spec/controllers/import/github_controller_spec.rb'
+    - 'spec/features/admin/admin_sees_background_migrations_spec.rb'
+    - 'spec/features/dashboard/snippets_spec.rb'
+    - 'spec/features/groups/container_registry_spec.rb'
+    - 'spec/features/help_dropdown_spec.rb'
+    - 'spec/features/issues/service_desk_spec.rb'
+    - 'spec/features/projects/container_registry_spec.rb'
+    - 'spec/features/projects/settings/branch_rules_callout_spec.rb'
+    - 'spec/features/projects/settings/registry_settings_spec.rb'
+    - 'spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb'
+    - 'spec/helpers/ci/pipeline_editor_helper_spec.rb'
+    - 'spec/helpers/clusters_helper_spec.rb'
+    - 'spec/helpers/container_registry/container_registry_helper_spec.rb'
+    - 'spec/helpers/ide_helper_spec.rb'
+    - 'spec/helpers/invite_members_helper_spec.rb'
+    - 'spec/helpers/issues_helper_spec.rb'
+    - 'spec/helpers/operations_helper_spec.rb'
+    - 'spec/helpers/projects/security/configuration_helper_spec.rb'
+    - 'spec/lib/gitlab/security/scan_configuration_spec.rb'
+    - 'spec/mailers/emails/pages_domains_spec.rb'
+    - 'spec/mailers/emails/profile_spec.rb'
+    - 'spec/presenters/clusters/cluster_presenter_spec.rb'
+    - 'spec/presenters/commit_status_presenter_spec.rb'
+    - 'spec/presenters/projects/security/configuration_presenter_spec.rb'
+    - 'spec/requests/api/import_github_spec.rb'
+    - 'spec/support/shared_examples/services/jira/requests/base_shared_examples.rb'
+    - 'spec/views/user_settings/profiles/show.html.haml_spec.rb'
diff --git a/rubocop/cop/gitlab/documentation_links/link.rb b/rubocop/cop/gitlab/documentation_links/link.rb
new file mode 100644
index 0000000000000..a5a26b27d83c2
--- /dev/null
+++ b/rubocop/cop/gitlab/documentation_links/link.rb
@@ -0,0 +1,182 @@
+# frozen_string_literal: true
+
+module RuboCop
+  module Cop
+    module Gitlab
+      module DocumentationLinks
+        # Ensure that `help_page_path` links to existing documentation and that the paths
+        # include the .md extension.
+        #
+        # @example
+        #
+        #   # bad
+        #   help_page_path('this/file/does/not/exist.md')
+        #   help_page_path('this/file/exists.md#but-not-this-anchor')
+        #   help_page_path('this/file/exists.md', anchor: 'but-not-this-anchor')
+        #   help_page_path(path_as_a_variable)
+        #   help_page_path('this/file/exists.md', anchor: anchor_as_a_variable)
+        #   help_page_path('this/file/exists')
+        #   help_page_path('this/file/exists.html')
+
+        #   # good
+        #   help_page_path('this/file/exists.md')
+        #   help_page_path('this/file/exists.md#and-this-anchor-too')
+        #   help_page_path('this/file/exists.md', anchor: 'and-this-anchor-too')
+        class Link < RuboCop::Cop::Base
+          extend RuboCop::Cop::AutoCorrector
+
+          MSG_PATH_NOT_A_STRING = '`help_page_path`\'s first argument must be passed as a string ' \
+            'so that Rubocop can ensure the linked file exists.'
+          MSG_PATH_NEEDS_MD_EXTENSION = 'Add .md extension to the link: %{path}.'
+          MSG_FILE_NOT_FOUND = 'This file does not exist: `%{file_path}`.'
+          MSG_ANCHOR_NOT_A_STRING = '`help_page_path`\'s `anchor` argument must be passed as a string ' \
+            'so that Rubocop can ensure it exists within the linked file.'
+          MSG_ANCHOR_NOT_FOUND = 'The anchor `#%{anchor}` was not found in `%{file_path}`.'
+
+          HEADER_ID = /(?:[ \t]+\{\#([A-Za-z][\w:-]*)\})?/
+          ATX_HEADER_MATCH = /^(\#{1,6})(.+?(?:\\#)?)\s*?#*#{HEADER_ID}\s*?\n/
+          NON_WORD_RE = /[^\p{Word}\- \t]/
+          MARKDOWN_LINK_TEXT = /\[(?<link_text>[^\]]+)\]\((?<link_url>[^)]+)\)/
+
+          class << self
+            attr_accessor :anchors_by_docs_file
+          end
+          self.anchors_by_docs_file = {}
+
+          def_node_matcher :help_page_path?, <<~PATTERN
+            (send _ {:help_page_url :help_page_path} $...)
+          PATTERN
+          RESTRICT_ON_SEND = %i[help_page_url help_page_path].to_set.freeze
+
+          def_node_matcher :anchor_param, <<~PATTERN
+            (send nil? %RESTRICT_ON_SEND
+              _
+              (hash
+                <(pair (sym :anchor) $_) ...>
+              )
+            )
+          PATTERN
+
+          def on_send(node)
+            return unless valid_argument_count?(node)
+            return unless first_argument_is_string?(node)
+
+            path = node.arguments.first.value
+            path_without_anchor = path.gsub(%r{#.*$}, '')
+
+            return unless path_has_md_extension?(node, path, path_without_anchor)
+
+            docs_file_path = File.join('doc', path_without_anchor)
+
+            return unless docs_file_exists?(node, docs_file_path)
+
+            anchor_exists_in_markdown?(node, docs_file_path)
+          end
+
+          def external_dependency_checksum
+            @external_dependency_checksum ||=
+              begin
+                mds = Dir["doc/**/*.md"]
+                digest = Digest::SHA512.new
+                mds.each { |md| digest.file(md) }
+                digest.hexdigest
+              end
+          end
+
+          private
+
+          def valid_argument_count?(node)
+            node.arguments.count > 0
+          end
+
+          def first_argument_is_string?(node)
+            return true if node.arguments.first.str_type?
+
+            add_offense(node, message: MSG_PATH_NOT_A_STRING)
+
+            false
+          end
+
+          def path_has_md_extension?(node, path, path_without_anchor)
+            return true if path_without_anchor.end_with?('.md')
+
+            add_offense(node, message: format(MSG_PATH_NEEDS_MD_EXTENSION, path: path)) do |corrector|
+              extension_pattern = /(\.[\da-zA-Z]+)?/
+              path_without_extension = path_without_anchor.gsub(/#{extension_pattern}$/, '')
+              arg_with_md_extension = path.gsub(/#{path_without_extension}#{extension_pattern}(\#.+)?$/,
+                "#{path_without_extension}.md\\2")
+              corrector.replace(node.arguments.first.source_range, "'#{arg_with_md_extension}'")
+            end
+
+            false
+          end
+
+          def docs_file_exists?(node, docs_file_path)
+            return true if File.exist?(docs_file_path)
+
+            add_offense(node, message: format(MSG_FILE_NOT_FOUND, file_path: docs_file_path))
+
+            false
+          end
+
+          def anchor_exists_in_markdown?(node, docs_file_path)
+            anchor = get_anchor(node)
+
+            return true unless anchor
+
+            anchors = get_anchors_in_markdown(docs_file_path)
+
+            return true if anchors.include?(anchor)
+
+            add_offense(node, message: format(MSG_ANCHOR_NOT_FOUND, anchor: anchor, file_path: docs_file_path))
+
+            false
+          end
+
+          def get_anchor(node)
+            return node.arguments.first.value[/#(.+)$/, 1] if node.arguments.length === 1
+
+            anchor_node = anchor_param(node)
+            return unless anchor_node
+            return anchor_node.value if anchor_node.str_type?
+
+            add_offense(node, message: MSG_ANCHOR_NOT_A_STRING)
+          end
+
+          # This methods extracts anchors from a Markdown file. The logic in here replicates our
+          # custom Kramdown header parser at https://gitlab.com/gitlab-org/ruby/gems/gitlab_kramdown/-/blob/bbc5ac439a2e6af60cbcce9a157283b2c5b59b38/lib/gitlab_kramdown/parser/header.rb.
+          # The logic is documented here: https://docs.gitlab.com/ee/user/markdown.html#heading-ids-and-links.
+          # There a special undocumnented syntax that makes it possible to set custom IDs, eg:
+          # ```md
+          # ### My heading {#my-custom-id}
+          # ```
+          # This would result in a `my-custom-id` anchor instead of `my-heading`. We are also handling
+          # this special syntax in here.
+          def get_anchors_in_markdown(docs_file_path)
+            self.class.anchors_by_docs_file.fetch(docs_file_path) do
+              docs_content = File.read(docs_file_path)
+              headers = docs_content.scan(ATX_HEADER_MATCH)
+              counters = Hash.new(0)
+
+              self.class.anchors_by_docs_file[docs_file_path] = headers.map do |header|
+                _level, text, id = header
+
+                id || generate_anchor(text, counters)
+              end
+            end
+          end
+
+          def generate_anchor(text, counters)
+            anchor = text.to_s.strip.downcase
+            anchor.gsub!(MARKDOWN_LINK_TEXT) { |s| MARKDOWN_LINK_TEXT.match(s)[:link_text].gsub(NON_WORD_RE, '') }
+            anchor.gsub!(NON_WORD_RE, '')
+            anchor.tr!(" \t", '-')
+            anchor << (counters[anchor] > 0 ? "-#{counters[anchor]}" : '')
+            counters[anchor] += 1
+            anchor
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/rubocop/rubocop-documentation.yml b/rubocop/rubocop-documentation.yml
new file mode 100644
index 0000000000000..efe6d1853fa20
--- /dev/null
+++ b/rubocop/rubocop-documentation.yml
@@ -0,0 +1,8 @@
+# Lints against outdated documentation links rendered in the product and enforces extension-less
+# paths.
+# For now, we disable the cop on all HAML files to not conflict with the `DocumentationLinks` HAML
+# linter. We'll eventually consolidate both cops into `Gitlab/DocumentationLinks/Link`.
+Gitlab/DocumentationLinks/Link:
+  Exclude:
+    - app/views/**/*.haml
+    - ee/app/views/**/*.haml
diff --git a/spec/rubocop/cop/gitlab/documentation_links/link_spec.rb b/spec/rubocop/cop/gitlab/documentation_links/link_spec.rb
new file mode 100644
index 0000000000000..ee7ea39d8d6fd
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/documentation_links/link_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require 'rubocop_spec_helper'
+require_relative '../../../../../rubocop/cop/gitlab/documentation_links/link'
+
+RSpec.describe RuboCop::Cop::Gitlab::DocumentationLinks::Link, feature_category: :navigation do
+  using RSpec::Parameterized::TableSyntax
+
+  shared_examples 'no offenses registered' do
+    it 'does not register any offenses' do
+      expect_no_offenses(code)
+    end
+  end
+
+  shared_examples 'offense registered' do |offense_message|
+    it 'registers an offense' do
+      expect_offense(<<~'RUBY', code: code, offense_message: offense_message)
+          %{code}
+          ^{code} %{offense_message}
+      RUBY
+    end
+  end
+
+  context 'when no argument is passed' do
+    let(:code) { "help_page_path" }
+
+    it_behaves_like 'no offenses registered'
+  end
+
+  context 'when the path is valid' do
+    before do
+      allow(File).to receive(:exist?).with('doc/this/file/exists.md').and_return(true)
+    end
+
+    where(:code) do
+      [
+        "help_page_path('/this/file/exists.md')",
+        "help_page_url('/this/file/exists.md')"
+      ]
+    end
+
+    with_them do
+      it_behaves_like 'no offenses registered'
+    end
+
+    describe 'anchors' do
+      before do
+        allow(File).to receive(:read).and_call_original
+        allow(File).to receive(:read).with('doc/this/file/exists.md').and_return(<<~MARKDOWN)
+          # Primary heading
+
+          Intro
+
+          ## This anchor exists
+
+          Content
+
+          ## This anchor exists
+
+          More content
+
+          ## This one has a custom ID {#my-custom-id}
+        MARKDOWN
+      end
+
+      context 'when the anchor is valid' do
+        where(:code) do
+          [
+            "help_page_path('/this/file/exists.md#primary-heading')",
+            "help_page_path('/this/file/exists.md#this-anchor-exists')",
+            "help_page_path('/this/file/exists.md#this-anchor-exists-1')",
+            "help_page_path('/this/file/exists.md', anchor: 'this-anchor-exists')",
+            "help_page_path('/this/file/exists.md', anchor: 'this-anchor-exists-1')",
+            "help_page_path('/this/file/exists.md', anchor: 'my-custom-id')",
+            "help_page_url('/this/file/exists.md#primary-heading')"
+          ]
+        end
+
+        with_them do
+          it_behaves_like 'no offenses registered'
+        end
+      end
+
+      context 'when the anchor is invalid' do
+        where(:code) do
+          [
+            "help_page_path('/this/file/exists.md#this-anchor-does-not-exist')",
+            "help_page_path('/this/file/exists.md', anchor: 'this-anchor-does-not-exist')",
+            "help_page_url('/this/file/exists.md#this-anchor-does-not-exist')"
+          ]
+        end
+
+        with_them do
+          it_behaves_like 'offense registered', "The anchor `#this-anchor-does-not-exist` was not found in [...]"
+        end
+      end
+
+      context 'when the anchor is not a string' do
+        let(:code) { "help_page_path('/this/file/exists.md', anchor: anchor_variable)" }
+
+        it_behaves_like 'offense registered', "`help_page_path`'s `anchor` argument must be passed as a string [...]"
+      end
+    end
+  end
+
+  context 'when the path is invalid' do
+    before do
+      allow(File).to receive(:exists).and_return(false)
+    end
+
+    where(:code) do
+      [
+        "help_page_path('/this/file/does/not/exist.md')",
+        "help_page_path('/this/file/does/not/exist.md#some-anchor')",
+        "help_page_path('/this/file/does/not/exist.md', anchor: 'some-anchor')",
+        "help_page_url('/this/file/does/not/exist.md')"
+      ]
+    end
+
+    with_them do
+      it_behaves_like 'offense registered', "This file does not exist: [...]"
+    end
+
+    context 'when the path is not a string' do
+      let(:code) { "help_page_path(path_variable)" }
+
+      it_behaves_like 'offense registered', "`help_page_path`'s first argument must be passed as a string [...]"
+    end
+
+    context 'when the path does not include the .md file extension' do
+      where(:path, :correction) do
+        '/this/path/lacks/md/extension'             | '/this/path/lacks/md/extension.md'
+        '/this/path/lacks/md/extension.html'        | '/this/path/lacks/md/extension.md'
+        '/this/path/lacks/md/extension#anchor'      | '/this/path/lacks/md/extension.md#anchor'
+        '/this/path/lacks/md/extension.html#anchor' | '/this/path/lacks/md/extension.md#anchor'
+      end
+
+      with_them do
+        it 'registers an offense and corrects' do
+          expect_offense(<<~'RUBY', code: "help_page_path('#{path}')")
+            %{code}
+            ^{code} Add .md extension to the link: [...]
+          RUBY
+
+          expect_correction("help_page_path('#{correction}')\n")
+        end
+      end
+    end
+  end
+
+  describe '#external_dependency_checksum' do
+    it 'returns a SHA256 digest used by RuboCop to invalid cache' do
+      expect(cop.external_dependency_checksum).to match(/^\h{128}$/)
+    end
+  end
+end
-- 
GitLab