Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions lib/rubydex/cli/command/explain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

require "rubydex/cli/command"

module Rubydex
module CLI
# `rdx explain <RULE> [PATH]` — prints documentation for every discovered rule with that name.
class Command
class Explain < Command
command "explain"
arguments "<RULE> [PATH]"
summary "Print complete documentation for a linter rule"

#: -> void
def run
parse_options!

rule_name = argv.shift
abort_with_usage("`explain` requires a rule name argument") unless rule_name

workspace_path = File.expand_path(argv.shift || Dir.pwd)
abort_with_usage("unexpected argument: #{argv.first}") unless argv.empty?
abort_with_usage("workspace is not a directory: #{workspace_path}") unless File.directory?(workspace_path)

require "rubydex/linter"

rules = load_linter_rules(workspace_path).select { |rule_class| rule_class.rule_name == rule_name }
abort("Rule does not exist: #{rule_name}") if rules.empty?

graph = Rubydex::Graph.configure_for_workspace(workspace_path)
rule_files = rules.map do |rule_class|
Object.const_source_location(rule_class.name).fetch(0)
end
graph.index_all([File.expand_path("../../linter/rule.rb", __dir__), *rule_files])
graph.resolve

puts(rules.sort_by(&:name).map { |rule_class| documentation_for(rule_class, graph) }.join("\n"))
end

private

#: (String workspace_path) -> Array[singleton(Rubydex::Linter::Rule)]
def load_linter_rules(workspace_path)
Rubydex::Linter::RuleLoader.load(workspace_path)
rescue Rubydex::Linter::RuleLoadError => error
abort(error.message)
end

#: (singleton(Rubydex::Linter::Rule) rule_class, Graph graph) -> String
def documentation_for(rule_class, graph)
rule_name = rule_class.name #: as !nil
declaration = graph[rule_name] #: as !nil
documentation = declaration.definitions.flat_map do |definition|
definition.comments.map { |comment| comment.string.gsub(/^#\s*/, "") }
end.join("\n")

return "#{rule_name}: no documentation available." if documentation.empty?

<<~DOCUMENTATION
#{rule_name}

#{documentation}
DOCUMENTATION
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubydex/cli/command/lint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def print_offenses(diagnostics, graph)
end

print_summary(graph.documents.count, diagnostics)
puts("For more information about a rule, run `rdx explain RuleName`.")
end

#: (Location location, workspace_path: String) -> String
Expand Down
110 changes: 105 additions & 5 deletions test/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def test_commands_are_discovered_from_subclasses

assert_includes(commands, Rubydex::CLI::Command::Query)
assert_includes(commands, Rubydex::CLI::Command::Console)
assert_includes(commands, Rubydex::CLI::Command::Explain)
assert_includes(commands, Rubydex::CLI::Command::Lint)
assert_includes(commands, Rubydex::CLI::Command::Mcp)
assert_includes(commands, Rubydex::CLI::Command::Skill)
Expand All @@ -33,6 +34,7 @@ def test_commands_are_discovered_from_subclasses
assert_equal("query", Rubydex::CLI::Command::Query.command_name)
assert_equal("query <CYPHER>", Rubydex::CLI::Command::Query.usage_form)
assert_equal("console", Rubydex::CLI::Command::Console.usage_form)
assert_equal("explain <RULE> [PATH]", Rubydex::CLI::Command::Explain.usage_form)
assert_equal("lint [PATH]", Rubydex::CLI::Command::Lint.usage_form)
end

Expand All @@ -48,15 +50,17 @@ def test_commands_are_listed_alphabetically
# before the offsets are compared: a missing one fails on its own assertion rather than on a
# comparison against nil. We collect the beginning offset of the first match (index 0) for each
# command so that we can compare their order below.
console, lint, mcp, query, help = ["console", "lint", "mcp", "query", "help"].map do |name|
console, explain, lint, mcp, query, skill, help = ["console", "explain", "lint", "mcp", "query", "skill", "help"].map do |name|
assert_stdout_includes_pattern(result, /^ #{name}\b/).begin(0)
end

assert_operator(console, :<, lint)
assert_operator(console, :<, explain)
assert_operator(explain, :<, lint)
assert_operator(lint, :<, mcp)
assert_operator(mcp, :<, query)
assert_operator(query, :<, skill)
# `help` is listed last rather than in alphabetical position.
assert_operator(query, :<, help)
assert_operator(skill, :<, help)
end

def test_dispatch_uses_the_declared_name_not_the_class_or_file_name
Expand Down Expand Up @@ -97,6 +101,7 @@ def test_usage_is_generated_from_the_declared_commands
[
Rubydex::CLI::Command::Query,
Rubydex::CLI::Command::Console,
Rubydex::CLI::Command::Explain,
Rubydex::CLI::Command::Lint,
Rubydex::CLI::Command::Mcp,
Rubydex::CLI::Command::Skill,
Expand Down Expand Up @@ -208,7 +213,7 @@ def test_query_supports_json_output
end

def test_command_help_is_available_per_subcommand
["query", "console", "lint", "mcp"].each do |command|
["query", "console", "explain", "lint", "mcp", "skill"].each do |command|
result = rdx(command, "--help")

assert_success_status(result)
Expand All @@ -217,7 +222,7 @@ def test_command_help_is_available_per_subcommand
end

def test_every_command_reports_an_invalid_option_with_the_usage
["query", "console", "lint", "mcp"].each do |command|
["query", "console", "explain", "lint", "mcp", "skill"].each do |command|
result = rdx(command, "--bogus-flag")

refute_success_status(result)
Expand Down Expand Up @@ -249,6 +254,100 @@ def test_mcp_rejects_extra_arguments
refute_stderr_includes(result, "Usage: rdx <command> [options]")
end

def test_explain_reports_discovered_rules_with_the_same_name_in_stable_order
with_context do |context|
context.write!("rubydex_linter/rules/shared_rule.rb", <<~RUBY)
module ExplainDuplicateFixtures
module First
# Flags raw SQL built through application query helpers.
#
# Prefer parameter binding instead.
class SharedRule < Rubydex::Linter::Rule
def severity = Rubydex::Severity::Error
def lint; end
end
end

module Second
class SharedRule < Rubydex::Linter::Rule
def severity = Rubydex::Severity::Error
def lint; end
end
end
end
RUBY

result = with_bundle_gemfile(nil) { rdx("explain", "SharedRule", context.absolute_path) }

assert_success_status(result)
assert_stdout_equals(<<~DOCS, result)
ExplainDuplicateFixtures::First::SharedRule

Flags raw SQL built through application query helpers.

Prefer parameter binding instead.

ExplainDuplicateFixtures::Second::SharedRule: no documentation available.
DOCS
end
end

def test_explain_reports_only_the_exact_rule_name
with_context do |context|
context.write!("rubydex_linter/rules/exact_rule.rb", <<~RUBY)
module ExplainExactFixtures
# Flags raw SQL string interpolation.
class ExactRule < Rubydex::Linter::Rule
class Helper; end

def severity = Rubydex::Severity::Error
def lint; end
end

class ExactRuleExtension < Rubydex::Linter::Rule
def severity = Rubydex::Severity::Error
def lint; end
end
end
RUBY

result = with_bundle_gemfile(nil) { rdx("explain", "ExactRule", context.absolute_path) }

assert_success_status(result)
assert_stdout_equals(<<~DOCS, result)
ExplainExactFixtures::ExactRule

Flags raw SQL string interpolation.
DOCS
end
end

def test_explain_rejects_an_unknown_rule
with_context do |context|
context.write!("rubydex_linter/rules/known_rule.rb", <<~RUBY)
class ExplainKnownRule < Rubydex::Linter::Rule
def severity = Rubydex::Severity::Error
def lint; end
end
RUBY

result = with_bundle_gemfile(nil) { rdx("explain", "MissingRule", context.absolute_path) }

refute_success_status(result)
assert_empty_stdout(result)
assert_stderr_includes(result, "Rule does not exist: MissingRule")
end
end

def test_explain_requires_a_rule_name
result = rdx("explain")

refute_success_status(result)
assert_empty_stdout(result)
assert_stderr_includes(result, "`explain` requires a rule name argument")
assert_stderr_includes(result, "Usage: rdx explain <RULE> [PATH]")
end

def test_lint_reports_a_project_rule_diagnostic_with_related_information
with_context do |context|
write_linter_rule(
Expand Down Expand Up @@ -278,6 +377,7 @@ class Foo; end
result,
/\d+ files inspected, 1 offense detected: 1 error, 0 warnings, 0 info, 0 hints/,
)
assert_stdout_includes(result, "For more information about a rule, run `rdx explain RuleName`.")
refute_stdout_includes(result, context.absolute_path)
assert_stderr_includes(result, "Indexing workspace...")
assert_stderr_includes(result, "Resolving graph...")
Expand Down
Loading