yard-lint 1.6.1 → 1.8.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +52 -8
  4. data/bin/yard-lint +35 -4
  5. data/lib/yard/lint/config.rb +21 -4
  6. data/lib/yard/lint/config_loader.rb +31 -6
  7. data/lib/yard/lint/config_updater.rb +22 -1
  8. data/lib/yard/lint/config_validator.rb +2 -1
  9. data/lib/yard/lint/executor/in_process_registry.rb +58 -23
  10. data/lib/yard/lint/executor/warning_dispatcher.rb +1 -0
  11. data/lib/yard/lint/git.rb +44 -3
  12. data/lib/yard/lint/path_grouper.rb +4 -1
  13. data/lib/yard/lint/results/aggregate.rb +15 -7
  14. data/lib/yard/lint/stats_calculator.rb +8 -2
  15. data/lib/yard/lint/templates/default_config.yml +49 -1
  16. data/lib/yard/lint/templates/strict_config.yml +44 -1
  17. data/lib/yard/lint/todo_generator.rb +35 -14
  18. data/lib/yard/lint/validators/base.rb +50 -1
  19. data/lib/yard/lint/validators/documentation/blank_line_before_definition/validator.rb +17 -2
  20. data/lib/yard/lint/validators/documentation/empty_comment_line/validator.rb +5 -2
  21. data/lib/yard/lint/validators/documentation/line_length/validator.rb +1 -11
  22. data/lib/yard/lint/validators/documentation/markdown_syntax/validator.rb +66 -9
  23. data/lib/yard/lint/validators/documentation/missing_return/validator.rb +3 -3
  24. data/lib/yard/lint/validators/documentation/orphaned_doc_comment/validator.rb +79 -11
  25. data/lib/yard/lint/validators/documentation/orphaned_doc_comment.rb +4 -4
  26. data/lib/yard/lint/validators/documentation/text_substitution/validator.rb +10 -2
  27. data/lib/yard/lint/validators/documentation/underfilled_lines/config.rb +36 -0
  28. data/lib/yard/lint/validators/documentation/underfilled_lines/messages_builder.rb +31 -0
  29. data/lib/yard/lint/validators/documentation/underfilled_lines/parser.rb +64 -0
  30. data/lib/yard/lint/validators/documentation/underfilled_lines/result.rb +26 -0
  31. data/lib/yard/lint/validators/documentation/underfilled_lines/validator.rb +266 -0
  32. data/lib/yard/lint/validators/documentation/underfilled_lines.rb +74 -0
  33. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/config.rb +10 -1
  34. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/parser.rb +18 -4
  35. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/validator.rb +38 -4
  36. data/lib/yard/lint/validators/documentation/undocumented_objects/parser.rb +6 -0
  37. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +30 -0
  38. data/lib/yard/lint/validators/semantic/abstract_methods/result.rb +1 -0
  39. data/lib/yard/lint/validators/semantic/abstract_methods/validator.rb +43 -4
  40. data/lib/yard/lint/validators/tags/api_tags/validator.rb +11 -1
  41. data/lib/yard/lint/validators/tags/collection_type/messages_builder.rb +36 -5
  42. data/lib/yard/lint/validators/tags/collection_type/validator.rb +10 -6
  43. data/lib/yard/lint/validators/tags/example_style/validator.rb +1 -1
  44. data/lib/yard/lint/validators/tags/example_syntax/config.rb +6 -1
  45. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +74 -3
  46. data/lib/yard/lint/validators/tags/forbidden_tags/validator.rb +7 -3
  47. data/lib/yard/lint/validators/tags/informal_notation/config.rb +6 -1
  48. data/lib/yard/lint/validators/tags/informal_notation/validator.rb +24 -3
  49. data/lib/yard/lint/validators/tags/invalid_types/config.rb +7 -1
  50. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +22 -5
  51. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +24 -10
  52. data/lib/yard/lint/validators/tags/missing_yield/validator.rb +44 -4
  53. data/lib/yard/lint/validators/tags/missing_yield.rb +8 -8
  54. data/lib/yard/lint/validators/tags/non_ascii_type/validator.rb +13 -1
  55. data/lib/yard/lint/validators/tags/option_tags/result.rb +1 -0
  56. data/lib/yard/lint/validators/tags/option_tags/validator.rb +30 -2
  57. data/lib/yard/lint/validators/tags/order/parser.rb +12 -5
  58. data/lib/yard/lint/validators/tags/order/validator.rb +7 -2
  59. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +21 -8
  60. data/lib/yard/lint/validators/tags/tag_group_separator/parser.rb +12 -5
  61. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +9 -7
  62. data/lib/yard/lint/validators/tags/tag_type_position/validator.rb +8 -2
  63. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +1 -1
  64. data/lib/yard/lint/validators/warnings/invalid_directive_format/parser.rb +2 -2
  65. data/lib/yard/lint/validators/warnings/invalid_tag_format/parser.rb +2 -2
  66. data/lib/yard/lint/validators/warnings/syntax_error/config.rb +22 -0
  67. data/lib/yard/lint/validators/warnings/syntax_error/parser.rb +28 -0
  68. data/lib/yard/lint/validators/warnings/syntax_error/result.rb +27 -0
  69. data/lib/yard/lint/validators/warnings/syntax_error/validator.rb +15 -0
  70. data/lib/yard/lint/validators/warnings/syntax_error.rb +34 -0
  71. data/lib/yard/lint/validators/warnings/unknown_directive/parser.rb +2 -2
  72. data/lib/yard/lint/validators/warnings/unknown_parameter_name/messages_builder.rb +51 -46
  73. data/lib/yard/lint/validators/warnings/unknown_tag/messages_builder.rb +20 -3
  74. data/lib/yard/lint/validators/warnings/unknown_tag/parser.rb +2 -2
  75. data/lib/yard/lint/version.rb +1 -1
  76. data/lib/yard/lint.rb +4 -1
  77. metadata +12 -1
data/lib/yard/lint/git.rb CHANGED
@@ -71,18 +71,33 @@ module Yard
71
71
  def uncommitted_files(path)
72
72
  ensure_git_repository!
73
73
 
74
- # Get both staged and unstaged changes
74
+ # Get both staged and unstaged changes to tracked files
75
75
  stdout, stderr, status = Open3.capture3('git', 'diff', '--name-only', 'HEAD')
76
76
 
77
77
  unless status.success?
78
78
  raise Error, "Git diff failed: #{stderr.strip}"
79
79
  end
80
80
 
81
- filter_ruby_files(stdout.split("\n"), path)
81
+ files = stdout.split("\n")
82
+
83
+ # Also include untracked (but not git-ignored) files - "all changes in
84
+ # the working directory" should cover newly added, not-yet-staged files.
85
+ others_stdout, _stderr, others_status =
86
+ Open3.capture3('git', 'ls-files', '--others', '--exclude-standard')
87
+ files += others_stdout.split("\n") if others_status.success?
88
+
89
+ filter_ruby_files(files.uniq, path)
82
90
  end
83
91
 
84
92
  private
85
93
 
94
+ # Absolute path to the repository root (git reports paths relative to it)
95
+ # @return [String] repo root, or the current directory if it can't be determined
96
+ def repository_root
97
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel')
98
+ status.success? ? stdout.strip : Dir.pwd
99
+ end
100
+
86
101
  # Ensure we're in a git repository
87
102
  # @raise [Error] if not in a git repository
88
103
  def ensure_git_repository!
@@ -99,14 +114,40 @@ module Yard
99
114
  # @return [Array<String>] absolute paths to Ruby files that exist
100
115
  def filter_ruby_files(files, path)
101
116
  base_path = File.expand_path(path)
117
+ # git reports paths relative to the repository root, which is not
118
+ # necessarily the current working directory, so expand against the
119
+ # repo root - otherwise running from a subdirectory finds nothing.
120
+ root = repository_root
102
121
 
103
122
  files
123
+ .map { |f| unquote_git_path(f) }
104
124
  .select { |f| f.end_with?('.rb') }
105
- .map { |f| File.expand_path(f) }
125
+ .map { |f| File.expand_path(f, root) }
106
126
  .select { |f| File.exist?(f) } # Skip deleted files
107
127
  .select { |f| file_within_path?(f, base_path) }
108
128
  end
109
129
 
130
+ # Unquotes a path as git emits it with the default core.quotepath=true:
131
+ # paths containing non-ASCII (or special) bytes are wrapped in double
132
+ # quotes with C-style escapes (e.g. "caf\303\251.rb"). Without this such
133
+ # files end with `"` rather than `.rb` and are silently dropped.
134
+ # @param path [String] a path from git output
135
+ # @return [String] the unquoted path
136
+ def unquote_git_path(path)
137
+ return path unless path.start_with?('"') && path.end_with?('"')
138
+
139
+ inner = path[1..-2]
140
+ unescaped = inner.gsub(/\\(?:(\d{3})|(.))/) do
141
+ octal = Regexp.last_match(1)
142
+ if octal
143
+ octal.to_i(8).chr
144
+ else
145
+ { 't' => "\t", 'n' => "\n", 'r' => "\r" }.fetch(Regexp.last_match(2), Regexp.last_match(2))
146
+ end
147
+ end
148
+ unescaped.force_encoding(Encoding::UTF_8)
149
+ end
150
+
110
151
  # Check if file is within the specified path
111
152
  # @param file [String] absolute file path
112
153
  # @param base_path [String] absolute base path
@@ -33,7 +33,10 @@ module Yard
33
33
 
34
34
  by_dir.each do |dir, dir_files|
35
35
  if should_group_directory?(dir, dir_files.uniq, limit)
36
- result << "#{dir}/**/*"
36
+ # For root-level files File.dirname is ".", and "./**/*" matches
37
+ # nothing under File.fnmatch (FNM_PATHNAME), so the generated todo
38
+ # file would fail to exclude them. Use a plain recursive glob.
39
+ result << (dir == '.' ? '**/*' : "#{dir}/**/*")
37
40
  else
38
41
  result.concat(dir_files.uniq)
39
42
  end
@@ -26,9 +26,14 @@ module Yard
26
26
  end
27
27
 
28
28
  # Get all offenses from all validators
29
- # @return [Array<Hash>] flattened array of all offenses
29
+ # Identical offenses are reported once: objects sharing one docstring
30
+ # (e.g. the reader and writer generated by attr_accessor) produce the
31
+ # same offense for each generated method
32
+ # @return [Array<Hash>] flattened array of unique offenses
30
33
  def offenses
31
- @results.flat_map(&:offenses)
34
+ @offenses ||= @results.flat_map(&:offenses).uniq do |offense|
35
+ offense.values_at(:validator, :name, :location, :location_line, :message)
36
+ end
32
37
  end
33
38
 
34
39
  # Total number of offenses
@@ -78,10 +83,11 @@ module Yard
78
83
  # @return [Integer] 0 for success, 1 for failure
79
84
  def exit_code
80
85
  # Check minimum coverage requirement first
81
- if @config&.min_coverage &&
82
- documentation_coverage &&
83
- documentation_coverage[:coverage] < @config.min_coverage
84
- return 1
86
+ if @config&.min_coverage
87
+ coverage = documentation_coverage
88
+ # Fail safe: if a minimum is required but coverage could not be
89
+ # determined (e.g. the YARD stats subprocess failed), do not pass.
90
+ return 1 if coverage.nil? || coverage[:coverage] < @config.min_coverage
85
91
  end
86
92
 
87
93
  return 0 if offenses.empty?
@@ -95,7 +101,9 @@ module Yard
95
101
  when SEVERITY_WARNING
96
102
  (statistics[:error] + statistics[:warning]).positive? ? 1 : 0
97
103
  when SEVERITY_CONVENTION
98
- offenses.any? ? 1 : 0
104
+ # Exclude 'never'-severity offenses, which are meant to run without
105
+ # ever failing the build (they are also omitted from #statistics).
106
+ (statistics[:error] + statistics[:warning] + statistics[:convention]).positive? ? 1 : 0
99
107
  else
100
108
  0
101
109
  end
@@ -20,6 +20,10 @@ module Yard
20
20
  return default_stats if files.empty?
21
21
 
22
22
  raw_stats = run_yard_stats_query
23
+ # nil means the YARD subprocess failed - return nil (coverage unknown)
24
+ # rather than default_stats, which would falsely report 100% coverage
25
+ # and let a MinCoverage gate pass.
26
+ return nil if raw_stats.nil?
23
27
  return default_stats if raw_stats.empty?
24
28
 
25
29
  parsed_stats = parse_stats_output(raw_stats)
@@ -52,8 +56,10 @@ module Yard
52
56
 
53
57
  stdout, _stderr, status = Open3.capture3(cmd)
54
58
 
55
- # Return empty string if YARD command fails
56
- return '' unless status.exitstatus.zero?
59
+ # Return nil if the YARD command fails (non-zero exit, or killed by
60
+ # a signal so exitstatus is nil) - distinct from genuinely empty
61
+ # output, so coverage is reported as unknown rather than 100%.
62
+ return nil unless status.exitstatus&.zero?
57
63
 
58
64
  stdout
59
65
  end
@@ -44,6 +44,12 @@ Documentation/UndocumentedMethodArguments:
44
44
  Description: 'Checks for method parameters without @param tags.'
45
45
  Enabled: true
46
46
  Severity: warning
47
+ # Match each parameter to a @param tag by name (catches a misnamed @param).
48
+ # Set to false to fall back to a lenient count-only comparison.
49
+ CheckParameterNames: true
50
+ # Opt-in: skip methods with no documentation at all (reported by
51
+ # Documentation/UndocumentedObjects) instead of flagging them here too.
52
+ SkipFullyUndocumented: false
47
53
  # AllowedParentClasses:
48
54
  # - StandardError
49
55
  # AllowedMethods: skip @param checks for specific methods (exact name, name/arity, /regex/)
@@ -108,6 +114,21 @@ Documentation/LineLength:
108
114
  Severity: convention
109
115
  MaxLength: 120
110
116
 
117
+ Documentation/UnderfilledLines:
118
+ Description: 'Detects documentation prose that wraps before using the available line width.'
119
+ Enabled: false # Opt-in validator (the inverse of LineLength)
120
+ Severity: convention
121
+ MaxLength: 120
122
+ # Only flag when the widest non-final line wastes at least this many columns.
123
+ MinTrailingSpace: 20
124
+ # Single-line descriptions are never flagged.
125
+ MinParagraphLines: 2
126
+ # A non-final line ending in one of these is a deliberate break (skipped).
127
+ # Add ',' to also respect comma breaks (suppresses more, catches less).
128
+ SentenceEndChars: ['.', '?', '!', ':', ';']
129
+ # Skip paragraphs with non-ASCII text (String#length is not display width).
130
+ SkipNonAscii: true
131
+
111
132
  Documentation/TextSubstitution:
112
133
  Description: 'Detects forbidden characters or strings in documentation and suggests replacements.'
113
134
  Enabled: false # Opt-in validator
@@ -142,6 +163,13 @@ Tags/InvalidTypes:
142
163
  - param
143
164
  - option
144
165
  - return
166
+ - yieldreturn
167
+ - yieldparam
168
+ - raise
169
+ # Opt-in: flag CamelCase type names that are neither loaded Ruby constants
170
+ # nor defined in the analyzed codebase (catches typos like `Strng`). Off by
171
+ # default because types from un-analyzed dependencies would also be flagged.
172
+ StrictConstantNames: false
145
173
 
146
174
  Tags/TypeSyntax:
147
175
  Description: 'Validates YARD type syntax using YARD parser.'
@@ -152,6 +180,8 @@ Tags/TypeSyntax:
152
180
  - option
153
181
  - return
154
182
  - yieldreturn
183
+ - yieldparam
184
+ - raise
155
185
 
156
186
  Tags/MeaninglessTag:
157
187
  Description: 'Detects @param/@option tags on classes, modules, or constants.'
@@ -180,6 +210,8 @@ Tags/CollectionType:
180
210
  - option
181
211
  - return
182
212
  - yieldreturn
213
+ - yieldparam
214
+ - raise
183
215
 
184
216
  Tags/TagTypePosition:
185
217
  Description: 'Validates type annotation position in tags.'
@@ -210,6 +242,10 @@ Tags/ExampleSyntax:
210
242
  Description: 'Validates Ruby syntax in @example tags.'
211
243
  Enabled: true
212
244
  Severity: warning
245
+ # Opt-in: skip @example blocks that are interactive console transcripts
246
+ # (irb/pry sessions, their `=>` output, or shell `$` prompts) rather than
247
+ # runnable Ruby. Off by default so a real syntax error is not hidden.
248
+ SkipNonRuby: false
213
249
 
214
250
  Tags/ExampleStyle:
215
251
  Description: 'Validates code style in @example tags using RuboCop/StandardRB.'
@@ -258,14 +294,20 @@ Tags/InformalNotation:
258
294
  Severity: warning
259
295
  CaseSensitive: false
260
296
  RequireStartOfLine: true
297
+ # Opt-in: also skip 4-space/tab indented Markdown code blocks (not just
298
+ # fenced ``` blocks). Off by default - indented text is also used for list
299
+ # continuations and wrapped prose, which would then be skipped too.
300
+ SkipIndentedCodeBlocks: false
261
301
  Patterns:
262
302
  Note: '@note'
303
+ IMPORTANT: '@note'
304
+ Important: '@note'
263
305
  Todo: '@todo'
264
306
  TODO: '@todo'
265
307
  FIXME: '@todo'
266
308
  See: '@see'
267
309
  See also: '@see'
268
- Warning: '@deprecated'
310
+ Warning: '@note'
269
311
  Deprecated: '@deprecated'
270
312
  Author: '@author'
271
313
  Version: '@version'
@@ -284,6 +326,7 @@ Tags/NonAsciiType:
284
326
  - return
285
327
  - yieldreturn
286
328
  - yieldparam
329
+ - raise
287
330
 
288
331
  Tags/TagGroupSeparator:
289
332
  Description: 'Enforces blank line separators between different YARD tag groups.'
@@ -313,6 +356,11 @@ Tags/ForbiddenTags:
313
356
  # - Tag: api # Forbids @api tag entirely (no Types = any occurrence)
314
357
 
315
358
  # Warnings validators - catches YARD parser errors
359
+ Warnings/SyntaxError:
360
+ Description: 'Detects Ruby files YARD cannot parse (syntax errors).'
361
+ Enabled: true
362
+ Severity: error
363
+
316
364
  Warnings/UnknownTag:
317
365
  Description: 'Detects unknown YARD tags.'
318
366
  Enabled: true
@@ -48,6 +48,11 @@ Documentation/UndocumentedMethodArguments:
48
48
  Description: 'Checks for method parameters without @param tags.'
49
49
  Enabled: true
50
50
  Severity: error
51
+ # Match each parameter to a @param tag by name (catches a misnamed @param).
52
+ CheckParameterNames: true
53
+ # Opt-in: skip methods with no documentation at all (reported by
54
+ # Documentation/UndocumentedObjects) instead of flagging them here too.
55
+ SkipFullyUndocumented: false
51
56
  # AllowedParentClasses:
52
57
  # - StandardError
53
58
  # AllowedMethods: skip @param checks for specific methods (exact name, name/arity, /regex/)
@@ -112,6 +117,16 @@ Documentation/LineLength:
112
117
  Severity: error
113
118
  MaxLength: 120
114
119
 
120
+ Documentation/UnderfilledLines:
121
+ Description: 'Detects documentation prose that wraps before using the available line width.'
122
+ Enabled: false # Opt-in validator (heuristic; stays convention even in strict)
123
+ Severity: convention
124
+ MaxLength: 120
125
+ MinTrailingSpace: 20
126
+ MinParagraphLines: 2
127
+ SentenceEndChars: ['.', '?', '!', ':', ';']
128
+ SkipNonAscii: true
129
+
115
130
  Documentation/TextSubstitution:
116
131
  Description: 'Detects forbidden characters or strings in documentation and suggests replacements.'
117
132
  Enabled: true
@@ -146,6 +161,13 @@ Tags/InvalidTypes:
146
161
  - param
147
162
  - option
148
163
  - return
164
+ - yieldreturn
165
+ - yieldparam
166
+ - raise
167
+ # The strict preset opts in: flag CamelCase type names that are neither loaded
168
+ # Ruby constants nor defined in the analyzed codebase (catches typos). Add any
169
+ # legitimate dependency types to ExtraTypes to silence false positives.
170
+ StrictConstantNames: true
149
171
 
150
172
  Tags/TypeSyntax:
151
173
  Description: 'Validates YARD type syntax using YARD parser.'
@@ -156,6 +178,8 @@ Tags/TypeSyntax:
156
178
  - option
157
179
  - return
158
180
  - yieldreturn
181
+ - yieldparam
182
+ - raise
159
183
 
160
184
  Tags/MeaninglessTag:
161
185
  Description: 'Detects @param/@option tags on classes, modules, or constants.'
@@ -184,6 +208,8 @@ Tags/CollectionType:
184
208
  - option
185
209
  - return
186
210
  - yieldreturn
211
+ - yieldparam
212
+ - raise
187
213
 
188
214
  Tags/TagTypePosition:
189
215
  Description: 'Validates type annotation position in tags.'
@@ -214,6 +240,10 @@ Tags/ExampleSyntax:
214
240
  Description: 'Validates Ruby syntax in @example tags.'
215
241
  Enabled: true
216
242
  Severity: error
243
+ # Opt-in: skip @example blocks that are interactive console transcripts
244
+ # (irb/pry sessions, their `=>` output, or shell `$` prompts) rather than
245
+ # runnable Ruby. Off by default so a real syntax error is not hidden.
246
+ SkipNonRuby: false
217
247
 
218
248
  Tags/ExampleStyle:
219
249
  Description: 'Validates code style in @example tags using RuboCop/StandardRB.'
@@ -254,6 +284,7 @@ Tags/RedundantParamDescription:
254
284
  IdPattern: true
255
285
  DirectionalDate: true
256
286
  TypeGeneric: true
287
+ ArticleParamPhrase: true
257
288
 
258
289
  Tags/InformalNotation:
259
290
  Description: 'Detects informal tag notation patterns like "Note:" instead of @note.'
@@ -261,14 +292,20 @@ Tags/InformalNotation:
261
292
  Severity: error
262
293
  CaseSensitive: false
263
294
  RequireStartOfLine: true
295
+ # Opt-in: also skip 4-space/tab indented Markdown code blocks (not just
296
+ # fenced ``` blocks). Off by default - indented text is also used for list
297
+ # continuations and wrapped prose, which would then be skipped too.
298
+ SkipIndentedCodeBlocks: false
264
299
  Patterns:
265
300
  Note: '@note'
301
+ IMPORTANT: '@note'
302
+ Important: '@note'
266
303
  Todo: '@todo'
267
304
  TODO: '@todo'
268
305
  FIXME: '@todo'
269
306
  See: '@see'
270
307
  See also: '@see'
271
- Warning: '@deprecated'
308
+ Warning: '@note'
272
309
  Deprecated: '@deprecated'
273
310
  Author: '@author'
274
311
  Version: '@version'
@@ -287,6 +324,7 @@ Tags/NonAsciiType:
287
324
  - return
288
325
  - yieldreturn
289
326
  - yieldparam
327
+ - raise
290
328
 
291
329
  Tags/TagGroupSeparator:
292
330
  Description: 'Enforces blank line separators between different YARD tag groups.'
@@ -316,6 +354,11 @@ Tags/ForbiddenTags:
316
354
  # - Tag: api # Forbids @api tag entirely (no Types = any occurrence)
317
355
 
318
356
  # Warnings validators - catches YARD parser errors
357
+ Warnings/SyntaxError:
358
+ Description: 'Detects Ruby files YARD cannot parse (syntax errors).'
359
+ Enabled: true
360
+ Severity: error
361
+
319
362
  Warnings/UnknownTag:
320
363
  Description: 'Detects unknown YARD tags.'
321
364
  Enabled: true
@@ -13,9 +13,12 @@ module Yard
13
13
  # @param config [Config] yard-lint configuration object with validator settings
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
+ # @param config_path [String, nil] config file to link the todo into
17
+ # (defaults to .yard-lint.yml in the current directory)
16
18
  # @return [Hash] result with :message, :offense_count, :validator_count
17
- def generate(path:, config:, force: false, exclude_limit: DEFAULT_EXCLUDE_LIMIT)
18
- new(path: path, config: config, force: force, exclude_limit: exclude_limit).generate
19
+ def generate(path:, config:, force: false, exclude_limit: DEFAULT_EXCLUDE_LIMIT, config_path: nil)
20
+ new(path: path, config: config, force: force, exclude_limit: exclude_limit,
21
+ config_path: config_path).generate
19
22
  end
20
23
  end
21
24
 
@@ -24,13 +27,14 @@ module Yard
24
27
  # @param config [Config] yard-lint configuration object
25
28
  # @param force [Boolean] whether to overwrite existing todo file
26
29
  # @param exclude_limit [Integer] minimum files before grouping into patterns
27
- def initialize(path:, config:, force:, exclude_limit:)
30
+ # @param config_path [String, nil] config file to link the todo into
31
+ def initialize(path:, config:, force:, exclude_limit:, config_path: nil)
28
32
  @path = path
29
33
  @config = config
30
34
  @force = force
31
35
  @exclude_limit = exclude_limit
32
36
  @todo_path = File.join(Dir.pwd, '.yard-lint-todo.yml')
33
- @config_path = File.join(Dir.pwd, Config::DEFAULT_CONFIG_FILE)
37
+ @config_path = config_path || File.join(Dir.pwd, Config::DEFAULT_CONFIG_FILE)
34
38
  end
35
39
 
36
40
  # Generate the .yard-lint-todo.yml file with exclusions for current violations
@@ -93,6 +97,12 @@ module Yard
93
97
  next unless @config.validator_enabled?(validator_name)
94
98
 
95
99
  validator_result = result_builder.build(validator_name, raw_results)
100
+ next unless validator_result
101
+
102
+ # Apply per-validator Exclude patterns (and drop no-location offenses)
103
+ # exactly as a normal run does, so the baseline does not silence files
104
+ # that are already excluded.
105
+ validator_result = runner.send(:filter_result_offenses, validator_name, validator_result)
96
106
  next unless validator_result && validator_result.offenses.any?
97
107
 
98
108
  # Extract file paths from offenses, skipping those without a location
@@ -206,17 +216,28 @@ module Yard
206
216
  # Update existing config file to add inherit_from todo file
207
217
  # @return [void]
208
218
  def update_existing_config
209
- config_yaml = YAML.load_file(@config_path) || {}
210
- inherit_from = Array(config_yaml['inherit_from'] || [])
211
-
212
- # Add todo file to inherit_from if not already present
213
- unless inherit_from.include?('.yard-lint-todo.yml')
214
- inherit_from.unshift('.yard-lint-todo.yml')
215
- config_yaml['inherit_from'] = inherit_from
219
+ config_yaml = ConfigLoader.load_yaml_file(@config_path)
220
+ return if Array(config_yaml['inherit_from']).include?('.yard-lint-todo.yml')
221
+
222
+ raw = File.read(@config_path)
223
+
224
+ # Edit the file textually rather than round-tripping through to_yaml,
225
+ # which would strip every comment and reformat the user's config.
226
+ updated =
227
+ if config_yaml.key?('inherit_from') && raw.match?(/^inherit_from:[ \t]*$/)
228
+ # Existing block list - prepend our entry as the first item.
229
+ raw.sub(/^inherit_from:[ \t]*$\n/) { "#{Regexp.last_match(0)} - .yard-lint-todo.yml\n" }
230
+ elsif config_yaml.key?('inherit_from')
231
+ # Existing inline/array form (rare) - fall back to a structured
232
+ # rewrite; comments cannot be preserved in this case.
233
+ config_yaml['inherit_from'] = ['.yard-lint-todo.yml'] + Array(config_yaml['inherit_from'])
234
+ config_yaml.to_yaml
235
+ else
236
+ # No inherit_from yet - prepend a block, preserving the file verbatim.
237
+ "inherit_from:\n - .yard-lint-todo.yml\n\n#{raw}"
238
+ end
216
239
 
217
- # Write updated config
218
- File.write(@config_path, config_yaml.to_yaml)
219
- end
240
+ File.write(@config_path, updated)
220
241
  end
221
242
 
222
243
  # Create a minimal config file that inherits from todo file
@@ -97,6 +97,52 @@ module Yard
97
97
  tags
98
98
  end
99
99
 
100
+ # Tracks docstring locations already processed by this validator
101
+ # instance. Objects generated from a single comment block (e.g. the
102
+ # reader and writer created by attr_accessor) share one docstring;
103
+ # content-scanning validators use this to report each docstring once.
104
+ # @param object [YARD::CodeObjects::Base] the code object to check
105
+ # @return [Boolean] true if this object's docstring was already seen
106
+ def duplicate_docstring?(object)
107
+ @scanned_docstrings ||= Set.new
108
+ key = [object.file, object.docstring.line_range&.first || object.line]
109
+
110
+ !@scanned_docstrings.add?(key)
111
+ end
112
+
113
+ # Converts a zero-based line offset within a docstring's text into an
114
+ # absolute line number in the source file, so offenses can point at
115
+ # the offending documentation line instead of the definition line.
116
+ # @param object [YARD::CodeObjects::Base] the documented object
117
+ # @param line_offset [Integer] zero-based offset within the docstring text
118
+ # @return [Integer] absolute source line number
119
+ def docstring_line(object, line_offset)
120
+ start_line = object.docstring.line_range&.first || object.line
121
+
122
+ start_line + line_offset
123
+ end
124
+
125
+ # Returns the lines of a source file, reading from disk only on the first
126
+ # call for each unique path. Invalid bytes are scrubbed so that callers
127
+ # matching regexes against the lines never raise Encoding::CompatibilityError
128
+ # on a non-UTF-8 source file.
129
+ # @param file [String] absolute path to the source file
130
+ # @return [Array<String>] lines of the file, memoized per path
131
+ def cached_lines(file)
132
+ @file_cache ||= {}
133
+ @file_cache[file] ||= File.readlines(file).map!(&:scrub)
134
+ end
135
+
136
+ # Returns the tag that actually carries a tag's types and description.
137
+ # For most tags that is the tag itself, but @option tags wrap their
138
+ # data in a nested pair tag - tag.types and tag.text are nil on the
139
+ # OptionTag itself, with the documented option living on tag.pair.
140
+ # @param tag [YARD::Tags::Tag] tag whose data holder should be resolved
141
+ # @return [YARD::Tags::Tag] the tag holding types/text data
142
+ def tag_data(tag)
143
+ tag.respond_to?(:pair) && tag.pair ? tag.pair : tag
144
+ end
145
+
100
146
  # Checks whether the object's enclosing class (or the object itself if it is
101
147
  # a class) has a superclass that appears in the validator's AllowedParentClasses
102
148
  # configuration list. When true, validators skip the object so that classes
@@ -236,7 +282,10 @@ module Yard
236
282
 
237
283
  return defaults[key] unless validator_name
238
284
 
239
- config.validator_config(validator_name, key) || defaults[key]
285
+ value = config.validator_config(validator_name, key)
286
+ # A nil? check (not ||) so that explicitly configured false values
287
+ # are honored instead of falling back to a truthy default
288
+ value.nil? ? defaults[key] : value
240
289
  end
241
290
  end
242
291
  end
@@ -50,8 +50,11 @@ module Yard
50
50
  if stripped.empty?
51
51
  blank_count += 1
52
52
  elsif stripped.start_with?('#')
53
- # Skip Ruby magic comments - they're not YARD documentation
54
- next if magic_comment?(stripped)
53
+ # Skip lines that are not YARD documentation: magic comments,
54
+ # shebangs, tool sigils/directives (Sorbet, RuboCop, Standard),
55
+ # and bare `#` separators. Treating them as a doc block caused
56
+ # spurious blank-line offenses for undocumented definitions.
57
+ next if non_documentation_comment?(stripped)
55
58
 
56
59
  has_doc_block = true
57
60
  break
@@ -72,6 +75,18 @@ module Yard
72
75
  line.match?(/^#\s*(frozen[_-]string[_-]literal|encoding|warn[_-]indent|shareable[_-]constant[_-]value)\s*:/i)
73
76
  end
74
77
 
78
+ # Check if a comment line is not YARD documentation (magic comment,
79
+ # shebang, tool sigil/directive, or a bare `#` separator).
80
+ # @param line [String] stripped comment line
81
+ # @return [Boolean] true if the line should not count as documentation
82
+ def non_documentation_comment?(line)
83
+ magic_comment?(line) ||
84
+ line.start_with?('#!') || # shebang
85
+ line.match?(/\A#\s*(rubocop|standard):/i) || # linter directives
86
+ line.match?(/\A#\s*typed:/i) || # Sorbet sigil
87
+ line.match?(/\A#+\s*\z/) # bare # separator
88
+ end
89
+
75
90
  # Check if the given pattern is enabled in configuration
76
91
  # @param violation_type [String] 'single' or 'orphaned'
77
92
  # @return [Boolean] whether the pattern is enabled
@@ -33,8 +33,11 @@ module Yard
33
33
  stripped = line.strip
34
34
 
35
35
  if stripped.empty? && comment_end.nil?
36
- # Skip empty lines before finding comment block
37
- next
36
+ # A blank line between the definition and any comment above
37
+ # means there is no attached docstring (YARD only attaches a
38
+ # comment immediately above the definition), so stop scanning -
39
+ # otherwise a detached file header is mistaken for the doc.
40
+ break
38
41
  elsif stripped.start_with?('#')
39
42
  comment_end ||= i
40
43
  comment_start = i
@@ -23,6 +23,7 @@ module Yard
23
23
 
24
24
  line_range = object.docstring.line_range
25
25
  return unless line_range
26
+ return if duplicate_docstring?(object)
26
27
 
27
28
  max_length = config_or_default('MaxLength').to_i
28
29
  source_lines = cached_lines(object.file)
@@ -40,17 +41,6 @@ module Yard
40
41
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
41
42
  collector.puts "#{max_length}|#{violations.join('|')}"
42
43
  end
43
-
44
- private
45
-
46
- # Returns the lines of a source file, reading from disk only on the first call
47
- # for each unique path.
48
- # @param file [String] absolute path to the source file
49
- # @return [Array<String>] lines of the file, memoized per path
50
- def cached_lines(file)
51
- @file_cache ||= {}
52
- @file_cache[file] ||= File.readlines(file)
53
- end
54
44
  end
55
45
  end
56
46
  end