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
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+ require 'digest'
6
+
7
+ module Yard
8
+ module Lint
9
+ module Formatters
10
+ # Renders lint results as SARIF 2.1.0 (Static Analysis Results Interchange
11
+ # Format) - the JSON format GitHub code scanning and other result aggregators
12
+ # ingest, so yard-lint offenses can surface as PR annotations / Security-tab
13
+ # alerts instead of being buried in CI logs.
14
+ #
15
+ # This is output only: it does not change which offenses are reported, the
16
+ # exit code, or any configuration - it is an alternative rendering of the
17
+ # same result. Rule metadata (`rules[]`) is populated from each validator's
18
+ # own YARD documentation via {Explainer.describe}.
19
+ class Sarif
20
+ # SARIF 2.1.0 JSON schema URI, emitted as the document's `$schema`.
21
+ # @return [String]
22
+ SCHEMA_URI = 'https://json.schemastore.org/sarif-2.1.0.json'
23
+
24
+ # Tool information URI, emitted as the driver's `informationUri`.
25
+ # @return [String]
26
+ INFORMATION_URI = 'https://github.com/mensfeld/yard-lint'
27
+
28
+ # Help URI for each rule (the wiki validator reference).
29
+ # @return [String]
30
+ HELP_URI = 'https://github.com/mensfeld/yard-lint/wiki/Validators'
31
+
32
+ # SARIF supports only error/warning/note (plus none). yard-lint's
33
+ # `convention` maps to note, and `never` (report-only) maps to none.
34
+ # @return [Hash{String => String}]
35
+ SEVERITY_LEVELS = {
36
+ 'error' => 'error',
37
+ 'warning' => 'warning',
38
+ 'convention' => 'note',
39
+ 'never' => 'none'
40
+ }.freeze
41
+
42
+ # Fallback SARIF level for any unrecognized severity.
43
+ # @return [String]
44
+ DEFAULT_LEVEL = 'warning'
45
+
46
+ # partialFingerprints key, versioned so the scheme can evolve without
47
+ # invalidating history. GitHub uses it to correlate results across runs.
48
+ # @return [String]
49
+ FINGERPRINT_KEY = 'yardLintOffense/v1'
50
+
51
+ # @param offenses [Array<Hash>] offense hashes (from `result.offenses`)
52
+ # @param base_dir [String] directory that offense paths are made relative
53
+ # to, so GitHub can map them onto the repository (default: current dir)
54
+ def initialize(offenses, base_dir: Dir.pwd)
55
+ @offenses = offenses
56
+ @base_dir = base_dir
57
+ end
58
+
59
+ # @return [String] pretty-printed SARIF 2.1.0 JSON
60
+ def generate
61
+ JSON.pretty_generate(document)
62
+ end
63
+
64
+ # @return [Hash] the SARIF document as a Ruby hash
65
+ def document
66
+ rule_ids = @offenses.map { |offense| offense[:validator] }.compact.uniq
67
+ rule_index = rule_ids.each_with_index.to_h
68
+
69
+ {
70
+ '$schema' => SCHEMA_URI,
71
+ 'version' => '2.1.0',
72
+ 'runs' => [
73
+ {
74
+ 'tool' => { 'driver' => driver(rule_ids) },
75
+ 'results' => @offenses.map { |offense| result(offense, rule_index) }
76
+ }
77
+ ]
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ # @param rule_ids [Array<String>] distinct validator names, in result order
84
+ # @return [Hash] the SARIF tool driver, including rule metadata
85
+ def driver(rule_ids)
86
+ {
87
+ 'name' => 'yard-lint',
88
+ 'informationUri' => INFORMATION_URI,
89
+ 'version' => Yard::Lint::VERSION,
90
+ 'rules' => rule_ids.map { |id| rule(id) }
91
+ }
92
+ end
93
+
94
+ # @param id [String] validator name (e.g. 'Tags/TypeSyntax')
95
+ # @return [Hash] a SARIF reportingDescriptor for the validator
96
+ def rule(id)
97
+ entry = {
98
+ 'id' => id,
99
+ 'name' => id.split('/').last,
100
+ 'helpUri' => HELP_URI,
101
+ 'defaultConfiguration' => { 'level' => level(default_severity(id)) }
102
+ }
103
+
104
+ described = Explainer.describe(id)
105
+ if described
106
+ entry['shortDescription'] = { 'text' => described[:short] }
107
+ entry['fullDescription'] = { 'text' => described[:full] }
108
+ end
109
+
110
+ entry
111
+ end
112
+
113
+ # @param offense [Hash] an offense hash
114
+ # @param rule_index [Hash{String => Integer}] validator name to rule index
115
+ # @return [Hash] a SARIF result
116
+ def result(offense, rule_index)
117
+ uri = relative_uri(offense[:location])
118
+ entry = {}
119
+
120
+ # ruleId/ruleIndex are optional in SARIF; omit them (rather than emit
121
+ # null, which is schema-invalid) for the rare offense with no validator.
122
+ validator = offense[:validator]
123
+ if validator
124
+ entry['ruleId'] = validator
125
+ entry['ruleIndex'] = rule_index[validator]
126
+ end
127
+
128
+ entry['level'] = level(offense[:severity])
129
+ entry['message'] = { 'text' => offense[:message].to_s }
130
+ entry['locations'] = [location(offense, uri)]
131
+ entry['partialFingerprints'] = { FINGERPRINT_KEY => fingerprint(offense, uri) }
132
+ entry
133
+ end
134
+
135
+ # @param offense [Hash] an offense hash
136
+ # @param uri [String] the artifact URI (already made relative)
137
+ # @return [Hash] a SARIF location
138
+ def location(offense, uri)
139
+ {
140
+ 'physicalLocation' => {
141
+ 'artifactLocation' => { 'uri' => uri },
142
+ 'region' => { 'startLine' => start_line(offense) }
143
+ }
144
+ }
145
+ end
146
+
147
+ # A line-independent fingerprint so GitHub correlates the same offense
148
+ # across commits without churn (close/reopen) when unrelated lines shift.
149
+ # Keys on the validator, file, and offending object, excluding the line
150
+ # number and (when an object is identified) the message, so a re-worded
151
+ # message does not churn alerts. Validators that do not identify an object
152
+ # fall back to a normalized message, so distinct offenses in one file
153
+ # still get distinct fingerprints instead of collapsing into one alert.
154
+ # @param offense [Hash] an offense hash
155
+ # @param uri [String] the artifact URI (already made relative)
156
+ # @return [String] a stable hex digest
157
+ def fingerprint(offense, uri)
158
+ element = offense[:element].to_s
159
+ # Tag the identity with its source so an element that happens to equal
160
+ # another offense's normalized message cannot collide.
161
+ identity = element.empty? ? "m:#{normalized_message(offense[:message])}" : "e:#{element}"
162
+ parts = [offense[:validator], uri, identity]
163
+ Digest::SHA256.hexdigest(parts.map(&:to_s).join("\x00"))
164
+ end
165
+
166
+ # Message reduced to a stable identity for offenses with no :element:
167
+ # whitespace is collapsed so trivial formatting differences do not churn
168
+ # alerts. Case is preserved - messages embed case-sensitive names (types,
169
+ # methods, constants), so downcasing could merge genuinely distinct offenses.
170
+ # @param message [String, nil] the offense message
171
+ # @return [String] the normalized message
172
+ def normalized_message(message)
173
+ message.to_s.gsub(/\s+/, ' ').strip
174
+ end
175
+
176
+ # @param severity [String, nil] a yard-lint severity
177
+ # @return [String] the SARIF level
178
+ def level(severity)
179
+ SEVERITY_LEVELS.fetch(severity.to_s, DEFAULT_LEVEL)
180
+ end
181
+
182
+ # SARIF regions are 1-based; guard against a missing or zero line.
183
+ # @param offense [Hash] an offense hash
184
+ # @return [Integer] a 1-based start line
185
+ def start_line(offense)
186
+ line = (offense[:location_line] || offense[:line]).to_i
187
+ line.positive? ? line : 1
188
+ end
189
+
190
+ # Make a path repository-relative so GitHub maps it onto a file. Paths
191
+ # already relative are kept; a path outside base_dir keeps its original
192
+ # value rather than emitting a `../` or absolute URI.
193
+ # @param location [String, nil] the offense location path
194
+ # @return [String] a relative URI where possible
195
+ def relative_uri(location)
196
+ path = location.to_s
197
+ return path if path.empty?
198
+
199
+ absolute = File.absolute_path(path, @base_dir)
200
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(@base_dir)).to_s
201
+ relative.start_with?('..') ? path : relative
202
+ rescue ArgumentError
203
+ # relative_path_from raises across platforms/mixed roots; fall back.
204
+ path
205
+ end
206
+
207
+ # The validator's default severity, from a defaults-only config, for the
208
+ # rule's defaultConfiguration.level.
209
+ # @param id [String] validator name
210
+ # @return [String] default severity
211
+ def default_severity(id)
212
+ @default_config ||= Config.new
213
+ @default_config.validator_severity(id)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
data/lib/yard/lint/git.rb CHANGED
@@ -89,15 +89,17 @@ module Yard
89
89
  filter_ruby_files(files.uniq, path)
90
90
  end
91
91
 
92
- private
93
-
94
- # Absolute path to the repository root (git reports paths relative to it)
92
+ # Absolute path to the repository root (git reports paths relative to it).
93
+ # Used to make report paths repository-relative; falls back to the current
94
+ # directory outside a git repo.
95
95
  # @return [String] repo root, or the current directory if it can't be determined
96
96
  def repository_root
97
97
  stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel')
98
98
  status.success? ? stdout.strip : Dir.pwd
99
99
  end
100
100
 
101
+ private
102
+
101
103
  # Ensure we're in a git repository
102
104
  # @raise [Error] if not in a git repository
103
105
  def ensure_git_repository!
@@ -22,8 +22,7 @@ module Yard
22
22
  validator_cfg = ConfigLoader.validator_config(validator_name)
23
23
  return nil unless validator_module && validator_cfg
24
24
 
25
- # Skip if this validator is a child of a composite
26
- return nil if composite_child?(validator_name)
25
+ return nil if reported_by_composite?(validator_name)
27
26
 
28
27
  # Handle composite validators (those that combine multiple validators)
29
28
  unless validator_cfg.combines_with.empty?
@@ -36,15 +35,17 @@ module Yard
36
35
 
37
36
  private
38
37
 
39
- # Check if a validator is a child of a composite validator
38
+ # Check if a composite parent reports this validator's offenses
39
+ # A child reports on its own only when --only selects it without its parent
40
40
  # @param validator_name [String] validator name to check
41
- # @return [Boolean] true if this is a composite child
42
- def composite_child?(validator_name)
41
+ # @return [Boolean] true if a composite parent reports it
42
+ def reported_by_composite?(validator_name)
43
43
  ConfigLoader::ALL_VALIDATORS.any? do |parent_name|
44
44
  parent_cfg = ConfigLoader.validator_config(parent_name)
45
45
  next unless parent_cfg
46
+ next unless parent_cfg.combines_with.include?(validator_name)
46
47
 
47
- parent_cfg.combines_with.include?(validator_name)
48
+ config.only_validators.empty? || config.only_validators.include?(parent_name)
48
49
  end
49
50
  end
50
51
 
@@ -102,11 +102,7 @@ module Yard
102
102
  validator_excludes = config.validator_exclude(validator_name)
103
103
  return files if validator_excludes.empty?
104
104
 
105
- files.reject do |file|
106
- validator_excludes.any? do |pattern|
107
- File.fnmatch(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
108
- end
109
- end
105
+ files.reject { |file| config.excluded_path?(file, validator_excludes) }
110
106
  end
111
107
 
112
108
  # Filter result offenses based on per-validator exclusions
@@ -124,23 +120,8 @@ module Yard
124
120
  return nil if result.offenses.empty?
125
121
  return result if validator_excludes.empty?
126
122
 
127
- working_dir = Dir.pwd
128
-
129
123
  filtered_offenses = result.offenses.reject do |offense|
130
- file_path = offense[:location] || offense[:file]
131
- next true unless file_path
132
-
133
- # Convert to relative path for pattern matching
134
- relative_path = if file_path.start_with?(working_dir)
135
- file_path.sub(%r{^#{Regexp.escape(working_dir)}/}, '')
136
- else
137
- file_path
138
- end
139
-
140
- # Check if file matches any exclusion pattern
141
- validator_excludes.any? do |pattern|
142
- File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
143
- end
124
+ config.excluded_path?(offense[:location] || offense[:file], validator_excludes)
144
125
  end
145
126
 
146
127
  # Return nil if no offenses remain after filtering
@@ -156,6 +156,11 @@ Documentation/DuplicateNamespaceComment:
156
156
  Enabled: true
157
157
  Severity: warning
158
158
 
159
+ Documentation/ReferenceTypo:
160
+ Description: 'Detects probable typos in method and constant references.'
161
+ Enabled: true
162
+ Severity: warning
163
+
159
164
  # Tags validators
160
165
  Tags/Order:
161
166
  Description: 'Enforces consistent ordering of YARD tags.'
@@ -381,6 +386,11 @@ Tags/ForbiddenTags:
381
386
  # - Object
382
387
  # - Tag: api # Forbids @api tag entirely (no Types = any occurrence)
383
388
 
389
+ Tags/OverloadConsistency:
390
+ Description: 'Checks the tags of each @overload against its own signature.'
391
+ Enabled: true
392
+ Severity: warning
393
+
384
394
  # Warnings validators - catches YARD parser errors
385
395
  Warnings/SyntaxError:
386
396
  Description: 'Detects Ruby files YARD cannot parse (syntax errors).'
@@ -154,6 +154,11 @@ Documentation/DuplicateNamespaceComment:
154
154
  Enabled: true
155
155
  Severity: error
156
156
 
157
+ Documentation/ReferenceTypo:
158
+ Description: 'Detects probable typos in method and constant references.'
159
+ Enabled: true
160
+ Severity: error
161
+
157
162
  # Tags validators
158
163
  Tags/Order:
159
164
  Description: 'Enforces consistent ordering of YARD tags.'
@@ -379,6 +384,11 @@ Tags/ForbiddenTags:
379
384
  # - Object
380
385
  # - Tag: api # Forbids @api tag entirely (no Types = any occurrence)
381
386
 
387
+ Tags/OverloadConsistency:
388
+ Description: 'Checks the tags of each @overload against its own signature.'
389
+ Enabled: true
390
+ Severity: error
391
+
382
392
  # Warnings validators - catches YARD parser errors
383
393
  Warnings/SyntaxError:
384
394
  Description: 'Detects Ruby files YARD cannot parse (syntax errors).'
@@ -14,12 +14,20 @@ module Yard
14
14
  # @param force [Boolean] whether to overwrite existing todo file if present
15
15
  # @param exclude_limit [Integer] minimum files in directory before grouping into wildcard patterns
16
16
  # @param config_path [String, nil] config file to link the todo into
17
- # (defaults to .yard-lint.yml in the current directory)
17
+ # (defaults to .yard-lint.yml in the config directory)
18
18
  # @return [Hash] result with :message, :offense_count, :validator_count
19
19
  def generate(path:, config:, force: false, exclude_limit: DEFAULT_EXCLUDE_LIMIT, config_path: nil)
20
20
  new(path: path, config: config, force: force, exclude_limit: exclude_limit,
21
21
  config_path: config_path).generate
22
22
  end
23
+
24
+ # Report .yard-lint-todo.yml Exclude entries that no longer match any offense
25
+ # @param path [String] directory or file path containing Ruby files to analyze for violations
26
+ # @param config [Config] yard-lint configuration object with validator settings
27
+ # @return [Hash] result with :message and :unused_entries (validator name => unused patterns)
28
+ def check(path:, config:)
29
+ new(path: path, config: config, force: false, exclude_limit: DEFAULT_EXCLUDE_LIMIT).check
30
+ end
23
31
  end
24
32
 
25
33
  # Initialize a new TodoGenerator instance
@@ -33,8 +41,8 @@ module Yard
33
41
  @config = config
34
42
  @force = force
35
43
  @exclude_limit = exclude_limit
36
- @todo_path = File.join(Dir.pwd, '.yard-lint-todo.yml')
37
- @config_path = config_path || File.join(Dir.pwd, Config::DEFAULT_CONFIG_FILE)
44
+ @todo_path = File.join(config.base_dir, '.yard-lint-todo.yml')
45
+ @config_path = config_path || File.join(config.base_dir, Config::DEFAULT_CONFIG_FILE)
38
46
  end
39
47
 
40
48
  # Generate the .yard-lint-todo.yml file with exclusions for current violations
@@ -65,8 +73,130 @@ module Yard
65
73
  build_success_result(violations_by_validator, lint_result[:total_offenses])
66
74
  end
67
75
 
76
+ # Re-lint without the todo file exclusions and find entries that match no offense
77
+ # Offenses can depend on the other linted files and on their order, so an entry counts as
78
+ # used when it matches an offense of a whole-project run or of a run on the path only.
79
+ # The path only limits which entries are judged: those that cover a file under it,
80
+ # or no project file at all. The given config is not modified.
81
+ # @return [Hash] result with :message and :unused_entries (validator name => unused patterns)
82
+ # @raise [Errors::TodoFileNotFoundError] if the todo file does not exist
83
+ def check
84
+ todo_entries = load_todo_entries
85
+ audit_config = build_audit_config(todo_entries)
86
+ path_files = Yard::Lint.send(:expand_path, @path, audit_config)
87
+ files = Yard::Lint.send(:expand_path, '.', audit_config) | path_files
88
+
89
+ violations_by_validator = audit_violations([files, path_files], audit_config)
90
+ files = files.map { |file| make_relative_path(file) }
91
+ path_files = path_files.map { |file| make_relative_path(file) }
92
+
93
+ unused_entries = todo_entries.each_with_object({}) do |(validator_name, patterns), unused|
94
+ next unless judged?(validator_name, audit_config)
95
+
96
+ violating_files = violations_by_validator.fetch(validator_name, [])
97
+ stale = patterns.select do |pattern|
98
+ in_scope?(pattern, files, path_files) && violating_files.none? { |file| todo_match?(pattern, file) }
99
+ end
100
+ unused[validator_name] = stale if stale.any?
101
+ end
102
+
103
+ { message: build_check_message(unused_entries), unused_entries: unused_entries }
104
+ end
105
+
68
106
  private
69
107
 
108
+ # Load per-validator Exclude patterns from the todo file
109
+ # @return [Hash{String => Array<String>}] validator names to their todo Exclude patterns
110
+ # @raise [Errors::TodoFileNotFoundError] if the todo file does not exist
111
+ def load_todo_entries
112
+ unless File.exist?(@todo_path)
113
+ raise Errors::TodoFileNotFoundError,
114
+ '.yard-lint-todo.yml not found. Use --auto-gen-config to generate it.'
115
+ end
116
+
117
+ ConfigLoader.load_yaml_file(@todo_path).each_with_object({}) do |(validator_name, settings), entries|
118
+ next unless settings.is_a?(Hash) && settings['Exclude'].is_a?(Array)
119
+
120
+ entries[validator_name] = settings['Exclude'].map(&:to_s)
121
+ end
122
+ end
123
+
124
+ # Build a copy of the config without the todo file's Exclude lists
125
+ # A per-validator Exclude replaces an inherited one, so a validator's todo list is in effect
126
+ # only when it is that validator's whole Exclude. Otherwise the user's Exclude stays as is.
127
+ # @param todo_entries [Hash{String => Array<String>}] validator names to todo Exclude patterns
128
+ # @return [Config] configuration for the audit run
129
+ def build_audit_config(todo_entries)
130
+ audit_config = Config.new(Marshal.load(Marshal.dump(@config.raw_config)))
131
+ audit_config.base_dir = @config.base_dir
132
+ audit_config.only_validators = @config.only_validators.dup
133
+
134
+ todo_entries.each do |validator_name, patterns|
135
+ next unless ConfigLoader::ALL_VALIDATORS.include?(validator_name)
136
+ next unless audit_config.validator_config(validator_name, 'Exclude') == patterns
137
+
138
+ audit_config.set_validator_config(validator_name, 'Exclude', [])
139
+ end
140
+
141
+ audit_config
142
+ end
143
+
144
+ # Lint each file set and merge the violating files per validator
145
+ # @param file_sets [Array<Array<String>>] file sets to lint separately
146
+ # @param config [Config] configuration for the audit run
147
+ # @return [Hash{String => Array<String>}] validator names to files with offenses in any run
148
+ def audit_violations(file_sets, config)
149
+ file_sets.uniq(&:sort).each_with_object({}) do |files, merged|
150
+ run_linting(files: files, config: config)[:violations_by_validator].each do |validator_name, paths|
151
+ merged[validator_name] = merged.fetch(validator_name, []) | paths
152
+ end
153
+ end
154
+ end
155
+
156
+ # Whether the audit can judge a validator's todo entries
157
+ # Entries of validators left out by only_validators are not judged, as they were not run.
158
+ # @param validator_name [String] validator name from the todo file
159
+ # @param config [Config] configuration for the audit run
160
+ # @return [Boolean] true unless only_validators leaves the validator out
161
+ def judged?(validator_name, config)
162
+ config.only_validators.empty? || config.only_validators.include?(validator_name)
163
+ end
164
+
165
+ # Whether a todo pattern belongs to the checked path
166
+ # @param pattern [String] todo Exclude pattern
167
+ # @param files [Array<String>] relative paths of all linted files
168
+ # @param path_files [Array<String>] relative paths of the files under the checked path
169
+ # @return [Boolean] true if the pattern covers a file under the path, or no linted file at all
170
+ def in_scope?(pattern, files, path_files)
171
+ path_files.any? { |file| todo_match?(pattern, file) } ||
172
+ files.none? { |file| todo_match?(pattern, file) }
173
+ end
174
+
175
+ # Whether a todo Exclude pattern matches a file
176
+ # @param pattern [String] todo Exclude pattern
177
+ # @param file [String] relative file path
178
+ # @return [Boolean] true if the pattern matches the file
179
+ def todo_match?(pattern, file)
180
+ File.fnmatch(pattern, file, Config::EXCLUDE_FLAGS)
181
+ end
182
+
183
+ # Build the summary message for a todo check
184
+ # @param unused_entries [Hash{String => Array<String>}] validator names to unused patterns
185
+ # @return [String] human-readable summary
186
+ def build_check_message(unused_entries)
187
+ return 'No unused entries found in .yard-lint-todo.yml' if unused_entries.empty?
188
+
189
+ count = unused_entries.values.sum(&:size)
190
+ lines = ["Found #{count} unused entr#{count == 1 ? 'y' : 'ies'} in .yard-lint-todo.yml:"]
191
+ unused_entries.each do |validator_name, patterns|
192
+ lines << " #{validator_name}:"
193
+ patterns.each { |pattern| lines << " - '#{pattern}'" }
194
+ end
195
+ lines << ''
196
+ lines << 'Remove these entries, or run yard-lint --regenerate-todo to rebuild the file.'
197
+ lines.join("\n")
198
+ end
199
+
70
200
  # Validate that the todo file doesn't already exist
71
201
  # @return [void]
72
202
  # @raise [Errors::TodoFileExistsError] if todo file exists and force is false
@@ -78,23 +208,25 @@ module Yard
78
208
  end
79
209
 
80
210
  # Run linting and collect violations per validator
211
+ # @param files [Array<String>, nil] files to lint (defaults to the files under the path)
212
+ # @param config [Config] configuration to lint with
81
213
  # @return [Hash] hash with :violations_by_validator and :total_offenses keys
82
- def run_linting
214
+ def run_linting(files: nil, config: @config)
83
215
  # Run each validator individually to track violations per validator
84
216
  # This is more reliable than trying to infer validator from offense name
85
- files = Yard::Lint.send(:expand_path, @path, @config)
86
- runner = Runner.new(files, @config)
217
+ files ||= Yard::Lint.send(:expand_path, @path, config)
218
+ runner = Runner.new(files, config)
87
219
 
88
220
  # Run validators and collect raw results
89
221
  raw_results = runner.send(:run_validators)
90
- result_builder = ResultBuilder.new(@config)
222
+ result_builder = ResultBuilder.new(config)
91
223
 
92
224
  violations_by_validator = {}
93
225
  total_offenses = 0
94
226
 
95
227
  # Process each validator's results
96
228
  ConfigLoader::ALL_VALIDATORS.each do |validator_name|
97
- next unless @config.validator_enabled?(validator_name)
229
+ next unless config.validator_enabled?(validator_name)
98
230
 
99
231
  validator_result = result_builder.build(validator_name, raw_results)
100
232
  next unless validator_result
@@ -137,14 +269,13 @@ module Yard
137
269
  end
138
270
  end
139
271
 
140
- # Convert absolute path to relative path from current directory
272
+ # Convert a path to the form Exclude patterns match (relative to the config directory)
141
273
  # @param path [String, nil] absolute or relative file path
142
- # @return [String, nil] relative path from current directory, or nil if path is nil
274
+ # @return [String, nil] path relative to the config directory, or nil if path is nil
143
275
  def make_relative_path(path)
144
276
  return path if path.nil?
145
277
 
146
- pwd = Dir.pwd
147
- path.start_with?(pwd) ? path.sub("#{pwd}/", '') : path
278
+ @config.relative_path(path)
148
279
  end
149
280
 
150
281
  # Build YAML content for the todo file
@@ -155,6 +155,9 @@ module Yard
155
155
  # when the object does not come from module_function
156
156
  def module_function_twin(object)
157
157
  return nil unless object.respond_to?(:scope)
158
+ # `def CONST.name` leaves a Proxy or ConstantObject namespace with no
159
+ # children; Proxy#is_a? answers for the resolved object
160
+ return nil unless object.namespace.is_a?(YARD::CodeObjects::NamespaceObject)
158
161
 
159
162
  twin = object.namespace.child(
160
163
  name: object.name,
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module ReferenceTypo
8
+ # Configuration for ReferenceTypo validator
9
+ class Config < ::Yard::Lint::Validators::Config
10
+ self.id = :reference_typo
11
+ self.defaults = {
12
+ 'Enabled' => true,
13
+ 'Severity' => 'warning'
14
+ }.freeze
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module ReferenceTypo
8
+ # Builds human-readable messages for ReferenceTypo violations
9
+ class MessagesBuilder
10
+ class << self
11
+ # Formats a reference typo violation message
12
+ # @param offense [Hash] offense details with :reference and :suggestion keys
13
+ # @return [String] formatted message
14
+ def call(offense)
15
+ "Reference '#{offense[:reference]}' does not resolve " \
16
+ "(did you mean '#{offense[:suggestion]}'?)"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module ReferenceTypo
8
+ # Parser for ReferenceTypo validator output
9
+ class Parser < ::Yard::Lint::Parsers::Base
10
+ # Parses validator output and extracts reference typo violations
11
+ # Expected format (two lines per violation):
12
+ # file.rb:LINE: ObjectName
13
+ # reference|suggestion
14
+ # @param yard_output [String] raw validator output
15
+ # @return [Array<Hash>] array with violation details
16
+ def call(yard_output, **)
17
+ return [] if yard_output.nil? || yard_output.strip.empty?
18
+
19
+ lines = yard_output.split("\n").map(&:strip).reject(&:empty?)
20
+
21
+ lines.each_slice(2).filter_map do |location_line, details_line|
22
+ next unless location_line && details_line
23
+
24
+ location_match = location_line.match(/^(.+):(\d+): (.+)$/)
25
+ next unless location_match
26
+
27
+ reference, suggestion = details_line.split('|', 2)
28
+ next if reference.to_s.empty? || suggestion.to_s.empty?
29
+
30
+ {
31
+ location: location_match[1],
32
+ line: location_match[2].to_i,
33
+ object_name: location_match[3],
34
+ reference: reference,
35
+ suggestion: suggestion
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end