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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module FeatureMap
6
+ module Private
7
+ module AssignmentMappers
8
+ # Calculate, cache, and return a mapping of file names (relative to the root
9
+ # of the repository) to a feature name.
10
+ #
11
+ # Example:
12
+ #
13
+ # {
14
+ # 'app/models/company.rb' => Feature.find('Onboarding'),
15
+ # ...
16
+ # }
17
+ class FileAnnotations
18
+ extend T::Sig
19
+ include Mapper
20
+
21
+ # NOTE: regex 'x' arg ignores whitespace within the _construction_ of the regex.
22
+ # regex 'm' arg allows the regex to _execute_ on multiline strings.
23
+ SINGLE_LINE_ANNOTATION_PATTERN = T.let(
24
+ /
25
+ \s* # Any amount of whitespace
26
+ (#{Constants::SINGLE_LINE_COMMENT_PATTERNS.join('|')}) # Single line comment start
27
+ \s* # Any amount of whitespace, not including newlines
28
+ @feature\s # We find the feature annotation followed by one space
29
+ (?<feature>.*?$) # A named capture grabs the rest as the feature until the line ends
30
+ /x.freeze,
31
+ Regexp
32
+ )
33
+ MULTILINE_ANNOTATION_PATTERN = T.let(
34
+ /
35
+ (?:#{Constants::MULTILINE_COMMENT_START_PATTERNS.join('|')}) # Any comment start
36
+ .*? # Followed by any characters, including newlines, until...
37
+ @feature\s # We find the feature annotation followed by one space
38
+ (?<feature>.*?$) # A named capture grabs the rest as the feature until the line ends
39
+ /xm.freeze,
40
+ Regexp
41
+ )
42
+ DESCRIPTION = 'Annotations at the top of file'
43
+
44
+ sig do
45
+ override.params(file: String)
46
+ .returns(T.nilable(CodeFeatures::Feature))
47
+ end
48
+ def map_file_to_feature(file)
49
+ file_annotation_based_feature(file)
50
+ end
51
+
52
+ sig do
53
+ override
54
+ .params(files: T::Array[String])
55
+ .returns(T::Hash[String, CodeFeatures::Feature])
56
+ end
57
+ def globs_to_feature(files)
58
+ files.each_with_object({}) do |filename_relative_to_root, mapping|
59
+ feature = file_annotation_based_feature(filename_relative_to_root)
60
+ next unless feature
61
+
62
+ mapping[filename_relative_to_root] = feature
63
+ end
64
+ end
65
+
66
+ sig do
67
+ override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
68
+ end
69
+ def update_cache(cache, files)
70
+ # We map files to nil features so that files whose annotation have been removed will be properly
71
+ # overwritten (i.e. removed) from the cache.
72
+ fileset = Set.new(files)
73
+ updated_cache_for_files = globs_to_feature(files)
74
+ cache.merge!(updated_cache_for_files)
75
+
76
+ invalid_files = cache.keys.select do |file|
77
+ # If a file is not tracked, it should be removed from the cache
78
+ !Private.file_tracked?(file) ||
79
+ # If a file no longer has a file annotation (i.e. `globs_to_feature` doesn't map it)
80
+ # it should be removed from the cache
81
+ # We make sure to only apply this to the input files since otherwise `updated_cache_for_files.key?(file)` would always return `false` when files == []
82
+ (fileset.include?(file) && !updated_cache_for_files.key?(file))
83
+ end
84
+
85
+ invalid_files.each do |invalid_file|
86
+ cache.delete(invalid_file)
87
+ end
88
+
89
+ cache
90
+ end
91
+
92
+ sig { params(lines: T::Array[String]).returns(T.nilable(String)) }
93
+ def identify_feature_from(lines)
94
+ matched_single_line_feature = lines.join("\n").match(SINGLE_LINE_ANNOTATION_PATTERN)
95
+ matched_multiline_feature = lines.join("\n").match(MULTILINE_ANNOTATION_PATTERN)
96
+ matched_feature = matched_single_line_feature || matched_multiline_feature
97
+ return if matched_feature.nil?
98
+
99
+ T.must(matched_feature
100
+ .values_at(:feature)
101
+ .first)
102
+ .gsub(/#{Constants::MULTILINE_COMMENT_END_PATTERNS.join('|')}/, '')
103
+ .strip
104
+ rescue ArgumentError => e
105
+ raise unless e.message.include?('invalid byte sequence')
106
+ end
107
+
108
+ sig { params(filename: String).returns(T.nilable(CodeFeatures::Feature)) }
109
+ def file_annotation_based_feature(filename)
110
+ # Not too sure what the following comment means but it was carried over from the code_ownership repo, so
111
+ # I've opted to leave it unchanged in case it is helpful for future engineers:
112
+ # > If for a directory is named with an ownable extension, we need to skip
113
+ # > so File.foreach doesn't blow up below. This was needed because Cypress
114
+ # > screenshots are saved to a folder with the test suite filename.
115
+ return if File.directory?(filename)
116
+ return unless File.file?(filename)
117
+
118
+ # The annotation should be on one of the first ten lines.
119
+ # If the annotation isn't in the first ten lines we assume it
120
+ # doesn't exist.
121
+
122
+ lines = File.foreach(filename).first(10)
123
+ return if lines.empty?
124
+
125
+ feature = identify_feature_from(lines)
126
+ return unless feature
127
+
128
+ Private.find_feature!(
129
+ feature,
130
+ filename
131
+ )
132
+ end
133
+
134
+ sig { params(filename: String).void }
135
+ def remove_file_annotation!(filename)
136
+ if file_annotation_based_feature(filename)
137
+ filepath = Pathname.new(filename)
138
+ lines = filepath.read.split("\n")
139
+ new_lines = lines.reject { |line| line[SINGLE_LINE_ANNOTATION_PATTERN] }
140
+ # We explicitly add a final new line since splitting by new line when reading the file lines
141
+ # ignores new lines at the ends of files
142
+ # We also remove leading new lines, since there is after a new line after an annotation
143
+ new_file_contents = "#{new_lines.join("\n")}\n".gsub(/\A\n+/, '')
144
+ filepath.write(new_file_contents)
145
+ end
146
+ end
147
+
148
+ sig { override.returns(String) }
149
+ def description
150
+ DESCRIPTION
151
+ end
152
+
153
+ sig { override.void }
154
+ def bust_caches!; end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,190 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ #
7
+ # This class is responsible for turning FeatureMap directives (e.g. annotations, directory assignments, etc)
8
+ # into a assignments.yml file, that can be used as an input to a variety of engineering team utilities (e.g.
9
+ # PR/release announcements, documentation generation, etc).
10
+ #
11
+ class AssignmentsFile
12
+ extend T::Sig
13
+
14
+ class FileContentError < StandardError; end
15
+
16
+ FILES_KEY = 'files'
17
+ FILE_FEATURE_KEY = 'feature'
18
+ FILE_MAPPER_KEY = 'mapper'
19
+ FEATURES_KEY = 'features'
20
+ FEATURE_FILES_KEY = 'files'
21
+
22
+ FeatureName = T.type_alias { String }
23
+ FilePath = T.type_alias { String }
24
+ MapperDescription = T.type_alias { String }
25
+
26
+ FileDetails = T.type_alias do
27
+ T::Hash[
28
+ String,
29
+ T.any(FeatureName, MapperDescription)
30
+ ]
31
+ end
32
+
33
+ FilesContent = T.type_alias do
34
+ T::Hash[
35
+ FilePath,
36
+ FileDetails
37
+ ]
38
+ end
39
+
40
+ FileList = T.type_alias { T::Array[String] }
41
+ TeamList = T.type_alias { T::Array[String] }
42
+
43
+ FeatureDetails = T.type_alias do
44
+ T::Hash[
45
+ String,
46
+ T.any(FileList, TeamList)
47
+ ]
48
+ end
49
+
50
+ FeaturesContent = T.type_alias do
51
+ T::Hash[
52
+ FeatureName,
53
+ FeatureDetails
54
+ ]
55
+ end
56
+
57
+ sig { returns(T::Array[String]) }
58
+ def self.actual_contents_lines
59
+ if path.exist?
60
+ content = path.read
61
+ lines = path.read.split("\n")
62
+ if content.end_with?("\n")
63
+ lines << ''
64
+ end
65
+ lines
66
+ else
67
+ ['']
68
+ end
69
+ end
70
+
71
+ sig { returns(T::Array[T.nilable(String)]) }
72
+ def self.expected_contents_lines
73
+ cache = Private.glob_cache.raw_cache_contents
74
+
75
+ header = <<~HEADER
76
+ # STOP! - DO NOT EDIT THIS FILE MANUALLY
77
+ # This file was automatically generated by "bin/featuremap validate". The next time this file
78
+ # is generated any changes will be lost. For more details:
79
+ # https://github.com/Beyond-Finance/feature_map
80
+ #
81
+ # It is recommended to commit this file into your source control. It will only change when the
82
+ # set of files assigned to a feature change, which should be explicitly tracked.
83
+ HEADER
84
+
85
+ files_content = T.let({}, FilesContent)
86
+ files_by_feature = T.let({}, T::Hash[FeatureName, FileList])
87
+ features_content = T.let({}, FeaturesContent)
88
+
89
+ cache.each do |mapper_description, assignment_map_cache|
90
+ assignment_map_cache = assignment_map_cache.sort_by do |glob, _feature|
91
+ glob
92
+ end
93
+
94
+ assignment_map_cache.to_h.each do |path, feature|
95
+ files_content[path] = T.let({ FILE_FEATURE_KEY => feature.name, FILE_MAPPER_KEY => mapper_description }, FileDetails)
96
+
97
+ files_by_feature[feature.name] ||= []
98
+ T.must(files_by_feature[feature.name]) << path
99
+ end
100
+ end
101
+
102
+ # Ordering of features in the resulting YAML content is determined by the order in which keys are added to
103
+ # each hash.
104
+ CodeFeatures.all.sort_by(&:name).each do |feature|
105
+ files = files_by_feature[feature.name] || []
106
+ expanded_files = files.flat_map { |file| Dir.glob(file) }.reject { |path| File.directory?(path) }
107
+
108
+ # Exclude features that have no releated files. These features are presumably irrelevant to the current
109
+ # repo/application.
110
+ next if expanded_files.empty?
111
+
112
+ features_content[feature.name] = T.let({ 'files' => expanded_files.sort }, FeatureDetails)
113
+
114
+ if !Private.configuration.skip_code_ownership
115
+ T.must(features_content[feature.name])['teams'] = expanded_files.map { |file| CodeOwnership.for_file(file)&.name }.compact.uniq.sort
116
+ end
117
+ end
118
+
119
+ [
120
+ *header.split("\n"),
121
+ '', # For line between header and file assignments lines
122
+ *{ FILES_KEY => files_content, FEATURES_KEY => features_content }.to_yaml.split("\n"),
123
+ '' # For end-of-file newline
124
+ ]
125
+ end
126
+
127
+ sig { void }
128
+ def self.write!
129
+ FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
130
+ path.write(expected_contents_lines.join("\n"))
131
+ end
132
+
133
+ sig { returns(Pathname) }
134
+ def self.path
135
+ Pathname.pwd.join('.feature_map/assignments.yml')
136
+ end
137
+
138
+ sig { params(files: T::Array[String]).void }
139
+ def self.update_cache!(files)
140
+ cache = Private.glob_cache
141
+ # Each mapper returns a new copy of the cache subset related to that mapper,
142
+ # which is then stored back into the cache.
143
+ Mapper.all.each do |mapper|
144
+ existing_cache = cache.raw_cache_contents.fetch(mapper.description, {})
145
+ updated_cache = mapper.update_cache(existing_cache, files)
146
+ cache.raw_cache_contents[mapper.description] = updated_cache
147
+ end
148
+ end
149
+
150
+ sig { returns(T::Boolean) }
151
+ def self.use_features_cache?
152
+ AssignmentsFile.path.exist? && !Private.configuration.skip_features_validation
153
+ end
154
+
155
+ sig { returns(GlobCache) }
156
+ def self.to_glob_cache
157
+ raw_cache_contents = T.let({}, GlobCache::CacheShape)
158
+ features_by_name = CodeFeatures.all.each_with_object({}) do |feature, map|
159
+ map[feature.name] = feature
160
+ end
161
+ mapper_descriptions = Set.new(Mapper.all.map(&:description))
162
+
163
+ features_file_content = YAML.load_file(path)
164
+ features_file_content[FILES_KEY]&.each do |file_path, file_assignment|
165
+ next if file_assignment.nil?
166
+ next if file_assignment[FILE_FEATURE_KEY].nil? || features_by_name[file_assignment[FILE_FEATURE_KEY]].nil?
167
+ next if file_assignment[FILE_MAPPER_KEY].nil? || !mapper_descriptions.include?(file_assignment[FILE_MAPPER_KEY])
168
+
169
+ raw_cache_contents[file_assignment[FILE_MAPPER_KEY]] ||= {}
170
+ raw_cache_contents.fetch(file_assignment[FILE_MAPPER_KEY])[file_path] = features_by_name[file_assignment[FILE_FEATURE_KEY]]
171
+ end
172
+
173
+ GlobCache.new(raw_cache_contents)
174
+ end
175
+
176
+ sig { returns(FeaturesContent) }
177
+ def self.load_features!
178
+ assignments_content = YAML.load_file(path)
179
+
180
+ return assignments_content[FEATURES_KEY] if assignments_content.is_a?(Hash) && assignments_content[FEATURES_KEY]
181
+
182
+ raise FileContentError, "Unexpected content found in #{path}. Use `bin/featuremap validate` to regenerate it and try again."
183
+ rescue Psych::SyntaxError => e
184
+ raise FileContentError, "Invalid YAML content found at #{path}. Error: #{e.message} Use `bin/featuremap validate` to generate it and try again."
185
+ rescue Errno::ENOENT
186
+ raise FileContentError, "No feature assignments file found at #{path}. Use `bin/featuremap validate` to generate it and try again."
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,96 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'faraday'
5
+
6
+ module FeatureMap
7
+ module Private
8
+ # This class is responsible for all interactions with the CodeCov platform.
9
+ class CodeCov
10
+ CODE_COV_API_BASE_URL = 'https://api.codecov.io/api/v2/'
11
+
12
+ class ApiError < StandardError; end
13
+ class ConfigurationError < StandardError; end
14
+
15
+ extend T::Sig
16
+
17
+ FilePath = T.type_alias { String }
18
+ CoverageStat = T.type_alias { String }
19
+
20
+ Coverage = T.type_alias do
21
+ T::Hash[
22
+ CoverageStat,
23
+ Integer
24
+ ]
25
+ end
26
+
27
+ TestCoverageStats = T.type_alias do
28
+ T::Hash[
29
+ FilePath,
30
+ Coverage
31
+ ]
32
+ end
33
+
34
+ sig { params(commit_sha: String, api_token: String).returns(TestCoverageStats) }
35
+ def self.fetch_coverage_stats(commit_sha, api_token)
36
+ commit_details_response = fetch_commit_details(commit_sha, api_token)
37
+ raise ApiError, "Failed to retrieve CodeCov stats for commit #{commit_sha}. Response: #{commit_details_response.status} - #{commit_details_response.body}" unless commit_details_response.success?
38
+
39
+ build_coverage_status(commit_details_response.body)
40
+ end
41
+
42
+ sig { params(commit_sha: String, api_token: String).returns(T.untyped) }
43
+ def self.fetch_commit_details(commit_sha, api_token)
44
+ conn.get("#{service}/#{owner}/repos/#{repo}/commits/#{commit_sha}",
45
+ {},
46
+ { 'Authorization' => "Bearer #{api_token}" })
47
+ end
48
+
49
+ sig { params(commit_details: T::Hash[T.untyped, T.untyped]).returns(TestCoverageStats) }
50
+ def self.build_coverage_status(commit_details)
51
+ file_coverage_details = commit_details.dig('report', 'files')
52
+ raise ApiError, 'No file coverage information retruned from CodeCov.' unless file_coverage_details
53
+
54
+ file_coverage_details.each_with_object({}) do |file_coverage, coverage_stats|
55
+ file_path = file_coverage['name']
56
+ file_coverage_stats = file_coverage['totals']
57
+
58
+ next if !file_path || !file_coverage_stats
59
+
60
+ coverage_stats[file_path] = {
61
+ 'lines' => file_coverage_stats['lines'],
62
+ 'hits' => file_coverage_stats['hits'],
63
+ 'misses' => file_coverage_stats['misses']
64
+ }
65
+ end
66
+ end
67
+
68
+ # TODO: Move these values to config.
69
+ sig { returns(String) }
70
+ def self.service
71
+ Private.configuration.code_cov['service'] ||
72
+ (raise ConfigurationError, 'Missing CodeCov configuration: service')
73
+ end
74
+
75
+ sig { returns(String) }
76
+ def self.owner
77
+ Private.configuration.code_cov['owner'] ||
78
+ (raise ConfigurationError, 'Missing CodeCov configuration: owner')
79
+ end
80
+
81
+ sig { returns(String) }
82
+ def self.repo
83
+ Private.configuration.code_cov['repo'] ||
84
+ (raise ConfigurationError, 'Missing CodeCov configuration: repo')
85
+ end
86
+
87
+ sig { returns(Faraday::Connection) }
88
+ def self.conn
89
+ @conn ||= T.let(Faraday.new(url: CODE_COV_API_BASE_URL) do |f|
90
+ f.request :json
91
+ f.response :json
92
+ end, T.nilable(Faraday::Connection))
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,46 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'parser/current'
5
+
6
+ module FeatureMap
7
+ module Private
8
+ class CyclomaticComplexityCalculator
9
+ extend T::Sig
10
+
11
+ COMPLEXITY_NODES = %i[
12
+ if case while until for
13
+ rescue when and or
14
+ ].freeze
15
+
16
+ sig { params(ast: T.nilable(Parser::AST::Node)).void }
17
+ def initialize(ast)
18
+ @ast = ast
19
+ @complexity = T.let(1, Integer) # Start at 1 for the base path
20
+ end
21
+
22
+ sig { returns(Integer) }
23
+ def calculate
24
+ process(@ast)
25
+ @complexity
26
+ end
27
+
28
+ private
29
+
30
+ sig { params(node: T.nilable(T.any(Parser::AST::Node, Symbol, Integer, String, NilClass))).void }
31
+ def process(node)
32
+ return unless node.is_a?(Parser::AST::Node)
33
+
34
+ # Increment complexity for each branching node
35
+ @complexity += 1 if COMPLEXITY_NODES.include?(node.type)
36
+
37
+ # Process children
38
+ node.children.each do |child|
39
+ # Nodes can have children that are Symbols, Integers, Strings, or nil
40
+ # We only want to process actual AST nodes
41
+ process(child) if child.is_a?(Parser::AST::Node)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end