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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +201 -498
- data/README.md +103 -1
- data/bin/yard-lint +100 -40
- data/lib/yard/lint/config.rb +81 -5
- data/lib/yard/lint/config_validator.rb +7 -191
- data/lib/yard/lint/contractable/contract.rb +219 -0
- data/lib/yard/lint/contractable/result.rb +93 -0
- data/lib/yard/lint/contractable/rule.rb +10 -0
- data/lib/yard/lint/contracts/base.rb +57 -0
- data/lib/yard/lint/contracts/category.rb +71 -0
- data/lib/yard/lint/contracts/config.rb +81 -0
- data/lib/yard/lint/contracts/validator.rb +72 -0
- data/lib/yard/lint/errors.rb +3 -0
- data/lib/yard/lint/executor/in_process_registry.rb +1 -1
- data/lib/yard/lint/explainer.rb +205 -0
- data/lib/yard/lint/formatters/sarif.rb +218 -0
- data/lib/yard/lint/git.rb +5 -3
- data/lib/yard/lint/result_builder.rb +7 -6
- data/lib/yard/lint/runner.rb +2 -21
- data/lib/yard/lint/templates/default_config.yml +10 -0
- data/lib/yard/lint/templates/strict_config.yml +10 -0
- data/lib/yard/lint/todo_generator.rb +143 -12
- data/lib/yard/lint/validators/base.rb +63 -0
- data/lib/yard/lint/validators/documentation/reference_typo/config.rb +20 -0
- data/lib/yard/lint/validators/documentation/reference_typo/messages_builder.rb +24 -0
- data/lib/yard/lint/validators/documentation/reference_typo/parser.rb +44 -0
- data/lib/yard/lint/validators/documentation/reference_typo/result.rb +27 -0
- data/lib/yard/lint/validators/documentation/reference_typo/validator.rb +214 -0
- data/lib/yard/lint/validators/documentation/reference_typo.rb +39 -0
- data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/parser.rb +13 -12
- data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/validator.rb +84 -5
- data/lib/yard/lint/validators/documentation/undocumented_boolean_methods.rb +16 -3
- data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +3 -2
- data/lib/yard/lint/validators/tags/example_style/validator.rb +4 -4
- data/lib/yard/lint/validators/tags/example_syntax/validator.rb +4 -2
- data/lib/yard/lint/validators/tags/invalid_types/messages_builder.rb +15 -4
- data/lib/yard/lint/validators/tags/invalid_types/parser.rb +18 -7
- data/lib/yard/lint/validators/tags/invalid_types/validator.rb +22 -12
- data/lib/yard/lint/validators/tags/overload_consistency/config.rb +20 -0
- data/lib/yard/lint/validators/tags/overload_consistency/messages_builder.rb +32 -0
- data/lib/yard/lint/validators/tags/overload_consistency/parser.rb +49 -0
- data/lib/yard/lint/validators/tags/overload_consistency/result.rb +27 -0
- data/lib/yard/lint/validators/tags/overload_consistency/validator.rb +71 -0
- data/lib/yard/lint/validators/tags/overload_consistency.rb +53 -0
- data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +3 -3
- data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +8 -0
- data/lib/yard/lint/validators/tags/tag_separator/validator.rb +8 -0
- data/lib/yard/lint/validators/tags/type_syntax/parser.rb +13 -2
- data/lib/yard/lint/validators/tags/type_syntax/validator.rb +11 -1
- data/lib/yard/lint/version.rb +1 -1
- data/lib/yard/lint.rb +3 -57
- 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
|
data/lib/yard/lint/errors.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
42
|
-
def
|
|
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
|
-
|
|
48
|
+
config.only_validators.empty? || config.only_validators.include?(parent_name)
|
|
48
49
|
end
|
|
49
50
|
end
|
|
50
51
|
|