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