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 +4 -4
- data/README.md +34 -232
- data/bin/docs +9 -0
- data/bin/readme +9 -0
- data/lib/feature_map/cli.rb +26 -0
- data/lib/feature_map/private/additional_metrics_file.rb +161 -0
- data/lib/feature_map/private/docs/index.html +75 -75
- data/lib/feature_map/private/documentation_site.rb +4 -1
- data/lib/feature_map/private/feature_metrics_calculator.rb +24 -6
- data/lib/feature_map/private/metrics_file.rb +1 -1
- data/lib/feature_map/private/test_coverage_file.rb +9 -1
- data/lib/feature_map/private.rb +13 -0
- data/lib/feature_map.rb +5 -0
- metadata +19 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 278f81ac986fb56714e9159ff94e5cb98c1bbca774486735c32cd837db13965d
|
4
|
+
data.tar.gz: 73b6c34564934df5f1febbfd7e5ec57d97142f4ff624b38294eed829090739c0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
3
|
+
This gem helps identify and manage features within large applications.
|
4
4
|
|
5
|
-
|
5
|
+
For usage documentation, please see the [README Site](https://beyond-finance.github.io/feature_map).
|
6
6
|
|
7
|
-
|
7
|
+
## Installation
|
8
8
|
|
9
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
33
|
-
|
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
|
-
|
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
|
-
|
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
|
-

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

|
193
|
-
1. Click the "Access" menu option from the left-hand navigation menu of the Settings page
|
194
|
-

|
195
|
-
1. Click the "Generate Token" button in the "API Tokens" section of the page
|
196
|
-

|
197
|
-
1. Enter a descriptive name for the token (e.g. FeatureMap CLI) and click the "Generate Token" button
|
198
|
-

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

|
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
|
-
|
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
data/bin/readme
ADDED
data/lib/feature_map/cli.rb
CHANGED
@@ -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
|