yard-lint 1.12.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -501
  3. data/README.md +94 -1
  4. data/bin/yard-lint +68 -32
  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 +42 -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 +3 -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/type_syntax/parser.rb +13 -2
  48. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +11 -1
  49. data/lib/yard/lint/version.rb +1 -1
  50. data/lib/yard/lint.rb +3 -57
  51. metadata +21 -1
data/README.md CHANGED
@@ -114,8 +114,14 @@ yard-lint --auto-gen-config
114
114
  yard-lint lib/
115
115
 
116
116
  # Fix violations incrementally by removing exclusions from .yard-lint-todo.yml
117
+
118
+ # Fail (exit 1) when .yard-lint-todo.yml has entries that no longer match any offense
119
+ # (use the PATH of your normal run; a PATH only limits which entries are reported)
120
+ yard-lint --check-todo lib/
117
121
  ```
118
122
 
123
+ `.yard-lint-todo.yml` is written and read next to the loaded `.yard-lint.yml` (or in the current directory without one), and its entries are relative to that directory, so the baseline works from any directory you run yard-lint in.
124
+
119
125
  **Learn more:** [Incremental Adoption Guide](https://github.com/mensfeld/yard-lint/wiki/Incremental-Adoption)
120
126
 
121
127
  ### Check Documentation Coverage
@@ -208,6 +214,26 @@ Then `:make` lints the current file and `:cnext` / `:cprev` jump between offense
208
214
 
209
215
  Then `M-x compile` and `M-g n` / `M-g p` navigate between offenses.
210
216
 
217
+ ### SARIF Output (GitHub Code Scanning)
218
+
219
+ Use `--format sarif` to emit [SARIF 2.1.0](https://sarifweb.azurewebsites.net/), the format GitHub code scanning and most result aggregators ingest. Uploading it makes yard-lint offenses appear as annotations in the PR "Files changed" view and the repository Security tab, instead of being buried in CI logs:
220
+
221
+ ```bash
222
+ yard-lint --format sarif lib/ > yard-lint.sarif
223
+ ```
224
+
225
+ Each rule carries its description (from the validator's own YARD docs), and severities map to SARIF levels (`error` → error, `warning` → warning, `convention` → note). Artifact paths are emitted repository-relative so GitHub maps them onto your files. In GitHub Actions, upload the file with `github/codeql-action/upload-sarif`:
226
+
227
+ ```yaml
228
+ - run: bundle exec yard-lint --format sarif lib/ > yard-lint.sarif
229
+ continue-on-error: true
230
+ - uses: github/codeql-action/upload-sarif@v3
231
+ with:
232
+ sarif_file: yard-lint.sarif
233
+ ```
234
+
235
+ (Run yard-lint from the repository root so paths resolve correctly; `continue-on-error` lets the upload step run even when offenses are found.)
236
+
211
237
  ## Configuration Basics
212
238
 
213
239
  Create a `.yard-lint.yml` file in your project root:
@@ -322,10 +348,35 @@ Documentation/UnderfilledLines:
322
348
 
323
349
  **Key features:**
324
350
  - Per-validator control (enable/disable, severity, exclusions)
351
+ - Category-level defaults (enable/disable, severity, exclusions) for a whole department
325
352
  - Configuration inheritance with `inherit_from` and `inherit_gem`
326
353
  - Automatic configuration validation with helpful error messages
327
354
  - Per-validator YARD options and file exclusions
328
355
 
356
+ **Category-level defaults:** set `Enabled` or `Severity` on a category (`Documentation`, `Tags`, `Warnings`, `Semantic`) to apply it to every validator in that department. A per-validator setting always wins over the category, which in turn wins over the validator's built-in default:
357
+
358
+ ```yaml
359
+ # Make every Tags/* offense an error...
360
+ Tags:
361
+ Severity: error
362
+
363
+ # ...except Tags/Order, which stays a convention
364
+ Tags/Order:
365
+ Severity: convention
366
+ ```
367
+
368
+ A category-level `Exclude` is combined with each validator's own `Exclude` (it is not replaced by it), so these files are skipped by every `Documentation/*` validator, including one that sets its own `Exclude`:
369
+
370
+ ```yaml
371
+ Documentation:
372
+ Exclude:
373
+ - 'lib/generated/**/*'
374
+ ```
375
+
376
+ A category accepts only the keys `Enabled` (`true` or `false`), `Severity` and `Exclude`; an unknown key or a non-boolean `Enabled` fails configuration validation.
377
+
378
+ `Exclude` patterns at every level are relative to the directory of the loaded `.yard-lint.yml` (or the current directory without one), whatever PATH or directory you run yard-lint from. A file outside that directory is matched as `../...` (with `--config config/yard-lint.yml`, write `../lib/generated/**/*`); absolute patterns also work.
379
+
329
380
  **Learn more:** [Complete Configuration Guide](https://github.com/mensfeld/yard-lint/wiki/Configuration)
330
381
 
331
382
  ## Catching Orphaned Documentation Comments
@@ -434,6 +485,47 @@ The validator reports one offense per wasteful paragraph and is deliberately con
434
485
 
435
486
  Unlike `LineLength` (which measures an objective property), "this line should have been longer" is a stylistic judgement. Projects that use **semantic line breaks** (one sentence or clause per line, see [sembr.org](https://sembr.org)) are never flagged - any paragraph whose non-final lines all end at a sentence boundary (`.?!:;`) is treated as intentional. Leave the validator off, or add `,` to `SentenceEndChars`, if that style is used heavily.
436
487
 
488
+ ## Catching Typos in References
489
+
490
+ The `Documentation/ReferenceTypo` validator (enabled by default, severity `warning`) checks `{Foo#bar}` links in docstrings and tag text, and `@see` targets. When a reference does not resolve and a close match exists in its namespace, it reports the reference and suggests the closest name:
491
+
492
+ ```ruby
493
+ # Bad - reports: Reference 'Parser#prase' does not resolve (did you mean 'Parser#parse'?)
494
+ # Delegates to {Parser#prase}
495
+ def call; end
496
+
497
+ # Good
498
+ # Delegates to {Parser#parse}
499
+ def call; end
500
+ ```
501
+
502
+ To keep false positives low, it skips references into namespaces YARD does not know (stdlib, other gems), methods that a loaded Ruby ancestor may define (e.g. `{#to_s}`), names with no close match, and code spans and code blocks. A bare constant such as `{Kafka}` is only compared with constants of the enclosing namespaces, never with top-level ones, so a constant of a gem YARD did not parse is not reported as a typo of a similar project name.
503
+
504
+ ## Checking @overload Tags
505
+
506
+ YARD checks `@param` names only against the method's own signature, never the tags inside an `@overload`. The `Tags/OverloadConsistency` validator (enabled by default, severity `warning`) checks each overload against its own signature and reports:
507
+
508
+ - a `@param` that names no parameter of that overload
509
+ - a parameter with no `@param`, when the overload documents at least one other parameter (splat and block parameters are not required; a `@param` on the method docstring counts for every overload)
510
+ - an overload with no `@return`, when another overload of the method has one and the method docstring has none
511
+
512
+ ```ruby
513
+ # Bad - reports: Overload 'fetch(key)' documents @param 'name', which is not in its signature
514
+ # Overload 'fetch(key)' has no @param for 'key'
515
+ # @overload fetch(key)
516
+ # @param name [String] the key
517
+ # @return [Object]
518
+ def fetch(*args); end
519
+
520
+ # Good
521
+ # @overload fetch(key)
522
+ # @param key [String] the key
523
+ # @return [Object]
524
+ def fetch(*args); end
525
+ ```
526
+
527
+ Overloads that forward arguments (`...`) or take anonymous `*`, `**` or `&` parameters are not checked for unknown names.
528
+
437
529
  ## Handling Non-Standard Types
438
530
 
439
531
  By default `Tags/InvalidTypes` accepts all built-in Ruby classes, constants, and a set of YARD pseudo-types (`nil`, `true`, `false`, `self`, `void`, `Boolean`, `undefined`, `unspecified`, `unknown`). If your project uses additional type names that are not real Ruby classes - project-specific aliases, LSP extensions, or informal conventions - you can declare them via `ExtraTypes` so yard-lint does not report them as `InvalidTagType` offenses.
@@ -530,7 +622,7 @@ Configuration:
530
622
  -c, --config FILE Path to config file (default: .yard-lint.yml)
531
623
 
532
624
  Output:
533
- -f, --format FORMAT Output format (text, json, quickfix)
625
+ -f, --format FORMAT Output format (text, json, quickfix, sarif)
534
626
  -q, --quiet Quiet mode (only show summary)
535
627
  --stats Show documentation coverage statistics
536
628
  --[no-]progress Show progress indicator (default: auto-detect TTY)
@@ -555,6 +647,7 @@ Configuration Generation:
555
647
  --force Force overwrite when using --init
556
648
  --auto-gen-config Generate .yard-lint-todo.yml to silence existing violations
557
649
  --regenerate-todo Regenerate .yard-lint-todo.yml (overwrites existing)
650
+ --check-todo Report .yard-lint-todo.yml entries that no longer match any offense
558
651
  --exclude-limit N Min files before grouping into pattern (default: 15)
559
652
 
560
653
  Information:
data/bin/yard-lint CHANGED
@@ -18,7 +18,7 @@ begin
18
18
  config_file = file
19
19
  end
20
20
 
21
- opts.on('-f', '--format FORMAT', 'Output format (text, json, quickfix)') do |format|
21
+ opts.on('-f', '--format FORMAT', 'Output format (text, json, quickfix, sarif)') do |format|
22
22
  options[:format] = format
23
23
  end
24
24
 
@@ -99,6 +99,10 @@ begin
99
99
  options[:force] = true
100
100
  end
101
101
 
102
+ opts.on('--check-todo', 'Report .yard-lint-todo.yml entries that no longer match any offense') do
103
+ options[:check_todo] = true
104
+ end
105
+
102
106
  opts.on('--exclude-limit COUNT', Integer, 'Min files before grouping into pattern (default: 15)') do |count|
103
107
  options[:exclude_limit] = count
104
108
  end
@@ -122,12 +126,14 @@ begin
122
126
  puts ' yard-lint --changed # Lint only uncommitted files'
123
127
  puts ' yard-lint --format json lib/ # Output in JSON format'
124
128
  puts ' yard-lint --format quickfix lib/ # Output in quickfix format (Vim/Emacs)'
129
+ puts ' yard-lint --format sarif lib/ # Output SARIF 2.1.0 (GitHub code scanning)'
125
130
  puts ' yard-lint --init # Generate default config'
126
131
  puts ' yard-lint --init --strict # Generate strict config'
127
132
  puts ' yard-lint --update # Update config with new validators'
128
133
  puts ' yard-lint --auto-gen-config # Generate todo file for existing violations'
129
134
  puts ' yard-lint --regenerate-todo # Regenerate todo file'
130
135
  puts ' yard-lint --auto-gen-config --exclude-limit 10 # Custom grouping threshold'
136
+ puts ' yard-lint --check-todo # Fail if todo file has unused entries'
131
137
  puts ' cat lib/foo.rb | yard-lint --stdin lib/foo.rb # Lint source piped from stdin'
132
138
  exit
133
139
  end
@@ -154,6 +160,35 @@ report_unknown_validators = lambda do |names|
154
160
  puts "\nRun `yard-lint --explain VALIDATOR` to see what a validator checks."
155
161
  end
156
162
 
163
+ # Apply --only filter if provided, exiting on unknown validator names
164
+ apply_only = lambda do |config|
165
+ next unless options[:only]
166
+
167
+ unknown = options[:only] - Yard::Lint::ConfigLoader::ALL_VALIDATORS
168
+ if unknown.any?
169
+ report_unknown_validators.call(unknown)
170
+ exit 1
171
+ end
172
+ config.only_validators = options[:only]
173
+ end
174
+
175
+ # Load the config (-c file or auto-discovered), exiting with a configuration error
176
+ # instead of a backtrace when it is missing, invalid or inherits in a cycle.
177
+ # Shared by the normal run, --auto-gen-config and --check-todo.
178
+ load_config = lambda do
179
+ if config_file
180
+ Yard::Lint::Config.from_file(config_file)
181
+ else
182
+ Yard::Lint::Config.load || Yard::Lint::Config.new
183
+ end
184
+ rescue Yard::Lint::Errors::ConfigFileNotFoundError, Yard::Lint::Errors::CircularDependencyError => e
185
+ puts "Configuration Error: #{e.message}"
186
+ exit 1
187
+ rescue Yard::Lint::Errors::InvalidConfigError => e
188
+ puts "Configuration Error:\n#{e.message}"
189
+ exit 1
190
+ end
191
+
157
192
  # Handle --explain flag (early exit; needs no config or path)
158
193
  if options[:explain]
159
194
  name = options[:explain]
@@ -221,12 +256,7 @@ end
221
256
  # Handle --auto-gen-config flag
222
257
  if options[:auto_gen_config]
223
258
  begin
224
- # Load config
225
- config = if config_file
226
- Yard::Lint::Config.from_file(config_file)
227
- else
228
- Yard::Lint::Config.load || Yard::Lint::Config.new
229
- end
259
+ config = load_config.call
230
260
 
231
261
  # Get path argument
232
262
  path = ARGV[0] || '.'
@@ -254,6 +284,26 @@ if options[:auto_gen_config]
254
284
  end
255
285
  end
256
286
 
287
+ # Handle --check-todo flag
288
+ if options[:check_todo]
289
+ begin
290
+ config = load_config.call
291
+ apply_only.call(config)
292
+
293
+ result = Yard::Lint::TodoGenerator.check(path: ARGV[0] || '.', config: config)
294
+
295
+ puts result[:message]
296
+ exit result[:unused_entries].empty? ? 0 : 1
297
+ rescue Yard::Lint::Errors::TodoFileNotFoundError, Yard::Lint::Errors::FileNotFoundError,
298
+ Yard::Lint::Git::Error => e
299
+ puts "Error: #{e.message}"
300
+ exit 1
301
+ rescue Yard::Lint::Errors::InvalidConfigError => e
302
+ puts "Configuration Error:\n#{e.message}"
303
+ exit 1
304
+ end
305
+ end
306
+
257
307
  # Get path argument (defaults to current directory)
258
308
  path = ARGV[0] || '.'
259
309
 
@@ -271,43 +321,23 @@ end
271
321
  YARD::Registry.clear
272
322
 
273
323
  # Load config and apply CLI overrides
274
- begin
275
- config = if config_file
276
- Yard::Lint::Config.from_file(config_file)
277
- else
278
- Yard::Lint::Config.load || Yard::Lint::Config.new
279
- end
280
- rescue Yard::Lint::Errors::ConfigFileNotFoundError => e
281
- puts "Configuration Error: #{e.message}"
282
- exit 1
283
- rescue Yard::Lint::Errors::InvalidConfigError => e
284
- puts "Configuration Error:\n#{e.message}"
285
- exit 1
286
- end
324
+ config = load_config.call
287
325
 
288
326
  # Apply CLI min_coverage override if provided
289
327
  config.min_coverage = options[:min_coverage] if options[:min_coverage]
290
328
 
291
- # Apply --only filter if provided
292
- if options[:only]
293
- unknown = options[:only] - Yard::Lint::ConfigLoader::ALL_VALIDATORS
294
- if unknown.any?
295
- report_unknown_validators.call(unknown)
296
- exit 1
297
- end
298
- config.only_validators = options[:only]
299
- end
329
+ apply_only.call(config)
300
330
 
301
331
  # Validate the output format before doing any (potentially long) work, rather
302
332
  # than running the whole lint and only then rejecting an unknown format.
303
- valid_formats = %w[text json quickfix]
333
+ valid_formats = %w[text json quickfix sarif]
304
334
  if options[:format] && !valid_formats.include?(options[:format])
305
335
  puts "Error: Unknown format '#{options[:format]}'. Valid formats: #{valid_formats.join(', ')}"
306
336
  exit 1
307
337
  end
308
338
 
309
- # Suppress progress for quickfix to avoid contaminating machine-readable output
310
- options[:progress] = false if options[:format] == 'quickfix' && options[:progress].nil?
339
+ # Suppress progress for machine-readable formats to avoid contaminating output
340
+ options[:progress] = false if %w[quickfix sarif].include?(options[:format]) && options[:progress].nil?
311
341
 
312
342
  # Run the linter
313
343
  begin
@@ -356,6 +386,12 @@ when 'quickfix'
356
386
  puts "#{offense[:location]}:#{offense[:location_line]}: #{char}: #{offense[:validator]}: #{message}"
357
387
  end
358
388
  exit result.exit_code
389
+ when 'sarif'
390
+ # Base paths on the repository root so GitHub can map offense URIs onto files
391
+ # even when yard-lint is run from a subdirectory.
392
+ sarif = Yard::Lint::Formatters::Sarif.new(result.offenses, base_dir: Yard::Lint::Git.repository_root)
393
+ puts sarif.generate
394
+ exit result.exit_code
359
395
  when 'text', nil
360
396
  # Calculate coverage stats if requested or configured
361
397
  coverage = result.documentation_coverage if options[:stats] || options[:min_coverage] || config.min_coverage
@@ -6,6 +6,7 @@ module Yard
6
6
  class Config
7
7
  attr_reader :raw_config, :validators
8
8
  attr_accessor :only_validators
9
+ attr_writer :base_dir
9
10
 
10
11
  # Default YAML config file name
11
12
  DEFAULT_CONFIG_FILE = '.yard-lint.yml'
@@ -16,6 +17,9 @@ module Yard
16
17
  # Metadata keys to skip when merging validator configs
17
18
  METADATA_KEYS = %w[Description StyleGuide VersionAdded VersionChanged].freeze
18
19
 
20
+ # File.fnmatch flags used for Exclude patterns
21
+ EXCLUDE_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
22
+
19
23
  # @param raw_config [Hash] raw configuration hash (new hierarchical format)
20
24
  def initialize(raw_config = {})
21
25
  @raw_config = raw_config
@@ -42,7 +46,7 @@ module Yard
42
46
  # Load with inheritance support
43
47
  merged_yaml = ConfigLoader.load(path)
44
48
 
45
- new(merged_yaml)
49
+ new(merged_yaml) { |config| config.base_dir = File.dirname(File.expand_path(path)) }
46
50
  end
47
51
 
48
52
  # Search for and load config file from current directory upwards
@@ -173,29 +177,101 @@ module Yard
173
177
  end
174
178
 
175
179
  # Get validator severity
180
+ #
181
+ # Resolution order (highest priority first):
182
+ # 1. an explicit per-validator Severity in the user's config
183
+ # 2. a category-level Severity (e.g. `Documentation: { Severity: convention }`),
184
+ # matching how category-level Enabled cascades in #validator_enabled?
185
+ # 3. the validator's built-in default Severity
186
+ # 4. the global 'warning' default
187
+ #
176
188
  # @param validator_name [String] full validator name
177
189
  # @return [String] severity level for this validator
178
190
  def validator_severity(validator_name)
191
+ # An explicit per-validator Severity in the user's config wins.
192
+ raw_validator = @raw_config[validator_name]
193
+ if raw_validator.is_a?(Hash) && raw_validator['Severity']
194
+ return raw_validator['Severity'].to_s
195
+ end
196
+
197
+ # Otherwise honor a category-level Severity, which previously validated
198
+ # but was ignored (the category-level Enabled key already cascades).
199
+ category = validator_name.split('/').first
200
+ raw_category = @raw_config[category]
201
+ if raw_category.is_a?(Hash) && raw_category['Severity']
202
+ return raw_category['Severity'].to_s
203
+ end
204
+
205
+ # Fall back to the validator's built-in default, then the global default.
179
206
  validator_config = validators[validator_name] || {}
180
207
  validator_config['Severity'] || 'warning'
181
208
  end
182
209
 
183
210
  # Get validator-specific exclude patterns
211
+ #
212
+ # A category-level Exclude (e.g. `Documentation: { Exclude: [...] }`) is
213
+ # unioned with the per-validator one rather than replaced by it, so a
214
+ # validator that sets its own Exclude still skips its category's files.
215
+ #
184
216
  # @param validator_name [String] full validator name
185
- # @return [Array<String>] exclusion patterns for this validator
217
+ # @return [Array<String>] exclusion patterns for this validator (category + per-validator)
186
218
  def validator_exclude(validator_name)
187
219
  validator_config = validators[validator_name] || {}
188
- validator_config['Exclude'] || []
220
+ raw_category = @raw_config[validator_name.split('/').first]
221
+ category_exclude = raw_category.is_a?(Hash) ? Array(raw_category['Exclude']) : []
222
+
223
+ (category_exclude + (validator_config['Exclude'] || [])).uniq
189
224
  end
190
225
 
191
- # Combined global and per-validator exclusions
226
+ # Combined global, category and per-validator exclusions
192
227
  # Returns all exclusion patterns that apply to this validator
193
228
  # @param validator_name [String] full validator name
194
- # @return [Array<String>] combined exclusion patterns (global + per-validator)
229
+ # @return [Array<String>] combined exclusion patterns (global + category + per-validator)
195
230
  def validator_all_excludes(validator_name)
196
231
  exclude + validator_exclude(validator_name)
197
232
  end
198
233
 
234
+ # Directory that Exclude patterns are relative to
235
+ # @return [String] directory of the loaded config file, or the current directory without one
236
+ def base_dir
237
+ @base_dir || Dir.pwd
238
+ end
239
+
240
+ # Whether a file matches any of the given Exclude patterns
241
+ # A pattern matches the path relative to {#base_dir} or the absolute path
242
+ # @param file [String] absolute file path, or one relative to the current directory
243
+ # @param patterns [Array<String>] glob patterns
244
+ # @return [Boolean] true if any pattern matches
245
+ def excluded_path?(file, patterns)
246
+ absolute = File.expand_path(file)
247
+ relative = relative_path(absolute)
248
+
249
+ patterns.any? do |pattern|
250
+ File.fnmatch(pattern, relative, EXCLUDE_FLAGS) || File.fnmatch(pattern, absolute, EXCLUDE_FLAGS)
251
+ end
252
+ end
253
+
254
+ # Path of a file relative to {#base_dir}
255
+ # Symlinks are resolved when the plain paths do not nest (/var is /private/var on macOS)
256
+ # @param file [String] absolute file path, or one relative to the current directory
257
+ # @return [String] relative path (with ../ for a file outside {#base_dir}), or the absolute
258
+ # path when there is no relative one (another drive)
259
+ def relative_path(file)
260
+ absolute = File.expand_path(file)
261
+ base = File.expand_path(base_dir)
262
+ return absolute.delete_prefix("#{base}/") if absolute.start_with?("#{base}/")
263
+
264
+ from, to = begin
265
+ [File.realpath(absolute), File.realpath(base)]
266
+ rescue SystemCallError
267
+ [absolute, base]
268
+ end
269
+
270
+ Pathname.new(from).relative_path_from(Pathname.new(to)).to_s
271
+ rescue ArgumentError
272
+ absolute
273
+ end
274
+
199
275
  # Get validator-specific configuration value
200
276
  # @param validator_name [String] full validator name
201
277
  # @param key [String] configuration key
@@ -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