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,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