feature_map 1.2.2 → 1.2.3
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 +4 -4
- data/README.md +0 -1
- data/bin/featuremap +0 -1
- data/lib/feature_map/cli.rb +0 -2
- data/lib/feature_map/code_features/plugin.rb +2 -21
- data/lib/feature_map/code_features/plugins/identity.rb +1 -8
- data/lib/feature_map/code_features.rb +1 -31
- data/lib/feature_map/commit.rb +0 -19
- data/lib/feature_map/configuration.rb +40 -17
- data/lib/feature_map/constants.rb +3 -5
- data/lib/feature_map/mapper.rb +0 -26
- data/lib/feature_map/output_color.rb +0 -11
- data/lib/feature_map/private/additional_metrics_file.rb +9 -103
- data/lib/feature_map/private/assignment_applicator.rb +0 -12
- data/lib/feature_map/private/assignment_mappers/directory_assignment.rb +4 -26
- data/lib/feature_map/private/assignment_mappers/feature_definition_assignment.rb +1 -21
- data/lib/feature_map/private/assignment_mappers/feature_globs.rb +7 -40
- data/lib/feature_map/private/assignment_mappers/file_annotations.rb +20 -44
- data/lib/feature_map/private/assignments_file.rb +8 -54
- data/lib/feature_map/private/code_cov.rb +2 -29
- data/lib/feature_map/private/cyclomatic_complexity_calculator.rb +1 -7
- data/lib/feature_map/private/docs/index.html +2 -2
- data/lib/feature_map/private/documentation_site.rb +0 -16
- data/lib/feature_map/private/extension_loader.rb +0 -3
- data/lib/feature_map/private/feature_assigner.rb +0 -4
- data/lib/feature_map/private/feature_metrics_calculator.rb +2 -16
- data/lib/feature_map/private/feature_plugins/assignment.rb +0 -6
- data/lib/feature_map/private/glob_cache.rb +2 -29
- data/lib/feature_map/private/health_calculator.rb +122 -0
- data/lib/feature_map/private/lines_of_code_calculator.rb +10 -21
- data/lib/feature_map/private/metrics_file.rb +1 -25
- data/lib/feature_map/private/percentile_metrics_calculator.rb +117 -0
- data/lib/feature_map/private/release_notification_builder.rb +1 -13
- data/lib/feature_map/private/test_coverage_file.rb +12 -39
- data/lib/feature_map/private/test_pyramid_file.rb +0 -41
- data/lib/feature_map/private/todo_inspector.rb +16 -30
- data/lib/feature_map/private/validations/features_up_to_date.rb +1 -6
- data/lib/feature_map/private/validations/files_have_features.rb +2 -7
- data/lib/feature_map/private/validations/files_have_unique_features.rb +1 -6
- data/lib/feature_map/private.rb +7 -44
- data/lib/feature_map/validator.rb +0 -13
- data/lib/feature_map.rb +8 -49
- metadata +4 -44
@@ -1,4 +1,3 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module FeatureMap
|
@@ -63,22 +62,9 @@ module FeatureMap
|
|
63
62
|
# The `window.FEATURES` global variable is used within the site logic to render an appropriate set of
|
64
63
|
# documentation artifacts and charts.
|
65
64
|
class DocumentationSite
|
66
|
-
extend T::Sig
|
67
|
-
|
68
65
|
ASSETS_DIRECTORY = 'docs'
|
69
66
|
FETAURE_DEFINITION_KEYS_TO_INCLUDE = %w[description dashboard_link documentation_link].freeze
|
70
67
|
|
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
|
-
feature_additional_metrics: AdditionalMetricsFile::FeaturesContent,
|
78
|
-
project_configuration: T::Hash[T.untyped, T.untyped],
|
79
|
-
git_ref: String
|
80
|
-
).void
|
81
|
-
end
|
82
68
|
def self.generate(
|
83
69
|
feature_assignments,
|
84
70
|
feature_metrics,
|
@@ -117,12 +103,10 @@ module FeatureMap
|
|
117
103
|
end
|
118
104
|
end
|
119
105
|
|
120
|
-
sig { returns(Pathname) }
|
121
106
|
def self.output_directory
|
122
107
|
Pathname.pwd.join('.feature_map/docs')
|
123
108
|
end
|
124
109
|
|
125
|
-
sig { returns(String) }
|
126
110
|
def self.assets_directory
|
127
111
|
File.join(File.dirname(__FILE__), ASSETS_DIRECTORY)
|
128
112
|
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module FeatureMap
|
@@ -7,8 +6,6 @@ module FeatureMap
|
|
7
6
|
# in the `.feature_map/config.yml` configuration.
|
8
7
|
module ExtensionLoader
|
9
8
|
class << self
|
10
|
-
extend T::Sig
|
11
|
-
sig { params(require_directive: String).void }
|
12
9
|
def load(require_directive)
|
13
10
|
# We want to transform the require directive to behave differently
|
14
11
|
# if it's a specific local file being required versus a gem
|
@@ -1,12 +1,8 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module FeatureMap
|
5
4
|
module Private
|
6
5
|
class FeatureAssigner
|
7
|
-
extend T::Sig
|
8
|
-
|
9
|
-
sig { params(globs_to_assigned_feature_map: GlobsToAssignedFeatureMap).returns(GlobsToAssignedFeatureMap) }
|
10
6
|
def self.assign_features(globs_to_assigned_feature_map)
|
11
7
|
globs_to_assigned_feature_map.each_with_object({}) do |(glob, feature), mapping|
|
12
8
|
# addresses the case where a directory name includes regex characters
|
@@ -1,12 +1,9 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
require 'rubocop'
|
5
4
|
module FeatureMap
|
6
5
|
module Private
|
7
6
|
class FeatureMetricsCalculator
|
8
|
-
extend T::Sig
|
9
|
-
|
10
7
|
ABC_SIZE_METRIC = 'abc_size'
|
11
8
|
CYCLOMATIC_COMPLEXITY_METRIC = 'cyclomatic_complexity'
|
12
9
|
LINES_OF_CODE_METRIC = 'lines_of_code'
|
@@ -14,23 +11,15 @@ module FeatureMap
|
|
14
11
|
COMPLEXITY_RATIO_METRIC = 'complexity_ratio'
|
15
12
|
ENCAPSULATION_RATIO_METRIC = 'encapsulation_ratio'
|
16
13
|
|
17
|
-
SUPPORTED_METRICS =
|
14
|
+
SUPPORTED_METRICS = [
|
18
15
|
ABC_SIZE_METRIC,
|
19
16
|
CYCLOMATIC_COMPLEXITY_METRIC,
|
20
17
|
LINES_OF_CODE_METRIC,
|
21
18
|
TODO_LOCATIONS_METRIC,
|
22
19
|
COMPLEXITY_RATIO_METRIC,
|
23
20
|
ENCAPSULATION_RATIO_METRIC
|
24
|
-
].freeze
|
25
|
-
|
26
|
-
FeatureMetrics = T.type_alias do
|
27
|
-
T::Hash[
|
28
|
-
String, # metric name
|
29
|
-
T.any(Integer, T.nilable(Float), T::Hash[String, String]) # score or todo locations with messages
|
30
|
-
]
|
31
|
-
end
|
21
|
+
].freeze
|
32
22
|
|
33
|
-
sig { params(file_paths: T::Array[String]).returns(FeatureMetrics) }
|
34
23
|
def self.calculate_for_feature(file_paths)
|
35
24
|
metrics = file_paths.map { |file| calculate_for_file(file) }
|
36
25
|
|
@@ -48,7 +37,6 @@ module FeatureMap
|
|
48
37
|
aggregate_metrics
|
49
38
|
end
|
50
39
|
|
51
|
-
sig { params(file_path: String).returns(FeatureMetrics) }
|
52
40
|
def self.calculate_for_file(file_path)
|
53
41
|
metrics = {
|
54
42
|
LINES_OF_CODE_METRIC => LinesOfCodeCalculator.new(file_path).calculate
|
@@ -76,14 +64,12 @@ module FeatureMap
|
|
76
64
|
)
|
77
65
|
end
|
78
66
|
|
79
|
-
sig { params(aggregate_metrics: T::Hash[String, T.untyped]).returns(T.nilable(Float)) }
|
80
67
|
def self.complexity_ratio(aggregate_metrics)
|
81
68
|
return 0.0 if aggregate_metrics[LINES_OF_CODE_METRIC].nil? || aggregate_metrics[CYCLOMATIC_COMPLEXITY_METRIC].nil? || aggregate_metrics[CYCLOMATIC_COMPLEXITY_METRIC].zero?
|
82
69
|
|
83
70
|
aggregate_metrics[LINES_OF_CODE_METRIC].to_f / aggregate_metrics[CYCLOMATIC_COMPLEXITY_METRIC]
|
84
71
|
end
|
85
72
|
|
86
|
-
sig { params(file_paths: T.nilable(T::Array[String]), aggregate_metrics: T::Hash[String, T.untyped]).returns(T.nilable(Float)) }
|
87
73
|
def self.encapsulation_ratio(file_paths, aggregate_metrics)
|
88
74
|
return 0.0 if file_paths.nil? || aggregate_metrics[LINES_OF_CODE_METRIC].nil? || aggregate_metrics[LINES_OF_CODE_METRIC].zero?
|
89
75
|
|
@@ -1,13 +1,7 @@
|
|
1
|
-
# typed: true
|
2
|
-
|
3
1
|
module FeatureMap
|
4
2
|
module Private
|
5
3
|
module FeaturePlugins
|
6
4
|
class Assignment < FeatureMap::CodeFeatures::Plugin
|
7
|
-
extend T::Sig
|
8
|
-
extend T::Helpers
|
9
|
-
|
10
|
-
sig { returns(T::Array[String]) }
|
11
5
|
def assigned_globs
|
12
6
|
@feature.raw_hash['assigned_globs'] || []
|
13
7
|
end
|
@@ -1,44 +1,22 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module FeatureMap
|
5
4
|
module Private
|
6
5
|
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
6
|
def initialize(raw_cache_contents)
|
27
7
|
@raw_cache_contents = raw_cache_contents
|
28
8
|
end
|
29
9
|
|
30
|
-
sig { returns(CacheShape) }
|
31
10
|
def raw_cache_contents
|
32
11
|
@raw_cache_contents
|
33
12
|
end
|
34
13
|
|
35
|
-
sig { params(files: T::Array[String]).returns(FilesByMapper) }
|
36
14
|
def mapper_descriptions_that_map_files(files)
|
37
15
|
files_by_mappers = files.to_h { |f| [f, Set.new([])] }
|
38
16
|
|
39
17
|
files_by_mappers_via_expanded_cache.each do |file, mappers|
|
40
18
|
mappers.each do |mapper|
|
41
|
-
|
19
|
+
files_by_mappers[file] << mapper if files_by_mappers[file]
|
42
20
|
end
|
43
21
|
end
|
44
22
|
|
@@ -47,10 +25,7 @@ module FeatureMap
|
|
47
25
|
|
48
26
|
private
|
49
27
|
|
50
|
-
sig { returns(CacheShape) }
|
51
28
|
def expanded_cache
|
52
|
-
@expanded_cache = T.let(@expanded_cache, T.nilable(CacheShape))
|
53
|
-
|
54
29
|
@expanded_cache ||= begin
|
55
30
|
expanded_cache = {}
|
56
31
|
@raw_cache_contents.each do |mapper_description, globs_by_feature|
|
@@ -60,11 +35,9 @@ module FeatureMap
|
|
60
35
|
end
|
61
36
|
end
|
62
37
|
|
63
|
-
sig { returns(FilesByMapper) }
|
64
38
|
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
39
|
@files_by_mappers_via_expanded_cache ||= begin
|
67
|
-
files_by_mappers =
|
40
|
+
files_by_mappers = {}
|
68
41
|
expanded_cache.each do |mapper_description, file_by_feature|
|
69
42
|
file_by_feature.each_key do |file|
|
70
43
|
files_by_mappers[file] ||= Set.new([])
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FeatureMap
|
4
|
+
module Private
|
5
|
+
class HealthCalculator
|
6
|
+
attr_reader :percentile_metrics, :cyclomatic_complexity_config, :encapsulation_config, :test_coverage_config, :todo_count_config
|
7
|
+
|
8
|
+
def initialize(percentile_metrics:, health_config:)
|
9
|
+
@percentile_metrics = percentile_metrics
|
10
|
+
@cyclomatic_complexity_config = health_config['components']['cyclomatic_complexity']
|
11
|
+
@encapsulation_config = health_config['components']['encapsulation']
|
12
|
+
@test_coverage_config = health_config['components']['test_coverage']
|
13
|
+
@todo_count_config = health_config['components']['todo_count']
|
14
|
+
end
|
15
|
+
|
16
|
+
def health_score_for(feature_name)
|
17
|
+
test_coverage_component = test_coverage_component_for(feature_name)
|
18
|
+
cyclomatic_complexity_component = cyclomatic_complexity_component_for(feature_name)
|
19
|
+
encapsulation_component = encapsulation_component_for(feature_name)
|
20
|
+
todo_count_component = todo_count_component_for(feature_name)
|
21
|
+
|
22
|
+
overall = [
|
23
|
+
test_coverage_component,
|
24
|
+
cyclomatic_complexity_component,
|
25
|
+
encapsulation_component,
|
26
|
+
todo_count_component
|
27
|
+
].sum { |c| c['health_score'] }
|
28
|
+
|
29
|
+
total_weight = [
|
30
|
+
cyclomatic_complexity_config,
|
31
|
+
encapsulation_config,
|
32
|
+
test_coverage_config,
|
33
|
+
todo_count_config
|
34
|
+
].sum { |c| c['weight'] }
|
35
|
+
|
36
|
+
{
|
37
|
+
'test_coverage_component' => test_coverage_component,
|
38
|
+
'cyclomatic_complexity_component' => cyclomatic_complexity_component,
|
39
|
+
'encapsulation_component' => encapsulation_component,
|
40
|
+
'todo_count_component' => todo_count_component,
|
41
|
+
'overall' => overall / total_weight * 100
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def cyclomatic_complexity_component_for(feature_name)
|
48
|
+
cyclomatic_complexity = percentile_metrics.cyclomatic_complexity_for(feature_name)
|
49
|
+
|
50
|
+
health_score_component(
|
51
|
+
cyclomatic_complexity_config['weight'],
|
52
|
+
cyclomatic_complexity['percentile'],
|
53
|
+
cyclomatic_complexity['percent_of_max'],
|
54
|
+
cyclomatic_complexity_config['percent_of_max_threshold']
|
55
|
+
)
|
56
|
+
end
|
57
|
+
|
58
|
+
def encapsulation_component_for(feature_name)
|
59
|
+
encapsulation = percentile_metrics.encapsulation_for(feature_name)
|
60
|
+
|
61
|
+
health_score_component(
|
62
|
+
encapsulation_config['weight'],
|
63
|
+
encapsulation['percentile'],
|
64
|
+
encapsulation['percent_of_max'],
|
65
|
+
encapsulation_config['percent_of_max_threshold']
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
def health_score_component(awardable_points, score, percent_of_max, percent_of_max_threshold)
|
70
|
+
close_to_maximum_score = percent_of_max_threshold && percent_of_max >= percent_of_max_threshold
|
71
|
+
component = { 'awardable_points' => awardable_points, 'close_to_maximum_score' => close_to_maximum_score }
|
72
|
+
|
73
|
+
# NOTE: Certain metrics scores are derived from their relative percentile.
|
74
|
+
# As a codebase converges, say on encapsulation, relative percentile
|
75
|
+
# scoring dictates that the lower percentiles always receive a
|
76
|
+
# worse score than higher percentiles even when their values are close.
|
77
|
+
#
|
78
|
+
# E.g., if encapsulation scores are of the set [9.6, 9.7, 9.8, 9.9],
|
79
|
+
# is it fair to award the feature at `9.6` dramatically less than `9.9?`
|
80
|
+
#
|
81
|
+
# Each host application can set a `percent_of_max_threshold` for these metrics such that
|
82
|
+
# if a given feature's score is within this threshold of the highest performing feature,
|
83
|
+
# it is awarded full points.
|
84
|
+
return component.merge('health_score' => awardable_points) if close_to_maximum_score
|
85
|
+
|
86
|
+
component.merge(
|
87
|
+
'health_score' => [(score.to_f / 100) * awardable_points, awardable_points].min
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_coverage_component_for(feature_name)
|
92
|
+
test_coverage = percentile_metrics.test_coverage_for(feature_name)
|
93
|
+
|
94
|
+
# NOTE: Test coverage is based on the absolute coverage percentage
|
95
|
+
# of code within a given feature. E.g., a score of 60 does not indicate
|
96
|
+
# that test coverage is within the sixtieth percentile -- but rather
|
97
|
+
# that 60% of its lines are covered by tests.
|
98
|
+
#
|
99
|
+
# FeatureMap does not mean to imply that all codebases should seek to
|
100
|
+
# cover 100% of lines, so `percent_of_max_threshold` in this case is the variance
|
101
|
+
# from 100% test coverage which should receive full marks.
|
102
|
+
health_score_component(
|
103
|
+
test_coverage_config['weight'],
|
104
|
+
test_coverage['score'],
|
105
|
+
test_coverage['score'],
|
106
|
+
test_coverage_config['percent_of_max_threshold']
|
107
|
+
)
|
108
|
+
end
|
109
|
+
|
110
|
+
def todo_count_component_for(feature_name)
|
111
|
+
todo_count = percentile_metrics.todo_count_for(feature_name)
|
112
|
+
|
113
|
+
health_score_component(
|
114
|
+
todo_count_config['weight'],
|
115
|
+
100 - todo_count['percent_of_max'],
|
116
|
+
100 - todo_count['percent_of_max'],
|
117
|
+
todo_count_config['percent_of_max_threshold']
|
118
|
+
)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
require 'parser/current'
|
@@ -6,33 +5,23 @@ require 'parser/current'
|
|
6
5
|
module FeatureMap
|
7
6
|
module Private
|
8
7
|
class LinesOfCodeCalculator
|
9
|
-
extend T::Sig
|
10
|
-
|
11
8
|
# NOTE: regex 'x' arg ignores whitespace within the _construction_ of the regex.
|
12
9
|
# regex 'm' arg allows the regex to _execute_ on multiline strings.
|
13
|
-
SINGLE_LINE_COMMENT_PATTERN =
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
)
|
10
|
+
SINGLE_LINE_COMMENT_PATTERN = /
|
11
|
+
\s* # Any amount of whitespace
|
12
|
+
(#{Constants::SINGLE_LINE_COMMENT_PATTERNS.join('|')}) # Any comment start
|
13
|
+
.* # And the rest of the line
|
14
|
+
/x.freeze
|
15
|
+
MULTI_LINE_COMMENT_PATTERN = /
|
16
|
+
(#{Constants::MULTILINE_COMMENT_START_PATTERNS.join('|')}) # Multiline comment start
|
17
|
+
.*? # Everything in between, but lazily so we stop when we hit...
|
18
|
+
(#{Constants::MULTILINE_COMMENT_END_PATTERNS.join('|')}) # ...Multiline comment end
|
19
|
+
/xm.freeze
|
29
20
|
|
30
|
-
sig { params(file_path: String).void }
|
31
21
|
def initialize(file_path)
|
32
22
|
@file_path = file_path
|
33
23
|
end
|
34
24
|
|
35
|
-
sig { returns(Integer) }
|
36
25
|
def calculate
|
37
26
|
# Ignore lines that are entirely whitespace or that are entirely a comment.
|
38
27
|
File
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module FeatureMap
|
@@ -9,41 +8,20 @@ module FeatureMap
|
|
9
8
|
# PR/release announcements, documentation generation, etc).
|
10
9
|
#
|
11
10
|
class MetricsFile
|
12
|
-
extend T::Sig
|
13
|
-
|
14
11
|
class FileContentError < StandardError; end
|
15
12
|
|
16
13
|
FEATURES_KEY = 'features'
|
17
14
|
|
18
|
-
FeatureName = T.type_alias { String }
|
19
|
-
|
20
|
-
FeatureMetrics = T.type_alias do
|
21
|
-
T::Hash[
|
22
|
-
String,
|
23
|
-
T.any(Integer, T.nilable(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
15
|
def self.write!
|
36
16
|
FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
|
37
17
|
|
38
18
|
path.write([header_comment, "\n", generate_content.to_yaml].join)
|
39
19
|
end
|
40
20
|
|
41
|
-
sig { returns(Pathname) }
|
42
21
|
def self.path
|
43
22
|
Pathname.pwd.join('.feature_map/metrics.yml')
|
44
23
|
end
|
45
24
|
|
46
|
-
sig { returns(String) }
|
47
25
|
def self.header_comment
|
48
26
|
<<~HEADER
|
49
27
|
# STOP! - DO NOT EDIT THIS FILE MANUALLY
|
@@ -58,9 +36,8 @@ module FeatureMap
|
|
58
36
|
HEADER
|
59
37
|
end
|
60
38
|
|
61
|
-
sig { returns(T::Hash[String, FeaturesContent]) }
|
62
39
|
def self.generate_content
|
63
|
-
feature_metrics =
|
40
|
+
feature_metrics = {}
|
64
41
|
|
65
42
|
Private.feature_file_assignments.each do |feature_name, files|
|
66
43
|
feature_metrics[feature_name] = FeatureMetricsCalculator.calculate_for_feature(files)
|
@@ -69,7 +46,6 @@ module FeatureMap
|
|
69
46
|
{ FEATURES_KEY => feature_metrics }
|
70
47
|
end
|
71
48
|
|
72
|
-
sig { returns(FeaturesContent) }
|
73
49
|
def self.load_features!
|
74
50
|
metrics_content = YAML.load_file(path)
|
75
51
|
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FeatureMap
|
4
|
+
module Private
|
5
|
+
class PercentileMetricsCalculator
|
6
|
+
attr_reader :metrics, :test_coverage
|
7
|
+
|
8
|
+
def initialize(metrics:, test_coverage:)
|
9
|
+
@metrics = metrics
|
10
|
+
@test_coverage = test_coverage
|
11
|
+
end
|
12
|
+
|
13
|
+
def cyclomatic_complexity_for(feature_name)
|
14
|
+
calculate(
|
15
|
+
cyclomatic_complexity_ratios,
|
16
|
+
metrics.dig(feature_name, FeatureMetricsCalculator::COMPLEXITY_RATIO_METRIC)
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
def encapsulation_for(feature_name)
|
21
|
+
calculate(
|
22
|
+
encapsulation_ratios,
|
23
|
+
metrics.dig(feature_name, FeatureMetricsCalculator::ENCAPSULATION_RATIO_METRIC)
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
def feature_size_for(feature_name)
|
28
|
+
calculate(
|
29
|
+
feature_sizes,
|
30
|
+
metrics.dig(feature_name, FeatureMetricsCalculator::LINES_OF_CODE_METRIC)
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_coverage_for(feature_name)
|
35
|
+
calculate(
|
36
|
+
test_coverage_ratios,
|
37
|
+
test_coverage.dig(feature_name, TestCoverageFile::COVERAGE_RATIO)
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def todo_count_for(feature_name)
|
42
|
+
calculate(
|
43
|
+
todo_counts,
|
44
|
+
metrics.dig(feature_name, FeatureMetricsCalculator::TODO_LOCATIONS_METRIC)&.size
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def calculate(collection, score)
|
51
|
+
if score.nil?
|
52
|
+
return { 'percentile' => 0.0, 'percent_of_max' => 0, 'score' => 0 }
|
53
|
+
end
|
54
|
+
|
55
|
+
max = collection.max || 0
|
56
|
+
percentile = percentile_of(collection, score)
|
57
|
+
percent_of_max = max.zero? ? 0 : ((score.to_f / max) * 100).round.to_i
|
58
|
+
|
59
|
+
{ 'percentile' => percentile, 'percent_of_max' => percent_of_max, 'score' => score }
|
60
|
+
end
|
61
|
+
|
62
|
+
def cyclomatic_complexity_ratios
|
63
|
+
return @cyclomatic_complexity_ratios if defined?(@cyclomatic_complexity_ratios)
|
64
|
+
|
65
|
+
@cyclomatic_complexity_ratios = metrics.values.map { |m| m[FeatureMetricsCalculator::COMPLEXITY_RATIO_METRIC] }.compact
|
66
|
+
end
|
67
|
+
|
68
|
+
def encapsulation_ratios
|
69
|
+
return @encapsulation_ratios if defined?(@encapsulation_ratios)
|
70
|
+
|
71
|
+
@encapsulation_ratios = metrics.values.map { |m| m[FeatureMetricsCalculator::ENCAPSULATION_RATIO_METRIC] }.compact
|
72
|
+
end
|
73
|
+
|
74
|
+
def feature_sizes
|
75
|
+
return @feature_sizes if defined?(@feature_sizes)
|
76
|
+
|
77
|
+
@feature_sizes = metrics.values.map { |m| m[FeatureMetricsCalculator::LINES_OF_CODE_METRIC] }.compact
|
78
|
+
end
|
79
|
+
|
80
|
+
# NOTE: This percentile calculation uses a midpoint convention for handling ties,
|
81
|
+
# where values equal to the target contribute 0.5 to the count.
|
82
|
+
# This approach considers each value as being "half below and half above" itself,
|
83
|
+
# resulting in the maximum value in a dataset having a percentile of (n-0.5)/n * 100
|
84
|
+
# instead of 100%.
|
85
|
+
def percentile_of(arr, val)
|
86
|
+
return 0.0 if arr.empty?
|
87
|
+
|
88
|
+
ensure_array_of_floats = arr.map(&:to_f)
|
89
|
+
ensure_float_value = val.to_f
|
90
|
+
|
91
|
+
below_or_equal_count = ensure_array_of_floats.reduce(0) do |acc, v|
|
92
|
+
if v < ensure_float_value
|
93
|
+
acc + 1
|
94
|
+
elsif v == ensure_float_value
|
95
|
+
acc + 0.5
|
96
|
+
else
|
97
|
+
acc
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
((100 * below_or_equal_count) / ensure_array_of_floats.length).to_f
|
102
|
+
end
|
103
|
+
|
104
|
+
def test_coverage_ratios
|
105
|
+
return @test_coverage_ratios if defined?(@test_coverage_ratios)
|
106
|
+
|
107
|
+
@test_coverage_ratios = test_coverage.values.map { |c| c[TestCoverageFile::COVERAGE_RATIO] }.compact
|
108
|
+
end
|
109
|
+
|
110
|
+
def todo_counts
|
111
|
+
return @todo_counts if defined?(@todo_counts)
|
112
|
+
|
113
|
+
@todo_counts = metrics.values.map { |c| c[FeatureMetricsCalculator::TODO_LOCATIONS_METRIC]&.size }.compact
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# typed: strict
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module FeatureMap
|
@@ -9,15 +8,7 @@ module FeatureMap
|
|
9
8
|
# (see https://app.slack.com/block-kit-builder).
|
10
9
|
#
|
11
10
|
class ReleaseNotificationBuilder
|
12
|
-
extend T::Sig
|
13
|
-
|
14
|
-
BlockKitSection = T.type_alias { T::Hash[String, T.untyped] }
|
15
|
-
BlockKitPayload = T.type_alias { T::Array[T::Hash[String, T.untyped]] }
|
16
|
-
|
17
11
|
class << self
|
18
|
-
extend T::Sig
|
19
|
-
|
20
|
-
sig { params(commits_by_feature: CommitsByFeature).returns(BlockKitPayload) }
|
21
12
|
def build(commits_by_feature)
|
22
13
|
return [] if commits_by_feature.empty?
|
23
14
|
|
@@ -26,13 +17,12 @@ module FeatureMap
|
|
26
17
|
feature_names.flat_map.with_index do |feature_name, index|
|
27
18
|
# Insert a divider between each feature but not above the first feature nor below the last feature.
|
28
19
|
divider = index.zero? ? [] : [{ type: 'divider' }]
|
29
|
-
divider + [build_feature_section(feature_name,
|
20
|
+
divider + [build_feature_section(feature_name, commits_by_feature[feature_name])]
|
30
21
|
end
|
31
22
|
end
|
32
23
|
|
33
24
|
private
|
34
25
|
|
35
|
-
sig { params(feature_name: String, commits: T::Array[Commit]).returns(BlockKitSection) }
|
36
26
|
def build_feature_section(feature_name, commits)
|
37
27
|
feature_header_markdown = "*_#{feature_name}_*"
|
38
28
|
|
@@ -61,12 +51,10 @@ module FeatureMap
|
|
61
51
|
}
|
62
52
|
end
|
63
53
|
|
64
|
-
sig { returns(T.nilable(String)) }
|
65
54
|
def documentation_site_url
|
66
55
|
Private.configuration.documentation_site_url
|
67
56
|
end
|
68
57
|
|
69
|
-
sig { returns(T.nilable(String)) }
|
70
58
|
def repository_url
|
71
59
|
Private.configuration.repository['url']
|
72
60
|
end
|