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
@@ -94,19 +94,20 @@ module Rigor
94
94
  Analyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
95
95
  end
96
96
 
97
- # Pillar 2 Slice 1 spec-derived flow facts from RSpec
98
- # matcher assertions (six-matcher floor: `be_a`,
99
- # `be_kind_of`, `be_instance_of`, `be_nil`, `eq(literal)`,
100
- # `eql(literal)`; `match(/regex/)`; `not_to` / `to_not`).
101
- #
102
- # Pillar 2 Slice 2 (v0.3.0) — additionally binds local
103
- # reads in `it` / spec bodies to their `let(:name) { ... }`
104
- # block's inferred return type. Composes with Slice 1:
105
- # a matcher narrowing fires after the let binding.
106
- def flow_contribution_for(call_node:, scope:)
107
- matcher = MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)
108
- return matcher if matcher
97
+ # ADR-37 slice 2 — Pillar 2 Slice 1 matcher narrowing
98
+ # (`expect(x).to be_a(T)` `post_return_facts` on `x`),
99
+ # method-gated by the engine on the expectation verbs.
100
+ type_specifier methods: %i[to not_to to_not] do |call_node, scope|
101
+ MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
102
+ end
109
103
 
104
+ # Pillar 2 Slice 2 — binds local reads in `it` / spec bodies to
105
+ # their `let(:name) { ... }` block's inferred return type. This is
106
+ # a *method-gated return type* (keyed on the let-bound name read),
107
+ # which the receiver-gated `dynamic_return` cannot express, so it
108
+ # stays on `flow_contribution_for` — the deprecated escape valve
109
+ # (ADR-37 slice 2 § "Outcome").
110
+ def flow_contribution_for(call_node:, scope:)
110
111
  let_binding_contribution(call_node, scope)
111
112
  end
112
113
 
@@ -35,29 +35,27 @@ module Rigor
35
35
  # in shared-context bodies; the diagnostic is the same
36
36
  # regardless of the surrounding chain.
37
37
  module HaveHttpStatusAnalyzer
38
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
38
+ # One matcher violation: the kebab-case `rule` and the
39
+ # human-readable `message`. Carries no location/path — the
40
+ # caller (the `node_rule` block) positions the diagnostic at the
41
+ # matcher's `message_loc` via `Plugin::Base#diagnostic`.
42
+ Violation = Struct.new(:rule, :message, keyword_init: true)
39
43
 
40
44
  MATCHER_NAME = :have_http_status
41
45
 
42
46
  module_function
43
47
 
44
- # @param path [String]
45
- # @param root [Prism::Node]
46
- # @return [Array<Diagnostic>]
47
- def diagnose(path:, root:)
48
- diagnostics = []
49
- walk(root) do |call_node|
50
- diagnostic = diagnostic_for(call_node, path)
51
- diagnostics << diagnostic if diagnostic
52
- end
53
- diagnostics
54
- end
55
-
56
- def walk(node, &)
57
- return unless node.is_a?(Prism::Node)
58
-
59
- yield node if call_to_matcher?(node)
60
- node.compact_child_nodes.each { |child| walk(child, &) }
48
+ # The matcher violation for a single CallNode, or nil when the
49
+ # node is not a `have_http_status(<int|symbol>)` matcher or its
50
+ # argument is statically valid. ADR-37: the engine owns the
51
+ # walk, so this is per-node logic — no traversal here.
52
+ #
53
+ # @param call_node [Prism::Node]
54
+ # @return [Violation, nil]
55
+ def violation_for(call_node)
56
+ return nil unless call_to_matcher?(call_node)
57
+
58
+ diagnostic_for(call_node)
61
59
  end
62
60
 
63
61
  # `have_http_status` is a matcher — called either with
@@ -77,32 +75,34 @@ module Rigor
77
75
  args.size == 1
78
76
  end
79
77
 
80
- def diagnostic_for(call_node, path)
78
+ def diagnostic_for(call_node)
81
79
  arg = call_node.arguments.arguments.first
82
80
  case arg
83
- when Prism::IntegerNode then integer_diagnostic(call_node, path, arg)
84
- when Prism::SymbolNode then symbol_diagnostic(call_node, path, arg)
81
+ when Prism::IntegerNode then integer_violation(arg)
82
+ when Prism::SymbolNode then symbol_violation(arg)
85
83
  end
86
84
  end
87
85
 
88
- def integer_diagnostic(call_node, path, integer_node)
86
+ def integer_violation(integer_node)
89
87
  value = integer_node.value
90
88
  return nil if HttpStatusCodes::VALID_NUMERIC_RANGE.cover?(value)
91
89
 
92
- build_diagnostic(
93
- call_node, path,
90
+ Violation.new(
94
91
  rule: "have_http_status.out-of-range",
95
92
  message: "have_http_status(#{value}) is outside the valid HTTP status " \
96
93
  "range #{HttpStatusCodes::VALID_NUMERIC_RANGE}"
97
94
  )
98
95
  end
99
96
 
100
- def symbol_diagnostic(call_node, path, symbol_node)
97
+ def symbol_violation(symbol_node)
101
98
  sym = symbol_node.unescaped.to_sym
102
- return nil if HttpStatusCodes::KNOWN_SYMBOLS.include?(sym)
99
+ known = HttpStatusCodes.known_symbols
100
+ # ADR-39: Rack unavailable → the accepted symbol set is
101
+ # unknowable, so decline rather than flag from a guess.
102
+ return nil if known.nil?
103
+ return nil if known.include?(sym)
103
104
 
104
- build_diagnostic(
105
- call_node, path,
105
+ Violation.new(
106
106
  rule: "have_http_status.unknown-symbol",
107
107
  message: "have_http_status(:#{sym}) is not a recognised HTTP status symbol " \
108
108
  "(see Rack::Utils::SYMBOL_TO_STATUS_CODE) or Rails status-group " \
@@ -110,18 +110,6 @@ module Rigor
110
110
  ":client_error / :server_error / :informational)"
111
111
  )
112
112
  end
113
-
114
- def build_diagnostic(call_node, path, rule:, message:)
115
- location = call_node.message_loc || call_node.location
116
- Diagnostic.new(
117
- path: path,
118
- line: location.start_line,
119
- column: location.start_column + 1,
120
- severity: :warning,
121
- rule: rule,
122
- message: message
123
- )
124
- end
125
113
  end
126
114
  end
127
115
  end
@@ -3,57 +3,54 @@
3
3
  module Rigor
4
4
  module Plugin
5
5
  class RspecRails < Rigor::Plugin::Base
6
- # Curated set of HTTP-status symbols `have_http_status`
7
- # accepts. Derived from `Rack::Utils::SYMBOL_TO_STATUS_CODE`
8
- # (Rack 3.x snapshot) plus Rails' status-group aliases
9
- # accepted by `ActionDispatch::TestResponse`.
6
+ # The set of HTTP-status symbols `have_http_status` accepts.
10
7
  #
11
- # Vendored as a frozen Set so the plugin has no
12
- # `rack` / `actionpack` runtime dependency — checking
13
- # against a static catalogue is intentional: a future
14
- # Rack release that adds a new alias would require
15
- # bumping this constant, but the false-positive risk is
16
- # bounded (the diagnostic just suggests a typo when in
17
- # fact the symbol is newly-added).
8
+ # ADR-39 the HTTP status symbols come from the **real**
9
+ # `Rack::Utils::SYMBOL_TO_STATUS_CODE` (the authority `have_http_status`
10
+ # itself resolves through `Rack::Utils.status_code`), not a vendored
11
+ # snapshot: a snapshot that lagged the installed Rack would flag a
12
+ # newly-added status symbol as a false `unknown-symbol` (a false
13
+ # positive on working code). When Rack cannot be loaded the symbol
14
+ # set is unknowable, so {known_symbols} returns `nil` and the caller
15
+ # **declines** to flag anything — reduced coverage, never a guess.
16
+ #
17
+ # The Rails status-group aliases (`:success`, `:missing`, …) are a
18
+ # small, stable convention set defined by Rails
19
+ # (`ActionDispatch::TestResponse`) — not a growing catalogue — so
20
+ # they are kept as a constant rather than pulling in actionpack.
18
21
  module HttpStatusCodes
19
- SYMBOL_TO_STATUS_CODE = {
20
- # 1xx Informational
21
- continue: 100, switching_protocols: 101, processing: 102, early_hints: 103,
22
- # 2xx Success
23
- ok: 200, created: 201, accepted: 202, non_authoritative_information: 203,
24
- no_content: 204, reset_content: 205, partial_content: 206, multi_status: 207,
25
- already_reported: 208, im_used: 226,
26
- # 3xx Redirection
27
- multiple_choices: 300, moved_permanently: 301, found: 302, see_other: 303,
28
- not_modified: 304, use_proxy: 305, temporary_redirect: 307,
29
- permanent_redirect: 308,
30
- # 4xx Client Error
31
- bad_request: 400, unauthorized: 401, payment_required: 402, forbidden: 403,
32
- not_found: 404, method_not_allowed: 405, not_acceptable: 406,
33
- proxy_authentication_required: 407, request_timeout: 408, conflict: 409,
34
- gone: 410, length_required: 411, precondition_failed: 412,
35
- payload_too_large: 413, uri_too_long: 414, unsupported_media_type: 415,
36
- range_not_satisfiable: 416, expectation_failed: 417, misdirected_request: 421,
37
- unprocessable_entity: 422, unprocessable_content: 422, locked: 423,
38
- failed_dependency: 424, too_early: 425, upgrade_required: 426,
39
- precondition_required: 428, too_many_requests: 429,
40
- request_header_fields_too_large: 431, unavailable_for_legal_reasons: 451,
41
- # 5xx Server Error
42
- internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
43
- service_unavailable: 503, gateway_timeout: 504, http_version_not_supported: 505,
44
- variant_also_negotiates: 506, insufficient_storage: 507, loop_detected: 508,
45
- not_extended: 510, network_authentication_required: 511,
46
- # Rails status-group aliases (see
47
- # ActionDispatch::TestResponse RESPONSE_PREDICATES)
48
- informational: :informational_group, success: :success_group,
49
- successful: :success_group, redirect: :redirect_group,
50
- client_error: :client_error_group, missing: :client_error_group,
51
- server_error: :server_error_group, error: :server_error_group
52
- }.freeze
53
-
54
- KNOWN_SYMBOLS = SYMBOL_TO_STATUS_CODE.keys.to_set.freeze
22
+ RAILS_STATUS_GROUP_ALIASES = %i[
23
+ informational success successful redirect
24
+ client_error missing server_error error
25
+ ].to_set.freeze
55
26
 
56
27
  VALID_NUMERIC_RANGE = (100..599)
28
+
29
+ module_function
30
+
31
+ # The authoritative set of accepted `have_http_status` symbols
32
+ # (real Rack status symbols ∪ the Rails group aliases), or `nil`
33
+ # when it cannot be determined because Rack is unavailable. A
34
+ # `nil` return means "decline" — the caller MUST NOT flag any
35
+ # symbol as unknown (ADR-39: never guess from a stale copy).
36
+ def known_symbols
37
+ rack = rack_status_symbols
38
+ return nil if rack.nil?
39
+
40
+ rack | RAILS_STATUS_GROUP_ALIASES
41
+ end
42
+
43
+ # The real `Rack::Utils::SYMBOL_TO_STATUS_CODE` keys, or `nil` when
44
+ # Rack is not loadable. Lazy require (core Rigor carries no Rack
45
+ # dependency; the plugin brings it); memoised for the process.
46
+ def rack_status_symbols
47
+ return @rack_status_symbols if defined?(@rack_status_symbols)
48
+
49
+ require "rack/utils"
50
+ @rack_status_symbols = Rack::Utils::SYMBOL_TO_STATUS_CODE.keys.to_set.freeze
51
+ rescue LoadError, StandardError
52
+ @rack_status_symbols = nil
53
+ end
57
54
  end
58
55
  end
59
56
  end
@@ -56,17 +56,18 @@ module Rigor
56
56
  description: "Validates rspec-rails behavioral matchers (have_http_status floor)."
57
57
  )
58
58
 
59
- def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
60
- HaveHttpStatusAnalyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
61
- end
62
-
63
- private
59
+ # ADR-37 the engine walks each file and hands us every
60
+ # CallNode; the analyzer is now pure per-node logic. The
61
+ # diagnostic points at the matcher name (`message_loc`),
62
+ # reproducing the previous `message_loc || location` positioning.
63
+ node_rule Prism::CallNode do |node, _scope, path|
64
+ violation = HaveHttpStatusAnalyzer.violation_for(node)
65
+ next [] unless violation
64
66
 
65
- def build_diagnostic(diag)
66
- Rigor::Analysis::Diagnostic.new(
67
- path: diag.path, line: diag.line, column: diag.column,
68
- message: diag.message, severity: diag.severity, rule: diag.rule
69
- )
67
+ [diagnostic(
68
+ node, path: path, location: node.message_loc,
69
+ message: violation.message, severity: :warning, rule: violation.rule
70
+ )]
70
71
  end
71
72
  end
72
73
 
@@ -37,7 +37,7 @@ module Rigor
37
37
  #
38
38
  # All look up `:col` against the model's columns
39
39
  # (`Entry#column?`). Unknown columns fire
40
- # `shoulda-matchers.unknown-column`.
40
+ # `unknown-column`.
41
41
  #
42
42
  # ### Association matchers
43
43
  #
@@ -47,12 +47,15 @@ module Rigor
47
47
  # have_and_belong_to_many(:assoc) ← expects :collection
48
48
  #
49
49
  # Unknown associations fire
50
- # `shoulda-matchers.unknown-association`. Known
50
+ # `unknown-association`. Known
51
51
  # associations with mismatched kind (`should belong_to(:posts)`
52
52
  # where `:posts` is `has_many`) fire
53
- # `shoulda-matchers.association-kind-mismatch`.
53
+ # `association-kind-mismatch`.
54
54
  module Analyzer
55
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
55
+ # One matcher violation (always `:warning`). Carries no
56
+ # path/location — the caller (the `node_rule` block) positions it
57
+ # at the matcher name (`message_loc`) via `Plugin::Base#diagnostic`.
58
+ Violation = Struct.new(:rule, :message, keyword_init: true)
56
59
 
57
60
  # `(matcher_name) => (:column | :association_singular | :association_collection)`
58
61
  # — the validation lane each matcher routes to.
@@ -85,68 +88,38 @@ module Rigor
85
88
  # @param model_index [Object, nil] the `:model_index`
86
89
  # fact value. When nil the analyzer falls silent.
87
90
  # @return [Array<Diagnostic>]
88
- def diagnose(path:, root:, model_index:)
91
+ # The matcher violations for a single call node (0..1), or `[]`
92
+ # when it is not a shoulda matcher, sits outside any
93
+ # `describe <ModelConst>` block, or the model is unknown.
94
+ # ADR-37: the engine owns the walk; the model anchor comes from
95
+ # the node-rule `NodeContext` ancestors (the innermost enclosing
96
+ # `describe`-with-constant — nested const describes override,
97
+ # `describe ".method"` string blocks inherit), exactly as the
98
+ # former recursive anchor propagation did.
99
+ #
100
+ # @param matcher_call [Prism::Node]
101
+ # @param ancestors [Array<Prism::Node>]
102
+ # @param model_index [Object, nil]
103
+ # @return [Array<Violation>]
104
+ def violations_for(matcher_call:, ancestors:, model_index:)
89
105
  return [] if model_index.nil?
106
+ return [] unless matcher_invocation?(matcher_call)
90
107
 
91
- diagnostics = []
92
- walk_describe(root, anchor_model: nil) do |matcher_call, anchor|
93
- entry = model_index.find(anchor)
94
- next if entry.nil?
108
+ anchor = anchor_for(ancestors)
109
+ return [] if anchor.nil?
95
110
 
96
- diagnostic = diagnostic_for(matcher_call, path, anchor, entry)
97
- diagnostics << diagnostic if diagnostic
98
- end
99
- diagnostics
100
- end
101
-
102
- # Walks for `RSpec.describe(Const)` / `describe(Const)`
103
- # blocks (the Const is the model anchor) and yields
104
- # every matcher call found in their body.
105
- #
106
- # The anchor stays the OUTERMOST describe-with-const
107
- # — nested describes / contexts inherit it without
108
- # overriding (a nested `describe ".active"` is not a
109
- # model constant). When a nested describe DOES name a
110
- # different model, the nested anchor wins inside that
111
- # subtree (rare; we still honour it).
112
- def walk_describe(node, anchor_model:, &)
113
- return unless node.is_a?(Prism::Node)
111
+ entry = model_index.find(anchor)
112
+ return [] if entry.nil?
114
113
 
115
- if describe_with_constant?(node)
116
- inner_anchor = describe_const_name(node) || anchor_model
117
- collect_matchers(node.block.body, inner_anchor, &) if node.block&.body
118
- return
119
- end
120
-
121
- node.compact_child_nodes.each do |child|
122
- walk_describe(child, anchor_model: anchor_model, &)
123
- end
114
+ violation = diagnostic_for(matcher_call, anchor, entry)
115
+ violation ? [violation] : []
124
116
  end
125
117
 
126
- # Walks the body of a describe block looking for:
127
- # (a) matcher calls `should MATCHER` or
128
- # `expect(...).to MATCHER` chains; we yield the
129
- # inner MATCHER call.
130
- # (b) nested describe / context blocks — we recurse
131
- # so deeper matchers are reachable.
132
- def collect_matchers(body, anchor, &)
133
- return unless body.is_a?(Prism::Node)
134
- return if anchor.nil?
135
-
136
- if matcher_invocation?(body)
137
- yield body, anchor
138
- return
139
- end
140
-
141
- if describe_with_constant?(body)
142
- inner_anchor = describe_const_name(body) || anchor
143
- collect_matchers(body.block.body, inner_anchor, &) if body.block&.body
144
- return
145
- end
146
-
147
- body.compact_child_nodes.each do |child|
148
- collect_matchers(child, anchor, &)
149
- end
118
+ # The model anchor in effect at a node: the constant named by the
119
+ # innermost enclosing `describe <Const> do … end`, or nil.
120
+ def anchor_for(ancestors)
121
+ describe = ancestors.rfind { |n| describe_with_constant?(n) }
122
+ describe && describe_const_name(describe)
150
123
  end
151
124
 
152
125
  # A direct matcher invocation is a `CallNode` whose
@@ -203,63 +176,48 @@ module Rigor
203
176
 
204
177
  # --- diagnostics ---
205
178
 
206
- def diagnostic_for(matcher_call, path, anchor, entry)
179
+ def diagnostic_for(matcher_call, anchor, entry)
207
180
  lane = MATCHER_TABLE.fetch(matcher_call.name)
208
181
  target = matcher_call.arguments.arguments.first.unescaped.to_sym
209
182
 
210
183
  case lane
211
184
  when :column
212
- column_diagnostic(matcher_call, path, anchor, entry, target)
185
+ column_violation(matcher_call, anchor, entry, target)
213
186
  when :association_singular
214
- association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :singular)
187
+ association_violation(matcher_call, anchor, entry, target, expected_kind: :singular)
215
188
  when :association_collection
216
- association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :collection)
189
+ association_violation(matcher_call, anchor, entry, target, expected_kind: :collection)
217
190
  end
218
191
  end
219
192
 
220
- def column_diagnostic(matcher_call, path, anchor, entry, column_name)
193
+ def column_violation(matcher_call, anchor, entry, column_name)
221
194
  return nil if entry.column?(column_name)
222
195
 
223
- build_diagnostic(
224
- matcher_call, path,
225
- rule: "shoulda-matchers.unknown-column",
196
+ Violation.new(
197
+ rule: "unknown-column",
226
198
  message: "#{matcher_call.name}(:#{column_name}) — no column `#{column_name}` on " \
227
199
  "#{anchor} (columns: #{entry.column_names.sort.join(', ')})"
228
200
  )
229
201
  end
230
202
 
231
- def association_diagnostic(matcher_call, path, anchor, entry, assoc_name, expected_kind:)
203
+ def association_violation(matcher_call, anchor, entry, assoc_name, expected_kind:)
232
204
  if entry.association?(assoc_name)
233
205
  actual = entry.association(assoc_name)[:kind]
234
206
  return nil if actual == expected_kind
235
207
 
236
- build_diagnostic(
237
- matcher_call, path,
238
- rule: "shoulda-matchers.association-kind-mismatch",
208
+ Violation.new(
209
+ rule: "association-kind-mismatch",
239
210
  message: "#{matcher_call.name}(:#{assoc_name}) on #{anchor} — `#{assoc_name}` is " \
240
211
  "a #{actual} association; #{matcher_call.name} expects #{expected_kind}"
241
212
  )
242
213
  else
243
- build_diagnostic(
244
- matcher_call, path,
245
- rule: "shoulda-matchers.unknown-association",
214
+ Violation.new(
215
+ rule: "unknown-association",
246
216
  message: "#{matcher_call.name}(:#{assoc_name}) — no association `#{assoc_name}` on " \
247
217
  "#{anchor} (associations: #{entry.association_names.sort.join(', ')})"
248
218
  )
249
219
  end
250
220
  end
251
-
252
- def build_diagnostic(call_node, path, rule:, message:)
253
- location = call_node.message_loc || call_node.location
254
- Diagnostic.new(
255
- path: path,
256
- line: location.start_line,
257
- column: location.start_column + 1,
258
- severity: :warning,
259
- rule: rule,
260
- message: message
261
- )
262
- end
263
221
  end
264
222
  end
265
223
  end
@@ -76,13 +76,19 @@ module Rigor
76
76
  @model_index_resolved = false
77
77
  end
78
78
 
79
- def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
79
+ # ADR-37 per-matcher validation over the engine-owned walk. The
80
+ # model anchor (the enclosing `describe <Model>` const) comes from
81
+ # the node-rule NodeContext ancestors; the diagnostic points at the
82
+ # matcher name (message_loc). The :model_index fact (from
83
+ # rigor-activerecord) is read lazily; without it the rule is silent.
84
+ node_rule Prism::CallNode do |node, _scope, path, _fc, context|
80
85
  index = model_index_or_nil
81
- return [] if index.nil?
86
+ next [] if index.nil?
82
87
 
83
- Analyzer.diagnose(
84
- path: path, root: root, model_index: index
85
- ).map { |diag| build_diagnostic(diag) }
88
+ Analyzer.violations_for(matcher_call: node, ancestors: context.ancestors, model_index: index).map do |violation|
89
+ diagnostic(node, path: path, location: node.message_loc,
90
+ message: violation.message, severity: :warning, rule: violation.rule)
91
+ end
86
92
  end
87
93
 
88
94
  private
@@ -99,13 +105,6 @@ module Rigor
99
105
  @model_index_resolved = true
100
106
  @model_index
101
107
  end
102
-
103
- def build_diagnostic(diag)
104
- Rigor::Analysis::Diagnostic.new(
105
- path: diag.path, line: diag.line, column: diag.column,
106
- message: diag.message, severity: diag.severity, rule: diag.rule
107
- )
108
- end
109
108
  end
110
109
 
111
110
  Rigor::Plugin.register(ShouldaMatchers)
@@ -31,35 +31,34 @@ module Rigor
31
31
 
32
32
  ENTRY_METHODS = (DIRECT_ENTRY_METHODS + SCHEDULED_ENTRY_METHODS).freeze
33
33
 
34
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
34
+ # One worker-call observation: the kebab-case `rule`, `severity`,
35
+ # and human-readable `message`. Carries no path/location — the
36
+ # caller (the `node_rule` block) positions it via
37
+ # `Plugin::Base#diagnostic`.
38
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
35
39
 
36
40
  module_function
37
41
 
38
- # @param path [String]
39
- # @param root [Prism::Node]
42
+ # The worker-call violations for a single call node (0..2), or
43
+ # `[]` when the node is not a `<Worker>.perform_*` entry call on a
44
+ # known worker. ADR-37: the engine owns the walk.
45
+ #
46
+ # @param call_node [Prism::Node]
40
47
  # @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
48
+ # @return [Array<Violation>]
49
+ def violations_for(call_node:, worker_index:)
50
+ return [] unless call_node.is_a?(Prism::CallNode) && entry_call?(call_node)
51
+
52
+ class_name = constant_receiver_name(call_node.receiver)
53
+ return [] if class_name.nil?
57
54
 
58
- def walk(node, &)
59
- return unless node.is_a?(Prism::Node)
55
+ entry = worker_index.find(class_name) || worker_index.find("::#{class_name}")
56
+ return [] if entry.nil?
60
57
 
61
- yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
62
- node.compact_child_nodes.each { |child| walk(child, &) }
58
+ violations = [info_violation(call_node, entry)]
59
+ arity = arity_violation(call_node, entry)
60
+ violations << arity if arity
61
+ violations
63
62
  end
64
63
 
65
64
  def entry_call?(node)
@@ -67,12 +66,8 @@ module Rigor
67
66
  (node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
68
67
  end
69
68
 
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,
69
+ def info_violation(call_node, entry)
70
+ Violation.new(
76
71
  severity: :info,
77
72
  rule: "worker-call",
78
73
  message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` " \
@@ -80,24 +75,20 @@ module Rigor
80
75
  )
81
76
  end
82
77
 
83
- def arity_check(path, call_node, entry)
78
+ def arity_violation(call_node, entry)
84
79
  all_args = (call_node.arguments&.arguments || []).size
85
80
  # Scheduled entries consume the first arg as the
86
81
  # schedule; the rest are forwarded.
87
82
  forwarded_count = SCHEDULED_ENTRY_METHODS.include?(call_node.name) ? all_args - 1 : all_args
88
83
 
89
84
  if SCHEDULED_ENTRY_METHODS.include?(call_node.name) && all_args.zero?
90
- return missing_schedule_diagnostic(path, call_node, entry)
85
+ return missing_schedule_violation(call_node, entry)
91
86
  end
92
87
 
93
88
  return nil if forwarded_count.negative?
94
89
  return nil if entry.accepts?(forwarded_count)
95
90
 
96
- location = call_node.location
97
- Diagnostic.new(
98
- path: path,
99
- line: location.start_line,
100
- column: location.start_column + 1,
91
+ Violation.new(
101
92
  severity: :error,
102
93
  rule: "wrong-arity",
103
94
  message: "`#{entry.class_name}.#{call_node.name}` expects " \
@@ -106,12 +97,8 @@ module Rigor
106
97
  )
107
98
  end
108
99
 
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,
100
+ def missing_schedule_violation(call_node, entry)
101
+ Violation.new(
115
102
  severity: :error,
116
103
  rule: "missing-schedule",
117
104
  message: "`#{entry.class_name}.#{call_node.name}` requires a schedule " \