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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +48 -5
- data/lib/yard/lint/templates/default_config.yml +15 -0
- data/lib/yard/lint/templates/strict_config.yml +10 -0
- data/lib/yard/lint/validators/base.rb +11 -0
- data/lib/yard/lint/validators/documentation/line_length/validator.rb +0 -11
- data/lib/yard/lint/validators/documentation/underfilled_lines/config.rb +36 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/messages_builder.rb +31 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/parser.rb +64 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/result.rb +26 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/validator.rb +266 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines.rb +74 -0
- data/lib/yard/lint/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '04219019cfc32c314c95eea949d26bd01a92c00cecbcffd44149ed537a09d148'
|
|
4
|
+
data.tar.gz: 563bc04039db71d71510bfcd1feba0f7f2bc01b025807f22d97f6085e5a30282
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
**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
|
|
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
|
|
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
|
data/lib/yard/lint/version.rb
CHANGED
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.
|
|
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
|