rigortype 0.1.9 → 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.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Sidekiq < Rigor::Plugin::Base
8
+ # Walks a parsed file's AST looking for
9
+ # `<WorkerClass>.perform_async(...)` /
10
+ # `.perform_inline(...)` / `.perform_in(time, ...)` /
11
+ # `.perform_at(time, ...)` calls and validates each
12
+ # against the {WorkerIndex}.
13
+ #
14
+ # Argument-shape rules:
15
+ #
16
+ # - `perform_async` / `perform_inline` — every
17
+ # argument is forwarded to `#perform`. Validate
18
+ # `actual == #perform.arity`.
19
+ # - `perform_in(interval, ...args)` /
20
+ # `perform_at(time, ...args)` — the FIRST argument
21
+ # is the schedule (a Time / Integer / ActiveSupport
22
+ # duration); the rest are forwarded to `#perform`.
23
+ # Validate `actual_args - 1 == #perform.arity`.
24
+ module Analyzer
25
+ # Methods that delegate to `#perform` 1:1.
26
+ DIRECT_ENTRY_METHODS = %i[perform_async perform_inline].freeze
27
+
28
+ # Methods whose first argument is a schedule (the
29
+ # remaining args are forwarded to `#perform`).
30
+ SCHEDULED_ENTRY_METHODS = %i[perform_in perform_at].freeze
31
+
32
+ ENTRY_METHODS = (DIRECT_ENTRY_METHODS + SCHEDULED_ENTRY_METHODS).freeze
33
+
34
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
35
+
36
+ module_function
37
+
38
+ # @param path [String]
39
+ # @param root [Prism::Node]
40
+ # @param worker_index [WorkerIndex]
41
+ # @return [Array<Diagnostic>]
42
+ def diagnose(path:, root:, worker_index:)
43
+ diagnostics = []
44
+ walk(root) do |call_node|
45
+ class_name = constant_receiver_name(call_node.receiver)
46
+ next if class_name.nil?
47
+
48
+ entry = worker_index.find(class_name) || worker_index.find("::#{class_name}")
49
+ next if entry.nil?
50
+
51
+ diagnostics << info_diagnostic(path, call_node, entry)
52
+ arity_diag = arity_check(path, call_node, entry)
53
+ diagnostics << arity_diag if arity_diag
54
+ end
55
+ diagnostics
56
+ end
57
+
58
+ def walk(node, &)
59
+ return unless node.is_a?(Prism::Node)
60
+
61
+ yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
62
+ node.compact_child_nodes.each { |child| walk(child, &) }
63
+ end
64
+
65
+ def entry_call?(node)
66
+ ENTRY_METHODS.include?(node.name) &&
67
+ (node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
68
+ end
69
+
70
+ def info_diagnostic(path, call_node, entry)
71
+ location = call_node.location
72
+ Diagnostic.new(
73
+ path: path,
74
+ line: location.start_line,
75
+ column: location.start_column + 1,
76
+ severity: :info,
77
+ rule: "worker-call",
78
+ message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` " \
79
+ "(arity #{entry.arity_label})"
80
+ )
81
+ end
82
+
83
+ def arity_check(path, call_node, entry)
84
+ all_args = (call_node.arguments&.arguments || []).size
85
+ # Scheduled entries consume the first arg as the
86
+ # schedule; the rest are forwarded.
87
+ forwarded_count = SCHEDULED_ENTRY_METHODS.include?(call_node.name) ? all_args - 1 : all_args
88
+
89
+ if SCHEDULED_ENTRY_METHODS.include?(call_node.name) && all_args.zero?
90
+ return missing_schedule_diagnostic(path, call_node, entry)
91
+ end
92
+
93
+ return nil if forwarded_count.negative?
94
+ return nil if entry.accepts?(forwarded_count)
95
+
96
+ location = call_node.location
97
+ Diagnostic.new(
98
+ path: path,
99
+ line: location.start_line,
100
+ column: location.start_column + 1,
101
+ severity: :error,
102
+ rule: "wrong-arity",
103
+ message: "`#{entry.class_name}.#{call_node.name}` expects " \
104
+ "#{describe_expected(entry, call_node.name)} forwarded to `#perform` " \
105
+ "(arity #{entry.arity_label}), got #{forwarded_count}"
106
+ )
107
+ end
108
+
109
+ def missing_schedule_diagnostic(path, call_node, entry)
110
+ location = call_node.location
111
+ Diagnostic.new(
112
+ path: path,
113
+ line: location.start_line,
114
+ column: location.start_column + 1,
115
+ severity: :error,
116
+ rule: "missing-schedule",
117
+ message: "`#{entry.class_name}.#{call_node.name}` requires a schedule " \
118
+ "(time / interval) as its first argument, got 0 arguments"
119
+ )
120
+ end
121
+
122
+ def describe_expected(entry, method_name)
123
+ if SCHEDULED_ENTRY_METHODS.include?(method_name)
124
+ "#{entry.arity_label} argument(s) (after the schedule)"
125
+ else
126
+ "#{entry.arity_label} argument(s)"
127
+ end
128
+ end
129
+
130
+ def constant_receiver_name(node)
131
+ case node
132
+ when Prism::ConstantReadNode then node.name.to_s
133
+ when Prism::ConstantPathNode then constant_path_name(node)
134
+ end
135
+ end
136
+
137
+ def constant_path_name(node)
138
+ parts = []
139
+ current = node
140
+ while current.is_a?(Prism::ConstantPathNode)
141
+ parts.unshift(current.name.to_s)
142
+ current = current.parent
143
+ end
144
+ case current
145
+ when nil then "::#{parts.join('::')}"
146
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "worker_index"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Sidekiq < Rigor::Plugin::Base
10
+ # Walks the configured worker-search paths via the
11
+ # plugin's `IoBoundary`, parses each `.rb` file with
12
+ # Prism, and collects classes that `include
13
+ # Sidekiq::Job` (or one of the configured marker
14
+ # modules). For each discovered class, the discoverer
15
+ # also reads the `#perform` method's parameter list
16
+ # and computes the arity envelope.
17
+ #
18
+ # Limitations (intentional for v0.1.0):
19
+ #
20
+ # - Only direct `include` matches against the
21
+ # configured marker modules. `class MyWorker;
22
+ # include Concerns::Sidekiqable; end` where
23
+ # `Concerns::Sidekiqable` re-includes `Sidekiq::Job`
24
+ # is NOT discovered. Add the intermediate module to
25
+ # `worker_marker_modules` if needed.
26
+ # - The qualified class name is the lexical path
27
+ # (`Admin::WelcomeWorker` for a class declared
28
+ # inside `module Admin`).
29
+ # - `#perform` arity is read from the syntactic
30
+ # parameter list. Methods built via
31
+ # `define_method` are out of scope.
32
+ class WorkerDiscoverer
33
+ def initialize(io_boundary:, search_paths:, marker_modules:)
34
+ @io_boundary = io_boundary
35
+ @search_paths = search_paths
36
+ @marker_modules = marker_modules.to_set
37
+ end
38
+
39
+ # @return [WorkerIndex]
40
+ def discover
41
+ entries = []
42
+ ruby_files_under(@search_paths).each do |path|
43
+ contents = read_safely(path)
44
+ next if contents.nil?
45
+
46
+ tree = Prism.parse(contents).value
47
+ walk_for_workers(tree, []) do |class_name, perform_def|
48
+ entries << build_entry(class_name, perform_def)
49
+ end
50
+ end
51
+ WorkerIndex.new(entries)
52
+ end
53
+
54
+ private
55
+
56
+ def read_safely(path)
57
+ @io_boundary.read_file(path)
58
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
59
+ nil
60
+ end
61
+
62
+ def ruby_files_under(roots)
63
+ roots.flat_map do |root|
64
+ absolute = File.expand_path(root)
65
+ next [] unless File.directory?(absolute)
66
+
67
+ Dir.glob(File.join(absolute, "**", "*.rb"))
68
+ end
69
+ end
70
+
71
+ def walk_for_workers(node, lexical_path, &)
72
+ return if node.nil?
73
+
74
+ case node
75
+ when Prism::ClassNode then visit_class(node, lexical_path, &)
76
+ when Prism::ModuleNode then visit_module(node, lexical_path, &)
77
+ else
78
+ node.compact_child_nodes.each { |child| walk_for_workers(child, lexical_path, &) }
79
+ end
80
+ end
81
+
82
+ def visit_class(node, lexical_path, &)
83
+ class_local_name = constant_path_name(node.constant_path)
84
+ return if class_local_name.nil?
85
+
86
+ full_name = (lexical_path + [class_local_name]).join("::")
87
+ if includes_marker_module?(node.body)
88
+ perform_def = lookup_perform_def(node.body)
89
+ yield full_name, perform_def
90
+ end
91
+
92
+ inner_path = lexical_path + [class_local_name]
93
+ walk_for_workers(node.body, inner_path, &) if node.body
94
+ end
95
+
96
+ def visit_module(node, lexical_path, &)
97
+ module_local_name = constant_path_name(node.constant_path)
98
+ return if module_local_name.nil?
99
+
100
+ inner_path = lexical_path + [module_local_name]
101
+ walk_for_workers(node.body, inner_path, &) if node.body
102
+ end
103
+
104
+ # Returns true if the class body contains a top-level
105
+ # `include <Module>` call where `<Module>` matches
106
+ # one of the configured marker modules.
107
+ def includes_marker_module?(body)
108
+ return false if body.nil?
109
+
110
+ body.compact_child_nodes.any? do |node|
111
+ next false unless node.is_a?(Prism::CallNode)
112
+ next false unless node.name == :include
113
+ next false unless node.receiver.nil?
114
+
115
+ arg = node.arguments&.arguments&.first
116
+ module_name = constant_path_name(arg)
117
+ module_name && @marker_modules.include?(module_name)
118
+ end
119
+ end
120
+
121
+ def constant_path_name(node)
122
+ return nil if node.nil?
123
+
124
+ case node
125
+ when Prism::ConstantReadNode then node.name.to_s
126
+ when Prism::ConstantPathNode
127
+ parts = []
128
+ current = node
129
+ while current.is_a?(Prism::ConstantPathNode)
130
+ parts.unshift(current.name.to_s)
131
+ current = current.parent
132
+ end
133
+ case current
134
+ when nil then "::#{parts.join('::')}"
135
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
136
+ end
137
+ end
138
+ end
139
+
140
+ # Returns the instance-side `def perform(...)` node
141
+ # from a class body, or `nil` when the class doesn't
142
+ # override `#perform`.
143
+ def lookup_perform_def(body)
144
+ return nil if body.nil?
145
+
146
+ body.compact_child_nodes.each do |node|
147
+ next unless node.is_a?(Prism::DefNode) && node.name == :perform
148
+ next if node.receiver.is_a?(Prism::SelfNode)
149
+
150
+ return node
151
+ end
152
+ nil
153
+ end
154
+
155
+ # Builds a `WorkerIndex::Entry` from the discovered
156
+ # class's `#perform` def. When the class doesn't
157
+ # override `#perform`, we record an "any-arity"
158
+ # entry — Sidekiq itself doesn't supply a default
159
+ # `#perform`, so calling `perform_async` on a
160
+ # worker without one is the user's bug, not the
161
+ # plugin's call to flag without runtime context.
162
+ def build_entry(class_name, perform_def)
163
+ if perform_def.nil?
164
+ return WorkerIndex::Entry.new(
165
+ class_name: class_name, min_arity: 0,
166
+ max_arity: Float::INFINITY
167
+ )
168
+ end
169
+
170
+ parameters = perform_def.parameters
171
+ if parameters.nil?
172
+ return WorkerIndex::Entry.new(
173
+ class_name: class_name, min_arity: 0, max_arity: 0
174
+ )
175
+ end
176
+
177
+ required_count = (parameters.requireds || []).size
178
+ optional_count = (parameters.optionals || []).size
179
+ rest_present = !parameters.rest.nil?
180
+
181
+ WorkerIndex::Entry.new(
182
+ class_name: class_name,
183
+ min_arity: required_count,
184
+ max_arity: rest_present ? Float::INFINITY : required_count + optional_count
185
+ )
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Sidekiq < Rigor::Plugin::Base
6
+ # Frozen catalogue of discovered Sidekiq worker
7
+ # classes keyed by qualified class name. Each entry
8
+ # holds the `#perform` method's arity envelope so the
9
+ # analyzer can validate `Worker.perform_async(...)`
10
+ # call sites.
11
+ #
12
+ # Same envelope shape as `rigor-activejob`'s
13
+ # `JobIndex::Entry`: `min_arity` / `max_arity` form a
14
+ # closed range (`Float::INFINITY` for the upper bound
15
+ # when `*args` is present).
16
+ class WorkerIndex
17
+ Entry = Data.define(:class_name, :min_arity, :max_arity) do
18
+ def arity_label
19
+ return "#{min_arity}+" if max_arity == Float::INFINITY
20
+ return min_arity.to_s if min_arity == max_arity
21
+
22
+ "#{min_arity}..#{max_arity}"
23
+ end
24
+
25
+ def accepts?(actual)
26
+ actual.between?(min_arity, max_arity)
27
+ end
28
+ end
29
+
30
+ attr_reader :entries
31
+
32
+ def initialize(entries)
33
+ @entries = entries.freeze
34
+ @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
35
+ freeze
36
+ end
37
+
38
+ # @return [Entry, nil]
39
+ def find(class_name)
40
+ @by_name[class_name.to_s]
41
+ end
42
+
43
+ def known?(class_name)
44
+ @by_name.key?(class_name.to_s)
45
+ end
46
+
47
+ def empty?
48
+ @entries.empty?
49
+ end
50
+
51
+ def size
52
+ @entries.size
53
+ end
54
+
55
+ def names
56
+ @by_name.keys
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "sidekiq/worker_index"
6
+ require_relative "sidekiq/worker_discoverer"
7
+ require_relative "sidekiq/analyzer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-sidekiq — validates `Worker.perform_async(...)`
12
+ # / `.perform_in(...)` / `.perform_at(...)` /
13
+ # `.perform_inline(...)` argument arity against the
14
+ # discovered `#perform` definitions.
15
+ #
16
+ # Tier 3C of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
17
+ # Statically discovers Sidekiq workers by walking
18
+ # `worker_search_paths` and parsing each file with
19
+ # Prism — no `sidekiq` runtime dependency.
20
+ #
21
+ # ## Configuration
22
+ #
23
+ # plugins:
24
+ # - gem: rigor-sidekiq
25
+ # config:
26
+ # worker_search_paths: ["app/workers", "app/sidekiq"] # default; optional
27
+ # worker_marker_modules: ["Sidekiq::Job", "Sidekiq::Worker"] # default; optional
28
+ #
29
+ # ## What it checks
30
+ #
31
+ # 1. **Argument arity** — `perform_async(args)` /
32
+ # `perform_inline(args)` forward every argument to
33
+ # `#perform`; `perform_in(t, args)` /
34
+ # `perform_at(t, args)` consume the first argument
35
+ # as the schedule and forward the rest. Mismatches
36
+ # emit `wrong-arity`.
37
+ # 2. **Missing schedule** — `perform_in()` /
38
+ # `perform_at()` with zero arguments emit
39
+ # `missing-schedule`.
40
+ #
41
+ # ## Limitations (v0.1.0)
42
+ #
43
+ # - Direct `include` matches only against the
44
+ # configured marker modules. Indirect includes via a
45
+ # custom concern are out of scope.
46
+ # - `#perform` arity is read from the syntactic
47
+ # parameter list. `define_method` actions are out of
48
+ # scope.
49
+ # - Required keyword arguments are not validated at
50
+ # the call site (positional-only for v0.1.0). Sidekiq
51
+ # serialises arguments to JSON, so keyword args are
52
+ # uncommon in practice.
53
+ # - The schedule argument's type isn't validated (no
54
+ # "is this a Time?" check); we just consume it.
55
+ class Sidekiq < Rigor::Plugin::Base
56
+ manifest(
57
+ id: "sidekiq",
58
+ version: "0.1.0",
59
+ description: "Validates Sidekiq `Worker.perform_async` argument arity.",
60
+ config_schema: {
61
+ "worker_search_paths" => :array,
62
+ "worker_marker_modules" => :array
63
+ }
64
+ )
65
+
66
+ DEFAULT_WORKER_SEARCH_PATHS = ["app/workers", "app/sidekiq"].freeze
67
+ DEFAULT_WORKER_MARKER_MODULES = %w[Sidekiq::Job Sidekiq::Worker].freeze
68
+
69
+ producer :worker_index do |_params|
70
+ WorkerDiscoverer.new(
71
+ io_boundary: io_boundary,
72
+ search_paths: @worker_search_paths,
73
+ marker_modules: @worker_marker_modules
74
+ ).discover
75
+ end
76
+
77
+ def init(_services)
78
+ @worker_search_paths = Array(config.fetch("worker_search_paths", DEFAULT_WORKER_SEARCH_PATHS)).map(&:to_s)
79
+ @worker_marker_modules = Array(
80
+ config.fetch("worker_marker_modules", DEFAULT_WORKER_MARKER_MODULES)
81
+ ).map(&:to_s)
82
+ @worker_index = nil
83
+ @load_error = nil
84
+ end
85
+
86
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
87
+ index = worker_index_or_nil
88
+ return [load_error_diagnostic(path)] if index.nil? && @load_error
89
+ return [] if index.nil? || index.empty?
90
+
91
+ Analyzer.diagnose(path: path, root: root, worker_index: index).map { |diag| build_diagnostic(diag) }
92
+ end
93
+
94
+ private
95
+
96
+ def worker_index_or_nil
97
+ return @worker_index if @worker_index
98
+
99
+ @worker_index = cache_for(:worker_index, params: {}).call
100
+ rescue StandardError => e
101
+ @load_error = "rigor-sidekiq: failed to discover workers: #{e.class}: #{e.message}"
102
+ nil
103
+ end
104
+
105
+ def load_error_diagnostic(path)
106
+ Rigor::Analysis::Diagnostic.new(
107
+ path: path, line: 1, column: 1,
108
+ message: @load_error,
109
+ severity: :warning,
110
+ rule: "load-error"
111
+ )
112
+ end
113
+
114
+ def build_diagnostic(diag)
115
+ Rigor::Analysis::Diagnostic.new(
116
+ path: diag.path, line: diag.line, column: diag.column,
117
+ message: diag.message, severity: diag.severity, rule: diag.rule
118
+ )
119
+ end
120
+ end
121
+
122
+ Rigor::Plugin.register(Sidekiq)
123
+ end
124
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/sidekiq"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # ADR-16 Tier A worked plugin: recognises Sinatra's class-
8
+ # level route DSL.
9
+ #
10
+ # Sinatra's modular style:
11
+ #
12
+ # class MyApp < Sinatra::Base
13
+ # get "/hello" do
14
+ # "Hello #{params['name']}"
15
+ # end
16
+ #
17
+ # post "/bye" do
18
+ # halt 403 if params["forbidden"]
19
+ # redirect "/landing"
20
+ # end
21
+ # end
22
+ #
23
+ # At runtime `Sinatra::Base#generate_method`
24
+ # (`lib/sinatra/base.rb:1788-1793`) does `define_method(name,
25
+ # &block); remove_method`, turning each block into a real
26
+ # instance method of the user's app class. The substrate's
27
+ # Tier A hook (`Rigor::Inference::MacroBlockSelfType`)
28
+ # replays the same contract statically: the block runs with
29
+ # `self : Nominal[MyApp]`, so bare identifiers (`params`,
30
+ # `redirect`, `halt`, `session`, `headers`, `content_type`,
31
+ # `body`, `status`, `erb`, …) resolve through
32
+ # `Sinatra::Base`'s RBS via rigor's normal inference path.
33
+ #
34
+ # ## Reach
35
+ #
36
+ # All nine class-level HTTP verb methods Sinatra exposes:
37
+ # `get`, `post`, `put`, `delete`, `head`, `options`, `patch`,
38
+ # `link`, `unlink` (`lib/sinatra/base.rb:1531-1553`). Both
39
+ # modular-style subclasses of `Sinatra::Base` and `Sinatra::Application`
40
+ # (classic top-level style, when used via `class App <
41
+ # Sinatra::Application`) match because the receiver
42
+ # constraint accepts every subclass.
43
+ #
44
+ # ## What the plugin does NOT do (yet)
45
+ #
46
+ # - **Routing diagnostics.** Path uniqueness, conflict
47
+ # detection, named-route reverse lookup — none of these
48
+ # are in slice 1c's scope.
49
+ # - **Custom helpers.** `helpers do ... end` blocks that
50
+ # inject module methods into the app's instance namespace
51
+ # are Tier C / Tier B work, not Tier A.
52
+ # - **Configure / settings.** `configure do ... end` and
53
+ # `set :session_secret, "..."` are settings DSL, not
54
+ # route DSL — handled by separate substrate entries when
55
+ # demand surfaces.
56
+ # - **Classic-style top-level routes.** A bare
57
+ # `get '/path' do ... end` at the top of a script (no
58
+ # enclosing `class < Sinatra::Base`) is the classic-mode
59
+ # pattern (`lib/sinatra/main.rb`). Tier A as currently
60
+ # wired requires the receiver's class to be visible at
61
+ # the call site; a top-level call's receiver is the
62
+ # classic-mode `Sinatra::Application`, which the
63
+ # `Sinatra::Delegator` mixin forwards from `main`. The
64
+ # classic style is deferred until the demand justifies
65
+ # the extra match shape.
66
+ #
67
+ # See `plugins/rigor-sinatra/README.md` for usage and the
68
+ # demo script under `plugins/rigor-sinatra/demo/`.
69
+ class Sinatra < Rigor::Plugin::Base
70
+ manifest(
71
+ id: "sinatra",
72
+ version: "0.1.0",
73
+ description: "Recognises Sinatra's class-level route DSL via ADR-16 Tier A.",
74
+ block_as_methods: [
75
+ Rigor::Plugin::Macro::BlockAsMethod.new(
76
+ receiver_constraint: "Sinatra::Base",
77
+ verbs: %i[get post put delete head options patch link unlink]
78
+ )
79
+ ]
80
+ )
81
+ end
82
+
83
+ Rigor::Plugin.register(Sinatra)
84
+ end
85
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when
4
+ # `.rigor.yml` lists `rigor-sinatra` under `plugins:`. The
5
+ # loader expects this `require` to side-effect a call to
6
+ # `Rigor::Plugin.register`, which the body of
7
+ # `lib/rigor/plugin/sinatra.rb` performs at load time.
8
+ require_relative "rigor/plugin/sinatra"