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