rigortype 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "http_status_codes"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class RspecRails < Rigor::Plugin::Base
10
+ # Validates `expect(response).to have_http_status(arg)` /
11
+ # the negated `.not_to have_http_status(arg)` and the
12
+ # bare matcher form `should have_http_status(arg)`.
13
+ #
14
+ # Recognised argument shapes:
15
+ #
16
+ # - **IntegerNode**: must be in `100..599`. Numbers outside
17
+ # that range fire `have_http_status.out-of-range`.
18
+ # - **SymbolNode**: must be one of the known
19
+ # `Rack::Utils::SYMBOL_TO_STATUS_CODE` keys OR a Rails
20
+ # status-group alias (`:success` / `:successful` /
21
+ # `:missing` / `:redirect` / `:error` / `:client_error` /
22
+ # `:server_error` / `:informational`). Unknown symbols
23
+ # fire `have_http_status.unknown-symbol` (typo-flavoured).
24
+ # - **StringNode**: passed through silently (a literal
25
+ # `"200"` is accepted by Rails at runtime; we don't
26
+ # second-guess the user's intent for the String form).
27
+ # - **Anything else** (variable, method call, computed
28
+ # expression): skipped — the plugin can't statically
29
+ # prove the runtime value.
30
+ #
31
+ # Walks every `Prism::CallNode` looking for matcher
32
+ # invocations rather than only the `expect(...).to ...`
33
+ # chain because `have_http_status` is also used as a
34
+ # plain matcher inside Rails' `assert_response` shim and
35
+ # in shared-context bodies; the diagnostic is the same
36
+ # regardless of the surrounding chain.
37
+ module HaveHttpStatusAnalyzer
38
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
39
+
40
+ MATCHER_NAME = :have_http_status
41
+
42
+ module_function
43
+
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, &) }
61
+ end
62
+
63
+ # `have_http_status` is a matcher — called either with
64
+ # no receiver (`have_http_status(200)` inside `.to(...)`)
65
+ # or with a receiver chain we don't care about here.
66
+ # Either way we want the call node whose name is
67
+ # `have_http_status` and that carries exactly one
68
+ # positional argument.
69
+ def call_to_matcher?(node)
70
+ node.is_a?(Prism::CallNode) &&
71
+ node.name == MATCHER_NAME &&
72
+ single_positional?(node)
73
+ end
74
+
75
+ def single_positional?(call_node)
76
+ args = call_node.arguments&.arguments || []
77
+ args.size == 1
78
+ end
79
+
80
+ def diagnostic_for(call_node, path)
81
+ arg = call_node.arguments.arguments.first
82
+ case arg
83
+ when Prism::IntegerNode then integer_diagnostic(call_node, path, arg)
84
+ when Prism::SymbolNode then symbol_diagnostic(call_node, path, arg)
85
+ end
86
+ end
87
+
88
+ def integer_diagnostic(call_node, path, integer_node)
89
+ value = integer_node.value
90
+ return nil if HttpStatusCodes::VALID_NUMERIC_RANGE.cover?(value)
91
+
92
+ build_diagnostic(
93
+ call_node, path,
94
+ rule: "have_http_status.out-of-range",
95
+ message: "have_http_status(#{value}) is outside the valid HTTP status " \
96
+ "range #{HttpStatusCodes::VALID_NUMERIC_RANGE}"
97
+ )
98
+ end
99
+
100
+ def symbol_diagnostic(call_node, path, symbol_node)
101
+ sym = symbol_node.unescaped.to_sym
102
+ return nil if HttpStatusCodes::KNOWN_SYMBOLS.include?(sym)
103
+
104
+ build_diagnostic(
105
+ call_node, path,
106
+ rule: "have_http_status.unknown-symbol",
107
+ message: "have_http_status(:#{sym}) is not a recognised HTTP status symbol " \
108
+ "(see Rack::Utils::SYMBOL_TO_STATUS_CODE) or Rails status-group " \
109
+ "alias (:success / :successful / :missing / :redirect / :error / " \
110
+ ":client_error / :server_error / :informational)"
111
+ )
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
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
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`.
10
+ #
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).
18
+ 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
55
+
56
+ VALID_NUMERIC_RANGE = (100..599)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "rspec_rails/have_http_status_analyzer"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ # rigor-rspec-rails — validates rspec-rails matchers whose
10
+ # arguments are statically checkable.
11
+ #
12
+ # v0.1.0 covers `have_http_status(int_or_symbol)`:
13
+ #
14
+ # - Integer: must be in `100..599`.
15
+ # - Symbol: must be one of the
16
+ # `Rack::Utils::SYMBOL_TO_STATUS_CODE` keys or one of
17
+ # Rails' status-group aliases (`:success`, `:successful`,
18
+ # `:missing`, `:redirect`, `:error`, `:client_error`,
19
+ # `:server_error`, `:informational`).
20
+ #
21
+ # ## Why this is a separate plugin from rigor-rspec
22
+ #
23
+ # `rigor-rspec` handles the **type-narrowing** matchers
24
+ # (`be_a`, `be_kind_of`, `be_nil`, `eq(literal)`, etc.) —
25
+ # ones that refine a local's static type so downstream
26
+ # calls in the same `it` body resolve at the narrowed
27
+ # type. `rigor-rspec-rails` handles the **behavioral**
28
+ # matchers — ones that assert runtime state (HTTP status,
29
+ # rendered template, route shape) without narrowing a
30
+ # type. The two are activated independently in `.rigor.yml`.
31
+ #
32
+ # ## Deferred matchers
33
+ #
34
+ # - `render_template(...)` — overlaps with
35
+ # `rigor-actionpack`'s render-target validation
36
+ # (`render :show` against the view tree). A separate
37
+ # slice would coordinate the two to avoid
38
+ # double-firing.
39
+ # - `route_to(...)` / `redirect_to(...)` — needs the
40
+ # routes table from `rigor-rails-routes`
41
+ # (`:helper_table` cross-plugin fact). Future slice.
42
+ # - `have_enqueued_job(JobClass)` /
43
+ # `have_enqueued_mail(MailerClass)` — class-existence
44
+ # check overlaps with engine's
45
+ # `inference.unresolved-constant`; queued behind a
46
+ # decision on which surface owns the diagnostic.
47
+ # - `have_received(:method)` — overlaps with engine's
48
+ # `call.undefined-method`; same coordination question.
49
+ # - `be_routable` — needs routes table.
50
+ # - `match_response_schema(...)` (rswag / OpenAPI) — out
51
+ # of scope, separate plugin.
52
+ class RspecRails < Rigor::Plugin::Base
53
+ manifest(
54
+ id: "rspec-rails",
55
+ version: "0.1.0",
56
+ description: "Validates rspec-rails behavioral matchers (have_http_status floor)."
57
+ )
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
64
+
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
+ )
70
+ end
71
+ end
72
+
73
+ Rigor::Plugin.register(RspecRails)
74
+ end
75
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rspec_rails"
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class ShouldaMatchers < Rigor::Plugin::Base
8
+ # Walks every `RSpec.describe <ModelConst> do ... end` /
9
+ # `describe <ModelConst> do ... end` block and validates
10
+ # the shoulda-matchers calls inside its body (any depth
11
+ # of nested `describe` / `context`) against the
12
+ # `:model_index` published by `rigor-activerecord`.
13
+ #
14
+ # The "anchor" for cross-checking is the OUTERMOST
15
+ # describe block whose argument is a constant — that
16
+ # constant names the model being specced. Nested
17
+ # `describe ".some_method"` (String / Symbol args) does
18
+ # NOT change the anchor.
19
+ #
20
+ # ## Recognised matcher calls (v0.1.0)
21
+ #
22
+ # ### Column / db matchers
23
+ #
24
+ # validate_presence_of(:col)
25
+ # validate_uniqueness_of(:col)
26
+ # validate_length_of(:col)
27
+ # validate_numericality_of(:col)
28
+ # validate_acceptance_of(:col)
29
+ # validate_inclusion_of(:col)
30
+ # validate_exclusion_of(:col)
31
+ # validate_absence_of(:col)
32
+ # validate_format_of(:col)
33
+ # validate_confirmation_of(:col)
34
+ # allow_value(...).for(:col)
35
+ # have_db_column(:col)
36
+ # have_db_index(:col)
37
+ #
38
+ # All look up `:col` against the model's columns
39
+ # (`Entry#column?`). Unknown columns fire
40
+ # `shoulda-matchers.unknown-column`.
41
+ #
42
+ # ### Association matchers
43
+ #
44
+ # belong_to(:assoc) ← expects :singular
45
+ # have_one(:assoc) ← expects :singular
46
+ # have_many(:assoc) ← expects :collection
47
+ # have_and_belong_to_many(:assoc) ← expects :collection
48
+ #
49
+ # Unknown associations fire
50
+ # `shoulda-matchers.unknown-association`. Known
51
+ # associations with mismatched kind (`should belong_to(:posts)`
52
+ # where `:posts` is `has_many`) fire
53
+ # `shoulda-matchers.association-kind-mismatch`.
54
+ module Analyzer
55
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
56
+
57
+ # `(matcher_name) => (:column | :association_singular | :association_collection)`
58
+ # — the validation lane each matcher routes to.
59
+ MATCHER_TABLE = {
60
+ # Column matchers — validate the named column exists on the model.
61
+ validate_presence_of: :column,
62
+ validate_uniqueness_of: :column,
63
+ validate_length_of: :column,
64
+ validate_numericality_of: :column,
65
+ validate_acceptance_of: :column,
66
+ validate_inclusion_of: :column,
67
+ validate_exclusion_of: :column,
68
+ validate_absence_of: :column,
69
+ validate_format_of: :column,
70
+ validate_confirmation_of: :column,
71
+ have_db_column: :column,
72
+ have_db_index: :column,
73
+ # Association matchers — validate the association
74
+ # exists AND its kind matches the matcher.
75
+ belong_to: :association_singular,
76
+ have_one: :association_singular,
77
+ have_many: :association_collection,
78
+ have_and_belong_to_many: :association_collection
79
+ }.freeze
80
+
81
+ module_function
82
+
83
+ # @param path [String]
84
+ # @param root [Prism::Node]
85
+ # @param model_index [Object, nil] the `:model_index`
86
+ # fact value. When nil the analyzer falls silent.
87
+ # @return [Array<Diagnostic>]
88
+ def diagnose(path:, root:, model_index:)
89
+ return [] if model_index.nil?
90
+
91
+ diagnostics = []
92
+ walk_describe(root, anchor_model: nil) do |matcher_call, anchor|
93
+ entry = model_index.find(anchor)
94
+ next if entry.nil?
95
+
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)
114
+
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
124
+ end
125
+
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
150
+ end
151
+
152
+ # A direct matcher invocation is a `CallNode` whose
153
+ # `name` is in `MATCHER_TABLE` and whose first argument
154
+ # is a `SymbolNode`. The chain shape (`should`,
155
+ # `expect(...).to`, `is_expected.to`) is irrelevant —
156
+ # we always recurse to the inner matcher, so a
157
+ # diagnostic fires on the matcher regardless of the
158
+ # surrounding chain.
159
+ def matcher_invocation?(node)
160
+ node.is_a?(Prism::CallNode) && MATCHER_TABLE.key?(node.name) && symbol_first_arg?(node)
161
+ end
162
+
163
+ def symbol_first_arg?(call_node)
164
+ args = call_node.arguments&.arguments || []
165
+ !args.empty? && args.first.is_a?(Prism::SymbolNode)
166
+ end
167
+
168
+ # Detects `RSpec.describe(Const) do ... end` and
169
+ # `describe(Const) do ... end`. Either form opens a
170
+ # scope whose anchor is `Const`. The receiver shape
171
+ # (RSpec vs nil) is allowed in both cases.
172
+ def describe_with_constant?(node)
173
+ return false unless node.is_a?(Prism::CallNode)
174
+ return false unless node.name == :describe
175
+ return false unless node.block.is_a?(Prism::BlockNode)
176
+
177
+ args = node.arguments&.arguments || []
178
+ first = args.first
179
+ first.is_a?(Prism::ConstantReadNode) || first.is_a?(Prism::ConstantPathNode)
180
+ end
181
+
182
+ def describe_const_name(node)
183
+ arg = node.arguments.arguments.first
184
+ render_constant_path(arg)
185
+ end
186
+
187
+ def render_constant_path(node)
188
+ case node
189
+ when Prism::ConstantReadNode then node.name.to_s
190
+ when Prism::ConstantPathNode
191
+ parts = []
192
+ current = node
193
+ while current.is_a?(Prism::ConstantPathNode)
194
+ parts.unshift(current.name.to_s)
195
+ current = current.parent
196
+ end
197
+ case current
198
+ when nil then "::#{parts.join('::')}"
199
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
200
+ end
201
+ end
202
+ end
203
+
204
+ # --- diagnostics ---
205
+
206
+ def diagnostic_for(matcher_call, path, anchor, entry)
207
+ lane = MATCHER_TABLE.fetch(matcher_call.name)
208
+ target = matcher_call.arguments.arguments.first.unescaped.to_sym
209
+
210
+ case lane
211
+ when :column
212
+ column_diagnostic(matcher_call, path, anchor, entry, target)
213
+ when :association_singular
214
+ association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :singular)
215
+ when :association_collection
216
+ association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :collection)
217
+ end
218
+ end
219
+
220
+ def column_diagnostic(matcher_call, path, anchor, entry, column_name)
221
+ return nil if entry.column?(column_name)
222
+
223
+ build_diagnostic(
224
+ matcher_call, path,
225
+ rule: "shoulda-matchers.unknown-column",
226
+ message: "#{matcher_call.name}(:#{column_name}) — no column `#{column_name}` on " \
227
+ "#{anchor} (columns: #{entry.column_names.sort.join(', ')})"
228
+ )
229
+ end
230
+
231
+ def association_diagnostic(matcher_call, path, anchor, entry, assoc_name, expected_kind:)
232
+ if entry.association?(assoc_name)
233
+ actual = entry.association(assoc_name)[:kind]
234
+ return nil if actual == expected_kind
235
+
236
+ build_diagnostic(
237
+ matcher_call, path,
238
+ rule: "shoulda-matchers.association-kind-mismatch",
239
+ message: "#{matcher_call.name}(:#{assoc_name}) on #{anchor} — `#{assoc_name}` is " \
240
+ "a #{actual} association; #{matcher_call.name} expects #{expected_kind}"
241
+ )
242
+ else
243
+ build_diagnostic(
244
+ matcher_call, path,
245
+ rule: "shoulda-matchers.unknown-association",
246
+ message: "#{matcher_call.name}(:#{assoc_name}) — no association `#{assoc_name}` on " \
247
+ "#{anchor} (associations: #{entry.association_names.sort.join(', ')})"
248
+ )
249
+ end
250
+ 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
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "shoulda_matchers/analyzer"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ # rigor-shoulda-matchers — validates shoulda-matchers
10
+ # matchers against the `:model_index` cross-plugin fact
11
+ # (ADR-9) published by `rigor-activerecord`.
12
+ #
13
+ # The plugin walks every `RSpec.describe <ModelConst> do
14
+ # ... end` block and validates the matchers inside
15
+ # against the model's known columns / associations.
16
+ #
17
+ # ## Recognised matchers (v0.1.0)
18
+ #
19
+ # **Column matchers** (validate the named column exists on
20
+ # the model):
21
+ #
22
+ # validate_presence_of(:col), validate_uniqueness_of(:col),
23
+ # validate_length_of(:col), validate_numericality_of(:col),
24
+ # validate_acceptance_of(:col), validate_inclusion_of(:col),
25
+ # validate_exclusion_of(:col), validate_absence_of(:col),
26
+ # validate_format_of(:col), validate_confirmation_of(:col),
27
+ # have_db_column(:col), have_db_index(:col)
28
+ #
29
+ # **Association matchers** (validate the association exists
30
+ # AND its kind matches):
31
+ #
32
+ # belong_to(:assoc), have_one(:assoc) ← :singular
33
+ # have_many(:assoc), have_and_belong_to_many(:assoc) ← :collection
34
+ #
35
+ # ## Cross-plugin dependency
36
+ #
37
+ # The plugin consumes `:model_index` from `rigor-activerecord`.
38
+ # When `rigor-activerecord` is NOT loaded (or hasn't
39
+ # published an index for the analysed model), the plugin
40
+ # falls silent — the cross-check is opt-in. Adding
41
+ # `rigor-activerecord` to `.rigor.yml` unlocks the
42
+ # diagnostics.
43
+ #
44
+ # ## Limitations (v0.1.0)
45
+ #
46
+ # - **No chained-matcher arg validation.** The chain
47
+ # options on `validate_length_of(:col).is_at_most(50)`,
48
+ # `validate_inclusion_of(:col).in_array([...])`,
49
+ # `allow_value("foo").for(:col)`, etc. are NOT validated
50
+ # (the `.is_at_most` etc. terminals are runtime-only).
51
+ # - **No polymorphic / through validation.** `belong_to(:user).polymorphic`,
52
+ # `have_many(:posts, through: :memberships)` only check
53
+ # the named association; the chain modifiers are
54
+ # ignored.
55
+ # - **No nested-attribute matchers.** `accept_nested_attributes_for(:posts)`
56
+ # not yet covered.
57
+ # - **No callback matchers.** `callback(:before_save).before(:save)`
58
+ # would need a separate slice (overlaps with the
59
+ # model_index's `callbacks` column already exposed but
60
+ # no rspec-side recogniser yet).
61
+ class ShouldaMatchers < Rigor::Plugin::Base
62
+ manifest(
63
+ id: "shoulda-matchers",
64
+ version: "0.1.0",
65
+ description: "Validates shoulda-matchers matchers (validate_presence_of / belong_to / " \
66
+ "have_many / have_db_column / ...) against :model_index from " \
67
+ "rigor-activerecord.",
68
+ consumes: [
69
+ { plugin_id: "activerecord", name: :model_index, optional: true }
70
+ ]
71
+ )
72
+
73
+ def init(services)
74
+ @services = services
75
+ @model_index = nil
76
+ @model_index_resolved = false
77
+ end
78
+
79
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
80
+ index = model_index_or_nil
81
+ return [] if index.nil?
82
+
83
+ Analyzer.diagnose(
84
+ path: path, root: root, model_index: index
85
+ ).map { |diag| build_diagnostic(diag) }
86
+ end
87
+
88
+ private
89
+
90
+ # Lazily resolves `:model_index` from
91
+ # `rigor-activerecord`. Returns nil when the plugin
92
+ # isn't loaded or no index has been published; the
93
+ # analyzer treats nil as "no cross-check available" and
94
+ # falls silent.
95
+ def model_index_or_nil
96
+ return @model_index if @model_index_resolved
97
+
98
+ @model_index = @services.fact_store.read(plugin_id: "activerecord", name: :model_index)
99
+ @model_index_resolved = true
100
+ @model_index
101
+ 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
+ end
110
+
111
+ Rigor::Plugin.register(ShouldaMatchers)
112
+ end
113
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/shoulda_matchers"