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
|
@@ -7,6 +7,7 @@ require_relative "../environment"
|
|
|
7
7
|
require_relative "../scope"
|
|
8
8
|
require_relative "../reflection"
|
|
9
9
|
require_relative "../type"
|
|
10
|
+
require_relative "../source/literals"
|
|
10
11
|
require_relative "../inference/def_return_typer"
|
|
11
12
|
require_relative "../inference/scope_indexer"
|
|
12
13
|
require_relative "../inference/rbs_type_translator"
|
|
@@ -893,9 +894,7 @@ module Rigor
|
|
|
893
894
|
end
|
|
894
895
|
|
|
895
896
|
def extract_symbol_arguments(call_node)
|
|
896
|
-
(call_node
|
|
897
|
-
arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
|
|
898
|
-
end
|
|
897
|
+
Source::Literals.symbol_arguments(call_node)
|
|
899
898
|
end
|
|
900
899
|
|
|
901
900
|
# Returns a closure that looks up `:@<attr_name>` in the
|
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require_relative "../environment"
|
|
6
6
|
require_relative "../scope"
|
|
7
7
|
require_relative "../type"
|
|
8
|
+
require_relative "../source/literals"
|
|
8
9
|
require_relative "../inference/scope_indexer"
|
|
9
10
|
|
|
10
11
|
module Rigor
|
|
@@ -307,9 +308,8 @@ module Rigor
|
|
|
307
308
|
def binding_name_for(call_node)
|
|
308
309
|
first_arg = call_node.arguments&.arguments&.first
|
|
309
310
|
return call_node.name == :subject ? :subject : nil if first_arg.nil?
|
|
310
|
-
return first_arg.unescaped.to_sym if first_arg.is_a?(Prism::SymbolNode) || first_arg.is_a?(Prism::StringNode)
|
|
311
311
|
|
|
312
|
-
|
|
312
|
+
Source::Literals.symbol_or_string(first_arg)
|
|
313
313
|
end
|
|
314
314
|
|
|
315
315
|
def type_block_body(block_node, scope_index)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Source
|
|
7
|
+
# Extracts literal Symbol/String values from Prism call arguments.
|
|
8
|
+
#
|
|
9
|
+
# The "is this argument a literal `:sym` or `"str"`, and if so what
|
|
10
|
+
# Symbol does it name?" question recurs across the analyzer (sig-gen
|
|
11
|
+
# observation, attr-accessor generation, synthetic-method scanning)
|
|
12
|
+
# and across nearly every DSL plugin (`state :draft`,
|
|
13
|
+
# `has_one_attached :avatar`, `validate_presence_of(:name)`, …). This
|
|
14
|
+
# module is the one place that answers it, so the
|
|
15
|
+
# `node.unescaped.to_sym if SymbolNode || StringNode` shape is written
|
|
16
|
+
# once rather than copied per call site.
|
|
17
|
+
#
|
|
18
|
+
# `#unescaped` (not `#value`) is used deliberately so an interpolation-
|
|
19
|
+
# free `"foo"` / `:foo` round-trips to `:foo` consistently for both
|
|
20
|
+
# node kinds.
|
|
21
|
+
#
|
|
22
|
+
# The surface is a small grid over two axes — which node kinds are
|
|
23
|
+
# accepted (`SymbolNode` only, or `SymbolNode`/`StringNode`) and what
|
|
24
|
+
# the caller wants back (the interned `Symbol`, or the raw `String`
|
|
25
|
+
# name). The SymbolNode-only forms ({.symbol} / {.symbol_name}) exist
|
|
26
|
+
# so a DSL that distinguishes `state :draft` from `state "draft"`
|
|
27
|
+
# keeps that distinction instead of silently widening to accept the
|
|
28
|
+
# string literal.
|
|
29
|
+
#
|
|
30
|
+
# | accepts | → Symbol | → String |
|
|
31
|
+
# | ------------------ | ------------------- | ------------------------ |
|
|
32
|
+
# | `:sym` only | {.symbol} | {.symbol_name} |
|
|
33
|
+
# | `:sym` or `"str"` | {.symbol_or_string} | {.symbol_or_string_name} |
|
|
34
|
+
module Literals
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# The Symbol a literal `Prism::SymbolNode` / `Prism::StringNode`
|
|
38
|
+
# names, or `nil` for any other node (including `nil`).
|
|
39
|
+
#
|
|
40
|
+
# @param node [Prism::Node, nil]
|
|
41
|
+
# @return [Symbol, nil]
|
|
42
|
+
def symbol_or_string(node)
|
|
43
|
+
return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
44
|
+
|
|
45
|
+
node.unescaped.to_sym
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The String a literal `Prism::SymbolNode` / `Prism::StringNode`
|
|
49
|
+
# names, or `nil` for any other node (including `nil`). The
|
|
50
|
+
# String-returning sibling of {.symbol_or_string} — for callers
|
|
51
|
+
# that key on the raw name rather than the interned Symbol (route
|
|
52
|
+
# helpers, factory names, filter targets). `#unescaped` round-trips
|
|
53
|
+
# an interpolation-free `:foo` / `"foo"` to `"foo"` for both kinds.
|
|
54
|
+
#
|
|
55
|
+
# @param node [Prism::Node, nil]
|
|
56
|
+
# @return [String, nil]
|
|
57
|
+
def symbol_or_string_name(node)
|
|
58
|
+
return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
59
|
+
|
|
60
|
+
node.unescaped
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The Symbol a literal `Prism::SymbolNode` names, or `nil` for any
|
|
64
|
+
# other node (including a `Prism::StringNode` and `nil`). Stricter
|
|
65
|
+
# than {.symbol_or_string}: a DSL that accepts only `:draft` and
|
|
66
|
+
# not `"draft"` keeps that distinction by reaching for this rather
|
|
67
|
+
# than the Symbol-or-String form.
|
|
68
|
+
#
|
|
69
|
+
# @param node [Prism::Node, nil]
|
|
70
|
+
# @return [Symbol, nil]
|
|
71
|
+
def symbol(node)
|
|
72
|
+
return nil unless node.is_a?(Prism::SymbolNode)
|
|
73
|
+
|
|
74
|
+
node.unescaped.to_sym
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The String a literal `Prism::SymbolNode` names, or `nil` for any
|
|
78
|
+
# other node (including a `Prism::StringNode` and `nil`). The
|
|
79
|
+
# String-returning sibling of {.symbol} — SymbolNode-only, but the
|
|
80
|
+
# caller wants the raw name rather than the interned Symbol.
|
|
81
|
+
#
|
|
82
|
+
# @param node [Prism::Node, nil]
|
|
83
|
+
# @return [String, nil]
|
|
84
|
+
def symbol_name(node)
|
|
85
|
+
return nil unless node.is_a?(Prism::SymbolNode)
|
|
86
|
+
|
|
87
|
+
node.unescaped
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Every literal Symbol/String positional argument of a call, in
|
|
91
|
+
# source order. Non-literal arguments are dropped. Returns `[]` when
|
|
92
|
+
# the call has no argument list.
|
|
93
|
+
#
|
|
94
|
+
# @param call_node [Prism::CallNode, nil]
|
|
95
|
+
# @return [Array<Symbol>]
|
|
96
|
+
def symbol_arguments(call_node)
|
|
97
|
+
args = call_node&.arguments&.arguments
|
|
98
|
+
return [] if args.nil?
|
|
99
|
+
|
|
100
|
+
args.filter_map { |arg| symbol_or_string(arg) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The literal Symbol/String at positional `index`, or `nil` when the
|
|
104
|
+
# call has no argument list, the index is out of range, or the
|
|
105
|
+
# argument there is not a literal Symbol/String.
|
|
106
|
+
#
|
|
107
|
+
# @param call_node [Prism::CallNode, nil]
|
|
108
|
+
# @param index [Integer]
|
|
109
|
+
# @return [Symbol, nil]
|
|
110
|
+
def symbol_arg(call_node, index)
|
|
111
|
+
args = call_node&.arguments&.arguments
|
|
112
|
+
return nil if args.nil?
|
|
113
|
+
|
|
114
|
+
symbol_or_string(args[index])
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -32,6 +32,32 @@ module Rigor
|
|
|
32
32
|
yield node
|
|
33
33
|
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# Like {.each}, but also yields the node's lexical ancestor chain
|
|
37
|
+
# (outermost first, EXCLUDING the node itself). The yielded
|
|
38
|
+
# `ancestors` array is the live descent stack — callers that retain
|
|
39
|
+
# it past the block invocation MUST copy it (`Plugin::NodeContext`
|
|
40
|
+
# does). Used by the plugin engine to give `node_rule` blocks their
|
|
41
|
+
# enclosing class / method / block context (ADR-37 slice 1d).
|
|
42
|
+
#
|
|
43
|
+
# @yieldparam node [Prism::Node]
|
|
44
|
+
# @yieldparam ancestors [Array<Prism::Node>]
|
|
45
|
+
# @return [Enumerator] when no block is given.
|
|
46
|
+
def each_with_ancestors(root, &)
|
|
47
|
+
return to_enum(__method__, root) unless block_given?
|
|
48
|
+
|
|
49
|
+
walk_with_ancestors(root, [], &)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def walk_with_ancestors(node, ancestors, &block)
|
|
54
|
+
return unless node.is_a?(Prism::Node)
|
|
55
|
+
|
|
56
|
+
block.call(node, ancestors)
|
|
57
|
+
ancestors.push(node)
|
|
58
|
+
node.compact_child_nodes.each { |child| walk_with_ancestors(child, ancestors, &block) }
|
|
59
|
+
ancestors.pop
|
|
60
|
+
end
|
|
35
61
|
end
|
|
36
62
|
end
|
|
37
63
|
end
|
data/lib/rigor/source.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "difference"
|
|
|
14
14
|
require_relative "refined"
|
|
15
15
|
require_relative "intersection"
|
|
16
16
|
require_relative "bound_method"
|
|
17
|
+
require_relative "../inference/budget_trace"
|
|
17
18
|
|
|
18
19
|
module Rigor
|
|
19
20
|
module Type
|
|
@@ -360,7 +361,11 @@ module Rigor
|
|
|
360
361
|
# Normalized union. Flattens nested Unions, deduplicates structurally
|
|
361
362
|
# equal members, drops Bot, and collapses 0/1-member results.
|
|
362
363
|
def union(*types)
|
|
363
|
-
collapse_union(normalized_union_members(types))
|
|
364
|
+
result = collapse_union(normalized_union_members(types))
|
|
365
|
+
if Inference::BudgetTrace.enabled? && result.is_a?(Union)
|
|
366
|
+
Inference::BudgetTrace.observe(Inference::BudgetTrace::UNION_ARITY, result.members.size)
|
|
367
|
+
end
|
|
368
|
+
result
|
|
364
369
|
end
|
|
365
370
|
|
|
366
371
|
# `key_of[T]` type function — projects the type-level
|
data/lib/rigor/type/union.rb
CHANGED
|
@@ -24,8 +24,31 @@ module Rigor
|
|
|
24
24
|
freeze
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Display-only adoption of two concise RBS spellings for the
|
|
28
|
+
# union (see docs/type-specification/normalization.md § "Interaction
|
|
29
|
+
# with display" and rbs-compatible-types.md § "Optionals"). Both are
|
|
30
|
+
# purely cosmetic: `@members` keeps every carrier verbatim, so the
|
|
31
|
+
# underlying type identity, RBS erasure, and round-trip are unchanged
|
|
32
|
+
# — only the human-facing rendering reads like the RBS the user wrote.
|
|
33
|
+
#
|
|
34
|
+
# * `true | false` → `bool` (the RBS boolean alias). The
|
|
35
|
+
# `bool` token leads the rendering, so `false | Foo | true` reads
|
|
36
|
+
# as `bool | Foo` rather than burying the pair mid-list.
|
|
37
|
+
# * `T | nil` → `T?` (the RBS optional sugar). Only
|
|
38
|
+
# applied when exactly one *logical* member remains beside `nil`,
|
|
39
|
+
# matching the rbs gem's own `to_s`: a multi-member union such as
|
|
40
|
+
# `Integer | String | nil` stays explicit rather than gaining a
|
|
41
|
+
# parenthesised `(Integer | String)?`. The two collapses compose,
|
|
42
|
+
# so `false | true | nil` reads as `bool?`.
|
|
27
43
|
def describe(verbosity = :short)
|
|
28
|
-
|
|
44
|
+
return "#{optional_inner(verbosity)}?" if optional?
|
|
45
|
+
|
|
46
|
+
if boolean_pair?
|
|
47
|
+
rest = members.reject { |m| boolean_literal?(m) }
|
|
48
|
+
["bool", *rest.map { |m| m.describe(verbosity) }].join(" | ")
|
|
49
|
+
else
|
|
50
|
+
members.map { |m| m.describe(verbosity) }.join(" | ")
|
|
51
|
+
end
|
|
29
52
|
end
|
|
30
53
|
|
|
31
54
|
# ADR-1 § "RBS round-trip is lossless" + the value-lattice
|
|
@@ -79,6 +102,47 @@ module Rigor
|
|
|
79
102
|
def inspect
|
|
80
103
|
"#<Rigor::Type::Union #{describe(:short)}>"
|
|
81
104
|
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Both `true` and `false` literals are present, so the pair can
|
|
109
|
+
# render as `bool`. A union carrying only one of them stays a
|
|
110
|
+
# plain literal (`true` / `false`) — that asymmetry is meaningful.
|
|
111
|
+
def boolean_pair?
|
|
112
|
+
members.any? { |m| boolean_literal?(m, true) } &&
|
|
113
|
+
members.any? { |m| boolean_literal?(m, false) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def boolean_literal?(member, which = :either)
|
|
117
|
+
return false unless member.is_a?(Constant)
|
|
118
|
+
|
|
119
|
+
case which
|
|
120
|
+
when :either then member.value.equal?(true) || member.value.equal?(false)
|
|
121
|
+
else member.value.equal?(which)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def nil_literal?(member)
|
|
126
|
+
member.is_a?(Constant) && member.value.nil?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# `nil` is present and, once the `bool` pair is treated as a
|
|
130
|
+
# single logical member, exactly one non-`nil` member remains —
|
|
131
|
+
# so the whole union renders as `T?`. Counting the bool pair as
|
|
132
|
+
# one is what lets `false | true | nil` reach `bool?`.
|
|
133
|
+
def optional?
|
|
134
|
+
return false unless members.any? { |m| nil_literal?(m) }
|
|
135
|
+
|
|
136
|
+
significant = members.reject { |m| nil_literal?(m) }
|
|
137
|
+
logical = significant.size - (boolean_pair? ? 1 : 0)
|
|
138
|
+
logical == 1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def optional_inner(verbosity)
|
|
142
|
+
return "bool" if boolean_pair?
|
|
143
|
+
|
|
144
|
+
members.find { |m| !nil_literal?(m) }.describe(verbosity)
|
|
145
|
+
end
|
|
82
146
|
end
|
|
83
147
|
end
|
|
84
148
|
end
|
data/lib/rigor/version.rb
CHANGED
data/lib/rigor.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "rigor/ast"
|
|
|
9
9
|
require_relative "rigor/environment"
|
|
10
10
|
require_relative "rigor/rbs_extended"
|
|
11
11
|
require_relative "rigor/testing"
|
|
12
|
+
require_relative "rigor/inference/budget_trace"
|
|
12
13
|
require_relative "rigor/inference/fallback"
|
|
13
14
|
require_relative "rigor/inference/fallback_tracer"
|
|
14
15
|
require_relative "rigor/inference/acceptance"
|
|
@@ -33,35 +33,31 @@ module Rigor
|
|
|
33
33
|
::ActionCable.server
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# One broadcast observation. Carries no path/location — the
|
|
37
|
+
# caller (the `node_rule` block) positions it via
|
|
38
|
+
# `Plugin::Base#diagnostic`.
|
|
39
|
+
Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
|
|
37
40
|
|
|
38
41
|
module_function
|
|
39
42
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
43
|
+
# The broadcast violations for a single call node, or `[]` when
|
|
44
|
+
# the node is not a `broadcast_to` / `ActionCable.server.broadcast`
|
|
45
|
+
# call this plugin recognises. ADR-37: the engine owns the walk.
|
|
46
|
+
#
|
|
47
|
+
# @param call_node [Prism::Node]
|
|
42
48
|
# @param channel_index [ChannelIndex]
|
|
43
|
-
# @return [Array<
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
diagnostics.concat(analyse_server_broadcast(path, call_node, channel_index))
|
|
52
|
-
end
|
|
49
|
+
# @return [Array<Violation>]
|
|
50
|
+
def violations_for(call_node:, channel_index:)
|
|
51
|
+
return [] unless call_node.is_a?(Prism::CallNode)
|
|
52
|
+
|
|
53
|
+
case call_node.name
|
|
54
|
+
when :broadcast_to then analyse_broadcast_to(call_node, channel_index)
|
|
55
|
+
when :broadcast then analyse_server_broadcast(call_node, channel_index)
|
|
56
|
+
else []
|
|
53
57
|
end
|
|
54
|
-
diagnostics
|
|
55
58
|
end
|
|
56
59
|
|
|
57
|
-
def
|
|
58
|
-
return unless node.is_a?(Prism::Node)
|
|
59
|
-
|
|
60
|
-
yield node if node.is_a?(Prism::CallNode)
|
|
61
|
-
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def analyse_broadcast_to(path, call_node, channel_index)
|
|
60
|
+
def analyse_broadcast_to(call_node, channel_index)
|
|
65
61
|
class_name = constant_receiver_name(call_node.receiver)
|
|
66
62
|
return [] if class_name.nil?
|
|
67
63
|
|
|
@@ -72,12 +68,12 @@ module Rigor
|
|
|
72
68
|
return [] unless class_name.end_with?("Channel")
|
|
73
69
|
|
|
74
70
|
entry = channel_index.find(class_name) || channel_index.find("::#{class_name}")
|
|
75
|
-
return [
|
|
71
|
+
return [unknown_channel_violation(class_name, channel_index)] if entry.nil?
|
|
76
72
|
|
|
77
|
-
[broadcast_target_info(
|
|
73
|
+
[broadcast_target_info(entry)]
|
|
78
74
|
end
|
|
79
75
|
|
|
80
|
-
def analyse_server_broadcast(
|
|
76
|
+
def analyse_server_broadcast(call_node, channel_index)
|
|
81
77
|
receiver_path = call_chain_string(call_node.receiver)
|
|
82
78
|
return [] unless SERVER_BROADCAST_RECEIVER_NAMES.include?(receiver_path)
|
|
83
79
|
|
|
@@ -88,60 +84,42 @@ module Rigor
|
|
|
88
84
|
return [] if channel_index.any_dynamic_streams?
|
|
89
85
|
|
|
90
86
|
stream_name = stream_arg.unescaped
|
|
91
|
-
if channel_index.all_stream_names.include?(stream_name)
|
|
92
|
-
return [server_broadcast_info(path, call_node, stream_name)]
|
|
93
|
-
end
|
|
87
|
+
return [server_broadcast_info(stream_name)] if channel_index.all_stream_names.include?(stream_name)
|
|
94
88
|
|
|
95
|
-
[
|
|
89
|
+
[unknown_stream_violation(stream_name, channel_index)]
|
|
96
90
|
end
|
|
97
91
|
|
|
98
|
-
def broadcast_target_info(
|
|
99
|
-
|
|
100
|
-
Diagnostic.new(
|
|
101
|
-
path: path,
|
|
102
|
-
line: location.start_line,
|
|
103
|
-
column: location.start_column + 1,
|
|
92
|
+
def broadcast_target_info(entry)
|
|
93
|
+
Violation.new(
|
|
104
94
|
severity: :info,
|
|
105
95
|
rule: "broadcast-target",
|
|
106
96
|
message: "`#{entry.class_name}.broadcast_to(...)` matches discovered channel"
|
|
107
97
|
)
|
|
108
98
|
end
|
|
109
99
|
|
|
110
|
-
def server_broadcast_info(
|
|
111
|
-
|
|
112
|
-
Diagnostic.new(
|
|
113
|
-
path: path,
|
|
114
|
-
line: location.start_line,
|
|
115
|
-
column: location.start_column + 1,
|
|
100
|
+
def server_broadcast_info(stream_name)
|
|
101
|
+
Violation.new(
|
|
116
102
|
severity: :info,
|
|
117
103
|
rule: "broadcast-stream",
|
|
118
104
|
message: "`broadcast(\"#{stream_name}\", ...)` matches a registered `stream_from`"
|
|
119
105
|
)
|
|
120
106
|
end
|
|
121
107
|
|
|
122
|
-
def
|
|
123
|
-
location = call_node.location
|
|
108
|
+
def unknown_channel_violation(class_name, channel_index)
|
|
124
109
|
suggestions = DidYouMean::SpellChecker.new(dictionary: channel_index.names).correct(class_name)
|
|
125
110
|
suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
|
|
126
|
-
|
|
127
|
-
path: path,
|
|
128
|
-
line: location.start_line,
|
|
129
|
-
column: location.start_column + 1,
|
|
111
|
+
Violation.new(
|
|
130
112
|
severity: :error,
|
|
131
113
|
rule: "unknown-channel",
|
|
132
114
|
message: "no ActionCable channel `#{class_name}`#{suggestion_part}"
|
|
133
115
|
)
|
|
134
116
|
end
|
|
135
117
|
|
|
136
|
-
def
|
|
137
|
-
location = call_node.location
|
|
118
|
+
def unknown_stream_violation(stream_name, channel_index)
|
|
138
119
|
dictionary = channel_index.all_stream_names.to_a
|
|
139
120
|
suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(stream_name)
|
|
140
121
|
suggestion_part = suggestions.empty? ? "" : " (did you mean `\"#{suggestions.first}\"`?)"
|
|
141
|
-
|
|
142
|
-
path: path,
|
|
143
|
-
line: location.start_line,
|
|
144
|
-
column: location.start_column + 1,
|
|
122
|
+
Violation.new(
|
|
145
123
|
severity: :warning,
|
|
146
124
|
rule: "unknown-stream",
|
|
147
125
|
message: "no `stream_from \"#{stream_name}\"` registration in any discovered " \
|
|
@@ -62,17 +62,14 @@ module Rigor
|
|
|
62
62
|
version: "0.1.0",
|
|
63
63
|
description: "Validates ActionCable broadcast call shape against discovered channels.",
|
|
64
64
|
config_schema: {
|
|
65
|
-
"channel_search_paths" => :array,
|
|
66
|
-
"channel_base_classes" =>
|
|
65
|
+
"channel_search_paths" => { kind: :array, default: ["app/channels"] },
|
|
66
|
+
"channel_base_classes" => {
|
|
67
|
+
kind: :array,
|
|
68
|
+
default: ["ApplicationCable::Channel", "ActionCable::Channel::Base"]
|
|
69
|
+
}
|
|
67
70
|
}
|
|
68
71
|
)
|
|
69
72
|
|
|
70
|
-
DEFAULT_CHANNEL_SEARCH_PATHS = ["app/channels"].freeze
|
|
71
|
-
DEFAULT_CHANNEL_BASE_CLASSES = [
|
|
72
|
-
"ApplicationCable::Channel",
|
|
73
|
-
"ActionCable::Channel::Base"
|
|
74
|
-
].freeze
|
|
75
|
-
|
|
76
73
|
producer :channel_index do |_params|
|
|
77
74
|
ChannelDiscoverer.new(
|
|
78
75
|
io_boundary: io_boundary,
|
|
@@ -82,22 +79,30 @@ module Rigor
|
|
|
82
79
|
end
|
|
83
80
|
|
|
84
81
|
def init(_services)
|
|
85
|
-
@channel_search_paths = Array(
|
|
86
|
-
|
|
87
|
-
).map(&:to_s)
|
|
88
|
-
@channel_base_classes = Array(
|
|
89
|
-
config.fetch("channel_base_classes", DEFAULT_CHANNEL_BASE_CLASSES)
|
|
90
|
-
).map(&:to_s)
|
|
82
|
+
@channel_search_paths = Array(config.fetch("channel_search_paths")).map(&:to_s)
|
|
83
|
+
@channel_base_classes = Array(config.fetch("channel_base_classes")).map(&:to_s)
|
|
91
84
|
@channel_index = nil
|
|
92
85
|
@load_error = nil
|
|
93
86
|
end
|
|
94
87
|
|
|
88
|
+
# File-level only: the load-error emission. Per-call broadcast
|
|
89
|
+
# validation runs over the engine-owned walk via the node_rule
|
|
90
|
+
# below (ADR-37). The channel index is lazily loaded + memoised by
|
|
91
|
+
# channel_index_or_nil, shared by both surfaces.
|
|
95
92
|
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
96
93
|
index = channel_index_or_nil
|
|
97
94
|
return [load_error_diagnostic(path)] if index.nil? && @load_error
|
|
98
|
-
return [] if index.nil? || index.empty?
|
|
99
95
|
|
|
100
|
-
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
node_rule Prism::CallNode do |node, _scope, path|
|
|
100
|
+
index = channel_index_or_nil
|
|
101
|
+
next [] if index.nil? || index.empty?
|
|
102
|
+
|
|
103
|
+
Analyzer.violations_for(call_node: node, channel_index: index).map do |violation|
|
|
104
|
+
diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
|
|
105
|
+
end
|
|
101
106
|
end
|
|
102
107
|
|
|
103
108
|
private
|
|
@@ -128,13 +133,6 @@ module Rigor
|
|
|
128
133
|
rule: "load-error"
|
|
129
134
|
)
|
|
130
135
|
end
|
|
131
|
-
|
|
132
|
-
def build_diagnostic(diag)
|
|
133
|
-
Rigor::Analysis::Diagnostic.new(
|
|
134
|
-
path: diag.path, line: diag.line, column: diag.column,
|
|
135
|
-
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
136
|
-
)
|
|
137
|
-
end
|
|
138
136
|
end
|
|
139
137
|
|
|
140
138
|
Rigor::Plugin.register(Actioncable)
|
|
@@ -45,53 +45,44 @@ module Rigor
|
|
|
45
45
|
method instance_method methods
|
|
46
46
|
].freeze
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# One mailer-call observation. Carries no path/location — the
|
|
49
|
+
# caller (the `node_rule` block) positions it via
|
|
50
|
+
# `Plugin::Base#diagnostic`.
|
|
51
|
+
Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
|
|
49
52
|
|
|
50
53
|
module_function
|
|
51
54
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
55
|
+
# The mailer-call violations for a single call node (0..2), or
|
|
56
|
+
# `[]` when it is not a `<Mailer>.action(...)` call on a known
|
|
57
|
+
# mailer. ADR-37: the engine owns the walk.
|
|
58
|
+
#
|
|
59
|
+
# @param call_node [Prism::Node]
|
|
54
60
|
# @param mailer_index [MailerIndex]
|
|
55
|
-
# @return [Array<
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
|
|
64
|
-
next if class_entry.nil?
|
|
65
|
-
|
|
66
|
-
action_entry = class_entry.find_action(call_node.name)
|
|
67
|
-
if action_entry.nil?
|
|
68
|
-
# Skip `unknown-action` when the mailer's include
|
|
69
|
-
# set has any unresolved module — the unresolved
|
|
70
|
-
# module may legitimately define the action
|
|
71
|
-
# (gem-shipped concern, dynamically loaded
|
|
72
|
-
# mailer extension). Mirrors the same predicate
|
|
73
|
-
# `rigor-actionpack` uses for unknown-filter-method.
|
|
74
|
-
next if class_entry.unresolved_includes?
|
|
75
|
-
|
|
76
|
-
diagnostics << unknown_action_diagnostic(path, call_node, class_entry)
|
|
77
|
-
next
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
diagnostics << action_call_info(path, call_node, class_entry, action_entry)
|
|
81
|
-
arity_diag = arity_check(path, call_node, class_entry, action_entry)
|
|
82
|
-
diagnostics << arity_diag if arity_diag
|
|
83
|
-
end
|
|
84
|
-
diagnostics
|
|
85
|
-
end
|
|
61
|
+
# @return [Array<Violation>]
|
|
62
|
+
def violations_for(call_node:, mailer_index:)
|
|
63
|
+
return [] unless call_node.is_a?(Prism::CallNode) && action_call_candidate?(call_node)
|
|
64
|
+
|
|
65
|
+
class_name = mailer_class_for_call(call_node)
|
|
66
|
+
return [] if class_name.nil?
|
|
67
|
+
return [] if RESERVED_CLASS_METHODS.include?(call_node.name)
|
|
86
68
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
69
|
+
class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
|
|
70
|
+
return [] if class_entry.nil?
|
|
71
|
+
|
|
72
|
+
action_entry = class_entry.find_action(call_node.name)
|
|
73
|
+
if action_entry.nil?
|
|
74
|
+
# Skip `unknown-action` when the mailer's include set has any
|
|
75
|
+
# unresolved module — it may legitimately define the action
|
|
76
|
+
# (gem-shipped concern, dynamically loaded extension).
|
|
77
|
+
return [] if class_entry.unresolved_includes?
|
|
78
|
+
|
|
79
|
+
return [unknown_action_violation(call_node, class_entry)]
|
|
80
|
+
end
|
|
92
81
|
|
|
93
|
-
|
|
94
|
-
|
|
82
|
+
violations = [action_call_info(call_node, class_entry, action_entry)]
|
|
83
|
+
arity = arity_violation(call_node, class_entry, action_entry)
|
|
84
|
+
violations << arity if arity
|
|
85
|
+
violations
|
|
95
86
|
end
|
|
96
87
|
|
|
97
88
|
def action_call_candidate?(node)
|
|
@@ -120,12 +111,8 @@ module Rigor
|
|
|
120
111
|
end
|
|
121
112
|
end
|
|
122
113
|
|
|
123
|
-
def action_call_info(
|
|
124
|
-
|
|
125
|
-
Diagnostic.new(
|
|
126
|
-
path: path,
|
|
127
|
-
line: location.start_line,
|
|
128
|
-
column: location.start_column + 1,
|
|
114
|
+
def action_call_info(_call_node, class_entry, action_entry)
|
|
115
|
+
Violation.new(
|
|
129
116
|
severity: :info,
|
|
130
117
|
rule: "mailer-call",
|
|
131
118
|
message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
|
|
@@ -133,7 +120,7 @@ module Rigor
|
|
|
133
120
|
)
|
|
134
121
|
end
|
|
135
122
|
|
|
136
|
-
def
|
|
123
|
+
def arity_violation(call_node, class_entry, action_entry)
|
|
137
124
|
args = call_node.arguments&.arguments || []
|
|
138
125
|
actual = args.size
|
|
139
126
|
return nil if action_entry.accepts?(actual)
|
|
@@ -147,11 +134,7 @@ module Rigor
|
|
|
147
134
|
# carrying calls don't surface as wrong-arity.
|
|
148
135
|
return nil if args.last.is_a?(Prism::KeywordHashNode) && action_entry.accepts?(actual - 1)
|
|
149
136
|
|
|
150
|
-
|
|
151
|
-
Diagnostic.new(
|
|
152
|
-
path: path,
|
|
153
|
-
line: location.start_line,
|
|
154
|
-
column: location.start_column + 1,
|
|
137
|
+
Violation.new(
|
|
155
138
|
severity: :error,
|
|
156
139
|
rule: "wrong-arity",
|
|
157
140
|
message: "`#{class_entry.class_name}.#{action_entry.method_name}` " \
|
|
@@ -159,14 +142,10 @@ module Rigor
|
|
|
159
142
|
)
|
|
160
143
|
end
|
|
161
144
|
|
|
162
|
-
def
|
|
163
|
-
location = call_node.location
|
|
145
|
+
def unknown_action_violation(call_node, class_entry)
|
|
164
146
|
known = class_entry.actions.keys.sort.join(", ")
|
|
165
147
|
known_part = known.empty? ? "no actions defined" : "known actions: #{known}"
|
|
166
|
-
|
|
167
|
-
path: path,
|
|
168
|
-
line: location.start_line,
|
|
169
|
-
column: location.start_column + 1,
|
|
148
|
+
Violation.new(
|
|
170
149
|
severity: :error,
|
|
171
150
|
rule: "unknown-action",
|
|
172
151
|
message: "`#{class_entry.class_name}.#{call_node.name}` is not a defined " \
|