summaryrefslogtreecommitdiff
path: root/rubocop/cop/gitlab/mark_used_feature_flags.rb
blob: 290e62436e2606d9466ef8c01dfc8cd346a276be (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# frozen_string_literal: true

require_relative '../../code_reuse_helpers'

module RuboCop
  module Cop
    module Gitlab
      # This cop tracks the usage of feature flags among the codebase.
      #
      # The files set in `tmp/feature_flags/*.used` can then be used for verification purpose.
      #
      class MarkUsedFeatureFlags < RuboCop::Cop::Cop
        include RuboCop::CodeReuseHelpers

        FEATURE_METHODS = %i[enabled? disabled?].freeze
        EXPERIMENTATION_METHODS = %i[active?].freeze
        EXPERIMENT_METHODS = %i[
          experiment
          experiment_enabled?
          push_frontend_experiment
        ].freeze
        RUGGED_METHODS = %i[
          use_rugged?
        ].freeze
        WORKER_METHODS = %i[
          data_consistency
          deduplicate
        ].freeze
        GRAPHQL_METHODS = %i[
          field
        ].freeze
        SELF_METHODS = %i[
          push_frontend_feature_flag
          push_force_frontend_feature_flag
          limit_feature_flag=
          limit_feature_flag_for_override=
        ].freeze + EXPERIMENT_METHODS + RUGGED_METHODS + WORKER_METHODS

        RESTRICT_ON_SEND = FEATURE_METHODS + EXPERIMENTATION_METHODS + GRAPHQL_METHODS + SELF_METHODS

        USAGE_DATA_COUNTERS_EVENTS_YAML_GLOBS = [
          File.expand_path("../../../config/metrics/aggregates/*.yml", __dir__),
          File.expand_path("../../../lib/gitlab/usage_data_counters/known_events/*.yml", __dir__)
        ].freeze

        DYNAMIC_FEATURE_FLAGS = [
          :usage_data_static_site_editor_commits, # https://gitlab.com/gitlab-org/gitlab/-/issues/284082
          :usage_data_static_site_editor_merge_requests, # https://gitlab.com/gitlab-org/gitlab/-/issues/284083
          :usage_data_users_clicking_license_testing_visiting_external_website, # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77866
          :usage_data_users_visiting_testing_license_compliance_full_report # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77866
        ].freeze

        class << self
          # We track feature flags in `on_new_investigation` only once per
          # rubocop whole run instead once per file.
          attr_accessor :feature_flags_already_tracked
        end

        # Called before all on_... have been called
        # When refining this method, always call `super`
        def on_new_investigation
          super

          return if self.class.feature_flags_already_tracked

          self.class.feature_flags_already_tracked = true

          track_dynamic_feature_flags!
          track_usage_data_counters_known_events!
        end

        def on_casgn(node)
          _, lhs_name, rhs = *node

          save_used_feature_flag(rhs.value) if lhs_name == :FEATURE_FLAG
        end

        def on_send(node)
          return if in_spec?(node)
          return unless trackable_flag?(node)

          flag_arg = flag_arg(node)
          flag_value = flag_value(node)
          return unless flag_value

          if flag_arg_is_str_or_sym?(flag_arg)
            if caller_is_feature_gitaly?(node)
              save_used_feature_flag("gitaly_#{flag_value}")
            else
              save_used_feature_flag(flag_value)
            end

            if experiment_method?(node) || experimentation_method?(node)
              # Additionally, mark experiment-related feature flag as used as well
              matching_feature_flags = defined_feature_flags.select { |flag| flag == "#{flag_value}_experiment_percentage" }
              matching_feature_flags.each do |matching_feature_flag|
                puts_if_ci(node, "The '#{matching_feature_flag}' feature flag tracks the #{flag_value} experiment, which is still in use, so we'll mark it as used.")
                save_used_feature_flag(matching_feature_flag)
              end
            end
          elsif flag_arg_is_send_type?(flag_arg)
            puts_if_ci(node, "Feature flag is dynamic: '#{flag_value}.")
          elsif flag_arg_is_dstr_or_dsym?(flag_arg)
            str_prefix = flag_arg.children[0]
            rest_children = flag_arg.children[1..]

            if rest_children.none? { |child| child.str_type? }
              matching_feature_flags = defined_feature_flags.select { |flag| flag.start_with?(str_prefix.value) }
              matching_feature_flags.each do |matching_feature_flag|
                puts_if_ci(node, "The '#{matching_feature_flag}' feature flag starts with '#{str_prefix.value}', so we'll optimistically mark it as used.")
                save_used_feature_flag(matching_feature_flag)
              end
            else
              puts_if_ci(node, "Interpolated feature flag name has multiple static string parts, we won't track it.")
            end
          else
            puts_if_ci(node, "Feature flag has an unknown type: #{flag_arg.type}.")
          end
        end

        private

        def puts_if_ci(node, text)
          puts "#{text} (call: `#{node.source}`, source: #{node.location.expression.source_buffer.name})" if ENV['CI']
        end

        def save_used_feature_flag(feature_flag_name)
          used_feature_flag_file = File.expand_path("../../../tmp/feature_flags/#{feature_flag_name}.used", __dir__)
          return if File.exist?(used_feature_flag_file)

          FileUtils.touch(used_feature_flag_file)
        end

        def class_caller(node)
          node.children[0]&.const_name.to_s
        end

        def method_name(node)
          node.children[1]
        end

        def flag_arg(node)
          if worker_method?(node)
            return unless node.children.size > 3

            node.children[3].each_pair.find do |pair|
              pair.key.value == :feature_flag
            end&.value
          elsif graphql_method?(node)
            return unless node.children.size > 3

            opts_index = node.children[3].hash_type? ? 3 : 4
            return unless node.children[opts_index]

            node.children[opts_index].each_pair.find do |pair|
              pair.key.value == :feature_flag
            end&.value
          else
            arg_index = rugged_method?(node) ? 3 : 2

            node.children[arg_index]
          end
        end

        def flag_value(node)
          flag_arg = flag_arg(node)
          return unless flag_arg

          if flag_arg.respond_to?(:value)
            flag_arg.value
          else
            flag_arg
          end.to_s.tr("\n/", ' _')
        end

        def flag_arg_is_str_or_sym?(flag_arg)
          flag_arg.str_type? || flag_arg.sym_type?
        end

        def flag_arg_is_send_type?(flag_arg)
          flag_arg.send_type?
        end

        def flag_arg_is_dstr_or_dsym?(flag_arg)
          (flag_arg.dstr_type? || flag_arg.dsym_type?) && flag_arg.children[0].str_type?
        end

        def caller_is_feature?(node)
          class_caller(node) == "Feature"
        end

        def caller_is_feature_gitaly?(node)
          class_caller(node) == "Feature::Gitaly"
        end

        def caller_is_experimentation?(node)
          class_caller(node) == "Gitlab::Experimentation"
        end

        def experiment_method?(node)
          EXPERIMENT_METHODS.include?(method_name(node))
        end

        def rugged_method?(node)
          RUGGED_METHODS.include?(method_name(node))
        end

        def feature_method?(node)
          FEATURE_METHODS.include?(method_name(node)) && (caller_is_feature?(node) || caller_is_feature_gitaly?(node))
        end

        def experimentation_method?(node)
          EXPERIMENTATION_METHODS.include?(method_name(node)) && caller_is_experimentation?(node)
        end

        def worker_method?(node)
          WORKER_METHODS.include?(method_name(node))
        end

        def graphql_method?(node)
          GRAPHQL_METHODS.include?(method_name(node)) && in_graphql_types?(node)
        end

        def self_method?(node)
          SELF_METHODS.include?(method_name(node)) && class_caller(node).empty?
        end

        def trackable_flag?(node)
          feature_method?(node) || experimentation_method?(node) || graphql_method?(node) || self_method?(node)
        end

        # Marking all event's feature flags as used as Gitlab::UsageDataCounters::HLLRedisCounter.track_event{,context}
        # is mostly used with dynamic event name.
        def track_dynamic_feature_flags!
          DYNAMIC_FEATURE_FLAGS.each(&method(:save_used_feature_flag))
        end

        # Marking all event's feature flags as used as Gitlab::UsageDataCounters::HLLRedisCounter.track_event{,context}
        # is mostly used with dynamic event name.
        def track_usage_data_counters_known_events!
          usage_data_counters_known_event_feature_flags.each(&method(:save_used_feature_flag))
        end

        def usage_data_counters_known_event_feature_flags
          USAGE_DATA_COUNTERS_EVENTS_YAML_GLOBS.each_with_object(Set.new) do |glob, memo|
            Dir.glob(glob).each do |path|
              YAML.safe_load(File.read(path))&.each do |hash|
                memo << hash['feature_flag'] if hash['feature_flag']
              end
            end
          end
        end

        def defined_feature_flags
          @defined_feature_flags ||= begin
            flags_paths = [
              'config/feature_flags/**/*.yml'
            ]

            # For EE additionally process `ee/` feature flags
            if ee?
              flags_paths << 'ee/config/feature_flags/**/*.yml'
            end

            # For JH additionally process `jh/` feature flags
            if jh?
              flags_paths << 'jh/config/feature_flags/**/*.yml'
            end

            flags_paths.each_with_object([]) do |flags_path, memo|
              flags_path = File.expand_path("../../../#{flags_path}", __dir__)
              Dir.glob(flags_path).each do |path|
                feature_flag_name = File.basename(path, '.yml')

                memo << feature_flag_name
              end
            end
          end
        end
      end
    end
  end
end