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,128 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ #
7
+ # This class is responsible for generating a standalone site that provides documentation of the features
8
+ # defined within a given application. This site consists of precompiled HTML, JS, and CSS content that
9
+ # is combined with a JSON file containing the assignment and metrics information about all application
10
+ # features.
11
+ #
12
+ # The code within the `docs` directory is directly copied into an output directory and combined
13
+ # with a single `feature-map-config.js` file containing content like the following:
14
+ # ```
15
+ # window.FEATURE_MAP_CONFIG = {
16
+ # environment: {
17
+ # "git_ref": "https://github.com/REPO/blob/GIT_SHA"
18
+ # },
19
+ # features: {
20
+ # "Foo": {
21
+ # "description": "The Foo feature with this sample application.",
22
+ # "dashboard_link": "https://example.com/dashbords/foo",
23
+ # "documentation_link": "https://example.com/docs/foo",
24
+ # "assignments": {
25
+ # "files": ["app/jobs/foo_job.rb", "app/lib/foo_service.rb"],
26
+ # "teams": ["team_a", "team_b"]
27
+ # },
28
+ # "metrics": {
29
+ # "abc_size": 12.34,
30
+ # "lines_of_code": 56,
31
+ # "cyclomatic_complexity": 7
32
+ # },
33
+ # "test_pyramid": {
34
+ # "unit_count": 100,
35
+ # "unit_pending": 12,
36
+ # "integration_count": 15,
37
+ # "integration_pending": 2,
38
+ # "regression_count": 6,
39
+ # "regression_pending": 0,
40
+ # }
41
+ # },
42
+ # "Bar": {
43
+ # "description": "Another feature within the application.",
44
+ # "dashboard_link": "https://example.com/docs/bar",
45
+ # "documentation_link": "https://example.com/dashbords/bar",
46
+ # "assignments":{
47
+ # "files": ["app/controllers/bar_controller.rb", "app/lib/bar_service.rb"],
48
+ # "teams": ["team_a"]
49
+ # },
50
+ # "metrics": {
51
+ # "abc_size": 98.76,
52
+ # "lines_of_code": 54,
53
+ # "cyclomatic_complexity": 32
54
+ # },
55
+ # "test_pyramid": null
56
+ # }
57
+ # },
58
+ # project: {
59
+ # ...values from ./feature_map/config.yml
60
+ # }
61
+ # };
62
+ # ```
63
+ # The `window.FEATURES` global variable is used within the site logic to render an appropriate set of
64
+ # documentation artifacts and charts.
65
+ class DocumentationSite
66
+ extend T::Sig
67
+
68
+ ASSETS_DIRECTORY = 'docs'
69
+ FETAURE_DEFINITION_KEYS_TO_INCLUDE = %w[description dashboard_link documentation_link].freeze
70
+
71
+ sig do
72
+ params(
73
+ feature_assignments: AssignmentsFile::FeaturesContent,
74
+ feature_metrics: MetricsFile::FeaturesContent,
75
+ feature_test_coverage: TestCoverageFile::FeaturesContent,
76
+ feature_test_pyramid: TestPyramidFile::FeaturesContent,
77
+ project_configuration: T::Hash[T.untyped, T.untyped],
78
+ git_ref: String
79
+ ).void
80
+ end
81
+ def self.generate(
82
+ feature_assignments,
83
+ feature_metrics,
84
+ feature_test_coverage,
85
+ feature_test_pyramid,
86
+ project_configuration,
87
+ git_ref
88
+ )
89
+ FileUtils.mkdir_p(output_directory) if !output_directory.exist?
90
+
91
+ features = feature_assignments.keys.each_with_object({}) do |feature_name, hash|
92
+ feature_definition = CodeFeatures.find(feature_name)
93
+ hash[feature_name] = feature_definition&.raw_hash&.slice(*FETAURE_DEFINITION_KEYS_TO_INCLUDE) || {}
94
+ hash[feature_name].merge!(
95
+ assignments: feature_assignments[feature_name],
96
+ metrics: feature_metrics[feature_name],
97
+ test_coverage: feature_test_coverage[feature_name],
98
+ test_pyramid: feature_test_pyramid[feature_name]
99
+ )
100
+ end
101
+
102
+ environment = {
103
+ git_ref: git_ref
104
+ }
105
+ feature_map_config = {
106
+ features: features,
107
+ environment: environment,
108
+ project: project_configuration
109
+ }.to_json
110
+ output_directory.join('feature-map-config.js').write("window.FEATURE_MAP_CONFIG = #{feature_map_config};")
111
+
112
+ Dir.each_child(assets_directory) do |file_name|
113
+ FileUtils.cp(File.join(assets_directory, file_name), output_directory.join(file_name))
114
+ end
115
+ end
116
+
117
+ sig { returns(Pathname) }
118
+ def self.output_directory
119
+ Pathname.pwd.join('.feature_map/docs')
120
+ end
121
+
122
+ sig { returns(String) }
123
+ def self.assets_directory
124
+ File.join(File.dirname(__FILE__), ASSETS_DIRECTORY)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,24 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ # This class handles loading extensions to feature_map using the `require` directive
7
+ # in the `.feature_map/config.yml` configuration.
8
+ module ExtensionLoader
9
+ class << self
10
+ extend T::Sig
11
+ sig { params(require_directive: String).void }
12
+ def load(require_directive)
13
+ # We want to transform the require directive to behave differently
14
+ # if it's a specific local file being required versus a gem
15
+ if require_directive.start_with?('.')
16
+ require File.join(Pathname.pwd, require_directive)
17
+ else
18
+ require require_directive
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class FeatureAssigner
7
+ extend T::Sig
8
+
9
+ sig { params(globs_to_assigned_feature_map: GlobsToAssignedFeatureMap).returns(GlobsToAssignedFeatureMap) }
10
+ def self.assign_features(globs_to_assigned_feature_map)
11
+ globs_to_assigned_feature_map.each_with_object({}) do |(glob, feature), mapping|
12
+ # addresses the case where a directory name includes regex characters
13
+ # such as `app/services/[test]/some_other_file.ts`
14
+ mapping[glob] = feature if File.exist?(glob)
15
+ Dir.glob(glob).each do |file|
16
+ mapping[file] ||= feature
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,76 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'rubocop'
5
+ module FeatureMap
6
+ module Private
7
+ class FeatureMetricsCalculator
8
+ extend T::Sig
9
+
10
+ ABC_SIZE_METRIC = 'abc_size'
11
+ CYCLOMATIC_COMPLEXITY_METRIC = 'cyclomatic_complexity'
12
+ LINES_OF_CODE_METRIC = 'lines_of_code'
13
+ TODO_LOCATIONS_METRIC = 'todo_locations'
14
+
15
+ SUPPORTED_METRICS = T.let([
16
+ ABC_SIZE_METRIC,
17
+ CYCLOMATIC_COMPLEXITY_METRIC,
18
+ LINES_OF_CODE_METRIC,
19
+ TODO_LOCATIONS_METRIC
20
+ ].freeze, T::Array[String])
21
+
22
+ FeatureMetrics = T.type_alias do
23
+ T::Hash[
24
+ String, # metric name
25
+ T.any(Integer, Float, T::Hash[String, String]) # score or todo locations with messages
26
+ ]
27
+ end
28
+
29
+ sig { params(file_paths: T::Array[String]).returns(FeatureMetrics) }
30
+ def self.calculate_for_feature(file_paths)
31
+ metrics = file_paths.map { |file| calculate_for_file(file) }
32
+
33
+ # Handle numeric metrics
34
+ aggregate_metrics = SUPPORTED_METRICS.each_with_object({}) do |metric_key, agg|
35
+ next if metric_key == TODO_LOCATIONS_METRIC
36
+
37
+ agg[metric_key] = metrics.sum { |m| m[metric_key] || 0 }
38
+ end
39
+
40
+ # Merge all todo locations
41
+ todo_locations = metrics.map { |m| m[TODO_LOCATIONS_METRIC] }.compact.reduce({}, :merge)
42
+ aggregate_metrics[TODO_LOCATIONS_METRIC] = todo_locations
43
+
44
+ aggregate_metrics
45
+ end
46
+
47
+ sig { params(file_path: String).returns(FeatureMetrics) }
48
+ def self.calculate_for_file(file_path)
49
+ metrics = {
50
+ LINES_OF_CODE_METRIC => LinesOfCodeCalculator.new(file_path).calculate
51
+ }
52
+
53
+ return metrics unless file_path.end_with?('.rb')
54
+
55
+ file_content = File.read(file_path)
56
+ source = RuboCop::ProcessedSource.new(file_content, RUBY_VERSION.to_f)
57
+ return metrics unless source.ast
58
+
59
+ # NOTE: We're using some internal RuboCop classes to calculate complexity metrics
60
+ # for each file. Doing this tightly couples our functionality with RuboCop,
61
+ # which does introduce some risk, should RuboCop decide to change the interface
62
+ # of these classes. That being said, this is a tradeoff we're willing to
63
+ # make right now.
64
+ abc_calculator = RuboCop::Cop::Metrics::Utils::AbcSizeCalculator.new(source.ast)
65
+ cyclomatic_calculator = CyclomaticComplexityCalculator.new(source.ast)
66
+ todo_locations = TodoInspector.new(file_path).calculate
67
+
68
+ metrics.merge(
69
+ ABC_SIZE_METRIC => abc_calculator.calculate.first.round(2),
70
+ CYCLOMATIC_COMPLEXITY_METRIC => cyclomatic_calculator.calculate,
71
+ TODO_LOCATIONS_METRIC => todo_locations
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,17 @@
1
+ # typed: true
2
+
3
+ module FeatureMap
4
+ module Private
5
+ module FeaturePlugins
6
+ class Assignment < FeatureMap::CodeFeatures::Plugin
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ sig { returns(T::Array[String]) }
11
+ def assigned_globs
12
+ @feature.raw_hash['assigned_globs'] || []
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,80 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class GlobCache
7
+ extend T::Sig
8
+
9
+ MapperDescription = T.type_alias { String }
10
+
11
+ CacheShape = T.type_alias do
12
+ T::Hash[
13
+ MapperDescription,
14
+ GlobsToAssignedFeatureMap
15
+ ]
16
+ end
17
+
18
+ FilesByMapper = T.type_alias do
19
+ T::Hash[
20
+ String,
21
+ T::Set[MapperDescription]
22
+ ]
23
+ end
24
+
25
+ sig { params(raw_cache_contents: CacheShape).void }
26
+ def initialize(raw_cache_contents)
27
+ @raw_cache_contents = raw_cache_contents
28
+ end
29
+
30
+ sig { returns(CacheShape) }
31
+ def raw_cache_contents
32
+ @raw_cache_contents
33
+ end
34
+
35
+ sig { params(files: T::Array[String]).returns(FilesByMapper) }
36
+ def mapper_descriptions_that_map_files(files)
37
+ files_by_mappers = files.to_h { |f| [f, Set.new([])] }
38
+
39
+ files_by_mappers_via_expanded_cache.each do |file, mappers|
40
+ mappers.each do |mapper|
41
+ T.must(files_by_mappers[file]) << mapper if files_by_mappers[file]
42
+ end
43
+ end
44
+
45
+ files_by_mappers
46
+ end
47
+
48
+ private
49
+
50
+ sig { returns(CacheShape) }
51
+ def expanded_cache
52
+ @expanded_cache = T.let(@expanded_cache, T.nilable(CacheShape))
53
+
54
+ @expanded_cache ||= begin
55
+ expanded_cache = {}
56
+ @raw_cache_contents.each do |mapper_description, globs_by_feature|
57
+ expanded_cache[mapper_description] = FeatureAssigner.assign_features(globs_by_feature)
58
+ end
59
+ expanded_cache
60
+ end
61
+ end
62
+
63
+ sig { returns(FilesByMapper) }
64
+ def files_by_mappers_via_expanded_cache
65
+ @files_by_mappers_via_expanded_cache ||= T.let(@files_by_mappers_via_expanded_cache, T.nilable(FilesByMapper))
66
+ @files_by_mappers_via_expanded_cache ||= begin
67
+ files_by_mappers = T.let({}, FilesByMapper)
68
+ expanded_cache.each do |mapper_description, file_by_feature|
69
+ file_by_feature.each_key do |file|
70
+ files_by_mappers[file] ||= Set.new([])
71
+ files_by_mappers.fetch(file) << mapper_description
72
+ end
73
+ end
74
+
75
+ files_by_mappers
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,49 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'parser/current'
5
+
6
+ module FeatureMap
7
+ module Private
8
+ class LinesOfCodeCalculator
9
+ extend T::Sig
10
+
11
+ # NOTE: regex 'x' arg ignores whitespace within the _construction_ of the regex.
12
+ # regex 'm' arg allows the regex to _execute_ on multiline strings.
13
+ SINGLE_LINE_COMMENT_PATTERN = T.let(
14
+ /
15
+ \s* # Any amount of whitespace
16
+ (#{Constants::SINGLE_LINE_COMMENT_PATTERNS.join('|')}) # Any comment start
17
+ .* # And the rest of the line
18
+ /x.freeze,
19
+ Regexp
20
+ )
21
+ MULTI_LINE_COMMENT_PATTERN = T.let(
22
+ /
23
+ (#{Constants::MULTILINE_COMMENT_START_PATTERNS.join('|')}) # Multiline comment start
24
+ .*? # Everything in between, but lazily so we stop when we hit...
25
+ (#{Constants::MULTILINE_COMMENT_END_PATTERNS.join('|')}) # ...Multiline comment end
26
+ /xm.freeze,
27
+ Regexp
28
+ )
29
+
30
+ sig { params(file_path: String).void }
31
+ def initialize(file_path)
32
+ @file_path = file_path
33
+ end
34
+
35
+ sig { returns(Integer) }
36
+ def calculate
37
+ # Ignore lines that are entirely whitespace or that are entirely a comment.
38
+ File
39
+ .readlines(@file_path)
40
+ .join("\n")
41
+ .gsub(SINGLE_LINE_COMMENT_PATTERN, '')
42
+ .gsub(MULTI_LINE_COMMENT_PATTERN, '')
43
+ .split("\n")
44
+ .reject { |l| l.strip == '' }
45
+ .size
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,86 @@
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 metrics.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 MetricsFile
12
+ extend T::Sig
13
+
14
+ class FileContentError < StandardError; end
15
+
16
+ FEATURES_KEY = 'features'
17
+
18
+ FeatureName = T.type_alias { String }
19
+
20
+ FeatureMetrics = T.type_alias do
21
+ T::Hash[
22
+ String,
23
+ T.any(Integer, Float, T::Hash[String, String])
24
+ ]
25
+ end
26
+
27
+ FeaturesContent = T.type_alias do
28
+ T::Hash[
29
+ FeatureName,
30
+ FeatureMetrics
31
+ ]
32
+ end
33
+
34
+ sig { void }
35
+ def self.write!
36
+ FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
37
+
38
+ path.write([header_comment, "\n", generate_content.to_yaml].join)
39
+ end
40
+
41
+ sig { returns(Pathname) }
42
+ def self.path
43
+ Pathname.pwd.join('.feature_map/metrics.yml')
44
+ end
45
+
46
+ sig { returns(String) }
47
+ def self.header_comment
48
+ <<~HEADER
49
+ # STOP! - DO NOT EDIT THIS FILE MANUALLY
50
+ # This file was automatically generated by "bin/featuremap validate". The next time this file
51
+ # is generated any changes will be lost. For more details:
52
+ # https://github.com/Beyond-Finance/feature_map
53
+ #
54
+ # It is NOT recommended to commit this file into your source control. It will change as a
55
+ # result of nearly all other source code changes. This file should be ignored by your source
56
+ # control but can be used for other feature analysis operations (e.g. documentation
57
+ # generation, etc).
58
+ HEADER
59
+ end
60
+
61
+ sig { returns(T::Hash[String, FeaturesContent]) }
62
+ def self.generate_content
63
+ feature_metrics = T.let({}, FeaturesContent)
64
+
65
+ Private.feature_file_assignments.each do |feature_name, files|
66
+ feature_metrics[feature_name] = FeatureMetricsCalculator.calculate_for_feature(files)
67
+ end
68
+
69
+ { FEATURES_KEY => feature_metrics }
70
+ end
71
+
72
+ sig { returns(FeaturesContent) }
73
+ def self.load_features!
74
+ metrics_content = YAML.load_file(path)
75
+
76
+ return metrics_content[FEATURES_KEY] if metrics_content.is_a?(Hash) && metrics_content[FEATURES_KEY]
77
+
78
+ raise FileContentError, "Unexpected content found in #{path}. Use `bin/featuremap validate` to regenerate it and try again."
79
+ rescue Psych::SyntaxError => e
80
+ raise FileContentError, "Invalid YAML content found at #{path}. Error: #{e.message} Use `bin/featuremap validate` to generate it and try again."
81
+ rescue Errno::ENOENT
82
+ raise FileContentError, "No feature metrics file found at #{path}. Use `bin/featuremap validate` to generate it and try again."
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,97 @@
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 file-level test coverage statistics into a test-coverage.yml
8
+ # file that captures test coverage statistics on a per-feature basis. This file can then be used as an input to
9
+ # a variety of engineering team utilities (e.g. PR/release announcements, documentation generation, etc).
10
+ #
11
+ class TestCoverageFile
12
+ extend T::Sig
13
+
14
+ class FileContentError < StandardError; end
15
+
16
+ FEATURES_KEY = 'features'
17
+
18
+ FeatureName = T.type_alias { String }
19
+ CoverageStat = T.type_alias { String }
20
+
21
+ FeatureCoverage = T.type_alias do
22
+ T::Hash[
23
+ CoverageStat,
24
+ Integer
25
+ ]
26
+ end
27
+
28
+ FeaturesContent = T.type_alias do
29
+ T::Hash[
30
+ FeatureName,
31
+ FeatureCoverage
32
+ ]
33
+ end
34
+
35
+ sig { params(coverage_stats: CodeCov::TestCoverageStats).void }
36
+ def self.write!(coverage_stats)
37
+ FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
38
+
39
+ path.write([header_comment, "\n", generate_content(coverage_stats).to_yaml].join)
40
+ end
41
+
42
+ sig { returns(Pathname) }
43
+ def self.path
44
+ Pathname.pwd.join('.feature_map/test-coverage.yml')
45
+ end
46
+
47
+ sig { returns(String) }
48
+ def self.header_comment
49
+ <<~HEADER
50
+ # STOP! - DO NOT EDIT THIS FILE MANUALLY
51
+ # This file was automatically generated by "bin/featuremap test_coverage". The next time this file
52
+ # is generated any changes will be lost. For more details:
53
+ # https://github.com/Beyond-Finance/feature_map
54
+ #
55
+ # It is NOT recommended to commit this file into your source control. It will change or become
56
+ # outdated frequently. Instead it should be regenerated when test coverage statistics are required.
57
+ # This file should be ignored by your source control, allowing the local copy to be used for other
58
+ # feature analysis operations (e.g. documentation generation, etc).
59
+ HEADER
60
+ end
61
+
62
+ sig { params(coverage_stats: CodeCov::TestCoverageStats).returns(T::Hash[String, FeaturesContent]) }
63
+ def self.generate_content(coverage_stats)
64
+ feature_test_coverage = T.let({}, FeaturesContent)
65
+
66
+ Private.feature_file_assignments.each do |feature_name, files|
67
+ feature_test_coverage[feature_name] = T.let({ 'lines' => 0, 'hits' => 0, 'misses' => 0 }, FeatureCoverage)
68
+
69
+ files.each_with_object(T.must(feature_test_coverage[feature_name])) do |file_path, coverage|
70
+ next unless coverage_stats[file_path]
71
+
72
+ coverage['lines'] = T.must(coverage['lines']) + (T.must(coverage_stats[file_path])['lines'] || 0)
73
+ coverage['hits'] = T.must(coverage['hits']) + (T.must(coverage_stats[file_path])['hits'] || 0)
74
+ coverage['misses'] = T.must(coverage['misses']) + (T.must(coverage_stats[file_path])['misses'] || 0)
75
+
76
+ coverage
77
+ end
78
+ end
79
+
80
+ { FEATURES_KEY => feature_test_coverage }
81
+ end
82
+
83
+ sig { returns(FeaturesContent) }
84
+ def self.load_features!
85
+ test_coverage_content = YAML.load_file(path)
86
+
87
+ return test_coverage_content[FEATURES_KEY] if test_coverage_content.is_a?(Hash) && test_coverage_content[FEATURES_KEY]
88
+
89
+ raise FileContentError, "Unexpected content found in #{path}. Use `bin/featuremap test_coverage` to regenerate it and try again."
90
+ rescue Psych::SyntaxError => e
91
+ raise FileContentError, "Invalid YAML content found at #{path}. Error: #{e.message} Use `bin/featuremap test_coverage` to generate it and try again."
92
+ rescue Errno::ENOENT
93
+ raise FileContentError, "No feature test coverage file found at #{path}. Use `bin/featuremap test_coverage` to generate it and try again."
94
+ end
95
+ end
96
+ end
97
+ end