rigortype 0.1.19 → 0.2.0
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/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
- data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
- data/lib/rigor/analysis/check_rules.rb +492 -71
- data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
- data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
- data/lib/rigor/analysis/fact_store.rb +5 -4
- data/lib/rigor/analysis/rule_catalog.rb +153 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
- data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
- data/lib/rigor/analysis/runner.rb +17 -6
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
- data/lib/rigor/analysis/worker_session.rb +10 -14
- data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
- data/lib/rigor/cache/store.rb +5 -3
- data/lib/rigor/cli/annotate_command.rb +28 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/check_command.rb +115 -16
- data/lib/rigor/cli/coverage_command.rb +148 -16
- data/lib/rigor/cli/coverage_scan.rb +57 -0
- data/lib/rigor/cli/explain_command.rb +2 -0
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
- data/lib/rigor/cli/mutation_protection_report.rb +73 -0
- data/lib/rigor/cli/options.rb +9 -0
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/protection_renderer.rb +63 -0
- data/lib/rigor/cli/protection_report.rb +68 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +2 -1
- data/lib/rigor/cli/triage_command.rb +2 -1
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_command.rb +2 -1
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/configuration/dependencies.rb +2 -4
- data/lib/rigor/configuration.rb +45 -7
- data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
- data/lib/rigor/environment/class_registry.rb +4 -3
- data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
- data/lib/rigor/environment/lockfile_resolver.rb +1 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
- data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
- data/lib/rigor/environment/rbs_loader.rb +49 -5
- data/lib/rigor/environment.rb +17 -7
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution.rb +3 -5
- data/lib/rigor/inference/acceptance.rb +17 -9
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +20 -28
- data/lib/rigor/inference/hkt_body.rb +8 -11
- data/lib/rigor/inference/hkt_body_parser.rb +10 -12
- data/lib/rigor/inference/hkt_registry.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
- data/lib/rigor/inference/method_dispatcher.rb +40 -48
- data/lib/rigor/inference/mutation_widening.rb +5 -11
- data/lib/rigor/inference/narrowing.rb +14 -16
- data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
- data/lib/rigor/inference/project_patched_methods.rb +4 -7
- data/lib/rigor/inference/project_patched_scanner.rb +2 -13
- data/lib/rigor/inference/protection_scanner.rb +86 -0
- data/lib/rigor/inference/scope_indexer.rb +129 -55
- data/lib/rigor/inference/statement_evaluator.rb +244 -114
- data/lib/rigor/inference/struct_fold_safety.rb +181 -0
- data/lib/rigor/inference/synthetic_method.rb +7 -7
- data/lib/rigor/language_server/completion_provider.rb +6 -12
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
- data/lib/rigor/language_server/hover_provider.rb +2 -3
- data/lib/rigor/language_server/hover_renderer.rb +2 -11
- data/lib/rigor/language_server/server.rb +9 -17
- data/lib/rigor/language_server.rb +4 -5
- data/lib/rigor/plugin/base.rb +10 -8
- data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
- data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
- data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
- data/lib/rigor/plugin/macro.rb +4 -5
- data/lib/rigor/plugin/manifest.rb +45 -66
- data/lib/rigor/plugin/registry.rb +6 -7
- data/lib/rigor/plugin/type_node_resolver.rb +6 -8
- data/lib/rigor/protection/mutation_scanner.rb +120 -0
- data/lib/rigor/protection/mutator.rb +246 -0
- data/lib/rigor/rbs_extended.rb +24 -36
- data/lib/rigor/reflection.rb +4 -7
- data/lib/rigor/scope/discovery_index.rb +14 -2
- data/lib/rigor/scope.rb +54 -11
- data/lib/rigor/sig_gen/observed_call.rb +3 -3
- data/lib/rigor/sig_gen/writer.rb +40 -2
- data/lib/rigor/source/constant_path.rb +62 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/bound_method.rb +2 -11
- data/lib/rigor/type/combinator.rb +16 -3
- data/lib/rigor/type/constant.rb +2 -11
- data/lib/rigor/type/data_class.rb +2 -11
- data/lib/rigor/type/data_instance.rb +2 -11
- data/lib/rigor/type/hash_shape.rb +2 -11
- data/lib/rigor/type/integer_range.rb +2 -11
- data/lib/rigor/type/intersection.rb +2 -11
- data/lib/rigor/type/nominal.rb +2 -11
- data/lib/rigor/type/plain_lattice.rb +37 -0
- data/lib/rigor/type/refined.rb +72 -13
- data/lib/rigor/type/singleton.rb +2 -11
- data/lib/rigor/type/struct_class.rb +75 -0
- data/lib/rigor/type/struct_instance.rb +93 -0
- data/lib/rigor/type/tuple.rb +5 -15
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
- data/sig/rigor/scope.rbs +9 -1
- data/sig/rigor/type.rbs +36 -1
- metadata +19 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "scope_indexer"
|
|
6
|
+
require_relative "../source/node_walker"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Inference
|
|
10
|
+
# Pure argument-type classification for {ParameterInferenceCollector} — no
|
|
11
|
+
# collector state, so it lives outside the orchestration class. Decides which
|
|
12
|
+
# argument types may seed a parameter (concrete enough for the protection
|
|
13
|
+
# metric to bite) and widens a literal argument to its nominal.
|
|
14
|
+
module ParameterArgTypes
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
CONSTANT_CLASSES = {
|
|
18
|
+
Integer => "Integer", Float => "Float", String => "String",
|
|
19
|
+
Symbol => "Symbol", Range => "Range", TrueClass => "TrueClass",
|
|
20
|
+
FalseClass => "FalseClass", NilClass => "NilClass"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# A parameter holds a *value of* a type across its lifetime, not a pinned
|
|
24
|
+
# literal — so a `Constant<"text">` argument widens to its nominal
|
|
25
|
+
# (`String`); recurses through unions so `Constant<"a"> | Constant<"b">`
|
|
26
|
+
# collapses to `String`.
|
|
27
|
+
def widen_for_param(type)
|
|
28
|
+
case type
|
|
29
|
+
when Type::Constant
|
|
30
|
+
name = constant_class_name(type.value)
|
|
31
|
+
name ? Type::Combinator.nominal_of(name) : type
|
|
32
|
+
when Type::Union
|
|
33
|
+
Type::Combinator.union(*type.members.map { |member| widen_for_param(member) })
|
|
34
|
+
else
|
|
35
|
+
type
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def constant_class_name(value)
|
|
40
|
+
CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The dispatch class for a receiver type, for the subset the collector
|
|
45
|
+
# resolves to a user `def` (mirrors `CheckRules#concrete_class_name` for the
|
|
46
|
+
# carriers a user-method receiver is typed as). `class_name_of` is the
|
|
47
|
+
# Nominal/Singleton-only variant for an implicit-self `self_type`.
|
|
48
|
+
def concrete_class_name(type)
|
|
49
|
+
case type
|
|
50
|
+
when Type::Nominal, Type::Singleton then type.class_name
|
|
51
|
+
when Type::Tuple then "Array"
|
|
52
|
+
when Type::HashShape then "Hash"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def class_name_of(type)
|
|
57
|
+
type.class_name if type.is_a?(Type::Nominal) || type.is_a?(Type::Singleton)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether `type` is too gradual to seed (not a concrete dispatch target) —
|
|
61
|
+
# the negation of `ProtectionScanner#concrete_receiver?` (a union is
|
|
62
|
+
# concrete only when every arm is).
|
|
63
|
+
def non_concrete?(type)
|
|
64
|
+
case type
|
|
65
|
+
when Type::Dynamic, Type::Top, Type::Bot then true
|
|
66
|
+
when Type::Union then type.members.any? { |member| non_concrete?(member) }
|
|
67
|
+
else false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ADR-67 WD3 + WD5 — call-site parameter type inference (capped fixpoint).
|
|
73
|
+
#
|
|
74
|
+
# A `def` parameter with no RBS signature types `untyped` (the gradual entry
|
|
75
|
+
# point), so a param flowing into an ivar or a receiver drains everything
|
|
76
|
+
# downstream to `Dynamic`. This pass closes that hole: it walks every project
|
|
77
|
+
# file, types each call's positional arguments in their lexical scope,
|
|
78
|
+
# resolves which user-defined `def` each call targets, and records the
|
|
79
|
+
# **union of resolved call-site argument types** per parameter — TypeProf's
|
|
80
|
+
# "a parameter's type is the union of every actual argument across all call
|
|
81
|
+
# sites". WD5: it iterates (cap {DEFAULT_ROUNDS}), re-seeding each round with
|
|
82
|
+
# the previous round's inferred parameters, so a parameter passed *another*
|
|
83
|
+
# parameter is typed one hop further per round (round 1 alone is the
|
|
84
|
+
# single-level pass).
|
|
85
|
+
#
|
|
86
|
+
# The result is keyed by `[class_name, method_name, kind]` — the same triple
|
|
87
|
+
# `StatementEvaluator#build_method_entry_scope` reconstructs from the lexical
|
|
88
|
+
# class path — so the consumer can seed an undeclared parameter with its
|
|
89
|
+
# inferred type. The table is precision-additive only: it never feeds a
|
|
90
|
+
# parameter-boundary diagnostic (an inferred type lives solely as a body
|
|
91
|
+
# local, and the boundary rules consult RBS, not body locals — see ADR-67
|
|
92
|
+
# WD1), so being wrong cannot manufacture a false positive at a caller.
|
|
93
|
+
#
|
|
94
|
+
# Soundness (WD4): the inferred type is a sound over-approximation only when
|
|
95
|
+
# every contributed call site resolves to a concrete argument type. Any
|
|
96
|
+
# `Dynamic` / `Top` / `Bot` argument (a `send`/dynamic-dispatch caller, or a
|
|
97
|
+
# parameter not yet typed in an earlier round) **poisons** the parameter,
|
|
98
|
+
# which then contributes nothing this round (it may type in a later round
|
|
99
|
+
# once its own argument resolves). Unresolved call sites do not contribute.
|
|
100
|
+
#
|
|
101
|
+
# What it does NOT do yet (deferred — the check-wiring slice): feeding the
|
|
102
|
+
# table into the `check` walk (only `coverage --protection` consumes it
|
|
103
|
+
# today), keyword / optional / rest / block parameters, inherited-method
|
|
104
|
+
# receivers, top-level helpers, and a rigorous closed-call-site-set proof
|
|
105
|
+
# (the union is optimistic over resolved sites — acceptable because the only
|
|
106
|
+
# consumer is the protection metric, which runs no diagnostics).
|
|
107
|
+
class ParameterInferenceCollector
|
|
108
|
+
EMPTY = {}.freeze
|
|
109
|
+
|
|
110
|
+
# A defensive widening cap (ADR-41): a parameter unioned from more than
|
|
111
|
+
# this many distinct concrete call-site types is widened to `untyped`
|
|
112
|
+
# (poisoned) rather than carrying an unbounded union.
|
|
113
|
+
MAX_CALL_SITE_TYPES = 16
|
|
114
|
+
|
|
115
|
+
# WD5 — the call-site union is a worklist fixpoint: each round re-types the
|
|
116
|
+
# project with the previous round's inferred parameters seeded, so a
|
|
117
|
+
# parameter passed *another* parameter is typed one hop further per round.
|
|
118
|
+
# Capped (no true-convergence requirement — the metric tolerates a bounded
|
|
119
|
+
# approximation, and the table can oscillate at the margin since a newly
|
|
120
|
+
# resolved receiver can surface a fresh untyped-argument call site). The
|
|
121
|
+
# cap matches the `Inference::BodyFixpoint` cap-3 convention. Round 1 alone
|
|
122
|
+
# is the single-level pass.
|
|
123
|
+
DEFAULT_ROUNDS = 3
|
|
124
|
+
|
|
125
|
+
# @param files [Array<String>] project `.rb` paths to scan for call sites.
|
|
126
|
+
# @param environment [Rigor::Environment]
|
|
127
|
+
# @param target_ruby [String, nil] Prism parse target.
|
|
128
|
+
# @param max_rounds [Integer] the WD5 fixpoint cap (1 = single-level).
|
|
129
|
+
# @return [Hash{[String,Symbol,Symbol] => Hash{Symbol => Rigor::Type}}] frozen.
|
|
130
|
+
def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
|
|
131
|
+
new(files: files, environment: environment, target_ruby: target_ruby, max_rounds: max_rounds).collect
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
|
|
135
|
+
@files = files
|
|
136
|
+
@environment = environment
|
|
137
|
+
@target_ruby = target_ruby
|
|
138
|
+
@max_rounds = max_rounds
|
|
139
|
+
# Reset per round (see {#run_round}). `[[class, method, kind], param_sym]`
|
|
140
|
+
# => [Type] of observed concrete arguments (a default-block Hash, not a
|
|
141
|
+
# `{}` literal, so the analyzer types its reads generically — {#finalize}),
|
|
142
|
+
# plus the ids widened to `untyped` (an untyped / over-cap argument).
|
|
143
|
+
@type_observations = Hash.new { |hash, id| hash[id] = [] }
|
|
144
|
+
@poisoned_params = Set.new
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def collect
|
|
148
|
+
parsed = parse_all
|
|
149
|
+
discovery = discovery_seed_tables
|
|
150
|
+
table = EMPTY
|
|
151
|
+
@max_rounds.times do
|
|
152
|
+
rounded = run_round(parsed, discovery, table)
|
|
153
|
+
return rounded if rounded == table # fixpoint reached
|
|
154
|
+
|
|
155
|
+
table = rounded
|
|
156
|
+
end
|
|
157
|
+
table
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
# Parse every project file once; rounds re-index the cached ASTs against an
|
|
163
|
+
# evolving seed rather than re-parsing.
|
|
164
|
+
def parse_all
|
|
165
|
+
@files.filter_map do |path|
|
|
166
|
+
result = Prism.parse(File.read(path), filepath: path, version: @target_ruby)
|
|
167
|
+
[path, result.value] if result.errors.empty?
|
|
168
|
+
rescue Errno::ENOENT, Errno::EISDIR, Errno::EACCES
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# One fixpoint round: re-type every file with `seed_table` (the previous
|
|
174
|
+
# round's inferred parameters) seeded, collecting the next round's table.
|
|
175
|
+
def run_round(parsed, discovery_tables, seed_table)
|
|
176
|
+
@type_observations = Hash.new { |hash, id| hash[id] = [] }
|
|
177
|
+
@poisoned_params = Set.new
|
|
178
|
+
seed_scope = build_seed_scope(discovery_tables, seed_table)
|
|
179
|
+
parsed.each do |path, ast|
|
|
180
|
+
index = ScopeIndexer.index(ast, default_scope: seed_scope.with_source_path(path))
|
|
181
|
+
Source::NodeWalker.each(ast) do |node|
|
|
182
|
+
record_call(node, index) if node.is_a?(Prism::CallNode)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
finalize
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A scope carrying the cross-file discovery index (so `Foo.new` receivers
|
|
189
|
+
# and implicit-self calls resolve to a user `def`) plus the prior round's
|
|
190
|
+
# inferred parameters (so an argument that reads a parameter types to its
|
|
191
|
+
# current inferred type — the WD5 propagation).
|
|
192
|
+
def build_seed_scope(discovery_tables, seed_table)
|
|
193
|
+
base = Scope.empty(environment: @environment)
|
|
194
|
+
tables = discovery_tables
|
|
195
|
+
tables = tables.merge(param_inferred_types: seed_table) unless seed_table.empty?
|
|
196
|
+
return base if tables.empty?
|
|
197
|
+
|
|
198
|
+
base.with_discovery(base.discovery.with(**tables))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def discovery_seed_tables
|
|
202
|
+
classes = ScopeIndexer.discovered_classes_for_paths(@files)
|
|
203
|
+
def_index = ScopeIndexer.discovered_def_index_for_paths(@files)
|
|
204
|
+
tables = {}
|
|
205
|
+
tables[:discovered_classes] = classes unless classes.empty?
|
|
206
|
+
DISCOVERY_FIELD.each do |index_key, field|
|
|
207
|
+
table = def_index.fetch(index_key)
|
|
208
|
+
tables[field] = table unless table.empty?
|
|
209
|
+
end
|
|
210
|
+
tables
|
|
211
|
+
rescue StandardError
|
|
212
|
+
# Discovery is best-effort; a malformed corner of the project must not
|
|
213
|
+
# crash the protection scan. Without discovery the collector simply
|
|
214
|
+
# resolves fewer call sites.
|
|
215
|
+
{}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
DISCOVERY_FIELD = {
|
|
219
|
+
def_nodes: :discovered_def_nodes,
|
|
220
|
+
singleton_def_nodes: :discovered_singleton_def_nodes,
|
|
221
|
+
def_sources: :discovered_def_sources,
|
|
222
|
+
superclasses: :discovered_superclasses,
|
|
223
|
+
includes: :discovered_includes,
|
|
224
|
+
class_sources: :discovered_class_sources,
|
|
225
|
+
method_visibilities: :discovered_method_visibilities,
|
|
226
|
+
methods: :discovered_methods,
|
|
227
|
+
data_member_layouts: :data_member_layouts,
|
|
228
|
+
struct_member_layouts: :struct_member_layouts
|
|
229
|
+
}.freeze
|
|
230
|
+
private_constant :DISCOVERY_FIELD
|
|
231
|
+
|
|
232
|
+
def record_call(call_node, index)
|
|
233
|
+
args = positional_args(call_node)
|
|
234
|
+
return if args.nil?
|
|
235
|
+
|
|
236
|
+
scope = index[call_node]
|
|
237
|
+
return if scope.nil?
|
|
238
|
+
|
|
239
|
+
callee = resolve_callee(call_node, scope, index)
|
|
240
|
+
return if callee.nil?
|
|
241
|
+
|
|
242
|
+
class_name, method, kind, def_node = callee
|
|
243
|
+
requireds = simple_requireds(def_node)
|
|
244
|
+
return if requireds.nil? || requireds.size != args.size
|
|
245
|
+
|
|
246
|
+
key = [class_name, method, kind]
|
|
247
|
+
args.each_with_index do |arg, i|
|
|
248
|
+
arg_scope = index[arg]
|
|
249
|
+
accumulate(key, requireds[i].name, arg_scope&.type_of(arg))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The plain positional arguments, or nil when the call carries any
|
|
254
|
+
# non-plain argument (splat / keyword / block-pass / forwarding) — those
|
|
255
|
+
# break the positional-index ↔ parameter mapping, so the call site is
|
|
256
|
+
# skipped rather than mis-attributed.
|
|
257
|
+
def positional_args(call_node)
|
|
258
|
+
arguments = call_node.arguments
|
|
259
|
+
return [] if arguments.nil?
|
|
260
|
+
|
|
261
|
+
list = arguments.arguments
|
|
262
|
+
return nil if list.any? { |arg| non_plain_argument?(arg) }
|
|
263
|
+
|
|
264
|
+
list
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def non_plain_argument?(arg)
|
|
268
|
+
arg.is_a?(Prism::SplatNode) ||
|
|
269
|
+
arg.is_a?(Prism::KeywordHashNode) ||
|
|
270
|
+
arg.is_a?(Prism::BlockArgumentNode) ||
|
|
271
|
+
arg.is_a?(Prism::ForwardingArgumentsNode) ||
|
|
272
|
+
arg.is_a?(Prism::AssocNode) ||
|
|
273
|
+
arg.is_a?(Prism::AssocSplatNode)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @return [[String, Symbol, Symbol, Prism::DefNode], nil]
|
|
277
|
+
def resolve_callee(call_node, scope, index)
|
|
278
|
+
if call_node.receiver.nil?
|
|
279
|
+
class_name, kind = implicit_self_target(scope)
|
|
280
|
+
else
|
|
281
|
+
class_name, kind = explicit_receiver_target(call_node.receiver, index, scope)
|
|
282
|
+
end
|
|
283
|
+
return nil if class_name.nil?
|
|
284
|
+
|
|
285
|
+
def_node = lookup_def(scope, class_name, call_node.name, kind)
|
|
286
|
+
return nil if def_node.nil?
|
|
287
|
+
|
|
288
|
+
[class_name, call_node.name, kind, def_node]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def implicit_self_target(scope)
|
|
292
|
+
self_type = scope.self_type
|
|
293
|
+
return [nil, nil] if self_type.nil?
|
|
294
|
+
|
|
295
|
+
[ParameterArgTypes.class_name_of(self_type), self_type.is_a?(Type::Singleton) ? :singleton : :instance]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def explicit_receiver_target(receiver, index, scope)
|
|
299
|
+
receiver_type = (index[receiver] || scope).type_of(receiver)
|
|
300
|
+
[ParameterArgTypes.concrete_class_name(receiver_type),
|
|
301
|
+
receiver_type.is_a?(Type::Singleton) ? :singleton : :instance]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def lookup_def(scope, class_name, method, kind)
|
|
305
|
+
table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
|
|
306
|
+
per_class = table[class_name]
|
|
307
|
+
per_class && per_class[method]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The required-positional parameters, or nil when the method's parameter
|
|
311
|
+
# list is not a simple all-required shape (matching the single-level
|
|
312
|
+
# contract `ExpressionTyper#user_method_param_shape_simple?` uses) or
|
|
313
|
+
# contains a destructured `(a, b)` slot (no bindable name).
|
|
314
|
+
def simple_requireds(def_node)
|
|
315
|
+
params = def_node.parameters
|
|
316
|
+
return [] if params.nil?
|
|
317
|
+
return nil unless params.is_a?(Prism::ParametersNode)
|
|
318
|
+
return nil unless params.optionals.empty? && params.rest.nil? && params.posts.empty? &&
|
|
319
|
+
params.keywords.empty? && params.keyword_rest.nil? && params.block.nil?
|
|
320
|
+
return nil unless params.requireds.all?(Prism::RequiredParameterNode)
|
|
321
|
+
|
|
322
|
+
params.requireds
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def accumulate(key, param_name, arg_type)
|
|
326
|
+
id = [key, param_name.to_sym]
|
|
327
|
+
return if @poisoned_params.include?(id)
|
|
328
|
+
|
|
329
|
+
if arg_type.nil? || ParameterArgTypes.non_concrete?(arg_type)
|
|
330
|
+
poison(id)
|
|
331
|
+
else
|
|
332
|
+
observations = @type_observations[id]
|
|
333
|
+
observations << ParameterArgTypes.widen_for_param(arg_type)
|
|
334
|
+
poison(id) if observations.length > MAX_CALL_SITE_TYPES
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A poisoned parameter is dropped from the observation store and recorded
|
|
339
|
+
# so later call sites short-circuit. `id` is the `[[class, method, kind],
|
|
340
|
+
# param]` pair.
|
|
341
|
+
def poison(id)
|
|
342
|
+
@poisoned_params << id
|
|
343
|
+
@type_observations.delete(id)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def finalize
|
|
347
|
+
# `result` is a default-block Hash (not a `{}` literal) so the analyzer
|
|
348
|
+
# types its reads generically rather than folding the empty shape — the
|
|
349
|
+
# nesting writes stay plain assignments, no literal-fold conditions.
|
|
350
|
+
result = Hash.new { |hash, key| hash[key] = {} }
|
|
351
|
+
@type_observations.each do |id, observations|
|
|
352
|
+
next if @poisoned_params.include?(id)
|
|
353
|
+
next if observations.empty?
|
|
354
|
+
|
|
355
|
+
union = Type::Combinator.union(*observations)
|
|
356
|
+
# A union that collapsed to a non-concrete shape (e.g. a gradual arm
|
|
357
|
+
# leaked in) is no better than `untyped`; drop it.
|
|
358
|
+
next if ParameterArgTypes.non_concrete?(union)
|
|
359
|
+
|
|
360
|
+
key, param = id
|
|
361
|
+
result[key][param] = union
|
|
362
|
+
end
|
|
363
|
+
result.transform_values(&:freeze).freeze
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -13,13 +13,10 @@ module Rigor
|
|
|
13
13
|
# project-side `lib/core_ext/string_extensions.rb` patches
|
|
14
14
|
# are visible to cross-file dispatch.
|
|
15
15
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# real-world `core_ext` patches return shapes the analyzer
|
|
21
|
-
# could heuristically extract via the same machinery the
|
|
22
|
-
# ADR-10 walker uses, but slice 2 keeps the surface narrow).
|
|
16
|
+
# The dispatcher answers `Dynamic[T]` (with a heuristic static
|
|
17
|
+
# facet) when `Entry#return_type` is non-nil, or `Dynamic[Top]`
|
|
18
|
+
# when the heuristic declined (`nil`). See {Entry} for the
|
|
19
|
+
# per-field contract.
|
|
23
20
|
class ProjectPatchedMethods
|
|
24
21
|
# Frozen value-object recording one `def` observed by the
|
|
25
22
|
# pre-pass. `class_name` is the qualified prefix
|
|
@@ -4,6 +4,7 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "project_patched_methods"
|
|
6
6
|
require_relative "../analysis/dependency_source_inference/return_type_heuristic"
|
|
7
|
+
require_relative "../source/constant_path"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
9
10
|
module Inference
|
|
@@ -161,7 +162,7 @@ module Rigor
|
|
|
161
162
|
private_class_method :walk_children
|
|
162
163
|
|
|
163
164
|
def descend_class_or_module(node, qualified_prefix, in_singleton_class, source_path, entries)
|
|
164
|
-
name =
|
|
165
|
+
name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
|
|
165
166
|
if name && node.body
|
|
166
167
|
walk_node(node.body, qualified_prefix + [name], in_singleton_class, source_path, entries)
|
|
167
168
|
else
|
|
@@ -193,18 +194,6 @@ module Rigor
|
|
|
193
194
|
)
|
|
194
195
|
end
|
|
195
196
|
private_class_method :record_def_node
|
|
196
|
-
|
|
197
|
-
def qualified_name_for(node)
|
|
198
|
-
case node
|
|
199
|
-
when Prism::ConstantReadNode then node.name.to_s
|
|
200
|
-
when Prism::ConstantPathNode
|
|
201
|
-
parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
|
|
202
|
-
return nil if !node.parent.nil? && parent.nil?
|
|
203
|
-
|
|
204
|
-
parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
private_class_method :qualified_name_for
|
|
208
197
|
end
|
|
209
198
|
end
|
|
210
199
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scope_indexer"
|
|
4
|
+
require_relative "../source/node_walker"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Inference
|
|
8
|
+
# ADR-63 Tier 1 — the static type-protection proxy. Walks every dispatch
|
|
9
|
+
# site (a method call with an explicit receiver) and classifies it by
|
|
10
|
+
# whether the receiver types to a concrete (non-`Dynamic`) type — i.e. a
|
|
11
|
+
# site where Rigor's call rules *can* bite (undefined-method / wrong-arity /
|
|
12
|
+
# argument-type-mismatch). A `Dynamic` / `Top` receiver (or a union with such
|
|
13
|
+
# an arm — gradually valid) is *unprotected*: Rigor is blind there, and a
|
|
14
|
+
# type annotation on it would buy real catching power.
|
|
15
|
+
#
|
|
16
|
+
# This is a sound UPPER BOUND on protection — a concrete receiver is
|
|
17
|
+
# necessary but not sufficient for a diagnostic to actually fire — and it is
|
|
18
|
+
# one `type_of` pass, so it runs interactively and in CI. The truth tier
|
|
19
|
+
# (does a diagnostic fire) is the phased mutation tier.
|
|
20
|
+
class ProtectionScanner
|
|
21
|
+
# A single unprotected call site.
|
|
22
|
+
Site = Data.define(:line, :receiver, :method_name)
|
|
23
|
+
|
|
24
|
+
FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
|
|
25
|
+
def total = protected_count + unprotected_count
|
|
26
|
+
|
|
27
|
+
# Protected ratio; a file with no dispatch sites is vacuously fully
|
|
28
|
+
# protected (nothing to get wrong).
|
|
29
|
+
def ratio = total.zero? ? 1.0 : protected_count.to_f / total
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(scope: nil)
|
|
33
|
+
@scope = scope || Scope.empty
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param root [Prism::Node] the parsed AST
|
|
37
|
+
# @return [FileResult]
|
|
38
|
+
def scan(root)
|
|
39
|
+
index = ScopeIndexer.index(root, default_scope: @scope)
|
|
40
|
+
protected_count = 0
|
|
41
|
+
sites = []
|
|
42
|
+
|
|
43
|
+
Source::NodeWalker.each(root) do |node|
|
|
44
|
+
next unless dispatch_site?(node)
|
|
45
|
+
|
|
46
|
+
receiver_type = index[node.receiver].type_of(node.receiver)
|
|
47
|
+
if concrete_receiver?(receiver_type)
|
|
48
|
+
protected_count += 1
|
|
49
|
+
else
|
|
50
|
+
sites << Site.new(
|
|
51
|
+
line: node.location.start_line,
|
|
52
|
+
receiver: safe_describe(receiver_type),
|
|
53
|
+
method_name: node.name.to_s
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
FileResult.new(protected_count: protected_count, unprotected_count: sites.size, sites: sites)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def dispatch_site?(node)
|
|
64
|
+
node.is_a?(Prism::CallNode) && !node.receiver.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A receiver Rigor can reason about: anything that is not `Dynamic` /
|
|
68
|
+
# `Top`, and (for a union) only when every arm is concrete — a single
|
|
69
|
+
# `Dynamic` arm makes the whole call gradually valid, so the rules stay
|
|
70
|
+
# silent there.
|
|
71
|
+
def concrete_receiver?(type)
|
|
72
|
+
case type
|
|
73
|
+
when Type::Dynamic, Type::Top then false
|
|
74
|
+
when Type::Union then type.members.all? { |member| concrete_receiver?(member) }
|
|
75
|
+
else true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def safe_describe(type)
|
|
80
|
+
type.respond_to?(:describe) ? type.describe(:short) : type.to_s
|
|
81
|
+
rescue StandardError
|
|
82
|
+
type.class.name
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|