rigortype 0.1.10 → 0.1.12
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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/scope.rbs +22 -0
- metadata +157 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activejob < Rigor::Plugin::Base
|
|
8
|
+
# Walks a parsed file's AST looking for
|
|
9
|
+
# `<JobClass>.perform_later(...)` /
|
|
10
|
+
# `.perform_now(...)` / `.perform(...)` calls and
|
|
11
|
+
# validates each against the {JobIndex}.
|
|
12
|
+
#
|
|
13
|
+
# The plugin recognises a call as job-shaped when the
|
|
14
|
+
# receiver is a `ConstantReadNode` / `ConstantPathNode`
|
|
15
|
+
# whose resolved name appears in the index, and the
|
|
16
|
+
# method name is one of the three ActiveJob entry
|
|
17
|
+
# points.
|
|
18
|
+
module Analyzer
|
|
19
|
+
# Methods that delegate to the job's `#perform`. All
|
|
20
|
+
# three accept the same argument shape — `perform_later`
|
|
21
|
+
# is the most common (queues for later execution),
|
|
22
|
+
# `perform_now` runs synchronously, and `perform` is
|
|
23
|
+
# the bare execution path.
|
|
24
|
+
ENTRY_METHODS = %i[perform_later perform_now perform].freeze
|
|
25
|
+
|
|
26
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param path [String]
|
|
31
|
+
# @param root [Prism::Node]
|
|
32
|
+
# @param job_index [JobIndex]
|
|
33
|
+
# @return [Array<Diagnostic>]
|
|
34
|
+
def diagnose(path:, root:, job_index:)
|
|
35
|
+
diagnostics = []
|
|
36
|
+
walk(root) do |call_node|
|
|
37
|
+
class_name = constant_receiver_name(call_node.receiver)
|
|
38
|
+
next if class_name.nil?
|
|
39
|
+
|
|
40
|
+
entry = job_index.find(class_name) || job_index.find("::#{class_name}")
|
|
41
|
+
next if entry.nil?
|
|
42
|
+
|
|
43
|
+
diagnostics << info_diagnostic(path, call_node, entry)
|
|
44
|
+
arity = arity_check(path, call_node, entry)
|
|
45
|
+
diagnostics << arity if arity
|
|
46
|
+
end
|
|
47
|
+
diagnostics
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def walk(node, &)
|
|
51
|
+
return unless node.is_a?(Prism::Node)
|
|
52
|
+
|
|
53
|
+
yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
|
|
54
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def entry_call?(node)
|
|
58
|
+
ENTRY_METHODS.include?(node.name) &&
|
|
59
|
+
(node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def info_diagnostic(path, call_node, entry)
|
|
63
|
+
location = call_node.location
|
|
64
|
+
Diagnostic.new(
|
|
65
|
+
path: path,
|
|
66
|
+
line: location.start_line,
|
|
67
|
+
column: location.start_column + 1,
|
|
68
|
+
severity: :info,
|
|
69
|
+
rule: "job-call",
|
|
70
|
+
message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` (arity #{entry.arity_label})"
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def arity_check(path, call_node, entry)
|
|
75
|
+
actual = (call_node.arguments&.arguments || []).size
|
|
76
|
+
return nil if entry.accepts?(actual)
|
|
77
|
+
|
|
78
|
+
location = call_node.location
|
|
79
|
+
Diagnostic.new(
|
|
80
|
+
path: path,
|
|
81
|
+
line: location.start_line,
|
|
82
|
+
column: location.start_column + 1,
|
|
83
|
+
severity: :error,
|
|
84
|
+
rule: "wrong-arity",
|
|
85
|
+
message: "`#{entry.class_name}.#{call_node.name}` expects #{entry.arity_label} argument(s), got #{actual}"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Renders a constant-path receiver as a String.
|
|
90
|
+
# Mirrors the helpers in rigor-activerecord /
|
|
91
|
+
# rigor-rails-routes for parity.
|
|
92
|
+
def constant_receiver_name(node)
|
|
93
|
+
case node
|
|
94
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
95
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def constant_path_name(node)
|
|
100
|
+
parts = []
|
|
101
|
+
current = node
|
|
102
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
103
|
+
parts.unshift(current.name.to_s)
|
|
104
|
+
current = current.parent
|
|
105
|
+
end
|
|
106
|
+
case current
|
|
107
|
+
when nil then "::#{parts.join('::')}"
|
|
108
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "job_index"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Activejob < Rigor::Plugin::Base
|
|
10
|
+
# Walks the configured job-search paths via the plugin's
|
|
11
|
+
# `IoBoundary`, parses each `.rb` file with Prism, and
|
|
12
|
+
# collects classes whose immediate superclass is one of
|
|
13
|
+
# the configured base classes. For each discovered class,
|
|
14
|
+
# the discoverer also reads the `#perform` method's
|
|
15
|
+
# parameter list and computes the arity envelope.
|
|
16
|
+
#
|
|
17
|
+
# Limitations (intentional for v0.1.0):
|
|
18
|
+
#
|
|
19
|
+
# - Only direct-superclass matches. `class WelcomeJob <
|
|
20
|
+
# BaseJob` where `BaseJob < ApplicationJob` is NOT
|
|
21
|
+
# discovered. List `BaseJob` in `job_base_classes`
|
|
22
|
+
# if needed.
|
|
23
|
+
# - The qualified class name is the lexical path
|
|
24
|
+
# (`Admin::WelcomeJob` for a class declared inside
|
|
25
|
+
# `module Admin`).
|
|
26
|
+
# - The `#perform` arity is read from the syntactic
|
|
27
|
+
# parameter list. Methods built via `define_method`
|
|
28
|
+
# are out of scope.
|
|
29
|
+
class JobDiscoverer
|
|
30
|
+
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
31
|
+
@io_boundary = io_boundary
|
|
32
|
+
@search_paths = search_paths
|
|
33
|
+
@base_classes = base_classes.to_set
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [JobIndex]
|
|
37
|
+
def discover
|
|
38
|
+
entries = []
|
|
39
|
+
ruby_files_under(@search_paths).each do |path|
|
|
40
|
+
contents = read_safely(path)
|
|
41
|
+
next if contents.nil?
|
|
42
|
+
|
|
43
|
+
tree = Prism.parse(contents).value
|
|
44
|
+
walk_for_jobs(tree, []) do |class_name, perform_def|
|
|
45
|
+
entries << build_entry(class_name, perform_def)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
JobIndex.new(entries)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def read_safely(path)
|
|
54
|
+
@io_boundary.read_file(path)
|
|
55
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ruby_files_under(roots)
|
|
60
|
+
roots.flat_map do |root|
|
|
61
|
+
absolute = File.expand_path(root)
|
|
62
|
+
next [] unless File.directory?(absolute)
|
|
63
|
+
|
|
64
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def walk_for_jobs(node, lexical_path, &)
|
|
69
|
+
return if node.nil?
|
|
70
|
+
|
|
71
|
+
case node
|
|
72
|
+
when Prism::ClassNode then visit_class(node, lexical_path, &)
|
|
73
|
+
when Prism::ModuleNode then visit_module(node, lexical_path, &)
|
|
74
|
+
else
|
|
75
|
+
node.compact_child_nodes.each { |child| walk_for_jobs(child, lexical_path, &) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def visit_class(node, lexical_path, &)
|
|
80
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
81
|
+
return if class_local_name.nil?
|
|
82
|
+
|
|
83
|
+
full_name = (lexical_path + [class_local_name]).join("::")
|
|
84
|
+
superclass = constant_path_name(node.superclass) if node.superclass
|
|
85
|
+
if superclass && @base_classes.include?(superclass)
|
|
86
|
+
perform_def = lookup_perform_def(node.body)
|
|
87
|
+
yield full_name, perform_def
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
inner_path = lexical_path + [class_local_name]
|
|
91
|
+
walk_for_jobs(node.body, inner_path, &) if node.body
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def visit_module(node, lexical_path, &)
|
|
95
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
96
|
+
return if module_local_name.nil?
|
|
97
|
+
|
|
98
|
+
inner_path = lexical_path + [module_local_name]
|
|
99
|
+
walk_for_jobs(node.body, inner_path, &) if node.body
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Renders `Foo::Bar` / `::Foo::Bar` as a String.
|
|
103
|
+
def constant_path_name(node)
|
|
104
|
+
return nil if node.nil?
|
|
105
|
+
|
|
106
|
+
case node
|
|
107
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
108
|
+
when Prism::ConstantPathNode
|
|
109
|
+
parts = []
|
|
110
|
+
current = node
|
|
111
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
112
|
+
parts.unshift(current.name.to_s)
|
|
113
|
+
current = current.parent
|
|
114
|
+
end
|
|
115
|
+
case current
|
|
116
|
+
when nil then "::#{parts.join('::')}"
|
|
117
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns the `def perform(...)` node from a class
|
|
123
|
+
# body, or `nil` when the class doesn't override
|
|
124
|
+
# `#perform`. Only matches instance-side `def perform`.
|
|
125
|
+
def lookup_perform_def(body)
|
|
126
|
+
return nil if body.nil?
|
|
127
|
+
|
|
128
|
+
body.compact_child_nodes.each do |node|
|
|
129
|
+
next unless node.is_a?(Prism::DefNode) && node.name == :perform
|
|
130
|
+
next if node.receiver.is_a?(Prism::SelfNode)
|
|
131
|
+
|
|
132
|
+
return node
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Builds a JobIndex::Entry from the discovered class's
|
|
138
|
+
# `#perform` def. When the class doesn't override
|
|
139
|
+
# `#perform`, we record an "any-arity" entry — Active
|
|
140
|
+
# Job's default `#perform` is abstract; calling
|
|
141
|
+
# `perform_later` on a job that didn't override it is
|
|
142
|
+
# itself a bug, but it's the user's bug, not the
|
|
143
|
+
# plugin's call to flag without runtime context.
|
|
144
|
+
def build_entry(class_name, perform_def)
|
|
145
|
+
if perform_def.nil?
|
|
146
|
+
return JobIndex::Entry.new(
|
|
147
|
+
class_name: class_name, min_arity: 0,
|
|
148
|
+
max_arity: Float::INFINITY, keyword_required: []
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
parameters = perform_def.parameters
|
|
153
|
+
if parameters.nil?
|
|
154
|
+
return JobIndex::Entry.new(
|
|
155
|
+
class_name: class_name, min_arity: 0,
|
|
156
|
+
max_arity: 0, keyword_required: []
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
required_count = (parameters.requireds || []).size
|
|
161
|
+
optional_count = (parameters.optionals || []).size
|
|
162
|
+
rest_present = !parameters.rest.nil?
|
|
163
|
+
keyword_required = (parameters.keywords || []).filter_map do |kw|
|
|
164
|
+
kw.name if kw.is_a?(Prism::RequiredKeywordParameterNode)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
JobIndex::Entry.new(
|
|
168
|
+
class_name: class_name,
|
|
169
|
+
min_arity: required_count,
|
|
170
|
+
max_arity: rest_present ? Float::INFINITY : required_count + optional_count,
|
|
171
|
+
keyword_required: keyword_required.map(&:to_sym)
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Activejob < Rigor::Plugin::Base
|
|
6
|
+
# Frozen catalogue of discovered ActiveJob subclasses
|
|
7
|
+
# keyed by qualified class name. Each entry holds the
|
|
8
|
+
# `#perform` method's arity envelope so the analyzer can
|
|
9
|
+
# validate `Job.perform_later(...)` call sites.
|
|
10
|
+
#
|
|
11
|
+
# `min_arity` / `max_arity` form a closed range
|
|
12
|
+
# (`Float::INFINITY` for the upper bound when `*args`
|
|
13
|
+
# is present). `keyword_required` lists any required
|
|
14
|
+
# keyword arguments — Active Job supports keyword args
|
|
15
|
+
# but they're rare in user code, so the analyzer only
|
|
16
|
+
# validates positional arity for v0.1.0.
|
|
17
|
+
class JobIndex
|
|
18
|
+
Entry = Data.define(:class_name, :min_arity, :max_arity, :keyword_required) do
|
|
19
|
+
# Flexible-friendly textual form of the arity for
|
|
20
|
+
# error messages: `1`, `1..2`, `2+`.
|
|
21
|
+
def arity_label
|
|
22
|
+
return "#{min_arity}+" if max_arity == Float::INFINITY
|
|
23
|
+
return min_arity.to_s if min_arity == max_arity
|
|
24
|
+
|
|
25
|
+
"#{min_arity}..#{max_arity}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Predicate for the analyzer's wrong-arity check.
|
|
29
|
+
def accepts?(actual)
|
|
30
|
+
actual.between?(min_arity, max_arity)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :entries
|
|
35
|
+
|
|
36
|
+
def initialize(entries)
|
|
37
|
+
@entries = entries.freeze
|
|
38
|
+
@by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Entry, nil]
|
|
43
|
+
def find(class_name)
|
|
44
|
+
@by_name[class_name.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def known?(class_name)
|
|
48
|
+
@by_name.key?(class_name.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty?
|
|
52
|
+
@entries.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def size
|
|
56
|
+
@entries.size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def names
|
|
60
|
+
@by_name.keys
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "activejob/job_index"
|
|
6
|
+
require_relative "activejob/job_discoverer"
|
|
7
|
+
require_relative "activejob/analyzer"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Plugin
|
|
11
|
+
# rigor-activejob — validates `Job.perform_later(...)` /
|
|
12
|
+
# `.perform_now(...)` / `.perform(...)` argument arity
|
|
13
|
+
# against the discovered `#perform` definitions.
|
|
14
|
+
#
|
|
15
|
+
# Tier 1D of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
|
|
16
|
+
# Statically discovers ActiveJob subclasses by walking
|
|
17
|
+
# the configured `job_search_paths` and parsing each
|
|
18
|
+
# file with Prism — no `active_job` runtime dependency.
|
|
19
|
+
#
|
|
20
|
+
# ## Configuration
|
|
21
|
+
#
|
|
22
|
+
# plugins:
|
|
23
|
+
# - gem: rigor-activejob
|
|
24
|
+
# config:
|
|
25
|
+
# job_search_paths: ["app/jobs"] # default; optional
|
|
26
|
+
# job_base_classes: ["ApplicationJob", "ActiveJob::Base"] # default; optional
|
|
27
|
+
#
|
|
28
|
+
# ## Limitations (v0.1.0)
|
|
29
|
+
#
|
|
30
|
+
# - Direct-superclass match only. `class WelcomeJob <
|
|
31
|
+
# BaseJob` where `BaseJob < ApplicationJob` is NOT
|
|
32
|
+
# discovered. Add `BaseJob` to `job_base_classes` if
|
|
33
|
+
# needed.
|
|
34
|
+
# - The `#perform` arity is read from the syntactic
|
|
35
|
+
# parameter list. Methods built via `define_method`
|
|
36
|
+
# are out of scope.
|
|
37
|
+
# - Required keyword arguments are recognised but not
|
|
38
|
+
# validated at the call site (positional arity only
|
|
39
|
+
# for v0.1.0).
|
|
40
|
+
class Activejob < Rigor::Plugin::Base
|
|
41
|
+
manifest(
|
|
42
|
+
id: "activejob",
|
|
43
|
+
version: "0.1.0",
|
|
44
|
+
description: "Validates ActiveJob `Job.perform_later` argument arity.",
|
|
45
|
+
config_schema: {
|
|
46
|
+
"job_search_paths" => :array,
|
|
47
|
+
"job_base_classes" => :array
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
DEFAULT_JOB_SEARCH_PATHS = ["app/jobs"].freeze
|
|
52
|
+
DEFAULT_JOB_BASE_CLASSES = %w[ApplicationJob ActiveJob::Base].freeze
|
|
53
|
+
|
|
54
|
+
# Cached: discovered job index. The producer reads every
|
|
55
|
+
# file under `job_search_paths` via the trusted
|
|
56
|
+
# `IoBoundary`; the descriptor's auto-collected
|
|
57
|
+
# `FileEntry` digests invalidate the cache when any of
|
|
58
|
+
# those files change.
|
|
59
|
+
producer :job_index do |_params|
|
|
60
|
+
JobDiscoverer.new(
|
|
61
|
+
io_boundary: io_boundary,
|
|
62
|
+
search_paths: @job_search_paths,
|
|
63
|
+
base_classes: @job_base_classes
|
|
64
|
+
).discover
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def init(_services)
|
|
68
|
+
@job_search_paths = Array(config.fetch("job_search_paths", DEFAULT_JOB_SEARCH_PATHS)).map(&:to_s)
|
|
69
|
+
@job_base_classes = Array(config.fetch("job_base_classes", DEFAULT_JOB_BASE_CLASSES)).map(&:to_s)
|
|
70
|
+
@job_index = nil
|
|
71
|
+
@load_error = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
75
|
+
index = job_index_or_nil
|
|
76
|
+
return [load_error_diagnostic(path)] if index.nil? && @load_error
|
|
77
|
+
return [] if index.nil? || index.empty?
|
|
78
|
+
|
|
79
|
+
Analyzer.diagnose(path: path, root: root, job_index: index).map { |diag| build_diagnostic(diag) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def job_index_or_nil
|
|
85
|
+
return @job_index if @job_index
|
|
86
|
+
|
|
87
|
+
# Read-then-cache pattern: the discoverer's
|
|
88
|
+
# IoBoundary reads happen INSIDE `discover`, which is
|
|
89
|
+
# invoked through `cache_for`'s producer block. The
|
|
90
|
+
# boundary's accumulated FileEntry digests get
|
|
91
|
+
# captured into the descriptor at cache_for time.
|
|
92
|
+
@job_index = cache_for(:job_index, params: {}).call
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
@load_error = "rigor-activejob: failed to discover jobs: #{e.class}: #{e.message}"
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_error_diagnostic(path)
|
|
99
|
+
Rigor::Analysis::Diagnostic.new(
|
|
100
|
+
path: path, line: 1, column: 1,
|
|
101
|
+
message: @load_error,
|
|
102
|
+
severity: :warning,
|
|
103
|
+
rule: "load-error"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_diagnostic(diag)
|
|
108
|
+
Rigor::Analysis::Diagnostic.new(
|
|
109
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
110
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
Rigor::Plugin.register(Activejob)
|
|
116
|
+
end
|
|
117
|
+
end
|