From eeda1fcac3f07e5493c3d9d2a8940753d7d04cb2 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 6 Aug 2026 11:59:09 +0100 Subject: [PATCH] Add linter rule explanations --- lib/rubydex/cli/command/explain.rb | 68 ++++++++++++++++++ lib/rubydex/cli/command/lint.rb | 1 + test/cli_test.rb | 110 +++++++++++++++++++++++++++-- 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 lib/rubydex/cli/command/explain.rb diff --git a/lib/rubydex/cli/command/explain.rb b/lib/rubydex/cli/command/explain.rb new file mode 100644 index 00000000..a723784a --- /dev/null +++ b/lib/rubydex/cli/command/explain.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rubydex/cli/command" + +module Rubydex + module CLI + # `rdx explain [PATH]` — prints documentation for every discovered rule with that name. + class Command + class Explain < Command + command "explain" + arguments " [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 diff --git a/lib/rubydex/cli/command/lint.rb b/lib/rubydex/cli/command/lint.rb index 15074b03..49564d4d 100644 --- a/lib/rubydex/cli/command/lint.rb +++ b/lib/rubydex/cli/command/lint.rb @@ -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 diff --git a/test/cli_test.rb b/test/cli_test.rb index 547a4a24..dd6de676 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -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) @@ -33,6 +34,7 @@ def test_commands_are_discovered_from_subclasses assert_equal("query", Rubydex::CLI::Command::Query.command_name) assert_equal("query ", Rubydex::CLI::Command::Query.usage_form) assert_equal("console", Rubydex::CLI::Command::Console.usage_form) + assert_equal("explain [PATH]", Rubydex::CLI::Command::Explain.usage_form) assert_equal("lint [PATH]", Rubydex::CLI::Command::Lint.usage_form) end @@ -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 @@ -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, @@ -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) @@ -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) @@ -249,6 +254,100 @@ def test_mcp_rejects_extra_arguments refute_stderr_includes(result, "Usage: rdx [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 [PATH]") + end + def test_lint_reports_a_project_rule_diagnostic_with_related_information with_context do |context| write_linter_rule( @@ -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...")