feature_map 1.1.0
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 +7 -0
- data/README.md +269 -0
- data/bin/featuremap +5 -0
- data/lib/feature_map/cli.rb +243 -0
- data/lib/feature_map/code_features/plugin.rb +79 -0
- data/lib/feature_map/code_features/plugins/identity.rb +39 -0
- data/lib/feature_map/code_features.rb +152 -0
- data/lib/feature_map/configuration.rb +43 -0
- data/lib/feature_map/constants.rb +11 -0
- data/lib/feature_map/mapper.rb +78 -0
- data/lib/feature_map/output_color.rb +42 -0
- data/lib/feature_map/private/assignment_mappers/directory_assignment.rb +150 -0
- data/lib/feature_map/private/assignment_mappers/feature_definition_assignment.rb +68 -0
- data/lib/feature_map/private/assignment_mappers/feature_globs.rb +138 -0
- data/lib/feature_map/private/assignment_mappers/file_annotations.rb +158 -0
- data/lib/feature_map/private/assignments_file.rb +190 -0
- data/lib/feature_map/private/code_cov.rb +96 -0
- data/lib/feature_map/private/cyclomatic_complexity_calculator.rb +46 -0
- data/lib/feature_map/private/docs/index.html +247 -0
- data/lib/feature_map/private/documentation_site.rb +128 -0
- data/lib/feature_map/private/extension_loader.rb +24 -0
- data/lib/feature_map/private/feature_assigner.rb +22 -0
- data/lib/feature_map/private/feature_metrics_calculator.rb +76 -0
- data/lib/feature_map/private/feature_plugins/assignment.rb +17 -0
- data/lib/feature_map/private/glob_cache.rb +80 -0
- data/lib/feature_map/private/lines_of_code_calculator.rb +49 -0
- data/lib/feature_map/private/metrics_file.rb +86 -0
- data/lib/feature_map/private/test_coverage_file.rb +97 -0
- data/lib/feature_map/private/test_pyramid_file.rb +151 -0
- data/lib/feature_map/private/todo_inspector.rb +57 -0
- data/lib/feature_map/private/validations/features_up_to_date.rb +78 -0
- data/lib/feature_map/private/validations/files_have_features.rb +45 -0
- data/lib/feature_map/private/validations/files_have_unique_features.rb +34 -0
- data/lib/feature_map/private.rb +204 -0
- data/lib/feature_map/validator.rb +29 -0
- data/lib/feature_map.rb +212 -0
- metadata +253 -0
@@ -0,0 +1,151 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module FeatureMap
|
5
|
+
module Private
|
6
|
+
#
|
7
|
+
# This class is responsible for compiling a set of feature-level test pyramid statistics into a test-pyramid.yml
|
8
|
+
# This file can then be used as an input to a variety of engineering team utilities
|
9
|
+
# (e.g. PR/release announcements, documentation generation, etc).
|
10
|
+
#
|
11
|
+
class TestPyramidFile
|
12
|
+
extend T::Sig
|
13
|
+
|
14
|
+
class FileContentError < StandardError; end
|
15
|
+
|
16
|
+
FEATURES_KEY = 'features'
|
17
|
+
|
18
|
+
FeatureName = T.type_alias { String }
|
19
|
+
PyramidStat = T.type_alias { String }
|
20
|
+
|
21
|
+
FeaturePyramid = T.type_alias do
|
22
|
+
T::Hash[
|
23
|
+
PyramidStat,
|
24
|
+
Integer
|
25
|
+
]
|
26
|
+
end
|
27
|
+
|
28
|
+
FeaturesContent = T.type_alias do
|
29
|
+
T::Hash[
|
30
|
+
FeatureName,
|
31
|
+
FeaturePyramid
|
32
|
+
]
|
33
|
+
end
|
34
|
+
|
35
|
+
sig do
|
36
|
+
params(
|
37
|
+
unit_examples: T::Array[T::Hash[T.untyped, T.untyped]],
|
38
|
+
integration_examples: T::Array[T::Hash[T.untyped, T.untyped]],
|
39
|
+
regression_examples: T::Array[T::Hash[T.untyped, T.untyped]],
|
40
|
+
regression_assignments: T::Hash[T.untyped, T.untyped]
|
41
|
+
).void
|
42
|
+
end
|
43
|
+
def self.write!(unit_examples, integration_examples, regression_examples, regression_assignments)
|
44
|
+
FileUtils.mkdir_p(path.dirname) if !path.dirname.exist?
|
45
|
+
|
46
|
+
regression_file_assignments = regression_assignments['features']&.transform_values do |feature|
|
47
|
+
feature['files']&.map { |file| filepath(file) } || []
|
48
|
+
end || {}
|
49
|
+
|
50
|
+
content = generate_content(
|
51
|
+
unit_examples.group_by { |ex| filepath(ex['id']) },
|
52
|
+
integration_examples.group_by { |ex| filepath(ex['id']) },
|
53
|
+
regression_examples.group_by { |ex| filepath(ex['id']) },
|
54
|
+
regression_file_assignments
|
55
|
+
)
|
56
|
+
path.write([header_comment, "\n", { FEATURES_KEY => content }.to_yaml].join)
|
57
|
+
end
|
58
|
+
|
59
|
+
sig { returns(Pathname) }
|
60
|
+
def self.path
|
61
|
+
Pathname.pwd.join('.feature_map/test-pyramid.yml')
|
62
|
+
end
|
63
|
+
|
64
|
+
sig { returns(String) }
|
65
|
+
def self.header_comment
|
66
|
+
<<~HEADER
|
67
|
+
# STOP! - DO NOT EDIT THIS FILE MANUALLY
|
68
|
+
# This file was automatically generated by "bin/featuremap test_pyramid". The next time this file
|
69
|
+
# is generated any changes will be lost. For more details:
|
70
|
+
# https://github.com/Beyond-Finance/feature_map
|
71
|
+
#
|
72
|
+
# It is NOT recommended to commit this file into your source control. It will change or become
|
73
|
+
# outdated frequently. Instead it should be regenerated when test pyramid statistics are required.
|
74
|
+
# This file should be ignored by your source control, allowing the local copy to be used for other
|
75
|
+
# feature analysis operations (e.g. documentation generation, etc).
|
76
|
+
HEADER
|
77
|
+
end
|
78
|
+
|
79
|
+
sig do
|
80
|
+
params(
|
81
|
+
unit_examples: T::Hash[T.untyped, T.untyped],
|
82
|
+
integration_examples: T::Hash[T.untyped, T.untyped],
|
83
|
+
regression_examples: T::Hash[T.untyped, T.untyped],
|
84
|
+
regression_file_assignments: T::Hash[T.untyped, T.untyped]
|
85
|
+
).returns(FeaturesContent)
|
86
|
+
end
|
87
|
+
def self.generate_content(unit_examples, integration_examples, regression_examples, regression_file_assignments)
|
88
|
+
Private.feature_file_assignments.reduce({}) do |content, (feature_name, files)|
|
89
|
+
regression_files = regression_file_assignments[feature_name] || []
|
90
|
+
regression_count, regression_pending = regression_files.reduce([0, 0]) do |accumulated_counts, file|
|
91
|
+
accumulated_count, accumulated_pending = accumulated_counts
|
92
|
+
count, pending = split(regression_examples[file])
|
93
|
+
|
94
|
+
[accumulated_count + count, accumulated_pending + pending]
|
95
|
+
end
|
96
|
+
|
97
|
+
pyramid = files.reduce({}) do |acc, file|
|
98
|
+
normalized_path = filepath(file)
|
99
|
+
|
100
|
+
unit_count, unit_pending = split(unit_examples["#{normalized_path}_spec"])
|
101
|
+
integration_count, integration_pending = split(integration_examples[normalized_path])
|
102
|
+
|
103
|
+
{
|
104
|
+
'unit_count' => (acc['unit_count'] || 0) + unit_count,
|
105
|
+
'unit_pending' => (acc['unit_pending'] || 0) + unit_pending,
|
106
|
+
'integration_count' => (acc['integration_count'] || 0) + integration_count,
|
107
|
+
'integration_pending' => (acc['integration_pending'] || 0) + integration_pending
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
{
|
112
|
+
feature_name => pyramid.merge(
|
113
|
+
'regression_count' => regression_count,
|
114
|
+
'regression_pending' => regression_pending
|
115
|
+
),
|
116
|
+
**content
|
117
|
+
}
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
sig { returns(FeaturesContent) }
|
122
|
+
def self.load_features!
|
123
|
+
test_coverage_content = YAML.load_file(path)
|
124
|
+
|
125
|
+
return test_coverage_content[FEATURES_KEY] if test_coverage_content.is_a?(Hash) && test_coverage_content[FEATURES_KEY]
|
126
|
+
|
127
|
+
raise FileContentError, "Unexpected content found in #{path}. Use `bin/featuremap test_coverage` to regenerate it and try again."
|
128
|
+
rescue Psych::SyntaxError => e
|
129
|
+
raise FileContentError, "Invalid YAML content found at #{path}. Error: #{e.message} Use `bin/featuremap test_coverage` to generate it and try again."
|
130
|
+
rescue Errno::ENOENT
|
131
|
+
raise FileContentError, "No feature test coverage file found at #{path}. Use `bin/featuremap test_coverage` to generate it and try again."
|
132
|
+
end
|
133
|
+
|
134
|
+
sig { params(examples: T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])).returns(T::Array[Integer]) }
|
135
|
+
def self.split(examples)
|
136
|
+
return [0, 0] if examples.nil?
|
137
|
+
|
138
|
+
examples.partition { |ex| ex['status'] == 'passed' }.map(&:count)
|
139
|
+
end
|
140
|
+
|
141
|
+
sig { params(pathlike: String).returns(String) }
|
142
|
+
def self.filepath(pathlike)
|
143
|
+
File
|
144
|
+
.join(File.dirname(pathlike), File.basename(pathlike, '.*'))
|
145
|
+
.gsub(%r{^\./}, '')
|
146
|
+
.gsub(%r{^spec/}, '')
|
147
|
+
.gsub(%r{^app/}, '')
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module FeatureMap
|
5
|
+
module Private
|
6
|
+
class TodoInspector
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
ENTERING_COMMENT = T.let(
|
10
|
+
/
|
11
|
+
(#{(Constants::SINGLE_LINE_COMMENT_PATTERNS + Constants::MULTILINE_COMMENT_START_PATTERNS).join('|')})
|
12
|
+
/x.freeze,
|
13
|
+
Regexp
|
14
|
+
)
|
15
|
+
|
16
|
+
EXITING_COMMENT = T.let(
|
17
|
+
/
|
18
|
+
(#{(Constants::SINGLE_LINE_COMMENT_PATTERNS + Constants::MULTILINE_COMMENT_END_PATTERNS).join('|')})
|
19
|
+
/x.freeze,
|
20
|
+
Regexp
|
21
|
+
)
|
22
|
+
|
23
|
+
TODO_PATTERN = T.let(
|
24
|
+
/
|
25
|
+
TODO:?\s* # TODO with optional colon with whitespace
|
26
|
+
(?<content>.*?) # The actual TODO content
|
27
|
+
(#{Constants::MULTILINE_COMMENT_END_PATTERNS.join('|')})?$ # ignores comment end patterns
|
28
|
+
/xi.freeze,
|
29
|
+
Regexp
|
30
|
+
)
|
31
|
+
|
32
|
+
sig { params(file_path: String).void }
|
33
|
+
def initialize(file_path)
|
34
|
+
@file_path = file_path
|
35
|
+
end
|
36
|
+
|
37
|
+
sig { returns(T::Hash[String, String]) }
|
38
|
+
def calculate
|
39
|
+
todos = {}
|
40
|
+
content = File.read(@file_path)
|
41
|
+
in_comment = T.let(false, T::Boolean)
|
42
|
+
|
43
|
+
content.each_line.with_index do |line, index|
|
44
|
+
in_comment ||= line.match?(ENTERING_COMMENT)
|
45
|
+
|
46
|
+
if in_comment && (match = line.match(TODO_PATTERN))
|
47
|
+
todos["#{@file_path}:#{index + 1}"] = T.must(match[:content]).strip
|
48
|
+
end
|
49
|
+
|
50
|
+
in_comment &&= !line.match?(EXITING_COMMENT)
|
51
|
+
end
|
52
|
+
|
53
|
+
todos
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# typed: strict
|
2
|
+
|
3
|
+
module FeatureMap
|
4
|
+
module Private
|
5
|
+
module Validations
|
6
|
+
class FeaturesUpToDate
|
7
|
+
extend T::Sig
|
8
|
+
extend T::Helpers
|
9
|
+
include Validator
|
10
|
+
|
11
|
+
sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
|
12
|
+
def validation_errors(files:, autocorrect: true, stage_changes: true)
|
13
|
+
return [] if Private.configuration.skip_features_validation
|
14
|
+
|
15
|
+
actual_content_lines = AssignmentsFile.actual_contents_lines
|
16
|
+
expected_content_lines = AssignmentsFile.expected_contents_lines
|
17
|
+
|
18
|
+
features_file_up_to_date = actual_content_lines == expected_content_lines
|
19
|
+
errors = T.let([], T::Array[String])
|
20
|
+
|
21
|
+
if !features_file_up_to_date
|
22
|
+
if autocorrect
|
23
|
+
AssignmentsFile.write!
|
24
|
+
if stage_changes
|
25
|
+
`git add #{AssignmentsFile.path}`
|
26
|
+
end
|
27
|
+
# If there is no current file or its empty, display a shorter message.
|
28
|
+
elsif actual_content_lines == ['']
|
29
|
+
errors << <<~FEATURES_FILE_ERROR
|
30
|
+
.feature_map/assignments.yml out of date. Run `bin/featuremap validate` to update the .feature_map/assignments.yml file
|
31
|
+
FEATURES_FILE_ERROR
|
32
|
+
else
|
33
|
+
missing_lines = expected_content_lines - actual_content_lines
|
34
|
+
extra_lines = actual_content_lines - expected_content_lines
|
35
|
+
|
36
|
+
missing_lines_text = if missing_lines.any?
|
37
|
+
<<~COMMENT
|
38
|
+
.feature_map/assignments.yml should contain the following lines, but does not:
|
39
|
+
#{missing_lines.map { |line| "- \"#{line}\"" }.join("\n")}
|
40
|
+
COMMENT
|
41
|
+
end
|
42
|
+
|
43
|
+
extra_lines_text = if extra_lines.any?
|
44
|
+
<<~COMMENT
|
45
|
+
.feature_map/assignments.yml should not contain the following lines, but it does:
|
46
|
+
#{extra_lines.map { |line| "- \"#{line}\"" }.join("\n")}
|
47
|
+
COMMENT
|
48
|
+
end
|
49
|
+
|
50
|
+
diff_text = if missing_lines_text && extra_lines_text
|
51
|
+
"#{missing_lines_text}\n#{extra_lines_text}".chomp
|
52
|
+
elsif missing_lines_text
|
53
|
+
missing_lines_text
|
54
|
+
elsif extra_lines_text
|
55
|
+
extra_lines_text
|
56
|
+
else
|
57
|
+
<<~TEXT
|
58
|
+
There may be extra lines, or lines are out of order.
|
59
|
+
You can try to regenerate the .feature_map/assignments.yml file from scratch:
|
60
|
+
1) `rm .feature_map/assignments.yml`
|
61
|
+
2) `bin/featuremap validate`
|
62
|
+
TEXT
|
63
|
+
end
|
64
|
+
|
65
|
+
errors << <<~FEATURES_FILE_ERROR
|
66
|
+
.feature_map/assignments.yml out of date. Run `bin/featuremap validate` to update the .feature_map/assignments.yml file
|
67
|
+
|
68
|
+
#{diff_text.chomp}
|
69
|
+
FEATURES_FILE_ERROR
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
errors
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# typed: strict
|
2
|
+
|
3
|
+
require 'code_ownership'
|
4
|
+
|
5
|
+
module FeatureMap
|
6
|
+
module Private
|
7
|
+
module Validations
|
8
|
+
class FilesHaveFeatures
|
9
|
+
extend T::Sig
|
10
|
+
extend T::Helpers
|
11
|
+
include Validator
|
12
|
+
|
13
|
+
sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
|
14
|
+
def validation_errors(files:, autocorrect: true, stage_changes: true)
|
15
|
+
cache = Private.glob_cache
|
16
|
+
file_mappings = cache.mapper_descriptions_that_map_files(files)
|
17
|
+
files_not_mapped_at_all = file_mappings.select do |_file, mapper_descriptions|
|
18
|
+
mapper_descriptions.count.zero?
|
19
|
+
end
|
20
|
+
|
21
|
+
errors = T.let([], T::Array[String])
|
22
|
+
|
23
|
+
# When a set of teams are configured that require assignments, ignore any files NOT
|
24
|
+
# assigned to one of these teams.
|
25
|
+
unless Private.configuration.require_assignment_for_teams.nil?
|
26
|
+
files_not_mapped_at_all.filter! do |file, _mappers|
|
27
|
+
file_team = CodeOwnership.for_file(file)
|
28
|
+
file_team && T.must(Private.configuration.require_assignment_for_teams).include?(file_team.name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
if files_not_mapped_at_all.any?
|
33
|
+
errors << <<~MSG
|
34
|
+
Some files are missing a feature assignment:
|
35
|
+
|
36
|
+
#{files_not_mapped_at_all.map { |file, _mappers| "- #{file}" }.join("\n")}
|
37
|
+
MSG
|
38
|
+
end
|
39
|
+
|
40
|
+
errors
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# typed: strict
|
2
|
+
|
3
|
+
module FeatureMap
|
4
|
+
module Private
|
5
|
+
module Validations
|
6
|
+
class FilesHaveUniqueFeatures
|
7
|
+
extend T::Sig
|
8
|
+
extend T::Helpers
|
9
|
+
include Validator
|
10
|
+
|
11
|
+
sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
|
12
|
+
def validation_errors(files:, autocorrect: true, stage_changes: true)
|
13
|
+
cache = Private.glob_cache
|
14
|
+
file_mappings = cache.mapper_descriptions_that_map_files(files)
|
15
|
+
files_mapped_by_multiple_mappers = file_mappings.select do |_file, mapper_descriptions|
|
16
|
+
mapper_descriptions.count > 1
|
17
|
+
end
|
18
|
+
|
19
|
+
errors = T.let([], T::Array[String])
|
20
|
+
|
21
|
+
if files_mapped_by_multiple_mappers.any?
|
22
|
+
errors << <<~MSG
|
23
|
+
Feature assignment should only be defined for each file in one way. The following files have had features assigned in multiple ways.
|
24
|
+
|
25
|
+
#{files_mapped_by_multiple_mappers.map { |file, descriptions| "- #{file} (#{descriptions.to_a.join(', ')})" }.join("\n")}
|
26
|
+
MSG
|
27
|
+
end
|
28
|
+
|
29
|
+
errors
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: strict
|
4
|
+
|
5
|
+
require 'feature_map/constants'
|
6
|
+
require 'feature_map/private/extension_loader'
|
7
|
+
require 'feature_map/private/cyclomatic_complexity_calculator'
|
8
|
+
require 'feature_map/private/lines_of_code_calculator'
|
9
|
+
require 'feature_map/private/todo_inspector'
|
10
|
+
require 'feature_map/private/feature_metrics_calculator'
|
11
|
+
require 'feature_map/private/assignments_file'
|
12
|
+
require 'feature_map/private/metrics_file'
|
13
|
+
require 'feature_map/private/glob_cache'
|
14
|
+
require 'feature_map/private/feature_assigner'
|
15
|
+
require 'feature_map/private/documentation_site'
|
16
|
+
require 'feature_map/private/code_cov'
|
17
|
+
require 'feature_map/private/test_coverage_file'
|
18
|
+
require 'feature_map/private/test_pyramid_file'
|
19
|
+
require 'feature_map/private/feature_plugins/assignment'
|
20
|
+
require 'feature_map/private/validations/files_have_features'
|
21
|
+
require 'feature_map/private/validations/features_up_to_date'
|
22
|
+
require 'feature_map/private/validations/files_have_unique_features'
|
23
|
+
require 'feature_map/private/assignment_mappers/file_annotations'
|
24
|
+
require 'feature_map/private/assignment_mappers/feature_globs'
|
25
|
+
require 'feature_map/private/assignment_mappers/directory_assignment'
|
26
|
+
require 'feature_map/private/assignment_mappers/feature_definition_assignment'
|
27
|
+
|
28
|
+
module FeatureMap
|
29
|
+
module Private
|
30
|
+
extend T::Sig
|
31
|
+
|
32
|
+
FeatureName = T.type_alias { String }
|
33
|
+
FileList = T.type_alias { T::Array[String] }
|
34
|
+
FeatureFiles = T.type_alias do
|
35
|
+
T::Hash[
|
36
|
+
FeatureName,
|
37
|
+
FileList
|
38
|
+
]
|
39
|
+
end
|
40
|
+
|
41
|
+
sig { returns(Configuration) }
|
42
|
+
def self.configuration
|
43
|
+
@configuration ||= T.let(@configuration, T.nilable(Configuration))
|
44
|
+
@configuration ||= Configuration.fetch
|
45
|
+
end
|
46
|
+
|
47
|
+
# This is just an alias for `configuration` that makes it more explicit what we're doing instead of just calling `configuration`.
|
48
|
+
# This is necessary because configuration may contain extensions of feature map, so those extensions should be loaded prior to
|
49
|
+
# calling APIs that provide feature assignment information.
|
50
|
+
sig { returns(Configuration) }
|
51
|
+
def self.load_configuration!
|
52
|
+
configuration
|
53
|
+
end
|
54
|
+
|
55
|
+
sig { void }
|
56
|
+
def self.bust_caches!
|
57
|
+
@configuration = nil
|
58
|
+
@tracked_files = nil
|
59
|
+
@glob_cache = nil
|
60
|
+
end
|
61
|
+
|
62
|
+
sig { params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).void }
|
63
|
+
def self.validate!(files:, autocorrect: true, stage_changes: true)
|
64
|
+
AssignmentsFile.update_cache!(files) if AssignmentsFile.use_features_cache?
|
65
|
+
|
66
|
+
errors = Validator.all.flat_map do |validator|
|
67
|
+
validator.validation_errors(
|
68
|
+
files: files,
|
69
|
+
autocorrect: autocorrect,
|
70
|
+
stage_changes: stage_changes
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
if errors.any?
|
75
|
+
errors << 'See https://github.com/Beyond-Finance/feature_map#README.md for more details'
|
76
|
+
raise InvalidFeatureMapConfigurationError.new(errors.join("\n")) # rubocop:disable Style/RaiseArgs
|
77
|
+
end
|
78
|
+
|
79
|
+
MetricsFile.write!
|
80
|
+
end
|
81
|
+
|
82
|
+
sig { params(git_ref: T.nilable(String)).void }
|
83
|
+
def self.generate_docs!(git_ref)
|
84
|
+
feature_assignments = AssignmentsFile.load_features!
|
85
|
+
feature_metrics = MetricsFile.load_features!
|
86
|
+
|
87
|
+
# Generating the test pyramid involves collecting dry-run coverage from rspec for unit, integration,
|
88
|
+
# and regression tests. This can be difficult to gather, so we allow for the docs site to be built
|
89
|
+
# without it.
|
90
|
+
feature_test_pyramid = TestPyramidFile.path.exist? ? TestPyramidFile.load_features! : {}
|
91
|
+
|
92
|
+
# Test coverage data can be onerous to load (e.g. generating a CodeCov token, etc). Allow engineers to generate
|
93
|
+
# and review the feature documentation without this data.
|
94
|
+
feature_test_coverage = TestCoverageFile.path.exist? ? TestCoverageFile.load_features! : {}
|
95
|
+
|
96
|
+
DocumentationSite.generate(
|
97
|
+
feature_assignments,
|
98
|
+
feature_metrics,
|
99
|
+
feature_test_coverage,
|
100
|
+
feature_test_pyramid,
|
101
|
+
configuration.raw_hash,
|
102
|
+
T.must(git_ref || configuration.repository['main_branch'])
|
103
|
+
)
|
104
|
+
end
|
105
|
+
|
106
|
+
sig do
|
107
|
+
params(
|
108
|
+
unit_path: String,
|
109
|
+
integration_path: String,
|
110
|
+
regression_path: String,
|
111
|
+
regression_assignments_path: String
|
112
|
+
).void
|
113
|
+
end
|
114
|
+
def self.generate_test_pyramid!(unit_path, integration_path, regression_path, regression_assignments_path)
|
115
|
+
unit_examples = JSON.parse(File.read(unit_path))&.fetch('examples')
|
116
|
+
integration_examples = JSON.parse(File.read(integration_path))&.fetch('examples')
|
117
|
+
regression_examples = JSON.parse(File.read(regression_path))&.fetch('examples')
|
118
|
+
regression_assignments = YAML.load_file(regression_assignments_path)
|
119
|
+
TestPyramidFile.write!(unit_examples, integration_examples, regression_examples, regression_assignments)
|
120
|
+
end
|
121
|
+
|
122
|
+
sig { params(commit_sha: String, code_cov_token: String).void }
|
123
|
+
def self.gather_test_coverage!(commit_sha, code_cov_token)
|
124
|
+
coverage_stats = CodeCov.fetch_coverage_stats(commit_sha, code_cov_token)
|
125
|
+
|
126
|
+
TestCoverageFile.write!(coverage_stats)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Returns a string version of the relative path to a Rails constant,
|
130
|
+
# or nil if it can't find something
|
131
|
+
sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(String)) }
|
132
|
+
def self.path_from_klass(klass)
|
133
|
+
if klass
|
134
|
+
path = Object.const_source_location(klass.to_s)&.first
|
135
|
+
(path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil
|
136
|
+
else
|
137
|
+
nil
|
138
|
+
end
|
139
|
+
rescue NameError
|
140
|
+
nil
|
141
|
+
end
|
142
|
+
|
143
|
+
#
|
144
|
+
# The output of this function is string pathnames relative to the root.
|
145
|
+
#
|
146
|
+
sig { returns(T::Array[String]) }
|
147
|
+
def self.tracked_files
|
148
|
+
@tracked_files ||= T.let(@tracked_files, T.nilable(T::Array[String]))
|
149
|
+
@tracked_files ||= Dir.glob(configuration.assigned_globs) - Dir.glob(configuration.unassigned_globs)
|
150
|
+
end
|
151
|
+
|
152
|
+
sig { params(file: String).returns(T::Boolean) }
|
153
|
+
def self.file_tracked?(file)
|
154
|
+
# Another way to accomplish this is
|
155
|
+
# (Dir.glob(configuration.assigned_globs) - Dir.glob(configuration.unassigned_globs)).include?(file)
|
156
|
+
# However, globbing out can take 5 or more seconds on a large repository, dramatically slowing down
|
157
|
+
# invocations to `bin/featuremap validate --diff`.
|
158
|
+
# Using `File.fnmatch?` is a lot faster!
|
159
|
+
in_assigned_globs = configuration.assigned_globs.any? do |assigned_glob|
|
160
|
+
File.fnmatch?(assigned_glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
161
|
+
end
|
162
|
+
|
163
|
+
in_unassigned_globs = configuration.unassigned_globs.any? do |unassigned_glob|
|
164
|
+
File.fnmatch?(unassigned_glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
165
|
+
end
|
166
|
+
in_assigned_globs && !in_unassigned_globs && File.exist?(file)
|
167
|
+
end
|
168
|
+
|
169
|
+
sig { params(feature_name: String, location_of_reference: String).returns(CodeFeatures::Feature) }
|
170
|
+
def self.find_feature!(feature_name, location_of_reference)
|
171
|
+
found_feature = CodeFeatures.find(feature_name)
|
172
|
+
if found_feature.nil?
|
173
|
+
raise StandardError, "Could not find feature with name: `#{feature_name}` in #{location_of_reference}. Make sure the feature is one of `#{CodeFeatures.all.map(&:name).sort}`"
|
174
|
+
else
|
175
|
+
found_feature
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
sig { returns(GlobCache) }
|
180
|
+
def self.glob_cache
|
181
|
+
@glob_cache ||= T.let(@glob_cache, T.nilable(GlobCache))
|
182
|
+
@glob_cache ||= if AssignmentsFile.use_features_cache?
|
183
|
+
AssignmentsFile.to_glob_cache
|
184
|
+
else
|
185
|
+
Mapper.to_glob_cache
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
sig { returns(FeatureFiles) }
|
190
|
+
def self.feature_file_assignments
|
191
|
+
glob_cache.raw_cache_contents.values.each_with_object(T.let({}, FeatureFiles)) do |assignment_map_cache, feature_files|
|
192
|
+
assignment_map_cache.to_h.each do |path, feature|
|
193
|
+
feature_files[feature.name] ||= T.let([], FileList)
|
194
|
+
files = Dir.glob(path).reject { |glob_entry| File.directory?(glob_entry) }
|
195
|
+
files.each { |file| T.must(feature_files[feature.name]) << file }
|
196
|
+
end
|
197
|
+
|
198
|
+
feature_files
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
private_constant :Private
|
204
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# typed: strict
|
2
|
+
|
3
|
+
module FeatureMap
|
4
|
+
module Validator
|
5
|
+
extend T::Sig
|
6
|
+
extend T::Helpers
|
7
|
+
|
8
|
+
interface!
|
9
|
+
|
10
|
+
sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
|
11
|
+
def validation_errors(files:, autocorrect: true, stage_changes: true); end
|
12
|
+
|
13
|
+
class << self
|
14
|
+
extend T::Sig
|
15
|
+
|
16
|
+
sig { params(base: T::Class[Validator]).void }
|
17
|
+
def included(base)
|
18
|
+
@validators ||= T.let(@validators, T.nilable(T::Array[T::Class[Validator]]))
|
19
|
+
@validators ||= []
|
20
|
+
@validators << base
|
21
|
+
end
|
22
|
+
|
23
|
+
sig { returns(T::Array[Validator]) }
|
24
|
+
def all
|
25
|
+
(@validators || []).map(&:new)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|