feature_map 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +269 -0
  3. data/bin/featuremap +5 -0
  4. data/lib/feature_map/cli.rb +243 -0
  5. data/lib/feature_map/code_features/plugin.rb +79 -0
  6. data/lib/feature_map/code_features/plugins/identity.rb +39 -0
  7. data/lib/feature_map/code_features.rb +152 -0
  8. data/lib/feature_map/configuration.rb +43 -0
  9. data/lib/feature_map/constants.rb +11 -0
  10. data/lib/feature_map/mapper.rb +78 -0
  11. data/lib/feature_map/output_color.rb +42 -0
  12. data/lib/feature_map/private/assignment_mappers/directory_assignment.rb +150 -0
  13. data/lib/feature_map/private/assignment_mappers/feature_definition_assignment.rb +68 -0
  14. data/lib/feature_map/private/assignment_mappers/feature_globs.rb +138 -0
  15. data/lib/feature_map/private/assignment_mappers/file_annotations.rb +158 -0
  16. data/lib/feature_map/private/assignments_file.rb +190 -0
  17. data/lib/feature_map/private/code_cov.rb +96 -0
  18. data/lib/feature_map/private/cyclomatic_complexity_calculator.rb +46 -0
  19. data/lib/feature_map/private/docs/index.html +247 -0
  20. data/lib/feature_map/private/documentation_site.rb +128 -0
  21. data/lib/feature_map/private/extension_loader.rb +24 -0
  22. data/lib/feature_map/private/feature_assigner.rb +22 -0
  23. data/lib/feature_map/private/feature_metrics_calculator.rb +76 -0
  24. data/lib/feature_map/private/feature_plugins/assignment.rb +17 -0
  25. data/lib/feature_map/private/glob_cache.rb +80 -0
  26. data/lib/feature_map/private/lines_of_code_calculator.rb +49 -0
  27. data/lib/feature_map/private/metrics_file.rb +86 -0
  28. data/lib/feature_map/private/test_coverage_file.rb +97 -0
  29. data/lib/feature_map/private/test_pyramid_file.rb +151 -0
  30. data/lib/feature_map/private/todo_inspector.rb +57 -0
  31. data/lib/feature_map/private/validations/features_up_to_date.rb +78 -0
  32. data/lib/feature_map/private/validations/files_have_features.rb +45 -0
  33. data/lib/feature_map/private/validations/files_have_unique_features.rb +34 -0
  34. data/lib/feature_map/private.rb +204 -0
  35. data/lib/feature_map/validator.rb +29 -0
  36. data/lib/feature_map.rb +212 -0
  37. metadata +253 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f1243d054f38c9a5520a3790190ad36cf49a447c4e931d7e5cc85cd1a958eb20
4
+ data.tar.gz: 275ed70329b4df76a18412719ee004c8626cb53f708905f5ff3413846fab34e2
5
+ SHA512:
6
+ metadata.gz: 62100534249d7ccc2ec67849ca92bcaf38dc55bd6d22a3d1aefb673b6a6222c16cddbb742af38f895832d758de85ffe75c6f93fccf2b4d6c292d344566b04089
7
+ data.tar.gz: e055eaa7af6fe4a6e27c9f29a38e1c2d1a687cbf0f5ba500541a0b2b21cbec9a78b29cc23c95128995eb81142b98cbeee3d01e4dc454084e956f8a748f0b8e95
data/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # FeatureMap
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.
4
+
5
+ ## Getting started
6
+
7
+ To get started there's a few things you should do.
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`.
20
+
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.
31
+
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.
38
+
39
+ ## Usage: Assigning Features
40
+
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
90
+
91
+ 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
+
93
+ 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
+
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
+ ## Development
247
+
248
+ Contributions are welcome and appreciated. Here's how to get started:
249
+
250
+ - clone repo: `$ git clone git@github.com:Beyond-Finance/feature_map.git`
251
+ - install dependencies: `$ bundle install`
252
+ - run tests: `$ bundle exec rspec`
253
+ - run Rubocop: `$ bundle exec rubocop`
254
+ - run Sorbet: `$ bundle exec srb tc`
255
+
256
+ That's it! Assuming you can complete all of these steps without any error or issues, you should be good to go.
257
+
258
+ #### Publication
259
+
260
+ When a new version of the gem is ready to be published, please follow these steps:
261
+
262
+ * Update `spec.version` value in the (feature_map.gemspec)[feature_map.gemspec] file.
263
+ * Assign a version to this release in accordance with [Semantic Versioning](https://semver.org/) based on the changes contained in this release.
264
+ * Create a new release tag in Github ([link](https://github.com/Beyond-Finance/feature_map/releases)) with a value that matches the new Gemspec version.
265
+ * Checkout the release tag in your local environment.
266
+ * Publish the new version of the gem to RubyGems ([docs](https://guides.rubygems.org/publishing/#publishing-to-rubygemsorg)), which largely consists of running the following commands:
267
+ * Build a new version of the gem: `gem build feature_map.gemspec`
268
+ * Authenticate with rubygems.org: `gem signin`
269
+ * Publish the new version of the gem: `gem push feature_map-[NEW_VERSION].gem`
data/bin/featuremap ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # typed: strict
3
+
4
+ require 'feature_map'
5
+ FeatureMap::Cli.run!(ARGV)
@@ -0,0 +1,243 @@
1
+ # typed: true
2
+
3
+ require 'optparse'
4
+ require 'pathname'
5
+ require 'fileutils'
6
+ require 'feature_map/output_color'
7
+
8
+ module FeatureMap
9
+ class Cli
10
+ def self.run!(argv)
11
+ command = argv.shift
12
+ if command == 'validate'
13
+ validate!(argv)
14
+ elsif command == 'docs'
15
+ docs!(argv)
16
+ elsif command == 'test_coverage'
17
+ test_coverage!(argv)
18
+ elsif command == 'test_pyramid'
19
+ test_pyramid!(argv)
20
+ elsif command == 'for_file'
21
+ for_file(argv)
22
+ elsif command == 'for_feature'
23
+ for_feature(argv)
24
+ elsif [nil, 'help'].include?(command)
25
+ puts <<~USAGE
26
+ Usage: bin/featuremap <subcommand>
27
+
28
+ Subcommands:
29
+ validate - run all validations
30
+ docs - generates feature documentation
31
+ test_coverage - generates per-feature test coverage statistics
32
+ test_pyramid - generates per-feature test pyramid (unit, integration, regression) statistics
33
+ for_file - find feature assignment for a single file
34
+ for_feature - find assignment information for a feature
35
+ help - display help information about feature_map
36
+ USAGE
37
+ else
38
+ puts "'#{command}' is not a feature_map command. See `bin/featuremap help`."
39
+ end
40
+ end
41
+
42
+ def self.validate!(argv)
43
+ options = {}
44
+
45
+ parser = OptionParser.new do |opts|
46
+ opts.banner = 'Usage: bin/featuremap validate [options]'
47
+
48
+ opts.on('--skip-autocorrect', 'Skip automatically correcting any errors, such as the .feature_map/assignments.yml file') do
49
+ options[:skip_autocorrect] = true
50
+ end
51
+
52
+ opts.on('-d', '--diff', 'Only run validations with staged files') do
53
+ options[:diff] = true
54
+ end
55
+
56
+ opts.on('-s', '--skip-stage', 'Skips staging the .feature_map/assignments.yml file') do
57
+ options[:skip_stage] = true
58
+ end
59
+
60
+ opts.on('--help', 'Shows this prompt') do
61
+ puts opts
62
+ exit
63
+ end
64
+ end
65
+ args = parser.order!(argv)
66
+ parser.parse!(args)
67
+
68
+ files = if options[:diff]
69
+ ENV.fetch('FEATUREMAP_GIT_STAGED_FILES') { `git diff --staged --name-only` }.split("\n").select do |file|
70
+ File.exist?(file)
71
+ end
72
+ else
73
+ nil
74
+ end
75
+
76
+ FeatureMap.validate!(
77
+ files: files,
78
+ autocorrect: !options[:skip_autocorrect],
79
+ stage_changes: !options[:skip_stage]
80
+ )
81
+
82
+ puts OutputColor.green('FeatureMap validation complete.')
83
+ end
84
+
85
+ def self.docs!(argv)
86
+ options = {}
87
+
88
+ parser = OptionParser.new do |opts|
89
+ opts.banner = 'Usage: bin/featuremap docs [options] [target_commit_sha]'
90
+
91
+ opts.on('-s', '--skip-stage', 'Skips staging the .feature_map/assignments.yml file') do
92
+ options[:skip_stage] = true
93
+ end
94
+
95
+ opts.on('--skip-validate', 'Skip the execution of the validate command, using the existing feature output files') do
96
+ options[:skip_validate] = true
97
+ end
98
+
99
+ opts.on('--help', 'Shows this prompt') do
100
+ puts opts
101
+ exit
102
+ end
103
+ end
104
+ args = parser.order!(argv)
105
+ parser.parse!(args)
106
+
107
+ non_flag_args = argv.reject { |arg| arg.start_with?('--') }
108
+ custom_git_ref = non_flag_args[0]
109
+
110
+ FeatureMap.validate!(stage_changes: !options[:skip_stage]) unless options[:skip_validate]
111
+
112
+ FeatureMap.generate_docs!(custom_git_ref)
113
+
114
+ puts OutputColor.green('FeatureMap documentaiton site generated.')
115
+ puts 'Open .feature_map/docs/index.html in a browser to view the documentation site.'
116
+ end
117
+
118
+ def self.test_coverage!(argv)
119
+ parser = OptionParser.new do |opts|
120
+ opts.banner = <<~MSG
121
+ Usage: bin/featuremap test_coverage [options] [code_cov_commit_sha].
122
+ Note: Requires environment variable `CODECOV_API_TOKEN`.
123
+ MSG
124
+
125
+ opts.on('--help', 'Shows this prompt') do
126
+ puts opts
127
+ exit
128
+ end
129
+ end
130
+ args = parser.order!(argv)
131
+ parser.parse!(args)
132
+ non_flag_args = argv.reject { |arg| arg.start_with?('--') }
133
+ custom_commit_sha = non_flag_args[0]
134
+
135
+ code_cov_token = ENV.fetch('CODECOV_API_TOKEN', '')
136
+ raise 'Please specify a CodeCov API token in your environment as `CODECOV_API_TOKEN`' if code_cov_token.empty?
137
+
138
+ # If no commit SHA was providid in the CLI command args, use the most recent commit of the main branch in the upstream remote.
139
+ commit_sha = custom_commit_sha || `git log -1 --format=%H origin/main`.chomp
140
+ puts "Pulling test coverage statistics for commit #{commit_sha}"
141
+
142
+ FeatureMap.gather_test_coverage!(commit_sha, code_cov_token)
143
+
144
+ puts OutputColor.green('FeatureMap test coverage statistics collected.')
145
+ puts 'View the resulting test coverage for each feature in .feature_map/test-coverage.yml'
146
+ end
147
+
148
+ def self.test_pyramid!(argv)
149
+ parser = OptionParser.new do |opts|
150
+ opts.banner = <<~MSG
151
+ Usage: bin/featuremap test_pyramid [unit_path] [integration_path] [regression_path] [regression_assignments_path].
152
+ Paths should point to files containing json-formatted rspec test summaries.
153
+ These can be generated via rspec's `-f j` flag.
154
+ MSG
155
+
156
+ opts.on('--help', 'Shows this prompt') do
157
+ puts opts
158
+ exit
159
+ end
160
+ end
161
+ args = parser.order!(argv)
162
+ parser.parse!(args)
163
+ non_flag_args = argv.reject { |arg| arg.start_with?('--') }
164
+
165
+ file_paths = non_flag_args.first(4)
166
+ raise 'Please specify all of [unit_path] [integration_path] [regression_path] [regression_assignments_path]' if file_paths.compact.size != 4
167
+
168
+ unit_path, integration_path, regression_path, regression_assignments_path = file_paths
169
+ FeatureMap.generate_test_pyramid!(unit_path, integration_path, regression_path, regression_assignments_path)
170
+ end
171
+
172
+ # For now, this just returns feature assignment
173
+ # Later, this could also return feature assignment errors about that file.
174
+ def self.for_file(argv)
175
+ options = {}
176
+
177
+ # Long-term, we probably want to use something like `thor` so we don't have to implement logic
178
+ # like this. In the short-term, this is a simple way for us to use the built-in OptionParser
179
+ # while having an ergonomic CLI.
180
+ files = argv.reject { |arg| arg.start_with?('--') }
181
+
182
+ parser = OptionParser.new do |opts|
183
+ opts.banner = 'Usage: bin/featuremap for_file [options]'
184
+
185
+ opts.on('--json', 'Output as JSON') do
186
+ options[:json] = true
187
+ end
188
+
189
+ opts.on('--help', 'Shows this prompt') do
190
+ puts opts
191
+ exit
192
+ end
193
+ end
194
+ args = parser.order!(argv)
195
+ parser.parse!(args)
196
+
197
+ if files.count != 1
198
+ raise 'Please pass in one file. Use `bin/featuremap for_file --help` for more info'
199
+ end
200
+
201
+ feature = FeatureMap.for_file(files.first)
202
+
203
+ feature_name = feature&.name || 'Unassigned'
204
+ feature_yml = feature&.config_yml || 'Unassigned'
205
+
206
+ if options[:json]
207
+ json = {
208
+ feature_name: feature_name,
209
+ feature_yml: feature_yml
210
+ }
211
+
212
+ puts json.to_json
213
+ else
214
+ puts <<~MSG
215
+ Feature: #{feature_name}
216
+ Feature YML: #{feature_yml}
217
+ MSG
218
+ end
219
+ end
220
+
221
+ def self.for_feature(argv)
222
+ parser = OptionParser.new do |opts|
223
+ opts.banner = 'Usage: bin/featuremap for_feature \'Onboarding\''
224
+
225
+ opts.on('--help', 'Shows this prompt') do
226
+ puts opts
227
+ exit
228
+ end
229
+ end
230
+ features = argv.reject { |arg| arg.start_with?('--') }
231
+ args = parser.order!(argv)
232
+ parser.parse!(args)
233
+
234
+ if features.count != 1
235
+ raise 'Please pass in one feature. Use `bin/featuremap for_feature --help` for more info'
236
+ end
237
+
238
+ puts FeatureMap.for_feature(features.first)
239
+ end
240
+
241
+ private_class_method :validate!
242
+ end
243
+ end
@@ -0,0 +1,79 @@
1
+ # typed: strict
2
+
3
+ module FeatureMap
4
+ module CodeFeatures
5
+ # Plugins allow a client to add validation on custom keys in the feature YML.
6
+ # For now, only a single plugin is allowed to manage validation on a top-level key.
7
+ # In the future we can think of allowing plugins to be gracefully merged with each other.
8
+ class Plugin
9
+ extend T::Helpers
10
+ extend T::Sig
11
+
12
+ abstract!
13
+
14
+ sig { params(feature: Feature).void }
15
+ def initialize(feature)
16
+ @feature = feature
17
+ end
18
+
19
+ sig { params(base: T.untyped).void }
20
+ def self.inherited(base) # rubocop:disable Lint/MissingSuper
21
+ all_plugins << T.cast(base, T.class_of(Plugin))
22
+ end
23
+
24
+ sig { returns(T::Array[T.class_of(Plugin)]) }
25
+ def self.all_plugins
26
+ @all_plugins ||= T.let(@all_plugins, T.nilable(T::Array[T.class_of(Plugin)]))
27
+ @all_plugins ||= []
28
+ @all_plugins
29
+ end
30
+
31
+ sig { params(features: T::Array[Feature]).returns(T::Array[String]) }
32
+ def self.validation_errors(features)
33
+ []
34
+ end
35
+
36
+ sig { params(feature: Feature).returns(T.attached_class) }
37
+ def self.for(feature)
38
+ register_feature(feature)
39
+ end
40
+
41
+ sig { params(feature: Feature, key: String).returns(String) }
42
+ def self.missing_key_error_message(feature, key)
43
+ "#{feature.name} is missing required key `#{key}`"
44
+ end
45
+
46
+ sig { returns(T::Hash[T.nilable(String), T::Hash[T.class_of(Plugin), Plugin]]) }
47
+ def self.registry
48
+ @registry ||= T.let(@registry, T.nilable(T::Hash[String, T::Hash[T.class_of(Plugin), Plugin]]))
49
+ @registry ||= {}
50
+ @registry
51
+ end
52
+
53
+ sig { params(feature: Feature).returns(T.attached_class) }
54
+ def self.register_feature(feature)
55
+ # We pull from the hash since `feature.name` uses the registry
56
+ feature_name = feature.raw_hash['name']
57
+
58
+ registry[feature_name] ||= {}
59
+ registry_for_feature = registry[feature_name] || {}
60
+ registry[feature_name] ||= {}
61
+ registry_for_feature[self] ||= new(feature)
62
+ T.unsafe(registry_for_feature[self])
63
+ end
64
+
65
+ sig { void }
66
+ def self.bust_caches!
67
+ all_plugins.each(&:clear_feature_registry!)
68
+ end
69
+
70
+ sig { void }
71
+ def self.clear_feature_registry!
72
+ @registry = nil
73
+ end
74
+
75
+ private_class_method :registry
76
+ private_class_method :register_feature
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ # typed: true
2
+
3
+ module FeatureMap
4
+ module CodeFeatures
5
+ module Plugins
6
+ class Identity < Plugin
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ IdentityStruct = Struct.new(:name)
11
+
12
+ sig { returns(IdentityStruct) }
13
+ def identity
14
+ IdentityStruct.new(
15
+ @feature.raw_hash['name']
16
+ )
17
+ end
18
+
19
+ sig { override.params(features: T::Array[CodeFeatures::Feature]).returns(T::Array[String]) }
20
+ def self.validation_errors(features)
21
+ errors = T.let([], T::Array[String])
22
+
23
+ uniq_set = Set.new
24
+ features.each do |feature|
25
+ for_feature = self.for(feature)
26
+
27
+ if !uniq_set.add?(for_feature.identity.name)
28
+ errors << "More than 1 definition for #{for_feature.identity.name} found"
29
+ end
30
+
31
+ errors << missing_key_error_message(feature, 'name') if for_feature.identity.name.nil?
32
+ end
33
+
34
+ errors
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end