Class: Ace::Lint::Atoms::BaseRunner

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/lint/atoms/base_runner.rb

Overview

Base class for Ruby linter runners Provides shared parsing logic for RuboCop-style JSON output

Direct Known Subclasses

RuboCopRunner, StandardrbRunner

Class Method Summary collapse

Class Method Details

.available?Boolean

Check if the linter is available (cached per process, thread-safe)

Returns:

  • (Boolean)

    True if linter command is available



19
20
21
22
23
24
25
# File 'lib/ace/lint/atoms/base_runner.rb', line 19

def available?
  BaseRunner.instance_variable_get(:@availability_mutex).synchronize do
    cache = BaseRunner.instance_variable_get(:@availability_cache)
    cmd = command_name
    cache[cmd] ||= system_has_command?(cmd)
  end
end

.build_offense_item(offense, file_path = nil) ⇒ Hash

Build offense item from linter offense

Parameters:

  • offense (Hash)

    Offense data

  • file_path (String) (defaults to: nil)

    File path

Returns:

  • (Hash)

    Offense item



193
194
195
196
197
198
199
200
201
202
203
# File 'lib/ace/lint/atoms/base_runner.rb', line 193

def build_offense_item(offense, file_path = nil)
  path = file_path || offense.dig("location", "path") || "unknown"
  location = offense["location"] || {}

  {
    file: path,
    line: location["line"] || 0,
    column: location["column"] || 0,
    message: "#{offense["cop_name"]}: #{offense["message"]}"
  }
end

.command_nameString

Command name to check for availability (subclass override)

Returns:

  • (String)

    Command name

Raises:

  • (NotImplementedError)


38
39
40
# File 'lib/ace/lint/atoms/base_runner.rb', line 38

def command_name
  raise NotImplementedError, "Subclass must implement command_name"
end

.parse_error_output(stdout, stderr, exit_status:) ⇒ Hash

Parse error output (issues found)

Parameters:

  • stdout (String)

    Linter stdout

  • stderr (String)

    Linter stderr

  • exit_status (Integer)

    Process exit status

Returns:

  • (Hash)

    Parsed result



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/ace/lint/atoms/base_runner.rb', line 88

def parse_error_output(stdout, stderr, exit_status:)
  unless stdout.strip.empty?
    begin
      return parse_json_output(stdout, exit_status: exit_status)
    rescue JSON::ParserError
      return parse_text_output(stderr, exit_status: exit_status)
    end
  end

  parse_text_output(stderr, exit_status: exit_status)
end

.parse_json_output(output, exit_status: nil) ⇒ Hash

Parse JSON output from linter (RuboCop-style format)

Parameters:

  • output (String)

    JSON string

  • exit_status (Integer, nil) (defaults to: nil)

    Process exit status

Returns:

  • (Hash)

    Parsed result

Raises:

  • (JSON::ParserError)

    if output is not valid JSON



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/ace/lint/atoms/base_runner.rb', line 105

def parse_json_output(output, exit_status: nil)
  data = JSON.parse(output)
  errors = []
  warnings = []

  # RuboCop JSON format: {files: [{path, offenses: [...]}]}
  if data.is_a?(Hash) && data.key?("files")
    data["files"].each do |file_data|
      file_path = file_data["path"] || "unknown"
      offenses = file_data["offenses"] || []

      offenses.each do |offense|
        item = build_offense_item(offense, file_path)
        if offense["severity"] == "error" || offense["severity"] == "fatal"
          errors << item
        else
          warnings << item
        end
      end
    end
  # Fallback: direct array format
  elsif data.is_a?(Array)
    data.each do |offense|
      item = build_offense_item(offense)
      if offense["severity"] == "error" || offense["severity"] == "fatal"
        errors << item
      else
        warnings << item
      end
    end
  end

  success = exit_status ? exit_status.zero? : errors.empty?

  {
    success: success,
    errors: errors,
    warnings: warnings
  }
end

.parse_success_output(stdout) ⇒ Hash

Parse successful output (no issues)

Parameters:

  • stdout (String)

    Linter output

Returns:

  • (Hash)

    Parsed result



73
74
75
76
77
78
79
80
81
# File 'lib/ace/lint/atoms/base_runner.rb', line 73

def parse_success_output(stdout)
  if stdout.strip.empty? || stdout.include?("no offenses")
    return {success: true, errors: [], warnings: []}
  end

  parse_json_output(stdout)
rescue JSON::ParserError
  {success: true, errors: [], warnings: []}
end

.parse_text_output(output, exit_status: nil) ⇒ Hash

Parse text output (fallback for non-JSON output)

Parameters:

  • output (String)

    Text output

  • exit_status (Integer, nil) (defaults to: nil)

    Process exit status

Returns:

  • (Hash)

    Parsed result



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/ace/lint/atoms/base_runner.rb', line 150

def parse_text_output(output, exit_status: nil)
  errors = []
  warnings = []

  output.each_line do |line|
    # Format: file:line:column: severity: message
    next unless line.match?(/^.+:\d+:\d+:/)

    parts = line.split(":", 5)
    next if parts.size < 5

    item = {
      file: parts[0],
      line: parts[1].to_i,
      column: parts[2].to_i,
      message: parts[4].strip
    }

    if error_severity?(parts[3], line)
      errors << item
    else
      warnings << item
    end
  end

  success = exit_status ? exit_status.zero? : errors.empty?

  # Add fallback error if non-zero exit but no offenses parsed
  if !success && errors.empty? && warnings.empty? && !output.strip.empty?
    errors << {message: "#{tool_name} failed: #{output.strip.lines.first&.strip || output.strip}"}
  end

  {
    success: success,
    errors: errors,
    warnings: warnings
  }
end

.reset_availability_cache!Object

Reset availability cache for this linter (for testing purposes)



29
30
31
32
33
34
# File 'lib/ace/lint/atoms/base_runner.rb', line 29

def reset_availability_cache!
  BaseRunner.instance_variable_get(:@availability_mutex).synchronize do
    cache = BaseRunner.instance_variable_get(:@availability_cache)
    cache.delete(command_name)
  end
end

.run(file_paths, fix: false, config_path: nil) ⇒ Hash

Execute the linter command Subclasses must implement: tool_name, build_command, unavailable_result

Parameters:

  • file_paths (String, Array<String>)

    Path(s) to lint

  • fix (Boolean) (defaults to: false)

    Apply autofix

  • config_path (String, nil) (defaults to: nil)

    Explicit config path

Returns:

  • (Hash)

    Result with :success, :errors, :warnings



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/ace/lint/atoms/base_runner.rb', line 48

def run(file_paths, fix: false, config_path: nil)
  paths = Array(file_paths)
  executable = resolve_command_path(command_name)
  return unavailable_result unless executable

  cmd = build_command(paths, fix: fix, config_path: config_path)
  cmd[0] = executable if command_invocation?(cmd[0], command_name)
  stdout, stderr, status = Open3.capture3(*cmd)

  if status.success?
    parse_success_output(stdout)
  else
    parse_error_output(stdout, stderr, exit_status: status.exitstatus)
  end
rescue => e
  {
    success: false,
    errors: [{message: "#{tool_name} execution failed for #{Array(file_paths).join(", ")}: #{e.message}"}],
    warnings: []
  }
end