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