feature_map 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|