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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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,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
|