feature_map 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +269 -0
  3. data/bin/featuremap +5 -0
  4. data/lib/feature_map/cli.rb +243 -0
  5. data/lib/feature_map/code_features/plugin.rb +79 -0
  6. data/lib/feature_map/code_features/plugins/identity.rb +39 -0
  7. data/lib/feature_map/code_features.rb +152 -0
  8. data/lib/feature_map/configuration.rb +43 -0
  9. data/lib/feature_map/constants.rb +11 -0
  10. data/lib/feature_map/mapper.rb +78 -0
  11. data/lib/feature_map/output_color.rb +42 -0
  12. data/lib/feature_map/private/assignment_mappers/directory_assignment.rb +150 -0
  13. data/lib/feature_map/private/assignment_mappers/feature_definition_assignment.rb +68 -0
  14. data/lib/feature_map/private/assignment_mappers/feature_globs.rb +138 -0
  15. data/lib/feature_map/private/assignment_mappers/file_annotations.rb +158 -0
  16. data/lib/feature_map/private/assignments_file.rb +190 -0
  17. data/lib/feature_map/private/code_cov.rb +96 -0
  18. data/lib/feature_map/private/cyclomatic_complexity_calculator.rb +46 -0
  19. data/lib/feature_map/private/docs/index.html +247 -0
  20. data/lib/feature_map/private/documentation_site.rb +128 -0
  21. data/lib/feature_map/private/extension_loader.rb +24 -0
  22. data/lib/feature_map/private/feature_assigner.rb +22 -0
  23. data/lib/feature_map/private/feature_metrics_calculator.rb +76 -0
  24. data/lib/feature_map/private/feature_plugins/assignment.rb +17 -0
  25. data/lib/feature_map/private/glob_cache.rb +80 -0
  26. data/lib/feature_map/private/lines_of_code_calculator.rb +49 -0
  27. data/lib/feature_map/private/metrics_file.rb +86 -0
  28. data/lib/feature_map/private/test_coverage_file.rb +97 -0
  29. data/lib/feature_map/private/test_pyramid_file.rb +151 -0
  30. data/lib/feature_map/private/todo_inspector.rb +57 -0
  31. data/lib/feature_map/private/validations/features_up_to_date.rb +78 -0
  32. data/lib/feature_map/private/validations/files_have_features.rb +45 -0
  33. data/lib/feature_map/private/validations/files_have_unique_features.rb +34 -0
  34. data/lib/feature_map/private.rb +204 -0
  35. data/lib/feature_map/validator.rb +29 -0
  36. data/lib/feature_map.rb +212 -0
  37. metadata +253 -0
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require 'yaml'
6
+ require 'csv'
7
+ require 'set'
8
+ require 'pathname'
9
+ require 'feature_map/code_features/plugin'
10
+ require 'feature_map/code_features/plugins/identity'
11
+
12
+ module FeatureMap
13
+ module CodeFeatures
14
+ extend T::Sig
15
+
16
+ NON_BREAKING_SPACE = T.let(65_279.chr(Encoding::UTF_8), String)
17
+
18
+ class IncorrectPublicApiUsageError < StandardError; end
19
+
20
+ sig { returns(T::Array[Feature]) }
21
+ def self.all
22
+ @all = T.let(@all, T.nilable(T::Array[Feature]))
23
+ @all ||= from_csv('.feature_map/feature_definitions.csv')
24
+ @all ||= for_directory('.feature_map/definitions')
25
+ end
26
+
27
+ sig { params(name: String).returns(T.nilable(Feature)) }
28
+ def self.find(name)
29
+ @index_by_name = T.let(@index_by_name, T.nilable(T::Hash[String, CodeFeatures::Feature]))
30
+ @index_by_name ||= begin
31
+ result = {}
32
+ all.each { |t| result[t.name] = t }
33
+ result
34
+ end
35
+
36
+ @index_by_name[name]
37
+ end
38
+
39
+ sig { params(file_path: String).returns(T.nilable(T::Array[Feature])) }
40
+ def self.from_csv(file_path)
41
+ return nil if !File.exist?(file_path)
42
+
43
+ file_lines = File.readlines(file_path)
44
+ # Remove any non-breaking space characters, as these can throw off the comment handling
45
+ # and/or attribute key values.
46
+ csv_content = file_lines.map { |line| line.gsub(NON_BREAKING_SPACE, '') }
47
+ .reject { |line| line.start_with?('#') }
48
+ .join.strip
49
+
50
+ CSV.parse(csv_content, headers: true).map do |csv_row|
51
+ feature_data = csv_row.to_h.transform_keys { |column_name| tag_value_for(column_name) }
52
+ Feature.from_hash(feature_data)
53
+ end
54
+ end
55
+
56
+ sig { params(dir: String).returns(T::Array[Feature]) }
57
+ def self.for_directory(dir)
58
+ Pathname.new(dir).glob('**/*.yml').map do |path|
59
+ Feature.from_yml(path.to_s)
60
+ rescue Psych::SyntaxError
61
+ raise IncorrectPublicApiUsageError, "The YML in #{path} has a syntax error!"
62
+ end
63
+ end
64
+
65
+ sig { params(features: T::Array[Feature]).returns(T::Array[String]) }
66
+ def self.validation_errors(features)
67
+ Plugin.all_plugins.flat_map do |plugin|
68
+ plugin.validation_errors(features)
69
+ end
70
+ end
71
+
72
+ sig { params(string: String).returns(String) }
73
+ def self.tag_value_for(string)
74
+ string.tr('&', ' ').gsub(/\s+/, '_').downcase
75
+ end
76
+
77
+ # Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change.
78
+ # Namely, the YML files that are the source of truth for features should not change, so we should not need to look at the YMLs again to verify.
79
+ # The primary reason this is helpful is for tests where each context is testing against a different set of features
80
+ sig { void }
81
+ def self.bust_caches!
82
+ Plugin.bust_caches!
83
+ @all = nil
84
+ @index_by_name = nil
85
+ end
86
+
87
+ class Feature
88
+ extend T::Sig
89
+
90
+ sig { params(config_yml: String).returns(Feature) }
91
+ def self.from_yml(config_yml)
92
+ hash = YAML.load_file(config_yml)
93
+
94
+ new(
95
+ config_yml: config_yml,
96
+ raw_hash: hash
97
+ )
98
+ end
99
+
100
+ sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(Feature) }
101
+ def self.from_hash(raw_hash)
102
+ new(
103
+ config_yml: nil,
104
+ raw_hash: raw_hash
105
+ )
106
+ end
107
+
108
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
109
+ attr_reader :raw_hash
110
+
111
+ sig { returns(T.nilable(String)) }
112
+ attr_reader :config_yml
113
+
114
+ sig do
115
+ params(
116
+ config_yml: T.nilable(String),
117
+ raw_hash: T::Hash[T.untyped, T.untyped]
118
+ ).void
119
+ end
120
+ def initialize(config_yml:, raw_hash:)
121
+ @config_yml = config_yml
122
+ @raw_hash = raw_hash
123
+ end
124
+
125
+ sig { returns(String) }
126
+ def name
127
+ Plugins::Identity.for(self).identity.name
128
+ end
129
+
130
+ sig { returns(String) }
131
+ def to_tag
132
+ CodeFeatures.tag_value_for(name)
133
+ end
134
+
135
+ sig { params(other: Object).returns(T::Boolean) }
136
+ def ==(other)
137
+ if other.is_a?(CodeFeatures::Feature)
138
+ name == other.name
139
+ else
140
+ false
141
+ end
142
+ end
143
+
144
+ alias eql? ==
145
+
146
+ sig { returns(Integer) }
147
+ def hash
148
+ name.hash
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+
3
+ module FeatureMap
4
+ class Configuration < T::Struct
5
+ extend T::Sig
6
+
7
+ const :assigned_globs, T::Array[String]
8
+ const :unassigned_globs, T::Array[String]
9
+ const :unbuilt_gems_path, T.nilable(String)
10
+ const :skip_features_validation, T::Boolean
11
+ const :raw_hash, T::Hash[T.untyped, T.untyped]
12
+ const :skip_code_ownership, T::Boolean
13
+ const :require_assignment_for_teams, T.nilable(T::Array[String])
14
+ const :ignore_feature_definitions, T::Boolean
15
+ const :code_cov, T::Hash[String, T.nilable(String)]
16
+ const :repository, T::Hash[String, T.nilable(String)]
17
+ const :documentation_site, T::Hash[String, T.untyped]
18
+
19
+ sig { returns(Configuration) }
20
+ def self.fetch
21
+ config_hash = YAML.load_file('.feature_map/config.yml')
22
+
23
+ if config_hash.key?('require')
24
+ config_hash['require'].each do |require_directive|
25
+ Private::ExtensionLoader.load(require_directive)
26
+ end
27
+ end
28
+
29
+ new(
30
+ assigned_globs: config_hash.fetch('assigned_globs', []),
31
+ unassigned_globs: config_hash.fetch('unassigned_globs', []),
32
+ skip_features_validation: config_hash.fetch('skip_features_validation', false),
33
+ raw_hash: config_hash,
34
+ skip_code_ownership: config_hash.fetch('skip_code_ownership', true),
35
+ require_assignment_for_teams: config_hash.fetch('require_assignment_for_teams', nil),
36
+ ignore_feature_definitions: config_hash.fetch('ignore_feature_definitions', false),
37
+ code_cov: config_hash.fetch('code_cov', {}),
38
+ repository: config_hash.fetch('repository', {}),
39
+ documentation_site: config_hash.fetch('documentation_site', {})
40
+ )
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module FeatureMap
6
+ module Constants
7
+ SINGLE_LINE_COMMENT_PATTERNS = T.let(['#', '//'].map { |r| Regexp.escape(r) }.freeze, T::Array[String])
8
+ MULTILINE_COMMENT_START_PATTERNS = T.let(['/*', '<!--', '"""', "'''"].map { |r| Regexp.escape(r) }.freeze, T::Array[String])
9
+ MULTILINE_COMMENT_END_PATTERNS = T.let(['*/', '-->', '"""', "'''"].map { |r| Regexp.escape(r) }.freeze, T::Array[String])
10
+ end
11
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module FeatureMap
6
+ module Mapper
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ interface!
11
+
12
+ class << self
13
+ extend T::Sig
14
+
15
+ sig { params(base: T::Class[Mapper]).void }
16
+ def included(base)
17
+ @mappers ||= T.let(@mappers, T.nilable(T::Array[T::Class[Mapper]]))
18
+ @mappers ||= []
19
+ @mappers << base
20
+ end
21
+
22
+ sig { returns(T::Array[Mapper]) }
23
+ def all
24
+ (@mappers || []).map(&:new)
25
+ end
26
+ end
27
+
28
+ #
29
+ # This should be fast when run with ONE file
30
+ #
31
+ sig do
32
+ abstract.params(file: String)
33
+ .returns(T.nilable(CodeFeatures::Feature))
34
+ end
35
+ def map_file_to_feature(file); end
36
+
37
+ #
38
+ # This should be fast when run with MANY files
39
+ #
40
+ sig do
41
+ abstract.params(files: T::Array[String])
42
+ .returns(T::Hash[String, CodeFeatures::Feature])
43
+ end
44
+ def globs_to_feature(files); end
45
+
46
+ #
47
+ # This should be fast when run with MANY files
48
+ #
49
+ sig do
50
+ abstract.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
51
+ end
52
+ def update_cache(cache, files); end
53
+
54
+ sig { abstract.returns(String) }
55
+ def description; end
56
+
57
+ sig { abstract.void }
58
+ def bust_caches!; end
59
+
60
+ sig { returns(Private::GlobCache) }
61
+ def self.to_glob_cache
62
+ glob_to_feature_map_by_mapper_description = {}
63
+
64
+ Mapper.all.each do |mapper|
65
+ mapped_files = mapper.globs_to_feature(Private.tracked_files)
66
+ glob_to_feature_map_by_mapper_description[mapper.description] ||= {}
67
+
68
+ mapped_files.each do |glob, feature|
69
+ next if feature.nil?
70
+
71
+ glob_to_feature_map_by_mapper_description.fetch(mapper.description)[glob] = feature
72
+ end
73
+ end
74
+
75
+ Private::GlobCache.new(glob_to_feature_map_by_mapper_description)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module OutputColor
6
+ extend T::Sig
7
+
8
+ sig { params(color_code: Integer, text: String).returns(String) }
9
+ def self.colorize(color_code, text)
10
+ "\e[#{color_code}m#{text}\e[0m"
11
+ end
12
+
13
+ sig { params(text: String).returns(String) }
14
+ def self.red(text)
15
+ colorize(31, text)
16
+ end
17
+
18
+ sig { params(text: String).returns(String) }
19
+ def self.green(text)
20
+ colorize(32, text)
21
+ end
22
+
23
+ sig { params(text: String).returns(String) }
24
+ def self.yellow(text)
25
+ colorize(33, text)
26
+ end
27
+
28
+ sig { params(text: String).returns(String) }
29
+ def self.blue(text)
30
+ colorize(34, text)
31
+ end
32
+
33
+ sig { params(text: String).returns(String) }
34
+ def self.pink(text)
35
+ colorize(35, text)
36
+ end
37
+
38
+ sig { params(text: String).returns(String) }
39
+ def self.light_blue(text)
40
+ colorize(36, text)
41
+ end
42
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module FeatureMap
6
+ module Private
7
+ module AssignmentMappers
8
+ class DirectoryAssignment
9
+ extend T::Sig
10
+ include Mapper
11
+
12
+ FEATURE_DIRECTORY_ASSIGNMENT_FILE_NAME = '.feature'
13
+
14
+ @@directory_cache = T.let({}, T::Hash[String, T.nilable(CodeFeatures::Feature)]) # rubocop:disable Style/ClassVars
15
+
16
+ sig do
17
+ override.params(file: String)
18
+ .returns(T.nilable(CodeFeatures::Feature))
19
+ end
20
+ def map_file_to_feature(file)
21
+ map_file_to_relevant_feature(file)
22
+ end
23
+
24
+ sig do
25
+ override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
26
+ end
27
+ def update_cache(cache, files)
28
+ globs_to_feature(files)
29
+ end
30
+
31
+ #
32
+ # Directory assignment ignores the passed in files when generating feature assignment lines.
33
+ # This is because directory assignment knows that the fastest way to find features for directory based assignment
34
+ # is to simply iterate over the directories and grab the feature, rather than iterating over each file just to get what directory it is in
35
+ # In theory this means that we may generate feature lines that cover files that are not in the passed in argument,
36
+ # but in practice this is not of consequence because in reality we never really want to generate feature assignments for only a
37
+ # subset of files, but rather we want feature assignments for all files.
38
+ #
39
+ sig do
40
+ override.params(files: T::Array[String])
41
+ .returns(T::Hash[String, CodeFeatures::Feature])
42
+ end
43
+ def globs_to_feature(files)
44
+ # The T.unsafe is because the upstream RBI is wrong for Pathname.glob
45
+ T
46
+ .unsafe(Pathname)
47
+ .glob(File.join('**/', FEATURE_DIRECTORY_ASSIGNMENT_FILE_NAME))
48
+ .map(&:cleanpath)
49
+ .each_with_object({}) do |pathname, res|
50
+ feature = feature_for_directory_assignment_file(pathname)
51
+ glob = glob_for_directory_assignment_file(pathname)
52
+ res[glob] = feature
53
+ end
54
+ end
55
+
56
+ sig { override.returns(String) }
57
+ def description
58
+ 'Feature Assigned in .feature'
59
+ end
60
+
61
+ sig { override.void }
62
+ def bust_caches!
63
+ @@directory_cache = {} # rubocop:disable Style/ClassVars
64
+ end
65
+
66
+ private
67
+
68
+ sig { params(file: Pathname).returns(CodeFeatures::Feature) }
69
+ def feature_for_directory_assignment_file(file)
70
+ raw_feature_value = File.foreach(file).first.strip
71
+
72
+ Private.find_feature!(
73
+ raw_feature_value,
74
+ file.to_s
75
+ )
76
+ end
77
+
78
+ # Takes a file and finds the relevant `.feature` file by walking up the directory
79
+ # structure. Example, given `a/b/c.rb`, this looks for `a/b/.feature`, `a/.feature`,
80
+ # and `.feature` in that order, stopping at the first file to actually exist.
81
+ # If the provided file is a directory, it will look for `.feature` in that directory and then upwards.
82
+ # We do additional caching so that we don't have to check for file existence every time.
83
+ sig { params(file: String).returns(T.nilable(CodeFeatures::Feature)) }
84
+ def map_file_to_relevant_feature(file)
85
+ file_path = Pathname.new(file)
86
+ feature = T.let(nil, T.nilable(CodeFeatures::Feature))
87
+
88
+ if File.directory?(file)
89
+ feature = get_feature_from_assignment_file_within_directory(file_path)
90
+ return feature unless feature.nil?
91
+ end
92
+
93
+ path_components = file_path.each_filename.to_a
94
+ if file_path.absolute?
95
+ path_components = ['/', *path_components]
96
+ end
97
+
98
+ (path_components.length - 1).downto(0).each do |i|
99
+ feature = get_feature_from_assignment_file_within_directory(
100
+ Pathname.new(File.join(*T.unsafe(path_components[0...i])))
101
+ )
102
+ return feature unless feature.nil?
103
+ end
104
+
105
+ feature
106
+ end
107
+
108
+ sig { params(directory: Pathname).returns(T.nilable(CodeFeatures::Feature)) }
109
+ def get_feature_from_assignment_file_within_directory(directory)
110
+ potential_directory_assignment_file = directory.join(FEATURE_DIRECTORY_ASSIGNMENT_FILE_NAME)
111
+
112
+ potential_directory_assignment_file_name = potential_directory_assignment_file.to_s
113
+
114
+ feature = nil
115
+ if @@directory_cache.key?(potential_directory_assignment_file_name)
116
+ feature = @@directory_cache[potential_directory_assignment_file_name]
117
+ elsif potential_directory_assignment_file.exist?
118
+ feature = feature_for_directory_assignment_file(potential_directory_assignment_file)
119
+
120
+ @@directory_cache[potential_directory_assignment_file_name] = feature
121
+ else
122
+ @@directory_cache[potential_directory_assignment_file_name] = nil
123
+ end
124
+
125
+ feature
126
+ end
127
+
128
+ sig { params(file: Pathname).returns(String) }
129
+ def glob_for_directory_assignment_file(file)
130
+ unescaped = file.dirname.cleanpath.join('**/**').to_s
131
+
132
+ # Globs can contain certain regex characters, like "[" and "]".
133
+ # However, when we are generating a glob from a .feature file, we
134
+ # need to escape bracket characters and interpret them literally.
135
+ # Otherwise the resulting glob will not actually match the directory
136
+ # containing the .feature file.
137
+ #
138
+ # Example
139
+ # file: "/some/[dir]/.feature"
140
+ # unescaped: "/some/[dir]/**/**"
141
+ # matches: "/some/d/file"
142
+ # matches: "/some/i/file"
143
+ # matches: "/some/r/file"
144
+ # does not match!: "/some/[dir]/file"
145
+ unescaped.gsub(/[\[\]]/) { |x| "\\#{x}" }
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module FeatureMap
6
+ module Private
7
+ module AssignmentMappers
8
+ class FeatureDefinitionAssignment
9
+ extend T::Sig
10
+ include Mapper
11
+
12
+ @@map_files_to_features = T.let(@map_files_to_features, T.nilable(T::Hash[String, CodeFeatures::Feature])) # rubocop:disable Style/ClassVars
13
+ @@map_files_to_features = {} # rubocop:disable Style/ClassVars
14
+
15
+ sig do
16
+ params(files: T::Array[String])
17
+ .returns(T::Hash[String, CodeFeatures::Feature])
18
+ end
19
+ def map_files_to_features(files)
20
+ return @@map_files_to_features if @@map_files_to_features&.keys && @@map_files_to_features.keys.count.positive?
21
+
22
+ @@map_files_to_features = CodeFeatures.all.each_with_object({}) do |feature, map| # rubocop:disable Style/ClassVars
23
+ map[feature.config_yml] = feature
24
+ end
25
+ end
26
+
27
+ sig do
28
+ override.params(file: String)
29
+ .returns(T.nilable(CodeFeatures::Feature))
30
+ end
31
+ def map_file_to_feature(file)
32
+ return nil if Private.configuration.ignore_feature_definitions
33
+
34
+ map_files_to_features([file])[file]
35
+ end
36
+
37
+ sig do
38
+ override.params(files: T::Array[String])
39
+ .returns(T::Hash[String, CodeFeatures::Feature])
40
+ end
41
+ def globs_to_feature(files)
42
+ return {} if Private.configuration.ignore_feature_definitions
43
+
44
+ CodeFeatures.all.each_with_object({}) do |feature, map|
45
+ map[feature.config_yml] = feature
46
+ end
47
+ end
48
+
49
+ sig { override.void }
50
+ def bust_caches!
51
+ @@map_files_to_features = {} # rubocop:disable Style/ClassVars
52
+ end
53
+
54
+ sig do
55
+ override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
56
+ end
57
+ def update_cache(cache, files)
58
+ globs_to_feature(files)
59
+ end
60
+
61
+ sig { override.returns(String) }
62
+ def description
63
+ 'Feature definition file assignment'
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module FeatureMap
6
+ module Private
7
+ module AssignmentMappers
8
+ class FeatureGlobs
9
+ extend T::Sig
10
+ include Mapper
11
+ include Validator
12
+
13
+ @@map_files_to_features = T.let(@map_files_to_features, T.nilable(T::Hash[String, FeatureMap::CodeFeatures::Feature])) # rubocop:disable Style/ClassVars
14
+ @@map_files_to_features = {} # rubocop:disable Style/ClassVars
15
+
16
+ sig do
17
+ params(files: T::Array[String])
18
+ .returns(T::Hash[String, FeatureMap::CodeFeatures::Feature])
19
+ end
20
+ def map_files_to_features(files)
21
+ return @@map_files_to_features if @@map_files_to_features&.keys && @@map_files_to_features.keys.count.positive?
22
+
23
+ @@map_files_to_features = FeatureMap::CodeFeatures.all.each_with_object({}) do |feature, map| # rubocop:disable Style/ClassVars
24
+ FeaturePlugins::Assignment.for(feature).assigned_globs.each do |glob|
25
+ Dir.glob(glob).each do |filename|
26
+ map[filename] = feature
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ class MappingContext < T::Struct
33
+ const :glob, String
34
+ const :feature, FeatureMap::CodeFeatures::Feature
35
+ end
36
+
37
+ class GlobOverlap < T::Struct
38
+ extend T::Sig
39
+
40
+ const :mapping_contexts, T::Array[MappingContext]
41
+
42
+ sig { returns(String) }
43
+ def description
44
+ # These are sorted only to prevent non-determinism in output between local and CI environments.
45
+ sorted_contexts = mapping_contexts.sort_by { |context| context.feature.config_yml.to_s }
46
+ description_args = sorted_contexts.map do |context|
47
+ "`#{context.glob}` (from `#{context.feature.config_yml}`)"
48
+ end
49
+
50
+ description_args.join(', ')
51
+ end
52
+ end
53
+
54
+ sig do
55
+ returns(T::Array[GlobOverlap])
56
+ end
57
+ def find_overlapping_globs
58
+ mapped_files = T.let({}, T::Hash[String, T::Array[MappingContext]])
59
+ FeatureMap::CodeFeatures.all.each_with_object({}) do |feature, _map|
60
+ FeaturePlugins::Assignment.for(feature).assigned_globs.each do |glob|
61
+ Dir.glob(glob).each do |filename|
62
+ mapped_files[filename] ||= []
63
+ T.must(mapped_files[filename]) << MappingContext.new(glob: glob, feature: feature)
64
+ end
65
+ end
66
+ end
67
+
68
+ overlaps = T.let([], T::Array[GlobOverlap])
69
+ mapped_files.each_value do |mapping_contexts|
70
+ if mapping_contexts.count > 1
71
+ overlaps << GlobOverlap.new(mapping_contexts: mapping_contexts)
72
+ end
73
+ end
74
+
75
+ overlaps.uniq do |glob_overlap|
76
+ glob_overlap.mapping_contexts.map do |context|
77
+ [context.glob, context.feature.name]
78
+ end
79
+ end
80
+ end
81
+
82
+ sig do
83
+ override.params(file: String)
84
+ .returns(T.nilable(FeatureMap::CodeFeatures::Feature))
85
+ end
86
+ def map_file_to_feature(file)
87
+ map_files_to_features([file])[file]
88
+ end
89
+
90
+ sig do
91
+ override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
92
+ end
93
+ def update_cache(cache, files)
94
+ globs_to_feature(files)
95
+ end
96
+
97
+ sig do
98
+ override.params(files: T::Array[String])
99
+ .returns(T::Hash[String, FeatureMap::CodeFeatures::Feature])
100
+ end
101
+ def globs_to_feature(files)
102
+ FeatureMap::CodeFeatures.all.each_with_object({}) do |feature, map|
103
+ FeaturePlugins::Assignment.for(feature).assigned_globs.each do |assigned_glob|
104
+ map[assigned_glob] = feature
105
+ end
106
+ end
107
+ end
108
+
109
+ sig { override.void }
110
+ def bust_caches!
111
+ @@map_files_to_features = {} # rubocop:disable Style/ClassVars
112
+ end
113
+
114
+ sig { override.returns(String) }
115
+ def description
116
+ 'Feature-specific assigned globs'
117
+ end
118
+
119
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
120
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
121
+ overlapping_globs = AssignmentMappers::FeatureGlobs.new.find_overlapping_globs
122
+
123
+ errors = T.let([], T::Array[String])
124
+
125
+ if overlapping_globs.any?
126
+ errors << <<~MSG
127
+ `assigned_globs` cannot overlap between features. The following globs overlap:
128
+
129
+ #{overlapping_globs.map { |overlap| "- #{overlap.description}" }.join("\n")}
130
+ MSG
131
+ end
132
+
133
+ errors
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end