feature_map 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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