yard-lint 1.11.0 → 1.13.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -498
  3. data/README.md +103 -1
  4. data/bin/yard-lint +100 -40
  5. data/lib/yard/lint/config.rb +81 -5
  6. data/lib/yard/lint/config_validator.rb +7 -191
  7. data/lib/yard/lint/contractable/contract.rb +219 -0
  8. data/lib/yard/lint/contractable/result.rb +93 -0
  9. data/lib/yard/lint/contractable/rule.rb +10 -0
  10. data/lib/yard/lint/contracts/base.rb +57 -0
  11. data/lib/yard/lint/contracts/category.rb +71 -0
  12. data/lib/yard/lint/contracts/config.rb +81 -0
  13. data/lib/yard/lint/contracts/validator.rb +72 -0
  14. data/lib/yard/lint/errors.rb +3 -0
  15. data/lib/yard/lint/executor/in_process_registry.rb +1 -1
  16. data/lib/yard/lint/explainer.rb +205 -0
  17. data/lib/yard/lint/formatters/sarif.rb +218 -0
  18. data/lib/yard/lint/git.rb +5 -3
  19. data/lib/yard/lint/result_builder.rb +7 -6
  20. data/lib/yard/lint/runner.rb +2 -21
  21. data/lib/yard/lint/templates/default_config.yml +10 -0
  22. data/lib/yard/lint/templates/strict_config.yml +10 -0
  23. data/lib/yard/lint/todo_generator.rb +143 -12
  24. data/lib/yard/lint/validators/base.rb +63 -0
  25. data/lib/yard/lint/validators/documentation/reference_typo/config.rb +20 -0
  26. data/lib/yard/lint/validators/documentation/reference_typo/messages_builder.rb +24 -0
  27. data/lib/yard/lint/validators/documentation/reference_typo/parser.rb +44 -0
  28. data/lib/yard/lint/validators/documentation/reference_typo/result.rb +27 -0
  29. data/lib/yard/lint/validators/documentation/reference_typo/validator.rb +214 -0
  30. data/lib/yard/lint/validators/documentation/reference_typo.rb +39 -0
  31. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/parser.rb +13 -12
  32. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/validator.rb +84 -5
  33. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods.rb +16 -3
  34. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +3 -2
  35. data/lib/yard/lint/validators/tags/example_style/validator.rb +4 -4
  36. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +4 -2
  37. data/lib/yard/lint/validators/tags/invalid_types/messages_builder.rb +15 -4
  38. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +18 -7
  39. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +22 -12
  40. data/lib/yard/lint/validators/tags/overload_consistency/config.rb +20 -0
  41. data/lib/yard/lint/validators/tags/overload_consistency/messages_builder.rb +32 -0
  42. data/lib/yard/lint/validators/tags/overload_consistency/parser.rb +49 -0
  43. data/lib/yard/lint/validators/tags/overload_consistency/result.rb +27 -0
  44. data/lib/yard/lint/validators/tags/overload_consistency/validator.rb +71 -0
  45. data/lib/yard/lint/validators/tags/overload_consistency.rb +53 -0
  46. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +3 -3
  47. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +8 -0
  48. data/lib/yard/lint/validators/tags/tag_separator/validator.rb +8 -0
  49. data/lib/yard/lint/validators/tags/type_syntax/parser.rb +13 -2
  50. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +11 -1
  51. data/lib/yard/lint/version.rb +1 -1
  52. data/lib/yard/lint.rb +3 -57
  53. metadata +23 -2
@@ -4,23 +4,9 @@ module Yard
4
4
  module Lint
5
5
  # Validates configuration structure and values to catch typos and invalid settings
6
6
  class ConfigValidator
7
- # Valid category names (from ConfigUpdater::CATEGORY_ORDER)
8
- VALID_CATEGORIES = %w[Documentation Tags Warnings Semantic].freeze
9
-
10
- # Keys that are valid at the root level but are not validators
11
- SPECIAL_KEYS = %w[
12
- AllValidators
13
- inherit_from
14
- inherit_gem
15
- ].freeze
16
-
17
- # Valid boolean values
18
- BOOLEAN_VALUES = [true, false].freeze
19
-
20
7
  # @param raw_config [Hash] raw configuration hash to validate
21
8
  def initialize(raw_config)
22
9
  @raw_config = raw_config
23
- @errors = []
24
10
  end
25
11
 
26
12
  # Validate configuration and raise error if invalid
@@ -36,191 +22,21 @@ module Yard
36
22
  # @return [void]
37
23
  # @raise [Errors::InvalidConfigError] if configuration is invalid
38
24
  def validate!
39
- validate_root_keys!
40
- validate_global_settings!
41
- validate_validators!
25
+ result = Contracts::Config.new.call(@raw_config)
42
26
 
43
- return if @errors.empty?
27
+ return if result.success?
44
28
 
45
- raise Errors::InvalidConfigError, build_error_message
29
+ raise Errors::InvalidConfigError, build_error_message(result.errors.values)
46
30
  end
47
31
 
48
32
  private
49
33
 
50
- # Validate root-level keys in configuration
51
- def validate_root_keys!
52
- @raw_config.each_key do |key|
53
- # Skip special keys
54
- next if SPECIAL_KEYS.include?(key)
55
-
56
- # Skip category-level configs (e.g., 'Documentation', 'Tags')
57
- next if VALID_CATEGORIES.include?(key)
58
-
59
- # Validator names must contain a '/' (category/name format)
60
- next if key.include?('/')
61
-
62
- # If we get here, it's an unknown validator (missing category prefix)
63
- @errors << "Unknown validator: '#{key}'"
64
- suggest_validator_name(key)
65
- end
66
- end
67
-
68
- # Validate AllValidators section
69
- def validate_global_settings!
70
- all_validators = @raw_config['AllValidators']
71
- return unless all_validators
72
-
73
- unless all_validators.is_a?(Hash)
74
- @errors << "Invalid AllValidators: must be a Hash, got #{all_validators.class}"
75
- return
76
- end
77
-
78
- # Only validate specific known settings, allow unknown keys to pass through
79
- # This allows users to put custom data in AllValidators
80
-
81
- # Validate FailOnSeverity value
82
- fail_on = all_validators['FailOnSeverity']
83
- if fail_on && !Config::VALID_SEVERITIES.include?(fail_on.to_s)
84
- @errors << "Invalid FailOnSeverity: '#{fail_on}'"
85
- @errors << " Valid values: #{Config::VALID_SEVERITIES.join(', ')}"
86
- end
87
-
88
- # Validate MinCoverage value
89
- min_cov = all_validators['MinCoverage']
90
- if min_cov && (!min_cov.is_a?(Numeric) || min_cov < 0 || min_cov > 100)
91
- @errors << "Invalid MinCoverage: '#{min_cov}'"
92
- @errors << ' Must be a number between 0 and 100'
93
- end
94
-
95
- # Validate Exclude is an array
96
- exclude = all_validators['Exclude']
97
- if exclude && !exclude.is_a?(Array)
98
- @errors << "Invalid Exclude in AllValidators: must be an array, got #{exclude.class}"
99
- end
100
-
101
- # Validate YardOptions is an array
102
- yard_options = all_validators['YardOptions']
103
- if yard_options && !yard_options.is_a?(Array)
104
- @errors << "Invalid YardOptions in AllValidators: must be an array, got #{yard_options.class}"
105
- end
106
- end
107
-
108
- # Validate validator-specific configurations
109
- def validate_validators!
110
- @raw_config.each do |key, value|
111
- # Only process validator keys (containing '/')
112
- next unless key.include?('/')
113
-
114
- unless value.is_a?(Hash)
115
- @errors << "Invalid configuration for validator '#{key}': expected a Hash, got #{value.class}"
116
- next
117
- end
118
-
119
- validate_validator_exists!(key)
120
- validate_validator_config!(key, value)
121
- end
122
- end
123
-
124
- # Check if validator name exists
125
- # @param validator_name [String] validator name to check
126
- # @return [void]
127
- def validate_validator_exists!(validator_name)
128
- return if ConfigLoader::ALL_VALIDATORS.include?(validator_name)
129
-
130
- @errors << "Unknown validator: '#{validator_name}'"
131
- suggest_validator_name(validator_name)
132
- end
133
-
134
- # Validate individual validator configuration
135
- # @param validator_name [String] validator name
136
- # @param config [Hash] validator configuration hash
137
- # @return [void]
138
- def validate_validator_config!(validator_name, config)
139
- # Validate Enabled value
140
- enabled = config['Enabled']
141
- if enabled && !BOOLEAN_VALUES.include?(enabled)
142
- @errors << "Invalid Enabled value for #{validator_name}: '#{enabled}'"
143
- @errors << ' Must be true or false'
144
- end
145
-
146
- # Validate Severity value
147
- severity = config['Severity']
148
- if severity && !Config::VALID_SEVERITIES.include?(severity.to_s)
149
- @errors << "Invalid Severity for #{validator_name}: '#{severity}'"
150
- @errors << " Valid values: #{Config::VALID_SEVERITIES.join(', ')}"
151
- suggest_similar_severity(severity.to_s)
152
- end
153
-
154
- # Validate Exclude is an array
155
- exclude = config['Exclude']
156
- if exclude && !exclude.is_a?(Array)
157
- @errors << "Invalid Exclude for #{validator_name}: must be an array, got #{exclude.class}"
158
- end
159
-
160
- # Validate YardOptions is an array
161
- yard_options = config['YardOptions']
162
- if yard_options && !yard_options.is_a?(Array)
163
- @errors << "Invalid YardOptions for #{validator_name}: must be an array, got #{yard_options.class}"
164
- end
165
-
166
- # Check for unknown validator-specific keys
167
- validate_validator_specific_keys!(validator_name, config)
168
- end
169
-
170
- # Validate validator-specific configuration keys
171
- # @param validator_name [String] validator name
172
- # @param config [Hash] validator configuration hash
173
- # @return [void]
174
- def validate_validator_specific_keys!(validator_name, config)
175
- # Skip if validator doesn't exist (already reported)
176
- return unless ConfigLoader::ALL_VALIDATORS.include?(validator_name)
177
-
178
- validator_config_class = ConfigLoader.validator_config(validator_name)
179
- return unless validator_config_class
180
-
181
- # Base config keys that are valid for all validators
182
- base_keys = %w[Enabled Severity Exclude YardOptions]
183
- valid_keys = validator_config_class.defaults.keys + Config::METADATA_KEYS + base_keys
184
-
185
- config.each_key do |key|
186
- next if valid_keys.include?(key)
187
-
188
- @errors << "Unknown configuration key for #{validator_name}: '#{key}'"
189
- @errors << " Valid keys: #{valid_keys.uniq.sort.join(', ')}"
190
- end
191
- end
192
-
193
- # Suggest similar validator names using did_you_mean
194
- # @param invalid_name [String] invalid validator name
195
- # @return [void]
196
- def suggest_validator_name(invalid_name)
197
- checker = DidYouMean::SpellChecker.new(dictionary: ConfigLoader::ALL_VALIDATORS)
198
- suggestions = checker.correct(invalid_name)
199
-
200
- if suggestions.any?
201
- @errors << " Did you mean: #{suggestions.first}?"
202
- else
203
- @errors << ' See https://github.com/mensfeld/yard-lint/wiki/Validators ' \
204
- 'for all available validators'
205
- end
206
- end
207
-
208
- # Suggest similar severity values
209
- # @param invalid_severity [String] invalid severity value
210
- # @return [void]
211
- def suggest_similar_severity(invalid_severity)
212
- checker = DidYouMean::SpellChecker.new(dictionary: Config::VALID_SEVERITIES)
213
- suggestions = checker.correct(invalid_severity)
214
-
215
- if suggestions.any?
216
- @errors << " Did you mean: #{suggestions.first}?"
217
- end
218
- end
219
-
220
34
  # Build comprehensive error message
221
- def build_error_message
35
+ # @param messages [Array<String>] error messages, each one or more lines
36
+ # @return [String] error message with one indented line per error line
37
+ def build_error_message(messages)
222
38
  header = 'Invalid configuration detected:'
223
- errors_text = @errors.map { |e| " #{e}" }.join("\n")
39
+ errors_text = messages.flat_map { |message| message.split("\n") }.map { |e| " #{e}" }.join("\n")
224
40
 
225
41
  "#{header}\n#{errors_text}"
226
42
  end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ # Rule based data validation, copied from karafka-core
6
+ module Contractable
7
+ # Base contract for all the contracts that check data format
8
+ #
9
+ # Copied from karafka-core (`Karafka::Core::Contractable`), without its `Configurable`
10
+ # dependency: `error_messages` is a plain class-level accessor.
11
+ #
12
+ # @note This contract does NOT support rules inheritance
13
+ class Contract
14
+ # Constant representing a miss during dig
15
+ # We use it as a result value not to return an array with found object and a state to
16
+ # prevent additional array allocation
17
+ DIG_MISS = Object.new
18
+
19
+ # Empty array for scope default to avoid allocating a new Array on each
20
+ # `#call` / `#validate!` invocation. Safe because scope is never mutated - it is only
21
+ # used in `scope + rule.path` which creates a new Array.
22
+ EMPTY_ARRAY = [].freeze
23
+
24
+ private_constant :DIG_MISS, :EMPTY_ARRAY
25
+
26
+ # Class level API definitions
27
+ class << self
28
+ # @return [Array<Rule>] all the validation rules defined for a given contract
29
+ attr_reader :rules
30
+
31
+ attr_writer :error_messages
32
+
33
+ # @return [Hash{String => String}] error messages for error keys that rules return
34
+ # instead of custom message Strings
35
+ def error_messages
36
+ @error_messages || {}
37
+ end
38
+
39
+ # Allows for definition of a scope/namespace for nested validations
40
+ #
41
+ # @param path [Symbol] path in the hash for nesting
42
+ #
43
+ # @example
44
+ # nested(:key) do
45
+ # required(:inside) { |inside| inside.is_a?(String) }
46
+ # end
47
+ def nested(path, &)
48
+ init_accu
49
+ @nested << path
50
+ begin
51
+ instance_eval(&)
52
+ ensure
53
+ # Always pop, even if the block raises. Otherwise a rescued exception during
54
+ # contract definition would leave `path` on @nested and prefix it onto every rule
55
+ # defined afterwards.
56
+ @nested.pop
57
+ end
58
+ end
59
+
60
+ # Defines a rule for a required field (required means, that will automatically create an
61
+ # error if missing)
62
+ #
63
+ # @param keys [Array<Symbol>] single or full path
64
+ # @param block [Proc] validation rule
65
+ def required(*keys, &block)
66
+ init_accu
67
+ @rules << Rule.new(@nested + keys, :required, block).freeze
68
+ end
69
+
70
+ # @param keys [Array<Symbol>] single or full path
71
+ # @param block [Proc] validation rule
72
+ def optional(*keys, &block)
73
+ init_accu
74
+ @rules << Rule.new(@nested + keys, :optional, block).freeze
75
+ end
76
+
77
+ # Defines a virtual rule that validates the whole data rather than a single key. Unlike
78
+ # `required`/`optional`, the block receives the full data and returns its own errors.
79
+ #
80
+ # @param block [Proc] validation rule, called with `(data, errors, contract)`. It must
81
+ # return either a non-Array (`true`/`nil`/`false`) for "no errors", or an `Array` of
82
+ # `[path, message]` error pairs (where `path` is itself an `Array` of symbols).
83
+ #
84
+ # @note The returned error `Array` is owned by the contract: `#call` prepends the current
85
+ # scope onto each pair in place and collects them, so a rule must return a freshly
86
+ # built `Array` on every call. Returning a memoized, shared or frozen `Array` is not
87
+ # supported -- in-place scoping would accumulate the prefix across validations (or
88
+ # raise `FrozenError`). Build the result in the block (e.g. `[[%i[id], :invalid]]`)
89
+ # rather than returning a constant.
90
+ def virtual(&block)
91
+ init_accu
92
+ @rules << Rule.new([], :virtual, block).freeze
93
+ end
94
+
95
+ private
96
+
97
+ # Initializes nestings and rules building accumulator
98
+ def init_accu
99
+ @nested ||= []
100
+ @rules ||= []
101
+ end
102
+ end
103
+
104
+ # Runs the validation
105
+ #
106
+ # The per-rule handling is inlined instead of dispatching to per-type methods because
107
+ # this runs per rule per validation. Required and optional rules share the whole flow
108
+ # except the missing-key handling. `DIG_MISS` is compared via `#equal?` so we never dispatch `#==` to the
109
+ # validated (user-provided) values.
110
+ #
111
+ # @param data [Hash] hash with data we want to validate
112
+ # @param scope [Array<String>] scope of this contract (if any) or empty array if no parent
113
+ # scope is needed if contract starts from root
114
+ # @return [Result] validation result
115
+ def call(data, scope: EMPTY_ARRAY)
116
+ errors = []
117
+
118
+ # A non-Hash root has no keys to dig; resolve it once here so #dig stays on its lean
119
+ # fast path and is only invoked with a Hash. Non-Hash data makes every required rule
120
+ # report missing, consistent with the non-Hash intermediate handling inside #dig.
121
+ data_is_hash = data.is_a?(Hash)
122
+
123
+ self.class.rules.each do |rule|
124
+ if rule.type == :virtual
125
+ result = rule.validator.call(data, errors, self)
126
+
127
+ # A virtual rule signals "no errors" with any non-Array result (true, but also a
128
+ # falsy `nil`/`false` returned by e.g. `condition && [[...], :err]`). Only an Array
129
+ # of error pairs is iterated.
130
+ next unless result.is_a?(Array)
131
+
132
+ # Apply the scope prefix in place on the rule's returned pairs and collect them
133
+ # directly. Per the `virtual` contract the rule hands back a freshly built Array
134
+ # each call (see `.virtual`), so mutating it here is safe and avoids allocating a
135
+ # new pair per error.
136
+ result.each do |sub_result|
137
+ sub_result[0] = scope + sub_result[0]
138
+ end
139
+
140
+ errors.push(*result)
141
+ else
142
+ for_checking = data_is_hash ? dig(data, rule.path) : DIG_MISS
143
+
144
+ if DIG_MISS.equal?(for_checking)
145
+ errors << [scope + rule.path, :missing] if rule.type == :required
146
+ else
147
+ result = rule.validator.call(for_checking, data, errors, self)
148
+
149
+ next if result == true
150
+
151
+ errors << [scope + rule.path, result || :format]
152
+ end
153
+ end
154
+ end
155
+
156
+ return Result.success if errors.empty?
157
+
158
+ Result.new(errors, self)
159
+ end
160
+
161
+ # Runs the validation like `#call`, but raises instead of returning a failed `Result`
162
+ #
163
+ # @param data [Hash] data for validation
164
+ # @param error_class [Class] error class that should be used when validation fails
165
+ # @param scope [Array<String>] scope of this contract (if any) or empty array if no parent
166
+ # scope is needed if contract starts from root
167
+ # @return [Boolean] true
168
+ # @raise [StandardError] any error provided in the error_class that inherits from the
169
+ # standard error
170
+ def validate!(data, error_class, scope: EMPTY_ARRAY)
171
+ result = call(data, scope: scope)
172
+
173
+ return true if result.success?
174
+
175
+ raise error_class, result.errors
176
+ end
177
+
178
+ private
179
+
180
+ # Tries to dig for a given key in a hash and returns it with indication whether or not it
181
+ # was possible to find it (dig returns nil and we don't know if it wasn't the digged key
182
+ # value)
183
+ #
184
+ # Uses `Hash#fetch` with the `DIG_MISS` sentinel as the default, which resolves presence
185
+ # and value in a single hash lookup instead of a `key?` check followed by `[]`.
186
+ # `fetch` with a default ignores `default_proc`.
187
+ #
188
+ # @param data [Hash]
189
+ # @param keys [Array<Symbol>]
190
+ # @return [DIG_MISS, Object] found element or DIG_MISS indicating that not found
191
+ def dig(data, keys)
192
+ case keys.length
193
+ when 1
194
+ data.fetch(keys[0], DIG_MISS)
195
+ when 2
196
+ mid = data.fetch(keys[0], DIG_MISS)
197
+
198
+ return DIG_MISS if DIG_MISS.equal?(mid)
199
+ return DIG_MISS unless mid.is_a?(Hash)
200
+
201
+ mid.fetch(keys[1], DIG_MISS)
202
+ else
203
+ current = data
204
+
205
+ keys.each do |nesting|
206
+ return DIG_MISS unless current.is_a?(Hash)
207
+
208
+ current = current.fetch(nesting, DIG_MISS)
209
+
210
+ return DIG_MISS if DIG_MISS.equal?(current)
211
+ end
212
+
213
+ current
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Contractable
6
+ # Representation of a validation result with resolved error messages
7
+ class Result
8
+ attr_reader :errors
9
+
10
+ # Empty hash that we use when no errors to save on allocations
11
+ EMPTY_HASH = {}.freeze
12
+
13
+ private_constant :EMPTY_HASH
14
+
15
+ class << self
16
+ # @return [Result] cached frozen success result to avoid allocating a new Result on
17
+ # every successful contract validation. Safe because successful results are
18
+ # identical regardless of contract (they all have @errors = EMPTY_HASH).
19
+ def success
20
+ @success ||= new([], nil).freeze
21
+ end
22
+ end
23
+
24
+ # Builds a result object and remaps (if needed) error keys to proper error messages
25
+ #
26
+ # @param errors [Array<Array>] array with sub-arrays with paths and error keys
27
+ # @param contract [Object] contract that generated the error
28
+ def initialize(errors, contract)
29
+ # Short track to skip object allocation for the happy path
30
+ if errors.empty?
31
+ @errors = EMPTY_HASH
32
+ return
33
+ end
34
+
35
+ hashed = {}
36
+
37
+ errors.each do |error|
38
+ scope = error.first.map(&:to_s).join('.').to_sym
39
+
40
+ # This will allow for usage of custom messages instead of yaml keys if needed
41
+ hashed[scope] = if error.last.is_a?(String)
42
+ error.last
43
+ else
44
+ build_message(contract, scope, error.last)
45
+ end
46
+ end
47
+
48
+ @errors = hashed
49
+ end
50
+
51
+ # @return [Boolean] true if no errors
52
+ def success?
53
+ errors.empty?
54
+ end
55
+
56
+ private
57
+
58
+ # Builds message based on the error messages and scope
59
+ #
60
+ # @param contract [Object] contract for which we build the result
61
+ # @param scope [Symbol] path to the key that has an error
62
+ # @param error_key [Symbol] error key for yaml errors lookup
63
+ # @return [String] error message
64
+ def build_message(contract, scope, error_key)
65
+ messages = contract.class.error_messages
66
+
67
+ # Split scope into parts for progressive checking
68
+ scope_parts = scope.to_s.split('.')
69
+
70
+ # Try full scope first, then progressively remove from beginning
71
+ # This allows us to have full path scoped errors but can also be used as a fallback,
72
+ # when scopes are dynamic. For example 'consumer_group_name.topic_name.name_format'
73
+ (0..scope_parts.length).each do |i|
74
+ current_scope_parts = scope_parts[i..]
75
+
76
+ key = if current_scope_parts.empty?
77
+ error_key.to_s
78
+ else
79
+ "#{current_scope_parts.join('.')}_#{error_key}"
80
+ end
81
+
82
+ return messages[key] if messages.key?(key)
83
+ end
84
+
85
+ # If nothing found, raise the original error
86
+ messages.fetch(error_key.to_s) do
87
+ messages.fetch("#{scope}_#{error_key}")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Contractable
6
+ # Representation of a single validation rule
7
+ Rule = Struct.new(:path, :type, :validator)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ # Contracts that validate the configuration structure and values
6
+ module Contracts
7
+ # Base for the configuration contracts with the helpers that build their error messages
8
+ class Base < Contractable::Contract
9
+ # Valid boolean values
10
+ BOOLEAN_VALUES = [true, false].freeze
11
+
12
+ class << self
13
+ private
14
+
15
+ # Builds an error message out of its lines
16
+ # @param lines [Array<String, nil>] message lines, nil lines are skipped
17
+ # @return [String] multi-line error message
18
+ def message(*lines)
19
+ lines.compact.join("\n")
20
+ end
21
+
22
+ # Converts errors of a nested contract into virtual rule errors of the parent contract
23
+ # @param result [Contractable::Result] nested contract validation result
24
+ # @return [Array<Array>] error pairs with the nested error scope as their path
25
+ def nested_errors(result)
26
+ result.errors.map { |scope, error| [[scope], error] }
27
+ end
28
+
29
+ # Suggest similar validator names using did_you_mean
30
+ # @param invalid_name [String] invalid validator name
31
+ # @return [String] suggestion line
32
+ def suggest_validator_name(invalid_name)
33
+ checker = DidYouMean::SpellChecker.new(dictionary: ConfigLoader::ALL_VALIDATORS)
34
+ suggestions = checker.correct(invalid_name)
35
+
36
+ if suggestions.any?
37
+ " Did you mean: #{suggestions.first}?"
38
+ else
39
+ ' See https://github.com/mensfeld/yard-lint/wiki/Validators ' \
40
+ 'for all available validators'
41
+ end
42
+ end
43
+
44
+ # Suggest similar severity values
45
+ # @param invalid_severity [String] invalid severity value
46
+ # @return [String, nil] suggestion line or nil when there is no similar value
47
+ def suggest_similar_severity(invalid_severity)
48
+ checker = DidYouMean::SpellChecker.new(dictionary: Lint::Config::VALID_SEVERITIES)
49
+ suggestions = checker.correct(invalid_severity)
50
+
51
+ " Did you mean: #{suggestions.first}?" if suggestions.any?
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Contracts
6
+ # Validates a category-level configuration (e.g. `Documentation: { Severity: ... }`)
7
+ class Category < Base
8
+ # Keys that are valid in a category-level configuration
9
+ KEYS = %w[Enabled Exclude Severity].freeze
10
+
11
+ # @return [String] category name
12
+ attr_reader :name
13
+
14
+ class << self
15
+ private
16
+
17
+ # Suggest similar category configuration keys
18
+ # @param invalid_key [String] invalid category key
19
+ # @return [String, nil] suggestion line or nil when there is no similar key
20
+ def suggest_category_key(invalid_key)
21
+ checker = DidYouMean::SpellChecker.new(dictionary: KEYS)
22
+ suggestions = checker.correct(invalid_key)
23
+
24
+ " Did you mean: #{suggestions.first}?" if suggestions.any?
25
+ end
26
+ end
27
+
28
+ # @param name [String] category name used in error messages
29
+ def initialize(name)
30
+ super()
31
+ @name = name
32
+ end
33
+
34
+ optional('Enabled') do |enabled, _data, _errors, contract|
35
+ BOOLEAN_VALUES.include?(enabled) ||
36
+ message(
37
+ "Invalid Enabled value for category #{contract.name}: '#{enabled}'",
38
+ ' Must be true or false'
39
+ )
40
+ end
41
+
42
+ optional('Exclude') do |exclude, _data, _errors, contract|
43
+ !exclude || exclude.is_a?(Array) ||
44
+ "Invalid Exclude for category #{contract.name}: must be an array, got #{exclude.class}"
45
+ end
46
+
47
+ optional('Severity') do |severity, _data, _errors, contract|
48
+ !severity || Lint::Config::VALID_SEVERITIES.include?(severity.to_s) ||
49
+ message(
50
+ "Invalid Severity for category #{contract.name}: '#{severity}'",
51
+ " Valid values: #{Lint::Config::VALID_SEVERITIES.join(', ')}",
52
+ suggest_similar_severity(severity.to_s)
53
+ )
54
+ end
55
+
56
+ virtual do |data, _errors, contract|
57
+ (data.keys - KEYS).map do |key|
58
+ [
59
+ [key],
60
+ message(
61
+ "Unknown configuration key for category #{contract.name}: '#{key}'",
62
+ " Valid keys: #{KEYS.join(', ')}",
63
+ suggest_category_key(key.to_s)
64
+ )
65
+ ]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end