yard-lint 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -498
  3. data/README.md +103 -1
  4. data/bin/yard-lint +100 -40
  5. data/lib/yard/lint/config.rb +81 -5
  6. data/lib/yard/lint/config_validator.rb +7 -191
  7. data/lib/yard/lint/contractable/contract.rb +219 -0
  8. data/lib/yard/lint/contractable/result.rb +93 -0
  9. data/lib/yard/lint/contractable/rule.rb +10 -0
  10. data/lib/yard/lint/contracts/base.rb +57 -0
  11. data/lib/yard/lint/contracts/category.rb +71 -0
  12. data/lib/yard/lint/contracts/config.rb +81 -0
  13. data/lib/yard/lint/contracts/validator.rb +72 -0
  14. data/lib/yard/lint/errors.rb +3 -0
  15. data/lib/yard/lint/executor/in_process_registry.rb +1 -1
  16. data/lib/yard/lint/explainer.rb +205 -0
  17. data/lib/yard/lint/formatters/sarif.rb +218 -0
  18. data/lib/yard/lint/git.rb +5 -3
  19. data/lib/yard/lint/result_builder.rb +7 -6
  20. data/lib/yard/lint/runner.rb +2 -21
  21. data/lib/yard/lint/templates/default_config.yml +10 -0
  22. data/lib/yard/lint/templates/strict_config.yml +10 -0
  23. data/lib/yard/lint/todo_generator.rb +143 -12
  24. data/lib/yard/lint/validators/base.rb +63 -0
  25. data/lib/yard/lint/validators/documentation/reference_typo/config.rb +20 -0
  26. data/lib/yard/lint/validators/documentation/reference_typo/messages_builder.rb +24 -0
  27. data/lib/yard/lint/validators/documentation/reference_typo/parser.rb +44 -0
  28. data/lib/yard/lint/validators/documentation/reference_typo/result.rb +27 -0
  29. data/lib/yard/lint/validators/documentation/reference_typo/validator.rb +214 -0
  30. data/lib/yard/lint/validators/documentation/reference_typo.rb +39 -0
  31. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/parser.rb +13 -12
  32. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/validator.rb +84 -5
  33. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods.rb +16 -3
  34. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +3 -2
  35. data/lib/yard/lint/validators/tags/example_style/validator.rb +4 -4
  36. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +4 -2
  37. data/lib/yard/lint/validators/tags/invalid_types/messages_builder.rb +15 -4
  38. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +18 -7
  39. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +22 -12
  40. data/lib/yard/lint/validators/tags/overload_consistency/config.rb +20 -0
  41. data/lib/yard/lint/validators/tags/overload_consistency/messages_builder.rb +32 -0
  42. data/lib/yard/lint/validators/tags/overload_consistency/parser.rb +49 -0
  43. data/lib/yard/lint/validators/tags/overload_consistency/result.rb +27 -0
  44. data/lib/yard/lint/validators/tags/overload_consistency/validator.rb +71 -0
  45. data/lib/yard/lint/validators/tags/overload_consistency.rb +53 -0
  46. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +3 -3
  47. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +8 -0
  48. data/lib/yard/lint/validators/tags/tag_separator/validator.rb +8 -0
  49. data/lib/yard/lint/validators/tags/type_syntax/parser.rb +13 -2
  50. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +11 -1
  51. data/lib/yard/lint/version.rb +1 -1
  52. data/lib/yard/lint.rb +3 -57
  53. metadata +23 -2
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Contracts
6
+ # Validates the whole configuration: root keys, AllValidators, categories and validators
7
+ class Config < Base
8
+ # Valid category names (from ConfigUpdater::CATEGORY_ORDER)
9
+ VALID_CATEGORIES = %w[Documentation Tags Warnings Semantic].freeze
10
+
11
+ # Keys that are valid at the root level but are not validators
12
+ SPECIAL_KEYS = %w[
13
+ AllValidators
14
+ inherit_from
15
+ inherit_gem
16
+ ].freeze
17
+
18
+ # Validator names must contain a '/' (category/name format)
19
+ virtual do |data|
20
+ data.keys.filter_map do |key|
21
+ next if SPECIAL_KEYS.include?(key)
22
+ next if VALID_CATEGORIES.include?(key)
23
+ next if key.include?('/')
24
+
25
+ [[key], message("Unknown validator: '#{key}'", suggest_validator_name(key))]
26
+ end
27
+ end
28
+
29
+ optional('AllValidators') do |all_validators|
30
+ !all_validators || all_validators.is_a?(Hash) ||
31
+ "Invalid AllValidators: must be a Hash, got #{all_validators.class}"
32
+ end
33
+
34
+ # Only specific known settings are validated, unknown keys pass through
35
+ # This allows users to put custom data in AllValidators
36
+ nested('AllValidators') do
37
+ optional('FailOnSeverity') do |fail_on|
38
+ !fail_on || Lint::Config::VALID_SEVERITIES.include?(fail_on.to_s) ||
39
+ message("Invalid FailOnSeverity: '#{fail_on}'", " Valid values: #{Lint::Config::VALID_SEVERITIES.join(', ')}")
40
+ end
41
+
42
+ optional('MinCoverage') do |min_cov|
43
+ !min_cov || (min_cov.is_a?(Numeric) && !(min_cov < 0 || min_cov > 100)) ||
44
+ message("Invalid MinCoverage: '#{min_cov}'", ' Must be a number between 0 and 100')
45
+ end
46
+
47
+ optional('Exclude') do |exclude|
48
+ !exclude || exclude.is_a?(Array) ||
49
+ "Invalid Exclude in AllValidators: must be an array, got #{exclude.class}"
50
+ end
51
+
52
+ optional('YardOptions') do |yard_options|
53
+ !yard_options || yard_options.is_a?(Array) ||
54
+ "Invalid YardOptions in AllValidators: must be an array, got #{yard_options.class}"
55
+ end
56
+ end
57
+
58
+ virtual do |data|
59
+ data.flat_map do |key, value|
60
+ next [] unless VALID_CATEGORIES.include?(key)
61
+ next [] unless value.is_a?(Hash)
62
+
63
+ nested_errors(Category.new(key).call(value, scope: [key]))
64
+ end
65
+ end
66
+
67
+ virtual do |data|
68
+ data.flat_map do |key, value|
69
+ next [] unless key.include?('/')
70
+
71
+ unless value.is_a?(Hash)
72
+ next [[[key], "Invalid configuration for validator '#{key}': expected a Hash, got #{value.class}"]]
73
+ end
74
+
75
+ nested_errors(Validator.new(key).call(value, scope: [key]))
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Contracts
6
+ # Validates a single validator configuration (e.g. `Tags/Order: { Enabled: ... }`)
7
+ class Validator < Base
8
+ # Base config keys that are valid for all validators
9
+ BASE_KEYS = %w[Enabled Severity Exclude YardOptions].freeze
10
+
11
+ # @return [String] validator name
12
+ attr_reader :name
13
+
14
+ # @param name [String] validator name used in error messages
15
+ def initialize(name)
16
+ super()
17
+ @name = name
18
+ end
19
+
20
+ virtual do |_data, _errors, contract|
21
+ next if ConfigLoader::ALL_VALIDATORS.include?(contract.name)
22
+
23
+ [[[], message("Unknown validator: '#{contract.name}'", suggest_validator_name(contract.name))]]
24
+ end
25
+
26
+ optional('Enabled') do |enabled, _data, _errors, contract|
27
+ !enabled || BOOLEAN_VALUES.include?(enabled) ||
28
+ message("Invalid Enabled value for #{contract.name}: '#{enabled}'", ' Must be true or false')
29
+ end
30
+
31
+ optional('Severity') do |severity, _data, _errors, contract|
32
+ !severity || Lint::Config::VALID_SEVERITIES.include?(severity.to_s) ||
33
+ message(
34
+ "Invalid Severity for #{contract.name}: '#{severity}'",
35
+ " Valid values: #{Lint::Config::VALID_SEVERITIES.join(', ')}",
36
+ suggest_similar_severity(severity.to_s)
37
+ )
38
+ end
39
+
40
+ optional('Exclude') do |exclude, _data, _errors, contract|
41
+ !exclude || exclude.is_a?(Array) ||
42
+ "Invalid Exclude for #{contract.name}: must be an array, got #{exclude.class}"
43
+ end
44
+
45
+ optional('YardOptions') do |yard_options, _data, _errors, contract|
46
+ !yard_options || yard_options.is_a?(Array) ||
47
+ "Invalid YardOptions for #{contract.name}: must be an array, got #{yard_options.class}"
48
+ end
49
+
50
+ # Unknown validator-specific keys (an unknown validator is already reported)
51
+ virtual do |data, _errors, contract|
52
+ next unless ConfigLoader::ALL_VALIDATORS.include?(contract.name)
53
+
54
+ validator_config_class = ConfigLoader.validator_config(contract.name)
55
+ next unless validator_config_class
56
+
57
+ valid_keys = (validator_config_class.defaults.keys + Lint::Config::METADATA_KEYS + BASE_KEYS).uniq.sort
58
+
59
+ (data.keys - valid_keys).map do |key|
60
+ [
61
+ [key],
62
+ message(
63
+ "Unknown configuration key for #{contract.name}: '#{key}'",
64
+ " Valid keys: #{valid_keys.join(', ')}"
65
+ )
66
+ ]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -22,6 +22,9 @@ module Yard
22
22
  # Raised when .yard-lint-todo.yml already exists without force flag
23
23
  class TodoFileExistsError < BaseError; end
24
24
 
25
+ # Raised when .yard-lint-todo.yml is required but does not exist
26
+ class TodoFileNotFoundError < BaseError; end
27
+
25
28
  # Raised when configuration is invalid
26
29
  class InvalidConfigError < BaseError; end
27
30
  end
@@ -112,7 +112,7 @@ module Yard
112
112
  next false unless obj.file
113
113
 
114
114
  file_excludes.any? do |pattern|
115
- File.fnmatch(pattern, obj.file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
115
+ File.fnmatch(pattern, obj.file, Config::EXCLUDE_FLAGS)
116
116
  end
117
117
  end
118
118
  end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ # Renders a human-readable explanation of a single validator for the terminal.
6
+ #
7
+ # The explanation is sourced from the validator's own YARD documentation block
8
+ # (the module file, e.g. `validators/tags/type_syntax.rb`) - yard-lint reads its
9
+ # own YARD docs to explain itself. This keeps the explanation next to the code it
10
+ # documents rather than in a separate file that can drift, and gives the doc
11
+ # blocks a standing reason to stay accurate.
12
+ #
13
+ # @example
14
+ # puts Yard::Lint::Explainer.call("Tags/TypeSyntax")
15
+ class Explainer
16
+ # Keys in a validator's defaults that are surfaced in the header rather than
17
+ # listed as tunable configuration options.
18
+ META_KEYS = %w[Enabled Severity].freeze
19
+
20
+ # Build the explanation for a validator.
21
+ # @param name [String] validator name (e.g. 'Tags/TypeSyntax')
22
+ # @return [String] formatted, terminal-ready explanation
23
+ def self.call(name)
24
+ new(name).call
25
+ end
26
+
27
+ # Structured description of a validator for programmatic consumers such as
28
+ # the SARIF formatter's rule metadata (as opposed to #call's terminal text).
29
+ # @param name [String] validator name (e.g. 'Tags/TypeSyntax')
30
+ # @return [Hash{Symbol => String}, nil] { short:, full: }, or nil when the
31
+ # name is unknown or the validator has no docstring
32
+ def self.describe(name)
33
+ new(name).describe
34
+ end
35
+
36
+ # @param name [String] validator name (e.g. 'Tags/TypeSyntax')
37
+ def initialize(name)
38
+ @name = name
39
+ end
40
+
41
+ # @return [Hash{Symbol => String}, nil] { short:, full: } description of the
42
+ # validator, or nil when unknown or undocumented
43
+ def describe
44
+ return nil unless ConfigLoader::ALL_VALIDATORS.include?(@name)
45
+
46
+ doc = documentation
47
+ return nil unless doc
48
+
49
+ full = prose_only(doc[:description])
50
+ return nil if full.empty?
51
+
52
+ { short: first_sentence(full), full: full }
53
+ end
54
+
55
+ # @return [String] formatted, terminal-ready explanation
56
+ # @raise [ArgumentError] if name is not a known validator
57
+ def call
58
+ unless ConfigLoader::ALL_VALIDATORS.include?(@name)
59
+ raise ArgumentError, "Unknown validator: #{@name.inspect}"
60
+ end
61
+
62
+ [header, body].compact.join("\n")
63
+ end
64
+
65
+ private
66
+
67
+ # @return [String] the metadata header (name, defaults) for the validator
68
+ def header
69
+ lines = [@name.to_s]
70
+ lines << " Enabled by default: #{default_config.validator_enabled?(@name)}"
71
+ lines << " Default severity: #{default_config.validator_severity(@name)}"
72
+
73
+ config_keys = validator_defaults.keys - META_KEYS
74
+ lines << " Configuration keys: #{config_keys.join(', ')}" if config_keys.any?
75
+ lines.join("\n")
76
+ end
77
+
78
+ # @return [String] the documentation body (description, config, examples)
79
+ def body
80
+ doc = documentation
81
+ return raw_comment_fallback if doc.nil?
82
+
83
+ sections = ["\n#{doc[:description]}"]
84
+ sections << render_examples(doc[:examples]) if doc[:examples].any?
85
+ sections.join("\n")
86
+ end
87
+
88
+ # @param examples [Array(String, String)] pairs of example label and code
89
+ # @return [String] the rendered "Examples:" section
90
+ def render_examples(examples)
91
+ rendered = examples.map do |name, text|
92
+ code = text.each_line.map { |line| " #{line}" }.join
93
+ " #{name}\n#{code}".rstrip
94
+ end
95
+ "\nExamples:\n#{rendered.join("\n\n")}"
96
+ end
97
+
98
+ # A config carrying only built-in defaults (no user overrides), used as the
99
+ # authoritative source for the validator's default enabled state and
100
+ # severity so the header never drifts from how the linter actually resolves
101
+ # them (see Config#validator_enabled? / #validator_severity).
102
+ # @return [Config] the defaults-only config
103
+ def default_config
104
+ @default_config ||= Config.new
105
+ end
106
+
107
+ # @return [Hash] the validator's default configuration
108
+ def validator_defaults
109
+ config = ConfigLoader.validator_config(@name)
110
+ config&.defaults || {}
111
+ end
112
+
113
+ # Parse the validator's module file with YARD and extract its description
114
+ # and examples. Runs in an isolated registry - any objects a caller parsed
115
+ # before us are saved and restored - so explaining a validator never
116
+ # clobbers the shared YARD::Registry.
117
+ # @return [Hash, nil] { description: String, examples: Array((String, String)) }
118
+ # or nil if no docstring is available
119
+ def documentation
120
+ path = source_path
121
+ return nil unless path && File.exist?(path)
122
+
123
+ saved = YARD::Registry.all
124
+ YARD::Registry.clear
125
+ begin
126
+ YARD.parse_string(File.read(path))
127
+ docstring = YARD::Registry.at(object_path)&.docstring
128
+ return nil if docstring.nil? || docstring.to_s.strip.empty?
129
+
130
+ {
131
+ description: docstring.to_s,
132
+ examples: docstring.tags(:example).map { |tag| [tag.name, tag.text.to_s] }
133
+ }
134
+ ensure
135
+ YARD::Registry.clear
136
+ saved.each { |object| YARD::Registry.register(object) }
137
+ end
138
+ end
139
+
140
+ # The human description of a validator: its docstring prose with the leading
141
+ # "<Name> validator" title line and any trailing "## ..." sections (e.g.
142
+ # Configuration) removed, leaving just the explanation.
143
+ # @param description [String] the raw docstring text
144
+ # @return [String] the trimmed prose (may be empty)
145
+ def prose_only(description)
146
+ lines = description.to_s.lines.take_while { |line| !line.start_with?('## ') }
147
+ text = lines.join
148
+ text = text.sub(/\A.*validator[ \t]*\n+/i, '') if text.match?(/\A.*validator[ \t]*\n/i)
149
+ text.strip
150
+ end
151
+
152
+ # @param text [String] prose text
153
+ # @return [String] the first sentence, for a concise one-line summary
154
+ def first_sentence(text)
155
+ flat = text.tr("\n", ' ').squeeze(' ').strip
156
+ flat[/.*?[.!?](?=\s|\z)/] || flat
157
+ end
158
+
159
+ # @return [String] the fully-qualified code object path (e.g.
160
+ # 'Yard::Lint::Validators::Tags::TypeSyntax')
161
+ def object_path
162
+ category, validator = @name.split('/')
163
+ "Yard::Lint::Validators::#{category}::#{validator}"
164
+ end
165
+
166
+ # Resolve the validator module file from its name by inverting the casing
167
+ # convention used in ConfigLoader.discover_validators.
168
+ # @return [String] absolute path to the validator module file
169
+ def source_path
170
+ category, validator = @name.split('/')
171
+ File.join(__dir__, 'validators', snake_case(category), "#{snake_case(validator)}.rb")
172
+ end
173
+
174
+ # Convert a PascalCase segment to snake_case (inverse of the
175
+ # `split('_').map(&:capitalize).join` used during discovery).
176
+ # @param string [String] PascalCase name (e.g. 'TypeSyntax')
177
+ # @return [String] snake_case name (e.g. 'type_syntax')
178
+ def snake_case(string)
179
+ string.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
180
+ end
181
+
182
+ # Defensive fallback: print the raw leading comment block from the module
183
+ # file when YARD yields no docstring. Every validator currently ships a doc
184
+ # block, so this should not normally be reached.
185
+ # @return [String, nil] the raw comment text, or nil if it cannot be read
186
+ def raw_comment_fallback
187
+ path = source_path
188
+ return nil unless path && File.exist?(path)
189
+
190
+ comment = []
191
+ File.foreach(path) do |line|
192
+ stripped = line.strip
193
+ if stripped.start_with?('#')
194
+ comment << stripped.sub(/\A#\s?/, '')
195
+ elsif stripped.start_with?('module ') && comment.any?
196
+ break
197
+ else
198
+ comment.clear
199
+ end
200
+ end
201
+ comment.any? ? "\n#{comment.join("\n")}" : nil
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+ require 'digest'
6
+
7
+ module Yard
8
+ module Lint
9
+ module Formatters
10
+ # Renders lint results as SARIF 2.1.0 (Static Analysis Results Interchange
11
+ # Format) - the JSON format GitHub code scanning and other result aggregators
12
+ # ingest, so yard-lint offenses can surface as PR annotations / Security-tab
13
+ # alerts instead of being buried in CI logs.
14
+ #
15
+ # This is output only: it does not change which offenses are reported, the
16
+ # exit code, or any configuration - it is an alternative rendering of the
17
+ # same result. Rule metadata (`rules[]`) is populated from each validator's
18
+ # own YARD documentation via {Explainer.describe}.
19
+ class Sarif
20
+ # SARIF 2.1.0 JSON schema URI, emitted as the document's `$schema`.
21
+ # @return [String]
22
+ SCHEMA_URI = 'https://json.schemastore.org/sarif-2.1.0.json'
23
+
24
+ # Tool information URI, emitted as the driver's `informationUri`.
25
+ # @return [String]
26
+ INFORMATION_URI = 'https://github.com/mensfeld/yard-lint'
27
+
28
+ # Help URI for each rule (the wiki validator reference).
29
+ # @return [String]
30
+ HELP_URI = 'https://github.com/mensfeld/yard-lint/wiki/Validators'
31
+
32
+ # SARIF supports only error/warning/note (plus none). yard-lint's
33
+ # `convention` maps to note, and `never` (report-only) maps to none.
34
+ # @return [Hash{String => String}]
35
+ SEVERITY_LEVELS = {
36
+ 'error' => 'error',
37
+ 'warning' => 'warning',
38
+ 'convention' => 'note',
39
+ 'never' => 'none'
40
+ }.freeze
41
+
42
+ # Fallback SARIF level for any unrecognized severity.
43
+ # @return [String]
44
+ DEFAULT_LEVEL = 'warning'
45
+
46
+ # partialFingerprints key, versioned so the scheme can evolve without
47
+ # invalidating history. GitHub uses it to correlate results across runs.
48
+ # @return [String]
49
+ FINGERPRINT_KEY = 'yardLintOffense/v1'
50
+
51
+ # @param offenses [Array<Hash>] offense hashes (from `result.offenses`)
52
+ # @param base_dir [String] directory that offense paths are made relative
53
+ # to, so GitHub can map them onto the repository (default: current dir)
54
+ def initialize(offenses, base_dir: Dir.pwd)
55
+ @offenses = offenses
56
+ @base_dir = base_dir
57
+ end
58
+
59
+ # @return [String] pretty-printed SARIF 2.1.0 JSON
60
+ def generate
61
+ JSON.pretty_generate(document)
62
+ end
63
+
64
+ # @return [Hash] the SARIF document as a Ruby hash
65
+ def document
66
+ rule_ids = @offenses.map { |offense| offense[:validator] }.compact.uniq
67
+ rule_index = rule_ids.each_with_index.to_h
68
+
69
+ {
70
+ '$schema' => SCHEMA_URI,
71
+ 'version' => '2.1.0',
72
+ 'runs' => [
73
+ {
74
+ 'tool' => { 'driver' => driver(rule_ids) },
75
+ 'results' => @offenses.map { |offense| result(offense, rule_index) }
76
+ }
77
+ ]
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ # @param rule_ids [Array<String>] distinct validator names, in result order
84
+ # @return [Hash] the SARIF tool driver, including rule metadata
85
+ def driver(rule_ids)
86
+ {
87
+ 'name' => 'yard-lint',
88
+ 'informationUri' => INFORMATION_URI,
89
+ 'version' => Yard::Lint::VERSION,
90
+ 'rules' => rule_ids.map { |id| rule(id) }
91
+ }
92
+ end
93
+
94
+ # @param id [String] validator name (e.g. 'Tags/TypeSyntax')
95
+ # @return [Hash] a SARIF reportingDescriptor for the validator
96
+ def rule(id)
97
+ entry = {
98
+ 'id' => id,
99
+ 'name' => id.split('/').last,
100
+ 'helpUri' => HELP_URI,
101
+ 'defaultConfiguration' => { 'level' => level(default_severity(id)) }
102
+ }
103
+
104
+ described = Explainer.describe(id)
105
+ if described
106
+ entry['shortDescription'] = { 'text' => described[:short] }
107
+ entry['fullDescription'] = { 'text' => described[:full] }
108
+ end
109
+
110
+ entry
111
+ end
112
+
113
+ # @param offense [Hash] an offense hash
114
+ # @param rule_index [Hash{String => Integer}] validator name to rule index
115
+ # @return [Hash] a SARIF result
116
+ def result(offense, rule_index)
117
+ uri = relative_uri(offense[:location])
118
+ entry = {}
119
+
120
+ # ruleId/ruleIndex are optional in SARIF; omit them (rather than emit
121
+ # null, which is schema-invalid) for the rare offense with no validator.
122
+ validator = offense[:validator]
123
+ if validator
124
+ entry['ruleId'] = validator
125
+ entry['ruleIndex'] = rule_index[validator]
126
+ end
127
+
128
+ entry['level'] = level(offense[:severity])
129
+ entry['message'] = { 'text' => offense[:message].to_s }
130
+ entry['locations'] = [location(offense, uri)]
131
+ entry['partialFingerprints'] = { FINGERPRINT_KEY => fingerprint(offense, uri) }
132
+ entry
133
+ end
134
+
135
+ # @param offense [Hash] an offense hash
136
+ # @param uri [String] the artifact URI (already made relative)
137
+ # @return [Hash] a SARIF location
138
+ def location(offense, uri)
139
+ {
140
+ 'physicalLocation' => {
141
+ 'artifactLocation' => { 'uri' => uri },
142
+ 'region' => { 'startLine' => start_line(offense) }
143
+ }
144
+ }
145
+ end
146
+
147
+ # A line-independent fingerprint so GitHub correlates the same offense
148
+ # across commits without churn (close/reopen) when unrelated lines shift.
149
+ # Keys on the validator, file, and offending object, excluding the line
150
+ # number and (when an object is identified) the message, so a re-worded
151
+ # message does not churn alerts. Validators that do not identify an object
152
+ # fall back to a normalized message, so distinct offenses in one file
153
+ # still get distinct fingerprints instead of collapsing into one alert.
154
+ # @param offense [Hash] an offense hash
155
+ # @param uri [String] the artifact URI (already made relative)
156
+ # @return [String] a stable hex digest
157
+ def fingerprint(offense, uri)
158
+ element = offense[:element].to_s
159
+ # Tag the identity with its source so an element that happens to equal
160
+ # another offense's normalized message cannot collide.
161
+ identity = element.empty? ? "m:#{normalized_message(offense[:message])}" : "e:#{element}"
162
+ parts = [offense[:validator], uri, identity]
163
+ Digest::SHA256.hexdigest(parts.map(&:to_s).join("\x00"))
164
+ end
165
+
166
+ # Message reduced to a stable identity for offenses with no :element:
167
+ # whitespace is collapsed so trivial formatting differences do not churn
168
+ # alerts. Case is preserved - messages embed case-sensitive names (types,
169
+ # methods, constants), so downcasing could merge genuinely distinct offenses.
170
+ # @param message [String, nil] the offense message
171
+ # @return [String] the normalized message
172
+ def normalized_message(message)
173
+ message.to_s.gsub(/\s+/, ' ').strip
174
+ end
175
+
176
+ # @param severity [String, nil] a yard-lint severity
177
+ # @return [String] the SARIF level
178
+ def level(severity)
179
+ SEVERITY_LEVELS.fetch(severity.to_s, DEFAULT_LEVEL)
180
+ end
181
+
182
+ # SARIF regions are 1-based; guard against a missing or zero line.
183
+ # @param offense [Hash] an offense hash
184
+ # @return [Integer] a 1-based start line
185
+ def start_line(offense)
186
+ line = (offense[:location_line] || offense[:line]).to_i
187
+ line.positive? ? line : 1
188
+ end
189
+
190
+ # Make a path repository-relative so GitHub maps it onto a file. Paths
191
+ # already relative are kept; a path outside base_dir keeps its original
192
+ # value rather than emitting a `../` or absolute URI.
193
+ # @param location [String, nil] the offense location path
194
+ # @return [String] a relative URI where possible
195
+ def relative_uri(location)
196
+ path = location.to_s
197
+ return path if path.empty?
198
+
199
+ absolute = File.absolute_path(path, @base_dir)
200
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(@base_dir)).to_s
201
+ relative.start_with?('..') ? path : relative
202
+ rescue ArgumentError
203
+ # relative_path_from raises across platforms/mixed roots; fall back.
204
+ path
205
+ end
206
+
207
+ # The validator's default severity, from a defaults-only config, for the
208
+ # rule's defaultConfiguration.level.
209
+ # @param id [String] validator name
210
+ # @return [String] default severity
211
+ def default_severity(id)
212
+ @default_config ||= Config.new
213
+ @default_config.validator_severity(id)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
data/lib/yard/lint/git.rb CHANGED
@@ -89,15 +89,17 @@ module Yard
89
89
  filter_ruby_files(files.uniq, path)
90
90
  end
91
91
 
92
- private
93
-
94
- # Absolute path to the repository root (git reports paths relative to it)
92
+ # Absolute path to the repository root (git reports paths relative to it).
93
+ # Used to make report paths repository-relative; falls back to the current
94
+ # directory outside a git repo.
95
95
  # @return [String] repo root, or the current directory if it can't be determined
96
96
  def repository_root
97
97
  stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel')
98
98
  status.success? ? stdout.strip : Dir.pwd
99
99
  end
100
100
 
101
+ private
102
+
101
103
  # Ensure we're in a git repository
102
104
  # @raise [Error] if not in a git repository
103
105
  def ensure_git_repository!
@@ -22,8 +22,7 @@ module Yard
22
22
  validator_cfg = ConfigLoader.validator_config(validator_name)
23
23
  return nil unless validator_module && validator_cfg
24
24
 
25
- # Skip if this validator is a child of a composite
26
- return nil if composite_child?(validator_name)
25
+ return nil if reported_by_composite?(validator_name)
27
26
 
28
27
  # Handle composite validators (those that combine multiple validators)
29
28
  unless validator_cfg.combines_with.empty?
@@ -36,15 +35,17 @@ module Yard
36
35
 
37
36
  private
38
37
 
39
- # Check if a validator is a child of a composite validator
38
+ # Check if a composite parent reports this validator's offenses
39
+ # A child reports on its own only when --only selects it without its parent
40
40
  # @param validator_name [String] validator name to check
41
- # @return [Boolean] true if this is a composite child
42
- def composite_child?(validator_name)
41
+ # @return [Boolean] true if a composite parent reports it
42
+ def reported_by_composite?(validator_name)
43
43
  ConfigLoader::ALL_VALIDATORS.any? do |parent_name|
44
44
  parent_cfg = ConfigLoader.validator_config(parent_name)
45
45
  next unless parent_cfg
46
+ next unless parent_cfg.combines_with.include?(validator_name)
46
47
 
47
- parent_cfg.combines_with.include?(validator_name)
48
+ config.only_validators.empty? || config.only_validators.include?(parent_name)
48
49
  end
49
50
  end
50
51