rigortype 0.1.11 → 0.1.13

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. metadata +22 -1
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renderer for `rigor plugins`. Produces a human-readable
8
+ # text table and a JSON representation from the same row
9
+ # shape (the Hash documented on
10
+ # {Rigor::CLI::PluginsCommand#loaded_row}).
11
+ #
12
+ # The two formats carry the same content; JSON is meant for
13
+ # tooling (SKILLs, CI, editor integrations) while text is
14
+ # for interactive inspection. Rows are printed in the order
15
+ # the loader resolved them.
16
+ class PluginsRenderer
17
+ def initialize(rows:, configuration_path:)
18
+ @rows = rows
19
+ @configuration_path = configuration_path
20
+ end
21
+
22
+ def text
23
+ lines = []
24
+ lines << header
25
+ lines << ""
26
+ @rows.each_with_index do |row, index|
27
+ lines.concat(row_lines(row))
28
+ lines << "" unless index == @rows.size - 1
29
+ end
30
+ lines << ""
31
+ lines << footer
32
+ lines.join("\n")
33
+ end
34
+
35
+ def json
36
+ JSON.pretty_generate(
37
+ {
38
+ "configuration" => @configuration_path,
39
+ "plugins" => @rows.map { |row| row_json(row) },
40
+ "summary" => summary
41
+ }
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def header
48
+ loaded = @rows.count { |r| r[:status] == :loaded }
49
+ errored = @rows.count { |r| r[:status] == :load_error }
50
+ config = @configuration_path || "(no .rigor.yml found; using defaults)"
51
+ "Plugin activation report\n " \
52
+ "configuration: #{config}\n " \
53
+ "loaded: #{loaded} load-error: #{errored}"
54
+ end
55
+
56
+ def footer
57
+ errored = @rows.select { |r| r[:status] == :load_error }
58
+ if errored.empty?
59
+ "All configured plugins loaded successfully."
60
+ else
61
+ "#{errored.size} plugin(s) failed to load — see above. " \
62
+ "Run with --strict to make this a CI gate."
63
+ end
64
+ end
65
+
66
+ def row_lines(row)
67
+ marker = row[:status] == :loaded ? "OK " : "ERR"
68
+ head = if row[:status] == :loaded
69
+ " [#{marker}] #{row[:id]} v#{row[:version]} (#{row[:gem]})"
70
+ else
71
+ " [#{marker}] #{row[:gem]}"
72
+ end
73
+ lines = [head]
74
+ lines << " #{row[:description]}" if row[:description]
75
+
76
+ if row[:status] == :load_error
77
+ lines << " load error: #{row[:load_error]}"
78
+ lines << " config: #{row[:config].inspect}" if row[:config] && !row[:config].empty?
79
+ return lines
80
+ end
81
+
82
+ lines.concat(loaded_detail_lines(row))
83
+ lines
84
+ end
85
+
86
+ def loaded_detail_lines(row)
87
+ lines = []
88
+ lines.concat(signature_paths_lines(row[:signature_paths])) if row[:signature_paths].any?
89
+ lines.concat(receiver_and_fact_lines(row))
90
+ lines.concat(macro_substrate_lines(row))
91
+ lines.concat(extra_surface_lines(row))
92
+ lines << " config: #{row[:config].inspect}" if row[:config] && !row[:config].empty?
93
+ lines
94
+ end
95
+
96
+ def receiver_and_fact_lines(row)
97
+ lines = []
98
+ lines << " open_receivers: #{row[:open_receivers].join(', ')}" if row[:open_receivers].any?
99
+ lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
100
+ lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
101
+ lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
102
+ lines
103
+ end
104
+
105
+ def extra_surface_lines(row)
106
+ lines = []
107
+ lines << " protocol_contracts: #{row[:protocol_contracts]}" if row[:protocol_contracts].positive?
108
+ lines << " source_rbs_synthesizer: yes" if row[:source_rbs_synthesizer]
109
+ lines << " type_node_resolvers: #{row[:type_node_resolvers]}" if row[:type_node_resolvers].positive?
110
+ if row[:hkt_registrations].positive? || row[:hkt_definitions].positive?
111
+ lines << " hkt: #{row[:hkt_registrations]} registration(s), #{row[:hkt_definitions]} definition(s)"
112
+ end
113
+ lines
114
+ end
115
+
116
+ def signature_paths_lines(paths)
117
+ lines = [" signature_paths:"]
118
+ paths.each do |entry|
119
+ marker = entry[:exists] ? "" : " (MISSING)"
120
+ lines << " #{entry[:path]} (#{entry[:rbs_files]} .rbs file(s))#{marker}"
121
+ end
122
+ lines
123
+ end
124
+
125
+ def macro_substrate_lines(row)
126
+ parts = []
127
+ parts << "block_as_methods=#{row[:block_as_methods]}" if row[:block_as_methods].positive?
128
+ parts << "heredoc_templates=#{row[:heredoc_templates]}" if row[:heredoc_templates].positive?
129
+ parts << "trait_registries=#{row[:trait_registries]}" if row[:trait_registries].positive?
130
+ parts << "external_files=#{row[:external_files]}" if row[:external_files].positive?
131
+ return [] if parts.empty?
132
+
133
+ [" macro substrate: #{parts.join(', ')}"]
134
+ end
135
+
136
+ def row_json(row)
137
+ {
138
+ "gem" => row[:gem],
139
+ "status" => row[:status].to_s,
140
+ "id" => row[:id],
141
+ "version" => row[:version],
142
+ "description" => row[:description],
143
+ "config" => row[:config],
144
+ "signature_paths" => row[:signature_paths].map do |sp|
145
+ { "path" => sp[:path], "exists" => sp[:exists], "rbs_files" => sp[:rbs_files] }
146
+ end,
147
+ "open_receivers" => row[:open_receivers],
148
+ "owns_receivers" => row[:owns_receivers],
149
+ "produces" => row[:produces],
150
+ "consumes" => row[:consumes],
151
+ "block_as_methods" => row[:block_as_methods],
152
+ "heredoc_templates" => row[:heredoc_templates],
153
+ "trait_registries" => row[:trait_registries],
154
+ "external_files" => row[:external_files],
155
+ "type_node_resolvers" => row[:type_node_resolvers],
156
+ "hkt_registrations" => row[:hkt_registrations],
157
+ "hkt_definitions" => row[:hkt_definitions],
158
+ "protocol_contracts" => row[:protocol_contracts],
159
+ "source_rbs_synthesizer" => row[:source_rbs_synthesizer],
160
+ "load_error" => row[:load_error]
161
+ }
162
+ end
163
+
164
+ def summary
165
+ {
166
+ "total" => @rows.size,
167
+ "loaded" => @rows.count { |r| r[:status] == :loaded },
168
+ "load_error" => @rows.count { |r| r[:status] == :load_error }
169
+ }
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # `rigor skill` — discover and print the SKILL.md files
8
+ # bundled with the `rigortype` gem.
9
+ #
10
+ # Rigor ships a small set of Agent Skills under `skills/` that
11
+ # walk an AI coding agent through onboarding (`rigor-project-init`),
12
+ # baseline reduction (`rigor-baseline-reduce`), and authoring a
13
+ # plugin (`rigor-plugin-author`). When Rigor is installed via
14
+ # `mise` / `gem install` / etc. the SKILL files live inside the
15
+ # gem checkout — the project being analysed has no copy, so an
16
+ # AI agent has no a priori way to find them.
17
+ #
18
+ # This command exposes the bundled skills via three subcommands:
19
+ #
20
+ # - `rigor skill list` — table of name + absolute path.
21
+ # - `rigor skill print <name>` — short header (paths + how to use)
22
+ # followed by the SKILL.md body. This
23
+ # is the form AI agents should call;
24
+ # the inline body plus the header's
25
+ # absolute paths together let the
26
+ # agent act with or without a file
27
+ # reading tool.
28
+ # - `rigor skill path <name>` — one-line absolute path, suitable
29
+ # as input to a Read tool.
30
+ #
31
+ # `rigor skill` with no subcommand is an alias for `list`.
32
+ class SkillCommand
33
+ USAGE = <<~USAGE
34
+ Usage: rigor skill <subcommand> [args]
35
+
36
+ Subcommands:
37
+ list List bundled skills (default when no subcommand given)
38
+ print <name> Print the SKILL.md body for <name> to stdout, with a header
39
+ path <name> Print the absolute path of the SKILL.md file for <name>
40
+
41
+ Examples:
42
+ rigor skill list
43
+ rigor skill print rigor-project-init
44
+ rigor skill path rigor-baseline-reduce
45
+ USAGE
46
+
47
+ # The bundled skills live at `<gem_root>/skills/`. From
48
+ # `lib/rigor/cli/skill_command.rb` that is three directories up.
49
+ SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
50
+
51
+ def initialize(argv:, out: $stdout, err: $stderr)
52
+ @argv = argv
53
+ @out = out
54
+ @err = err
55
+ end
56
+
57
+ # @return [Integer] CLI exit status.
58
+ def run
59
+ subcommand = @argv.shift || "list"
60
+
61
+ case subcommand
62
+ when "list" then run_list
63
+ when "print" then run_print
64
+ when "path" then run_path
65
+ when "-h", "--help", "help"
66
+ print_usage(@out)
67
+ 0
68
+ else
69
+ @err.puts("Unknown subcommand: #{subcommand}")
70
+ print_usage(@err)
71
+ Rigor::CLI::EXIT_USAGE
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def run_list
78
+ skills = discover_skills
79
+ if skills.empty?
80
+ @err.puts("No bundled skills found under #{SKILLS_ROOT}")
81
+ return 1
82
+ end
83
+
84
+ width = skills.map { |s| s.fetch(:name).length }.max
85
+ skills.each do |skill|
86
+ @out.puts(format("%-#{width}s %s", skill.fetch(:name), skill.fetch(:path)))
87
+ end
88
+ 0
89
+ end
90
+
91
+ def run_print
92
+ name = @argv.shift
93
+ return usage_error("`print` requires a skill name") if name.nil?
94
+
95
+ skill = find_skill(name)
96
+ return name_error(name) if skill.nil?
97
+
98
+ @out.puts(render_print_header(skill))
99
+ @out.puts
100
+ @out.write(File.read(skill.fetch(:path)))
101
+ 0
102
+ end
103
+
104
+ def run_path
105
+ name = @argv.shift
106
+ return usage_error("`path` requires a skill name") if name.nil?
107
+
108
+ skill = find_skill(name)
109
+ return name_error(name) if skill.nil?
110
+
111
+ @out.puts(skill.fetch(:path))
112
+ 0
113
+ end
114
+
115
+ # The header that precedes the SKILL.md body when an agent
116
+ # runs `rigor skill print <name>`. Kept as `# `-prefixed
117
+ # comment lines so the combined output remains parseable as
118
+ # markdown — anything below `---` (the SKILL frontmatter
119
+ # marker) is unchanged.
120
+ def render_print_header(skill)
121
+ references_dir = File.join(File.dirname(skill.fetch(:path)), "references")
122
+ ref_line = if File.directory?(references_dir)
123
+ "# References: #{references_dir}/ (read referenced `references/NN-*.md` files from here)"
124
+ else
125
+ "# References: (none)"
126
+ end
127
+ <<~HEADER.chomp
128
+ # Rigor skill: #{skill.fetch(:name)}
129
+ # Source: #{skill.fetch(:path)}
130
+ #{ref_line}
131
+ #
132
+ # The body below is the canonical SKILL definition shipped with
133
+ # rigortype #{Rigor::VERSION}. Follow its instructions.
134
+ HEADER
135
+ end
136
+
137
+ def discover_skills
138
+ return [] unless File.directory?(SKILLS_ROOT)
139
+
140
+ Dir.children(SKILLS_ROOT).sort.filter_map do |name|
141
+ skill_md = File.join(SKILLS_ROOT, name, "SKILL.md")
142
+ next unless File.file?(skill_md)
143
+
144
+ { name: name, path: skill_md }
145
+ end
146
+ end
147
+
148
+ def find_skill(name)
149
+ discover_skills.find { |s| s.fetch(:name) == name }
150
+ end
151
+
152
+ def name_error(name)
153
+ @err.puts("Unknown skill: #{name}")
154
+ @err.puts("Available skills:")
155
+ discover_skills.each { |s| @err.puts(" #{s.fetch(:name)}") }
156
+ 1
157
+ end
158
+
159
+ def usage_error(message)
160
+ @err.puts(message)
161
+ print_usage(@err)
162
+ Rigor::CLI::EXIT_USAGE
163
+ end
164
+
165
+ def print_usage(io)
166
+ io.puts(USAGE)
167
+ end
168
+ end
169
+ end
170
+ end
data/lib/rigor/cli.rb CHANGED
@@ -32,7 +32,9 @@ module Rigor
32
32
  "baseline" => :run_baseline,
33
33
  "triage" => :run_triage,
34
34
  "coverage" => :run_coverage,
35
- "playground" => :run_playground
35
+ "plugins" => :run_plugins,
36
+ "playground" => :run_playground,
37
+ "skill" => :run_skill
36
38
  }.freeze
37
39
 
38
40
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -475,9 +477,29 @@ module Rigor
475
477
 
476
478
  File.write(path, init_template)
477
479
  @out.puts("Created #{path}")
480
+ print_init_next_steps(path)
478
481
  0
479
482
  end
480
483
 
484
+ # `rigor init`'s template ships empty `plugins:` so a fresh
485
+ # init has nothing to validate — but the moment the user adds
486
+ # any plugin entry, the activation-failure surfaces enumerated
487
+ # in `rigor plugins`'s docstring become real. Point them at
488
+ # the verification command + the canonical readiness flow so
489
+ # silent failures (the cwd / Gemfile / signature_paths
490
+ # mismatches that surfaced during the Mastodon trial) get
491
+ # caught the first time the user wires a plugin, not the first
492
+ # time `rigor check` reports false positives that should have
493
+ # been covered.
494
+ def print_init_next_steps(path)
495
+ @out.puts ""
496
+ @out.puts "Next steps:"
497
+ @out.puts " 1. Edit #{path} — add the `plugins:` your project needs."
498
+ @out.puts " 2. Run `rigor plugins` to verify every configured plugin loads."
499
+ @out.puts " (`--strict` exits 1 on failure; ideal CI gate.)"
500
+ @out.puts " 3. Run `rigor check` to analyse your code."
501
+ end
502
+
481
503
  # Renders the starter `.rigor.yml` body. The template
482
504
  # serialises `Configuration::DEFAULTS` (so the on-disk file
483
505
  # round-trips through `Configuration.load`) and prepends a
@@ -597,6 +619,12 @@ module Rigor
597
619
  CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
598
620
  end
599
621
 
622
+ def run_plugins
623
+ require_relative "cli/plugins_command"
624
+
625
+ CLI::PluginsCommand.new(argv: @argv, out: @out, err: @err).run
626
+ end
627
+
600
628
  def run_playground
601
629
  begin
602
630
  require "rigor/playground"
@@ -608,6 +636,12 @@ module Rigor
608
636
  Rigor::CLI::PlaygroundCommand.new(@argv[1..], @out, @err).run
609
637
  end
610
638
 
639
+ def run_skill
640
+ require_relative "cli/skill_command"
641
+
642
+ CLI::SkillCommand.new(argv: @argv, out: @out, err: @err).run
643
+ end
644
+
611
645
  def write_result(result, format)
612
646
  case format
613
647
  when "json"
@@ -653,7 +687,9 @@ module Rigor
653
687
  mcp Run the Rigor MCP server over stdio (ADR-33)
654
688
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
655
689
  coverage Report type-precision coverage (precise vs Dynamic ratio)
690
+ plugins Report activation status of every configured plugin
656
691
  playground Start the browser playground (requires rigor-playground gem)
692
+ skill List or print bundled Agent Skills (rigor-project-init, ...)
657
693
  version Print the Rigor version
658
694
  help Print this help
659
695
  HELP
@@ -39,6 +39,7 @@ module Rigor
39
39
  PROFILES = {
40
40
  lenient: {
41
41
  "call.undefined-method" => :error,
42
+ "call.unresolved-toplevel" => :off,
42
43
  "call.wrong-arity" => :error,
43
44
  "call.argument-type-mismatch" => :warning,
44
45
  "call.possible-nil-receiver" => :warning,
@@ -54,6 +55,7 @@ module Rigor
54
55
  }.freeze,
55
56
  balanced: {
56
57
  "call.undefined-method" => :error,
58
+ "call.unresolved-toplevel" => :warning,
57
59
  "call.wrong-arity" => :error,
58
60
  "call.argument-type-mismatch" => :error,
59
61
  "call.possible-nil-receiver" => :error,
@@ -69,6 +71,7 @@ module Rigor
69
71
  }.freeze,
70
72
  strict: {
71
73
  "call.undefined-method" => :error,
74
+ "call.unresolved-toplevel" => :error,
72
75
  "call.wrong-arity" => :error,
73
76
  "call.argument-type-mismatch" => :error,
74
77
  "call.possible-nil-receiver" => :error,
@@ -106,6 +106,8 @@ module Rigor
106
106
  params_node = params_root.parameters
107
107
  return {} if params_node.nil?
108
108
 
109
+ apply_auto_splat(params_node)
110
+
109
111
  bindings = {}
110
112
  bind_positionals(params_node, bindings, 0)
111
113
  bind_rest(params_node, bindings)
@@ -115,6 +117,39 @@ module Rigor
115
117
  bindings
116
118
  end
117
119
 
120
+ # Ruby blocks (NOT lambdas) auto-splat a single yielded
121
+ # Tuple-shaped value when the block declares more than one
122
+ # required positional parameter:
123
+ #
124
+ # { a: 1 }.each { |k, v| ... }
125
+ #
126
+ # yields `[key, value]` as a single arg, but the two-param
127
+ # block sees `k = key, v = value`. RBS / IteratorDispatch
128
+ # encode this as the block taking ONE `[K, V]` Tuple
129
+ # parameter; without this fix-up the binder would assign
130
+ # `k = Tuple[K, V]` and `v = Dynamic[Top]`, and any call
131
+ # on `k.<method-not-on-Tuple>` would false-fire.
132
+ #
133
+ # The rule fires only when (a) the receiver yields exactly
134
+ # one value (`expected_param_types.size == 1`), (b) the
135
+ # block declares more than one positional slot, and (c)
136
+ # that single expected element is a Tuple. Multi-arg yields
137
+ # (e.g. `each_with_index`'s `(element, index)` pair) are
138
+ # NOT auto-splatted — matching Ruby semantics where a
139
+ # multi-arg yield to a `|a, b, c|` block fills the extra
140
+ # slot with nil rather than splatting any element.
141
+ def apply_auto_splat(params_node)
142
+ return unless @expected_param_types.size == 1
143
+
144
+ pos_count = params_node.requireds.size + params_node.optionals.size + params_node.posts.size
145
+ return unless pos_count > 1
146
+
147
+ first = @expected_param_types[0]
148
+ return unless first.is_a?(Type::Tuple)
149
+
150
+ @expected_param_types = first.elements
151
+ end
152
+
118
153
  def bind_positionals(params_node, bindings, cursor)
119
154
  cursor = bind_required_positionals(params_node, bindings, cursor)
120
155
  cursor = bind_optional_positionals(params_node, bindings, cursor)
@@ -6,6 +6,7 @@ require_relative "../type"
6
6
  require_relative "../ast"
7
7
  require_relative "block_parameter_binder"
8
8
  require_relative "fallback"
9
+ require_relative "indexed_narrowing"
9
10
  require_relative "macro_block_self_type"
10
11
  require_relative "method_dispatcher"
11
12
  require_relative "narrowing"
@@ -1133,6 +1134,69 @@ module Rigor
1133
1134
  type_of(body.last)
1134
1135
  end
1135
1136
 
1137
+ # Indexed-collection narrowing — `receiver[key]` after a
1138
+ # prior `receiver[key] ||= default` reads the post-`||=`
1139
+ # type when the receiver and key are stable enough to
1140
+ # address. Sits ahead of `MethodDispatcher.dispatch` so
1141
+ # the standard `Hash#[]` / `Array#[]` answer (which would
1142
+ # fold to `Constant[nil]` for an empty `HashShape{}` or
1143
+ # `Tuple[]`) does not override the narrowing. See
1144
+ # {Inference::IndexedNarrowing}.
1145
+ def indexed_narrowing_for(node)
1146
+ IndexedNarrowing.lookup_for_call(node, scope) || method_chain_narrowing_for(node)
1147
+ end
1148
+
1149
+ # Stable single-hop chain narrowing — `receiver.method`
1150
+ # after an `is_a?` / `kind_of?` / `instance_of?` predicate
1151
+ # established the narrowing on the dominated edge. The
1152
+ # call MUST be no-arg + no-block + rooted at a local-var /
1153
+ # ivar read; everything else falls through to the
1154
+ # standard dispatcher. ROADMAP § Future cycles —
1155
+ # "Method-call receiver narrowing across stable
1156
+ # receivers" — Law-of-Demeter-justified single-hop scope.
1157
+ def method_chain_narrowing_for(node)
1158
+ return nil unless node.is_a?(Prism::CallNode)
1159
+ return nil unless node.block.nil?
1160
+ return nil unless node.arguments.nil? || node.arguments.arguments.empty?
1161
+
1162
+ case node.receiver
1163
+ when Prism::LocalVariableReadNode
1164
+ scope.method_chain_narrowing(:local, node.receiver.name, node.name)
1165
+ when Prism::InstanceVariableReadNode
1166
+ scope.method_chain_narrowing(:ivar, node.receiver.name, node.name)
1167
+ end
1168
+ end
1169
+
1170
+ # v0.0.3 A — implicit-self calls prefer a same-named
1171
+ # top-level `def` over RBS dispatch. Without this,
1172
+ # a helper like `def select(...)` defined inside an
1173
+ # `RSpec.describe ... do ... end` block mis-routes
1174
+ # through `Enumerable#select` / `Object#select` and
1175
+ # the caller observes `Array[Elem]` instead of the
1176
+ # helper's actual return type. The check fires only
1177
+ # for `node.receiver.nil?` (true implicit self), so
1178
+ # explicit-receiver dispatch is unaffected.
1179
+ def try_local_def_dispatch(node, receiver, arg_types)
1180
+ local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1181
+ return nil unless local_def
1182
+
1183
+ local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1184
+ return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1185
+
1186
+ # The local def matches by name but the inference was
1187
+ # disqualified — either the parameter shape is too complex
1188
+ # for the first-iteration binder (kwargs / optionals /
1189
+ # rest), or ADR-24 slice 1's conservative gate declined
1190
+ # the resolved return type inside a class body (see
1191
+ # `adoptable_self_call_result?`). `Dynamic[Top]` is the
1192
+ # safest answer: RBS dispatch would be wrong (the method
1193
+ # is user-defined and shadows whatever ancestor method the
1194
+ # dispatch would find), and `Dynamic[Top]` propagates
1195
+ # correctly through downstream call chains without
1196
+ # surfacing misleading false-positive diagnostics.
1197
+ dynamic_top
1198
+ end
1199
+
1136
1200
  # Slice 2 routes call expressions through `MethodDispatcher`. The
1137
1201
  # receiver and every argument are typed first, then the dispatcher is
1138
1202
  # asked for a result type. A nil result triggers the fail-soft fallback
@@ -1140,40 +1204,15 @@ module Rigor
1140
1204
  # their own fallbacks for unrecognised receivers/args, so the tracer
1141
1205
  # captures both the immediate dispatch miss and the deeper cause).
1142
1206
  def call_type_for(node)
1207
+ narrowed = indexed_narrowing_for(node)
1208
+ return narrowed if narrowed
1209
+
1143
1210
  receiver = call_receiver_type_for(node)
1144
1211
  arg_types = call_arg_types(node)
1145
1212
  block_type = block_return_type_for(node, receiver, arg_types)
1146
1213
 
1147
- # v0.0.3 A implicit-self calls prefer a same-named
1148
- # top-level `def` over RBS dispatch. Without this,
1149
- # a helper like `def select(...)` defined inside an
1150
- # `RSpec.describe ... do ... end` block mis-routes
1151
- # through `Enumerable#select` / `Object#select` and
1152
- # the caller observes `Array[Elem]` instead of the
1153
- # helper's actual return type. The check fires only
1154
- # for `node.receiver.nil?` (true implicit self), so
1155
- # explicit-receiver dispatch is unaffected.
1156
- local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1157
- if local_def
1158
- local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1159
- return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1160
-
1161
- # The local def matches by name but the inference
1162
- # was disqualified — either the parameter shape is
1163
- # too complex for the first-iteration binder
1164
- # (kwargs / optionals / rest), or ADR-24 slice 1's
1165
- # conservative gate declined the resolved return
1166
- # type inside a class body (see
1167
- # `adoptable_self_call_result?`).
1168
- # Returning `Dynamic[Top]` is the safest answer:
1169
- # we know RBS dispatch would be wrong (the
1170
- # method is user-defined and shadows whatever
1171
- # ancestor method the dispatch would find), and
1172
- # `Dynamic[Top]` propagates correctly through
1173
- # downstream call chains without surfacing
1174
- # misleading false-positive diagnostics.
1175
- return dynamic_top
1176
- end
1214
+ local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1215
+ return local_def_result if local_def_result
1177
1216
 
1178
1217
  # v0.0.6 phase 2 — per-element block fold for Tuple
1179
1218
  # receivers. When `[a, b, c].map { |x| f(x) }` and the