rigortype 0.1.10 → 0.1.11
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/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli.rb +16 -3
- 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 +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -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 +273 -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 +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -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 +34 -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 +463 -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 +277 -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 +167 -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 +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -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
- metadata +149 -1
|
@@ -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
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activerecord < Rigor::Plugin::Base
|
|
8
|
+
# Per-file AST walker. For each `Model.find(...)` /
|
|
9
|
+
# `Model.find_by(...)` / `Model.where(...)` call where
|
|
10
|
+
# `Model` is a name in the {ModelIndex}, emits diagnostics:
|
|
11
|
+
#
|
|
12
|
+
# | Method | Recognised arg shape | Validation |
|
|
13
|
+
# | --- | --- | --- |
|
|
14
|
+
# | `Model.find(id)` | any positional | arity check (1+ args) |
|
|
15
|
+
# | `Model.find_by(col: v, ...)` | keyword args | each key must be a column |
|
|
16
|
+
# | `Model.where(col: v, ...)` | keyword args | each key must be a column |
|
|
17
|
+
# | `Model.where(string)` | String literal | parser-side; not validated |
|
|
18
|
+
#
|
|
19
|
+
# Successful matches surface as `:info` diagnostics naming
|
|
20
|
+
# the resolved table; unknown columns surface as `:error`.
|
|
21
|
+
# Calls whose receiver is not a model in the index, and
|
|
22
|
+
# calls with non-keyword arguments to `where` / `find_by`,
|
|
23
|
+
# stay silent.
|
|
24
|
+
class Analyzer
|
|
25
|
+
# Methods that take a column → value Hash and need each key
|
|
26
|
+
# validated against the receiver's column set. The bang
|
|
27
|
+
# variants (`find_by!` raises instead of returning nil;
|
|
28
|
+
# `find_or_create_by!` raises on a validation failure) and
|
|
29
|
+
# `create_or_find_by` / `create_or_find_by!` take the
|
|
30
|
+
# identical column-hash argument shape.
|
|
31
|
+
COLUMN_HASH_METHODS = %i[
|
|
32
|
+
where
|
|
33
|
+
find_by find_by!
|
|
34
|
+
find_or_create_by find_or_create_by!
|
|
35
|
+
find_or_initialize_by
|
|
36
|
+
create_or_find_by create_or_find_by!
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
DID_YOU_MEAN_DISTANCE = 3
|
|
40
|
+
|
|
41
|
+
attr_reader :diagnostics
|
|
42
|
+
|
|
43
|
+
def initialize(path:, model_index:)
|
|
44
|
+
@path = path
|
|
45
|
+
@model_index = model_index
|
|
46
|
+
@diagnostics = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def analyze(root)
|
|
50
|
+
walk(root) { |node| visit_call(node) if node.is_a?(Prism::CallNode) }
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def walk(node, &)
|
|
57
|
+
return if node.nil?
|
|
58
|
+
|
|
59
|
+
yield node
|
|
60
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visit_call(node)
|
|
64
|
+
model_name = constant_receiver_name(node.receiver)
|
|
65
|
+
return if model_name.nil?
|
|
66
|
+
|
|
67
|
+
entry = @model_index.find(model_name) ||
|
|
68
|
+
@model_index.find("::#{model_name}")
|
|
69
|
+
return if entry.nil?
|
|
70
|
+
|
|
71
|
+
case node.name
|
|
72
|
+
when :find then validate_find(node, entry)
|
|
73
|
+
when *COLUMN_HASH_METHODS then validate_column_hash_call(node, entry)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_find(node, entry)
|
|
78
|
+
arity = call_argument_count(node)
|
|
79
|
+
if arity.zero?
|
|
80
|
+
push_error(node, "wrong-arity",
|
|
81
|
+
"`#{entry.class_name}.find` expects at least 1 argument, got 0")
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
push_info(node, "model-call",
|
|
86
|
+
"`#{entry.class_name}.find` returns #{entry.class_name} (table: `#{entry.table_name}`)")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_column_hash_call(node, entry)
|
|
90
|
+
keyword_pairs = keyword_argument_pairs(node)
|
|
91
|
+
return push_recognised(node, entry) if keyword_pairs.empty?
|
|
92
|
+
|
|
93
|
+
unknown = keyword_pairs.reject { |pair| valid_query_key?(entry, pair[:key]) }
|
|
94
|
+
if unknown.empty?
|
|
95
|
+
keyword_pairs.each { |pair| validate_enum_value(node, entry, pair) }
|
|
96
|
+
push_recognised(node, entry, keyword_pairs.map { |p| p[:key] })
|
|
97
|
+
else
|
|
98
|
+
unknown.each do |pair|
|
|
99
|
+
key = pair[:key]
|
|
100
|
+
suggestion = closest_column(key, entry.column_names)
|
|
101
|
+
hint = suggestion ? " (did you mean `:#{suggestion}`?)" : ""
|
|
102
|
+
push_error(node, "unknown-column",
|
|
103
|
+
"`#{entry.class_name}.#{node.name}(#{key}: ...)` references " \
|
|
104
|
+
"unknown column `#{key}` on table `#{entry.table_name}`#{hint}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ActiveRecord's `where` / `find_by` / `find_or_initialize_by`
|
|
110
|
+
# accept either a column name *or* a singular association
|
|
111
|
+
# name (belongs_to / has_one). The latter resolves to the
|
|
112
|
+
# association's FK column behind the scenes:
|
|
113
|
+
#
|
|
114
|
+
# AccountPin.find_by(account: x)
|
|
115
|
+
# # → SELECT … WHERE account_id = x.id
|
|
116
|
+
#
|
|
117
|
+
# Without this allowance, every `find_by(<assoc>:)` call
|
|
118
|
+
# surfaces as an `unknown-column` false positive — Mastodon
|
|
119
|
+
# saw ~100 such hits across the API controllers, all of
|
|
120
|
+
# which were the canonical Rails idiom rather than typos.
|
|
121
|
+
#
|
|
122
|
+
# `alias_attribute` names resolve to their target before
|
|
123
|
+
# the column / association check — querying by an aliased
|
|
124
|
+
# attribute is a valid Rails idiom and must not surface as
|
|
125
|
+
# an `unknown-column`.
|
|
126
|
+
def valid_query_key?(entry, key)
|
|
127
|
+
key = entry.resolve_alias(key) if entry.alias?(key)
|
|
128
|
+
|
|
129
|
+
return true if entry.column?(key)
|
|
130
|
+
|
|
131
|
+
assoc = entry.association(key)
|
|
132
|
+
assoc && assoc[:kind] == :singular
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# When the column is an enum-bearing column AND the
|
|
136
|
+
# passed value is a Symbol literal, the value MUST be
|
|
137
|
+
# one of the declared enum values. Non-Symbol values
|
|
138
|
+
# (variables, expressions) decline so dynamic call
|
|
139
|
+
# sites stay silent. Sequences (`status: [:active,
|
|
140
|
+
# :archived]`) are intentionally NOT walked here —
|
|
141
|
+
# the relation-shape check belongs in a future track.
|
|
142
|
+
def validate_enum_value(node, entry, pair)
|
|
143
|
+
return unless entry.enum?(pair[:key])
|
|
144
|
+
return unless pair[:value].is_a?(Prism::SymbolNode)
|
|
145
|
+
|
|
146
|
+
value = pair[:value].unescaped
|
|
147
|
+
values = entry.enum_values(pair[:key])
|
|
148
|
+
return if values.include?(value)
|
|
149
|
+
|
|
150
|
+
suggestion = closest_column(value, values)
|
|
151
|
+
hint = suggestion ? " (did you mean `:#{suggestion}`?)" : ""
|
|
152
|
+
push_error(node, "unknown-enum-value",
|
|
153
|
+
"`#{entry.class_name}.#{node.name}(#{pair[:key]}: :#{value})` references " \
|
|
154
|
+
"unknown enum value `:#{value}` (declared: #{values.map { |v| ":#{v}" }.join(', ')})#{hint}")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def push_recognised(node, entry, keys = nil)
|
|
158
|
+
msg = "`#{entry.class_name}.#{node.name}`"
|
|
159
|
+
msg += " (#{keys.map { |k| ":#{k}" }.join(', ')})" if keys && !keys.empty?
|
|
160
|
+
msg += " on table `#{entry.table_name}`"
|
|
161
|
+
push_info(node, "model-call", msg)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def constant_receiver_name(node)
|
|
165
|
+
case node
|
|
166
|
+
when Prism::ConstantReadNode
|
|
167
|
+
node.name.to_s
|
|
168
|
+
when Prism::ConstantPathNode
|
|
169
|
+
constant_path_name(node)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def constant_path_name(node)
|
|
174
|
+
parts = []
|
|
175
|
+
current = node
|
|
176
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
177
|
+
parts.unshift(current.name.to_s)
|
|
178
|
+
current = current.parent
|
|
179
|
+
end
|
|
180
|
+
case current
|
|
181
|
+
when nil
|
|
182
|
+
"::#{parts.join('::')}"
|
|
183
|
+
when Prism::ConstantReadNode
|
|
184
|
+
"#{current.name}::#{parts.join('::')}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def call_argument_count(node)
|
|
189
|
+
return 0 if node.arguments.nil?
|
|
190
|
+
|
|
191
|
+
node.arguments.arguments.size
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Returns the symbol-keyed pairs of any KeywordHashNode
|
|
195
|
+
# argument as `{ key:, value: }` rows. Plain hash-literal
|
|
196
|
+
# arguments (`Model.where({a: 1})`) are NOT walked —
|
|
197
|
+
# Rails accepts both, but the keyword form is the
|
|
198
|
+
# idiomatic one. The `value` Prism node is preserved so
|
|
199
|
+
# the enum check downstream can recognise Symbol
|
|
200
|
+
# literals without re-walking the call.
|
|
201
|
+
def keyword_argument_pairs(node)
|
|
202
|
+
return [] if node.arguments.nil?
|
|
203
|
+
|
|
204
|
+
pairs = []
|
|
205
|
+
node.arguments.arguments.each do |arg|
|
|
206
|
+
next unless arg.is_a?(Prism::KeywordHashNode)
|
|
207
|
+
|
|
208
|
+
arg.elements.each do |pair|
|
|
209
|
+
next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
210
|
+
|
|
211
|
+
pairs << { key: pair.key.unescaped, value: pair.value }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
pairs
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def closest_column(name, candidates)
|
|
218
|
+
best = nil
|
|
219
|
+
best_distance = DID_YOU_MEAN_DISTANCE + 1
|
|
220
|
+
candidates.each do |candidate|
|
|
221
|
+
distance = levenshtein(name, candidate)
|
|
222
|
+
if distance < best_distance
|
|
223
|
+
best = candidate
|
|
224
|
+
best_distance = distance
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
best
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def levenshtein(a, b) # rubocop:disable Naming/MethodParameterName
|
|
231
|
+
return b.length if a.empty?
|
|
232
|
+
return a.length if b.empty?
|
|
233
|
+
|
|
234
|
+
rows = Array.new(a.length + 1) { |_i| Array.new(b.length + 1, 0) }
|
|
235
|
+
(0..a.length).each { |i| rows[i][0] = i }
|
|
236
|
+
(0..b.length).each { |j| rows[0][j] = j }
|
|
237
|
+
|
|
238
|
+
(1..a.length).each do |i|
|
|
239
|
+
(1..b.length).each do |j|
|
|
240
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
241
|
+
rows[i][j] = [
|
|
242
|
+
rows[i - 1][j] + 1,
|
|
243
|
+
rows[i][j - 1] + 1,
|
|
244
|
+
rows[i - 1][j - 1] + cost
|
|
245
|
+
].min
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
rows[a.length][b.length]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def push_info(node, rule, message)
|
|
252
|
+
push_diagnostic(node, severity: :info, rule: rule, message: message)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def push_error(node, rule, message)
|
|
256
|
+
push_diagnostic(node, severity: :error, rule: rule, message: message)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def push_diagnostic(node, severity:, rule:, message:)
|
|
260
|
+
location = node.location
|
|
261
|
+
@diagnostics << Rigor::Analysis::Diagnostic.new(
|
|
262
|
+
path: @path,
|
|
263
|
+
line: location.start_line,
|
|
264
|
+
column: location.start_column + 1,
|
|
265
|
+
message: message,
|
|
266
|
+
severity: severity,
|
|
267
|
+
rule: rule
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|