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,151 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ #
7
+ # This class is responsible for compiling a set of feature-level test pyramid statistics into a test-pyramid.yml
8
+ # This file can then be used as an input to a variety of engineering team utilities
9
+ # (e.g. PR/release announcements, documentation generation, etc).
10
+ #
11
+ class TestPyramidFile
12
+ extend T::Sig
13
+
14
+ class FileContentError < StandardError; end
15
+
16
+ FEATURES_KEY = 'features'
17
+
18
+ FeatureName = T.type_alias { String }
19
+ PyramidStat = T.type_alias { String }
20
+
21
+ FeaturePyramid = T.type_alias do
22
+ T::Hash[
23
+ PyramidStat,
24
+ Integer
25
+ ]
26
+ end
27
+
28
+ FeaturesContent = T.type_alias do
29
+ T::Hash[
30
+ FeatureName,
31
+ FeaturePyramid
32
+ ]
33
+ end
34
+
35
+ sig do
36
+ params(
37
+ unit_examples: T::Array[T::Hash[T.untyped, T.untyped]],
38
+ integration_examples: T::Array[T::Hash[T.untyped, T.untyped]],
39
+ regression_examples: T::Array[T::Hash[T.untyped, T.untyped]],
40
+ regression_assignments: T::Hash[T.untyped, T.untyped]
41
+ ).void
42
+ end
43
+ def self.write!(unit_examples, integration_examples, regression_examples, regression_assignments)
44
+ FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
45
+
46
+ regression_file_assignments = regression_assignments['features']&.transform_values do |feature|
47
+ feature['files']&.map { |file| filepath(file) } || []
48
+ end || {}
49
+
50
+ content = generate_content(
51
+ unit_examples.group_by { |ex| filepath(ex['id']) },
52
+ integration_examples.group_by { |ex| filepath(ex['id']) },
53
+ regression_examples.group_by { |ex| filepath(ex['id']) },
54
+ regression_file_assignments
55
+ )
56
+ path.write([header_comment, "\n", { FEATURES_KEY => content }.to_yaml].join)
57
+ end
58
+
59
+ sig { returns(Pathname) }
60
+ def self.path
61
+ Pathname.pwd.join('.feature_map/test-pyramid.yml')
62
+ end
63
+
64
+ sig { returns(String) }
65
+ def self.header_comment
66
+ <<~HEADER
67
+ # STOP! - DO NOT EDIT THIS FILE MANUALLY
68
+ # This file was automatically generated by "bin/featuremap test_pyramid". The next time this file
69
+ # is generated any changes will be lost. For more details:
70
+ # https://github.com/Beyond-Finance/feature_map
71
+ #
72
+ # It is NOT recommended to commit this file into your source control. It will change or become
73
+ # outdated frequently. Instead it should be regenerated when test pyramid statistics are required.
74
+ # This file should be ignored by your source control, allowing the local copy to be used for other
75
+ # feature analysis operations (e.g. documentation generation, etc).
76
+ HEADER
77
+ end
78
+
79
+ sig do
80
+ params(
81
+ unit_examples: T::Hash[T.untyped, T.untyped],
82
+ integration_examples: T::Hash[T.untyped, T.untyped],
83
+ regression_examples: T::Hash[T.untyped, T.untyped],
84
+ regression_file_assignments: T::Hash[T.untyped, T.untyped]
85
+ ).returns(FeaturesContent)
86
+ end
87
+ def self.generate_content(unit_examples, integration_examples, regression_examples, regression_file_assignments)
88
+ Private.feature_file_assignments.reduce({}) do |content, (feature_name, files)|
89
+ regression_files = regression_file_assignments[feature_name] || []
90
+ regression_count, regression_pending = regression_files.reduce([0, 0]) do |accumulated_counts, file|
91
+ accumulated_count, accumulated_pending = accumulated_counts
92
+ count, pending = split(regression_examples[file])
93
+
94
+ [accumulated_count + count, accumulated_pending + pending]
95
+ end
96
+
97
+ pyramid = files.reduce({}) do |acc, file|
98
+ normalized_path = filepath(file)
99
+
100
+ unit_count, unit_pending = split(unit_examples["#{normalized_path}_spec"])
101
+ integration_count, integration_pending = split(integration_examples[normalized_path])
102
+
103
+ {
104
+ 'unit_count' => (acc['unit_count'] || 0) + unit_count,
105
+ 'unit_pending' => (acc['unit_pending'] || 0) + unit_pending,
106
+ 'integration_count' => (acc['integration_count'] || 0) + integration_count,
107
+ 'integration_pending' => (acc['integration_pending'] || 0) + integration_pending
108
+ }
109
+ end
110
+
111
+ {
112
+ feature_name => pyramid.merge(
113
+ 'regression_count' => regression_count,
114
+ 'regression_pending' => regression_pending
115
+ ),
116
+ **content
117
+ }
118
+ end
119
+ end
120
+
121
+ sig { returns(FeaturesContent) }
122
+ def self.load_features!
123
+ test_coverage_content = YAML.load_file(path)
124
+
125
+ return test_coverage_content[FEATURES_KEY] if test_coverage_content.is_a?(Hash) && test_coverage_content[FEATURES_KEY]
126
+
127
+ raise FileContentError, "Unexpected content found in #{path}. Use `bin/featuremap test_coverage` to regenerate it and try again."
128
+ rescue Psych::SyntaxError => e
129
+ raise FileContentError, "Invalid YAML content found at #{path}. Error: #{e.message} Use `bin/featuremap test_coverage` to generate it and try again."
130
+ rescue Errno::ENOENT
131
+ raise FileContentError, "No feature test coverage file found at #{path}. Use `bin/featuremap test_coverage` to generate it and try again."
132
+ end
133
+
134
+ sig { params(examples: T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])).returns(T::Array[Integer]) }
135
+ def self.split(examples)
136
+ return [0, 0] if examples.nil?
137
+
138
+ examples.partition { |ex| ex['status'] == 'passed' }.map(&:count)
139
+ end
140
+
141
+ sig { params(pathlike: String).returns(String) }
142
+ def self.filepath(pathlike)
143
+ File
144
+ .join(File.dirname(pathlike), File.basename(pathlike, '.*'))
145
+ .gsub(%r{^\./}, '')
146
+ .gsub(%r{^spec/}, '')
147
+ .gsub(%r{^app/}, '')
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,57 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class TodoInspector
7
+ extend T::Sig
8
+
9
+ ENTERING_COMMENT = T.let(
10
+ /
11
+ (#{(Constants::SINGLE_LINE_COMMENT_PATTERNS + Constants::MULTILINE_COMMENT_START_PATTERNS).join('|')})
12
+ /x.freeze,
13
+ Regexp
14
+ )
15
+
16
+ EXITING_COMMENT = T.let(
17
+ /
18
+ (#{(Constants::SINGLE_LINE_COMMENT_PATTERNS + Constants::MULTILINE_COMMENT_END_PATTERNS).join('|')})
19
+ /x.freeze,
20
+ Regexp
21
+ )
22
+
23
+ TODO_PATTERN = T.let(
24
+ /
25
+ TODO:?\s* # TODO with optional colon with whitespace
26
+ (?<content>.*?) # The actual TODO content
27
+ (#{Constants::MULTILINE_COMMENT_END_PATTERNS.join('|')})?$ # ignores comment end patterns
28
+ /xi.freeze,
29
+ Regexp
30
+ )
31
+
32
+ sig { params(file_path: String).void }
33
+ def initialize(file_path)
34
+ @file_path = file_path
35
+ end
36
+
37
+ sig { returns(T::Hash[String, String]) }
38
+ def calculate
39
+ todos = {}
40
+ content = File.read(@file_path)
41
+ in_comment = T.let(false, T::Boolean)
42
+
43
+ content.each_line.with_index do |line, index|
44
+ in_comment ||= line.match?(ENTERING_COMMENT)
45
+
46
+ if in_comment && (match = line.match(TODO_PATTERN))
47
+ todos["#{@file_path}:#{index + 1}"] = T.must(match[:content]).strip
48
+ end
49
+
50
+ in_comment &&= !line.match?(EXITING_COMMENT)
51
+ end
52
+
53
+ todos
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,78 @@
1
+ # typed: strict
2
+
3
+ module FeatureMap
4
+ module Private
5
+ module Validations
6
+ class FeaturesUpToDate
7
+ extend T::Sig
8
+ extend T::Helpers
9
+ include Validator
10
+
11
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
13
+ return [] if Private.configuration.skip_features_validation
14
+
15
+ actual_content_lines = AssignmentsFile.actual_contents_lines
16
+ expected_content_lines = AssignmentsFile.expected_contents_lines
17
+
18
+ features_file_up_to_date = actual_content_lines == expected_content_lines
19
+ errors = T.let([], T::Array[String])
20
+
21
+ if !features_file_up_to_date
22
+ if autocorrect
23
+ AssignmentsFile.write!
24
+ if stage_changes
25
+ `git add #{AssignmentsFile.path}`
26
+ end
27
+ # If there is no current file or its empty, display a shorter message.
28
+ elsif actual_content_lines == ['']
29
+ errors << <<~FEATURES_FILE_ERROR
30
+ .feature_map/assignments.yml out of date. Run `bin/featuremap validate` to update the .feature_map/assignments.yml file
31
+ FEATURES_FILE_ERROR
32
+ else
33
+ missing_lines = expected_content_lines - actual_content_lines
34
+ extra_lines = actual_content_lines - expected_content_lines
35
+
36
+ missing_lines_text = if missing_lines.any?
37
+ <<~COMMENT
38
+ .feature_map/assignments.yml should contain the following lines, but does not:
39
+ #{missing_lines.map { |line| "- \"#{line}\"" }.join("\n")}
40
+ COMMENT
41
+ end
42
+
43
+ extra_lines_text = if extra_lines.any?
44
+ <<~COMMENT
45
+ .feature_map/assignments.yml should not contain the following lines, but it does:
46
+ #{extra_lines.map { |line| "- \"#{line}\"" }.join("\n")}
47
+ COMMENT
48
+ end
49
+
50
+ diff_text = if missing_lines_text && extra_lines_text
51
+ "#{missing_lines_text}\n#{extra_lines_text}".chomp
52
+ elsif missing_lines_text
53
+ missing_lines_text
54
+ elsif extra_lines_text
55
+ extra_lines_text
56
+ else
57
+ <<~TEXT
58
+ There may be extra lines, or lines are out of order.
59
+ You can try to regenerate the .feature_map/assignments.yml file from scratch:
60
+ 1) `rm .feature_map/assignments.yml`
61
+ 2) `bin/featuremap validate`
62
+ TEXT
63
+ end
64
+
65
+ errors << <<~FEATURES_FILE_ERROR
66
+ .feature_map/assignments.yml out of date. Run `bin/featuremap validate` to update the .feature_map/assignments.yml file
67
+
68
+ #{diff_text.chomp}
69
+ FEATURES_FILE_ERROR
70
+ end
71
+ end
72
+
73
+ errors
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ # typed: strict
2
+
3
+ require 'code_ownership'
4
+
5
+ module FeatureMap
6
+ module Private
7
+ module Validations
8
+ class FilesHaveFeatures
9
+ extend T::Sig
10
+ extend T::Helpers
11
+ include Validator
12
+
13
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
14
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
15
+ cache = Private.glob_cache
16
+ file_mappings = cache.mapper_descriptions_that_map_files(files)
17
+ files_not_mapped_at_all = file_mappings.select do |_file, mapper_descriptions|
18
+ mapper_descriptions.count.zero?
19
+ end
20
+
21
+ errors = T.let([], T::Array[String])
22
+
23
+ # When a set of teams are configured that require assignments, ignore any files NOT
24
+ # assigned to one of these teams.
25
+ unless Private.configuration.require_assignment_for_teams.nil?
26
+ files_not_mapped_at_all.filter! do |file, _mappers|
27
+ file_team = CodeOwnership.for_file(file)
28
+ file_team && T.must(Private.configuration.require_assignment_for_teams).include?(file_team.name)
29
+ end
30
+ end
31
+
32
+ if files_not_mapped_at_all.any?
33
+ errors << <<~MSG
34
+ Some files are missing a feature assignment:
35
+
36
+ #{files_not_mapped_at_all.map { |file, _mappers| "- #{file}" }.join("\n")}
37
+ MSG
38
+ end
39
+
40
+ errors
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+
3
+ module FeatureMap
4
+ module Private
5
+ module Validations
6
+ class FilesHaveUniqueFeatures
7
+ extend T::Sig
8
+ extend T::Helpers
9
+ include Validator
10
+
11
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
13
+ cache = Private.glob_cache
14
+ file_mappings = cache.mapper_descriptions_that_map_files(files)
15
+ files_mapped_by_multiple_mappers = file_mappings.select do |_file, mapper_descriptions|
16
+ mapper_descriptions.count > 1
17
+ end
18
+
19
+ errors = T.let([], T::Array[String])
20
+
21
+ if files_mapped_by_multiple_mappers.any?
22
+ errors << <<~MSG
23
+ Feature assignment should only be defined for each file in one way. The following files have had features assigned in multiple ways.
24
+
25
+ #{files_mapped_by_multiple_mappers.map { |file, descriptions| "- #{file} (#{descriptions.to_a.join(', ')})" }.join("\n")}
26
+ MSG
27
+ end
28
+
29
+ errors
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require 'feature_map/constants'
6
+ require 'feature_map/private/extension_loader'
7
+ require 'feature_map/private/cyclomatic_complexity_calculator'
8
+ require 'feature_map/private/lines_of_code_calculator'
9
+ require 'feature_map/private/todo_inspector'
10
+ require 'feature_map/private/feature_metrics_calculator'
11
+ require 'feature_map/private/assignments_file'
12
+ require 'feature_map/private/metrics_file'
13
+ require 'feature_map/private/glob_cache'
14
+ require 'feature_map/private/feature_assigner'
15
+ require 'feature_map/private/documentation_site'
16
+ require 'feature_map/private/code_cov'
17
+ require 'feature_map/private/test_coverage_file'
18
+ require 'feature_map/private/test_pyramid_file'
19
+ require 'feature_map/private/feature_plugins/assignment'
20
+ require 'feature_map/private/validations/files_have_features'
21
+ require 'feature_map/private/validations/features_up_to_date'
22
+ require 'feature_map/private/validations/files_have_unique_features'
23
+ require 'feature_map/private/assignment_mappers/file_annotations'
24
+ require 'feature_map/private/assignment_mappers/feature_globs'
25
+ require 'feature_map/private/assignment_mappers/directory_assignment'
26
+ require 'feature_map/private/assignment_mappers/feature_definition_assignment'
27
+
28
+ module FeatureMap
29
+ module Private
30
+ extend T::Sig
31
+
32
+ FeatureName = T.type_alias { String }
33
+ FileList = T.type_alias { T::Array[String] }
34
+ FeatureFiles = T.type_alias do
35
+ T::Hash[
36
+ FeatureName,
37
+ FileList
38
+ ]
39
+ end
40
+
41
+ sig { returns(Configuration) }
42
+ def self.configuration
43
+ @configuration ||= T.let(@configuration, T.nilable(Configuration))
44
+ @configuration ||= Configuration.fetch
45
+ end
46
+
47
+ # This is just an alias for `configuration` that makes it more explicit what we're doing instead of just calling `configuration`.
48
+ # This is necessary because configuration may contain extensions of feature map, so those extensions should be loaded prior to
49
+ # calling APIs that provide feature assignment information.
50
+ sig { returns(Configuration) }
51
+ def self.load_configuration!
52
+ configuration
53
+ end
54
+
55
+ sig { void }
56
+ def self.bust_caches!
57
+ @configuration = nil
58
+ @tracked_files = nil
59
+ @glob_cache = nil
60
+ end
61
+
62
+ sig { params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).void }
63
+ def self.validate!(files:, autocorrect: true, stage_changes: true)
64
+ AssignmentsFile.update_cache!(files) if AssignmentsFile.use_features_cache?
65
+
66
+ errors = Validator.all.flat_map do |validator|
67
+ validator.validation_errors(
68
+ files: files,
69
+ autocorrect: autocorrect,
70
+ stage_changes: stage_changes
71
+ )
72
+ end
73
+
74
+ if errors.any?
75
+ errors << 'See https://github.com/Beyond-Finance/feature_map#README.md for more details'
76
+ raise InvalidFeatureMapConfigurationError.new(errors.join("\n")) # rubocop:disable Style/RaiseArgs
77
+ end
78
+
79
+ MetricsFile.write!
80
+ end
81
+
82
+ sig { params(git_ref: T.nilable(String)).void }
83
+ def self.generate_docs!(git_ref)
84
+ feature_assignments = AssignmentsFile.load_features!
85
+ feature_metrics = MetricsFile.load_features!
86
+
87
+ # Generating the test pyramid involves collecting dry-run coverage from rspec for unit, integration,
88
+ # and regression tests. This can be difficult to gather, so we allow for the docs site to be built
89
+ # without it.
90
+ feature_test_pyramid = TestPyramidFile.path.exist? ? TestPyramidFile.load_features! : {}
91
+
92
+ # Test coverage data can be onerous to load (e.g. generating a CodeCov token, etc). Allow engineers to generate
93
+ # and review the feature documentation without this data.
94
+ feature_test_coverage = TestCoverageFile.path.exist? ? TestCoverageFile.load_features! : {}
95
+
96
+ DocumentationSite.generate(
97
+ feature_assignments,
98
+ feature_metrics,
99
+ feature_test_coverage,
100
+ feature_test_pyramid,
101
+ configuration.raw_hash,
102
+ T.must(git_ref || configuration.repository['main_branch'])
103
+ )
104
+ end
105
+
106
+ sig do
107
+ params(
108
+ unit_path: String,
109
+ integration_path: String,
110
+ regression_path: String,
111
+ regression_assignments_path: String
112
+ ).void
113
+ end
114
+ def self.generate_test_pyramid!(unit_path, integration_path, regression_path, regression_assignments_path)
115
+ unit_examples = JSON.parse(File.read(unit_path))&.fetch('examples')
116
+ integration_examples = JSON.parse(File.read(integration_path))&.fetch('examples')
117
+ regression_examples = JSON.parse(File.read(regression_path))&.fetch('examples')
118
+ regression_assignments = YAML.load_file(regression_assignments_path)
119
+ TestPyramidFile.write!(unit_examples, integration_examples, regression_examples, regression_assignments)
120
+ end
121
+
122
+ sig { params(commit_sha: String, code_cov_token: String).void }
123
+ def self.gather_test_coverage!(commit_sha, code_cov_token)
124
+ coverage_stats = CodeCov.fetch_coverage_stats(commit_sha, code_cov_token)
125
+
126
+ TestCoverageFile.write!(coverage_stats)
127
+ end
128
+
129
+ # Returns a string version of the relative path to a Rails constant,
130
+ # or nil if it can't find something
131
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(String)) }
132
+ def self.path_from_klass(klass)
133
+ if klass
134
+ path = Object.const_source_location(klass.to_s)&.first
135
+ (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil
136
+ else
137
+ nil
138
+ end
139
+ rescue NameError
140
+ nil
141
+ end
142
+
143
+ #
144
+ # The output of this function is string pathnames relative to the root.
145
+ #
146
+ sig { returns(T::Array[String]) }
147
+ def self.tracked_files
148
+ @tracked_files ||= T.let(@tracked_files, T.nilable(T::Array[String]))
149
+ @tracked_files ||= Dir.glob(configuration.assigned_globs) - Dir.glob(configuration.unassigned_globs)
150
+ end
151
+
152
+ sig { params(file: String).returns(T::Boolean) }
153
+ def self.file_tracked?(file)
154
+ # Another way to accomplish this is
155
+ # (Dir.glob(configuration.assigned_globs) - Dir.glob(configuration.unassigned_globs)).include?(file)
156
+ # However, globbing out can take 5 or more seconds on a large repository, dramatically slowing down
157
+ # invocations to `bin/featuremap validate --diff`.
158
+ # Using `File.fnmatch?` is a lot faster!
159
+ in_assigned_globs = configuration.assigned_globs.any? do |assigned_glob|
160
+ File.fnmatch?(assigned_glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
161
+ end
162
+
163
+ in_unassigned_globs = configuration.unassigned_globs.any? do |unassigned_glob|
164
+ File.fnmatch?(unassigned_glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
165
+ end
166
+ in_assigned_globs && !in_unassigned_globs && File.exist?(file)
167
+ end
168
+
169
+ sig { params(feature_name: String, location_of_reference: String).returns(CodeFeatures::Feature) }
170
+ def self.find_feature!(feature_name, location_of_reference)
171
+ found_feature = CodeFeatures.find(feature_name)
172
+ if found_feature.nil?
173
+ raise StandardError, "Could not find feature with name: `#{feature_name}` in #{location_of_reference}. Make sure the feature is one of `#{CodeFeatures.all.map(&:name).sort}`"
174
+ else
175
+ found_feature
176
+ end
177
+ end
178
+
179
+ sig { returns(GlobCache) }
180
+ def self.glob_cache
181
+ @glob_cache ||= T.let(@glob_cache, T.nilable(GlobCache))
182
+ @glob_cache ||= if AssignmentsFile.use_features_cache?
183
+ AssignmentsFile.to_glob_cache
184
+ else
185
+ Mapper.to_glob_cache
186
+ end
187
+ end
188
+
189
+ sig { returns(FeatureFiles) }
190
+ def self.feature_file_assignments
191
+ glob_cache.raw_cache_contents.values.each_with_object(T.let({}, FeatureFiles)) do |assignment_map_cache, feature_files|
192
+ assignment_map_cache.to_h.each do |path, feature|
193
+ feature_files[feature.name] ||= T.let([], FileList)
194
+ files = Dir.glob(path).reject { |glob_entry| File.directory?(glob_entry) }
195
+ files.each { |file| T.must(feature_files[feature.name]) << file }
196
+ end
197
+
198
+ feature_files
199
+ end
200
+ end
201
+ end
202
+
203
+ private_constant :Private
204
+ end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+
3
+ module FeatureMap
4
+ module Validator
5
+ extend T::Sig
6
+ extend T::Helpers
7
+
8
+ interface!
9
+
10
+ sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
11
+ def validation_errors(files:, autocorrect: true, stage_changes: true); end
12
+
13
+ class << self
14
+ extend T::Sig
15
+
16
+ sig { params(base: T::Class[Validator]).void }
17
+ def included(base)
18
+ @validators ||= T.let(@validators, T.nilable(T::Array[T::Class[Validator]]))
19
+ @validators ||= []
20
+ @validators << base
21
+ end
22
+
23
+ sig { returns(T::Array[Validator]) }
24
+ def all
25
+ (@validators || []).map(&:new)
26
+ end
27
+ end
28
+ end
29
+ end