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,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