feature_map 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +269 -0
- data/bin/featuremap +5 -0
- data/lib/feature_map/cli.rb +243 -0
- data/lib/feature_map/code_features/plugin.rb +79 -0
- data/lib/feature_map/code_features/plugins/identity.rb +39 -0
- data/lib/feature_map/code_features.rb +152 -0
- data/lib/feature_map/configuration.rb +43 -0
- data/lib/feature_map/constants.rb +11 -0
- data/lib/feature_map/mapper.rb +78 -0
- data/lib/feature_map/output_color.rb +42 -0
- data/lib/feature_map/private/assignment_mappers/directory_assignment.rb +150 -0
- data/lib/feature_map/private/assignment_mappers/feature_definition_assignment.rb +68 -0
- data/lib/feature_map/private/assignment_mappers/feature_globs.rb +138 -0
- data/lib/feature_map/private/assignment_mappers/file_annotations.rb +158 -0
- data/lib/feature_map/private/assignments_file.rb +190 -0
- data/lib/feature_map/private/code_cov.rb +96 -0
- data/lib/feature_map/private/cyclomatic_complexity_calculator.rb +46 -0
- data/lib/feature_map/private/docs/index.html +247 -0
- data/lib/feature_map/private/documentation_site.rb +128 -0
- data/lib/feature_map/private/extension_loader.rb +24 -0
- data/lib/feature_map/private/feature_assigner.rb +22 -0
- data/lib/feature_map/private/feature_metrics_calculator.rb +76 -0
- data/lib/feature_map/private/feature_plugins/assignment.rb +17 -0
- data/lib/feature_map/private/glob_cache.rb +80 -0
- data/lib/feature_map/private/lines_of_code_calculator.rb +49 -0
- data/lib/feature_map/private/metrics_file.rb +86 -0
- data/lib/feature_map/private/test_coverage_file.rb +97 -0
- data/lib/feature_map/private/test_pyramid_file.rb +151 -0
- data/lib/feature_map/private/todo_inspector.rb +57 -0
- data/lib/feature_map/private/validations/features_up_to_date.rb +78 -0
- data/lib/feature_map/private/validations/files_have_features.rb +45 -0
- data/lib/feature_map/private/validations/files_have_unique_features.rb +34 -0
- data/lib/feature_map/private.rb +204 -0
- data/lib/feature_map/validator.rb +29 -0
- data/lib/feature_map.rb +212 -0
- 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
|