diff --git a/lib/rubydex/cli/command.rb b/lib/rubydex/cli/command.rb index 1e23e1560..6d7ac8faa 100644 --- a/lib/rubydex/cli/command.rb +++ b/lib/rubydex/cli/command.rb @@ -135,21 +135,30 @@ def parse_options!(options: false) end # Builds the workspace graph, sending progress messages to `progress_io`. - #: (IO progress_io) -> Rubydex::Graph - def build_graph(progress_io) - graph = Rubydex::Graph.configure_for_workspace(Dir.pwd) - with_timer(progress_io, "Indexing workspace...") { graph.index_workspace } + #: (IO progress_io, ?workspace_path: String, ?config: Rubydex::Config, ?fail_on_index_errors: bool) -> Rubydex::Graph + def build_graph( + progress_io, + workspace_path: Dir.pwd, + config: Rubydex::Config.load(workspace_path), + fail_on_index_errors: false + ) + graph = Rubydex::Graph.new + graph.load_config(config) + errors = with_timer(progress_io, "Indexing workspace...") { graph.index_workspace } + abort(errors.join("\n")) if fail_on_index_errors && !errors.empty? + with_timer(progress_io, "Resolving graph...") { graph.resolve } graph end - #: (IO io, String message) { -> void } -> void + #: [T] (IO io, String message) { -> T } -> T def with_timer(io, message) io.print(message) start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - yield + result = yield duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start io.puts(" finished in #{duration.round(2)}ms") + result end end end diff --git a/lib/rubydex/cli/command/lint.rb b/lib/rubydex/cli/command/lint.rb new file mode 100644 index 000000000..d58a7abe9 --- /dev/null +++ b/lib/rubydex/cli/command/lint.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rubydex/cli/command" + +module Rubydex + module CLI + # `rdx lint [PATH]` — discovers project and dependency rules and runs them against a workspace. + class Command + class Lint < Command + RULE_GLOB = "rubydex_linter/rules/**/*.rb" #: String + + command "lint" + arguments "[PATH]" + summary "Run semantic lint rules against a workspace" + + #: -> void + def run + parse_options! + + 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) + + # Keep top-level help lightweight: command discovery loads this file before the native + # extension, while linter support is only needed when this command runs. + require "rubydex/linter" + + rules = load_linter_rules(workspace_path) + abort("No Rubydex::Linter::Rule subclasses were loaded") if rules.empty? + config = Rubydex::Config.load(workspace_path) + warn_unknown_rules(config.linter, rules) + + graph = build_graph($stderr, workspace_path:, config:, fail_on_index_errors: true) + result = Rubydex::Linter::Runner.new(graph, rules:, config: config.linter).run + result.diagnostics.each { |diagnostic| puts(format_linter_diagnostic(diagnostic)) } + exit(1) unless result.success? + end + + private + + #: (Rubydex::LinterConfig config, Array[singleton(Rubydex::Linter::Rule)] known_rule_classes) -> void + def warn_unknown_rules(config, known_rule_classes) + known_rule_names = known_rule_classes.map(&:rule_name).uniq.sort + unknown_rule_names = config.rules.keys.reject { |name| known_rule_names.include?(name) }.sort + return if unknown_rule_names.empty? + + formatted_names = unknown_rule_names.map { |name| "`#{name}`" }.join(", ") + warn( + "warning: linter config references rules that were not loaded: #{formatted_names}. " \ + "Known rules: #{known_rule_names.join(", ")}", + ) + end + + #: (String workspace_path) -> Array[singleton(Linter::Rule)] + def load_linter_rules(workspace_path) + existing_rules = Rubydex::Linter::Rule.subclasses + rule_files = Dir.glob(RULE_GLOB, base: workspace_path).map do |rule_file| + File.expand_path(rule_file, workspace_path) + end + if ENV["BUNDLE_GEMFILE"] + rule_files.concat(Gem.find_latest_files(RULE_GLOB)) + end + + rule_files.each do |rule_file| + require rule_file + rescue LoadError, SyntaxError => error + abort("Unable to load linter rules from #{rule_file}: #{error.message}") + end + + Rubydex::Linter::Rule.subclasses - existing_rules + end + + #: (Location location) -> String + def format_linter_location(location) + display_location = location.to_display + path = begin + display_location.to_file_path + rescue Rubydex::Location::NotFileUriError + display_location.uri + end + + "#{path}:#{display_location.start_line}:#{display_location.start_column}" + end + + #: (Diagnostic diagnostic) -> String + def format_linter_diagnostic(diagnostic) + content = +"#{format_linter_location(diagnostic.location)}: " \ + "#{diagnostic.severity.value}: #{diagnostic.rule}: #{diagnostic.message}" + + diagnostic.related_information.each do |information| + content << "\n #{format_linter_location(information.location)}: #{information.message}" + end + + content + end + end + end + end +end diff --git a/lib/rubydex/config.rb b/lib/rubydex/config.rb index a02b02528..5b001d052 100644 --- a/lib/rubydex/config.rb +++ b/lib/rubydex/config.rb @@ -14,6 +14,12 @@ def initialize(rules) @rules = rules.freeze freeze end + + #: (singleton(Linter::Rule) rule_class) -> bool + def rule_enabled?(rule_class) + rule = @rules[rule_class.rule_name] + !rule || rule.enabled? + end end # The settings of a single linter rule, read from a `[linter.rules.RuleName]` table. diff --git a/lib/rubydex/linter.rb b/lib/rubydex/linter.rb new file mode 100644 index 000000000..e53e874de --- /dev/null +++ b/lib/rubydex/linter.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rubydex" +require "rubydex/linter/result" +require "rubydex/linter/rule" +require "rubydex/linter/runner" + +module Rubydex + # Framework for running semantic lint rules against a resolved Rubydex graph. + module Linter + end +end diff --git a/lib/rubydex/linter/result.rb b/lib/rubydex/linter/result.rb new file mode 100644 index 000000000..aaa49c906 --- /dev/null +++ b/lib/rubydex/linter/result.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Rubydex + module Linter + class Result + #: Array[Diagnostic] + attr_reader :diagnostics + + #: (Array[Diagnostic]) -> void + def initialize(diagnostics) + @diagnostics = diagnostics + end + + #: () -> bool + def success? + @diagnostics.none? { |diagnostic| diagnostic.severity == Severity::Error } + end + end + end +end diff --git a/lib/rubydex/linter/rule.rb b/lib/rubydex/linter/rule.rb new file mode 100644 index 000000000..04d0c77d2 --- /dev/null +++ b/lib/rubydex/linter/rule.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Rubydex + module Linter + # Base class for semantic lint rules. + # @abstract + class Rule + class << self + #: () -> String + def rule_name + name.split("::").last + end + end + + #: Graph + attr_reader :graph + + #: LinterConfig + attr_reader :config + + #: Array[Diagnostic] + attr_reader :diagnostics + + #: (Graph, config: LinterConfig) -> void + def initialize(graph, config:) + @graph = graph + @config = config + @diagnostics = [] #: Array[Diagnostic] + end + + # @abstract + #: () -> singleton(Severity::Base) + def severity + raise NotImplementedError, "Subclasses must implement the severity method" + end + + # @abstract + #: () -> void + def lint + raise NotImplementedError, "Subclasses must implement the lint method" + end + + protected + + #: ( + #| String, + #| Location, + #| ?related_information: Array[RelatedInformation], + #| ) -> void + def add_diagnostic(message, location, related_information: []) + @diagnostics << Diagnostic.new( + rule: self.class.rule_name, + message: message, + location: location, + severity: severity, + related_information: related_information, + ) + end + end + end +end diff --git a/lib/rubydex/linter/runner.rb b/lib/rubydex/linter/runner.rb new file mode 100644 index 000000000..b232ef4c0 --- /dev/null +++ b/lib/rubydex/linter/runner.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "pathname" + +module Rubydex + module Linter + class Runner + #: Graph + attr_reader :graph + + #: Array[singleton(Rule)] + attr_reader :rules + + #: (Graph, rules: Array[singleton(Rule)], config: LinterConfig) -> void + def initialize(graph, rules:, config:) + raise ArgumentError, "At least one linter rule is required" if rules.empty? + + @graph = graph + @config = config + @rules = rules.select { |rule| config.rule_enabled?(rule) }.sort_by { |rule| rule.name.to_s } + end + + #: () -> Result + def run + rule_diagnostics = @rules.flat_map do |rule_class| + rule = rule_class.new(@graph, config: @config) + rule.lint + rule.diagnostics + end + + diagnostics = (@graph.diagnostics + rule_diagnostics).select do |diagnostic| + diagnostic_in_workspace?(diagnostic) + end.sort_by do |diagnostic| + location = diagnostic.location + [ + location.uri, + location.start_line, + location.start_column, + location.end_line, + location.end_column, + diagnostic.rule, + diagnostic.message, + ] + end + + Result.new(diagnostics) + end + + private + + #: (Diagnostic) -> bool + def diagnostic_in_workspace?(diagnostic) + path = URI::RFC2396_PARSER.unescape(diagnostic.location.to_file_path) + workspace_path = Pathname.new(File.expand_path(@graph.workspace_path)) + relative_path = Pathname.new(File.expand_path(path)).relative_path_from(workspace_path) + + relative_path.each_filename.first != ".." + rescue Location::NotFileUriError + true + rescue ArgumentError + false + end + end + end +end diff --git a/rbi/rubydex.rbi b/rbi/rubydex.rbi index b54adb7d4..934b272fa 100644 --- a/rbi/rubydex.rbi +++ b/rbi/rubydex.rbi @@ -356,6 +356,75 @@ class Rubydex::Diagnostic def related_information; end end +module Rubydex::Linter; end + +class Rubydex::Linter::Rule + abstract! + + sig { returns(String) } + def self.rule_name; end + + sig { params(graph: Rubydex::Graph, config: Rubydex::LinterConfig).void } + def initialize(graph, config:); end + + sig { returns(Rubydex::Graph) } + def graph; end + + sig { returns(Rubydex::LinterConfig) } + def config; end + + sig { returns(T::Array[Rubydex::Diagnostic]) } + def diagnostics; end + + sig { abstract.returns(T.class_of(Rubydex::Severity::Base)) } + def severity; end + + sig { abstract.void } + def lint; end + + protected + + sig do + params( + message: String, + location: Rubydex::Location, + related_information: T::Array[Rubydex::RelatedInformation], + ).void + end + def add_diagnostic(message, location, related_information: []); end +end + +class Rubydex::Linter::Runner + sig do + params( + graph: Rubydex::Graph, + rules: T::Array[T.class_of(Rubydex::Linter::Rule)], + config: Rubydex::LinterConfig, + ).void + end + def initialize(graph, rules:, config:); end + + sig { returns(Rubydex::Graph) } + def graph; end + + sig { returns(T::Array[T.class_of(Rubydex::Linter::Rule)]) } + def rules; end + + sig { returns(Rubydex::Linter::Result) } + def run; end +end + +class Rubydex::Linter::Result + sig { params(diagnostics: T::Array[Rubydex::Diagnostic]).void } + def initialize(diagnostics); end + + sig { returns(T::Array[Rubydex::Diagnostic]) } + def diagnostics; end + + sig { returns(T::Boolean) } + def success?; end +end + class Rubydex::Keyword sig { params(name: String, documentation: String).void } def initialize(name, documentation); end @@ -424,6 +493,9 @@ class Rubydex::LinterConfig sig { params(rules: T::Hash[String, Rubydex::RuleConfig]).void } def initialize(rules); end + + sig { params(rule_class: T.class_of(Rubydex::Linter::Rule)).returns(T::Boolean) } + def rule_enabled?(rule_class); end end # The settings of a single linter rule, read from a `[linter.rules.RuleName]` table. diff --git a/test/cli_test.rb b/test/cli_test.rb index d121d5575..89da97ea8 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -25,12 +25,14 @@ 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::Lint) assert_includes(commands, Rubydex::CLI::Command::Mcp) # The declared name is what the class reports, and drives its usage line. 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("lint [PATH]", Rubydex::CLI::Command::Lint.usage_form) end def test_commands_are_listed_alphabetically @@ -45,11 +47,12 @@ 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, mcp, query, help = ["console", "mcp", "query", "help"].map do |name| + console, lint, mcp, query, help = ["console", "lint", "mcp", "query", "help"].map do |name| assert_stdout_includes_pattern(result, /^ #{name}\b/).begin(0) end - assert_operator(console, :<, mcp) + assert_operator(console, :<, lint) + assert_operator(lint, :<, mcp) assert_operator(mcp, :<, query) # `help` is listed last rather than in alphabetical position. assert_operator(query, :<, help) @@ -93,6 +96,7 @@ def test_usage_is_generated_from_the_declared_commands [ Rubydex::CLI::Command::Query, Rubydex::CLI::Command::Console, + Rubydex::CLI::Command::Lint, Rubydex::CLI::Command::Mcp, ].each do |command| assert_stdout_includes_pattern(result, /^ #{Regexp.escape(command.usage_form)}\s{2,}\S/) @@ -199,7 +203,7 @@ def test_query_supports_json_output end def test_command_help_is_available_per_subcommand - ["query", "console", "mcp"].each do |command| + ["query", "console", "lint", "mcp"].each do |command| result = rdx(command, "--help") assert_success_status(result) @@ -208,7 +212,7 @@ def test_command_help_is_available_per_subcommand end def test_every_command_reports_an_invalid_option_with_the_usage - ["query", "console", "mcp"].each do |command| + ["query", "console", "lint", "mcp"].each do |command| result = rdx(command, "--bogus-flag") refute_success_status(result) @@ -235,6 +239,74 @@ def test_mcp_rejects_extra_arguments assert_stderr_includes(result, "unexpected argument: two") end + def test_lint_reports_a_project_rule_diagnostic_with_related_information + with_context do |context| + write_linter_rule( + context, + "CLITestProjectErrorRule", + path: "rubydex_linter/rules/nested/no_foo.rb", + ) + context.write!("app.rb", "class Foo; end\nclass Foo; end\n") + Gem.expects(:find_latest_files).never + + result = with_bundle_gemfile(nil) { rdx("lint", context.absolute_path) } + + refute_success_status(result) + assert_stdout_equals( + <<~OUTPUT, + #{context.absolute_path_to("app.rb")}:1:7: error: CLITestProjectErrorRule: Foo is not allowed. + #{context.absolute_path_to("app.rb")}:2:7: Foo is also defined here. + OUTPUT + result, + ) + assert_stderr_includes(result, "Indexing workspace...") + assert_stderr_includes(result, "Resolving graph...") + end + end + + def test_lint_allows_a_clean_workspace + with_context do |context| + write_linter_rule(context, "CLITestProjectCleanRule") + context.write!("app.rb", "class Bar; end\n") + + result = with_bundle_gemfile(nil) { rdx("lint", context.absolute_path) } + + assert_success_status(result) + assert_empty_stdout(result) + end + end + + def test_lint_loads_rules_from_bundled_dependencies + with_context do |context| + rule_path = "fake_gem/lib/rubydex_linter/rules/no_foo.rb" + write_linter_rule(context, "CLITestDependencyErrorRule", path: rule_path) + context.write!("app.rb", "class Foo; end\n") + Gem.expects(:find_latest_files) + .with("rubydex_linter/rules/**/*.rb") + .returns([context.absolute_path_to(rule_path)]) + + result = with_bundle_gemfile(context.absolute_path_to("Gemfile")) do + rdx("lint", context.absolute_path) + end + + refute_success_status(result) + assert_stdout_includes(result, "error: CLITestDependencyErrorRule: Foo is not allowed.") + end + end + + def test_lint_requires_a_discovered_rule_before_indexing + with_context do |context| + context.write!("app.rb", "class Foo; end\n") + + result = with_bundle_gemfile(nil) { rdx("lint", context.absolute_path) } + + refute_success_status(result) + assert_empty_stdout(result) + assert_stderr_includes(result, "No Rubydex::Linter::Rule subclasses were loaded") + refute_stderr_includes(result, "Indexing workspace...") + end + end + # `irb` is not a runtime dependency, so its absence is reported rather than raised. The graph is # stubbed out: this is about the `require`, and indexing a workspace would prove nothing here. def test_console_reports_a_missing_irb @@ -258,6 +330,48 @@ def test_console_surfaces_a_load_error_raised_from_inside_irb private + # Each in-process invocation loads a distinct named rule. Reopening the same class would not add + # a subclass, so the linter could not identify which rule came from that invocation. + #: (Test::Helpers::Context context, String class_name, ?path: String) -> void + def write_linter_rule(context, class_name, path: "rubydex_linter/rules/no_foo.rb") + context.write!(path, <<~RUBY) + # frozen_string_literal: true + + class #{class_name} < Rubydex::Linter::Rule + def severity = Rubydex::Severity::Error + + def lint + declaration = graph["Foo"] + return unless declaration + + definitions = declaration.definitions.sort_by { |definition| definition.location }.to_a + primary = definitions.shift + return unless primary + + add_diagnostic( + "Foo is not allowed.", + primary.name_location || primary.location, + related_information: definitions.map do |definition| + Rubydex::RelatedInformation.new( + "Foo is also defined here.", + definition.name_location || definition.location, + ) + end, + ) + end + end + RUBY + end + + #: [R] (String?) { -> R } -> R + def with_bundle_gemfile(value) + previous = ENV["BUNDLE_GEMFILE"] + ENV["BUNDLE_GEMFILE"] = value + yield + ensure + ENV["BUNDLE_GEMFILE"] = previous + end + #: (LoadError error) -> void def console_raising_on_require(error) console = Rubydex::CLI::Command::Console.any_instance diff --git a/test/linter_test.rb b/test/linter_test.rb new file mode 100644 index 000000000..6caa41ec4 --- /dev/null +++ b/test/linter_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "test_helper" +require "helpers/context" +require "rubydex/linter" + +class LinterTest < Minitest::Test + include Test::Helpers::WithContext + + class WarningRule < Rubydex::Linter::Rule + def severity = Rubydex::Severity::Warning + + def lint + add_diagnostic( + "A warning.", + location("untitled:warning"), + related_information: [ + Rubydex::RelatedInformation.new("Related context.", location("untitled:related")), + ], + ) + end + + private + + def location(uri) + Rubydex::Location.new(uri: uri, start_line: 0, end_line: 0, start_column: 0, end_column: 1) + end + end + + class ErrorRule < Rubydex::Linter::Rule + def severity = Rubydex::Severity::Error + + def lint + add_diagnostic( + "An error.", + Rubydex::Location.new( + uri: "untitled:error", + start_line: 0, + end_line: 0, + start_column: 0, + end_column: 1, + ), + ) + end + end + + class SilentRule < Rubydex::Linter::Rule + def severity = Rubydex::Severity::Hint + def lint; end + end + + class OutsideWorkspaceRule < Rubydex::Linter::Rule + def severity = Rubydex::Severity::Information + + def lint + sibling = File.join(File.dirname(graph.workspace_path), "#{File.basename(graph.workspace_path)}-other", "file.rb") + sibling.prepend("/") if Gem.win_platform? + uri = URI::File.build(path: sibling).to_s + + add_diagnostic( + "Outside the workspace.", + Rubydex::Location.new(uri: uri, start_line: 0, end_line: 0, start_column: 0, end_column: 1), + ) + end + end + + def test_runner_builds_diagnostics_with_rule_severity_and_related_information + result = Rubydex::Linter::Runner.new(Rubydex::Graph.new, rules: [WarningRule], config: linter_config).run + diagnostic = result.diagnostics.fetch(0) + + assert_equal("WarningRule", diagnostic.rule) + assert_equal("A warning.", diagnostic.message) + assert_equal(Rubydex::Severity::Warning, diagnostic.severity) + assert_equal(["Related context."], diagnostic.related_information.map(&:message)) + end + + def test_rule_exposes_linter_config + config = linter_config + rule = WarningRule.new(Rubydex::Graph.new, config:) + + assert_same(config, rule.config) + end + + def test_runner_drops_disabled_rules + config = linter_config("WarningRule" => false) + runner = Rubydex::Linter::Runner.new( + Rubydex::Graph.new, + rules: [WarningRule, ErrorRule], + config:, + ) + + assert_equal([ErrorRule], runner.rules) + assert_equal(["ErrorRule"], runner.run.diagnostics.map(&:rule)) + end + + def test_runner_allows_every_rule_to_be_disabled + config = linter_config("WarningRule" => false) + runner = Rubydex::Linter::Runner.new(Rubydex::Graph.new, rules: [WarningRule], config:) + + assert_empty(runner.rules) + assert_predicate(runner.run, :success?) + end + + def test_result_fails_only_for_error_diagnostics + non_error_diagnostics = [ + Rubydex::Severity::Warning, + Rubydex::Severity::Information, + Rubydex::Severity::Hint, + ].map { |severity| diagnostic(severity) } + + assert_predicate(Rubydex::Linter::Result.new(non_error_diagnostics), :success?) + refute_predicate( + Rubydex::Linter::Result.new([*non_error_diagnostics, diagnostic(Rubydex::Severity::Error)]), + :success?, + ) + end + + def test_runner_includes_native_graph_diagnostics + graph = Rubydex::Graph.new + path = File.join(graph.workspace_path, "broken.rb") + path.prepend("/") if Gem.win_platform? + graph.index_source(URI::File.build(path: path).to_s, "class Broken", "ruby") + + result = Rubydex::Linter::Runner.new(graph, rules: [SilentRule], config: linter_config).run + + assert_equal(["parse-error", "parse-error"], result.diagnostics.map(&:rule)) + assert(result.diagnostics.all? { |diagnostic| diagnostic.severity == Rubydex::Severity::Information }) + assert_predicate(result, :success?) + end + + def test_runner_requires_rules + error = assert_raises(ArgumentError) do + Rubydex::Linter::Runner.new(Rubydex::Graph.new, rules: [], config: linter_config) + end + + assert_equal("At least one linter rule is required", error.message) + end + + def test_runner_filters_diagnostics_outside_the_workspace + with_context do |context| + context.write!("workspace/inside.rb") + context.write!("workspace-other/file.rb") + graph = Rubydex::Graph.configure_for_workspace(context.absolute_path_to("workspace")) + + result = Rubydex::Linter::Runner.new(graph, rules: [OutsideWorkspaceRule], config: linter_config).run + + assert_empty(result.diagnostics) + end + end + + def test_runner_keeps_diagnostics_indexed_through_a_symlinked_workspace_path + with_context do |context| + context.write!("workspace/inside.rb") + context.write!("outside/broken.rb", "class Broken") + link = context.absolute_path_to("workspace/link") + File.symlink(context.absolute_path_to("outside"), link) + graph = Rubydex::Graph.configure_for_workspace(context.absolute_path_to("workspace")) + graph.index_all([link]) + + result = Rubydex::Linter::Runner.new(graph, rules: [SilentRule], config: linter_config).run + + expected_uri = context.uri_to("workspace/link/broken.rb") + assert_equal([expected_uri, expected_uri], result.diagnostics.map { |diagnostic| diagnostic.location.uri }) + end + end + + private + + #: (?Hash[String, bool] rules) -> Rubydex::LinterConfig + def linter_config(rules = {}) + Rubydex::LinterConfig.new( + rules.to_h { |name, enabled| [name, Rubydex::RuleConfig.new(name, enabled)] }, + ) + end + + #: (singleton(Rubydex::Severity::Base) severity) -> Rubydex::Diagnostic + def diagnostic(severity) + Rubydex::Diagnostic.new( + rule: "TestRule", + message: "Test diagnostic.", + location: Rubydex::Location.new( + uri: "untitled:test", + start_line: 0, + end_line: 0, + start_column: 0, + end_column: 1, + ), + severity: severity, + ) + end +end