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