diff --git a/.rubocop_todo/gitlab/documentation_links/link.yml b/.rubocop_todo/gitlab/documentation_links/link.yml new file mode 100644 index 0000000000000000000000000000000000000000..e41a645ac2b915d217db4625df13b36398180c56 --- /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 0000000000000000000000000000000000000000..a5a26b27d83c202749bd02bd0a8d7facab925e6d --- /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 0000000000000000000000000000000000000000..efe6d1853fa20a20c89cb06689704d9b3da6d3f8 --- /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 0000000000000000000000000000000000000000..ee7ea39d8d6fd89a22fc337a376d52f04ebbfe64 --- /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