rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -68,22 +68,22 @@ module Rigor
68
68
  class Actionpack < Rigor::Plugin::Base
69
69
  manifest(
70
70
  id: "actionpack",
71
- # Bumped 2026-05-27analyzer-side nested-module
72
- # qualification slice. `diagnose_filters` and
73
- # `diagnose_renders` now thread the enclosing
74
- # namespace through the AST walk so a
75
- # `module Admin; class DomainBlocksController; end`
76
- # file resolves as `Admin::DomainBlocksController`
77
- # matching the qualification the
78
- # `ControllerDiscoverer` already records. Fixes render
79
- # paths (`admin/domain_blocks/new` not bare
80
- # `domain_blocks/new`) and filter-chain validation
81
- # silently skipping nested controllers.
82
- version: "0.7.0",
71
+ # Bumped 2026-06-02ADR-37 node_rule migration. The four
72
+ # phases (helper / filter / render / strong-params) now run
73
+ # per-call over the engine-owned walk instead of the
74
+ # hand-rolled `diagnostics_for_file` traversal; the enclosing
75
+ # controller is read from the node-rule `NodeContext` ancestors.
76
+ # Nested-module qualification is preserved — a
77
+ # `module Admin; class DomainBlocksController; end` file still
78
+ # resolves as `Admin::DomainBlocksController` (matching the
79
+ # `ControllerDiscoverer`), so render paths
80
+ # (`admin/domain_blocks/new`) and filter-chain validation on
81
+ # nested controllers are unchanged.
82
+ version: "0.8.0",
83
83
  description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
84
84
  config_schema: {
85
- "controller_search_paths" => :array,
86
- "view_search_paths" => :array
85
+ "controller_search_paths" => { kind: :array, default: ["app/controllers"] },
86
+ "view_search_paths" => { kind: :array, default: ["app/views"] }
87
87
  },
88
88
  consumes: [
89
89
  { plugin_id: "rails-routes", name: :helper_table, optional: true },
@@ -91,9 +91,6 @@ module Rigor
91
91
  ]
92
92
  )
93
93
 
94
- DEFAULT_CONTROLLER_SEARCH_PATHS = ["app/controllers"].freeze
95
- DEFAULT_VIEW_SEARCH_PATHS = ["app/views"].freeze
96
-
97
94
  # Phase 2 cached producer — the controller index built
98
95
  # from `controller_search_paths`. The IoBoundary records
99
96
  # a `FileEntry` digest for every file the discoverer
@@ -108,12 +105,8 @@ module Rigor
108
105
 
109
106
  def init(services)
110
107
  @services = services
111
- @controller_search_paths = Array(
112
- config.fetch("controller_search_paths", DEFAULT_CONTROLLER_SEARCH_PATHS)
113
- ).map(&:to_s)
114
- @view_search_paths = Array(
115
- config.fetch("view_search_paths", DEFAULT_VIEW_SEARCH_PATHS)
116
- ).map(&:to_s)
108
+ @controller_search_paths = Array(config.fetch("controller_search_paths")).map(&:to_s)
109
+ @view_search_paths = Array(config.fetch("view_search_paths")).map(&:to_s)
117
110
  @helper_table = nil
118
111
  @helper_table_resolved = false
119
112
  @controller_index = nil
@@ -121,63 +114,75 @@ module Rigor
121
114
  @model_index_resolved = false
122
115
  end
123
116
 
124
- def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
125
- return [] unless controller_file?(path)
126
-
127
- helper_diagnostics(path, root) +
128
- filter_diagnostics(path, root) +
129
- render_diagnostics(path, root) +
130
- permit_diagnostics(path, root)
131
- end
117
+ # ADR-37 the four Action Pack phases run per-call over the
118
+ # engine-owned walk. Each rule gates on `controller_file?(path)` (the
119
+ # plugin only validates files under `controller_search_paths`,
120
+ # exactly as the former `diagnostics_for_file` top-level guard did),
121
+ # then delegates to a per-node `Analyzer.*_violations_for` and
122
+ # positions each location-free `Violation` with `Base#diagnostic`.
123
+ # The filter / render phases read the enclosing controller from the
124
+ # node-rule `NodeContext` ancestors (its fifth block argument).
132
125
 
133
- private
126
+ # Phase 4 — route-helper consumption.
127
+ node_rule Prism::CallNode do |node, _scope, path|
128
+ next [] unless controller_file?(path)
134
129
 
135
- def helper_diagnostics(path, root)
136
130
  table = helper_table
137
- return [] if table.nil? || table.empty?
131
+ next [] if table.nil? || table.empty?
138
132
 
139
- Analyzer.diagnose(path: path, root: root, helper_table: table)
140
- .map { |diag| build_diagnostic(diag) }
133
+ Analyzer.helper_violations_for(call_node: node, helper_table: table).map do |v|
134
+ diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
135
+ end
141
136
  end
142
137
 
143
- # Phase 2 — runs the filter-chain validator over the
144
- # controller's class body using the cached
145
- # {ControllerIndex}. Skips silently when the index is
146
- # absent or doesn't recognise the file's top-level class.
147
- def filter_diagnostics(path, root)
138
+ # Phase 2 — filter-chain validation. Skips silently when the
139
+ # controller index is absent or doesn't recognise the enclosing
140
+ # class.
141
+ node_rule Prism::CallNode do |node, _scope, path, _fc, context|
142
+ next [] unless controller_file?(path)
143
+
148
144
  index = controller_index_or_nil
149
- return [] if index.nil? || index.empty?
145
+ next [] if index.nil? || index.empty?
150
146
 
151
- Analyzer.diagnose_filters(path: path, root: root, controller_index: index)
152
- .map { |diag| build_diagnostic(diag) }
147
+ Analyzer.filter_violations_for(call_node: node, ancestors: context.ancestors, controller_index: index).map do |v|
148
+ diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
149
+ end
153
150
  end
154
151
 
155
- # Phase 3 — runs the render-target validator against the
156
- # configured `view_search_paths`. Always invoked
157
- # regardless of whether the controller is in the index;
158
- # render shapes are recognised purely from the call site
159
- # + class name, no per-controller pre-discovery needed.
160
- def render_diagnostics(path, root)
161
- Analyzer.diagnose_renders(
162
- path: path, root: root,
163
- view_search_roots: @view_search_paths,
164
- controller_index: controller_index_or_nil
165
- ).map { |diag| build_diagnostic(diag) }
152
+ # Phase 3 — render-target validation against the configured
153
+ # `view_search_paths`. Recognised purely from the call site + the
154
+ # enclosing controller name, so no per-controller pre-discovery is
155
+ # needed; the controller index is consulted only to suppress
156
+ # gem-shipped-view false positives.
157
+ node_rule Prism::CallNode do |node, _scope, path, _fc, context|
158
+ next [] unless controller_file?(path)
159
+
160
+ Analyzer.render_violations_for(
161
+ call_node: node, ancestors: context.ancestors, path: path,
162
+ view_search_roots: @view_search_paths, controller_index: controller_index_or_nil
163
+ ).map do |v|
164
+ diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
165
+ end
166
166
  end
167
167
 
168
- # Phase 1 — strong-parameter validation. Reads the
169
- # `:model_index` fact from the cross-plugin fact store
170
- # (published by rigor-activerecord) and validates every
171
- # `params.require(:user).permit(:name, :email)` chain
172
- # against the User model's column list.
173
- def permit_diagnostics(path, root)
168
+ # Phase 1 — strong-parameter validation. Reads the `:model_index`
169
+ # fact from the cross-plugin fact store (published by
170
+ # rigor-activerecord) and validates every
171
+ # `params.require(:user).permit(:name, :email)` chain against the
172
+ # User model's column list.
173
+ node_rule Prism::CallNode do |node, _scope, path|
174
+ next [] unless controller_file?(path)
175
+
174
176
  index = model_index
175
- return [] if index.nil? || index.empty?
177
+ next [] if index.nil? || index.empty?
176
178
 
177
- Analyzer.diagnose_permits(path: path, root: root, model_index: index)
178
- .map { |diag| build_diagnostic(diag) }
179
+ Analyzer.permit_violations_for(call_node: node, model_index: index).map do |v|
180
+ diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
181
+ end
179
182
  end
180
183
 
184
+ private
185
+
181
186
  def controller_index_or_nil
182
187
  return @controller_index if @controller_index
183
188
 
@@ -247,13 +252,6 @@ module Rigor
247
252
  path.include?("/#{root}/") || path.start_with?("#{root}/") || path == root
248
253
  end
249
254
  end
250
-
251
- def build_diagnostic(diag)
252
- Rigor::Analysis::Diagnostic.new(
253
- path: diag.path, line: diag.line, column: diag.column,
254
- message: diag.message, severity: diag.severity, rule: diag.rule
255
- )
256
- end
257
255
  end
258
256
 
259
257
  Rigor::Plugin.register(Actionpack)
@@ -23,35 +23,33 @@ module Rigor
23
23
  # the bare execution path.
24
24
  ENTRY_METHODS = %i[perform_later perform_now perform].freeze
25
25
 
26
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
26
+ # One job-call observation. Carries no path/location the
27
+ # caller (the `node_rule` block) positions it via
28
+ # `Plugin::Base#diagnostic`.
29
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
27
30
 
28
31
  module_function
29
32
 
30
- # @param path [String]
31
- # @param root [Prism::Node]
33
+ # The job-call violations for a single call node (0..2), or `[]`
34
+ # when the node is not a `<Job>.perform_*` entry call on a known
35
+ # job. ADR-37: the engine owns the walk.
36
+ #
37
+ # @param call_node [Prism::Node]
32
38
  # @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
+ # @return [Array<Violation>]
40
+ def violations_for(call_node:, job_index:)
41
+ return [] unless call_node.is_a?(Prism::CallNode) && entry_call?(call_node)
39
42
 
40
- entry = job_index.find(class_name) || job_index.find("::#{class_name}")
41
- next if entry.nil?
43
+ class_name = constant_receiver_name(call_node.receiver)
44
+ return [] if class_name.nil?
42
45
 
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)
46
+ entry = job_index.find(class_name) || job_index.find("::#{class_name}")
47
+ return [] if entry.nil?
52
48
 
53
- yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
54
- node.compact_child_nodes.each { |child| walk(child, &) }
49
+ violations = [info_violation(call_node, entry)]
50
+ arity = arity_violation(call_node, entry)
51
+ violations << arity if arity
52
+ violations
55
53
  end
56
54
 
57
55
  def entry_call?(node)
@@ -59,27 +57,19 @@ module Rigor
59
57
  (node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
60
58
  end
61
59
 
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,
60
+ def info_violation(call_node, entry)
61
+ Violation.new(
68
62
  severity: :info,
69
63
  rule: "job-call",
70
64
  message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` (arity #{entry.arity_label})"
71
65
  )
72
66
  end
73
67
 
74
- def arity_check(path, call_node, entry)
68
+ def arity_violation(call_node, entry)
75
69
  actual = (call_node.arguments&.arguments || []).size
76
70
  return nil if entry.accepts?(actual)
77
71
 
78
- location = call_node.location
79
- Diagnostic.new(
80
- path: path,
81
- line: location.start_line,
82
- column: location.start_column + 1,
72
+ Violation.new(
83
73
  severity: :error,
84
74
  rule: "wrong-arity",
85
75
  message: "`#{entry.class_name}.#{call_node.name}` expects #{entry.arity_label} argument(s), got #{actual}"
@@ -43,14 +43,11 @@ module Rigor
43
43
  version: "0.1.0",
44
44
  description: "Validates ActiveJob `Job.perform_later` argument arity.",
45
45
  config_schema: {
46
- "job_search_paths" => :array,
47
- "job_base_classes" => :array
46
+ "job_search_paths" => { kind: :array, default: ["app/jobs"] },
47
+ "job_base_classes" => { kind: :array, default: %w[ApplicationJob ActiveJob::Base] }
48
48
  }
49
49
  )
50
50
 
51
- DEFAULT_JOB_SEARCH_PATHS = ["app/jobs"].freeze
52
- DEFAULT_JOB_BASE_CLASSES = %w[ApplicationJob ActiveJob::Base].freeze
53
-
54
51
  # Cached: discovered job index. The producer reads every
55
52
  # file under `job_search_paths` via the trusted
56
53
  # `IoBoundary`; the descriptor's auto-collected
@@ -65,18 +62,30 @@ module Rigor
65
62
  end
66
63
 
67
64
  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)
65
+ @job_search_paths = Array(config.fetch("job_search_paths")).map(&:to_s)
66
+ @job_base_classes = Array(config.fetch("job_base_classes")).map(&:to_s)
70
67
  @job_index = nil
71
68
  @load_error = nil
72
69
  end
73
70
 
71
+ # File-level only: the load-error emission. Per-call arity
72
+ # validation runs over the engine-owned walk via the node_rule
73
+ # below (ADR-37). The job index is lazily loaded + memoised by
74
+ # job_index_or_nil, shared by both surfaces.
74
75
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
75
76
  index = job_index_or_nil
76
77
  return [load_error_diagnostic(path)] if index.nil? && @load_error
77
- return [] if index.nil? || index.empty?
78
78
 
79
- Analyzer.diagnose(path: path, root: root, job_index: index).map { |diag| build_diagnostic(diag) }
79
+ []
80
+ end
81
+
82
+ node_rule Prism::CallNode do |node, _scope, path|
83
+ index = job_index_or_nil
84
+ next [] if index.nil? || index.empty?
85
+
86
+ Analyzer.violations_for(call_node: node, job_index: index).map do |violation|
87
+ diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
88
+ end
80
89
  end
81
90
 
82
91
  private
@@ -103,13 +112,6 @@ module Rigor
103
112
  rule: "load-error"
104
113
  )
105
114
  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
115
  end
114
116
 
115
117
  Rigor::Plugin.register(Activejob)
@@ -36,8 +36,6 @@ module Rigor
36
36
  create_or_find_by create_or_find_by!
37
37
  ].freeze
38
38
 
39
- DID_YOU_MEAN_DISTANCE = 3
40
-
41
39
  attr_reader :diagnostics
42
40
 
43
41
  def initialize(path:, model_index:)
@@ -107,7 +105,7 @@ module Rigor
107
105
  else
108
106
  unknown.each do |pair|
109
107
  key = pair[:key]
110
- suggestion = closest_column(key, entry.column_names)
108
+ suggestion = Rigor::Plugin::Base.suggest(key, entry.column_names)
111
109
  hint = suggestion ? " (did you mean `:#{suggestion}`?)" : ""
112
110
  push_error(node, "unknown-column",
113
111
  "`#{entry.class_name}.#{node.name}(#{key}: ...)` references " \
@@ -157,7 +155,7 @@ module Rigor
157
155
  values = entry.enum_values(pair[:key])
158
156
  return if values.include?(value)
159
157
 
160
- suggestion = closest_column(value, values)
158
+ suggestion = Rigor::Plugin::Base.suggest(value, values)
161
159
  hint = suggestion ? " (did you mean `:#{suggestion}`?)" : ""
162
160
  push_error(node, "unknown-enum-value",
163
161
  "`#{entry.class_name}.#{node.name}(#{pair[:key]}: :#{value})` references " \
@@ -224,40 +222,6 @@ module Rigor
224
222
  pairs
225
223
  end
226
224
 
227
- def closest_column(name, candidates)
228
- best = nil
229
- best_distance = DID_YOU_MEAN_DISTANCE + 1
230
- candidates.each do |candidate|
231
- distance = levenshtein(name, candidate)
232
- if distance < best_distance
233
- best = candidate
234
- best_distance = distance
235
- end
236
- end
237
- best
238
- end
239
-
240
- def levenshtein(a, b) # rubocop:disable Naming/MethodParameterName
241
- return b.length if a.empty?
242
- return a.length if b.empty?
243
-
244
- rows = Array.new(a.length + 1) { |_i| Array.new(b.length + 1, 0) }
245
- (0..a.length).each { |i| rows[i][0] = i }
246
- (0..b.length).each { |j| rows[0][j] = j }
247
-
248
- (1..a.length).each do |i|
249
- (1..b.length).each do |j|
250
- cost = a[i - 1] == b[j - 1] ? 0 : 1
251
- rows[i][j] = [
252
- rows[i - 1][j] + 1,
253
- rows[i][j - 1] + 1,
254
- rows[i - 1][j - 1] + cost
255
- ].min
256
- end
257
- end
258
- rows[a.length][b.length]
259
- end
260
-
261
225
  def push_info(node, rule, message)
262
226
  push_diagnostic(node, severity: :info, rule: rule, message: message)
263
227
  end
@@ -267,14 +231,8 @@ module Rigor
267
231
  end
268
232
 
269
233
  def push_diagnostic(node, severity:, rule:, message:)
270
- location = node.location
271
- @diagnostics << Rigor::Analysis::Diagnostic.new(
272
- path: @path,
273
- line: location.start_line,
274
- column: location.start_column + 1,
275
- message: message,
276
- severity: severity,
277
- rule: rule
234
+ @diagnostics << Rigor::Analysis::Diagnostic.from_node(
235
+ node, path: @path, message: message, severity: severity, rule: rule
278
236
  )
279
237
  end
280
238
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -312,10 +313,9 @@ module Rigor
312
313
  args = node.arguments&.arguments
313
314
  return nil if args.nil? || args.empty?
314
315
 
315
- name_node = args.first
316
- return nil unless name_node.is_a?(Prism::SymbolNode)
316
+ name = Rigor::Source::Literals.symbol_name(args.first)
317
+ return nil if name.nil?
317
318
 
318
- name = name_node.unescaped
319
319
  polymorphic = POLYMORPHIC_BY_DEFAULT.include?(node.name) ||
320
320
  association_option(args, "polymorphic") == true
321
321
 
@@ -326,7 +326,7 @@ module Rigor
326
326
  if polymorphic
327
327
  target = nil
328
328
  else
329
- target = explicit_class_name(args) || Inflector.classify(name)
329
+ target = explicit_class_name(args) || Rigor::Plugin::Inflector.classify(name)
330
330
  return nil if target.nil? || target.empty?
331
331
  end
332
332
 
@@ -153,7 +153,7 @@ module Rigor
153
153
  override = row[:table_name_override]
154
154
  return override if override
155
155
  end
156
- Inflector.tableize(strip_leading_namespace(chain.first.fetch(:class_name)))
156
+ Rigor::Plugin::Inflector.tableize(strip_leading_namespace(chain.first.fetch(:class_name)))
157
157
  end
158
158
 
159
159
  # Dedups association-style rows by `:name`, keeping the
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "rigor/plugin"
4
4
 
5
- require_relative "activerecord/inflector"
6
5
  require_relative "activerecord/schema_table"
7
6
  require_relative "activerecord/schema_parser"
8
7
  require_relative "activerecord/model_index"
@@ -69,9 +68,9 @@ module Rigor
69
68
  version: "0.5.0",
70
69
  description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
71
70
  config_schema: {
72
- "schema_file" => :string,
73
- "model_search_paths" => :array,
74
- "model_base_classes" => :array
71
+ "schema_file" => { kind: :string, default: "db/schema.rb" },
72
+ "model_search_paths" => { kind: :array, default: ["app/models"] },
73
+ "model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
75
74
  },
76
75
  produces: [:model_index],
77
76
  # ADR-25 — the bundled `ActiveRecord::Relation` RBS, the
@@ -87,10 +86,6 @@ module Rigor
87
86
  open_receivers: ["ActiveRecord::Relation"]
88
87
  )
89
88
 
90
- DEFAULT_SCHEMA_FILE = "db/schema.rb"
91
- DEFAULT_MODEL_SEARCH_PATHS = ["app/models"].freeze
92
- DEFAULT_MODEL_BASE_CLASSES = %w[ApplicationRecord ActiveRecord::Base].freeze
93
-
94
89
  # The class the bundled `sig/active_record/relation.rbs`
95
90
  # describes; `flow_contribution_for` contributes
96
91
  # `ActiveRecord::Relation[Model]` for relation-returning
@@ -117,10 +112,11 @@ module Rigor
117
112
  end
118
113
 
119
114
  def init(_services)
120
- @schema_file = config.fetch("schema_file", DEFAULT_SCHEMA_FILE)
121
- @model_search_paths = Array(config.fetch("model_search_paths", DEFAULT_MODEL_SEARCH_PATHS)).map(&:to_s)
122
- @model_base_classes = Array(config.fetch("model_base_classes", DEFAULT_MODEL_BASE_CLASSES)).map(&:to_s)
115
+ @schema_file = config.fetch("schema_file")
116
+ @model_search_paths = Array(config.fetch("model_search_paths")).map(&:to_s)
117
+ @model_base_classes = Array(config.fetch("model_base_classes")).map(&:to_s)
123
118
  @schema_table = nil
119
+ @schema_load_attempted = false
124
120
  @model_index = nil
125
121
  @load_errors = []
126
122
  end
@@ -555,7 +551,16 @@ module Rigor
555
551
 
556
552
  def schema_table_or_nil
557
553
  return @schema_table if @schema_table
558
-
554
+ # Memoize the *failure*, not just the success: `model_index`
555
+ # (and thus this method) is invoked per AR call site, so a
556
+ # missing / unreadable schema file would otherwise re-attempt
557
+ # the read and append a fresh interpolated error string to
558
+ # `@load_errors` on every call. On a large Rails app that grew
559
+ # `@load_errors` to millions of retained strings (measured: 4.2 M
560
+ # strings / ~1.5 GB on Redmine). One attempt is enough.
561
+ return nil if @schema_load_attempted
562
+
563
+ @schema_load_attempted = true
559
564
  # Same pattern: read schema file via boundary, then call
560
565
  # cache_for so the descriptor includes the file digest.
561
566
  io_boundary.read_file(@schema_file)
@@ -62,14 +62,8 @@ module Rigor
62
62
  end
63
63
 
64
64
  def push_info(node, rule, message)
65
- location = node.location
66
- @diagnostics << Rigor::Analysis::Diagnostic.new(
67
- path: @path,
68
- line: location.start_line,
69
- column: location.start_column + 1,
70
- message: message,
71
- severity: :info,
72
- rule: rule
65
+ @diagnostics << Rigor::Analysis::Diagnostic.from_node(
66
+ node, path: @path, message: message, severity: :info, rule: rule
73
67
  )
74
68
  end
75
69
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -126,13 +127,7 @@ module Rigor
126
127
  end
127
128
 
128
129
  def symbol_literal_arg(node)
129
- args = node.arguments&.arguments
130
- return nil if args.nil? || args.empty?
131
-
132
- first = args.first
133
- return nil unless first.is_a?(Prism::SymbolNode)
134
-
135
- first.unescaped
130
+ Rigor::Source::Literals.symbol_name(node.arguments&.arguments&.first)
136
131
  end
137
132
 
138
133
  def constant_path_name(node)
@@ -48,13 +48,11 @@ module Rigor
48
48
  version: "0.1.0",
49
49
  description: "Types ActiveStorage attachment macros (has_one_attached / has_many_attached) on AR models.",
50
50
  config_schema: {
51
- "model_search_paths" => :array
51
+ "model_search_paths" => { kind: :array, default: ["app/models"] }
52
52
  },
53
53
  consumes: [{ plugin_id: "activerecord", name: :model_index, optional: true }]
54
54
  )
55
55
 
56
- DEFAULT_MODEL_SEARCH_PATHS = ["app/models"].freeze
57
-
58
56
  # Cached: attachment index. Walks every `.rb` file under
59
57
  # `model_search_paths` for `has_*_attached` macros.
60
58
  producer :attachment_index do |_params|
@@ -66,9 +64,7 @@ module Rigor
66
64
  end
67
65
 
68
66
  def init(_services)
69
- @model_search_paths = Array(
70
- config.fetch("model_search_paths", DEFAULT_MODEL_SEARCH_PATHS)
71
- ).map(&:to_s)
67
+ @model_search_paths = Array(config.fetch("model_search_paths")).map(&:to_s)
72
68
  @attachment_index = nil
73
69
  @load_errors = []
74
70
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -187,10 +188,10 @@ module Rigor
187
188
  current = node
188
189
  while current.is_a?(Prism::CallNode)
189
190
  if %i[required optional].include?(current.name)
190
- key_node = current.arguments&.arguments&.first
191
- return [nil, nil] unless key_node.is_a?(Prism::SymbolNode)
191
+ key = Rigor::Source::Literals.symbol(current.arguments&.arguments&.first)
192
+ return [nil, nil] if key.nil?
192
193
 
193
- return [key_node.unescaped.to_sym, current.name]
194
+ return [key, current.name]
194
195
  end
195
196
  current = current.receiver
196
197
  end
@@ -47,7 +47,11 @@ module Rigor
47
47
  version: "0.1.0",
48
48
  description: "Recognises `class T < Dry::Validation::Contract` subclasses and " \
49
49
  "publishes the contract FQN set.",
50
- produces: [:dry_validation_contracts]
50
+ produces: [:dry_validation_contracts],
51
+ # Auto-contribute the bundled RBS overlay (Contract#call -> Result,
52
+ # Result#success?/#to_h/...) per ADR-25, so no project-side
53
+ # signature_paths wiring is needed.
54
+ signature_paths: ["sig"]
51
55
  )
52
56
 
53
57
  def prepare(services)