ace-lint 0.25.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 +7 -0
- data/.ace-defaults/lint/config.yml +5 -0
- data/.ace-defaults/lint/kramdown.yml +23 -0
- data/.ace-defaults/lint/markdown.yml +16 -0
- data/.ace-defaults/lint/ruby.yml +67 -0
- data/.ace-defaults/lint/skills.yml +138 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-lint.yml +11 -0
- data/CHANGELOG.md +584 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +14 -0
- data/exe/ace-lint +14 -0
- data/handbook/skills/as-lint-fix-issue-from/SKILL.md +34 -0
- data/handbook/skills/as-lint-process-report/SKILL.md +29 -0
- data/handbook/skills/as-lint-run/SKILL.md +27 -0
- data/handbook/workflow-instructions/lint/process-report.wf.md +175 -0
- data/handbook/workflow-instructions/lint/run.wf.md +145 -0
- data/lib/ace/lint/atoms/allowed_tools_validator.rb +100 -0
- data/lib/ace/lint/atoms/base_runner.rb +239 -0
- data/lib/ace/lint/atoms/comment_validator.rb +63 -0
- data/lib/ace/lint/atoms/config_locator.rb +162 -0
- data/lib/ace/lint/atoms/frontmatter_extractor.rb +74 -0
- data/lib/ace/lint/atoms/kramdown_parser.rb +81 -0
- data/lib/ace/lint/atoms/pattern_matcher.rb +96 -0
- data/lib/ace/lint/atoms/rubocop_runner.rb +67 -0
- data/lib/ace/lint/atoms/skill_schema_loader.rb +83 -0
- data/lib/ace/lint/atoms/standardrb_runner.rb +45 -0
- data/lib/ace/lint/atoms/type_detector.rb +121 -0
- data/lib/ace/lint/atoms/validator_registry.rb +113 -0
- data/lib/ace/lint/atoms/yaml_parser.rb +11 -0
- data/lib/ace/lint/atoms/yaml_validator.rb +69 -0
- data/lib/ace/lint/cli/commands/lint.rb +318 -0
- data/lib/ace/lint/cli.rb +25 -0
- data/lib/ace/lint/models/lint_result.rb +87 -0
- data/lib/ace/lint/models/validation_error.rb +31 -0
- data/lib/ace/lint/molecules/frontmatter_validator.rb +131 -0
- data/lib/ace/lint/molecules/group_resolver.rb +122 -0
- data/lib/ace/lint/molecules/kramdown_formatter.rb +66 -0
- data/lib/ace/lint/molecules/markdown_linter.rb +249 -0
- data/lib/ace/lint/molecules/offense_deduplicator.rb +65 -0
- data/lib/ace/lint/molecules/ruby_linter.rb +205 -0
- data/lib/ace/lint/molecules/skill_validator.rb +462 -0
- data/lib/ace/lint/molecules/validator_chain.rb +150 -0
- data/lib/ace/lint/molecules/yaml_linter.rb +53 -0
- data/lib/ace/lint/organisms/lint_doctor.rb +289 -0
- data/lib/ace/lint/organisms/lint_orchestrator.rb +294 -0
- data/lib/ace/lint/organisms/report_generator.rb +213 -0
- data/lib/ace/lint/organisms/result_reporter.rb +130 -0
- data/lib/ace/lint/version.rb +7 -0
- data/lib/ace/lint.rb +141 -0
- metadata +248 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/pattern_matcher"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lint
|
|
7
|
+
module Molecules
|
|
8
|
+
# Resolves which validator group applies to files based on pattern matching
|
|
9
|
+
# Uses configuration groups with glob patterns to determine validators
|
|
10
|
+
class GroupResolver
|
|
11
|
+
# Default group configuration when no groups are defined
|
|
12
|
+
DEFAULT_GROUPS = {
|
|
13
|
+
default: {
|
|
14
|
+
patterns: ["**/*.rb"],
|
|
15
|
+
validators: [:standardrb],
|
|
16
|
+
fallback_validators: [:rubocop]
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :groups
|
|
21
|
+
|
|
22
|
+
# Initialize with groups configuration
|
|
23
|
+
# @param groups [Hash] Groups configuration from ruby.yml
|
|
24
|
+
def initialize(groups = nil)
|
|
25
|
+
@groups = normalize_groups(groups || DEFAULT_GROUPS)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resolve validator group for a single file
|
|
29
|
+
# @param file_path [String] File path to resolve
|
|
30
|
+
# @return [Hash] { group_name:, validators:, fallback_validators:, config: }
|
|
31
|
+
def resolve(file_path)
|
|
32
|
+
match = Atoms::PatternMatcher.best_group_match(file_path, @groups)
|
|
33
|
+
|
|
34
|
+
if match
|
|
35
|
+
group_name, config = match
|
|
36
|
+
build_result(group_name, config)
|
|
37
|
+
else
|
|
38
|
+
# No match - use default group if available, otherwise return nil
|
|
39
|
+
default = @groups[:default]
|
|
40
|
+
if default
|
|
41
|
+
build_result(:default, default)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve validator groups for multiple files, grouping them by matched group
|
|
47
|
+
# @param file_paths [Array<String>] File paths to resolve
|
|
48
|
+
# @return [Hash<Symbol, Hash>] Map of group_name => { files:, validators:, ... }
|
|
49
|
+
def resolve_batch(file_paths)
|
|
50
|
+
result = Hash.new { |h, k| h[k] = {files: []} }
|
|
51
|
+
|
|
52
|
+
file_paths.each do |file_path|
|
|
53
|
+
resolution = resolve(file_path)
|
|
54
|
+
|
|
55
|
+
if resolution
|
|
56
|
+
group_name = resolution[:group_name]
|
|
57
|
+
result[group_name][:files] << file_path
|
|
58
|
+
# Store group config (only once per group)
|
|
59
|
+
result[group_name].merge!(resolution) { |_k, old, _new| old }
|
|
60
|
+
else
|
|
61
|
+
# Unmatched files go to :_unmatched_ group
|
|
62
|
+
result[:_unmatched_][:files] << file_path
|
|
63
|
+
result[:_unmatched_][:validators] ||= []
|
|
64
|
+
result[:_unmatched_][:group_name] ||= :_unmatched_
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Normalize groups configuration to use symbols and arrays
|
|
74
|
+
# @param groups [Hash] Raw groups configuration
|
|
75
|
+
# @return [Hash] Normalized groups
|
|
76
|
+
def normalize_groups(groups)
|
|
77
|
+
return {} if groups.nil?
|
|
78
|
+
|
|
79
|
+
groups.each_with_object({}) do |(name, config), result|
|
|
80
|
+
result[name.to_sym] = {
|
|
81
|
+
patterns: normalize_array(config[:patterns] || config["patterns"]),
|
|
82
|
+
validators: normalize_validators(config[:validators] || config["validators"]),
|
|
83
|
+
fallback_validators: normalize_validators(config[:fallback_validators] || config["fallback_validators"]),
|
|
84
|
+
config_path: config[:config_path] || config["config_path"]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Normalize array values
|
|
90
|
+
# @param value [Array, String, nil] Value to normalize
|
|
91
|
+
# @return [Array] Normalized array
|
|
92
|
+
def normalize_array(value)
|
|
93
|
+
return [] if value.nil?
|
|
94
|
+
|
|
95
|
+
Array(value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Normalize validators to symbols
|
|
99
|
+
# @param validators [Array, String, Symbol, nil] Validators to normalize
|
|
100
|
+
# @return [Array<Symbol>] Normalized validators
|
|
101
|
+
def normalize_validators(validators)
|
|
102
|
+
return [] if validators.nil?
|
|
103
|
+
|
|
104
|
+
Array(validators).map { |v| v.to_s.downcase.to_sym }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Build result hash for a matched group
|
|
108
|
+
# @param group_name [Symbol] Group name
|
|
109
|
+
# @param config [Hash] Group configuration
|
|
110
|
+
# @return [Hash] Resolution result
|
|
111
|
+
def build_result(group_name, config)
|
|
112
|
+
{
|
|
113
|
+
group_name: group_name,
|
|
114
|
+
validators: config[:validators] || [],
|
|
115
|
+
fallback_validators: config[:fallback_validators] || [],
|
|
116
|
+
config_path: config[:config_path]
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/kramdown_parser"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lint
|
|
7
|
+
module Molecules
|
|
8
|
+
# Formats markdown with kramdown
|
|
9
|
+
class KramdownFormatter
|
|
10
|
+
# Format markdown file in place
|
|
11
|
+
# @param file_path [String] Path to markdown file
|
|
12
|
+
# @param options [Hash] Kramdown options
|
|
13
|
+
# @return [Hash] Result with :success, :formatted, :errors
|
|
14
|
+
def self.format_file(file_path, options: {})
|
|
15
|
+
content = File.read(file_path)
|
|
16
|
+
result = format_content(content, options: options)
|
|
17
|
+
|
|
18
|
+
if result[:success] && result[:formatted_content]
|
|
19
|
+
File.write(file_path, result[:formatted_content])
|
|
20
|
+
{success: true, formatted: true, errors: []}
|
|
21
|
+
else
|
|
22
|
+
{success: false, formatted: false, errors: result[:errors]}
|
|
23
|
+
end
|
|
24
|
+
rescue Errno::ENOENT
|
|
25
|
+
{success: false, formatted: false, errors: ["File not found: #{file_path}"]}
|
|
26
|
+
rescue => e
|
|
27
|
+
{success: false, formatted: false, errors: ["Error formatting file: #{e.message}"]}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Format markdown content
|
|
31
|
+
# @param content [String] Markdown content
|
|
32
|
+
# @param options [Hash] Kramdown options
|
|
33
|
+
# @return [Hash] Result with :success, :formatted_content, :errors
|
|
34
|
+
def self.format_content(content, options: {})
|
|
35
|
+
# Load kramdown configuration from ace-core config cascade
|
|
36
|
+
# Config location: .ace/lint/kramdown.yml
|
|
37
|
+
kramdown_config = Ace::Lint.kramdown_config
|
|
38
|
+
|
|
39
|
+
# Convert string keys to symbols (kramdown expects symbols)
|
|
40
|
+
kramdown_opts = kramdown_config.transform_keys(&:to_sym)
|
|
41
|
+
|
|
42
|
+
# Merge: config file < formatting defaults < CLI options
|
|
43
|
+
default_options = {
|
|
44
|
+
line_width: kramdown_opts[:line_width] || 120,
|
|
45
|
+
remove_block_html_tags: false,
|
|
46
|
+
remove_span_html_tags: false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
merged_options = kramdown_opts.merge(default_options).merge(options)
|
|
50
|
+
Atoms::KramdownParser.format(content, options: merged_options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if content would change after formatting
|
|
54
|
+
# @param content [String] Markdown content
|
|
55
|
+
# @param options [Hash] Kramdown options
|
|
56
|
+
# @return [Boolean] True if formatting would change content
|
|
57
|
+
def self.needs_formatting?(content, options: {})
|
|
58
|
+
result = format_content(content, options: options)
|
|
59
|
+
return false unless result[:success]
|
|
60
|
+
|
|
61
|
+
result[:formatted_content] != content
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/kramdown_parser"
|
|
4
|
+
require_relative "../atoms/frontmatter_extractor"
|
|
5
|
+
require_relative "../models/lint_result"
|
|
6
|
+
require_relative "../models/validation_error"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lint
|
|
10
|
+
module Molecules
|
|
11
|
+
# Validates markdown syntax via kramdown
|
|
12
|
+
class MarkdownLinter
|
|
13
|
+
# Typography characters to detect
|
|
14
|
+
EM_DASH = "\u2014"
|
|
15
|
+
SMART_QUOTES = [
|
|
16
|
+
"\u201C", # Left double quotation mark "
|
|
17
|
+
"\u201D", # Right double quotation mark "
|
|
18
|
+
"\u2018", # Left single quotation mark '
|
|
19
|
+
"\u2019" # Right single quotation mark '
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Fenced code block pattern (``` or ~~~, with optional up to 3 leading spaces per CommonMark)
|
|
23
|
+
# Captures the fence character and length for proper matching
|
|
24
|
+
FENCE_PATTERN = /^(\s{0,3})(`{3,}|~{3,})/
|
|
25
|
+
# Markdown link pattern [text](url) - captures link text for typography checking
|
|
26
|
+
LINK_PATTERN = /\[([^\]]*)\]\([^)]*\)/
|
|
27
|
+
# Inline code pattern - handles single and double backtick spans
|
|
28
|
+
INLINE_CODE_PATTERN = /``[^`]+``|`[^`]+`/
|
|
29
|
+
# Validate markdown file
|
|
30
|
+
# @param file_path [String] Path to markdown file
|
|
31
|
+
# @param options [Hash] Kramdown options
|
|
32
|
+
# @return [Models::LintResult] Validation result
|
|
33
|
+
def self.lint(file_path, options: {})
|
|
34
|
+
content = File.read(file_path)
|
|
35
|
+
lint_content(file_path, content, options: options)
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
Models::LintResult.new(
|
|
38
|
+
file_path: file_path,
|
|
39
|
+
success: false,
|
|
40
|
+
errors: [Models::ValidationError.new(message: "File not found: #{file_path}")]
|
|
41
|
+
)
|
|
42
|
+
rescue => e
|
|
43
|
+
Models::LintResult.new(
|
|
44
|
+
file_path: file_path,
|
|
45
|
+
success: false,
|
|
46
|
+
errors: [Models::ValidationError.new(message: "Error reading file: #{e.message}")]
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validate markdown content
|
|
51
|
+
# @param file_path [String] Path for reference
|
|
52
|
+
# @param content [String] Markdown content
|
|
53
|
+
# @param options [Hash] Kramdown options
|
|
54
|
+
# @return [Models::LintResult] Validation result
|
|
55
|
+
def self.lint_content(file_path, content, options: {})
|
|
56
|
+
markdown_content = strip_frontmatter(content)
|
|
57
|
+
result = Atoms::KramdownParser.parse(markdown_content, options: options)
|
|
58
|
+
|
|
59
|
+
errors = result[:errors].map do |msg|
|
|
60
|
+
Models::ValidationError.new(message: msg, severity: :error)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
warnings = result[:warnings].map do |msg|
|
|
64
|
+
Models::ValidationError.new(message: msg, severity: :warning)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add style checks
|
|
68
|
+
style_warnings = check_markdown_style(markdown_content)
|
|
69
|
+
warnings.concat(style_warnings)
|
|
70
|
+
|
|
71
|
+
# Add typography checks
|
|
72
|
+
config = Ace::Lint.markdown_config
|
|
73
|
+
typography_issues = check_typography(markdown_content, config)
|
|
74
|
+
typography_issues.each do |issue|
|
|
75
|
+
if issue.severity == :error
|
|
76
|
+
errors << issue
|
|
77
|
+
else
|
|
78
|
+
warnings << issue
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Models::LintResult.new(
|
|
83
|
+
file_path: file_path,
|
|
84
|
+
success: result[:success] && errors.empty?,
|
|
85
|
+
errors: errors,
|
|
86
|
+
warnings: warnings
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check markdown style best practices
|
|
91
|
+
# @param content [String] Markdown content
|
|
92
|
+
# @return [Array<Models::ValidationError>] Style warnings
|
|
93
|
+
def self.check_markdown_style(content)
|
|
94
|
+
warnings = []
|
|
95
|
+
lines = content.lines
|
|
96
|
+
|
|
97
|
+
lines.each_with_index do |line, idx|
|
|
98
|
+
line_num = idx + 1
|
|
99
|
+
next_line = lines[idx + 1]
|
|
100
|
+
|
|
101
|
+
# Check: blank line after headers
|
|
102
|
+
if line.match?(/^\#{1,6}\s+\S/) && next_line && !next_line.strip.empty?
|
|
103
|
+
warnings << Models::ValidationError.new(
|
|
104
|
+
line: line_num,
|
|
105
|
+
message: "Missing blank line after heading",
|
|
106
|
+
severity: :warning
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check: blank line before lists (unless first line or after another list item)
|
|
111
|
+
prev_line = idx.positive? ? lines[idx - 1] : nil
|
|
112
|
+
if line.match?(/^[*-]\s+\S/) && prev_line && !prev_line.strip.empty? && !prev_line.match?(/^[*-]\s+\S/)
|
|
113
|
+
warnings << Models::ValidationError.new(
|
|
114
|
+
line: line_num,
|
|
115
|
+
message: "Missing blank line before list",
|
|
116
|
+
severity: :warning
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check: blank line after lists
|
|
121
|
+
if prev_line&.match?(/^[*-]\s+\S/) && !line.match?(/^[*-]\s+\S/) && !line.strip.empty?
|
|
122
|
+
warnings << Models::ValidationError.new(
|
|
123
|
+
line: line_num,
|
|
124
|
+
message: "Missing blank line after list",
|
|
125
|
+
severity: :warning
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Check: blank line before code blocks
|
|
130
|
+
if line.match?(/^```/) && prev_line && !prev_line.strip.empty?
|
|
131
|
+
warnings << Models::ValidationError.new(
|
|
132
|
+
line: line_num,
|
|
133
|
+
message: "Missing blank line before code block",
|
|
134
|
+
severity: :warning
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check: blank line after code blocks (closing ```)
|
|
139
|
+
if prev_line&.match?(/^```$/) && !line.strip.empty?
|
|
140
|
+
warnings << Models::ValidationError.new(
|
|
141
|
+
line: line_num,
|
|
142
|
+
message: "Missing blank line after code block",
|
|
143
|
+
severity: :warning
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Check: file should end with newline
|
|
149
|
+
unless content.end_with?("\n")
|
|
150
|
+
warnings << Models::ValidationError.new(
|
|
151
|
+
message: "Missing trailing newline at end of file",
|
|
152
|
+
severity: :warning
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
warnings
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.strip_frontmatter(content)
|
|
160
|
+
extraction = Atoms::FrontmatterExtractor.extract(content)
|
|
161
|
+
return content unless extraction[:has_frontmatter]
|
|
162
|
+
|
|
163
|
+
frontmatter_lines = extraction[:frontmatter].to_s.lines.count + 2
|
|
164
|
+
("\n" * frontmatter_lines) + extraction[:body].to_s
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check typography issues (em-dashes, smart quotes)
|
|
168
|
+
# Skips content inside fenced code blocks and inline code
|
|
169
|
+
# @param content [String] Markdown content
|
|
170
|
+
# @param config [Hash] Markdown configuration with typography settings
|
|
171
|
+
# @return [Array<Models::ValidationError>] Typography issues
|
|
172
|
+
def self.check_typography(content, config)
|
|
173
|
+
issues = []
|
|
174
|
+
typography_config = config["typography"] || {}
|
|
175
|
+
em_dash_severity = typography_config["em_dash"] || "warn"
|
|
176
|
+
smart_quotes_severity = typography_config["smart_quotes"] || "warn"
|
|
177
|
+
|
|
178
|
+
# Return early if both checks are disabled
|
|
179
|
+
return issues if em_dash_severity == "off" && smart_quotes_severity == "off"
|
|
180
|
+
|
|
181
|
+
lines = content.lines
|
|
182
|
+
in_code_block = false
|
|
183
|
+
fence_char = nil
|
|
184
|
+
fence_length = 0
|
|
185
|
+
|
|
186
|
+
lines.each_with_index do |line, idx|
|
|
187
|
+
line_num = idx + 1
|
|
188
|
+
|
|
189
|
+
# Track fenced code block state with proper fence matching
|
|
190
|
+
if (match = line.match(FENCE_PATTERN))
|
|
191
|
+
current_fence_char = match[2][0] # First char (` or ~)
|
|
192
|
+
current_fence_length = match[2].length
|
|
193
|
+
|
|
194
|
+
if in_code_block
|
|
195
|
+
# Only close if same char and at least same length
|
|
196
|
+
if current_fence_char == fence_char && current_fence_length >= fence_length
|
|
197
|
+
in_code_block = false
|
|
198
|
+
fence_char = nil
|
|
199
|
+
fence_length = 0
|
|
200
|
+
end
|
|
201
|
+
else
|
|
202
|
+
# Opening fence
|
|
203
|
+
in_code_block = true
|
|
204
|
+
fence_char = current_fence_char
|
|
205
|
+
fence_length = current_fence_length
|
|
206
|
+
end
|
|
207
|
+
next
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Skip lines inside code blocks
|
|
211
|
+
next if in_code_block
|
|
212
|
+
|
|
213
|
+
# Remove inline code spans (handles both single and double backticks)
|
|
214
|
+
# Then remove link markup but keep link text for checking
|
|
215
|
+
line_without_code = line.gsub(INLINE_CODE_PATTERN, "")
|
|
216
|
+
.gsub(LINK_PATTERN, '\1')
|
|
217
|
+
|
|
218
|
+
# Check for em-dashes
|
|
219
|
+
if em_dash_severity != "off" && line_without_code.include?(EM_DASH)
|
|
220
|
+
severity = (em_dash_severity == "error") ? :error : :warning
|
|
221
|
+
issues << Models::ValidationError.new(
|
|
222
|
+
line: line_num,
|
|
223
|
+
message: "Em-dash character found; use double hyphens (--) instead",
|
|
224
|
+
severity: severity
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Check for smart quotes
|
|
229
|
+
if smart_quotes_severity != "off"
|
|
230
|
+
SMART_QUOTES.each do |quote|
|
|
231
|
+
if line_without_code.include?(quote)
|
|
232
|
+
severity = (smart_quotes_severity == "error") ? :error : :warning
|
|
233
|
+
quote_type = ["\u201C", "\u201D"].include?(quote) ? "double" : "single"
|
|
234
|
+
issues << Models::ValidationError.new(
|
|
235
|
+
line: line_num,
|
|
236
|
+
message: "Smart #{quote_type} quote (#{quote}) found; use ASCII quotes instead",
|
|
237
|
+
severity: severity
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
issues
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lint
|
|
5
|
+
module Molecules
|
|
6
|
+
# Deduplicates linting offenses from multiple validators
|
|
7
|
+
# Handles offenses from StandardRB, RuboCop, and other Ruby linters
|
|
8
|
+
class OffenseDeduplicator
|
|
9
|
+
# Deduplicate offenses by file:line:column:normalized_message
|
|
10
|
+
# Keeps the offense with the most detailed message when duplicates found
|
|
11
|
+
# @param offenses [Array<Hash>] Offenses to deduplicate
|
|
12
|
+
# @return [Array<Hash>] Deduplicated offenses
|
|
13
|
+
def self.deduplicate(offenses)
|
|
14
|
+
return [] if offenses.empty?
|
|
15
|
+
|
|
16
|
+
seen = {}
|
|
17
|
+
|
|
18
|
+
offenses.each do |offense|
|
|
19
|
+
key = offense_key(offense)
|
|
20
|
+
if seen[key]
|
|
21
|
+
# Keep the offense with the longer/more detailed message
|
|
22
|
+
existing_len = seen[key][:message]&.length || 0
|
|
23
|
+
new_len = offense[:message]&.length || 0
|
|
24
|
+
seen[key] = offense if new_len > existing_len
|
|
25
|
+
else
|
|
26
|
+
seen[key] = offense
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
seen.values
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generate unique key for an offense
|
|
34
|
+
# @param offense [Hash] Offense data
|
|
35
|
+
# @return [String] Unique key
|
|
36
|
+
def self.offense_key(offense)
|
|
37
|
+
file = offense[:file] || ""
|
|
38
|
+
line = offense[:line] || 0
|
|
39
|
+
column = offense[:column] || 0
|
|
40
|
+
# Normalize message: strip cop name prefix, downcase, remove extra whitespace
|
|
41
|
+
message = normalize_message(offense[:message] || "")
|
|
42
|
+
|
|
43
|
+
"#{file}:#{line}:#{column}:#{message}"
|
|
44
|
+
end
|
|
45
|
+
private_class_method :offense_key
|
|
46
|
+
|
|
47
|
+
# Normalize message for comparison
|
|
48
|
+
# @param message [String] Original message
|
|
49
|
+
# @return [String] Normalized message
|
|
50
|
+
def self.normalize_message(message)
|
|
51
|
+
# Remove cop name prefix with various formats (e.g., "Style/StringLiterals: ", "Layout/TrailingWhitespace - ")
|
|
52
|
+
# Updated regex handles:
|
|
53
|
+
# - Standard: "Style/StringLiterals: "
|
|
54
|
+
# - With numbers: "Rails/HTTPStatus: ", "Lint/BooleanSymbol: "
|
|
55
|
+
# - Nested: "Style/HashSyntax/Compact: "
|
|
56
|
+
# - Hyphens: "Style-Guide: "
|
|
57
|
+
normalized = message.sub(/\A[A-Za-z0-9]+(?:[\/-][A-Za-z0-9]+)+[:\s-]+/, "")
|
|
58
|
+
# Downcase and strip extra whitespace
|
|
59
|
+
normalized.downcase.gsub(/\s+/, " ").strip
|
|
60
|
+
end
|
|
61
|
+
private_class_method :normalize_message
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|