yard-lint 1.6.1 → 1.7.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -3
  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 +34 -1
  16. data/lib/yard/lint/templates/strict_config.yml +34 -1
  17. data/lib/yard/lint/todo_generator.rb +35 -14
  18. data/lib/yard/lint/validators/base.rb +39 -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 -0
  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/undocumented_method_arguments/config.rb +10 -1
  28. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/parser.rb +18 -4
  29. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/validator.rb +38 -4
  30. data/lib/yard/lint/validators/documentation/undocumented_objects/parser.rb +6 -0
  31. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +30 -0
  32. data/lib/yard/lint/validators/semantic/abstract_methods/result.rb +1 -0
  33. data/lib/yard/lint/validators/semantic/abstract_methods/validator.rb +43 -4
  34. data/lib/yard/lint/validators/tags/api_tags/validator.rb +11 -1
  35. data/lib/yard/lint/validators/tags/collection_type/messages_builder.rb +36 -5
  36. data/lib/yard/lint/validators/tags/collection_type/validator.rb +10 -6
  37. data/lib/yard/lint/validators/tags/example_style/validator.rb +1 -1
  38. data/lib/yard/lint/validators/tags/example_syntax/config.rb +6 -1
  39. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +74 -3
  40. data/lib/yard/lint/validators/tags/forbidden_tags/validator.rb +7 -3
  41. data/lib/yard/lint/validators/tags/informal_notation/config.rb +6 -1
  42. data/lib/yard/lint/validators/tags/informal_notation/validator.rb +24 -3
  43. data/lib/yard/lint/validators/tags/invalid_types/config.rb +7 -1
  44. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +22 -5
  45. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +24 -10
  46. data/lib/yard/lint/validators/tags/missing_yield/validator.rb +44 -4
  47. data/lib/yard/lint/validators/tags/missing_yield.rb +8 -8
  48. data/lib/yard/lint/validators/tags/non_ascii_type/validator.rb +13 -1
  49. data/lib/yard/lint/validators/tags/option_tags/result.rb +1 -0
  50. data/lib/yard/lint/validators/tags/option_tags/validator.rb +30 -2
  51. data/lib/yard/lint/validators/tags/order/parser.rb +12 -5
  52. data/lib/yard/lint/validators/tags/order/validator.rb +7 -2
  53. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +21 -8
  54. data/lib/yard/lint/validators/tags/tag_group_separator/parser.rb +12 -5
  55. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +9 -7
  56. data/lib/yard/lint/validators/tags/tag_type_position/validator.rb +8 -2
  57. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +1 -1
  58. data/lib/yard/lint/validators/warnings/invalid_directive_format/parser.rb +2 -2
  59. data/lib/yard/lint/validators/warnings/invalid_tag_format/parser.rb +2 -2
  60. data/lib/yard/lint/validators/warnings/syntax_error/config.rb +22 -0
  61. data/lib/yard/lint/validators/warnings/syntax_error/parser.rb +28 -0
  62. data/lib/yard/lint/validators/warnings/syntax_error/result.rb +27 -0
  63. data/lib/yard/lint/validators/warnings/syntax_error/validator.rb +15 -0
  64. data/lib/yard/lint/validators/warnings/syntax_error.rb +34 -0
  65. data/lib/yard/lint/validators/warnings/unknown_directive/parser.rb +2 -2
  66. data/lib/yard/lint/validators/warnings/unknown_parameter_name/messages_builder.rb +51 -46
  67. data/lib/yard/lint/validators/warnings/unknown_tag/messages_builder.rb +20 -3
  68. data/lib/yard/lint/validators/warnings/unknown_tag/parser.rb +2 -2
  69. data/lib/yard/lint/version.rb +1 -1
  70. data/lib/yard/lint.rb +4 -1
  71. metadata +6 -1
@@ -18,15 +18,22 @@ module Yard
18
18
  def in_process_query(object, collector)
19
19
  return unless object.has_tag?(:example)
20
20
 
21
+ skip_non_ruby = config_or_default('SkipNonRuby')
21
22
  example_tags = object.tags(:example)
22
23
 
23
24
  example_tags.each_with_index do |example, index|
24
25
  code = example.text
25
26
  next if code.nil? || code.empty?
26
27
 
27
- # Clean the code: strip output indicators (#=>) and everything after it
28
+ # Opt-in: an interactive console transcript is not runnable Ruby,
29
+ # so skip it rather than reporting its prompts as syntax errors.
30
+ next if skip_non_ruby && console_transcript?(code)
31
+
32
+ # Clean the code: strip YARD output indicators (# =>) and
33
+ # everything after them, but only when the "# =>" is a real
34
+ # trailing comment - not when it appears inside a string literal
28
35
  code_lines = code.split("\n").map do |line|
29
- line.sub(/\s*#\s*=>.*$/, '')
36
+ strip_output_marker(line)
30
37
  end
31
38
 
32
39
  cleaned_code = code_lines.join("\n").strip
@@ -51,7 +58,7 @@ module Yard
51
58
  $VERBOSE = nil
52
59
  RubyVM::InstructionSequence.compile(cleaned_code)
53
60
  rescue SyntaxError => e
54
- example_name = example.name || "Example #{index + 1}"
61
+ example_name = example.name.to_s.empty? ? "Example #{index + 1}" : example.name
55
62
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
56
63
  collector.puts 'syntax_error'
57
64
  collector.puts example_name
@@ -67,6 +74,70 @@ module Yard
67
74
  end
68
75
  end
69
76
  end
77
+
78
+ private
79
+
80
+ # Matches a line that begins (after optional whitespace) with an
81
+ # interactive console prompt or REPL output marker:
82
+ # >> / ?> irb input / continuation
83
+ # => irb/pry result output
84
+ # irb( irb> irb prompt
85
+ # pry( / [n] pry( pry prompt
86
+ # $ shell prompt
87
+ CONSOLE_PROMPT = /\A\s*(?:>>|\?>|=>|\$\s|irb[\s(>]|(?:\[\d+\]\s*)?pry[\s(>])/.freeze
88
+
89
+ private_constant :CONSOLE_PROMPT
90
+
91
+ # Check whether an @example body is an interactive console transcript
92
+ # (irb/pry session or shell prompt) rather than runnable Ruby.
93
+ # @param code [String] the raw @example text
94
+ # @return [Boolean] true if any line looks like a console prompt/output
95
+ def console_transcript?(code)
96
+ code.each_line.any? { |line| line.match?(CONSOLE_PROMPT) }
97
+ end
98
+
99
+ # Removes a trailing YARD `# => result` output marker from a line of
100
+ # example code, but only when the `#` actually starts a comment - a
101
+ # `#` inside a string or character literal is left untouched, so a
102
+ # string such as `"result # => x"` is not corrupted into an
103
+ # unterminated literal.
104
+ # @param line [String] a single line of example source
105
+ # @return [String] the line with a trailing output marker removed
106
+ def strip_output_marker(line)
107
+ in_single = false
108
+ in_double = false
109
+ i = 0
110
+
111
+ while i < line.length
112
+ char = line[i]
113
+
114
+ if (in_single || in_double) && char == '\\'
115
+ i += 2
116
+ next
117
+ end
118
+
119
+ if in_single
120
+ in_single = false if char == "'"
121
+ elsif in_double
122
+ in_double = false if char == '"'
123
+ elsif char == "'"
124
+ in_single = true
125
+ elsif char == '"'
126
+ in_double = true
127
+ elsif char == '#'
128
+ # A comment starts here (outside any string). Strip it only
129
+ # when it is a YARD output marker; leave ordinary comments,
130
+ # which the Ruby parser handles fine.
131
+ return line[0...i].rstrip if line[i..].match?(/\A#\s*=>/)
132
+
133
+ break
134
+ end
135
+
136
+ i += 1
137
+ end
138
+
139
+ line
140
+ end
70
141
  end
71
142
  end
72
143
  end
@@ -18,7 +18,11 @@ module Yard
18
18
  patterns = forbidden_patterns
19
19
  return if patterns.empty?
20
20
 
21
- object.docstring.tags.each do |tag|
21
+ # Include tags nested inside @overload blocks - they live on the
22
+ # overload's own docstring and are invisible to docstring.tags
23
+ forbidden_tag_names = patterns.map { |pattern| pattern['Tag'] }.compact.uniq
24
+
25
+ all_typed_tags(object.docstring, forbidden_tag_names).each do |tag|
22
26
  patterns.each do |pattern|
23
27
  next unless matches_pattern?(tag, pattern)
24
28
 
@@ -45,7 +49,7 @@ module Yard
45
49
  pattern_types = pattern['Types']
46
50
  return true if pattern_types.nil? || pattern_types.empty?
47
51
 
48
- tag_types = tag.types || []
52
+ tag_types = tag_data(tag).types || []
49
53
  (tag_types & pattern_types).any?
50
54
  end
51
55
 
@@ -54,7 +58,7 @@ module Yard
54
58
  # @param pattern [Hash] the matched pattern
55
59
  # @return [String] details line for parser
56
60
  def build_details(tag, pattern)
57
- types_text = (tag.types || []).join(',')
61
+ types_text = (tag_data(tag).types || []).join(',')
58
62
  pattern_types = (pattern['Types'] || []).join(',')
59
63
  "#{tag.tag_name}|#{types_text}|#{pattern_types}"
60
64
  end
@@ -13,6 +13,11 @@ module Yard
13
13
  'Severity' => 'warning',
14
14
  'CaseSensitive' => false,
15
15
  'RequireStartOfLine' => true,
16
+ # Opt-in: also skip 4-space (or tab) indented Markdown code blocks,
17
+ # not just fenced (```) blocks. Off by default because indented
18
+ # content is also used for list continuations and wrapped prose,
19
+ # which would then be skipped too.
20
+ 'SkipIndentedCodeBlocks' => false,
16
21
  'Patterns' => {
17
22
  'Note' => '@note',
18
23
  'IMPORTANT' => '@note',
@@ -22,7 +27,7 @@ module Yard
22
27
  'FIXME' => '@todo',
23
28
  'See' => '@see',
24
29
  'See also' => '@see',
25
- 'Warning' => '@deprecated',
30
+ 'Warning' => '@note',
26
31
  'Deprecated' => '@deprecated',
27
32
  'Author' => '@author',
28
33
  'Version' => '@version',
@@ -19,16 +19,19 @@ module Yard
19
19
  def in_process_query(object, collector)
20
20
  docstring_text = object.docstring.to_s
21
21
  return if docstring_text.empty?
22
+ return if duplicate_docstring?(object)
22
23
 
23
24
  patterns = config_patterns
24
25
  case_sensitive = config_case_sensitive
25
26
  require_start = config_require_start_of_line
27
+ skip_indented = config_skip_indented_code_blocks
26
28
 
27
29
  found_patterns = find_informal_patterns(
28
30
  docstring_text,
29
31
  patterns,
30
32
  case_sensitive,
31
- require_start
33
+ require_start,
34
+ skip_indented
32
35
  )
33
36
 
34
37
  return if found_patterns.empty?
@@ -37,7 +40,8 @@ module Yard
37
40
  # Line 1: file:line: object_title
38
41
  # Line 2: pattern|replacement|line_offset|line_text (pipe-separated)
39
42
  found_patterns.each do |match|
40
- collector.puts "#{object.file}:#{object.line}: #{object.title}"
43
+ line = docstring_line(object, match[:line_offset])
44
+ collector.puts "#{object.file}:#{line}: #{object.title}"
41
45
  collector.puts "#{match[:pattern]}|#{match[:replacement]}|" \
42
46
  "#{match[:line_offset]}|#{match[:line_text]}"
43
47
  end
@@ -50,8 +54,9 @@ module Yard
50
54
  # @param patterns [Hash] pattern => replacement mapping
51
55
  # @param case_sensitive [Boolean] whether to match case-sensitively
52
56
  # @param require_start [Boolean] whether pattern must be at start of line
57
+ # @param skip_indented [Boolean] whether to also skip indented code blocks
53
58
  # @return [Array<Hash>] array of matches with pattern, replacement, line_offset, line_text
54
- def find_informal_patterns(docstring_text, patterns, case_sensitive, require_start)
59
+ def find_informal_patterns(docstring_text, patterns, case_sensitive, require_start, skip_indented = false)
55
60
  found_patterns = []
56
61
  matched_lines = Set.new
57
62
  in_code_block = false
@@ -66,6 +71,9 @@ module Yard
66
71
  # Skip lines inside code blocks
67
72
  next if in_code_block
68
73
 
74
+ # Optionally skip 4-space/tab indented Markdown code blocks
75
+ next if skip_indented && indented_code_line?(line)
76
+
69
77
  # Skip lines already matched (avoids duplicate reports for similar patterns)
70
78
  next if matched_lines.include?(line_offset)
71
79
 
@@ -87,6 +95,14 @@ module Yard
87
95
  found_patterns
88
96
  end
89
97
 
98
+ # Check if a line is part of a Markdown indented code block
99
+ # (4+ leading spaces or a leading tab, with non-whitespace content).
100
+ # @param line [String] the raw docstring line
101
+ # @return [Boolean] true if the line is indented code
102
+ def indented_code_line?(line)
103
+ line.match?(/\A(?: {4}|\t)/) && !line.strip.empty?
104
+ end
105
+
90
106
  # Check if a line matches the informal pattern
91
107
  # @param line [String] the line to check
92
108
  # @param pattern [String] the pattern to match (without colon)
@@ -125,6 +141,11 @@ module Yard
125
141
  def config_require_start_of_line
126
142
  config_or_default('RequireStartOfLine')
127
143
  end
144
+
145
+ # @return [Boolean] whether to skip indented Markdown code blocks
146
+ def config_skip_indented_code_blocks
147
+ config_or_default('SkipIndentedCodeBlocks')
148
+ end
128
149
  end
129
150
  end
130
151
  end
@@ -12,7 +12,13 @@ module Yard
12
12
  'Enabled' => true,
13
13
  'Severity' => 'warning',
14
14
  'ValidatedTags' => %w[param option return yieldreturn yieldparam raise],
15
- 'ExtraTypes' => []
15
+ 'ExtraTypes' => [],
16
+ # Opt-in: when true, a CamelCase type name that is neither a loaded
17
+ # Ruby constant nor resolvable in the analyzed codebase's YARD
18
+ # registry is flagged (catches typos like `Strng`). Off by default
19
+ # because YARD does not load the project, so types defined only in
20
+ # un-analyzed dependencies would otherwise be reported.
21
+ 'StrictConstantNames' => false
16
22
  }.freeze
17
23
  end
18
24
  end
@@ -10,8 +10,12 @@ module Yard
10
10
  # /path/to/file.rb:10: ClassName#method_name
11
11
  # @tagname param_name:BadType1,BadType2|@tagname2:BadType3
12
12
  class Parser < Parsers::Base
13
- # @return [Regexp] matches "file:line: ClassName#method"
14
- LOCATION_REGEX = /^(.+):(\d+):\s+(.+)[#.](.+)$/
13
+ # @return [Regexp] matches "file:line: ObjectTitle"
14
+ LOCATION_REGEX = /^(.+):(\d+):\s+(.+)$/
15
+ # @return [Regexp] splits a title into namespace and method name on
16
+ # the last # or . separator; titles without one (e.g. CONST,
17
+ # Foo::Bar) are kept whole
18
+ TITLE_REGEX = /\A(.*)[#.]([^#.]+)\z/
15
19
  # @return [Regexp] matches the tag violations line (starts with @)
16
20
  TAG_VIOLATIONS_REGEX = /^@/
17
21
 
@@ -23,18 +27,22 @@ module Yard
23
27
  i = 0
24
28
 
25
29
  while i < lines.size
26
- match = lines[i].match(LOCATION_REGEX)
30
+ # Violation lines (starting with @) must never be consumed as
31
+ # location lines, even if they happen to match the loose regex
32
+ match = lines[i].match?(TAG_VIOLATIONS_REGEX) ? nil : lines[i].match(LOCATION_REGEX)
27
33
 
28
34
  unless match
29
35
  i += 1
30
36
  next
31
37
  end
32
38
 
39
+ class_name, method_name = split_title(match[3])
40
+
33
41
  offense = {
34
42
  location: match[1],
35
43
  line: match[2].to_i,
36
- class_name: match[3],
37
- method_name: match[4],
44
+ class_name: class_name,
45
+ method_name: method_name,
38
46
  tag_violations: []
39
47
  }
40
48
 
@@ -54,6 +62,15 @@ module Yard
54
62
 
55
63
  private
56
64
 
65
+ # Splits a YARD object title into namespace and method name parts
66
+ # @param title [String] object title (e.g. "Foo#bar", "#bar", "CONST")
67
+ # @return [Array(String, String)] namespace and method name; for titles
68
+ # without a separator both parts are the full title
69
+ def split_title(title)
70
+ match = title.match(TITLE_REGEX)
71
+ match ? [match[1], match[2]] : [title, title]
72
+ end
73
+
57
74
  # Parse "tagname param:Type1,Type2|tagname2:Type3" into structured data
58
75
  # @param line [String] the violations line
59
76
  # @return [Array<Hash>] each entry has :tag, :param (may be nil), :types
@@ -22,6 +22,7 @@ module Yard
22
22
  undefined
23
23
  unspecified
24
24
  unknown
25
+ Boolean
25
26
  ].freeze
26
27
 
27
28
  private_constant :ALLOWED_DEFAULTS
@@ -34,17 +35,18 @@ module Yard
34
35
  def in_process_query(object, collector)
35
36
  checked_tags = config_or_default('ValidatedTags')
36
37
  extra_types = config_or_default('ExtraTypes')
38
+ strict = config_or_default('StrictConstantNames')
37
39
  allowed_types = ALLOWED_DEFAULTS + extra_types
38
40
 
39
41
  # Collect per-tag violations to surface in the offense message.
40
42
  # Each entry is "tagname param_name:Type1,Type2" (param_name omitted when nil).
41
43
  tag_violations = all_typed_tags(object.docstring, checked_tags).filter_map do |tag|
42
- bad = (tag.types || [])
44
+ bad = (tag_data(tag).types || [])
43
45
  .compact
44
46
  .flat_map { |type| extract_type_names(type) }
45
47
  .uniq
46
48
  .reject { |type| allowed_types.include?(type) }
47
- .reject { |type| type_defined?(type) }
49
+ .reject { |type| type_defined?(type, strict: strict) }
48
50
  .reject { |type| type.include?('#') }
49
51
  next if bad.empty?
50
52
 
@@ -73,17 +75,16 @@ module Yard
73
75
  # Check if a type is defined in Ruby runtime or YARD registry
74
76
  # In in-process mode, parsed classes are in YARD registry but not loaded into Ruby
75
77
  # @param type [String] type name to check
78
+ # @param strict [Boolean] when true, a syntactically valid but unknown
79
+ # constant name (e.g. a typo) is treated as undefined instead of recognized
76
80
  # @return [Boolean] true if type is defined (or at least recognized as a valid type)
77
- def type_defined?(type)
81
+ def type_defined?(type, strict: false)
78
82
  # Symbol and string literal types (:foo, "bar") are valid hash key notations
79
83
  return true if type.start_with?(':', '"', "'")
80
84
 
81
- # Check Ruby runtime first
82
- # The shell query uses: !(Kernel.const_defined?(type) rescue nil).nil?
83
- # This means: if const_defined? returns ANY value (true or false, not nil),
84
- # the type is considered "recognized" and should not be flagged as invalid.
85
- # This allows common types like "Boolean" which aren't actual Ruby classes
86
- # but are still recognized by Ruby as valid constant names to check.
85
+ # Check Ruby runtime first.
86
+ # const_defined? returns true (loaded constant), false (valid name but
87
+ # not loaded), or raises NameError (invalid constant syntax).
87
88
  begin
88
89
  const_result = Kernel.const_defined?(type)
89
90
  rescue NameError
@@ -91,7 +92,20 @@ module Yard
91
92
  # These aren't valid Ruby constants, so we can't check them this way
92
93
  const_result = nil
93
94
  end
94
- return true unless const_result.nil?
95
+
96
+ if strict
97
+ # Strict mode: only a constant actually loaded in this process
98
+ # (Ruby core/stdlib) is accepted outright. A valid-but-unloaded
99
+ # name (const_result == false) or invalid syntax (nil) still gets
100
+ # the YARD registry check below, so codebase-defined types pass but
101
+ # unknown CamelCase names (typos) are flagged.
102
+ return true if const_result == true
103
+ else
104
+ # Lenient (default): any syntactically valid constant name is
105
+ # accepted, because YARD does not load the project's code so most
106
+ # real types are not const_defined? in this process.
107
+ return true unless const_result.nil?
108
+ end
95
109
 
96
110
  # Check YARD registry (for classes defined in parsed files)
97
111
  # This may fail for malformed type strings or registry issues
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ripper'
4
+
3
5
  module Yard
4
6
  module Lint
5
7
  module Validators
@@ -12,11 +14,15 @@ module Yard
12
14
 
13
15
  # Matches the `yield` keyword. The negative lookbehind `(?<![:.])`
14
16
  # prevents matching method calls like `Fiber.yield` or `yielder.yield`
15
- # and symbol literals like `:yield`. Word boundaries ensure `yield_self`
16
- # and similar identifiers are not matched.
17
+ # and symbol literals like `:yield`. The negative lookahead `(?!:)`
18
+ # prevents matching the label form - a symbol hash key or keyword
19
+ # argument like `{ yield: true }` (a real block yield is never
20
+ # directly followed by a colon: `yield ::Const` has a space).
21
+ # Word boundaries ensure `yield_self` and similar identifiers are
22
+ # not matched.
17
23
  # Known limitation: `yield` inside regex literals (e.g. /yield/) is
18
24
  # not stripped before scanning; it is rare enough to be acceptable.
19
- YIELD_PATTERN = /(?<![:.])\byield\b/.freeze
25
+ YIELD_PATTERN = /(?<![:.])\byield\b(?!:)/.freeze
20
26
 
21
27
  # @return [Regexp] matches full-line Ruby comments
22
28
  COMMENT_LINE_PATTERN = /\A\s*#/.freeze
@@ -58,8 +64,42 @@ module Yard
58
64
  end
59
65
 
60
66
  # @param source [String] raw method source code
61
- # @return [Boolean] true if the source contains the `yield` keyword
67
+ # @return [Boolean] true if the method itself yields (ignoring yields
68
+ # that belong to a nested method definition)
62
69
  def source_contains_yield?(source)
70
+ sexp = ::Ripper.sexp(source)
71
+ # If the isolated source does not parse on its own, fall back to the
72
+ # line scan rather than silently missing a yield.
73
+ return yield_in_lines?(source) if sexp.nil?
74
+
75
+ method_level_yield?(sexp)
76
+ end
77
+
78
+ # Walks the Ripper S-expression for a `yield` that belongs to the
79
+ # method being analysed - one nested directly inside this definition
80
+ # rather than inside a nested `def`. `def_depth` counts enclosing
81
+ # method definitions; the analysed method's own body is depth 1, so a
82
+ # yield inside a nested `def` (depth 2+) is correctly ignored.
83
+ # @param node [Object] a Ripper S-expression node
84
+ # @param def_depth [Integer] number of enclosing `def`/`defs` nodes
85
+ # @return [Boolean] true if a yield at the method's own level is found
86
+ def method_level_yield?(node, def_depth = 0)
87
+ return false unless node.is_a?(::Array)
88
+
89
+ case node.first
90
+ when :yield, :yield0
91
+ return def_depth == 1
92
+ when :def, :defs
93
+ return node.any? { |child| method_level_yield?(child, def_depth + 1) }
94
+ end
95
+
96
+ node.any? { |child| method_level_yield?(child, def_depth) }
97
+ end
98
+
99
+ # Line-based fallback used when Ripper cannot parse the source.
100
+ # @param source [String] raw method source code
101
+ # @return [Boolean] true if a `yield` keyword appears on a code line
102
+ def yield_in_lines?(source)
63
103
  source.each_line.any? do |line|
64
104
  next false if line.match?(COMMENT_LINE_PATTERN)
65
105
 
@@ -13,14 +13,6 @@ module Yard
13
13
  #
14
14
  # This validator is disabled by default (opt-in).
15
15
  #
16
- # @note Does not flag the inverse case (has `@yield` tag but no actual
17
- # `yield` in source) - that is intentional for abstract/overridable methods.
18
- #
19
- # @note Known limitation: `yield` appearing inside heredoc bodies or
20
- # multi-line string literals may produce false positives. These cases
21
- # are rare enough in practice that the validator does not attempt to
22
- # handle them.
23
- #
24
16
  # @example Bad - method yields but block is undocumented
25
17
  # # Iterates over items
26
18
  # # @param items [Array] the items
@@ -43,6 +35,14 @@ module Yard
43
35
  # items.each { |item| yield item }
44
36
  # end
45
37
  #
38
+ # @note Does not flag the inverse case (has `@yield` tag but no actual
39
+ # `yield` in source) - that is intentional for abstract/overridable methods.
40
+ #
41
+ # @note Known limitation: `yield` appearing inside heredoc bodies or
42
+ # multi-line string literals may produce false positives. These cases
43
+ # are rare enough in practice that the validator does not attempt to
44
+ # handle them.
45
+ #
46
46
  # ## Configuration
47
47
  #
48
48
  # Tags/MissingYield:
@@ -14,6 +14,15 @@ module Yard
14
14
  # Pattern to match non-ASCII characters
15
15
  NON_ASCII_PATTERN = /[^\x00-\x7F]/
16
16
 
17
+ # Matches a string literal type (e.g. "naïve"). Such literals are
18
+ # values, not Ruby type names, so non-ASCII inside them is valid.
19
+ STRING_LITERAL = /\A("[^"]*"|'[^']*')\z/
20
+ # Matches a quoted-symbol literal type (e.g. :"naïve"); non-ASCII
21
+ # inside the quoted value is likewise valid.
22
+ QUOTED_SYMBOL_LITERAL = /\A:("[^"]*"|'[^']*')\z/
23
+
24
+ private_constant :STRING_LITERAL, :QUOTED_SYMBOL_LITERAL
25
+
17
26
  # Execute query for a single object during in-process execution.
18
27
  # Checks type specifications for non-ASCII characters.
19
28
  # @param object [YARD::CodeObjects::Base] the code object to query
@@ -21,12 +30,15 @@ module Yard
21
30
  # @return [void]
22
31
  def in_process_query(object, collector)
23
32
  validated_tags = config.validator_config('Tags/NonAsciiType', 'ValidatedTags') ||
24
- %w[param option return yieldreturn yieldparam]
33
+ Config.defaults['ValidatedTags']
25
34
 
26
35
  all_typed_tags(object.docstring, validated_tags).each do |tag|
27
36
  next unless tag.types
28
37
 
29
38
  tag.types.each do |type_str|
39
+ # Skip literal value types - non-ASCII is valid inside them.
40
+ next if type_str.match?(STRING_LITERAL) || type_str.match?(QUOTED_SYMBOL_LITERAL)
41
+
30
42
  non_ascii_chars = type_str.scan(NON_ASCII_PATTERN).uniq
31
43
  next if non_ascii_chars.empty?
32
44
 
@@ -28,6 +28,7 @@ module Yard
28
28
  severity: configured_severity,
29
29
  type: self.class.offense_type,
30
30
  name: offense_data[:name] || self.class.offense_name,
31
+ validator: validator_name,
31
32
  message: build_message(offense_data),
32
33
  location: offense_data[:location] || offense_data[:file],
33
34
  location_line: offense_data[:line] || offense_data[:location_line] || 0
@@ -28,8 +28,15 @@ module Yard
28
28
 
29
29
  return unless has_options_param
30
30
 
31
- # Check if method has any @option tags
32
- option_tags = object.tags(:option)
31
+ # A parameter that merely has an options-like name but is
32
+ # documented as a non-Hash type (e.g. a Boolean keyword argument
33
+ # or an Array) is not an options hash, so do not demand @option.
34
+ return if options_param_documented_as_non_hash?(object, parameter_names)
35
+
36
+ # Check if method has any @option tags; tags nested inside
37
+ # overload blocks live on the overload's own docstring, so
38
+ # check those too
39
+ option_tags = all_typed_tags(object.docstring, %w[option])
33
40
 
34
41
  return unless option_tags.empty?
35
42
 
@@ -39,6 +46,27 @@ module Yard
39
46
 
40
47
  private
41
48
 
49
+ # Whether an options-named parameter is documented with a concrete
50
+ # non-Hash @param type. Such a parameter (e.g. `@param options
51
+ # [Boolean]`) is not an options hash and should not require @option.
52
+ # @param object [YARD::CodeObjects::MethodObject] the method
53
+ # @param parameter_names [Array<String>] configured options names
54
+ # @return [Boolean]
55
+ def options_param_documented_as_non_hash?(object, parameter_names)
56
+ param_tags = all_typed_tags(object.docstring, %w[param])
57
+
58
+ object.parameters.any? do |param|
59
+ name = param[0].to_s.gsub(/[*:]/, '')
60
+ next false unless parameter_names.include?(name)
61
+
62
+ tag = param_tags.find { |t| t.name == name }
63
+ types = tag&.types
64
+ next false if types.nil? || types.empty?
65
+
66
+ types.none? { |type| type.to_s.match?(/\AHash\b|\AHash[<{(]/) }
67
+ end
68
+ end
69
+
42
70
  # @return [Array<String>] parameter names that should have @option tags
43
71
  def config_parameter_names
44
72
  config.validator_config('Tags/OptionTags', 'ParameterNames') || Config.defaults['ParameterNames']
@@ -40,12 +40,19 @@ module Yard
40
40
  end
41
41
 
42
42
  base_hash.delete_if { |_key, value| value == 'valid' }
43
- order = base_hash.values.map(&:last)
44
43
 
45
- Validators::Documentation::UndocumentedMethodArguments::Parser
46
- .new
47
- .call(base_hash.values.map(&:first).join("\n"))
48
- .each.with_index { |element, index| element[:order] = order[index] }
44
+ location_parser = Validators::Documentation::UndocumentedMethodArguments::Parser.new
45
+
46
+ # Parse each location together with its own ordering so that an
47
+ # unparseable location line drops only its own offense instead of
48
+ # shifting the orderings of all offenses that follow it
49
+ base_hash.values.filter_map do |location, ordering|
50
+ element = location_parser.call(location).first
51
+ next unless element
52
+
53
+ element[:order] = ordering
54
+ element
55
+ end
49
56
  end
50
57
 
51
58
  private
@@ -17,7 +17,9 @@ module Yard
17
17
  # @param collector [Executor::ResultCollector] collector for output
18
18
  # @return [void]
19
19
  def in_process_query(object, collector)
20
- return if object.is_alias?
20
+ # is_alias? exists only on method objects; on namespace objects
21
+ # YARD's method_missing raises NameError, so guard by type first
22
+ return if object.type == :method && object.is_alias?
21
23
 
22
24
  # Extract @tag names from docstring
23
25
  tag_pattern = /^@(\S+)/
@@ -51,7 +53,10 @@ module Yard
51
53
 
52
54
  # @return [Array<String>] tags order
53
55
  def tags_order
54
- config.validator_config('Tags/Order', 'EnforcedOrder')
56
+ # Fall back to the default when EnforcedOrder is unset or
57
+ # explicitly null in the config - otherwise `nil.dup` crashes
58
+ # the entire run.
59
+ config_or_default('EnforcedOrder')
55
60
  end
56
61
  end
57
62
  end