rigortype 0.1.14 → 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 +10 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +428 -6
- data/lib/rigor/analysis/diagnostic.rb +55 -3
- data/lib/rigor/analysis/rule_catalog.rb +80 -0
- data/lib/rigor/analysis/runner.rb +71 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugin_command.rb +245 -0
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +143 -5
- data/lib/rigor/configuration/severity_profile.rb +9 -0
- 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 +184 -27
- 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/scope.rb +27 -1
- 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/triage/catalogue.rb +71 -5
- 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/scope.rbs +3 -0
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +33 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- data/skills/rigor-project-init/SKILL.md +72 -7
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
- metadata +53 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
data/lib/rigor/scope.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Rigor
|
|
|
20
20
|
:ivars, :cvars, :globals,
|
|
21
21
|
:class_ivars, :class_cvars, :program_globals,
|
|
22
22
|
:discovered_classes, :in_source_constants, :discovered_methods,
|
|
23
|
-
:discovered_def_nodes, :discovered_method_visibilities,
|
|
23
|
+
:discovered_def_nodes, :discovered_def_sources, :discovered_method_visibilities,
|
|
24
24
|
:discovered_superclasses, :discovered_includes,
|
|
25
25
|
:indexed_narrowings, :method_chain_narrowings,
|
|
26
26
|
:source_path
|
|
@@ -89,6 +89,7 @@ module Rigor
|
|
|
89
89
|
in_source_constants: EMPTY_VAR_BINDINGS,
|
|
90
90
|
discovered_methods: EMPTY_CLASS_BINDINGS,
|
|
91
91
|
discovered_def_nodes: EMPTY_CLASS_BINDINGS,
|
|
92
|
+
discovered_def_sources: EMPTY_CLASS_BINDINGS,
|
|
92
93
|
discovered_method_visibilities: EMPTY_CLASS_BINDINGS,
|
|
93
94
|
discovered_superclasses: EMPTY_CLASS_BINDINGS,
|
|
94
95
|
discovered_includes: EMPTY_CLASS_BINDINGS,
|
|
@@ -111,6 +112,7 @@ module Rigor
|
|
|
111
112
|
@in_source_constants = in_source_constants
|
|
112
113
|
@discovered_methods = discovered_methods
|
|
113
114
|
@discovered_def_nodes = discovered_def_nodes
|
|
115
|
+
@discovered_def_sources = discovered_def_sources
|
|
114
116
|
@discovered_method_visibilities = discovered_method_visibilities
|
|
115
117
|
@discovered_superclasses = discovered_superclasses
|
|
116
118
|
@discovered_includes = discovered_includes
|
|
@@ -361,6 +363,27 @@ module Rigor
|
|
|
361
363
|
rebuild(discovered_def_nodes: table)
|
|
362
364
|
end
|
|
363
365
|
|
|
366
|
+
# Companion to {#user_def_for}: returns the `"path:line"` where
|
|
367
|
+
# the project defines `class_name#method_name` (instance-side),
|
|
368
|
+
# or nil. Populated only by the cross-file project pre-pass
|
|
369
|
+
# ({Inference::ScopeIndexer.discovered_def_index_for_paths}) — a
|
|
370
|
+
# `Prism::Location` hides its source file, so the site is recorded
|
|
371
|
+
# at scan time. `CheckRules#undefined_method_diagnostic` consults
|
|
372
|
+
# this to name the defining file when a project monkey-patch on a
|
|
373
|
+
# core/stdlib/gem class is called cross-file, so the diagnostic
|
|
374
|
+
# can point at `pre_eval:` (ADR-17) instead of reading as a bare
|
|
375
|
+
# unresolved call.
|
|
376
|
+
def user_def_site_for(class_name, method_name)
|
|
377
|
+
table = @discovered_def_sources[class_name.to_s]
|
|
378
|
+
return nil unless table
|
|
379
|
+
|
|
380
|
+
table[method_name.to_sym]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def with_discovered_def_sources(table)
|
|
384
|
+
rebuild(discovered_def_sources: table)
|
|
385
|
+
end
|
|
386
|
+
|
|
364
387
|
# ADR-24 slice 2 — per-class table mapping a fully
|
|
365
388
|
# qualified user-class name to its superclass name AS
|
|
366
389
|
# WRITTEN at the `class Foo < Bar` declaration (`"Bar"`,
|
|
@@ -558,6 +581,7 @@ module Rigor
|
|
|
558
581
|
class_ivars: @class_ivars, class_cvars: @class_cvars, program_globals: @program_globals,
|
|
559
582
|
discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
|
|
560
583
|
discovered_methods: @discovered_methods, discovered_def_nodes: @discovered_def_nodes,
|
|
584
|
+
discovered_def_sources: @discovered_def_sources,
|
|
561
585
|
discovered_method_visibilities: @discovered_method_visibilities,
|
|
562
586
|
discovered_superclasses: @discovered_superclasses,
|
|
563
587
|
discovered_includes: @discovered_includes,
|
|
@@ -576,6 +600,7 @@ module Rigor
|
|
|
576
600
|
in_source_constants: in_source_constants,
|
|
577
601
|
discovered_methods: discovered_methods,
|
|
578
602
|
discovered_def_nodes: discovered_def_nodes,
|
|
603
|
+
discovered_def_sources: discovered_def_sources,
|
|
579
604
|
discovered_method_visibilities: discovered_method_visibilities,
|
|
580
605
|
discovered_superclasses: discovered_superclasses,
|
|
581
606
|
discovered_includes: discovered_includes,
|
|
@@ -607,6 +632,7 @@ module Rigor
|
|
|
607
632
|
in_source_constants: in_source_constants,
|
|
608
633
|
discovered_methods: discovered_methods,
|
|
609
634
|
discovered_def_nodes: discovered_def_nodes,
|
|
635
|
+
discovered_def_sources: discovered_def_sources,
|
|
610
636
|
discovered_method_visibilities: discovered_method_visibilities,
|
|
611
637
|
discovered_superclasses: discovered_superclasses,
|
|
612
638
|
discovered_includes: discovered_includes,
|
|
@@ -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
|
@@ -22,6 +22,7 @@ module Rigor
|
|
|
22
22
|
module_function
|
|
23
23
|
|
|
24
24
|
UNDEFINED_METHOD_RULE = "call.undefined-method"
|
|
25
|
+
UNRESOLVED_TOPLEVEL_RULE = "call.unresolved-toplevel"
|
|
25
26
|
|
|
26
27
|
# `undefined method `foo' for <receiver>`
|
|
27
28
|
UNDEF_METHOD = /\Aundefined method [`'"]([^`'"]+)['"`] for (.+)\z/
|
|
@@ -104,9 +105,19 @@ module Rigor
|
|
|
104
105
|
# monkey-patch): a known AR method on `Array[...]` deserves
|
|
105
106
|
# the precise relation-misinference hint, not the generic
|
|
106
107
|
# "project core-ext" guess H2 would otherwise claim it for.
|
|
108
|
+
#
|
|
109
|
+
# H2K (known project patch) runs before the generic H2: the
|
|
110
|
+
# engine has already proved the defining site via the
|
|
111
|
+
# `project_definition_site` field (ADR-17), so those
|
|
112
|
+
# diagnostics get the high-confidence file-naming hint rather
|
|
113
|
+
# than the spread-based guess. H7 (unresolved toplevel) runs
|
|
114
|
+
# before the systemic / genuine-bug catch-alls so toplevel
|
|
115
|
+
# resolution misses route to `pre_eval:` (ADR-34) instead of
|
|
116
|
+
# reading as scattered bugs.
|
|
107
117
|
def recognisers
|
|
108
118
|
%i[h1_activesupport h4_ar_relation h3_gem_without_rbs
|
|
109
|
-
h2_monkey_patch
|
|
119
|
+
h2k_known_project_patch h2_monkey_patch h7_unresolved_toplevel
|
|
120
|
+
h5_systemic_cluster h6_genuine_bugs]
|
|
110
121
|
end
|
|
111
122
|
|
|
112
123
|
# --- H1 — likely ActiveSupport core_ext --------------------
|
|
@@ -127,6 +138,31 @@ module Rigor
|
|
|
127
138
|
), matched]
|
|
128
139
|
end
|
|
129
140
|
|
|
141
|
+
# --- H2K — known project monkey-patch (engine-proven) ------
|
|
142
|
+
# ADR-17 / WD3 slice 4: the `call.undefined-method` rule sets
|
|
143
|
+
# `project_definition_site` when the project itself defines the
|
|
144
|
+
# called method on the receiver class somewhere in the file set
|
|
145
|
+
# (a reopened core/stdlib/gem class the dispatcher does not
|
|
146
|
+
# apply cross-file). That is direct evidence — not a spread
|
|
147
|
+
# heuristic — so this recogniser is `:likely` and names the
|
|
148
|
+
# defining files outright. It runs before the generic H2.
|
|
149
|
+
def h2k_known_project_patch(pool)
|
|
150
|
+
matched = pool.select(&:project_definition_site)
|
|
151
|
+
return nil if matched.empty?
|
|
152
|
+
|
|
153
|
+
files = matched.map { |d| d.project_definition_site.sub(/:\d+\z/, "") }
|
|
154
|
+
.uniq.sort
|
|
155
|
+
[Hint.new(
|
|
156
|
+
id: "project-monkey-patch-known", confidence: :likely,
|
|
157
|
+
diagnostic_count: matched.size,
|
|
158
|
+
summary: "#{matched.size} undefined-method site(s) resolve to project " \
|
|
159
|
+
"definitions in #{files.first(3).join(', ')} — reopened core/" \
|
|
160
|
+
"stdlib/gem classes Rigor does not apply cross-file",
|
|
161
|
+
action: "List #{files.size == 1 ? 'this file' : 'these files'} in " \
|
|
162
|
+
"`.rigor.yml`'s `pre_eval:` (ADR-17): #{files.join(', ')}"
|
|
163
|
+
), matched]
|
|
164
|
+
end
|
|
165
|
+
|
|
130
166
|
# --- H2 — likely a project monkey-patch / refinement -------
|
|
131
167
|
def h2_monkey_patch(pool)
|
|
132
168
|
groups = undefined_method_groups(pool).select do |(_method, _recv), diags|
|
|
@@ -179,6 +215,30 @@ module Rigor
|
|
|
179
215
|
), matched]
|
|
180
216
|
end
|
|
181
217
|
|
|
218
|
+
# --- H7 — unresolved toplevel implicit-self calls ----------
|
|
219
|
+
# ADR-34: `call.unresolved-toplevel` fires on a toplevel
|
|
220
|
+
# implicit-self call (no receiver, outside any def / class /
|
|
221
|
+
# module) that resolves against no visible contributor. The
|
|
222
|
+
# canonical opt-out is `pre_eval:` — the file is usually a
|
|
223
|
+
# script relying on methods defined by a monkey-patch or a
|
|
224
|
+
# required helper Rigor did not walk. Grouped, not per-site,
|
|
225
|
+
# so the report names the cluster once.
|
|
226
|
+
def h7_unresolved_toplevel(pool)
|
|
227
|
+
matched = pool.select { |d| rule_of(d) == UNRESOLVED_TOPLEVEL_RULE }
|
|
228
|
+
return nil if matched.empty?
|
|
229
|
+
|
|
230
|
+
files = matched.map(&:path).uniq.sort
|
|
231
|
+
[Hint.new(
|
|
232
|
+
id: "unresolved-toplevel", confidence: :possible,
|
|
233
|
+
diagnostic_count: matched.size,
|
|
234
|
+
summary: "#{matched.size} toplevel call(s) resolve to nothing visible " \
|
|
235
|
+
"across #{files.size} file(s) (#{top_methods(matched, parser: :toplevel)})",
|
|
236
|
+
action: "If a monkey-patch or required helper defines these, list its " \
|
|
237
|
+
"file in `.rigor.yml`'s `pre_eval:` (ADR-17); otherwise they may " \
|
|
238
|
+
"be genuine typos or missing requires."
|
|
239
|
+
), matched]
|
|
240
|
+
end
|
|
241
|
+
|
|
182
242
|
# --- H5 — systemic single-file cluster ---------------------
|
|
183
243
|
def h5_systemic_cluster(pool)
|
|
184
244
|
bucket = pool.group_by { |d| [d.path, rule_of(d)] }
|
|
@@ -282,10 +342,16 @@ module Rigor
|
|
|
282
342
|
groups.keys.first(3).map { |method, recv| "`#{method}` on #{recv}" }.join(", ")
|
|
283
343
|
end
|
|
284
344
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
345
|
+
# `parser: :undefined_method` (default) reads the method from
|
|
346
|
+
# the parsed `undefined-method` shape; `parser: :toplevel`
|
|
347
|
+
# reads the structured `method_name` field directly (the
|
|
348
|
+
# `unresolved-toplevel` rule carries no receiver to parse).
|
|
349
|
+
def top_methods(diagnostics, limit: 5, parser: :undefined_method)
|
|
350
|
+
names = diagnostics.filter_map do |d|
|
|
351
|
+
parser == :toplevel ? d.method_name : parse_undefined_method(d)&.fetch(:method)
|
|
352
|
+
end
|
|
353
|
+
names.tally.sort_by { |method, count| [-count, method] }
|
|
354
|
+
.first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
|
|
289
355
|
end
|
|
290
356
|
|
|
291
357
|
def rule_of(diag)
|
|
@@ -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 " \
|