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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/activejob"