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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/bin/featuremap +0 -1
  4. data/lib/feature_map/cli.rb +0 -2
  5. data/lib/feature_map/code_features/plugin.rb +2 -21
  6. data/lib/feature_map/code_features/plugins/identity.rb +1 -8
  7. data/lib/feature_map/code_features.rb +1 -31
  8. data/lib/feature_map/commit.rb +0 -19
  9. data/lib/feature_map/configuration.rb +40 -17
  10. data/lib/feature_map/constants.rb +3 -5
  11. data/lib/feature_map/mapper.rb +0 -26
  12. data/lib/feature_map/output_color.rb +0 -11
  13. data/lib/feature_map/private/additional_metrics_file.rb +9 -103
  14. data/lib/feature_map/private/assignment_applicator.rb +0 -12
  15. data/lib/feature_map/private/assignment_mappers/directory_assignment.rb +4 -26
  16. data/lib/feature_map/private/assignment_mappers/feature_definition_assignment.rb +1 -21
  17. data/lib/feature_map/private/assignment_mappers/feature_globs.rb +7 -40
  18. data/lib/feature_map/private/assignment_mappers/file_annotations.rb +20 -44
  19. data/lib/feature_map/private/assignments_file.rb +8 -54
  20. data/lib/feature_map/private/code_cov.rb +2 -29
  21. data/lib/feature_map/private/cyclomatic_complexity_calculator.rb +1 -7
  22. data/lib/feature_map/private/docs/index.html +2 -2
  23. data/lib/feature_map/private/documentation_site.rb +0 -16
  24. data/lib/feature_map/private/extension_loader.rb +0 -3
  25. data/lib/feature_map/private/feature_assigner.rb +0 -4
  26. data/lib/feature_map/private/feature_metrics_calculator.rb +2 -16
  27. data/lib/feature_map/private/feature_plugins/assignment.rb +0 -6
  28. data/lib/feature_map/private/glob_cache.rb +2 -29
  29. data/lib/feature_map/private/health_calculator.rb +122 -0
  30. data/lib/feature_map/private/lines_of_code_calculator.rb +10 -21
  31. data/lib/feature_map/private/metrics_file.rb +1 -25
  32. data/lib/feature_map/private/percentile_metrics_calculator.rb +117 -0
  33. data/lib/feature_map/private/release_notification_builder.rb +1 -13
  34. data/lib/feature_map/private/test_coverage_file.rb +12 -39
  35. data/lib/feature_map/private/test_pyramid_file.rb +0 -41
  36. data/lib/feature_map/private/todo_inspector.rb +16 -30
  37. data/lib/feature_map/private/validations/features_up_to_date.rb +1 -6
  38. data/lib/feature_map/private/validations/files_have_features.rb +2 -7
  39. data/lib/feature_map/private/validations/files_have_unique_features.rb +1 -6
  40. data/lib/feature_map/private.rb +7 -44
  41. data/lib/feature_map/validator.rb +0 -13
  42. data/lib/feature_map.rb +8 -49
  43. 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 = T.let([
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, T::Array[String])
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
- T.must(files_by_mappers[file]) << mapper if files_by_mappers[file]
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 = T.let({}, FilesByMapper)
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 = 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
- )
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 = T.let({}, FeaturesContent)
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, T.must(commits_by_feature[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