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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
# Opt-in counters for the hard-coded inference cutoffs — the
|
|
6
|
+
# "budget" guards that silently return `Dynamic[top]` / `nil` /
|
|
7
|
+
# a fallback bound rather than emitting a diagnostic. These are
|
|
8
|
+
# the *operative* cutoffs in the engine today (the configurable
|
|
9
|
+
# `budgets:` table in docs/type-specification/inference-budgets.md
|
|
10
|
+
# is not yet wired); counting how often each fires on a real
|
|
11
|
+
# project is the only way to see where inference actually stops.
|
|
12
|
+
#
|
|
13
|
+
# Three categories, one per guard site:
|
|
14
|
+
#
|
|
15
|
+
# - {RECURSION_GUARD} — `ExpressionTyper#infer_user_method_return`
|
|
16
|
+
# detected a `(receiver, method)` cycle and returned
|
|
17
|
+
# `Dynamic[top]` (the de-facto recursion-depth budget, effective
|
|
18
|
+
# depth 1).
|
|
19
|
+
# - {ANCESTOR_WALK_LIMIT} — `resolve_user_def_through_ancestors`
|
|
20
|
+
# hit the 100-node BFS cap and gave up resolving the self-call.
|
|
21
|
+
# - {HKT_FUEL_EXHAUSTED} — `HktReducer` ran out of its reduction
|
|
22
|
+
# fuel budget and unwound to `app.bound`.
|
|
23
|
+
#
|
|
24
|
+
# Enabled only when `RIGOR_BUDGET_TRACE` is set (to any non-empty
|
|
25
|
+
# value) in the environment, or via {enable!} in tests. When
|
|
26
|
+
# disabled, {hit} is a single boolean check and returns
|
|
27
|
+
# immediately, so normal runs pay nothing.
|
|
28
|
+
#
|
|
29
|
+
# Counters are process-global (Mutex-guarded) so they aggregate
|
|
30
|
+
# across threads, but they do NOT cross `fork` boundaries — run
|
|
31
|
+
# `rigor check --workers 0` to keep all inference in one process
|
|
32
|
+
# when collecting a trace.
|
|
33
|
+
module BudgetTrace
|
|
34
|
+
RECURSION_GUARD = :recursion_guard
|
|
35
|
+
ANCESTOR_WALK_LIMIT = :ancestor_walk_limit
|
|
36
|
+
HKT_FUEL_EXHAUSTED = :hkt_fuel_exhausted
|
|
37
|
+
|
|
38
|
+
CATEGORIES = [RECURSION_GUARD, ANCESTOR_WALK_LIMIT, HKT_FUEL_EXHAUSTED].freeze
|
|
39
|
+
|
|
40
|
+
# Distribution (histogram) categories — read-only observations of
|
|
41
|
+
# a value's size at a site, used to choose budget defaults from an
|
|
42
|
+
# observed tail rather than a guess (ADR-41 WD3 / Slice 2a). No cap
|
|
43
|
+
# is enforced; these only record. `UNION_ARITY` is the member count
|
|
44
|
+
# of every `Type::Union` that `Combinator.union` produces — the
|
|
45
|
+
# distribution the `union_size` budget default should be set from.
|
|
46
|
+
UNION_ARITY = :union_arity
|
|
47
|
+
|
|
48
|
+
DISTRIBUTION_CATEGORIES = [UNION_ARITY].freeze
|
|
49
|
+
|
|
50
|
+
@enabled = !ENV["RIGOR_BUDGET_TRACE"].to_s.empty?
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
@counts = Hash.new(0)
|
|
53
|
+
@distributions = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
def enabled?
|
|
58
|
+
@enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Test / programmatic toggles. Production enablement is the
|
|
62
|
+
# `RIGOR_BUDGET_TRACE` env var read once at load time.
|
|
63
|
+
def enable!
|
|
64
|
+
@enabled = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def disable!
|
|
68
|
+
@enabled = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Records one firing of `category`. No-op (one boolean check)
|
|
72
|
+
# when tracing is disabled.
|
|
73
|
+
def hit(category)
|
|
74
|
+
return unless @enabled
|
|
75
|
+
|
|
76
|
+
@mutex.synchronize { @counts[category] += 1 }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Frozen snapshot of the current counts, every known category
|
|
80
|
+
# present (zero-filled) so consumers can render a stable table.
|
|
81
|
+
def snapshot
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
CATEGORIES.to_h { |category| [category, @counts[category]] }.freeze
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Records one observation of `value` (an Integer size) into
|
|
88
|
+
# `category`'s histogram. No-op (one boolean check) when disabled.
|
|
89
|
+
def observe(category, value)
|
|
90
|
+
return unless @enabled
|
|
91
|
+
|
|
92
|
+
@mutex.synchronize { @distributions[category][value] += 1 }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Frozen `{value => count}` histogram for a distribution category.
|
|
96
|
+
def distribution(category)
|
|
97
|
+
@mutex.synchronize { @distributions[category].dup.freeze }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Summary of a distribution category: total observation count, max
|
|
101
|
+
# observed value, selected percentiles, and how many observations
|
|
102
|
+
# met or exceeded each threshold in `over`. Percentiles use the
|
|
103
|
+
# nearest-rank method over the expanded sample.
|
|
104
|
+
def summarize(category, over: [])
|
|
105
|
+
hist = distribution(category)
|
|
106
|
+
total = hist.values.sum
|
|
107
|
+
return { count: 0, max: 0, percentiles: {}, over: over.to_h { |t| [t, 0] } } if total.zero?
|
|
108
|
+
|
|
109
|
+
sorted = hist.keys.sort
|
|
110
|
+
{ count: total,
|
|
111
|
+
max: sorted.last,
|
|
112
|
+
percentiles: { p50: percentile(hist, total, 0.50), p90: percentile(hist, total, 0.90),
|
|
113
|
+
p99: percentile(hist, total, 0.99) },
|
|
114
|
+
over: over.to_h { |t| [t, hist.sum { |value, n| value >= t ? n : 0 }] } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Nearest-rank percentile over a `{value => count}` histogram
|
|
118
|
+
# without materialising the full sample.
|
|
119
|
+
def percentile(hist, total, fraction)
|
|
120
|
+
rank = (fraction * total).ceil
|
|
121
|
+
cumulative = 0
|
|
122
|
+
hist.keys.sort.each do |value|
|
|
123
|
+
cumulative += hist[value]
|
|
124
|
+
return value if cumulative >= rank
|
|
125
|
+
end
|
|
126
|
+
hist.keys.max
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reset
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
@counts.clear
|
|
132
|
+
@distributions.clear
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require_relative "../type"
|
|
6
6
|
require_relative "../ast"
|
|
7
7
|
require_relative "block_parameter_binder"
|
|
8
|
+
require_relative "budget_trace"
|
|
8
9
|
require_relative "fallback"
|
|
9
10
|
require_relative "indexed_narrowing"
|
|
10
11
|
require_relative "macro_block_self_type"
|
|
@@ -1358,7 +1359,10 @@ module Rigor
|
|
|
1358
1359
|
|
|
1359
1360
|
seen[current] = true
|
|
1360
1361
|
visited += 1
|
|
1361
|
-
|
|
1362
|
+
if visited > ANCESTOR_WALK_LIMIT
|
|
1363
|
+
BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
|
|
1364
|
+
return nil
|
|
1365
|
+
end
|
|
1362
1366
|
|
|
1363
1367
|
found = scope.user_def_for(current, method_name)
|
|
1364
1368
|
return found if found
|
|
@@ -1432,7 +1436,10 @@ module Rigor
|
|
|
1432
1436
|
# carrier for top-level / DSL-block defs) printable.
|
|
1433
1437
|
signature = [receiver.describe(:short), def_node.name]
|
|
1434
1438
|
stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
|
|
1435
|
-
|
|
1439
|
+
if stack.include?(signature)
|
|
1440
|
+
BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
|
|
1441
|
+
return Type::Combinator.untyped
|
|
1442
|
+
end
|
|
1436
1443
|
|
|
1437
1444
|
stack.push(signature)
|
|
1438
1445
|
begin
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "hkt_body"
|
|
4
|
+
require_relative "budget_trace"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Inference
|
|
@@ -71,6 +72,7 @@ module Rigor
|
|
|
71
72
|
walk(definition.body_tree, bindings: bindings_for(definition, app.args), state: state) || app.bound
|
|
72
73
|
end
|
|
73
74
|
rescue FuelExhausted
|
|
75
|
+
BudgetTrace.hit(BudgetTrace::HKT_FUEL_EXHAUSTED)
|
|
74
76
|
app.bound
|
|
75
77
|
end
|
|
76
78
|
end
|
|
@@ -115,14 +115,31 @@ module Rigor
|
|
|
115
115
|
# overload-list position.
|
|
116
116
|
overloads = ReceiverAffinity.reorder(overloads, self_type: self_type, environment: environment)
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
passes = lambda do |require_block|
|
|
119
|
+
run_selection_passes(
|
|
120
|
+
overloads, arg_types: arg_types, self_type: self_type, instance_type: instance_type,
|
|
121
|
+
type_vars: type_vars, block_required: require_block, param_overrides: param_overrides
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
match = passes.call(block_required)
|
|
122
126
|
return match if match
|
|
123
|
-
return overloads.find { |mt| overload_has_block?(mt) } if block_required
|
|
124
127
|
|
|
125
|
-
#
|
|
128
|
+
# A block at the call site that no block-declaring overload
|
|
129
|
+
# matched: Ruby ignores a block handed to a method that never
|
|
130
|
+
# yields it, so retry treating the block as ignorable rather
|
|
131
|
+
# than failing the dispatch. Without this, a block-bearing
|
|
132
|
+
# call to a method whose RBS declares no block (e.g.
|
|
133
|
+
# `define_command(:x) do … end` against
|
|
134
|
+
# `def define_command: (Symbol) -> Symbol`) degraded to
|
|
135
|
+
# `Dynamic[Top]` — and on a self-send suppressed the whole
|
|
136
|
+
# method's return type.
|
|
137
|
+
if block_required
|
|
138
|
+
match = passes.call(false)
|
|
139
|
+
return match if match
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# No (usable) block at the call site: prefer an overload that does
|
|
126
143
|
# not REQUIRE a block over `overloads.first`. Methods like
|
|
127
144
|
# `Array#filter` / `Enumerable#map` declare the block-
|
|
128
145
|
# bearing overload first (`() { ... } -> Array[Elem]`) and
|
|
@@ -64,6 +64,19 @@ module Rigor
|
|
|
64
64
|
module RbsDispatch
|
|
65
65
|
module_function
|
|
66
66
|
|
|
67
|
+
# ADR-43 — ancestor classes whose RBS is authoritative and
|
|
68
|
+
# COMPLETE, so a call a subclass makes that the ancestor's RBS
|
|
69
|
+
# does not declare is a genuine mistake rather than a gap.
|
|
70
|
+
# Membership unlocks inherited-method resolution (and thus
|
|
71
|
+
# `call.undefined-method`) for Ruby-source subclasses of these
|
|
72
|
+
# classes; every other RBS ancestor stays on the Dynamic
|
|
73
|
+
# fallback. Seeded with the plugin contract base — this repo
|
|
74
|
+
# owns both the class and `sig/rigor/plugin/base.rbs`, and the
|
|
75
|
+
# `lib` self-check keeps them in lock-step. NOT a place for
|
|
76
|
+
# third-party/core classes whose objects answer to methods
|
|
77
|
+
# their RBS omits (`ActionController::Base`, `Hash`, …).
|
|
78
|
+
ALLOWED_RBS_COMPLETE_ANCESTORS = ["Rigor::Plugin::Base"].freeze
|
|
79
|
+
|
|
67
80
|
# @param receiver [Rigor::Type]
|
|
68
81
|
# @param method_name [Symbol]
|
|
69
82
|
# @param args [Array<Rigor::Type>]
|
|
@@ -94,8 +107,19 @@ module Rigor
|
|
|
94
107
|
# @return [Rigor::Type, nil] inferred return type, or `nil`
|
|
95
108
|
# when no rule resolves (no class name, no method, dispatch
|
|
96
109
|
# on a Top/Dynamic[Top] receiver, etc.).
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
# @param scope [Rigor::Scope, nil] when supplied, enables
|
|
111
|
+
# ADR-43 RBS-complete-ancestor resolution: a call on a
|
|
112
|
+
# Ruby-source subclass not known to RBS, whose discovered
|
|
113
|
+
# superclass chain reaches an allow-listed RBS-complete
|
|
114
|
+
# ancestor (e.g. `Rigor::Plugin::Base`), resolves against
|
|
115
|
+
# that ancestor's RBS. `nil` (the default for every caller
|
|
116
|
+
# that does not thread a scope) keeps the legacy behaviour —
|
|
117
|
+
# such an inherited call stays unresolved and degrades to
|
|
118
|
+
# `Dynamic[Top]`, which is the false-positive-safe default
|
|
119
|
+
# for the open hierarchies (`< ActionController::Base`, …)
|
|
120
|
+
# the allow-list deliberately excludes.
|
|
121
|
+
def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil, self_type_override: nil, # rubocop:disable Metrics/ParameterLists
|
|
122
|
+
public_only: false, scope: nil)
|
|
99
123
|
return nil if environment.nil?
|
|
100
124
|
return nil unless environment.rbs_loader
|
|
101
125
|
|
|
@@ -106,7 +130,8 @@ module Rigor
|
|
|
106
130
|
environment: environment,
|
|
107
131
|
block_type: block_type,
|
|
108
132
|
self_type_override: self_type_override,
|
|
109
|
-
public_only: public_only
|
|
133
|
+
public_only: public_only,
|
|
134
|
+
scope: scope
|
|
110
135
|
)
|
|
111
136
|
end
|
|
112
137
|
|
|
@@ -148,37 +173,37 @@ module Rigor
|
|
|
148
173
|
class << self
|
|
149
174
|
private
|
|
150
175
|
|
|
151
|
-
def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil,
|
|
152
|
-
public_only: false)
|
|
176
|
+
def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil, # rubocop:disable Metrics/ParameterLists
|
|
177
|
+
public_only: false, scope: nil)
|
|
153
178
|
args ||= []
|
|
154
179
|
case receiver
|
|
155
180
|
when Type::Union
|
|
156
181
|
dispatch_union(receiver, method_name, args, environment, block_type, self_type_override,
|
|
157
|
-
public_only: public_only)
|
|
182
|
+
public_only: public_only, scope: scope)
|
|
158
183
|
else
|
|
159
184
|
dispatch_one(receiver, method_name, args, environment, block_type, self_type_override,
|
|
160
|
-
public_only: public_only)
|
|
185
|
+
public_only: public_only, scope: scope)
|
|
161
186
|
end
|
|
162
187
|
end
|
|
163
188
|
|
|
164
|
-
def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil,
|
|
165
|
-
public_only: false)
|
|
189
|
+
def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil, # rubocop:disable Metrics/ParameterLists
|
|
190
|
+
public_only: false, scope: nil)
|
|
166
191
|
results = receiver.members.map do |member|
|
|
167
192
|
dispatch_one(member, method_name, args, environment, block_type, self_type_override,
|
|
168
|
-
public_only: public_only)
|
|
193
|
+
public_only: public_only, scope: scope)
|
|
169
194
|
end
|
|
170
195
|
return nil if results.any?(&:nil?)
|
|
171
196
|
|
|
172
197
|
Type::Combinator.union(*results)
|
|
173
198
|
end
|
|
174
199
|
|
|
175
|
-
def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil,
|
|
176
|
-
public_only: false)
|
|
200
|
+
def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil, # rubocop:disable Metrics/ParameterLists
|
|
201
|
+
public_only: false, scope: nil)
|
|
177
202
|
descriptor = receiver_descriptor(receiver)
|
|
178
203
|
return nil unless descriptor
|
|
179
204
|
|
|
180
205
|
class_name, kind, receiver_args = descriptor
|
|
181
|
-
method_definition = lookup_method(environment, class_name, kind, method_name)
|
|
206
|
+
method_definition = lookup_method(environment, class_name, kind, method_name, scope)
|
|
182
207
|
return nil unless method_definition
|
|
183
208
|
return nil if public_only && method_private?(method_definition)
|
|
184
209
|
|
|
@@ -267,7 +292,26 @@ module Rigor
|
|
|
267
292
|
method_definition.accessibility == :private
|
|
268
293
|
end
|
|
269
294
|
|
|
270
|
-
def lookup_method(environment, class_name, kind, method_name)
|
|
295
|
+
def lookup_method(environment, class_name, kind, method_name, scope = nil)
|
|
296
|
+
direct = lookup_method_on(environment, class_name, kind, method_name)
|
|
297
|
+
return direct if direct
|
|
298
|
+
|
|
299
|
+
# ADR-43 — scoped inherited-method resolution. The direct
|
|
300
|
+
# lookup misses when `class_name` is a Ruby-source subclass
|
|
301
|
+
# absent from RBS (so no ancestor walk runs). If its
|
|
302
|
+
# discovered superclass chain reaches an allow-listed
|
|
303
|
+
# RBS-complete ancestor, resolve the method there so
|
|
304
|
+
# inherited contract calls (`self.manifest` on a plugin)
|
|
305
|
+
# resolve and the normal call rules apply. Bounded to the
|
|
306
|
+
# allow-list, so open hierarchies stay on the Dynamic
|
|
307
|
+
# fallback (no false positive on `< ActionController::Base`).
|
|
308
|
+
ancestor = allowed_rbs_complete_ancestor(environment, class_name, scope)
|
|
309
|
+
return nil unless ancestor
|
|
310
|
+
|
|
311
|
+
lookup_method_on(environment, ancestor, kind, method_name)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def lookup_method_on(environment, class_name, kind, method_name)
|
|
271
315
|
case kind
|
|
272
316
|
when :instance
|
|
273
317
|
Rigor::Reflection.instance_method_definition(class_name, method_name, environment: environment)
|
|
@@ -276,6 +320,29 @@ module Rigor
|
|
|
276
320
|
end
|
|
277
321
|
end
|
|
278
322
|
|
|
323
|
+
# The first allow-listed, RBS-complete ancestor reachable from
|
|
324
|
+
# `class_name` through `scope.discovered_superclasses`, or nil.
|
|
325
|
+
# Returns nil when no scope is threaded, when `class_name` is
|
|
326
|
+
# itself RBS-known (the direct lookup already had authority),
|
|
327
|
+
# or when the discovered chain reaches no allow-listed class.
|
|
328
|
+
# The walk carries a visited set so a malformed cyclic
|
|
329
|
+
# `A < B < A` source cannot loop.
|
|
330
|
+
def allowed_rbs_complete_ancestor(environment, class_name, scope)
|
|
331
|
+
return nil if scope.nil?
|
|
332
|
+
return nil if Rigor::Reflection.rbs_class_known?(class_name, environment: environment)
|
|
333
|
+
|
|
334
|
+
supers = scope.discovered_superclasses
|
|
335
|
+
seen = {}
|
|
336
|
+
current = supers[class_name.to_s]
|
|
337
|
+
until current.nil? || seen[current]
|
|
338
|
+
return current if ALLOWED_RBS_COMPLETE_ANCESTORS.include?(current)
|
|
339
|
+
|
|
340
|
+
seen[current] = true
|
|
341
|
+
current = supers[current]
|
|
342
|
+
end
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
279
346
|
# Slice 4 phase 2d substitution map. Zips the class's
|
|
280
347
|
# declared type-parameter names against the receiver's
|
|
281
348
|
# `type_args`. Returns an empty hash when either side is
|
|
@@ -71,7 +71,7 @@ module Rigor
|
|
|
71
71
|
# @param environment [Rigor::Environment, nil] required for
|
|
72
72
|
# RBS-backed dispatch; when nil only constant folding can fire.
|
|
73
73
|
# @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
|
|
74
|
-
def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength
|
|
74
|
+
def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
75
75
|
block_type: nil, environment: nil,
|
|
76
76
|
call_node: nil, scope: nil)
|
|
77
77
|
return nil if receiver_type.nil?
|
|
@@ -94,7 +94,7 @@ module Rigor
|
|
|
94
94
|
# consults the registry when both `call_node` and `scope`
|
|
95
95
|
# are supplied — the dispatcher's own internal callers
|
|
96
96
|
# (per-element block fold, etc.) skip this tier.
|
|
97
|
-
plugin_result = try_plugin_contribution(call_node, scope)
|
|
97
|
+
plugin_result = try_plugin_contribution(call_node, scope, receiver_type)
|
|
98
98
|
return plugin_result if plugin_result
|
|
99
99
|
|
|
100
100
|
# ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
|
|
@@ -125,7 +125,7 @@ module Rigor
|
|
|
125
125
|
|
|
126
126
|
rbs_result = RbsDispatch.try_dispatch(
|
|
127
127
|
receiver: receiver_type, method_name: method_name, args: arg_types,
|
|
128
|
-
environment: environment, block_type: block_type
|
|
128
|
+
environment: environment, block_type: block_type, scope: scope
|
|
129
129
|
)
|
|
130
130
|
if rbs_result
|
|
131
131
|
record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
|
|
@@ -187,6 +187,19 @@ module Rigor
|
|
|
187
187
|
discovered_result = try_discovered_method(receiver_type, method_name, scope)
|
|
188
188
|
return discovered_result if discovered_result
|
|
189
189
|
|
|
190
|
+
# ADR-5 robustness — synthesized-stub-type tier. When the
|
|
191
|
+
# receiver is a type Rigor invented to make an otherwise-
|
|
192
|
+
# unbuildable project signature resolve (a missing-namespace
|
|
193
|
+
# module, or a stub for a referenced-but-undeclared type like
|
|
194
|
+
# an unavailable `DRb::DRbServer`), the stub carries no methods,
|
|
195
|
+
# so an unresolved call against it would otherwise mis-fire
|
|
196
|
+
# `call.undefined-method`. Resolve it to `Dynamic[Top]` instead
|
|
197
|
+
# — the same no-false-positive contract as the dependency-
|
|
198
|
+
# source tier. Sits below every real resolution tier so a
|
|
199
|
+
# genuine signature always wins.
|
|
200
|
+
stub_result = try_synthesized_stub_type(receiver_type, environment)
|
|
201
|
+
return stub_result if stub_result
|
|
202
|
+
|
|
190
203
|
# Slice 7 phase 10 — user-class ancestor fallback. When
|
|
191
204
|
# the receiver is `Nominal[T]` or `Singleton[T]` for a
|
|
192
205
|
# class not in the RBS environment (typically a
|
|
@@ -253,6 +266,31 @@ module Rigor
|
|
|
253
266
|
end
|
|
254
267
|
end
|
|
255
268
|
|
|
269
|
+
# ADR-5 robustness — returns `Dynamic[Top]` when the receiver is
|
|
270
|
+
# an instance or singleton of a type Rigor synthesized (a
|
|
271
|
+
# missing-namespace module or a referenced-type stub). The stub
|
|
272
|
+
# has no methods, so the call would otherwise reach the
|
|
273
|
+
# user-class fallback and surface `call.undefined-method`; the
|
|
274
|
+
# honest answer for a type Rigor invented is "unknown shape",
|
|
275
|
+
# i.e. `Dynamic[Top]`. Returns nil (declines) for any real type.
|
|
276
|
+
def try_synthesized_stub_type(receiver_type, environment)
|
|
277
|
+
return nil if environment.nil?
|
|
278
|
+
|
|
279
|
+
loader = environment.rbs_loader
|
|
280
|
+
return nil if loader.nil? || !loader.respond_to?(:synthesized_type_names)
|
|
281
|
+
|
|
282
|
+
names = loader.synthesized_type_names
|
|
283
|
+
return nil if names.empty?
|
|
284
|
+
|
|
285
|
+
class_name =
|
|
286
|
+
case receiver_type
|
|
287
|
+
when Type::Nominal, Type::Singleton then receiver_type.class_name.to_s.sub(/\A::/, "")
|
|
288
|
+
end
|
|
289
|
+
return nil unless class_name && names.include?(class_name)
|
|
290
|
+
|
|
291
|
+
Type::Combinator.untyped
|
|
292
|
+
end
|
|
293
|
+
|
|
256
294
|
# ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
|
|
257
295
|
# slice 7. Walks every loaded plugin's
|
|
258
296
|
# `#flow_contribution_for(call_node:, scope:)` hook,
|
|
@@ -340,13 +378,13 @@ module Rigor
|
|
|
340
378
|
end
|
|
341
379
|
end
|
|
342
380
|
|
|
343
|
-
def try_plugin_contribution(call_node, scope)
|
|
381
|
+
def try_plugin_contribution(call_node, scope, receiver_type)
|
|
344
382
|
return nil if call_node.nil? || scope.nil?
|
|
345
383
|
|
|
346
384
|
registry = scope.environment&.plugin_registry
|
|
347
385
|
return nil if registry.nil? || registry.empty?
|
|
348
386
|
|
|
349
|
-
contributions = collect_plugin_contributions(registry, call_node, scope)
|
|
387
|
+
contributions = collect_plugin_contributions(registry, call_node, scope, receiver_type)
|
|
350
388
|
return nil if contributions.empty?
|
|
351
389
|
|
|
352
390
|
FlowContribution::Merger.merge(contributions).return_type
|
|
@@ -622,12 +660,21 @@ module Rigor
|
|
|
622
660
|
end
|
|
623
661
|
end
|
|
624
662
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
663
|
+
# ADR-37 slice 2 — gathers each plugin's return-type contribution
|
|
664
|
+
# from BOTH the narrow `dynamic_return` DSL (receiver-gated, wrapped
|
|
665
|
+
# as a return-only `FlowContribution`) and the legacy
|
|
666
|
+
# `flow_contribution_for` escape valve, so migrated and unmigrated
|
|
667
|
+
# plugins compose through the same merger.
|
|
668
|
+
def collect_plugin_contributions(registry, call_node, scope, receiver_type)
|
|
669
|
+
registry.plugins.flat_map do |plugin|
|
|
670
|
+
contributions = []
|
|
671
|
+
legacy = plugin.flow_contribution_for(call_node: call_node, scope: scope)
|
|
672
|
+
contributions << legacy if legacy.is_a?(FlowContribution)
|
|
673
|
+
dynamic = plugin.dynamic_return_type(call_node: call_node, scope: scope, receiver_type: receiver_type)
|
|
674
|
+
contributions << FlowContribution.new(return_type: dynamic) if dynamic
|
|
675
|
+
contributions
|
|
629
676
|
rescue StandardError
|
|
630
|
-
|
|
677
|
+
[]
|
|
631
678
|
end
|
|
632
679
|
end
|
|
633
680
|
|
|
@@ -10,7 +10,11 @@ module Rigor
|
|
|
10
10
|
# engine recognises an AST node class (that is `CoverageScanner`'s job),
|
|
11
11
|
# but whether the type it produces carries useful static information.
|
|
12
12
|
#
|
|
13
|
-
# Each visited node is classified into one of eight
|
|
13
|
+
# Each visited *expression* node is classified into one of eight
|
|
14
|
+
# precision tiers (non-expression syntax nodes — argument /
|
|
15
|
+
# parameter lists, parameter declarations, hash pairs, statement
|
|
16
|
+
# wrappers, clause headers — are skipped; see
|
|
17
|
+
# {NON_EXPRESSION_NODE_TYPES}):
|
|
14
18
|
#
|
|
15
19
|
# :constant — Constant[T]: literal value known exactly
|
|
16
20
|
# :nominal — Nominal/Singleton: class identity known
|
|
@@ -33,6 +37,59 @@ module Rigor
|
|
|
33
37
|
dynamic_specific dynamic_top top
|
|
34
38
|
].freeze
|
|
35
39
|
|
|
40
|
+
# Prism node classes that do not denote a value-producing
|
|
41
|
+
# expression, so typing them is meaningless — they have no
|
|
42
|
+
# runtime value to carry a type. Counting them (they always fall
|
|
43
|
+
# to the `dynamic_top` fallback) silently diluted the precision
|
|
44
|
+
# ratio: on a real survey target (shugo/textbringer) they were
|
|
45
|
+
# ~49% of every "opaque" node, dragging the headline number ~13
|
|
46
|
+
# points below the true expression-level precision. We exclude
|
|
47
|
+
# them from BOTH numerator and denominator so the ratio measures
|
|
48
|
+
# what it claims to — the type quality of actual expressions.
|
|
49
|
+
#
|
|
50
|
+
# The set is deliberately CONSERVATIVE: only nodes that are
|
|
51
|
+
# unambiguously non-expressions in Ruby's grammar are listed —
|
|
52
|
+
# argument / parameter list containers and the parameter
|
|
53
|
+
# declarations inside them; the `key => value` pair node (its key
|
|
54
|
+
# and value are themselves walked and counted); the program /
|
|
55
|
+
# statements sequence wrappers (their value is the last child,
|
|
56
|
+
# already counted — listing them avoids double-counting); and the
|
|
57
|
+
# clause-header nodes whose body, not the header, carries the
|
|
58
|
+
# value. Anything that *could* be a value expression (`BlockNode`,
|
|
59
|
+
# `BeginNode`, `ImplicitNode`, `ParenthesesNode`, splats, …) is
|
|
60
|
+
# left in so a genuine inference gap stays visible.
|
|
61
|
+
#
|
|
62
|
+
# Compared by class NAME so a Prism version that lacks one of the
|
|
63
|
+
# newer node classes does not break loading.
|
|
64
|
+
NON_EXPRESSION_NODE_TYPES = %w[
|
|
65
|
+
Prism::ProgramNode
|
|
66
|
+
Prism::StatementsNode
|
|
67
|
+
Prism::ArgumentsNode
|
|
68
|
+
Prism::BlockArgumentNode
|
|
69
|
+
Prism::ParametersNode
|
|
70
|
+
Prism::BlockParametersNode
|
|
71
|
+
Prism::NumberedParametersNode
|
|
72
|
+
Prism::ItParametersNode
|
|
73
|
+
Prism::KeywordHashNode
|
|
74
|
+
Prism::RequiredParameterNode
|
|
75
|
+
Prism::OptionalParameterNode
|
|
76
|
+
Prism::RestParameterNode
|
|
77
|
+
Prism::KeywordRestParameterNode
|
|
78
|
+
Prism::BlockParameterNode
|
|
79
|
+
Prism::RequiredKeywordParameterNode
|
|
80
|
+
Prism::OptionalKeywordParameterNode
|
|
81
|
+
Prism::ForwardingParameterNode
|
|
82
|
+
Prism::NoKeywordsParameterNode
|
|
83
|
+
Prism::ImplicitRestNode
|
|
84
|
+
Prism::AssocNode
|
|
85
|
+
Prism::AssocSplatNode
|
|
86
|
+
Prism::WhenNode
|
|
87
|
+
Prism::InNode
|
|
88
|
+
Prism::ElseNode
|
|
89
|
+
Prism::EnsureNode
|
|
90
|
+
Prism::RescueNode
|
|
91
|
+
].to_set.freeze
|
|
92
|
+
|
|
36
93
|
TIER_RANK = TIERS.each_with_index.to_h.freeze
|
|
37
94
|
private_constant :TIER_RANK
|
|
38
95
|
|
|
@@ -87,6 +144,8 @@ module Rigor
|
|
|
87
144
|
total = 0
|
|
88
145
|
|
|
89
146
|
Source::NodeWalker.each(root) do |node|
|
|
147
|
+
next if NON_EXPRESSION_NODE_TYPES.include?(node.class.name)
|
|
148
|
+
|
|
90
149
|
type = scope_index[node].type_of(node)
|
|
91
150
|
tier = classify(type)
|
|
92
151
|
tier_counts[tier] += 1
|