rigortype 0.2.5 → 0.2.6
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/docs/handbook/09-plugins.md +5 -2
- data/docs/handbook/appendix-liskov.md +5 -3
- data/docs/handbook/appendix-phpstan.md +2 -2
- data/docs/install.md +1 -1
- data/docs/manual/02-cli-reference.md +58 -1
- data/docs/manual/06-baseline.md +12 -0
- data/docs/manual/11-ci.md +6 -6
- data/docs/manual/15-type-protection-coverage.md +29 -0
- data/docs/manual/plugins/rigor-minitest.md +1 -1
- data/lib/rigor/cli/check_command.rb +4 -33
- data/lib/rigor/cli/check_runner_factory.rb +63 -0
- data/lib/rigor/cli/doctor_command.rb +295 -0
- data/lib/rigor/cli/plugins_command.rb +2 -2
- data/lib/rigor/cli/plugins_renderer.rb +1 -1
- data/lib/rigor/cli/protection_renderer.rb +32 -2
- data/lib/rigor/cli/protection_report.rb +32 -6
- data/lib/rigor/cli/upgrade_command.rb +25 -0
- data/lib/rigor/cli.rb +17 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/inference/dynamic_origin.rb +67 -0
- data/lib/rigor/inference/expression_typer.rb +22 -10
- data/lib/rigor/inference/fallback.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
- data/lib/rigor/inference/method_dispatcher.rb +19 -4
- data/lib/rigor/inference/mutation_widening.rb +18 -0
- data/lib/rigor/inference/protection_scanner.rb +6 -3
- data/lib/rigor/inference/statement_evaluator.rb +5 -4
- data/lib/rigor/plugin/base.rb +34 -7
- data/lib/rigor/plugin/registry.rb +1 -1
- data/lib/rigor/scope.rb +16 -5
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
- data/sig/rigor/plugin/base.rbs +2 -0
- data/sig/rigor/scope.rbs +3 -1
- data/skills/rigor-plugin-author/SKILL.md +8 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
- metadata +5 -1
|
@@ -37,7 +37,7 @@ module Rigor
|
|
|
37
37
|
# `source_rbs_synthesizer:`);
|
|
38
38
|
# - the ADR-37 narrow extension protocols read off the plugin
|
|
39
39
|
# class — `node_rule` node types, `dynamic_return` receivers,
|
|
40
|
-
# `
|
|
40
|
+
# `narrowing_facts` methods.
|
|
41
41
|
#
|
|
42
42
|
# `--capabilities` switches to a focused catalogue of just the
|
|
43
43
|
# narrow-protocol gate values + produced/consumed facts (ADR-37
|
|
@@ -194,7 +194,7 @@ module Rigor
|
|
|
194
194
|
|
|
195
195
|
# ADR-37 narrow extension protocols. Unlike the 10 declarative
|
|
196
196
|
# manifest fields, these are class-level DSLs (`node_rule` /
|
|
197
|
-
# `dynamic_return` / `
|
|
197
|
+
# `dynamic_return` / `narrowing_facts`), so they are read off the
|
|
198
198
|
# plugin class rather than the manifest. The gate values — node
|
|
199
199
|
# types, receiver class names, specified method names — are the
|
|
200
200
|
# greppable, enumerable surface the capability catalogue exposes.
|
|
@@ -170,7 +170,7 @@ module Rigor
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
# ADR-37 narrow extension protocols (node_rule / dynamic_return /
|
|
173
|
-
#
|
|
173
|
+
# narrowing_facts). Surfaced in the full report alongside the
|
|
174
174
|
# declarative surfaces; `--capabilities` is the focused view.
|
|
175
175
|
def narrow_protocol_lines(row)
|
|
176
176
|
lines = []
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require_relative "../inference/dynamic_origin"
|
|
6
|
+
|
|
5
7
|
module Rigor
|
|
6
8
|
class CLI
|
|
7
9
|
# Renders an {ProtectionReport} (ADR-63 Tier 1) as text or JSON. The text
|
|
@@ -12,6 +14,15 @@ module Rigor
|
|
|
12
14
|
TOP_CALLS = 15
|
|
13
15
|
TOP_FILES = 10
|
|
14
16
|
|
|
17
|
+
# ADR-73 P6 / ADR-75 WD2 — the actionable axis for each tractability
|
|
18
|
+
# category, shown next to a hole's origin so a user knows whether (and
|
|
19
|
+
# how) a type can close it.
|
|
20
|
+
TRACTABILITY_HINTS = {
|
|
21
|
+
Inference::DynamicOrigin::ADD_RBS => "add RBS",
|
|
22
|
+
Inference::DynamicOrigin::ENABLE_PLUGIN => "enable a plugin / pre_eval",
|
|
23
|
+
Inference::DynamicOrigin::ENGINE_GAP => "engine gap — report it"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
15
26
|
def initialize(out:)
|
|
16
27
|
@out = out
|
|
17
28
|
end
|
|
@@ -40,13 +51,32 @@ module Rigor
|
|
|
40
51
|
return if calls.empty?
|
|
41
52
|
|
|
42
53
|
@out.puts "\nAdd a type here — methods most often called on an untyped receiver:"
|
|
54
|
+
render_tractability_summary(report)
|
|
43
55
|
calls.first(TOP_CALLS).each do |call|
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
label = origin_label(call.dynamic_origin)
|
|
57
|
+
@out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s%<label>s",
|
|
58
|
+
count: call.count, method: call.method_name,
|
|
59
|
+
sites: call.examples.join(" "), label: label)
|
|
46
60
|
end
|
|
47
61
|
@out.puts " (#{calls.size - TOP_CALLS} more)" if calls.size > TOP_CALLS
|
|
48
62
|
end
|
|
49
63
|
|
|
64
|
+
def render_tractability_summary(report)
|
|
65
|
+
summary = report.tractability_summary
|
|
66
|
+
return if summary.empty?
|
|
67
|
+
|
|
68
|
+
parts = summary.sort_by { |_, n| -n }.map { |axis, n| "#{n} #{axis.to_s.tr('_', '-')}" }
|
|
69
|
+
@out.puts " by tractability: #{parts.join(' · ')} (add-rbs = closable with a type)"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def origin_label(origin)
|
|
73
|
+
return "" if origin.nil?
|
|
74
|
+
|
|
75
|
+
tractability = Inference::DynamicOrigin.tractability(origin)
|
|
76
|
+
hint = tractability ? " → #{TRACTABILITY_HINTS[tractability]}" : ""
|
|
77
|
+
" [#{origin.to_s.tr('_', '-')}#{hint}]"
|
|
78
|
+
end
|
|
79
|
+
|
|
50
80
|
def render_files(report)
|
|
51
81
|
worst = report.files.reject { |f| f.unprotected_count.zero? }.sort_by(&:ratio).first(TOP_FILES)
|
|
52
82
|
return if worst.empty?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../inference/dynamic_origin"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
class CLI
|
|
5
7
|
# ADR-63 Tier 1 — aggregates per-file {Inference::ProtectionScanner}
|
|
@@ -9,7 +11,7 @@ module Rigor
|
|
|
9
11
|
# untyped dispatches, where a receiver annotation buys the most catching
|
|
10
12
|
# power.
|
|
11
13
|
FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
|
|
12
|
-
UntypedCall = Data.define(:method_name, :count, :examples)
|
|
14
|
+
UntypedCall = Data.define(:method_name, :count, :examples, :dynamic_origin)
|
|
13
15
|
|
|
14
16
|
ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors) do
|
|
15
17
|
def total_protected = files.sum(&:protected_count)
|
|
@@ -17,17 +19,37 @@ module Rigor
|
|
|
17
19
|
def grand_total = total_protected + total_unprotected
|
|
18
20
|
def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
|
|
19
21
|
|
|
22
|
+
# ADR-73 P6 — total dispatch-site count per tractability axis across
|
|
23
|
+
# the classified holes (those with a recorded `dynamic_origin`), so a
|
|
24
|
+
# user sees at a glance how much of the untyped surface a type can
|
|
25
|
+
# actually close (`add_rbs`) vs. needs a plugin (`enable_plugin`) vs.
|
|
26
|
+
# is an engine gap. Keys are omitted when zero; an all-unclassified
|
|
27
|
+
# run yields `{}`.
|
|
28
|
+
def tractability_summary
|
|
29
|
+
untyped_calls.each_with_object(Hash.new(0)) do |c, acc|
|
|
30
|
+
axis = c.dynamic_origin && Inference::DynamicOrigin.tractability(c.dynamic_origin)
|
|
31
|
+
acc[axis] += c.count if axis
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
def to_h
|
|
21
36
|
{
|
|
22
37
|
"protected" => total_protected,
|
|
23
38
|
"unprotected" => total_unprotected,
|
|
24
39
|
"protection_ratio" => ratio.round(4),
|
|
40
|
+
"tractability_summary" => tractability_summary.transform_keys(&:to_s),
|
|
25
41
|
"files" => files.map do |f|
|
|
26
42
|
{ "path" => f.path, "protected" => f.protected_count,
|
|
27
43
|
"unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4) }
|
|
28
44
|
end,
|
|
29
45
|
"add_a_type_here" => untyped_calls.map do |c|
|
|
30
|
-
{ "method" => c.method_name, "count" => c.count, "examples" => c.examples }
|
|
46
|
+
entry = { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
|
|
47
|
+
if c.dynamic_origin
|
|
48
|
+
entry["dynamic_origin"] = c.dynamic_origin
|
|
49
|
+
tractability = Inference::DynamicOrigin.tractability(c.dynamic_origin)
|
|
50
|
+
entry["tractability"] = tractability if tractability
|
|
51
|
+
end
|
|
52
|
+
entry
|
|
31
53
|
end,
|
|
32
54
|
"parse_errors" => parse_errors
|
|
33
55
|
}
|
|
@@ -37,7 +59,7 @@ module Rigor
|
|
|
37
59
|
class ProtectionAccumulator
|
|
38
60
|
def initialize
|
|
39
61
|
@files = []
|
|
40
|
-
@calls = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
|
|
62
|
+
@calls = Hash.new { |h, k| h[k] = { count: 0, examples: [], origins: Hash.new(0) } }
|
|
41
63
|
@parse_errors = []
|
|
42
64
|
end
|
|
43
65
|
|
|
@@ -50,6 +72,7 @@ module Rigor
|
|
|
50
72
|
bucket = @calls[site.method_name]
|
|
51
73
|
bucket[:count] += 1
|
|
52
74
|
bucket[:examples] << "#{path}:#{site.line}" if bucket[:examples].size < 3
|
|
75
|
+
bucket[:origins][site.dynamic_origin] += 1 if site.dynamic_origin
|
|
53
76
|
end
|
|
54
77
|
end
|
|
55
78
|
|
|
@@ -58,9 +81,12 @@ module Rigor
|
|
|
58
81
|
end
|
|
59
82
|
|
|
60
83
|
def to_report
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
calls = @calls.map do |method, v|
|
|
85
|
+
origin = v[:origins].max_by { |_, count| count }&.first
|
|
86
|
+
UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples],
|
|
87
|
+
dynamic_origin: origin)
|
|
88
|
+
end
|
|
89
|
+
untyped = calls.sort_by { |c| [-c.count, c.method_name] }
|
|
64
90
|
ProtectionReport.new(files: @files, untyped_calls: untyped, parse_errors: @parse_errors)
|
|
65
91
|
end
|
|
66
92
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# `rigor upgrade` — the ADR-50 WD7 migration command skeleton.
|
|
8
|
+
#
|
|
9
|
+
# The real body lands when a concrete backwards-compatibility break
|
|
10
|
+
# gives it a target (e.g. re-running `baseline regenerate` against a
|
|
11
|
+
# strengthened default profile, surfacing renamed suppression ids,
|
|
12
|
+
# reporting `bleeding_edge:` graduations). Until then it prints the
|
|
13
|
+
# current version and notes that upgrade is queued.
|
|
14
|
+
class UpgradeCommand < Command
|
|
15
|
+
USAGE = "Usage: rigor upgrade [options]"
|
|
16
|
+
|
|
17
|
+
# @return [Integer] CLI exit status.
|
|
18
|
+
def run
|
|
19
|
+
@out.puts("rigor upgrade: No migration target available yet (ADR-50 WD7, queued).")
|
|
20
|
+
@out.puts("Current version: #{Rigor::VERSION}")
|
|
21
|
+
0
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -43,7 +43,9 @@ module Rigor
|
|
|
43
43
|
"skill" => :run_skill,
|
|
44
44
|
"describe" => :run_describe,
|
|
45
45
|
"docs" => :run_docs,
|
|
46
|
-
"show-bleedingedge" => :run_show_bleedingedge
|
|
46
|
+
"show-bleedingedge" => :run_show_bleedingedge,
|
|
47
|
+
"doctor" => :run_doctor,
|
|
48
|
+
"upgrade" => :run_upgrade
|
|
47
49
|
}.freeze
|
|
48
50
|
|
|
49
51
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -317,6 +319,18 @@ module Rigor
|
|
|
317
319
|
CLI::ShowBleedingedgeCommand.new(argv: @argv, out: @out, err: @err).run
|
|
318
320
|
end
|
|
319
321
|
|
|
322
|
+
def run_doctor
|
|
323
|
+
require_relative "cli/doctor_command"
|
|
324
|
+
|
|
325
|
+
CLI::DoctorCommand.new(argv: @argv, out: @out, err: @err).run
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def run_upgrade
|
|
329
|
+
require_relative "cli/upgrade_command"
|
|
330
|
+
|
|
331
|
+
CLI::UpgradeCommand.new(argv: @argv, out: @out, err: @err).run
|
|
332
|
+
end
|
|
333
|
+
|
|
320
334
|
def help
|
|
321
335
|
<<~HELP
|
|
322
336
|
Usage: rigor <command> [options]
|
|
@@ -342,6 +356,8 @@ module Rigor
|
|
|
342
356
|
skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
|
|
343
357
|
docs Print the bundled docs offline (docs <name>, docs --list)
|
|
344
358
|
show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
|
|
359
|
+
doctor Classify setup problems vs clean run with routed next actions (ADR-77)
|
|
360
|
+
upgrade Migration command skeleton (ADR-50 WD7, queued)
|
|
345
361
|
version Print the Rigor version
|
|
346
362
|
help Print this help
|
|
347
363
|
HELP
|
|
@@ -18,7 +18,7 @@ module Rigor
|
|
|
18
18
|
# (`Rigor::RbsExtended::PredicateEffect`).
|
|
19
19
|
# 3. RBS::Extended `assert*` directives
|
|
20
20
|
# (`Rigor::RbsExtended::AssertEffect`).
|
|
21
|
-
# 4. Plugin contributions via `
|
|
21
|
+
# 4. Plugin contributions via `narrowing_facts` DSL (ADR-52).
|
|
22
22
|
#
|
|
23
23
|
# Each of those four carriers translates to / from Fact at
|
|
24
24
|
# its boundary; downstream of {Rigor::FlowContribution#to_element_list}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
# ADR-75 v1 cause set — precision-additive provenance for Dynamic[T]
|
|
6
|
+
# introduction sites. Each symbol identifies a distinct family of
|
|
7
|
+
# reason that an expression widened to Dynamic, surfaced by
|
|
8
|
+
# `rigor coverage --protection` so users can distinguish tractable
|
|
9
|
+
# holes (e.g. an explicit `untyped` RBS signature) from intractable
|
|
10
|
+
# ones (e.g. unsupported syntax).
|
|
11
|
+
#
|
|
12
|
+
# Provenance is a side-channel: it never participates in subtyping,
|
|
13
|
+
# consistency, normalization, or erasure, and no diagnostic fires
|
|
14
|
+
# from it.
|
|
15
|
+
module DynamicOrigin
|
|
16
|
+
# A gem with no resolvable RBS.
|
|
17
|
+
EXTERNAL_GEM_WITHOUT_RBS = :external_gem_without_rbs
|
|
18
|
+
# Value across macro/DSL expansion or plugin-declared dynamic return.
|
|
19
|
+
FRAMEWORK_DSL_BOUNDARY = :framework_dsl_boundary
|
|
20
|
+
# Budget/fuel guard widened to Dynamic.
|
|
21
|
+
ANALYZER_BUDGET_CUTOFF = :analyzer_budget_cutoff
|
|
22
|
+
# Authored `untyped` contract.
|
|
23
|
+
EXPLICIT_UNTYPED = :explicit_untyped
|
|
24
|
+
# Inference fallback on unmodeled construct.
|
|
25
|
+
UNSUPPORTED_SYNTAX = :unsupported_syntax
|
|
26
|
+
|
|
27
|
+
CAUSES = [
|
|
28
|
+
EXTERNAL_GEM_WITHOUT_RBS,
|
|
29
|
+
FRAMEWORK_DSL_BOUNDARY,
|
|
30
|
+
ANALYZER_BUDGET_CUTOFF,
|
|
31
|
+
EXPLICIT_UNTYPED,
|
|
32
|
+
UNSUPPORTED_SYNTAX
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# ADR-73 P6 / ADR-75 WD2 — tractability: given the cause, can a user
|
|
36
|
+
# close this `Dynamic` hole, and on which axis. `coverage --protection`
|
|
37
|
+
# surfaces it so a user does not chase a hole a hand-written type
|
|
38
|
+
# cannot fix. Three coarse, action-oriented categories:
|
|
39
|
+
#
|
|
40
|
+
# - `:add_rbs` — write or install RBS (a missing gem signature,
|
|
41
|
+
# or an authored `untyped` to tighten).
|
|
42
|
+
# - `:enable_plugin`— a framework / DSL boundary; needs a plugin or
|
|
43
|
+
# `pre_eval:`, not a hand-written type.
|
|
44
|
+
# - `:engine_gap` — not user-closable (a budget cutoff or unmodeled
|
|
45
|
+
# syntax); report it.
|
|
46
|
+
ADD_RBS = :add_rbs
|
|
47
|
+
ENABLE_PLUGIN = :enable_plugin
|
|
48
|
+
ENGINE_GAP = :engine_gap
|
|
49
|
+
|
|
50
|
+
TRACTABILITY = {
|
|
51
|
+
EXTERNAL_GEM_WITHOUT_RBS => ADD_RBS,
|
|
52
|
+
EXPLICIT_UNTYPED => ADD_RBS,
|
|
53
|
+
FRAMEWORK_DSL_BOUNDARY => ENABLE_PLUGIN,
|
|
54
|
+
ANALYZER_BUDGET_CUTOFF => ENGINE_GAP,
|
|
55
|
+
UNSUPPORTED_SYNTAX => ENGINE_GAP
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# @return [Symbol, nil] the tractability category for a cause, or nil
|
|
61
|
+
# when the cause is unknown / absent.
|
|
62
|
+
def tractability(cause)
|
|
63
|
+
TRACTABILITY[cause]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -9,6 +9,7 @@ require_relative "../analysis/self_call_resolution_recorder"
|
|
|
9
9
|
require_relative "block_parameter_binder"
|
|
10
10
|
require_relative "body_fixpoint"
|
|
11
11
|
require_relative "budget_trace"
|
|
12
|
+
require_relative "dynamic_origin"
|
|
12
13
|
require_relative "fallback"
|
|
13
14
|
require_relative "flow_tracer"
|
|
14
15
|
require_relative "indexed_narrowing"
|
|
@@ -224,15 +225,20 @@ module Rigor
|
|
|
224
225
|
def initialize(scope:, tracer: nil)
|
|
225
226
|
@scope = scope
|
|
226
227
|
@tracer = tracer
|
|
228
|
+
@typing_node = nil
|
|
227
229
|
end
|
|
228
230
|
|
|
229
231
|
def type_of(node)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
previous = @typing_node
|
|
233
|
+
@typing_node = node
|
|
234
|
+
result = if FlowTracer.active?
|
|
235
|
+
FlowTracer.trace_node(node) { untraced_type_of(node) }
|
|
236
|
+
else
|
|
237
|
+
untraced_type_of(node)
|
|
238
|
+
end
|
|
239
|
+
result
|
|
240
|
+
ensure
|
|
241
|
+
@typing_node = previous
|
|
236
242
|
end
|
|
237
243
|
|
|
238
244
|
def untraced_type_of(node)
|
|
@@ -1011,11 +1017,12 @@ module Rigor
|
|
|
1011
1017
|
|
|
1012
1018
|
def fallback_for(node, family:)
|
|
1013
1019
|
inner = dynamic_top
|
|
1014
|
-
record_fallback(node, family: family, inner_type: inner)
|
|
1020
|
+
record_fallback(node, family: family, inner_type: inner, origin: DynamicOrigin::UNSUPPORTED_SYNTAX)
|
|
1021
|
+
scope.record_dynamic_origin(node, DynamicOrigin::UNSUPPORTED_SYNTAX)
|
|
1015
1022
|
inner
|
|
1016
1023
|
end
|
|
1017
1024
|
|
|
1018
|
-
def record_fallback(node, family:, inner_type:)
|
|
1025
|
+
def record_fallback(node, family:, inner_type:, origin: nil)
|
|
1019
1026
|
return unless tracer
|
|
1020
1027
|
|
|
1021
1028
|
location = node.respond_to?(:location) ? node.location : nil
|
|
@@ -1023,7 +1030,8 @@ module Rigor
|
|
|
1023
1030
|
node_class: node.class,
|
|
1024
1031
|
location: location,
|
|
1025
1032
|
family: family,
|
|
1026
|
-
inner_type: inner_type
|
|
1033
|
+
inner_type: inner_type,
|
|
1034
|
+
origin: origin
|
|
1027
1035
|
)
|
|
1028
1036
|
tracer.record_fallback(event)
|
|
1029
1037
|
end
|
|
@@ -2128,6 +2136,7 @@ module Rigor
|
|
|
2128
2136
|
# this signature's summary sees the floor, not the stale `bot` seed.
|
|
2129
2137
|
def degrade_entangled_fixpoint(summaries, plain_signature)
|
|
2130
2138
|
BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
|
|
2139
|
+
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
2131
2140
|
summaries[plain_signature][:assumption] = Type::Combinator.untyped
|
|
2132
2141
|
Type::Combinator.untyped
|
|
2133
2142
|
end
|
|
@@ -2152,6 +2161,7 @@ module Rigor
|
|
|
2152
2161
|
# Out of iterations and still unstable — collapse to today's
|
|
2153
2162
|
# widening behaviour.
|
|
2154
2163
|
BudgetTrace.hit(BudgetTrace::RECURSION_FIXPOINT_CAP)
|
|
2164
|
+
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
2155
2165
|
summaries[plain_signature][:assumption] = Type::Combinator.untyped
|
|
2156
2166
|
return Type::Combinator.untyped
|
|
2157
2167
|
end
|
|
@@ -2244,6 +2254,7 @@ module Rigor
|
|
|
2244
2254
|
return type unless would_have_been_guarded && !fully_value_pinned?(type)
|
|
2245
2255
|
|
|
2246
2256
|
BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
|
|
2257
|
+
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
2247
2258
|
# ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned
|
|
2248
2259
|
# must be byte-identical to the plain guard's `untyped`. This path
|
|
2249
2260
|
# deliberately does NOT route to the in-progress fixpoint summary:
|
|
@@ -2363,7 +2374,8 @@ module Rigor
|
|
|
2363
2374
|
locals: locals.freeze,
|
|
2364
2375
|
self_type: receiver,
|
|
2365
2376
|
discovery: scope.discovery,
|
|
2366
|
-
struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body)
|
|
2377
|
+
struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body),
|
|
2378
|
+
dynamic_origins: scope.dynamic_origins
|
|
2367
2379
|
)
|
|
2368
2380
|
end
|
|
2369
2381
|
|
|
@@ -20,8 +20,8 @@ module Rigor
|
|
|
20
20
|
# - inner_type: the Rigor::Type returned to the caller (currently
|
|
21
21
|
# always Dynamic[Top]; later slices may carry richer fallback
|
|
22
22
|
# types).
|
|
23
|
-
class Fallback < Data.define(:node_class, :location, :family, :inner_type)
|
|
24
|
-
def initialize(node_class:, location:, family:, inner_type:)
|
|
23
|
+
class Fallback < Data.define(:node_class, :location, :family, :inner_type, :origin)
|
|
24
|
+
def initialize(node_class:, location:, family:, inner_type:, origin: nil)
|
|
25
25
|
raise ArgumentError, "node_class must be a Class, got #{node_class.class}" unless node_class.is_a?(Class)
|
|
26
26
|
|
|
27
27
|
unless FALLBACK_FAMILIES.include?(family)
|
|
@@ -218,11 +218,27 @@ module Rigor
|
|
|
218
218
|
# is what ultimately limits how wide an inferred type gets.
|
|
219
219
|
UNION_FOLD_OUTPUT_LIMIT = 8
|
|
220
220
|
|
|
221
|
+
# ADR-78 — reflexive over-fold guard.
|
|
222
|
+
# Reflective dispatch (`public_send` / `send` / `__send__`) must
|
|
223
|
+
# NOT constant-fold unless the method-name argument is itself a
|
|
224
|
+
# value-pinned literal `Constant[Symbol]`. With a runtime-variable
|
|
225
|
+
# method name the dispatched method is not statically determined,
|
|
226
|
+
# so the call degrades to the RBS result (`untyped`) — exactly as
|
|
227
|
+
# it does without the guard, but explicit so a later shape-carrier
|
|
228
|
+
# preservation tier (ADR-76 WD2) cannot surface an over-fold as a
|
|
229
|
+
# spurious `flow.always-truthy-condition`.
|
|
230
|
+
REFLECTIVE_SEND_METHODS = %i[public_send send __send__].to_set.freeze
|
|
231
|
+
|
|
221
232
|
# @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
|
|
222
233
|
def try_dispatch(context)
|
|
223
234
|
receiver = context.receiver
|
|
224
235
|
method_name = context.method_name
|
|
225
236
|
args = context.args
|
|
237
|
+
|
|
238
|
+
if REFLECTIVE_SEND_METHODS.include?(method_name)
|
|
239
|
+
first_arg = args.first
|
|
240
|
+
return nil unless first_arg.is_a?(Type::Constant) && first_arg.value.is_a?(Symbol)
|
|
241
|
+
end
|
|
226
242
|
# v0.0.7 — `String#%` against a `Tuple` / `HashShape`
|
|
227
243
|
# argument runs Ruby's format-string engine when both
|
|
228
244
|
# sides are statically constant. The standard
|
|
@@ -102,7 +102,11 @@ module Rigor
|
|
|
102
102
|
find_index: :tuple_find_index,
|
|
103
103
|
rindex: :tuple_rindex,
|
|
104
104
|
flatten: :tuple_flatten,
|
|
105
|
-
join: :tuple_join
|
|
105
|
+
join: :tuple_join,
|
|
106
|
+
freeze: :shape_self,
|
|
107
|
+
dup: :shape_self,
|
|
108
|
+
clone: :shape_self,
|
|
109
|
+
itself: :shape_self
|
|
106
110
|
}.freeze
|
|
107
111
|
|
|
108
112
|
# Byte cap on a folded `tuple.join` result — a huge tuple times a
|
|
@@ -151,7 +155,23 @@ module Rigor
|
|
|
151
155
|
:< => :hash_compare,
|
|
152
156
|
:<= => :hash_compare,
|
|
153
157
|
:> => :hash_compare,
|
|
154
|
-
:>= => :hash_compare
|
|
158
|
+
:>= => :hash_compare,
|
|
159
|
+
# ADR-76 WD2 / ADR-78 WD3 — pure self-returners preserve the
|
|
160
|
+
# `HashShape` carrier instead of degrading to the nominal `Hash`
|
|
161
|
+
# via the RBS `() -> self` signature, so
|
|
162
|
+
# `MESSAGES = {…}.freeze; MESSAGES[reason]` folds the value union
|
|
163
|
+
# rather than reading `Dynamic`. `dup` / `clone` produce a fresh
|
|
164
|
+
# object, but Rigor's shape carriers are immutable values, so
|
|
165
|
+
# preserving the carrier is sound for reads; a later in-place
|
|
166
|
+
# mutation routes through `MutationWidening`. (The `Tuple` table
|
|
167
|
+
# carries the same four entries; both became safe once the
|
|
168
|
+
# block-form over-fold guard landed in `try_dispatch` — ADR-78
|
|
169
|
+
# WD1 — so `CONST = [...].freeze; CONST.any? { … }` no longer
|
|
170
|
+
# folds the no-block result and fires a reflexive always-truthy.)
|
|
171
|
+
freeze: :shape_self,
|
|
172
|
+
dup: :shape_self,
|
|
173
|
+
clone: :shape_self,
|
|
174
|
+
itself: :shape_self
|
|
155
175
|
}.freeze
|
|
156
176
|
|
|
157
177
|
# @return [Rigor::Type, nil] the precise element/value type, or
|
|
@@ -189,6 +209,17 @@ module Rigor
|
|
|
189
209
|
method_name = context.method_name
|
|
190
210
|
args = context.args
|
|
191
211
|
args ||= []
|
|
212
|
+
# ADR-78 WD1 — every shape handler folds *no-block* semantics; none
|
|
213
|
+
# evaluates a passed block. So a block-form call (`tuple.any? { … }`,
|
|
214
|
+
# `tuple.sum { … }`, `tuple.count { … }`) must NOT fold the no-block
|
|
215
|
+
# result — doing so ignores the block (an over-fold: `any? { false }`
|
|
216
|
+
# would still fold `Constant[true]`). Declining defers to BlockFolding
|
|
217
|
+
# / RBS. This is the over-fold class the Tuple shape-carrier
|
|
218
|
+
# preservation (ADR-76 WD2) surfaced as reflexive `always-truthy` on
|
|
219
|
+
# `CONST = [...].freeze; CONST.any? { … }`; fixing it at the fold (not
|
|
220
|
+
# the rule) unblocks that preservation.
|
|
221
|
+
return nil unless context.block_type.nil?
|
|
222
|
+
|
|
192
223
|
handler = RECEIVER_HANDLERS[receiver.class]
|
|
193
224
|
return nil unless handler
|
|
194
225
|
|
|
@@ -236,6 +267,14 @@ module Rigor
|
|
|
236
267
|
send(handler, shape, method_name, args)
|
|
237
268
|
end
|
|
238
269
|
|
|
270
|
+
# ADR-76 WD2 / ADR-78 WD3 — a pure self-returner
|
|
271
|
+
# (`freeze` / `dup` / `clone` / `itself`) returns the receiver
|
|
272
|
+
# carrier unchanged, preserving the shape that the nominal
|
|
273
|
+
# `() -> self` RBS signature would otherwise drop.
|
|
274
|
+
def shape_self(carrier, _method_name, _args)
|
|
275
|
+
carrier
|
|
276
|
+
end
|
|
277
|
+
|
|
239
278
|
def dispatch_nominal_size(nominal, method_name, args)
|
|
240
279
|
projection = nominal_projection(nominal, method_name, args)
|
|
241
280
|
return projection if projection
|
|
@@ -6,6 +6,7 @@ require_relative "../flow_contribution"
|
|
|
6
6
|
require_relative "../flow_contribution/merger"
|
|
7
7
|
require_relative "../builtins/hkt_builtins"
|
|
8
8
|
require_relative "../builtins/static_return_refinements"
|
|
9
|
+
require_relative "dynamic_origin"
|
|
9
10
|
require_relative "flow_tracer"
|
|
10
11
|
require_relative "method_dispatcher/call_context"
|
|
11
12
|
require_relative "method_dispatcher/constant_folding"
|
|
@@ -83,7 +84,7 @@ module Rigor
|
|
|
83
84
|
result
|
|
84
85
|
end
|
|
85
86
|
|
|
86
|
-
def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
87
|
+
def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
87
88
|
block_type: nil, environment: nil,
|
|
88
89
|
call_node: nil, scope: nil)
|
|
89
90
|
return nil if receiver_type.nil?
|
|
@@ -114,7 +115,12 @@ module Rigor
|
|
|
114
115
|
# are supplied — the dispatcher's own internal callers
|
|
115
116
|
# (per-element block fold, etc.) skip this tier.
|
|
116
117
|
plugin_result = try_plugin_contribution(call_node, scope, receiver_type)
|
|
117
|
-
|
|
118
|
+
if plugin_result
|
|
119
|
+
if plugin_result.is_a?(Type::Dynamic)
|
|
120
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::FRAMEWORK_DSL_BOUNDARY)
|
|
121
|
+
end
|
|
122
|
+
return plugin_result
|
|
123
|
+
end
|
|
118
124
|
|
|
119
125
|
# ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
|
|
120
126
|
# type tier. Sits ABOVE `RbsDispatch.try_dispatch` so
|
|
@@ -145,6 +151,9 @@ module Rigor
|
|
|
145
151
|
rbs_result = RbsDispatch.try_dispatch(context)
|
|
146
152
|
if rbs_result
|
|
147
153
|
record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
|
|
154
|
+
if rbs_result.is_a?(Type::Dynamic) && rbs_result.static_facet.is_a?(Type::Top)
|
|
155
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::EXPLICIT_UNTYPED)
|
|
156
|
+
end
|
|
148
157
|
return rbs_result
|
|
149
158
|
end
|
|
150
159
|
|
|
@@ -174,7 +183,10 @@ module Rigor
|
|
|
174
183
|
# `Dynamic[top]` so the patched call resolves
|
|
175
184
|
# cross-file without `call.undefined-method`.
|
|
176
185
|
patched_result = try_project_patched_method(receiver_type, method_name, environment)
|
|
177
|
-
|
|
186
|
+
if patched_result
|
|
187
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
|
|
188
|
+
return patched_result
|
|
189
|
+
end
|
|
178
190
|
|
|
179
191
|
# ADR-10 slice 2b-ii — dependency-source inference tier.
|
|
180
192
|
# Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
|
|
@@ -186,7 +198,10 @@ module Rigor
|
|
|
186
198
|
# dynamic-origin envelope; per-method return-type
|
|
187
199
|
# precision is queued for a later slice.
|
|
188
200
|
dep_source_result = try_dependency_source(receiver_type, method_name, environment)
|
|
189
|
-
|
|
201
|
+
if dep_source_result
|
|
202
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
|
|
203
|
+
return dep_source_result
|
|
204
|
+
end
|
|
190
205
|
|
|
191
206
|
# v0.1.3 — discovered-method dispatch tier. When the
|
|
192
207
|
# receiver class has no RBS BUT scope_indexer recorded
|
|
@@ -102,8 +102,22 @@ module Rigor
|
|
|
102
102
|
replace
|
|
103
103
|
].to_set.freeze
|
|
104
104
|
|
|
105
|
+
# Methods that return the receiver (or a shallow copy) and
|
|
106
|
+
# cannot mutate it. They must not trigger widening or any
|
|
107
|
+
# other receiver-fact invalidation. The list is intentionally
|
|
108
|
+
# narrow — only methods whose purity is unconditional and
|
|
109
|
+
# whose return value is the receiver itself (or a copy that
|
|
110
|
+
# leaves the original untouched).
|
|
111
|
+
PURE_SELF_RETURNERS = %i[freeze dup clone itself].freeze
|
|
112
|
+
|
|
105
113
|
module_function
|
|
106
114
|
|
|
115
|
+
# True when `method_name` is a pure self-returner that must
|
|
116
|
+
# not invalidate the receiver's facts.
|
|
117
|
+
def pure_self_returner?(method_name)
|
|
118
|
+
PURE_SELF_RETURNERS.include?(method_name)
|
|
119
|
+
end
|
|
120
|
+
|
|
107
121
|
# Returns a scope with the call's receiver widened, when the
|
|
108
122
|
# receiver is a local-/instance-variable read whose current
|
|
109
123
|
# binding is a literal-shape carrier (`Tuple` / `HashShape`)
|
|
@@ -114,6 +128,8 @@ module Rigor
|
|
|
114
128
|
# @param current_scope [Rigor::Scope]
|
|
115
129
|
# @return [Rigor::Scope]
|
|
116
130
|
def widen_after_call(call_node:, current_scope:)
|
|
131
|
+
return current_scope if pure_self_returner?(call_node.name)
|
|
132
|
+
|
|
117
133
|
receiver = call_node.receiver
|
|
118
134
|
return current_scope if receiver.nil?
|
|
119
135
|
|
|
@@ -181,6 +197,8 @@ module Rigor
|
|
|
181
197
|
end
|
|
182
198
|
|
|
183
199
|
def widen_for_outer_receiver(call_node, scope)
|
|
200
|
+
return scope if pure_self_returner?(call_node.name)
|
|
201
|
+
|
|
184
202
|
receiver = call_node.receiver
|
|
185
203
|
return scope if receiver.nil?
|
|
186
204
|
|
|
@@ -19,7 +19,7 @@ module Rigor
|
|
|
19
19
|
# (does a diagnostic fire) is the phased mutation tier.
|
|
20
20
|
class ProtectionScanner
|
|
21
21
|
# A single unprotected call site.
|
|
22
|
-
Site = Data.define(:line, :receiver, :method_name)
|
|
22
|
+
Site = Data.define(:line, :receiver, :method_name, :dynamic_origin)
|
|
23
23
|
|
|
24
24
|
FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
|
|
25
25
|
def total = protected_count + unprotected_count
|
|
@@ -43,14 +43,17 @@ module Rigor
|
|
|
43
43
|
Source::NodeWalker.each(root) do |node|
|
|
44
44
|
next unless dispatch_site?(node)
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
scope = index[node.receiver]
|
|
47
|
+
receiver_type = scope.type_of(node.receiver)
|
|
47
48
|
if concrete_receiver?(receiver_type)
|
|
48
49
|
protected_count += 1
|
|
49
50
|
else
|
|
51
|
+
origin = scope.dynamic_origins[node.receiver]
|
|
50
52
|
sites << Site.new(
|
|
51
53
|
line: node.location.start_line,
|
|
52
54
|
receiver: safe_describe(receiver_type),
|
|
53
|
-
method_name: node.name.to_s
|
|
55
|
+
method_name: node.name.to_s,
|
|
56
|
+
dynamic_origin: origin
|
|
54
57
|
)
|
|
55
58
|
end
|
|
56
59
|
end
|