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,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: strict
|
4
|
+
|
5
|
+
require 'yaml'
|
6
|
+
require 'csv'
|
7
|
+
require 'set'
|
8
|
+
require 'pathname'
|
9
|
+
require 'feature_map/code_features/plugin'
|
10
|
+
require 'feature_map/code_features/plugins/identity'
|
11
|
+
|
12
|
+
module FeatureMap
|
13
|
+
module CodeFeatures
|
14
|
+
extend T::Sig
|
15
|
+
|
16
|
+
NON_BREAKING_SPACE = T.let(65_279.chr(Encoding::UTF_8), String)
|
17
|
+
|
18
|
+
class IncorrectPublicApiUsageError < StandardError; end
|
19
|
+
|
20
|
+
sig { returns(T::Array[Feature]) }
|
21
|
+
def self.all
|
22
|
+
@all = T.let(@all, T.nilable(T::Array[Feature]))
|
23
|
+
@all ||= from_csv('.feature_map/feature_definitions.csv')
|
24
|
+
@all ||= for_directory('.feature_map/definitions')
|
25
|
+
end
|
26
|
+
|
27
|
+
sig { params(name: String).returns(T.nilable(Feature)) }
|
28
|
+
def self.find(name)
|
29
|
+
@index_by_name = T.let(@index_by_name, T.nilable(T::Hash[String, CodeFeatures::Feature]))
|
30
|
+
@index_by_name ||= begin
|
31
|
+
result = {}
|
32
|
+
all.each { |t| result[t.name] = t }
|
33
|
+
result
|
34
|
+
end
|
35
|
+
|
36
|
+
@index_by_name[name]
|
37
|
+
end
|
38
|
+
|
39
|
+
sig { params(file_path: String).returns(T.nilable(T::Array[Feature])) }
|
40
|
+
def self.from_csv(file_path)
|
41
|
+
return nil if !File.exist?(file_path)
|
42
|
+
|
43
|
+
file_lines = File.readlines(file_path)
|
44
|
+
# Remove any non-breaking space characters, as these can throw off the comment handling
|
45
|
+
# and/or attribute key values.
|
46
|
+
csv_content = file_lines.map { |line| line.gsub(NON_BREAKING_SPACE, '') }
|
47
|
+
.reject { |line| line.start_with?('#') }
|
48
|
+
.join.strip
|
49
|
+
|
50
|
+
CSV.parse(csv_content, headers: true).map do |csv_row|
|
51
|
+
feature_data = csv_row.to_h.transform_keys { |column_name| tag_value_for(column_name) }
|
52
|
+
Feature.from_hash(feature_data)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
sig { params(dir: String).returns(T::Array[Feature]) }
|
57
|
+
def self.for_directory(dir)
|
58
|
+
Pathname.new(dir).glob('**/*.yml').map do |path|
|
59
|
+
Feature.from_yml(path.to_s)
|
60
|
+
rescue Psych::SyntaxError
|
61
|
+
raise IncorrectPublicApiUsageError, "The YML in #{path} has a syntax error!"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
sig { params(features: T::Array[Feature]).returns(T::Array[String]) }
|
66
|
+
def self.validation_errors(features)
|
67
|
+
Plugin.all_plugins.flat_map do |plugin|
|
68
|
+
plugin.validation_errors(features)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
sig { params(string: String).returns(String) }
|
73
|
+
def self.tag_value_for(string)
|
74
|
+
string.tr('&', ' ').gsub(/\s+/, '_').downcase
|
75
|
+
end
|
76
|
+
|
77
|
+
# Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change.
|
78
|
+
# Namely, the YML files that are the source of truth for features should not change, so we should not need to look at the YMLs again to verify.
|
79
|
+
# The primary reason this is helpful is for tests where each context is testing against a different set of features
|
80
|
+
sig { void }
|
81
|
+
def self.bust_caches!
|
82
|
+
Plugin.bust_caches!
|
83
|
+
@all = nil
|
84
|
+
@index_by_name = nil
|
85
|
+
end
|
86
|
+
|
87
|
+
class Feature
|
88
|
+
extend T::Sig
|
89
|
+
|
90
|
+
sig { params(config_yml: String).returns(Feature) }
|
91
|
+
def self.from_yml(config_yml)
|
92
|
+
hash = YAML.load_file(config_yml)
|
93
|
+
|
94
|
+
new(
|
95
|
+
config_yml: config_yml,
|
96
|
+
raw_hash: hash
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(Feature) }
|
101
|
+
def self.from_hash(raw_hash)
|
102
|
+
new(
|
103
|
+
config_yml: nil,
|
104
|
+
raw_hash: raw_hash
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
sig { returns(T::Hash[T.untyped, T.untyped]) }
|
109
|
+
attr_reader :raw_hash
|
110
|
+
|
111
|
+
sig { returns(T.nilable(String)) }
|
112
|
+
attr_reader :config_yml
|
113
|
+
|
114
|
+
sig do
|
115
|
+
params(
|
116
|
+
config_yml: T.nilable(String),
|
117
|
+
raw_hash: T::Hash[T.untyped, T.untyped]
|
118
|
+
).void
|
119
|
+
end
|
120
|
+
def initialize(config_yml:, raw_hash:)
|
121
|
+
@config_yml = config_yml
|
122
|
+
@raw_hash = raw_hash
|
123
|
+
end
|
124
|
+
|
125
|
+
sig { returns(String) }
|
126
|
+
def name
|
127
|
+
Plugins::Identity.for(self).identity.name
|
128
|
+
end
|
129
|
+
|
130
|
+
sig { returns(String) }
|
131
|
+
def to_tag
|
132
|
+
CodeFeatures.tag_value_for(name)
|
133
|
+
end
|
134
|
+
|
135
|
+
sig { params(other: Object).returns(T::Boolean) }
|
136
|
+
def ==(other)
|
137
|
+
if other.is_a?(CodeFeatures::Feature)
|
138
|
+
name == other.name
|
139
|
+
else
|
140
|
+
false
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
alias eql? ==
|
145
|
+
|
146
|
+
sig { returns(Integer) }
|
147
|
+
def hash
|
148
|
+
name.hash
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# typed: strict
|
2
|
+
|
3
|
+
module FeatureMap
|
4
|
+
class Configuration < T::Struct
|
5
|
+
extend T::Sig
|
6
|
+
|
7
|
+
const :assigned_globs, T::Array[String]
|
8
|
+
const :unassigned_globs, T::Array[String]
|
9
|
+
const :unbuilt_gems_path, T.nilable(String)
|
10
|
+
const :skip_features_validation, T::Boolean
|
11
|
+
const :raw_hash, T::Hash[T.untyped, T.untyped]
|
12
|
+
const :skip_code_ownership, T::Boolean
|
13
|
+
const :require_assignment_for_teams, T.nilable(T::Array[String])
|
14
|
+
const :ignore_feature_definitions, T::Boolean
|
15
|
+
const :code_cov, T::Hash[String, T.nilable(String)]
|
16
|
+
const :repository, T::Hash[String, T.nilable(String)]
|
17
|
+
const :documentation_site, T::Hash[String, T.untyped]
|
18
|
+
|
19
|
+
sig { returns(Configuration) }
|
20
|
+
def self.fetch
|
21
|
+
config_hash = YAML.load_file('.feature_map/config.yml')
|
22
|
+
|
23
|
+
if config_hash.key?('require')
|
24
|
+
config_hash['require'].each do |require_directive|
|
25
|
+
Private::ExtensionLoader.load(require_directive)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
new(
|
30
|
+
assigned_globs: config_hash.fetch('assigned_globs', []),
|
31
|
+
unassigned_globs: config_hash.fetch('unassigned_globs', []),
|
32
|
+
skip_features_validation: config_hash.fetch('skip_features_validation', false),
|
33
|
+
raw_hash: config_hash,
|
34
|
+
skip_code_ownership: config_hash.fetch('skip_code_ownership', true),
|
35
|
+
require_assignment_for_teams: config_hash.fetch('require_assignment_for_teams', nil),
|
36
|
+
ignore_feature_definitions: config_hash.fetch('ignore_feature_definitions', false),
|
37
|
+
code_cov: config_hash.fetch('code_cov', {}),
|
38
|
+
repository: config_hash.fetch('repository', {}),
|
39
|
+
documentation_site: config_hash.fetch('documentation_site', {})
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: strict
|
4
|
+
|
5
|
+
module FeatureMap
|
6
|
+
module Constants
|
7
|
+
SINGLE_LINE_COMMENT_PATTERNS = T.let(['#', '//'].map { |r| Regexp.escape(r) }.freeze, T::Array[String])
|
8
|
+
MULTILINE_COMMENT_START_PATTERNS = T.let(['/*', '<!--', '"""', "'''"].map { |r| Regexp.escape(r) }.freeze, T::Array[String])
|
9
|
+
MULTILINE_COMMENT_END_PATTERNS = T.let(['*/', '-->', '"""', "'''"].map { |r| Regexp.escape(r) }.freeze, T::Array[String])
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: strict
|
4
|
+
|
5
|
+
module FeatureMap
|
6
|
+
module Mapper
|
7
|
+
extend T::Sig
|
8
|
+
extend T::Helpers
|
9
|
+
|
10
|
+
interface!
|
11
|
+
|
12
|
+
class << self
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
sig { params(base: T::Class[Mapper]).void }
|
16
|
+
def included(base)
|
17
|
+
@mappers ||= T.let(@mappers, T.nilable(T::Array[T::Class[Mapper]]))
|
18
|
+
@mappers ||= []
|
19
|
+
@mappers << base
|
20
|
+
end
|
21
|
+
|
22
|
+
sig { returns(T::Array[Mapper]) }
|
23
|
+
def all
|
24
|
+
(@mappers || []).map(&:new)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
#
|
29
|
+
# This should be fast when run with ONE file
|
30
|
+
#
|
31
|
+
sig do
|
32
|
+
abstract.params(file: String)
|
33
|
+
.returns(T.nilable(CodeFeatures::Feature))
|
34
|
+
end
|
35
|
+
def map_file_to_feature(file); end
|
36
|
+
|
37
|
+
#
|
38
|
+
# This should be fast when run with MANY files
|
39
|
+
#
|
40
|
+
sig do
|
41
|
+
abstract.params(files: T::Array[String])
|
42
|
+
.returns(T::Hash[String, CodeFeatures::Feature])
|
43
|
+
end
|
44
|
+
def globs_to_feature(files); end
|
45
|
+
|
46
|
+
#
|
47
|
+
# This should be fast when run with MANY files
|
48
|
+
#
|
49
|
+
sig do
|
50
|
+
abstract.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
|
51
|
+
end
|
52
|
+
def update_cache(cache, files); end
|
53
|
+
|
54
|
+
sig { abstract.returns(String) }
|
55
|
+
def description; end
|
56
|
+
|
57
|
+
sig { abstract.void }
|
58
|
+
def bust_caches!; end
|
59
|
+
|
60
|
+
sig { returns(Private::GlobCache) }
|
61
|
+
def self.to_glob_cache
|
62
|
+
glob_to_feature_map_by_mapper_description = {}
|
63
|
+
|
64
|
+
Mapper.all.each do |mapper|
|
65
|
+
mapped_files = mapper.globs_to_feature(Private.tracked_files)
|
66
|
+
glob_to_feature_map_by_mapper_description[mapper.description] ||= {}
|
67
|
+
|
68
|
+
mapped_files.each do |glob, feature|
|
69
|
+
next if feature.nil?
|
70
|
+
|
71
|
+
glob_to_feature_map_by_mapper_description.fetch(mapper.description)[glob] = feature
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
Private::GlobCache.new(glob_to_feature_map_by_mapper_description)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: strict
|
4
|
+
|
5
|
+
module OutputColor
|
6
|
+
extend T::Sig
|
7
|
+
|
8
|
+
sig { params(color_code: Integer, text: String).returns(String) }
|
9
|
+
def self.colorize(color_code, text)
|
10
|
+
"\e[#{color_code}m#{text}\e[0m"
|
11
|
+
end
|
12
|
+
|
13
|
+
sig { params(text: String).returns(String) }
|
14
|
+
def self.red(text)
|
15
|
+
colorize(31, text)
|
16
|
+
end
|
17
|
+
|
18
|
+
sig { params(text: String).returns(String) }
|
19
|
+
def self.green(text)
|
20
|
+
colorize(32, text)
|
21
|
+
end
|
22
|
+
|
23
|
+
sig { params(text: String).returns(String) }
|
24
|
+
def self.yellow(text)
|
25
|
+
colorize(33, text)
|
26
|
+
end
|
27
|
+
|
28
|
+
sig { params(text: String).returns(String) }
|
29
|
+
def self.blue(text)
|
30
|
+
colorize(34, text)
|
31
|
+
end
|
32
|
+
|
33
|
+
sig { params(text: String).returns(String) }
|
34
|
+
def self.pink(text)
|
35
|
+
colorize(35, text)
|
36
|
+
end
|
37
|
+
|
38
|
+
sig { params(text: String).returns(String) }
|
39
|
+
def self.light_blue(text)
|
40
|
+
colorize(36, text)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: true
|
4
|
+
|
5
|
+
module FeatureMap
|
6
|
+
module Private
|
7
|
+
module AssignmentMappers
|
8
|
+
class DirectoryAssignment
|
9
|
+
extend T::Sig
|
10
|
+
include Mapper
|
11
|
+
|
12
|
+
FEATURE_DIRECTORY_ASSIGNMENT_FILE_NAME = '.feature'
|
13
|
+
|
14
|
+
@@directory_cache = T.let({}, T::Hash[String, T.nilable(CodeFeatures::Feature)]) # rubocop:disable Style/ClassVars
|
15
|
+
|
16
|
+
sig do
|
17
|
+
override.params(file: String)
|
18
|
+
.returns(T.nilable(CodeFeatures::Feature))
|
19
|
+
end
|
20
|
+
def map_file_to_feature(file)
|
21
|
+
map_file_to_relevant_feature(file)
|
22
|
+
end
|
23
|
+
|
24
|
+
sig do
|
25
|
+
override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
|
26
|
+
end
|
27
|
+
def update_cache(cache, files)
|
28
|
+
globs_to_feature(files)
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# Directory assignment ignores the passed in files when generating feature assignment lines.
|
33
|
+
# This is because directory assignment knows that the fastest way to find features for directory based assignment
|
34
|
+
# is to simply iterate over the directories and grab the feature, rather than iterating over each file just to get what directory it is in
|
35
|
+
# In theory this means that we may generate feature lines that cover files that are not in the passed in argument,
|
36
|
+
# but in practice this is not of consequence because in reality we never really want to generate feature assignments for only a
|
37
|
+
# subset of files, but rather we want feature assignments for all files.
|
38
|
+
#
|
39
|
+
sig do
|
40
|
+
override.params(files: T::Array[String])
|
41
|
+
.returns(T::Hash[String, CodeFeatures::Feature])
|
42
|
+
end
|
43
|
+
def globs_to_feature(files)
|
44
|
+
# The T.unsafe is because the upstream RBI is wrong for Pathname.glob
|
45
|
+
T
|
46
|
+
.unsafe(Pathname)
|
47
|
+
.glob(File.join('**/', FEATURE_DIRECTORY_ASSIGNMENT_FILE_NAME))
|
48
|
+
.map(&:cleanpath)
|
49
|
+
.each_with_object({}) do |pathname, res|
|
50
|
+
feature = feature_for_directory_assignment_file(pathname)
|
51
|
+
glob = glob_for_directory_assignment_file(pathname)
|
52
|
+
res[glob] = feature
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
sig { override.returns(String) }
|
57
|
+
def description
|
58
|
+
'Feature Assigned in .feature'
|
59
|
+
end
|
60
|
+
|
61
|
+
sig { override.void }
|
62
|
+
def bust_caches!
|
63
|
+
@@directory_cache = {} # rubocop:disable Style/ClassVars
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
sig { params(file: Pathname).returns(CodeFeatures::Feature) }
|
69
|
+
def feature_for_directory_assignment_file(file)
|
70
|
+
raw_feature_value = File.foreach(file).first.strip
|
71
|
+
|
72
|
+
Private.find_feature!(
|
73
|
+
raw_feature_value,
|
74
|
+
file.to_s
|
75
|
+
)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Takes a file and finds the relevant `.feature` file by walking up the directory
|
79
|
+
# structure. Example, given `a/b/c.rb`, this looks for `a/b/.feature`, `a/.feature`,
|
80
|
+
# and `.feature` in that order, stopping at the first file to actually exist.
|
81
|
+
# If the provided file is a directory, it will look for `.feature` in that directory and then upwards.
|
82
|
+
# We do additional caching so that we don't have to check for file existence every time.
|
83
|
+
sig { params(file: String).returns(T.nilable(CodeFeatures::Feature)) }
|
84
|
+
def map_file_to_relevant_feature(file)
|
85
|
+
file_path = Pathname.new(file)
|
86
|
+
feature = T.let(nil, T.nilable(CodeFeatures::Feature))
|
87
|
+
|
88
|
+
if File.directory?(file)
|
89
|
+
feature = get_feature_from_assignment_file_within_directory(file_path)
|
90
|
+
return feature unless feature.nil?
|
91
|
+
end
|
92
|
+
|
93
|
+
path_components = file_path.each_filename.to_a
|
94
|
+
if file_path.absolute?
|
95
|
+
path_components = ['/', *path_components]
|
96
|
+
end
|
97
|
+
|
98
|
+
(path_components.length - 1).downto(0).each do |i|
|
99
|
+
feature = get_feature_from_assignment_file_within_directory(
|
100
|
+
Pathname.new(File.join(*T.unsafe(path_components[0...i])))
|
101
|
+
)
|
102
|
+
return feature unless feature.nil?
|
103
|
+
end
|
104
|
+
|
105
|
+
feature
|
106
|
+
end
|
107
|
+
|
108
|
+
sig { params(directory: Pathname).returns(T.nilable(CodeFeatures::Feature)) }
|
109
|
+
def get_feature_from_assignment_file_within_directory(directory)
|
110
|
+
potential_directory_assignment_file = directory.join(FEATURE_DIRECTORY_ASSIGNMENT_FILE_NAME)
|
111
|
+
|
112
|
+
potential_directory_assignment_file_name = potential_directory_assignment_file.to_s
|
113
|
+
|
114
|
+
feature = nil
|
115
|
+
if @@directory_cache.key?(potential_directory_assignment_file_name)
|
116
|
+
feature = @@directory_cache[potential_directory_assignment_file_name]
|
117
|
+
elsif potential_directory_assignment_file.exist?
|
118
|
+
feature = feature_for_directory_assignment_file(potential_directory_assignment_file)
|
119
|
+
|
120
|
+
@@directory_cache[potential_directory_assignment_file_name] = feature
|
121
|
+
else
|
122
|
+
@@directory_cache[potential_directory_assignment_file_name] = nil
|
123
|
+
end
|
124
|
+
|
125
|
+
feature
|
126
|
+
end
|
127
|
+
|
128
|
+
sig { params(file: Pathname).returns(String) }
|
129
|
+
def glob_for_directory_assignment_file(file)
|
130
|
+
unescaped = file.dirname.cleanpath.join('**/**').to_s
|
131
|
+
|
132
|
+
# Globs can contain certain regex characters, like "[" and "]".
|
133
|
+
# However, when we are generating a glob from a .feature file, we
|
134
|
+
# need to escape bracket characters and interpret them literally.
|
135
|
+
# Otherwise the resulting glob will not actually match the directory
|
136
|
+
# containing the .feature file.
|
137
|
+
#
|
138
|
+
# Example
|
139
|
+
# file: "/some/[dir]/.feature"
|
140
|
+
# unescaped: "/some/[dir]/**/**"
|
141
|
+
# matches: "/some/d/file"
|
142
|
+
# matches: "/some/i/file"
|
143
|
+
# matches: "/some/r/file"
|
144
|
+
# does not match!: "/some/[dir]/file"
|
145
|
+
unescaped.gsub(/[\[\]]/) { |x| "\\#{x}" }
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: true
|
4
|
+
|
5
|
+
module FeatureMap
|
6
|
+
module Private
|
7
|
+
module AssignmentMappers
|
8
|
+
class FeatureDefinitionAssignment
|
9
|
+
extend T::Sig
|
10
|
+
include Mapper
|
11
|
+
|
12
|
+
@@map_files_to_features = T.let(@map_files_to_features, T.nilable(T::Hash[String, CodeFeatures::Feature])) # rubocop:disable Style/ClassVars
|
13
|
+
@@map_files_to_features = {} # rubocop:disable Style/ClassVars
|
14
|
+
|
15
|
+
sig do
|
16
|
+
params(files: T::Array[String])
|
17
|
+
.returns(T::Hash[String, CodeFeatures::Feature])
|
18
|
+
end
|
19
|
+
def map_files_to_features(files)
|
20
|
+
return @@map_files_to_features if @@map_files_to_features&.keys && @@map_files_to_features.keys.count.positive?
|
21
|
+
|
22
|
+
@@map_files_to_features = CodeFeatures.all.each_with_object({}) do |feature, map| # rubocop:disable Style/ClassVars
|
23
|
+
map[feature.config_yml] = feature
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
sig do
|
28
|
+
override.params(file: String)
|
29
|
+
.returns(T.nilable(CodeFeatures::Feature))
|
30
|
+
end
|
31
|
+
def map_file_to_feature(file)
|
32
|
+
return nil if Private.configuration.ignore_feature_definitions
|
33
|
+
|
34
|
+
map_files_to_features([file])[file]
|
35
|
+
end
|
36
|
+
|
37
|
+
sig do
|
38
|
+
override.params(files: T::Array[String])
|
39
|
+
.returns(T::Hash[String, CodeFeatures::Feature])
|
40
|
+
end
|
41
|
+
def globs_to_feature(files)
|
42
|
+
return {} if Private.configuration.ignore_feature_definitions
|
43
|
+
|
44
|
+
CodeFeatures.all.each_with_object({}) do |feature, map|
|
45
|
+
map[feature.config_yml] = feature
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
sig { override.void }
|
50
|
+
def bust_caches!
|
51
|
+
@@map_files_to_features = {} # rubocop:disable Style/ClassVars
|
52
|
+
end
|
53
|
+
|
54
|
+
sig do
|
55
|
+
override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
|
56
|
+
end
|
57
|
+
def update_cache(cache, files)
|
58
|
+
globs_to_feature(files)
|
59
|
+
end
|
60
|
+
|
61
|
+
sig { override.returns(String) }
|
62
|
+
def description
|
63
|
+
'Feature definition file assignment'
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: true
|
4
|
+
|
5
|
+
module FeatureMap
|
6
|
+
module Private
|
7
|
+
module AssignmentMappers
|
8
|
+
class FeatureGlobs
|
9
|
+
extend T::Sig
|
10
|
+
include Mapper
|
11
|
+
include Validator
|
12
|
+
|
13
|
+
@@map_files_to_features = T.let(@map_files_to_features, T.nilable(T::Hash[String, FeatureMap::CodeFeatures::Feature])) # rubocop:disable Style/ClassVars
|
14
|
+
@@map_files_to_features = {} # rubocop:disable Style/ClassVars
|
15
|
+
|
16
|
+
sig do
|
17
|
+
params(files: T::Array[String])
|
18
|
+
.returns(T::Hash[String, FeatureMap::CodeFeatures::Feature])
|
19
|
+
end
|
20
|
+
def map_files_to_features(files)
|
21
|
+
return @@map_files_to_features if @@map_files_to_features&.keys && @@map_files_to_features.keys.count.positive?
|
22
|
+
|
23
|
+
@@map_files_to_features = FeatureMap::CodeFeatures.all.each_with_object({}) do |feature, map| # rubocop:disable Style/ClassVars
|
24
|
+
FeaturePlugins::Assignment.for(feature).assigned_globs.each do |glob|
|
25
|
+
Dir.glob(glob).each do |filename|
|
26
|
+
map[filename] = feature
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class MappingContext < T::Struct
|
33
|
+
const :glob, String
|
34
|
+
const :feature, FeatureMap::CodeFeatures::Feature
|
35
|
+
end
|
36
|
+
|
37
|
+
class GlobOverlap < T::Struct
|
38
|
+
extend T::Sig
|
39
|
+
|
40
|
+
const :mapping_contexts, T::Array[MappingContext]
|
41
|
+
|
42
|
+
sig { returns(String) }
|
43
|
+
def description
|
44
|
+
# These are sorted only to prevent non-determinism in output between local and CI environments.
|
45
|
+
sorted_contexts = mapping_contexts.sort_by { |context| context.feature.config_yml.to_s }
|
46
|
+
description_args = sorted_contexts.map do |context|
|
47
|
+
"`#{context.glob}` (from `#{context.feature.config_yml}`)"
|
48
|
+
end
|
49
|
+
|
50
|
+
description_args.join(', ')
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
sig do
|
55
|
+
returns(T::Array[GlobOverlap])
|
56
|
+
end
|
57
|
+
def find_overlapping_globs
|
58
|
+
mapped_files = T.let({}, T::Hash[String, T::Array[MappingContext]])
|
59
|
+
FeatureMap::CodeFeatures.all.each_with_object({}) do |feature, _map|
|
60
|
+
FeaturePlugins::Assignment.for(feature).assigned_globs.each do |glob|
|
61
|
+
Dir.glob(glob).each do |filename|
|
62
|
+
mapped_files[filename] ||= []
|
63
|
+
T.must(mapped_files[filename]) << MappingContext.new(glob: glob, feature: feature)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
overlaps = T.let([], T::Array[GlobOverlap])
|
69
|
+
mapped_files.each_value do |mapping_contexts|
|
70
|
+
if mapping_contexts.count > 1
|
71
|
+
overlaps << GlobOverlap.new(mapping_contexts: mapping_contexts)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
overlaps.uniq do |glob_overlap|
|
76
|
+
glob_overlap.mapping_contexts.map do |context|
|
77
|
+
[context.glob, context.feature.name]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
sig do
|
83
|
+
override.params(file: String)
|
84
|
+
.returns(T.nilable(FeatureMap::CodeFeatures::Feature))
|
85
|
+
end
|
86
|
+
def map_file_to_feature(file)
|
87
|
+
map_files_to_features([file])[file]
|
88
|
+
end
|
89
|
+
|
90
|
+
sig do
|
91
|
+
override.params(cache: GlobsToAssignedFeatureMap, files: T::Array[String]).returns(GlobsToAssignedFeatureMap)
|
92
|
+
end
|
93
|
+
def update_cache(cache, files)
|
94
|
+
globs_to_feature(files)
|
95
|
+
end
|
96
|
+
|
97
|
+
sig do
|
98
|
+
override.params(files: T::Array[String])
|
99
|
+
.returns(T::Hash[String, FeatureMap::CodeFeatures::Feature])
|
100
|
+
end
|
101
|
+
def globs_to_feature(files)
|
102
|
+
FeatureMap::CodeFeatures.all.each_with_object({}) do |feature, map|
|
103
|
+
FeaturePlugins::Assignment.for(feature).assigned_globs.each do |assigned_glob|
|
104
|
+
map[assigned_glob] = feature
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
sig { override.void }
|
110
|
+
def bust_caches!
|
111
|
+
@@map_files_to_features = {} # rubocop:disable Style/ClassVars
|
112
|
+
end
|
113
|
+
|
114
|
+
sig { override.returns(String) }
|
115
|
+
def description
|
116
|
+
'Feature-specific assigned globs'
|
117
|
+
end
|
118
|
+
|
119
|
+
sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
|
120
|
+
def validation_errors(files:, autocorrect: true, stage_changes: true)
|
121
|
+
overlapping_globs = AssignmentMappers::FeatureGlobs.new.find_overlapping_globs
|
122
|
+
|
123
|
+
errors = T.let([], T::Array[String])
|
124
|
+
|
125
|
+
if overlapping_globs.any?
|
126
|
+
errors << <<~MSG
|
127
|
+
`assigned_globs` cannot overlap between features. The following globs overlap:
|
128
|
+
|
129
|
+
#{overlapping_globs.map { |overlap| "- #{overlap.description}" }.join("\n")}
|
130
|
+
MSG
|
131
|
+
end
|
132
|
+
|
133
|
+
errors
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|