yard-lint 1.7.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3e167ca549d10a6391cdcee97de9e53356448146f58d045dbd7aed87466460c
4
- data.tar.gz: 17197fe60701c4b0f93deefb4b9b933e7deba9eaa9499d201908649b09da1019
3
+ metadata.gz: '04219019cfc32c314c95eea949d26bd01a92c00cecbcffd44149ed537a09d148'
4
+ data.tar.gz: 563bc04039db71d71510bfcd1feba0f7f2bc01b025807f22d97f6085e5a30282
5
5
  SHA512:
6
- metadata.gz: f274aa4a55a41ab329ba44a6c76d4d2c0c9a431ea8da8a2ded67ee2208adec3cf7a658d275fc5ef843511ddb23dfdae96575dee565cd7435b93330af614ef4b0
7
- data.tar.gz: ad921b1b3156088cbaeaf760773bf1d6f947759d7b29555cb2133586d5ccf12f1b41f98968598bf3ed21e6b98755727b835088f05d760d131bc081e9adffea8a
6
+ metadata.gz: 0f7856c26319046238cfd2fbf9515f0d21d0f50915df9e14e92971dc7c3555d028e4e83a267f0385b96a9453624b7f8b4fc209f2f609bb1044abda4516c0ac21
7
+ data.tar.gz: 3150e72089b7268bd63db4557eafc8dfc72ba7d398adc111442bf5f9f7534bb44284584074942dd8f5a10300cf5b44f9d11ec0e70dd0968fbd3326f8312853c4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 1.8.0 (2026-06-17)
2
+ - **[Feature]** Added `Documentation/UnderfilledLines` (opt-in, disabled by default, severity `convention`) - the inverse of `Documentation/LineLength`. Where `LineLength` flags comment lines that are too long, `UnderfilledLines` flags documentation prose that wraps *too early*: text that uses only a fraction of the available width and spills onto extra lines, wasting vertical space (common in AI-generated docs). It reports one offense per wasteful paragraph, and only when greedily re-wrapping the paragraph's words at `MaxLength` would genuinely use fewer lines, the widest line wastes at least `MinTrailingSpace` columns (default 20), and the paragraph is not deliberately broken one sentence/clause per line. Only the free-text description body is checked - YARD tags, fenced/indented code, lists, tables, headings, blockquotes and non-ASCII text are skipped. Because "this line should have been longer" is a stylistic judgement, the validator is deliberately conservative (biased toward not producing false positives) and stays opt-in; projects that use semantic line breaks (one sentence/clause per line, see sembr.org) are never flagged. Configurable via `MaxLength`, `MinTrailingSpace`, `MinParagraphLines`, `SentenceEndChars`, and `SkipNonAscii`.
3
+ - **[Change]** Centralized source-file reading into `Validators::Base#cached_lines` (shared by `Documentation/LineLength` and `Documentation/UnderfilledLines` instead of being duplicated in each), and made it scrub invalid bytes so a non-UTF-8 source file can no longer raise `Encoding::CompatibilityError` while a validator matches a regex against its lines.
4
+
1
5
  ## 1.7.0 (2026-06-15)
2
6
  - **[Feature]** `Tags/ExampleSyntax` gained an opt-in `SkipNonRuby` option (default `false`). The validator compiles every `@example` body as Ruby, so an irb/pry session, its `=>` output, or a shell `$` transcript was reported as a syntax error. With `SkipNonRuby: true`, `@example` blocks that are interactive console transcripts are skipped. Off by default so a genuine syntax error in a normal example is not hidden.
3
7
  - **[Feature]** `Tags/InvalidTypes` gained an opt-in `StrictConstantNames` option (default `false`). The type check was deliberately lenient - any syntactically valid constant name was accepted - so a misspelled class name like `Strng` was never flagged, defeating the validator's headline use case. With `StrictConstantNames: true`, a CamelCase type that is neither a loaded Ruby constant nor resolvable in the analyzed codebase's YARD registry is reported. It stays off by default (types defined only in un-analyzed dependencies would otherwise be flagged - add those to `ExtraTypes`); the strict template (`--init --strict`) enables it. `Boolean` is now always accepted as a pseudo-type.
data/README.md CHANGED
@@ -25,13 +25,13 @@ YARD-Lint validates your YARD documentation for:
25
25
  - **Parse Errors** - Files YARD cannot parse (syntax errors) are reported instead of silently skipped, so a run never passes over code that does not parse
26
26
  - **Code Examples** - Syntax validation in `@example` tags (with an opt-in skip for irb/pry/shell console transcripts), optional style validation with RuboCop/StandardRB
27
27
  - **Semantic Correctness** - Abstract methods with implementations, redundant descriptions
28
- - **Style & Formatting** - Empty comment lines, blank lines before definitions, informal notation patterns, tag group separators, configurable documentation line length (opt-in)
28
+ - **Style & Formatting** - Empty comment lines, blank lines before definitions, informal notation patterns, tag group separators, and configurable documentation line length and line fill — flagging comment lines that are too long or prose that wraps before using the available width (both opt-in)
29
29
  - **Smart Suggestions** - "Did you mean" suggestions for typos in parameter names, tags, and configuration settings
30
30
  - **Configuration Safety** - Validates `.yard-lint.yml` for typos and invalid settings before processing
31
31
  - **Performance** - In-process YARD execution with shared registry (~10x faster than shell-based execution)
32
32
  - **Incremental Adoption** - `--auto-gen-config` generates a baseline todo file to adopt on legacy codebases without fixing everything first
33
33
 
34
- **See the complete list:** [All Features](https://github.com/mensfeld/yard-lint/wiki/Features) | [34 Validators](https://github.com/mensfeld/yard-lint/wiki/Validators)
34
+ **See the complete list:** [All Features](https://github.com/mensfeld/yard-lint/wiki/Features) | [36 Validators](https://github.com/mensfeld/yard-lint/wiki/Validators)
35
35
 
36
36
  ## Installation
37
37
 
@@ -239,6 +239,11 @@ Documentation/UndocumentedObjects:
239
239
  Documentation/UndocumentedMethodArguments:
240
240
  Enabled: true
241
241
  Severity: warning
242
+ # Match each @param to a parameter by name (default). Catches a misnamed tag,
243
+ # not just a missing one. Set to false for a lenient count-only comparison.
244
+ CheckParameterNames: true
245
+ # Opt-in: skip methods with no documentation at all (left to UndocumentedObjects)
246
+ SkipFullyUndocumented: false
242
247
  # Skip @param checks for specific methods (exact name, name/arity, /regex/)
243
248
  AllowedMethods:
244
249
  - call # service objects: call(args) is self-documenting
@@ -270,6 +275,9 @@ Tags/InvalidTypes:
270
275
  ExtraTypes:
271
276
  - generic # Solargraph generic type parameter (lsegal/yard#1683)
272
277
  - MyNamespace::CustomType
278
+ # Opt-in: flag CamelCase types that are not loaded constants nor defined in the
279
+ # analyzed codebase (catches typos like `Strng`). Enabled by --init --strict.
280
+ StrictConstantNames: false
273
281
 
274
282
  # Opt-in: Require @return tags on all methods
275
283
  Documentation/MissingReturn:
@@ -287,6 +295,11 @@ Tags/ExampleStyle:
287
295
  Documentation/LineLength:
288
296
  Enabled: true
289
297
  MaxLength: 100
298
+
299
+ # Opt-in: Flag documentation prose that wraps before using the available width
300
+ Documentation/UnderfilledLines:
301
+ Enabled: true
302
+ MaxLength: 100
290
303
  ```
291
304
 
292
305
  **Key features:**
@@ -374,9 +387,38 @@ end
374
387
 
375
388
  Method calls like `Fiber.yield` and `yielder.yield` (Enumerator::Yielder) are not flagged - only the `yield` keyword triggers the check.
376
389
 
390
+ ## Encouraging full-width documentation (opt-in)
391
+
392
+ `Documentation/LineLength` flags comment lines that are too *long*. `Documentation/UnderfilledLines` is its inverse: it flags documentation prose that wraps **too early** - text that uses only a fraction of the available width and spills onto extra lines, wasting vertical space. This pattern is especially common in AI-generated documentation.
393
+
394
+ Enable it in `.yard-lint.yml`:
395
+
396
+ ```yaml
397
+ Documentation/UnderfilledLines:
398
+ Enabled: true
399
+ MaxLength: 120 # target width (match your Documentation/LineLength)
400
+ MinTrailingSpace: 20 # only flag when the widest line wastes >= 20 columns
401
+ ```
402
+
403
+ ```ruby
404
+ # Bad - prose wraps at ~55 columns when 120 are available
405
+ # Processes the incoming payload and returns a normalized
406
+ # hash that downstream consumers can rely on for routing.
407
+ def process(payload); end
408
+
409
+ # Good - prose fills the available width before wrapping
410
+ # Processes the incoming payload and returns a normalized hash that downstream
411
+ # consumers can rely on for routing.
412
+ def process(payload); end
413
+ ```
414
+
415
+ The validator reports one offense per wasteful paragraph and is deliberately conservative - it would rather miss a case than emit a false positive. It only looks at the free-text description body (tags, fenced/indented code, lists, tables, headings, blockquotes and non-ASCII text are left alone), and a paragraph is reported only when re-wrapping its words at `MaxLength` would genuinely use fewer lines.
416
+
417
+ 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.
418
+
377
419
  ## Handling Non-Standard Types
378
420
 
379
- By default `Tags/InvalidTypes` accepts all built-in Ruby classes, constants, and a set of YARD pseudo-types (`nil`, `true`, `false`, `self`, `void`, `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.
421
+ 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.
380
422
 
381
423
  ### Project-Specific Type Aliases
382
424
 
@@ -400,12 +442,13 @@ Tags/InvalidTypes:
400
442
 
401
443
  ### Built-In Pseudo-Types (no configuration needed)
402
444
 
403
- The following lowercase YARD pseudo-types are accepted out of the box and do **not** need to be listed in `ExtraTypes`:
445
+ The following YARD pseudo-types are accepted out of the box and do **not** need to be listed in `ExtraTypes`:
404
446
 
405
447
  | Type | Meaning |
406
448
  |------|---------|
407
449
  | `nil` | Explicitly nil |
408
450
  | `true` / `false` | Boolean literals |
451
+ | `Boolean` | YARD boolean pseudo-type (`true` or `false`) |
409
452
  | `self` | Returns the receiver |
410
453
  | `void` | No meaningful return value |
411
454
  | `undefined` | Type is intentionally unspecified (used by Solargraph) |
@@ -526,7 +569,7 @@ The text formatter also shows the validator path (e.g., `[Documentation/Orphaned
526
569
  - **[Wiki Home](https://github.com/mensfeld/yard-lint/wiki)** - Full documentation
527
570
  - **[Installation](https://github.com/mensfeld/yard-lint/wiki/Installation)** - Installation guide
528
571
  - **[Configuration](https://github.com/mensfeld/yard-lint/wiki/Configuration)** - Complete configuration reference
529
- - **[Validators](https://github.com/mensfeld/yard-lint/wiki/Validators)** - All 34 validators documented
572
+ - **[Validators](https://github.com/mensfeld/yard-lint/wiki/Validators)** - All 35 validators documented
530
573
  - **[Features](https://github.com/mensfeld/yard-lint/wiki/Features)** - All features explained
531
574
 
532
575
  ### Workflows
@@ -114,6 +114,21 @@ Documentation/LineLength:
114
114
  Severity: convention
115
115
  MaxLength: 120
116
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
+
117
132
  Documentation/TextSubstitution:
118
133
  Description: 'Detects forbidden characters or strings in documentation and suggests replacements.'
119
134
  Enabled: false # Opt-in validator
@@ -117,6 +117,16 @@ Documentation/LineLength:
117
117
  Severity: error
118
118
  MaxLength: 120
119
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
+
120
130
  Documentation/TextSubstitution:
121
131
  Description: 'Detects forbidden characters or strings in documentation and suggests replacements.'
122
132
  Enabled: true
@@ -122,6 +122,17 @@ module Yard
122
122
  start_line + line_offset
123
123
  end
124
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
+
125
136
  # Returns the tag that actually carries a tag's types and description.
126
137
  # For most tags that is the tag itself, but @option tags wrap their
127
138
  # data in a nested pair tag - tag.types and tag.text are nil on the
@@ -41,17 +41,6 @@ module Yard
41
41
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
42
42
  collector.puts "#{max_length}|#{violations.join('|')}"
43
43
  end
44
-
45
- private
46
-
47
- # Returns the lines of a source file, reading from disk only on the first call
48
- # for each unique path.
49
- # @param file [String] absolute path to the source file
50
- # @return [Array<String>] lines of the file, memoized per path
51
- def cached_lines(file)
52
- @file_cache ||= {}
53
- @file_cache[file] ||= File.readlines(file)
54
- end
55
44
  end
56
45
  end
57
46
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module UnderfilledLines
8
+ # Configuration for UnderfilledLines validator
9
+ class Config < ::Yard::Lint::Validators::Config
10
+ self.id = :underfilled_lines
11
+ self.defaults = {
12
+ 'Enabled' => false,
13
+ 'Severity' => 'convention',
14
+ # Target width. Re-wrapping prose at this width must save a line for an
15
+ # offense to be reported. Mirror your Documentation/LineLength MaxLength.
16
+ 'MaxLength' => 120,
17
+ # Only flag when the widest non-final line of the paragraph leaves at
18
+ # least this many unused columns - avoids nitpicking near-full prose.
19
+ 'MinTrailingSpace' => 20,
20
+ # Paragraphs shorter than this are never flagged (a single line cannot
21
+ # be "under-filled" - there is nothing to pull up onto it).
22
+ 'MinParagraphLines' => 2,
23
+ # A non-final prose line ending in one of these characters is treated as
24
+ # a deliberate sentence/clause break, and its paragraph is left alone.
25
+ # Add ',' to also respect comma breaks (suppresses more, catches less).
26
+ 'SentenceEndChars' => ['.', '?', '!', ':', ';'],
27
+ # Skip paragraphs containing non-ASCII text: String#length is not a
28
+ # reliable display width for CJK/full-width/emoji content.
29
+ 'SkipNonAscii' => true
30
+ }.freeze
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module UnderfilledLines
8
+ # Builds human-readable messages for UnderfilledLines violations.
9
+ class MessagesBuilder
10
+ class << self
11
+ # @param offense [Hash] offense details with :actual_lines, :reflowed_lines,
12
+ # :widest_fill, :max_length and :object_name
13
+ # @return [String] formatted message
14
+ def call(offense)
15
+ actual = offense[:actual_lines]
16
+ reflowed = offense[:reflowed_lines]
17
+ widest = offense[:widest_fill]
18
+ max_length = offense[:max_length]
19
+ object_name = offense[:object_name]
20
+
21
+ "Documentation paragraph uses #{actual} lines but fits in #{reflowed} " \
22
+ "at <=#{max_length} cols [widest line filled to #{widest}/#{max_length}] " \
23
+ "for '#{object_name}'"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module UnderfilledLines
8
+ # Parses UnderfilledLines validator output into structured violation hashes.
9
+ #
10
+ # Expected format (two lines per object with violations):
11
+ # file.rb:OBJECT_LINE: ObjectName
12
+ # MAX_LENGTH|START:ACTUAL:REFLOW:WIDEST|START:ACTUAL:REFLOW:WIDEST|...
13
+ class Parser < Parsers::Base
14
+ # @param output [String] raw validator output
15
+ # @return [Array<Hash>] array of violation hashes
16
+ def call(output, **)
17
+ return [] if output.nil? || output.empty?
18
+
19
+ violations = []
20
+ lines = output.lines.map(&:chomp)
21
+
22
+ i = 0
23
+ while i < lines.size
24
+ location_match = lines[i].match(/^(.+):(\d+): (.+)$/)
25
+
26
+ if location_match
27
+ file_path = location_match[1]
28
+ object_line = location_match[2].to_i
29
+ object_name = location_match[3]
30
+
31
+ i += 1
32
+ if i < lines.size
33
+ parts = lines[i].split('|')
34
+ max_length = parts.shift.to_i
35
+
36
+ parts.each do |part|
37
+ start, actual, reflow, widest = part.split(':', 4)
38
+ next unless start && actual && reflow && widest
39
+
40
+ violations << {
41
+ location: file_path,
42
+ line: start.to_i,
43
+ object_line: object_line,
44
+ object_name: object_name,
45
+ actual_lines: actual.to_i,
46
+ reflowed_lines: reflow.to_i,
47
+ widest_fill: widest.to_i,
48
+ max_length: max_length
49
+ }
50
+ end
51
+ end
52
+ end
53
+
54
+ i += 1
55
+ end
56
+
57
+ violations
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module UnderfilledLines
8
+ # Result wrapper for UnderfilledLines validator.
9
+ class Result < Results::Base
10
+ self.default_severity = 'convention'
11
+ self.offense_type = 'line'
12
+ self.offense_name = 'UnderfilledLines'
13
+
14
+ private
15
+
16
+ # @param offense [Hash] offense details from the parser
17
+ # @return [String] formatted message
18
+ def build_message(offense)
19
+ MessagesBuilder.call(offense)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module UnderfilledLines
8
+ # Detects documentation prose that wraps before using the available line width.
9
+ #
10
+ # Uses YARD's `docstring.line_range` to locate the exact source lines belonging
11
+ # to the docstring block (mirroring {LineLength}), classifies each line as prose
12
+ # or structural, groups contiguous prose into paragraphs, and reports a
13
+ # paragraph when greedily re-wrapping it at `MaxLength` would use fewer lines.
14
+ class Validator < Validators::Base
15
+ in_process visibility: :all
16
+
17
+ # Characters that may trail a sentence boundary char without changing the
18
+ # fact that the line ends a clause (closing bracket/quote/backtick).
19
+ TRAILING_CLOSERS = /[)\]"'`]+\z/
20
+
21
+ # Execute query for a single object during in-process execution.
22
+ # @param object [YARD::CodeObjects::Base] the code object to query
23
+ # @param collector [Executor::ResultCollector] collector for output
24
+ # @return [void]
25
+ def in_process_query(object, collector)
26
+ return unless object.file && File.exist?(object.file)
27
+ return if object.docstring.all.empty?
28
+
29
+ line_range = object.docstring.line_range
30
+ return unless line_range
31
+ return if duplicate_docstring?(object)
32
+
33
+ max_length = config_or_default('MaxLength').to_i
34
+ min_trailing = config_or_default('MinTrailingSpace').to_i
35
+ min_lines = [config_or_default('MinParagraphLines').to_i, 2].max
36
+ boundary = Array(config_or_default('SentenceEndChars'))
37
+ skip_non_ascii = config_or_default('SkipNonAscii')
38
+
39
+ source_lines = cached_lines(object.file)
40
+ classified = classify_lines(line_range, source_lines, skip_non_ascii)
41
+
42
+ violations = []
43
+ group_paragraphs(classified).each do |paragraph|
44
+ next if paragraph.size < min_lines
45
+ next if ventilated?(paragraph, boundary)
46
+
47
+ actual = paragraph.size
48
+ reflowed = reflow_count(paragraph, max_length)
49
+ next unless reflowed < actual
50
+
51
+ widest = paragraph[0..-2].map { |line| line[:length] }.max
52
+ next if (max_length - widest) < min_trailing
53
+
54
+ violations << "#{paragraph.first[:line_no]}:#{actual}:#{reflowed}:#{widest}"
55
+ end
56
+
57
+ return if violations.empty?
58
+
59
+ collector.puts "#{object.file}:#{object.line}: #{object.title}"
60
+ collector.puts "#{max_length}|#{violations.join('|')}"
61
+ end
62
+
63
+ private
64
+
65
+ # Classify each source line in the docstring range. Plain-prose lines become
66
+ # hashes; everything else (blank lines, tags, code, markdown structure) is a
67
+ # `:break` marker that separates paragraphs.
68
+ # @param line_range [Range] absolute source line numbers of the docstring
69
+ # @param source_lines [Array<String>] raw lines of the source file
70
+ # @param skip_non_ascii [Boolean] treat non-ASCII lines as structural
71
+ # @return [Array<Hash, Symbol>] prose hashes interleaved with :break markers
72
+ def classify_lines(line_range, source_lines, skip_non_ascii)
73
+ base_indent = base_indent_for(line_range, source_lines)
74
+ result = []
75
+ in_fence = false
76
+ in_tag_region = false
77
+
78
+ line_range.each do |line_no|
79
+ raw = source_lines[line_no - 1].to_s.rstrip
80
+ marker = raw.match(/\A(\s*#+\s?)(.*)\z/)
81
+
82
+ # Not a comment line (e.g. the definition itself, or a block comment body)
83
+ unless marker
84
+ result << :break
85
+ in_tag_region = false
86
+ next
87
+ end
88
+
89
+ content = marker[2]
90
+ stripped = content.strip
91
+
92
+ # Fenced code block delimiters toggle the fence; the delimiter line and
93
+ # everything inside it is structural.
94
+ if stripped.start_with?('```', '~~~')
95
+ in_fence = !in_fence
96
+ result << :break
97
+ next
98
+ end
99
+ if in_fence
100
+ result << :break
101
+ next
102
+ end
103
+
104
+ if stripped.empty?
105
+ result << :break
106
+ in_tag_region = false
107
+ next
108
+ end
109
+
110
+ # A real YARD tag/directive begins at column 0 of the comment content.
111
+ # It and its indented continuation lines are skipped; only the free-text
112
+ # description body is checked.
113
+ if content.start_with?('@')
114
+ in_tag_region = true
115
+ result << :break
116
+ next
117
+ end
118
+ if in_tag_region
119
+ if content.match?(/\A[ \t]/)
120
+ result << :break
121
+ next
122
+ end
123
+ in_tag_region = false
124
+ end
125
+
126
+ # Lines indented past the block's base indentation are code samples,
127
+ # ASCII diagrams or nested list continuations - not flowing prose.
128
+ indent = content.length - content.lstrip.length
129
+ if indent > base_indent || structural_content?(stripped, skip_non_ascii)
130
+ result << :break
131
+ next
132
+ end
133
+
134
+ result << {
135
+ line_no: line_no,
136
+ prefix_width: raw.length - stripped.length,
137
+ content: stripped,
138
+ length: raw.length
139
+ }
140
+ end
141
+
142
+ result
143
+ end
144
+
145
+ # The smallest content indentation among non-empty comment lines in the
146
+ # range. Flowing prose sits at this base; anything deeper is treated as a
147
+ # nested, code or diagram line.
148
+ # @param line_range [Range] absolute source line numbers of the docstring
149
+ # @param source_lines [Array<String>] raw lines of the source file
150
+ # @return [Integer] base indentation in columns (0 for normal `# ` comments)
151
+ def base_indent_for(line_range, source_lines)
152
+ indents = []
153
+ line_range.each do |line_no|
154
+ raw = source_lines[line_no - 1].to_s.rstrip
155
+ marker = raw.match(/\A(\s*#+\s?)(.*)\z/)
156
+ next unless marker
157
+
158
+ content = marker[2]
159
+ next if content.strip.empty?
160
+
161
+ indents << (content.length - content.lstrip.length)
162
+ end
163
+ indents.min || 0
164
+ end
165
+
166
+ # Whether a prose-looking comment line is actually structural and must not
167
+ # take part in reflow (markdown, code, diagrams, RDoc directives, ...).
168
+ # @param stripped [String] comment content, stripped of the marker and whitespace
169
+ # @param skip_non_ascii [Boolean] treat non-ASCII content as structural
170
+ # @return [Boolean]
171
+ def structural_content?(stripped, skip_non_ascii)
172
+ return true if skip_non_ascii && !stripped.ascii_only?
173
+ # Markdown heading, blockquote
174
+ return true if stripped.start_with?('#', '>')
175
+ # Table row
176
+ return true if stripped.include?('|')
177
+ # List items (bulleted or ordered)
178
+ return true if stripped.match?(/\A[-*+]\s/)
179
+ return true if stripped.match?(/\A\d+[.)]\s/)
180
+ # Thematic break (---, ***, ___)
181
+ return true if stripped.match?(/\A([-*_])\1\1+\z/)
182
+ # RDoc directives
183
+ return true if stripped.match?(/\A:\w+:/)
184
+ return true if stripped.end_with?(':nodoc:', ':doc:')
185
+ # Intentional hard break / hyphenation
186
+ return true if stripped.end_with?('-')
187
+ # Column alignment (2+ internal spaces) or box-drawing diagrams
188
+ return true if stripped.match?(/\S {2,}\S/)
189
+ return true if stripped.match?(/[─-╿▀-▟]/)
190
+
191
+ false
192
+ end
193
+
194
+ # Group the classified entries into paragraphs: maximal runs of contiguous
195
+ # prose lines sharing the same comment-marker indentation.
196
+ # @param classified [Array<Hash, Symbol>] output of {#classify_lines}
197
+ # @return [Array<Array<Hash>>] paragraphs, each an array of prose line hashes
198
+ def group_paragraphs(classified)
199
+ paragraphs = []
200
+ current = []
201
+
202
+ flush = lambda do
203
+ paragraphs << current unless current.empty?
204
+ current = []
205
+ end
206
+
207
+ classified.each do |entry|
208
+ if entry == :break
209
+ flush.call
210
+ elsif current.empty? || current.last[:prefix_width] == entry[:prefix_width]
211
+ current << entry
212
+ else
213
+ flush.call
214
+ current << entry
215
+ end
216
+ end
217
+ flush.call
218
+
219
+ paragraphs
220
+ end
221
+
222
+ # Whether a paragraph is deliberately broken one sentence/clause per line.
223
+ # True when every non-final line ends at a sentence boundary character.
224
+ # @param paragraph [Array<Hash>] prose line hashes
225
+ # @param boundary [Array<String>] sentence-ending characters
226
+ # @return [Boolean]
227
+ def ventilated?(paragraph, boundary)
228
+ return false if boundary.empty?
229
+
230
+ paragraph[0..-2].all? do |line|
231
+ last = line[:content].sub(TRAILING_CLOSERS, '')[-1]
232
+ last && boundary.include?(last)
233
+ end
234
+ end
235
+
236
+ # Number of lines the paragraph's words occupy when greedily wrapped at
237
+ # `max_length`, using the paragraph's comment-marker prefix on every line.
238
+ # A word that does not fit starts a new line, so breaks forced by an
239
+ # unbreakable long token (URL, namespaced constant) are reproduced and do
240
+ # not count as savings.
241
+ # @param paragraph [Array<Hash>] prose line hashes
242
+ # @param max_length [Integer] target width
243
+ # @return [Integer] number of wrapped lines
244
+ def reflow_count(paragraph, max_length)
245
+ prefix_width = paragraph.first[:prefix_width]
246
+ words = paragraph.flat_map { |line| line[:content].split }
247
+ return paragraph.size if words.empty?
248
+
249
+ lines = 1
250
+ current = prefix_width + words.first.length
251
+ words.drop(1).each do |word|
252
+ if current + 1 + word.length <= max_length
253
+ current += 1 + word.length
254
+ else
255
+ lines += 1
256
+ current = prefix_width + word.length
257
+ end
258
+ end
259
+ lines
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ # UnderfilledLines validator
8
+ #
9
+ # The inverse of {LineLength}: instead of flagging documentation lines that are
10
+ # too *long*, it flags documentation prose paragraphs that wrap **too early** -
11
+ # text that uses only a fraction of the available width and spills onto extra
12
+ # lines, wasting vertical space. This pattern is common in AI-generated docs.
13
+ #
14
+ # Only the free-text description body is checked. YARD tags (`@param`, `@return`,
15
+ # `@example`, ...), fenced and indented code, lists, tables, headings,
16
+ # blockquotes and non-ASCII text are left untouched. A paragraph is reported
17
+ # only when *all* of these hold, so ambiguous cases are never flagged:
18
+ #
19
+ # * greedily re-wrapping its words at `MaxLength` would genuinely use fewer lines;
20
+ # * the unused space on its widest line is at least `MinTrailingSpace` columns;
21
+ # * it is not deliberately broken one sentence/clause per line (every non-final
22
+ # line ending at a `SentenceEndChars` boundary is treated as an intentional
23
+ # semantic line break and skipped).
24
+ #
25
+ # Disabled by default - this is a stylistic, opinionated check. Enable it to
26
+ # tighten draft or AI-written documentation.
27
+ #
28
+ # @example Bad - prose wraps at ~55 columns when 120 are available
29
+ # # Processes the incoming payload and returns a normalized
30
+ # # hash that downstream consumers can rely on for routing.
31
+ # def process(payload)
32
+ # end
33
+ #
34
+ # @example Good - prose fills the available width before wrapping
35
+ # # Processes the incoming payload and returns a normalized hash that downstream
36
+ # # consumers can rely on for routing.
37
+ # def process(payload)
38
+ # end
39
+ #
40
+ # @example Good - semantic line breaks (one sentence per line) are never flagged
41
+ # # Validates the input.
42
+ # # Normalizes the casing.
43
+ # # Persists the record.
44
+ # def call
45
+ # end
46
+ #
47
+ # ## Configuration
48
+ #
49
+ # To enable with custom settings:
50
+ #
51
+ # Documentation/UnderfilledLines:
52
+ # Enabled: true
53
+ # MaxLength: 120
54
+ # MinTrailingSpace: 20
55
+ #
56
+ # ## Reliability
57
+ #
58
+ # Unlike {LineLength} - which measures an objective property (characters over a
59
+ # limit) - "this line should have been longer" is a stylistic judgement. The
60
+ # validator is deliberately conservative and biased toward silence: it would
61
+ # rather miss a case than emit a false positive. Projects that use semantic line
62
+ # breaks (one sentence or clause per line, see https://sembr.org) should leave it
63
+ # off, or add `,` to `SentenceEndChars`.
64
+ #
65
+ # To disable:
66
+ #
67
+ # Documentation/UnderfilledLines:
68
+ # Enabled: false
69
+ module UnderfilledLines
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -3,6 +3,6 @@
3
3
  module Yard
4
4
  module Lint
5
5
  # @return [String] version of the YARD Lint gem
6
- VERSION = '1.7.0'
6
+ VERSION = '1.8.0'
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yard-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld
@@ -121,6 +121,12 @@ files:
121
121
  - lib/yard/lint/validators/documentation/text_substitution/parser.rb
122
122
  - lib/yard/lint/validators/documentation/text_substitution/result.rb
123
123
  - lib/yard/lint/validators/documentation/text_substitution/validator.rb
124
+ - lib/yard/lint/validators/documentation/underfilled_lines.rb
125
+ - lib/yard/lint/validators/documentation/underfilled_lines/config.rb
126
+ - lib/yard/lint/validators/documentation/underfilled_lines/messages_builder.rb
127
+ - lib/yard/lint/validators/documentation/underfilled_lines/parser.rb
128
+ - lib/yard/lint/validators/documentation/underfilled_lines/result.rb
129
+ - lib/yard/lint/validators/documentation/underfilled_lines/validator.rb
124
130
  - lib/yard/lint/validators/documentation/undocumented_boolean_methods.rb
125
131
  - lib/yard/lint/validators/documentation/undocumented_boolean_methods/config.rb
126
132
  - lib/yard/lint/validators/documentation/undocumented_boolean_methods/parser.rb