feature_map 1.2.0 → 1.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d557f6ea527d73672a53ea6a279e8574a0157d03ffe5da289cc12aa05fec2d3
4
- data.tar.gz: 0b5d04be8cfd69f16d8f5ffc8e1ef0b4c7e5fa8de276feb3c776c7d733cce758
3
+ metadata.gz: 278f81ac986fb56714e9159ff94e5cb98c1bbca774486735c32cd837db13965d
4
+ data.tar.gz: 73b6c34564934df5f1febbfd7e5ec57d97142f4ff624b38294eed829090739c0
5
5
  SHA512:
6
- metadata.gz: 5fa1edc5ba79772619aec3f730147ccffa37a19cc889959192086469757522d72f4c635fa67cb815237c9b0a8e71557c2fb52948e9f9db63b4c1f4338f0ca316
7
- data.tar.gz: 7d75f638f6d06921b146d81a590bebb72e9db3ed1229bf833781aca68b9e92a274fb016190af9884ba8800c6ca982945a370634e73af3b9171b0c873c73cfcdc
6
+ metadata.gz: 5e4cd3dad27e16983f4cd4470c1d7c0f65dffbc630d4155cbf8994869a52469c7455d015066be951d0f6e39929277f17d68bfc254c42f6227d1446f060a27371
7
+ data.tar.gz: 531ed4a45bea00c64ff62733167fd6ea3d9f8f16342ce9f881e75ba935970a299204587bc18e38ec52cc20517595e7e79fc58a5a988a1e8857337a788b718c11
data/README.md CHANGED
@@ -1,250 +1,32 @@
1
1
  # FeatureMap
2
2
 
3
- This gem helps identify and manage features within large Ruby and Rails applications. This gem works best in large, usually monolithic code bases for applications that incorporate a wide range of features with various dependencies.
3
+ This gem helps identify and manage features within large applications.
4
4
 
5
- ## Getting started
5
+ For usage documentation, please see the [README Site](https://beyond-finance.github.io/feature_map).
6
6
 
7
- To get started there's a few things you should do.
7
+ ## Installation
8
8
 
9
- 1) Create a `.feature_map/config.yml` file and declare where your files live. Here's a sample to start with:
10
- ```yml
11
- assigned_globs:
12
- - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
13
- unassigned_globs:
14
- - db/**/*
15
- - app/services/some_file1.rb
16
- - app/services/some_file2.rb
17
- - frontend/javascripts/**/__generated__/**/*
18
- ```
19
- You may find a more comprehensive example in this repository's `.feature_map/config.yml`.
9
+ FeatureMap may be installed directly into a Ruby environment or Ruby on Rails application from [RubyGems](https://rubygems.org/gems/feature_map).
20
10
 
21
- 2) Define the features of our your application. There are two methods for defining features:
22
- * YAML Definitions: Each feature can be defined in a separate YAML file within the `.feature_map/definitions` directory. Here's an example, that would live at `.feature_map/definitions/onboarding.yml`:
23
- ```yml
24
- name: Onboarding
25
- description: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
26
- documentation_link: https://www.notion.so/onboarding-feature-abcd1234
27
- ```
28
- * CSV Definitions: All features can be defined within a single CSV file located at `.feature_map/feature_definitions.csv`. Here's an example of what that file might look like:
29
- ```
30
- # Comment explaining the purpose of this file and how it should be managed.
11
+ \
12
+ **Global Installation**
13
+ > `gem install feature_map`
31
14
 
32
- Name,Description,Documentation Link,Custom Attribute
33
- Onboarding,"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",https://www.notion.so/onboarding-feature-abcd1234,Test 123
34
- User Management,"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation",,
35
- ```
36
- 3) Declare feature assignments. You can do this at a directory level or at a file level. All of the files within the `assigned_globs` you declared in step 1 will need to have a feature assigned (or be opted out via `unassigned_globs`). See the next section for more detail.
37
- 4) Run validations when you commit, and/or in CI. If you run validations in CI, ensure that if your `assignments.yml` file gets changed, that gets pushed to the PR. A `metrics.yml` file will also be generated but we recommend NOT commiting that file because it changes very frequently.
15
+ **Gemfile**
16
+ > `gem 'feature_map', '~> 1.2'``
38
17
 
39
- ## Usage: Assigning Features
18
+ **Inline**\
19
+ FeatureMap may also be executed without direct installation via [inline bundling](https://bundler.io/guides/bundler_in_a_single_file_ruby_script.html). For more information see [Inline Execution]({{ '/getting-started/inline-execution' | relative_url }}).
40
20
 
41
- There are multiple ways to assign the feature for a source file using this gem.
42
-
43
- ### Directory-Based Assignment
44
- Directory based assignment allows for all files in that directory and all its sub-directories to be assigned to a single feature. To define this, add a `.feature` file inside that directory with the name of the feature as the contents of that file.
45
-
46
- ### File-Annotation Based Assignment
47
- File annotations are a last resort if there is no clear home for your code. File annotations go at the top of your file, and look like this:
48
- ```ruby
49
- # @feature Onboarding
50
- ```
51
-
52
- ### Glob-Based Assignment
53
- In the YML configuration of a feature, you can set `assigned_globs` to be a glob of files assigned to this feature. For example, in `onboarding.yml`:
54
- ```yml
55
- name: Onboarding
56
- assigned_globs:
57
- - app/services/stuff_for_onboarding/**/**
58
- - app/controllers/other_stuff_for_onboarding/**/**
59
- ```
60
-
61
- ### Feature Definition File Assignment
62
- By default any feature definition YML files, located in the `.feature_map/definitions' directory, are assigned to their corresponding feature.
63
-
64
- The leading `.` in the path for these files results in them being quoted within the resulting `.feature_map/assignments` file. The following is an example of this content:
65
- ```yml
66
- ---
67
- files:
68
- ".feature_map/definitions/bar.yml":
69
- feature: Bar
70
- mapper: Feature definition file assignment
71
- features:
72
- Bar:
73
- - ".feature_map/definitions/bar.yml"
74
- ```
75
-
76
- In cases when the feature assignments for these files is irrelevant, this behavior can be disabled by setting the `ignore_feature_definitions` key in the `.feature_map/config.yml` file to `true`.
77
-
78
- ### Custom Assignment
79
- To enable custom assignment, you can inject your own custom classes into `feature_map`.
80
- To do this, first create a class that adheres to the `FeatureMap::Mapper` and/or `FeatureMap::Validator` interface.
81
- Then, in `.feature_map/config.yml`, you can require that file:
82
- ```yml
83
- require:
84
- - ./lib/my_extension.rb
85
- ```
86
-
87
- Now, `bin/featuremap validate` will automatically include your new mapper and/or validator. See [`spec/lib/feature_map/private/extension_loader_spec.rb](spec/lib/feature_map/private/extension_loader_spec.rb) for an example of what this looks like.
88
-
89
- ## Usage: Reading FeatureMap
21
+ ## FeatureMap Gem Structure
90
22
 
91
23
  Check out [`lib/feature_map.rb`](https://github.com/Beyond-Finance/feature_map/blob/main/lib/feature_map.rb) to see the public API.
92
24
 
93
25
  Check out [`feature_map_spec.rb`](https://github.com/Beyond-Finance/feature_map/blob/main/spec/lib/feature_map_spec.rb) to see examples of how the feature map utility is used.
94
26
 
95
- ### `for_file`
96
- `FeatureMap.for_file`, given a relative path to a file returns a `CodeFeatures::Feature` if there is a feature assigned to the file, `nil` otherwise.
97
-
98
- ```ruby
99
- FeatureMap.for_file('path/to/file/relative/to/application/root.rb')
100
- ```
101
-
102
- Contributor note: If you are making updates to this method or the methods getting used here, please benchmark the performance of the new implementation against the current for both `for_files` and `for_file` (with 1, 100, 1000 files).
103
-
104
- See `feature_map_spec.rb` for examples.
105
-
106
- ### `for_backtrace`
107
- `FeatureMap.for_backtrace` can be given a backtrace and will either return `nil`, or a `CodeFeatures::Feature`.
108
-
109
- ```ruby
110
- FeatureMap.for_backtrace(exception.backtrace)
111
- ```
112
-
113
- This will go through the backtrace, and return the feature of the first files with a feature assignment associated with frames within the backtrace.
114
-
115
- See `feature_map_spec.rb` for an example.
116
-
117
- ### `for_class`
118
-
119
- `FeatureMap.for_class` can be given a class and will either return `nil`, or a `CodeFeatures::Feature`.
120
-
121
- ```ruby
122
- FeatureMap.for_class(MyClass)
123
- ```
124
-
125
- Under the hood, this finds the file where the class is defined and returns the featuer assigned to that file.
126
-
127
- See `feature_map_spec.rb` for an example.
128
-
129
- ### `for_feature`
130
- `FeatureMap.for_feature` can be used to generate a feature report for a single feature.
131
- ```ruby
132
- FeatureMap.for_feature('Onboarding')
133
- ```
134
-
135
- You can shovel this into a markdown file for easy viewing using the CLI:
136
- ```
137
- bin/feature_map for_feature 'Onboarding' > tmp/onboarding_feature_report.md
138
- ```
139
-
140
- ## Usage: Generating Feature Assignment files
141
-
142
- When you run `bin/featuremap validate`, the following files will automatically be generated:
143
- * `.feature_map/assignments.yml`: Captures a mapping of files within a repository to their corresponding feature and a mapping of features to their corresponding files.
144
- * `.feature_map/metrics.yml`: Captures a set of metrics rolled up at the feature level (i.e. computed over all files assigned to the feature).
145
-
146
- ## Usage: Generating Documentation
147
-
148
- The feature map gem captures valuable insights about the features of your application (e.g. metrics like ABC size, lines of code, and cyclomatic complexity). To review this information locally, you can run `bin/featuremap docs` to produce a single, self contained HTML file that includes a fully functional documentation site with useful diagrams and details about the features of your application. This file is created within the `.feature_map/docs` directory and the `index.html` file can loaded in the browser of your choice by running `open .feature_map/docs/index.html`.
149
-
150
- **Example screenshot**
151
- ![Feature Map Docs Dashboard](readme_assets/feature-map-docs-dashboard.png)
152
-
153
- ## Usage: Generating the Test Pyramid
154
-
155
- The feature map gem supports reporting on the [test pyramid](https://martinfowler.com/bliki/TestPyramid.html) coverage of the application and its constituent features. It works, broadly, by: accepting test execution reports (e.g., rspec's `json` format) for unit, integration, and regression tests -- and then matches those tests to their corresponding features as follows:
156
- - Unit: Uses tech stack conventions, e.g., rspec tests in `spec/models/user_spec.rb` are resolved to the `app/models/user.rb` implementation file, which may be matched with its feature annotation.
157
- - Integration: Integration tests do not correspond to a single implementation file (though generally live within the same codebase), so they must be directly annotated with the feature they support.
158
- - Regression: Regression tests do not correspond to a single implementation file, and may live in a given codebase or in a separate repository. They must both be tagged with the feature that they support, and a reference to the assignments of regression tests must be passed to the test pyramid generation command.
159
-
160
- Once test pyramid data has been generated for a give project, it's automatically included in that project's doc site.
161
-
162
- To generate the test pyramid data, run:
163
- > bin/featuremap test_pyramid [unit_examples_file] [integration_examples_file] [regression_examples_file] [regression_assignments_file]
164
-
165
- Note: FeatureMap currently only supports _examples files_ in rspec's `json` format (--format json), though support for others (e.g., jest's `--json`, or the JUnit XML format) may be added in the future.
166
-
167
- ## Usage: Collecting Test Coverage
168
-
169
- When you run `bin/featuremap test_coverage`, the test coverage statistics the latest commit on the main branch will be pulled from [CodeCov](https://codecov.io/) and collected into a set of per-feature test coverage statistics. This feature level test coverage data is then captured in the `.feature_map/test-coverage.yml` file.
170
-
171
- This command requires the following CodeCov account settings to be configured within the `.feature_map/config.yml` file:
172
-
173
- ```yml
174
- code_cov:
175
- service: github
176
- owner: Acme-Org
177
- repo: sample_app
178
- ```
179
-
180
- See the [CodeCov API docs](https://docs.codecov.com/reference/repos_retrieve) for more information about the expected values for these configurations.
181
-
182
- Test coverage statistics can be pulled for a specific commit (e.g. the latest commit on a feature branch) by including the full commit SHA as an argument at the end of this CLI command (e.g. `bin/featuremap test_coverage ae80a927654997be4f48d3dbcd1320083cf22eea`). Before running this command please check the [CodeCov dashboard](https://app.codecov.io/) for your application to ensure test coverage statistics have been reported for this commit.
183
-
184
- ### CodeCov API Token Generation
185
-
186
- Running the `bin/featuremap test_coverage` requires an active CodeCov API access token to be specified in the `CODECOV_API_TOKEN` environment variable of your shell session. This token is used to retrieve coverage statistics from the CodeCov account configured in the `.feature_map/config.yml` file.
187
-
188
- Use the following steps to generate a new CodeCov API token:
189
-
190
- 1. Log into your [CodeCov account](https://app.codecov.io/)
191
- 1. Click the "Settings" menu option in the profile dropdown menu in the top right corner of the screen
192
- ![CodeCov profile menu with Settings menu highlighted](readme_assets/codeCov-profileMenu.png)
193
- 1. Click the "Access" menu option from the left-hand navigation menu of the Settings page
194
- ![CodeCov Settings page navigation bar with Access menu highlighted](readme_assets/codeCov-settingsMenu.png)
195
- 1. Click the "Generate Token" button in the "API Tokens" section of the page
196
- ![CodeCov Access page with Generate Token button highlighted](readme_assets/codeCov-apiTokensTable.png)
197
- 1. Enter a descriptive name for the token (e.g. FeatureMap CLI) and click the "Generate Token" button
198
- ![CodeCov API token creation modal](readme_assets/codeCov-createTokenModal.png)
199
- 1. __IMPORTANT__: Copy the access token value presented on the screen and store it in a secure location (e.g. 1Password entry, BitWarden entry, etc)
200
- ![CodeCov newly created API token modal](readme_assets/codeCov-newTokenModal.png)
201
-
202
- #### __OPTIONAL__: Store the token as an environment variable in your shell's environment:
203
- **ZSH**
204
- ```shell
205
- echo 'export CODECOV_API_TOKEN="YOUR_CODECOV_API_TOKEN"' >> ~/.zshrc
206
- source ~/.zshrc
207
- ```
208
-
209
- **Bash**
210
- ```shell
211
- echo 'export CODECOV_API_TOKEN="YOUR_CODECOV_API_TOKEN"' >> ~/.bashrc
212
- source ~/.bashrc
213
- ```
214
-
215
- ## Proper Configuration & Validation
216
-
217
- FeatureMap comes with a validation function to ensure the following things are true:
218
-
219
- 1) Only one mechanism is defining the feature assignment for a file. That is -- you can't have a file annotation on a file assigned via glob-based assignment. This helps make feature assignment behavior more clear by avoiding concerns about precedence.
220
- 2) All features referenced as an assignment for any file is a valid feature (i.e. it's in the list of `CodeFeatures.all`).
221
- 3) All files have a feature assigned. You can specify in `unassigned_globs` to represent a TODO list of files to add feature assignments to.
222
- * Teams using the [CodeOwnership](https://github.com/rubyatscale/code_ownership/tree/main) gem include a `require_assignment_for_teams` key in the `.feature_map/config.yml` file to have this validation to apply a specific list of team. This allows feature assignments to be rolled out in a gradual manner on a team-by-team basis. The `require_assignment_for_teams` configuration should contain a list of team names (i.e. the value from the `name` key in the associated `config/teams/*.yml` file) for the teams whose files will be included in this validation.
223
- 3) The `assignments.yml` file is up to date. This is automatically corrected and staged unless specified otherwise with `bin/featuremap validate --skip-autocorrect --skip-stage`. You can turn this validation off by setting `skip_features_validation: true` in `.feature_map/config.yml`.
224
-
225
- FeatureMap also allows you to specify which globs and file extensions should be considered assignable.
226
-
227
- Here is an example `.feature_map/config.yml`.
228
- ```yml
229
- assigned_globs:
230
- - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
231
- unassigned_globs:
232
- - db/**/*
233
- - app/services/some_file1.rb
234
- - app/services/some_file2.rb
235
- - frontend/javascripts/**/__generated__/**/*
236
- ```
237
- You can call the validation function with the Ruby API
238
- ```ruby
239
- FeatureMap.validate!
240
- ```
241
- or the CLI
242
- ```
243
- bin/featuremap validate
244
- ```
245
-
246
27
  ## Development
247
28
 
29
+ ### Ruby Gem
248
30
  Contributions are welcome and appreciated. Here's how to get started:
249
31
 
250
32
  - clone repo: `$ git clone git@github.com:Beyond-Finance/feature_map.git`
@@ -255,7 +37,21 @@ Contributions are welcome and appreciated. Here's how to get started:
255
37
 
256
38
  That's it! Assuming you can complete all of these steps without any error or issues, you should be good to go.
257
39
 
258
- #### Publication
40
+ ### Documentation Site
41
+
42
+ The documentation site is a React application which is built on the Vite framework. There are two steps to building the site: first, the skeleton of the site is compiled and committed into this repository; second, the various artifacts are injected from a host repository into a project-specific instance of the site via [bin/featuremap docs](https://beyond-finance.github.io/feature_map/public-interface/docs).
43
+
44
+ Compilation of the build asset is done via `npm run build` from within the [docs](./docs) folder. This compiles the React app into a single static file which is placed in [./lib/feature_map/private/docs/index.html](./lib/feature_map/private/docs/index.html]).
45
+
46
+ The documentation site may be run locally to aid in development via `bin/docs`. It uses sample data found in [docs/src/data/sample_config.js](./docs/src/data/sample_config.js).
47
+
48
+ More information on the development of the documentation site may be found in the [Docs Readme](./docs/README.md).
49
+
50
+ ### README Site
51
+
52
+ The README site is built with Jekyll and TailwindCSS and is hosted via GitHub Pages at: https://beyond-finance.github.io/feature_map. It can be run locally to aid in development via `bin/readme`.
53
+
54
+ ### Publication
259
55
 
260
56
  When a new version of the gem is ready to be published, please follow these steps:
261
57
 
@@ -267,3 +63,9 @@ When a new version of the gem is ready to be published, please follow these step
267
63
  * Build a new version of the gem: `gem build feature_map.gemspec`
268
64
  * Authenticate with rubygems.org: `gem signin`
269
65
  * Publish the new version of the gem: `gem push feature_map-[NEW_VERSION].gem`
66
+
67
+ ## Colophon
68
+
69
+ The structure and execution of FeatureMap's initial version was based on the gem [CodeOwnership](https://github.com/rubyatscale/code_ownership).
70
+
71
+ The structure and styling of the README site was based on a theme from [Spinal](https://spinalcms.com/resources/documentation-theme-built-with-tailwind-css/).
data/bin/docs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env sh
2
+
3
+ if ! gem list foreman -i --silent; then
4
+ echo "Installing foreman..."
5
+ gem install foreman
6
+ fi
7
+
8
+ cd docs && npm install && cd ..
9
+ exec foreman start -f Procfile.docs "$@"
data/bin/readme ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env sh
2
+
3
+ if ! gem list foreman -i --silent; then
4
+ echo "Installing foreman..."
5
+ gem install foreman
6
+ fi
7
+
8
+ cp ./lib/feature_map/private/docs/index.html ./readme/example-docs-site.html
9
+ exec foreman start -f Procfile.readme "$@"
@@ -19,6 +19,8 @@ module FeatureMap
19
19
  test_coverage!(argv)
20
20
  elsif command == 'test_pyramid'
21
21
  test_pyramid!(argv)
22
+ elsif command == 'additional_metrics'
23
+ additional_metrics!(argv)
22
24
  elsif command == 'for_file'
23
25
  for_file(argv)
24
26
  elsif command == 'for_feature'
@@ -34,6 +36,7 @@ module FeatureMap
34
36
  for_file - find feature assignment for a single file
35
37
  test_coverage - generates per-feature test coverage statistics
36
38
  test_pyramid - generates per-feature test pyramid (unit, integration, regression) statistics
39
+ additional_metrics - generates additional metrics per-feature (e.g. health score)
37
40
  validate - run all validations
38
41
 
39
42
  ##################################################
@@ -126,6 +129,10 @@ module FeatureMap
126
129
  options[:skip_validate] = true
127
130
  end
128
131
 
132
+ opts.on('--skip-additional-metrics', 'Skip the execution of the additional_metrics command, using the existing feature output files') do
133
+ options[:skip_additional_metrics] = true
134
+ end
135
+
129
136
  opts.on('--help', 'Shows this prompt') do
130
137
  puts opts
131
138
  exit
@@ -138,6 +145,7 @@ module FeatureMap
138
145
  custom_git_ref = non_flag_args[0]
139
146
 
140
147
  FeatureMap.validate!(stage_changes: !options[:skip_stage]) unless options[:skip_validate]
148
+ FeatureMap.generate_additional_metrics! unless options[:skip_additional_metrics]
141
149
 
142
150
  FeatureMap.generate_docs!(custom_git_ref)
143
151
 
@@ -199,6 +207,24 @@ module FeatureMap
199
207
  FeatureMap.generate_test_pyramid!(unit_path, integration_path, regression_path, regression_assignments_path)
200
208
  end
201
209
 
210
+ def self.additional_metrics!(argv)
211
+ parser = OptionParser.new do |opts|
212
+ opts.banner = <<~MSG
213
+ Usage: bin/featuremap additional_metrics
214
+ Should be run after metrics and test coverage files have been generated
215
+ MSG
216
+
217
+ opts.on('--help', 'Shows this prompt') do
218
+ puts opts
219
+ exit
220
+ end
221
+ end
222
+ args = parser.order!(argv)
223
+ parser.parse!(args)
224
+
225
+ FeatureMap.generate_additional_metrics!
226
+ end
227
+
202
228
  # For now, this just returns feature assignment
203
229
  # Later, this could also return feature assignment errors about that file.
204
230
  def self.for_file(argv)
@@ -0,0 +1,161 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module FeatureMap
5
+ module Private
6
+ class AdditionalMetricsFile
7
+ extend T::Sig
8
+
9
+ class FileContentError < StandardError; end
10
+
11
+ FEATURES_KEY = 'features'
12
+
13
+ FeatureName = T.type_alias { String }
14
+
15
+ FeatureMetrics = T.type_alias do
16
+ T::Hash[
17
+ String,
18
+ T.any(Integer, Float, T::Hash[String, String])
19
+ ]
20
+ end
21
+
22
+ FeaturesContent = T.type_alias do
23
+ T::Hash[
24
+ FeatureName,
25
+ FeatureMetrics
26
+ ]
27
+ end
28
+
29
+ sig { params(metrics: T::Hash[String, T.untyped], test_coverage: T::Hash[String, T.untyped], health_config: T::Hash[String, T.untyped]).void }
30
+ def self.write!(metrics, test_coverage, health_config)
31
+ FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
32
+
33
+ path.write([header_comment, "\n", generate_content(metrics, test_coverage, health_config).to_yaml].join)
34
+ end
35
+
36
+ sig { returns(Pathname) }
37
+ def self.path
38
+ Pathname.pwd.join('.feature_map/additional-metrics.yml')
39
+ end
40
+
41
+ sig { returns(String) }
42
+ def self.header_comment
43
+ <<~HEADER
44
+ # STOP! - DO NOT EDIT THIS FILE MANUALLY
45
+ # This file was automatically generated by "bin/featuremap additional_metrics". The next time this file
46
+ # is generated any changes will be lost. For more details:
47
+ # https://github.com/Beyond-Finance/feature_map
48
+ #
49
+ # It is NOT recommended to commit this file into your source control. It will change as a
50
+ # result of nearly all other source code changes. This file should be ignored by your source
51
+ # control but can be used for other feature analysis operations (e.g. documentation
52
+ # generation, etc).
53
+ HEADER
54
+ end
55
+
56
+ sig { params(feature_metrics: T::Hash[String, T.untyped], feature_test_coverage: T::Hash[String, T.untyped], health_config: T::Hash[String, T.untyped]).returns(T::Hash[String, FeaturesContent]) }
57
+ def self.generate_content(feature_metrics, feature_test_coverage, health_config)
58
+ feature_additional_metrics = T.let({}, FeaturesContent)
59
+
60
+ cyclomatic_complexity_ratios = feature_metrics.map { |_k, m| m[FeatureMetricsCalculator::COMPLEXITY_RATIO_METRIC] }.compact
61
+ encapsulation_ratios = feature_metrics.map { |_k, m| m[FeatureMetricsCalculator::ENCAPSULATION_RATIO_METRIC] }.compact
62
+ feature_sizes = feature_metrics.map { |_k, m| m[FeatureMetricsCalculator::LINES_OF_CODE_METRIC] }.compact
63
+ test_coverage_ratios = feature_test_coverage.map { |_k, c| c[TestCoverageFile::COVERAGE_RATIO] }.compact
64
+
65
+ Private.feature_file_assignments.each_key do |feature_name|
66
+ cyclomatic_complexity = calculate(cyclomatic_complexity_ratios, feature_metrics.dig(feature_name, FeatureMetricsCalculator::COMPLEXITY_RATIO_METRIC) || 0)
67
+ encapsulation = calculate(encapsulation_ratios, feature_metrics.dig(feature_name, FeatureMetricsCalculator::ENCAPSULATION_RATIO_METRIC) || 0)
68
+ feature_size = calculate(feature_sizes, feature_metrics.dig(feature_name, FeatureMetricsCalculator::LINES_OF_CODE_METRIC) || 0)
69
+ test_coverage = calculate(test_coverage_ratios, feature_test_coverage.dig(feature_name, TestCoverageFile::COVERAGE_RATIO) || 0)
70
+ health = health_score_for(cyclomatic_complexity, encapsulation, test_coverage, health_config)
71
+
72
+ feature_additional_metrics[feature_name] = {
73
+ 'cyclomatic_complexity' => cyclomatic_complexity,
74
+ 'encapsulation' => encapsulation,
75
+ 'feature_size' => feature_size,
76
+ 'test_coverage' => test_coverage,
77
+ 'health' => health
78
+ }
79
+ end
80
+
81
+ { FEATURES_KEY => feature_additional_metrics }
82
+ end
83
+
84
+ sig { returns(FeaturesContent) }
85
+ def self.load_features!
86
+ metrics_content = YAML.load_file(path)
87
+
88
+ return metrics_content[FEATURES_KEY] if metrics_content.is_a?(Hash) && metrics_content[FEATURES_KEY]
89
+
90
+ raise FileContentError, "Unexpected content found in #{path}. Use `bin/featuremap additional_metrics` to regenerate it and try again."
91
+ rescue Psych::SyntaxError => e
92
+ raise FileContentError, "Invalid YAML content found at #{path}. Error: #{e.message} Use `bin/featuremap additional_metrics` to generate it and try again."
93
+ rescue Errno::ENOENT
94
+ raise FileContentError, "No feature metrics file found at #{path}. Use `bin/featuremap additional_metrics` to generate it and try again."
95
+ end
96
+
97
+ sig { params(collection: T::Array[T.any(Integer, Float)], score: T.any(Integer, Float)).returns({ 'percentile' => Float, 'percent_of_max' => Integer, 'score' => T.any(Integer, Float) }) }
98
+ def self.calculate(collection, score)
99
+ max = collection.max || 0
100
+ percentile = percentile_of(collection, score)
101
+ percent_of_max = max.zero? ? 0 : ((score.to_f / max) * 100).round.to_i
102
+
103
+ { 'percentile' => percentile, 'percent_of_max' => percent_of_max, 'score' => score }
104
+ end
105
+
106
+ sig { params(arr: T::Array[T.any(Integer, Float)], val: T.any(Integer, Float)).returns(Float) }
107
+ def self.percentile_of(arr, val)
108
+ return 0.0 if arr.empty?
109
+
110
+ ensure_array_of_floats = arr.map(&:to_f)
111
+ ensure_float_value = val.to_f
112
+
113
+ below_or_equal_count = ensure_array_of_floats.reduce(0) do |acc, v|
114
+ if v < ensure_float_value
115
+ acc + 1
116
+ elsif v == ensure_float_value
117
+ acc + 0.5
118
+ else
119
+ acc
120
+ end
121
+ end
122
+
123
+ ((100 * below_or_equal_count) / ensure_array_of_floats.length).to_f
124
+ end
125
+
126
+ sig { params(awardable_points: Integer, score: T.any(Float, Integer), score_threshold: Integer, percent_of_max: T.nilable(T.any(Integer, Float)), percent_of_max_threshold: T.nilable(T.any(Integer, Float))).returns({ 'awardable_points' => Integer, 'health_score' => T.any(Float, Integer), 'close_to_maximum_score' => T::Boolean, 'exceeds_score_threshold' => T::Boolean }) }
127
+ def self.health_score_component(awardable_points, score, score_threshold, percent_of_max = 0, percent_of_max_threshold = 100)
128
+ close_to_maximum_score = T.must(percent_of_max) >= T.must(percent_of_max_threshold)
129
+ exceeds_score_threshold = score >= score_threshold
130
+
131
+ if close_to_maximum_score || exceeds_score_threshold
132
+ { 'awardable_points' => awardable_points, 'health_score' => awardable_points, 'close_to_maximum_score' => close_to_maximum_score, 'exceeds_score_threshold' => exceeds_score_threshold }
133
+ else
134
+ { 'awardable_points' => awardable_points, 'health_score' => (score.to_f / score_threshold) * awardable_points, 'close_to_maximum_score' => close_to_maximum_score, 'exceeds_score_threshold' => exceeds_score_threshold }
135
+ end
136
+ end
137
+
138
+ sig {
139
+ params(
140
+ encapsulation: T::Hash[String, T.any(Integer, Float)],
141
+ cyclomatic_complexity: T::Hash[String, T.any(Integer, Float)],
142
+ test_coverage: T::Hash[String, T.any(Integer, Float)],
143
+ health_config: T::Hash[String, T.untyped]
144
+ ).returns(T::Hash[String, T.untyped])
145
+ }
146
+ def self.health_score_for(encapsulation, cyclomatic_complexity, test_coverage, health_config)
147
+ cyclomatic_complexity_config = health_config['components']['cyclomatic_complexity']
148
+ encapsulation_config = health_config['components']['encapsulation']
149
+ test_coverage_config = health_config['components']['test_coverage']
150
+
151
+ test_coverage_component = health_score_component(test_coverage_config['weight'], T.must(test_coverage['score']), test_coverage_config['score_threshold'])
152
+ cyclomatic_complexity_component = health_score_component(cyclomatic_complexity_config['weight'], T.must(cyclomatic_complexity['percentile']), cyclomatic_complexity_config['score_threshold'], cyclomatic_complexity['percent_of_max'], 100 - cyclomatic_complexity_config['minimum_variance'])
153
+ encapsulation_component = health_score_component(encapsulation_config['weight'], T.must(encapsulation['percentile']), encapsulation_config['score_threshold'], encapsulation['percent_of_max'], 100 - encapsulation_config['minimum_variance'])
154
+
155
+ overall = test_coverage_component['health_score'] + cyclomatic_complexity_component['health_score'] + encapsulation_component['health_score']
156
+
157
+ { 'test_coverage_component' => test_coverage_component, 'cyclomatic_complexity_component' => cyclomatic_complexity_component, 'encapsulation_component' => encapsulation_component, 'overall' => overall }
158
+ end
159
+ end
160
+ end
161
+ end