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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/lint/config.yml +5 -0
  3. data/.ace-defaults/lint/kramdown.yml +23 -0
  4. data/.ace-defaults/lint/markdown.yml +16 -0
  5. data/.ace-defaults/lint/ruby.yml +67 -0
  6. data/.ace-defaults/lint/skills.yml +138 -0
  7. data/.ace-defaults/nav/protocols/wfi-sources/ace-lint.yml +11 -0
  8. data/CHANGELOG.md +584 -0
  9. data/LICENSE +21 -0
  10. data/README.md +40 -0
  11. data/Rakefile +14 -0
  12. data/exe/ace-lint +14 -0
  13. data/handbook/skills/as-lint-fix-issue-from/SKILL.md +34 -0
  14. data/handbook/skills/as-lint-process-report/SKILL.md +29 -0
  15. data/handbook/skills/as-lint-run/SKILL.md +27 -0
  16. data/handbook/workflow-instructions/lint/process-report.wf.md +175 -0
  17. data/handbook/workflow-instructions/lint/run.wf.md +145 -0
  18. data/lib/ace/lint/atoms/allowed_tools_validator.rb +100 -0
  19. data/lib/ace/lint/atoms/base_runner.rb +239 -0
  20. data/lib/ace/lint/atoms/comment_validator.rb +63 -0
  21. data/lib/ace/lint/atoms/config_locator.rb +162 -0
  22. data/lib/ace/lint/atoms/frontmatter_extractor.rb +74 -0
  23. data/lib/ace/lint/atoms/kramdown_parser.rb +81 -0
  24. data/lib/ace/lint/atoms/pattern_matcher.rb +96 -0
  25. data/lib/ace/lint/atoms/rubocop_runner.rb +67 -0
  26. data/lib/ace/lint/atoms/skill_schema_loader.rb +83 -0
  27. data/lib/ace/lint/atoms/standardrb_runner.rb +45 -0
  28. data/lib/ace/lint/atoms/type_detector.rb +121 -0
  29. data/lib/ace/lint/atoms/validator_registry.rb +113 -0
  30. data/lib/ace/lint/atoms/yaml_parser.rb +11 -0
  31. data/lib/ace/lint/atoms/yaml_validator.rb +69 -0
  32. data/lib/ace/lint/cli/commands/lint.rb +318 -0
  33. data/lib/ace/lint/cli.rb +25 -0
  34. data/lib/ace/lint/models/lint_result.rb +87 -0
  35. data/lib/ace/lint/models/validation_error.rb +31 -0
  36. data/lib/ace/lint/molecules/frontmatter_validator.rb +131 -0
  37. data/lib/ace/lint/molecules/group_resolver.rb +122 -0
  38. data/lib/ace/lint/molecules/kramdown_formatter.rb +66 -0
  39. data/lib/ace/lint/molecules/markdown_linter.rb +249 -0
  40. data/lib/ace/lint/molecules/offense_deduplicator.rb +65 -0
  41. data/lib/ace/lint/molecules/ruby_linter.rb +205 -0
  42. data/lib/ace/lint/molecules/skill_validator.rb +462 -0
  43. data/lib/ace/lint/molecules/validator_chain.rb +150 -0
  44. data/lib/ace/lint/molecules/yaml_linter.rb +53 -0
  45. data/lib/ace/lint/organisms/lint_doctor.rb +289 -0
  46. data/lib/ace/lint/organisms/lint_orchestrator.rb +294 -0
  47. data/lib/ace/lint/organisms/report_generator.rb +213 -0
  48. data/lib/ace/lint/organisms/result_reporter.rb +130 -0
  49. data/lib/ace/lint/version.rb +7 -0
  50. data/lib/ace/lint.rb +141 -0
  51. 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