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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +25 -1
- data/lib/rigor/analysis/diagnostic.rb +40 -0
- data/lib/rigor/analysis/runner.rb +61 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +135 -5
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +127 -8
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +52 -2
- 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
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
78
|
+
def diagnostic_for(call_node)
|
|
81
79
|
arg = call_node.arguments.arguments.first
|
|
82
80
|
case arg
|
|
83
|
-
when Prism::IntegerNode then
|
|
84
|
-
when Prism::SymbolNode then
|
|
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
|
|
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
|
-
|
|
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
|
|
97
|
+
def symbol_violation(symbol_node)
|
|
101
98
|
sym = symbol_node.unescaped.to_sym
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
12
|
-
# `
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
# `
|
|
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
|
-
# `
|
|
50
|
+
# `unknown-association`. Known
|
|
51
51
|
# associations with mismatched kind (`should belong_to(:posts)`
|
|
52
52
|
# where `:posts` is `has_many`) fire
|
|
53
|
-
# `
|
|
53
|
+
# `association-kind-mismatch`.
|
|
54
54
|
module Analyzer
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
entry = model_index.find(anchor)
|
|
94
|
-
next if entry.nil?
|
|
108
|
+
anchor = anchor_for(ancestors)
|
|
109
|
+
return [] if anchor.nil?
|
|
95
110
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
#
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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,
|
|
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
|
-
|
|
185
|
+
column_violation(matcher_call, anchor, entry, target)
|
|
213
186
|
when :association_singular
|
|
214
|
-
|
|
187
|
+
association_violation(matcher_call, anchor, entry, target, expected_kind: :singular)
|
|
215
188
|
when :association_collection
|
|
216
|
-
|
|
189
|
+
association_violation(matcher_call, anchor, entry, target, expected_kind: :collection)
|
|
217
190
|
end
|
|
218
191
|
end
|
|
219
192
|
|
|
220
|
-
def
|
|
193
|
+
def column_violation(matcher_call, anchor, entry, column_name)
|
|
221
194
|
return nil if entry.column?(column_name)
|
|
222
195
|
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
next [] if index.nil?
|
|
82
87
|
|
|
83
|
-
Analyzer.
|
|
84
|
-
path: path,
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
39
|
-
#
|
|
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<
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
59
|
-
return
|
|
55
|
+
entry = worker_index.find(class_name) || worker_index.find("::#{class_name}")
|
|
56
|
+
return [] if entry.nil?
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
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 " \
|