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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/docs/handbook/09-plugins.md +5 -2
  3. data/docs/handbook/appendix-liskov.md +5 -3
  4. data/docs/handbook/appendix-phpstan.md +2 -2
  5. data/docs/install.md +1 -1
  6. data/docs/manual/02-cli-reference.md +58 -1
  7. data/docs/manual/06-baseline.md +12 -0
  8. data/docs/manual/11-ci.md +6 -6
  9. data/docs/manual/15-type-protection-coverage.md +29 -0
  10. data/docs/manual/plugins/rigor-minitest.md +1 -1
  11. data/lib/rigor/cli/check_command.rb +4 -33
  12. data/lib/rigor/cli/check_runner_factory.rb +63 -0
  13. data/lib/rigor/cli/doctor_command.rb +295 -0
  14. data/lib/rigor/cli/plugins_command.rb +2 -2
  15. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  16. data/lib/rigor/cli/protection_renderer.rb +32 -2
  17. data/lib/rigor/cli/protection_report.rb +32 -6
  18. data/lib/rigor/cli/upgrade_command.rb +25 -0
  19. data/lib/rigor/cli.rb +17 -1
  20. data/lib/rigor/flow_contribution/fact.rb +1 -1
  21. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  22. data/lib/rigor/inference/expression_typer.rb +22 -10
  23. data/lib/rigor/inference/fallback.rb +2 -2
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  26. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  27. data/lib/rigor/inference/mutation_widening.rb +18 -0
  28. data/lib/rigor/inference/protection_scanner.rb +6 -3
  29. data/lib/rigor/inference/statement_evaluator.rb +5 -4
  30. data/lib/rigor/plugin/base.rb +34 -7
  31. data/lib/rigor/plugin/registry.rb +1 -1
  32. data/lib/rigor/scope.rb +16 -5
  33. data/lib/rigor/version.rb +1 -1
  34. data/lib/rigor.rb +1 -0
  35. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  36. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  37. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  38. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
  39. data/sig/rigor/plugin/base.rbs +2 -0
  40. data/sig/rigor/scope.rbs +3 -1
  41. data/skills/rigor-plugin-author/SKILL.md +8 -5
  42. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  43. 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
- # `type_specifier` methods.
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` / `type_specifier`), so they are read off the
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
- # type_specifier). Surfaced in the full report alongside the
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
- @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
45
- count: call.count, method: call.method_name, sites: call.examples.join(" "))
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
- untyped = @calls
62
- .map { |method, v| UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples]) }
63
- .sort_by { |c| [-c.count, c.method_name] }
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 `type_specifier` DSL (ADR-52).
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
- return untraced_type_of(node) unless FlowTracer.active?
231
-
232
- # `rigor trace` — bracket the recursion with enter/result events.
233
- # The tracer is observational only: the inferred type flows
234
- # through unchanged (see FlowTracer's contract).
235
- FlowTracer.trace_node(node) { untraced_type_of(node) }
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
- return plugin_result if plugin_result
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
- return patched_result if patched_result
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
- return dep_source_result if dep_source_result
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
- receiver_type = index[node.receiver].type_of(node.receiver)
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