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,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lint
|
|
8
|
+
module Atoms
|
|
9
|
+
# Base class for Ruby linter runners
|
|
10
|
+
# Provides shared parsing logic for RuboCop-style JSON output
|
|
11
|
+
class BaseRunner
|
|
12
|
+
# Thread-safe availability cache (shared across all subclasses)
|
|
13
|
+
@availability_mutex = Mutex.new
|
|
14
|
+
@availability_cache = {}
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Check if the linter is available (cached per process, thread-safe)
|
|
18
|
+
# @return [Boolean] True if linter command is available
|
|
19
|
+
def available?
|
|
20
|
+
BaseRunner.instance_variable_get(:@availability_mutex).synchronize do
|
|
21
|
+
cache = BaseRunner.instance_variable_get(:@availability_cache)
|
|
22
|
+
cmd = command_name
|
|
23
|
+
cache[cmd] ||= system_has_command?(cmd)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reset availability cache for this linter (for testing purposes)
|
|
28
|
+
# @internal
|
|
29
|
+
def reset_availability_cache!
|
|
30
|
+
BaseRunner.instance_variable_get(:@availability_mutex).synchronize do
|
|
31
|
+
cache = BaseRunner.instance_variable_get(:@availability_cache)
|
|
32
|
+
cache.delete(command_name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Command name to check for availability (subclass override)
|
|
37
|
+
# @return [String] Command name
|
|
38
|
+
def command_name
|
|
39
|
+
raise NotImplementedError, "Subclass must implement command_name"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Execute the linter command
|
|
43
|
+
# Subclasses must implement: tool_name, build_command, unavailable_result
|
|
44
|
+
# @param file_paths [String, Array<String>] Path(s) to lint
|
|
45
|
+
# @param fix [Boolean] Apply autofix
|
|
46
|
+
# @param config_path [String, nil] Explicit config path
|
|
47
|
+
# @return [Hash] Result with :success, :errors, :warnings
|
|
48
|
+
def run(file_paths, fix: false, config_path: nil)
|
|
49
|
+
paths = Array(file_paths)
|
|
50
|
+
return unavailable_result unless available?
|
|
51
|
+
|
|
52
|
+
cmd = build_command(paths, fix: fix, config_path: config_path)
|
|
53
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
54
|
+
|
|
55
|
+
if status.success?
|
|
56
|
+
parse_success_output(stdout)
|
|
57
|
+
else
|
|
58
|
+
parse_error_output(stdout, stderr, exit_status: status.exitstatus)
|
|
59
|
+
end
|
|
60
|
+
rescue => e
|
|
61
|
+
{
|
|
62
|
+
success: false,
|
|
63
|
+
errors: [{message: "#{tool_name} execution failed for #{Array(file_paths).join(", ")}: #{e.message}"}],
|
|
64
|
+
warnings: []
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Parse successful output (no issues)
|
|
69
|
+
# @param stdout [String] Linter output
|
|
70
|
+
# @return [Hash] Parsed result
|
|
71
|
+
def parse_success_output(stdout)
|
|
72
|
+
if stdout.strip.empty? || stdout.include?("no offenses")
|
|
73
|
+
return {success: true, errors: [], warnings: []}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
parse_json_output(stdout)
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
{success: true, errors: [], warnings: []}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse error output (issues found)
|
|
82
|
+
# @param stdout [String] Linter stdout
|
|
83
|
+
# @param stderr [String] Linter stderr
|
|
84
|
+
# @param exit_status [Integer] Process exit status
|
|
85
|
+
# @return [Hash] Parsed result
|
|
86
|
+
def parse_error_output(stdout, stderr, exit_status:)
|
|
87
|
+
unless stdout.strip.empty?
|
|
88
|
+
begin
|
|
89
|
+
return parse_json_output(stdout, exit_status: exit_status)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
return parse_text_output(stderr, exit_status: exit_status)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
parse_text_output(stderr, exit_status: exit_status)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Parse JSON output from linter (RuboCop-style format)
|
|
99
|
+
# @param output [String] JSON string
|
|
100
|
+
# @param exit_status [Integer, nil] Process exit status
|
|
101
|
+
# @return [Hash] Parsed result
|
|
102
|
+
# @raise [JSON::ParserError] if output is not valid JSON
|
|
103
|
+
def parse_json_output(output, exit_status: nil)
|
|
104
|
+
data = JSON.parse(output)
|
|
105
|
+
errors = []
|
|
106
|
+
warnings = []
|
|
107
|
+
|
|
108
|
+
# RuboCop JSON format: {files: [{path, offenses: [...]}]}
|
|
109
|
+
if data.is_a?(Hash) && data.key?("files")
|
|
110
|
+
data["files"].each do |file_data|
|
|
111
|
+
file_path = file_data["path"] || "unknown"
|
|
112
|
+
offenses = file_data["offenses"] || []
|
|
113
|
+
|
|
114
|
+
offenses.each do |offense|
|
|
115
|
+
item = build_offense_item(offense, file_path)
|
|
116
|
+
if offense["severity"] == "error" || offense["severity"] == "fatal"
|
|
117
|
+
errors << item
|
|
118
|
+
else
|
|
119
|
+
warnings << item
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
# Fallback: direct array format
|
|
124
|
+
elsif data.is_a?(Array)
|
|
125
|
+
data.each do |offense|
|
|
126
|
+
item = build_offense_item(offense)
|
|
127
|
+
if offense["severity"] == "error" || offense["severity"] == "fatal"
|
|
128
|
+
errors << item
|
|
129
|
+
else
|
|
130
|
+
warnings << item
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
success = exit_status ? exit_status.zero? : errors.empty?
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
success: success,
|
|
139
|
+
errors: errors,
|
|
140
|
+
warnings: warnings
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parse text output (fallback for non-JSON output)
|
|
145
|
+
# @param output [String] Text output
|
|
146
|
+
# @param exit_status [Integer, nil] Process exit status
|
|
147
|
+
# @return [Hash] Parsed result
|
|
148
|
+
def parse_text_output(output, exit_status: nil)
|
|
149
|
+
errors = []
|
|
150
|
+
warnings = []
|
|
151
|
+
|
|
152
|
+
output.each_line do |line|
|
|
153
|
+
# Format: file:line:column: severity: message
|
|
154
|
+
next unless line.match?(/^.+:\d+:\d+:/)
|
|
155
|
+
|
|
156
|
+
parts = line.split(":", 5)
|
|
157
|
+
next if parts.size < 5
|
|
158
|
+
|
|
159
|
+
item = {
|
|
160
|
+
file: parts[0],
|
|
161
|
+
line: parts[1].to_i,
|
|
162
|
+
column: parts[2].to_i,
|
|
163
|
+
message: parts[4].strip
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if error_severity?(parts[3], line)
|
|
167
|
+
errors << item
|
|
168
|
+
else
|
|
169
|
+
warnings << item
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
success = exit_status ? exit_status.zero? : errors.empty?
|
|
174
|
+
|
|
175
|
+
# Add fallback error if non-zero exit but no offenses parsed
|
|
176
|
+
if !success && errors.empty? && warnings.empty? && !output.strip.empty?
|
|
177
|
+
errors << {message: "#{tool_name} failed: #{output.strip.lines.first&.strip || output.strip}"}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
success: success,
|
|
182
|
+
errors: errors,
|
|
183
|
+
warnings: warnings
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Build offense item from linter offense
|
|
188
|
+
# @param offense [Hash] Offense data
|
|
189
|
+
# @param file_path [String] File path
|
|
190
|
+
# @return [Hash] Offense item
|
|
191
|
+
def build_offense_item(offense, file_path = nil)
|
|
192
|
+
path = file_path || offense.dig("location", "path") || "unknown"
|
|
193
|
+
location = offense["location"] || {}
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
file: path,
|
|
197
|
+
line: location["line"] || 0,
|
|
198
|
+
column: location["column"] || 0,
|
|
199
|
+
message: "#{offense["cop_name"]}: #{offense["message"]}"
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
protected
|
|
204
|
+
|
|
205
|
+
# Check if a command is available on the system
|
|
206
|
+
# @param cmd [String] Command name
|
|
207
|
+
# @return [Boolean] True if command exists
|
|
208
|
+
def system_has_command?(cmd)
|
|
209
|
+
# Cross-platform: use system with redirect to null
|
|
210
|
+
system("#{cmd} --version > /dev/null 2>&1")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Check if severity indicates an error
|
|
214
|
+
# @param severity_field [String] The severity field from text output
|
|
215
|
+
# @param line [String] Full line for context
|
|
216
|
+
# @return [Boolean] True if error severity
|
|
217
|
+
def error_severity?(severity_field, line)
|
|
218
|
+
severity = severity_field.strip.upcase
|
|
219
|
+
# RuboCop: E/F = error, C/W = warning
|
|
220
|
+
severity == "E" || severity == "F" || line.include?("error") || line.include?("Error")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Subclass interface methods - must be implemented
|
|
224
|
+
def tool_name
|
|
225
|
+
raise NotImplementedError, "Subclass must implement tool_name"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_command(_paths, fix:, config_path:)
|
|
229
|
+
raise NotImplementedError, "Subclass must implement build_command"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def unavailable_result
|
|
233
|
+
raise NotImplementedError, "Subclass must implement unavailable_result"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lint
|
|
5
|
+
module Atoms
|
|
6
|
+
# Validates presence of required YAML comments in frontmatter
|
|
7
|
+
# Used for SKILL.md files that require documentation comments
|
|
8
|
+
class CommentValidator
|
|
9
|
+
class << self
|
|
10
|
+
# Validate that required comments are present in the raw content
|
|
11
|
+
# @param content [String] Raw file content (with frontmatter)
|
|
12
|
+
# @param required_comments [Array<String>] Comment prefixes to check for
|
|
13
|
+
# @return [Array<String>] List of missing comment patterns
|
|
14
|
+
def validate(content, required_comments:)
|
|
15
|
+
return [] if required_comments.nil? || required_comments.empty?
|
|
16
|
+
return required_comments if content.nil? || content.empty?
|
|
17
|
+
|
|
18
|
+
# Extract frontmatter section (between --- markers)
|
|
19
|
+
frontmatter = extract_frontmatter_raw(content)
|
|
20
|
+
return required_comments if frontmatter.nil?
|
|
21
|
+
|
|
22
|
+
missing = []
|
|
23
|
+
|
|
24
|
+
required_comments.each do |comment_pattern|
|
|
25
|
+
# Check if the comment pattern exists in frontmatter
|
|
26
|
+
# The pattern is like "# context:" - we check if it appears in the YAML section
|
|
27
|
+
unless frontmatter.include?(comment_pattern)
|
|
28
|
+
missing << comment_pattern
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
missing
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Find the line number where a comment should be added
|
|
36
|
+
# @param content [String] Raw file content
|
|
37
|
+
# @return [Integer] Line number for error reporting (typically line 1-2)
|
|
38
|
+
def frontmatter_start_line
|
|
39
|
+
1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Extract raw frontmatter content including comments
|
|
45
|
+
# @param content [String] Full file content
|
|
46
|
+
# @return [String, nil] Raw frontmatter text or nil if not found
|
|
47
|
+
def extract_frontmatter_raw(content)
|
|
48
|
+
return nil unless content.start_with?("---\n", "---\r\n")
|
|
49
|
+
|
|
50
|
+
# Find the ending delimiter
|
|
51
|
+
start_index = content.index("\n") + 1
|
|
52
|
+
end_match = content.match(/\n---\n|\n---\r\n/, start_index)
|
|
53
|
+
|
|
54
|
+
return nil unless end_match
|
|
55
|
+
|
|
56
|
+
# Return the raw frontmatter including comments
|
|
57
|
+
content[0...end_match.end(0)]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lint
|
|
7
|
+
module Atoms
|
|
8
|
+
# Locates configuration files for validators with precedence rules
|
|
9
|
+
# Precedence: explicit path > .ace/lint/ > native config > gem defaults
|
|
10
|
+
# Results are cached to avoid repeated filesystem I/O (thread-safe)
|
|
11
|
+
class ConfigLocator
|
|
12
|
+
# Native config file names for each tool
|
|
13
|
+
NATIVE_CONFIGS = {
|
|
14
|
+
standardrb: [".standard.yml"],
|
|
15
|
+
rubocop: [".rubocop.yml"]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Default config paths within gem defaults directory
|
|
19
|
+
GEM_DEFAULT_CONFIGS = {
|
|
20
|
+
standardrb: nil, # StandardRB uses its own defaults
|
|
21
|
+
rubocop: ".rubocop.yml"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Thread-safe class-level cache for config lookup results
|
|
25
|
+
@config_cache = {}
|
|
26
|
+
@cache_mutex = Mutex.new
|
|
27
|
+
|
|
28
|
+
# Locate config file for a validator
|
|
29
|
+
# @param tool [String, Symbol] Validator name (e.g., :standardrb, :rubocop)
|
|
30
|
+
# @param project_root [String] Project root directory
|
|
31
|
+
# @param explicit_path [String, nil] Explicit config path from user config
|
|
32
|
+
# @return [Hash] { path: String|nil, source: Symbol }
|
|
33
|
+
# source: :explicit, :ace_config, :native, :gem_defaults, :none
|
|
34
|
+
def self.locate(tool, project_root:, explicit_path: nil)
|
|
35
|
+
tool_sym = tool.to_s.downcase.to_sym
|
|
36
|
+
|
|
37
|
+
# 1. Explicit path takes highest precedence (not cached)
|
|
38
|
+
if explicit_path && !explicit_path.empty?
|
|
39
|
+
full_path = resolve_path(explicit_path, project_root)
|
|
40
|
+
return {path: full_path, source: :explicit, exists: File.exist?(full_path)}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build cache key (exclude explicit_path from caching as it may vary)
|
|
44
|
+
cache_key = "#{tool_sym}:#{project_root}"
|
|
45
|
+
|
|
46
|
+
# Thread-safe cache lookup and population
|
|
47
|
+
@cache_mutex.synchronize do
|
|
48
|
+
# Return cached result if available
|
|
49
|
+
return @config_cache[cache_key].dup if @config_cache.key?(cache_key)
|
|
50
|
+
|
|
51
|
+
# 2. Check .ace/lint/ directory
|
|
52
|
+
ace_config = find_ace_config(tool_sym, project_root)
|
|
53
|
+
if ace_config
|
|
54
|
+
@config_cache[cache_key] = ace_config
|
|
55
|
+
return ace_config.dup
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 3. Check for native config in project root
|
|
59
|
+
native_config = find_native_config(tool_sym, project_root)
|
|
60
|
+
if native_config
|
|
61
|
+
@config_cache[cache_key] = native_config
|
|
62
|
+
return native_config.dup
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 4. Fall back to gem defaults
|
|
66
|
+
gem_config = find_gem_default_config(tool_sym)
|
|
67
|
+
if gem_config
|
|
68
|
+
@config_cache[cache_key] = gem_config
|
|
69
|
+
return gem_config.dup
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result = {path: nil, source: :none, exists: false}
|
|
73
|
+
@config_cache[cache_key] = result
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolve a potentially relative path
|
|
79
|
+
# @param path [String] Path to resolve
|
|
80
|
+
# @param base [String] Base directory for relative paths
|
|
81
|
+
# @return [String] Resolved absolute path
|
|
82
|
+
def self.resolve_path(path, base)
|
|
83
|
+
return path if Pathname.new(path).absolute?
|
|
84
|
+
|
|
85
|
+
File.expand_path(path, base)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Find config in .ace/lint/ directory
|
|
89
|
+
# @param tool [Symbol] Validator name
|
|
90
|
+
# @param project_root [String] Project root directory
|
|
91
|
+
# @return [Hash, nil] Config info or nil if not found
|
|
92
|
+
def self.find_ace_config(tool, project_root)
|
|
93
|
+
ace_lint_dir = File.join(project_root, ".ace", "lint")
|
|
94
|
+
return nil unless File.directory?(ace_lint_dir)
|
|
95
|
+
|
|
96
|
+
# Try tool-specific config names
|
|
97
|
+
config_names = native_config_names(tool)
|
|
98
|
+
config_names.each do |name|
|
|
99
|
+
path = File.join(ace_lint_dir, name)
|
|
100
|
+
return {path: path, source: :ace_config, exists: true} if File.exist?(path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
private_class_method :find_ace_config
|
|
106
|
+
|
|
107
|
+
# Find native config in project root
|
|
108
|
+
# @param tool [Symbol] Validator name
|
|
109
|
+
# @param project_root [String] Project root directory
|
|
110
|
+
# @return [Hash, nil] Config info or nil if not found
|
|
111
|
+
def self.find_native_config(tool, project_root)
|
|
112
|
+
config_names = native_config_names(tool)
|
|
113
|
+
config_names.each do |name|
|
|
114
|
+
path = File.join(project_root, name)
|
|
115
|
+
return {path: path, source: :native, exists: true} if File.exist?(path)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
private_class_method :find_native_config
|
|
121
|
+
|
|
122
|
+
# Find gem default config
|
|
123
|
+
# @param tool [Symbol] Validator name
|
|
124
|
+
# @return [Hash, nil] Config info or nil if not found
|
|
125
|
+
def self.find_gem_default_config(tool)
|
|
126
|
+
config_file = GEM_DEFAULT_CONFIGS[tool]
|
|
127
|
+
return nil unless config_file
|
|
128
|
+
|
|
129
|
+
# Try Gem.loaded_specs first (installed gem)
|
|
130
|
+
gem_root = ::Gem.loaded_specs["ace-lint"]&.gem_dir
|
|
131
|
+
|
|
132
|
+
# Fallback for development environments (e.g., mono-repo)
|
|
133
|
+
gem_root ||= File.expand_path("../../..", __dir__) if __dir__
|
|
134
|
+
|
|
135
|
+
return nil unless gem_root
|
|
136
|
+
|
|
137
|
+
path = File.join(gem_root, ".ace-defaults", "lint", config_file)
|
|
138
|
+
return {path: path, source: :gem_defaults, exists: true} if File.exist?(path)
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
private_class_method :find_gem_default_config
|
|
143
|
+
|
|
144
|
+
# Get native config file names for a tool
|
|
145
|
+
# @param tool [Symbol] Validator name
|
|
146
|
+
# @return [Array<String>] List of possible config file names
|
|
147
|
+
def self.native_config_names(tool)
|
|
148
|
+
NATIVE_CONFIGS[tool] || []
|
|
149
|
+
end
|
|
150
|
+
private_class_method :native_config_names
|
|
151
|
+
|
|
152
|
+
# Reset config cache (for testing)
|
|
153
|
+
# @return [void]
|
|
154
|
+
def self.reset_cache!
|
|
155
|
+
@cache_mutex.synchronize do
|
|
156
|
+
@config_cache = {}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lint
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure function to extract frontmatter from markdown
|
|
7
|
+
class FrontmatterExtractor
|
|
8
|
+
# Extract frontmatter and content from markdown
|
|
9
|
+
# @param content [String] Markdown content with potential frontmatter
|
|
10
|
+
# @return [Hash] Result with :frontmatter, :body, :has_frontmatter
|
|
11
|
+
def self.extract(content)
|
|
12
|
+
return empty_result if content.nil? || content.empty?
|
|
13
|
+
|
|
14
|
+
# Check if content starts with frontmatter delimiter
|
|
15
|
+
unless content.start_with?("---\n", "---\r\n")
|
|
16
|
+
return {
|
|
17
|
+
frontmatter: nil,
|
|
18
|
+
body: content,
|
|
19
|
+
has_frontmatter: false
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Find the ending delimiter
|
|
24
|
+
# Start search after the first "---\n"
|
|
25
|
+
start_index = content.index("\n") + 1
|
|
26
|
+
end_match = content.match(/\n---\n|\n---\r\n/, start_index)
|
|
27
|
+
|
|
28
|
+
unless end_match
|
|
29
|
+
return {
|
|
30
|
+
frontmatter: nil,
|
|
31
|
+
body: content,
|
|
32
|
+
has_frontmatter: false,
|
|
33
|
+
error: "Missing closing '---' delimiter for frontmatter"
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
end_index = end_match.begin(0)
|
|
38
|
+
|
|
39
|
+
# Extract frontmatter YAML (between the delimiters)
|
|
40
|
+
frontmatter_content = content[4...end_index]
|
|
41
|
+
|
|
42
|
+
# Extract body content (after the closing delimiter)
|
|
43
|
+
body_start = end_match.end(0)
|
|
44
|
+
body_content = content[body_start..] || ""
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
frontmatter: frontmatter_content,
|
|
48
|
+
body: body_content,
|
|
49
|
+
has_frontmatter: true
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if content has frontmatter
|
|
54
|
+
# @param content [String] Markdown content
|
|
55
|
+
# @return [Boolean] True if frontmatter detected
|
|
56
|
+
def self.has_frontmatter?(content)
|
|
57
|
+
return false if content.nil? || content.empty?
|
|
58
|
+
|
|
59
|
+
content.start_with?("---\n", "---\r\n") &&
|
|
60
|
+
(content.include?("\n---\n") || content.include?("\n---\r\n"))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.empty_result
|
|
64
|
+
{
|
|
65
|
+
frontmatter: nil,
|
|
66
|
+
body: "",
|
|
67
|
+
has_frontmatter: false,
|
|
68
|
+
error: "Empty content"
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown-parser-gfm"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lint
|
|
8
|
+
module Atoms
|
|
9
|
+
# Pure function to parse markdown with kramdown
|
|
10
|
+
class KramdownParser
|
|
11
|
+
# Parse markdown content with kramdown
|
|
12
|
+
# @param content [String] Markdown content
|
|
13
|
+
# @param options [Hash] Kramdown options
|
|
14
|
+
# @return [Hash] Result with :success, :document, :errors, :warnings
|
|
15
|
+
def self.parse(content, options: {})
|
|
16
|
+
# Minimal defaults - let kramdown use its defaults
|
|
17
|
+
# Users can override via .ace/lint/config.yml
|
|
18
|
+
default_options = {
|
|
19
|
+
input: "GFM" # Use GitHub Flavored Markdown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
merged_options = default_options.merge(options)
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
document = Kramdown::Document.new(content, merged_options)
|
|
26
|
+
|
|
27
|
+
# Kramdown collects warnings during parsing (as strings)
|
|
28
|
+
# All warnings are informational, not errors
|
|
29
|
+
warnings = document.warnings || []
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
success: true, # Kramdown warnings don't indicate parsing failure
|
|
33
|
+
document: document,
|
|
34
|
+
errors: [],
|
|
35
|
+
warnings: warnings
|
|
36
|
+
}
|
|
37
|
+
rescue => e
|
|
38
|
+
{
|
|
39
|
+
success: false,
|
|
40
|
+
document: nil,
|
|
41
|
+
errors: ["Kramdown parsing error: #{e.message}"],
|
|
42
|
+
warnings: []
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Format markdown content with kramdown
|
|
48
|
+
# @param content [String] Markdown content
|
|
49
|
+
# @param options [Hash] Kramdown options
|
|
50
|
+
# @return [Hash] Result with :success, :formatted_content, :errors
|
|
51
|
+
def self.format(content, options: {})
|
|
52
|
+
parse_result = parse(content, options: options)
|
|
53
|
+
|
|
54
|
+
return {success: false, formatted_content: nil, errors: parse_result[:errors]} unless parse_result[:success]
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
# Convert back to markdown
|
|
58
|
+
formatted = parse_result[:document].to_kramdown
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
success: true,
|
|
62
|
+
formatted_content: formatted,
|
|
63
|
+
errors: []
|
|
64
|
+
}
|
|
65
|
+
rescue => e
|
|
66
|
+
{
|
|
67
|
+
success: false,
|
|
68
|
+
formatted_content: nil,
|
|
69
|
+
errors: ["Kramdown formatting error: #{e.message}"]
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Kramdown warnings are already formatted strings
|
|
75
|
+
def self.format_kramdown_message(warning)
|
|
76
|
+
warning.to_s
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|