ace-lint 0.25.0 → 0.27.4

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.
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "set"
5
+ require_relative "../atoms/type_detector"
6
+
7
+ module Ace
8
+ module Lint
9
+ module Organisms
10
+ # Coordinates deterministic and agent-assisted auto-fix flows.
11
+ class AutoFixOrchestrator
12
+ FIX_CAPABLE_TYPES = [:markdown, :skill, :workflow, :agent, :ruby].freeze
13
+ DEFAULT_PROVIDER_MODEL = "gemini:flash-latest@yolo".freeze
14
+
15
+ def initialize(
16
+ orchestrator:,
17
+ lint_options:,
18
+ with_agent:,
19
+ model:,
20
+ file_block_start:,
21
+ file_block_end:,
22
+ no_changes_marker:,
23
+ max_file_bytes:,
24
+ max_total_bytes:
25
+ )
26
+ @orchestrator = orchestrator
27
+ @lint_options = lint_options
28
+ @with_agent = with_agent
29
+ @model = model
30
+ @file_block_start = file_block_start
31
+ @file_block_end = file_block_end
32
+ @no_changes_marker = no_changes_marker
33
+ @max_file_bytes = max_file_bytes
34
+ @max_total_bytes = max_total_bytes
35
+ end
36
+
37
+ def run(paths)
38
+ fix_options = @lint_options.merge(fix: true)
39
+ fix_options.delete(:format)
40
+
41
+ fixable_paths, lint_only_paths = partition_fixable_paths(paths)
42
+ fixed_results = if fixable_paths.empty?
43
+ []
44
+ else
45
+ @orchestrator.lint_files(fixable_paths, options: fix_options)
46
+ end
47
+
48
+ paths_for_final_lint = if lint_only_paths.empty?
49
+ paths
50
+ else
51
+ fixable_paths + lint_only_paths
52
+ end
53
+ lint_results = @orchestrator.lint_files(paths_for_final_lint, options: lint_only_options(@lint_options))
54
+ merged_results = merge_fix_and_lint_results(fixed_results, lint_results)
55
+
56
+ return merged_results unless @with_agent
57
+
58
+ run_agent_fix(paths, initial_results: merged_results)
59
+ end
60
+
61
+ def run_dry_run(paths)
62
+ lint_results = @orchestrator.lint_files(paths, options: lint_only_options(@lint_options))
63
+
64
+ issues = collect_issues(lint_results)
65
+ if issues.empty?
66
+ puts "No violations found."
67
+ else
68
+ issues.each do |issue|
69
+ puts "Would attempt to fix: #{issue}"
70
+ end
71
+
72
+ file_count = lint_results.select { |result| result.has_errors? || result.has_warnings? }.map(&:file_path).uniq.count
73
+ puts "#{issues.count} issue(s) detected in #{file_count} file(s) during dry-run."
74
+ end
75
+
76
+ return unless @with_agent
77
+ return puts "\nNo remaining issues for agent prompt." if lint_results.none? { |result| result.has_errors? || result.has_warnings? }
78
+
79
+ prompt = build_agent_prompt(lint_results)
80
+ puts "\nAgent prompt (dry-run):"
81
+ puts prompt
82
+ end
83
+
84
+ private
85
+
86
+ def run_agent_fix(paths, initial_results:)
87
+ remaining_results = initial_results.select { |result| result.has_errors? || result.has_warnings? }
88
+ if remaining_results.empty?
89
+ puts "\nAll violations resolved by deterministic auto-fix; agent not launched."
90
+ return initial_results
91
+ end
92
+
93
+ prompt = build_agent_prompt(remaining_results)
94
+ editable_files = remaining_results.map(&:file_path).uniq.sort
95
+ original_contents = snapshot_file_contents(editable_files)
96
+ provider_model = @model || Ace::Lint.config.dig("lint", "doctor_agent_model") || DEFAULT_PROVIDER_MODEL
97
+ puts "\nLaunching agent to fix remaining violations..."
98
+ warn "[ace-lint] Warning: --auto-fix-with-agent sends full file contents to #{provider_model}."
99
+
100
+ ensure_ace_llm_available!
101
+
102
+ begin
103
+ response = Ace::LLM::QueryInterface.query(
104
+ provider_model,
105
+ prompt,
106
+ system: nil,
107
+ timeout: 600,
108
+ fallback: false,
109
+ sandbox: "workspace-write",
110
+ working_dir: Dir.pwd
111
+ )
112
+ response_text = response[:text].to_s
113
+ puts response_text unless response_text.empty?
114
+
115
+ edits = parse_agent_file_edits(response_text, allowed_files: editable_files)
116
+ if edits.empty?
117
+ raise Ace::Support::Cli::Error.new(
118
+ "Agent returned no editable file blocks (expected #{@file_block_start}path>> ... #{@file_block_end}).",
119
+ exit_code: 2
120
+ )
121
+ end
122
+
123
+ changed_files = apply_agent_file_edits(edits, original_contents: original_contents)
124
+ if changed_files.empty?
125
+ raise Ace::Support::Cli::Error.new(
126
+ "Agent response parsed, but no file content changed on disk.",
127
+ exit_code: 2
128
+ )
129
+ end
130
+
131
+ puts "Applied agent edits to #{changed_files.count} file(s): #{changed_files.join(", ")}"
132
+ rescue => e
133
+ raise Ace::Support::Cli::Error.new("Agent fix failed: #{e.message}", exit_code: 2)
134
+ end
135
+
136
+ @orchestrator.lint_files(paths, options: lint_only_options(@lint_options))
137
+ end
138
+
139
+ def build_agent_prompt(results)
140
+ issue_lines = []
141
+ files = Set.new
142
+ total_payload_bytes = 0
143
+
144
+ results.each do |result|
145
+ (result.errors + result.warnings).each do |issue|
146
+ severity = issue.severity == :warning ? "WARNING" : "ERROR"
147
+ location = issue.line ? " (#{result.file_path}:#{issue.line})" : " (#{result.file_path})"
148
+ issue_lines << "- [#{severity}] #{issue.message}#{location}"
149
+ end
150
+ files << result.file_path if result.has_errors? || result.has_warnings?
151
+ end
152
+
153
+ file_sections = files.to_a.sort.map do |path|
154
+ content = File.exist?(path) ? File.read(path) : "(file missing)"
155
+ if content.bytesize > @max_file_bytes
156
+ raise Ace::Support::Cli::Error.new(
157
+ "Agent fix input too large for #{path} (#{content.bytesize} bytes, max #{@max_file_bytes}).",
158
+ exit_code: 2
159
+ )
160
+ end
161
+
162
+ total_payload_bytes += content.bytesize
163
+ if total_payload_bytes > @max_total_bytes
164
+ raise Ace::Support::Cli::Error.new(
165
+ "Agent fix payload exceeds #{@max_total_bytes} bytes; narrow file selection.",
166
+ exit_code: 2
167
+ )
168
+ end
169
+
170
+ fence = markdown_fence_for(content)
171
+ <<~SECTION
172
+ ## File: #{path}
173
+
174
+ #{fence}text
175
+ #{content}
176
+ #{fence}
177
+ SECTION
178
+ end
179
+
180
+ <<~PROMPT
181
+ The following lint issues remain after deterministic auto-fix:
182
+
183
+ #{issue_lines.join("\n")}
184
+
185
+ Fix the remaining issues in-place in this repository.
186
+
187
+ Full content for files with remaining issues:
188
+
189
+ #{file_sections.join("\n")}
190
+
191
+ Output contract (required):
192
+ - Return one or more full-file replacement blocks using this exact format:
193
+ #{@file_block_start}relative/path>>
194
+ <full updated file content>
195
+ #{@file_block_end}
196
+ - Only include files listed above.
197
+ - Do not wrap response in markdown code fences.
198
+ - If no changes are possible, respond exactly with: #{@no_changes_marker}
199
+
200
+ After fixing, run:
201
+ ace-lint #{files.to_a.sort.join(" ")}
202
+ PROMPT
203
+ end
204
+
205
+ def ensure_ace_llm_available!
206
+ require "ace/llm"
207
+ rescue LoadError
208
+ raise Ace::Support::Cli::Error.new(
209
+ "--auto-fix-with-agent requires ace-llm gem. Install with: gem install ace-llm",
210
+ exit_code: 2
211
+ )
212
+ end
213
+
214
+ def snapshot_file_contents(paths)
215
+ paths.each_with_object({}) do |path, acc|
216
+ acc[path] = File.exist?(path) ? File.read(path) : nil
217
+ end
218
+ end
219
+
220
+ def parse_agent_file_edits(response_text, allowed_files:)
221
+ response = response_text.to_s
222
+ return {} if response.strip == @no_changes_marker
223
+
224
+ allowed_map = allowed_files.each_with_object({}) do |path, acc|
225
+ normalized = path.sub(%r{\A\./}, "")
226
+ acc[File.expand_path(normalized, Dir.pwd)] = normalized
227
+ end
228
+
229
+ edits = {}
230
+ current_path = nil
231
+ current_buffer = []
232
+
233
+ response.each_line do |line|
234
+ stripped = line.strip
235
+
236
+ if current_path
237
+ if stripped == @file_block_end
238
+ edits[current_path] = current_buffer.join
239
+ current_path = nil
240
+ current_buffer = []
241
+ else
242
+ current_buffer << line
243
+ end
244
+ next
245
+ end
246
+
247
+ next unless stripped.start_with?(@file_block_start) && stripped.end_with?(">>")
248
+
249
+ raw_path = stripped.delete_prefix(@file_block_start).delete_suffix(">>").strip
250
+ expanded = File.expand_path(raw_path, Dir.pwd)
251
+ resolved_path = allowed_map[expanded]
252
+ unless resolved_path
253
+ raise Ace::Support::Cli::Error.new(
254
+ "Agent attempted to edit unexpected file: #{raw_path}",
255
+ exit_code: 2
256
+ )
257
+ end
258
+ if edits.key?(resolved_path)
259
+ raise Ace::Support::Cli::Error.new(
260
+ "Agent returned duplicate edit block for #{resolved_path}",
261
+ exit_code: 2
262
+ )
263
+ end
264
+
265
+ current_path = resolved_path
266
+ end
267
+
268
+ if current_path
269
+ raise Ace::Support::Cli::Error.new(
270
+ "Agent response ended before #{@file_block_end}",
271
+ exit_code: 2
272
+ )
273
+ end
274
+
275
+ edits
276
+ end
277
+
278
+ def apply_agent_file_edits(edits, original_contents:)
279
+ changed_files = []
280
+
281
+ edits.each do |path, content|
282
+ next if original_contents[path] == content
283
+
284
+ File.write(path, content)
285
+ changed_files << path
286
+ end
287
+
288
+ changed_files
289
+ end
290
+
291
+ def lint_only_options(options)
292
+ lint_options = options.dup
293
+ lint_options.delete(:fix)
294
+ lint_options.delete(:format)
295
+ lint_options
296
+ end
297
+
298
+ def partition_fixable_paths(paths)
299
+ forced_type = @lint_options[:type]
300
+ if forced_type
301
+ return fix_capable_type?(forced_type) ? [paths, []] : [[], paths]
302
+ end
303
+
304
+ paths.partition do |path|
305
+ content = begin
306
+ File.read(path)
307
+ rescue
308
+ nil
309
+ end
310
+ type = Ace::Lint::Atoms::TypeDetector.detect(path, content: content)
311
+ fix_capable_type?(type)
312
+ end
313
+ end
314
+
315
+ def fix_capable_type?(type)
316
+ FIX_CAPABLE_TYPES.include?(type)
317
+ end
318
+
319
+ def markdown_fence_for(content)
320
+ max_backticks = content.to_s.scan(/`+/).map(&:length).max || 0
321
+ "`" * [3, max_backticks + 1].max
322
+ end
323
+
324
+ def merge_fix_and_lint_results(fixed_results, lint_results)
325
+ fixed_by_path = fixed_results.each_with_object({}) { |result, acc| acc[result.file_path] = result }
326
+ lint_paths = lint_results.map(&:file_path)
327
+ merged = lint_results.map do |lint_result|
328
+ merge_result = fixed_by_path[lint_result.file_path]
329
+ next lint_result unless merge_result
330
+
331
+ merged_errors = lint_result.errors.dup
332
+ merged_warnings = merge_validation_errors(merge_result.warnings, lint_result.warnings)
333
+ merged_errors = merge_validation_errors(merge_result.errors, merged_errors) if merge_result.failed?
334
+
335
+ Models::LintResult.new(
336
+ file_path: lint_result.file_path,
337
+ success: merged_errors.empty?,
338
+ errors: merged_errors,
339
+ warnings: merged_warnings,
340
+ formatted: merge_result.formatted? || lint_result.formatted?,
341
+ skipped: lint_result.skipped?,
342
+ skip_reason: lint_result.skip_reason,
343
+ runner: lint_result.runner
344
+ )
345
+ end
346
+
347
+ fixed_results.each do |fixed_result|
348
+ next if lint_paths.include?(fixed_result.file_path)
349
+
350
+ merged << fixed_result
351
+ end
352
+ merged
353
+ end
354
+
355
+ def merge_validation_errors(left, right)
356
+ combined = []
357
+ seen = {}
358
+
359
+ (Array(left) + Array(right)).each do |issue|
360
+ key = "#{issue.line}|#{issue.message}|#{issue.severity}"
361
+ next if seen[key]
362
+
363
+ seen[key] = true
364
+ combined << issue
365
+ end
366
+ combined
367
+ end
368
+
369
+ def collect_issues(results)
370
+ issues = []
371
+ results.each do |result|
372
+ (result.errors + result.warnings).each do |issue|
373
+ location = issue.line ? "#{result.file_path}:#{issue.line}" : result.file_path
374
+ issues << "#{location}: #{issue.message}"
375
+ end
376
+ end
377
+ issues
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
@@ -5,6 +5,7 @@ require_relative "../molecules/markdown_linter"
5
5
  require_relative "../molecules/yaml_linter"
6
6
  require_relative "../molecules/frontmatter_validator"
7
7
  require_relative "../molecules/kramdown_formatter"
8
+ require_relative "../molecules/markdown_surgical_fixer"
8
9
  require_relative "../molecules/ruby_linter"
9
10
  require_relative "../molecules/group_resolver"
10
11
  require_relative "../molecules/skill_validator"
@@ -268,16 +269,47 @@ module Ace
268
269
  # Skill, workflow, and agent files are markdown-based and support formatting
269
270
  return nil unless [:markdown, :skill, :workflow, :agent].include?(type)
270
271
 
272
+ warnings = []
273
+ formatted = false
274
+
275
+ if options[:fix]
276
+ fix_result = Molecules::MarkdownSurgicalFixer.fix_file(file_path)
277
+ unless fix_result[:success]
278
+ return Models::LintResult.new(
279
+ file_path: file_path,
280
+ success: false,
281
+ errors: fix_result[:errors].map { |msg| Models::ValidationError.new(message: msg) },
282
+ warnings: [],
283
+ formatted: false
284
+ )
285
+ end
286
+ warnings.concat(Array(fix_result[:warnings]).map { |msg| Models::ValidationError.new(message: msg, severity: :warning) })
287
+ formatted ||= fix_result[:formatted]
288
+ end
289
+
290
+ return nil unless options[:format] || options[:fix]
291
+
292
+ unless options[:format]
293
+ return Models::LintResult.new(
294
+ file_path: file_path,
295
+ success: true,
296
+ errors: [],
297
+ warnings: warnings,
298
+ formatted: formatted
299
+ )
300
+ end
301
+
271
302
  kramdown_opts = options[:kramdown_options] || {}
272
- format_result = Molecules::KramdownFormatter.format_file(file_path, options: kramdown_opts)
303
+ format_result = Molecules::KramdownFormatter.format_file(file_path, options: kramdown_opts, guardrails: true)
273
304
 
274
305
  if format_result[:success]
306
+ warnings.concat(Array(format_result[:warnings]).map { |msg| Models::ValidationError.new(message: msg, severity: :warning) })
275
307
  Models::LintResult.new(
276
308
  file_path: file_path,
277
309
  success: true,
278
310
  errors: [],
279
- warnings: [],
280
- formatted: format_result[:formatted]
311
+ warnings: warnings,
312
+ formatted: formatted || format_result[:formatted]
281
313
  )
282
314
  else
283
315
  Models::LintResult.new(
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lint
5
- VERSION = "0.25.0"
5
+ VERSION = '0.27.4'
6
6
  end
7
7
  end
data/lib/ace/lint.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "lint/molecules/markdown_linter"
29
29
  require_relative "lint/molecules/yaml_linter"
30
30
  require_relative "lint/molecules/frontmatter_validator"
31
31
  require_relative "lint/molecules/kramdown_formatter"
32
+ require_relative "lint/molecules/markdown_surgical_fixer"
32
33
  require_relative "lint/molecules/group_resolver"
33
34
  require_relative "lint/molecules/validator_chain"
34
35
  require_relative "lint/molecules/skill_validator"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.27.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -15,56 +15,56 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.8'
18
+ version: '0.9'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.8'
25
+ version: '0.9'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ace-support-core
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.25'
32
+ version: '0.29'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.25'
39
+ version: '0.29'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ace-b36ts
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.7'
46
+ version: '0.13'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.7'
53
+ version: '0.13'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: ace-support-cli
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.3'
60
+ version: '0.6'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.3'
67
+ version: '0.6'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: kramdown
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -211,11 +211,13 @@ files:
211
211
  - lib/ace/lint/molecules/group_resolver.rb
212
212
  - lib/ace/lint/molecules/kramdown_formatter.rb
213
213
  - lib/ace/lint/molecules/markdown_linter.rb
214
+ - lib/ace/lint/molecules/markdown_surgical_fixer.rb
214
215
  - lib/ace/lint/molecules/offense_deduplicator.rb
215
216
  - lib/ace/lint/molecules/ruby_linter.rb
216
217
  - lib/ace/lint/molecules/skill_validator.rb
217
218
  - lib/ace/lint/molecules/validator_chain.rb
218
219
  - lib/ace/lint/molecules/yaml_linter.rb
220
+ - lib/ace/lint/organisms/auto_fix_orchestrator.rb
219
221
  - lib/ace/lint/organisms/lint_doctor.rb
220
222
  - lib/ace/lint/organisms/lint_orchestrator.rb
221
223
  - lib/ace/lint/organisms/report_generator.rb